mirror of
https://github.com/snailyp/gemini-balance.git
synced 2026-05-11 18:09:55 +08:00
refactor: 优化 JS 结构、API 调用和密钥管理
此次提交引入了重要的重构和改进: - JavaScript ([`app/static/js/config_editor.js`](app/static/js/config_editor.js:1), [`app/static/js/keys_status.js`](app/static/js/keys_status.js:1), [`app/static/js/error_logs.js`](app/static/js/error_logs.js:1)): - 通过初始化函数(例如 [`initializeKeyPaginationAndSearch()`](app/static/js/config_editor.js:985),[`initializeAutoRefreshControls()`](app/static/js/config_editor.js:936))实现代码模块化,以实现更好的组织。 - 通过采用 `fetchAPI` 辅助函数(在 [`showApiCallDetails()`](app/static/js/config_editor.js:1097),[`fetchAndDisplayLogs()`](app/static/js/error_logs.js:68),[`fetchKeyStatus()`](app/static/js/keys_status.js:283) 中可见其用法)标准化 API 交互。 - 改进了分页、搜索和 DOM 元素管理,尤其是在 [`config_editor.js`](app/static/js/config_editor.js:1) 和 [`keys_status.js`](app/static/js/keys_status.js:1) 中。 - 在 [`config_editor.js`](app/static/js/config_editor.js:1029) 中通过 [`registerServiceWorker()`](app/static/js/config_editor.js:1018) 添加了 service worker 注册。 - Gemini API ([`app/router/gemini_routes.py`](app/router/gemini_routes.py:1)): - 在 [`verify_selected_keys()`](app/router/gemini_routes.py:328) 端点内的 `GeminiRequest` 中添加了 `generation_config`(包含 `temperature`、`top_p`、`max_output_tokens`),以实现更可控和一致的 API 密钥验证调用。 - 配置用户界面 ([`app/templates/config_editor.html`](app/templates/config_editor.html:1)): - 将 `sensitive-input` 类应用于各种 API 密钥和令牌字段(例如 [`AUTH_TOKEN`](app/templates/config_editor.html:149),[`PAID_KEY`](app/templates/config_editor.html:339),[`SMMS_SECRET_TOKEN`](app/templates/config_editor.html:364)),以启用特定的客户端处理(例如屏蔽或特殊验证)。 这些更改旨在提高代码的可维护性,标准化前端后端通信,增强 API 交互的稳健性,并优化用于应用程序配置和 API 密钥状态管理的用户界面。
This commit is contained in:
@@ -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(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 `
|
||||
<td class="text-center px-3 py-3">
|
||||
<input type="checkbox" class="row-checkbox form-checkbox h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500" data-key="${fullKey}" data-log-id="${log.id}">
|
||||
</td>
|
||||
<td>${sequentialId}</td>
|
||||
<td class="relative group" title="${fullKey}">
|
||||
${maskedKey}
|
||||
<button class="copy-btn absolute top-1/2 right-2 transform -translate-y-1/2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity text-xs" data-copy-text="${fullKey}" title="复制完整密钥">
|
||||
<i class="far fa-copy"></i>
|
||||
</button>
|
||||
</td>
|
||||
<td>${log.error_type || '未知'}</td>
|
||||
<td class="error-code-content" title="${log.error_code || ''}">${errorCodeContent}</td>
|
||||
<td>${log.model_name || '未知'}</td>
|
||||
<td>${formattedTime}</td>
|
||||
<td>
|
||||
<button class="btn-view-details mr-2" data-log-id="${log.id}">
|
||||
<i class="fas fa-eye mr-1"></i>详情
|
||||
</button>
|
||||
<button class="btn-delete-row text-danger-600 hover:text-danger-800" data-log-id="${log.id}" title="删除此日志">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
}
|
||||
|
||||
// 渲染错误日志表格
|
||||
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 = `
|
||||
<td class="text-center px-3 py-3"> <!-- Checkbox column -->
|
||||
<input type="checkbox" class="row-checkbox form-checkbox h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500" data-key="${fullKey}" data-log-id="${log.id}"> <!-- 添加 data-log-id -->
|
||||
</td>
|
||||
<td>${sequentialId}</td> <!-- 显示从1开始的序号 -->
|
||||
<td class="relative group" title="${fullKey}"> <!-- Added relative/group for button positioning -->
|
||||
${maskedKey}
|
||||
<!-- Added copy button for the key in the table row -->
|
||||
<button class="copy-btn absolute top-1/2 right-2 transform -translate-y-1/2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity text-xs" data-copy-text="${log.gemini_key || ''}" title="复制完整密钥">
|
||||
<i class="far fa-copy"></i>
|
||||
</button>
|
||||
</td>
|
||||
<td>${log.error_type || '未知'}</td>
|
||||
<td class="error-code-content" title="${log.error_code || ''}">${errorCodeContent}</td>
|
||||
<td>${log.model_name || '未知'}</td>
|
||||
<td>${formattedTime}</td>
|
||||
<td>
|
||||
<button class="btn-view-details mr-2" data-log-id="${log.id}"> <!-- 添加 mr-2 -->
|
||||
<i class="fas fa-eye mr-1"></i>详情
|
||||
</button>
|
||||
<button class="btn-delete-row text-danger-600 hover:text-danger-800" data-log-id="${log.id}" title="删除此日志">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
|
||||
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
|
||||
|
||||
@@ -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 = '<i class="fas fa-spinner fa-spin"></i> 验证中';
|
||||
|
||||
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 = '<i class="fas fa-spinner fa-spin"></i> 重置中';
|
||||
|
||||
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 = '<i class="fas fa-redo-alt"></i> 重置'; // 恢复原始图标和文本
|
||||
@@ -508,29 +545,12 @@ async function executeResetAll(type) {
|
||||
resetButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 重置中';
|
||||
|
||||
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 = '<i class="fas fa-spinner fa-spin"></i> 验证中';
|
||||
}
|
||||
|
||||
|
||||
// 获取选中的密钥
|
||||
const keysToVerify = getSelectedKeys(type);
|
||||
|
||||
if (keysToVerify.length === 0) {
|
||||
showNotification(`没有选中的${type === 'valid' ? '有效' : '无效'}密钥可验证`, 'warning');
|
||||
if (verifyButton) { // Restore button if no keys selected
|
||||
verifyButton.innerHTML = originalVerifyHtml;
|
||||
// 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) {
|
||||
</div>`;
|
||||
|
||||
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 = `
|
||||
<div class="text-center py-10 text-danger-500">
|
||||
<i class="fas fa-exclamation-triangle text-3xl"></i>
|
||||
<p class="mt-2">加载失败: ${error.message}</p>
|
||||
<p class="mt-2">加载失败: ${apiError.message}</p>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
@@ -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 = `
|
||||
<div class="text-center py-10 text-gray-500">
|
||||
<i class="fas fa-info-circle text-3xl"></i>
|
||||
<p class="mt-2">该密钥在最近24小时内没有调用记录。</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
let tableHtml = `
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">模型名称</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">调用次数 (24h)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">`;
|
||||
const sortedModels = Object.entries(data).sort(([, countA], [, countB]) => countB - countA);
|
||||
sortedModels.forEach(([model, count]) => {
|
||||
tableHtml += `
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${model}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${count}</td>
|
||||
</tr>`;
|
||||
});
|
||||
tableHtml += `
|
||||
</tbody>
|
||||
</table>`;
|
||||
container.innerHTML = tableHtml;
|
||||
}
|
||||
|
||||
// 设置标题
|
||||
titleElement.textContent = `密钥 ${keyDisplay} - 最近24小时请求详情`;
|
||||
|
||||
@@ -1210,28 +1238,18 @@ window.showKeyUsageDetails = async function(key) {
|
||||
</div>`;
|
||||
|
||||
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 = `
|
||||
<div class="text-center py-10 text-danger-500">
|
||||
<i class="fas fa-exclamation-triangle text-3xl"></i>
|
||||
<p class="mt-2">加载失败: ${error.message}</p>
|
||||
<p class="mt-2">加载失败: ${apiError.message}</p>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
@@ -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 = `
|
||||
<div class="text-center py-10 text-gray-500">
|
||||
<i class="fas fa-info-circle text-3xl"></i>
|
||||
<p class="mt-2">该密钥在最近24小时内没有调用记录。</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建表格
|
||||
let tableHtml = `
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">模型名称</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">调用次数 (24h)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
`;
|
||||
|
||||
// 排序模型(可选,按调用次数降序)
|
||||
const sortedModels = Object.entries(data).sort(([, countA], [, countB]) => countB - countA);
|
||||
|
||||
// 填充表格行
|
||||
sortedModels.forEach(([model, count]) => {
|
||||
tableHtml += `
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${model}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${count}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
tableHtml += `
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
container.innerHTML = tableHtml;
|
||||
}
|
||||
|
||||
// --- 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 ---
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
<label for="AUTH_TOKEN" class="block font-semibold mb-2 text-gray-700">认证令牌</label>
|
||||
<div class="flex items-center">
|
||||
<div class="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">
|
||||
<input type="text" id="AUTH_TOKEN" name="AUTH_TOKEN" placeholder="默认使用ALLOWED_TOKENS中的第一个" class="array-input flex-grow px-3 py-2 border-none rounded-l-md focus:outline-none">
|
||||
<input type="text" id="AUTH_TOKEN" name="AUTH_TOKEN" placeholder="默认使用ALLOWED_TOKENS中的第一个" class="array-input flex-grow px-3 py-2 border-none rounded-l-md focus:outline-none sensitive-input">
|
||||
<button type="button" id="generateAuthTokenBtn" class="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" title="生成随机令牌">
|
||||
<i class="fas fa-dice"></i>
|
||||
</button>
|
||||
@@ -336,7 +336,7 @@
|
||||
<!-- 付费API密钥 -->
|
||||
<div class="mb-6">
|
||||
<label for="PAID_KEY" class="block font-semibold mb-2 text-gray-700">付费API密钥</label>
|
||||
<input type="text" id="PAID_KEY" name="PAID_KEY" placeholder="AIzaSyxxxxxxxxxxxxxxxxxxx" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<input type="text" id="PAID_KEY" name="PAID_KEY" placeholder="AIzaSyxxxxxxxxxxxxxxxxxxx" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 sensitive-input">
|
||||
<small class="text-gray-500 mt-1 block">用于图像生成的付费API密钥</small>
|
||||
</div>
|
||||
|
||||
@@ -361,14 +361,14 @@
|
||||
<!-- SM.MS密钥 -->
|
||||
<div class="mb-6 provider-config active" data-provider="smms">
|
||||
<label for="SMMS_SECRET_TOKEN" class="block font-semibold mb-2 text-gray-700">SM.MS密钥</label>
|
||||
<input type="text" id="SMMS_SECRET_TOKEN" name="SMMS_SECRET_TOKEN" placeholder="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<input type="text" id="SMMS_SECRET_TOKEN" name="SMMS_SECRET_TOKEN" placeholder="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 sensitive-input">
|
||||
<small class="text-gray-500 mt-1 block">SM.MS图床的密钥</small>
|
||||
</div>
|
||||
|
||||
<!-- PicGo API密钥 -->
|
||||
<div class="mb-6 provider-config" data-provider="picgo">
|
||||
<label for="PICGO_API_KEY" class="block font-semibold mb-2 text-gray-700">PicGo API密钥</label>
|
||||
<input type="text" id="PICGO_API_KEY" name="PICGO_API_KEY" placeholder="xxxx" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<input type="text" id="PICGO_API_KEY" name="PICGO_API_KEY" placeholder="xxxx" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 sensitive-input">
|
||||
<small class="text-gray-500 mt-1 block">PicGo的API密钥</small>
|
||||
</div>
|
||||
|
||||
@@ -382,7 +382,7 @@
|
||||
<!-- Cloudflare认证码 -->
|
||||
<div class="mb-6 provider-config" data-provider="cloudflare_imgbed">
|
||||
<label for="CLOUDFLARE_IMGBED_AUTH_CODE" class="block font-semibold mb-2 text-gray-700">Cloudflare认证码</label>
|
||||
<input type="text" id="CLOUDFLARE_IMGBED_AUTH_CODE" name="CLOUDFLARE_IMGBED_AUTH_CODE" placeholder="xxxxxxxxx" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<input type="text" id="CLOUDFLARE_IMGBED_AUTH_CODE" name="CLOUDFLARE_IMGBED_AUTH_CODE" placeholder="xxxxxxxxx" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 sensitive-input">
|
||||
<small class="text-gray-500 mt-1 block">Cloudflare图床的认证码</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user