Files
gemini-banlancer/web/static/js/app.js
2025-11-20 12:24:05 +08:00

4164 lines
186 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
// frontend/js/components/slidingTabs.js
var SlidingTabs = class {
/**
* @param {HTMLElement} containerElement - The main container element with the `data-sliding-tabs-container` attribute.
*/
constructor(containerElement) {
this.container = containerElement;
this.indicator = this.container.querySelector("[data-tab-indicator]");
this.tabs = this.container.querySelectorAll("[data-tab-item]");
this.activeTab = this.container.querySelector(".tab-active");
if (!this.indicator || this.tabs.length === 0) {
console.error("SlidingTabs component is missing required elements (indicator or items).", this.container);
return;
}
this.init();
}
init() {
if (this.activeTab) {
setTimeout(() => this.updateIndicator(this.activeTab), 50);
}
this.bindEvents();
}
updateIndicator(targetTab) {
if (!targetTab) return;
const containerRect = this.container.getBoundingClientRect();
const targetRect = targetTab.getBoundingClientRect();
const left = targetRect.left - containerRect.left;
const width = targetRect.width;
this.indicator.style.left = `${left}px`;
this.indicator.style.width = `${width}px`;
}
bindEvents() {
this.tabs.forEach((tab) => {
tab.addEventListener("click", (e) => {
if (this.activeTab) {
this.activeTab.classList.remove("tab-active");
}
tab.classList.add("tab-active");
this.activeTab = tab;
this.updateIndicator(this.activeTab);
});
tab.addEventListener("mouseenter", () => {
this.updateIndicator(tab);
});
});
this.container.addEventListener("mouseleave", () => {
this.updateIndicator(this.activeTab);
});
}
};
document.addEventListener("DOMContentLoaded", () => {
const allTabContainers = document.querySelectorAll("[data-sliding-tabs-container]");
allTabContainers.forEach((container) => {
new SlidingTabs(container);
});
});
// frontend/js/components/customSelect.js
var CustomSelect = class _CustomSelect {
constructor(container) {
this.container = container;
this.trigger = this.container.querySelector(".custom-select-trigger");
this.panel = this.container.querySelector(".custom-select-panel");
if (!this.trigger || !this.panel) {
console.warn("CustomSelect cannot initialize: missing .custom-select-trigger or .custom-select-panel.", this.container);
return;
}
this.nativeSelect = this.container.querySelector("select");
this.triggerText = this.trigger.querySelector("span");
this.template = this.panel.querySelector(".custom-select-option-template");
if (typeof _CustomSelect.openInstance === "undefined") {
_CustomSelect.openInstance = null;
_CustomSelect.initGlobalListener();
}
if (this.nativeSelect) {
this.generateOptions();
this.updateTriggerText();
}
this.bindEvents();
}
static initGlobalListener() {
document.addEventListener("click", (event) => {
if (_CustomSelect.openInstance && !_CustomSelect.openInstance.container.contains(event.target)) {
_CustomSelect.openInstance.close();
}
});
}
generateOptions() {
this.panel.querySelectorAll(":scope > *:not(.custom-select-option-template)").forEach((child) => child.remove());
Array.from(this.nativeSelect.options).forEach((option) => {
let item;
if (this.template) {
item = this.template.cloneNode(true);
item.classList.remove("custom-select-option-template");
item.removeAttribute("hidden");
} else {
item = document.createElement("a");
item.href = "#";
item.className = "block px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-600";
}
item.classList.add("custom-select-option");
item.textContent = option.textContent;
item.dataset.value = option.value;
if (option.selected) {
item.classList.add("is-selected");
}
this.panel.appendChild(item);
});
}
bindEvents() {
this.trigger.addEventListener("click", (event) => {
if (this.trigger.classList.contains("is-disabled")) {
return;
}
event.stopPropagation();
if (_CustomSelect.openInstance && _CustomSelect.openInstance !== this) {
_CustomSelect.openInstance.close();
}
this.toggle();
});
if (this.nativeSelect) {
this.panel.addEventListener("click", (event) => {
event.preventDefault();
const option = event.target.closest(".custom-select-option");
if (option) {
this.selectOption(option);
}
});
}
}
selectOption(optionEl) {
const selectedValue = optionEl.dataset.value;
if (this.nativeSelect.value !== selectedValue) {
this.nativeSelect.value = selectedValue;
this.nativeSelect.dispatchEvent(new Event("change", { bubbles: true }));
}
this.updateTriggerText();
this.panel.querySelectorAll(".custom-select-option").forEach((el) => el.classList.remove("is-selected"));
optionEl.classList.add("is-selected");
this.close();
}
updateTriggerText() {
if (!this.nativeSelect || !this.triggerText) return;
const selectedOption = this.nativeSelect.options[this.nativeSelect.selectedIndex];
if (selectedOption) {
this.triggerText.textContent = selectedOption.textContent;
}
}
toggle() {
this.panel.classList.toggle("hidden");
if (this.panel.classList.contains("hidden")) {
if (_CustomSelect.openInstance === this) {
_CustomSelect.openInstance = null;
}
} else {
_CustomSelect.openInstance = this;
}
}
open() {
this.panel.classList.remove("hidden");
_CustomSelect.openInstance = this;
}
close() {
this.panel.classList.add("hidden");
if (_CustomSelect.openInstance === this) {
_CustomSelect.openInstance = null;
}
}
};
// frontend/js/components/ui.js
var ModalManager = class {
/**
* Shows a generic modal by its ID.
* @param {string} modalId The ID of the modal element to show.
*/
show(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.remove("hidden");
} else {
console.error(`Modal with ID "${modalId}" not found.`);
}
}
/**
* Hides a generic modal by its ID.
* @param {string} modalId The ID of the modal element to hide.
*/
hide(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.add("hidden");
} else {
console.error(`Modal with ID "${modalId}" not found.`);
}
}
/**
* Shows a confirmation dialog. This is a versatile method for 'Are you sure?' style prompts.
* It dynamically sets the title, message, and confirm action for a generic confirmation modal.
* @param {object} options - The options for the confirmation modal.
* @param {string} options.modalId - The ID of the confirmation modal element (e.g., 'resetModal', 'deleteConfirmModal').
* @param {string} options.title - The title to display in the modal header.
* @param {string} options.message - The message to display in the modal body. Can contain HTML.
* @param {function} options.onConfirm - The callback function to execute when the confirm button is clicked.
* @param {boolean} [options.disableConfirm=false] - Whether the confirm button should be initially disabled.
*/
showConfirm({ modalId, title, message, onConfirm, disableConfirm = false }) {
const modalElement = document.getElementById(modalId);
if (!modalElement) {
console.error(`Confirmation modal with ID "${modalId}" not found.`);
return;
}
const titleElement = modalElement.querySelector('[id$="ModalTitle"]');
const messageElement = modalElement.querySelector('[id$="ModalMessage"]');
const confirmButton = modalElement.querySelector('[id^="confirm"]');
if (!titleElement || !messageElement || !confirmButton) {
console.error(`Modal "${modalId}" is missing required child elements (title, message, or confirm button).`);
return;
}
titleElement.textContent = title;
messageElement.innerHTML = message;
confirmButton.disabled = disableConfirm;
const newConfirmButton = confirmButton.cloneNode(true);
confirmButton.parentNode.replaceChild(newConfirmButton, confirmButton);
newConfirmButton.onclick = () => onConfirm();
this.show(modalId);
}
/**
* Shows a result modal to indicate the outcome of an operation (success or failure).
* @param {boolean} success - If true, displays a success icon and title; otherwise, shows failure indicators.
* @param {string|Node} message - The message to display. Can be a simple string or a complex DOM Node for rich content.
* @param {boolean} [autoReload=false] - If true, the page will automatically reload when the modal is closed.
*/
showResult(success, message, autoReload = false) {
const modalElement = document.getElementById("resultModal");
if (!modalElement) {
console.error("Result modal with ID 'resultModal' not found.");
return;
}
const titleElement = document.getElementById("resultModalTitle");
const messageElement = document.getElementById("resultModalMessage");
const iconElement = document.getElementById("resultIcon");
const confirmButton = document.getElementById("resultModalConfirmBtn");
if (!titleElement || !messageElement || !iconElement || !confirmButton) {
console.error("Result modal is missing required child elements.");
return;
}
titleElement.textContent = success ? "\u64CD\u4F5C\u6210\u529F" : "\u64CD\u4F5C\u5931\u8D25";
if (success) {
iconElement.innerHTML = '<i class="fas fa-check-circle text-success-500"></i>';
iconElement.className = "text-6xl mb-3 text-success-500";
} else {
iconElement.innerHTML = '<i class="fas fa-times-circle text-danger-500"></i>';
iconElement.className = "text-6xl mb-3 text-danger-500";
}
messageElement.innerHTML = "";
if (typeof message === "string") {
const messageDiv = document.createElement("div");
messageDiv.innerText = message;
messageElement.appendChild(messageDiv);
} else if (message instanceof Node) {
messageElement.appendChild(message);
} else {
const messageDiv = document.createElement("div");
messageDiv.innerText = String(message);
messageElement.appendChild(messageDiv);
}
confirmButton.onclick = () => this.closeResult(autoReload);
this.show("resultModal");
}
/**
* Closes the result modal.
* @param {boolean} [reload=false] - If true, reloads the page after closing the modal.
*/
closeResult(reload = false) {
this.hide("resultModal");
if (reload) {
location.reload();
}
}
/**
* Shows and initializes the progress modal for long-running operations.
* @param {string} title - The title to display for the progress modal.
*/
showProgress(title) {
const modal = document.getElementById("progressModal");
if (!modal) {
console.error("Progress modal with ID 'progressModal' not found.");
return;
}
const titleElement = document.getElementById("progressModalTitle");
const statusText = document.getElementById("progressStatusText");
const progressBar = document.getElementById("progressBar");
const progressPercentage = document.getElementById("progressPercentage");
const progressLog = document.getElementById("progressLog");
const closeButton = document.getElementById("progressModalCloseBtn");
const closeIcon = document.getElementById("closeProgressModalBtn");
if (!titleElement || !statusText || !progressBar || !progressPercentage || !progressLog || !closeButton || !closeIcon) {
console.error("Progress modal is missing required child elements.");
return;
}
titleElement.textContent = title;
statusText.textContent = "\u51C6\u5907\u5F00\u59CB...";
progressBar.style.width = "0%";
progressPercentage.textContent = "0%";
progressLog.innerHTML = "";
closeButton.disabled = true;
closeIcon.disabled = true;
this.show("progressModal");
}
/**
* Updates the progress bar and status text within the progress modal.
* @param {number} processed - The number of items that have been processed.
* @param {number} total - The total number of items to process.
* @param {string} status - The current status message to display.
*/
updateProgress(processed, total, status) {
const modal = document.getElementById("progressModal");
if (!modal || modal.classList.contains("hidden")) return;
const progressBar = document.getElementById("progressBar");
const progressPercentage = document.getElementById("progressPercentage");
const statusText = document.getElementById("progressStatusText");
const closeButton = document.getElementById("progressModalCloseBtn");
const closeIcon = document.getElementById("closeProgressModalBtn");
const percentage = total > 0 ? Math.round(processed / total * 100) : 0;
progressBar.style.width = `${percentage}%`;
progressPercentage.textContent = `${percentage}%`;
statusText.textContent = status;
if (processed === total) {
closeButton.disabled = false;
closeIcon.disabled = false;
}
}
/**
* Adds a log entry to the progress modal's log area.
* @param {string} message - The log message to append.
* @param {boolean} [isError=false] - If true, styles the log entry as an error.
*/
addProgressLog(message, isError = false) {
const progressLog = document.getElementById("progressLog");
if (!progressLog) return;
const logEntry = document.createElement("div");
logEntry.textContent = message;
logEntry.className = isError ? "text-danger-600" : "text-gray-700";
progressLog.appendChild(logEntry);
progressLog.scrollTop = progressLog.scrollHeight;
}
/**
* Closes the progress modal.
* @param {boolean} [reload=false] - If true, reloads the page after closing.
*/
closeProgress(reload = false) {
this.hide("progressModal");
if (reload) {
location.reload();
}
}
};
var UIPatterns = class {
/**
* Animates numerical values in elements from 0 to their target number.
* The target number is read from the element's text content.
* @param {string} selector - The CSS selector for the elements to animate (e.g., '.stat-value').
* @param {number} [duration=1500] - The duration of the animation in milliseconds.
*/
animateCounters(selector = ".stat-value", duration = 1500) {
const statValues = document.querySelectorAll(selector);
statValues.forEach((valueElement) => {
const finalValue = parseInt(valueElement.textContent, 10);
if (isNaN(finalValue)) return;
if (!valueElement.dataset.originalValue) {
valueElement.dataset.originalValue = valueElement.textContent;
}
let startValue = 0;
const startTime = performance.now();
const updateCounter = (currentTime) => {
const elapsedTime = currentTime - startTime;
if (elapsedTime < duration) {
const progress = elapsedTime / duration;
const easeOutValue = 1 - Math.pow(1 - progress, 3);
const currentValue = Math.floor(easeOutValue * finalValue);
valueElement.textContent = currentValue;
requestAnimationFrame(updateCounter);
} else {
valueElement.textContent = valueElement.dataset.originalValue;
}
};
requestAnimationFrame(updateCounter);
});
}
/**
* Toggles the visibility of a content section with a smooth height animation.
* It expects a specific HTML structure where the header and content are within a common parent (e.g., a card).
* The content element should have a `collapsed` class when hidden.
* @param {HTMLElement} header - The header element that was clicked to trigger the toggle.
*/
toggleSection(header) {
const card = header.closest(".stats-card");
if (!card) return;
const content = card.querySelector(".key-content");
const toggleIcon = header.querySelector(".toggle-icon");
if (!content || !toggleIcon) {
console.error("Toggle section failed: Content or icon element not found.", { header });
return;
}
const isCollapsed = content.classList.contains("collapsed");
toggleIcon.classList.toggle("collapsed", !isCollapsed);
if (isCollapsed) {
content.classList.remove("collapsed");
content.style.maxHeight = null;
content.style.opacity = null;
content.style.paddingTop = null;
content.style.paddingBottom = null;
content.style.overflow = "hidden";
requestAnimationFrame(() => {
const targetHeight = content.scrollHeight;
content.style.maxHeight = `${targetHeight}px`;
content.style.opacity = "1";
content.style.paddingTop = "1rem";
content.style.paddingBottom = "1rem";
content.addEventListener("transitionend", function onExpansionEnd() {
content.removeEventListener("transitionend", onExpansionEnd);
if (!content.classList.contains("collapsed")) {
content.style.maxHeight = "";
content.style.overflow = "visible";
}
}, { once: true });
});
} else {
const currentHeight = content.scrollHeight;
content.style.maxHeight = `${currentHeight}px`;
content.style.overflow = "hidden";
requestAnimationFrame(() => {
content.style.maxHeight = "0px";
content.style.opacity = "0";
content.style.paddingTop = "0";
content.style.paddingBottom = "0";
content.classList.add("collapsed");
});
}
}
};
var modalManager = new ModalManager();
var uiPatterns = new UIPatterns();
// frontend/js/components/taskCenter.js
var TaskCenterManager = class {
constructor() {
this.tasks = [];
this.activePolls = /* @__PURE__ */ new Map();
this.heartbeatInterval = null;
this.MINIMUM_TASK_DISPLAY_TIME_MS = 800;
this.hasUnreadCompletedTasks = false;
this.isAnimating = false;
this.countdownTimer = null;
this.trigger = document.getElementById("task-hub-trigger");
this.panel = document.getElementById("task-hub-panel");
this.countdownBar = document.getElementById("task-hub-countdown-bar");
this.countdownRing = document.getElementById("task-hub-countdown-ring");
this.indicator = document.getElementById("task-hub-indicator");
this.clearBtn = document.getElementById("task-hub-clear-btn");
this.taskListContainer = document.getElementById("task-list-container");
this.emptyState = document.getElementById("task-list-empty");
}
// [THE FINAL, DEFINITIVE VERSION]
init() {
if (!this.trigger || !this.panel) {
console.warn("Task Center UI core elements not found. Initialization skipped.");
return;
}
this.trigger.addEventListener("click", (event) => {
event.stopPropagation();
if (this.isAnimating) return;
if (this.panel.classList.contains("hidden")) {
this._handleUserInteraction();
this.openPanel();
} else {
this.closePanel();
}
});
document.addEventListener("click", (event) => {
if (!this.panel.classList.contains("hidden") && !this.isAnimating && !this.panel.contains(event.target) && !this.trigger.contains(event.target)) {
this.closePanel();
}
});
this.trigger.addEventListener("mouseenter", this._stopCountdown.bind(this));
this.panel.addEventListener("mouseenter", this._handleUserInteraction.bind(this));
const handleMouseLeave = () => {
if (!this.panel.classList.contains("hidden")) {
this._startCountdown();
}
};
this.panel.addEventListener("mouseleave", handleMouseLeave);
this.trigger.addEventListener("mouseleave", handleMouseLeave);
this.clearBtn?.addEventListener("click", this.clearCompletedTasks.bind(this));
this.taskListContainer.addEventListener("click", (event) => {
const toggleHeader = event.target.closest("[data-task-toggle]");
if (!toggleHeader) return;
this._handleUserInteraction();
const taskItem = toggleHeader.closest(".task-list-item");
const content = taskItem.querySelector("[data-task-content]");
if (!content) return;
const isCollapsed = content.classList.contains("collapsed");
toggleHeader.classList.toggle("expanded", isCollapsed);
if (isCollapsed) {
content.classList.remove("collapsed");
content.style.maxHeight = `${content.scrollHeight}px`;
content.style.opacity = "1";
content.addEventListener("transitionend", () => {
if (!content.classList.contains("collapsed")) content.style.maxHeight = "none";
}, { once: true });
} else {
content.style.maxHeight = `${content.scrollHeight}px`;
requestAnimationFrame(() => {
content.style.maxHeight = "0px";
content.style.opacity = "0";
content.classList.add("collapsed");
});
}
});
this._render();
this._startHeartbeat();
console.log("Task Center UI Initialized [Multi-Task Heartbeat Polling Architecture - IGNITED].");
}
async startTask(taskDefinition) {
try {
const initialTaskData = await taskDefinition.start();
if (!initialTaskData || !initialTaskData.id) throw new Error("Task definition did not return a valid initial task object.");
const newTask = {
id: initialTaskData.id,
definition: taskDefinition,
data: initialTaskData,
timestamp: /* @__PURE__ */ new Date(),
startTime: Date.now()
};
if (!initialTaskData.is_running) {
console.log(`[TaskCenter] Task ${newTask.id} completed synchronously. Skipping poll.`);
taskDefinition.renderToastNarrative(newTask.data, {}, toastManager);
this.tasks.unshift(newTask);
this._render();
this._handleTaskCompletion(newTask);
return;
}
this.tasks.unshift(newTask);
this.activePolls.set(newTask.id, newTask);
this._render();
this.openPanel();
taskDefinition.renderToastNarrative(newTask.data, {}, toastManager);
this._updateIndicatorState();
} catch (error) {
console.error("Failed to start task:", error);
toastManager.show(`\u4EFB\u52A1\u542F\u52A8\u5931\u8D25: ${error.message}`, "error");
}
}
_startHeartbeat() {
if (this.heartbeatInterval) return;
this.heartbeatInterval = setInterval(this._tick.bind(this), 1500);
}
_stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
async _tick() {
if (this.activePolls.size === 0) {
return;
}
for (const taskId of [...this.activePolls.keys()]) {
const task = this.activePolls.get(taskId);
if (!task) continue;
try {
const response = await task.definition.poll(taskId);
if (!response.success || !response.data) throw new Error(response.message || "Polling failed");
const oldData = { ...task.data };
task.data = response.data;
this._updateTaskItemInHistory(task.id, task.data);
task.definition.renderToastNarrative(task.data, oldData, toastManager);
if (!task.data.is_running) {
this._handleTaskCompletion(task);
}
} catch (error) {
console.error(`Polling for task ${taskId} failed:`, error);
task.data.error = error.message;
this._updateTaskItemInHistory(task.id, task.data);
this._handleTaskCompletion(task);
}
}
}
_handleTaskCompletion(task) {
this.activePolls.delete(task.id);
this._updateIndicatorState();
const toastId = `task-${task.id}`;
const finalize = async () => {
await toastManager.dismiss(toastId, !task.data.error);
this._updateTaskItemInDom(task);
this.hasUnreadCompletedTasks = true;
this._updateIndicatorState();
if (task.data.error) {
if (task.definition.onError) task.definition.onError(task.data);
} else {
if (task.definition.onSuccess) task.definition.onSuccess(task.data);
}
};
const elapsedTime = Date.now() - task.startTime;
const remainingTime = this.MINIMUM_TASK_DISPLAY_TIME_MS - elapsedTime;
if (remainingTime > 0) {
setTimeout(finalize, remainingTime);
} else {
finalize();
}
}
// [REFACTORED for robustness]
_updateIndicatorState() {
const hasRunningTasks = this.activePolls.size > 0;
const shouldBeVisible = hasRunningTasks || this.hasUnreadCompletedTasks;
this.indicator.classList.toggle("hidden", !shouldBeVisible);
}
// [REFACTORED for robustness]
clearCompletedTasks() {
this.tasks = this.tasks.filter((task) => this.activePolls.has(task.id));
this.hasUnreadCompletedTasks = false;
this._render();
}
// [NEW SAFETY METHOD]
_updateTaskItemInHistory(taskId, newData) {
const taskInHistory = this.tasks.find((t) => t.id === taskId);
if (taskInHistory) {
taskInHistory.data = newData;
}
}
// --- 渲染与DOM操作 ---
_render() {
this.taskListContainer.innerHTML = this.tasks.map((task) => this._createTaskItemHtml(task)).join("");
const hasTasks = this.tasks.length > 0;
this.taskListContainer.classList.toggle("hidden", !hasTasks);
this.emptyState.classList.toggle("hidden", hasTasks);
this._updateIndicatorState();
}
_createTaskItemHtml(task) {
const innerHtml = task.definition.renderTaskCenterItem(task.data, task.timestamp, this._formatTimeAgo);
return `<div class="task-list-item" data-task-id="${task.id}">${innerHtml}</div>`;
}
_updateTaskItemInDom(task) {
const item = this.taskListContainer.querySelector(`[data-task-id="${task.id}"]`);
if (item) {
item.innerHTML = task.definition.renderTaskCenterItem(task.data, task.timestamp, this._formatTimeAgo);
}
}
// --- 核心面板开关逻辑 ---
openPanel() {
if (this.isAnimating || !this.panel.classList.contains("hidden")) return;
this.isAnimating = true;
this.panel.classList.remove("hidden");
this.panel.classList.add("animate-panel-in");
setTimeout(() => {
this.panel.classList.remove("animate-panel-in");
this.isAnimating = false;
this._startCountdown();
}, 150);
}
closePanel() {
if (this.isAnimating || this.panel.classList.contains("hidden")) return;
this._stopCountdown();
this.isAnimating = true;
this.panel.classList.add("animate-panel-out");
setTimeout(() => {
this.panel.classList.remove("animate-panel-out");
this.panel.classList.add("hidden");
this.isAnimating = false;
}, 50);
}
// --- [新增] 倒计时管理方法 ---
/**
* 启动或重启倒计时和进度条动画
* @private
*/
_startCountdown() {
this._stopCountdown();
this.countdownBar.classList.add("w-full", "duration-[4950ms]");
this.countdownRing.style.transition = "none";
this.countdownRing.style.strokeDashoffset = "72.26";
void this.countdownRing.offsetHeight;
this.countdownRing.style.transition = "stroke-dashoffset 4.95s linear";
this.countdownRing.style.strokeDashoffset = "0";
this.countdownTimer = setTimeout(() => {
this.closePanel();
}, 4950);
}
/**
* 停止倒计时并重置进度条
* @private
*/
_stopCountdown() {
if (this.countdownTimer) {
clearTimeout(this.countdownTimer);
this.countdownTimer = null;
}
this.countdownBar.classList.remove("w-full");
this.countdownRing.style.transition = "none";
this.countdownRing.style.strokeDashoffset = "72.26";
}
// [NEW] A central handler for any action that confirms the user has seen the panel.
_handleUserInteraction() {
this._stopCountdown();
if (this.hasUnreadCompletedTasks) {
this.hasUnreadCompletedTasks = false;
this._updateIndicatorState();
}
}
_formatTimeAgo(date) {
if (!date) return "";
const seconds = Math.floor((/* @__PURE__ */ new Date() - new Date(date)) / 1e3);
if (seconds < 2) return "\u521A\u521A";
if (seconds < 60) return `${seconds}\u79D2\u524D`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}\u5206\u949F\u524D`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}\u5C0F\u65F6\u524D`;
const days = Math.floor(hours / 24);
return `${days}\u5929\u524D`;
}
};
var ToastManager = class {
constructor() {
this.container = document.getElementById("toast-container");
if (!this.container) {
this.container = document.createElement("div");
this.container.id = "toast-container";
this.container.className = "fixed bottom-4 right-4 z-[100] w-full max-w-sm space-y-3";
document.body.appendChild(this.container);
}
this.activeToasts = /* @__PURE__ */ new Map();
}
/**
* 显示一个 Toast 通知
* @param {string} message - The message to display.
* @param {string} [type='info'] - 'info', 'success', or 'error'.
* @param {number} [duration=4000] - Duration in milliseconds.
*/
show(message, type = "info", duration = 4e3) {
const toastElement = this._createToastHtml(message, type);
this.container.appendChild(toastElement);
requestAnimationFrame(() => {
toastElement.classList.remove("opacity-0", "translate-y-2");
toastElement.classList.add("opacity-100", "translate-y-0");
});
setTimeout(() => {
toastElement.classList.remove("opacity-100", "translate-y-0");
toastElement.classList.add("opacity-0", "translate-y-2");
toastElement.addEventListener("transitionend", () => toastElement.remove(), { once: true });
}, duration);
}
// [NEW] 创建或更新一个带进度条的Toast
showProgressToast(toastId, title, message, progress) {
if (this.activeToasts.has(toastId)) {
const toastElement = this.activeToasts.get(toastId);
const messageEl = toastElement.querySelector(".toast-message");
const progressBar = toastElement.querySelector(".toast-progress-bar");
messageEl.textContent = `${message} - ${Math.round(progress)}%`;
anime({
targets: progressBar,
width: `${progress}%`,
duration: 400,
easing: "easeOutQuad"
});
} else {
const toastElement = this._createProgressToastHtml(toastId, title, message, progress);
this.container.appendChild(toastElement);
this.activeToasts.set(toastId, toastElement);
requestAnimationFrame(() => {
toastElement.classList.remove("opacity-0", "translate-x-full");
toastElement.classList.add("opacity-100", "translate-x-0");
});
}
}
// [NEW] 移除一个进度Toast
dismiss(toastId, success = null) {
return new Promise((resolve) => {
if (!this.activeToasts.has(toastId)) {
resolve();
return;
}
const toastElement = this.activeToasts.get(toastId);
const performFadeOut = () => {
toastElement.classList.remove("opacity-100", "translate-x-0");
toastElement.classList.add("opacity-0", "translate-x-full");
toastElement.addEventListener("transitionend", () => {
toastElement.remove();
this.activeToasts.delete(toastId);
resolve();
}, { once: true });
};
if (success === null) {
performFadeOut();
} else {
const iconContainer = toastElement.querySelector(".toast-icon");
const messageEl = toastElement.querySelector(".toast-message");
if (success) {
const progressBar = toastElement.querySelector(".toast-progress-bar");
messageEl.textContent = "\u5DF2\u5B8C\u6210";
anime({
targets: progressBar,
width: "100%",
duration: 300,
easing: "easeOutQuad",
complete: () => {
iconContainer.innerHTML = `<i class="fas fa-check-circle text-white"></i>`;
iconContainer.className = `toast-icon bg-green-500`;
setTimeout(performFadeOut, 900);
}
});
} else {
iconContainer.innerHTML = `<i class="fas fa-times-circle text-white"></i>`;
iconContainer.className = `toast-icon bg-red-500`;
messageEl.textContent = "\u5931\u8D25";
setTimeout(performFadeOut, 1200);
}
}
});
}
_createToastHtml(message, type) {
const icons = {
info: { class: "bg-blue-500", icon: "fa-info-circle" },
success: { class: "bg-green-500", icon: "fa-check-circle" },
error: { class: "bg-red-500", icon: "fa-exclamation-triangle" }
};
const typeInfo = icons[type] || icons.info;
const toast = document.createElement("div");
toast.className = "toast-item opacity-0 translate-y-2 transition-all duration-300 ease-out";
toast.innerHTML = `
<div class="toast-icon ${typeInfo.class}">
<i class="fas ${typeInfo.icon}"></i>
</div>
<div class="toast-content">
<p class="toast-title">${this._capitalizeFirstLetter(type)}</p>
<p class="toast-message">${message}</p>
</div>
`;
return toast;
}
// [NEW] 创建带进度条Toast的HTML结构
_createProgressToastHtml(toastId, title, message, progress) {
const toast = document.createElement("div");
toast.className = "toast-item opacity-0 translate-x-full transition-all duration-300 ease-out";
toast.dataset.toastId = toastId;
toast.innerHTML = `
<div class="toast-icon bg-blue-500">
<i class="fas fa-spinner animate-spin"></i>
</div>
<div class="toast-content">
<p class="toast-title">${title}</p>
<p class="toast-message">${message} - ${Math.round(progress)}%</p>
<div class="w-full bg-slate-200 dark:bg-zinc-700 rounded-full h-1 mt-1.5 overflow-hidden">
<div class="toast-progress-bar bg-blue-500 h-1 rounded-full" style="width: ${progress}%;"></div>
</div>
</div>
`;
return toast;
}
_capitalizeFirstLetter(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
};
var taskCenterManager = new TaskCenterManager();
var toastManager = new ToastManager();
// frontend/js/pages/dashboard.js
function init() {
console.log("[Modern Frontend] Dashboard module loaded. Future logic will execute here.");
}
// frontend/js/components/tagInput.js
var TagInput = class {
constructor(container, options = {}) {
if (!container) {
console.error("TagInput container not found.");
return;
}
this.container = container;
this.input = container.querySelector(".tag-input-new");
this.tags = [];
this.options = {
validator: /.+/,
validationMessage: "\u8F93\u5165\u683C\u5F0F\u65E0\u6548",
...options
};
this.copyBtn = document.createElement("button");
this.copyBtn.className = "tag-copy-btn";
this.copyBtn.innerHTML = '<i class="fas fa-copy"></i>';
this.copyBtn.title = "\u590D\u5236\u6240\u6709";
this.container.appendChild(this.copyBtn);
this._initEventListeners();
}
_initEventListeners() {
this.container.addEventListener("click", (e) => {
if (e.target.closest(".tag-delete")) {
this._removeTag(e.target.closest(".tag-item"));
}
});
if (this.input) {
this.input.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === "," || e.key === " ") {
e.preventDefault();
const value = this.input.value.trim();
if (value) {
this._addTag(value);
this.input.value = "";
}
}
});
this.input.addEventListener("blur", () => {
const value = this.input.value.trim();
if (value) {
this._addTag(value);
this.input.value = "";
}
});
}
this.copyBtn.addEventListener("click", this._handleCopyAll.bind(this));
}
_addTag(raw_value) {
const value = raw_value.toLowerCase();
if (!this.options.validator.test(value)) {
console.warn(`Tag validation failed for value: "${value}". Rule: ${this.options.validator}`);
this.input.placeholder = this.options.validationMessage;
this.input.classList.add("input-error");
setTimeout(() => {
this.input.classList.remove("input-error");
this.input.placeholder = "\u6DFB\u52A0...";
}, 2e3);
return;
}
if (this.tags.includes(value)) return;
this.tags.push(value);
const tagEl = document.createElement("span");
tagEl.className = "tag-item";
tagEl.innerHTML = `<span class="tag-text">${value}</span><button class="tag-delete">&times;</button>`;
this.container.insertBefore(tagEl, this.input);
}
// 处理复制逻辑的专用方法
_handleCopyAll() {
const tagsString = this.tags.join(",");
if (!tagsString) {
this.copyBtn.innerHTML = "<span>\u65E0\u5185\u5BB9!</span>";
this.copyBtn.classList.add("none");
setTimeout(() => {
this.copyBtn.innerHTML = '<i class="fas fa-copy"></i>';
this.copyBtn.classList.remove("copied");
}, 1500);
return;
}
navigator.clipboard.writeText(tagsString).then(() => {
this.copyBtn.innerHTML = "<span>\u5DF2\u590D\u5236!</span>";
this.copyBtn.classList.add("copied");
setTimeout(() => {
this.copyBtn.innerHTML = '<i class="fas fa-copy"></i>';
this.copyBtn.classList.remove("copied");
}, 2e3);
}).catch((err) => {
console.error("Could not copy text: ", err);
this.copyBtn.innerHTML = "<span>\u5931\u8D25!</span>";
setTimeout(() => {
this.copyBtn.innerHTML = '<i class="fas fa-copy"></i>';
}, 2e3);
});
}
_removeTag(tagEl) {
const value = tagEl.querySelector(".tag-text").textContent;
this.tags = this.tags.filter((t) => t !== value);
tagEl.remove();
}
getValues() {
return this.tags;
}
setValues(values) {
this.container.querySelectorAll(".tag-item").forEach((el) => el.remove());
this.tags = [];
if (Array.isArray(values)) {
values.filter((value) => value).forEach((value) => this._addTag(value));
}
}
};
// frontend/js/pages/keys/requestSettingsModal.js
var RequestSettingsModal = class {
constructor({ onSave }) {
this.modalId = "request-settings-modal";
this.modal = document.getElementById(this.modalId);
this.onSave = onSave;
if (!this.modal) {
throw new Error(`Modal with id "${this.modalId}" not found.`);
}
this.elements = {
saveBtn: document.getElementById("request-settings-save-btn"),
customHeadersContainer: document.getElementById("CUSTOM_HEADERS_container"),
addCustomHeaderBtn: document.getElementById("addCustomHeaderBtn"),
streamOptimizerEnabled: document.getElementById("STREAM_OPTIMIZER_ENABLED"),
streamingSettingsPanel: document.getElementById("streaming-settings-panel"),
streamMinDelay: document.getElementById("STREAM_MIN_DELAY"),
streamMaxDelay: document.getElementById("STREAM_MAX_DELAY"),
streamShortTextThresh: document.getElementById("STREAM_SHORT_TEXT_THRESHOLD"),
streamLongTextThresh: document.getElementById("STREAM_LONG_TEXT_THRESHOLD"),
streamChunkSize: document.getElementById("STREAM_CHUNK_SIZE"),
fakeStreamEnabled: document.getElementById("FAKE_STREAM_ENABLED"),
fakeStreamInterval: document.getElementById("FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS"),
toolsCodeExecutionEnabled: document.getElementById("TOOLS_CODE_EXECUTION_ENABLED"),
urlContextEnabled: document.getElementById("URL_CONTEXT_ENABLED"),
showSearchLink: document.getElementById("SHOW_SEARCH_LINK"),
showThinkingProcess: document.getElementById("SHOW_THINKING_PROCESS"),
safetySettingsContainer: document.getElementById("SAFETY_SETTINGS_container"),
addSafetySettingBtn: document.getElementById("addSafetySettingBtn"),
configOverrides: document.getElementById("group-config-overrides")
};
this._initEventListeners();
}
// --- 公共 API ---
/**
* 打開模態框並填充數據
* @param {object} data - 用於填充表單的數據
*/
open(data) {
this._populateForm(data);
modalManager.show(this.modalId);
}
/**
* 關閉模態框
*/
close() {
modalManager.hide(this.modalId);
}
// --- 內部事件與邏輯 ---
_initEventListeners() {
this.modal.addEventListener("click", (e) => {
const removeBtn = e.target.closest(".remove-btn");
if (removeBtn) {
removeBtn.parentElement.remove();
}
});
if (this.elements.addCustomHeaderBtn) {
this.elements.addCustomHeaderBtn.addEventListener("click", () => this.addCustomHeaderItem());
}
if (this.elements.addSafetySettingBtn) {
this.elements.addSafetySettingBtn.addEventListener("click", () => this.addSafetySettingItem());
}
if (this.elements.saveBtn) {
this.elements.saveBtn.addEventListener("click", this._handleSave.bind(this));
}
if (this.elements.streamOptimizerEnabled) {
this.elements.streamOptimizerEnabled.addEventListener("change", (e) => {
this._toggleStreamingPanel(e.target.checked);
});
}
const closeAction = () => {
modalManager.hide(this.modalId);
};
const closeTriggers = this.modal.querySelectorAll(`[data-modal-close="${this.modalId}"]`);
closeTriggers.forEach((trigger) => {
trigger.addEventListener("click", closeAction);
});
this.modal.addEventListener("click", (event) => {
if (event.target === this.modal) {
closeAction();
}
});
}
async _handleSave() {
const data = this._collectFormData();
if (this.onSave) {
try {
if (this.elements.saveBtn) {
this.elements.saveBtn.disabled = true;
this.elements.saveBtn.textContent = "Saving...";
}
await this.onSave(data);
this.close();
} catch (error) {
console.error("Failed to save request settings:", error);
alert(`\u4FDD\u5B58\u5931\u6557: ${error.message}`);
} finally {
if (this.elements.saveBtn) {
this.elements.saveBtn.disabled = false;
this.elements.saveBtn.textContent = "Save Changes";
}
}
}
}
// --- 所有表單處理輔助方法 ---
_populateForm(data = {}) {
const isStreamOptimizerEnabled = !!data.stream_optimizer_enabled;
this._setToggle(this.elements.streamOptimizerEnabled, isStreamOptimizerEnabled);
this._toggleStreamingPanel(isStreamOptimizerEnabled);
this._setValue(this.elements.streamMinDelay, data.stream_min_delay);
this._setValue(this.elements.streamMaxDelay, data.stream_max_delay);
this._setValue(this.elements.streamShortTextThresh, data.stream_short_text_threshold);
this._setValue(this.elements.streamLongTextThresh, data.stream_long_text_threshold);
this._setValue(this.elements.streamChunkSize, data.stream_chunk_size);
this._setToggle(this.elements.fakeStreamEnabled, data.fake_stream_enabled);
this._setValue(this.elements.fakeStreamInterval, data.fake_stream_empty_data_interval_seconds);
this._setToggle(this.elements.toolsCodeExecutionEnabled, data.tools_code_execution_enabled);
this._setToggle(this.elements.urlContextEnabled, data.url_context_enabled);
this._setToggle(this.elements.showSearchLink, data.show_search_link);
this._setToggle(this.elements.showThinkingProcess, data.show_thinking_process);
this._setValue(this.elements.configOverrides, data.config_overrides);
this._populateKVItems(this.elements.customHeadersContainer, data.custom_headers, this.addCustomHeaderItem.bind(this));
this._clearContainer(this.elements.safetySettingsContainer);
if (data.safety_settings && typeof data.safety_settings === "object") {
for (const [key, value] of Object.entries(data.safety_settings)) {
this.addSafetySettingItem(key, value);
}
}
}
/**
* Collects all data from the form fields and returns it as an object.
* @returns {object} The collected request configuration data.
*/
collectFormData() {
return {
// Simple Toggles & Inputs
stream_optimizer_enabled: this.elements.streamOptimizerEnabled.checked,
stream_min_delay: parseInt(this.elements.streamMinDelay.value, 10),
stream_max_delay: parseInt(this.elements.streamMaxDelay.value, 10),
stream_short_text_threshold: parseInt(this.elements.streamShortTextThresh.value, 10),
stream_long_text_threshold: parseInt(this.elements.streamLongTextThresh.value, 10),
stream_chunk_size: parseInt(this.elements.streamChunkSize.value, 10),
fake_stream_enabled: this.elements.fakeStreamEnabled.checked,
fake_stream_empty_data_interval_seconds: parseInt(this.elements.fakeStreamInterval.value, 10),
tools_code_execution_enabled: this.elements.toolsCodeExecutionEnabled.checked,
url_context_enabled: this.elements.urlContextEnabled.checked,
show_search_link: this.elements.showSearchLink.checked,
show_thinking_process: this.elements.showThinkingProcess.checked,
config_overrides: this.elements.configOverrides.value,
// Dynamic & Complex Fields
custom_headers: this._collectKVItems(this.elements.customHeadersContainer),
safety_settings: this._collectSafetySettings(this.elements.safetySettingsContainer)
// TODO: Collect from Tag Inputs
// image_models: this.imageModelsInput.getValues(),
};
}
// 控制流式面板显示/隐藏的辅助函数
_toggleStreamingPanel(is_enabled) {
if (this.elements.streamingSettingsPanel) {
if (is_enabled) {
this.elements.streamingSettingsPanel.classList.remove("hidden");
} else {
this.elements.streamingSettingsPanel.classList.add("hidden");
}
}
}
/**
* Adds a new key-value pair item for Custom Headers.
* @param {string} [key=''] - The initial key.
* @param {string} [value=''] - The initial value.
*/
addCustomHeaderItem(key = "", value = "") {
const container = this.elements.customHeadersContainer;
const item = document.createElement("div");
item.className = "dynamic-kv-item";
item.innerHTML = `
<input type="text" class="modal-input text-xs bg-zinc-100 dark:bg-zinc-700/50" placeholder="Header Name" value="${key}">
<input type="text" class="modal-input text-xs" placeholder="Header Value" value="${value}">
<button type="button" class="remove-btn text-zinc-400 hover:text-red-500 transition-colors"><i class="fas fa-trash-alt"></i></button>
`;
container.appendChild(item);
}
/**
* Adds a new item for Safety Settings.
* @param {string} [category=''] - The initial category.
* @param {string} [threshold=''] - The initial threshold.
*/
addSafetySettingItem(category = "", threshold = "") {
const container = this.elements.safetySettingsContainer;
const item = document.createElement("div");
item.className = "safety-setting-item flex items-center gap-x-2";
const harmCategories = [
"HARM_CATEGORY_HARASSMENT",
"HARM_CATEGORY_HATE_SPEECH",
"HARM_CATEGORY_SEXUALLY_EXPLICIT",
"HARM_CATEGORY_DANGEROUS_CONTENT",
"HARM_CATEGORY_DANGEROUS_CONTENT",
"HARM_CATEGORY_CIVIC_INTEGRITY"
];
const harmThresholds = [
"BLOCK_OFF",
"BLOCK_NONE",
"BLOCK_LOW_AND_ABOVE",
"BLOCK_MEDIUM_AND_ABOVE",
"BLOCK_ONLY_HIGH"
];
const categorySelect = document.createElement("select");
categorySelect.className = "modal-input flex-grow";
harmCategories.forEach((cat) => {
const option = new Option(cat.replace("HARM_CATEGORY_", ""), cat);
if (cat === category) option.selected = true;
categorySelect.add(option);
});
const thresholdSelect = document.createElement("select");
thresholdSelect.className = "modal-input w-48";
harmThresholds.forEach((thr) => {
const option = new Option(thr.replace("BLOCK_", "").replace("_AND_ABOVE", "+"), thr);
if (thr === threshold) option.selected = true;
thresholdSelect.add(option);
});
const removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.className = "remove-btn text-zinc-400 hover:text-red-500 transition-colors";
removeButton.innerHTML = `<i class="fas fa-trash-alt"></i>`;
item.appendChild(categorySelect);
item.appendChild(thresholdSelect);
item.appendChild(removeButton);
container.appendChild(item);
}
// --- Private Helper Methods for Form Handling ---
_setValue(element, value) {
if (element && value !== null && value !== void 0) {
element.value = value;
}
}
_setToggle(element, value) {
if (element) {
element.checked = !!value;
}
}
_clearContainer(container) {
if (container) {
const firstChild = container.firstElementChild;
const isTemplate = firstChild && (firstChild.tagName === "TEMPLATE" || firstChild.id === "kv-item-header");
let child = isTemplate ? firstChild.nextElementSibling : container.firstElementChild;
while (child) {
const next = child.nextElementSibling;
child.remove();
child = next;
}
}
}
_populateKVItems(container, items, addItemFn) {
this._clearContainer(container);
if (items && typeof items === "object") {
for (const [key, value] of Object.entries(items)) {
addItemFn(key, value);
}
}
}
_collectKVItems(container) {
const items = {};
container.querySelectorAll(".dynamic-kv-item").forEach((item) => {
const keyEl = item.querySelector(".dynamic-kv-key");
const valueEl = item.querySelector(".dynamic-kv-value");
if (keyEl && valueEl && keyEl.value) {
items[keyEl.value] = valueEl.value;
}
});
return items;
}
_collectSafetySettings(container) {
const items = {};
container.querySelectorAll(".safety-setting-item").forEach((item) => {
const categorySelect = item.querySelector("select:first-child");
const thresholdSelect = item.querySelector("select:last-of-type");
if (categorySelect && thresholdSelect && categorySelect.value) {
items[categorySelect.value] = thresholdSelect.value;
}
});
return items;
}
};
// frontend/js/services/api.js
var APIClientError = class extends Error {
constructor(message, status, code, rawMessageFromServer) {
super(message);
this.name = "APIClientError";
this.status = status;
this.code = code;
this.rawMessageFromServer = rawMessageFromServer;
}
};
var apiPromiseCache = /* @__PURE__ */ new Map();
async function apiFetch(url, options = {}) {
const isGetRequest = !options.method || options.method.toUpperCase() === "GET";
const cacheKey = isGetRequest && !options.noCache ? url : null;
if (cacheKey && apiPromiseCache.has(cacheKey)) {
return apiPromiseCache.get(cacheKey);
}
const token = localStorage.getItem("bearerToken");
const headers = {
"Content-Type": "application/json",
...options.headers
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const requestPromise = (async () => {
try {
const response = await fetch(url, { ...options, headers });
if (response.status === 401) {
if (cacheKey) apiPromiseCache.delete(cacheKey);
localStorage.removeItem("bearerToken");
if (window.location.pathname !== "/login") {
window.location.href = "/login?error=\u4F1A\u8BDD\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55\u3002";
}
throw new APIClientError("Unauthorized", 401, "UNAUTHORIZED", "Session expired or token is invalid.");
}
if (!response.ok) {
let errorData = null;
let rawMessage = "";
try {
rawMessage = await response.text();
if (rawMessage) {
errorData = JSON.parse(rawMessage);
}
} catch (e) {
errorData = { error: { code: "UNKNOWN_FORMAT", message: rawMessage || response.statusText } };
}
const code = errorData?.error?.code || "UNKNOWN_ERROR";
const messageFromServer = errorData?.error?.message || rawMessage || "No message provided by server.";
const error = new APIClientError(
`API request failed: ${response.status}`,
response.status,
code,
messageFromServer
);
throw error;
}
return response;
} catch (error) {
if (cacheKey) apiPromiseCache.delete(cacheKey);
throw error;
}
})();
if (cacheKey) {
apiPromiseCache.set(cacheKey, requestPromise);
}
return requestPromise;
}
async function apiFetchJson(url, options = {}) {
try {
const response = await apiFetch(url, options);
const clonedResponse = response.clone();
const jsonData = await clonedResponse.json();
return jsonData;
} catch (error) {
throw error;
}
}
// frontend/js/components/apiKeyManager.js
var ApiKeyManager = class {
constructor() {
}
// [新增] 开始一个向指定分组添加Keys的异步任务
/**
* Starts a task to add multiple API keys to a specific group.
* @param {number} groupId - The ID of the group.
* @param {string} keysText - A string of keys, separated by newlines.
* @returns {Promise<object>} A promise that resolves to the initial task status object.
*/
async addKeysToGroup(groupId, keysText, validate) {
const payload = {
key_group_id: groupId,
keys: keysText,
validate_on_import: validate
};
const response = await apiFetch(`/admin/keygroups/${groupId}/apikeys/bulk`, {
method: "POST",
body: JSON.stringify(payload),
noCache: true
});
return response.json();
}
// [新增] 查询一个指定任务的当前状态
/**
* Gets the current status of a background task.
* @param {string} taskId - The ID of the task.
* @returns {Promise<object>} A promise that resolves to the task status object.
*/
getTaskStatus(taskId, options = {}) {
return apiFetchJson(`/admin/tasks/${taskId}`, options);
}
/**
* Fetches a paginated and filtered list of keys.
* @param {string} type - The type of keys to fetch ('valid' or 'invalid').
* @param {number} [page=1] - The page number to retrieve.
* @param {number} [limit=10] - The number of keys per page.
* @param {string} [searchTerm=''] - A search term to filter keys.
* @param {number|null} [failCountThreshold=null] - A threshold for filtering by failure count.
* @returns {Promise<object>} A promise that resolves to the API response data.
*/
async fetchKeys(type, page = 1, limit = 10, searchTerm = "", failCountThreshold = null) {
const params = new URLSearchParams({
page,
limit,
status: type
});
if (searchTerm) params.append("search", searchTerm);
if (failCountThreshold !== null) params.append("fail_count_threshold", failCountThreshold);
return await apiFetch(`/api/keys?${params.toString()}`);
}
/**
* Starts a task to unlink multiple API keys from a specific group.
* @param {number} groupId - The ID of the group.
* @param {string} keysText - A string of keys, separated by newlines.
* @returns {Promise<object>} A promise that resolves to the initial task status object.
*/
async unlinkKeysFromGroup(groupId, keysInput) {
let keysAsText;
if (Array.isArray(keysInput)) {
keysAsText = keysInput.join("\n");
} else {
keysAsText = keysInput;
}
const payload = {
key_group_id: groupId,
keys: keysAsText
};
const response = await apiFetch(`/admin/keygroups/${groupId}/apikeys/bulk`, {
method: "DELETE",
body: JSON.stringify(payload),
noCache: true
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(errorData.message || `Request failed with status ${response.status}`);
}
return response.json();
}
/**
* 更新一个Key在特定分组中的状态 (e.g., 'ACTIVE', 'DISABLED').
* @param {number} groupId - The ID of the group.
* @param {number} keyId - The ID of the API key (api_keys.id).
* @param {string} newStatus - The new operational status ('ACTIVE', 'DISABLED', etc.).
* @returns {Promise<object>} A promise that resolves to the updated mapping object.
*/
async updateKeyStatusInGroup(groupId, keyId, newStatus) {
const endpoint = `/admin/keygroups/${groupId}/apikeys/${keyId}`;
const payload = { status: newStatus };
return await apiFetchJson(endpoint, {
method: "PUT",
body: JSON.stringify(payload),
noCache: true
});
}
/**
* [MODIFIED] Fetches a paginated and filtered list of API key details for a specific group.
* @param {number} groupId - The ID of the group.
* @param {object} [params={}] - An object containing pagination and filter parameters.
* @param {number} [params.page=1] - The page number to fetch.
* @param {number} [params.limit=20] - The number of items per page.
* @param {string} [params.status] - An optional status to filter the keys by.
* @returns {Promise<object>} A promise that resolves to a pagination object.
*/
async getKeysForGroup(groupId, params = {}) {
const query = new URLSearchParams({
page: params.page || 1,
// Default to page 1 if not provided
limit: params.limit || 20
// Default to 20 per page if not provided
});
if (params.status) {
query.append("status", params.status);
}
if (params.keyword && params.keyword.trim() !== "") {
query.append("keyword", params.keyword.trim());
}
const url = `/admin/keygroups/${groupId}/apikeys?${query.toString()}`;
const responseData = await apiFetchJson(url, { noCache: true });
if (!responseData.success || typeof responseData.data !== "object" || !Array.isArray(responseData.data.items)) {
throw new Error(responseData.message || "Failed to fetch paginated keys for the group.");
}
return responseData.data;
}
/**
* 启动一个重新验证一个或多个Key的异步任务。
* @param {number} groupId - The ID of the group context for validation.
* @param {string[]} keyValues - An array of API key strings to revalidate.
* @returns {Promise<object>} A promise that resolves to the initial task status object.
*/
async revalidateKeys(groupId, keyValues) {
const payload = {
keys: keyValues.join("\n")
};
const url = `/admin/keygroups/${groupId}/apikeys/test`;
const responseData = await apiFetchJson(url, {
method: "POST",
body: JSON.stringify(payload),
noCache: true
});
if (!responseData.success || !responseData.data) {
throw new Error(responseData.message || "Failed to start revalidation task.");
}
return responseData.data;
}
/**
* Starts a generic bulk action task for an entire group based on filters.
* This single function replaces the need for separate cleanup, revalidate, and restore functions.
* @param {number} groupId The group ID.
* @param {object} payload The body of the request, defining the action and filters.
* @returns {Promise<object>} The initial task response with a task_id.
*/
async startGroupBulkActionTask(groupId, payload) {
const url = `/admin/keygroups/${groupId}/bulk-actions`;
const responseData = await apiFetchJson(url, {
method: "POST",
body: JSON.stringify(payload)
});
if (!responseData.success || !responseData.data) {
throw new Error(responseData.message || "\u672A\u80FD\u542F\u52A8\u5206\u7EC4\u6279\u91CF\u4EFB\u52A1\u3002");
}
return responseData.data;
}
/**
* [NEW] Fetches all keys for a group, filtered by status, for export purposes using the dedicated export API.
* @param {number} groupId The ID of the group.
* @param {string[]} statuses An array of statuses to filter by (e.g., ['active', 'cooldown']). Use ['all'] for everything.
* @returns {Promise<string[]>} A promise that resolves to an array of API key strings.
*/
async exportKeysForGroup(groupId, statuses = ["all"]) {
const params = new URLSearchParams();
statuses.forEach((status) => params.append("status", status));
const url = `/admin/keygroups/${groupId}/apikeys/export?${params.toString()}`;
const responseData = await apiFetchJson(url, { noCache: true });
if (!responseData.success || !Array.isArray(responseData.data)) {
throw new Error(responseData.message || "\u672A\u80FD\u83B7\u53D6\u7528\u4E8E\u5BFC\u51FA\u7684Key\u5217\u8868\u3002");
}
return responseData.data;
}
/** 以下为GB预置函数未做对齐
* Verifies a single API key.
* @param {string} key - The API key to verify.
* @returns {Promise<object>} A promise that resolves to the API response data.
*/
async verifyKey(key) {
return await apiFetch(`/gemini/v1beta/verify-key/${key}`, { method: "POST" });
}
/**
* Verifies a batch of selected API keys.
* @param {string[]} keys - An array of API keys to verify.
* @returns {Promise<object>} A promise that resolves to the API response data.
*/
async verifySelectedKeys(keys) {
return await apiFetch(`/gemini/v1beta/verify-selected-keys`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ keys })
});
}
/**
* Resets the failure count for a single API key.
* @param {string} key - The API key whose failure count is to be reset.
* @returns {Promise<object>} A promise that resolves to the API response data.
*/
async resetFailCount(key) {
return await apiFetch(`/gemini/v1beta/reset-fail-count/${key}`, { method: "POST" });
}
/**
* Resets the failure count for a batch of selected API keys.
* @param {string[]} keys - An array of API keys to reset.
* @returns {Promise<object>} A promise that resolves to the API response data.
*/
async resetSelectedFailCounts(keys) {
return await apiFetch(`/gemini/v1beta/reset-selected-fail-counts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ keys })
});
}
/**
* Deletes a single API key.
* @param {string} key - The API key to delete.
* @returns {Promise<object>} A promise that resolves to the API response data.
*/
async deleteKey(key) {
return await apiFetch(`/api/config/keys/${key}`, { method: "DELETE" });
}
/**
* Deletes a batch of selected API keys.
* @param {string[]} keys - An array of API keys to delete.
* @returns {Promise<object>} A promise that resolves to the API response data.
*/
async deleteSelectedKeys(keys) {
return await apiFetch("/api/config/keys/delete-selected", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ keys })
});
}
/**
* Fetches all keys, both valid and invalid.
* @returns {Promise<object>} A promise that resolves to an object containing 'valid_keys' and 'invalid_keys' arrays.
*/
async fetchAllKeys() {
return await apiFetch("/api/keys/all");
}
/**
* Fetches usage details for a specific key over the last 24 hours.
* @param {string} key - The API key to get details for.
* @returns {Promise<object>} A promise that resolves to the API response data.
*/
async getKeyUsageDetails(key) {
return await apiFetch(`/api/key-usage-details/${key}`);
}
/**
* Fetches API call statistics for a given period.
* @param {string} period - The time period for the stats (e.g., '1m', '1h', '24h').
* @returns {Promise<object>} A promise that resolves to the API response data.
*/
async getStatsDetails(period) {
return await apiFetch(`/api/stats/details?period=${period}`);
}
};
var apiKeyManager = new ApiKeyManager();
// frontend/js/utils/utils.js
function debounce(func, wait) {
let timeout;
const debounced = function(...args) {
const context = this;
const later = () => {
clearTimeout(timeout);
func.apply(context, args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
debounced.cancel = () => {
clearTimeout(timeout);
};
return debounced;
}
function isValidApiKeyFormat(key) {
const patterns = [
// Google Gemini API Key: AIzaSy + 33 characters (alphanumeric, _, -)
/^AIzaSy[\w-]{33}$/,
// OpenAI API Key (新格式): sk- + 48 alphanumeric characters
/^sk-[\w]{48}$/,
// Google AI Studio Key: gsk_ + alphanumeric & hyphens
/^gsk_[\w-]{40,}$/,
// Anthropic API Key (示例): sk-ant-api03- + long string
/^sk-ant-api\d{2}-[\w-]{80,}$/,
// Fallback for other potential "sk-" keys with a reasonable length
/^sk-[\w-]{20,}$/
];
return patterns.some((pattern) => pattern.test(key));
}
function escapeHTML(str) {
if (typeof str !== "string") {
return str;
}
return str.replace(/[&<>"']/g, function(match) {
return {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;"
}[match];
});
}
// frontend/js/pages/keys/addApiModal.js
var AddApiModal = class {
constructor({ onImportSuccess }) {
this.modalId = "add-api-modal";
this.onImportSuccess = onImportSuccess;
this.activeGroupId = null;
this.elements = {
modal: document.getElementById(this.modalId),
title: document.getElementById("add-api-modal-title"),
inputView: document.getElementById("add-api-input-view"),
textarea: document.getElementById("api-add-textarea"),
importBtn: document.getElementById("add-api-import-btn"),
validateCheckbox: document.getElementById("validate-on-import-checkbox")
};
if (!this.elements.modal) {
throw new Error(`Modal with id "${this.modalId}" not found.`);
}
this._initEventListeners();
}
open(activeGroupId) {
if (!activeGroupId) {
console.error("Cannot open AddApiModal: activeGroupId is required.");
return;
}
this.activeGroupId = activeGroupId;
this._reset();
modalManager.show(this.modalId);
}
_initEventListeners() {
this.elements.importBtn?.addEventListener("click", this._handleSubmit.bind(this));
const closeAction = () => {
this._reset();
modalManager.hide(this.modalId);
};
const closeTriggers = this.elements.modal.querySelectorAll(`[data-modal-close="${this.modalId}"]`);
closeTriggers.forEach((trigger) => trigger.addEventListener("click", closeAction));
this.elements.modal.addEventListener("click", (event) => {
if (event.target === this.elements.modal) closeAction();
});
}
async _handleSubmit(event) {
event.preventDefault();
const cleanedKeys = this._parseAndCleanKeys(this.elements.textarea.value);
if (cleanedKeys.length === 0) {
alert("\u6CA1\u6709\u68C0\u6D4B\u5230\u6709\u6548\u7684API Keys\u3002");
return;
}
this.elements.importBtn.disabled = true;
this.elements.importBtn.innerHTML = `<i class="fas fa-spinner fa-spin mr-2"></i>\u6B63\u5728\u542F\u52A8...`;
const addKeysTask = {
start: async () => {
const shouldValidate = this.elements.validateCheckbox.checked;
const response = await apiKeyManager.addKeysToGroup(this.activeGroupId, cleanedKeys.join("\n"), shouldValidate);
if (!response.success || !response.data) throw new Error(response.message || "\u542F\u52A8\u5BFC\u5165\u4EFB\u52A1\u5931\u8D25\u3002");
return response.data;
},
poll: async (taskId) => {
return await apiKeyManager.getTaskStatus(taskId, { noCache: true });
},
renderTaskCenterItem: (data, timestamp, formatTimeAgo) => {
const timeAgo = formatTimeAgo(timestamp);
let contentHtml = "";
if (!data.is_running && !data.error) {
const result = data.result || {};
const newlyLinked = result.newly_linked_count || 0;
const alreadyLinked = result.already_linked_count || 0;
const summaryTitle = `\u6279\u91CF\u94FE\u63A5 ${newlyLinked} Key\uFF0C\u5DF2\u8DF3\u8FC7 ${alreadyLinked}`;
contentHtml = `
<div class="task-item-main">
<div class="task-item-icon-summary text-green-500"><i class="fas fa-check-circle"></i></div>
<div class="task-item-content flex-grow">
<div class="flex justify-between items-center cursor-pointer" data-task-toggle>
<p class="task-item-title">${summaryTitle}</p>
<i class="fas fa-chevron-down task-toggle-icon"></i>
</div>
<div class="task-details-content collapsed" data-task-content>
<div class="task-details-body space-y-1">
<p class="flex justify-between"><span>\u6709\u6548\u8F93\u5165:</span> <span class="font-semibold">${data.total}</span></p>
<p class="flex justify-between"><span>\u5206\u7EC4\u4E2D\u5DF2\u5B58\u5728 (\u8DF3\u8FC7):</span> <span class="font-semibold">${alreadyLinked}</span></p>
<p class="flex justify-between font-bold"><span>\u65B0\u589E\u94FE\u63A5:</span> <span>${newlyLinked}</span></p>
</div>
</div>
</div>
</div>
`;
} else if (!data.is_running && data.error) {
contentHtml = `
<div class="task-item-main">
<div class="task-item-icon-summary text-red-500"><i class="fas fa-times-circle"></i></div>
<div class="task-item-content flex-grow">
<p class="task-item-title">\u6279\u91CF\u6DFB\u52A0\u5931\u8D25</p>
<p class="task-item-status text-red-500 truncate" title="${data.error || "\u672A\u77E5\u9519\u8BEF"}">
${data.error || "\u672A\u77E5\u9519\u8BEF"}
</p>
</div>
</div>`;
} else {
contentHtml = `
<div class="task-item-main gap-3">
<div class="task-item-icon task-item-icon-running"><i class="fas fa-spinner animate-spin"></i></div>
<div class="task-item-content flex-grow">
<p class="task-item-title">\u6279\u91CF\u6DFB\u52A0 ${data.total} \u4E2AAPI Key</p>
<p class="task-item-status">\u8FD0\u884C\u4E2D...</p>
</div>
</div>`;
}
return `${contentHtml}<div class="task-item-timestamp">${timeAgo}</div>`;
},
renderToastNarrative: (data, oldData, toastManager2) => {
const toastId = `task-${data.id}`;
const progress = data.total > 0 ? data.processed / data.total * 100 : 0;
toastManager2.showProgressToast(toastId, `\u6279\u91CF\u6DFB\u52A0Key`, "\u5904\u7406\u4E2D", progress);
},
// This now ONLY shows the FINAL summary toast, after everything else is done.
onSuccess: (data) => {
if (this.onImportSuccess) this.onImportSuccess();
const newlyLinked = data.result?.newly_linked_count || 0;
toastManager.show(`\u4EFB\u52A1\u5B8C\u6210\uFF01\u6210\u529F\u94FE\u63A5 ${newlyLinked} \u4E2AKey\u3002`, "success");
},
// This is the final error handler.
onError: (data) => {
toastManager.show(`\u4EFB\u52A1\u5931\u8D25: ${data.error || "\u672A\u77E5\u9519\u8BEF"}`, "error");
}
};
taskCenterManager.startTask(addKeysTask);
modalManager.hide(this.modalId);
this._reset();
}
_reset() {
this.elements.title.textContent = "\u6279\u91CF\u6DFB\u52A0 API Keys";
this.elements.inputView.classList.remove("hidden");
this.elements.textarea.value = "";
this.elements.textarea.disabled = false;
this.elements.importBtn.disabled = false;
this.elements.importBtn.innerHTML = "\u5BFC\u5165";
}
_parseAndCleanKeys(text) {
const keys = text.replace(/[,;]/g, " ").split(/[\s\n]+/);
const cleanedKeys = keys.map((key) => key.trim()).filter((key) => isValidApiKeyFormat(key));
return [...new Set(cleanedKeys)];
}
};
// frontend/js/pages/keys/deleteApiModal.js
var DeleteApiModal = class {
constructor({ onDeleteSuccess }) {
this.modalId = "delete-api-modal";
this.onDeleteSuccess = onDeleteSuccess;
this.activeGroupId = null;
this.elements = {
modal: document.getElementById(this.modalId),
textarea: document.getElementById("api-delete-textarea"),
deleteBtn: document.getElementById(this.modalId).querySelector(".modal-btn-danger")
};
if (!this.elements.modal) {
throw new Error(`Modal with id "${this.modalId}" not found.`);
}
this._initEventListeners();
}
open(activeGroupId) {
if (!activeGroupId) {
console.error("Cannot open DeleteApiModal: activeGroupId is required.");
return;
}
this.activeGroupId = activeGroupId;
this._reset();
modalManager.show(this.modalId);
}
_initEventListeners() {
this.elements.deleteBtn?.addEventListener("click", this._handleSubmit.bind(this));
const closeAction = () => {
this._reset();
modalManager.hide(this.modalId);
};
const closeTriggers = this.elements.modal.querySelectorAll(`[data-modal-close="${this.modalId}"]`);
closeTriggers.forEach((trigger) => trigger.addEventListener("click", closeAction));
this.elements.modal.addEventListener("click", (event) => {
if (event.target === this.elements.modal) closeAction();
});
}
async _handleSubmit(event) {
event.preventDefault();
const cleanedKeys = this._parseAndCleanKeys(this.elements.textarea.value);
if (cleanedKeys.length === 0) {
alert("\u6CA1\u6709\u68C0\u6D4B\u5230\u6709\u6548\u7684API Keys\u3002");
return;
}
this.elements.deleteBtn.disabled = true;
this.elements.deleteBtn.innerHTML = `<i class="fas fa-spinner fa-spin mr-2"></i>\u6B63\u5728\u542F\u52A8...`;
const deleteKeysTask = {
start: async () => {
const response = await apiKeyManager.unlinkKeysFromGroup(this.activeGroupId, cleanedKeys.join("\n"));
if (!response.success || !response.data) throw new Error(response.message || "\u542F\u52A8\u89E3\u7ED1\u4EFB\u52A1\u5931\u8D25\u3002");
return response.data;
},
poll: async (taskId) => {
return await apiKeyManager.getTaskStatus(taskId, { noCache: true });
},
renderTaskCenterItem: (data, timestamp, formatTimeAgo) => {
const timeAgo = formatTimeAgo(timestamp);
let contentHtml = "";
if (!data.is_running && !data.error) {
const result = data.result || {};
const unlinked = result.unlinked_count || 0;
const deleted = result.hard_deleted_count || 0;
const notFound = result.not_found_count || 0;
const totalInput = data.total;
const summaryTitle = `\u89E3\u7ED1 ${unlinked} Key\uFF0C\u6E05\u7406 ${deleted}`;
contentHtml = `
<div class="task-item-main">
<div class="task-item-icon-summary text-green-500"><i class="fas fa-check-circle"></i></div>
<div class="task-item-content flex-grow">
<div class="flex justify-between items-center cursor-pointer" data-task-toggle>
<p class="task-item-title">${summaryTitle}</p>
<i class="fas fa-chevron-down task-toggle-icon"></i>
</div>
<div class="task-details-content collapsed" data-task-content>
<div class="task-details-body space-y-1">
<p class="flex justify-between"><span>\u6709\u6548\u8F93\u5165:</span> <span class="font-semibold">${totalInput}</span></p>
<p class="flex justify-between"><span>\u672A\u5728\u5206\u7EC4\u4E2D\u627E\u5230:</span> <span class="font-semibold">${notFound}</span></p>
<p class="flex justify-between"><span>\u4ECE\u5206\u7EC4\u4E2D\u89E3\u7ED1:</span> <span class="font-semibold">${unlinked}</span></p>
<p class="flex justify-between font-bold"><span>\u5F7B\u5E95\u6E05\u7406\u5B64\u7ACBKey:</span> <span>${deleted}</span></p>
</div>
</div>
</div>
</div>
`;
} else if (!data.is_running && data.error) {
contentHtml = `
<div class="task-item-main">
<div class="task-item-icon-summary text-red-500"><i class="fas fa-times-circle"></i></div>
<div class="task-item-content flex-grow">
<p class="task-item-title">\u6279\u91CF\u5220\u9664\u5931\u8D25</p>
<p class="task-item-status text-red-500 truncate" title="${data.error || "\u672A\u77E5\u9519\u8BEF"}">
${data.error || "\u672A\u77E5\u9519\u8BEF"}
</p>
</div>
</div>`;
} else {
contentHtml = `
<div class="task-item-main gap-3">
<div class="task-item-icon task-item-icon-running"><i class="fas fa-spinner animate-spin"></i></div>
<div class="task-item-content flex-grow">
<p class="task-item-title">\u6279\u91CF\u5220\u9664 ${data.total} \u4E2AAPI Key</p>
<p class="task-item-status">\u8FD0\u884C\u4E2D...</p>
</div>
</div>`;
}
return `${contentHtml}<div class="task-item-timestamp">${timeAgo}</div>`;
},
// he Toast is now solely responsible for showing real-time progress.
renderToastNarrative: (data, oldData, toastManager2) => {
const toastId = `task-${data.id}`;
const progress = data.total > 0 ? data.processed / data.total * 100 : 0;
toastManager2.showProgressToast(toastId, `\u6279\u91CF\u5220\u9664Key`, "\u5904\u7406\u4E2D", progress);
},
// This now ONLY shows the FINAL summary toast, after everything else is done.
onSuccess: (data) => {
if (this.onDeleteSuccess) this.onDeleteSuccess();
const newlyLinked = data.result?.newly_linked_count || 0;
toastManager.show(`\u4EFB\u52A1\u5B8C\u6210\uFF01\u6210\u529F\u5220\u9664 ${newlyLinked} \u4E2AKey\u3002`, "success");
},
// This is the final error handler.
onError: (data) => {
toastManager.show(`\u4EFB\u52A1\u5931\u8D25: ${data.error || "\u672A\u77E5\u9519\u8BEF"}`, "error");
}
};
taskCenterManager.startTask(deleteKeysTask);
modalManager.hide(this.modalId);
this._reset();
}
_reset() {
this.elements.textarea.value = "";
this.elements.deleteBtn.disabled = false;
this.elements.deleteBtn.innerHTML = "\u5220\u9664";
}
_parseAndCleanKeys(text) {
const keys = text.replace(/[,;]/g, " ").split(/[\s\n]+/);
const cleanedKeys = keys.map((key) => key.trim()).filter((key) => isValidApiKeyFormat(key));
return [...new Set(cleanedKeys)];
}
};
// frontend/js/pages/keys/keyGroupModal.js
var MAX_GROUP_NAME_LENGTH = 32;
var KeyGroupModal = class {
constructor({ onSave, tagInputInstances }) {
this.modalId = "keygroup-modal";
this.onSave = onSave;
this.tagInputs = tagInputInstances;
this.editingGroupId = null;
const modal = document.getElementById(this.modalId);
if (!modal) {
throw new Error(`Modal with id "${this.modalId}" not found.`);
}
this.elements = {
modal,
title: document.getElementById("modal-title"),
saveBtn: document.getElementById("modal-save-btn"),
// 表单字段
nameInput: document.getElementById("group-name"),
nameHelper: document.getElementById("group-name-helper"),
displayNameInput: document.getElementById("group-display-name"),
descriptionInput: document.getElementById("group-description"),
strategySelect: document.getElementById("group-strategy"),
maxRetriesInput: document.getElementById("group-max-retries"),
failureThresholdInput: document.getElementById("group-key-blacklist-threshold"),
enableProxyToggle: document.getElementById("group-enable-proxy"),
enableSmartGatewayToggle: document.getElementById("group-enable-smart-gateway"),
// 自动验证设置
enableKeyCheckToggle: document.getElementById("group-enable-key-check"),
keyCheckSettingsPanel: document.getElementById("key-check-settings"),
keyCheckModelInput: document.getElementById("group-key-check-model"),
keyCheckIntervalInput: document.getElementById("group-key-check-interval-minutes"),
keyCheckConcurrencyInput: document.getElementById("group-key-check-concurrency"),
keyCooldownInput: document.getElementById("group-key-cooldown-minutes"),
keyCheckEndpointInput: document.getElementById("group-key-check-endpoint")
};
this._initEventListeners();
}
open(groupData = null) {
this._populateForm(groupData);
modalManager.show(this.modalId);
}
close() {
modalManager.hide(this.modalId);
}
_initEventListeners() {
if (this.elements.saveBtn) {
this.elements.saveBtn.addEventListener("click", this._handleSave.bind(this));
}
if (this.elements.nameInput) {
this.elements.nameInput.addEventListener("input", this._sanitizeGroupName.bind(this));
}
if (this.elements.enableKeyCheckToggle) {
this.elements.enableKeyCheckToggle.addEventListener("change", (e) => {
this.elements.keyCheckSettingsPanel.classList.toggle("hidden", !e.target.checked);
});
}
const closeAction = () => this.close();
const closeTriggers = this.elements.modal.querySelectorAll(`[data-modal-close="${this.modalId}"]`);
closeTriggers.forEach((trigger) => trigger.addEventListener("click", closeAction));
this.elements.modal.addEventListener("click", (event) => {
if (event.target === this.elements.modal) closeAction();
});
}
// 实时净化 group name 的哨兵函数
_sanitizeGroupName(event) {
const input = event.target;
let value = input.value;
value = value.toLowerCase();
value = value.replace(/[^a-z0-9-]/g, "");
if (value.length > MAX_GROUP_NAME_LENGTH) {
value = value.substring(0, MAX_GROUP_NAME_LENGTH);
}
if (input.value !== value) {
input.value = value;
}
}
async _handleSave() {
this._sanitizeGroupName({ target: this.elements.nameInput });
const data = this._collectFormData();
if (!data.name || !data.display_name) {
alert("\u5206\u7EC4\u540D\u79F0\u548C\u663E\u793A\u540D\u79F0\u662F\u5FC5\u586B\u9879\u3002");
return;
}
const groupNameRegex = /^[a-z0-9-]+$/;
if (!groupNameRegex.test(data.name) || data.name.length > MAX_GROUP_NAME_LENGTH) {
alert("\u5206\u7EC4\u540D\u79F0\u683C\u5F0F\u65E0\u6548\u3002\u4EC5\u9650\u4F7F\u7528\u5C0F\u5199\u5B57\u6BCD\u3001\u6570\u5B57\u548C\u8FDE\u5B57\u7B26(-)\uFF0C\u4E14\u957F\u5EA6\u4E0D\u8D85\u8FC732\u4E2A\u5B57\u7B26\u3002");
return;
}
if (this.onSave) {
this.elements.saveBtn.disabled = true;
this.elements.saveBtn.textContent = "\u4FDD\u5B58\u4E2D...";
try {
await this.onSave(data);
this.close();
} catch (error) {
console.error("Failed to save key group:", error);
} finally {
this.elements.saveBtn.disabled = false;
this.elements.saveBtn.textContent = "\u4FDD\u5B58";
}
}
}
_populateForm(data) {
if (data) {
this.editingGroupId = data.id;
this.elements.title.textContent = "\u7F16\u8F91 Key Group";
this.elements.nameInput.value = data.name || "";
this.elements.nameInput.disabled = false;
this.elements.displayNameInput.value = data.display_name || "";
this.elements.descriptionInput.value = data.description || "";
this.elements.strategySelect.value = data.polling_strategy || "random";
this.elements.enableProxyToggle.checked = data.enable_proxy || false;
const settings = data.settings && data.settings.SettingsJSON ? data.settings.SettingsJSON : {};
this.elements.maxRetriesInput.value = settings.max_retries ?? "";
this.elements.failureThresholdInput.value = settings.key_blacklist_threshold ?? "";
this.elements.enableSmartGatewayToggle.checked = settings.enable_smart_gateway || false;
const isKeyCheckEnabled = settings.enable_key_check || false;
this.elements.enableKeyCheckToggle.checked = isKeyCheckEnabled;
this.elements.keyCheckSettingsPanel.classList.toggle("hidden", !isKeyCheckEnabled);
this.elements.keyCheckModelInput.value = settings.key_check_model || "";
this.elements.keyCheckIntervalInput.value = settings.key_check_interval_minutes ?? "";
this.elements.keyCheckConcurrencyInput.value = settings.key_check_concurrency ?? "";
this.elements.keyCooldownInput.value = settings.key_cooldown_minutes ?? "";
this.elements.keyCheckEndpointInput.value = settings.key_check_endpoint || "";
this.tagInputs.models.setValues(data.allowed_models || []);
this.tagInputs.upstreams.setValues(data.allowed_upstreams || []);
this.tagInputs.tokens.setValues(data.allowed_tokens || []);
} else {
this.editingGroupId = null;
this.elements.title.textContent = "\u521B\u5EFA\u65B0\u7684 Key Group";
this._resetForm();
}
}
_collectFormData() {
const parseIntOrNull = (value) => {
const trimmed = value.trim();
return trimmed === "" ? null : parseInt(trimmed, 10);
};
const formData = {
name: this.elements.nameInput.value.trim(),
display_name: this.elements.displayNameInput.value.trim(),
description: this.elements.descriptionInput.value.trim(),
polling_strategy: this.elements.strategySelect.value,
max_retries: parseIntOrNull(this.elements.maxRetriesInput.value),
key_blacklist_threshold: parseIntOrNull(this.elements.failureThresholdInput.value),
enable_proxy: this.elements.enableProxyToggle.checked,
enable_smart_gateway: this.elements.enableSmartGatewayToggle.checked,
enable_key_check: this.elements.enableKeyCheckToggle.checked,
key_check_model: this.elements.keyCheckModelInput.value.trim() || null,
key_check_interval_minutes: parseIntOrNull(this.elements.keyCheckIntervalInput.value),
key_check_concurrency: parseIntOrNull(this.elements.keyCheckConcurrencyInput.value),
key_cooldown_minutes: parseIntOrNull(this.elements.keyCooldownInput.value),
key_check_endpoint: this.elements.keyCheckEndpointInput.value.trim() || null,
allowed_models: this.tagInputs.models.getValues(),
allowed_upstreams: this.tagInputs.upstreams.getValues(),
allowed_tokens: this.tagInputs.tokens.getValues()
};
if (this.editingGroupId) {
formData.id = this.editingGroupId;
}
return formData;
}
/**
* [核心修正] 完整且健壮的表单重置方法
*/
_resetForm() {
this.elements.nameInput.value = "";
this.elements.nameInput.disabled = false;
this.elements.displayNameInput.value = "";
this.elements.descriptionInput.value = "";
this.elements.strategySelect.value = "random";
this.elements.maxRetriesInput.value = "";
this.elements.failureThresholdInput.value = "";
this.elements.enableProxyToggle.checked = false;
this.elements.enableSmartGatewayToggle.checked = false;
this.elements.enableKeyCheckToggle.checked = false;
this.elements.keyCheckSettingsPanel.classList.add("hidden");
this.elements.keyCheckModelInput.value = "";
this.elements.keyCheckIntervalInput.value = "";
this.elements.keyCheckConcurrencyInput.value = "";
this.elements.keyCooldownInput.value = "";
this.elements.keyCheckEndpointInput.value = "";
this.tagInputs.models.setValues([]);
this.tagInputs.upstreams.setValues([]);
this.tagInputs.tokens.setValues([]);
}
};
// frontend/js/pages/keys/cloneGroupModal.js
var CloneGroupModal = class {
constructor({ onCloneSuccess }) {
this.modalId = "clone-group-modal";
this.onCloneSuccess = onCloneSuccess;
this.activeGroup = null;
this.elements = {
modal: document.getElementById(this.modalId),
title: document.getElementById("clone-group-modal-title"),
confirmBtn: document.getElementById("clone-group-confirm-btn")
};
if (!this.elements.modal) {
console.error(`Modal with id "${this.modalId}" not found. Ensure the HTML is in your document.`);
return;
}
this._initEventListeners();
}
open(group) {
if (!group || !group.id) {
console.error("Cannot open CloneGroupModal: a group object with an ID is required.");
return;
}
this.activeGroup = group;
this.elements.title.innerHTML = `\u786E\u8BA4\u514B\u9686\u5206\u7EC4 <code class="text-base font-semibold text-blue-500">${group.display_name}</code>`;
this._reset();
modalManager.show(this.modalId);
}
_initEventListeners() {
this.elements.confirmBtn?.addEventListener("click", this._handleSubmit.bind(this));
const closeAction = () => {
this._reset();
modalManager.hide(this.modalId);
};
const closeTriggers = this.elements.modal.querySelectorAll(`[data-modal-close="${this.modalId}"]`);
closeTriggers.forEach((trigger) => trigger.addEventListener("click", closeAction));
this.elements.modal.addEventListener("click", (event) => {
if (event.target === this.elements.modal) closeAction();
});
}
async _handleSubmit() {
if (!this.activeGroup) return;
this.elements.confirmBtn.disabled = true;
this.elements.confirmBtn.innerHTML = `<i class="fas fa-spinner fa-spin mr-2"></i>\u514B\u9686\u4E2D...`;
try {
const endpoint = `/admin/keygroups/${this.activeGroup.id}/clone`;
const response = await apiFetch(endpoint, {
method: "POST",
noCache: true
});
const result = await response.json();
if (result.success && result.data) {
toastManager.show(`\u5206\u7EC4 '${this.activeGroup.display_name}' \u5DF2\u6210\u529F\u514B\u9686\u3002`, "success");
if (this.onCloneSuccess) {
this.onCloneSuccess(result.data);
}
modalManager.hide(this.modalId);
} else {
throw new Error(result.error?.message || result.message || "\u514B\u9686\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002");
}
} catch (error) {
toastManager.show(`\u514B\u9686\u5931\u8D25: ${error.message}`, "error");
} finally {
this._reset();
}
}
_reset() {
if (this.elements.confirmBtn) {
this.elements.confirmBtn.disabled = false;
this.elements.confirmBtn.innerHTML = "\u786E\u8BA4\u514B\u9686";
}
}
};
// frontend/js/pages/keys/deleteGroupModal.js
var DeleteGroupModal = class {
constructor({ onDeleteSuccess }) {
this.modalId = "delete-group-modal";
this.onDeleteSuccess = onDeleteSuccess;
this.activeGroup = null;
this.elements = {
modal: document.getElementById(this.modalId),
title: document.getElementById("delete-group-modal-title"),
confirmInput: document.getElementById("delete-group-confirm-input"),
confirmBtn: document.getElementById("delete-group-confirm-btn")
};
if (!this.elements.modal) {
throw new Error(`Modal with id "${this.modalId}" not found.`);
}
this._initEventListeners();
}
open(group) {
if (!group || !group.id) {
console.error("Cannot open DeleteGroupModal: group object with id is required.");
return;
}
this.activeGroup = group;
this.elements.title.innerHTML = `\u786E\u8BA4\u5220\u9664\u5206\u7EC4 <code class="text-base font-semibold text-red-500">${group.display_name}</code>`;
this._reset();
modalManager.show(this.modalId);
}
_initEventListeners() {
this.elements.confirmBtn?.addEventListener("click", this._handleSubmit.bind(this));
this.elements.confirmInput?.addEventListener("input", () => {
const isConfirmed = this.elements.confirmInput.value.trim() === "\u5220\u9664";
this.elements.confirmBtn.disabled = !isConfirmed;
});
const closeAction = () => {
this._reset();
modalManager.hide(this.modalId);
};
const closeTriggers = this.elements.modal.querySelectorAll(`[data-modal-close="${this.modalId}"]`);
closeTriggers.forEach((trigger) => trigger.addEventListener("click", closeAction));
this.elements.modal.addEventListener("click", (event) => {
if (event.target === this.elements.modal) closeAction();
});
}
async _handleSubmit() {
if (!this.activeGroup) return;
this.elements.confirmBtn.disabled = true;
this.elements.confirmBtn.innerHTML = `<i class="fas fa-spinner fa-spin mr-2"></i>\u5220\u9664\u4E2D...`;
try {
const endpoint = `/admin/keygroups/${this.activeGroup.id}`;
const response = await apiFetch(endpoint, {
method: "DELETE",
noCache: true
// Ensure a fresh request
});
const result = await response.json();
if (result.success) {
toastManager.show(`\u5206\u7EC4 '${this.activeGroup.display_name}' \u5DF2\u6210\u529F\u5220\u9664\u3002`, "success");
if (this.onDeleteSuccess) {
this.onDeleteSuccess(this.activeGroup.id);
}
modalManager.hide(this.modalId);
} else {
throw new Error(result.error?.message || result.message || "\u5220\u9664\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002");
}
} catch (error) {
toastManager.show(`\u5220\u9664\u5931\u8D25: ${error.message}`, "error");
} finally {
this._reset();
}
}
_reset() {
if (this.elements.confirmInput) this.elements.confirmInput.value = "";
if (this.elements.confirmBtn) {
this.elements.confirmBtn.disabled = true;
this.elements.confirmBtn.innerHTML = "\u786E\u8BA4\u5220\u9664";
}
}
};
// frontend/js/services/errorHandler.js
var ERROR_MESSAGES2 = {
"STATE_CONFLICT_MASTER_REVOKED": "\u64CD\u4F5C\u5931\u8D25\uFF1A\u65E0\u6CD5\u6FC0\u6D3B\u4E00\u4E2A\u5DF2\u88AB\u6C38\u4E45\u540A\u9500\uFF08Revoked\uFF09\u7684Key\u3002",
"NOT_FOUND": "\u64CD\u4F5C\u5931\u8D25\uFF1A\u76EE\u6807\u8D44\u6E90\u4E0D\u5B58\u5728\u6216\u5DF2\u4ECE\u672C\u7EC4\u79FB\u9664\u3002\u5217\u8868\u5C06\u81EA\u52A8\u5237\u65B0\u3002",
"NO_KEYS_MATCH_FILTER": "\u6CA1\u6709\u627E\u5230\u4EFB\u4F55\u7B26\u5408\u5F53\u524D\u8FC7\u6EE4\u6761\u4EF6\u7684Key\u53EF\u4F9B\u64CD\u4F5C\u3002",
// You can add many more specific codes here as your application grows.
"DEFAULT": "\u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u6216\u8054\u7CFB\u7BA1\u7406\u5458\u3002"
};
function handleApiError(error, toastManager2, options = {}) {
const prefix = options.prefix || "";
const errorCode = error?.code || "DEFAULT";
const displayMessage = ERROR_MESSAGES2[errorCode] || error.rawMessageFromServer || error.message || ERROR_MESSAGES2["DEFAULT"];
toastManager2.show(`${prefix}${displayMessage}`, "error");
}
// frontend/js/pages/keys/apiKeyList.js
var ApiKeyList = class {
/**
* @param {HTMLElement} container - The DOM element that will contain the API key list.
*/
constructor(container) {
if (!container) {
throw new Error("ApiKeyListManager requires a valid container element.");
}
this.elements = {
container,
// This is the scrollable list container now, e.g., #api-list-container
gridContainer: null,
// Will be dynamically created inside container
paginationContainer: document.querySelector(".pagination-controls"),
// Find the pagination container
itemsPerPageSelect: document.querySelector(".items-per-page-select"),
// Find the dropdown
selectAllCheckbox: document.getElementById("select-all"),
batchActionButton: document.querySelector(".batch-action-btn"),
// The trigger button
batchActionPanel: document.querySelector(".batch-action-panel"),
// The dropdown panel
statusFilterSelects: document.querySelectorAll(".status-filter-select"),
desktopSearchInput: document.getElementById("desktop-search-input"),
mobileSearchBtn: document.getElementById("mobile-search-btn"),
desktopQuickActionsPanel: document.getElementById("desktop-quick-actions-panel"),
mobileQuickActionsPanel: document.getElementById("mobile-quick-actions-panel"),
desktopMultifunctionPanel: document.getElementById("desktop-multifunction-panel"),
mobileMultifunctionPanel: document.getElementById("mobile-multifunction-panel")
};
this.state = {
currentKeys: [],
// Now holds only the keys for the current page
selectedKeyIds: /* @__PURE__ */ new Set(),
isApiKeysLoading: false,
activeGroupId: null,
activeGroupName: "",
currentPage: 1,
itemsPerPage: 20,
// Default value, will be updated from select
totalItems: 0,
totalPages: 1,
filterStatus: "all",
searchText: ""
};
this.debouncedSearch = debounce(() => {
this.state.currentPage = 1;
this.loadApiKeys(this.state.activeGroupId, true);
}, 300);
this.boundListeners = {
handleContainerClick: this._handleContainerClick.bind(this),
handlePaginationClick: this.handlePaginationClick.bind(this),
handleItemsPerPageChange: this._handleItemsPerPageChange.bind(this),
handleSelectAllChange: this._handleSelectAllChange.bind(this),
handleStatusFilterChange: this._handleStatusFilterChange.bind(this),
handleBatchActionClick: this._handleBatchActionClick.bind(this),
handleDocumentClickForMenuClose: this._handleDocumentClickForMenuClose.bind(this),
handleSearchInput: this._handleSearchInput.bind(this),
handleSearchEnter: this._handleSearchEnter.bind(this),
showMobileSearchModal: this._showMobileSearchModal.bind(this),
handleGlobalClick: this._handleGlobalClick.bind(this)
};
}
init() {
if (!this.elements.container) return;
this.elements.container.addEventListener("click", this.boundListeners.handleContainerClick);
this.elements.paginationContainer?.addEventListener("click", this.boundListeners.handlePaginationClick);
this.elements.selectAllCheckbox?.addEventListener("change", this.boundListeners.handleSelectAllChange);
this.elements.batchActionPanel?.addEventListener("click", this.boundListeners.handleBatchActionClick);
this.elements.desktopSearchInput?.addEventListener("input", this.boundListeners.handleSearchInput);
this.elements.desktopSearchInput?.addEventListener("keydown", this.boundListeners.handleSearchEnter);
this.elements.mobileSearchBtn?.addEventListener("click", this.boundListeners.showMobileSearchModal);
document.addEventListener("click", this.boundListeners.handleDocumentClickForMenuClose);
document.body.addEventListener("click", this.boundListeners.handleGlobalClick);
const itemsPerPageSelect = this.elements.itemsPerPageSelect?.querySelector("select");
if (itemsPerPageSelect) {
itemsPerPageSelect.addEventListener("change", this.boundListeners.handleItemsPerPageChange);
this.state.itemsPerPage = parseInt(itemsPerPageSelect.value, 10);
}
this.elements.statusFilterSelects.forEach((selectContainer) => {
const actualSelect = selectContainer.querySelector("select");
actualSelect?.addEventListener("change", this.boundListeners.handleStatusFilterChange);
});
this._renderMultifunctionMenu();
this._renderQuickActionsMenu();
}
destroy() {
console.log("Destroying ApiKeyList instance and cleaning up listeners.");
this.elements.container.removeEventListener("click", this.boundListeners.handleContainerClick);
this.elements.paginationContainer?.removeEventListener("click", this.boundListeners.handlePaginationClick);
this.elements.selectAllCheckbox?.removeEventListener("change", this.boundListeners.handleSelectAllChange);
this.elements.batchActionPanel?.removeEventListener("click", this.boundListeners.handleBatchActionClick);
this.elements.desktopSearchInput?.removeEventListener("input", this.boundListeners.handleSearchInput);
this.elements.desktopSearchInput?.removeEventListener("keydown", this.boundListeners.handleSearchEnter);
this.elements.mobileSearchBtn?.removeEventListener("click", this.boundListeners.showMobileSearchModal);
document.removeEventListener("click", this.boundListeners.handleDocumentClickForMenuClose);
document.body.removeEventListener("click", this.boundListeners.handleGlobalClick);
const itemsPerPageSelect = this.elements.itemsPerPageSelect?.querySelector("select");
if (itemsPerPageSelect) {
itemsPerPageSelect.removeEventListener("change", this.boundListeners.handleItemsPerPageChange);
}
this.elements.statusFilterSelects.forEach((selectContainer) => {
const actualSelect = selectContainer.querySelector("select");
actualSelect?.removeEventListener("change", this.boundListeners.handleStatusFilterChange);
});
this.debouncedSearch.cancel?.();
this.elements.container.innerHTML = "";
}
_handleItemsPerPageChange(e) {
this.state.itemsPerPage = parseInt(e.target.value, 10);
this.state.currentPage = 1;
this.loadApiKeys(this.state.activeGroupId, true);
}
_handleStatusFilterChange(e) {
this.state.filterStatus = e.target.value;
this.state.currentPage = 1;
this.loadApiKeys(this.state.activeGroupId, true);
}
_handleDocumentClickForMenuClose(event) {
if (!event.target.closest(".api-card")) {
this._closeAllActionMenus();
}
}
_handleGlobalClick(event) {
this._handleQuickActionClick(event);
this._handleMultifunctionMenuClick(event);
this._handleDropdownToggle(event);
}
/**
* Updates the active group context for the manager.
* @param {number} groupId The new active group ID.
*/
setActiveGroup(groupId, groupName) {
this.state.activeGroupId = groupId;
this.state.activeGroupName = groupName || "";
this.state.currentPage = 1;
}
/**
* Fetches and renders API keys for the specified group.
* @param {number} groupId - The ID of the group to load keys for.
* @param {boolean} [force=false] - If true, bypasses the cache and fetches from the server.
*/
async loadApiKeys(groupId, force = false) {
this.state.selectedKeyIds.clear();
if (!groupId) {
this.state.currentKeys = [];
this.render();
return;
}
this.state.isApiKeysLoading = true;
this.render();
try {
const { currentPage, itemsPerPage, filterStatus, searchText } = this.state;
const params = {
page: currentPage,
limit: itemsPerPage
};
if (filterStatus !== "all") {
params.status = filterStatus;
}
if (searchText && searchText.trim() !== "") {
params.keyword = searchText.trim();
}
const pageData = await apiKeyManager.getKeysForGroup(groupId, params);
this.state.currentKeys = pageData.items;
this.state.totalItems = pageData.total;
this.state.totalPages = pageData.pages;
this.state.currentPage = pageData.page;
} catch (error) {
toastManager.show(`\u52A0\u8F7DAPI Keys\u5931\u8D25: ${error.message || "\u672A\u77E5\u9519\u8BEF"}`, "error");
this.state.currentKeys = [];
this.state.totalItems = 0;
this.state.totalPages = 1;
} finally {
this.state.isApiKeysLoading = false;
this.render();
}
}
/**
* Renders the list of API keys based on the current state.
*/
render() {
if (this.state.isApiKeysLoading && this.state.currentKeys.length === 0) {
this.elements.container.innerHTML = '<div class="flex items-center justify-center h-full text-zinc-500"><p>\u6B63\u5728\u52A0\u8F7D API Keys...</p></div>';
if (this.elements.paginationContainer) this.elements.paginationContainer.innerHTML = "";
return;
}
if (!this.state.activeGroupId) {
this.elements.container.innerHTML = '<div class="flex items-center justify-center h-full text-zinc-500"><p>\u8BF7\u5148\u5728\u5DE6\u4FA7\u9009\u62E9\u4E00\u4E2A\u5206\u7EC4</p></div>';
if (this.elements.paginationContainer) this.elements.paginationContainer.innerHTML = "";
return;
}
if (this.state.currentKeys.length === 0) {
this.elements.container.innerHTML = '<div class="flex items-center justify-center h-full text-zinc-500"><p>\u8BE5\u5206\u7EC4\u4E0B\u8FD8\u6CA1\u6709 API Key\u3002</p></div>';
if (this.elements.paginationContainer) this.elements.paginationContainer.innerHTML = "";
return;
}
const listHtml = this.state.currentKeys.map((apiKey) => this._createApiKeyCardHtml(apiKey)).join("");
this.elements.container.innerHTML = `<div class="grid grid-cols-1 md:grid-cols-2 gap-3">${listHtml}</div>`;
this.elements.gridContainer = this.elements.container.firstChild;
if (this.elements.paginationContainer) {
this.elements.paginationContainer.innerHTML = this._createPaginationHtml();
}
this._updateAllStatusIndicators();
this._syncCardCheckboxes();
this._syncSelectionUI();
}
// [NEW] Handles clicks on pagination buttons
handlePaginationClick(event) {
const button = event.target.closest("button[data-page]");
if (!button || button.disabled) return;
const newPage = parseInt(button.dataset.page, 10);
if (newPage !== this.state.currentPage) {
this.state.currentPage = newPage;
this.loadApiKeys(this.state.activeGroupId, true);
}
}
// [NEW] Generates the HTML for the pagination controls
_createPaginationHtml() {
const { currentPage, totalPages } = this.state;
if (totalPages < 1) return "";
const baseButtonClasses = "pagination-button px-3 py-1 rounded text-sm transition-colors duration-150 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed";
const activeClasses = "bg-zinc-500 text-white font-semibold";
const inactiveClasses = "hover:bg-zinc-200 dark:hover:bg-zinc-700";
let html = "";
const prevDisabled = currentPage <= 1 ? "disabled" : "";
const nextDisabled = currentPage >= totalPages ? "disabled" : "";
html += `<button class="${baseButtonClasses} ${inactiveClasses}" data-page="${currentPage - 1}" ${prevDisabled}>
<i class="fas fa-chevron-left"></i>
</button>`;
const pagesToShow = this._getPaginationPages(currentPage, totalPages);
pagesToShow.forEach((page) => {
if (page === "...") {
html += `<span class="px-3 py-1 text-zinc-400 dark:text-zinc-500 text-sm">...</span>`;
} else {
const pageClasses = page === currentPage ? activeClasses : inactiveClasses;
html += `<button class="${baseButtonClasses} ${pageClasses}" data-page="${page}">${page}</button>`;
}
});
html += `<button class="${baseButtonClasses} ${inactiveClasses}" data-page="${currentPage + 1}" ${nextDisabled}>
<i class="fas fa-chevron-right"></i>
</button>`;
return html;
}
// [NEW] Helper to determine which page numbers to show in pagination (e.g., 1 ... 5 6 7 ... 12)
_getPaginationPages(current, total, width = 2) {
if (total <= width * 2 + 3) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const pages = [1];
if (current > width + 2) pages.push("...");
for (let i = Math.max(2, current - width); i <= Math.min(total - 1, current + width); i++) {
pages.push(i);
}
if (current < total - width - 1) pages.push("...");
pages.push(total);
return pages;
}
/**
* Handles all actions originating from within an API key card.
* @param {Event} event - The click event.
*/
async handleCardAction(event) {
const button = event.target.closest("button[data-action]");
if (!button) return;
const action = button.dataset.action;
const card = button.closest(".api-card");
if (!card) return;
if (action === "toggle-menu") {
const menu = card.querySelector('[data-menu="actions"]');
if (menu) {
const isMenuOpen = !menu.classList.contains("hidden");
this._closeAllActionMenus(card);
if (!isMenuOpen) {
menu.classList.remove("hidden");
}
}
return;
}
this._closeAllActionMenus();
const keyId = parseInt(card.dataset.keyId, 10);
const groupId = this.state.activeGroupId;
const menuToClose = card.querySelector('[data-menu="actions"]');
if (menuToClose && !menuToClose.classList.contains("hidden")) {
this._closeAllActionMenus();
}
const apiKeyData = this.state.currentKeys.find((key) => key.id === keyId);
if (!apiKeyData) {
toastManager.show("\u9519\u8BEF: \u627E\u4E0D\u5230\u8BE5Key\u7684\u6570\u636E\u3002\u53EF\u80FD\u662F\u5217\u8868\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u5C1D\u8BD5\u5237\u65B0\u3002", "error");
return;
}
const fullApiKey = apiKeyData.api_key;
switch (action) {
case "toggle-visibility":
case "copy-key":
this._handleLocalCardActions(action, button, card, fullApiKey);
break;
case "set-status": {
const newStatus = button.dataset.newStatus;
if (!newStatus) return;
try {
await apiKeyManager.updateKeyStatusInGroup(groupId, keyId, newStatus);
toastManager.show(`Key \u72B6\u6001\u5DF2\u6210\u529F\u66F4\u65B0\u4E3A ${newStatus}`, "success");
} catch (error) {
handleApiError(error, toastManager);
} finally {
await this.loadApiKeys(groupId, true);
}
break;
}
case "revalidate": {
const revalidateTask = this._createRevalidateTaskDefinition(groupId, fullApiKey);
taskCenterManager.startTask(revalidateTask);
break;
}
case "delete-key": {
const result = await Swal.fire({
target: "#main-content-wrapper",
width: "20rem",
backdrop: `rgba(0,0,0,0.5)`,
heightAuto: false,
customClass: {
popup: `swal2-custom-style ${document.documentElement.classList.contains("dark") ? "swal2-dark" : ""}`
},
title: "\u786E\u8BA4\u5220\u9664",
html: `\u786E\u5B9A\u8981\u4ECE <b>\u5F53\u524D\u5206\u7EC4</b> \u4E2D\u79FB\u9664\u8FD9\u4E2AKey\u5417\uFF1F`,
//icon: 'warning',
showCancelButton: true,
confirmButtonText: "\u786E\u8BA4",
cancelButtonText: "\u53D6\u6D88",
reverseButtons: true,
confirmButtonColor: "#d33",
cancelButtonColor: "#6b7280",
focusConfirm: false,
focusCancel: false
});
if (!result.isConfirmed) {
return;
}
try {
const unlinkResult = await apiKeyManager.unlinkKeysFromGroup(groupId, [fullApiKey]);
if (!unlinkResult.success) {
throw new Error(unlinkResult.message || "\u540E\u7AEF\u672A\u80FD\u79FB\u9664Key\u3002");
}
toastManager.show(`\u6210\u529F\u79FB\u9664 1 \u4E2AKey\u3002`, "success");
await this.loadApiKeys(groupId, true);
} catch (error) {
const errorMessage = error && error.message ? error.message : ERROR_MESSAGES["DEFAULT"];
toastManager.show(`\u79FB\u9664Key\u5931\u8D25: ${errorMessage}`, "error");
await this.loadApiKeys(groupId, true);
}
break;
}
}
}
// --- Private Helper Methods (copied from original file) ---
_createRevalidateTaskDefinition(groupId, fullApiKey) {
return {
start: () => apiKeyManager.revalidateKeys(groupId, [fullApiKey]),
poll: (taskId) => apiKeyManager.getTaskStatus(taskId, { noCache: true }),
onSuccess: (data) => {
toastManager.show(`Key\u9A8C\u8BC1\u5B8C\u6210`, "success");
this.loadApiKeys(groupId, true);
},
onError: (data) => {
toastManager.show(`\u9A8C\u8BC1\u4EFB\u52A1\u5931\u8D25: ${data.error || "\u672A\u77E5\u9519\u8BEF"}`, "error");
},
renderToastNarrative: (data, oldData, toastManager2) => {
const toastId = `task-${data.id}`;
const progress = data.total > 0 ? data.processed / data.total * 100 : 0;
toastManager2.showProgressToast(toastId, `\u6B63\u5728\u9A8C\u8BC1Key`, "\u5904\u7406\u4E2D", progress);
},
renderTaskCenterItem: (data, timestamp, formatTimeAgo) => {
const timeAgo = formatTimeAgo(timestamp);
const maskedKey = escapeHTML(`${fullApiKey.substring(0, 4)}...${fullApiKey.substring(fullApiKey.length - 4)}`);
let contentHtml = "";
const isDone = !data.is_running;
if (isDone) {
if (data.error) {
const safeError = escapeHTML(data.error);
contentHtml = `
<div class="task-item-main">
<div class="task-item-icon-summary text-red-500"><i class="fas fa-exclamation-triangle"></i></div>
<div class="task-item-content flex-grow">
<p class="task-item-title">\u9A8C\u8BC1\u4EFB\u52A1\u51FA\u9519: ${maskedKey}</p>
<p class="task-item-status text-red-500 truncate" title="${safeError}">${safeError}</p>
</div>
</div>`;
} else {
const result = data.result?.results?.[0];
const isSuccess = result?.status === "valid";
const iconClass = isSuccess ? "text-green-500 fas fa-check-circle" : "text-red-500 fas fa-times-circle";
const title = isSuccess ? "\u9A8C\u8BC1\u6210\u529F" : "\u9A8C\u8BC1\u5931\u8D25";
const safeMessage = escapeHTML(result?.message || "\u6CA1\u6709\u8BE6\u7EC6\u4FE1\u606F\u3002");
contentHtml = `
<div class="task-item-main">
<div class="task-item-icon-summary"><i class="${iconClass}"></i></div>
<div class="task-item-content flex-grow">
<p class="task-item-title">${title}: ${maskedKey}</p>
<p class="task-item-status truncate" title="${safeMessage}">${safeMessage}</p>
</div>
</div>
`;
}
} else {
contentHtml = `
<div class="task-item-main gap-3">
<div class="task-item-icon task-item-icon-running"><i class="fas fa-spinner animate-spin"></i></div>
<div class="task-item-content flex-grow">
<p class="task-item-title">\u6B63\u5728\u9A8C\u8BC1: ${maskedKey}</p>
<p class="task-item-status">\u8FD0\u884C\u4E2D... (${data.processed}/${data.total})</p>
</div>
</div>`;
}
return `${contentHtml}<div class="task-item-timestamp">${timeAgo}</div>`;
}
};
}
/**
* Creates the HTML for a single API key card, adapting to the flat APIKeyDetails structure.
* @param {object} item - The APIKeyDetails object from the API.
* @returns {string} The HTML string for the card.
*/
_createApiKeyCardHtml(item) {
if (!item || !item.api_key) return "";
const maskedKey = escapeHTML(`${item.api_key.substring(0, 4)}......${item.api_key.substring(item.api_key.length - 4)}`);
const status = escapeHTML(item.status);
const errorCount = escapeHTML(item.consecutive_error_count);
const keyId = escapeHTML(item.id);
const mappingId = escapeHTML(`${item.api_key_id}-${item.key_group_id}`);
const setActiveAction = `data-action="set-status" data-new-status="ACTIVE"`;
const revalidateAction = `data-action="revalidate"`;
const disableAction = `data-action="set-status" data-new-status="DISABLED"`;
const deleteAction = `data-action="delete-key"`;
return `
<div class="api-card group relative flex items-center gap-x-3 rounded-lg p-3 bg-white dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700/60"
data-status="${status}"
data-key-id="${keyId}"
data-mapping-id="${mappingId}">
<input type="checkbox" class="api-key-checkbox h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500 shrink-0">
<span data-status-indicator class="w-2 h-2 rounded-full shrink-0"></span>
<div class="flex-grow min-w-0">
<p class="font-mono text-xs font-semibold truncate">${maskedKey}</p>
<p class="text-xs text-zinc-400 mt-1">\u5931\u8D25: ${errorCount} \u6B21</p>
</div>
<!-- [DESKTOP ONLY] \u5FEB\u901F\u64CD\u4F5C\u6309\u94AE - \u5728\u79FB\u52A8\u7AEF(<lg)\u9690\u85CF -->
<div class="hidden lg:flex items-center gap-x-2 text-zinc-400 text-xs z-10">
<button class="hover:text-blue-500" data-action="toggle-visibility" title="\u67E5\u770B\u5B8C\u6574Key"><i class="fas fa-eye"></i></button>
<button class="hover:text-blue-500" data-action="copy-key" title="\u590D\u5236Key"><i class="fas fa-copy"></i></button>
</div>
<!-- [DESKTOP ONLY] Hover Menu - \u5728\u79FB\u52A8\u7AEF(<lg)\u9690\u85CF -->
<div class="hidden lg:flex absolute right-14 top-1/2 -translate-y-1/2 items-center bg-zinc-200 dark:bg-zinc-700 rounded-full shadow-md opacity-0 lg:group-hover:opacity-100 transition-opacity duration-200 z-20">
<button class="px-2 py-1 hover:text-green-500" ${setActiveAction} title="\u8BBE\u4E3A\u53EF\u7528"><i class="fas fa-check-circle"></i></button>
<button class="px-2 py-1 hover:text-blue-500" ${revalidateAction} title="\u91CD\u65B0\u9A8C\u8BC1"><i class="fas fa-sync-alt"></i></button>
<button class="px-2 py-1 hover:text-yellow-500" ${disableAction} title="\u7981\u7528"><i class="fas fa-ban"></i></button>
<button class="px-2 py-1 hover:text-red-500" ${deleteAction} title="\u4ECE\u5206\u7EC4\u4E2D\u79FB\u9664"><i class="fas fa-trash-alt"></i></button>
</div>
<!-- [MOBILE ONLY] Kebab Menu and Dropdown - \u5728\u684C\u9762\u7AEF(>=lg)\u9690\u85CF -->
<div class="relative lg:hidden">
<button data-action="toggle-menu" class="flex items-center justify-center h-8 w-8 rounded-full hover:bg-zinc-100 dark:hover:bg-zinc-700 text-zinc-500" title="\u66F4\u591A\u64CD\u4F5C">
<i class="fas fa-ellipsis-v"></i>
</button>
<div data-menu="actions" class="absolute right-0 mt-2 w-48 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-md shadow-lg z-30 hidden">
<!-- [NEW] "\u67E5\u770B"\u548C"\u590D\u5236"\u64CD\u4F5C\u5DF2\u79FB\u5165\u79FB\u52A8\u7AEF\u83DC\u5355 -->
<button class="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-x-3" data-action="toggle-visibility"><i class="fas fa-eye w-4"></i> \u67E5\u770B/\u9690\u85CF Key</button>
<button class="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-x-3" data-action="copy-key"><i class="fas fa-copy w-4"></i> \u590D\u5236 Key</button>
<div class="border-t border-zinc-200 dark:border-zinc-700 my-1"></div>
<button class="w-full text-left px-4 py-2 text-sm text-green-600 hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-x-3" ${setActiveAction}><i class="fas fa-check-circle w-4"></i> \u8BBE\u4E3A\u53EF\u7528</button>
<button class="w-full text-left px-4 py-2 text-sm text-blue-600 hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-x-3" ${revalidateAction}><i class="fas fa-sync-alt w-4"></i> \u91CD\u65B0\u9A8C\u8BC1</button>
<button class="w-full text-left px-4 py-2 text-sm text-yellow-600 hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-x-3" ${disableAction}><i class="fas fa-ban w-4"></i> \u7981\u7528</button>
<div class="border-t border-zinc-200 dark:border-zinc-700 my-1"></div>
<button class="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-x-3" ${deleteAction}><i class="fas fa-trash-alt w-4"></i> \u4ECE\u5206\u7EC4\u4E2D\u79FB\u9664</button>
</div>
</div>
</div>
`;
}
_handleLocalCardActions(action, button, card, fullApiKey) {
switch (action) {
case "toggle-visibility": {
const safeApiKey = escapeHTML(fullApiKey);
Swal.fire({
target: "#main-content-wrapper",
width: "24rem",
// 适配移动端宽度
backdrop: `rgba(0,0,0,0.5)`,
heightAuto: false,
customClass: {
popup: `swal2-custom-style rounded-xl ${document.documentElement.classList.contains("dark") ? "swal2-dark" : ""}`,
htmlContainer: "m-0 text-left"
// 移除默认边距
},
showConfirmButton: false,
showCloseButton: false,
html: `
<div class="mt-2 flex items-center justify-between rounded-md bg-zinc-100 dark:bg-zinc-700 p-3 font-mono text-sm text-zinc-800 dark:text-zinc-200">
<code class="break-all">${safeApiKey}</code>
<button id="swal-copy-key-btn" class="ml-4 p-2 rounded-md hover:bg-zinc-200 dark:hover:bg-zinc-600 text-zinc-500 dark:text-zinc-300" title="\u590D\u5236">
<i class="far fa-copy"></i>
</button>
</div>
`,
didOpen: (modal) => {
const copyBtn = modal.querySelector("#swal-copy-key-btn");
if (copyBtn) {
copyBtn.addEventListener("click", () => {
navigator.clipboard.writeText(fullApiKey).then(() => {
toastManager.show("API Key \u5DF2\u590D\u5236\u5230\u526A\u8D34\u677F\u3002", "success");
copyBtn.innerHTML = '<i class="fas fa-check text-green-500"></i>';
setTimeout(() => {
copyBtn.innerHTML = '<i class="far fa-copy"></i>';
}, 1500);
}).catch((err) => {
toastManager.show(`\u590D\u5236\u5931\u8D25: ${err.message}`, "error");
});
});
}
}
});
break;
}
case "copy-key": {
if (!fullApiKey) {
toastManager.show("\u65E0\u6CD5\u627E\u5230\u5B8C\u6574\u7684Key\u7528\u4E8E\u590D\u5236\u3002", "error");
break;
}
navigator.clipboard.writeText(fullApiKey).then(() => {
toastManager.show("API Key \u5DF2\u590D\u5236\u5230\u526A\u8D34\u677F\u3002", "success");
}).catch((err) => {
toastManager.show(`\u590D\u5236\u5931\u8D25: ${err.message}`, "error");
});
break;
}
}
}
_updateAllStatusIndicators() {
const allCards = this.elements.container.querySelectorAll(".api-card[data-status]");
allCards.forEach((card) => this._updateApiKeyStatusIndicator(card));
}
_updateApiKeyStatusIndicator(cardElement) {
const status = cardElement.dataset.status;
if (!status) return;
const indicator = cardElement.querySelector("[data-status-indicator]");
if (!indicator) return;
const statusColors = {
"ACTIVE": "bg-green-500",
"PENDING": "bg-gray-400",
"COOLDOWN": "bg-yellow-500",
"DISABLED": "bg-orange-500",
"BANNED": "bg-red-500"
};
Object.values(statusColors).forEach((colorClass) => indicator.classList.remove(colorClass));
if (statusColors[status]) {
indicator.classList.add(statusColors[status]);
}
}
/**
* [NEW HELPER] Closes all action menus, optionally ignoring one card.
* @param {HTMLElement} [ignoreCard=null] - The card whose menu should not be closed.
*/
_closeAllActionMenus(ignoreCard = null) {
this.elements.container.querySelectorAll(".api-card").forEach((card) => {
if (card === ignoreCard) return;
const menu = card.querySelector('[data-menu="actions"]');
if (menu) {
menu.classList.add("hidden");
}
});
}
/**
* [NEW] A central click handler for the entire list container.
* It delegates clicks on checkboxes or action buttons to specific handlers.
*/
_handleContainerClick(event) {
const target = event.target;
if (target.matches(".api-key-checkbox")) {
this._handleSelectionChange(target);
return;
}
const actionButton = target.closest("button[data-action]");
if (actionButton) {
this.handleCardAction(event);
return;
}
}
/**
* [NEW] Handles a click on an individual API key's checkbox.
* @param {HTMLInputElement} checkbox - The checkbox element that was clicked.
*/
_handleSelectionChange(checkbox) {
const card = checkbox.closest(".api-card");
if (!card) return;
const keyId = parseInt(card.dataset.keyId, 10);
if (isNaN(keyId)) return;
if (checkbox.checked) {
this.state.selectedKeyIds.add(keyId);
} else {
this.state.selectedKeyIds.delete(keyId);
}
this._syncSelectionUI();
}
/**
* [NEW] Synchronizes the UI elements based on the current selection state.
* This includes the "Select All" checkbox and batch action buttons.
*/
_syncSelectionUI() {
if (!this.elements.selectAllCheckbox || !this.elements.batchActionButton) return;
const selectedCount = this.state.selectedKeyIds.size;
const visibleKeysCount = this.state.currentKeys.length;
if (selectedCount === 0) {
this.elements.selectAllCheckbox.checked = false;
this.elements.selectAllCheckbox.indeterminate = false;
} else if (selectedCount < visibleKeysCount) {
this.elements.selectAllCheckbox.checked = false;
this.elements.selectAllCheckbox.indeterminate = true;
} else if (selectedCount === visibleKeysCount && visibleKeysCount > 0) {
this.elements.selectAllCheckbox.checked = true;
this.elements.selectAllCheckbox.indeterminate = false;
}
const isDisabled = selectedCount === 0;
if (isDisabled) {
this.elements.batchActionButton.classList.add("is-disabled");
} else {
this.elements.batchActionButton.classList.remove("is-disabled");
}
this.elements.batchActionButton.style.pointerEvents = isDisabled ? "none" : "auto";
this.elements.batchActionButton.style.opacity = isDisabled ? "0.5" : "1";
const counter = this.elements.batchActionButton.querySelector("span");
if (counter) {
counter.textContent = isDisabled ? "\u6279\u91CF\u64CD\u4F5C" : `\u5DF2\u9009 ${selectedCount} \u9879`;
}
}
/**
* [NEW] Handles the change event of the main "Select All" checkbox.
* @param {Event} event - The change event object.
*/
_handleSelectAllChange(event) {
const isChecked = event.target.checked;
this.state.currentKeys.forEach((key) => {
if (isChecked) {
this.state.selectedKeyIds.add(key.id);
} else {
this.state.selectedKeyIds.delete(key.id);
}
});
this._syncCardCheckboxes();
this._syncSelectionUI();
}
/**
* [NEW] Ensures that the checked status of each individual card's checkbox
* matches the selection state stored in `this.state.selectedKeyIds`.
*/
_syncCardCheckboxes() {
if (!this.elements.gridContainer) return;
const checkboxes = this.elements.gridContainer.querySelectorAll(".api-key-checkbox");
checkboxes.forEach((checkbox) => {
const card = checkbox.closest(".api-card");
if (card) {
const keyId = parseInt(card.dataset.keyId, 10);
if (!isNaN(keyId)) {
checkbox.checked = this.state.selectedKeyIds.has(keyId);
}
}
});
}
/**
* [NEW] Handles clicks within the batch action dropdown panel.
* Uses event delegation to determine which action was triggered.
* This version is adapted for the final HTML structure.
* @param {Event} event - The click event.
*/
_handleBatchActionClick(event) {
const button = event.target.closest("button[data-batch-action]");
if (!button) return;
event.preventDefault();
const customSelectContainer = button.closest(".custom-select");
if (customSelectContainer) {
const panel = customSelectContainer.querySelector(".custom-select-panel");
if (panel) panel.classList.add("hidden");
}
const action = button.dataset.batchAction;
const selectedIds = Array.from(this.state.selectedKeyIds);
if (selectedIds.length === 0) {
toastManager.show("\u6CA1\u6709\u9009\u4E2D\u4EFB\u4F55Key\u3002", "warning");
return;
}
switch (action) {
case "copy-to-clipboard":
this._batchCopyToClipboard(selectedIds);
break;
case "set-status-active":
this._batchSetStatus("ACTIVE", selectedIds);
break;
case "set-status-disabled":
this._batchSetStatus("DISABLED", selectedIds);
break;
case "revalidate":
this._batchRevalidate(selectedIds);
break;
case "delete":
this._batchDelete(selectedIds);
break;
default:
console.warn(`Unknown batch action: ${action}`);
}
}
/**
* [NEW] Helper for batch updating the status of selected keys.
* @param {string} newStatus - The new status ('ACTIVE' or 'DISABLED').
* @param {number[]} keyIds - An array of selected key IDs.
*/
async _batchSetStatus(newStatus, keyIds) {
const groupId = this.state.activeGroupId;
const actionText = newStatus === "ACTIVE" ? "\u542F\u7528" : "\u7981\u7528";
toastManager.show(`\u6B63\u5728\u6279\u91CF${actionText} ${keyIds.length} \u4E2AKey...`, "info", 3e3);
try {
const promises = keyIds.map((id) => apiKeyManager.updateKeyStatusInGroup(groupId, id, newStatus));
const results = await Promise.allSettled(promises);
const fulfilledCount = results.filter((r) => r.status === "fulfilled").length;
const rejectedCount = results.length - fulfilledCount;
let toastMessage = `\u6279\u91CF${actionText}\u64CD\u4F5C\u5B8C\u6210\u3002`;
let toastType = "success";
if (rejectedCount > 0) {
toastMessage = `\u64CD\u4F5C\u5B8C\u6210: ${fulfilledCount} \u4E2A\u6210\u529F\uFF0C${rejectedCount} \u4E2A\u5931\u8D25\uFF08\u53EF\u80FD\u7531\u4E8EKey\u72B6\u6001\u9650\u5236\uFF09\u3002\u5217\u8868\u5DF2\u66F4\u65B0\u3002`;
toastType = fulfilledCount > 0 ? "warning" : "error";
}
toastManager.show(toastMessage, toastType);
} catch (error) {
toastManager.show(`\u6279\u91CF${actionText}\u65F6\u53D1\u751F\u7F51\u7EDC\u9519\u8BEF: ${error.message || "\u672A\u77E5\u9519\u8BEF"}`, "error");
} finally {
await this.loadApiKeys(groupId, true);
}
}
/**
* [NEW] Helper for batch revalidating selected keys.
* @param {number[]} keyIds - An array of selected key IDs.
*/
_batchRevalidate(keyIds) {
const groupId = this.state.activeGroupId;
const currentKeysMap = new Map(this.state.currentKeys.map((key) => [key.id, key.api_key]));
const keysToRevalidate = keyIds.map((id) => currentKeysMap.get(id)).filter(Boolean);
if (keysToRevalidate.length === 0) {
toastManager.show("\u627E\u4E0D\u5230\u5339\u914D\u7684Key\u8FDB\u884C\u9A8C\u8BC1\u3002\u8BF7\u5237\u65B0\u5217\u8868\u540E\u91CD\u8BD5\u3002", "error");
return;
}
const revalidateTask = {
start: () => apiKeyManager.revalidateKeys(groupId, keysToRevalidate),
poll: (taskId) => apiKeyManager.getTaskStatus(taskId, { noCache: true }),
onSuccess: (data) => {
toastManager.show(`\u6279\u91CF\u9A8C\u8BC1\u5B8C\u6210\u3002`, "success");
this.loadApiKeys(groupId, true);
},
onError: (data) => {
toastManager.show(`\u6279\u91CF\u9A8C\u8BC1\u4EFB\u52A1\u5931\u8D25: ${data.error || "\u672A\u77E5\u9519\u8BEF"}`, "error");
},
renderToastNarrative: (data, oldData, toastManager2) => {
const toastId = `task-${data.id}`;
const progress = data.total > 0 ? data.processed / data.total * 100 : 0;
toastManager2.showProgressToast(toastId, `\u6B63\u5728\u6279\u91CF\u9A8C\u8BC1Key`, `\u5904\u7406\u4E2D (${data.processed}/${data.total})`, progress);
},
// [MODIFIED] This is the core fix. A new, detailed renderer for the task center.
renderTaskCenterItem: (data, timestamp, formatTimeAgo) => {
const timeAgo = formatTimeAgo(timestamp);
let contentHtml = "";
if (data.is_running) {
contentHtml = `
<div class="task-item-main gap-3">
<div class="task-item-icon task-item-icon-running"><i class="fas fa-spinner animate-spin"></i></div>
<div class="task-item-content flex-grow">
<p class="task-item-title">\u6279\u91CF\u9A8C\u8BC1 ${data.total} \u4E2AKey</p>
<p class="task-item-status">\u8FD0\u884C\u4E2D... (${data.processed}/${data.total})</p>
</div>
</div>`;
} else {
if (data.error) {
contentHtml = `
<div class="task-item-main">
<div class="task-item-icon-summary text-red-500"><i class="fas fa-exclamation-triangle"></i></div>
<div class="task-item-content flex-grow">
<p class="task-item-title">\u6279\u91CF\u9A8C\u8BC1\u4EFB\u52A1\u51FA\u9519</p>
<p class="task-item-status text-red-500 truncate" title="${data.error}">${data.error}</p>
</div>
</div>`;
} else {
const results = data.result?.results || [];
const validCount = results.filter((r) => r.status === "valid").length;
const invalidCount = results.length - validCount;
const summaryTitle = `\u9A8C\u8BC1\u5B8C\u6210: ${validCount}\u4E2A\u6709\u6548, ${invalidCount}\u4E2A\u65E0\u6548`;
const overallIconClass = invalidCount > 0 ? "text-yellow-500 fas fa-exclamation-triangle" : "text-green-500 fas fa-check-circle";
const detailsHtml = results.map((result) => {
const maskedKey = escapeHTML(`${result.key.substring(0, 4)}...${result.key.substring(result.key.length - 4)}`);
const safeMessage = escapeHTML(result.message);
if (result.status === "valid") {
return `
<div class="flex items-start text-xs">
<i class="fas fa-check-circle text-green-500 mt-0.5 mr-2"></i>
<div class="flex-grow">
<p class="font-mono">${maskedKey}</p>
<p class="text-zinc-400">${safeMessage}</p>
</div>
</div>`;
} else {
return `
<div class="flex items-start text-xs">
<i class="fas fa-times-circle text-red-500 mt-0.5 mr-2"></i>
<div class="flex-grow">
<p class="font-mono">${maskedKey}</p>
<p class="text-zinc-400">${safeMessage}</p>
</div>
</div>`;
}
}).join("");
contentHtml = `
<div class="task-item-main">
<div class="task-item-icon-summary"><i class="${overallIconClass}"></i></div>
<div class="task-item-content flex-grow">
<div class="flex justify-between items-center cursor-pointer" data-task-toggle>
<p class="task-item-title">${summaryTitle}</p>
<i class="fas fa-chevron-down task-toggle-icon"></i>
</div>
<div class="task-details-content collapsed" data-task-content>
<div class="task-details-body space-y-2 mt-2">
${detailsHtml}
</div>
</div>
</div>
</div>`;
}
}
return `${contentHtml}<div class="task-item-timestamp">${timeAgo}</div>`;
}
};
taskCenterManager.startTask(revalidateTask);
}
/**
* [NEW] Helper for batch deleting (unlinking) selected keys from the group.
* @param {number[]} keyIds - An array of selected key IDs.
*/
async _batchDelete(keyIds) {
const groupId = this.state.activeGroupId;
const selectedCount = keyIds.length;
const result = await Swal.fire({
target: "#main-content-wrapper",
width: "20rem",
backdrop: `rgba(0,0,0,0.5)`,
heightAuto: false,
customClass: {
popup: `swal2-custom-style ${document.documentElement.classList.contains("dark") ? "swal2-dark" : ""}`
},
title: "\u786E\u8BA4\u6279\u91CF\u79FB\u9664",
html: `\u786E\u5B9A\u8981\u4ECE <b>\u5F53\u524D\u5206\u7EC4</b> \u4E2D\u79FB\u9664\u9009\u4E2D\u7684 <b>${selectedCount}</b> \u4E2AKey\u5417\uFF1F`,
showCancelButton: true,
confirmButtonText: "\u786E\u8BA4",
cancelButtonText: "\u53D6\u6D88",
reverseButtons: true,
confirmButtonColor: "#d33",
cancelButtonColor: "#6b7280"
});
if (!result.isConfirmed) return;
const keysToDelete = this.state.currentKeys.filter((key) => keyIds.includes(key.id)).map((key) => key.api_key);
if (keysToDelete.length === 0) {
toastManager.show("\u627E\u4E0D\u5230\u5339\u914D\u7684Key\u8FDB\u884C\u79FB\u9664\u3002\u8BF7\u5237\u65B0\u5217\u8868\u540E\u91CD\u8BD5\u3002", "error");
return;
}
try {
const unlinkResult = await apiKeyManager.unlinkKeysFromGroup(groupId, keysToDelete);
if (!unlinkResult.success) {
throw new Error(unlinkResult.message || "\u540E\u7AEF\u672A\u80FD\u79FB\u9664Keys\u3002");
}
toastManager.show(`\u6210\u529F\u79FB\u9664 ${keysToDelete.length} \u4E2AKey\u3002`, "success");
await this.loadApiKeys(groupId, true);
} catch (error) {
const errorMessage = error && error.message ? error.message : "\u672A\u77E5\u9519\u8BEF";
toastManager.show(`\u6279\u91CF\u79FB\u9664Key\u5931\u8D25: ${errorMessage}`, "error");
await this.loadApiKeys(groupId, true);
}
}
/**
* [NEW] Helper for copying all selected API keys to the clipboard.
* @param {number[]} keyIds - An array of selected key IDs.
*/
_batchCopyToClipboard(keyIds) {
const currentKeysMap = new Map(this.state.currentKeys.map((key) => [key.id, key.api_key]));
const keysToCopy = keyIds.map((id) => currentKeysMap.get(id)).filter(Boolean);
if (keysToCopy.length === 0) {
toastManager.show("\u6CA1\u6709\u627E\u5230\u53EF\u590D\u5236\u7684Key\u3002\u5217\u8868\u53EF\u80FD\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u5237\u65B0\u3002", "warning");
return;
}
const textToCopy = keysToCopy.join("\n");
navigator.clipboard.writeText(textToCopy).then(() => {
toastManager.show(`\u6210\u529F\u590D\u5236 ${keysToCopy.length} \u4E2AKey\u5230\u526A\u8D34\u677F\u3002`, "success");
}).catch((err) => {
console.error("Failed to copy keys to clipboard:", err);
toastManager.show(`\u590D\u5236\u5931\u8D25: ${err.message}`, "error");
});
}
/** [NEW] Shows the mobile search modal using SweetAlert2. */
_showMobileSearchModal() {
Swal.fire({
target: "#main-content-wrapper",
width: "24rem",
backdrop: `rgba(0,0,0,0.5)`,
heightAuto: false,
customClass: {
popup: `swal2-custom-style rounded-xl ${document.documentElement.classList.contains("dark") ? "swal2-dark" : ""}`,
htmlContainer: "m-0"
},
showConfirmButton: false,
// We don't need a confirm button
showCloseButton: false,
// A close button is user-friendly
html: `
<div class="flex items-center justify-between rounded-md bg-zinc-100 dark:bg-zinc-700 p-3 mt-2 font-mono text-sm text-zinc-800 dark:text-zinc-200">
<input type="text" id="swal-search-input" placeholder="\u641C\u7D22 API Key..." class="w-full focus:outline-none focus:ring-0 rounded-lg dark:placeholder-zinc-400"
autocomplete="off">
</div>
`,
didOpen: (modal) => {
const input = modal.querySelector("#swal-search-input");
if (!input) return;
input.value = this.state.searchText;
input.focus();
input.addEventListener("input", this._handleSearchInput.bind(this));
input.addEventListener("keydown", (event) => {
this._handleSearchEnter.bind(this)(event);
if (event.key === "Enter") {
event.preventDefault();
Swal.close();
}
});
}
});
}
/** [NEW] Central handler for any search input event. */
_handleSearchInput(event) {
this._updateSearchStateAndSyncInputs(event.target.value);
this.debouncedSearch();
}
/** Synchronizes search text across UI state and inputs. */
_updateSearchStateAndSyncInputs(value) {
this.state.searchText = value;
if (this.elements.desktopSearchInput && document.activeElement !== this.elements.desktopSearchInput) {
this.elements.desktopSearchInput.value = value;
}
}
/** [MODIFIED] Handles 'Enter' key press for immediate search. Bug fixed. */
_handleSearchEnter(event) {
if (event.key === "Enter") {
event.preventDefault();
this.debouncedSearch.cancel?.();
this.state.currentPage = 1;
this.loadApiKeys(this.state.activeGroupId, true);
}
}
/** Hides the mobile search overlay */
_hideMobileSearch() {
this.elements.mobileSearchOverlay?.classList.add("hidden");
}
/**
* A private helper that returns the complete quick actions configuration.
* This is the single, authoritative source for all quick action menu data.
* @returns {Array<Object>} The configuration array for quick actions.
*/
_getQuickActionsConfiguration() {
return [
{ action: "revalidate-invalid", text: "\u9A8C\u8BC1\u6240\u6709\u65E0\u6548Key", icon: "fa-rocket text-blue-500", requiresConfirm: false },
{ action: "revalidate-all", text: "\u9A8C\u8BC1\u6240\u6709Key", icon: "fa-sync-alt text-blue-500", requiresConfirm: true, confirmText: "\u6B64\u64CD\u4F5C\u5C06\u5BF9\u5F53\u524D\u5206\u7EC4\u4E0B\u7684 **\u6240\u6709Key** \u53D1\u8D77\u4E00\u6B21API\u8BF7\u6C42\u4EE5\u9A8C\u8BC1\u5176\u6709\u6548\u6027\uFF0C\u53EF\u80FD\u4F1A\u6D88\u8017\u5927\u91CF\u989D\u5EA6\u3002\u786E\u5B9A\u8981\u7EE7\u7EED\u5417\uFF1F" },
{ action: "restore-cooldown", text: "\u6062\u590D\u6240\u6709\u51B7\u5374\u4E2DKey", icon: "fa-undo text-green-500", requiresConfirm: false },
{ type: "divider" },
{ action: "cleanup-banned", text: "\u5220\u9664\u6240\u6709\u5931\u6548Key", icon: "fa-trash-alt", danger: true, requiresConfirm: true, confirmText: "\u786E\u5B9A\u8981\u4ECE\u5F53\u524D\u5206\u7EC4\u4E2D\u79FB\u9664 **\u6240\u6709** \u72B6\u6001\u4E3A `BANNED` \u7684Key\u5417\uFF1F\u6B64\u64CD\u4F5C\u4E0D\u53EF\u6062\u590D\u3002" }
];
}
/**
* Renders the content of the quick actions dropdown menu into both desktop and mobile panels.
*/
_renderQuickActionsMenu() {
const actions = this._getQuickActionsConfiguration();
const menuHtml = actions.map((item) => {
if (item.type === "divider") {
return '<div class="menu-divider"></div>';
}
return `
<button data-quick-action="${item.action}" class="menu-item ${item.danger ? "menu-item-danger" : ""} whitespace-nowrap">
<i class="fas ${item.icon} menu-item-icon"></i>
<span>${item.text}</span>
</button>
`;
}).join("");
const menuWrapper = `<div class="py-1">${menuHtml}</div>`;
if (this.elements.desktopQuickActionsPanel) {
this.elements.desktopQuickActionsPanel.innerHTML = menuWrapper;
}
if (this.elements.mobileQuickActionsPanel) {
this.elements.mobileQuickActionsPanel.innerHTML = menuWrapper;
}
}
/**
* [NEW] Handles clicks on any quick action button.
* @param {Event} event The click event.
*/
async _handleQuickActionClick(event) {
const button = event.target.closest("button[data-quick-action]");
if (!button) return;
event.preventDefault();
const action = button.dataset.quickAction;
const groupId = this.state.activeGroupId;
if (!groupId) {
toastManager.show("\u8BF7\u5148\u9009\u62E9\u4E00\u4E2A\u5206\u7EC4\u3002", "warning");
return;
}
const actionConfig = this._getQuickActionConfig(action);
if (actionConfig && actionConfig.requiresConfirm) {
const result = await Swal.fire({
target: "#main-content-wrapper",
title: "\u8BF7\u786E\u8BA4\u64CD\u4F5C",
html: actionConfig.confirmText,
icon: "warning",
showCancelButton: true,
confirmButtonText: "\u786E\u8BA4\u6267\u884C",
cancelButtonText: "\u53D6\u6D88",
reverseButtons: true,
confirmButtonColor: "#d33",
customClass: { popup: `swal2-custom-style ${document.documentElement.classList.contains("dark") ? "swal2-dark" : ""}` }
});
if (!result.isConfirmed) return;
}
this._executeQuickAction(action, groupId);
}
/** [NEW] Helper to retrieve action configuration. */
_getQuickActionConfig(action) {
const actions = this._getQuickActionsConfiguration();
return actions.find((a) => a.action === action);
}
/**
* [MODIFIED] Executes the quick action by building the correct payload for the unified bulk-actions endpoint.
* @param {string} action The action to execute.
* @param {number} groupId The current group ID.
*/
async _executeQuickAction(action, groupId) {
let taskDefinition;
let payload;
let taskTitle;
switch (action) {
case "revalidate-invalid":
taskTitle = "\u9A8C\u8BC1\u5206\u7EC4\u65E0\u6548Key";
payload = {
action: "revalidate",
filter: {
// Backend should interpret 'invalid' as this set of statuses
status: ["disabled", "cooldown", "banned"]
}
};
break;
case "revalidate-all":
taskTitle = "\u9A8C\u8BC1\u5206\u7EC4\u6240\u6709Key";
payload = {
action: "revalidate",
filter: { status: ["all"] }
};
break;
case "restore-cooldown":
taskTitle = "\u6062\u590D\u51B7\u5374\u4E2DKey";
payload = {
action: "set_status",
new_status: "active",
// The target status
filter: { status: ["cooldown"] }
};
break;
case "cleanup-banned":
taskTitle = "\u6E05\u7406\u5931\u6548Key";
payload = {
action: "delete",
filter: { status: ["banned"] }
};
break;
default:
toastManager.show(`\u672A\u77E5\u7684\u5FEB\u901F\u5904\u7F6E\u64CD\u4F5C: ${action}`, "error");
return;
}
try {
const response = await apiKeyManager.startGroupBulkActionTask(groupId, payload);
if (response && response.id) {
const startPromise = Promise.resolve(response);
const taskDefinition2 = this._createGroupTaskDefinition(taskTitle, startPromise, groupId, action);
taskCenterManager.startTask(taskDefinition2);
} else {
if (response && response.result && response.result.message) {
toastManager.show(response.result.message, "info");
} else {
toastManager.show("\u64CD\u4F5C\u5DF2\u5B8C\u6210\uFF0C\u4F46\u65E0\u4EFB\u4F55\u9879\u76EE\u88AB\u66F4\u6539\u3002", "info");
}
}
} catch (error) {
handleApiError(error, toastManager, { prefix: `${taskTitle}\u65F6\u53D1\u751F\u610F\u5916\u9519\u8BEF: ` });
}
}
/**
* [NEW] Generic task definition factory for group-level operations.
*/
_createGroupTaskDefinition(title, startPromise, groupId, action) {
return {
start: () => startPromise,
poll: (taskId) => apiKeyManager.getTaskStatus(taskId, { noCache: true }),
onSuccess: (data) => {
toastManager.show(`${title}\u4EFB\u52A1\u5B8C\u6210\u3002`, "success");
this.loadApiKeys(groupId, true);
},
onError: (data) => {
const displayMessage = escapeHTML(data.error || "\u4EFB\u52A1\u6267\u884C\u65F6\u53D1\u751F\u672A\u77E5\u9519\u8BEF");
toastManager.show(`${title}\u4EFB\u52A1\u5931\u8D25: ${displayMessage}`, "error");
},
renderToastNarrative: (data, oldData, toastManager2) => {
const toastId = `task-${data.id}`;
const progress = data.total > 0 ? data.processed / data.total * 100 : 0;
toastManager2.showProgressToast(toastId, title, `\u5904\u7406\u4E2D (${data.processed}/${data.total})`, progress);
},
renderTaskCenterItem: (data, timestamp, formatTimeAgo) => {
const timeAgo = formatTimeAgo(timestamp);
let contentHtml = "";
if (data.is_running) {
contentHtml = `
<div class="task-item-main gap-3">
<div class="task-item-icon task-item-icon-running"><i class="fas fa-spinner animate-spin"></i></div>
<div class="task-item-content flex-grow">
<p class="task-item-title">${title}</p>
<p class="task-item-status">\u8FD0\u884C\u4E2D... (${data.processed}/${data.total})</p>
</div>
</div>`;
} else if (data.error) {
const safeError = escapeHTML(data.error);
contentHtml = `
<div class="task-item-main">
<div class="task-item-icon-summary text-red-500"><i class="fas fa-exclamation-triangle"></i></div>
<div class="task-item-content flex-grow">
<p class="task-item-title">${title}\u4EFB\u52A1\u51FA\u9519</p>
<p class="task-item-status text-red-500 truncate" title="${safeError}">${safeError}</p>
</div>
</div>`;
} else {
let summary = "\u4EFB\u52A1\u5DF2\u5B8C\u6210\u3002";
const result = data.result || {};
const iconClass = "fas fa-check-circle text-green-500";
switch (action) {
case "cleanup-banned":
summary = `\u6210\u529F\u6E05\u7406 ${result.unlinked_count || 0} \u4E2A\u5931\u6548Key\u3002`;
break;
case "restore-cooldown":
if (result.skipped_count > 0) {
summary = `\u5B8C\u6210: ${result.updated_count || 0} \u4E2A\u5DF2\u6062\u590D, ${result.skipped_count} \u4E2A\u88AB\u8DF3\u8FC7\u3002`;
} else {
summary = `\u6210\u529F\u6062\u590D ${result.updated_count || 0} \u4E2A\u51B7\u5374\u4E2DKey\u3002`;
}
break;
case "revalidate-invalid":
case "revalidate-all":
const results = result.results || [];
const validCount = results.filter((r) => r.status === "valid").length;
const invalidCount = results.length - validCount;
summary = `\u9A8C\u8BC1\u5B8C\u6210: ${validCount}\u4E2A\u6709\u6548, ${invalidCount}\u4E2A\u65E0\u6548\u3002`;
break;
default:
summary = `\u5904\u7406\u4E86 ${data.processed} \u4E2AKey\u3002`;
}
const safeSummary = escapeHTML(summary);
contentHtml = `
<div class="task-item-main">
<div class="task-item-icon-summary"><i class="${iconClass}"></i></div>
<div class="task-item-content flex-grow">
<p class="task-item-title">${title}</p>
<p class="task-item-status truncate" title="${safeSummary}">${safeSummary}</p>
</div>
</div>`;
}
return `${contentHtml}<div class="task-item-timestamp">${timeAgo}</div>`;
}
};
}
/**
* [NEW] A generic handler to open/close any custom dropdown menu.
* It uses data attributes to link triggers to panels.
* @param {Event} event The click event.
*/
_handleDropdownToggle(event) {
const toggleButton = event.target.closest('[data-toggle="custom-select"]');
if (!toggleButton) {
this._closeAllDropdowns();
return;
}
const targetSelector = toggleButton.dataset.target;
const targetPanel = document.querySelector(targetSelector);
if (!targetPanel) return;
const isPanelOpen = !targetPanel.classList.contains("hidden");
this._closeAllDropdowns(targetPanel);
if (!isPanelOpen) {
targetPanel.classList.remove("hidden");
}
}
/**
* [NEW] A helper utility to close all custom dropdown panels.
* @param {HTMLElement} [excludePanel=null] An optional panel to exclude from closing.
*/
_closeAllDropdowns(excludePanel = null) {
const allPanels = document.querySelectorAll(".custom-select-panel");
allPanels.forEach((panel) => {
if (panel !== excludePanel) {
panel.classList.add("hidden");
}
});
}
/**
* [NEW] Renders the content of the multifunction (export) dropdown menu.
*/
_renderMultifunctionMenu() {
const actions = [
{ action: "export-all", text: "\u5BFC\u51FA\u6240\u6709Key", icon: "fa-file-export" },
{ action: "export-valid", text: "\u5BFC\u51FA\u6709\u6548Key", icon: "fa-file-alt" },
{ action: "export-invalid", text: "\u5BFC\u51FA\u65E0\u6548Key", icon: "fa-file-excel" }
];
const menuHtml = actions.map((item) => {
return `
<button data-multifunction-action="${item.action}" class="menu-item whitespace-nowrap">
<i class="fas ${item.icon} menu-item-icon"></i>
<span>${item.text}</span>
</button>
`;
}).join("");
const menuWrapper = `<div class="py-1">${menuHtml}</div>`;
if (this.elements.desktopMultifunctionPanel) {
this.elements.desktopMultifunctionPanel.innerHTML = menuWrapper;
}
if (this.elements.mobileMultifunctionPanel) {
this.elements.mobileMultifunctionPanel.innerHTML = menuWrapper;
}
}
/**
* [NEW] Handles clicks on any multifunction menu button.
* @param {Event} event The click event.
*/
async _handleMultifunctionMenuClick(event) {
const button = event.target.closest("button[data-multifunction-action]");
if (!button) return;
event.preventDefault();
this._closeAllDropdowns();
const action = button.dataset.multifunctionAction;
const groupId = this.state.activeGroupId;
if (!groupId) {
toastManager.show("\u8BF7\u5148\u9009\u62E9\u4E00\u4E2A\u5206\u7EC4\u3002", "warning");
return;
}
let statuses = [];
let filename = `${this.state.activeGroupName}_keys.txt`;
switch (action) {
case "export-all":
statuses = ["all"];
filename = `${this.state.activeGroupName}_all_keys.txt`;
break;
case "export-valid":
statuses = ["active", "cooldown"];
filename = `${this.state.activeGroupName}_valid_keys.txt`;
break;
case "export-invalid":
statuses = ["disabled", "banned"];
filename = `${this.state.activeGroupName}_invalid_keys.txt`;
break;
default:
return;
}
toastManager.show("\u6B63\u5728\u51C6\u5907\u5BFC\u51FA\u6570\u636E...", "info");
try {
const keys = await apiKeyManager.exportKeysForGroup(groupId, statuses);
if (keys.length === 0) {
toastManager.show("\u6CA1\u6709\u627E\u5230\u7B26\u5408\u6761\u4EF6\u7684Key\u53EF\u4F9B\u5BFC\u51FA\u3002", "warning");
return;
}
this._triggerTextFileDownload(keys.join("\n"), filename);
toastManager.show(`\u6210\u529F\u5BFC\u51FA ${keys.length} \u4E2AKey\u3002`, "success");
} catch (error) {
toastManager.show(`\u5BFC\u51FA\u5931\u8D25: ${error.message}`, "error");
}
}
/**
* [NEW] A utility function to trigger a text file download in the browser.
* @param {string} content The content of the file.
* @param {string} filename The desired name of the file.
*/
_triggerTextFileDownload(content, filename) {
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
};
var apiKeyList_default = ApiKeyList;
// frontend/js/pages/keys/index.js
var KeyGroupsPage = class {
constructor() {
this.state = {
groups: [],
groupDetailsCache: {},
activeGroupId: null,
isLoading: true
};
this.debouncedSaveOrder = debounce(this.saveGroupOrder.bind(this), 1500);
this.elements = {
dashboardTitle: document.querySelector("#group-dashboard h2"),
dashboardControls: document.querySelector("#group-dashboard .flex.items-center.gap-x-3"),
apiListContainer: document.getElementById("api-list-container"),
groupListCollapsible: document.getElementById("group-list-collapsible"),
desktopGroupContainer: document.querySelector("#desktop-group-cards-list .card-list-content"),
mobileGroupContainer: document.getElementById("mobile-group-cards-list"),
addGroupBtnContainer: document.getElementById("add-group-btn-container"),
groupMenuToggle: document.getElementById("group-menu-toggle"),
mobileActiveGroupDisplay: document.querySelector(".mobile-group-selector > div")
};
this.initialized = this.elements.desktopGroupContainer !== null && this.elements.apiListContainer !== null;
if (this.initialized) {
this.apiKeyList = new apiKeyList_default(this.elements.apiListContainer);
}
const allowedModelsInput = new TagInput(document.getElementById("allowed-models-container"), {
validator: /^[a-z0-9\.-]+$/,
validationMessage: "\u65E0\u6548\u7684\u6A21\u578B\u683C\u5F0F"
});
const allowedUpstreamsInput = new TagInput(document.getElementById("allowed-upstreams-container"), {
validator: /^(https?:\/\/)?[\w\.-]+\.[a-z]{2,}(\/[\w\.-]*)*\/?$/i,
validationMessage: "\u65E0\u6548\u7684 URL \u683C\u5F0F"
});
const allowedTokensInput = new TagInput(document.getElementById("allowed-tokens-container"), {
validator: /.+/,
validationMessage: "\u4EE4\u724C\u4E0D\u80FD\u4E3A\u7A7A"
});
this.keyGroupModal = new KeyGroupModal({
onSave: this.handleSaveGroup.bind(this),
tagInputInstances: {
models: allowedModelsInput,
upstreams: allowedUpstreamsInput,
tokens: allowedTokensInput
}
});
this.deleteGroupModal = new DeleteGroupModal({
onDeleteSuccess: (deletedGroupId) => {
if (this.state.activeGroupId === deletedGroupId) {
this.state.activeGroupId = null;
this.apiKeyList.loadApiKeys(null);
}
this.loadKeyGroups(true);
}
});
this.addApiModal = new AddApiModal({
onImportSuccess: () => this.apiKeyList.loadApiKeys(this.state.activeGroupId, true)
});
this.cloneGroupModal = new CloneGroupModal({
onCloneSuccess: (clonedGroup) => {
if (clonedGroup && clonedGroup.id) {
this.state.activeGroupId = clonedGroup.id;
}
this.loadKeyGroups(true);
}
});
this.deleteApiModal = new DeleteApiModal({
onDeleteSuccess: () => this.apiKeyList.loadApiKeys(this.state.activeGroupId, true)
});
this.requestSettingsModal = new RequestSettingsModal({
onSave: this.handleSaveRequestSettings.bind(this)
});
this.activeTooltip = null;
}
async init() {
if (!this.initialized) {
console.error("KeyGroupsPage: Could not initialize. Essential container elements like 'desktopGroupContainer' or 'apiListContainer' are missing from the DOM.");
return;
}
this.initEventListeners();
if (this.apiKeyList) {
this.apiKeyList.init();
}
await this.loadKeyGroups();
}
initEventListeners() {
document.body.addEventListener("click", (event) => {
const addGroupBtn = event.target.closest(".add-group-btn");
const addApiBtn = event.target.closest("#add-api-btn");
const deleteApiBtn = event.target.closest("#delete-api-btn");
if (addGroupBtn) this.keyGroupModal.open();
if (addApiBtn) this.addApiModal.open(this.state.activeGroupId);
if (deleteApiBtn) this.deleteApiModal.open(this.state.activeGroupId);
});
this.elements.dashboardControls?.addEventListener("click", (event) => {
const button = event.target.closest("button[data-action]");
if (!button) return;
const action = button.dataset.action;
const activeGroup = this.state.groups.find((g) => g.id === this.state.activeGroupId);
switch (action) {
case "edit-group":
if (activeGroup) {
this.openEditGroupModal(activeGroup.id);
} else {
alert("\u8BF7\u5148\u9009\u62E9\u4E00\u4E2A\u5206\u7EC4\u8FDB\u884C\u7F16\u8F91\u3002");
}
break;
case "open-settings":
this.openRequestSettingsModal();
break;
case "clone-group":
if (activeGroup) {
this.cloneGroupModal.open(activeGroup);
} else {
alert("\u8BF7\u5148\u9009\u62E9\u4E00\u4E2A\u5206\u7EC4\u8FDB\u884C\u514B\u9686\u3002");
}
break;
case "delete-group":
console.log("Delete action triggered for group:", this.state.activeGroupId);
this.deleteGroupModal.open(activeGroup);
break;
}
});
this.elements.groupListCollapsible?.addEventListener("click", (event) => {
this.handleGroupCardClick(event);
});
this.elements.groupMenuToggle?.addEventListener("click", (event) => {
event.stopPropagation();
const menu = this.elements.groupListCollapsible;
if (!menu) return;
menu.classList.toggle("hidden");
setTimeout(() => {
menu.classList.toggle("mobile-group-menu-active");
}, 0);
});
document.addEventListener("click", (event) => {
const menu = this.elements.groupListCollapsible;
const toggle = this.elements.groupMenuToggle;
if (menu && menu.classList.contains("mobile-group-menu-active") && !menu.contains(event.target) && !toggle.contains(event.target)) {
this._closeMobileMenu();
}
});
this.initCustomSelects();
this.initTooltips();
this.initDragAndDrop();
this._initBatchActions();
}
// 4. 数据获取与渲染逻辑
async loadKeyGroups(force = false) {
this.state.isLoading = true;
try {
const responseData = await apiFetchJson("/admin/keygroups", { noCache: force });
if (responseData && responseData.success && Array.isArray(responseData.data)) {
this.state.groups = responseData.data;
} else {
console.error("API response format is incorrect:", responseData);
this.state.groups = [];
}
if (this.state.groups.length > 0 && !this.state.activeGroupId) {
this.state.activeGroupId = this.state.groups[0].id;
}
this.renderGroupList();
if (this.state.activeGroupId) {
this.updateDashboard();
}
this.updateAllHealthIndicators();
} catch (error) {
console.error("Failed to load or parse key groups:", error);
this.state.groups = [];
this.renderGroupList();
this.updateDashboard();
} finally {
this.state.isLoading = false;
}
if (this.state.activeGroupId) {
this.updateDashboard();
} else {
this.apiKeyList.loadApiKeys(null);
}
}
/**
* Helper function to determine health indicator CSS classes based on success rate.
* @param {number} rate - The success rate (0-100).
* @returns {{ring: string, dot: string}} - The CSS classes for the ring and dot.
*/
_getHealthIndicatorClasses(rate) {
if (rate >= 50) return { ring: "bg-green-500/20", dot: "bg-green-500" };
if (rate >= 30) return { ring: "bg-yellow-500/20", dot: "bg-yellow-500" };
if (rate >= 10) return { ring: "bg-orange-500/20", dot: "bg-orange-500" };
return { ring: "bg-red-500/20", dot: "bg-red-500" };
}
/**
* Renders the list of group cards based on the current state.
*/
renderGroupList() {
if (!this.state.groups) return;
const desktopListHtml = this.state.groups.map((group) => {
const isActive = group.id === this.state.activeGroupId;
const cardClass = isActive ? "group-card-active" : "group-card-inactive";
const successRate = 100;
const healthClasses = this._getHealthIndicatorClasses(successRate);
const channelTag = this._getChannelTypeTag(group.channel_type || "Local");
const customTags = this._getCustomTags(group.custom_tags);
return `
<div class="${cardClass}" data-group-id="${group.id}" data-success-rate="${successRate}">
<div class="flex items-center gap-x-3">
<div data-health-indicator class="health-indicator-ring ${healthClasses.ring}">
<div data-health-dot class="health-indicator-dot ${healthClasses.dot}"></div>
</div>
<div class="flex-grow">
<!-- [\u6700\u7EC8\u5E03\u5C40] 1. \u540D\u79F0 -> 2. \u63CF\u8FF0 -> 3. \u6807\u7B7E -->
<h3 class="font-semibold text-sm">${group.display_name}</h3>
<p class="card-sub-text my-1.5">${group.description || "No description available"}</p>
<div class="flex items-center gap-x-1.5 flex-wrap">
${channelTag}
${customTags}
</div>
</div>
</div>
</div>`;
}).join("");
if (this.elements.desktopGroupContainer) {
this.elements.desktopGroupContainer.innerHTML = desktopListHtml;
if (this.elements.addGroupBtnContainer) {
this.elements.desktopGroupContainer.parentElement.appendChild(this.elements.addGroupBtnContainer);
}
}
const mobileListHtml = this.state.groups.map((group) => {
const isActive = group.id === this.state.activeGroupId;
const cardClass = isActive ? "group-card-active" : "group-card-inactive";
return `
<div class="${cardClass}" data-group-id="${group.id}">
<h3 class="font-semibold text-sm">${group.display_name})</h3>
<p class="card-sub-text my-1.5">${group.description || "No description available"}</p>
</div>`;
}).join("");
if (this.elements.mobileGroupContainer) {
this.elements.mobileGroupContainer.innerHTML = mobileListHtml;
}
}
// 事件处理器和UI更新函数现在完全由 state 驱动
handleGroupCardClick(event) {
const clickedCard = event.target.closest("[data-group-id]");
if (!clickedCard) return;
const groupId = parseInt(clickedCard.dataset.groupId, 10);
if (this.state.activeGroupId !== groupId) {
this.state.activeGroupId = groupId;
this.renderGroupList();
this.updateDashboard();
}
if (window.innerWidth < 1024) {
this._closeMobileMenu();
}
}
// [NEW HELPER METHOD] Centralizes the logic for closing the mobile menu.
_closeMobileMenu() {
const menu = this.elements.groupListCollapsible;
if (!menu) return;
menu.classList.remove("mobile-group-menu-active");
menu.classList.add("hidden");
}
updateDashboard() {
const activeGroup = this.state.groups.find((g) => g.id === this.state.activeGroupId);
if (activeGroup) {
if (this.elements.dashboardTitle) {
this.elements.dashboardTitle.textContent = `${activeGroup.display_name}`;
}
if (this.elements.mobileActiveGroupDisplay) {
this.elements.mobileActiveGroupDisplay.innerHTML = `
<h3 class="font-semibold text-sm">${activeGroup.display_name}</h3>
<p class="card-sub-text">\u5F53\u524D\u9009\u62E9</p>`;
}
this.apiKeyList.setActiveGroup(activeGroup.id, activeGroup.display_name);
this.apiKeyList.loadApiKeys(activeGroup.id);
} else {
if (this.elements.dashboardTitle) this.elements.dashboardTitle.textContent = "No Group Selected";
if (this.elements.mobileActiveGroupDisplay) this.elements.mobileActiveGroupDisplay.innerHTML = `<h3 class="font-semibold text-sm">\u8BF7\u9009\u62E9\u4E00\u4E2A\u5206\u7EC4</h3>`;
this.apiKeyList.loadApiKeys(null);
}
}
/**
* Handles the saving of a key group with modern toast notifications.
* @param {object} groupData The data collected from the KeyGroupModal.
*/
async handleSaveGroup(groupData) {
const isEditing = !!groupData.id;
const endpoint = isEditing ? `/admin/keygroups/${groupData.id}` : "/admin/keygroups";
const method = isEditing ? "PUT" : "POST";
console.log(`[CONTROLLER] ${isEditing ? "Updating" : "Creating"} group...`, { endpoint, method, data: groupData });
try {
const response = await apiFetch(endpoint, {
method,
body: JSON.stringify(groupData),
noCache: true
});
const result = await response.json();
if (!result.success) {
throw new Error(result.message || "An unknown error occurred on the server.");
}
if (isEditing) {
console.log(`[CACHE INVALIDATION] Deleting cached details for group ${groupData.id}.`);
delete this.state.groupDetailsCache[groupData.id];
}
if (!isEditing && result.data && result.data.id) {
this.state.activeGroupId = result.data.id;
}
toastManager.show(`\u5206\u7EC4 "${groupData.display_name}" \u5DF2\u6210\u529F\u4FDD\u5B58\u3002`, "success");
await this.loadKeyGroups(true);
} catch (error) {
console.error(`Failed to save group:`, error.message);
toastManager.show(`\u4FDD\u5B58\u5931\u8D25: ${error.message}`, "error");
throw error;
}
}
/**
* Opens the KeyGroupModal for editing, utilizing a cache-then-fetch strategy.
* @param {number} groupId The ID of the group to edit.
*/
async openEditGroupModal(groupId) {
if (this.state.groupDetailsCache[groupId]) {
console.log(`[CACHE HIT] Using cached details for group ${groupId}.`);
this.keyGroupModal.open(this.state.groupDetailsCache[groupId]);
return;
}
console.log(`[CACHE MISS] Fetching details for group ${groupId}.`);
try {
const endpoint = `/admin/keygroups/${groupId}`;
const responseData = await apiFetchJson(endpoint, { noCache: true });
if (responseData && responseData.success) {
const groupDetails = responseData.data;
this.state.groupDetailsCache[groupId] = groupDetails;
this.keyGroupModal.open(groupDetails);
} else {
throw new Error(responseData.message || "Failed to load group details.");
}
} catch (error) {
console.error(`Failed to fetch details for group ${groupId}:`, error);
alert(`\u65E0\u6CD5\u52A0\u8F7D\u5206\u7EC4\u8BE6\u60C5: ${error.message}`);
}
}
async openRequestSettingsModal() {
if (!this.state.activeGroupId) {
modalManager.showResult(false, "\u8BF7\u5148\u9009\u62E9\u4E00\u4E2A\u5206\u7EC4\u3002");
return;
}
console.log(`Opening request settings for group ID: ${this.state.activeGroupId}`);
const data = {};
this.requestSettingsModal.open(data);
}
/**
* @param {object} data The data collected from the RequestSettingsModal.
*/
async handleSaveRequestSettings(data) {
if (!this.state.activeGroupId) {
throw new Error("No active group selected.");
}
console.log(`[CONTROLLER] Saving request settings for group ${this.state.activeGroupId}:`, data);
return Promise.resolve();
}
initCustomSelects() {
const customSelects = document.querySelectorAll(".custom-select");
customSelects.forEach((select) => new CustomSelect(select));
}
_initBatchActions() {
}
/**
* Sends the new group UI order to the backend API.
* @param {Array<object>} orderData - An array of objects, e.g., [{id: 1, order: 0}, {id: 2, order: 1}]
*/
async saveGroupOrder(orderData) {
console.log("Debounced save triggered. Sending UI order to API:", orderData);
try {
const response = await apiFetch("/admin/keygroups/order", {
method: "PUT",
body: JSON.stringify(orderData),
noCache: true
});
const result = await response.json();
if (!result.success) {
throw new Error(result.message || "Failed to save UI order on the server.");
}
console.log("UI order saved successfully.");
} catch (error) {
console.error("Failed to save new group UI order:", error);
this.loadKeyGroups();
}
}
/**
* Initializes drag-and-drop functionality for the group list.
*/
initDragAndDrop() {
if (typeof Sortable === "undefined") {
console.error("SortableJS is not loaded.");
return;
}
const container = this.elements.desktopGroupContainer;
if (!container) return;
new Sortable(container, {
animation: 150,
ghostClass: "sortable-ghost",
dragClass: "sortable-drag",
filter: "#add-group-btn-container",
onEnd: (evt) => {
const groupCards = Array.from(container.querySelectorAll("[data-group-id]"));
const orderedState = groupCards.map((card) => {
const cardId = parseInt(card.dataset.groupId, 10);
return this.state.groups.find((group) => group.id === cardId);
}).filter(Boolean);
if (orderedState.length !== this.state.groups.length) {
console.error("Drag-and-drop failed: Could not map all DOM elements to state. Aborting.");
return;
}
this.state.groups = orderedState;
const payload = this.state.groups.map((group, index) => ({
id: group.id,
order: index
}));
this.debouncedSaveOrder(payload);
}
});
}
/**
* Helper function to generate a styled HTML tag for the channel type.
* @param {string} type - The channel type string (e.g., 'OpenAI', 'Azure').
* @returns {string} - The generated HTML span element.
*/
_getChannelTypeTag(type) {
if (!type) return "";
const styles = {
"OpenAI": "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300",
"Azure": "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300",
"Claude": "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300",
"Gemini": "bg-sky-100 text-sky-800 dark:bg-sky-900 dark:text-sky-300",
"Local": "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
};
const baseClass = "inline-block text-xs font-medium px-2 py-0.5 rounded-md";
const tagClass = styles[type] || styles["Local"];
return `<span class="${baseClass} ${tagClass}">${type}</span>`;
}
/**
* Generates styled HTML for custom tags with deterministically assigned colors.
* @param {string[]} tags - An array of custom tag strings.
* @returns {string} - The generated HTML for all custom tags.
*/
_getCustomTags(tags) {
if (!tags || !Array.isArray(tags) || tags.length === 0) {
return "";
}
const colorPalette = [
"bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300",
"bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300",
"bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-300",
"bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-300",
"bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-300",
"bg-lime-100 text-lime-800 dark:bg-lime-900 dark:text-lime-300"
];
const baseClass = "inline-block text-xs font-medium px-2 py-0.5 rounded-md";
return tags.map((tag) => {
let hash = 0;
for (let i = 0; i < tag.length; i++) {
hash += tag.charCodeAt(i);
}
const colorClass = colorPalette[hash % colorPalette.length];
return `<span class="${baseClass} ${colorClass}">${tag}</span>`;
}).join("");
}
_updateHealthIndicator(cardElement) {
const rate = parseFloat(cardElement.dataset.successRate);
if (isNaN(rate)) return;
const indicator = cardElement.querySelector("[data-health-indicator]");
const dot = cardElement.querySelector("[data-health-dot]");
if (!indicator || !dot) return;
const colors = {
green: ["bg-green-500/20", "bg-green-500"],
yellow: ["bg-yellow-500/20", "bg-yellow-500"],
orange: ["bg-orange-500/20", "bg-orange-500"],
red: ["bg-red-500/20", "bg-red-500"]
};
Object.values(colors).forEach(([bgClass, dotClass]) => {
indicator.classList.remove(bgClass);
dot.classList.remove(dotClass);
});
let newColor;
if (rate >= 50) newColor = colors.green;
else if (rate >= 25) newColor = colors.yellow;
else if (rate >= 10) newColor = colors.orange;
else newColor = colors.red;
indicator.classList.add(newColor[0]);
dot.classList.add(newColor[1]);
}
updateAllHealthIndicators() {
if (!this.elements.groupListCollapsible) return;
const allCards = this.elements.groupListCollapsible.querySelectorAll("[data-success-rate]");
allCards.forEach((card) => this._updateHealthIndicator(card));
}
initTooltips() {
const tooltipIcons = document.querySelectorAll(".tooltip-icon");
tooltipIcons.forEach((icon) => {
icon.addEventListener("mouseenter", (e) => this.showTooltip(e));
icon.addEventListener("mouseleave", () => this.hideTooltip());
});
}
showTooltip(e) {
this.hideTooltip();
const target = e.currentTarget;
const text = target.dataset.tooltipText;
if (!text) return;
const tooltip = document.createElement("div");
tooltip.className = "global-tooltip";
tooltip.textContent = text;
document.body.appendChild(tooltip);
this.activeTooltip = tooltip;
const targetRect = target.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
let top = targetRect.top - tooltipRect.height - 8;
let left = targetRect.left + targetRect.width / 2 - tooltipRect.width / 2;
if (top < 0) top = targetRect.bottom + 8;
if (left < 0) left = 8;
if (left + tooltipRect.width > window.innerWidth) {
left = window.innerWidth - tooltipRect.width - 8;
}
tooltip.style.top = `${top}px`;
tooltip.style.left = `${left}px`;
}
hideTooltip() {
if (this.activeTooltip) {
this.activeTooltip.remove();
this.activeTooltip = null;
}
}
};
function init2() {
console.log("[Modern Frontend] Keys page controller loaded.");
const page = new KeyGroupsPage();
page.init();
}
// frontend/js/main.js
var pageModules = {
"dashboard": init,
//'settings': initSettings,
//'error-logs': initErrorLogs,
"keys": init2
//keys是page_handler中对该页面pageID的定义简称我们沿用即可
// 未来新增的页面,只需在这里添加一行映射
};
document.addEventListener("DOMContentLoaded", () => {
const allTabContainers = document.querySelectorAll("[data-sliding-tabs-container]");
allTabContainers.forEach((container) => new SlidingTabs(container));
const allSelectContainers = document.querySelectorAll("[data-custom-select-container]");
allSelectContainers.forEach((container) => new CustomSelect(container));
taskCenterManager.init();
const pageContainer = document.querySelector("[data-page-id]");
if (pageContainer) {
const pageId = pageContainer.dataset.pageId;
if (pageId && pageModules[pageId]) {
pageModules[pageId]();
}
}
});
window.modalManager = modalManager;
window.taskCenterManager = taskCenterManager;
window.toastManager = toastManager;
window.uiPatterns = uiPatterns;
})();