Files
gemini-banlancer/frontend/js/pages/keys/apiKeyList.js
2025-11-20 12:24:05 +08:00

1435 lines
70 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 = '<div class="flex items-center justify-center h-full text-zinc-500"><p>正在加载 API Keys...</p></div>';
if (this.elements.paginationContainer) this.elements.paginationContainer.innerHTML = '';
return;
}
if (!this.state.activeGroupId) {
this.elements.container.innerHTML = '<div class="flex items-center justify-center h-full text-zinc-500"><p>请先在左侧选择一个分组</p></div>';
if (this.elements.paginationContainer) this.elements.paginationContainer.innerHTML = '';
return;
}
if (this.state.currentKeys.length === 0) {
this.elements.container.innerHTML = '<div class="flex items-center justify-center h-full text-zinc-500"><p>该分组下还没有 API Key。</p></div>';
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 = `<div class="grid grid-cols-1 md:grid-cols-2 gap-3">${listHtml}</div>`;
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 += `<button class="${baseButtonClasses} ${inactiveClasses}" data-page="${currentPage - 1}" ${prevDisabled}>
<i class="fas fa-chevron-left"></i>
</button>`;
// Page number buttons
const pagesToShow = this._getPaginationPages(currentPage, totalPages);
pagesToShow.forEach(page => {
if (page === '...') {
html += `<span class="px-3 py-1 text-zinc-400 dark:text-zinc-500 text-sm">...</span>`;
} else {
const pageClasses = page === currentPage ? activeClasses : inactiveClasses;
html += `<button class="${baseButtonClasses} ${pageClasses}" data-page="${page}">${page}</button>`;
}
});
html += `<button class="${baseButtonClasses} ${inactiveClasses}" data-page="${currentPage + 1}" ${nextDisabled}>
<i class="fas fa-chevron-right"></i>
</button>`;
return html;
}
// [NEW] Helper to determine which page numbers to show in pagination (e.g., 1 ... 5 6 7 ... 12)
_getPaginationPages(current, total, width = 2) {
if (total <= (width * 2) + 3) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const pages = [1];
if (current > width + 2) pages.push('...');
for (let i = Math.max(2, current - width); i <= Math.min(total - 1, current + width); i++) {
pages.push(i);
}
if (current < total - width - 1) pages.push('...');
pages.push(total);
return pages;
}
/**
* Handles all actions originating from within an API key card.
* @param {Event} event - The click event.
*/
async handleCardAction(event) {
const button = event.target.closest('button[data-action]');
if (!button) return;
const action = button.dataset.action;
const card = button.closest('.api-card');
if (!card) return;
// 专门处理移动端菜单的显示/隐藏逻辑
if (action === 'toggle-menu') {
const menu = card.querySelector('[data-menu="actions"]');
if (menu) {
const isMenuOpen = !menu.classList.contains('hidden');
this._closeAllActionMenus(card); // 关闭其他菜单
if (!isMenuOpen) {
menu.classList.remove('hidden'); // 打开当前菜单
}
}
return;
}
this._closeAllActionMenus();
const keyId = parseInt(card.dataset.keyId, 10);
const groupId = this.state.activeGroupId;
// 如果是移动端菜单里的操作,执行后需要关闭菜单
const menuToClose = card.querySelector('[data-menu="actions"]');
if (menuToClose && !menuToClose.classList.contains('hidden')) {
this._closeAllActionMenus();
}
const apiKeyData = this.state.currentKeys.find(key => key.id === keyId);
if (!apiKeyData) {
toastManager.show('错误: 找不到该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: `确定要从 <b>当前分组</b> 中移除这个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 = `
<div class="task-item-main">
<div class="task-item-icon-summary text-red-500"><i class="fas fa-exclamation-triangle"></i></div>
<div class="task-item-content flex-grow">
<p class="task-item-title">验证任务出错: ${maskedKey}</p>
<p class="task-item-status text-red-500 truncate" title="${safeError}">${safeError}</p>
</div>
</div>`;
} 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 = `
<div class="task-item-main">
<div class="task-item-icon-summary"><i class="${iconClass}"></i></div>
<div class="task-item-content flex-grow">
<p class="task-item-title">${title}: ${maskedKey}</p>
<p class="task-item-status truncate" title="${safeMessage}">${safeMessage}</p>
</div>
</div>
`;
}
} else {
// --- Case 3: Task is still running ---
contentHtml = `
<div class="task-item-main gap-3">
<div class="task-item-icon task-item-icon-running"><i class="fas fa-spinner animate-spin"></i></div>
<div class="task-item-content flex-grow">
<p class="task-item-title">正在验证: ${maskedKey}</p>
<p class="task-item-status">运行中... (${data.processed}/${data.total})</p>
</div>
</div>`;
}
return `${contentHtml}<div class="task-item-timestamp">${timeAgo}</div>`;
},
};
}
/**
* Creates the HTML for a single API key card, adapting to the flat APIKeyDetails structure.
* @param {object} item - The APIKeyDetails object from the API.
* @returns {string} The HTML string for the card.
*/
_createApiKeyCardHtml(item) {
if (!item || !item.api_key) return ''; // 安全检查
// --- 数据准备 ---
const maskedKey = escapeHTML(`${item.api_key.substring(0, 4)}......${item.api_key.substring(item.api_key.length - 4)}`);
const status = escapeHTML(item.status);
const errorCount = escapeHTML(item.consecutive_error_count);
const keyId = escapeHTML(item.id);
const mappingId = escapeHTML(`${item.api_key_id}-${item.key_group_id}`);
// --- 行为挂钩 ---
const setActiveAction = `data-action="set-status" data-new-status="ACTIVE"`;
const revalidateAction = `data-action="revalidate"`;
const disableAction = `data-action="set-status" data-new-status="DISABLED"`;
const deleteAction = `data-action="delete-key"`;
// --- 模板渲染 ---
return `
<div class="api-card group relative flex items-center gap-x-3 rounded-lg p-3 bg-white dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700/60"
data-status="${status}"
data-key-id="${keyId}"
data-mapping-id="${mappingId}">
<input type="checkbox" class="api-key-checkbox h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500 shrink-0">
<span data-status-indicator class="w-2 h-2 rounded-full shrink-0"></span>
<div class="flex-grow min-w-0">
<p class="font-mono text-xs font-semibold truncate">${maskedKey}</p>
<p class="text-xs text-zinc-400 mt-1">失败: ${errorCount} 次</p>
</div>
<!-- [DESKTOP ONLY] 快速操作按钮 - 在移动端(<lg)隐藏 -->
<div class="hidden lg:flex items-center gap-x-2 text-zinc-400 text-xs z-10">
<button class="hover:text-blue-500" data-action="toggle-visibility" title="查看完整Key"><i class="fas fa-eye"></i></button>
<button class="hover:text-blue-500" data-action="copy-key" title="复制Key"><i class="fas fa-copy"></i></button>
</div>
<!-- [DESKTOP ONLY] Hover Menu - 在移动端(<lg)隐藏 -->
<div class="hidden lg:flex absolute right-14 top-1/2 -translate-y-1/2 items-center bg-zinc-200 dark:bg-zinc-700 rounded-full shadow-md opacity-0 lg:group-hover:opacity-100 transition-opacity duration-200 z-20">
<button class="px-2 py-1 hover:text-green-500" ${setActiveAction} title="设为可用"><i class="fas fa-check-circle"></i></button>
<button class="px-2 py-1 hover:text-blue-500" ${revalidateAction} title="重新验证"><i class="fas fa-sync-alt"></i></button>
<button class="px-2 py-1 hover:text-yellow-500" ${disableAction} title="禁用"><i class="fas fa-ban"></i></button>
<button class="px-2 py-1 hover:text-red-500" ${deleteAction} title="从分组中移除"><i class="fas fa-trash-alt"></i></button>
</div>
<!-- [MOBILE ONLY] Kebab Menu and Dropdown - 在桌面端(>=lg)隐藏 -->
<div class="relative lg:hidden">
<button data-action="toggle-menu" class="flex items-center justify-center h-8 w-8 rounded-full hover:bg-zinc-100 dark:hover:bg-zinc-700 text-zinc-500" title="更多操作">
<i class="fas fa-ellipsis-v"></i>
</button>
<div data-menu="actions" class="absolute right-0 mt-2 w-48 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-md shadow-lg z-30 hidden">
<!-- [NEW] "查看"和"复制"操作已移入移动端菜单 -->
<button class="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-x-3" data-action="toggle-visibility"><i class="fas fa-eye w-4"></i> 查看/隐藏 Key</button>
<button class="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-x-3" data-action="copy-key"><i class="fas fa-copy w-4"></i> 复制 Key</button>
<div class="border-t border-zinc-200 dark:border-zinc-700 my-1"></div>
<button class="w-full text-left px-4 py-2 text-sm text-green-600 hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-x-3" ${setActiveAction}><i class="fas fa-check-circle w-4"></i> 设为可用</button>
<button class="w-full text-left px-4 py-2 text-sm text-blue-600 hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-x-3" ${revalidateAction}><i class="fas fa-sync-alt w-4"></i> 重新验证</button>
<button class="w-full text-left px-4 py-2 text-sm text-yellow-600 hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-x-3" ${disableAction}><i class="fas fa-ban w-4"></i> 禁用</button>
<div class="border-t border-zinc-200 dark:border-zinc-700 my-1"></div>
<button class="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-x-3" ${deleteAction}><i class="fas fa-trash-alt w-4"></i> 从分组中移除</button>
</div>
</div>
</div>
`;
}
_handleLocalCardActions(action, button, card, fullApiKey) {
switch (action) {
case 'toggle-visibility': {
const safeApiKey = escapeHTML(fullApiKey);
Swal.fire({
target: '#main-content-wrapper',
width: '24rem', // 适配移动端宽度
backdrop: `rgba(0,0,0,0.5)`,
heightAuto: false,
customClass: {
popup: `swal2-custom-style rounded-xl ${document.documentElement.classList.contains('dark') ? 'swal2-dark' : ''}`,
htmlContainer: 'm-0 text-left', // 移除默认边距
},
showConfirmButton: false,
showCloseButton: false,
html: `
<div class="mt-2 flex items-center justify-between rounded-md bg-zinc-100 dark:bg-zinc-700 p-3 font-mono text-sm text-zinc-800 dark:text-zinc-200">
<code class="break-all">${safeApiKey}</code>
<button id="swal-copy-key-btn" class="ml-4 p-2 rounded-md hover:bg-zinc-200 dark:hover:bg-zinc-600 text-zinc-500 dark:text-zinc-300" title="复制">
<i class="far fa-copy"></i>
</button>
</div>
`,
didOpen: (modal) => {
const copyBtn = modal.querySelector('#swal-copy-key-btn');
if (copyBtn) {
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(fullApiKey).then(() => {
toastManager.show('API Key 已复制到剪贴板。', 'success');
copyBtn.innerHTML = '<i class="fas fa-check text-green-500"></i>';
setTimeout(() => {
copyBtn.innerHTML = '<i class="far fa-copy"></i>';
}, 1500);
}).catch(err => {
toastManager.show(`复制失败: ${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 = `
<div class="task-item-main gap-3">
<div class="task-item-icon task-item-icon-running"><i class="fas fa-spinner animate-spin"></i></div>
<div class="task-item-content flex-grow">
<p class="task-item-title">批量验证 ${data.total} 个Key</p>
<p class="task-item-status">运行中... (${data.processed}/${data.total})</p>
</div>
</div>`;
}
// --- State 2: Task is COMPLETE ---
else {
// Case 2a: The entire task failed before processing keys
if (data.error) {
contentHtml = `
<div class="task-item-main">
<div class="task-item-icon-summary text-red-500"><i class="fas fa-exclamation-triangle"></i></div>
<div class="task-item-content flex-grow">
<p class="task-item-title">批量验证任务出错</p>
<p class="task-item-status text-red-500 truncate" title="${data.error}">${data.error}</p>
</div>
</div>`;
}
// 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 `
<div class="flex items-start text-xs">
<i class="fas fa-check-circle text-green-500 mt-0.5 mr-2"></i>
<div class="flex-grow">
<p class="font-mono">${maskedKey}</p>
<p class="text-zinc-400">${safeMessage}</p>
</div>
</div>`;
} else {
return `
<div class="flex items-start text-xs">
<i class="fas fa-times-circle text-red-500 mt-0.5 mr-2"></i>
<div class="flex-grow">
<p class="font-mono">${maskedKey}</p>
<p class="text-zinc-400">${safeMessage}</p>
</div>
</div>`;
}
}).join('');
contentHtml = `
<div class="task-item-main">
<div class="task-item-icon-summary"><i class="${overallIconClass}"></i></div>
<div class="task-item-content flex-grow">
<div class="flex justify-between items-center cursor-pointer" data-task-toggle>
<p class="task-item-title">${summaryTitle}</p>
<i class="fas fa-chevron-down task-toggle-icon"></i>
</div>
<div class="task-details-content collapsed" data-task-content>
<div class="task-details-body space-y-2 mt-2">
${detailsHtml}
</div>
</div>
</div>
</div>`;
}
}
return `${contentHtml}<div class="task-item-timestamp">${timeAgo}</div>`;
},
};
taskCenterManager.startTask(revalidateTask);
}
/**
* [NEW] Helper for batch deleting (unlinking) selected keys from the group.
* @param {number[]} keyIds - An array of selected key IDs.
*/
async _batchDelete(keyIds) {
const groupId = this.state.activeGroupId;
const selectedCount = keyIds.length;
const result = await Swal.fire({
target: '#main-content-wrapper',
width: '20rem',
backdrop: `rgba(0,0,0,0.5)`,
heightAuto: false,
customClass: {
popup: `swal2-custom-style ${document.documentElement.classList.contains('dark') ? 'swal2-dark' : ''}`
},
title: '确认批量移除',
html: `确定要从 <b>当前分组</b> 中移除选中的 <b>${selectedCount}</b> 个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: `
<div class="flex items-center justify-between rounded-md bg-zinc-100 dark:bg-zinc-700 p-3 mt-2 font-mono text-sm text-zinc-800 dark:text-zinc-200">
<input type="text" id="swal-search-input" placeholder="搜索 API Key..." class="w-full focus:outline-none focus:ring-0 rounded-lg dark:placeholder-zinc-400"
autocomplete="off">
</div>
`,
didOpen: (modal) => {
const input = modal.querySelector('#swal-search-input');
if (!input) return;
input.value = this.state.searchText;
input.focus();
// 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<Object>} 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 '<div class="menu-divider"></div>';
}
return `
<button data-quick-action="${item.action}" class="menu-item ${item.danger ? 'menu-item-danger' : ''} whitespace-nowrap">
<i class="fas ${item.icon} menu-item-icon"></i>
<span>${item.text}</span>
</button>
`;
}).join('');
const menuWrapper = `<div class="py-1">${menuHtml}</div>`;
if (this.elements.desktopQuickActionsPanel) {
this.elements.desktopQuickActionsPanel.innerHTML = menuWrapper;
}
if (this.elements.mobileQuickActionsPanel) {
this.elements.mobileQuickActionsPanel.innerHTML = menuWrapper;
}
}
/**
* [NEW] Handles clicks on any quick action button.
* @param {Event} event The click event.
*/
async _handleQuickActionClick(event) {
const button = event.target.closest('button[data-quick-action]');
if (!button) return;
event.preventDefault();
// 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({
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 = `
<div class="task-item-main gap-3">
<div class="task-item-icon task-item-icon-running"><i class="fas fa-spinner animate-spin"></i></div>
<div class="task-item-content flex-grow">
<p class="task-item-title">${title}</p>
<p class="task-item-status">运行中... (${data.processed}/${data.total})</p>
</div>
</div>`;
} else if (data.error) {
const safeError = escapeHTML(data.error);
contentHtml = `
<div class="task-item-main">
<div class="task-item-icon-summary text-red-500"><i class="fas fa-exclamation-triangle"></i></div>
<div class="task-item-content flex-grow">
<p class="task-item-title">${title}任务出错</p>
<p class="task-item-status text-red-500 truncate" title="${safeError}">${safeError}</p>
</div>
</div>`;
} 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 = `
<div class="task-item-main">
<div class="task-item-icon-summary"><i class="${iconClass}"></i></div>
<div class="task-item-content flex-grow">
<p class="task-item-title">${title}</p>
<p class="task-item-status truncate" title="${safeSummary}">${safeSummary}</p>
</div>
</div>`;
}
return `${contentHtml}<div class="task-item-timestamp">${timeAgo}</div>`;
}
};
}
/**
* [NEW] A generic handler to open/close any custom dropdown menu.
* It uses data attributes to link triggers to panels.
* @param {Event} event The click event.
*/
_handleDropdownToggle(event) {
const toggleButton = event.target.closest('[data-toggle="custom-select"]');
// If 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 `
<button data-multifunction-action="${item.action}" class="menu-item whitespace-nowrap">
<i class="fas ${item.icon} menu-item-icon"></i>
<span>${item.text}</span>
</button>
`;
}).join('');
const menuWrapper = `<div class="py-1">${menuHtml}</div>`;
if (this.elements.desktopMultifunctionPanel) {
this.elements.desktopMultifunctionPanel.innerHTML = menuWrapper;
}
if (this.elements.mobileMultifunctionPanel) {
this.elements.mobileMultifunctionPanel.innerHTML = menuWrapper;
}
}
/**
* [NEW] Handles clicks on any multifunction menu button.
* @param {Event} event The click event.
*/
async _handleMultifunctionMenuClick(event) {
const button = event.target.closest('button[data-multifunction-action]');
if (!button) return;
event.preventDefault();
this._closeAllDropdowns();
const action = button.dataset.multifunctionAction;
const groupId = this.state.activeGroupId;
if (!groupId) {
toastManager.show('请先选择一个分组。', '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;