// Filename: frontend/js/pages/keys/apiKeyList.js import { apiKeyManager } from '../../components/apiKeyManager.js'; import { taskCenterManager, toastManager } from '../../components/taskCenter.js'; import { debounce, escapeHTML } from '../../utils/utils.js'; import { handleApiError } from '../../services/errorHandler.js'; /** * Manages the UI and state for the API Key list within a selected group. * This class handles fetching, rendering, and all user interactions with the API key cards. */ class ApiKeyList { /** * @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: 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: 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; // Reset to page 1 for new search 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) { // Assuming groupName comes from groupList now this.state.activeGroupId = groupId; this.state.activeGroupName = groupName || ''; this.state.currentPage = 1; // Reset to page 1 whenever group changes } /** * 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); // Update state with new pagination info from the API response this.state.currentKeys = pageData.items; this.state.totalItems = pageData.total; this.state.totalPages = pageData.pages; this.state.currentPage = pageData.page; } catch (error) { toastManager.show(`加载API Keys失败: ${error.message || '未知错误'}`, '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 = '

正在加载 API Keys...

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

请先在左侧选择一个分组

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

该分组下还没有 API Key。

'; if (this.elements.paginationContainer) this.elements.paginationContainer.innerHTML = ''; return; } // Render the list of keys const listHtml = this.state.currentKeys.map(apiKey => this._createApiKeyCardHtml(apiKey)).join(''); this.elements.container.innerHTML = `
${listHtml}
`; this.elements.gridContainer = this.elements.container.firstChild; // Reference the grid // Render the pagination controls 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' : ''; // Previous button html += ``; // Page number buttons 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('错误: 找不到该Key的数据。可能是列表已过期,请尝试刷新。', '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 状态已成功更新为 ${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: '确认删除', html: `确定要从 当前分组 中移除这个Key吗?`, //icon: 'warning', showCancelButton: true, confirmButtonText: '确认', cancelButtonText: '取消', 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 || '后端未能移除Key。'); } toastManager.show(`成功移除 1 个Key。`, 'success'); await this.loadApiKeys(groupId, true); } catch (error) { const errorMessage = error && error.message ? error.message : ERROR_MESSAGES['DEFAULT']; toastManager.show(`移除Key失败: ${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验证完成`, 'success'); this.loadApiKeys(groupId, true); }, onError: (data) => { toastManager.show(`验证任务失败: ${data.error || '未知错误'}`, 'error'); }, renderToastNarrative: (data, oldData, toastManager) => { const toastId = `task-${data.id}`; const progress = data.total > 0 ? (data.processed / data.total) * 100 : 0; toastManager.showProgressToast(toastId, `正在验证Key`, '处理中', 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) { // --- Task is complete --- if (data.error) { // --- Case 1: The task itself failed to run --- const safeError = escapeHTML(data.error); contentHtml = `

验证任务出错: ${maskedKey}

${safeError}

`; } else { // --- Case 2: The task ran, and we have results --- const result = data.result?.results?.[0]; // Get the first (and only) result 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 ? '验证成功' : '验证失败'; // Safely get the message, providing a default if it's missing. const safeMessage = escapeHTML(result?.message || '没有详细信息。'); contentHtml = `

${title}: ${maskedKey}

${safeMessage}

`; } } else { // --- Case 3: Task is still running --- contentHtml = `

正在验证: ${maskedKey}

运行中... (${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}

失败: ${errorCount} 次

`; } _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 已复制到剪贴板。', 'success'); copyBtn.innerHTML = ''; setTimeout(() => { copyBtn.innerHTML = ''; }, 1500); }).catch(err => { toastManager.show(`复制失败: ${err.message}`, 'error'); }); }); } }, }); break; } case 'copy-key': { if (!fullApiKey) { toastManager.show('无法找到完整的Key用于复制。', 'error'); break; } navigator.clipboard.writeText(fullApiKey).then(() => { toastManager.show('API Key 已复制到剪贴板。', 'success'); }).catch(err => { toastManager.show(`复制失败: ${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', }; // A more robust way to remove old classes 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; // Case 1: A checkbox was clicked if (target.matches('.api-key-checkbox')) { this._handleSelectionChange(target); return; } // Case 2: An action button (e.g., copy, delete) was clicked const actionButton = target.closest('button[data-action]'); if (actionButton) { // We pass the original event object to handleCardAction 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; // Update the main "Select All" checkbox state 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; } // Enable or disable batch action buttons const isDisabled = selectedCount === 0; if (isDisabled) { this.elements.batchActionButton.classList.add('is-disabled'); } else { this.elements.batchActionButton.classList.remove('is-disabled'); } // Optionally, update a counter in the UI 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 ? '批量操作' : `已选 ${selectedCount} 项`; } } /** * [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; // Update the state based on all currently visible keys this.state.currentKeys.forEach(key => { if (isChecked) { this.state.selectedKeyIds.add(key.id); } else { this.state.selectedKeyIds.delete(key.id); } }); // Sync the entire UI to reflect the new state 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) { // [MODIFIED] The selector is the same, but we are now certain it's a button. const button = event.target.closest('button[data-batch-action]'); if (!button) return; event.preventDefault(); // Auto-close the custom select panel after an action. 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('没有选中任何Key。', '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' ? '启用' : '禁用'; toastManager.show(`正在批量${actionText} ${keyIds.length} 个Key...`, 'info', 3000); 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 = `批量${actionText}操作完成。`; let toastType = 'success'; if (rejectedCount > 0) { toastMessage = `操作完成: ${fulfilledCount} 个成功,${rejectedCount} 个失败(可能由于Key状态限制)。列表已更新。`; // If some succeeded, we can still consider it a partial success visually. toastType = fulfilledCount > 0 ? 'warning' : 'error'; } toastManager.show(toastMessage, toastType); } catch (error) { toastManager.show(`批量${actionText}时发生网络错误: ${error.message || '未知错误'}`, '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; // Find the full key strings for the selected IDs 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('找不到匹配的Key进行验证。请刷新列表后重试。', 'error'); return; } const revalidateTask = { start: () => apiKeyManager.revalidateKeys(groupId, keysToRevalidate), poll: (taskId) => apiKeyManager.getTaskStatus(taskId, { noCache: true }), onSuccess: (data) => { toastManager.show(`批量验证完成。`, 'success'); this.loadApiKeys(groupId, true); }, onError: (data) => { toastManager.show(`批量验证任务失败: ${data.error || '未知错误'}`, 'error'); }, renderToastNarrative: (data, oldData, toastManager) => { const toastId = `task-${data.id}`; const progress = data.total > 0 ? (data.processed / data.total) * 100 : 0; toastManager.showProgressToast(toastId, `正在批量验证Key`, `处理中 (${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 = ''; // --- State 1: Task is RUNNING --- if (data.is_running) { contentHtml = `

批量验证 ${data.total} 个Key

运行中... (${data.processed}/${data.total})

`; } // --- State 2: Task is COMPLETE --- else { // Case 2a: The entire task failed before processing keys if (data.error) { contentHtml = `

批量验证任务出错

${data.error}

`; } // Case 2b: The task ran and we have detailed results else { const results = data.result?.results || []; const validCount = results.filter(r => r.status === 'valid').length; const invalidCount = results.length - validCount; const summaryTitle = `验证完成: ${validCount}个有效, ${invalidCount}个无效`; const overallIconClass = invalidCount > 0 ? 'text-yellow-500 fas fa-exclamation-triangle' // Partial success/warning : 'text-green-500 fas fa-check-circle'; // Full success // Generate the detailed list of results for the expandable area 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: '确认批量移除', html: `确定要从 当前分组 中移除选中的 ${selectedCount} 个Key吗?`, showCancelButton: true, confirmButtonText: '确认', cancelButtonText: '取消', 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('找不到匹配的Key进行移除。请刷新列表后重试。', 'error'); return; } try { const unlinkResult = await apiKeyManager.unlinkKeysFromGroup(groupId, keysToDelete); if (!unlinkResult.success) { throw new Error(unlinkResult.message || '后端未能移除Keys。'); } toastManager.show(`成功移除 ${keysToDelete.length} 个Key。`, 'success'); await this.loadApiKeys(groupId, true); // Refresh list } catch (error) { const errorMessage = error && error.message ? error.message : '未知错误'; toastManager.show(`批量移除Key失败: ${errorMessage}`, 'error'); await this.loadApiKeys(groupId, true); // Refresh anyway } } /** * [NEW] Helper for copying all selected API keys to the clipboard. * @param {number[]} keyIds - An array of selected key IDs. */ _batchCopyToClipboard(keyIds) { // Step 1: Find the full API key strings for the selected IDs from the current state. 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('没有找到可复制的Key。列表可能已过期,请刷新。', 'warning'); return; } // Step 2: Join the keys into a single string, separated by newlines. // This format is easy to paste into text editors or other tools. const textToCopy = keysToCopy.join('\n'); // Step 3: Use the Clipboard API to write the text. navigator.clipboard.writeText(textToCopy).then(() => { // Success feedback toastManager.show(`成功复制 ${keysToCopy.length} 个Key到剪贴板。`, 'success'); }).catch(err => { // Error feedback console.error('Failed to copy keys to clipboard:', err); toastManager.show(`复制失败: ${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(); // Add event listeners directly to the Swal input input.addEventListener('input', this._handleSearchInput.bind(this)); input.addEventListener('keydown', (event) => { this._handleSearchEnter.bind(this)(event); // Ensure correct 'this' context 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; // Sync desktop input if the event didn't come from it 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?.(); // Cancel any pending debounced search 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: '验证所有无效Key', icon: 'fa-rocket text-blue-500', requiresConfirm: false }, { action: 'revalidate-all', text: '验证所有Key', icon: 'fa-sync-alt text-blue-500', requiresConfirm: true, confirmText: '此操作将对当前分组下的 **所有Key** 发起一次API请求以验证其有效性,可能会消耗大量额度。确定要继续吗?' }, { action: 'restore-cooldown', text: '恢复所有冷却中Key', icon: 'fa-undo text-green-500', requiresConfirm: false }, { type: 'divider' }, { action: 'cleanup-banned', text: '删除所有失效Key', icon: 'fa-trash-alt', danger: true, requiresConfirm: true, confirmText: '确定要从当前分组中移除 **所有** 状态为 `BANNED` 的Key吗?此操作不可恢复。' } ]; } /** * Renders the content of the quick actions dropdown menu into both desktop and mobile panels. */ _renderQuickActionsMenu() { const actions = this._getQuickActionsConfiguration(); // Fetch config from the helper 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(); // Assume the custom-select component handles closing itself. const action = button.dataset.quickAction; const groupId = this.state.activeGroupId; if (!groupId) { toastManager.show('请先选择一个分组。', 'warning'); return; } // Find the action config to check for confirmation const actionConfig = this._getQuickActionConfig(action); if (actionConfig && actionConfig.requiresConfirm) { const result = await Swal.fire({ backdrop: `rgba(0,0,0,0.5)`, heightAuto: false, target: '#main-content-wrapper', title: '请确认操作', html: actionConfig.confirmText, icon: 'warning', showCancelButton: true, confirmButtonText: '确认执行', cancelButtonText: '取消', 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; // We will build a payload object instead of calling different functions let taskTitle; switch (action) { case 'revalidate-invalid': taskTitle = '验证分组无效Key'; payload = { action: 'revalidate', filter: { // Backend should interpret 'invalid' as this set of statuses status: ['disabled', 'cooldown', 'banned'] } }; break; case 'revalidate-all': taskTitle = '验证分组所有Key'; payload = { action: 'revalidate', filter: { status: ['all'] } }; break; case 'restore-cooldown': taskTitle = '恢复冷却中Key'; payload = { action: 'set_status', new_status: 'active', // The target status filter: { status: ['cooldown'] } }; break; case 'cleanup-banned': taskTitle = '清理失效Key'; payload = { action: 'delete', filter: { status: ['banned'] } }; break; default: toastManager.show(`未知的快速处置操作: ${action}`, 'error'); return; } try { const response = await apiKeyManager.startGroupBulkActionTask(groupId, payload); if (response && response.id) { const startPromise = Promise.resolve(response); const taskDefinition = this._createGroupTaskDefinition(taskTitle, startPromise, groupId, action); taskCenterManager.startTask(taskDefinition); } else { if (response && response.result && response.result.message) { toastManager.show(response.result.message, 'info'); } else { toastManager.show('操作已完成,但无任何项目被更改。', 'info'); } } } catch (error) { handleApiError(error, toastManager, { prefix: `${taskTitle}时发生意外错误: ` }); } } /** * [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}任务完成。`, 'success'); this.loadApiKeys(groupId, true); }, onError: (data) => { const displayMessage = escapeHTML(data.error || '任务执行时发生未知错误'); toastManager.show(`${title}任务失败: ${displayMessage}`, 'error'); }, renderToastNarrative: (data, oldData, toastManager) => { const toastId = `task-${data.id}`; const progress = data.total > 0 ? (data.processed / data.total) * 100 : 0; toastManager.showProgressToast(toastId, title, `处理中 (${data.processed}/${data.total})`, progress); }, renderTaskCenterItem: (data, timestamp, formatTimeAgo) => { const timeAgo = formatTimeAgo(timestamp); let contentHtml = ''; if (data.is_running) { contentHtml = `

${title}

运行中... (${data.processed}/${data.total})

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

${title}任务出错

${safeError}

`; } else { let summary = '任务已完成。'; const result = data.result || {}; const iconClass = 'fas fa-check-circle text-green-500'; switch (action) { case 'cleanup-banned': // For this action, backend returns `unlinked_count` summary = `成功清理 ${result.unlinked_count || 0} 个失效Key。`; break; case 'restore-cooldown': // For this action, backend returns `updated_count` and `skipped_count` if (result.skipped_count > 0) { summary = `完成: ${result.updated_count || 0} 个已恢复, ${result.skipped_count} 个被跳过。`; } else { summary = `成功恢复 ${result.updated_count || 0} 个冷却中Key。`; } break; case 'revalidate-invalid': case 'revalidate-all': // For revalidation, we can show a summary of valid/invalid const results = result.results || []; const validCount = results.filter(r => r.status === 'valid').length; const invalidCount = results.length - validCount; summary = `验证完成: ${validCount}个有效, ${invalidCount}个无效。`; break; default: summary = `处理了 ${data.processed} 个Key。`; } 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 the click is outside any toggle button, close all panels. if (!toggleButton) { this._closeAllDropdowns(); return; } const targetSelector = toggleButton.dataset.target; const targetPanel = document.querySelector(targetSelector); if (!targetPanel) return; const isPanelOpen = !targetPanel.classList.contains('hidden'); // First, close all other panels. this._closeAllDropdowns(targetPanel); // Then, toggle the state of the target panel. 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: '导出所有Key', icon: 'fa-file-export' }, { action: 'export-valid', text: '导出有效Key', icon: 'fa-file-alt' }, { action: 'export-invalid', text: '导出无效Key', 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('请先选择一个分组。', '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('正在准备导出数据...', 'info'); try { const keys = await apiKeyManager.exportKeysForGroup(groupId, statuses); if (keys.length === 0) { toastManager.show('没有找到符合条件的Key可供导出。', 'warning'); return; } this._triggerTextFileDownload(keys.join('\n'), filename); toastManager.show(`成功导出 ${keys.length} 个Key。`, 'success'); } catch (error) { toastManager.show(`导出失败: ${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); } } export default ApiKeyList;