(() => { // 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 = ''; iconElement.className = "text-6xl mb-3 text-success-500"; } else { iconElement.innerHTML = ''; 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 `
${innerHtml}
`; } _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 = ``; iconContainer.className = `toast-icon bg-green-500`; setTimeout(performFadeOut, 900); } }); } else { iconContainer.innerHTML = ``; 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 = `

${this._capitalizeFirstLetter(type)}

${message}

`; 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 = `

${title}

${message} - ${Math.round(progress)}%

`; 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 = ''; 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 = `${value}`; this.container.insertBefore(tagEl, this.input); } // 处理复制逻辑的专用方法 _handleCopyAll() { const tagsString = this.tags.join(","); if (!tagsString) { this.copyBtn.innerHTML = "\u65E0\u5185\u5BB9!"; this.copyBtn.classList.add("none"); setTimeout(() => { this.copyBtn.innerHTML = ''; this.copyBtn.classList.remove("copied"); }, 1500); return; } navigator.clipboard.writeText(tagsString).then(() => { this.copyBtn.innerHTML = "\u5DF2\u590D\u5236!"; this.copyBtn.classList.add("copied"); setTimeout(() => { this.copyBtn.innerHTML = ''; this.copyBtn.classList.remove("copied"); }, 2e3); }).catch((err) => { console.error("Could not copy text: ", err); this.copyBtn.innerHTML = "\u5931\u8D25!"; setTimeout(() => { this.copyBtn.innerHTML = ''; }, 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 = ` `; 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 = ``; 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} 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} 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} 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} 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} 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} 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} 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} 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} 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} 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} 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} 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} 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} 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} 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} 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} 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} 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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 = `\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 = `

${summaryTitle}

`; } else if (!data.is_running && data.error) { contentHtml = `

\u6279\u91CF\u6DFB\u52A0\u5931\u8D25

${data.error || "\u672A\u77E5\u9519\u8BEF"}

`; } else { contentHtml = `

\u6279\u91CF\u6DFB\u52A0 ${data.total} \u4E2AAPI Key

\u8FD0\u884C\u4E2D...

`; } return `${contentHtml}
${timeAgo}
`; }, 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 = `\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 = `

${summaryTitle}

`; } else if (!data.is_running && data.error) { contentHtml = `

\u6279\u91CF\u5220\u9664\u5931\u8D25

${data.error || "\u672A\u77E5\u9519\u8BEF"}

`; } else { contentHtml = `

\u6279\u91CF\u5220\u9664 ${data.total} \u4E2AAPI Key

\u8FD0\u884C\u4E2D...

`; } return `${contentHtml}
${timeAgo}
`; }, // 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 ${group.display_name}`; 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 = `\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 ${group.display_name}`; 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 = `\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 = '

\u6B63\u5728\u52A0\u8F7D API Keys...

'; if (this.elements.paginationContainer) this.elements.paginationContainer.innerHTML = ""; return; } if (!this.state.activeGroupId) { this.elements.container.innerHTML = '

\u8BF7\u5148\u5728\u5DE6\u4FA7\u9009\u62E9\u4E00\u4E2A\u5206\u7EC4

'; if (this.elements.paginationContainer) this.elements.paginationContainer.innerHTML = ""; return; } if (this.state.currentKeys.length === 0) { this.elements.container.innerHTML = '

\u8BE5\u5206\u7EC4\u4E0B\u8FD8\u6CA1\u6709 API Key\u3002

'; if (this.elements.paginationContainer) this.elements.paginationContainer.innerHTML = ""; return; } const listHtml = this.state.currentKeys.map((apiKey) => this._createApiKeyCardHtml(apiKey)).join(""); this.elements.container.innerHTML = `
${listHtml}
`; 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 += ``; const pagesToShow = this._getPaginationPages(currentPage, totalPages); pagesToShow.forEach((page) => { if (page === "...") { html += `...`; } else { const pageClasses = page === currentPage ? activeClasses : inactiveClasses; html += ``; } }); html += ``; 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 \u5F53\u524D\u5206\u7EC4 \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 = `

\u9A8C\u8BC1\u4EFB\u52A1\u51FA\u9519: ${maskedKey}

${safeError}

`; } 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 = `

${title}: ${maskedKey}

${safeMessage}

`; } } else { contentHtml = `

\u6B63\u5728\u9A8C\u8BC1: ${maskedKey}

\u8FD0\u884C\u4E2D... (${data.processed}/${data.total})

`; } return `${contentHtml}
${timeAgo}
`; } }; } /** * 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 `

${maskedKey}

\u5931\u8D25: ${errorCount} \u6B21

`; } _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: `
${safeApiKey}
`, 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 = ''; setTimeout(() => { copyBtn.innerHTML = ''; }, 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 = `

\u6279\u91CF\u9A8C\u8BC1 ${data.total} \u4E2AKey

\u8FD0\u884C\u4E2D... (${data.processed}/${data.total})

`; } else { if (data.error) { contentHtml = `

\u6279\u91CF\u9A8C\u8BC1\u4EFB\u52A1\u51FA\u9519

${data.error}

`; } 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 `

${maskedKey}

${safeMessage}

`; } else { return `

${maskedKey}

${safeMessage}

`; } }).join(""); contentHtml = `

${summaryTitle}

`; } } return `${contentHtml}
${timeAgo}
`; } }; 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 \u5F53\u524D\u5206\u7EC4 \u4E2D\u79FB\u9664\u9009\u4E2D\u7684 ${selectedCount} \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: `
`, 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} 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 ''; } return ` `; }).join(""); const menuWrapper = `
${menuHtml}
`; 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 = `

${title}

\u8FD0\u884C\u4E2D... (${data.processed}/${data.total})

`; } else if (data.error) { const safeError = escapeHTML(data.error); contentHtml = `

${title}\u4EFB\u52A1\u51FA\u9519

${safeError}

`; } 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 = `

${title}

${safeSummary}

`; } return `${contentHtml}
${timeAgo}
`; } }; } /** * [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 ` `; }).join(""); const menuWrapper = `
${menuHtml}
`; 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 `

${group.display_name}

${group.description || "No description available"}

${channelTag} ${customTags}
`; }).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 `

${group.display_name})

${group.description || "No description available"}

`; }).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 = `

${activeGroup.display_name}

\u5F53\u524D\u9009\u62E9

`; } 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 = `

\u8BF7\u9009\u62E9\u4E00\u4E2A\u5206\u7EC4

`; 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} 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 `${type}`; } /** * 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 `${tag}`; }).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; })();