Files
gemini-balance/app/static/js/keys_status.js
snaily 8ec1d16e9d refactor: 优化 JS 结构、API 调用和密钥管理
此次提交引入了重要的重构和改进:

- JavaScript ([`app/static/js/config_editor.js`](app/static/js/config_editor.js:1), [`app/static/js/keys_status.js`](app/static/js/keys_status.js:1), [`app/static/js/error_logs.js`](app/static/js/error_logs.js:1)):
  - 通过初始化函数(例如 [`initializeKeyPaginationAndSearch()`](app/static/js/config_editor.js:985),[`initializeAutoRefreshControls()`](app/static/js/config_editor.js:936))实现代码模块化,以实现更好的组织。
  - 通过采用 `fetchAPI` 辅助函数(在 [`showApiCallDetails()`](app/static/js/config_editor.js:1097),[`fetchAndDisplayLogs()`](app/static/js/error_logs.js:68),[`fetchKeyStatus()`](app/static/js/keys_status.js:283) 中可见其用法)标准化 API 交互。
  - 改进了分页、搜索和 DOM 元素管理,尤其是在 [`config_editor.js`](app/static/js/config_editor.js:1) 和 [`keys_status.js`](app/static/js/keys_status.js:1) 中。
  - 在 [`config_editor.js`](app/static/js/config_editor.js:1029) 中通过 [`registerServiceWorker()`](app/static/js/config_editor.js:1018) 添加了 service worker 注册。

- Gemini API ([`app/router/gemini_routes.py`](app/router/gemini_routes.py:1)):
  - 在 [`verify_selected_keys()`](app/router/gemini_routes.py:328) 端点内的 `GeminiRequest` 中添加了 `generation_config`(包含 `temperature`、`top_p`、`max_output_tokens`),以实现更可控和一致的 API 密钥验证调用。

- 配置用户界面 ([`app/templates/config_editor.html`](app/templates/config_editor.html:1)):
  - 将 `sensitive-input` 类应用于各种 API 密钥和令牌字段(例如 [`AUTH_TOKEN`](app/templates/config_editor.html:149),[`PAID_KEY`](app/templates/config_editor.html:339),[`SMMS_SECRET_TOKEN`](app/templates/config_editor.html:364)),以启用特定的客户端处理(例如屏蔽或特殊验证)。

这些更改旨在提高代码的可维护性,标准化前端后端通信,增强 API 交互的稳健性,并优化用于应用程序配置和 API 密钥状态管理的用户界面。
2025-05-07 13:58:05 +08:00

1450 lines
63 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.
// 统计数据可视化交互效果
function copyToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
} else {
return new Promise((resolve, reject) => {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (successful) {
resolve();
} else {
reject(new Error('复制失败'));
}
} catch (err) {
document.body.removeChild(textArea);
reject(err);
}
});
}
}
// API 调用辅助函数 (与 error_logs.js 中的版本类似)
async function fetchAPI(url, options = {}) {
try {
const response = await fetch(url, options);
if (response.status === 204) {
return null; // Indicate success with no content for DELETE etc.
}
let responseData;
try {
// Clone the response to allow reading it multiple times if needed (e.g., for text fallback)
const clonedResponse = response.clone();
responseData = await response.json();
} catch (e) {
// If JSON parsing fails, try to get text, especially if response wasn't ok
if (!response.ok) {
const textResponse = await response.text(); // Use original response for text
throw new Error(textResponse || `HTTP error! status: ${response.status} - ${response.statusText}`);
}
// If response is ok but not JSON, maybe return raw text or handle differently
console.warn("Response was not JSON for URL:", url);
// Consider returning text or null based on expected non-JSON success cases
return await response.text(); // Example: return text for non-JSON success
}
if (!response.ok) {
// Prefer error message from API response body (already parsed as JSON)
const message = responseData?.detail || responseData?.message || responseData?.error || `HTTP error! status: ${response.status}`;
throw new Error(message);
}
return responseData; // Return parsed JSON data
} catch (error) {
console.error('API Call Failed:', error.message, 'URL:', url, 'Options:', options);
// Re-throw the error so the calling function knows the operation failed
// Add more context if possible
throw new Error(`API请求失败: ${error.message}`);
}
}
// 添加统计项动画效果
function initStatItemAnimations() {
const statItems = document.querySelectorAll('.stat-item');
statItems.forEach(item => {
item.addEventListener('mouseenter', () => {
item.style.transform = 'scale(1.05)';
const icon = item.querySelector('.stat-icon');
if (icon) {
icon.style.opacity = '0.2';
icon.style.transform = 'scale(1.1) rotate(0deg)';
}
});
item.addEventListener('mouseleave', () => {
item.style.transform = '';
const icon = item.querySelector('.stat-icon');
if (icon) {
icon.style.opacity = '';
icon.style.transform = '';
}
});
});
}
// 获取指定类型区域内选中的密钥
function getSelectedKeys(type) {
const checkboxes = document.querySelectorAll(`#${type}Keys .key-checkbox:checked`);
return Array.from(checkboxes).map(cb => cb.value);
}
// 更新指定类型区域的批量操作按钮状态和计数
function updateBatchActions(type) {
const selectedKeys = getSelectedKeys(type);
const count = selectedKeys.length;
const batchActionsDiv = document.getElementById(`${type}BatchActions`);
const selectedCountSpan = document.getElementById(`${type}SelectedCount`);
const buttons = batchActionsDiv.querySelectorAll('button');
if (count > 0) {
batchActionsDiv.classList.remove('hidden');
selectedCountSpan.textContent = count;
buttons.forEach(button => button.disabled = false);
} else {
batchActionsDiv.classList.add('hidden');
selectedCountSpan.textContent = '0';
buttons.forEach(button => button.disabled = true);
}
// 更新全选复选框状态
const selectAllCheckbox = document.getElementById(`selectAll${type.charAt(0).toUpperCase() + type.slice(1)}`);
const allCheckboxes = document.querySelectorAll(`#${type}Keys .key-checkbox`);
// 只有在有可见的 key 时才考虑全选状态
const visibleCheckboxes = document.querySelectorAll(`#${type}Keys li:not([style*="display: none"]) .key-checkbox`);
if (selectAllCheckbox && visibleCheckboxes.length > 0) {
selectAllCheckbox.checked = count === visibleCheckboxes.length;
selectAllCheckbox.indeterminate = count > 0 && count < visibleCheckboxes.length;
} else if (selectAllCheckbox) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
}
}
// 全选/取消全选指定类型的密钥
function toggleSelectAll(type, isChecked) {
const checkboxes = document.querySelectorAll(`#${type}Keys li:not([style*="display: none"]) .key-checkbox`); // 只选择可见的
checkboxes.forEach(checkbox => {
checkbox.checked = isChecked;
});
updateBatchActions(type);
}
// 复制选中的密钥
function copySelectedKeys(type) {
const selectedKeys = getSelectedKeys(type);
if (selectedKeys.length === 0) {
showNotification('没有选中的密钥可复制', 'warning');
return;
}
const keysText = selectedKeys.join('\n');
copyToClipboard(keysText)
.then(() => {
showNotification(`已成功复制 ${selectedKeys.length} 个选中的${type === 'valid' ? '有效' : '无效'}密钥`);
})
.catch((err) => {
console.error('无法复制文本: ', err);
showNotification('复制失败,请重试', 'error');
});
}
// 单个复制保持不变
function copyKey(key) {
copyToClipboard(key)
.then(() => {
showNotification(`已成功复制密钥`);
})
.catch((err) => {
console.error('无法复制文本: ', err);
showNotification('复制失败,请重试', 'error');
});
}
// showCopyStatus 函数已废弃。
async function verifyKey(key, button) {
try {
// 禁用按钮并显示加载状态
button.disabled = true;
const originalHtml = button.innerHTML;
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 验证中';
try {
const data = await fetchAPI(`/gemini/v1beta/verify-key/${key}`, { method: 'POST' });
// 根据验证结果更新UI并显示模态提示框
if (data && (data.success || data.status === 'valid')) {
// 验证成功,显示成功结果
button.style.backgroundColor = '#27ae60';
// 使用结果模态框显示成功消息
showResultModal(true, '密钥验证成功');
// 模态框关闭时会自动刷新页面
} else {
// 验证失败,显示失败结果
const errorMsg = data.error || '密钥无效';
button.style.backgroundColor = '#e74c3c';
// 使用结果模态框显示失败消息改为true以在关闭时刷新
showResultModal(false, '密钥验证失败: ' + errorMsg, true);
}
} catch (apiError) {
console.error('密钥验证 API 请求失败:', apiError);
showResultModal(false, `验证请求失败: ${apiError.message}`, true);
} finally {
// 1秒后恢复按钮原始状态 (如果页面不刷新)
// 由于现在成功和失败都会刷新,这部分逻辑可以简化或移除
// 但为了防止未来修改刷新逻辑,暂时保留,但可能不会执行
setTimeout(() => {
if (!document.getElementById('resultModal') || document.getElementById('resultModal').classList.contains('hidden')) {
button.innerHTML = originalHtml;
button.disabled = false;
button.style.backgroundColor = '';
}
}, 1000);
}
} catch (error) {
console.error('验证失败:', error);
// 确保在捕获到错误时恢复按钮状态 (如果页面不刷新)
// button.disabled = false; // 由 finally 处理或因刷新而无需处理
// button.innerHTML = '<i class="fas fa-check-circle"></i> 验证';
showResultModal(false, '验证处理失败: ' + error.message, true); // 改为true以在关闭时刷新
}
}
async function resetKeyFailCount(key, button) {
try {
// 禁用按钮并显示加载状态
button.disabled = true;
const originalHtml = button.innerHTML;
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 重置中';
const data = await fetchAPI(`/gemini/v1beta/reset-fail-count/${key}`, { method: 'POST' });
// 根据重置结果更新UI
if (data.success) {
showNotification('失败计数重置成功');
// 成功时保留绿色背景一会儿
button.style.backgroundColor = '#27ae60';
// 稍后刷新页面
setTimeout(() => location.reload(), 1000);
} else {
const errorMsg = data.message || '重置失败';
showNotification('重置失败: ' + errorMsg, 'error');
// 失败时保留红色背景一会儿
button.style.backgroundColor = '#e74c3c';
// 如果失败1秒后恢复按钮
setTimeout(() => {
button.innerHTML = originalHtml;
button.disabled = false;
button.style.backgroundColor = '';
}, 1000);
}
// 恢复按钮状态逻辑已移至成功/失败分支内
} catch (apiError) {
console.error('重置失败:', apiError);
showNotification(`重置请求失败: ${apiError.message}`, 'error');
// 确保在捕获到错误时恢复按钮状态
button.disabled = false;
button.innerHTML = '<i class="fas fa-redo-alt"></i> 重置'; // 恢复原始图标和文本
button.style.backgroundColor = ''; // 清除可能设置的背景色
}
}
// 显示重置确认模态框 (基于选中的密钥)
function showResetModal(type) {
const modalElement = document.getElementById('resetModal');
const titleElement = document.getElementById('resetModalTitle');
const messageElement = document.getElementById('resetModalMessage');
const confirmButton = document.getElementById('confirmResetBtn');
const selectedKeys = getSelectedKeys(type);
const count = selectedKeys.length;
// 设置标题和消息
titleElement.textContent = '批量重置失败次数';
if (count > 0) {
messageElement.textContent = `确定要批量重置选中的 ${count}${type === 'valid' ? '有效' : '无效'}密钥的失败次数吗?`;
confirmButton.disabled = false; // 确保按钮可用
} else {
// 这个情况理论上不会发生,因为按钮在未选中时是禁用的
messageElement.textContent = `请先选择要重置的${type === 'valid' ? '有效' : '无效'}密钥。`;
confirmButton.disabled = true;
}
// 设置确认按钮事件
confirmButton.onclick = () => executeResetAll(type);
// 显示模态框
modalElement.classList.remove('hidden');
}
function closeResetModal() {
document.getElementById('resetModal').classList.add('hidden');
}
// 触发显示模态框
function resetAllKeysFailCount(type, event) {
// 阻止事件冒泡
if (event) {
event.stopPropagation();
}
// 显示模态确认框
showResetModal(type);
}
// 关闭模态框并根据参数决定是否刷新页面
function closeResultModal(reload = true) {
document.getElementById('resultModal').classList.add('hidden');
if (reload) {
location.reload(); // 操作完成后刷新页面
}
}
// 显示操作结果模态框 (通用版本)
function showResultModal(success, message, autoReload = true) {
const modalElement = document.getElementById('resultModal');
const titleElement = document.getElementById('resultModalTitle');
const messageElement = document.getElementById('resultModalMessage');
const iconElement = document.getElementById('resultIcon');
const confirmButton = document.getElementById('resultModalConfirmBtn');
// 设置标题
titleElement.textContent = success ? '操作成功' : '操作失败';
// 设置图标
if (success) {
iconElement.innerHTML = '<i class="fas fa-check-circle text-success-500"></i>';
iconElement.className = 'text-6xl mb-3 text-success-500'; // 稍微增大图标
} else {
iconElement.innerHTML = '<i class="fas fa-times-circle text-danger-500"></i>';
iconElement.className = 'text-6xl mb-3 text-danger-500'; // 稍微增大图标
}
// 清空现有内容并设置新消息
messageElement.innerHTML = ''; // 清空
if (typeof message === 'string') {
// 对于普通字符串消息,保持原有逻辑
const messageDiv = document.createElement('div');
messageDiv.innerText = message; // 使用 innerText 防止 XSS
messageElement.appendChild(messageDiv);
} else if (message instanceof Node) {
// 如果传入的是 DOM 节点,直接添加
messageElement.appendChild(message);
} else {
// 其他类型转为字符串
const messageDiv = document.createElement('div');
messageDiv.innerText = String(message);
messageElement.appendChild(messageDiv);
}
// 设置确认按钮点击事件
confirmButton.onclick = () => closeResultModal(autoReload);
// 显示模态框
modalElement.classList.remove('hidden');
}
// 显示批量验证结果的专用模态框
function showVerificationResultModal(data) {
const modalElement = document.getElementById('resultModal');
const titleElement = document.getElementById('resultModalTitle');
const messageElement = document.getElementById('resultModalMessage');
const iconElement = document.getElementById('resultIcon');
const confirmButton = document.getElementById('resultModalConfirmBtn');
const successfulKeys = data.successful_keys || [];
const failedKeys = data.failed_keys || {};
const validCount = data.valid_count || 0;
const invalidCount = data.invalid_count || 0;
// 设置标题和图标
titleElement.textContent = '批量验证结果';
if (invalidCount === 0 && validCount > 0) {
iconElement.innerHTML = '<i class="fas fa-check-double text-success-500"></i>';
iconElement.className = 'text-6xl mb-3 text-success-500';
} else if (invalidCount > 0 && validCount > 0) {
iconElement.innerHTML = '<i class="fas fa-exclamation-triangle text-warning-500"></i>';
iconElement.className = 'text-6xl mb-3 text-warning-500';
} else if (invalidCount > 0 && validCount === 0) {
iconElement.innerHTML = '<i class="fas fa-times-circle text-danger-500"></i>';
iconElement.className = 'text-6xl mb-3 text-danger-500';
} else { // 都为 0 或其他情况
iconElement.innerHTML = '<i class="fas fa-info-circle text-gray-500"></i>';
iconElement.className = 'text-6xl mb-3 text-gray-500';
}
// 构建详细内容
messageElement.innerHTML = ''; // 清空
const summaryDiv = document.createElement('div');
summaryDiv.className = 'text-center mb-4 text-lg';
summaryDiv.innerHTML = `验证完成:<span class="font-semibold text-success-600">${validCount}</span> 个成功,<span class="font-semibold text-danger-600">${invalidCount}</span> 个失败。`;
messageElement.appendChild(summaryDiv);
// 成功列表
if (successfulKeys.length > 0) {
const successDiv = document.createElement('div');
successDiv.className = 'mb-3';
const successHeader = document.createElement('div');
successHeader.className = 'flex justify-between items-center mb-1';
successHeader.innerHTML = `<h4 class="font-semibold text-success-700">成功密钥 (${successfulKeys.length}):</h4>`;
const copySuccessBtn = document.createElement('button');
copySuccessBtn.className = 'px-2 py-0.5 bg-green-100 hover:bg-green-200 text-green-700 text-xs rounded transition-colors';
copySuccessBtn.innerHTML = '<i class="fas fa-copy mr-1"></i>复制全部';
copySuccessBtn.onclick = (e) => {
e.stopPropagation();
copyToClipboard(successfulKeys.join('\n'))
.then(() => showNotification(`已复制 ${successfulKeys.length} 个成功密钥`, 'success'))
.catch(() => showNotification('复制失败', 'error'));
};
successHeader.appendChild(copySuccessBtn);
successDiv.appendChild(successHeader);
const successList = document.createElement('ul');
successList.className = 'list-disc list-inside text-sm text-gray-600 max-h-20 overflow-y-auto bg-gray-50 p-2 rounded border border-gray-200';
successfulKeys.forEach(key => {
const li = document.createElement('li');
li.className = 'font-mono';
// Store full key in dataset for potential future use, display masked
li.dataset.fullKey = key;
li.textContent = key.substring(0, 4) + '...' + key.substring(key.length - 4);
successList.appendChild(li);
});
successDiv.appendChild(successList);
messageElement.appendChild(successDiv);
}
// 失败列表
if (Object.keys(failedKeys).length > 0) {
const failDiv = document.createElement('div');
failDiv.className = 'mb-1'; // 减少底部边距
const failHeader = document.createElement('div');
failHeader.className = 'flex justify-between items-center mb-1';
failHeader.innerHTML = `<h4 class="font-semibold text-danger-700">失败密钥 (${Object.keys(failedKeys).length}):</h4>`;
const copyFailBtn = document.createElement('button');
copyFailBtn.className = 'px-2 py-0.5 bg-red-100 hover:bg-red-200 text-red-700 text-xs rounded transition-colors';
copyFailBtn.innerHTML = '<i class="fas fa-copy mr-1"></i>复制全部';
const failedKeysArray = Object.keys(failedKeys); // Get array of failed keys
copyFailBtn.onclick = (e) => {
e.stopPropagation();
copyToClipboard(failedKeysArray.join('\n'))
.then(() => showNotification(`已复制 ${failedKeysArray.length} 个失败密钥`, 'success'))
.catch(() => showNotification('复制失败', 'error'));
};
failHeader.appendChild(copyFailBtn);
failDiv.appendChild(failHeader);
const failList = document.createElement('ul');
failList.className = 'text-sm text-gray-600 max-h-32 overflow-y-auto bg-red-50 p-2 rounded border border-red-200 space-y-1'; // 增加最大高度和间距
Object.entries(failedKeys).forEach(([key, error]) => {
const li = document.createElement('li');
// li.className = 'flex justify-between items-center'; // Restore original layout
li.className = 'flex flex-col items-start'; // Start with vertical layout
const keySpanContainer = document.createElement('div');
keySpanContainer.className = 'flex justify-between items-center w-full'; // Ensure key and button are on the same line initially
const keySpan = document.createElement('span');
keySpan.className = 'font-mono';
// Store full key in dataset, display masked
keySpan.dataset.fullKey = key;
keySpan.textContent = key.substring(0, 4) + '...' + key.substring(key.length - 4);
const detailsButton = document.createElement('button');
detailsButton.className = 'ml-2 px-2 py-0.5 bg-red-200 hover:bg-red-300 text-red-700 text-xs rounded transition-colors';
detailsButton.innerHTML = '<i class="fas fa-info-circle mr-1"></i>详情';
detailsButton.dataset.error = error; // 将错误信息存储在 data 属性中
detailsButton.onclick = (e) => {
e.stopPropagation(); // Prevent modal close
const button = e.currentTarget;
const listItem = button.closest('li');
const errorMsg = button.dataset.error;
const errorDetailsId = `error-details-${key.replace(/[^a-zA-Z0-9]/g, '')}`; // Create unique ID
let errorDiv = listItem.querySelector(`#${errorDetailsId}`);
if (errorDiv) {
// Collapse: Remove error div and reset li layout
errorDiv.remove();
// listItem.className = 'flex justify-between items-center'; // Restore original layout
listItem.className = 'flex flex-col items-start'; // Keep vertical layout
button.innerHTML = '<i class="fas fa-info-circle mr-1"></i>详情'; // Restore button text
} else {
// Expand: Create and append error div, change li layout
errorDiv = document.createElement('div');
errorDiv.id = errorDetailsId;
errorDiv.className = 'w-full mt-1 pl-0 text-xs text-red-600 bg-red-50 p-1 rounded border border-red-100 whitespace-pre-wrap break-words'; // Adjusted padding
errorDiv.textContent = errorMsg;
listItem.appendChild(errorDiv);
listItem.className = 'flex flex-col items-start'; // Change layout to vertical
button.innerHTML = '<i class="fas fa-chevron-up mr-1"></i>收起'; // Change button text
// Move button to be alongside the keySpan for vertical layout (already done)
}
};
keySpanContainer.appendChild(keySpan); // Add keySpan to container
keySpanContainer.appendChild(detailsButton); // Add button to container
li.appendChild(keySpanContainer); // Add container to list item
failList.appendChild(li);
});
failDiv.appendChild(failList);
messageElement.appendChild(failDiv);
}
// 设置确认按钮点击事件 - 总是自动刷新
confirmButton.onclick = () => closeResultModal(true); // Always reload
// 显示模态框
modalElement.classList.remove('hidden');
}
async function executeResetAll(type) {
try {
// 关闭确认模态框
closeResetModal();
// 找到对应的重置按钮以显示加载状态
const resetButton = document.querySelector(`button[data-reset-type="${type}"]`);
if (!resetButton) {
showResultModal(false, `找不到${type === 'valid' ? '有效' : '无效'}密钥区域的批量重置按钮`, false); // Don't reload if button not found
return;
}
// 获取选中的密钥
const keysToReset = getSelectedKeys(type);
if (keysToReset.length === 0) {
showNotification(`没有选中的${type === 'valid' ? '有效' : '无效'}密钥可重置`, 'warning');
return;
}
// 禁用按钮并显示加载状态
resetButton.disabled = true;
const originalHtml = resetButton.innerHTML;
resetButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 重置中';
try {
const options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keys: keysToReset, key_type: type })
};
const data = await fetchAPI(`/gemini/v1beta/reset-selected-fail-counts`, options);
// 根据重置结果显示模态框
if (data.success) {
const message = data.reset_count !== undefined ?
`成功重置 ${data.reset_count} 个选中的${type === 'valid' ? '有效' : '无效'}密钥的失败次数` :
`成功重置 ${keysToReset.length} 个选中的密钥`;
showResultModal(true, message); // 成功后刷新页面
} else {
const errorMsg = data.message || '批量重置失败';
// 失败后不自动刷新页面,让用户看到错误信息
showResultModal(false, '批量重置失败: ' + errorMsg, false);
}
} catch (apiError) {
console.error('批量重置 API 请求失败:', apiError);
showResultModal(false, `批量重置请求失败: ${apiError.message}`, false);
} finally {
// 恢复按钮状态 (仅在不刷新的情况下)
if (!document.getElementById('resultModal') || document.getElementById('resultModal').classList.contains('hidden') || document.getElementById('resultModalTitle').textContent.includes('失败')) {
resetButton.innerHTML = originalHtml;
resetButton.disabled = false;
}
}
} catch (error) {
console.error('批量重置处理失败:', error);
showResultModal(false, '批量重置处理失败: ' + error.message, false); // 失败后不自动刷新
}
}
function scrollToTop() {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function scrollToBottom() {
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
}
// 移除这个函数,因为它可能正在干扰按钮的显示
// HTML中已经设置了滚动按钮为flex显示不需要JavaScript额外控制
// function updateScrollButtons() {
// // 不执行任何操作
// }
function refreshPage(button) {
button.classList.add('loading'); // Maybe add a loading class for visual feedback
button.disabled = true;
const icon = button.querySelector('i');
if (icon) icon.classList.add('fa-spin'); // Add spin animation
setTimeout(() => {
window.location.reload();
// No need to remove loading/spin as page reloads
}, 300);
}
// 展开/收起区块内容的函数,带有平滑动画效果。
// @param {HTMLElement} header - 被点击的区块头部元素。
// @param {string} sectionId - (当前未使用,但可用于更精确的目标定位) 关联内容区块的ID。
function toggleSection(header, sectionId) {
const toggleIcon = header.querySelector('.toggle-icon');
// 内容元素是卡片内的 .key-content div
const card = header.closest('.stats-card');
const content = card ? card.querySelector('.key-content') : null;
// 批量操作栏和分页控件也可能影响内容区域的动画高度计算
const batchActions = card ? card.querySelector('[id$="BatchActions"]') : null;
const pagination = card ? card.querySelector('[id$="PaginationControls"]') : null;
if (!toggleIcon || !content) {
console.error("Toggle section failed: Icon or content element not found. Header:", header, "SectionId:", sectionId);
return;
}
const isCollapsed = content.classList.contains('collapsed');
toggleIcon.classList.toggle('collapsed', !isCollapsed); // 更新箭头图标方向
if (isCollapsed) {
// --- 准备展开动画 ---
content.classList.remove('collapsed'); // 移除 collapsed 类以应用展开的样式
// 步骤 1: 重置内联样式让CSS控制初始的“隐藏”状态 (通常是 maxHeight: 0, opacity: 0)。
// 同时,确保 overflow 在动画开始前是 hidden。
content.style.maxHeight = ''; // 清除可能存在的内联 maxHeight
content.style.opacity = ''; // 清除可能存在的内联 opacity
content.style.paddingTop = ''; // 清除内联 padding
content.style.paddingBottom = '';
content.style.overflow = 'hidden'; // 动画过程中隐藏溢出内容
// 步骤 2: 使用 requestAnimationFrame (rAF) 确保浏览器在计算 scrollHeight 之前
// 已经应用了上一步的样式重置特别是如果CSS中有过渡效果
requestAnimationFrame(() => {
// 步骤 3: 计算内容区的目标高度。
// 这包括内容本身的 scrollHeight以及任何可见的批量操作栏和分页控件的高度。
let targetHeight = content.scrollHeight;
if (batchActions && !batchActions.classList.contains('hidden')) {
targetHeight += batchActions.offsetHeight;
}
if (pagination && pagination.offsetHeight > 0) {
// 尝试获取分页控件的 margin-top以获得更精确的高度
const paginationStyle = getComputedStyle(pagination);
const paginationMarginTop = parseFloat(paginationStyle.marginTop) || 0;
targetHeight += pagination.offsetHeight + paginationMarginTop;
}
// 步骤 4: 设置 maxHeight 和 opacity 以触发CSS过渡到展开状态。
content.style.maxHeight = targetHeight + 'px';
content.style.opacity = '1';
// 假设展开后的 padding 为 1rem (p-4 in Tailwind). 根据实际情况调整。
content.style.paddingTop = '1rem';
content.style.paddingBottom = '1rem';
// 步骤 5: 监听 transitionend 事件。动画结束后,移除 maxHeight 以允许内容动态调整,
// 并将 overflow 设置为 visible以防内容变化后被裁剪。
content.addEventListener('transitionend', function onExpansionEnd() {
content.removeEventListener('transitionend', onExpansionEnd); // 清理监听器
// 再次检查确保是在展开状态 (避免在快速连续点击时出错)
if (!content.classList.contains('collapsed')) {
content.style.maxHeight = ''; // 允许内容自适应高度
content.style.overflow = 'visible'; // 允许内容溢出(如果需要)
}
}, { once: true }); // 确保监听器只执行一次
});
} else {
// --- 准备收起动画 ---
// 步骤 1: 获取当前内容区的可见高度。
// 这对于从当前渲染高度平滑过渡到0是必要的。
let currentVisibleHeight = content.scrollHeight; // scrollHeight 应该已经是包括padding的内部高度
if (batchActions && !batchActions.classList.contains('hidden')) {
currentVisibleHeight += batchActions.offsetHeight;
}
if (pagination && pagination.offsetHeight > 0) {
const paginationStyle = getComputedStyle(pagination);
const paginationMarginTop = parseFloat(paginationStyle.marginTop) || 0;
currentVisibleHeight += pagination.offsetHeight + paginationMarginTop;
}
// 步骤 2: 将 maxHeight 设置为当前计算的可见高度,以确保过渡从当前高度开始。
// 同时,确保 overflow 在动画开始前是 hidden。
content.style.maxHeight = currentVisibleHeight + 'px';
content.style.overflow = 'hidden';
// 步骤 3: 使用 requestAnimationFrame (rAF) 确保浏览器应用了上述 maxHeight。
requestAnimationFrame(() => {
// 步骤 4: 过渡到目标状态 (收起): maxHeight 和 padding 设为0opacity 设为0。
content.style.maxHeight = '0px';
content.style.opacity = '0';
content.style.paddingTop = '0';
content.style.paddingBottom = '0';
// 在动画开始(或即将开始)后添加 collapsed 类以便CSS可以应用最终的折叠样式。
content.classList.add('collapsed');
});
}
}
// 筛选有效密钥(根据失败次数阈值)并更新批量操作状态
function filterValidKeys() {
const thresholdInput = document.getElementById('failCountThreshold');
const validKeysList = document.getElementById('validKeys'); // Get the UL element
if (!validKeysList) return; // Exit if the list doesn't exist
const validKeyItems = validKeysList.querySelectorAll('li[data-key]'); // Select li elements within the list
// 读取阈值如果输入无效或为空则默认为0不过滤
const threshold = parseInt(thresholdInput.value, 10);
const filterThreshold = isNaN(threshold) || threshold < 0 ? 0 : threshold;
let hasVisibleItems = false;
validKeyItems.forEach(item => {
// 确保只处理包含 data-fail-count 的 li 元素
if (item.dataset.failCount !== undefined) {
const failCount = parseInt(item.dataset.failCount, 10);
// 如果失败次数大于等于阈值,则显示,否则隐藏
if (failCount >= filterThreshold) {
item.style.display = 'flex'; // 使用 flex 因为 li 现在是 flex 容器
hasVisibleItems = true;
} else {
item.style.display = 'none'; // 隐藏
// 如果隐藏了一个项,取消其选中状态
const checkbox = item.querySelector('.key-checkbox');
if (checkbox && checkbox.checked) {
checkbox.checked = false;
}
}
}
});
// 更新有效密钥的批量操作状态和全选复选框
updateBatchActions('valid');
// 处理“暂无有效密钥”消息
const noMatchMsgId = 'no-valid-keys-msg';
let noMatchMsg = validKeysList.querySelector(`#${noMatchMsgId}`);
const initialKeyCount = validKeysList.querySelectorAll('li[data-key]').length; // 获取初始密钥数量
if (!hasVisibleItems && initialKeyCount > 0) { // 仅当初始有密钥但现在都不可见时显示
if (!noMatchMsg) {
noMatchMsg = document.createElement('li');
noMatchMsg.id = noMatchMsgId;
noMatchMsg.className = 'text-center text-gray-500 py-4 col-span-full';
noMatchMsg.textContent = '没有符合条件的有效密钥';
validKeysList.appendChild(noMatchMsg);
}
noMatchMsg.style.display = '';
} else if (noMatchMsg) {
noMatchMsg.style.display = 'none';
}
}
// --- Initialization Helper Functions ---
function initializePageAnimationsAndEffects() {
initStatItemAnimations(); // Already an external function
const animateCounters = () => {
const statValues = document.querySelectorAll('.stat-value');
statValues.forEach(valueElement => {
const finalValue = parseInt(valueElement.textContent, 10);
if (!isNaN(finalValue)) {
if (!valueElement.dataset.originalValue) {
valueElement.dataset.originalValue = valueElement.textContent;
}
let startValue = 0;
const duration = 1500;
const startTime = performance.now();
const updateCounter = (currentTime) => {
const elapsedTime = currentTime - startTime;
if (elapsedTime < duration) {
const progress = elapsedTime / duration;
const easeOutValue = 1 - Math.pow(1 - progress, 3);
const currentValue = Math.floor(easeOutValue * finalValue);
valueElement.textContent = currentValue;
requestAnimationFrame(updateCounter);
} else {
valueElement.textContent = valueElement.dataset.originalValue;
}
};
requestAnimationFrame(updateCounter);
}
});
};
setTimeout(animateCounters, 300);
document.querySelectorAll('.stats-card').forEach(card => {
card.addEventListener('mouseenter', () => {
card.classList.add('shadow-lg');
card.style.transform = 'translateY(-2px)';
});
card.addEventListener('mouseleave', () => {
card.classList.remove('shadow-lg');
card.style.transform = '';
});
});
}
function initializeSectionToggleListeners() {
document.querySelectorAll('.stats-card-header').forEach(header => {
if (header.querySelector('.toggle-icon')) {
header.addEventListener('click', (event) => {
if (event.target.closest('input, label, button, select')) {
return;
}
const card = header.closest('.stats-card');
const content = card ? card.querySelector('.key-content') : null;
const sectionId = content ? content.id : null;
if (sectionId) {
toggleSection(header, sectionId);
} else {
console.warn("Could not determine sectionId for toggle.");
}
});
}
});
}
function initializeKeyFilterControls() {
const thresholdInput = document.getElementById('failCountThreshold');
if (thresholdInput) {
thresholdInput.addEventListener('input', filterValidKeys);
}
}
function initializeGlobalBatchVerificationHandlers() {
window.showVerifyModal = function(type, event) {
if (event) {
event.stopPropagation();
}
const modalElement = document.getElementById('verifyModal');
const titleElement = document.getElementById('verifyModalTitle');
const messageElement = document.getElementById('verifyModalMessage');
const confirmButton = document.getElementById('confirmVerifyBtn');
const selectedKeys = getSelectedKeys(type);
const count = selectedKeys.length;
titleElement.textContent = '批量验证密钥';
if (count > 0) {
messageElement.textContent = `确定要批量验证选中的 ${count}${type === 'valid' ? '有效' : '无效'}密钥吗?此操作可能需要一些时间。`;
confirmButton.disabled = false;
} else {
messageElement.textContent = `请先选择要验证的${type === 'valid' ? '有效' : '无效'}密钥。`;
confirmButton.disabled = true;
}
confirmButton.onclick = () => executeVerifyAll(type);
modalElement.classList.remove('hidden');
};
window.closeVerifyModal = function() {
document.getElementById('verifyModal').classList.add('hidden');
};
// executeVerifyAll 变为 initializeGlobalBatchVerificationHandlers 的局部函数
async function executeVerifyAll(type) { // Removed window.
try {
window.closeVerifyModal(); // Calls the global close function, which is fine.
const verifyButton = document.querySelector(`#${type}BatchActions button:nth-child(1)`); // Assuming verify is the first button
let originalVerifyHtml = '';
if (verifyButton) {
originalVerifyHtml = verifyButton.innerHTML;
verifyButton.disabled = true;
verifyButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 验证中';
}
const keysToVerify = getSelectedKeys(type);
if (keysToVerify.length === 0) {
showNotification(`没有选中的${type === 'valid' ? '有效' : '无效'}密钥可验证`, 'warning');
if (verifyButton) { // Restore button if no keys selected
verifyButton.innerHTML = originalVerifyHtml;
}
return;
}
showNotification('开始批量验证,请稍候...', 'info');
const options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keys: keysToVerify })
};
const data = await fetchAPI(`/gemini/v1beta/verify-selected-keys`, options);
if(data) {
showVerificationResultModal(data);
} else {
throw new Error("API did not return verification data.");
}
} catch (apiError) {
console.error('批量验证处理失败:', apiError);
showResultModal(false, `批量验证处理失败: ${apiError.message}`, true);
} finally {
console.log("Bulk verification process finished.");
// Button state will be reset on page reload or by updateBatchActions
}
}
// The confirmButton.onclick in showVerifyModal (defined earlier in initializeGlobalBatchVerificationHandlers)
// will correctly reference this local executeVerifyAll due to closure.
}
function initializeKeySelectionListeners() {
const validKeysList = document.getElementById('validKeys');
if (validKeysList) {
validKeysList.addEventListener('change', (event) => {
if (event.target.classList.contains('key-checkbox')) {
updateBatchActions('valid');
}
});
}
const invalidKeysList = document.getElementById('invalidKeys');
if (invalidKeysList) {
invalidKeysList.addEventListener('change', (event) => {
if (event.target.classList.contains('key-checkbox')) {
updateBatchActions('invalid');
}
});
}
}
function initializeAutoRefreshControls() {
const autoRefreshToggle = document.getElementById('autoRefreshToggle');
const autoRefreshIntervalTime = 60000; // 60秒
let autoRefreshTimer = null;
function startAutoRefresh() {
if (autoRefreshTimer) return;
console.log('启动自动刷新...');
showNotification('自动刷新已启动', 'info', 2000);
autoRefreshTimer = setInterval(() => {
console.log('自动刷新 keys_status 页面...');
location.reload();
}, autoRefreshIntervalTime);
}
function stopAutoRefresh() {
if (autoRefreshTimer) {
console.log('停止自动刷新...');
showNotification('自动刷新已停止', 'info', 2000);
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
}
if (autoRefreshToggle) {
const isAutoRefreshEnabled = localStorage.getItem('autoRefreshEnabled') === 'true';
autoRefreshToggle.checked = isAutoRefreshEnabled;
if (isAutoRefreshEnabled) {
startAutoRefresh();
}
autoRefreshToggle.addEventListener('change', () => {
if (autoRefreshToggle.checked) {
localStorage.setItem('autoRefreshEnabled', 'true');
startAutoRefresh();
} else {
localStorage.setItem('autoRefreshEnabled', 'false');
stopAutoRefresh();
}
});
}
}
// These variables are used by pagination and search, define them in a scope accessible by initializeKeyPaginationAndSearch
let allValidKeys = [];
let allInvalidKeys = [];
let filteredValidKeys = [];
let itemsPerPage = 10; // Default
let validCurrentPage = 1; // Also used by displayPage
let invalidCurrentPage = 1; // Also used by displayPage
function initializeKeyPaginationAndSearch() {
const validKeysListElement = document.getElementById('validKeys');
const invalidKeysListElement = document.getElementById('invalidKeys');
const searchInput = document.getElementById('keySearchInput');
const itemsPerPageSelect = document.getElementById('itemsPerPageSelect');
const thresholdInput = document.getElementById('failCountThreshold'); // Already used by initializeKeyFilterControls
if (validKeysListElement) {
allValidKeys = Array.from(validKeysListElement.querySelectorAll('li[data-key]'));
allValidKeys.forEach(li => {
const keyTextSpan = li.querySelector('.key-text');
if (keyTextSpan && keyTextSpan.dataset.fullKey) {
li.dataset.key = keyTextSpan.dataset.fullKey;
}
});
filteredValidKeys = [...allValidKeys];
}
if (invalidKeysListElement) {
allInvalidKeys = Array.from(invalidKeysListElement.querySelectorAll('li[data-key]'));
allInvalidKeys.forEach(li => {
const keyTextSpan = li.querySelector('.key-text');
if (keyTextSpan && keyTextSpan.dataset.fullKey) {
li.dataset.key = keyTextSpan.dataset.fullKey;
}
});
}
if (itemsPerPageSelect) {
itemsPerPage = parseInt(itemsPerPageSelect.value, 10); // Initialize itemsPerPage
itemsPerPageSelect.addEventListener('change', () => {
itemsPerPage = parseInt(itemsPerPageSelect.value, 10);
filterAndSearchValidKeys(); // Re-filter and display page 1 for valid keys
displayPage('invalid', 1, allInvalidKeys); // Reset invalid keys to page 1
});
}
// Initial display calls
filterAndSearchValidKeys();
displayPage('invalid', 1, allInvalidKeys);
// Event listeners for search and filter (thresholdInput listener is in initializeKeyFilterControls)
if (searchInput) {
searchInput.addEventListener('input', filterAndSearchValidKeys);
}
}
function registerServiceWorker() {
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/static/service-worker.js')
.then(registration => {
console.log('ServiceWorker注册成功:', registration.scope);
})
.catch(error => {
console.log('ServiceWorker注册失败:', error);
});
});
}
}
// 初始化
document.addEventListener('DOMContentLoaded', () => {
initializePageAnimationsAndEffects();
initializeSectionToggleListeners();
initializeKeyFilterControls();
initializeGlobalBatchVerificationHandlers();
initializeKeySelectionListeners();
initializeAutoRefreshControls();
initializeKeyPaginationAndSearch(); // This will also handle initial display
registerServiceWorker();
// Initial batch actions update might be needed if not covered by displayPage
// updateBatchActions('valid');
// updateBatchActions('invalid');
});
function toggleKeyVisibility(button) {
const keyContainer = button.closest('.flex.items-center.gap-1');
const keyTextSpan = keyContainer.querySelector('.key-text');
const eyeIcon = button.querySelector('i');
const fullKey = keyTextSpan.dataset.fullKey;
const maskedKey = fullKey.substring(0, 4) + '...' + fullKey.substring(fullKey.length - 4);
if (keyTextSpan.textContent === maskedKey) {
keyTextSpan.textContent = fullKey;
eyeIcon.classList.remove('fa-eye');
eyeIcon.classList.add('fa-eye-slash');
button.title = '隐藏密钥';
} else {
keyTextSpan.textContent = maskedKey;
eyeIcon.classList.remove('fa-eye-slash');
eyeIcon.classList.add('fa-eye');
button.title = '显示密钥';
}
}
// --- API 调用详情模态框逻辑 ---
// 显示 API 调用详情模态框
async function showApiCallDetails(period) {
const modal = document.getElementById('apiCallDetailsModal');
const contentArea = document.getElementById('apiCallDetailsContent');
const titleElement = document.getElementById('apiCallDetailsModalTitle');
if (!modal || !contentArea || !titleElement) {
console.error('无法找到 API 调用详情模态框元素');
showNotification('无法显示详情,页面元素缺失', 'error');
return;
}
// 设置标题
let periodText = '';
switch (period) {
case '1m': periodText = '最近 1 分钟'; break;
case '1h': periodText = '最近 1 小时'; break;
case '24h': periodText = '最近 24 小时'; break;
default: periodText = '指定时间段';
}
titleElement.textContent = `${periodText} API 调用详情`;
// 显示模态框并设置加载状态
modal.classList.remove('hidden');
contentArea.innerHTML = `
<div class="text-center py-10">
<i class="fas fa-spinner fa-spin text-primary-600 text-3xl"></i>
<p class="text-gray-500 mt-2">加载中...</p>
</div>`;
try {
const data = await fetchAPI(`/api/stats/details?period=${period}`);
if (data) {
renderApiCallDetails(data, contentArea);
} else {
renderApiCallDetails([], contentArea); // Show empty state if no data
}
} catch (apiError) {
console.error('获取 API 调用详情失败:', apiError);
contentArea.innerHTML = `
<div class="text-center py-10 text-danger-500">
<i class="fas fa-exclamation-triangle text-3xl"></i>
<p class="mt-2">加载失败: ${apiError.message}</p>
</div>`;
}
}
// 关闭 API 调用详情模态框
function closeApiCallDetailsModal() {
const modal = document.getElementById('apiCallDetailsModal');
if (modal) {
modal.classList.add('hidden');
}
}
// 渲染 API 调用详情到模态框
function renderApiCallDetails(data, container) {
if (!data || data.length === 0) {
container.innerHTML = `
<div class="text-center py-10 text-gray-500">
<i class="fas fa-info-circle text-3xl"></i>
<p class="mt-2">该时间段内没有 API 调用记录。</p>
</div>`;
return;
}
// 创建表格
let tableHtml = `
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">时间</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">密钥 (部分)</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">模型</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
`;
// 填充表格行
data.forEach(call => {
const timestamp = new Date(call.timestamp).toLocaleString();
const keyDisplay = call.key ? `${call.key.substring(0, 4)}...${call.key.substring(call.key.length - 4)}` : 'N/A';
const statusClass = call.status === 'success' ? 'text-success-600' : 'text-danger-600';
const statusIcon = call.status === 'success' ? 'fa-check-circle' : 'fa-times-circle';
tableHtml += `
<tr>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-700">${timestamp}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 font-mono">${keyDisplay}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500">${call.model || 'N/A'}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm ${statusClass}">
<i class="fas ${statusIcon} mr-1"></i>
${call.status}
</td>
</tr>
`;
});
tableHtml += `
</tbody>
</table>
`;
container.innerHTML = tableHtml;
}
// --- 密钥使用详情模态框逻辑 ---
// 显示密钥使用详情模态框
window.showKeyUsageDetails = async function(key) {
const modal = document.getElementById('keyUsageDetailsModal');
const contentArea = document.getElementById('keyUsageDetailsContent');
const titleElement = document.getElementById('keyUsageDetailsModalTitle');
const keyDisplay = key.substring(0, 4) + '...' + key.substring(key.length - 4);
if (!modal || !contentArea || !titleElement) {
console.error('无法找到密钥使用详情模态框元素');
showNotification('无法显示详情,页面元素缺失', 'error');
return;
}
// renderKeyUsageDetails 变为 showKeyUsageDetails 的局部函数
function renderKeyUsageDetails(data, container) {
if (!data || Object.keys(data).length === 0) {
container.innerHTML = `
<div class="text-center py-10 text-gray-500">
<i class="fas fa-info-circle text-3xl"></i>
<p class="mt-2">该密钥在最近24小时内没有调用记录。</p>
</div>`;
return;
}
let tableHtml = `
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">模型名称</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">调用次数 (24h)</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">`;
const sortedModels = Object.entries(data).sort(([, countA], [, countB]) => countB - countA);
sortedModels.forEach(([model, count]) => {
tableHtml += `
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${model}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${count}</td>
</tr>`;
});
tableHtml += `
</tbody>
</table>`;
container.innerHTML = tableHtml;
}
// 设置标题
titleElement.textContent = `密钥 ${keyDisplay} - 最近24小时请求详情`;
// 显示模态框并设置加载状态
modal.classList.remove('hidden');
contentArea.innerHTML = `
<div class="text-center py-10">
<i class="fas fa-spinner fa-spin text-primary-600 text-3xl"></i>
<p class="text-gray-500 mt-2">加载中...</p>
</div>`;
try {
const data = await fetchAPI(`/api/key-usage-details/${key}`);
if (data) {
renderKeyUsageDetails(data, contentArea);
} else {
renderKeyUsageDetails({}, contentArea); // Show empty state if no data
}
} catch (apiError) {
console.error('获取密钥使用详情失败:', apiError);
contentArea.innerHTML = `
<div class="text-center py-10 text-danger-500">
<i class="fas fa-exclamation-triangle text-3xl"></i>
<p class="mt-2">加载失败: ${apiError.message}</p>
</div>`;
}
}
// 关闭密钥使用详情模态框
window.closeKeyUsageDetailsModal = function() {
const modal = document.getElementById('keyUsageDetailsModal');
if (modal) {
modal.classList.add('hidden');
}
}
// window.renderKeyUsageDetails 函数已被移入 showKeyUsageDetails 内部, 此处残留代码已删除。
// --- Key List Display & Pagination ---
/**
* Displays key list items for a specific type and page.
* @param {string} type 'valid' or 'invalid'
* @param {number} page Page number (1-based)
* @param {Array} keyItemsArray The array of li elements to paginate (e.g., filteredValidKeys, allInvalidKeys)
*/
function displayPage(type, page, keyItemsArray) {
const listElement = document.getElementById(`${type}Keys`);
const paginationControls = document.getElementById(`${type}PaginationControls`);
if (!listElement || !paginationControls) return;
// Update current page based on type
if (type === 'valid') {
validCurrentPage = page;
// Read itemsPerPage from the select specifically for valid keys
const itemsPerPageSelect = document.getElementById('itemsPerPageSelect');
itemsPerPage = itemsPerPageSelect ? parseInt(itemsPerPageSelect.value, 10) : 10;
} else {
invalidCurrentPage = page;
// For invalid keys, use a fixed itemsPerPage or the same global one
// itemsPerPage = 10; // Or read from a different select if needed
}
const totalItems = keyItemsArray.length;
const totalPages = Math.ceil(totalItems / itemsPerPage);
page = Math.max(1, Math.min(page, totalPages || 1)); // Ensure page is valid
// Update current page variable again after validation
if (type === 'valid') {
validCurrentPage = page;
} else {
invalidCurrentPage = page;
}
const startIndex = (page - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
listElement.innerHTML = ''; // Clear current list content
const pageItems = keyItemsArray.slice(startIndex, endIndex);
if (pageItems.length > 0) {
pageItems.forEach(item => listElement.appendChild(item.cloneNode(true))); // Clone node to avoid issues if original array is modified elsewhere
} else if (totalItems === 0 && type === 'valid' && (document.getElementById('failCountThreshold').value !== '0' || document.getElementById('keySearchInput').value !== '')) {
// Handle empty state after filtering/searching for valid keys
const noMatchMsgId = 'no-valid-keys-msg';
let noMatchMsg = listElement.querySelector(`#${noMatchMsgId}`);
if (!noMatchMsg) {
noMatchMsg = document.createElement('li');
noMatchMsg.id = noMatchMsgId;
noMatchMsg.className = 'text-center text-gray-500 py-4 col-span-full';
noMatchMsg.textContent = '没有符合条件的有效密钥';
listElement.appendChild(noMatchMsg);
}
noMatchMsg.style.display = '';
} else if (totalItems === 0) {
// Handle empty state for initially empty lists
const emptyMsg = document.createElement('li');
emptyMsg.className = 'text-center text-gray-500 py-4 col-span-full';
emptyMsg.textContent = `暂无${type === 'valid' ? '有效' : '无效'}密钥`;
listElement.appendChild(emptyMsg);
}
setupPaginationControls(type, page, totalPages, keyItemsArray);
updateBatchActions(type); // Update batch actions based on the currently displayed page
// Re-attach event listeners for buttons inside the newly added list items if needed (using event delegation is better)
}
/**
* Sets up pagination controls.
* @param {string} type 'valid' or 'invalid'
* @param {number} currentPage Current page number
* @param {number} totalPages Total number of pages
* @param {Array} keyItemsArray The array of li elements being paginated
*/
function setupPaginationControls(type, currentPage, totalPages, keyItemsArray) {
const controlsContainer = document.getElementById(`${type}PaginationControls`);
if (!controlsContainer) return;
controlsContainer.innerHTML = '';
if (totalPages <= 1) {
return; // No controls needed for single/no page
}
// Previous Button
const prevButton = document.createElement('button');
prevButton.innerHTML = '<i class="fas fa-chevron-left"></i>';
prevButton.className = 'px-3 py-1 rounded bg-gray-200 hover:bg-gray-300 disabled:opacity-50 disabled:cursor-not-allowed text-sm';
prevButton.disabled = currentPage === 1;
prevButton.onclick = () => displayPage(type, currentPage - 1, keyItemsArray);
controlsContainer.appendChild(prevButton);
// Page Number Buttons (Logic for ellipsis)
const maxPageButtons = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxPageButtons / 2));
let endPage = Math.min(totalPages, startPage + maxPageButtons - 1);
if (endPage - startPage + 1 < maxPageButtons) {
startPage = Math.max(1, endPage - maxPageButtons + 1);
}
// First Page Button & Ellipsis
if (startPage > 1) {
const firstPageButton = document.createElement('button');
firstPageButton.textContent = '1';
firstPageButton.className = 'px-3 py-1 rounded bg-gray-200 hover:bg-gray-300 text-sm';
firstPageButton.onclick = () => displayPage(type, 1, keyItemsArray);
controlsContainer.appendChild(firstPageButton);
if (startPage > 2) {
const ellipsis = document.createElement('span');
ellipsis.textContent = '...';
ellipsis.className = 'px-3 py-1 text-gray-500 text-sm';
controlsContainer.appendChild(ellipsis);
}
}
// Middle Page Buttons
for (let i = startPage; i <= endPage; i++) {
const pageButton = document.createElement('button');
pageButton.textContent = i;
pageButton.className = `px-3 py-1 rounded text-sm ${i === currentPage ? 'bg-primary-600 text-white font-semibold' : 'bg-gray-200 hover:bg-gray-300'}`;
pageButton.onclick = () => displayPage(type, i, keyItemsArray);
controlsContainer.appendChild(pageButton);
}
// Ellipsis & Last Page Button
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
const ellipsis = document.createElement('span');
ellipsis.textContent = '...';
ellipsis.className = 'px-3 py-1 text-gray-500 text-sm';
controlsContainer.appendChild(ellipsis);
}
const lastPageButton = document.createElement('button');
lastPageButton.textContent = totalPages;
lastPageButton.className = 'px-3 py-1 rounded bg-gray-200 hover:bg-gray-300 text-sm';
lastPageButton.onclick = () => displayPage(type, totalPages, keyItemsArray);
controlsContainer.appendChild(lastPageButton);
}
// Next Button
const nextButton = document.createElement('button');
nextButton.innerHTML = '<i class="fas fa-chevron-right"></i>';
nextButton.className = 'px-3 py-1 rounded bg-gray-200 hover:bg-gray-300 disabled:opacity-50 disabled:cursor-not-allowed text-sm';
nextButton.disabled = currentPage === totalPages;
nextButton.onclick = () => displayPage(type, currentPage + 1, keyItemsArray);
controlsContainer.appendChild(nextButton);
}
// --- Filtering & Searching (Valid Keys Only) ---
/**
* Filters and searches the valid keys based on threshold and search term.
* Updates the `filteredValidKeys` array and redisplays the first page.
*/
function filterAndSearchValidKeys() {
const thresholdInput = document.getElementById('failCountThreshold');
const searchInput = document.getElementById('keySearchInput');
const threshold = parseInt(thresholdInput.value, 10);
const filterThreshold = isNaN(threshold) || threshold < 0 ? 0 : threshold;
const searchTerm = searchInput.value.trim().toLowerCase();
// Filter from the original full list (allValidKeys)
filteredValidKeys = allValidKeys.filter(item => {
const failCount = parseInt(item.dataset.failCount, 10);
const fullKey = item.dataset.key || ''; // Use data-key which should hold the full key
const failCountMatch = failCount >= filterThreshold;
const searchMatch = searchTerm === '' || fullKey.toLowerCase().includes(searchTerm);
return failCountMatch && searchMatch;
});
// Reset to the first page after filtering/searching
validCurrentPage = 1;
displayPage('valid', validCurrentPage, filteredValidKeys);
}