From 8ec1d16e9d9981085d57a8a859eb1209bf13431e Mon Sep 17 00:00:00 2001 From: snaily Date: Wed, 7 May 2025 13:58:05 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96=20JS=20=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E3=80=81API=20=E8=B0=83=E7=94=A8=E5=92=8C=E5=AF=86?= =?UTF-8?q?=E9=92=A5=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此次提交引入了重要的重构和改进: - 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 密钥状态管理的用户界面。 --- app/router/gemini_routes.py | 3 +- app/static/js/config_editor.js | 1100 ++++++++++++++++-------------- app/static/js/error_logs.js | 552 +++++++-------- app/static/js/keys_status.js | 671 +++++++++--------- app/templates/config_editor.html | 10 +- 5 files changed, 1175 insertions(+), 1161 deletions(-) diff --git a/app/router/gemini_routes.py b/app/router/gemini_routes.py index 3146403..d5edd74 100644 --- a/app/router/gemini_routes.py +++ b/app/router/gemini_routes.py @@ -328,7 +328,8 @@ async def verify_selected_keys( try: # 重用单密钥验证逻辑的核心部分 gemini_request = GeminiRequest( - contents=[GeminiContent(role="user", parts=[{"text": "hi"}])] + contents=[GeminiContent(role="user", parts=[{"text": "hi"}])], + generation_config={"temperature": 0.7, "top_p": 1.0, "max_output_tokens": 10} ) # 注意:这里直接调用 chat_service.generate_content,不依赖于 key_manager 获取密钥 await chat_service.generate_content( diff --git a/app/static/js/config_editor.js b/app/static/js/config_editor.js index 136eb5f..3915d90 100644 --- a/app/static/js/config_editor.js +++ b/app/static/js/config_editor.js @@ -1,23 +1,59 @@ -// 将需要在外部函数访问的 DOM 元素移到外部 +// Constants +const SENSITIVE_INPUT_CLASS = 'sensitive-input'; +const ARRAY_ITEM_CLASS = 'array-item'; +const ARRAY_INPUT_CLASS = 'array-input'; +const MAP_ITEM_CLASS = 'map-item'; +const MAP_KEY_INPUT_CLASS = 'map-key-input'; +const MAP_VALUE_INPUT_CLASS = 'map-value-input'; +const SAFETY_SETTING_ITEM_CLASS = 'safety-setting-item'; +const SHOW_CLASS = 'show'; // For modals +const API_KEY_REGEX = /AIzaSy\S{33}/g; +const PROXY_REGEX = /(?:https?|socks5):\/\/(?:[^:@\/]+(?::[^@\/]+)?@)?(?:[^:\/\s]+)(?::\d+)?/g; +const MASKED_VALUE = '••••••••'; + +// DOM Elements - Global Scope for frequently accessed elements const safetySettingsContainer = document.getElementById('SAFETY_SETTINGS_container'); const thinkingModelsContainer = document.getElementById('THINKING_MODELS_container'); +const apiKeyModal = document.getElementById('apiKeyModal'); +const apiKeyBulkInput = document.getElementById('apiKeyBulkInput'); +const apiKeySearchInput = document.getElementById('apiKeySearchInput'); +const bulkDeleteApiKeyModal = document.getElementById('bulkDeleteApiKeyModal'); +const bulkDeleteApiKeyInput = document.getElementById('bulkDeleteApiKeyInput'); +const proxyModal = document.getElementById('proxyModal'); +const proxyBulkInput = document.getElementById('proxyBulkInput'); +const bulkDeleteProxyModal = document.getElementById('bulkDeleteProxyModal'); +const bulkDeleteProxyInput = document.getElementById('bulkDeleteProxyInput'); +const resetConfirmModal = document.getElementById('resetConfirmModal'); +const configForm = document.getElementById('configForm'); // Added for frequent use + +// Modal Control Functions +function openModal(modalElement) { + if (modalElement) { + modalElement.classList.add(SHOW_CLASS); + } +} + +function closeModal(modalElement) { + if (modalElement) { + modalElement.classList.remove(SHOW_CLASS); + } +} document.addEventListener('DOMContentLoaded', function() { - // 初始化配置 + // Initialize configuration initConfig(); - // 标签切换 + // Tab switching const tabButtons = document.querySelectorAll('.tab-btn'); tabButtons.forEach(button => { button.addEventListener('click', function(e) { - // 防止事件冒泡 e.stopPropagation(); const tabId = this.getAttribute('data-tab'); switchTab(tabId); }); }); - // 上传提供商切换 + // Upload provider switching const uploadProviderSelect = document.getElementById('UPLOAD_PROVIDER'); if (uploadProviderSelect) { uploadProviderSelect.addEventListener('change', function() { @@ -25,11 +61,10 @@ document.addEventListener('DOMContentLoaded', function() { }); } - // 切换按钮事件 + // Toggle switch events const toggleSwitches = document.querySelectorAll('.toggle-switch'); toggleSwitches.forEach(toggleSwitch => { toggleSwitch.addEventListener('click', function(e) { - // 防止事件冒泡 e.stopPropagation(); const checkbox = this.querySelector('input[type="checkbox"]'); if (checkbox) { @@ -38,304 +73,311 @@ document.addEventListener('DOMContentLoaded', function() { }); }); - // 保存按钮 + // Save button const saveBtn = document.getElementById('saveBtn'); if (saveBtn) { saveBtn.addEventListener('click', saveConfig); } - // 重置按钮 + // Reset button const resetBtn = document.getElementById('resetBtn'); if (resetBtn) { - resetBtn.addEventListener('click', resetConfig); + resetBtn.addEventListener('click', resetConfig); // resetConfig will open the modal } - // 滚动按钮 + // Scroll buttons window.addEventListener('scroll', toggleScrollButtons); - // --- 新增:API Key 模态框和搜索相关 --- - const apiKeyModal = document.getElementById('apiKeyModal'); + // API Key Modal Elements and Events const addApiKeyBtn = document.getElementById('addApiKeyBtn'); const closeApiKeyModalBtn = document.getElementById('closeApiKeyModalBtn'); const cancelAddApiKeyBtn = document.getElementById('cancelAddApiKeyBtn'); const confirmAddApiKeyBtn = document.getElementById('confirmAddApiKeyBtn'); - const apiKeyBulkInput = document.getElementById('apiKeyBulkInput'); - const apiKeySearchInput = document.getElementById('apiKeySearchInput'); - const bulkDeleteApiKeyBtn = document.getElementById('bulkDeleteApiKeyBtn'); // 新增 - const bulkDeleteApiKeyModal = document.getElementById('bulkDeleteApiKeyModal'); // 新增 - const closeBulkDeleteModalBtn = document.getElementById('closeBulkDeleteModalBtn'); // 新增 - const cancelBulkDeleteApiKeyBtn = document.getElementById('cancelBulkDeleteApiKeyBtn'); // 新增 - const confirmBulkDeleteApiKeyBtn = document.getElementById('confirmBulkDeleteApiKeyBtn'); // 新增 - const bulkDeleteApiKeyInput = document.getElementById('bulkDeleteApiKeyInput'); // 新增 - - // --- 新增:Proxy 模态框相关 --- - const proxyModal = document.getElementById('proxyModal'); - const addProxyBtn = document.getElementById('addProxyBtn'); // Changed from bulkAddProxyBtn + + if (addApiKeyBtn) { + addApiKeyBtn.addEventListener('click', () => { + openModal(apiKeyModal); + if (apiKeyBulkInput) apiKeyBulkInput.value = ''; + }); + } + if (closeApiKeyModalBtn) closeApiKeyModalBtn.addEventListener('click', () => closeModal(apiKeyModal)); + if (cancelAddApiKeyBtn) cancelAddApiKeyBtn.addEventListener('click', () => closeModal(apiKeyModal)); + if (confirmAddApiKeyBtn) confirmAddApiKeyBtn.addEventListener('click', handleBulkAddApiKeys); + if (apiKeySearchInput) apiKeySearchInput.addEventListener('input', handleApiKeySearch); + + + // Bulk Delete API Key Modal Elements and Events + const bulkDeleteApiKeyBtn = document.getElementById('bulkDeleteApiKeyBtn'); + const closeBulkDeleteModalBtn = document.getElementById('closeBulkDeleteModalBtn'); + const cancelBulkDeleteApiKeyBtn = document.getElementById('cancelBulkDeleteApiKeyBtn'); + const confirmBulkDeleteApiKeyBtn = document.getElementById('confirmBulkDeleteApiKeyBtn'); + + if (bulkDeleteApiKeyBtn) { + bulkDeleteApiKeyBtn.addEventListener('click', () => { + openModal(bulkDeleteApiKeyModal); + if (bulkDeleteApiKeyInput) bulkDeleteApiKeyInput.value = ''; + }); + } + if (closeBulkDeleteModalBtn) closeBulkDeleteModalBtn.addEventListener('click', () => closeModal(bulkDeleteApiKeyModal)); + if (cancelBulkDeleteApiKeyBtn) cancelBulkDeleteApiKeyBtn.addEventListener('click', () => closeModal(bulkDeleteApiKeyModal)); + if (confirmBulkDeleteApiKeyBtn) confirmBulkDeleteApiKeyBtn.addEventListener('click', handleBulkDeleteApiKeys); + + + // Proxy Modal Elements and Events + const addProxyBtn = document.getElementById('addProxyBtn'); const closeProxyModalBtn = document.getElementById('closeProxyModalBtn'); const cancelAddProxyBtn = document.getElementById('cancelAddProxyBtn'); const confirmAddProxyBtn = document.getElementById('confirmAddProxyBtn'); - const proxyBulkInput = document.getElementById('proxyBulkInput'); - const bulkDeleteProxyBtn = document.getElementById('bulkDeleteProxyBtn'); // 新增 - const bulkDeleteProxyModal = document.getElementById('bulkDeleteProxyModal'); // 新增 - const closeBulkDeleteProxyModalBtn = document.getElementById('closeBulkDeleteProxyModalBtn'); // 新增 - const cancelBulkDeleteProxyBtn = document.getElementById('cancelBulkDeleteProxyBtn'); // 新增 - const confirmBulkDeleteProxyBtn = document.getElementById('confirmBulkDeleteProxyBtn'); // 新增 - const bulkDeleteProxyInput = document.getElementById('bulkDeleteProxyInput'); // 新增 - // --- 结束:Proxy 模态框相关 --- - - // --- 新增:重置确认模态框相关 --- - const resetConfirmModal = document.getElementById('resetConfirmModal'); + + if (addProxyBtn) { + addProxyBtn.addEventListener('click', () => { + openModal(proxyModal); + if (proxyBulkInput) proxyBulkInput.value = ''; + }); + } + if (closeProxyModalBtn) closeProxyModalBtn.addEventListener('click', () => closeModal(proxyModal)); + if (cancelAddProxyBtn) cancelAddProxyBtn.addEventListener('click', () => closeModal(proxyModal)); + if (confirmAddProxyBtn) confirmAddProxyBtn.addEventListener('click', handleBulkAddProxies); + + + // Bulk Delete Proxy Modal Elements and Events + const bulkDeleteProxyBtn = document.getElementById('bulkDeleteProxyBtn'); + const closeBulkDeleteProxyModalBtn = document.getElementById('closeBulkDeleteProxyModalBtn'); + const cancelBulkDeleteProxyBtn = document.getElementById('cancelBulkDeleteProxyBtn'); + const confirmBulkDeleteProxyBtn = document.getElementById('confirmBulkDeleteProxyBtn'); + + if (bulkDeleteProxyBtn) { + bulkDeleteProxyBtn.addEventListener('click', () => { + openModal(bulkDeleteProxyModal); + if (bulkDeleteProxyInput) bulkDeleteProxyInput.value = ''; + }); + } + if (closeBulkDeleteProxyModalBtn) closeBulkDeleteProxyModalBtn.addEventListener('click', () => closeModal(bulkDeleteProxyModal)); + if (cancelBulkDeleteProxyBtn) cancelBulkDeleteProxyBtn.addEventListener('click', () => closeModal(bulkDeleteProxyModal)); + if (confirmBulkDeleteProxyBtn) confirmBulkDeleteProxyBtn.addEventListener('click', handleBulkDeleteProxies); + + + // Reset Confirmation Modal Elements and Events const closeResetModalBtn = document.getElementById('closeResetModalBtn'); const cancelResetBtn = document.getElementById('cancelResetBtn'); const confirmResetBtn = document.getElementById('confirmResetBtn'); - // --- 结束:新增 --- - // const safetySettingsContainer = document.getElementById('SAFETY_SETTINGS_container'); // Moved outside - - // 打开模态框 - if (addApiKeyBtn) { - addApiKeyBtn.addEventListener('click', () => { - if (apiKeyModal) { - apiKeyModal.classList.add('show'); - } - if (apiKeyBulkInput) apiKeyBulkInput.value = ''; // 清空输入框 - }); - } - - // 关闭模态框 (X 按钮) - if (closeApiKeyModalBtn) { - closeApiKeyModalBtn.addEventListener('click', () => { - if (apiKeyModal) { - apiKeyModal.classList.remove('show'); - } - }); - } - - // 关闭模态框 (取消按钮) - if (cancelAddApiKeyBtn) { - cancelAddApiKeyBtn.addEventListener('click', () => { - if (apiKeyModal) { - apiKeyModal.classList.remove('show'); - } - }); - } - - // 点击模态框外部关闭 (处理两个模态框) - window.addEventListener('click', (event) => { - if (event.target == apiKeyModal) { - apiKeyModal.classList.remove('show'); - } - if (event.target == resetConfirmModal) { - resetConfirmModal.classList.remove('show'); - } - if (event.target == bulkDeleteApiKeyModal) { // 新增对批量删除模态框的处理 - bulkDeleteApiKeyModal.classList.remove('show'); - } - if (event.target == proxyModal) { // 新增对代理模态框的处理 - proxyModal.classList.remove('show'); - } - if (event.target == bulkDeleteProxyModal) { // 新增对批量删除代理模态框的处理 - bulkDeleteProxyModal.classList.remove('show'); - } - }); - - // 确认添加 API Key - if (confirmAddApiKeyBtn) { - confirmAddApiKeyBtn.addEventListener('click', handleBulkAddApiKeys); - } - - // API Key 搜索 (稍后实现具体逻辑) - if (apiKeySearchInput) { - apiKeySearchInput.addEventListener('input', handleApiKeySearch); - } - - // --- 新增:批量删除 API Key 相关事件 --- - // 打开批量删除模态框 - if (bulkDeleteApiKeyBtn) { - bulkDeleteApiKeyBtn.addEventListener('click', () => { - if (bulkDeleteApiKeyModal) { - bulkDeleteApiKeyModal.classList.add('show'); - } - if (bulkDeleteApiKeyInput) bulkDeleteApiKeyInput.value = ''; // 清空输入框 - }); - } - - // 关闭批量删除模态框 (X 按钮) - if (closeBulkDeleteModalBtn) { - closeBulkDeleteModalBtn.addEventListener('click', () => { - if (bulkDeleteApiKeyModal) { - bulkDeleteApiKeyModal.classList.remove('show'); - } - }); - } - - // 关闭批量删除模态框 (取消按钮) - if (cancelBulkDeleteApiKeyBtn) { - cancelBulkDeleteApiKeyBtn.addEventListener('click', () => { - if (bulkDeleteApiKeyModal) { - bulkDeleteApiKeyModal.classList.remove('show'); - } - }); - } - - // 确认批量删除 API Key - if (confirmBulkDeleteApiKeyBtn) { - confirmBulkDeleteApiKeyBtn.addEventListener('click', handleBulkDeleteApiKeys); - } - // --- 结束:批量删除 API Key 相关 --- - // --- 结束:API Key 相关 --- - - // --- 新增:Proxy 模态框事件 --- - // 打开模态框 (Changed event listener to addProxyBtn) - if (addProxyBtn) { - addProxyBtn.addEventListener('click', () => { - if (proxyModal) { - proxyModal.classList.add('show'); - } - if (proxyBulkInput) proxyBulkInput.value = ''; // 清空输入框 - }); - } - - // 关闭模态框 (X 按钮) - if (closeProxyModalBtn) { - closeProxyModalBtn.addEventListener('click', () => { - if (proxyModal) { - proxyModal.classList.remove('show'); - } - }); - } - - // 关闭模态框 (取消按钮) - if (cancelAddProxyBtn) { - cancelAddProxyBtn.addEventListener('click', () => { - if (proxyModal) { - proxyModal.classList.remove('show'); - } - }); - } - - // 确认添加 Proxy - if (confirmAddProxyBtn) { - confirmAddProxyBtn.addEventListener('click', handleBulkAddProxies); - } - // --- 结束:Proxy 模态框事件 --- - - // --- 新增:批量删除 Proxy 相关事件 --- - // 打开批量删除模态框 - if (bulkDeleteProxyBtn) { - bulkDeleteProxyBtn.addEventListener('click', () => { - if (bulkDeleteProxyModal) { - bulkDeleteProxyModal.classList.add('show'); - } - if (bulkDeleteProxyInput) bulkDeleteProxyInput.value = ''; // 清空输入框 - }); - } - - // 关闭批量删除模态框 (X 按钮) - if (closeBulkDeleteProxyModalBtn) { - closeBulkDeleteProxyModalBtn.addEventListener('click', () => { - if (bulkDeleteProxyModal) { - bulkDeleteProxyModal.classList.remove('show'); - } - }); - } - - // 关闭批量删除模态框 (取消按钮) - if (cancelBulkDeleteProxyBtn) { - cancelBulkDeleteProxyBtn.addEventListener('click', () => { - if (bulkDeleteProxyModal) { - bulkDeleteProxyModal.classList.remove('show'); - } - }); - } - - // 确认批量删除 Proxy - if (confirmBulkDeleteProxyBtn) { - confirmBulkDeleteProxyBtn.addEventListener('click', handleBulkDeleteProxies); - } - // --- 结束:批量删除 Proxy 相关 --- - - // --- 新增:重置确认模态框事件监听 (移到 DOMContentLoaded 内部) --- - if (closeResetModalBtn) { - closeResetModalBtn.addEventListener('click', () => { - if (resetConfirmModal) { - resetConfirmModal.classList.remove('show'); - } - }); - } - if (cancelResetBtn) { - cancelResetBtn.addEventListener('click', () => { - if (resetConfirmModal) { - resetConfirmModal.classList.remove('show'); - } - }); - } + if (closeResetModalBtn) closeResetModalBtn.addEventListener('click', () => closeModal(resetConfirmModal)); + if (cancelResetBtn) cancelResetBtn.addEventListener('click', () => closeModal(resetConfirmModal)); if (confirmResetBtn) { - // 调用之前定义的 executeReset 函数 confirmResetBtn.addEventListener('click', () => { - if (resetConfirmModal) { - resetConfirmModal.classList.remove('show'); // 关闭模态框 - } - executeReset(); // 执行重置逻辑 + closeModal(resetConfirmModal); + executeReset(); }); } - // --- 结束:重置相关 --- - // 移除了静态生成令牌按钮的事件监听器,现在按钮是动态生成的 + // Click outside modal to close + window.addEventListener('click', (event) => { + const modals = [apiKeyModal, resetConfirmModal, bulkDeleteApiKeyModal, proxyModal, bulkDeleteProxyModal]; + modals.forEach(modal => { + if (event.target === modal) { + closeModal(modal); + } + }); + }); - // 认证令牌生成按钮事件绑定 + // Removed static token generation button event listener, now handled dynamically if needed or by specific buttons. + + // Authentication token generation button const generateAuthTokenBtn = document.getElementById('generateAuthTokenBtn'); const authTokenInput = document.getElementById('AUTH_TOKEN'); if (generateAuthTokenBtn && authTokenInput) { generateAuthTokenBtn.addEventListener('click', function() { - const newToken = generateRandomToken(); + const newToken = generateRandomToken(); // Assuming generateRandomToken is defined elsewhere authTokenInput.value = newToken; + if (authTokenInput.classList.contains(SENSITIVE_INPUT_CLASS)) { + const event = new Event('focusout', { bubbles: true, cancelable: true }); + authTokenInput.dispatchEvent(event); + } showNotification('已生成新认证令牌', 'success'); }); } - // --- 修改:思考模型预算映射不再需要手动添加按钮 --- - // const addBudgetMapItemBtn = document.getElementById('addBudgetMapItemBtn'); - // if (addBudgetMapItemBtn) { - // addBudgetMapItemBtn.addEventListener('click', addBudgetMapItem); - // } - // --- 结束:思考模型预算映射相关 --- - - // 添加事件委托,处理动态添加的 THINKING_MODELS 输入框的 input 事件 - // const thinkingModelsContainer = document.getElementById('THINKING_MODELS_container'); // Moved outside + // Event delegation for THINKING_MODELS input changes to update budget map keys if (thinkingModelsContainer) { thinkingModelsContainer.addEventListener('input', function(event) { - if (event.target && event.target.classList.contains('array-input') && event.target.closest('.array-item[data-model-id]')) { - const modelInput = event.target; - const modelId = modelInput.closest('.array-item').getAttribute('data-model-id'); - const budgetKeyInput = document.querySelector(`.map-key-input[data-model-id="${modelId}"]`); + const target = event.target; + if (target && target.classList.contains(ARRAY_INPUT_CLASS) && target.closest(`.${ARRAY_ITEM_CLASS}[data-model-id]`)) { + const modelInput = target; + const modelItem = modelInput.closest(`.${ARRAY_ITEM_CLASS}`); + const modelId = modelItem.getAttribute('data-model-id'); + const budgetKeyInput = document.querySelector(`.${MAP_KEY_INPUT_CLASS}[data-model-id="${modelId}"]`); if (budgetKeyInput) { budgetKeyInput.value = modelInput.value; } } }); } + + // Event delegation for dynamically added remove buttons and generate token buttons within array items + if(configForm) { // Ensure configForm exists before adding event listener + configForm.addEventListener('click', function(event) { + const target = event.target; + const removeButton = target.closest('.remove-btn'); + const generateButton = target.closest('.generate-btn'); - // --- 新增:安全设置相关 --- + if (removeButton && removeButton.closest(`.${ARRAY_ITEM_CLASS}`)) { + const arrayItem = removeButton.closest(`.${ARRAY_ITEM_CLASS}`); + const parentContainer = arrayItem.parentElement; + const isThinkingModelItem = arrayItem.hasAttribute('data-model-id') && parentContainer && parentContainer.id === 'THINKING_MODELS_container'; + const isSafetySettingItem = arrayItem.classList.contains(SAFETY_SETTING_ITEM_CLASS); + + if (isThinkingModelItem) { + const modelId = arrayItem.getAttribute('data-model-id'); + const budgetMapItem = document.querySelector(`.${MAP_ITEM_CLASS}[data-model-id="${modelId}"]`); + if (budgetMapItem) { + budgetMapItem.remove(); + } + // Check and add placeholder for budget map if empty + const budgetContainer = document.getElementById('THINKING_BUDGET_MAP_container'); + if (budgetContainer && budgetContainer.children.length === 0) { + budgetContainer.innerHTML = '
请在上方添加思考模型,预算将自动关联。
'; + } + } + arrayItem.remove(); + // Check and add placeholder for safety settings if empty + if (isSafetySettingItem && parentContainer && parentContainer.children.length === 0) { + parentContainer.innerHTML = '
定义模型的安全过滤阈值。
'; + } + } else if (generateButton && generateButton.closest(`.${ARRAY_ITEM_CLASS}`)) { + const inputField = generateButton.closest(`.${ARRAY_ITEM_CLASS}`).querySelector(`.${ARRAY_INPUT_CLASS}`); + if (inputField) { + const newToken = generateRandomToken(); + inputField.value = newToken; + if (inputField.classList.contains(SENSITIVE_INPUT_CLASS)) { + const event = new Event('focusout', { bubbles: true, cancelable: true }); + inputField.dispatchEvent(event); + } + showNotification('已生成新令牌', 'success'); + } + } + }); + } + + + // Add Safety Setting button const addSafetySettingBtn = document.getElementById('addSafetySettingBtn'); if (addSafetySettingBtn) { addSafetySettingBtn.addEventListener('click', () => addSafetySettingItem()); } - // --- 结束:安全设置相关 --- -}); // <-- DOMContentLoaded 结束括号 + initializeSensitiveFields(); // Initialize sensitive field handling +}); // <-- DOMContentLoaded end -// --- 新增:生成唯一ID --- +/** + * Initializes sensitive input field behavior (masking/unmasking). + */ +function initializeSensitiveFields() { + if (!configForm) return; + + // Helper function: Mask field + function maskField(field) { + if (field.value && field.value !== MASKED_VALUE) { + field.setAttribute('data-real-value', field.value); + field.value = MASKED_VALUE; + } else if (!field.value) { // If field value is empty string + field.removeAttribute('data-real-value'); + // Ensure empty value doesn't show as asterisks + if (field.value === MASKED_VALUE) field.value = ''; + } + } + + // Helper function: Unmask field + function unmaskField(field) { + if (field.hasAttribute('data-real-value')) { + field.value = field.getAttribute('data-real-value'); + } + // If no data-real-value and value is MASKED_VALUE, it might be an initial empty sensitive field, clear it + else if (field.value === MASKED_VALUE && !field.hasAttribute('data-real-value')) { + field.value = ''; + } + } + + // Initial masking for existing sensitive fields on page load + // This function is called after populateForm and after dynamic element additions (via event delegation) + function initialMaskAllExisting() { + const sensitiveFields = configForm.querySelectorAll(`.${SENSITIVE_INPUT_CLASS}`); + sensitiveFields.forEach(field => { + if (field.type === 'password') { + // For password fields, browser handles it. We just ensure data-original-type is set + // and if it has a value, we also store data-real-value so it can be shown when switched to text + if (field.value) { + field.setAttribute('data-real-value', field.value); + } + // No need to set to MASKED_VALUE as browser handles it. + } else if (field.type === 'text' || field.tagName.toLowerCase() === 'textarea') { + maskField(field); + } + }); + } + initialMaskAllExisting(); + + + // Event delegation for dynamic and static fields + configForm.addEventListener('focusin', function(event) { + const target = event.target; + if (target.classList.contains(SENSITIVE_INPUT_CLASS)) { + if (target.type === 'password') { + // Record original type to switch back on blur + if (!target.hasAttribute('data-original-type')) { + target.setAttribute('data-original-type', 'password'); + } + target.type = 'text'; // Switch to text type to show content + // If data-real-value exists (e.g., set during populateForm), use it + if (target.hasAttribute('data-real-value')) { + target.value = target.getAttribute('data-real-value'); + } + // Otherwise, the browser's existing password value will be shown directly + } else { // For type="text" or textarea + unmaskField(target); + } + } + }); + + configForm.addEventListener('focusout', function(event) { + const target = event.target; + if (target.classList.contains(SENSITIVE_INPUT_CLASS)) { + // First, if the field is currently text and has a value, update data-real-value + if (target.type === 'text' || target.tagName.toLowerCase() === 'textarea') { + if (target.value && target.value !== MASKED_VALUE) { + target.setAttribute('data-real-value', target.value); + } else if (!target.value) { // If value is empty, remove data-real-value + target.removeAttribute('data-real-value'); + } + } + + // Then handle type switching and masking + if (target.getAttribute('data-original-type') === 'password' && target.type === 'text') { + target.type = 'password'; // Switch back to password type + // For password type, browser handles masking automatically, no need to set MASKED_VALUE manually + // data-real-value has already been updated by the logic above + } else if (target.type === 'text' || target.tagName.toLowerCase() === 'textarea') { + // For text or textarea sensitive fields, perform masking + maskField(target); + } + } + }); +} + +/** + * Generates a UUID. + * @returns {string} A new UUID. + */ function generateUUID() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); } -// --- 结束:生成唯一ID --- - -// 初始化配置 +/** + * Initializes the configuration by fetching it from the server and populating the form. + */ async function initConfig() { try { showNotification('正在加载配置...', 'info'); @@ -385,8 +427,12 @@ async function initConfig() { // --- 结束:处理 SAFETY_SETTINGS 默认值 --- populateForm(config); + // After populateForm, initialize masking for all populated sensitive fields + if (configForm) { // Ensure form exists + initializeSensitiveFields(); // Call initializeSensitiveFields to handle initial masking + } - // 确保上传提供商有默认值 + // Ensure upload provider has a default value const uploadProvider = document.getElementById('UPLOAD_PROVIDER'); if (uploadProvider && !uploadProvider.value) { uploadProvider.value = 'smms'; // 设置默认值为 smms @@ -412,11 +458,17 @@ async function initConfig() { }; populateForm(defaultConfig); + if (configForm) { // Ensure form exists + initializeSensitiveFields(); // Call initializeSensitiveFields to handle initial masking + } toggleProviderConfig('smms'); } } -// 填充表单 +/** + * Populates the configuration form with data. + * @param {object} config - The configuration object. + */ function populateForm(config) { const modelIdMap = {}; // modelName -> modelId @@ -440,7 +492,6 @@ function populateForm(config) { config.THINKING_MODELS.forEach(modelName => { if (modelName && typeof modelName === 'string' && modelName.trim()) { const trimmedModelName = modelName.trim(); - // Call addArrayItemWithValue to add the model DOM element and get its ID const modelId = addArrayItemWithValue('THINKING_MODELS', trimmedModelName); if (modelId) { modelIdMap[trimmedModelName] = modelId; @@ -464,11 +515,9 @@ function populateForm(config) { const trimmedModelName = modelName.trim(); const modelId = modelIdMap[trimmedModelName]; // Look up the ID if (modelId) { - // Call the function specifically designed to add ONLY the budget map DOM element createAndAppendBudgetMapItem(trimmedModelName, budgetValue, modelId); budgetItemsAdded = true; } else { - // Log if a budget entry exists but its corresponding model wasn't found/added console.warn(`Budget map: Could not find model ID for '${trimmedModelName}'. Skipping budget item.`); } } else { @@ -476,7 +525,6 @@ function populateForm(config) { } } } - // Add placeholder only if no budget items were successfully added if (!budgetItemsAdded && budgetMapContainer) { budgetMapContainer.innerHTML = '
请在上方添加思考模型,预算将自动关联。
'; } @@ -486,10 +534,9 @@ function populateForm(config) { if (Array.isArray(value) && key !== 'THINKING_MODELS') { const container = document.getElementById(`${key}_container`); if (container) { - // Container already cleared, just add items value.forEach(itemValue => { if (typeof itemValue === 'string') { - addArrayItemWithValue(key, itemValue); // This adds non-thinking model array items + addArrayItemWithValue(key, itemValue); } else { console.warn(`Invalid item found in array '${key}':`, itemValue); } @@ -522,7 +569,7 @@ function populateForm(config) { toggleProviderConfig(uploadProvider.value); } - // --- 新增:填充 SAFETY_SETTINGS --- + // Populate SAFETY_SETTINGS let safetyItemsAdded = false; if (safetySettingsContainer && Array.isArray(config.SAFETY_SETTINGS)) { config.SAFETY_SETTINGS.forEach(setting => { @@ -534,209 +581,179 @@ function populateForm(config) { } }); } - // 如果没有添加任何安全设置项,则显示占位符 if (safetySettingsContainer && !safetyItemsAdded) { safetySettingsContainer.innerHTML = '
定义模型的安全过滤阈值。
'; } - // --- 结束:填充 SAFETY_SETTINGS --- } -// --- 新增:处理批量添加 API Key 的逻辑 --- +/** + * Handles the bulk addition of API keys from the modal input. + */ function handleBulkAddApiKeys() { - const apiKeyBulkInput = document.getElementById('apiKeyBulkInput'); const apiKeyContainer = document.getElementById('API_KEYS_container'); - const apiKeyModal = document.getElementById('apiKeyModal'); - if (!apiKeyBulkInput || !apiKeyContainer || !apiKeyModal) return; const bulkText = apiKeyBulkInput.value; - const keyRegex = /AIzaSy\S{33}/g; // 全局匹配 - const extractedKeys = bulkText.match(keyRegex) || []; + const extractedKeys = bulkText.match(API_KEY_REGEX) || []; - // 获取当前已有的 keys - const currentKeyInputs = apiKeyContainer.querySelectorAll('.array-input'); - const currentKeys = Array.from(currentKeyInputs).map(input => input.value).filter(key => key.trim() !== ''); + const currentKeyInputs = apiKeyContainer.querySelectorAll(`.${ARRAY_INPUT_CLASS}.${SENSITIVE_INPUT_CLASS}`); + let currentKeys = Array.from(currentKeyInputs).map(input => { + return input.hasAttribute('data-real-value') ? input.getAttribute('data-real-value') : input.value; + }).filter(key => key && key.trim() !== '' && key !== MASKED_VALUE); - // 合并并去重 const combinedKeys = new Set([...currentKeys, ...extractedKeys]); const uniqueKeys = Array.from(combinedKeys); - // 清空现有列表显示 - const existingItems = apiKeyContainer.querySelectorAll('.array-item'); - existingItems.forEach(item => item.remove()); + apiKeyContainer.innerHTML = ''; // Clear existing items more directly - // 重新填充列表 uniqueKeys.forEach(key => { addArrayItemWithValue('API_KEYS', key); }); + + const newKeyInputs = apiKeyContainer.querySelectorAll(`.${ARRAY_INPUT_CLASS}.${SENSITIVE_INPUT_CLASS}`); + newKeyInputs.forEach(input => { + if (configForm && typeof initializeSensitiveFields === 'function') { + const focusoutEvent = new Event('focusout', { bubbles: true, cancelable: true }); + input.dispatchEvent(focusoutEvent); + } + }); - // 关闭模态框 - apiKeyModal.classList.remove('show'); + closeModal(apiKeyModal); showNotification(`添加/更新了 ${uniqueKeys.length} 个唯一密钥`, 'success'); } -// --- 新增:处理 API Key 搜索的逻辑 --- +/** + * Handles searching/filtering of API keys in the list. + */ function handleApiKeySearch() { - const apiKeySearchInput = document.getElementById('apiKeySearchInput'); const apiKeyContainer = document.getElementById('API_KEYS_container'); - if (!apiKeySearchInput || !apiKeyContainer) return; const searchTerm = apiKeySearchInput.value.toLowerCase(); - const keyItems = apiKeyContainer.querySelectorAll('.array-item'); + const keyItems = apiKeyContainer.querySelectorAll(`.${ARRAY_ITEM_CLASS}`); keyItems.forEach(item => { - const input = item.querySelector('.array-input'); + const input = item.querySelector(`.${ARRAY_INPUT_CLASS}.${SENSITIVE_INPUT_CLASS}`); if (input) { - const key = input.value.toLowerCase(); - if (key.includes(searchTerm)) { - item.style.display = 'flex'; // 或者 'block',取决于你的布局 - } else { - item.style.display = 'none'; - } + const realValue = input.hasAttribute('data-real-value') ? input.getAttribute('data-real-value').toLowerCase() : input.value.toLowerCase(); + item.style.display = realValue.includes(searchTerm) ? 'flex' : 'none'; } }); } -// --- 新增:处理批量删除 API Key 的逻辑 --- +/** + * Handles the bulk deletion of API keys based on input from the modal. + */ function handleBulkDeleteApiKeys() { - const bulkDeleteTextarea = document.getElementById('bulkDeleteApiKeyInput'); // Use the textarea ID const apiKeyContainer = document.getElementById('API_KEYS_container'); - const bulkDeleteModal = document.getElementById('bulkDeleteApiKeyModal'); + if (!bulkDeleteApiKeyInput || !apiKeyContainer || !bulkDeleteApiKeyModal) return; - if (!bulkDeleteTextarea || !apiKeyContainer || !bulkDeleteModal) return; - - const bulkText = bulkDeleteTextarea.value; + const bulkText = bulkDeleteApiKeyInput.value; if (!bulkText.trim()) { showNotification('请粘贴需要删除的 API 密钥', 'warning'); return; } - // Use the same regex as for adding keys to extract keys to delete - const keyRegex = /AIzaSy\S{33}/g; - const keysToDelete = new Set(bulkText.match(keyRegex) || []); // Create a Set for efficient lookup + const keysToDelete = new Set(bulkText.match(API_KEY_REGEX) || []); if (keysToDelete.size === 0) { showNotification('未在输入内容中提取到有效的 API 密钥格式', 'warning'); - // Optionally clear the textarea or keep it as is - // bulkDeleteTextarea.value = ''; return; } - const keyItems = apiKeyContainer.querySelectorAll('.array-item'); + const keyItems = apiKeyContainer.querySelectorAll(`.${ARRAY_ITEM_CLASS}`); let deleteCount = 0; keyItems.forEach(item => { - const input = item.querySelector('.array-input'); - // Check if the input exists and its value is in the set of keys to delete - if (input && keysToDelete.has(input.value)) { - item.remove(); // Remove the entire array item element + const input = item.querySelector(`.${ARRAY_INPUT_CLASS}.${SENSITIVE_INPUT_CLASS}`); + const realValue = input && (input.hasAttribute('data-real-value') ? input.getAttribute('data-real-value') : input.value); + if (realValue && keysToDelete.has(realValue)) { + item.remove(); deleteCount++; } }); - // Close the modal - bulkDeleteModal.classList.remove('show'); + closeModal(bulkDeleteApiKeyModal); - // Provide feedback if (deleteCount > 0) { showNotification(`成功删除了 ${deleteCount} 个匹配的密钥`, 'success'); } else { - // This message implies keys were extracted but not found in the current list showNotification('列表中未找到您输入的任何密钥进行删除', 'info'); } - - // Clear the textarea after processing - bulkDeleteTextarea.value = ''; + bulkDeleteApiKeyInput.value = ''; } -// --- 新增:处理批量添加 Proxy 的逻辑 --- +/** + * Handles the bulk addition of proxies from the modal input. + */ function handleBulkAddProxies() { - const proxyBulkInput = document.getElementById('proxyBulkInput'); const proxyContainer = document.getElementById('PROXIES_container'); - const proxyModal = document.getElementById('proxyModal'); - if (!proxyBulkInput || !proxyContainer || !proxyModal) return; const bulkText = proxyBulkInput.value; - // 匹配 http(s):// 或 socks5:// 格式的代理,允许包含用户名密码 - const proxyRegex = /(?:https?|socks5):\/\/(?:[^:@\/]+(?::[^@\/]+)?@)?(?:[^:\/\s]+)(?::\d+)?/g; - const extractedProxies = bulkText.match(proxyRegex) || []; + const extractedProxies = bulkText.match(PROXY_REGEX) || []; - // 获取当前已有的 proxies - const currentProxyInputs = proxyContainer.querySelectorAll('.array-input'); + const currentProxyInputs = proxyContainer.querySelectorAll(`.${ARRAY_INPUT_CLASS}`); const currentProxies = Array.from(currentProxyInputs).map(input => input.value).filter(proxy => proxy.trim() !== ''); - // 合并并去重 const combinedProxies = new Set([...currentProxies, ...extractedProxies]); const uniqueProxies = Array.from(combinedProxies); - // 清空现有列表显示 - const existingItems = proxyContainer.querySelectorAll('.array-item'); - existingItems.forEach(item => item.remove()); + proxyContainer.innerHTML = ''; // Clear existing items - // 重新填充列表 uniqueProxies.forEach(proxy => { addArrayItemWithValue('PROXIES', proxy); }); - // 关闭模态框 - proxyModal.classList.remove('show'); + closeModal(proxyModal); showNotification(`添加/更新了 ${uniqueProxies.length} 个唯一代理`, 'success'); } -// --- 结束:处理批量添加 Proxy 的逻辑 --- -// --- 新增:处理批量删除 Proxy 的逻辑 --- +/** + * Handles the bulk deletion of proxies based on input from the modal. + */ function handleBulkDeleteProxies() { - const bulkDeleteTextarea = document.getElementById('bulkDeleteProxyInput'); const proxyContainer = document.getElementById('PROXIES_container'); - const bulkDeleteModal = document.getElementById('bulkDeleteProxyModal'); + if (!bulkDeleteProxyInput || !proxyContainer || !bulkDeleteProxyModal) return; - if (!bulkDeleteTextarea || !proxyContainer || !bulkDeleteModal) return; - - const bulkText = bulkDeleteTextarea.value; + const bulkText = bulkDeleteProxyInput.value; if (!bulkText.trim()) { showNotification('请粘贴需要删除的代理地址', 'warning'); return; } - // 使用与添加时相同的正则表达式来提取要删除的代理 - const proxyRegex = /(?:https?|socks5):\/\/(?:[^:@\/]+(?::[^@\/]+)?@)?(?:[^:\/\s]+)(?::\d+)?/g; - const proxiesToDelete = new Set(bulkText.match(proxyRegex) || []); // 使用 Set 进行高效查找 + const proxiesToDelete = new Set(bulkText.match(PROXY_REGEX) || []); if (proxiesToDelete.size === 0) { showNotification('未在输入内容中提取到有效的代理地址格式', 'warning'); return; } - const proxyItems = proxyContainer.querySelectorAll('.array-item'); + const proxyItems = proxyContainer.querySelectorAll(`.${ARRAY_ITEM_CLASS}`); let deleteCount = 0; proxyItems.forEach(item => { - const input = item.querySelector('.array-input'); - // 检查输入框是否存在及其值是否在要删除的集合中 + const input = item.querySelector(`.${ARRAY_INPUT_CLASS}`); if (input && proxiesToDelete.has(input.value)) { - item.remove(); // 删除整个数组项元素 + item.remove(); deleteCount++; } }); - // 关闭模态框 - bulkDeleteModal.classList.remove('show'); + closeModal(bulkDeleteProxyModal); - // 提供反馈 if (deleteCount > 0) { showNotification(`成功删除了 ${deleteCount} 个匹配的代理`, 'success'); } else { showNotification('列表中未找到您输入的任何代理进行删除', 'info'); } - - // 处理后清空文本区域 - bulkDeleteTextarea.value = ''; + bulkDeleteProxyInput.value = ''; } -// --- 结束:处理批量删除 Proxy 的逻辑 --- -// 切换标签 +/** + * Switches the active configuration tab. + * @param {string} tabId - The ID of the tab to switch to. + */ function switchTab(tabId) { // 更新标签按钮状态 const tabButtons = document.querySelectorAll('.tab-btn'); @@ -763,7 +780,10 @@ function switchTab(tabId) { }); } -// 切换上传提供商配置 +/** + * Toggles the visibility of configuration sections for different upload providers. + * @param {string} provider - The selected upload provider. + */ function toggleProviderConfig(provider) { const providerConfigs = document.querySelectorAll('.provider-config'); providerConfigs.forEach(config => { @@ -775,113 +795,137 @@ function toggleProviderConfig(provider) { }); } +/** + * Creates and appends an input field for an array item. + * @param {string} key - The configuration key for the array. + * @param {string} value - The initial value for the input field. + * @param {boolean} isSensitive - Whether the input is for sensitive data. + * @param {string|null} modelId - Optional model ID for thinking models. + * @returns {HTMLInputElement} The created input element. + */ +function createArrayInput(key, value, isSensitive, modelId = null) { + const input = document.createElement('input'); + input.type = 'text'; + input.name = `${key}[]`; // Used for form submission if not handled by JS + input.value = value; + let inputClasses = `${ARRAY_INPUT_CLASS} flex-grow px-3 py-2 border-none rounded-l-md focus:outline-none`; + if (isSensitive) { + inputClasses += ` ${SENSITIVE_INPUT_CLASS}`; + } + input.className = inputClasses; + if (modelId) { + input.setAttribute('data-model-id', modelId); + input.placeholder = '思考模型名称'; + } + return input; +} -// 添加数组项 +/** + * Creates a generate token button for allowed tokens. + * @returns {HTMLButtonElement} The created button element. + */ +function createGenerateTokenButton() { + const generateBtn = document.createElement('button'); + generateBtn.type = 'button'; + generateBtn.className = 'generate-btn px-2 py-2 text-gray-500 hover:text-primary-600 focus:outline-none rounded-r-md bg-gray-100 hover:bg-gray-200 transition-colors'; + generateBtn.innerHTML = ''; + generateBtn.title = '生成随机令牌'; + // Event listener will be added via delegation in DOMContentLoaded + return generateBtn; +} + +/** + * Creates a remove button for an array item. + * @returns {HTMLButtonElement} The created button element. + */ +function createRemoveButton() { + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'remove-btn text-gray-400 hover:text-red-500 focus:outline-none transition-colors duration-150'; + removeBtn.innerHTML = ''; + removeBtn.title = '删除'; + // Event listener will be added via delegation in DOMContentLoaded + return removeBtn; +} + + +/** + * Adds a new item to an array configuration section (e.g., API_KEYS, ALLOWED_TOKENS). + * This function is typically called by a "+" button. + * @param {string} key - The configuration key for the array (e.g., 'API_KEYS'). + */ function addArrayItem(key) { const container = document.getElementById(`${key}_container`); if (!container) return; - const newItemValue = ''; // Start with an empty value for new items - const modelId = addArrayItemWithValue(key, newItemValue); // Add the DOM element + const newItemValue = ''; // New items start empty + const modelId = addArrayItemWithValue(key, newItemValue); // This adds the DOM element - // If it's a thinking model, also add the corresponding budget map item if (key === 'THINKING_MODELS' && modelId) { createAndAppendBudgetMapItem(newItemValue, 0, modelId); // Default budget 0 } } -// 添加带值的数组项 (Adds array item DOM, returns modelId if it's a thinking model) + +/** + * Adds an array item with a specific value to the DOM. + * This is used both for initially populating the form and for adding new items. + * @param {string} key - The configuration key (e.g., 'API_KEYS', 'THINKING_MODELS'). + * @param {string} value - The value for the array item. + * @returns {string|null} The generated modelId if it's a thinking model, otherwise null. + */ function addArrayItemWithValue(key, value) { const container = document.getElementById(`${key}_container`); if (!container) return null; const isThinkingModel = key === 'THINKING_MODELS'; + const isAllowedToken = key === 'ALLOWED_TOKENS'; + const isSensitive = key === 'API_KEYS' || isAllowedToken; const modelId = isThinkingModel ? generateUUID() : null; const arrayItem = document.createElement('div'); - // 主容器使用 Flexbox - arrayItem.className = 'array-item flex items-center mb-2 gap-2'; // 添加 gap-2 来分隔元素 + arrayItem.className = `${ARRAY_ITEM_CLASS} flex items-center mb-2 gap-2`; if (isThinkingModel) { - arrayItem.setAttribute('data-model-id', modelId); // 添加ID属性 + arrayItem.setAttribute('data-model-id', modelId); } - - // 创建一个包装器 div 来包含输入框和生成按钮 const inputWrapper = document.createElement('div'); - // 这个包装器占据主要空间,并使用 Flexbox inputWrapper.className = 'flex items-center flex-grow border border-gray-300 rounded-md focus-within:border-primary-500 focus-within:ring focus-within:ring-primary-200 focus-within:ring-opacity-50'; - const input = document.createElement('input'); - input.type = 'text'; - input.name = `${key}[]`; - input.value = value; - // 输入框占据包装器内的主要空间,移除边框和圆角,因为包装器已有 - input.className = 'array-input flex-grow px-3 py-2 border-none rounded-l-md focus:outline-none'; // 移除右侧圆角 - if (isThinkingModel) { - input.setAttribute('data-model-id', modelId); // 添加ID属性 - input.placeholder = '思考模型名称'; // 添加占位符 - } + const input = createArrayInput(key, value, isSensitive, isThinkingModel ? modelId : null); + inputWrapper.appendChild(input); - - inputWrapper.appendChild(input); // 将输入框添加到包装器 - - // 只为 ALLOWED_TOKENS 添加生成按钮 - if (key === 'ALLOWED_TOKENS') { - const generateBtn = document.createElement('button'); - generateBtn.type = 'button'; - // 按钮样式,放在输入框右侧,有背景和内边距,调整颜色 - generateBtn.className = 'generate-btn px-2 py-2 text-gray-500 hover:text-primary-600 focus:outline-none rounded-r-md bg-gray-100 hover:bg-gray-200 transition-colors'; // 添加背景和右侧圆角 - generateBtn.innerHTML = ''; - generateBtn.title = '生成随机令牌'; - generateBtn.addEventListener('click', function() { - const newToken = generateRandomToken(); - input.value = newToken; - showNotification('已生成新令牌', 'success'); - }); - inputWrapper.appendChild(generateBtn); // 将生成按钮添加到包装器 + if (isAllowedToken) { + const generateBtn = createGenerateTokenButton(); + inputWrapper.appendChild(generateBtn); } else { - // 如果不是 ALLOWED_TOKENS,确保输入框有右侧圆角 + // Ensure right-side rounding if no button is present input.classList.add('rounded-r-md'); } + + const removeBtn = createRemoveButton(); - const removeBtn = document.createElement('button'); - removeBtn.type = 'button'; - // 删除按钮样式,保持不变 - removeBtn.className = 'remove-btn text-gray-400 hover:text-red-500 focus:outline-none transition-colors duration-150'; - removeBtn.innerHTML = ''; - removeBtn.title = '删除'; - removeBtn.addEventListener('click', function() { - const currentArrayItem = this.closest('.array-item'); - if (isThinkingModel) { - const currentModelId = currentArrayItem.getAttribute('data-model-id'); - // 查找并删除对应的预算映射项 - const budgetMapItem = document.querySelector(`.map-item[data-model-id="${currentModelId}"]`); - if (budgetMapItem) { - budgetMapItem.remove(); - // 检查预算映射容器是否为空,如果是,则添加回占位符 - const budgetContainer = document.getElementById('THINKING_BUDGET_MAP_container'); - if (budgetContainer && budgetContainer.children.length === 0) { - budgetContainer.innerHTML = '
请在上方添加思考模型,预算将自动关联。
'; - } - } - } - currentArrayItem.remove(); // 删除模型项本身 - }); - - // 将包装器(包含输入框和可能的生成按钮)和删除按钮添加到主容器 arrayItem.appendChild(inputWrapper); arrayItem.appendChild(removeBtn); - - // 插入到容器末尾 container.appendChild(arrayItem); - // 返回生成的 ID (如果是思考模型) 或 null + // Initialize sensitive field if applicable + if (isSensitive && input.value) { + if (configForm && typeof initializeSensitiveFields === 'function') { + const focusoutEvent = new Event('focusout', { bubbles: true, cancelable: true }); + input.dispatchEvent(focusoutEvent); + } + } return isThinkingModel ? modelId : null; - // Note: This function no longer automatically calls createAndAppendBudgetMapItem } -// --- 新增:专门用于创建和添加预算映射 DOM 元素 --- +/** + * Creates and appends a DOM element for a thinking model's budget mapping. + * @param {string} mapKey - The model name (key for the map). + * @param {number|string} mapValue - The budget value. + * @param {string} modelId - The unique ID of the corresponding thinking model. + */ function createAndAppendBudgetMapItem(mapKey, mapValue, modelId) { const container = document.getElementById('THINKING_BUDGET_MAP_container'); if (!container) { @@ -897,37 +941,33 @@ function createAndAppendBudgetMapItem(mapKey, mapValue, modelId) { } const mapItem = document.createElement('div'); - mapItem.className = 'map-item flex items-center mb-2 gap-2'; - mapItem.setAttribute('data-model-id', modelId); // Add ID attribute + mapItem.className = `${MAP_ITEM_CLASS} flex items-center mb-2 gap-2`; + mapItem.setAttribute('data-model-id', modelId); - // Key Input (Model Name) - Read-only const keyInput = document.createElement('input'); keyInput.type = 'text'; keyInput.value = mapKey; keyInput.placeholder = '模型名称 (自动关联)'; keyInput.readOnly = true; - keyInput.className = 'map-key-input flex-grow px-3 py-2 border border-gray-300 rounded-md focus:outline-none bg-gray-100 text-gray-500'; + keyInput.className = `${MAP_KEY_INPUT_CLASS} flex-grow px-3 py-2 border border-gray-300 rounded-md focus:outline-none bg-gray-100 text-gray-500`; keyInput.setAttribute('data-model-id', modelId); - // Value Input (Budget) - Integer const valueInput = document.createElement('input'); valueInput.type = 'number'; - // Ensure mapValue is treated as integer, default to 0 if invalid const intValue = parseInt(mapValue, 10); valueInput.value = isNaN(intValue) ? 0 : intValue; valueInput.placeholder = '预算 (整数)'; - valueInput.className = 'map-value-input w-24 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50'; - valueInput.min = 0; // 添加最小值 - valueInput.max = 24576; // 添加最大值 + valueInput.className = `${MAP_VALUE_INPUT_CLASS} w-24 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50`; + valueInput.min = 0; + valueInput.max = 24576; valueInput.addEventListener('input', function() { - // 限制输入为0到24576之间的整数 - let value = this.value.replace(/[^0-9]/g, ''); - if (value !== '') { - value = parseInt(value, 10); - if (value < 0) value = 0; - if (value > 24576) value = 24576; - } - this.value = value; + let val = this.value.replace(/[^0-9]/g, ''); + if (val !== '') { + val = parseInt(val, 10); + if (val < 0) val = 0; + if (val > 24576) val = 24576; + } + this.value = val; // Corrected variable name }); // Remove Button - Removed for budget map items @@ -944,62 +984,62 @@ function createAndAppendBudgetMapItem(mapKey, mapValue, modelId) { container.appendChild(mapItem); } -// --- 结束:专门的预算映射项创建函数 --- - -// 收集表单数据 +/** + * Collects all data from the configuration form. + * @returns {object} An object containing all configuration data. + */ function collectFormData() { const formData = {}; - // 处理普通输入 - const inputs = document.querySelectorAll('input[type="text"], input[type="number"], select'); - inputs.forEach(input => { - if (!input.name.includes('[]')) { - if (input.type === 'number') { - formData[input.name] = parseFloat(input.value); + // 处理普通输入和 select + const inputsAndSelects = document.querySelectorAll('input[type="text"], input[type="number"], input[type="password"], select, textarea'); + inputsAndSelects.forEach(element => { + if (element.name && !element.name.includes('[]') && !element.closest('.array-container') && !element.closest(`.${MAP_ITEM_CLASS}`) && !element.closest(`.${SAFETY_SETTING_ITEM_CLASS}`)) { + if (element.type === 'number') { + formData[element.name] = parseFloat(element.value); + } else if (element.classList.contains(SENSITIVE_INPUT_CLASS) && element.hasAttribute('data-real-value')) { + formData[element.name] = element.getAttribute('data-real-value'); } else { - // 确保 select 元素的值也被正确收集 - formData[input.name] = input.value; + formData[element.name] = element.value; } } }); - // 处理复选框 const checkboxes = document.querySelectorAll('input[type="checkbox"]'); checkboxes.forEach(checkbox => { formData[checkbox.name] = checkbox.checked; }); - // 处理数组 const arrayContainers = document.querySelectorAll('.array-container'); arrayContainers.forEach(container => { const key = container.id.replace('_container', ''); - const arrayInputs = container.querySelectorAll('.array-input'); - formData[key] = Array.from(arrayInputs).map(input => input.value).filter(value => value.trim() !== ''); + const arrayInputs = container.querySelectorAll(`.${ARRAY_INPUT_CLASS}`); + formData[key] = Array.from(arrayInputs).map(input => { + if (input.classList.contains(SENSITIVE_INPUT_CLASS) && input.hasAttribute('data-real-value')) { + return input.getAttribute('data-real-value'); + } + return input.value; + }).filter(value => value && value.trim() !== '' && value !== MASKED_VALUE); // Ensure MASKED_VALUE is also filtered if not handled }); - // --- 新增:处理 THINKING_BUDGET_MAP --- const budgetMapContainer = document.getElementById('THINKING_BUDGET_MAP_container'); if (budgetMapContainer) { formData['THINKING_BUDGET_MAP'] = {}; - const mapItems = budgetMapContainer.querySelectorAll('.map-item'); + const mapItems = budgetMapContainer.querySelectorAll(`.${MAP_ITEM_CLASS}`); mapItems.forEach(item => { - const keyInput = item.querySelector('.map-key-input'); - const valueInput = item.querySelector('.map-value-input'); + const keyInput = item.querySelector(`.${MAP_KEY_INPUT_CLASS}`); + const valueInput = item.querySelector(`.${MAP_VALUE_INPUT_CLASS}`); if (keyInput && valueInput && keyInput.value.trim() !== '') { - // 将预算值解析为整数 - const budgetValue = parseInt(valueInput.value, 10); // 使用基数10 - // 检查是否为有效数字,如果不是则默认为 0 + const budgetValue = parseInt(valueInput.value, 10); formData['THINKING_BUDGET_MAP'][keyInput.value.trim()] = isNaN(budgetValue) ? 0 : budgetValue; } }); } - // --- 结束:处理 THINKING_BUDGET_MAP --- - // --- 新增:处理 SAFETY_SETTINGS --- if (safetySettingsContainer) { formData['SAFETY_SETTINGS'] = []; - const settingItems = safetySettingsContainer.querySelectorAll('.safety-setting-item'); + const settingItems = safetySettingsContainer.querySelectorAll(`.${SAFETY_SETTING_ITEM_CLASS}`); settingItems.forEach(item => { const categorySelect = item.querySelector('.safety-category-select'); const thresholdSelect = item.querySelector('.safety-threshold-select'); @@ -1011,12 +1051,13 @@ function collectFormData() { } }); } - // --- 结束:处理 SAFETY_SETTINGS --- return formData; } -// 辅助函数:停止定时任务 +/** + * Stops the scheduler task on the server. + */ async function stopScheduler() { try { const response = await fetch('/api/scheduler/stop', { method: 'POST' }); @@ -1030,7 +1071,9 @@ async function stopScheduler() { } } -// 辅助函数:启动定时任务 +/** + * Starts the scheduler task on the server. + */ async function startScheduler() { try { const response = await fetch('/api/scheduler/start', { method: 'POST' }); @@ -1044,7 +1087,9 @@ async function startScheduler() { } } -// 保存配置 +/** + * Saves the current configuration to the server. + */ async function saveConfig() { try { const formData = collectFormData(); @@ -1086,7 +1131,10 @@ async function saveConfig() { } } -// 重置配置 (现在只负责打开模态框) +/** + * Initiates the configuration reset process by showing a confirmation modal. + * @param {Event} [event] - The click event, if triggered by a button. + */ function resetConfig(event) { // 阻止事件冒泡和默认行为 if (event) { @@ -1096,25 +1144,22 @@ function resetConfig(event) { console.log('resetConfig called. Event target:', event ? event.target.id : 'No event'); - // 确保只有当事件来自重置按钮时才显示模态框 - if (!event || event.target.id === 'resetBtn' || event.currentTarget.id === 'resetBtn') { - const resetConfirmModal = document.getElementById('resetConfirmModal'); + // Ensure modal is shown only if the event comes from the reset button + if (!event || event.target.id === 'resetBtn' || (event.currentTarget && event.currentTarget.id === 'resetBtn')) { if (resetConfirmModal) { - resetConfirmModal.classList.add('show'); + openModal(resetConfirmModal); } else { - // Fallback if modal doesn't exist for some reason console.error("Reset confirmation modal not found! Falling back to default confirm."); - // Fallback to original confirm behavior - if (!confirm('确定要重置所有配置吗?这将恢复到默认值。')) { - return; + if (confirm('确定要重置所有配置吗?这将恢复到默认值。')) { + executeReset(); } - // If confirmed, proceed with the reset logic directly (less ideal) - executeReset(); } } } -// --- 新增:将实际重置逻辑提取到一个单独的函数 --- +/** + * Executes the actual configuration reset after confirmation. + */ async function executeReset() { try { showNotification('正在重置配置...', 'info'); @@ -1127,6 +1172,18 @@ async function executeReset() { } const config = await response.json(); populateForm(config); + // Re-initialize masking for sensitive fields after reset + if (configForm && typeof initializeSensitiveFields === 'function') { + const sensitiveFields = configForm.querySelectorAll(`.${SENSITIVE_INPUT_CLASS}`); + sensitiveFields.forEach(field => { + if (field.type === 'password') { + if (field.value) field.setAttribute('data-real-value', field.value); + } else if (field.type === 'text' || field.tagName.toLowerCase() === 'textarea') { + const focusoutEvent = new Event('focusout', { bubbles: true, cancelable: true }); + field.dispatchEvent(focusoutEvent); + } + }); + } showNotification('配置已重置为默认值', 'success'); // 3. 启动新的定时任务 @@ -1139,7 +1196,12 @@ async function executeReset() { await startScheduler(); } } -// 显示通知 + +/** + * Displays a notification message to the user. + * @param {string} message - The message to display. + * @param {string} [type='info'] - The type of notification ('info', 'success', 'error', 'warning'). + */ function showNotification(message, type = 'info') { const notification = document.getElementById('notification'); notification.textContent = message; @@ -1161,64 +1223,58 @@ function showNotification(message, type = 'info') { }, 3000); } -// 刷新页面 +/** + * Refreshes the current page. + * @param {HTMLButtonElement} [button] - The button that triggered the refresh (to show loading state). + */ function refreshPage(button) { - button.classList.add('loading'); + if (button) button.classList.add('loading'); location.reload(); } -// 滚动到顶部 +/** + * Scrolls the page to the top. + */ function scrollToTop() { - window.scrollTo({ - top: 0, - behavior: 'smooth' - }); + window.scrollTo({ top: 0, behavior: 'smooth' }); } -// 滚动到底部 +/** + * Scrolls the page to the bottom. + */ function scrollToBottom() { - window.scrollTo({ - top: document.body.scrollHeight, - behavior: 'smooth' - }); + window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); } -// 切换滚动按钮显示 +/** + * Toggles the visibility of scroll-to-top/bottom buttons based on scroll position. + */ function toggleScrollButtons() { const scrollButtons = document.querySelector('.scroll-buttons'); - - if (window.scrollY > 200) { - scrollButtons.style.display = 'flex'; - } else { - scrollButtons.style.display = 'none'; + if (scrollButtons) { + scrollButtons.style.display = (window.scrollY > 200) ? 'flex' : 'none'; } } -// --- 新增:生成随机令牌函数 --- +/** + * Generates a random token string. + * @returns {string} A randomly generated token. + */ function generateRandomToken() { const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_'; const length = 48; let result = 'sk-'; - const charactersLength = characters.length; for (let i = 0; i < length; i++) { - result += characters.charAt(Math.floor(Math.random() * charactersLength)); + result += characters.charAt(Math.floor(Math.random() * characters.length)); } return result; } -// --- 结束:生成随机令牌函数 --- - -// Deprecated: This function is now effectively replaced by createAndAppendBudgetMapItem -// for the initial population logic. It delegates to the new function if called. -function addBudgetMapItemWithValue(mapKey, mapValue, modelId) { - // console.warn("Deprecated call to addBudgetMapItemWithValue, use createAndAppendBudgetMapItem instead for population."); - // Delegate to the new function which handles DOM creation - createAndAppendBudgetMapItem(mapKey, mapValue, modelId); -} -/* --- 结束:(addBudgetMapItemWithValue 已弃用) --- */ - - -// --- 新增:添加安全设置项的函数 --- +/** + * Adds a new safety setting item to the DOM. + * @param {string} [category=''] - The initial category for the setting. + * @param {string} [threshold=''] - The initial threshold for the setting. + */ function addSafetySettingItem(category = '', threshold = '') { const container = document.getElementById('SAFETY_SETTINGS_container'); if (!container) { @@ -1248,48 +1304,34 @@ function addSafetySettingItem(category = '', threshold = '') { ]; const settingItem = document.createElement('div'); - settingItem.className = 'safety-setting-item flex items-center mb-2 gap-2'; + settingItem.className = `${SAFETY_SETTING_ITEM_CLASS} flex items-center mb-2 gap-2`; - // Category Select const categorySelect = document.createElement('select'); categorySelect.className = 'safety-category-select flex-grow px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 bg-white'; harmCategories.forEach(cat => { const option = document.createElement('option'); option.value = cat; - option.textContent = cat.replace('HARM_CATEGORY_', ''); // 显示更友好的名称 - if (cat === category) { - option.selected = true; - } + option.textContent = cat.replace('HARM_CATEGORY_', ''); + if (cat === category) option.selected = true; categorySelect.appendChild(option); }); - // Threshold Select const thresholdSelect = document.createElement('select'); thresholdSelect.className = 'safety-threshold-select w-48 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 bg-white'; harmThresholds.forEach(thr => { const option = document.createElement('option'); option.value = thr; - option.textContent = thr.replace('BLOCK_', '').replace('_AND_ABOVE', '+'); // 简化显示 - if (thr === threshold) { - option.selected = true; - } + option.textContent = thr.replace('BLOCK_', '').replace('_AND_ABOVE', '+'); + if (thr === threshold) option.selected = true; thresholdSelect.appendChild(option); }); - // Remove Button const removeBtn = document.createElement('button'); removeBtn.type = 'button'; removeBtn.className = 'remove-btn text-gray-400 hover:text-red-500 focus:outline-none transition-colors duration-150'; removeBtn.innerHTML = ''; removeBtn.title = '删除此设置'; - removeBtn.addEventListener('click', function() { - const currentItem = this.closest('.safety-setting-item'); - currentItem.remove(); - // 检查容器是否为空,如果是,则添加回占位符 - if (container.children.length === 0) { - container.innerHTML = '
定义模型的安全过滤阈值。
'; - } - }); + // Event listener for removeBtn is now handled by event delegation in DOMContentLoaded settingItem.appendChild(categorySelect); settingItem.appendChild(thresholdSelect); @@ -1297,5 +1339,3 @@ function addSafetySettingItem(category = '', threshold = '') { container.appendChild(settingItem); } -// --- 结束:添加安全设置项的函数 --- - diff --git a/app/static/js/error_logs.js b/app/static/js/error_logs.js index 64d13e0..69a1d74 100644 --- a/app/static/js/error_logs.js +++ b/app/static/js/error_logs.js @@ -9,24 +9,67 @@ function scrollToBottom() { window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); } +// API 调用辅助函数 +async function fetchAPI(url, options = {}) { + try { + const response = await fetch(url, options); + + // Handle cases where response might be empty but still ok (e.g., 204 No Content for DELETE) + if (response.status === 204) { + return null; // Indicate success with no content + } + + let responseData; + try { + responseData = await response.json(); + } catch (e) { + // Handle non-JSON responses if necessary, or assume error if JSON expected + if (!response.ok) { + // If response is not ok and not JSON, use statusText + throw new Error(`HTTP error! status: ${response.status} - ${response.statusText}`); + } + // If response is ok but not JSON, maybe return raw text or handle differently + // For now, let's assume successful non-JSON is not expected or handled later + console.warn("Response was not JSON for URL:", url); + return await response.text(); // Or handle as needed + } + + + if (!response.ok) { + // Prefer error message from API response body if available + const message = responseData?.detail || `HTTP error! status: ${response.status} - ${response.statusText}`; + throw new Error(message); + } + + return responseData; // Return parsed JSON data for successful responses + + } catch (error) { + // Catch network errors or errors thrown from above + console.error('API Call Failed:', error.message, 'URL:', url, 'Options:', options); + // Re-throw the error so the calling function knows the operation failed + throw error; + } +} + // Refresh function removed as the buttons are gone. // If refresh functionality is needed elsewhere, it can be triggered directly by calling loadErrorLogs(). -// 全局变量 -let currentPage = 1; -let pageSize = 10; -// let totalPages = 1; // totalPages will be calculated dynamically based on API response if available, or based on fetched data length -let errorLogs = []; // Store fetched logs for details view -let currentSort = { // 新增:存储当前排序状态 - field: 'id', // 默认按 ID 排序 - order: 'desc' // 默认降序 -}; -let currentSearch = { // Store current search parameters - key: '', - error: '', - errorCode: '', // Added error code search - startDate: '', - endDate: '' +// 全局状态管理 +let errorLogState = { + currentPage: 1, + pageSize: 10, + logs: [], // 存储获取的日志 + sort: { + field: 'id', // 默认按 ID 排序 + order: 'desc' // 默认降序 + }, + search: { + key: '', + error: '', + errorCode: '', + startDate: '', + endDate: '' + } }; // DOM Elements Cache @@ -60,73 +103,70 @@ let confirmDeleteBtn; // 新增:确认删除按钮 let deleteConfirmMessage; // 新增:删除确认消息元素 let idsToDeleteGlobally = []; // 新增:存储待删除的ID -// 页面加载完成后执行 -document.addEventListener('DOMContentLoaded', function() { - // Cache DOM elements +// Helper functions for initialization +function cacheDOMElements() { pageSizeSelector = document.getElementById('pageSize'); - // refreshBtn = document.getElementById('refreshBtn'); // Removed tableBody = document.getElementById('errorLogsTable'); paginationElement = document.getElementById('pagination'); loadingIndicator = document.getElementById('loadingIndicator'); noDataMessage = document.getElementById('noDataMessage'); errorMessage = document.getElementById('errorMessage'); logDetailModal = document.getElementById('logDetailModal'); - // Get all elements that should close the modal modalCloseBtns = document.querySelectorAll('#closeLogDetailModalBtn, #closeModalFooterBtn'); keySearchInput = document.getElementById('keySearch'); errorSearchInput = document.getElementById('errorSearch'); - errorCodeSearchInput = document.getElementById('errorCodeSearch'); // Get error code input + errorCodeSearchInput = document.getElementById('errorCodeSearch'); startDateInput = document.getElementById('startDate'); endDateInput = document.getElementById('endDate'); searchBtn = document.getElementById('searchBtn'); pageInput = document.getElementById('pageInput'); goToPageBtn = document.getElementById('goToPageBtn'); - selectAllCheckbox = document.getElementById('selectAllCheckbox'); // 新增 - copySelectedKeysBtn = document.getElementById('copySelectedKeysBtn'); // 新增 - deleteSelectedBtn = document.getElementById('deleteSelectedBtn'); // 新增 - sortByIdHeader = document.getElementById('sortById'); // 新增 + selectAllCheckbox = document.getElementById('selectAllCheckbox'); + copySelectedKeysBtn = document.getElementById('copySelectedKeysBtn'); + deleteSelectedBtn = document.getElementById('deleteSelectedBtn'); + sortByIdHeader = document.getElementById('sortById'); if (sortByIdHeader) { - sortIcon = sortByIdHeader.querySelector('i'); // 新增 + sortIcon = sortByIdHeader.querySelector('i'); } - selectedCountSpan = document.getElementById('selectedCount'); // 新增 - deleteConfirmModal = document.getElementById('deleteConfirmModal'); // 新增 - closeDeleteConfirmModalBtn = document.getElementById('closeDeleteConfirmModalBtn'); // 新增 - cancelDeleteBtn = document.getElementById('cancelDeleteBtn'); // 新增 - confirmDeleteBtn = document.getElementById('confirmDeleteBtn'); // 新增 - deleteConfirmMessage = document.getElementById('deleteConfirmMessage'); // 新增 + selectedCountSpan = document.getElementById('selectedCount'); + deleteConfirmModal = document.getElementById('deleteConfirmModal'); + closeDeleteConfirmModalBtn = document.getElementById('closeDeleteConfirmModalBtn'); + cancelDeleteBtn = document.getElementById('cancelDeleteBtn'); + confirmDeleteBtn = document.getElementById('confirmDeleteBtn'); + deleteConfirmMessage = document.getElementById('deleteConfirmMessage'); +} - // Initialize page size selector +function initializePageSizeControls() { if (pageSizeSelector) { - pageSizeSelector.value = pageSize; + pageSizeSelector.value = errorLogState.pageSize; pageSizeSelector.addEventListener('change', function() { - pageSize = parseInt(this.value); - currentPage = 1; // Reset to first page + errorLogState.pageSize = parseInt(this.value); + errorLogState.currentPage = 1; // Reset to first page loadErrorLogs(); }); } +} - // Refresh button event listener removed - - // Initialize search button +function initializeSearchControls() { if (searchBtn) { searchBtn.addEventListener('click', function() { - // Update search parameters from input fields - currentSearch.key = keySearchInput ? keySearchInput.value.trim() : ''; - currentSearch.error = errorSearchInput ? errorSearchInput.value.trim() : ''; - currentSearch.errorCode = errorCodeSearchInput ? errorCodeSearchInput.value.trim() : ''; // Get error code value - currentSearch.startDate = startDateInput ? startDateInput.value : ''; - currentSearch.endDate = endDateInput ? endDateInput.value : ''; - currentPage = 1; // Reset to first page on new search + errorLogState.search.key = keySearchInput ? keySearchInput.value.trim() : ''; + errorLogState.search.error = errorSearchInput ? errorSearchInput.value.trim() : ''; + errorLogState.search.errorCode = errorCodeSearchInput ? errorCodeSearchInput.value.trim() : ''; + errorLogState.search.startDate = startDateInput ? startDateInput.value : ''; + errorLogState.search.endDate = endDateInput ? endDateInput.value : ''; + errorLogState.currentPage = 1; // Reset to first page on new search loadErrorLogs(); }); } +} - // Initialize modal close buttons +function initializeModalControls() { + // Log Detail Modal if (logDetailModal && modalCloseBtns) { modalCloseBtns.forEach(btn => { btn.addEventListener('click', closeLogDetailModal); }); - // Optional: Close modal if clicking outside the content logDetailModal.addEventListener('click', function(event) { if (event.target === logDetailModal) { closeLogDetailModal(); @@ -134,52 +174,7 @@ document.addEventListener('DOMContentLoaded', function() { }); } - // Initial load of error logs - loadErrorLogs(); - - // Add event listeners for copy buttons inside the modal and table - setupCopyButtons(); // This will now also handle table copy buttons if called after render - - // Add event listeners for bulk selection - setupBulkSelectionListeners(); // 新增:设置批量选择监听器 - - // 新增:为页码跳转按钮添加事件监听器 - if (goToPageBtn && pageInput) { - goToPageBtn.addEventListener('click', function() { - const targetPage = parseInt(pageInput.value); - // 需要获取总页数来验证输入 - // 暂时无法直接获取 totalPages,需要在 updatePagination 中存储或重新计算 - // 简单的验证:必须是正整数 - if (!isNaN(targetPage) && targetPage >= 1) { - // 理想情况下,应检查 targetPage <= totalPages - // 但 totalPages 可能未知,所以暂时只跳转 - currentPage = targetPage; - loadErrorLogs(); - pageInput.value = ''; // 清空输入框 - } else { - showNotification('请输入有效的页码', 'error', 2000); - pageInput.value = ''; // 清空无效输入 - } - }); - // 允许按 Enter 键跳转 - pageInput.addEventListener('keypress', function(event) { - if (event.key === 'Enter') { - goToPageBtn.click(); // 触发按钮点击 - } - }); - } - - // 新增:为批量删除按钮添加事件监听器 - if (deleteSelectedBtn) { - deleteSelectedBtn.addEventListener('click', handleDeleteSelected); - } - - // 新增:为 ID 排序表头添加事件监听器 - if (sortByIdHeader) { - sortByIdHeader.addEventListener('click', handleSortById); - } - - // 新增:为删除确认模态框按钮添加事件监听器 + // Delete Confirm Modal if (closeDeleteConfirmModalBtn) { closeDeleteConfirmModalBtn.addEventListener('click', hideDeleteConfirmModal); } @@ -189,7 +184,6 @@ document.addEventListener('DOMContentLoaded', function() { if (confirmDeleteBtn) { confirmDeleteBtn.addEventListener('click', handleConfirmDelete); } - // Optional: Close modal if clicking outside the content if (deleteConfirmModal) { deleteConfirmModal.addEventListener('click', function(event) { if (event.target === deleteConfirmModal) { @@ -197,6 +191,55 @@ document.addEventListener('DOMContentLoaded', function() { } }); } +} + +function initializePaginationJumpControls() { + if (goToPageBtn && pageInput) { + goToPageBtn.addEventListener('click', function() { + const targetPage = parseInt(pageInput.value); + if (!isNaN(targetPage) && targetPage >= 1) { + errorLogState.currentPage = targetPage; + loadErrorLogs(); + pageInput.value = ''; + } else { + showNotification('请输入有效的页码', 'error', 2000); + pageInput.value = ''; + } + }); + pageInput.addEventListener('keypress', function(event) { + if (event.key === 'Enter') { + goToPageBtn.click(); + } + }); + } +} + +function initializeActionControls() { + if (deleteSelectedBtn) { + deleteSelectedBtn.addEventListener('click', handleDeleteSelected); + } + if (sortByIdHeader) { + sortByIdHeader.addEventListener('click', handleSortById); + } + // Bulk selection listeners are closely related to actions + setupBulkSelectionListeners(); +} + +// 页面加载完成后执行 +document.addEventListener('DOMContentLoaded', function() { + cacheDOMElements(); + initializePageSizeControls(); + initializeSearchControls(); + initializeModalControls(); + initializePaginationJumpControls(); + initializeActionControls(); + + // Initial load of error logs + loadErrorLogs(); + + // Add event listeners for copy buttons inside the modal and table + // This needs to be called after initial render and potentially after each render if content is dynamic + setupCopyButtons(); }); // 新增:显示删除确认模态框 @@ -265,6 +308,36 @@ function handleCopyResult(buttonElement, success) { setTimeout(() => { iconElement.className = originalIcon; }, success ? 2000 : 3000); // Restore original icon class } +// 新的内部辅助函数,封装实际的复制操作和反馈 +function _performCopy(text, buttonElement) { + let copySuccess = false; + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text).then(() => { + if (buttonElement) { + handleCopyResult(buttonElement, true); + } else { + showNotification('已复制到剪贴板', 'success'); + } + }).catch(err => { + console.error('Clipboard API failed, attempting fallback:', err); + copySuccess = fallbackCopyTextToClipboard(text); + if (buttonElement) { + handleCopyResult(buttonElement, copySuccess); + } else { + showNotification(copySuccess ? '已复制到剪贴板' : '复制失败', copySuccess ? 'success' : 'error'); + } + }); + } else { + console.warn("Clipboard API not available or context insecure. Using fallback copy method."); + copySuccess = fallbackCopyTextToClipboard(text); + if (buttonElement) { + handleCopyResult(buttonElement, copySuccess); + } else { + showNotification(copySuccess ? '已复制到剪贴板' : '复制失败', copySuccess ? 'success' : 'error'); + } + } +} + // Function to set up copy button listeners (using modern API with fallback) - Updated to handle table copy buttons function setupCopyButtons(containerSelector = 'body') { // Find buttons within the specified container (defaults to body) @@ -306,44 +379,13 @@ function handleCopyButtonClick() { if (textToCopy) { - let copySuccess = false; - // Try modern clipboard API first (requires HTTPS or localhost) - if (navigator.clipboard && window.isSecureContext) { - navigator.clipboard.writeText(textToCopy).then(() => { - handleCopyResult(button, true); // Use helper for feedback - }).catch(err => { - console.error('Clipboard API failed, attempting fallback:', err); - // Attempt fallback if modern API fails - copySuccess = fallbackCopyTextToClipboard(textToCopy); - handleCopyResult(button, copySuccess); // Use helper for feedback - }); - } else { - // Use fallback if modern API is not available or context is insecure - console.warn("Clipboard API not available or context insecure. Using fallback copy method."); - copySuccess = fallbackCopyTextToClipboard(textToCopy); - handleCopyResult(button, copySuccess); // Use helper for feedback - } + _performCopy(textToCopy, button); // 使用新的辅助函数 } else { console.warn('No text found to copy for target:', targetId || 'direct text'); showNotification('没有内容可复制', 'warning'); } } // End of handleCopyButtonClick function -// Function to set up copy button listeners (using modern API with fallback) - Updated to handle table copy buttons -function setupCopyButtons(containerSelector = 'body') { - // Find buttons within the specified container (defaults to body) - const container = document.querySelector(containerSelector); - if (!container) return; - - const copyButtons = container.querySelectorAll('.copy-btn'); - copyButtons.forEach(button => { - // Remove existing listener to prevent duplicates if called multiple times - button.removeEventListener('click', handleCopyButtonClick); - // Add the listener - button.addEventListener('click', handleCopyButtonClick); - }); -} - // 新增:设置批量选择相关的事件监听器 function setupBulkSelectionListeners() { if (selectAllCheckbox) { @@ -432,33 +474,12 @@ function handleCopySelectedKeys() { if (keysToCopy.length > 0) { const textToCopy = keysToCopy.join('\n'); // 每行一个密钥 - copyTextToClipboard(textToCopy, copySelectedKeysBtn); // 使用通用复制函数 + _performCopy(textToCopy, copySelectedKeysBtn); // 使用新的辅助函数 } else { showNotification('没有选中的密钥可复制', 'warning'); } } -// 新增:通用的文本复制函数(结合现有逻辑) -function copyTextToClipboard(text, buttonElement = null) { - let copySuccess = false; - if (navigator.clipboard && window.isSecureContext) { - navigator.clipboard.writeText(text).then(() => { - if (buttonElement) handleCopyResult(buttonElement, true); - else showNotification('已复制到剪贴板', 'success'); - }).catch(err => { - console.error('Clipboard API failed, attempting fallback:', err); - copySuccess = fallbackCopyTextToClipboard(text); - if (buttonElement) handleCopyResult(buttonElement, copySuccess); - else showNotification(copySuccess ? '已复制到剪贴板' : '复制失败', copySuccess ? 'success' : 'error'); - }); - } else { - console.warn("Clipboard API not available or context insecure. Using fallback copy method."); - copySuccess = fallbackCopyTextToClipboard(text); - if (buttonElement) handleCopyResult(buttonElement, copySuccess); - else showNotification(copySuccess ? '已复制到剪贴板' : '复制失败', copySuccess ? 'success' : 'error'); - } -} - // 修改:处理批量删除按钮点击的函数 - 改为显示模态框 function handleDeleteSelected() { const selectedCheckboxes = tableBody.querySelectorAll('.row-checkbox:checked'); @@ -495,23 +516,17 @@ async function performActualDelete(logIds) { const method = 'DELETE'; const body = isSingleDelete ? null : JSON.stringify({ ids: logIds }); const headers = isSingleDelete ? {} : { 'Content-Type': 'application/json' }; + const options = { + method: method, + headers: headers, + body: body, // fetchAPI handles null body correctly + }; try { - // Rename 'response' to 'deleteResponse' and remove duplicate fetch - const deleteResponse = await fetch(url, { - method: method, - headers: headers, - body: body, - }); - // Removed duplicate fetch call below - - if (!deleteResponse.ok) { - let errorData; - try { errorData = await deleteResponse.json(); } catch (e) { /* ignore */ } - const actionText = isSingleDelete ? `删除该条日志` : `批量删除 ${logIds.length} 条日志`; - throw new Error(errorData?.detail || `${actionText}失败: ${deleteResponse.statusText}`); - } + // Use fetchAPI for the delete request + await fetchAPI(url, options); // fetchAPI returns null for 204 No Content + // If fetchAPI doesn't throw, the request was successful const successMessage = isSingleDelete ? `成功删除该日志` : `成功删除 ${logIds.length} 条日志`; showNotification(successMessage, 'success'); // 取消全选 @@ -537,18 +552,18 @@ function handleDeleteLogRow(logId) { // 新增:处理 ID 排序点击的函数 function handleSortById() { - if (currentSort.field === 'id') { + if (errorLogState.sort.field === 'id') { // 如果当前是按 ID 排序,切换顺序 - currentSort.order = currentSort.order === 'asc' ? 'desc' : 'asc'; + errorLogState.sort.order = errorLogState.sort.order === 'asc' ? 'desc' : 'asc'; } else { // 如果当前不是按 ID 排序,切换到按 ID 排序,默认为降序 - currentSort.field = 'id'; - currentSort.order = 'desc'; + errorLogState.sort.field = 'id'; + errorLogState.sort.order = 'desc'; } // 更新图标 updateSortIcon(); // 重新加载第一页数据 - currentPage = 1; + errorLogState.currentPage = 1; loadErrorLogs(); } @@ -558,8 +573,8 @@ function updateSortIcon() { // 移除所有可能的排序类 sortIcon.classList.remove('fa-sort', 'fa-sort-up', 'fa-sort-down', 'text-gray-400', 'text-primary-600'); - if (currentSort.field === 'id') { - sortIcon.classList.add(currentSort.order === 'asc' ? 'fa-sort-up' : 'fa-sort-down'); + if (errorLogState.sort.field === 'id') { + sortIcon.classList.add(errorLogState.sort.order === 'asc' ? 'fa-sort-up' : 'fa-sort-down'); sortIcon.classList.add('text-primary-600'); // 高亮显示 } else { // 如果不是按 ID 排序,显示默认图标 @@ -578,56 +593,49 @@ async function loadErrorLogs() { showError(false); showNoData(false); - const offset = (currentPage - 1) * pageSize; + const offset = (errorLogState.currentPage - 1) * errorLogState.pageSize; try { // Construct the API URL with search and sort parameters - let apiUrl = `/api/logs/errors?limit=${pageSize}&offset=${offset}`; + let apiUrl = `/api/logs/errors?limit=${errorLogState.pageSize}&offset=${offset}`; // 添加排序参数 - apiUrl += `&sort_by=${currentSort.field}&sort_order=${currentSort.order}`; + apiUrl += `&sort_by=${errorLogState.sort.field}&sort_order=${errorLogState.sort.order}`; // 添加搜索参数 - if (currentSearch.key) { - apiUrl += `&key_search=${encodeURIComponent(currentSearch.key)}`; + if (errorLogState.search.key) { + apiUrl += `&key_search=${encodeURIComponent(errorLogState.search.key)}`; } - if (currentSearch.error) { - apiUrl += `&error_search=${encodeURIComponent(currentSearch.error)}`; + if (errorLogState.search.error) { + apiUrl += `&error_search=${encodeURIComponent(errorLogState.search.error)}`; } - if (currentSearch.errorCode) { // Add error code to API request - apiUrl += `&error_code_search=${encodeURIComponent(currentSearch.errorCode)}`; + if (errorLogState.search.errorCode) { // Add error code to API request + apiUrl += `&error_code_search=${encodeURIComponent(errorLogState.search.errorCode)}`; } - if (currentSearch.startDate) { - apiUrl += `&start_date=${encodeURIComponent(currentSearch.startDate)}`; + if (errorLogState.search.startDate) { + apiUrl += `&start_date=${encodeURIComponent(errorLogState.search.startDate)}`; } - if (currentSearch.endDate) { - apiUrl += `&end_date=${encodeURIComponent(currentSearch.endDate)}`; + if (errorLogState.search.endDate) { + apiUrl += `&end_date=${encodeURIComponent(errorLogState.search.endDate)}`; } - const response = await fetch(apiUrl); - if (!response.ok) { - // Try to get error message from response body - let errorData; - try { - errorData = await response.json(); - } catch (e) { - // Ignore if response is not JSON - } - throw new Error(errorData?.detail || `网络响应异常: ${response.statusText}`); - } - const data = await response.json(); + // Use fetchAPI to get logs + const data = await fetchAPI(apiUrl); + // API 现在返回 { logs: [], total: count } + // fetchAPI already parsed JSON if (data && Array.isArray(data.logs)) { - errorLogs = data.logs; // Store the list data (contains error_code) - renderErrorLogs(errorLogs); - updatePagination(errorLogs.length, data.total || -1); + errorLogState.logs = data.logs; // Store the list data (contains error_code) + renderErrorLogs(errorLogState.logs); + updatePagination(errorLogState.logs.length, data.total || -1); // Use total from response } else { - throw new Error('无法识别的API响应格式'); + // Handle unexpected data format even after successful fetch + console.error('Unexpected API response format:', data); + throw new Error('无法识别的API响应格式'); } - showLoading(false); - if (errorLogs.length === 0) { + if (errorLogState.logs.length === 0) { showNoData(true); } } catch (error) { @@ -637,6 +645,54 @@ async function loadErrorLogs() { } } +// Helper function to create HTML for a single log row +function _createLogRowHtml(log, sequentialId) { + // Format date + let formattedTime = 'N/A'; + try { + const requestTime = new Date(log.request_time); + if (!isNaN(requestTime)) { + formattedTime = requestTime.toLocaleString('zh-CN', { + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false + }); + } + } catch (e) { console.error("Error formatting date:", e); } + + const errorCodeContent = log.error_code || '无'; + + const maskKey = (key) => { + if (!key || key.length < 8) return key || '无'; + return `${key.substring(0, 4)}...${key.substring(key.length - 4)}`; + }; + const maskedKey = maskKey(log.gemini_key); + const fullKey = log.gemini_key || ''; + + return ` + + + + ${sequentialId} + + ${maskedKey} + + + ${log.error_type || '未知'} + ${errorCodeContent} + ${log.model_name || '未知'} + ${formattedTime} + + + + + `; +} // 渲染错误日志表格 function renderErrorLogs(logs) { @@ -654,61 +710,12 @@ function renderErrorLogs(logs) { return; } - const startIndex = (currentPage - 1) * pageSize; // Calculate starting index for the current page + const startIndex = (errorLogState.currentPage - 1) * errorLogState.pageSize; - logs.forEach((log, index) => { // Add index parameter to forEach + logs.forEach((log, index) => { + const sequentialId = startIndex + index + 1; const row = document.createElement('tr'); - const sequentialId = startIndex + index + 1; // Calculate sequential ID for the current page - // Format date - let formattedTime = 'N/A'; - try { - const requestTime = new Date(log.request_time); - if (!isNaN(requestTime)) { - formattedTime = requestTime.toLocaleString('zh-CN', { - year: 'numeric', month: '2-digit', day: '2-digit', - hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false - }); - } - } catch (e) { console.error("Error formatting date:", e); } - - - // Display error code instead of truncated log - const errorCodeContent = log.error_code || '无'; - - // Mask the Gemini key for display in the table - const maskKey = (key) => { - if (!key || key.length < 8) return key || '无'; // Don't mask short keys or null - return `${key.substring(0, 4)}...${key.substring(key.length - 4)}`; - }; - const maskedKey = maskKey(log.gemini_key); - const fullKey = log.gemini_key || ''; // Store the full key - - row.innerHTML = ` - - - - ${sequentialId} - - ${maskedKey} - - - - ${log.error_type || '未知'} - ${errorCodeContent} - ${log.model_name || '未知'} - ${formattedTime} - - - - - `; - + row.innerHTML = _createLogRowHtml(log, sequentialId); tableBody.appendChild(row); }); @@ -751,15 +758,14 @@ async function showLogDetails(logId) { document.body.style.overflow = 'hidden'; // Prevent body scrolling try { - const response = await fetch(`/api/logs/errors/${logId}/details`); - if (!response.ok) { - let errorData; - try { - errorData = await response.json(); - } catch (e) { /* ignore */ } - throw new Error(errorData?.detail || `获取日志详情失败: ${response.statusText}`); + // Use fetchAPI to get log details + const logDetails = await fetchAPI(`/api/logs/errors/${logId}/details`); + + // fetchAPI handles response.ok check and JSON parsing + if (!logDetails) { + // Handle case where API returns success but no data (if possible) + throw new Error('未找到日志详情'); } - const logDetails = await response.json(); // Format date let formattedTime = 'N/A'; @@ -839,8 +845,8 @@ function updatePagination(currentItemCount, totalItems) { // Calculate total pages only if totalItems is known and valid let totalPages = 1; if (totalItems >= 0) { - totalPages = Math.max(1, Math.ceil(totalItems / pageSize)); - } else if (currentItemCount < pageSize && currentPage === 1) { + totalPages = Math.max(1, Math.ceil(totalItems / errorLogState.pageSize)); + } else if (currentItemCount < errorLogState.pageSize && errorLogState.currentPage === 1) { // If less items than page size fetched on page 1, assume it's the only page totalPages = 1; } else { @@ -848,15 +854,15 @@ function updatePagination(currentItemCount, totalItems) { // We can show Prev/Next based on current page and if items were returned console.warn("Total item count unknown, pagination will be limited."); // Basic Prev/Next for unknown total - addPaginationLink(paginationElement, '«', currentPage > 1, () => { currentPage--; loadErrorLogs(); }); - addPaginationLink(paginationElement, currentPage.toString(), true, null, true); // Current page number (non-clickable) - addPaginationLink(paginationElement, '»', currentItemCount === pageSize, () => { currentPage++; loadErrorLogs(); }); // Next enabled if full page was returned + addPaginationLink(paginationElement, '«', errorLogState.currentPage > 1, () => { errorLogState.currentPage--; loadErrorLogs(); }); + addPaginationLink(paginationElement, errorLogState.currentPage.toString(), true, null, true); // Current page number (non-clickable) + addPaginationLink(paginationElement, '»', currentItemCount === errorLogState.pageSize, () => { errorLogState.currentPage++; loadErrorLogs(); }); // Next enabled if full page was returned return; // Exit here for limited pagination } const maxPagesToShow = 5; // Max number of page links to show - let startPage = Math.max(1, currentPage - Math.floor(maxPagesToShow / 2)); + let startPage = Math.max(1, errorLogState.currentPage - Math.floor(maxPagesToShow / 2)); let endPage = Math.min(totalPages, startPage + maxPagesToShow - 1); // Adjust startPage if endPage reaches the limit first @@ -866,11 +872,11 @@ function updatePagination(currentItemCount, totalItems) { // Previous Button - addPaginationLink(paginationElement, '«', currentPage > 1, () => { currentPage--; loadErrorLogs(); }); + addPaginationLink(paginationElement, '«', errorLogState.currentPage > 1, () => { errorLogState.currentPage--; loadErrorLogs(); }); // First Page Button if (startPage > 1) { - addPaginationLink(paginationElement, '1', true, () => { currentPage = 1; loadErrorLogs(); }); + addPaginationLink(paginationElement, '1', true, () => { errorLogState.currentPage = 1; loadErrorLogs(); }); if (startPage > 2) { addPaginationLink(paginationElement, '...', false); // Ellipsis } @@ -878,7 +884,7 @@ function updatePagination(currentItemCount, totalItems) { // Page Number Buttons for (let i = startPage; i <= endPage; i++) { - addPaginationLink(paginationElement, i.toString(), true, () => { currentPage = i; loadErrorLogs(); }, i === currentPage); + addPaginationLink(paginationElement, i.toString(), true, () => { errorLogState.currentPage = i; loadErrorLogs(); }, i === errorLogState.currentPage); } // Last Page Button @@ -886,12 +892,12 @@ function updatePagination(currentItemCount, totalItems) { if (endPage < totalPages - 1) { addPaginationLink(paginationElement, '...', false); // Ellipsis } - addPaginationLink(paginationElement, totalPages.toString(), true, () => { currentPage = totalPages; loadErrorLogs(); }); + addPaginationLink(paginationElement, totalPages.toString(), true, () => { errorLogState.currentPage = totalPages; loadErrorLogs(); }); } // Next Button - addPaginationLink(paginationElement, '»', currentPage < totalPages, () => { currentPage++; loadErrorLogs(); }); + addPaginationLink(paginationElement, '»', errorLogState.currentPage < totalPages, () => { errorLogState.currentPage++; loadErrorLogs(); }); } // Helper function to add pagination links diff --git a/app/static/js/keys_status.js b/app/static/js/keys_status.js index 255f4bd..32355ca 100644 --- a/app/static/js/keys_status.js +++ b/app/static/js/keys_status.js @@ -27,6 +27,49 @@ function copyToClipboard(text) { } } +// 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'); @@ -132,7 +175,7 @@ function copyKey(key) { }); } -// 移除 showCopyStatus 函数,因为它已被 showNotification 替代 +// showCopyStatus 函数已废弃。 async function verifyKey(key, button) { try { @@ -142,13 +185,10 @@ async function verifyKey(key, button) { button.innerHTML = ' 验证中'; try { - const response = await fetch(`/gemini/v1beta/verify-key/${key}`, { - method: 'POST' - }); - const data = await response.json(); + const data = await fetchAPI(`/gemini/v1beta/verify-key/${key}`, { method: 'POST' }); // 根据验证结果更新UI并显示模态提示框 - if (data.success || data.status === 'valid') { + if (data && (data.success || data.status === 'valid')) { // 验证成功,显示成功结果 button.style.backgroundColor = '#27ae60'; // 使用结果模态框显示成功消息 @@ -161,9 +201,9 @@ async function verifyKey(key, button) { // 使用结果模态框显示失败消息,改为true以在关闭时刷新 showResultModal(false, '密钥验证失败: ' + errorMsg, true); } - } catch (fetchError) { - console.error('API请求失败:', fetchError); - showResultModal(false, '验证请求失败: ' + fetchError.message, true); // 改为true以在关闭时刷新 + } catch (apiError) { + console.error('密钥验证 API 请求失败:', apiError); + showResultModal(false, `验证请求失败: ${apiError.message}`, true); } finally { // 1秒后恢复按钮原始状态 (如果页面不刷新) // 由于现在成功和失败都会刷新,这部分逻辑可以简化或移除 @@ -193,10 +233,7 @@ async function resetKeyFailCount(key, button) { const originalHtml = button.innerHTML; button.innerHTML = ' 重置中'; - const response = await fetch(`/gemini/v1beta/reset-fail-count/${key}`, { - method: 'POST' - }); - const data = await response.json(); + const data = await fetchAPI(`/gemini/v1beta/reset-fail-count/${key}`, { method: 'POST' }); // 根据重置结果更新UI if (data.success) { @@ -220,9 +257,9 @@ async function resetKeyFailCount(key, button) { // 恢复按钮状态逻辑已移至成功/失败分支内 - } catch (error) { - console.error('重置失败:', error); - showNotification('重置请求失败: ' + error.message, 'error'); + } catch (apiError) { + console.error('重置失败:', apiError); + showNotification(`重置请求失败: ${apiError.message}`, 'error'); // 确保在捕获到错误时恢复按钮状态 button.disabled = false; button.innerHTML = ' 重置'; // 恢复原始图标和文本 @@ -508,29 +545,12 @@ async function executeResetAll(type) { resetButton.innerHTML = ' 重置中'; try { - // 调用新的后端 API 来重置选定的密钥 - const response = await fetch(`/gemini/v1beta/reset-selected-fail-counts`, { // 假设的新 API 端点 + const options = { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ keys: keysToReset, key_type: type }) // 发送密钥列表和类型 - }); - - if (!response.ok) { - // 尝试解析错误信息 - let errorMsg = `服务器返回错误: ${response.status}`; - try { - const errorData = await response.json(); - errorMsg = errorData.message || errorMsg; - } catch (e) { - // 如果解析失败,使用原始错误信息 - } - - throw new Error(errorMsg); - } - - const data = await response.json(); + 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) { @@ -543,9 +563,9 @@ async function executeResetAll(type) { // 失败后不自动刷新页面,让用户看到错误信息 showResultModal(false, '批量重置失败: ' + errorMsg, false); } - } catch (fetchError) { - console.error('API请求失败:', fetchError); - showResultModal(false, '批量重置请求失败: ' + fetchError.message, 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('失败')) { @@ -587,85 +607,106 @@ function refreshPage(button) { } -// 恢复之前的 toggleSection 函数以修复展开/收缩动画 +// 展开/收起区块内容的函数,带有平滑动画效果。 +// @param {HTMLElement} header - 被点击的区块头部元素。 +// @param {string} sectionId - (当前未使用,但可用于更精确的目标定位) 关联内容区块的ID。 function toggleSection(header, sectionId) { const toggleIcon = header.querySelector('.toggle-icon'); - // 需要找到正确的 content 元素。它不再是紧邻的兄弟元素,而是 card 内的 key-content div + // 内容元素是卡片内的 .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; // 获取分页控件 + + // 批量操作栏和分页控件也可能影响内容区域的动画高度计算 + const batchActions = card ? card.querySelector('[id$="BatchActions"]') : null; + const pagination = card ? card.querySelector('[id$="PaginationControls"]') : null; - if (toggleIcon && content) { - const isCollapsed = content.classList.contains('collapsed'); + if (!toggleIcon || !content) { + console.error("Toggle section failed: Icon or content element not found. Header:", header, "SectionId:", sectionId); + return; + } - // 切换图标状态 - toggleIcon.classList.toggle('collapsed', !isCollapsed); + const isCollapsed = content.classList.contains('collapsed'); + toggleIcon.classList.toggle('collapsed', !isCollapsed); // 更新箭头图标方向 - if (isCollapsed) { - // 展开内容 - content.classList.remove('collapsed'); - // 先移除内联样式,让 CSS 控制初始状态 - content.style.maxHeight = ''; - content.style.opacity = ''; - content.style.padding = ''; - content.style.overflow = ''; - // 使用 requestAnimationFrame 确保浏览器应用了初始状态 - requestAnimationFrame(() => { - // 计算内容的实际高度 - const scrollHeight = content.scrollHeight; - let totalHeight = scrollHeight; + if (isCollapsed) { + // --- 准备展开动画 --- + content.classList.remove('collapsed'); // 移除 collapsed 类以应用展开的样式 - // 如果批量操作栏存在且可见,也计算其高度 - if (batchActions && !batchActions.classList.contains('hidden')) { - totalHeight += batchActions.offsetHeight; - } - // 如果分页控件存在且可见,也计算其高度和 margin-top - if (pagination && pagination.offsetHeight > 0) { - // Assuming mt-4 which is 1rem = 16px (adjust if needed) - totalHeight += pagination.offsetHeight + 16; - } + // 步骤 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'; // 动画过程中隐藏溢出内容 - content.style.maxHeight = totalHeight + 'px'; - content.style.opacity = '1'; - content.style.padding = '1rem'; // 恢复 padding - content.style.overflow = 'hidden'; // Keep hidden during transition + // 步骤 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; + } - // 动画结束后移除 max-height 以允许内容动态变化 - content.addEventListener('transitionend', function handler() { - content.removeEventListener('transitionend', handler); - if (!content.classList.contains('collapsed')) { // 确保是在展开状态 - content.style.maxHeight = ''; - content.style.overflow = 'visible'; - } - }, { once: true }); - }); - } else { - // 收起内容 - // 先计算当前总高度 - let currentHeight = content.scrollHeight; - if (batchActions && !batchActions.classList.contains('hidden')) { - currentHeight += batchActions.offsetHeight; - } - if (pagination && pagination.offsetHeight > 0) { - currentHeight += pagination.offsetHeight + 16; - } - // 设置一个明确的高度,然后过渡到 0 - content.style.maxHeight = currentHeight + 'px'; - content.style.overflow = 'hidden'; // Ensure overflow is hidden before starting transition - requestAnimationFrame(() => { - content.style.maxHeight = '0px'; - content.style.opacity = '0'; - content.style.padding = '0 1rem'; // 保持左右 padding,收起上下 padding - content.classList.add('collapsed'); - }); - } + // 步骤 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 { - console.error("Toggle section failed: Icon or content not found.", header, sectionId); + // --- 准备收起动画 --- + // 步骤 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'); @@ -720,140 +761,109 @@ function filterValidKeys() { } -// 初始化 -document.addEventListener('DOMContentLoaded', () => { - // 初始化统计区块动画 - initStatItemAnimations(); +// --- 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 => { - // 检查 header 是否包含 toggle-icon,避免为其他卡片(如统计卡片)添加监听器 - if (header.querySelector('.toggle-icon')) { - header.addEventListener('click', (event) => { - // 确保点击的不是内部交互元素(如输入框、复选框、标签、按钮、选择框) - if (event.target.closest('input, label, button, select')) { - return; - } - // 从 header 中提取 sectionId (例如从关联的 content div 的 id) - const card = header.closest('.stats-card'); - const content = card ? card.querySelector('.key-content') : null; - const sectionId = content ? content.id : null; - if (sectionId) { + 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."); - } - }); - } + } else { + console.warn("Could not determine sectionId for toggle."); + } + }); + } }); +} - // 添加筛选输入框事件监听 +function initializeKeyFilterControls() { const thresholdInput = document.getElementById('failCountThreshold'); if (thresholdInput) { - // 使用 'input' 事件实时响应输入变化 thresholdInput.addEventListener('input', filterValidKeys); - // 初始加载时应用一次筛选 (现在由 pagination/search 初始化处理) - // filterValidKeys(); } +} - // --- 批量验证相关函数 (明确挂载到 window) --- - - // 显示验证确认模态框 (基于选中的密钥) +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; // 确保按钮可用 + 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'); - } + }; - window.executeVerifyAll = async function(type) { + // executeVerifyAll 变为 initializeGlobalBatchVerificationHandlers 的局部函数 + async function executeVerifyAll(type) { // Removed window. try { - // 关闭确认模态框 - closeVerifyModal(); - - // 找到对应的验证按钮以显示加载状态 + 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) { @@ -861,85 +871,64 @@ document.addEventListener('DOMContentLoaded', () => { verifyButton.disabled = true; verifyButton.innerHTML = ' 验证中'; } - - - // 获取选中的密钥 const keysToVerify = getSelectedKeys(type); - if (keysToVerify.length === 0) { showNotification(`没有选中的${type === 'valid' ? '有效' : '无效'}密钥可验证`, 'warning'); - if (verifyButton) { // Restore button if no keys selected - verifyButton.innerHTML = originalVerifyHtml; - // Button disable state will be handled by updateBatchActions after reload or modal close - } + if (verifyButton) { // Restore button if no keys selected + verifyButton.innerHTML = originalVerifyHtml; + } return; } - - // 显示一个通用的加载提示 showNotification('开始批量验证,请稍候...', 'info'); - - // 调用新的后端 API 来验证选定的密钥 - const response = await fetch(`/gemini/v1beta/verify-selected-keys`, { // 假设的新 API 端点 + const options = { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ keys: keysToVerify }) // 只发送密钥列表 - }); - - if (!response.ok) { - let errorMsg = `服务器返回错误: ${response.status}`; - try { - const errorData = await response.json(); - errorMsg = errorData.message || errorMsg; - } catch (e) { /*忽略解析错误*/ } - throw new Error(errorMsg); + 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."); } - - const data = await response.json(); - - // 使用新的专用模态框显示结果 - showVerificationResultModal(data); - // 注意:autoReload 逻辑已移至 showVerificationResultModal 内部 (现在总是刷新) - - } catch (error) { - console.error('批量验证处理失败:', error); - // 失败后也刷新页面,让用户看到可能更新的状态 - showResultModal(false, '批量验证处理失败: ' + error.message, true); + } 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 + 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. +} - // --- 复选框事件监听 --- - // Attach listeners dynamically after pagination renders content, or use event delegation - document.getElementById('validKeys').addEventListener('change', (event) => { - if (event.target.classList.contains('key-checkbox')) { - updateBatchActions('valid'); - } - }); - document.getElementById('invalidKeys').addEventListener('change', (event) => { - if (event.target.classList.contains('key-checkbox')) { - updateBatchActions('invalid'); - } - }); +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'); + } + }); + } +} - - // 初始化批量操作区域状态 (在 pagination 初始化后进行) - // updateBatchActions('valid'); // Called by displayPage - // updateBatchActions('invalid'); // Called by displayPage - - - // --- 滚动和页面控制 --- (Scroll buttons handled by base.html) - // --- 自动刷新控制 --- +function initializeAutoRefreshControls() { const autoRefreshToggle = document.getElementById('autoRefreshToggle'); const autoRefreshIntervalTime = 60000; // 60秒 let autoRefreshTimer = null; function startAutoRefresh() { - if (autoRefreshTimer) return; // 防止重复启动 + if (autoRefreshTimer) return; console.log('启动自动刷新...'); showNotification('自动刷新已启动', 'info', 2000); autoRefreshTimer = setInterval(() => { @@ -958,14 +947,11 @@ document.addEventListener('DOMContentLoaded', () => { } if (autoRefreshToggle) { - // 从 localStorage 读取状态并初始化 const isAutoRefreshEnabled = localStorage.getItem('autoRefreshEnabled') === 'true'; autoRefreshToggle.checked = isAutoRefreshEnabled; if (isAutoRefreshEnabled) { startAutoRefresh(); } - - // 添加事件监听器 autoRefreshToggle.addEventListener('change', () => { if (autoRefreshToggle.checked) { localStorage.setItem('autoRefreshEnabled', 'true'); @@ -976,32 +962,37 @@ document.addEventListener('DOMContentLoaded', () => { } }); } +} - // --- Pagination and Search Initialization --- - // This part needs to be integrated with the pagination logic from the provided file content - // Assuming the pagination/search related code from the file_content is now part of this script +// 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 - // --- Get DOM Elements for Pagination/Search --- + +function initializeKeyPaginationAndSearch() { const validKeysListElement = document.getElementById('validKeys'); const invalidKeysListElement = document.getElementById('invalidKeys'); - // const thresholdInput = document.getElementById('failCountThreshold'); // Already defined const searchInput = document.getElementById('keySearchInput'); const itemsPerPageSelect = document.getElementById('itemsPerPageSelect'); + const thresholdInput = document.getElementById('failCountThreshold'); // Already used by initializeKeyFilterControls - // --- Store Initial Key Data --- 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; // Ensure li has full key for search + li.dataset.key = keyTextSpan.dataset.fullKey; } }); - filteredValidKeys = [...allValidKeys]; // Start with all keys + filteredValidKeys = [...allValidKeys]; } if (invalidKeysListElement) { allInvalidKeys = Array.from(invalidKeysListElement.querySelectorAll('li[data-key]')); - allInvalidKeys.forEach(li => { + allInvalidKeys.forEach(li => { const keyTextSpan = li.querySelector('.key-text'); if (keyTextSpan && keyTextSpan.dataset.fullKey) { li.dataset.key = keyTextSpan.dataset.fullKey; @@ -1009,41 +1000,54 @@ document.addEventListener('DOMContentLoaded', () => { }); } - // --- Initial Display --- if (itemsPerPageSelect) { - itemsPerPage = parseInt(itemsPerPageSelect.value, 10); + 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 + }); } - filterAndSearchValidKeys(); // This applies initial filter/search and calls displayPage('valid', 1, ...) - displayPage('invalid', 1, allInvalidKeys); // Display first page of invalid keys + + // Initial display calls + filterAndSearchValidKeys(); + displayPage('invalid', 1, allInvalidKeys); - // --- Event Listeners for Pagination/Search --- - if (thresholdInput) { - thresholdInput.addEventListener('input', filterAndSearchValidKeys); - } + // Event listeners for search and filter (thresholdInput listener is in initializeKeyFilterControls) if (searchInput) { searchInput.addEventListener('input', filterAndSearchValidKeys); } - if (itemsPerPageSelect) { - itemsPerPageSelect.addEventListener('change', () => { - itemsPerPage = parseInt(itemsPerPageSelect.value, 10); - filterAndSearchValidKeys(); // Re-filter and display page 1 +} + +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); + }); }); } - -}); - -// Service Worker registration -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'); @@ -1097,27 +1101,18 @@ async function showApiCallDetails(period) { `; try { - // 调用后端 API 获取数据 - const response = await fetch(`/api/stats/details?period=${period}`); - if (!response.ok) { - let errorMsg = `服务器错误: ${response.status}`; - try { - const errorData = await response.json(); - errorMsg = errorData.detail || errorMsg; - } catch (e) { /* 忽略解析错误 */ } - throw new Error(errorMsg); + const data = await fetchAPI(`/api/stats/details?period=${period}`); + if (data) { + renderApiCallDetails(data, contentArea); + } else { + renderApiCallDetails([], contentArea); // Show empty state if no data } - const data = await response.json(); - - // 渲染数据 - renderApiCallDetails(data, contentArea); - - } catch (error) { - console.error('获取 API 调用详情失败:', error); + } catch (apiError) { + console.error('获取 API 调用详情失败:', apiError); contentArea.innerHTML = `
-

加载失败: ${error.message}

+

加载失败: ${apiError.message}

`; } } @@ -1198,6 +1193,39 @@ window.showKeyUsageDetails = async function(key) { return; } + // renderKeyUsageDetails 变为 showKeyUsageDetails 的局部函数 + function renderKeyUsageDetails(data, container) { + if (!data || Object.keys(data).length === 0) { + container.innerHTML = ` +
+ +

该密钥在最近24小时内没有调用记录。

+
`; + return; + } + let tableHtml = ` + + + + + + + + `; + const sortedModels = Object.entries(data).sort(([, countA], [, countB]) => countB - countA); + sortedModels.forEach(([model, count]) => { + tableHtml += ` + + + + `; + }); + tableHtml += ` + +
模型名称调用次数 (24h)
${model}${count}
`; + container.innerHTML = tableHtml; + } + // 设置标题 titleElement.textContent = `密钥 ${keyDisplay} - 最近24小时请求详情`; @@ -1210,28 +1238,18 @@ window.showKeyUsageDetails = async function(key) { `; try { - // 调用新的后端 API 获取数据 - // 注意:后端需要实现 /api/key-usage-details/{key} 端点 - const response = await fetch(`/api/key-usage-details/${key}`); - if (!response.ok) { - let errorMsg = `服务器错误: ${response.status}`; - try { - const errorData = await response.json(); - errorMsg = errorData.detail || errorMsg; // 假设后端错误信息在 detail 字段 - } catch (e) { /* 忽略解析错误 */ } - throw new Error(errorMsg); + const data = await fetchAPI(`/api/key-usage-details/${key}`); + if (data) { + renderKeyUsageDetails(data, contentArea); + } else { + renderKeyUsageDetails({}, contentArea); // Show empty state if no data } - const data = await response.json(); - - // 渲染数据 - renderKeyUsageDetails(data, contentArea); - - } catch (error) { - console.error('获取密钥使用详情失败:', error); + } catch (apiError) { + console.error('获取密钥使用详情失败:', apiError); contentArea.innerHTML = `
-

加载失败: ${error.message}

+

加载失败: ${apiError.message}

`; } } @@ -1244,58 +1262,7 @@ window.closeKeyUsageDetailsModal = function() { } } -// 渲染密钥使用详情到模态框 (这个函数主要由 showKeyUsageDetails 调用,不一定需要全局,但保持一致性) -window.renderKeyUsageDetails = function(data, container) { - // data 预期格式: { "model_name1": count1, "model_name2": count2, ... } - if (!data || Object.keys(data).length === 0) { - container.innerHTML = ` -
- -

该密钥在最近24小时内没有调用记录。

-
`; - return; - } - - // 创建表格 - let tableHtml = ` - - - - - - - - - `; - - // 排序模型(可选,按调用次数降序) - const sortedModels = Object.entries(data).sort(([, countA], [, countB]) => countB - countA); - - // 填充表格行 - sortedModels.forEach(([model, count]) => { - tableHtml += ` - - - - - `; - }); - - tableHtml += ` - -
模型名称调用次数 (24h)
${model}${count}
- `; - - container.innerHTML = tableHtml; -} - -// --- Global Variables for Pagination --- -let itemsPerPage = 10; // Default, will be updated from select -let validCurrentPage = 1; -let invalidCurrentPage = 1; -let allValidKeys = []; // Stores all original valid key li elements -let allInvalidKeys = []; // Stores all original invalid key li elements -let filteredValidKeys = []; // Stores filtered and searched valid key li elements +// window.renderKeyUsageDetails 函数已被移入 showKeyUsageDetails 内部, 此处残留代码已删除。 // --- Key List Display & Pagination --- diff --git a/app/templates/config_editor.html b/app/templates/config_editor.html index d55f1c2..5dc07c5 100644 --- a/app/templates/config_editor.html +++ b/app/templates/config_editor.html @@ -146,7 +146,7 @@
- + @@ -336,7 +336,7 @@
- + 用于图像生成的付费API密钥
@@ -361,14 +361,14 @@
- + SM.MS图床的密钥
- + PicGo的API密钥
@@ -382,7 +382,7 @@
- + Cloudflare图床的认证码