mirror of
https://github.com/snailyp/gemini-balance.git
synced 2026-05-27 11:19:32 +08:00
此次提交引入了重要的重构和改进: - 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 密钥状态管理的用户界面。
1450 lines
63 KiB
JavaScript
1450 lines
63 KiB
JavaScript
// 统计数据可视化交互效果
|
||
|
||
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 设为0,opacity 设为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);
|
||
}
|