1435 lines
70 KiB
JavaScript
1435 lines
70 KiB
JavaScript
// 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;
|