// Filename: frontend/js/pages/keys/apiKeyList.js
import { apiKeyManager } from '../../components/apiKeyManager.js';
import { taskCenterManager, toastManager } from '../../components/taskCenter.js';
import { debounce, escapeHTML } from '../../utils/utils.js';
import { handleApiError } from '../../services/errorHandler.js';
/**
* Manages the UI and state for the API Key list within a selected group.
* This class handles fetching, rendering, and all user interactions with the API key cards.
*/
class ApiKeyList {
/**
* @param {HTMLElement} container - The DOM element that will contain the API key list.
*/
constructor(container) {
if (!container) {
throw new Error("ApiKeyListManager requires a valid container element.");
}
this.elements = {
container: container, // This is the scrollable list container now, e.g., #api-list-container
gridContainer: null, // Will be dynamically created inside container
paginationContainer: document.querySelector('.pagination-controls'), // Find the pagination container
itemsPerPageSelect: document.querySelector('.items-per-page-select'), // Find the dropdown
selectAllCheckbox: document.getElementById('select-all'),
batchActionButton: document.querySelector('.batch-action-btn'), // The trigger button
batchActionPanel: document.querySelector('.batch-action-panel'), // The dropdown panel
statusFilterSelects: document.querySelectorAll('.status-filter-select'),
desktopSearchInput: document.getElementById('desktop-search-input'),
mobileSearchBtn: document.getElementById('mobile-search-btn'),
desktopQuickActionsPanel: document.getElementById('desktop-quick-actions-panel'),
mobileQuickActionsPanel: document.getElementById('mobile-quick-actions-panel'),
desktopMultifunctionPanel: document.getElementById('desktop-multifunction-panel'),
mobileMultifunctionPanel: document.getElementById('mobile-multifunction-panel'),
};
this.state = {
currentKeys: [], // Now holds only the keys for the current page
selectedKeyIds: new Set(),
isApiKeysLoading: false,
activeGroupId: null,
activeGroupName: '',
currentPage: 1,
itemsPerPage: 20, // Default value, will be updated from select
totalItems: 0,
totalPages: 1,
filterStatus: 'all',
searchText: '',
};
this.debouncedSearch = debounce(() => {
this.state.currentPage = 1; // Reset to page 1 for new search
this.loadApiKeys(this.state.activeGroupId, true);
}, 300);
this.boundListeners = {
handleContainerClick: this._handleContainerClick.bind(this),
handlePaginationClick: this.handlePaginationClick.bind(this),
handleItemsPerPageChange: this._handleItemsPerPageChange.bind(this),
handleSelectAllChange: this._handleSelectAllChange.bind(this),
handleStatusFilterChange: this._handleStatusFilterChange.bind(this),
handleBatchActionClick: this._handleBatchActionClick.bind(this),
handleDocumentClickForMenuClose: this._handleDocumentClickForMenuClose.bind(this),
handleSearchInput: this._handleSearchInput.bind(this),
handleSearchEnter: this._handleSearchEnter.bind(this),
showMobileSearchModal: this._showMobileSearchModal.bind(this),
handleGlobalClick: this._handleGlobalClick.bind(this)
};
}
init() {
if (!this.elements.container) return;
this.elements.container.addEventListener('click', this.boundListeners.handleContainerClick);
this.elements.paginationContainer?.addEventListener('click', this.boundListeners.handlePaginationClick);
this.elements.selectAllCheckbox?.addEventListener('change', this.boundListeners.handleSelectAllChange);
this.elements.batchActionPanel?.addEventListener('click', this.boundListeners.handleBatchActionClick);
this.elements.desktopSearchInput?.addEventListener('input', this.boundListeners.handleSearchInput);
this.elements.desktopSearchInput?.addEventListener('keydown', this.boundListeners.handleSearchEnter);
this.elements.mobileSearchBtn?.addEventListener('click', this.boundListeners.showMobileSearchModal);
document.addEventListener('click', this.boundListeners.handleDocumentClickForMenuClose);
document.body.addEventListener('click', this.boundListeners.handleGlobalClick);
const itemsPerPageSelect = this.elements.itemsPerPageSelect?.querySelector('select');
if (itemsPerPageSelect) {
itemsPerPageSelect.addEventListener('change', this.boundListeners.handleItemsPerPageChange);
this.state.itemsPerPage = parseInt(itemsPerPageSelect.value, 10);
}
this.elements.statusFilterSelects.forEach(selectContainer => {
const actualSelect = selectContainer.querySelector('select');
actualSelect?.addEventListener('change', this.boundListeners.handleStatusFilterChange);
});
this._renderMultifunctionMenu();
this._renderQuickActionsMenu();
}
destroy() {
console.log("Destroying ApiKeyList instance and cleaning up listeners.");
this.elements.container.removeEventListener('click', this.boundListeners.handleContainerClick);
this.elements.paginationContainer?.removeEventListener('click', this.boundListeners.handlePaginationClick);
this.elements.selectAllCheckbox?.removeEventListener('change', this.boundListeners.handleSelectAllChange);
this.elements.batchActionPanel?.removeEventListener('click', this.boundListeners.handleBatchActionClick);
this.elements.desktopSearchInput?.removeEventListener('input', this.boundListeners.handleSearchInput);
this.elements.desktopSearchInput?.removeEventListener('keydown', this.boundListeners.handleSearchEnter);
this.elements.mobileSearchBtn?.removeEventListener('click', this.boundListeners.showMobileSearchModal);
document.removeEventListener('click', this.boundListeners.handleDocumentClickForMenuClose);
document.body.removeEventListener('click', this.boundListeners.handleGlobalClick);
const itemsPerPageSelect = this.elements.itemsPerPageSelect?.querySelector('select');
if (itemsPerPageSelect) {
itemsPerPageSelect.removeEventListener('change', this.boundListeners.handleItemsPerPageChange);
}
this.elements.statusFilterSelects.forEach(selectContainer => {
const actualSelect = selectContainer.querySelector('select');
actualSelect?.removeEventListener('change', this.boundListeners.handleStatusFilterChange);
});
this.debouncedSearch.cancel?.();
this.elements.container.innerHTML = '';
}
_handleItemsPerPageChange(e) {
this.state.itemsPerPage = parseInt(e.target.value, 10);
this.state.currentPage = 1;
this.loadApiKeys(this.state.activeGroupId, true);
}
_handleStatusFilterChange(e) {
this.state.filterStatus = e.target.value;
this.state.currentPage = 1;
this.loadApiKeys(this.state.activeGroupId, true);
}
_handleDocumentClickForMenuClose(event) {
if (!event.target.closest('.api-card')) {
this._closeAllActionMenus();
}
}
_handleGlobalClick(event) {
this._handleQuickActionClick(event);
this._handleMultifunctionMenuClick(event);
this._handleDropdownToggle(event);
// 其他可能需要全局监听的点击事件...
}
/**
* Updates the active group context for the manager.
* @param {number} groupId The new active group ID.
*/
setActiveGroup(groupId, groupName) { // Assuming groupName comes from groupList now
this.state.activeGroupId = groupId;
this.state.activeGroupName = groupName || '';
this.state.currentPage = 1; // Reset to page 1 whenever group changes
}
/**
* Fetches and renders API keys for the specified group.
* @param {number} groupId - The ID of the group to load keys for.
* @param {boolean} [force=false] - If true, bypasses the cache and fetches from the server.
*/
async loadApiKeys(groupId, force = false) {
this.state.selectedKeyIds.clear();
if (!groupId) {
this.state.currentKeys = [];
this.render();
return;
}
this.state.isApiKeysLoading = true;
this.render();
try {
const { currentPage, itemsPerPage, filterStatus, searchText } = this.state;
const params = {
page: currentPage,
limit: itemsPerPage,
};
if (filterStatus !== 'all') {
params.status = filterStatus;
}
if (searchText && searchText.trim() !== '') {
params.keyword = searchText.trim();
}
const pageData = await apiKeyManager.getKeysForGroup(groupId, params);
// Update state with new pagination info from the API response
this.state.currentKeys = pageData.items;
this.state.totalItems = pageData.total;
this.state.totalPages = pageData.pages;
this.state.currentPage = pageData.page;
} catch (error) {
toastManager.show(`加载API Keys失败: ${error.message || '未知错误'}`, 'error');
this.state.currentKeys = [];
this.state.totalItems = 0;
this.state.totalPages = 1;
} finally {
this.state.isApiKeysLoading = false;
this.render();
}
}
/**
* Renders the list of API keys based on the current state.
*/
render() {
if (this.state.isApiKeysLoading && this.state.currentKeys.length === 0) {
this.elements.container.innerHTML = '
正在加载 API Keys...
';
if (this.elements.paginationContainer) this.elements.paginationContainer.innerHTML = '';
return;
}
if (!this.state.activeGroupId) {
this.elements.container.innerHTML = '
请先在左侧选择一个分组
';
if (this.elements.paginationContainer) this.elements.paginationContainer.innerHTML = '';
return;
}
if (this.state.currentKeys.length === 0) {
this.elements.container.innerHTML = '
该分组下还没有 API Key。
';
if (this.elements.paginationContainer) this.elements.paginationContainer.innerHTML = '';
return;
}
// Render the list of keys
const listHtml = this.state.currentKeys.map(apiKey => this._createApiKeyCardHtml(apiKey)).join('');
this.elements.container.innerHTML = `
${listHtml}
`;
this.elements.gridContainer = this.elements.container.firstChild; // Reference the grid
// Render the pagination controls
if (this.elements.paginationContainer) {
this.elements.paginationContainer.innerHTML = this._createPaginationHtml();
}
this._updateAllStatusIndicators();
this._syncCardCheckboxes();
this._syncSelectionUI();
}
// [NEW] Handles clicks on pagination buttons
handlePaginationClick(event) {
const button = event.target.closest('button[data-page]');
if (!button || button.disabled) return;
const newPage = parseInt(button.dataset.page, 10);
if (newPage !== this.state.currentPage) {
this.state.currentPage = newPage;
this.loadApiKeys(this.state.activeGroupId, true);
}
}
// [NEW] Generates the HTML for the pagination controls
_createPaginationHtml() {
const { currentPage, totalPages } = this.state;
if (totalPages < 1) return '';
const baseButtonClasses = "pagination-button px-3 py-1 rounded text-sm transition-colors duration-150 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed";
const activeClasses = "bg-zinc-500 text-white font-semibold";
const inactiveClasses = "hover:bg-zinc-200 dark:hover:bg-zinc-700";
let html = '';
const prevDisabled = currentPage <= 1 ? 'disabled' : '';
const nextDisabled = currentPage >= totalPages ? 'disabled' : '';
// Previous button
html += ``;
// Page number buttons
const pagesToShow = this._getPaginationPages(currentPage, totalPages);
pagesToShow.forEach(page => {
if (page === '...') {
html += `...`;
} else {
const pageClasses = page === currentPage ? activeClasses : inactiveClasses;
html += ``;
}
});
html += ``;
return html;
}
// [NEW] Helper to determine which page numbers to show in pagination (e.g., 1 ... 5 6 7 ... 12)
_getPaginationPages(current, total, width = 2) {
if (total <= (width * 2) + 3) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const pages = [1];
if (current > width + 2) pages.push('...');
for (let i = Math.max(2, current - width); i <= Math.min(total - 1, current + width); i++) {
pages.push(i);
}
if (current < total - width - 1) pages.push('...');
pages.push(total);
return pages;
}
/**
* Handles all actions originating from within an API key card.
* @param {Event} event - The click event.
*/
async handleCardAction(event) {
const button = event.target.closest('button[data-action]');
if (!button) return;
const action = button.dataset.action;
const card = button.closest('.api-card');
if (!card) return;
// 专门处理移动端菜单的显示/隐藏逻辑
if (action === 'toggle-menu') {
const menu = card.querySelector('[data-menu="actions"]');
if (menu) {
const isMenuOpen = !menu.classList.contains('hidden');
this._closeAllActionMenus(card); // 关闭其他菜单
if (!isMenuOpen) {
menu.classList.remove('hidden'); // 打开当前菜单
}
}
return;
}
this._closeAllActionMenus();
const keyId = parseInt(card.dataset.keyId, 10);
const groupId = this.state.activeGroupId;
// 如果是移动端菜单里的操作,执行后需要关闭菜单
const menuToClose = card.querySelector('[data-menu="actions"]');
if (menuToClose && !menuToClose.classList.contains('hidden')) {
this._closeAllActionMenus();
}
const apiKeyData = this.state.currentKeys.find(key => key.id === keyId);
if (!apiKeyData) {
toastManager.show('错误: 找不到该Key的数据。可能是列表已过期,请尝试刷新。', 'error');
return;
}
const fullApiKey = apiKeyData.api_key;
switch (action) {
case 'toggle-visibility':
case 'copy-key':
this._handleLocalCardActions(action, button, card, fullApiKey);
break;
case 'set-status': {
const newStatus = button.dataset.newStatus;
if (!newStatus) return;
try {
await apiKeyManager.updateKeyStatusInGroup(groupId, keyId, newStatus);
toastManager.show(`Key 状态已成功更新为 ${newStatus}`, 'success');
} catch (error) {
handleApiError(error, toastManager);
}finally {
await this.loadApiKeys(groupId, true);
}
break;
}
case 'revalidate': {
const revalidateTask = this._createRevalidateTaskDefinition(groupId, fullApiKey);
taskCenterManager.startTask(revalidateTask);
break;
}
case 'delete-key': {
const result = await Swal.fire({
target: '#main-content-wrapper',
width: '20rem',
backdrop: `rgba(0,0,0,0.5)`,
heightAuto: false,
customClass: {
popup: `swal2-custom-style ${document.documentElement.classList.contains('dark') ? 'swal2-dark' : ''}`
},
title: '确认删除',
html: `确定要从 当前分组 中移除这个Key吗?`,
//icon: 'warning',
showCancelButton: true,
confirmButtonText: '确认',
cancelButtonText: '取消',
reverseButtons: true,
confirmButtonColor: '#d33',
cancelButtonColor: '#6b7280',
focusConfirm: false,
focusCancel: false,
});
if (!result.isConfirmed) {
return;
}
try {
const unlinkResult = await apiKeyManager.unlinkKeysFromGroup(groupId, [fullApiKey]);
if (!unlinkResult.success) {
throw new Error(unlinkResult.message || '后端未能移除Key。');
}
toastManager.show(`成功移除 1 个Key。`, 'success');
await this.loadApiKeys(groupId, true);
} catch (error) {
const errorMessage = error && error.message ? error.message : ERROR_MESSAGES['DEFAULT'];
toastManager.show(`移除Key失败: ${errorMessage}`, 'error');
await this.loadApiKeys(groupId, true);
}
break;
}
}
}
// --- Private Helper Methods (copied from original file) ---
_createRevalidateTaskDefinition(groupId, fullApiKey) {
return {
start: () => apiKeyManager.revalidateKeys(groupId, [fullApiKey]),
poll: (taskId) => apiKeyManager.getTaskStatus(taskId, { noCache: true }),
onSuccess: (data) => {
toastManager.show(`Key验证完成`, 'success');
this.loadApiKeys(groupId, true);
},
onError: (data) => {
toastManager.show(`验证任务失败: ${data.error || '未知错误'}`, 'error');
},
renderToastNarrative: (data, oldData, toastManager) => {
const toastId = `task-${data.id}`;
const progress = data.total > 0 ? (data.processed / data.total) * 100 : 0;
toastManager.showProgressToast(toastId, `正在验证Key`, '处理中', progress);
},
renderTaskCenterItem: (data, timestamp, formatTimeAgo) => {
const timeAgo = formatTimeAgo(timestamp);
const maskedKey = escapeHTML(`${fullApiKey.substring(0, 4)}...${fullApiKey.substring(fullApiKey.length - 4)}`);
let contentHtml = '';
const isDone = !data.is_running;
if (isDone) {
// --- Task is complete ---
if (data.error) {
// --- Case 1: The task itself failed to run ---
const safeError = escapeHTML(data.error);
contentHtml = `
验证任务出错: ${maskedKey}
${safeError}
`;
} else {
// --- Case 2: The task ran, and we have results ---
const result = data.result?.results?.[0]; // Get the first (and only) result
const isSuccess = result?.status === 'valid';
const iconClass = isSuccess ? 'text-green-500 fas fa-check-circle' : 'text-red-500 fas fa-times-circle';
const title = isSuccess ? '验证成功' : '验证失败';
// Safely get the message, providing a default if it's missing.
const safeMessage = escapeHTML(result?.message || '没有详细信息。');
contentHtml = `
${title}: ${maskedKey}
${safeMessage}
`;
}
} else {
// --- Case 3: Task is still running ---
contentHtml = `
正在验证: ${maskedKey}
运行中... (${data.processed}/${data.total})
`;
}
return `${contentHtml}
${timeAgo}
`;
},
};
}
/**
* Creates the HTML for a single API key card, adapting to the flat APIKeyDetails structure.
* @param {object} item - The APIKeyDetails object from the API.
* @returns {string} The HTML string for the card.
*/
_createApiKeyCardHtml(item) {
if (!item || !item.api_key) return ''; // 安全检查
// --- 数据准备 ---
const maskedKey = escapeHTML(`${item.api_key.substring(0, 4)}......${item.api_key.substring(item.api_key.length - 4)}`);
const status = escapeHTML(item.status);
const errorCount = escapeHTML(item.consecutive_error_count);
const keyId = escapeHTML(item.id);
const mappingId = escapeHTML(`${item.api_key_id}-${item.key_group_id}`);
// --- 行为挂钩 ---
const setActiveAction = `data-action="set-status" data-new-status="ACTIVE"`;
const revalidateAction = `data-action="revalidate"`;
const disableAction = `data-action="set-status" data-new-status="DISABLED"`;
const deleteAction = `data-action="delete-key"`;
// --- 模板渲染 ---
return `