From 51bb71bdb5552467bd90da9a122133bee3f45126 Mon Sep 17 00:00:00 2001 From: snaily Date: Fri, 11 Apr 2025 03:16:51 +0800 Subject: [PATCH] =?UTF-8?q?```git=20feat:=20=E6=B7=BB=E5=8A=A0=E5=AF=86?= =?UTF-8?q?=E9=92=A5=E6=A3=80=E6=9F=A5=E8=B0=83=E5=BA=A6=E5=99=A8=E5=B9=B6?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E5=89=8D=E7=AB=AFUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要变更: - **调度器功能:** - 集成 APScheduler 实现定时任务,用于定期检查API密钥的有效性。 - 在 `.env.example` 和 `app/config/config.py` 中添加了 `CHECK_INTERVAL_HOURS` 和 `TIMEZONE` 配置项。 - 在应用生命周期 (`app/core/application.py`) 中添加了调度器的启动和停止逻辑。 - 新增 `app/scheduler/` 目录及相关实现 (`key_checker.py`)。 - 新增 `app/router/scheduler_routes.py` 用于调度器相关API (如果未来需要)。 - 在 `requirements.txt` 中添加 `apscheduler` 依赖。 - **前端重构与改进:** - 引入 `app/templates/base.html` 作为基础模板,统一页面结构和样式引入。 - 使用新的样式(推测为Tailwind CSS)重构了 `auth.html`, `config_editor.html`, `error_logs.html`, `keys_status.html` 页面,提升了UI一致性和响应式布局。 - 删除了旧的CSS文件 (`auth.css`, `config_editor.css`, `error_logs.css`, `keys_status.css`)。 - 更新了对应的 JavaScript 文件 (`config_editor.js`, `error_logs.js`, `keys_status.js`) 以适应新的HTML结构和交互。 - 在 `keys_status.html` 页面增加了按失败次数过滤密钥、批量重置失败次数、确认模态框等功能。 - 添加了新的 Logo 图片 (`logo.png`, `logo1.png`)。 - **其他:** - 更新了 `app/router/routes.py` 以包含新的路由。 - 对 `app/service/key/key_manager.py` 和 `app/database/services.py` 进行了相关调整以支持新功能。 ``` --- .env.example | 2 + app/config/config.py | 6 +- app/core/application.py | 11 +- app/database/services.py | 28 +- app/router/gemini_routes.py | 68 ++- app/router/log_routes.py | 6 +- app/router/routes.py | 3 +- app/router/scheduler_routes.py | 63 +++ app/scheduler/key_checker.py | 100 ++++ app/service/key/key_manager.py | 10 + app/static/css/auth.css | 249 ---------- app/static/css/config_editor.css | 794 ------------------------------- app/static/css/error_logs.css | 390 --------------- app/static/css/keys_status.css | 563 ---------------------- app/static/icons/logo.png | Bin 0 -> 39548 bytes app/static/icons/logo1.png | Bin 0 -> 18105 bytes app/static/js/config_editor.js | 118 ++++- app/static/js/error_logs.js | 174 +++++-- app/static/js/keys_status.js | 307 ++++++++++-- app/templates/auth.html | 154 ++++-- app/templates/base.html | 265 +++++++++++ app/templates/config_editor.html | 751 +++++++++++++++++------------ app/templates/error_logs.html | 375 +++++++++------ app/templates/keys_status.html | 428 +++++++++++------ requirements.txt | 1 + 25 files changed, 2090 insertions(+), 2776 deletions(-) create mode 100644 app/router/scheduler_routes.py create mode 100644 app/scheduler/key_checker.py delete mode 100644 app/static/css/auth.css delete mode 100644 app/static/css/config_editor.css delete mode 100644 app/static/css/error_logs.css delete mode 100644 app/static/css/keys_status.css create mode 100644 app/static/icons/logo.png create mode 100644 app/static/icons/logo1.png create mode 100644 app/templates/base.html diff --git a/.env.example b/.env.example index ec98ec7..35374b2 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,8 @@ SHOW_THINKING_PROCESS=true BASE_URL=https://generativelanguage.googleapis.com/v1beta MAX_FAILURES=10 MAX_RETRIES=3 +CHECK_INTERVAL_HOURS=1 +TIMEZONE=Asia/Shanghai # 请求超时时间(秒) TIME_OUT=300 #########################image_generate 相关配置########################### diff --git a/app/config/config.py b/app/config/config.py index 8cf2d5b..7043cb3 100644 --- a/app/config/config.py +++ b/app/config/config.py @@ -62,7 +62,11 @@ class Settings(BaseSettings): STREAM_SHORT_TEXT_THRESHOLD: int = DEFAULT_STREAM_SHORT_TEXT_THRESHOLD STREAM_LONG_TEXT_THRESHOLD: int = DEFAULT_STREAM_LONG_TEXT_THRESHOLD STREAM_CHUNK_SIZE: int = DEFAULT_STREAM_CHUNK_SIZE - + + # 调度器配置 + CHECK_INTERVAL_HOURS: int = 1 # 默认检查间隔为1小时 + TIMEZONE: str = "Asia/Shanghai" # 默认时区 + def __init__(self, **kwargs): super().__init__(**kwargs) # 设置默认AUTH_TOKEN(如果未提供) diff --git a/app/core/application.py b/app/core/application.py index b63fe42..d465306 100644 --- a/app/core/application.py +++ b/app/core/application.py @@ -14,6 +14,7 @@ from app.service.key.key_manager import get_key_manager_instance from app.core.initialization import initialize_app from app.database.connection import connect_to_db, disconnect_from_db from app.database.initialization import initialize_database +from app.scheduler.key_checker import start_scheduler, stop_scheduler # 导入调度器函数 logger = get_application_logger() @@ -44,12 +45,20 @@ async def lifespan(app: FastAPI): except Exception as e: logger.error(f"Failed to initialize application: {str(e)}") raise - + + # 启动调度器 + start_scheduler() + logger.info("Scheduler started successfully.") + yield # 应用程序运行期间 # 关闭事件 logger.info("Application shutting down...") + # 停止调度器 + stop_scheduler() + logger.info("Scheduler stopped.") + # 断开数据库连接 await disconnect_from_db() diff --git a/app/database/services.py b/app/database/services.py index f73cd96..b14a814 100644 --- a/app/database/services.py +++ b/app/database/services.py @@ -1,10 +1,9 @@ """ 数据库服务模块 """ -import datetime import json from typing import Dict, List, Optional, Any, Union -from datetime import date, timedelta # Import date and timedelta +from datetime import datetime from sqlalchemy import select, insert, update, func # Import func for COUNT @@ -142,7 +141,7 @@ async def add_error_log( model_name=model_name, error_code=error_code, request_msg=request_msg_json, - request_time=datetime.datetime.now() + request_time=datetime.now() ) ) await database.execute(query) @@ -158,8 +157,8 @@ async def get_error_logs( offset: int = 0, key_search: Optional[str] = None, error_search: Optional[str] = None, - start_date: Optional[date] = None, - end_date: Optional[date] = None + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None ) -> List[Dict[str, Any]]: """ 获取错误日志,支持搜索和日期过滤 @@ -169,8 +168,8 @@ async def get_error_logs( offset (int): 偏移量 key_search (Optional[str]): Gemini密钥搜索词 (模糊匹配) error_search (Optional[str]): 错误类型或日志内容搜索词 (模糊匹配) - start_date (Optional[date]): 开始日期 - end_date (Optional[date]): 结束日期 + start_date (Optional[datetime]): 开始日期时间 + end_date (Optional[datetime]): 结束日期时间 Returns: List[Dict[str, Any]]: 错误日志列表 @@ -189,8 +188,8 @@ async def get_error_logs( if start_date: query = query.where(ErrorLog.request_time >= start_date) if end_date: - # Add 1 day to end_date to include the whole day - query = query.where(ErrorLog.request_time < end_date + timedelta(days=1)) + # Use the datetime object directly for comparison + query = query.where(ErrorLog.request_time < end_date) # Apply ordering, limit, and offset query = query.order_by(ErrorLog.request_time.desc()).limit(limit).offset(offset) @@ -205,8 +204,8 @@ async def get_error_logs( async def get_error_logs_count( key_search: Optional[str] = None, error_search: Optional[str] = None, - start_date: Optional[date] = None, - end_date: Optional[date] = None + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None ) -> int: """ 获取符合条件的错误日志总数 @@ -214,8 +213,8 @@ async def get_error_logs_count( Args: key_search (Optional[str]): Gemini密钥搜索词 (模糊匹配) error_search (Optional[str]): 错误类型或日志内容搜索词 (模糊匹配) - start_date (Optional[date]): 开始日期 - end_date (Optional[date]): 结束日期 + start_date (Optional[datetime]): 开始日期时间 + end_date (Optional[datetime]): 结束日期时间 Returns: int: 日志总数 @@ -234,7 +233,8 @@ async def get_error_logs_count( if start_date: query = query.where(ErrorLog.request_time >= start_date) if end_date: - query = query.where(ErrorLog.request_time < end_date + timedelta(days=1)) + # Use the datetime object directly for comparison + query = query.where(ErrorLog.request_time < end_date) count_result = await database.fetch_one(query) return count_result[0] if count_result else 0 diff --git a/app/router/gemini_routes.py b/app/router/gemini_routes.py index 3d09871..e879bb4 100644 --- a/app/router/gemini_routes.py +++ b/app/router/gemini_routes.py @@ -146,15 +146,67 @@ async def stream_generate_content( logger.error(f"Streaming request failed: {str(e)}") raise HTTPException(status_code=500, detail="Streaming request failed") from e +@router.post("/reset-all-fail-counts") +async def reset_all_key_fail_counts(key_type: str = None, key_manager: KeyManager = Depends(get_key_manager)): + """批量重置Gemini API密钥的失败计数,可选择性地仅重置有效或无效密钥""" + logger.info("-" * 50 + "reset_all_gemini_key_fail_counts" + "-" * 50) + logger.info(f"Received reset request with key_type: {key_type}") + + try: + # 获取分类后的密钥 + keys_by_status = await key_manager.get_keys_by_status() + valid_keys = keys_by_status.get("valid_keys", {}) + invalid_keys = keys_by_status.get("invalid_keys", {}) + + # 根据类型选择要重置的密钥 + keys_to_reset = [] + if key_type == "valid": + keys_to_reset = list(valid_keys.keys()) + logger.info(f"Resetting only valid keys, count: {len(keys_to_reset)}") + elif key_type == "invalid": + keys_to_reset = list(invalid_keys.keys()) + logger.info(f"Resetting only invalid keys, count: {len(keys_to_reset)}") + else: + # 重置所有密钥 + await key_manager.reset_failure_counts() + return JSONResponse({"success": True, "message": "所有密钥的失败计数已重置"}) + + # 批量重置指定类型的密钥 + for key in keys_to_reset: + await key_manager.reset_key_failure_count(key) + + return JSONResponse({ + "success": True, + "message": f"{key_type}密钥的失败计数已重置", + "reset_count": len(keys_to_reset) + }) + except Exception as e: + logger.error(f"Failed to reset key failure counts: {str(e)}") + return JSONResponse({"success": False, "message": f"批量重置失败: {str(e)}"}, status_code=500) + + +@router.post("/reset-fail-count/{api_key}") +async def reset_key_fail_count(api_key: str, key_manager: KeyManager = Depends(get_key_manager)): + """重置指定Gemini API密钥的失败计数""" + logger.info("-" * 50 + "reset_gemini_key_fail_count" + "-" * 50) + logger.info(f"Resetting failure count for API key: {api_key}") + + try: + result = await key_manager.reset_key_failure_count(api_key) + if result: + return JSONResponse({"success": True, "message": "失败计数已重置"}) + return JSONResponse({"success": False, "message": "未找到指定密钥"}, status_code=404) + except Exception as e: + logger.error(f"Failed to reset key failure count: {str(e)}") + return JSONResponse({"success": False, "message": f"重置失败: {str(e)}"}, status_code=500) @router.post("/verify-key/{api_key}") -async def verify_key(api_key: str, chat_service: GeminiChatService = Depends(get_chat_service)): +async def verify_key(api_key: str, chat_service: GeminiChatService = Depends(get_chat_service), key_manager: KeyManager = Depends(get_key_manager)): """验证Gemini API密钥的有效性""" logger.info("-" * 50 + "verify_gemini_key" + "-" * 50) logger.info("Verifying API key validity") try: - # 使用generate_content接口测试key的有效性 gemini_request = GeminiRequest( contents=[ @@ -167,13 +219,19 @@ async def verify_key(api_key: str, chat_service: GeminiChatService = Depends(get response = await chat_service.generate_content( settings.TEST_MODEL, - gemini_request, + gemini_request, api_key ) if response: - return JSONResponse({"status": "valid"}) - return JSONResponse({"status": "invalid"}) + return JSONResponse({"status": "valid"}) except Exception as e: logger.error(f"Key verification failed: {str(e)}") + + # 验证出现异常时增加失败计数 + async with key_manager.failure_count_lock: + if api_key in key_manager.key_failure_counts: + key_manager.key_failure_counts[api_key] += 1 + logger.warning(f"Verification exception for key: {api_key}, incrementing failure count") + return JSONResponse({"status": "invalid", "error": str(e)}) \ No newline at end of file diff --git a/app/router/log_routes.py b/app/router/log_routes.py index bbd6b7b..0f3d56c 100644 --- a/app/router/log_routes.py +++ b/app/router/log_routes.py @@ -2,7 +2,7 @@ 日志路由模块 """ from typing import Any, Dict, List, Optional -from datetime import date +from datetime import datetime from pydantic import BaseModel from fastapi import APIRouter, HTTPException, Request, Query from fastapi.responses import RedirectResponse @@ -29,8 +29,8 @@ async def get_error_logs_api( offset: int = Query(0, ge=0), key_search: Optional[str] = Query(None, description="Search term for Gemini key (partial match)"), error_search: Optional[str] = Query(None, description="Search term for error type or log message"), - start_date: Optional[date] = Query(None, description="Start date for filtering (YYYY-MM-DD)"), - end_date: Optional[date] = Query(None, description="End date for filtering (YYYY-MM-DD)") + start_date: Optional[datetime] = Query(None, description="Start datetime for filtering (YYYY-MM-DDTHH:MM)"), + end_date: Optional[datetime] = Query(None, description="End datetime for filtering (YYYY-MM-DDTHH:MM)") ): """ 获取错误日志 diff --git a/app/router/routes.py b/app/router/routes.py index bc36739..68712dc 100644 --- a/app/router/routes.py +++ b/app/router/routes.py @@ -8,7 +8,7 @@ from fastapi.templating import Jinja2Templates from app.core.security import verify_auth_token from app.log.logger import get_routes_logger -from app.router import gemini_routes, openai_routes, config_routes, log_routes +from app.router import gemini_routes, openai_routes, config_routes, log_routes, scheduler_routes # 新增导入 from app.service.key.key_manager import get_key_manager_instance logger = get_routes_logger() @@ -30,6 +30,7 @@ def setup_routers(app: FastAPI) -> None: app.include_router(gemini_routes.router_v1beta) app.include_router(config_routes.router) app.include_router(log_routes.router) + app.include_router(scheduler_routes.router) # 新增包含 scheduler 路由 # 添加页面路由 setup_page_routes(app) diff --git a/app/router/scheduler_routes.py b/app/router/scheduler_routes.py new file mode 100644 index 0000000..91189d5 --- /dev/null +++ b/app/router/scheduler_routes.py @@ -0,0 +1,63 @@ +""" +定时任务控制路由模块 +""" + +from fastapi import APIRouter, Request, HTTPException, status # 移除 Depends, 添加 Request +from fastapi.responses import JSONResponse + +from app.core.security import verify_auth_token # 导入 verify_auth_token +from app.scheduler.key_checker import start_scheduler, stop_scheduler +from app.log.logger import get_routes_logger # 使用路由日志记录器 + +logger = get_routes_logger() + +router = APIRouter( + prefix="/api/scheduler", + tags=["Scheduler"] + # 移除全局依赖 +) + +# 认证检查的辅助函数 +async def verify_token(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 scheduler API") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + +@router.post("/start", summary="启动定时任务") +async def start_scheduler_endpoint(request: Request): # 添加 request 参数 + """Start the background scheduler task""" + """ + await verify_token(request) # 在函数开始处进行认证检查 + """ + try: + logger.info("Received request to start scheduler.") + start_scheduler() # 调用 key_checker 中的函数 + return JSONResponse(content={"message": "Scheduler started successfully."}, status_code=status.HTTP_200_OK) + except Exception as e: + logger.error(f"Error starting scheduler: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to start scheduler: {str(e)}" + ) + +@router.post("/stop", summary="停止定时任务") +async def stop_scheduler_endpoint(request: Request): # 添加 request 参数 + """Stop the background scheduler task""" + """ + await verify_token(request) # 在函数开始处进行认证检查 + """ + try: + logger.info("Received request to stop scheduler.") + stop_scheduler() # 调用 key_checker 中的函数 + return JSONResponse(content={"message": "Scheduler stopped successfully."}, status_code=status.HTTP_200_OK) + except Exception as e: + logger.error(f"Error stopping scheduler: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to stop scheduler: {str(e)}" + ) \ No newline at end of file diff --git a/app/scheduler/key_checker.py b/app/scheduler/key_checker.py new file mode 100644 index 0000000..4dd0b0d --- /dev/null +++ b/app/scheduler/key_checker.py @@ -0,0 +1,100 @@ +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from app.service.key.key_manager import get_key_manager_instance +from app.service.chat.gemini_chat_service import GeminiChatService +from app.domain.gemini_models import GeminiRequest, GeminiContent +from app.config.config import settings +from app.log.logger import Logger # 导入 Logger 类 + +logger = Logger.setup_logger("scheduler") # 使用 Logger.setup_logger + +async def check_failed_keys(): + """ + 定时检查失败次数大于0的API密钥,并尝试验证它们。 + 如果验证成功,重置失败计数;如果失败,增加失败计数。 + """ + logger.info("Starting scheduled check for failed API keys...") + try: + key_manager = await get_key_manager_instance() + # 确保 KeyManager 已经初始化 + if not key_manager or not hasattr(key_manager, 'key_failure_counts'): + logger.warning("KeyManager instance not available or not initialized. Skipping check.") + return + + # 创建 GeminiChatService 实例用于验证 + # 注意:这里直接创建实例,而不是通过依赖注入,因为这是后台任务 + chat_service = GeminiChatService(settings.BASE_URL, key_manager) + + # 获取需要检查的 key 列表 (失败次数 > 0) + keys_to_check = [] + async with key_manager.failure_count_lock: # 访问共享数据需要加锁 + # 复制一份以避免在迭代时修改字典 + failure_counts_copy = key_manager.key_failure_counts.copy() + keys_to_check = [key for key, count in failure_counts_copy.items() if count > 0] # 检查所有失败次数大于0的key + + if not keys_to_check: + logger.info("No keys with failure count > 0 found. Skipping verification.") + return + + logger.info(f"Found {len(keys_to_check)} keys with failure count > 0 to verify.") + + for key in keys_to_check: + # 隐藏部分 key 用于日志记录 + log_key = f"{key[:4]}...{key[-4:]}" if len(key) > 8 else key + logger.info(f"Verifying key: {log_key}...") + try: + # 构造测试请求 + gemini_request = GeminiRequest( + contents=[ + GeminiContent( + role="user", + parts=[{"text": "hi"}] # 使用简单的文本进行验证 + ) + ] + ) + # 调用 generate_content 进行验证 + await chat_service.generate_content( + 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.") + # 直接操作计数器,需要加锁 + async with key_manager.failure_count_lock: + # 再次检查 key 是否存在且失败次数未达上限 + if key in key_manager.key_failure_counts and key_manager.key_failure_counts[key] < key_manager.MAX_FAILURES: + key_manager.key_failure_counts[key] += 1 + logger.info(f"Failure count for key {log_key} incremented to {key_manager.key_failure_counts[key]}.") + elif key in key_manager.key_failure_counts: + logger.warning(f"Key {log_key} reached MAX_FAILURES ({key_manager.MAX_FAILURES}). Not incrementing further.") + + + except Exception as e: + logger.error(f"An error occurred during the scheduled key check: {str(e)}", exc_info=True) + +def setup_scheduler(): + """设置并启动 APScheduler""" + scheduler = AsyncIOScheduler(timezone=str(settings.TIMEZONE)) # 从配置读取时区 + # 添加定时任务,例如每小时执行一次 (可以调整) + scheduler.add_job(check_failed_keys, 'interval', hours=settings.CHECK_INTERVAL_HOURS) + scheduler.start() + logger.info(f"Scheduler started. Key check job scheduled to run every {settings.CHECK_INTERVAL_HOURS} hour(s).") + return scheduler + +# 可以在这里添加一个全局的 scheduler 实例,以便在应用关闭时优雅地停止 +scheduler_instance = None + +def start_scheduler(): + global scheduler_instance + if scheduler_instance is None: + scheduler_instance = setup_scheduler() + +def stop_scheduler(): + global scheduler_instance + if scheduler_instance and scheduler_instance.running: + scheduler_instance.shutdown() + logger.info("Scheduler stopped.") \ No newline at end of file diff --git a/app/service/key/key_manager.py b/app/service/key/key_manager.py index 4f27ffa..ec87db7 100644 --- a/app/service/key/key_manager.py +++ b/app/service/key/key_manager.py @@ -37,6 +37,16 @@ class KeyManager: async with self.failure_count_lock: for key in self.key_failure_counts: self.key_failure_counts[key] = 0 + + async def reset_key_failure_count(self, key: str) -> bool: + """重置指定key的失败计数""" + async with self.failure_count_lock: + if key in self.key_failure_counts: + self.key_failure_counts[key] = 0 + logger.info(f"Reset failure count for key: {key}") + return True + logger.warning(f"Attempt to reset failure count for non-existent key: {key}") + return False async def get_next_working_key(self) -> str: """获取下一可用的API key""" diff --git a/app/static/css/auth.css b/app/static/css/auth.css deleted file mode 100644 index 12571ce..0000000 --- a/app/static/css/auth.css +++ /dev/null @@ -1,249 +0,0 @@ -body { - font-family: 'Roboto', sans-serif; - line-height: 1.6; - margin: 0; - padding: 0; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - min-height: 100vh; - display: flex; - justify-content: center; - align-items: center; -} - -.container { - max-width: 400px; - width: 90%; - background: rgba(255, 255, 255, 0.95); - padding: 40px; - border-radius: 20px; - box-shadow: 0 15px 35px rgba(0,0,0,0.2); - backdrop-filter: blur(10px); - transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); -} - -.container:hover { - transform: translateY(-5px); - box-shadow: 0 20px 40px rgba(0,0,0,0.25); -} - -.logo { - text-align: center; - margin-bottom: 30px; - animation: fadeIn 1s ease; -} - -.logo i { - font-size: 48px; - color: #764ba2; - margin-bottom: 15px; -} - -h2 { - color: #2c3e50; - text-align: center; - margin-bottom: 30px; - font-weight: 700; - font-size: 24px; - animation: slideDown 0.5s ease; -} - -form { - display: flex; - flex-direction: column; - gap: 20px; -} - -.input-group { - position: relative; - animation: slideUp 0.5s ease; -} - -.input-group i { - position: absolute; - left: 12px; - top: 50%; - transform: translateY(-50%); - color: #764ba2; - font-size: 18px; -} - -input { - width: 100%; - padding: 12px 12px 12px 40px; - border: 2px solid #e0e0e0; - border-radius: 10px; - font-size: 16px; - box-sizing: border-box; - transition: all 0.3s ease; - background: rgba(255, 255, 255, 0.9); -} - -input:focus { - border-color: #764ba2; - box-shadow: 0 0 10px rgba(118, 75, 162, 0.2); - outline: none; -} - -button { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - border: none; - padding: 14px; - border-radius: 10px; - cursor: pointer; - font-size: 16px; - font-weight: bold; - transition: all 0.3s ease; - position: relative; - overflow: hidden; -} - -button:hover { - transform: translateY(-2px); - box-shadow: 0 5px 15px rgba(118, 75, 162, 0.3); -} - -button:active { - transform: translateY(0); -} - -button::after { - content: ''; - position: absolute; - top: 50%; - left: 50%; - width: 0; - height: 0; - background: rgba(255, 255, 255, 0.2); - border-radius: 50%; - transform: translate(-50%, -50%); - transition: width 0.6s, height 0.6s; -} - -button:active::after { - width: 200px; - height: 200px; - opacity: 0; -} - -.error-message { - color: #e74c3c; - margin-top: 15px; - text-align: center; - font-weight: bold; - padding: 10px; - border-radius: 5px; - background: rgba(231, 76, 60, 0.1); - animation: shake 0.5s ease; -} - -.copyright { - position: fixed; - bottom: 0; - left: 0; - width: 100%; - background: rgba(255, 255, 255, 0.9); - padding: 10px 0; - text-align: center; - font-size: 14px; - color: #2c3e50; - backdrop-filter: blur(5px); - border-top: 1px solid rgba(0,0,0,0.1); -} - -.copyright a { - color: #764ba2; - text-decoration: none; - transition: color 0.3s ease; -} - -.copyright a:hover { - color: #667eea; -} - -.copyright img { - width: 20px; - height: 20px; - border-radius: 50%; - vertical-align: middle; - margin-right: 5px; -} - -@keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } -} - -@keyframes slideDown { - from { transform: translateY(-20px); opacity: 0; } - to { transform: translateY(0); opacity: 1; } -} - -@keyframes slideUp { - from { transform: translateY(20px); opacity: 0; } - to { transform: translateY(0); opacity: 1; } -} - -@keyframes shake { - 0%, 100% { transform: translateX(0); } - 25% { transform: translateX(-5px); } - 75% { transform: translateX(5px); } -} - -@media (max-width: 768px) { - .container { - width: 85%; - padding: 30px; - } - .logo i { - font-size: 40px; - } - h2 { - font-size: 22px; - } - input { - padding: 10px 10px 10px 35px; - font-size: 15px; - } - .input-group i { - font-size: 16px; - } - button { - padding: 12px; - font-size: 15px; - } -} - -@media (max-width: 480px) { - .container { - width: 90%; - padding: 25px; - } - .logo i { - font-size: 36px; - } - h2 { - font-size: 20px; - margin-bottom: 25px; - } - form { - gap: 15px; - } - input { - padding: 10px 10px 10px 32px; - font-size: 14px; - } - .input-group i { - font-size: 15px; - left: 10px; - } - button { - padding: 10px; - font-size: 14px; - } - .error-message { - font-size: 14px; - padding: 8px; - margin-top: 12px; - } -} diff --git a/app/static/css/config_editor.css b/app/static/css/config_editor.css deleted file mode 100644 index 87f97f8..0000000 --- a/app/static/css/config_editor.css +++ /dev/null @@ -1,794 +0,0 @@ -* { - box-sizing: border-box; -} - -body { - font-family: 'Roboto', sans-serif; - line-height: 1.6; - margin: 0; - padding: 20px; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - min-height: 100vh; -} - -.container { - max-width: 900px; - width: 95%; - background: rgba(255, 255, 255, 0.95); - padding: 40px; - border-radius: 20px; - box-shadow: 0 15px 35px rgba(0,0,0,0.2); - backdrop-filter: blur(10px); - position: relative; - margin: 20px auto; - overflow-y: auto; - max-height: calc(100vh - 40px); - scrollbar-width: none; - -ms-overflow-style: none; -} - -.container::-webkit-scrollbar { - display: none; -} - -h1 { - color: #2c3e50; - text-align: center; - margin-bottom: 30px; - font-weight: 700; - font-size: 32px; - position: relative; - padding-bottom: 15px; -} - -h1::after { - content: ''; - position: absolute; - bottom: 0; - left: 50%; - transform: translateX(-50%); - width: 100px; - height: 4px; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - border-radius: 2px; -} - -h2 { - color: #2c3e50; - margin-bottom: 20px; - font-size: 1.5em; - padding-bottom: 10px; - border-bottom: 2px solid rgba(0,0,0,0.1); - display: flex; - align-items: center; - gap: 10px; -} - -/* 导航标签样式 */ -.nav-tabs { - display: flex; - justify-content: center; - margin-bottom: 30px; - border-bottom: 2px solid rgba(0,0,0,0.1); - padding-bottom: 10px; - width: 100%; - max-width: 500px; - margin-left: auto; - margin-right: auto; -} - -.tab-link { - color: #2c3e50; - text-decoration: none; - padding: 12px 25px; - border-radius: 8px 8px 0 0; - font-weight: bold; - transition: all 0.3s ease; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - position: relative; - margin: 0 5px; - flex: 1; - text-align: center; -} - -.tab-link:hover { - background: rgba(102, 126, 234, 0.1); -} - -.tab-link.active { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - box-shadow: 0 5px 15px rgba(118, 75, 162, 0.3); -} - -.tab-link.active::after { - content: ''; - position: absolute; - bottom: -12px; - left: 0; - width: 100%; - height: 4px; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - border-radius: 2px; -} - -.tab-link i { - font-size: 16px; -} - -.config-tabs { - display: flex; - justify-content: center; - margin-bottom: 30px; - flex-wrap: wrap; - gap: 10px; -} - -.tab-btn { - background: rgba(255, 255, 255, 0.7); - border: 1px solid rgba(0,0,0,0.1); - padding: 10px 20px; - border-radius: 30px; - cursor: pointer; - font-weight: bold; - transition: all 0.3s ease; - color: #2c3e50; - font-size: 14px; -} - -.tab-btn:hover { - background: rgba(255, 255, 255, 0.9); - transform: translateY(-2px); - box-shadow: 0 5px 15px rgba(0,0,0,0.1); -} - -.tab-btn.active { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - box-shadow: 0 5px 15px rgba(118, 75, 162, 0.3); -} - -.config-section { - display: none; - animation: fadeIn 0.5s ease forwards; - background: rgba(248, 249, 250, 0.9); - padding: 25px; - border-radius: 15px; - margin-bottom: 30px; - border: 1px solid rgba(0,0,0,0.1); -} - -.config-section.active { - display: block; -} - -.config-item { - margin-bottom: 25px; - position: relative; -} - -.config-item label { - display: block; - margin-bottom: 8px; - font-weight: bold; - color: #2c3e50; -} - -.config-item input[type="text"], -.config-item input[type="number"], -.config-item select { - width: 100%; - padding: 12px 15px; - border: 1px solid rgba(0,0,0,0.1); - border-radius: 8px; - font-size: 16px; - transition: all 0.3s ease; - background: white; - box-sizing: border-box; -} - -.config-item input[type="text"]:focus, -.config-item input[type="number"]:focus, -.config-item select:focus { - border-color: #667eea; - box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2); - outline: none; -} - -.help-text { - display: block; - margin-top: 5px; - font-size: 12px; - color: #7f8c8d; -} - -.array-container { - border: 1px solid rgba(0,0,0,0.1); - border-radius: 8px; - padding: 15px; - background: white; - /* margin-bottom: 10px; */ /* Removed as controls are now outside */ - width: 100%; - min-height: 60px; -} - -/* Specific style for API Keys container to make it scrollable */ -#API_KEYS_container { - max-height: 300px; /* Adjust this value as needed */ - overflow-y: auto; - /* Optional: Add some padding to the right for the scrollbar */ - padding-right: 5px; - margin-bottom: 10px; /* Add margin below the container */ -} - -/* Search Input Styles */ -.search-container { - margin-bottom: 10px; -} - -#apiKeySearchInput { - width: 100%; - padding: 10px 15px; - border: 1px solid rgba(0,0,0,0.1); - border-radius: 8px; - font-size: 14px; - box-sizing: border-box; -} - -#apiKeySearchInput:focus { - border-color: #667eea; - box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2); - outline: none; -} - - -.array-item { - display: flex; - margin-bottom: 10px; - gap: 10px; - background-color: rgba(248, 249, 250, 0.5); - padding: 8px; - border-radius: 8px; - border: 1px dashed rgba(102, 126, 234, 0.3); - transition: all 0.3s ease; -} - -.array-item:hover { - background-color: rgba(248, 249, 250, 0.8); - border-color: rgba(102, 126, 234, 0.5); - box-shadow: 0 2px 8px rgba(0,0,0,0.05); -} - -.array-item input { - flex: 1; - box-sizing: border-box; - padding: 12px 15px; - border: 1px solid rgba(0,0,0,0.1); - border-radius: 8px; - font-size: 16px; - transition: all 0.3s ease; - background: white; -} - -.array-item input:focus { - border-color: #667eea; - box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2); - outline: none; -} - -.array-controls { - display: flex; - justify-content: flex-end; - margin-top: 10px; /* Increase margin-top for spacing */ -} - -.add-btn { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - border: none; - padding: 8px 15px; - border-radius: 8px; - cursor: pointer; - font-size: 14px; - font-weight: bold; - transition: all 0.3s ease; - display: flex; - align-items: center; - gap: 5px; -} - -.add-btn:hover { - transform: translateY(-2px); - box-shadow: 0 5px 15px rgba(118, 75, 162, 0.3); -} - -.remove-btn { - background: #e74c3c; - color: white; - border: none; - width: 30px; - height: 30px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 0.3s ease; -} - -.remove-btn:hover { - background: #c0392b; - transform: scale(1.1); -} - -.toggle { - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: nowrap; -} - -.toggle label { - margin-bottom: 0; - flex: 1; - padding-right: 15px; -} - -.toggle-switch { - position: relative; - display: inline-block; - width: 60px; - height: 34px; - flex-shrink: 0; -} - -.toggle-switch input { - opacity: 0; - width: 0; - height: 0; -} - -.toggle-slider { - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: #ccc; - transition: .4s; - border-radius: 34px; -} - -.toggle-slider:before { - position: absolute; - content: ""; - height: 26px; - width: 26px; - left: 4px; - bottom: 4px; - background-color: white; - transition: .4s; - border-radius: 50%; - box-shadow: 0 2px 5px rgba(0,0,0,0.2); -} - -input:checked + .toggle-slider { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); -} - -input:focus + .toggle-slider { - box-shadow: 0 0 1px #667eea; -} - -input:checked + .toggle-slider:before { - transform: translateX(26px); -} - -.form-actions { - display: flex; - justify-content: center; - gap: 20px; - margin-top: 40px; -} - -.save-btn, .reset-btn { - padding: 12px 30px; - border-radius: 30px; - font-size: 16px; - font-weight: bold; - cursor: pointer; - transition: all 0.3s ease; - display: flex; - align-items: center; - justify-content: center; - gap: 10px; - border: none; - box-shadow: 0 4px 10px rgba(0,0,0,0.1); -} - -.save-btn { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; -} - -.save-btn:hover { - transform: translateY(-3px); - box-shadow: 0 10px 20px rgba(118, 75, 162, 0.3); - background: linear-gradient(135deg, #764ba2 0%, #667eea 100%); -} - -.save-btn:active { - transform: translateY(1px); - box-shadow: 0 5px 10px rgba(118, 75, 162, 0.2); -} - -.reset-btn { - background: linear-gradient(135deg, #e0e0e0 0%, #bdc3c7 100%); - color: #2c3e50; -} - -.reset-btn:hover { - background: linear-gradient(135deg, #bdc3c7 0%, #e0e0e0 100%); - transform: translateY(-3px); - box-shadow: 0 10px 20px rgba(0,0,0,0.1); -} - -.reset-btn:active { - transform: translateY(1px); - box-shadow: 0 5px 10px rgba(0,0,0,0.05); -} - -.save-status { - position: fixed; - top: 20px; - left: 50%; - transform: translateX(-50%); - background: rgba(39, 174, 96, 0.9); - color: white; - padding: 10px 20px; - border-radius: 30px; - display: flex; - align-items: center; - gap: 10px; - font-weight: bold; - box-shadow: 0 5px 15px rgba(0,0,0,0.2); - z-index: 1000; - opacity: 0; - transition: all 0.3s ease; -} - -.save-status.show { - opacity: 1; -} - -.save-status.error { - background: rgba(231, 76, 60, 0.9); -} - -.provider-config { - display: none; -} - -.provider-config.active { - display: block; -} - -.notification { - position: fixed; - bottom: 20px; - right: 20px; - padding: 15px 25px; - border-radius: 8px; - background: rgba(0,0,0,0.8); - color: white; - font-weight: bold; - box-shadow: 0 5px 15px rgba(0,0,0,0.2); - z-index: 1000; - opacity: 0; - transform: translateY(20px); - transition: all 0.3s ease; -} - -.notification.show { - opacity: 1; - transform: translateY(0); -} - -.notification.success { - background: rgba(39, 174, 96, 0.9); -} - -.notification.error { - background: rgba(231, 76, 60, 0.9); -} - -.scroll-buttons { - position: fixed; - right: 20px; - bottom: 20px; - display: flex; - flex-direction: column; - gap: 10px; - z-index: 1000; -} - -.scroll-btn { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - width: 40px; - height: 40px; - border: none; - border-radius: 50%; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - font-size: 20px; - transition: all 0.3s ease; - backdrop-filter: blur(5px); - box-shadow: 0 2px 10px rgba(0,0,0,0.2); -} - -.scroll-btn:hover { - background: linear-gradient(135deg, #764ba2 0%, #667eea 100%); - transform: scale(1.1); -} - -.scroll-btn:active { - transform: scale(0.95); -} - -.refresh-btn { - position: fixed; - top: 20px; - right: 20px; - z-index: 1000; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: #fff; - border: none; - padding: 10px 20px; - border-radius: 25px; - cursor: pointer; - font-size: 14px; - font-weight: bold; - transition: all 0.3s ease; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); -} - -.refresh-btn:hover { - transform: scale(1.05); - box-shadow: 0 8px 20px rgba(118, 75, 162, 0.3); - background: linear-gradient(135deg, #764ba2 0%, #667eea 100%); -} - -.refresh-btn:active { - transform: scale(0.95); -} - -.refresh-btn i { - transition: transform 0.5s ease; -} - -.refresh-btn.loading i { - animation: spin 1s linear infinite; -} - -.copyright { - position: fixed; - bottom: 0; - left: 0; - width: 100%; - background: rgba(255, 255, 255, 0.9); - padding: 10px 0; - text-align: center; - font-size: 14px; - color: #2c3e50; - backdrop-filter: blur(5px); - border-top: 1px solid rgba(0,0,0,0.1); -} - -.copyright a { - color: #764ba2; - text-decoration: none; - transition: color 0.3s ease; -} - -.copyright a:hover { - color: #667eea; -} - -.copyright img { - width: 20px; - height: 20px; - border-radius: 50%; - vertical-align: middle; - margin-right: 5px; -} - -/* Modal Styles */ -.modal { - display: none; /* Hidden by default */ - position: fixed; /* Change back to fixed */ - z-index: 1001; /* Sit on top */ - left: 0; - top: 0; - width: 100%; /* Full width */ - height: 100%; /* Full height */ - /* overflow: auto; Removed */ - background-color: rgba(0,0,0,0.6); /* Black w/ opacity */ - /* backdrop-filter: blur(5px); Removed */ - align-items: center; - justify-content: center; -} - -.modal.show { - display: flex; - align-items: center; - justify-content: center; -} - -.modal-content { - background-color: #fefefe; - padding: 30px; - border: 1px solid #888; - width: 80%; /* Could be more or less, depending on screen size */ - max-width: 600px; - border-radius: 15px; - box-shadow: 0 10px 30px rgba(0,0,0,0.2); - position: relative; - animation: slideIn 0.3s ease-out; - margin: auto; -} - -.modal-content h2 { - margin-top: 0; - color: #2c3e50; - border-bottom: none; /* Remove border from h2 inside modal */ - padding-bottom: 0; -} - -.modal-content p { - color: #7f8c8d; - font-size: 14px; - margin-bottom: 15px; -} - -.modal-content textarea { - width: 100%; - padding: 10px; - border: 1px solid #ccc; - border-radius: 8px; - margin-bottom: 20px; - font-family: monospace; /* Use monospace for keys */ - font-size: 14px; - resize: vertical; /* Allow vertical resizing */ - min-height: 150px; -} - -.modal-actions { - display: flex; - justify-content: flex-end; - gap: 15px; - margin-top: 10px; -} - -.close-btn { - color: #aaa; - position: absolute; - top: 10px; - right: 20px; - font-size: 28px; - font-weight: bold; - line-height: 1; -} - -.close-btn:hover, -.close-btn:focus { - color: black; - text-decoration: none; - cursor: pointer; -} - -@keyframes slideIn { - from { transform: translateY(-50px); opacity: 0; } - to { transform: translateY(0); opacity: 1; } -} - - -@keyframes fadeIn { - from { opacity: 0; transform: translateY(20px); } - to { opacity: 1; transform: translateY(0); } -} - -@keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} - -@media (max-width: 768px) { - .container { - width: 100%; - padding: 20px; - margin: 10px auto; - } - body { - padding: 10px; - } - h1 { - font-size: 24px; - } - .nav-tabs { - flex-direction: column; - align-items: center; - gap: 10px; - } - .tab-link { - width: 100%; - justify-content: center; - border-radius: 8px; - } - .tab-link.active::after { - display: none; - } - .config-tabs { - flex-direction: column; - } - .tab-btn { - width: 100%; - text-align: center; - } - .toggle { - flex-direction: column; - align-items: flex-start; - gap: 10px; - } - .form-actions { - flex-direction: column; - gap: 15px; - } - .save-btn, .reset-btn { - width: 100%; - justify-content: center; - } - .scroll-buttons { - right: 10px; - bottom: 10px; - } - .scroll-btn { - width: 35px; - height: 35px; - font-size: 16px; - } - .refresh-btn { - top: 10px; - right: 10px; - padding: 8px 16px; - font-size: 12px; - } -} - -@media (max-width: 480px) { - .container { - padding: 15px; - } - h1 { - font-size: 20px; - } - .config-section { - padding: 15px; - } - .config-item input[type="text"], - .config-item input[type="number"], - .config-item select { - padding: 10px; - font-size: 14px; - } -} diff --git a/app/static/css/error_logs.css b/app/static/css/error_logs.css deleted file mode 100644 index 073fdb5..0000000 --- a/app/static/css/error_logs.css +++ /dev/null @@ -1,390 +0,0 @@ -/* error_logs.css - Styles specific to the error logs page, complementing config_editor.css */ - -/* Inherit body, container, h1, nav-tabs, scroll-buttons, refresh-btn, copyright from config_editor.css */ - -/* Add padding to the main content section */ -.config-section { - padding: 25px; /* Increased padding */ - background-color: #fff; /* Ensure white background */ - border-radius: 12px; /* Slightly larger radius */ - box-shadow: 0 5px 15px rgba(0, 0, 0, 0.08); /* Softer shadow */ - margin-top: 20px; -} -/* Style the controls container */ -.controls-container { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 20px; - padding: 15px; - background-color: #f8f9fa; /* Lighter background */ - border-radius: 6px; /* Slightly smaller radius */ - border: 1px solid #e9ecef; /* Lighter border */ - flex-wrap: wrap; /* Allow wrapping on smaller screens */ - gap: 15px; /* Add gap between items */ -} - -.page-size-selector { - display: flex; - align-items: center; - gap: 8px; -} - -.page-size-selector label { - font-weight: bold; - color: #2c3e50; - margin-bottom: 0; /* Override default label margin */ -} - -.page-size-selector select { - padding: 8px 12px; - border: 1px solid rgba(0,0,0,0.1); - border-radius: 6px; - background-color: white; - font-size: 14px; - min-width: 70px; /* Ensure select has some width */ -} - -.page-size-selector span { - color: #2c3e50; -} - -/* Style the action button (refresh) */ -.action-btn { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - border: none; - padding: 10px 20px; - border-radius: 25px; - cursor: pointer; - font-size: 14px; - font-weight: bold; - transition: all 0.3s ease; - display: flex; - align-items: center; - gap: 8px; - box-shadow: 0 4px 10px rgba(0,0,0,0.1); -} - -.action-btn:hover { - transform: translateY(-2px); - box-shadow: 0 8px 15px rgba(118, 75, 162, 0.2); -} - -.action-btn i { - font-size: 1em; -} - -/* Search Container Styles */ -.search-container { - display: flex; - flex-wrap: wrap; /* Allow wrapping on smaller screens */ - gap: 10px; /* Space between elements */ - align-items: center; - padding: 15px; - background-color: #f8f9fa; /* Consistent light background */ - border-radius: 6px; - border: 1px solid #e9ecef; /* Lighter border */ - margin-bottom: 25px; /* Increased margin */ -} - -.search-container input[type="text"], -.search-container input[type="date"] { - padding: 8px 12px; - border: 1px solid rgba(0,0,0,0.1); - border-radius: 6px; - font-size: 14px; - flex-grow: 1; /* Allow inputs to grow */ - min-width: 150px; /* Minimum width for inputs */ -} - -.search-container input[type="date"] { - min-width: 130px; /* Slightly less width for date inputs */ - flex-grow: 0; /* Don't let date inputs grow excessively */ -} - - -.search-container span { - color: #2c3e50; - margin: 0 5px; -} - -.search-container .action-btn { - padding: 8px 15px; /* Slightly smaller padding for search button */ - font-size: 14px; - flex-shrink: 0; /* Prevent button from shrinking */ -} - -/* Table container and styled table */ -.table-container { - overflow-x: auto; /* Allow horizontal scrolling for table */ - background-color: #ffffff; - border-radius: 6px; - box-shadow: 0 2px 8px rgba(0,0,0,0.06); /* Softer shadow */ - border: 1px solid #e9ecef; /* Lighter border */ -} - -.styled-table { - width: 100%; - border-collapse: collapse; - font-size: 14px; - color: #333; -} - -.styled-table thead tr { - background-color: #eef2f7; /* Lighter, slightly blueish header */ - text-align: left; - font-weight: 600; /* Slightly bolder */ - color: #495057; /* Darker grey text */ - border-bottom: 1px solid #dee2e6; /* Thinner border */ -} - -.styled-table th, -.styled-table td { - padding: 14px 15px; /* Increased padding */ - border-bottom: 1px solid #f1f3f5; /* Very light border */ - vertical-align: middle; -} - -.styled-table tbody tr { - transition: background-color 0.2s ease; -} - -.styled-table tbody tr:hover { - background-color: #f8f9fa; /* Lighter hover effect */ -} - -/* Zebra striping for table body */ -.styled-table tbody tr:nth-child(even) { - background-color: #fcfdff; /* Very light blue for even rows */ -} - -.styled-table tbody tr:nth-child(even):hover { - background-color: #f0f4f8; /* Slightly darker hover for even rows */ -} - -.styled-table tbody tr:last-of-type { - border-bottom: none; /* Remove border from last row */ -} - -/* Error log content truncation */ -.error-log-content { - max-width: 250px; /* Adjust as needed */ - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - cursor: help; /* Indicate it's expandable/clickable */ -} - -/* Style for the 'View Details' button */ -.btn-view-details { - background-color: #e9ecef; /* Light grey background */ - color: #495057; /* Dark grey text */ - border: 1px solid #dee2e6; /* Subtle border */ - padding: 4px 8px; /* Slightly smaller padding */ - border-radius: 4px; - cursor: pointer; - font-size: 12px; - transition: all 0.2s ease; -} - -.btn-view-details:hover { - background-color: #dee2e6; /* Darker grey on hover */ - border-color: #ced4da; - color: #212529; -} - -/* Status Indicators (Loading, No Data, Error) */ -.status-indicator { - text-align: center; - padding: 30px 15px; - margin: 20px 0; - border-radius: 8px; - display: none; /* Hidden by default */ - color: #555; -} - -.status-indicator.loading { - background-color: rgba(240, 240, 240, 0.7); -} - -.status-indicator.no-data { - background-color: rgba(240, 240, 240, 0.7); -} - -.status-indicator.error { - background-color: rgba(231, 76, 60, 0.1); /* Light red background */ - color: #c0392b; /* Darker red text */ - font-weight: bold; -} - -.status-indicator p { - margin-top: 10px; - margin-bottom: 0; - font-size: 1.1em; -} - -/* Basic spinner animation */ -.spinner { - border: 4px solid rgba(0, 0, 0, 0.1); - width: 36px; - height: 36px; - border-radius: 50%; - border-left-color: #667eea; - animation: spin 1s ease infinite; - margin: 0 auto; -} - -@keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } -} - -/* Pagination Styles */ -.pagination-container { - display: flex; - justify-content: center; - padding: 20px 0; - margin-top: 20px; - border-top: 1px solid rgba(0,0,0,0.1); -} - -.pagination { - list-style: none; - padding: 0; - margin: 0; - display: flex; - gap: 5px; -} - -.pagination .page-item { - margin: 0; -} - -.pagination .page-link { - display: block; - padding: 8px 14px; /* Slightly wider */ - color: #6c757d; /* Grey text */ - background-color: white; - border: 1px solid #dee2e6; /* Lighter border */ - border-radius: 4px; - text-decoration: none; - transition: all 0.3s ease; - font-size: 14px; -} - -.pagination .page-link:hover { - background-color: #e9ecef; /* Light grey hover */ - border-color: #dee2e6; -} - -.pagination .page-item.active .page-link { - background: linear-gradient(135deg, #708cf3 0%, #8b60d5 100%); /* Adjusted gradient */ - color: white; - border-color: #708cf3; - font-weight: 600; -} - -.pagination .page-item.disabled .page-link { - color: #aaa; - pointer-events: none; - background-color: #f9f9f9; - border-color: #ddd; -} - -/* Modal Styles (Inherit base from config_editor.css, add specifics) */ -#logDetailModal .modal-content { - max-width: 800px; /* Wider modal for logs */ -} - -#logDetailModal h2 { - font-size: 1.5em; - margin-bottom: 20px; - padding-bottom: 10px; - border-bottom: 1px solid #eee; -} - -.modal-body-content { - max-height: 60vh; /* Limit height and allow scrolling */ - overflow-y: auto; - padding-right: 15px; /* Space for scrollbar */ -} - -.detail-item { - margin-bottom: 15px; -} - -.detail-item h6 { - font-weight: bold; - color: #2c3e50; - margin-bottom: 5px; -} - -.detail-item p, -.detail-item pre { - background-color: #e9ecef; /* Lighter background for code blocks */ - padding: 12px; - border-radius: 5px; - border: 1px solid #dee2e6; /* Match border color */ - font-size: 14px; - color: #333; - margin: 0; - white-space: pre-wrap; /* Ensure pre content wraps */ - word-wrap: break-word; -} - -.detail-item pre { - max-height: 200px; /* Limit height of individual pre blocks */ - overflow-y: auto; -} - -/* Notification Style (Inherited from config_editor.css) */ -#copyStatus { - /* Uses .notification styles from config_editor.css */ - background-color: rgba(39, 174, 96, 0.9); /* Green for success */ -} - -/* Responsive Adjustments */ -@media (max-width: 768px) { - .controls-container { - flex-direction: column; - align-items: stretch; /* Stretch items to full width */ - } - - .page-size-selector { - justify-content: center; /* Center page size selector */ - } - - .action-btn { - width: 100%; /* Full width button */ - justify-content: center; - } - - .styled-table { - font-size: 13px; - } - - .styled-table th, - .styled-table td { - padding: 10px 8px; - } - - .error-log-content { - max-width: 150px; - } - - #logDetailModal .modal-content { - width: 95%; - padding: 20px; - } -} - -@media (max-width: 480px) { - .styled-table { - font-size: 12px; - } - .btn-view-details { - padding: 4px 8px; - font-size: 11px; - } -} diff --git a/app/static/css/keys_status.css b/app/static/css/keys_status.css deleted file mode 100644 index 858cf18..0000000 --- a/app/static/css/keys_status.css +++ /dev/null @@ -1,563 +0,0 @@ -body { - font-family: 'Roboto', sans-serif; - line-height: 1.6; - margin: 0; - padding: 20px; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - min-height: 100vh; -} - -.container { - max-width: 900px; - width: 95%; - background: rgba(255, 255, 255, 0.95); - padding: 40px; - border-radius: 20px; - box-shadow: 0 15px 35px rgba(0,0,0,0.2); - backdrop-filter: blur(10px); - position: relative; - margin: 20px auto; - overflow-y: auto; - max-height: calc(100vh - 40px); - scrollbar-width: none; - -ms-overflow-style: none; -} - -.container::-webkit-scrollbar { - display: none; -} - -h1 { - color: #2c3e50; - text-align: center; - margin-bottom: 15px; - font-weight: 700; - font-size: 32px; - position: relative; - padding-bottom: 15px; -} - -h1::after { - content: ''; - position: absolute; - bottom: 0; - left: 50%; - transform: translateX(-50%); - width: 100px; - height: 4px; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - border-radius: 2px; -} - -/* 导航标签样式 */ -.nav-tabs { - display: flex; - justify-content: center; - margin-bottom: 30px; - border-bottom: 2px solid rgba(0,0,0,0.1); - padding-bottom: 10px; - width: 100%; - max-width: 500px; - margin-left: auto; - margin-right: auto; -} - -.tab-link { - color: #2c3e50; - text-decoration: none; - padding: 12px 25px; - border-radius: 8px 8px 0 0; - font-weight: bold; - transition: all 0.3s ease; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - position: relative; - margin: 0 5px; - flex: 1; - text-align: center; -} - -.tab-link:hover { - background: rgba(102, 126, 234, 0.1); -} - -.tab-link.active { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - box-shadow: 0 5px 15px rgba(118, 75, 162, 0.3); -} - -.tab-link.active::after { - content: ''; - position: absolute; - bottom: -12px; - left: 0; - width: 100%; - height: 4px; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - border-radius: 2px; -} - -.tab-link i { - font-size: 16px; -} - -.key-list { - margin-bottom: 30px; - background: rgba(248, 249, 250, 0.9); - padding: 25px; - border-radius: 15px; - transition: all 0.3s ease; - border: 1px solid rgba(0,0,0,0.1); - animation: fadeIn 0.5s ease forwards; -} - -.key-list:hover { - transform: translateY(-5px); - box-shadow: 0 10px 20px rgba(0,0,0,0.1); -} - -.key-list:nth-child(2) { - animation-delay: 0.2s; -} - -.key-list h2 { - color: #2c3e50; - margin-bottom: 20px; - display: flex; - justify-content: space-between; - align-items: center; - font-size: 1.5em; - padding-bottom: 10px; - border-bottom: 2px solid rgba(0,0,0,0.1); - cursor: pointer; -} - -.key-list h2 .toggle-icon { - margin-right: 10px; - transition: transform 0.3s ease; -} - -.key-list h2 .toggle-icon.collapsed { - transform: rotate(-90deg); -} - -.key-list .key-content { - transition: all 0.3s ease-out; - overflow: hidden; - height: auto; - opacity: 1; -} - -.key-list .key-content.collapsed { - height: 0; - opacity: 0; - padding-top: 0; - padding-bottom: 0; -} - -ul { - list-style-type: none; - padding: 0; - margin: 0; -} - -li { - background: white; - border: 1px solid rgba(0,0,0,0.1); - margin-bottom: 12px; - padding: 15px; - border-radius: 10px; - transition: all 0.3s ease; - display: flex; - justify-content: space-between; - align-items: center; - box-shadow: 0 2px 5px rgba(0,0,0,0.05); -} - -li:hover { - transform: translateX(5px); - box-shadow: 0 5px 15px rgba(0,0,0,0.1); -} - -.key-info { - display: flex; - align-items: center; - gap: 15px; - flex: 1; /* Allow key-info to take up available space */ - min-width: 0; /* Prevent flex item from overflowing */ -} - -.key-text { - font-family: 'Roboto Mono', monospace; - color: #2c3e50; - word-break: break-all; /* Ensure long keys wrap */ - flex-shrink: 1; /* Allow key text to shrink if needed */ - margin-right: 10px; /* Add space between key text and toggle button */ -} - -.fail-count { - background: rgba(231, 76, 60, 0.1); - color: #e74c3c; - padding: 4px 10px; - border-radius: 15px; - font-size: 0.85em; - display: flex; - align-items: center; - gap: 5px; -} - -.fail-count i { - font-size: 12px; -} - -.key-actions { - display: flex; - gap: 10px; - align-items: center; -} - -.verify-btn, .copy-btn { - color: white; - border: none; - padding: 8px 15px; - border-radius: 30px; - cursor: pointer; - font-size: 14px; - font-weight: bold; - transition: all 0.3s ease; - display: flex; - align-items: center; - justify-content: center; - gap: 5px; - box-shadow: 0 4px 10px rgba(0,0,0,0.1); -} - -.verify-btn { - background: linear-gradient(135deg, #2ecc71, #27ae60); -} - -.verify-btn:hover { - transform: translateY(-2px); - box-shadow: 0 5px 15px rgba(46, 204, 113, 0.3); -} - -.verify-btn:disabled { - opacity: 0.7; - cursor: not-allowed; - transform: none; - box-shadow: none; -} - -.verify-btn i { - font-size: 14px; -} - -.copy-btn { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); -} - -.copy-btn:hover { - transform: translateY(-2px); - box-shadow: 0 5px 15px rgba(118, 75, 162, 0.3); - background: linear-gradient(135deg, #764ba2 0%, #667eea 100%); -} - -.copy-btn:active { - transform: translateY(1px); - box-shadow: 0 5px 10px rgba(118, 75, 162, 0.2); -} - -.copy-btn i { - font-size: 14px; -} - -.total { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - padding: 15px 25px; - border-radius: 10px; - font-weight: bold; - text-align: center; - font-size: 1.2em; - margin-top: 30px; - box-shadow: 0 5px 15px rgba(0,0,0,0.1); -} - -#copyStatus { - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - padding: 15px 30px; - border-radius: 25px; - font-weight: bold; - opacity: 0; - transition: all 0.3s ease; - backdrop-filter: blur(5px); - box-shadow: 0 5px 15px rgba(0,0,0,0.2); - z-index: 1000; - text-align: center; - min-width: 200px; - color: white; -} - -#copyStatus.success { - background: rgba(39, 174, 96, 0.95); -} - -#copyStatus.error { - background: rgba(231, 76, 60, 0.95); -} - -.status-badge { - padding: 4px 12px; - border-radius: 15px; - font-size: 0.9em; - font-weight: bold; - margin-right: 10px; -} - -.status-valid { - background: rgba(39, 174, 96, 0.1); - color: #27ae60; -} - -.status-invalid { - background: rgba(231, 76, 60, 0.1); - color: #e74c3c; -} - -.scroll-buttons { - position: fixed; - right: 20px; - bottom: 20px; - display: none; - flex-direction: column; - gap: 10px; - z-index: 1000; -} - -.scroll-btn { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - width: 40px; - height: 40px; - border: none; - border-radius: 50%; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - font-size: 20px; - transition: all 0.3s ease; - backdrop-filter: blur(5px); - box-shadow: 0 2px 10px rgba(0,0,0,0.2); -} - -.scroll-btn:hover { - background: linear-gradient(135deg, #764ba2 0%, #667eea 100%); - transform: scale(1.1); -} - -.scroll-btn:active { - transform: scale(0.95); -} - -.refresh-btn { - position: fixed; - top: 20px; - right: 20px; - z-index: 1000; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: #fff; - border: none; - padding: 10px 20px; - border-radius: 25px; - cursor: pointer; - font-size: 14px; - font-weight: bold; - transition: all 0.3s ease; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); -} - -.refresh-btn:hover { - transform: scale(1.05); - box-shadow: 0 8px 20px rgba(118, 75, 162, 0.3); - background: linear-gradient(135deg, #764ba2 0%, #667eea 100%); -} - -.refresh-btn:active { - transform: scale(0.95); -} - -.refresh-btn i { - transition: transform 0.5s ease; -} - -.refresh-btn.loading i { - animation: spin 1s linear infinite; -} - -.copyright { - position: fixed; - bottom: 0; - left: 0; - width: 100%; - background: rgba(255, 255, 255, 0.9); - padding: 10px 0; - text-align: center; - font-size: 14px; - color: #2c3e50; - backdrop-filter: blur(5px); - border-top: 1px solid rgba(0,0,0,0.1); -} - -.copyright a { - color: #764ba2; - text-decoration: none; - transition: color 0.3s ease; -} - -.copyright a:hover { - color: #667eea; -} - -.copyright img { - width: 20px; - height: 20px; - border-radius: 50%; - vertical-align: middle; - margin-right: 5px; -} - -@keyframes fadeIn { - from { opacity: 0; transform: translateY(20px); } - to { opacity: 1; transform: translateY(0); } -} - -@keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} - -/* Toggle Visibility Button Styles */ -.toggle-vis-btn { - background: none; - border: none; - color: #7f8c8d; /* Subtle color */ - cursor: pointer; - padding: 5px; - font-size: 16px; - transition: color 0.3s ease; - margin-left: 5px; /* Space from key text */ - flex-shrink: 0; /* Prevent button from shrinking */ -} - -.toggle-vis-btn:hover { - color: #34495e; /* Darker color on hover */ -} - -.toggle-vis-btn i { - vertical-align: middle; -} - -@media (max-width: 768px) { - .container { - width: 100%; - padding: 20px; - margin: 10px auto; - } - body { - padding: 10px; - } - h1 { - font-size: 24px; - } - .nav-tabs { - flex-direction: column; - align-items: center; - gap: 10px; - } - .tab-link { - width: 100%; - justify-content: center; - border-radius: 8px; - } - .tab-link.active::after { - display: none; - } - .key-list h2 { - font-size: 1.2em; - flex-direction: column; - gap: 10px; - align-items: flex-start; - } - .key-info { - flex-direction: column; - align-items: flex-start; - gap: 8px; - width: 100%; /* Ensure key-info takes full width */ - } - li { - flex-direction: column; - gap: 10px; - } - .key-actions { - width: 100%; - flex-direction: column; - } - - .verify-btn, .copy-btn { - width: 100%; - justify-content: center; - } - .key-text { - /* word-break: break-all; */ /* Already applied above */ - margin-right: 0; /* Remove right margin on smaller screens */ - } - .scroll-buttons { - right: 10px; - bottom: 10px; - } - .scroll-btn { - width: 35px; - height: 35px; - font-size: 16px; - } - .refresh-btn { - top: 10px; - right: 10px; - padding: 8px 16px; - font-size: 12px; - } -} - -@media (max-width: 480px) { - .container { - padding: 15px; - } - h1 { - font-size: 20px; - } - .key-list { - padding: 15px; - } - .status-badge { - padding: 3px 8px; - font-size: 0.8em; - } - .fail-count { - font-size: 0.8em; - } - .total { - font-size: 1em; - padding: 12px 20px; - } -} diff --git a/app/static/icons/logo.png b/app/static/icons/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..3c2831b2c5e0470baf8f32c2b9dfa0f35d1cc291 GIT binary patch literal 39548 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 literal 0 HcmV?d00001 diff --git a/app/static/icons/logo1.png b/app/static/icons/logo1.png new file mode 100644 index 0000000000000000000000000000000000000000..685e96261e4c35217b95ad364f7eec1c291bf4ea GIT binary patch literal 18105 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 E0}%}GaR2}S literal 0 HcmV?d00001 diff --git a/app/static/js/config_editor.js b/app/static/js/config_editor.js index 850db49..0513062 100644 --- a/app/static/js/config_editor.js +++ b/app/static/js/config_editor.js @@ -310,9 +310,13 @@ function switchTab(tabId) { const tabButtons = document.querySelectorAll('.tab-btn'); tabButtons.forEach(button => { if (button.getAttribute('data-tab') === tabId) { - button.classList.add('active'); + // 激活状态:主色背景,白色文字,添加阴影 + button.classList.remove('bg-white', 'bg-opacity-50', 'text-gray-700', 'hover:bg-opacity-70'); + button.classList.add('bg-primary-600', 'text-white', 'shadow-md'); } else { - button.classList.remove('active'); + // 非激活状态:白色背景,灰色文字,无阴影 + button.classList.remove('bg-primary-600', 'text-white', 'shadow-md'); + button.classList.add('bg-white', 'bg-opacity-50', 'text-gray-700', 'hover:bg-opacity-70'); } }); @@ -354,18 +358,19 @@ function addArrayItemWithValue(key, value) { if (!container) return; const arrayItem = document.createElement('div'); - arrayItem.className = 'array-item'; + arrayItem.className = 'array-item flex justify-between items-center mb-2'; // 使用 Flexbox 布局,垂直居中,底部增加间距 const input = document.createElement('input'); input.type = 'text'; input.name = `${key}[]`; input.value = value; - input.className = 'array-input'; + input.className = 'array-input flex-grow px-3 py-2 rounded-md border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 mr-2'; // 输入框占据大部分空间,添加样式和右边距 const removeBtn = document.createElement('button'); removeBtn.type = 'button'; - removeBtn.className = 'remove-btn'; - removeBtn.innerHTML = ''; + removeBtn.className = 'remove-btn text-gray-400 hover:text-red-500 focus:outline-none transition-colors duration-150 ml-2'; // 新的 Tailwind 样式 + removeBtn.innerHTML = ''; // 改用垃圾桶图标 + removeBtn.title = '删除'; // 添加悬停提示 removeBtn.addEventListener('click', function() { arrayItem.remove(); }); @@ -411,12 +416,43 @@ function collectFormData() { return formData; } +// 辅助函数:停止定时任务 +async function stopScheduler() { + try { + const response = await fetch('/api/scheduler/stop', { method: 'POST' }); + if (!response.ok) { + console.warn(`停止定时任务失败: ${response.status}`); + } else { + console.log('定时任务已停止'); + } + } catch (error) { + console.error('调用停止定时任务API时出错:', error); + } +} + +// 辅助函数:启动定时任务 +async function startScheduler() { + try { + const response = await fetch('/api/scheduler/start', { method: 'POST' }); + if (!response.ok) { + console.warn(`启动定时任务失败: ${response.status}`); + } else { + console.log('定时任务已启动'); + } + } catch (error) { + console.error('调用启动定时任务API时出错:', error); + } +} + // 保存配置 async function saveConfig() { try { const formData = collectFormData(); - + showNotification('正在保存配置...', 'info'); + + // 1. 停止定时任务 + await stopScheduler(); const response = await fetch('/api/config', { method: 'PUT', @@ -435,24 +471,37 @@ async function saveConfig() { // 显示保存状态 const saveStatus = document.getElementById('saveStatus'); - saveStatus.classList.add('show'); + saveStatus.style.opacity = "1"; + saveStatus.style.transform = "translate(-50%, -50%) scale(1.1)"; setTimeout(() => { - saveStatus.classList.remove('show'); + saveStatus.style.opacity = "0"; + saveStatus.style.transform = "translate(-50%, -50%) scale(0.95)"; }, 3000); showNotification('配置保存成功', 'success'); + + // 3. 启动新的定时任务 + await startScheduler(); + } catch (error) { console.error('保存配置失败:', error); - + // 保存失败时,也尝试重启定时任务,以防万一 + await startScheduler(); // 显示错误状态 const saveStatus = document.getElementById('saveStatus'); - saveStatus.classList.add('show', 'error'); + saveStatus.style.backgroundColor = "#ef4444"; // 红色背景 + saveStatus.style.opacity = "1"; + saveStatus.style.transform = "translate(-50%, -50%) scale(1.1)"; saveStatus.querySelector('.status-icon i').className = 'fas fa-times-circle'; saveStatus.querySelector('.status-text').textContent = '配置保存失败'; setTimeout(() => { - saveStatus.classList.remove('show', 'error'); + saveStatus.style.opacity = "0"; + saveStatus.style.transform = "translate(-50%, -50%) scale(0.95)"; + setTimeout(() => { + saveStatus.style.backgroundColor = "#22c55e"; // 恢复绿色背景 + }, 300); }, 3000); showNotification('保存配置失败: ' + error.message, 'error'); @@ -491,6 +540,9 @@ function resetConfig(event) { async function executeReset() { try { showNotification('正在重置配置...', 'info'); + + // 1. 停止定时任务 + await stopScheduler(); const response = await fetch('/api/config/reset', { method: 'POST' }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); @@ -498,24 +550,48 @@ async function executeReset() { const config = await response.json(); populateForm(config); showNotification('配置已重置为默认值', 'success'); + + // 3. 启动新的定时任务 + await startScheduler(); + } catch (error) { console.error('重置配置失败:', error); showNotification('重置配置失败: ' + error.message, 'error'); + // 重置失败时,也尝试重启定时任务 + await startScheduler(); } } - // 显示通知 function showNotification(message, type = 'info') { const notification = document.getElementById('notification'); notification.textContent = message; - notification.className = 'notification show'; - if (type) { - notification.classList.add(type); + // 设置适当的样式 + if (type === 'error') { + notification.classList.add('bg-danger-500'); + notification.classList.remove('bg-black'); + } else { + notification.classList.remove('bg-danger-500'); + notification.classList.add('bg-black'); + + // 可以为不同类型设置不同的颜色 + if (type === 'success') { + notification.style.backgroundColor = '#22c55e'; // 绿色 + } else if (type === 'info') { + notification.style.backgroundColor = '#3b82f6'; // 蓝色 + } else if (type === 'warning') { + notification.style.backgroundColor = '#f59e0b'; // 橙色 + } } + // 应用过渡效果 - 与keys_status.js中一致 + notification.style.opacity = "1"; + notification.style.transform = "translate(-50%, 0)"; + + // 设置自动消失 setTimeout(() => { - notification.classList.remove('show'); + notification.style.opacity = "0"; + notification.style.transform = "translate(-50%, 10px)"; }, 3000); } @@ -527,8 +603,7 @@ function refreshPage(button) { // 滚动到顶部 function scrollToTop() { - const container = document.querySelector('.container'); - container.scrollTo({ + window.scrollTo({ top: 0, behavior: 'smooth' }); @@ -536,9 +611,8 @@ function scrollToTop() { // 滚动到底部 function scrollToBottom() { - const container = document.querySelector('.container'); - container.scrollTo({ - top: container.scrollHeight, + window.scrollTo({ + top: document.body.scrollHeight, behavior: 'smooth' }); } diff --git a/app/static/js/error_logs.js b/app/static/js/error_logs.js index bea68a6..98a4894 100644 --- a/app/static/js/error_logs.js +++ b/app/static/js/error_logs.js @@ -9,32 +9,12 @@ function scrollToBottom() { window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); } -// 刷新页面功能 -function refreshPage(button) { - if (button) { - // Use 'loading' class consistent with config_editor.css animation - button.classList.add('loading'); - // Disable button while refreshing - button.disabled = true; - } - - // Fetch new data instead of full reload for a smoother experience - loadErrorLogs().finally(() => { - if (button) { - // Remove loading class and re-enable button after fetch completes - button.classList.remove('loading'); - button.disabled = false; - } - }); - // Optional: Keep reload as fallback or if preferred - // setTimeout(() => { - // window.location.reload(); - // }, 500); -} +// Refresh function removed as the buttons are gone. +// If refresh functionality is needed elsewhere, it can be triggered directly by calling loadErrorLogs(). // 全局变量 let currentPage = 1; -let pageSize = 20; +let pageSize = 10; // let totalPages = 1; // totalPages will be calculated dynamically based on API response if available, or based on fetched data length let errorLogs = []; // Store fetched logs for details view let currentSearch = { // Store current search parameters @@ -46,7 +26,7 @@ let currentSearch = { // Store current search parameters // DOM Elements Cache let pageSizeSelector; -let refreshBtn; +// let refreshBtn; // Removed, as the button is deleted let tableBody; let paginationElement; let loadingIndicator; @@ -59,12 +39,14 @@ let errorSearchInput; let startDateInput; let endDateInput; let searchBtn; +let pageInput; // 新增:页码输入框 +let goToPageBtn; // 新增:跳转按钮 // 页面加载完成后执行 document.addEventListener('DOMContentLoaded', function() { // Cache DOM elements pageSizeSelector = document.getElementById('pageSize'); - refreshBtn = document.getElementById('refreshBtn'); + // refreshBtn = document.getElementById('refreshBtn'); // Removed tableBody = document.getElementById('errorLogsTable'); paginationElement = document.getElementById('pagination'); loadingIndicator = document.getElementById('loadingIndicator'); @@ -78,6 +60,8 @@ document.addEventListener('DOMContentLoaded', function() { startDateInput = document.getElementById('startDate'); endDateInput = document.getElementById('endDate'); searchBtn = document.getElementById('searchBtn'); + pageInput = document.getElementById('pageInput'); // 新增 + goToPageBtn = document.getElementById('goToPageBtn'); // 新增 // Initialize page size selector if (pageSizeSelector) { @@ -89,18 +73,7 @@ document.addEventListener('DOMContentLoaded', function() { }); } - // Initialize refresh button (using the one inside the controls container) - if (refreshBtn) { - refreshBtn.addEventListener('click', function() { - // Add loading state to the button itself - this.classList.add('loading'); - this.disabled = true; - loadErrorLogs().finally(() => { - this.classList.remove('loading'); - this.disabled = false; - }); - }); - } + // Refresh button event listener removed // Initialize search button if (searchBtn) { @@ -130,8 +103,113 @@ document.addEventListener('DOMContentLoaded', function() { // Initial load of error logs loadErrorLogs(); + + // Add event listeners for copy buttons inside the modal + setupCopyButtons(); + + // 新增:为页码跳转按钮添加事件监听器 + if (goToPageBtn && pageInput) { + goToPageBtn.addEventListener('click', function() { + const targetPage = parseInt(pageInput.value); + // 需要获取总页数来验证输入 + // 暂时无法直接获取 totalPages,需要在 updatePagination 中存储或重新计算 + // 简单的验证:必须是正整数 + if (!isNaN(targetPage) && targetPage >= 1) { + // 理想情况下,应检查 targetPage <= totalPages + // 但 totalPages 可能未知,所以暂时只跳转 + currentPage = targetPage; + loadErrorLogs(); + pageInput.value = ''; // 清空输入框 + } else { + showNotification('请输入有效的页码', 'error', 2000); + pageInput.value = ''; // 清空无效输入 + } + }); + // 允许按 Enter 键跳转 + pageInput.addEventListener('keypress', function(event) { + if (event.key === 'Enter') { + goToPageBtn.click(); // 触发按钮点击 + } + }); + } }); +// Fallback copy function using document.execCommand +function fallbackCopyTextToClipboard(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"; + + 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; + } + + 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 +} + +// Function to set up copy button listeners (using modern API with fallback) +function setupCopyButtons() { + const copyButtons = document.querySelectorAll('.copy-btn'); + copyButtons.forEach(button => { + button.addEventListener('click', function() { + const targetId = this.getAttribute('data-target'); + const targetElement = document.getElementById(targetId); + + if (targetElement) { + const textToCopy = targetElement.textContent; + let copySuccess = false; + + // Try modern clipboard API first (requires HTTPS or localhost) + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(textToCopy).then(() => { + handleCopyResult(this, true); // Use helper for feedback + }).catch(err => { + console.error('Clipboard API failed, attempting fallback:', err); + // Attempt fallback if modern API fails + copySuccess = fallbackCopyTextToClipboard(textToCopy); + handleCopyResult(this, copySuccess); // Use helper for feedback + }); + } else { + // Use fallback if modern API is not available or context is insecure + console.warn("Clipboard API not available or context insecure. Using fallback copy method."); + copySuccess = fallbackCopyTextToClipboard(textToCopy); + handleCopyResult(this, copySuccess); // Use helper for feedback + } + } else { + console.error('Target element not found:', targetId); + showNotification('复制出错:找不到目标元素', 'error'); + } + }); + }); +} + // 加载错误日志数据 async function loadErrorLogs() { showLoading(true); @@ -389,10 +467,18 @@ function updatePagination(currentItemCount, totalItems) { // Helper function to add pagination links function addPaginationLink(parentElement, text, enabled, clickHandler, isActive = false) { const pageItem = document.createElement('li'); - pageItem.className = `page-item ${!enabled ? 'disabled' : ''} ${isActive ? 'active' : ''}`; + // 移除 'page-item' 和 'active' 类,使用 Tailwind 类进行样式化 + // pageItem.className = `page-item ${!enabled ? 'disabled' : ''} ${isActive ? 'active' : ''}`; const pageLink = document.createElement('a'); - pageLink.className = 'page-link'; + // 使用 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; @@ -402,12 +488,14 @@ function addPaginationLink(parentElement, text, enabled, clickHandler, isActive clickHandler(); }); } else if (!enabled) { - pageLink.addEventListener('click', e => e.preventDefault()); // Prevent click on disabled + 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 } - - pageItem.appendChild(pageLink); - parentElement.appendChild(pageItem); + // 不再需要 li 元素,直接将 a 元素添加到父元素 + // pageItem.appendChild(pageLink); + parentElement.appendChild(pageLink); } diff --git a/app/static/js/keys_status.js b/app/static/js/keys_status.js index 413bd02..412d5ac 100644 --- a/app/static/js/keys_status.js +++ b/app/static/js/keys_status.js @@ -27,15 +27,21 @@ function copyToClipboard(text) { function copyKeys(type) { const keys = Array.from(document.querySelectorAll(`#${type}Keys .key-text`)).map(span => span.dataset.fullKey); - const jsonKeys = JSON.stringify(keys); - copyToClipboard(jsonKeys) + if (keys.length === 0) { + showCopyStatus('没有可复制的密钥', true); + return; + } + + const keysText = keys.join('\n'); + + copyToClipboard(keysText) .then(() => { - showCopyStatus(`已成功复制${type === 'valid' ? '有效' : '无效'}密钥到剪贴板`); + showCopyStatus(`已成功复制${keys.length}个${type === 'valid' ? '有效' : '无效'}密钥到剪贴板`); }) .catch((err) => { console.error('无法复制文本: ', err); - showCopyStatus('复制失败,请重试'); + showCopyStatus('复制失败,请重试', true); }); } @@ -46,21 +52,32 @@ function copyKey(key) { }) .catch((err) => { console.error('无法复制文本: ', err); - showCopyStatus('复制失败,请重试'); + showCopyStatus('复制失败,请重试', true); }); } -function showCopyStatus(message, type = 'success') { +function showCopyStatus(message, isError = false) { const statusElement = document.getElementById('copyStatus'); statusElement.textContent = message; - statusElement.className = type; // 设置样式类 - statusElement.style.opacity = 1; + + // 添加适当的样式类 + if (isError) { + statusElement.classList.add('bg-danger-500'); + statusElement.classList.remove('bg-black'); + } else { + statusElement.classList.remove('bg-danger-500'); + statusElement.classList.add('bg-black'); + } + + // 应用过渡效果 + statusElement.style.opacity = "1"; + statusElement.style.transform = "translate(-50%, 0)"; + + // 设置自动消失 setTimeout(() => { - statusElement.style.opacity = 0; - setTimeout(() => { - statusElement.className = ''; // 清除样式类 - }, 300); - }, 2000); + statusElement.style.opacity = "0"; + statusElement.style.transform = "translate(-50%, 10px)"; + }, 3000); } async function verifyKey(key, button) { @@ -70,59 +87,223 @@ async function verifyKey(key, button) { const originalHtml = button.innerHTML; button.innerHTML = ' 验证中'; - const response = await fetch(`/gemini/v1beta/verify-key/${key}`, { + try { + const response = await fetch(`/gemini/v1beta/verify-key/${key}`, { + method: 'POST' + }); + const data = await response.json(); + + // 根据验证结果更新UI并显示模态提示框 + if (data.success || data.status === 'valid') { + // 验证成功,显示成功结果 + button.style.backgroundColor = '#27ae60'; + // 使用结果模态框显示成功消息 + showResultModal(true, '密钥验证成功'); + // 模态框关闭时会自动刷新页面 + } else { + // 验证失败,显示失败结果 + const errorMsg = data.error || '密钥无效'; + button.style.backgroundColor = '#e74c3c'; + // 使用结果模态框显示失败消息,但不自动刷新页面 + showResultModal(false, '密钥验证失败: ' + errorMsg, true); // 改为true以在关闭时刷新 + } + } catch (fetchError) { + console.error('API请求失败:', fetchError); + showResultModal(false, '验证请求失败: ' + fetchError.message, true); // 改为true以在关闭时刷新 + } finally { + // 1秒后恢复按钮原始状态 + setTimeout(() => { + button.innerHTML = originalHtml; + button.disabled = false; + button.style.backgroundColor = ''; + }, 1000); + } + } catch (error) { + console.error('验证失败:', error); + button.disabled = false; + button.innerHTML = ' 验证'; + showResultModal(false, '验证处理失败: ' + error.message, true); // 改为true以在关闭时刷新 + } +} + +async function resetKeyFailCount(key, button) { + try { + // 禁用按钮并显示加载状态 + button.disabled = true; + const originalHtml = button.innerHTML; + button.innerHTML = ' 重置中'; + + const response = await fetch(`/gemini/v1beta/reset-fail-count/${key}`, { method: 'POST' }); const data = await response.json(); - // 根据验证结果更新UI - if (data.status === 'valid') { - showCopyStatus('密钥验证成功', 'success'); + // 根据重置结果更新UI + if (data.success) { + showCopyStatus('失败计数重置成功'); button.style.backgroundColor = '#27ae60'; + setTimeout(() => location.reload(), 1500); } else { - showCopyStatus('密钥验证失败', 'error'); + const errorMsg = data.message || '重置失败'; + showCopyStatus('重置失败: ' + errorMsg, true); button.style.backgroundColor = '#e74c3c'; } - // 3秒后恢复按钮原始状态 + // 1秒后恢复按钮原始状态 setTimeout(() => { button.innerHTML = originalHtml; button.disabled = false; button.style.backgroundColor = ''; - }, 3000); + }, 1000); } catch (error) { - console.error('验证失败:', error); - showCopyStatus('验证请求失败', 'error'); + console.error('重置失败:', error); + showCopyStatus('重置请求失败: ' + error.message, true); button.disabled = false; - button.innerHTML = ' 验证'; + button.innerHTML = ' 重置'; + } +} + +function showResetModal(type) { + const modalElement = document.getElementById('resetModal'); + const titleElement = document.getElementById('resetModalTitle'); + const messageElement = document.getElementById('resetModalMessage'); + const confirmButton = document.getElementById('confirmResetBtn'); + + // 设置标题和消息 + titleElement.textContent = '批量重置失败次数'; + messageElement.textContent = `确定要批量重置${type === 'valid' ? '有效' : '无效'}密钥的失败次数吗?`; + + // 设置确认按钮事件 + confirmButton.onclick = () => executeResetAll(type); + + // 显示模态框 + modalElement.classList.remove('hidden'); +} + +function closeResetModal() { + document.getElementById('resetModal').classList.add('hidden'); +} + +// 触发显示模态框 +function resetAllKeysFailCount(type, event) { + // 阻止事件冒泡 + if (event) { + event.stopPropagation(); + } + + // 显示模态确认框 + showResetModal(type); +} + +// 执行批量重置 +// 关闭模态框并根据参数决定是否刷新页面 +function closeResultModal(reload = true) { + document.getElementById('resultModal').classList.add('hidden'); + if (reload) { + location.reload(); // 操作完成后刷新页面 + } +} + +// 显示操作结果模态框 +function showResultModal(success, message, autoReload = true) { + const modalElement = document.getElementById('resultModal'); + const titleElement = document.getElementById('resultModalTitle'); + const messageElement = document.getElementById('resultModalMessage'); + const iconElement = document.getElementById('resultIcon'); + const confirmButton = document.getElementById('resultModalConfirmBtn'); + + // 设置标题 + titleElement.textContent = success ? '操作成功' : '操作失败'; + + // 设置图标 + if (success) { + iconElement.innerHTML = ''; + iconElement.className = 'text-5xl mb-3 text-success-500'; + } else { + iconElement.innerHTML = ''; + iconElement.className = 'text-5xl mb-3 text-danger-500'; + } + + // 设置消息 + messageElement.textContent = message; + + // 设置确认按钮点击事件 + confirmButton.onclick = () => closeResultModal(autoReload); + + // 显示模态框 + modalElement.classList.remove('hidden'); +} + +async function executeResetAll(type) { + try { + // 关闭确认模态框 + closeResetModal(); + + // 使用data-reset-type属性直接找到对应的重置按钮 + const resetButton = document.querySelector(`button[data-reset-type="${type}"]`); + + if (!resetButton) { + // 如果找不到按钮,显示错误并返回 + showResultModal(false, `找不到${type === 'valid' ? '有效' : '无效'}密钥区域的批量重置按钮`); + return; + } + + // 禁用按钮并显示加载状态 + resetButton.disabled = true; + const originalHtml = resetButton.innerHTML; + resetButton.innerHTML = ' 重置中'; + + try { + // 调用API,传递类型参数 + const response = await fetch(`/gemini/v1beta/reset-all-fail-counts?key_type=${type}`, { + method: 'POST' + }); + + if (!response.ok) { + throw new Error(`服务器返回错误: ${response.status}`); + } + + const data = await response.json(); + + // 根据重置结果显示模态框 + if (data.success) { + const message = data.reset_count ? + `成功重置${data.reset_count}个${type === 'valid' ? '有效' : '无效'}密钥的失败次数` : + '所有失败次数重置成功'; + showResultModal(true, message); + } else { + const errorMsg = data.message || '批量重置失败'; + showResultModal(false, '批量重置失败: ' + errorMsg); + } + } catch (fetchError) { + console.error('API请求失败:', fetchError); + showResultModal(false, '批量重置请求失败: ' + fetchError.message); + } finally { + // 恢复按钮原始状态 + setTimeout(() => { + resetButton.innerHTML = originalHtml; + resetButton.disabled = false; + }, 500); + } + } catch (error) { + console.error('批量重置失败:', error); + showResultModal(false, '批量重置处理失败: ' + error.message); } } function scrollToTop() { - const container = document.querySelector('.container'); - container.scrollTo({ - top: 0, - behavior: 'smooth' - }); + window.scrollTo({ top: 0, behavior: 'smooth' }); } function scrollToBottom() { - const container = document.querySelector('.container'); - container.scrollTo({ - top: container.scrollHeight, - behavior: 'smooth' - }); + window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); } +// 移除这个函数,因为它可能正在干扰按钮的显示 +// HTML中已经设置了滚动按钮为flex显示,不需要JavaScript额外控制 function updateScrollButtons() { - const container = document.querySelector('.container'); - const scrollButtons = document.querySelector('.scroll-buttons'); - if (container.scrollHeight > container.clientHeight) { - scrollButtons.style.display = 'flex'; - } else { - scrollButtons.style.display = 'none'; - } + // 不执行任何操作 } function refreshPage(button) { @@ -142,22 +323,50 @@ function toggleSection(header, sectionId) { content.classList.toggle('collapsed'); } +// 筛选有效密钥(根据失败次数阈值) +function filterValidKeys() { + const thresholdInput = document.getElementById('failCountThreshold'); + const validKeyItems = document.querySelectorAll('#validKeys li'); + // 读取阈值,如果输入无效或为空,则默认为0(不过滤) + const threshold = parseInt(thresholdInput.value, 10); + const filterThreshold = isNaN(threshold) || threshold < 0 ? 0 : threshold; + + validKeyItems.forEach(item => { + const failCount = parseInt(item.dataset.failCount, 10); + // 如果失败次数大于等于阈值,则显示,否则隐藏 + if (failCount >= filterThreshold) { + item.style.display = ''; // 显示 + } else { + item.style.display = 'none'; // 隐藏 + } + }); +} + // 初始化 document.addEventListener('DOMContentLoaded', () => { - // 检查滚动按钮 - updateScrollButtons(); - + // 移除对滚动按钮显示的控制,让它们由HTML/CSS控制 + // 监听展开/折叠事件 document.querySelectorAll('.key-list h2').forEach(header => { header.addEventListener('click', () => { - setTimeout(updateScrollButtons, 300); + // 不再调用updateScrollButtons }); }); // 更新版权年份 - const copyrightYear = document.querySelector('.copyright script'); - if (copyrightYear) { - copyrightYear.textContent = new Date().getFullYear(); + const copyrightYearElement = document.querySelector('.copyright script'); + if (copyrightYearElement && copyrightYearElement.parentNode.classList.contains('copyright')) { + // 确保只更新版权部分的年份 + copyrightYearElement.textContent = new Date().getFullYear(); + } + + // 添加筛选输入框事件监听 + const thresholdInput = document.getElementById('failCountThreshold'); + if (thresholdInput) { + // 使用 'input' 事件实时响应输入变化 + thresholdInput.addEventListener('input', filterValidKeys); + // 初始加载时应用一次筛选(基于默认值1) + filterValidKeys(); } }); @@ -174,8 +383,8 @@ if ('serviceWorker' in navigator) { }); } function toggleKeyVisibility(button) { - const keyInfoDiv = button.closest('.key-info'); - const keyTextSpan = keyInfoDiv.querySelector('.key-text'); + const keyContainer = button.closest('.flex.items-center.gap-1'); + const keyTextSpan = keyContainer.querySelector('.key-text'); const eyeIcon = button.querySelector('i'); const fullKey = keyTextSpan.dataset.fullKey; const maskedKey = fullKey.substring(0, 4) + '...' + fullKey.substring(fullKey.length - 4); diff --git a/app/templates/auth.html b/app/templates/auth.html index 0945654..9ebdcff 100644 --- a/app/templates/auth.html +++ b/app/templates/auth.html @@ -1,42 +1,124 @@ - - - - - - 验证页面 - - - - - - - - - - - -
- -

安全验证

-
-
- - +{% extends "base.html" %} + +{% block title %}验证页面 - Gemini Balance{% endblock %} + +{% block head_extra_styles %} + +{% endblock %} + +{% block content %} +
+
+
+
+
-
+ +

+ Gemini Balance Logo + Gemini Balance +

+ + +
+ + +
+ + + {% if error %} -

{{ error }}

+

+ {{ error }} +

{% endif %}
- - - - + +
+ +
+ +{% endblock %} + +{% block body_scripts %} + +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..715693b --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,265 @@ + + + + + + {% block title %}Gemini Balance{% endblock %} + + + + + + + + + + + + {% block head_extra_scripts %}{% endblock %} + + + + {% block content %}{% endblock %} + + + + + + + {% block body_scripts %}{% endblock %} + + \ No newline at end of file diff --git a/app/templates/config_editor.html b/app/templates/config_editor.html index 21339ef..3c47c6b 100644 --- a/app/templates/config_editor.html +++ b/app/templates/config_editor.html @@ -1,336 +1,455 @@ - - - - - - 配置编辑器 - - - - - - - - - - - -
- -

Gemini Balance

- - -
- - - - -
- -
- - 配置已保存 -
- -
- -
-

API相关配置

+{% extends "base.html" %} + +{% block title %}配置编辑器 - Gemini Balance{% endblock %} + +{% block head_extra_styles %} + +{% endblock %} + +{% block content %} +
+
+ + +

+ Gemini Balance Logo + Gemini Balance +

+ + + + + +
+ + + + + +
+ + +
+ + 配置已保存 +
+ + + + +
+

+ API相关配置 +

+ + +
+ +
+ +
+
+ +
+
+ +
+ Gemini API密钥列表,每行一个 +
+ + +
+ +
+ +
+
+ +
+ 允许访问API的令牌列表 +
+ + +
+ + + 用于API认证的令牌 +
+ + +
+ + + Gemini API的基础URL +
+ + +
+ + + API密钥失败后标记为无效的次数 +
+ + +
+ + + API请求的超时时间 +
+ + +
+ + + API请求失败后的最大重试次数 +
+
-
- -
- + +
+

+ 模型相关配置 +

+ + +
+ + + 用于测试API密钥的模型
-
- + + +
+ +
+ +
+
+ +
+ 支持图像处理的模型列表
-
- + + +
+ +
+ +
+
+ +
+ 支持搜索功能的模型列表 +
+ + +
+ +
+ +
+
+ +
+ 需要过滤的模型列表 +
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+
+ + +
+

+ 图像生成配置 +

+ + +
+ + + 用于图像生成的付费API密钥 +
+ + +
+ + + 用于图像生成的模型 +
+ + +
+ + + 图片上传服务提供商 +
+ + +
+ + + SM.MS图床的密钥 +
+ + +
+ + + PicGo的API密钥 +
+ + +
+ + + Cloudflare图床的URL +
+ + +
+ + + Cloudflare图床的认证码 +
+
+ + +
+

+ 流式输出优化器 +

+ + +
+ +
+ + +
+
+ + +
+ + + 流式输出的最小延迟时间 +
+ + +
+ + + 流式输出的最大延迟时间 +
+ + +
+ + + 短文本的字符阈值 +
+ + +
+ + + 长文本的字符阈值 +
+ + +
+ + + 流式输出的分块大小
- Gemini API密钥列表,每行一个
-
- -
- -
-
- -
- 允许访问API的令牌列表 -
- -
- - - 用于API认证的令牌 -
- -
- - - Gemini API的基础URL -
- -
- - - API密钥失败后标记为无效的次数 -
- -
- - - API请求的超时时间 -
- -
- - - API请求失败后的最大重试次数 -
-
- - -
-

模型相关配置

- -
- - - 用于测试API密钥的模型 -
- -
- -
- -
- -
+ +
+

+ 定时任务配置 +

+ + +
+ + + 定时检查密钥状态的间隔时间(单位:小时)
- 支持图像处理的模型列表 -
- -
- -
- -
- -
-
- 支持搜索功能的模型列表 -
- -
- -
- -
- -
-
- 需要过滤的模型列表 -
- -
- -
- - + + +
+ + + 定时任务使用的时区,格式如 "Asia/Shanghai" 或 "UTC"
-
- -
- - -
+ +
+ +
- -
- -
- - -
-
-
- - -
-

图像生成配置

- -
- - - 用于图像生成的付费API密钥 -
- -
- - - 用于图像生成的模型 -
- -
- - - 图片上传服务提供商 -
- -
- - - SM.MS图床的密钥 -
- -
- - - PicGo的API密钥 -
- -
- - - Cloudflare图床的URL -
- -
- - - Cloudflare图床的认证码 -
-
- - -
-

流式输出优化器

- -
- -
- - -
-
- -
- - - 流式输出的最小延迟时间 -
- -
- - - 流式输出的最大延迟时间 -
- -
- - - 短文本的字符阈值 -
- -
- - - 长文本的字符阈值 -
- -
- - - 流式输出的分块大小 -
-
- -
- - -
- + +
- + +
- - -
- + + +
+ +
- - - - + + + - - -