From 4becc8d4d470475e8ae6ca17ce1a5b353cb260bb Mon Sep 17 00:00:00 2001 From: snaily Date: Wed, 14 May 2025 14:25:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=B9=E8=BF=9B=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E5=8A=9F=E8=83=BD=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E5=88=9D=E5=A7=8B=E5=8C=96=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交主要包含以下更新: - **错误日志页面增强**: - 重构了 [`app/static/js/error_logs.js`](app/static/js/error_logs.js) 中的分页逻辑,将样式控制移至 CSS,简化了 JavaScript 代码。 - 更新了 [`app/templates/error_logs.html`](app/templates/error_logs.html) 中的分页样式,使其与 `keys_status.html` 保持一致,提升了视觉统一性。 - 在错误日志页面新增了“清空全部”按钮,方便用户一键清除所有错误记录。 - 调整了错误日志表格头部的文本颜色为白色,以改善深色主题下的可读性。 - **应用初始化与配置优化**: - 调整了 [`app/config/config.py`](app/config/config.py) 中日志记录器的获取方式,确保在配置加载早期即可用。 - 在 [`app/core/application.py`](app/core/application.py) 中引入了更明确的数据库连接管理(连接、断开、初始化)逻辑。 - 优化了 [`app/utils/helpers.py`](app/utils/helpers.py) 中项目路径和版本文件路径的定义方式,使其在模块级别初始化。 - **依赖清理**: - 从 [`requirements.txt`](requirements.txt) 中移除了不必要的注释。 这些更改旨在提升错误日志模块的用户体验和功能性,并优化应用程序的启动和配置管理流程。 --- app/config/config.py | 16 +- app/core/application.py | 78 +- app/database/connection.py | 1 - app/database/models.py | 3 +- app/database/services.py | 39 +- app/handler/message_converter.py | 12 +- app/log/logger.py | 3 + app/router/config_routes.py | 9 - app/router/error_log_routes.py | 22 + app/scheduler/scheduled_tasks.py | 7 +- app/service/chat/gemini_chat_service.py | 33 +- app/service/chat/openai_chat_service.py | 6 +- app/service/client/api_client.py | 4 +- app/service/config/config_service.py | 7 +- app/service/embedding/embedding_service.py | 30 +- app/service/error_log/error_log_service.py | 24 + app/service/image/image_create_service.py | 2 +- app/service/key/key_manager.py | 52 +- app/service/model/model_service.py | 3 +- .../openai_compatiable_service.py | 7 - .../request_log/request_log_service.py | 6 +- app/service/stats/stats_service.py | 26 +- app/service/update/update_service.py | 23 +- app/static/js/error_logs.js | 1586 +++++++++-------- app/templates/error_logs.html | 104 +- app/utils/helpers.py | 10 +- requirements.txt | 3 +- 27 files changed, 1116 insertions(+), 1000 deletions(-) diff --git a/app/config/config.py b/app/config/config.py index 4e688dc..72b4a5c 100644 --- a/app/config/config.py +++ b/app/config/config.py @@ -121,9 +121,9 @@ settings = Settings() def _parse_db_value(key: str, db_value: str, target_type: Type) -> Any: """尝试将数据库字符串值解析为目标 Python 类型""" - from app.log.logger import get_config_logger # 函数内导入 + from app.log.logger import get_config_logger - logger = get_config_logger() # 函数内初始化 + logger = get_config_logger() try: # 处理 List[str] if target_type == List[str]: @@ -234,9 +234,9 @@ async def sync_initial_settings(): 2. 将数据库设置合并到内存 settings (数据库优先)。 3. 将最终的内存 settings 同步回数据库。 """ - from app.log.logger import get_config_logger # 函数内导入 + from app.log.logger import get_config_logger - logger = get_config_logger() # 函数内初始化 + logger = get_config_logger() # 延迟导入以避免循环依赖和确保数据库连接已初始化 from app.database.connection import database from app.database.models import Settings as SettingsModel @@ -360,14 +360,14 @@ async def sync_initial_settings(): continue # 序列化值为字符串或 JSON 字符串 - if isinstance(value, (list, dict)): # 处理列表和字典 + if isinstance(value, (list, dict)): db_value = json.dumps( value, ensure_ascii=False - ) # 使用 ensure_ascii=False 以支持非 ASCII 字符 + ) elif isinstance(value, bool): db_value = str(value).lower() - elif value is None: # 处理 None 值 - db_value = "" # 或者根据需要设为 NULL 或其他标记 + elif value is None: + db_value = "" else: db_value = str(value) diff --git a/app/core/application.py b/app/core/application.py index ec14eca..d9ea9d9 100644 --- a/app/core/application.py +++ b/app/core/application.py @@ -1,35 +1,32 @@ from contextlib import asynccontextmanager -from pathlib import Path # Add pathlib import +from pathlib import Path + from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from app.config.config import settings, sync_initial_settings +from app.database.connection import connect_to_db, disconnect_from_db +from app.database.initialization import initialize_database +from app.exception.exceptions import setup_exception_handlers from app.log.logger import get_application_logger from app.middleware.middleware import setup_middlewares -from app.exception.exceptions import setup_exception_handlers from app.router.routes import setup_routers -from app.service.key.key_manager import get_key_manager_instance -from app.database.connection import connect_to_db, disconnect_from_db -from app.utils.helpers import get_current_version # Import from helpers -from app.database.initialization import initialize_database from app.scheduler.scheduled_tasks import start_scheduler, stop_scheduler +from app.service.key.key_manager import get_key_manager_instance from app.service.update.update_service import check_for_updates +from app.utils.helpers import get_current_version # Import from helpers logger = get_application_logger() -# Define project paths using pathlib -# Assuming this file is at app/core/application.py PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent -# VERSION_FILE_PATH = PROJECT_ROOT / "VERSION" # Removed: Defined in helpers.py STATIC_DIR = PROJECT_ROOT / "app" / "static" TEMPLATES_DIR = PROJECT_ROOT / "app" / "templates" -# Removed _get_current_version function definition, moved to helpers.py - # 初始化模板引擎,并添加全局变量 templates = Jinja2Templates(directory="app/templates") + # 定义一个函数来更新模板全局变量 def update_template_globals(app: FastAPI, update_info: dict): # Jinja2Templates 实例没有直接更新全局变量的方法 @@ -40,114 +37,105 @@ def update_template_globals(app: FastAPI, update_info: dict): # --- Helper functions for lifespan --- - async def _setup_database_and_config(app_settings): """Initializes database, syncs settings, and initializes KeyManager.""" - initialize_database() + await initialize_database() logger.info("Database initialized successfully") await connect_to_db() await sync_initial_settings() - # Initialize KeyManager using potentially updated settings await get_key_manager_instance(app_settings.API_KEYS) logger.info("Database, config sync, and KeyManager initialized successfully") + async def _shutdown_database(): """Disconnects from the database.""" await disconnect_from_db() + def _start_scheduler(): """Starts the background scheduler.""" try: start_scheduler() logger.info("Scheduler started successfully.") except Exception as e: - logger.error(f"Failed to start scheduler: {e}") + logger.error(f"Failed to start scheduler: {e}") + def _stop_scheduler(): """Stops the background scheduler.""" stop_scheduler() + async def _perform_update_check(app: FastAPI): """Checks for updates and stores the info in app.state.""" update_available, latest_version, error_message = await check_for_updates() - current_version = get_current_version() # Use imported function + current_version = get_current_version() update_info = { "update_available": update_available, "latest_version": latest_version, "error_message": error_message, - "current_version": current_version + "current_version": current_version, } - # Ensure app.state exists and store update info if not hasattr(app, "state"): from starlette.datastructures import State + app.state = State() app.state.update_info = update_info logger.info(f"Update check completed. Info: {update_info}") -# --- Application Lifespan --- @asynccontextmanager async def lifespan(app: FastAPI): """ Manages the application startup and shutdown events. - + Args: app: FastAPI应用实例 """ - # Startup events logger.info("Application starting up...") try: - # Setup database, config, and KeyManager - await _setup_database_and_config(settings) # Pass settings object - - # Perform update check after core components are ready - # await _perform_update_check(app) # Removed: Version check moved to frontend API call - - # Start the scheduler + await _setup_database_and_config(settings) + await _perform_update_check(app) _start_scheduler() except Exception as e: - logger.critical(f"Critical error during application startup: {str(e)}", exc_info=True) - # Depending on the severity, you might want to prevent the app from fully starting - # For now, we log critically and let it yield, potentially in a broken state. - # Consider adding more robust error handling here if startup failures should halt the app. + logger.critical( + f"Critical error during application startup: {str(e)}", exc_info=True + ) - yield # Application runs + yield - # Shutdown events logger.info("Application shutting down...") _stop_scheduler() await _shutdown_database() + def create_app() -> FastAPI: """ 创建并配置FastAPI应用程序实例 - + Returns: FastAPI: 配置好的FastAPI应用程序实例 """ - # Removed: initialize_app() call # 创建FastAPI应用 - # Read version from file for consistency - current_version = get_current_version() # Use imported function + current_version = get_current_version() app = FastAPI( title="Gemini Balance API", description="Gemini API代理服务,支持负载均衡和密钥管理", version=current_version, - lifespan=lifespan + lifespan=lifespan, ) - # Initialize app.state early to ensure it exists before lifespan potentially uses it if not hasattr(app, "state"): from starlette.datastructures import State + app.state = State() - # Set a default/initial state for update_info app.state.update_info = { "update_available": False, "latest_version": None, "error_message": "Initializing...", - "current_version": current_version # Use version read earlier + "current_version": current_version, } # 配置静态文件 @@ -155,11 +143,11 @@ def create_app() -> FastAPI: # 配置中间件 setup_middlewares(app) - + # 配置异常处理器 setup_exception_handlers(app) - + # 配置路由 setup_routers(app) - + return app diff --git a/app/database/connection.py b/app/database/connection.py index 78cd874..7cd113c 100644 --- a/app/database/connection.py +++ b/app/database/connection.py @@ -4,7 +4,6 @@ from pathlib import Path from databases import Database from sqlalchemy import create_engine, MetaData -# from sqlalchemy.orm import sessionmaker # 不再需要 from sqlalchemy.ext.declarative import declarative_base from app.config.config import settings diff --git a/app/database/models.py b/app/database/models.py index d0073a1..04ad604 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -42,11 +42,12 @@ class ErrorLog(Base): def __repr__(self): return f"" -# 新增 RequestLog 模型 + class RequestLog(Base): """ API 请求日志表 """ + __tablename__ = "t_request_log" id = Column(Integer, primary_key=True, autoincrement=True) diff --git a/app/database/services.py b/app/database/services.py index 5ae8509..f5f0184 100644 --- a/app/database/services.py +++ b/app/database/services.py @@ -71,7 +71,7 @@ async def update_setting(key: str, value: str, description: Optional[str] = None .values( value=value, description=description if description else setting["description"], - updated_at=datetime.now() # Use datetime.now() + updated_at=datetime.now() ) ) await database.execute(query) @@ -85,8 +85,8 @@ async def update_setting(key: str, value: str, description: Optional[str] = None key=key, value=value, description=description, - created_at=datetime.now(), # Use datetime.now() - updated_at=datetime.now() # Use datetime.now() + created_at=datetime.now(), + updated_at=datetime.now() ) ) await database.execute(query) @@ -158,8 +158,8 @@ async def get_error_logs( error_code_search: Optional[str] = None, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None, - sort_by: str = 'id', # 新增排序字段 - sort_order: str = 'desc' # 新增排序顺序 ('asc' or 'desc') + sort_by: str = 'id', + sort_order: str = 'desc' ) -> List[Dict[str, Any]]: """ 获取错误日志,支持搜索、日期过滤和排序 @@ -200,28 +200,20 @@ async def get_error_logs( if start_date: query = query.where(ErrorLog.request_time >= start_date) if end_date: - # Use the datetime object directly for comparison query = query.where(ErrorLog.request_time < end_date) if error_code_search: try: - # Attempt to convert search string to integer for exact match error_code_int = int(error_code_search) query = query.where(ErrorLog.error_code == error_code_int) except ValueError: - # If conversion fails, log a warning and potentially skip this filter - # or handle as needed (e.g., return no results for invalid code format) logger.warning(f"Invalid format for error_code_search: '{error_code_search}'. Expected an integer. Skipping error code filter.") - # Optionally, force no results if the format is invalid: - # query = query.where(False) # This ensures no rows are returned - # 添加排序逻辑 - sort_column = getattr(ErrorLog, sort_by, ErrorLog.id) # 获取排序字段,默认为 id + sort_column = getattr(ErrorLog, sort_by, ErrorLog.id) if sort_order.lower() == 'asc': query = query.order_by(asc(sort_column)) else: query = query.order_by(desc(sort_column)) - # Apply limit and offset query = query.limit(limit).offset(offset) result = await database.fetch_all(query) @@ -254,7 +246,6 @@ async def get_error_logs_count( try: query = select(func.count()).select_from(ErrorLog) - # Apply the same filters as get_error_logs if key_search: query = query.where(ErrorLog.gemini_key.ilike(f"%{key_search}%")) if error_search: @@ -265,23 +256,19 @@ async def get_error_logs_count( if start_date: query = query.where(ErrorLog.request_time >= start_date) if end_date: - # Use the datetime object directly for comparison query = query.where(ErrorLog.request_time < end_date) if error_code_search: try: - # Attempt to convert search string to integer for exact match error_code_int = int(error_code_search) query = query.where(ErrorLog.error_code == error_code_int) except ValueError: - # If conversion fails, log a warning and potentially skip this filter logger.warning(f"Invalid format for error_code_search in count: '{error_code_search}'. Expected an integer. Skipping error code filter.") - # Optionally, force count to 0 if the format is invalid: - # return 0 # Or query = query.where(False) before fetching + count_result = await database.fetch_one(query) return count_result[0] if count_result else 0 except Exception as e: - logger.exception(f"Failed to count error logs with filters: {str(e)}") # Use exception for stack trace + logger.exception(f"Failed to count error logs with filters: {str(e)}") raise @@ -315,7 +302,6 @@ async def get_error_log_details(log_id: int) -> Optional[Dict[str, Any]]: logger.exception(f"Failed to get error log details for ID {log_id}: {str(e)}") raise -# --- 异步删除函数 (使用 databases 库) --- async def delete_error_logs_by_ids(log_ids: List[int]) -> int: """ @@ -345,7 +331,7 @@ async def delete_error_logs_by_ids(log_ids: List[int]) -> int: except Exception as e: # 数据库连接或执行错误 logger.error(f"Error during bulk deletion of error logs {log_ids}: {e}", exc_info=True) - raise # Re-raise the exception for the router to handle + raise async def delete_error_log_by_id(log_id: int) -> bool: """ @@ -364,7 +350,7 @@ async def delete_error_log_by_id(log_id: int) -> bool: if not exists: logger.warning(f"Attempted to delete non-existent error log with ID: {log_id}") - return False # 或者可以抛出 404 异常,由路由处理 + return False # 执行删除 delete_query = delete(ErrorLog).where(ErrorLog.id == log_id) @@ -373,9 +359,7 @@ async def delete_error_log_by_id(log_id: int) -> bool: return True except Exception as e: logger.error(f"Error deleting error log with ID {log_id}: {e}", exc_info=True) - raise # Re-raise the exception for the router to handle - -# --- RequestLog Services (保持异步) --- + raise # 新增函数:添加请求日志 async def add_request_log( @@ -412,7 +396,6 @@ async def add_request_log( latency_ms=latency_ms ) await database.execute(query) - # logger.debug(f"Added request log: key={api_key[:4]}..., success={is_success}, model={model_name}") # Use debug level return True except Exception as e: logger.error(f"Failed to add request log: {str(e)}") diff --git a/app/handler/message_converter.py b/app/handler/message_converter.py index fe8edfd..378871a 100644 --- a/app/handler/message_converter.py +++ b/app/handler/message_converter.py @@ -128,12 +128,7 @@ class OpenAIMessageConverter(MessageConverter): raise ValueError(f"Unsupported media format: {format}") try: - # Decode Base64 to check size - # Be careful with memory usage for very large files - # Consider streaming decoding or checking length heuristic first if memory is a concern - decoded_data = base64.b64decode( - data, validate=True - ) # Use validate=True for stricter check + decoded_data = base64.b64decode(data, validate=True) if len(decoded_data) > max_size: logger.error( f"Media data size ({len(decoded_data)} bytes) exceeds limit ({max_size} bytes)." @@ -141,7 +136,6 @@ class OpenAIMessageConverter(MessageConverter): raise ValueError( f"Media data size exceeds limit of {max_size // 1024 // 1024}MB" ) - # No need to return decoded_data, just the original base64 if valid return data except base64.binascii.Error as e: logger.error(f"Invalid Base64 data provided: {e}") @@ -163,7 +157,6 @@ class OpenAIMessageConverter(MessageConverter): if "content" in msg and isinstance(msg["content"], list): for content_item in msg["content"]: if not isinstance(content_item, dict): - # Skip non-dict items if any unexpected format appears logger.warning( f"Skipping unexpected content item format: {type(content_item)}" ) @@ -184,13 +177,11 @@ class OpenAIMessageConverter(MessageConverter): logger.error( f"Failed to convert image URL {content_item['image_url']['url']}: {e}" ) - # Decide how to handle: skip part, add error text, etc. parts.append( { "text": f"[Error processing image: {content_item['image_url']['url']}]" } ) - # --- Add handling for input_audio --- elif content_type == "input_audio" and content_item.get( "input_audio" ): @@ -205,7 +196,6 @@ class OpenAIMessageConverter(MessageConverter): continue try: - # Validate size and format validated_data = self._validate_media_data( audio_format, audio_data, diff --git a/app/log/logger.py b/app/log/logger.py index 4ec4746..37e74fc 100644 --- a/app/log/logger.py +++ b/app/log/logger.py @@ -223,3 +223,6 @@ def get_openai_compatible_logger(): def get_error_log_logger(): return Logger.setup_logger("error_log") + +def get_request_log_logger(): + return Logger.setup_logger("request_log") diff --git a/app/router/config_routes.py b/app/router/config_routes.py index b11c88d..6c453ff 100644 --- a/app/router/config_routes.py +++ b/app/router/config_routes.py @@ -55,7 +55,6 @@ async def reset_config(request: Request): raise HTTPException(status_code=400, detail=str(e)) -# Pydantic model for bulk delete request class DeleteKeysRequest(BaseModel): keys: List[str] = Field(..., description="List of API keys to delete") @@ -70,9 +69,6 @@ async def delete_single_key(key_to_delete: str, request: Request): logger.info(f"Attempting to delete key: {key_to_delete}") result = await ConfigService.delete_key(key_to_delete) if not result.get("success"): - # Optionally, translate specific errors to HTTP status codes - # For now, let's assume 400 for any failure from service if not found, - # or 500 if it was an unexpected error (though service should handle that) raise HTTPException( status_code=( 404 if "not found" in result.get("message", "").lower() else 400 @@ -81,7 +77,6 @@ async def delete_single_key(key_to_delete: str, request: Request): ) return result except HTTPException as e: - # Re-raise HTTPExceptions directly raise e except Exception as e: logger.error(f"Error deleting key '{key_to_delete}': {e}", exc_info=True) @@ -104,14 +99,10 @@ async def delete_selected_keys_route( try: logger.info(f"Attempting to bulk delete {len(delete_request.keys)} keys.") result = await ConfigService.delete_selected_keys(delete_request.keys) - # Similar to single delete, we can check result["success"] if not result.get("success") and result.get("deleted_count", 0) == 0: - # If no keys were actually deleted, it might be a client error (e.g., all keys not found) - # or an empty list was somehow passed despite the check above. raise HTTPException( status_code=400, detail=result.get("message", "Failed to delete keys.") ) - # If some keys were deleted but others not found, it's still a partial success, return 200 with details. return result except HTTPException as e: raise e diff --git a/app/router/error_log_routes.py b/app/router/error_log_routes.py index df89260..8a4a7fc 100644 --- a/app/router/error_log_routes.py +++ b/app/router/error_log_routes.py @@ -209,3 +209,25 @@ async def delete_error_log_api(request: Request, log_id: int = Path(..., ge=1)): raise HTTPException( status_code=500, detail="Internal server error during deletion" ) + + +@router.delete("/errors/all", status_code=status.HTTP_204_NO_CONTENT) +async def delete_all_error_logs_api(request: Request): + """ + 删除所有错误日志 (异步) + """ + auth_token = request.cookies.get("auth_token") + if not auth_token or not verify_auth_token(auth_token): + logger.warning("Unauthorized access attempt to delete all error logs") + raise HTTPException(status_code=401, detail="Not authenticated") + + try: + deleted_count = await error_log_service.process_delete_all_error_logs() + logger.info(f"Successfully deleted all {deleted_count} error logs.") + # No body needed for 204 response + return Response(status_code=status.HTTP_204_NO_CONTENT) + except Exception as e: + logger.exception(f"Error deleting all error logs: {str(e)}") + raise HTTPException( + status_code=500, detail="Internal server error during deletion of all logs" + ) diff --git a/app/scheduler/scheduled_tasks.py b/app/scheduler/scheduled_tasks.py index b502812..be57a23 100644 --- a/app/scheduler/scheduled_tasks.py +++ b/app/scheduler/scheduled_tasks.py @@ -58,21 +58,18 @@ async def check_failed_keys(): contents=[ GeminiContent( role="user", - parts=[{"text": "hi"}], # 使用简单的文本进行验证 + parts=[{"text": "hi"}], ) ] ) - # 调用 generate_content 进行验证 await chat_service.generate_content( - settings.TEST_MODEL, gemini_request, key # 使用配置中定义的测试模型 + settings.TEST_MODEL, gemini_request, key ) - # 如果没有抛出异常,说明 key 有效 logger.info( f"Key {log_key} verification successful. Resetting failure count." ) await key_manager.reset_key_failure_count(key) except Exception as e: - # 验证失败,增加失败计数 logger.warning( f"Key {log_key} verification failed: {str(e)}. Incrementing failure count." ) diff --git a/app/service/chat/gemini_chat_service.py b/app/service/chat/gemini_chat_service.py index e025c3d..313cb89 100644 --- a/app/service/chat/gemini_chat_service.py +++ b/app/service/chat/gemini_chat_service.py @@ -144,7 +144,7 @@ class GeminiChatService: """生成内容""" payload = _build_payload(model, request) start_time = time.perf_counter() - request_datetime = datetime.datetime.now() # Record request time + request_datetime = datetime.datetime.now() is_success = False status_code = None response = None @@ -152,20 +152,18 @@ class GeminiChatService: try: response = await self.api_client.generate_content(payload, model, api_key) is_success = True - status_code = 200 # Assume 200 on success + status_code = 200 return self.response_handler.handle_response(response, model, stream=False) except Exception as e: is_success = False error_log_msg = str(e) logger.error(f"Normal API call failed with error: {error_log_msg}") - # Try to parse status code from exception match = re.search(r"status code (\d+)", error_log_msg) if match: status_code = int(match.group(1)) else: - status_code = 500 # Default to 500 if parsing fails + status_code = 500 - # Log error to error log table await add_error_log( gemini_key=api_key, model_name=model, @@ -174,11 +172,10 @@ class GeminiChatService: error_code=status_code, request_msg=payload ) - raise e # Re-throw exception for upstream handling + raise e finally: end_time = time.perf_counter() latency_ms = int((end_time - start_time) * 1000) - # Log request to request log table await add_request_log( model_name=model, api_key=api_key, @@ -240,16 +237,14 @@ class GeminiChatService: logger.warning( f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}" ) - # Parse error code for logging match = re.search(r"status code (\d+)", error_log_msg) if match: status_code = int(match.group(1)) else: status_code = 500 - # Log error to error log table await add_error_log( - gemini_key=current_attempt_key, # Log key used for this failed attempt + gemini_key=current_attempt_key, model_name=model, error_type="gemini-chat-stream", error_log=error_log_msg, @@ -257,28 +252,26 @@ class GeminiChatService: request_msg=payload ) - # Attempt to switch API Key api_key = await self.key_manager.handle_api_failure(current_attempt_key, retries) if api_key: logger.info(f"Switched to new API key: {api_key}") - else: # No more keys or retries exceeded by handle_api_failure logic - logger.error(f"No valid API key available after {retries} retries.") - break # Exit loop if no key available + else: + logger.error(f"No valid API key available after {retries} retries.") + break if retries >= max_retries: logger.error( f"Max retries ({max_retries}) reached for streaming." ) - break # Exit loop after max retries + break finally: - # Log the final outcome of the streaming request end_time = time.perf_counter() latency_ms = int((end_time - start_time) * 1000) await add_request_log( model_name=model, - api_key=final_api_key, # Log the last key used - is_success=is_success, # Log the final success status - status_code=status_code, # Log the last known status code - latency_ms=latency_ms, # Log total time including retries + api_key=final_api_key, + is_success=is_success, + status_code=status_code, + latency_ms=latency_ms, request_time=request_datetime ) diff --git a/app/service/chat/openai_chat_service.py b/app/service/chat/openai_chat_service.py index aeea4e6..75ccc3b 100644 --- a/app/service/chat/openai_chat_service.py +++ b/app/service/chat/openai_chat_service.py @@ -173,7 +173,7 @@ class OpenAIChatService: self, original_chunk: Dict[str, Any], text: str ) -> Dict[str, Any]: """创建包含指定文本的OpenAI响应块""" - chunk_copy = json.loads(json.dumps(original_chunk)) # 深拷贝 + chunk_copy = json.loads(json.dumps(original_chunk)) if chunk_copy.get("choices") and "delta" in chunk_copy["choices"][0]: chunk_copy["choices"][0]["delta"]["content"] = text return chunk_copy @@ -184,10 +184,8 @@ class OpenAIChatService: api_key: str, ) -> Union[Dict[str, Any], AsyncGenerator[str, None]]: """创建聊天完成""" - # 转换消息格式 messages, instruction = self.message_converter.convert(request.messages) - # 构建请求payload payload = _build_payload(request, messages, instruction) if request.stream: @@ -219,7 +217,6 @@ class OpenAIChatService: is_success = False error_log_msg = str(e) logger.error(f"Normal API call failed with error: {error_log_msg}") - # Try to parse status code from exception match = re.search(r"status code (\d+)", error_log_msg) if match: status_code = int(match.group(1)) @@ -587,7 +584,6 @@ class OpenAIChatService: error_code=status_code, request_msg={"image_data_truncated": image_data[:1000]}, ) - # Re-raise the exception so the caller knows about the failure raise e finally: end_time = time.perf_counter() diff --git a/app/service/client/api_client.py b/app/service/client/api_client.py index 662fd3f..3e7205e 100644 --- a/app/service/client/api_client.py +++ b/app/service/client/api_client.py @@ -53,16 +53,14 @@ class GeminiApiClient(ApiClient): url = f"{self.base_url}/models?key={api_key}" try: response = await client.get(url) - response.raise_for_status() # 如果状态码不是 2xx,则引发 HTTPStatusError + response.raise_for_status() return response.json() except httpx.HTTPStatusError as e: logger.error(f"获取模型列表失败: {e.response.status_code}") logger.error(e.response.text) - # 返回 None 而不是抛出异常,以便上层处理 return None except httpx.RequestError as e: logger.error(f"请求模型列表失败: {e}") - # 返回 None 而不是抛出异常 return None async def generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> Dict[str, Any]: diff --git a/app/service/config/config_service.py b/app/service/config/config_service.py index 78fa553..f47690b 100644 --- a/app/service/config/config_service.py +++ b/app/service/config/config_service.py @@ -76,7 +76,6 @@ class ConfigService: } if key in existing_keys: - # Preserve original description if not explicitly provided data["description"] = existing_settings_map[key].get( "description", description ) @@ -111,7 +110,7 @@ class ConfigService: logger.info(f"Updated {len(settings_to_update)} settings.") except Exception as e: logger.error(f"Failed to bulk update/insert settings: {str(e)}") - raise # Re-raise the exception after logging + raise # 重置并重新初始化 KeyManager try: @@ -120,8 +119,6 @@ class ConfigService: logger.info("KeyManager instance re-initialized with updated settings.") except Exception as e: logger.error(f"Failed to re-initialize KeyManager: {str(e)}") - # Decide if this error should prevent returning the updated config - # For now, we log the error and continue return await ConfigService.get_config() @@ -244,13 +241,11 @@ class ConfigService: models = await model_service.get_gemini_openai_models(api_key) return models except HTTPException as e: - # Re-raise HTTPExceptions directly if they are already specific raise e except Exception as e: logger.error( f"Failed to fetch models for UI in ConfigService: {e}", exc_info=True ) - # Raise a generic HTTPException for other errors raise HTTPException( status_code=500, detail=f"Failed to fetch models for UI: {str(e)}" ) diff --git a/app/service/embedding/embedding_service.py b/app/service/embedding/embedding_service.py index f78202a..43ad4d4 100644 --- a/app/service/embedding/embedding_service.py +++ b/app/service/embedding/embedding_service.py @@ -1,15 +1,15 @@ import datetime import time -import re # For potential status code parsing from generic errors +import re from typing import List, Union import openai -from openai import APIStatusError # Import specific error type +from openai import APIStatusError from openai.types import CreateEmbeddingResponse from app.config.config import settings from app.log.logger import get_embeddings_logger -from app.database.services import add_error_log, add_request_log # Import DB logging functions +from app.database.services import add_error_log, add_request_log logger = get_embeddings_logger() @@ -26,7 +26,6 @@ class EmbeddingService: status_code = None response = None error_log_msg = "" - # Prepare request message for logging (truncate if list or long string) if isinstance(input_text, list): request_msg_log = {"input_truncated": [str(item)[:100] + "..." if len(str(item)) > 100 else str(item) for item in input_text[:5]]} if len(input_text) > 5: @@ -46,32 +45,29 @@ class EmbeddingService: status_code = e.status_code error_log_msg = f"OpenAI API error: {e}" logger.error(f"Error creating embedding (APIStatusError): {error_log_msg}") - raise e # Re-raise the specific error + raise e except Exception as e: is_success = False error_log_msg = f"Generic error: {e}" logger.error(f"Error creating embedding (Exception): {error_log_msg}") - # Try to parse status code from generic error (less reliable) match = re.search(r"status code (\d+)", str(e)) if match: status_code = int(match.group(1)) else: - status_code = 500 # Default if parsing fails - raise e # Re-raise the generic error + status_code = 500 + raise e finally: end_time = time.perf_counter() latency_ms = int((end_time - start_time) * 1000) if not is_success: - # Log error to database if it failed - await add_error_log( - gemini_key=api_key, # Using gemini_key parameter name for consistency - model_name=model, - error_type="openai-embedding", - error_log=error_log_msg, - error_code=status_code, - request_msg=request_msg_log + await add_error_log( + gemini_key=api_key, + model_name=model, + error_type="openai-embedding", + error_log=error_log_msg, + error_code=status_code, + request_msg=request_msg_log ) - # Log request outcome to database regardless of success/failure await add_request_log( model_name=model, api_key=api_key, diff --git a/app/service/error_log/error_log_service.py b/app/service/error_log/error_log_service.py index 2bd5ae2..cc43995 100644 --- a/app/service/error_log/error_log_service.py +++ b/app/service/error_log/error_log_service.py @@ -153,3 +153,27 @@ async def process_delete_error_log_by_id(log_id: int) -> bool: exc_info=True, ) raise + + +async def process_delete_all_error_logs() -> int: + """ + 处理删除所有错误日志的请求。 + 返回删除的日志数量。 + """ + try: + # 确保数据库已连接 (如果适用,类似于 delete_old_error_logs) + # if not database.is_connected: + # await database.connect() + # logger.info("Database connection established for deleting all error logs.") + + deleted_count = await db_services.delete_all_error_logs() + logger.info( + f"Successfully processed request to delete all error logs. Count: {deleted_count}" + ) + return deleted_count + except Exception as e: + logger.error( + f"Service error in process_delete_all_error_logs: {e}", + exc_info=True, + ) + raise diff --git a/app/service/image/image_create_service.py b/app/service/image/image_create_service.py index 4924db7..e8ab02a 100644 --- a/app/service/image/image_create_service.py +++ b/app/service/image/image_create_service.py @@ -137,7 +137,7 @@ class ImageCreateService: ) response_data = { - "created": int(time.time()), # Current timestamp + "created": int(time.time()), "data": images_data, } return response_data diff --git a/app/service/key/key_manager.py b/app/service/key/key_manager.py index 5d3f2b4..1d00f98 100644 --- a/app/service/key/key_manager.py +++ b/app/service/key/key_manager.py @@ -60,7 +60,6 @@ class KeyManager: current_key = await self.get_next_key() if current_key == initial_key: - # await self.reset_failure_counts() 取消重置 return current_key async def handle_api_failure(self, api_key: str, retries: int) -> str: @@ -101,25 +100,12 @@ class KeyManager: for key in self.key_failure_counts: if self.key_failure_counts[key] < self.MAX_FAILURES: return key - # 如果所有 key 都无效,或者列表为空,则尝试返回第一个(如果列表不为空) - # 或者根据具体逻辑处理,这里保持原样,可能在空列表或全无效时需要调整 if self.api_keys: return self.api_keys[0] - # 如果 api_keys 为空,这里会出问题。实际应用中应有非空保证或更好处理。 - # 为了保持接口一致性,如果列表为空,可能应该抛出异常或返回特定值。 - # 暂且假设 api_keys 不会为空,或者调用者处理后续的空 key 问题。 - # 根据现有代码,如果api_keys为空,self.api_keys[0]会报错。 - # 如果没有有效key且列表不空,返回第一个。若列表为空,这里会出IndexError。 - # 更安全的做法是: if not self.api_keys: logger.warning("API key list is empty, cannot get first valid key.") - # Depending on desired behavior, either raise error or return an indicator like "" or None - # For now, let's allow it to potentially fail if a key is expected by caller - # but it's better to be explicit. Let's return empty string for consistency with handle_api_failure return "" - return self.api_keys[ - 0 - ] # Fallback to the first key if no key is "valid" but list is not empty + return self.api_keys[0] _singleton_instance = None @@ -142,20 +128,13 @@ async def get_key_manager_instance(api_keys: list = None) -> KeyManager: async with _singleton_lock: if _singleton_instance is None: if api_keys is None: - # This case needs careful handling. If it's the very first call, api_keys are required. - # If it's after a reset and no api_keys are provided, what should happen? - # The original ValueError was "API keys are required to initialize the KeyManager". - # Let's assume if api_keys is None here, it's an error unless we are restoring from non-None _preserved_old_api_keys_for_reset. - # However, the user's request implies new api_keys will be part of the reset flow. - # For now, stick to a strict requirement for api_keys if _singleton_instance is None. raise ValueError( "API keys are required to initialize or re-initialize the KeyManager instance." ) - if not api_keys: # Handle case where api_keys is an empty list + if not api_keys: logger.warning( "Initializing KeyManager with an empty list of API keys." ) - # Consider if this should be an error or allowed. Current KeyManager supports it. _singleton_instance = KeyManager(api_keys) logger.info( @@ -164,33 +143,28 @@ async def get_key_manager_instance(api_keys: list = None) -> KeyManager: # 1. 恢复失败计数 if _preserved_failure_counts: - # Initialize new instance's failure_counts for all new keys to 0 current_failure_counts = { key: 0 for key in _singleton_instance.api_keys } - # Inherit counts for keys that exist in both old and new lists for key, count in _preserved_failure_counts.items(): if key in current_failure_counts: current_failure_counts[key] = count _singleton_instance.key_failure_counts = current_failure_counts logger.info("Inherited failure counts for applicable keys.") - _preserved_failure_counts = None # Clear after use + _preserved_failure_counts = None # 2. 调整 key_cycle 的起始点 start_key_for_new_cycle = None if ( _preserved_old_api_keys_for_reset and _preserved_next_key_in_cycle - and _singleton_instance.api_keys # Ensure new api_keys list is not empty + and _singleton_instance.api_keys ): try: - # Find the index of the preserved next key in the *old* list start_idx_in_old = _preserved_old_api_keys_for_reset.index( _preserved_next_key_in_cycle ) - # Iterate through the old key list (circularly) starting from _preserved_next_key_in_cycle - # Find the first key that also exists in the new api_keys list for i in range(len(_preserved_old_api_keys_for_reset)): current_old_key_idx = (start_idx_in_old + i) % len( _preserved_old_api_keys_for_reset @@ -214,26 +188,22 @@ async def get_key_manager_instance(api_keys: list = None) -> KeyManager: if start_key_for_new_cycle and _singleton_instance.api_keys: try: - # Find the index of the determined start_key in the new api_keys list target_idx = _singleton_instance.api_keys.index( start_key_for_new_cycle ) - # Advance the new cycle by calling next() target_idx times - # This positions the cycle so that the *next* call to next() will yield start_key_for_new_cycle for _ in range(target_idx): next(_singleton_instance.key_cycle) logger.info( f"Key cycle in new instance advanced. Next call to get_next_key() will yield: {start_key_for_new_cycle}" ) except ValueError: - # This should not happen if start_key_for_new_cycle was correctly found in api_keys logger.warning( f"Determined start key '{start_key_for_new_cycle}' not found in new API keys during cycle advancement. " "New cycle will start from the beginning." ) except ( StopIteration - ): # Should not happen with cycle unless api_keys is empty, handled by _singleton_instance.api_keys check + ): logger.error( "StopIteration while advancing key cycle, implies empty new API key list previously missed." ) @@ -254,7 +224,6 @@ async def get_key_manager_instance(api_keys: list = None) -> KeyManager: # 清理所有保存的状态 _preserved_old_api_keys_for_reset = None _preserved_next_key_in_cycle = None - # _preserved_failure_counts already cleared return _singleton_instance @@ -275,21 +244,16 @@ async def reset_key_manager_instance(): _preserved_old_api_keys_for_reset = _singleton_instance.api_keys.copy() # 3. 保存 key_cycle 的下一个 key 提示 - # This should be the key that get_next_key() would return next. try: - if ( - _singleton_instance.api_keys - ): # Only if there are keys to cycle through - # Calling get_next_key() consumes one key and returns it. This is the key - # we want the new cycle to effectively start with. + if _singleton_instance.api_keys: _preserved_next_key_in_cycle = ( await _singleton_instance.get_next_key() ) else: - _preserved_next_key_in_cycle = None # No keys, so no next key + _preserved_next_key_in_cycle = None except ( StopIteration - ): # Should be caught by "if _singleton_instance.api_keys" + ): logger.warning( "Could not preserve next key hint: key cycle was empty or exhausted in old instance." ) diff --git a/app/service/model/model_service.py b/app/service/model/model_service.py index 16755d4..61929fd 100644 --- a/app/service/model/model_service.py +++ b/app/service/model/model_service.py @@ -10,8 +10,7 @@ logger = get_model_logger() class ModelService: async def get_gemini_models(self, api_key: str) -> Optional[Dict[str, Any]]: - """使用 GeminiApiClient 获取并过滤模型列表""" - api_client = GeminiApiClient(base_url=settings.BASE_URL) # 实例化客户端 + api_client = GeminiApiClient(base_url=settings.BASE_URL) gemini_models = await api_client.get_models(api_key) if gemini_models is None: diff --git a/app/service/openai_compatiable/openai_compatiable_service.py b/app/service/openai_compatiable/openai_compatiable_service.py index 5ad67e4..104c61f 100644 --- a/app/service/openai_compatiable/openai_compatiable_service.py +++ b/app/service/openai_compatiable/openai_compatiable_service.py @@ -79,7 +79,6 @@ class OpenAICompatiableService: is_success = False error_log_msg = str(e) logger.error(f"Normal API call failed with error: {error_log_msg}") - # Try to parse status code from exception match = re.search(r"status code (\d+)", error_log_msg) if match: status_code = int(match.group(1)) @@ -140,14 +139,12 @@ class OpenAICompatiableService: logger.warning( f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}" ) - # Parse error code for logging match = re.search(r"status code (\d+)", error_log_msg) if match: status_code = int(match.group(1)) else: status_code = 500 - # Log error to error log table await add_error_log( gemini_key=current_attempt_key, model_name=model, @@ -157,8 +154,6 @@ class OpenAICompatiableService: request_msg=payload, ) - # Attempt to switch API Key - # Ensure key_manager is available (might need adjustment if not always passed) if self.key_manager: api_key = await self.key_manager.handle_api_failure( current_attempt_key, retries @@ -178,7 +173,6 @@ class OpenAICompatiableService: logger.error(f"Max retries ({max_retries}) reached for streaming.") break finally: - # Log the final outcome of the streaming request end_time = time.perf_counter() latency_ms = int((end_time - start_time) * 1000) await add_request_log( @@ -189,7 +183,6 @@ class OpenAICompatiableService: latency_ms=latency_ms, request_time=request_datetime, ) - # If the loop finished due to failure, yield error and DONE if not is_success and retries >= max_retries: yield f"data: {json.dumps({'error': 'Streaming failed after retries'})}\n\n" yield "data: [DONE]\n\n" diff --git a/app/service/request_log/request_log_service.py b/app/service/request_log/request_log_service.py index 65a92a2..2356173 100644 --- a/app/service/request_log/request_log_service.py +++ b/app/service/request_log/request_log_service.py @@ -6,12 +6,12 @@ from datetime import datetime, timedelta, timezone from sqlalchemy import delete -from app import database +from app.database.connection import database from app.config.config import settings from app.database.models import RequestLog -from app.log.logger import Logger +from app.log.logger import get_request_log_logger -logger = Logger.setup_logger("request_log_service") +logger = get_request_log_logger() async def delete_old_request_logs_task(): diff --git a/app/service/stats/stats_service.py b/app/service/stats/stats_service.py index 859af2f..3bc9a93 100644 --- a/app/service/stats/stats_service.py +++ b/app/service/stats/stats_service.py @@ -41,7 +41,7 @@ class StatsService: ), 1, ), - (RequestLog.status_code is None, 1), # type: ignore + (RequestLog.status_code is None, 1), else_=0, ) ).label("failure"), @@ -96,7 +96,7 @@ class StatsService: ), 1, ), - (RequestLog.status_code is None, 1), # type: ignore + (RequestLog.status_code is None, 1), else_=0, ) ).label("failure"), @@ -166,25 +166,24 @@ class StatsService: RequestLog.request_time.label("timestamp"), RequestLog.api_key.label("key"), RequestLog.model_name.label("model"), - RequestLog.status_code, # We might need to map this to 'success'/'failure' later + RequestLog.status_code, ) .where(RequestLog.request_time >= start_time) .order_by(RequestLog.request_time.desc()) - ) # Order by most recent first + ) results = await database.fetch_all(query) - # Convert results to list of dicts and map status_code details = [] for row in results: - status = "failure" # 默认状态为 failure,如果 status_code 有效且在 200-299 范围内则更新为 success - if row["status_code"] is not None: # 检查 status_code 是否为空 + status = "failure" + if row["status_code"] is not None: status = "success" if 200 <= row["status_code"] < 300 else "failure" details.append( { "timestamp": row[ "timestamp" - ].isoformat(), # Use ISO format for JS compatibility + ].isoformat(), "key": row["key"], "model": row["model"], "status": status, @@ -197,7 +196,6 @@ class StatsService: except Exception as e: logger.error(f"Failed to get API call details for period '{period}': {e}") - # Re-raise the exception to be handled by the route raise async def get_key_usage_details_last_24h(self, key: str) -> dict | None: @@ -225,10 +223,10 @@ class StatsService: .where( RequestLog.api_key == key, RequestLog.request_time >= cutoff_time, - RequestLog.model_name.isnot(None), # Ensure model_name is not null + RequestLog.model_name.isnot(None), ) .group_by(RequestLog.model_name) - .order_by(func.count(RequestLog.id).desc()) # Order by count descending + .order_by(func.count(RequestLog.id).desc()) ) results = await database.fetch_all(query) @@ -237,7 +235,7 @@ class StatsService: logger.info( f"No usage details found for key ending in ...{key[-4:]} in the last 24h." ) - return {} # Return empty dict if no records found + return {} usage_details = {row["model_name"]: row["call_count"] for row in results} logger.info( @@ -250,6 +248,4 @@ class StatsService: f"Failed to get key usage details for key ending in ...{key[-4:]}: {e}", exc_info=True, ) - # Depending on requirements, you might return None or raise the exception - # Raising allows the route handler to return a 500 error. - raise # Re-raise the exception + raise diff --git a/app/service/update/update_service.py b/app/service/update/update_service.py index 1181702..ae7ba98 100644 --- a/app/service/update/update_service.py +++ b/app/service/update/update_service.py @@ -7,11 +7,7 @@ from app.log.logger import get_update_logger logger = get_update_logger() -# GitHub repository details are read from settings (defined in app/config/config.py or environment variables) - -# GITHUB_API_URL will be constructed inside the function to ensure settings are loaded - -VERSION_FILE_PATH = "VERSION" # Path relative to project root +VERSION_FILE_PATH = "VERSION" async def check_for_updates() -> Tuple[bool, Optional[str], Optional[str]]: """ @@ -24,9 +20,6 @@ async def check_for_updates() -> Tuple[bool, Optional[str], Optional[str]]: - Optional[str]: 如果检查失败,则为错误消息,否则为 None。 """ try: - # Read current version from VERSION file - # Ensure the path is correct relative to the execution context or use absolute path if needed - # Assuming execution from project root d:/develop/pythonProjects/gemini-balance with open(VERSION_FILE_PATH, 'r', encoding='utf-8') as f: current_v = f.read().strip() if not current_v: @@ -41,25 +34,22 @@ async def check_for_updates() -> Tuple[bool, Optional[str], Optional[str]]: logger.info(f"当前应用程序版本 (from {VERSION_FILE_PATH}): {current_v}") - # Check if repository details are configured in settings if not settings.GITHUB_REPO_OWNER or not settings.GITHUB_REPO_NAME or \ settings.GITHUB_REPO_OWNER == "your_owner" or settings.GITHUB_REPO_NAME == "your_repo": logger.warning("GitHub repository owner/name not configured in settings. Skipping update check.") return False, None, "Update check skipped: Repository not configured in settings." - # Construct the API URL inside the function to ensure settings are loaded github_api_url = f"https://api.github.com/repos/{settings.GITHUB_REPO_OWNER}/{settings.GITHUB_REPO_NAME}/releases/latest" - logger.debug(f"Checking for updates at URL: {github_api_url}") # Log the URL for debugging + logger.debug(f"Checking for updates at URL: {github_api_url}") try: async with httpx.AsyncClient(timeout=10.0) as client: - # 添加 User-Agent 头,GitHub API 可能需要 headers = { "Accept": "application/vnd.github.v3+json", - "User-Agent": f"{settings.GITHUB_REPO_NAME}-UpdateChecker/1.0" # Use repo name from settings for User-Agent + "User-Agent": f"{settings.GITHUB_REPO_NAME}-UpdateChecker/1.0" } - response = await client.get(github_api_url, headers=headers) # Use the locally constructed URL - response.raise_for_status() # 对错误的 HTTP 状态码(4xx 或 5xx)抛出异常 + response = await client.get(github_api_url, headers=headers) + response.raise_for_status() latest_release = response.json() latest_v_str = latest_release.get("tag_name") @@ -68,7 +58,6 @@ async def check_for_updates() -> Tuple[bool, Optional[str], Optional[str]]: logger.warning("在最新的 GitHub release 响应中找不到 'tag_name'。") return False, None, "无法从 GitHub 解析最新版本。" - # 移除 tag 名称中可能存在的 'v' 前缀 if latest_v_str.startswith('v'): latest_v_str = latest_v_str[1:] @@ -98,8 +87,6 @@ async def check_for_updates() -> Tuple[bool, Optional[str], Optional[str]]: logger.error(f"检查更新时发生网络错误: {e}") return False, None, "更新检查期间发生网络错误。" except version.InvalidVersion: - # Note: latest_v_str might not be defined if the error occurs before fetching it. - # Consider adding a check or default value for logging. latest_v_str_for_log = latest_v_str if 'latest_v_str' in locals() else 'N/A' logger.error(f"发现无效的版本格式。当前 (from {VERSION_FILE_PATH}): '{current_v}', 最新: '{latest_v_str_for_log}'") return False, None, "遇到无效的版本格式。" diff --git a/app/static/js/error_logs.js b/app/static/js/error_logs.js index 69a1d74..e925d31 100644 --- a/app/static/js/error_logs.js +++ b/app/static/js/error_logs.js @@ -2,53 +2,62 @@ // 页面滚动功能 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" }); } // API 调用辅助函数 async function fetchAPI(url, options = {}) { - try { - const response = await fetch(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; + // 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. @@ -56,20 +65,20 @@ async function fetchAPI(url, options = {}) { // 全局状态管理 let errorLogState = { - currentPage: 1, - pageSize: 10, - logs: [], // 存储获取的日志 - sort: { - field: 'id', // 默认按 ID 排序 - order: 'desc' // 默认降序 - }, - search: { - key: '', - error: '', - errorCode: '', - startDate: '', - endDate: '' - } + currentPage: 1, + pageSize: 10, + logs: [], // 存储获取的日志 + sort: { + field: "id", // 默认按 ID 排序 + order: "desc", // 默认降序 + }, + search: { + key: "", + error: "", + errorCode: "", + startDate: "", + endDate: "", + }, }; // DOM Elements Cache @@ -105,572 +114,643 @@ let idsToDeleteGlobally = []; // 新增:存储待删除的ID // Helper functions for initialization function cacheDOMElements() { - pageSizeSelector = document.getElementById('pageSize'); - tableBody = document.getElementById('errorLogsTable'); - paginationElement = document.getElementById('pagination'); - loadingIndicator = document.getElementById('loadingIndicator'); - noDataMessage = document.getElementById('noDataMessage'); - errorMessage = document.getElementById('errorMessage'); - logDetailModal = document.getElementById('logDetailModal'); - modalCloseBtns = document.querySelectorAll('#closeLogDetailModalBtn, #closeModalFooterBtn'); - keySearchInput = document.getElementById('keySearch'); - errorSearchInput = document.getElementById('errorSearch'); - 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'); - if (sortByIdHeader) { - 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'); + pageSizeSelector = document.getElementById("pageSize"); + tableBody = document.getElementById("errorLogsTable"); + paginationElement = document.getElementById("pagination"); + loadingIndicator = document.getElementById("loadingIndicator"); + noDataMessage = document.getElementById("noDataMessage"); + errorMessage = document.getElementById("errorMessage"); + logDetailModal = document.getElementById("logDetailModal"); + modalCloseBtns = document.querySelectorAll( + "#closeLogDetailModalBtn, #closeModalFooterBtn" + ); + keySearchInput = document.getElementById("keySearch"); + errorSearchInput = document.getElementById("errorSearch"); + 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"); + if (sortByIdHeader) { + 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"); } function initializePageSizeControls() { - if (pageSizeSelector) { - pageSizeSelector.value = errorLogState.pageSize; - pageSizeSelector.addEventListener('change', function() { - errorLogState.pageSize = parseInt(this.value); - errorLogState.currentPage = 1; // Reset to first page - loadErrorLogs(); - }); - } + if (pageSizeSelector) { + pageSizeSelector.value = errorLogState.pageSize; + pageSizeSelector.addEventListener("change", function () { + errorLogState.pageSize = parseInt(this.value); + errorLogState.currentPage = 1; // Reset to first page + loadErrorLogs(); + }); + } } function initializeSearchControls() { - if (searchBtn) { - searchBtn.addEventListener('click', function() { - 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(); - }); - } + if (searchBtn) { + searchBtn.addEventListener("click", function () { + 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(); + }); + } } function initializeModalControls() { - // Log Detail Modal - if (logDetailModal && modalCloseBtns) { - modalCloseBtns.forEach(btn => { - btn.addEventListener('click', closeLogDetailModal); - }); - logDetailModal.addEventListener('click', function(event) { - if (event.target === logDetailModal) { - closeLogDetailModal(); - } - }); - } + // Log Detail Modal + if (logDetailModal && modalCloseBtns) { + modalCloseBtns.forEach((btn) => { + btn.addEventListener("click", closeLogDetailModal); + }); + logDetailModal.addEventListener("click", function (event) { + if (event.target === logDetailModal) { + closeLogDetailModal(); + } + }); + } - // Delete Confirm Modal - if (closeDeleteConfirmModalBtn) { - closeDeleteConfirmModalBtn.addEventListener('click', hideDeleteConfirmModal); - } - if (cancelDeleteBtn) { - cancelDeleteBtn.addEventListener('click', hideDeleteConfirmModal); - } - if (confirmDeleteBtn) { - confirmDeleteBtn.addEventListener('click', handleConfirmDelete); - } - if (deleteConfirmModal) { - deleteConfirmModal.addEventListener('click', function(event) { - if (event.target === deleteConfirmModal) { - hideDeleteConfirmModal(); - } - }); - } + // Delete Confirm Modal + if (closeDeleteConfirmModalBtn) { + closeDeleteConfirmModalBtn.addEventListener( + "click", + hideDeleteConfirmModal + ); + } + if (cancelDeleteBtn) { + cancelDeleteBtn.addEventListener("click", hideDeleteConfirmModal); + } + if (confirmDeleteBtn) { + confirmDeleteBtn.addEventListener("click", handleConfirmDelete); + } + if (deleteConfirmModal) { + deleteConfirmModal.addEventListener("click", function (event) { + if (event.target === deleteConfirmModal) { + hideDeleteConfirmModal(); + } + }); + } } 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(); - } - }); - } + 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(); + 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(); +document.addEventListener("DOMContentLoaded", function () { + cacheDOMElements(); + initializePageSizeControls(); + initializeSearchControls(); + initializeModalControls(); + initializePaginationJumpControls(); + initializeActionControls(); - // Initial load of error logs - loadErrorLogs(); + // 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(); + // 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(); }); // 新增:显示删除确认模态框 function showDeleteConfirmModal(message) { - if (deleteConfirmModal && deleteConfirmMessage) { - deleteConfirmMessage.textContent = message; - deleteConfirmModal.classList.add('show'); - document.body.style.overflow = 'hidden'; // Prevent body scrolling - } + if (deleteConfirmModal && deleteConfirmMessage) { + deleteConfirmMessage.textContent = message; + deleteConfirmModal.classList.add("show"); + document.body.style.overflow = "hidden"; // Prevent body scrolling + } } // 新增:隐藏删除确认模态框 function hideDeleteConfirmModal() { - if (deleteConfirmModal) { - deleteConfirmModal.classList.remove('show'); - document.body.style.overflow = ''; // Restore body scrolling - idsToDeleteGlobally = []; // 清空待删除ID - } + if (deleteConfirmModal) { + deleteConfirmModal.classList.remove("show"); + document.body.style.overflow = ""; // Restore body scrolling + idsToDeleteGlobally = []; // 清空待删除ID + } } // 新增:处理确认删除按钮点击 function handleConfirmDelete() { - if (idsToDeleteGlobally.length > 0) { - performActualDelete(idsToDeleteGlobally); - } - hideDeleteConfirmModal(); // 关闭模态框 + if (idsToDeleteGlobally.length > 0) { + performActualDelete(idsToDeleteGlobally); + } + hideDeleteConfirmModal(); // 关闭模态框 } // Fallback copy function using document.execCommand function fallbackCopyTextToClipboard(text) { - const textArea = document.createElement("textarea"); - textArea.value = text; + const textArea = document.createElement("textarea"); + textArea.value = text; - // Avoid scrolling to bottom - textArea.style.top = "0"; - textArea.style.left = "0"; - textArea.style.position = "fixed"; + // Avoid scrolling to bottom + textArea.style.top = "0"; + textArea.style.left = "0"; + textArea.style.position = "fixed"; - document.body.appendChild(textArea); - textArea.focus(); - textArea.select(); + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); - let successful = false; - try { - successful = document.execCommand('copy'); - } catch (err) { - console.error('Fallback copy failed:', err); - successful = false; - } + let successful = false; + try { + successful = document.execCommand("copy"); + } catch (err) { + console.error("Fallback copy failed:", err); + successful = false; + } - document.body.removeChild(textArea); - return successful; + document.body.removeChild(textArea); + return successful; } // Helper function to handle feedback after copy attempt (both modern and fallback) function handleCopyResult(buttonElement, success) { - const originalIcon = buttonElement.querySelector('i').className; // Store original icon class - const iconElement = buttonElement.querySelector('i'); - if (success) { - iconElement.className = 'fas fa-check text-success-500'; // Use checkmark icon class - showNotification('已复制到剪贴板', 'success', 2000); - } else { - iconElement.className = 'fas fa-times text-danger-500'; // Use error icon class - showNotification('复制失败', 'error', 3000); - } - setTimeout(() => { iconElement.className = originalIcon; }, success ? 2000 : 3000); // Restore original icon class + const originalIcon = buttonElement.querySelector("i").className; // Store original icon class + const iconElement = buttonElement.querySelector("i"); + if (success) { + iconElement.className = "fas fa-check text-success-500"; // Use checkmark icon class + showNotification("已复制到剪贴板", "success", 2000); + } else { + iconElement.className = "fas fa-times text-danger-500"; // Use error icon class + showNotification("复制失败", "error", 3000); + } + 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."); + 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); + handleCopyResult(buttonElement, copySuccess); } else { - showNotification(copySuccess ? '已复制到剪贴板' : '复制失败', copySuccess ? 'success' : 'error'); + 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) - const container = document.querySelector(containerSelector); - if (!container) return; +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); - }); + 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); + }); } // Extracted click handler logic for reusability and removing listeners function handleCopyButtonClick() { - const button = this; // 'this' refers to the button clicked - const targetId = button.getAttribute('data-target'); - const textToCopyDirect = button.getAttribute('data-copy-text'); // For direct text copy (e.g., table key) - let textToCopy = ''; + const button = this; // 'this' refers to the button clicked + const targetId = button.getAttribute("data-target"); + const textToCopyDirect = button.getAttribute("data-copy-text"); // For direct text copy (e.g., table key) + let textToCopy = ""; - if (textToCopyDirect) { - textToCopy = textToCopyDirect; - } else if (targetId) { - const targetElement = document.getElementById(targetId); - if (targetElement) { - textToCopy = targetElement.textContent; - } else { - console.error('Target element not found:', targetId); - showNotification('复制出错:找不到目标元素', 'error'); - return; // Exit if target element not found - } + if (textToCopyDirect) { + textToCopy = textToCopyDirect; + } else if (targetId) { + const targetElement = document.getElementById(targetId); + if (targetElement) { + textToCopy = targetElement.textContent; } else { - console.error('No data-target or data-copy-text attribute found on button:', button); - showNotification('复制出错:未指定复制内容', 'error'); - return; // Exit if no source specified + console.error("Target element not found:", targetId); + showNotification("复制出错:找不到目标元素", "error"); + return; // Exit if target element not found } + } else { + console.error( + "No data-target or data-copy-text attribute found on button:", + button + ); + showNotification("复制出错:未指定复制内容", "error"); + return; // Exit if no source specified + } - - if (textToCopy) { - _performCopy(textToCopy, button); // 使用新的辅助函数 - } else { - console.warn('No text found to copy for target:', targetId || 'direct text'); - showNotification('没有内容可复制', 'warning'); - } + if (textToCopy) { + _performCopy(textToCopy, button); // 使用新的辅助函数 + } else { + console.warn( + "No text found to copy for target:", + targetId || "direct text" + ); + showNotification("没有内容可复制", "warning"); + } } // End of handleCopyButtonClick function // 新增:设置批量选择相关的事件监听器 function setupBulkSelectionListeners() { - if (selectAllCheckbox) { - selectAllCheckbox.addEventListener('change', handleSelectAllChange); - } + if (selectAllCheckbox) { + selectAllCheckbox.addEventListener("change", handleSelectAllChange); + } - if (tableBody) { - // 使用事件委托处理行复选框的点击 - tableBody.addEventListener('change', handleRowCheckboxChange); - } + if (tableBody) { + // 使用事件委托处理行复选框的点击 + tableBody.addEventListener("change", handleRowCheckboxChange); + } - if (copySelectedKeysBtn) { - copySelectedKeysBtn.addEventListener('click', handleCopySelectedKeys); - } + if (copySelectedKeysBtn) { + copySelectedKeysBtn.addEventListener("click", handleCopySelectedKeys); + } - // 新增:为批量删除按钮添加事件监听器 (如果尚未添加) - // 通常在 DOMContentLoaded 中添加一次即可 - // if (deleteSelectedBtn && !deleteSelectedBtn.hasListener) { - // deleteSelectedBtn.addEventListener('click', handleDeleteSelected); - // deleteSelectedBtn.hasListener = true; // 标记已添加 - // } + // 新增:为批量删除按钮添加事件监听器 (如果尚未添加) + // 通常在 DOMContentLoaded 中添加一次即可 + // if (deleteSelectedBtn && !deleteSelectedBtn.hasListener) { + // deleteSelectedBtn.addEventListener('click', handleDeleteSelected); + // deleteSelectedBtn.hasListener = true; // 标记已添加 + // } } -// 新增:处理“全选”复选框变化的函数 +// 新增:处理"全选"复选框变化的函数 function handleSelectAllChange() { - const isChecked = selectAllCheckbox.checked; - const rowCheckboxes = tableBody.querySelectorAll('.row-checkbox'); - rowCheckboxes.forEach(checkbox => { - checkbox.checked = isChecked; - }); - updateSelectedState(); + const isChecked = selectAllCheckbox.checked; + const rowCheckboxes = tableBody.querySelectorAll(".row-checkbox"); + rowCheckboxes.forEach((checkbox) => { + checkbox.checked = isChecked; + }); + updateSelectedState(); } // 新增:处理行复选框变化的函数 (事件委托) function handleRowCheckboxChange(event) { - if (event.target.classList.contains('row-checkbox')) { - updateSelectedState(); - } + if (event.target.classList.contains("row-checkbox")) { + updateSelectedState(); + } } // 新增:更新选中状态(计数、按钮状态、全选框状态) function updateSelectedState() { - const rowCheckboxes = tableBody.querySelectorAll('.row-checkbox'); - const selectedCheckboxes = tableBody.querySelectorAll('.row-checkbox:checked'); - const selectedCount = selectedCheckboxes.length; + const rowCheckboxes = tableBody.querySelectorAll(".row-checkbox"); + const selectedCheckboxes = tableBody.querySelectorAll( + ".row-checkbox:checked" + ); + const selectedCount = selectedCheckboxes.length; - // 移除了数字显示,不再更新selectedCountSpan - // 仍然更新复制按钮的禁用状态 - if (copySelectedKeysBtn) { - copySelectedKeysBtn.disabled = selectedCount === 0; - - // 可选:根据选中项数量更新按钮标题属性 - copySelectedKeysBtn.setAttribute('title', `复制${selectedCount}项选中密钥`); - } - // 新增:更新批量删除按钮的禁用状态 - if (deleteSelectedBtn) { - deleteSelectedBtn.disabled = selectedCount === 0; - deleteSelectedBtn.setAttribute('title', `删除${selectedCount}项选中日志`); - } + // 移除了数字显示,不再更新selectedCountSpan + // 仍然更新复制按钮的禁用状态 + if (copySelectedKeysBtn) { + copySelectedKeysBtn.disabled = selectedCount === 0; - // 更新“全选”复选框的状态 - if (selectAllCheckbox) { - if (rowCheckboxes.length > 0 && selectedCount === rowCheckboxes.length) { - selectAllCheckbox.checked = true; - selectAllCheckbox.indeterminate = false; - } else if (selectedCount > 0) { - selectAllCheckbox.checked = false; - selectAllCheckbox.indeterminate = true; // 部分选中状态 - } else { - selectAllCheckbox.checked = false; - selectAllCheckbox.indeterminate = false; - } - } + // 可选:根据选中项数量更新按钮标题属性 + copySelectedKeysBtn.setAttribute("title", `复制${selectedCount}项选中密钥`); + } + // 新增:更新批量删除按钮的禁用状态 + if (deleteSelectedBtn) { + deleteSelectedBtn.disabled = selectedCount === 0; + deleteSelectedBtn.setAttribute("title", `删除${selectedCount}项选中日志`); + } + + // 更新"全选"复选框的状态 + if (selectAllCheckbox) { + if (rowCheckboxes.length > 0 && selectedCount === rowCheckboxes.length) { + selectAllCheckbox.checked = true; + selectAllCheckbox.indeterminate = false; + } else if (selectedCount > 0) { + selectAllCheckbox.checked = false; + selectAllCheckbox.indeterminate = true; // 部分选中状态 + } else { + selectAllCheckbox.checked = false; + selectAllCheckbox.indeterminate = false; + } + } } -// 新增:处理“复制选中密钥”按钮点击的函数 +// 新增:处理"复制选中密钥"按钮点击的函数 function handleCopySelectedKeys() { - const selectedCheckboxes = tableBody.querySelectorAll('.row-checkbox:checked'); - const keysToCopy = []; - selectedCheckboxes.forEach(checkbox => { - const key = checkbox.getAttribute('data-key'); - if (key) { - keysToCopy.push(key); - } - }); + const selectedCheckboxes = tableBody.querySelectorAll( + ".row-checkbox:checked" + ); + const keysToCopy = []; + selectedCheckboxes.forEach((checkbox) => { + const key = checkbox.getAttribute("data-key"); + if (key) { + keysToCopy.push(key); + } + }); - if (keysToCopy.length > 0) { - const textToCopy = keysToCopy.join('\n'); // 每行一个密钥 - _performCopy(textToCopy, copySelectedKeysBtn); // 使用新的辅助函数 - } else { - showNotification('没有选中的密钥可复制', 'warning'); - } + if (keysToCopy.length > 0) { + const textToCopy = keysToCopy.join("\n"); // 每行一个密钥 + _performCopy(textToCopy, copySelectedKeysBtn); // 使用新的辅助函数 + } else { + showNotification("没有选中的密钥可复制", "warning"); + } } // 修改:处理批量删除按钮点击的函数 - 改为显示模态框 function handleDeleteSelected() { - const selectedCheckboxes = tableBody.querySelectorAll('.row-checkbox:checked'); - const logIdsToDelete = []; - selectedCheckboxes.forEach(checkbox => { - const logId = checkbox.getAttribute('data-log-id'); // 需要在渲染时添加 data-log-id - if (logId) { - logIdsToDelete.push(parseInt(logId)); - } - }); - - if (logIdsToDelete.length === 0) { - showNotification('没有选中的日志可删除', 'warning'); - return; + const selectedCheckboxes = tableBody.querySelectorAll( + ".row-checkbox:checked" + ); + const logIdsToDelete = []; + selectedCheckboxes.forEach((checkbox) => { + const logId = checkbox.getAttribute("data-log-id"); // 需要在渲染时添加 data-log-id + if (logId) { + logIdsToDelete.push(parseInt(logId)); } + }); - if (logIdsToDelete.length === 0) { - showNotification('没有选中的日志可删除', 'warning'); - return; - } + if (logIdsToDelete.length === 0) { + showNotification("没有选中的日志可删除", "warning"); + return; + } - // 存储待删除ID并显示模态框 - idsToDeleteGlobally = logIdsToDelete; - const message = `确定要删除选中的 ${logIdsToDelete.length} 条日志吗?此操作不可恢复!`; - showDeleteConfirmModal(message); + if (logIdsToDelete.length === 0) { + showNotification("没有选中的日志可删除", "warning"); + return; + } + + // 存储待删除ID并显示模态框 + idsToDeleteGlobally = logIdsToDelete; + const message = `确定要删除选中的 ${logIdsToDelete.length} 条日志吗?此操作不可恢复!`; + showDeleteConfirmModal(message); } // 新增:执行实际的删除操作(提取自原 handleDeleteSelected 和 handleDeleteLogRow) async function performActualDelete(logIds) { - if (!logIds || logIds.length === 0) return; + if (!logIds || logIds.length === 0) return; - const isSingleDelete = logIds.length === 1; - const url = isSingleDelete ? `/api/logs/errors/${logIds[0]}` : '/api/logs/errors'; - 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 - }; + const isSingleDelete = logIds.length === 1; + const url = isSingleDelete + ? `/api/logs/errors/${logIds[0]}` + : "/api/logs/errors"; + 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 { - // Use fetchAPI for the delete request - await fetchAPI(url, options); // fetchAPI returns null for 204 No Content + try { + // 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'); - // 取消全选 - if (selectAllCheckbox) selectAllCheckbox.checked = false; - // 重新加载当前页数据 - loadErrorLogs(); - } catch (error) { - console.error('批量删除错误日志失败:', error); - showNotification(`批量删除失败: ${error.message}`, 'error', 5000); - } + // If fetchAPI doesn't throw, the request was successful + const successMessage = isSingleDelete + ? `成功删除该日志` + : `成功删除 ${logIds.length} 条日志`; + showNotification(successMessage, "success"); + // 取消全选 + if (selectAllCheckbox) selectAllCheckbox.checked = false; + // 重新加载当前页数据 + loadErrorLogs(); + } catch (error) { + console.error("批量删除错误日志失败:", error); + showNotification(`批量删除失败: ${error.message}`, "error", 5000); + } } // 修改:处理单行删除按钮点击的函数 - 改为显示模态框 function handleDeleteLogRow(logId) { - if (!logId) return; + if (!logId) return; - // 存储待删除ID并显示模态框 - idsToDeleteGlobally = [parseInt(logId)]; // 存储为数组 - // 使用通用确认消息,不显示具体ID - const message = `确定要删除这条日志吗?此操作不可恢复!`; - showDeleteConfirmModal(message); + // 存储待删除ID并显示模态框 + idsToDeleteGlobally = [parseInt(logId)]; // 存储为数组 + // 使用通用确认消息,不显示具体ID + const message = `确定要删除这条日志吗?此操作不可恢复!`; + showDeleteConfirmModal(message); } // 新增:处理 ID 排序点击的函数 function handleSortById() { - if (errorLogState.sort.field === 'id') { - // 如果当前是按 ID 排序,切换顺序 - errorLogState.sort.order = errorLogState.sort.order === 'asc' ? 'desc' : 'asc'; - } else { - // 如果当前不是按 ID 排序,切换到按 ID 排序,默认为降序 - errorLogState.sort.field = 'id'; - errorLogState.sort.order = 'desc'; - } - // 更新图标 - updateSortIcon(); - // 重新加载第一页数据 - errorLogState.currentPage = 1; - loadErrorLogs(); + if (errorLogState.sort.field === "id") { + // 如果当前是按 ID 排序,切换顺序 + errorLogState.sort.order = + errorLogState.sort.order === "asc" ? "desc" : "asc"; + } else { + // 如果当前不是按 ID 排序,切换到按 ID 排序,默认为降序 + errorLogState.sort.field = "id"; + errorLogState.sort.order = "desc"; + } + // 更新图标 + updateSortIcon(); + // 重新加载第一页数据 + errorLogState.currentPage = 1; + loadErrorLogs(); } // 新增:更新排序图标的函数 function updateSortIcon() { - if (!sortIcon) return; - // 移除所有可能的排序类 - sortIcon.classList.remove('fa-sort', 'fa-sort-up', 'fa-sort-down', 'text-gray-400', 'text-primary-600'); + if (!sortIcon) return; + // 移除所有可能的排序类 + sortIcon.classList.remove( + "fa-sort", + "fa-sort-up", + "fa-sort-down", + "text-gray-400", + "text-primary-600" + ); - 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 排序,显示默认图标 - sortIcon.classList.add('fa-sort', 'text-gray-400'); - } + 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 排序,显示默认图标 + sortIcon.classList.add("fa-sort", "text-gray-400"); + } } // 加载错误日志数据 async function loadErrorLogs() { - // 重置选择状态 - if (selectAllCheckbox) selectAllCheckbox.checked = false; - if (selectAllCheckbox) selectAllCheckbox.indeterminate = false; - updateSelectedState(); // 更新按钮状态和计数 + // 重置选择状态 + if (selectAllCheckbox) selectAllCheckbox.checked = false; + if (selectAllCheckbox) selectAllCheckbox.indeterminate = false; + updateSelectedState(); // 更新按钮状态和计数 - showLoading(true); - showError(false); - showNoData(false); + showLoading(true); + showError(false); + showNoData(false); - const offset = (errorLogState.currentPage - 1) * errorLogState.pageSize; + const offset = (errorLogState.currentPage - 1) * errorLogState.pageSize; - try { - // Construct the API URL with search and sort parameters - let apiUrl = `/api/logs/errors?limit=${errorLogState.pageSize}&offset=${offset}`; - // 添加排序参数 - apiUrl += `&sort_by=${errorLogState.sort.field}&sort_order=${errorLogState.sort.order}`; + try { + // Construct the API URL with search and sort parameters + let apiUrl = `/api/logs/errors?limit=${errorLogState.pageSize}&offset=${offset}`; + // 添加排序参数 + apiUrl += `&sort_by=${errorLogState.sort.field}&sort_order=${errorLogState.sort.order}`; - // 添加搜索参数 - if (errorLogState.search.key) { - apiUrl += `&key_search=${encodeURIComponent(errorLogState.search.key)}`; - } - if (errorLogState.search.error) { - apiUrl += `&error_search=${encodeURIComponent(errorLogState.search.error)}`; - } - if (errorLogState.search.errorCode) { // Add error code to API request - apiUrl += `&error_code_search=${encodeURIComponent(errorLogState.search.errorCode)}`; - } - if (errorLogState.search.startDate) { - apiUrl += `&start_date=${encodeURIComponent(errorLogState.search.startDate)}`; - } - if (errorLogState.search.endDate) { - apiUrl += `&end_date=${encodeURIComponent(errorLogState.search.endDate)}`; - } - - // Use fetchAPI to get logs - const data = await fetchAPI(apiUrl); - - // API 现在返回 { logs: [], total: count } - // fetchAPI already parsed JSON - if (data && Array.isArray(data.logs)) { - 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 { - // Handle unexpected data format even after successful fetch - console.error('Unexpected API response format:', data); - throw new Error('无法识别的API响应格式'); - } - - showLoading(false); - - if (errorLogState.logs.length === 0) { - showNoData(true); - } - } catch (error) { - console.error('获取错误日志失败:', error); - showLoading(false); - showError(true, error.message); // Show specific error message + // 添加搜索参数 + if (errorLogState.search.key) { + apiUrl += `&key_search=${encodeURIComponent(errorLogState.search.key)}`; } + if (errorLogState.search.error) { + apiUrl += `&error_search=${encodeURIComponent( + errorLogState.search.error + )}`; + } + if (errorLogState.search.errorCode) { + // Add error code to API request + apiUrl += `&error_code_search=${encodeURIComponent( + errorLogState.search.errorCode + )}`; + } + if (errorLogState.search.startDate) { + apiUrl += `&start_date=${encodeURIComponent( + errorLogState.search.startDate + )}`; + } + if (errorLogState.search.endDate) { + apiUrl += `&end_date=${encodeURIComponent(errorLogState.search.endDate)}`; + } + + // Use fetchAPI to get logs + const data = await fetchAPI(apiUrl); + + // API 现在返回 { logs: [], total: count } + // fetchAPI already parsed JSON + if (data && Array.isArray(data.logs)) { + 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 { + // Handle unexpected data format even after successful fetch + console.error("Unexpected API response format:", data); + throw new Error("无法识别的API响应格式"); + } + + showLoading(false); + + if (errorLogState.logs.length === 0) { + showNoData(true); + } + } catch (error) { + console.error("获取错误日志失败:", error); + showLoading(false); + showError(true, error.message); // Show specific error message + } } // 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); } + // 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 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 || ''; + 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 ` + return ` - + ${sequentialId} @@ -679,15 +759,19 @@ function _createLogRowHtml(log, sequentialId) { - ${log.error_type || '未知'} - ${errorCodeContent} - ${log.model_name || '未知'} + ${log.error_type || "未知"} + ${errorCodeContent} + ${log.model_name || "未知"} ${formattedTime} - @@ -696,284 +780,366 @@ function _createLogRowHtml(log, sequentialId) { // 渲染错误日志表格 function renderErrorLogs(logs) { - if (!tableBody) return; - tableBody.innerHTML = ''; // Clear previous entries + if (!tableBody) return; + tableBody.innerHTML = ""; // Clear previous entries - // 重置全选复选框状态(在清空表格后) - if (selectAllCheckbox) { - selectAllCheckbox.checked = false; - selectAllCheckbox.indeterminate = false; - } + // 重置全选复选框状态(在清空表格后) + if (selectAllCheckbox) { + selectAllCheckbox.checked = false; + selectAllCheckbox.indeterminate = false; + } - if (!logs || logs.length === 0) { - // Handled by showNoData - return; - } + if (!logs || logs.length === 0) { + // Handled by showNoData + return; + } - const startIndex = (errorLogState.currentPage - 1) * errorLogState.pageSize; + const startIndex = (errorLogState.currentPage - 1) * errorLogState.pageSize; - logs.forEach((log, index) => { - const sequentialId = startIndex + index + 1; - const row = document.createElement('tr'); - row.innerHTML = _createLogRowHtml(log, sequentialId); - tableBody.appendChild(row); + logs.forEach((log, index) => { + const sequentialId = startIndex + index + 1; + const row = document.createElement("tr"); + row.innerHTML = _createLogRowHtml(log, sequentialId); + 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); }); + }); - // 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); - }); + // 新增:为新渲染的删除按钮添加事件监听器 + document.querySelectorAll(".btn-delete-row").forEach((button) => { + button.addEventListener("click", function () { + const logId = this.getAttribute("data-log-id"); + handleDeleteLogRow(logId); }); + }); - // 新增:为新渲染的删除按钮添加事件监听器 - document.querySelectorAll('.btn-delete-row').forEach(button => { - button.addEventListener('click', function() { - const logId = this.getAttribute('data-log-id'); - handleDeleteLogRow(logId); - }); - }); - - // Re-initialize copy buttons specifically for the newly rendered table rows - setupCopyButtons('#errorLogsTable'); - // Update selected state after rendering - updateSelectedState(); + // Re-initialize copy buttons specifically for the newly rendered table rows + setupCopyButtons("#errorLogsTable"); + // Update selected state after rendering + updateSelectedState(); } // 显示错误日志详情 (从 API 获取) async function showLogDetails(logId) { - if (!logDetailModal) return; + if (!logDetailModal) return; - // Show loading state in modal (optional) - // Clear previous content and show a spinner or message - document.getElementById('modalGeminiKey').textContent = '加载中...'; - document.getElementById('modalErrorType').textContent = '加载中...'; - document.getElementById('modalErrorLog').textContent = '加载中...'; - document.getElementById('modalRequestMsg').textContent = '加载中...'; - document.getElementById('modalModelName').textContent = '加载中...'; - document.getElementById('modalRequestTime').textContent = '加载中...'; + // Show loading state in modal (optional) + // Clear previous content and show a spinner or message + document.getElementById("modalGeminiKey").textContent = "加载中..."; + document.getElementById("modalErrorType").textContent = "加载中..."; + document.getElementById("modalErrorLog").textContent = "加载中..."; + document.getElementById("modalRequestMsg").textContent = "加载中..."; + document.getElementById("modalModelName").textContent = "加载中..."; + document.getElementById("modalRequestTime").textContent = "加载中..."; - logDetailModal.classList.add('show'); - document.body.style.overflow = 'hidden'; // Prevent body scrolling + logDetailModal.classList.add("show"); + document.body.style.overflow = "hidden"; // Prevent body scrolling - try { - // Use fetchAPI to get log details - const logDetails = await fetchAPI(`/api/logs/errors/${logId}/details`); + try { + // 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('未找到日志详情'); - } - - // Format date - let formattedTime = 'N/A'; - try { - const requestTime = new Date(logDetails.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 (logDetails.request_msg) { - try { - if (typeof logDetails.request_msg === 'object' && logDetails.request_msg !== null) { - formattedRequestMsg = JSON.stringify(logDetails.request_msg, null, 2); - } else if (typeof logDetails.request_msg === 'string') { - // Try parsing if it looks like JSON, otherwise display as string - const trimmedMsg = logDetails.request_msg.trim(); - if (trimmedMsg.startsWith('{') || trimmedMsg.startsWith('[')) { - formattedRequestMsg = JSON.stringify(JSON.parse(logDetails.request_msg), null, 2); - } else { - formattedRequestMsg = logDetails.request_msg; - } - } else { - formattedRequestMsg = String(logDetails.request_msg); - } - } catch (e) { - formattedRequestMsg = String(logDetails.request_msg); // Fallback - console.warn("Could not parse request_msg as JSON:", e); - } - } - - // Populate modal content with fetched details - document.getElementById('modalGeminiKey').textContent = logDetails.gemini_key || '无'; - document.getElementById('modalErrorType').textContent = logDetails.error_type || '未知'; - document.getElementById('modalErrorLog').textContent = logDetails.error_log || '无'; // Full error log - document.getElementById('modalRequestMsg').textContent = formattedRequestMsg; // Full request message - document.getElementById('modalModelName').textContent = logDetails.model_name || '未知'; - document.getElementById('modalRequestTime').textContent = formattedTime; - - // Re-initialize copy buttons specifically for the modal after content is loaded - setupCopyButtons('#logDetailModal'); - - } catch (error) { - console.error('获取日志详情失败:', error); - // Show error in modal - document.getElementById('modalGeminiKey').textContent = '错误'; - document.getElementById('modalErrorType').textContent = '错误'; - document.getElementById('modalErrorLog').textContent = `加载失败: ${error.message}`; - document.getElementById('modalRequestMsg').textContent = '错误'; - document.getElementById('modalModelName').textContent = '错误'; - document.getElementById('modalRequestTime').textContent = '错误'; - // Optionally show a notification - showNotification(`加载日志详情失败: ${error.message}`, 'error', 5000); + // fetchAPI handles response.ok check and JSON parsing + if (!logDetails) { + // Handle case where API returns success but no data (if possible) + throw new Error("未找到日志详情"); } + + // Format date + let formattedTime = "N/A"; + try { + const requestTime = new Date(logDetails.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 (logDetails.request_msg) { + try { + if ( + typeof logDetails.request_msg === "object" && + logDetails.request_msg !== null + ) { + formattedRequestMsg = JSON.stringify(logDetails.request_msg, null, 2); + } else if (typeof logDetails.request_msg === "string") { + // Try parsing if it looks like JSON, otherwise display as string + const trimmedMsg = logDetails.request_msg.trim(); + if (trimmedMsg.startsWith("{") || trimmedMsg.startsWith("[")) { + formattedRequestMsg = JSON.stringify( + JSON.parse(logDetails.request_msg), + null, + 2 + ); + } else { + formattedRequestMsg = logDetails.request_msg; + } + } else { + formattedRequestMsg = String(logDetails.request_msg); + } + } catch (e) { + formattedRequestMsg = String(logDetails.request_msg); // Fallback + console.warn("Could not parse request_msg as JSON:", e); + } + } + + // Populate modal content with fetched details + document.getElementById("modalGeminiKey").textContent = + logDetails.gemini_key || "无"; + document.getElementById("modalErrorType").textContent = + logDetails.error_type || "未知"; + document.getElementById("modalErrorLog").textContent = + logDetails.error_log || "无"; // Full error log + document.getElementById("modalRequestMsg").textContent = + formattedRequestMsg; // Full request message + document.getElementById("modalModelName").textContent = + logDetails.model_name || "未知"; + document.getElementById("modalRequestTime").textContent = formattedTime; + + // Re-initialize copy buttons specifically for the modal after content is loaded + setupCopyButtons("#logDetailModal"); + } catch (error) { + console.error("获取日志详情失败:", error); + // Show error in modal + document.getElementById("modalGeminiKey").textContent = "错误"; + document.getElementById("modalErrorType").textContent = "错误"; + document.getElementById( + "modalErrorLog" + ).textContent = `加载失败: ${error.message}`; + document.getElementById("modalRequestMsg").textContent = "错误"; + document.getElementById("modalModelName").textContent = "错误"; + document.getElementById("modalRequestTime").textContent = "错误"; + // Optionally show a notification + showNotification(`加载日志详情失败: ${error.message}`, "error", 5000); + } } // Close Log Detail Modal function closeLogDetailModal() { - if (logDetailModal) { - logDetailModal.classList.remove('show'); - // Optional: Restore body scrolling - document.body.style.overflow = ''; - } + if (logDetailModal) { + logDetailModal.classList.remove("show"); + // Optional: Restore body scrolling + document.body.style.overflow = ""; + } } - // 更新分页控件 function updatePagination(currentItemCount, totalItems) { - if (!paginationElement) return; - paginationElement.innerHTML = ''; // Clear existing pagination + 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 / 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 { - // 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, '«', 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 + // Calculate total pages only if totalItems is known and valid + let totalPages = 1; + if (totalItems >= 0) { + 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 { + // 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, + "«", + 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, + errorLogState.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, + "«", + errorLogState.currentPage > 1, + () => { + errorLogState.currentPage--; + loadErrorLogs(); } + ); - - const maxPagesToShow = 5; // Max number of page links to show - 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 - if (endPage === totalPages) { - startPage = Math.max(1, endPage - maxPagesToShow + 1); + // First Page Button + if (startPage > 1) { + addPaginationLink(paginationElement, "1", true, () => { + errorLogState.currentPage = 1; + loadErrorLogs(); + }); + if (startPage > 2) { + addPaginationLink(paginationElement, "...", false); // Ellipsis } + } + // Page Number Buttons + for (let i = startPage; i <= endPage; i++) { + addPaginationLink( + paginationElement, + i.toString(), + true, + () => { + errorLogState.currentPage = i; + loadErrorLogs(); + }, + i === errorLogState.currentPage + ); + } - // Previous Button - addPaginationLink(paginationElement, '«', errorLogState.currentPage > 1, () => { errorLogState.currentPage--; loadErrorLogs(); }); - - // First Page Button - if (startPage > 1) { - addPaginationLink(paginationElement, '1', true, () => { errorLogState.currentPage = 1; loadErrorLogs(); }); - if (startPage > 2) { - addPaginationLink(paginationElement, '...', false); // Ellipsis - } + // Last Page Button + if (endPage < totalPages) { + if (endPage < totalPages - 1) { + addPaginationLink(paginationElement, "...", false); // Ellipsis } + addPaginationLink(paginationElement, totalPages.toString(), true, () => { + errorLogState.currentPage = totalPages; + loadErrorLogs(); + }); + } - // Page Number Buttons - for (let i = startPage; i <= endPage; i++) { - addPaginationLink(paginationElement, i.toString(), true, () => { errorLogState.currentPage = i; loadErrorLogs(); }, i === errorLogState.currentPage); + // Next Button + addPaginationLink( + paginationElement, + "»", + errorLogState.currentPage < totalPages, + () => { + errorLogState.currentPage++; + loadErrorLogs(); } - - // Last Page Button - if (endPage < totalPages) { - if (endPage < totalPages - 1) { - addPaginationLink(paginationElement, '...', false); // Ellipsis - } - addPaginationLink(paginationElement, totalPages.toString(), true, () => { errorLogState.currentPage = totalPages; loadErrorLogs(); }); - } - - - // Next Button - addPaginationLink(paginationElement, '»', errorLogState.currentPage < totalPages, () => { errorLogState.currentPage++; loadErrorLogs(); }); + ); } // Helper function to add pagination links -function addPaginationLink(parentElement, text, enabled, clickHandler, isActive = false) { - const pageItem = document.createElement('li'); - // 移除 'page-item' 和 'active' 类,使用 Tailwind 类进行样式化 - // pageItem.className = `page-item ${!enabled ? 'disabled' : ''} ${isActive ? 'active' : ''}`; +function addPaginationLink( + parentElement, + text, + enabled, + clickHandler, + isActive = false +) { + // const pageItem = document.createElement('li'); // We are not using
  • anymore - const pageLink = document.createElement('a'); - // 使用 Tailwind 类进行样式化 - pageLink.className = `px-3 py-1 rounded-md text-sm transition duration-150 ease-in-out ${ - isActive - ? 'bg-primary-600 text-white font-semibold shadow-md cursor-default' // 突出当前页样式 - : enabled - ? 'bg-white text-gray-700 hover:bg-primary-50 hover:text-primary-600 border border-gray-300' // 可点击页码样式 - : 'bg-gray-100 text-gray-400 cursor-not-allowed border border-gray-200' // 禁用状态样式 (如 '...') - }`; - pageLink.href = '#'; // Prevent page jump - pageLink.innerHTML = text; + const pageLink = document.createElement("a"); - if (enabled && clickHandler) { - pageLink.addEventListener('click', function(e) { - e.preventDefault(); - clickHandler(); - }); - } else if (!enabled) { - pageLink.addEventListener('click', e => e.preventDefault()); // Prevent click on disabled or active - } else if (isActive) { - pageLink.addEventListener('click', e => e.preventDefault()); // Prevent click on active page - } + // Base Tailwind classes for layout, size, and transition. Colors/borders will come from CSS. + let baseClasses = + "px-3 py-1 rounded-md text-sm transition duration-150 ease-in-out"; // Common classes - // 不再需要 li 元素,直接将 a 元素添加到父元素 - // pageItem.appendChild(pageLink); - parentElement.appendChild(pageLink); + if (isActive) { + pageLink.className = `${baseClasses} active`; // Add 'active' class for CSS + } else if (enabled) { + pageLink.className = baseClasses; // Just base classes, CSS handles the rest + } else { + // Disabled link (e.g., '...' or unavailable prev/next) + pageLink.className = `${baseClasses} disabled`; // Add 'disabled' class for CSS + } + + pageLink.href = "#"; // Prevent page jump + pageLink.innerHTML = text; + + if (enabled && clickHandler) { + pageLink.addEventListener("click", function (e) { + e.preventDefault(); + clickHandler(); + }); + } else { + // Handles !enabled (includes isActive as clickHandler is null for it, and '...' which has no clickHandler) + pageLink.addEventListener("click", (e) => e.preventDefault()); + } + + parentElement.appendChild(pageLink); // Directly append to the