From f05d67939fd745df18770b5f71becdf7595c5ab6 Mon Sep 17 00:00:00 2001 From: snaily Date: Thu, 10 Apr 2025 18:32:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0API=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E9=87=8D=E8=AF=95=E5=B9=B6=E6=94=B9=E8=BF=9BUI/UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要变更: 1. **API 请求重试机制:** * 在配置 (`.env.example`, `config.py`, `constants.py`) 中添加 `MAX_RETRIES` 设置,用于控制 API 请求失败后的最大重试次数 (默认为 3)。 * 更新 `RetryHandler` (`retry_handler.py`) 以使用此配置。 * 将 `RetryHandler` 应用于 Gemini 和 OpenAI 的内容生成路由 (`gemini_routes.py`, `openai_routes.py`),使其能够根据配置进行重试。 * 在配置编辑器页面 (`config_editor.html`) 添加 `MAX_RETRIES` 的输入字段。 2. **密钥状态页面 (Keys Status) UI/UX 改进:** * 默认隐藏 API 密钥的完整内容,仅显示部分字符 (`keys_status.html`),提高安全性。 * 添加了切换按钮和相应的 JavaScript (`keys_status.js`) 及 CSS (`keys_status.css`),允许用户点击查看或隐藏完整的密钥。 * 更新了“复制密钥”功能 (`keys_status.js`),确保复制的是完整的密钥而非掩码后的部分。 3. **错误日志页面 (Error Logs) 重构与改进:** * 重构了 HTML 结构 (`error_logs.html`),使用更一致和语义化的 class(如 `config-section`, `controls-container`, `styled-table`, `status-indicator`),并移除了 Bootstrap 依赖。 * 更新了 CSS (`error_logs.css`) 以匹配新的 HTML 结构,改进了页面布局和视觉样式。 * 改进了 JavaScript (`error_logs.js`),优化了加载、无数据、错误状态的显示逻辑,改进了分页功能,并添加了通用的通知显示函数 (`showNotification`)。 * 在错误日志表格和详情弹窗中添加了“错误类型”列/字段。 4. **其他:** * 对聊天服务 (`gemini_chat_service.py`, `openai_chat_service.py`) 和密钥管理器 (`key_manager.py`) 进行了相关更新 --- .env.example | 1 + app/config/config.py | 3 +- app/core/constants.py | 1 + app/handler/retry_handler.py | 3 +- app/router/gemini_routes.py | 4 +- app/router/openai_routes.py | 2 +- app/service/chat/gemini_chat_service.py | 7 +- app/service/chat/openai_chat_service.py | 7 +- app/service/key/key_manager.py | 8 +- app/static/css/error_logs.css | 512 +++++++++++++----------- app/static/css/keys_status.css | 31 +- app/static/js/error_logs.js | 491 +++++++++++++++-------- app/static/js/keys_status.js | 21 +- app/templates/config_editor.html | 6 + app/templates/error_logs.html | 218 +++++----- app/templates/keys_status.html | 10 +- 16 files changed, 791 insertions(+), 534 deletions(-) diff --git a/.env.example b/.env.example index abd8b6c..ec98ec7 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,7 @@ SHOW_SEARCH_LINK=true SHOW_THINKING_PROCESS=true BASE_URL=https://generativelanguage.googleapis.com/v1beta MAX_FAILURES=10 +MAX_RETRIES=3 # 请求超时时间(秒) TIME_OUT=300 #########################image_generate 相关配置########################### diff --git a/app/config/config.py b/app/config/config.py index 4a88ace..8cf2d5b 100644 --- a/app/config/config.py +++ b/app/config/config.py @@ -9,7 +9,7 @@ from pydantic import ValidationError from pydantic_settings import BaseSettings from sqlalchemy import insert, update, select -from app.core.constants import API_VERSION, DEFAULT_CREATE_IMAGE_MODEL, DEFAULT_FILTER_MODELS, DEFAULT_MODEL, DEFAULT_STREAM_CHUNK_SIZE, DEFAULT_STREAM_LONG_TEXT_THRESHOLD, DEFAULT_STREAM_MAX_DELAY, DEFAULT_STREAM_MIN_DELAY, DEFAULT_STREAM_SHORT_TEXT_THRESHOLD, DEFAULT_TIMEOUT +from app.core.constants import API_VERSION, DEFAULT_CREATE_IMAGE_MODEL, DEFAULT_FILTER_MODELS, DEFAULT_MODEL, DEFAULT_STREAM_CHUNK_SIZE, DEFAULT_STREAM_LONG_TEXT_THRESHOLD, DEFAULT_STREAM_MAX_DELAY, DEFAULT_STREAM_MIN_DELAY, DEFAULT_STREAM_SHORT_TEXT_THRESHOLD, DEFAULT_TIMEOUT, MAX_RETRIES from app.log.logger import get_config_logger # 延迟导入以避免循环依赖,仅在 sync_initial_settings 中使用 # from app.database.connection import database @@ -36,6 +36,7 @@ class Settings(BaseSettings): MAX_FAILURES: int = 3 TEST_MODEL: str = DEFAULT_MODEL TIME_OUT: int = DEFAULT_TIMEOUT + MAX_RETRIES: int = MAX_RETRIES # 模型相关配置 SEARCH_MODELS: List[str] = ["gemini-2.0-flash-exp"] diff --git a/app/core/constants.py b/app/core/constants.py index 32ccf78..6d0b93b 100644 --- a/app/core/constants.py +++ b/app/core/constants.py @@ -5,6 +5,7 @@ # API相关常量 API_VERSION = "v1beta" DEFAULT_TIMEOUT = 300 # 秒 +MAX_RETRIES = 3 # 最大重试次数 # 模型相关常量 SUPPORTED_ROLES = ["user", "model", "system"] diff --git a/app/handler/retry_handler.py b/app/handler/retry_handler.py index c56e57e..cdeedd8 100644 --- a/app/handler/retry_handler.py +++ b/app/handler/retry_handler.py @@ -3,6 +3,7 @@ from functools import wraps from typing import Callable, TypeVar +from app.core.constants import MAX_RETRIES from app.log.logger import get_retry_logger T = TypeVar("T") @@ -12,7 +13,7 @@ logger = get_retry_logger() class RetryHandler: """重试处理装饰器""" - def __init__(self, max_retries: int = 3, key_arg: str = "api_key"): + def __init__(self, max_retries: int = MAX_RETRIES, key_arg: str = "api_key"): self.max_retries = max_retries self.key_arg = key_arg diff --git a/app/router/gemini_routes.py b/app/router/gemini_routes.py index fa93e6d..3d09871 100644 --- a/app/router/gemini_routes.py +++ b/app/router/gemini_routes.py @@ -87,7 +87,7 @@ async def list_models( @router.post("/models/{model_name}:generateContent") @router_v1beta.post("/models/{model_name}:generateContent") -@RetryHandler(max_retries=3, key_arg="api_key") +@RetryHandler(max_retries=settings.MAX_RETRIES, key_arg="api_key") async def generate_content( model_name: str, request: GeminiRequest, @@ -118,7 +118,7 @@ async def generate_content( @router.post("/models/{model_name}:streamGenerateContent") @router_v1beta.post("/models/{model_name}:streamGenerateContent") -@RetryHandler(max_retries=3, key_arg="api_key") +@RetryHandler(max_retries=settings.MAX_RETRIES, key_arg="api_key") async def stream_generate_content( model_name: str, request: GeminiRequest, diff --git a/app/router/openai_routes.py b/app/router/openai_routes.py index 16ce913..65de8c1 100644 --- a/app/router/openai_routes.py +++ b/app/router/openai_routes.py @@ -62,7 +62,7 @@ async def list_models( @router.post("/v1/chat/completions") @router.post("/hf/v1/chat/completions") -@RetryHandler(max_retries=3, key_arg="api_key") +@RetryHandler(max_retries=settings.MAX_RETRIES, key_arg="api_key") async def chat_completion( request: ChatRequest, _=Depends(security_service.verify_authorization), diff --git a/app/service/chat/gemini_chat_service.py b/app/service/chat/gemini_chat_service.py index 3d2a291..844af4f 100644 --- a/app/service/chat/gemini_chat_service.py +++ b/app/service/chat/gemini_chat_service.py @@ -174,7 +174,7 @@ class GeminiChatService: ) -> AsyncGenerator[str, None]: """流式生成内容""" retries = 0 - max_retries = 3 + max_retries = settings.MAX_RETRIES payload = _build_payload(model, request) while retries < max_retries: try: @@ -225,8 +225,9 @@ class GeminiChatService: ) # 尝试切换 API Key - api_key = await self.key_manager.handle_api_failure(api_key) - logger.info(f"Switched to new API key: {api_key}") + api_key = await self.key_manager.handle_api_failure(api_key,retries) + if api_key: + logger.info(f"Switched to new API key: {api_key}") if retries >= max_retries: logger.error( f"Max retries ({max_retries}) reached for streaming. Raising error" diff --git a/app/service/chat/openai_chat_service.py b/app/service/chat/openai_chat_service.py index 29ac7a2..a217255 100644 --- a/app/service/chat/openai_chat_service.py +++ b/app/service/chat/openai_chat_service.py @@ -219,7 +219,7 @@ class OpenAIChatService: ) -> AsyncGenerator[str, None]: """处理流式聊天完成,添加重试逻辑""" retries = 0 - max_retries = 3 + max_retries = settings.MAX_RETRIES while retries < max_retries: try: tool_call_flag = False @@ -281,8 +281,9 @@ class OpenAIChatService: ) # 尝试切换 API Key - api_key = await self.key_manager.handle_api_failure(api_key) - logger.info(f"Switched to new API key: {api_key}") + api_key = await self.key_manager.handle_api_failure(api_key,retries) + if api_key: + logger.info(f"Switched to new API key: {api_key}") if retries >= max_retries: logger.error( f"Max retries ({max_retries}) reached for streaming. Raising error" diff --git a/app/service/key/key_manager.py b/app/service/key/key_manager.py index 3573144..4f27ffa 100644 --- a/app/service/key/key_manager.py +++ b/app/service/key/key_manager.py @@ -52,7 +52,7 @@ class KeyManager: # await self.reset_failure_counts() 取消重置 return current_key - async def handle_api_failure(self, api_key: str) -> str: + async def handle_api_failure(self, api_key: str,retries: int) -> str: """处理API调用失败""" async with self.failure_count_lock: self.key_failure_counts[api_key] += 1 @@ -60,8 +60,10 @@ class KeyManager: logger.warning( f"API key {api_key} has failed {self.MAX_FAILURES} times" ) - - return await self.get_next_working_key() + if retries < settings.MAX_RETRIES: + return await self.get_next_working_key() + else: + return "" def get_fail_count(self, key: str) -> int: """获取指定密钥的失败次数""" diff --git a/app/static/css/error_logs.css b/app/static/css/error_logs.css index dcdd01d..7f6d402 100644 --- a/app/static/css/error_logs.css +++ b/app/static/css/error_logs.css @@ -1,271 +1,331 @@ -/* 错误日志页面样式 */ +/* error_logs.css - Styles specific to the error logs page, complementing config_editor.css */ -/* 全局样式 */ -body { - font-family: 'Roboto', sans-serif; - background-color: #f8f9fa; +/* Inherit body, container, h1, nav-tabs, config-section, scroll-buttons, refresh-btn, copyright from config_editor.css */ + +/* Style the controls container */ +.controls-container { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding: 15px; + background-color: rgba(255, 255, 255, 0.8); + border-radius: 8px; + border: 1px solid rgba(0,0,0,0.05); + flex-wrap: wrap; /* Allow wrapping on smaller screens */ + gap: 15px; /* Add gap between items */ +} + +.page-size-selector { + display: flex; + align-items: center; + gap: 8px; +} + +.page-size-selector label { + font-weight: bold; + color: #2c3e50; + margin-bottom: 0; /* Override default label margin */ +} + +.page-size-selector select { + padding: 8px 12px; + border: 1px solid rgba(0,0,0,0.1); + border-radius: 6px; + background-color: white; + font-size: 14px; + min-width: 70px; /* Ensure select has some width */ +} + +.page-size-selector span { + color: #2c3e50; +} + +/* Style the action button (refresh) */ +.action-btn { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + padding: 10px 20px; + border-radius: 25px; + cursor: pointer; + font-size: 14px; + font-weight: bold; + transition: all 0.3s ease; + display: flex; + align-items: center; + gap: 8px; + box-shadow: 0 4px 10px rgba(0,0,0,0.1); +} + +.action-btn:hover { + transform: translateY(-2px); + box-shadow: 0 8px 15px rgba(118, 75, 162, 0.2); +} + +.action-btn i { + font-size: 1em; +} + +/* Table container and styled table */ +.table-container { + overflow-x: auto; /* Allow horizontal scrolling for table */ + background-color: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.05); + border: 1px solid rgba(0,0,0,0.05); +} + +.styled-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; color: #333; } -.container { - max-width: 1200px; - margin: 0 auto; - padding: 20px; +.styled-table thead tr { + background-color: #f8f9fa; /* Light grey header */ + text-align: left; + font-weight: bold; + color: #2c3e50; + border-bottom: 2px solid #dee2e6; } -h1 { - color: #764ba2; - font-weight: 700; - margin-bottom: 0; +.styled-table th, +.styled-table td { + padding: 12px 15px; + border-bottom: 1px solid #eee; /* Lighter border for rows */ + vertical-align: middle; } -/* 导航栏样式 */ -.nav-tabs { - display: flex; - margin-bottom: 20px; - border-bottom: none; +.styled-table tbody tr { + transition: background-color 0.2s ease; } -.tab-link { - padding: 10px 20px; - margin-right: 5px; - color: #555; - text-decoration: none; - border-radius: 5px; - transition: all 0.3s ease; - display: flex; - align-items: center; +.styled-table tbody tr:hover { + background-color: #f1f1f1; /* Subtle hover effect */ } -.tab-link i { - margin-right: 5px; +.styled-table tbody tr:last-of-type { + border-bottom: none; /* Remove border from last row */ } -.tab-link:hover { - background-color: #f0f0f0; - color: #764ba2; -} - -.tab-link.active { - background-color: #764ba2; - color: white; -} - -/* 卡片样式 */ -.card { - border-radius: 10px; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); - margin-bottom: 20px; - border: none; -} - -.card-header { - background-color: #f8f9fa; - border-bottom: 1px solid #eee; - padding: 15px 20px; - border-radius: 10px 10px 0 0 !important; -} - -.card-body { - padding: 20px; -} - -.card-footer { - background-color: #f8f9fa; - border-top: 1px solid #eee; - padding: 15px 20px; - border-radius: 0 0 10px 10px !important; -} - -/* 表格样式 */ -.table { - font-size: 0.9rem; -} - -.table th { - background-color: #f8f9fa; - font-weight: 600; -} - -/* 错误日志内容截断 */ +/* Error log content truncation */ .error-log-content { - max-width: 300px; + max-width: 250px; /* Adjust as needed */ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + cursor: help; /* Indicate it's expandable/clickable */ } -/* 模态框样式 */ -.modal-dialog { - max-width: 800px; +/* Style for the 'View Details' button */ +.btn-view-details { + background-color: #667eea; + color: white; + border: none; + padding: 5px 10px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + transition: background-color 0.3s ease; } -pre { - white-space: pre-wrap; - word-wrap: break-word; - max-height: 300px; - overflow-y: auto; +.btn-view-details:hover { + background-color: #5a6fd0; } -/* 分页样式 */ -.pagination .page-item.active .page-link { - background-color: #0d6efd; - border-color: #0d6efd; +/* Status Indicators (Loading, No Data, Error) */ +.status-indicator { + text-align: center; + padding: 30px 15px; + margin: 20px 0; + border-radius: 8px; + display: none; /* Hidden by default */ + color: #555; +} + +.status-indicator.loading { + background-color: rgba(240, 240, 240, 0.7); +} + +.status-indicator.no-data { + background-color: rgba(240, 240, 240, 0.7); +} + +.status-indicator.error { + background-color: rgba(231, 76, 60, 0.1); /* Light red background */ + color: #c0392b; /* Darker red text */ + font-weight: bold; +} + +.status-indicator p { + margin-top: 10px; + margin-bottom: 0; + font-size: 1.1em; +} + +/* Basic spinner animation */ +.spinner { + border: 4px solid rgba(0, 0, 0, 0.1); + width: 36px; + height: 36px; + border-radius: 50%; + border-left-color: #667eea; + animation: spin 1s ease infinite; + margin: 0 auto; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Pagination Styles */ +.pagination-container { + display: flex; + justify-content: center; + padding: 20px 0; + margin-top: 20px; + border-top: 1px solid rgba(0,0,0,0.1); +} + +.pagination { + list-style: none; + padding: 0; + margin: 0; + display: flex; + gap: 5px; +} + +.pagination .page-item { + margin: 0; } .pagination .page-link { - color: #0d6efd; -} - -/* 按钮样式 */ -.btn-view-details { - padding: 0.25rem 0.5rem; - font-size: 0.8rem; -} - -/* 加载指示器样式 */ -#loadingIndicator .spinner-border { - width: 3rem; - height: 3rem; -} - -/* 刷新按钮样式 */ -.refresh-btn { - position: fixed; - top: 20px; - right: 20px; - width: 40px; - height: 40px; - border-radius: 50%; - background-color: #764ba2; - color: white; - border: none; - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); - display: flex; - justify-content: center; - align-items: center; - cursor: pointer; - z-index: 1000; - transition: all 0.3s ease; -} - -.refresh-btn:hover { - background-color: #5d3b82; - transform: scale(1.1); -} - -.refresh-btn i { - font-size: 18px; -} - -.rotating { - animation: rotate 1s linear infinite; -} - -@keyframes rotate { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} - -/* 滚动按钮样式 */ -.scroll-buttons { - position: fixed; - bottom: 20px; - right: 20px; - display: flex; - flex-direction: column; - gap: 10px; - z-index: 1000; -} - -.scroll-btn { - width: 40px; - height: 40px; - border-radius: 50%; - background-color: #764ba2; - color: white; - border: none; - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); - display: flex; - justify-content: center; - align-items: center; - cursor: pointer; - transition: all 0.3s ease; -} - -.scroll-btn:hover { - background-color: #5d3b82; - transform: scale(1.1); -} - -/* 版权信息样式 */ -.copyright { - text-align: center; - margin-top: 30px; - padding: 20px 0; - color: #666; - font-size: 0.9rem; - border-top: 1px solid #eee; -} - -.copyright a { - color: #764ba2; + display: block; + padding: 8px 12px; + color: #667eea; + background-color: white; + border: 1px solid #ddd; + border-radius: 4px; text-decoration: none; + transition: all 0.3s ease; + font-size: 14px; } -.copyright a:hover { - text-decoration: underline; +.pagination .page-link:hover { + background-color: #f0f0f0; + border-color: #ccc; } -.copyright img { - width: 20px; - height: 20px; - border-radius: 50%; - vertical-align: middle; - margin-right: 5px; -} - -/* 复制状态提示 */ -#copyStatus { - position: fixed; - bottom: 20px; - left: 50%; - transform: translateX(-50%); - background-color: rgba(0, 0, 0, 0.7); +.pagination .page-item.active .page-link { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; - padding: 10px 20px; - border-radius: 5px; - z-index: 1000; - opacity: 0; - transition: opacity 0.3s ease; + border-color: #667eea; + font-weight: bold; } -#copyStatus.show { - opacity: 1; +.pagination .page-item.disabled .page-link { + color: #aaa; + pointer-events: none; + background-color: #f9f9f9; + border-color: #ddd; } -/* 响应式调整 */ +/* Modal Styles (Inherit base from config_editor.css, add specifics) */ +#logDetailModal .modal-content { + max-width: 800px; /* Wider modal for logs */ +} + +#logDetailModal h2 { + font-size: 1.5em; + margin-bottom: 20px; + padding-bottom: 10px; + border-bottom: 1px solid #eee; +} + +.modal-body-content { + max-height: 60vh; /* Limit height and allow scrolling */ + overflow-y: auto; + padding-right: 15px; /* Space for scrollbar */ +} + +.detail-item { + margin-bottom: 15px; +} + +.detail-item h6 { + font-weight: bold; + color: #2c3e50; + margin-bottom: 5px; +} + +.detail-item p, +.detail-item pre { + background-color: #f8f9fa; + padding: 10px; + border-radius: 4px; + border: 1px solid #eee; + font-size: 14px; + color: #333; + margin: 0; + white-space: pre-wrap; /* Ensure pre content wraps */ + word-wrap: break-word; +} + +.detail-item pre { + max-height: 200px; /* Limit height of individual pre blocks */ + overflow-y: auto; +} + +/* Notification Style (Inherited from config_editor.css) */ +#copyStatus { + /* Uses .notification styles from config_editor.css */ + background-color: rgba(39, 174, 96, 0.9); /* Green for success */ +} + +/* Responsive Adjustments */ @media (max-width: 768px) { - .d-flex.justify-content-between { + .controls-container { flex-direction: column; - align-items: flex-start !important; + align-items: stretch; /* Stretch items to full width */ } - - .d-flex.justify-content-between > div { - margin-top: 1rem; - width: 100%; + + .page-size-selector { + justify-content: center; /* Center page size selector */ } - - .card-header .d-flex { - flex-direction: column; + + .action-btn { + width: 100%; /* Full width button */ + justify-content: center; } - - .card-header .input-group { - margin-bottom: 0.5rem; - margin-right: 0 !important; + + .styled-table { + font-size: 13px; } - - #refreshBtn { - width: 100%; + + .styled-table th, + .styled-table td { + padding: 10px 8px; + } + + .error-log-content { + max-width: 150px; + } + + #logDetailModal .modal-content { + width: 95%; + padding: 20px; + } +} + +@media (max-width: 480px) { + .styled-table { + font-size: 12px; + } + .btn-view-details { + padding: 4px 8px; + font-size: 11px; } } diff --git a/app/static/css/keys_status.css b/app/static/css/keys_status.css index 11659b4..858cf18 100644 --- a/app/static/css/keys_status.css +++ b/app/static/css/keys_status.css @@ -186,12 +186,16 @@ li:hover { display: flex; align-items: center; gap: 15px; - flex: 1; + flex: 1; /* Allow key-info to take up available space */ + min-width: 0; /* Prevent flex item from overflowing */ } .key-text { font-family: 'Roboto Mono', monospace; color: #2c3e50; + word-break: break-all; /* Ensure long keys wrap */ + flex-shrink: 1; /* Allow key text to shrink if needed */ + margin-right: 10px; /* Add space between key text and toggle button */ } .fail-count { @@ -443,6 +447,27 @@ li:hover { to { transform: rotate(360deg); } } +/* Toggle Visibility Button Styles */ +.toggle-vis-btn { + background: none; + border: none; + color: #7f8c8d; /* Subtle color */ + cursor: pointer; + padding: 5px; + font-size: 16px; + transition: color 0.3s ease; + margin-left: 5px; /* Space from key text */ + flex-shrink: 0; /* Prevent button from shrinking */ +} + +.toggle-vis-btn:hover { + color: #34495e; /* Darker color on hover */ +} + +.toggle-vis-btn i { + vertical-align: middle; +} + @media (max-width: 768px) { .container { width: 100%; @@ -478,6 +503,7 @@ li:hover { flex-direction: column; align-items: flex-start; gap: 8px; + width: 100%; /* Ensure key-info takes full width */ } li { flex-direction: column; @@ -493,7 +519,8 @@ li:hover { justify-content: center; } .key-text { - word-break: break-all; + /* word-break: break-all; */ /* Already applied above */ + margin-right: 0; /* Remove right margin on smaller screens */ } .scroll-buttons { right: 10px; diff --git a/app/static/js/error_logs.js b/app/static/js/error_logs.js index c2d30d4..c0b82ee 100644 --- a/app/static/js/error_logs.js +++ b/app/static/js/error_logs.js @@ -1,258 +1,397 @@ -// 错误日志页面JavaScript +// 错误日志页面JavaScript (Updated for new structure, no Bootstrap) // 页面滚动功能 function scrollToTop() { - window.scrollTo({ - top: 0, - behavior: 'smooth' - }); + window.scrollTo({ top: 0, behavior: 'smooth' }); } function scrollToBottom() { - window.scrollTo({ - top: document.body.scrollHeight, - behavior: 'smooth' - }); + window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); } // 刷新页面功能 function refreshPage(button) { if (button) { - button.classList.add('rotating'); + // Use 'loading' class consistent with config_editor.css animation + button.classList.add('loading'); + // Disable button while refreshing + button.disabled = true; } - - setTimeout(() => { - window.location.reload(); - }, 500); + + // Fetch new data instead of full reload for a smoother experience + loadErrorLogs().finally(() => { + if (button) { + // Remove loading class and re-enable button after fetch completes + button.classList.remove('loading'); + button.disabled = false; + } + }); + // Optional: Keep reload as fallback or if preferred + // setTimeout(() => { + // window.location.reload(); + // }, 500); } // 全局变量 let currentPage = 1; let pageSize = 20; -let totalPages = 1; -let errorLogs = []; +// 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 + +// DOM Elements Cache +let pageSizeSelector; +let refreshBtn; +let tableBody; +let paginationElement; +let loadingIndicator; +let noDataMessage; +let errorMessage; +let logDetailModal; +let modalCloseBtns; // Collection of close buttons for the modal // 页面加载完成后执行 document.addEventListener('DOMContentLoaded', function() { - // 初始化页面大小选择器 - const pageSizeSelector = document.getElementById('pageSize'); - pageSizeSelector.value = pageSize; - pageSizeSelector.addEventListener('change', function() { - pageSize = parseInt(this.value); - currentPage = 1; // 重置到第一页 - loadErrorLogs(); - }); - - // 初始化刷新按钮 - document.getElementById('refreshBtn').addEventListener('click', function() { - loadErrorLogs(); - }); - - // 加载错误日志数据 + // Cache DOM elements + pageSizeSelector = document.getElementById('pageSize'); + refreshBtn = document.getElementById('refreshBtn'); + 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'); + + // Initialize page size selector + if (pageSizeSelector) { + pageSizeSelector.value = pageSize; + pageSizeSelector.addEventListener('change', function() { + pageSize = parseInt(this.value); + currentPage = 1; // Reset to first page + loadErrorLogs(); + }); + } + + // Initialize refresh button (using the one inside the controls container) + if (refreshBtn) { + refreshBtn.addEventListener('click', function() { + // Add loading state to the button itself + this.classList.add('loading'); + this.disabled = true; + loadErrorLogs().finally(() => { + this.classList.remove('loading'); + this.disabled = false; + }); + }); + } + + // Initialize modal close buttons + 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(); + } + }); + } + + // Initial load of error logs loadErrorLogs(); }); // 加载错误日志数据 -function loadErrorLogs() { +async function loadErrorLogs() { showLoading(true); showError(false); showNoData(false); - + const offset = (currentPage - 1) * pageSize; - - fetch(`/api/logs/errors?limit=${pageSize}&offset=${offset}`) - .then(response => { - if (!response.ok) { - throw new Error('网络响应异常'); + + try { + const response = await fetch(`/api/logs/errors?limit=${pageSize}&offset=${offset}`); + 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 } - return response.json(); - }) - .then(data => { + throw new Error(errorData?.detail || `网络响应异常: ${response.statusText}`); + } + const data = await response.json(); + // Assuming the API returns an object like { logs: [], total: count } + // If it only returns an array, we can't get the total count accurately for pagination + if (Array.isArray(data)) { errorLogs = data; - renderErrorLogs(); - showLoading(false); - - if (errorLogs.length === 0) { - showNoData(true); - } - }) - .catch(error => { - console.error('获取错误日志失败:', error); - showLoading(false); - showError(true); - }); + renderErrorLogs(errorLogs); // Pass data directly + updatePagination(errorLogs.length, -1); // Indicate unknown total + } else if (data && Array.isArray(data.logs)) { + errorLogs = data.logs; + renderErrorLogs(errorLogs); // Pass logs array + updatePagination(errorLogs.length, data.total || -1); // Pass total count if available + } else { + throw new Error('无法识别的API响应格式'); + } + + + showLoading(false); + + if (errorLogs.length === 0) { + showNoData(true); + } + } catch (error) { + console.error('获取错误日志失败:', error); + showLoading(false); + showError(true, error.message); // Show specific error message + } } + // 渲染错误日志表格 -function renderErrorLogs() { - const tableBody = document.getElementById('errorLogsTable'); - tableBody.innerHTML = ''; - - errorLogs.forEach(log => { +function renderErrorLogs(logs) { + if (!tableBody) return; + tableBody.innerHTML = ''; // Clear previous entries + + if (!logs || logs.length === 0) { + // Handled by showNoData + return; + } + + logs.forEach(log => { const row = document.createElement('tr'); - - // 格式化日期 - const requestTime = new Date(log.request_time); - const formattedTime = requestTime.toLocaleString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }); - - // 截断错误日志内容 + + // 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); } + + + // Truncate error log content for display const errorLogContent = log.error_log ? log.error_log.substring(0, 100) + (log.error_log.length > 100 ? '...' : '') : '无'; - + row.innerHTML = ` ${log.id} ${log.gemini_key || '无'} - ${errorLogContent} + ${log.error_type || '未知'} + ${errorLogContent} ${log.model_name || '未知'} ${formattedTime} - `; - + tableBody.appendChild(row); }); - - // 添加详情按钮事件监听 + + // Add event listeners to new 'View Details' buttons document.querySelectorAll('.btn-view-details').forEach(button => { button.addEventListener('click', function() { const logId = parseInt(this.getAttribute('data-log-id')); showLogDetails(logId); }); }); - - // 更新分页 - updatePagination(); } -// 显示错误日志详情 +// 显示错误日志详情 (Custom Modal Logic) function showLogDetails(logId) { - const log = errorLogs.find(log => log.id === logId); - if (!log) return; - - // 格式化日期 - const requestTime = new Date(log.request_time); - const formattedTime = requestTime.toLocaleString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }); - - // 格式化请求消息 - let formattedRequestMsg = ''; + const log = errorLogs.find(l => l.id === logId); + if (!log || !logDetailModal) return; + + // 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); } + + + // Format request message (handle potential JSON) + let formattedRequestMsg = '无'; if (log.request_msg) { try { - if (typeof log.request_msg === 'string') { - formattedRequestMsg = log.request_msg; - } else { - formattedRequestMsg = JSON.stringify(log.request_msg, null, 2); + // Check if it's already an object/array + if (typeof log.request_msg === 'object' && log.request_msg !== null) { + formattedRequestMsg = JSON.stringify(log.request_msg, null, 2); + } + // Check if it's a JSON string + else if (typeof log.request_msg === 'string' && log.request_msg.trim().startsWith('{') || log.request_msg.trim().startsWith('[')) { + formattedRequestMsg = JSON.stringify(JSON.parse(log.request_msg), null, 2); + } + else { + formattedRequestMsg = String(log.request_msg); } } catch (e) { - formattedRequestMsg = String(log.request_msg); + formattedRequestMsg = String(log.request_msg); // Fallback to string + console.warn("Could not parse request_msg as JSON:", e); } - } else { - formattedRequestMsg = '无'; } - - // 填充模态框内容 + + // Populate modal content document.getElementById('modalGeminiKey').textContent = log.gemini_key || '无'; + document.getElementById('modalErrorType').textContent = log.error_type || '未知'; document.getElementById('modalErrorLog').textContent = log.error_log || '无'; document.getElementById('modalRequestMsg').textContent = formattedRequestMsg; - // Add model name display logic here - assuming an element with id 'modalModelName' exists document.getElementById('modalModelName').textContent = log.model_name || '未知'; document.getElementById('modalRequestTime').textContent = formattedTime; - - // 显示模态框 - const modal = new bootstrap.Modal(document.getElementById('logDetailModal')); - modal.show(); + + // Show the modal + logDetailModal.classList.add('show'); + // Optional: Prevent body scrolling when modal is open + document.body.style.overflow = 'hidden'; } +// Close Log Detail Modal +function closeLogDetailModal() { + if (logDetailModal) { + logDetailModal.classList.remove('show'); + // Optional: Restore body scrolling + document.body.style.overflow = ''; + } +} + + // 更新分页控件 -function updatePagination() { - const paginationElement = document.getElementById('pagination'); - paginationElement.innerHTML = ''; - - // 计算总页数 - const totalCount = errorLogs.length; - totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); - - // 上一页按钮 - const prevItem = document.createElement('li'); - prevItem.className = `page-item ${currentPage === 1 ? 'disabled' : ''}`; - prevItem.innerHTML = ``; - prevItem.addEventListener('click', function(e) { - e.preventDefault(); - if (currentPage > 1) { - currentPage--; - loadErrorLogs(); +function updatePagination(currentItemCount, totalItems) { + if (!paginationElement) return; + paginationElement.innerHTML = ''; // Clear existing pagination + + // 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) { + // If less items than page size fetched on page 1, assume it's the only page + totalPages = 1; + } else { + // If total is unknown and more items might exist, we can't build full pagination + // 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 + 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 endPage = Math.min(totalPages, startPage + maxPagesToShow - 1); + + // Adjust startPage if endPage reaches the limit first + if (endPage === totalPages) { + startPage = Math.max(1, endPage - maxPagesToShow + 1); + } + + + // Previous Button + addPaginationLink(paginationElement, '«', currentPage > 1, () => { currentPage--; loadErrorLogs(); }); + + // First Page Button + if (startPage > 1) { + addPaginationLink(paginationElement, '1', true, () => { currentPage = 1; loadErrorLogs(); }); + if (startPage > 2) { + addPaginationLink(paginationElement, '...', false); // Ellipsis } - }); - paginationElement.appendChild(prevItem); - - // 页码按钮 - for (let i = 1; i <= totalPages; i++) { - const pageItem = document.createElement('li'); - pageItem.className = `page-item ${i === currentPage ? 'active' : ''}`; - pageItem.innerHTML = `${i}`; - pageItem.addEventListener('click', function(e) { + } + + // Page Number Buttons + for (let i = startPage; i <= endPage; i++) { + addPaginationLink(paginationElement, i.toString(), true, () => { currentPage = i; loadErrorLogs(); }, i === currentPage); + } + + // Last Page Button + if (endPage < totalPages) { + if (endPage < totalPages - 1) { + addPaginationLink(paginationElement, '...', false); // Ellipsis + } + addPaginationLink(paginationElement, totalPages.toString(), true, () => { currentPage = totalPages; loadErrorLogs(); }); + } + + + // Next Button + addPaginationLink(paginationElement, '»', currentPage < totalPages, () => { currentPage++; loadErrorLogs(); }); +} + +// Helper function to add pagination links +function addPaginationLink(parentElement, text, enabled, clickHandler, isActive = false) { + const pageItem = document.createElement('li'); + pageItem.className = `page-item ${!enabled ? 'disabled' : ''} ${isActive ? 'active' : ''}`; + + const pageLink = document.createElement('a'); + pageLink.className = 'page-link'; + pageLink.href = '#'; // Prevent page jump + pageLink.innerHTML = text; + + if (enabled && clickHandler) { + pageLink.addEventListener('click', function(e) { e.preventDefault(); - currentPage = i; - loadErrorLogs(); + clickHandler(); }); - paginationElement.appendChild(pageItem); + } else if (!enabled) { + pageLink.addEventListener('click', e => e.preventDefault()); // Prevent click on disabled } - - // 下一页按钮 - const nextItem = document.createElement('li'); - nextItem.className = `page-item ${currentPage === totalPages ? 'disabled' : ''}`; - nextItem.innerHTML = ``; - nextItem.addEventListener('click', function(e) { - e.preventDefault(); - if (currentPage < totalPages) { - currentPage++; - loadErrorLogs(); - } - }); - paginationElement.appendChild(nextItem); + + + pageItem.appendChild(pageLink); + parentElement.appendChild(pageItem); } -// 显示/隐藏加载指示器 + +// 显示/隐藏状态指示器 (using 'active' class) function showLoading(show) { - const loadingIndicator = document.getElementById('loadingIndicator'); - if (show) { - loadingIndicator.classList.remove('d-none'); - } else { - loadingIndicator.classList.add('d-none'); - } + if (loadingIndicator) loadingIndicator.style.display = show ? 'block' : 'none'; } -// 显示/隐藏错误消息 -function showError(show) { - const errorMessage = document.getElementById('errorMessage'); - if (show) { - errorMessage.classList.remove('d-none'); - } else { - errorMessage.classList.add('d-none'); - } -} - -// 显示/隐藏无数据消息 function showNoData(show) { - const noDataMessage = document.getElementById('noDataMessage'); - if (show) { - noDataMessage.classList.remove('d-none'); - } else { - noDataMessage.classList.add('d-none'); + if (noDataMessage) noDataMessage.style.display = show ? 'block' : 'none'; +} + +function showError(show, message = '加载错误日志失败,请稍后重试。') { + if (errorMessage) { + errorMessage.style.display = show ? 'block' : 'none'; + if (show) { + // Update the error message content + const p = errorMessage.querySelector('p'); + if (p) p.textContent = message; + } } } + +// Function to show temporary status notifications (like copy success) +function showNotification(message, type = 'success', duration = 3000) { + const notificationElement = document.getElementById('copyStatus'); // Or a more generic ID if needed + if (!notificationElement) return; + + notificationElement.textContent = message; + notificationElement.className = `notification ${type} show`; // Add 'show' class + + // Hide after duration + setTimeout(() => { + notificationElement.classList.remove('show'); + }, duration); +} + +// Example Usage (if copy functionality is added later): +// showNotification('密钥已复制!', 'success'); +// showNotification('复制失败!', 'error'); diff --git a/app/static/js/keys_status.js b/app/static/js/keys_status.js index c9bc849..413bd02 100644 --- a/app/static/js/keys_status.js +++ b/app/static/js/keys_status.js @@ -26,7 +26,7 @@ function copyToClipboard(text) { } function copyKeys(type) { - const keys = Array.from(document.querySelectorAll(`#${type}Keys .key-text`)).map(span => span.textContent.trim()); + const keys = Array.from(document.querySelectorAll(`#${type}Keys .key-text`)).map(span => span.dataset.fullKey); const jsonKeys = JSON.stringify(keys); copyToClipboard(jsonKeys) @@ -173,3 +173,22 @@ if ('serviceWorker' in navigator) { }); }); } +function toggleKeyVisibility(button) { + const keyInfoDiv = button.closest('.key-info'); + const keyTextSpan = keyInfoDiv.querySelector('.key-text'); + const eyeIcon = button.querySelector('i'); + const fullKey = keyTextSpan.dataset.fullKey; + const maskedKey = fullKey.substring(0, 4) + '...' + fullKey.substring(fullKey.length - 4); + + if (keyTextSpan.textContent === maskedKey) { + keyTextSpan.textContent = fullKey; + eyeIcon.classList.remove('fa-eye'); + eyeIcon.classList.add('fa-eye-slash'); + button.title = '隐藏密钥'; + } else { + keyTextSpan.textContent = maskedKey; + eyeIcon.classList.remove('fa-eye-slash'); + eyeIcon.classList.add('fa-eye'); + button.title = '显示密钥'; + } +} diff --git a/app/templates/config_editor.html b/app/templates/config_editor.html index fd93ddd..21339ef 100644 --- a/app/templates/config_editor.html +++ b/app/templates/config_editor.html @@ -101,6 +101,12 @@ API请求的超时时间 + +
+ + + API请求失败后的最大重试次数 +
diff --git a/app/templates/error_logs.html b/app/templates/error_logs.html index cee38d5..d4fc347 100644 --- a/app/templates/error_logs.html +++ b/app/templates/error_logs.html @@ -12,7 +12,9 @@ - + + + @@ -20,84 +22,74 @@ -
-
- - -
-
-
错误日志列表
-
-
- 每页显示 - - -
- -
-
-
-
- - - - - - - - - - - - - - -
IDGemini密钥错误日志模型名称请求时间操作
-
- -
-
- 加载中... -
-

加载中,请稍候...

-
- -
-

暂无错误日志数据

-
- -
- 加载错误日志失败,请稍后重试。 -
-
- +

Gemini Balance

+ + +
+

错误日志列表

+ +
+
+ + +
+ +
+ +
+ + + + + + + + + + + + + + + +
IDGemini密钥错误类型错误日志模型名称请求时间操作
+
+ +
+
+

加载中,请稍候...

+
+ +
+

暂无错误日志数据

+
+ +
+

加载错误日志失败,请稍后重试。

+
+ +
+
    + +
@@ -111,51 +103,51 @@
-
+
- - -