diff --git a/app/log/logger.py b/app/log/logger.py index 35e2fa5..551eb16 100644 --- a/app/log/logger.py +++ b/app/log/logger.py @@ -152,4 +152,8 @@ def get_middleware_logger(): def get_routes_logger(): - return Logger.setup_logger("routes") \ No newline at end of file + return Logger.setup_logger("routes") + + +def get_config_routes_logger(): + return Logger.setup_logger("config_routes") \ No newline at end of file diff --git a/app/router/config_routes.py b/app/router/config_routes.py new file mode 100644 index 0000000..5acdae4 --- /dev/null +++ b/app/router/config_routes.py @@ -0,0 +1,48 @@ +""" +配置路由模块 +""" +from typing import Any, Dict +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import RedirectResponse + +from app.core.security import verify_auth_token +from app.log.logger import get_config_routes_logger +from app.service.config.config_service import ConfigService + +# 创建路由 +router = APIRouter(prefix="/api/config", tags=["config"]) + +logger = get_config_routes_logger() + + +@router.get("", response_model=Dict[str, Any]) +async def get_config(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 config page") + return RedirectResponse(url="/", status_code=302) + return ConfigService.get_config() + + +@router.put("", response_model=Dict[str, Any]) +async def update_config(config_data: Dict[str, Any], 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 config page") + return RedirectResponse(url="/", status_code=302) + try: + return ConfigService.update_config(config_data) + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/reset", response_model=Dict[str, Any]) +async def reset_config(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 config page") + return RedirectResponse(url="/", status_code=302) + try: + return ConfigService.reset_config() + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) diff --git a/app/router/routes.py b/app/router/routes.py index d842b08..a2ec9f3 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 +from app.router import gemini_routes, openai_routes, config_routes from app.service.key.key_manager import get_key_manager_instance logger = get_routes_logger() @@ -28,6 +28,7 @@ def setup_routers(app: FastAPI) -> None: app.include_router(openai_routes.router) app.include_router(gemini_routes.router) app.include_router(gemini_routes.router_v1beta) + app.include_router(config_routes.router) # 添加页面路由 setup_page_routes(app) @@ -97,6 +98,21 @@ def setup_page_routes(app: FastAPI) -> None: except Exception as e: logger.error(f"Error retrieving keys status: {str(e)}") raise + + @app.get("/config", response_class=HTMLResponse) + async def config_page(request: Request): + """配置编辑页面""" + try: + auth_token = request.cookies.get("auth_token") + if not auth_token or not verify_auth_token(auth_token): + logger.warning("Unauthorized access attempt to config page") + return RedirectResponse(url="/", status_code=302) + + logger.info("Config page accessed successfully") + return templates.TemplateResponse("config_editor.html", {"request": request}) + except Exception as e: + logger.error(f"Error accessing config page: {str(e)}") + raise def setup_health_routes(app: FastAPI) -> None: diff --git a/app/service/config/config_service.py b/app/service/config/config_service.py new file mode 100644 index 0000000..198e294 --- /dev/null +++ b/app/service/config/config_service.py @@ -0,0 +1,104 @@ +""" +配置服务模块 +""" +import os +from typing import Any, Dict +import json +from dotenv import load_dotenv, set_key + +from app.config.config import settings, Settings + + +class ConfigService: + """配置服务类,用于管理应用程序配置""" + + @staticmethod + def get_config() -> Dict[str, Any]: + """ + 获取当前配置 + + Returns: + Dict[str, Any]: 配置字典 + """ + config_dict = {} + + # 获取Settings类的所有字段 + for field_name in settings.model_fields: + value = getattr(settings, field_name) + config_dict[field_name] = value + + return config_dict + + @staticmethod + def update_config(config_data: Dict[str, Any]) -> Dict[str, Any]: + """ + 更新配置 + + Args: + config_data (Dict[str, Any]): 新的配置数据 + + Returns: + Dict[str, Any]: 更新后的配置字典 + """ + # 更新settings对象 + for key, value in config_data.items(): + if hasattr(settings, key): + setattr(settings, key, value) + + # 更新.env文件 + ConfigService._update_env_file(config_data) + + return ConfigService.get_config() + + @staticmethod + def reset_config() -> Dict[str, Any]: + """ + 重置配置到默认值 + + Returns: + Dict[str, Any]: 重置后的配置字典 + """ + # 重新加载.env文件 + load_dotenv(override=True) + + # 重新创建settings对象 + global settings + settings = Settings() + + return ConfigService.get_config() + + @staticmethod + def _update_env_file(config_data: Dict[str, Any]) -> None: + """ + 更新.env文件 + + Args: + config_data (Dict[str, Any]): 配置数据 + """ + env_path = ".env" + + # 确保.env文件存在 + if not os.path.exists(env_path): + # 如果不存在,复制.env.example + if os.path.exists(".env.example"): + with open(".env.example", "r", encoding="utf-8") as example_file: + with open(env_path, "w", encoding="utf-8") as env_file: + env_file.write(example_file.read()) + else: + # 创建空文件 + open(env_path, "w", encoding="utf-8").close() + + # 更新.env文件中的配置 + for key, value in config_data.items(): + # 处理不同类型的值 + if isinstance(value, list): + # 将列表转换为JSON字符串 + env_value = json.dumps(value) + elif isinstance(value, bool): + # 布尔值转换为小写字符串 + env_value = str(value).lower() + else: + env_value = str(value) + + # 更新.env文件 + set_key(env_path, key, env_value) diff --git a/app/static/css/config_editor.css b/app/static/css/config_editor.css new file mode 100644 index 0000000..ed0f002 --- /dev/null +++ b/app/static/css/config_editor.css @@ -0,0 +1,792 @@ +* { + 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; /* Stay in place */ + z-index: 1001; /* Sit on top */ + left: 0; + top: 0; + width: 100%; /* Full width */ + height: 100%; /* Full height */ + overflow: auto; /* Enable scroll if needed */ + background-color: rgba(0,0,0,0.6); /* Black w/ opacity */ + backdrop-filter: blur(5px); +} + +.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/keys_status.css b/app/static/css/keys_status.css index e943cbc..ce1d179 100644 --- a/app/static/css/keys_status.css +++ b/app/static/css/keys_status.css @@ -30,7 +30,7 @@ body { h1 { color: #2c3e50; text-align: center; - margin-bottom: 30px; + margin-bottom: 15px; font-weight: 700; font-size: 32px; position: relative; @@ -49,6 +49,61 @@ h1::after { 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); @@ -161,27 +216,34 @@ li:hover { } .verify-btn, .copy-btn { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border: none; padding: 8px 15px; - border-radius: 8px; + 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); + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); } .verify-btn:hover { transform: translateY(-2px); - box-shadow: 0 5px 15px rgba(46, 204, 113, 0.3); + box-shadow: 0 5px 15px rgba(118, 75, 162, 0.3); + background: linear-gradient(135deg, #764ba2 0%, #667eea 100%); +} + +.verify-btn:active { + transform: translateY(1px); + box-shadow: 0 5px 10px rgba(118, 75, 162, 0.2); } .verify-btn:disabled { @@ -195,13 +257,19 @@ li:hover { 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(0); + transform: translateY(1px); + box-shadow: 0 5px 10px rgba(118, 75, 162, 0.2); } .copy-btn i { @@ -393,6 +461,19 @@ li:hover { 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; diff --git a/app/static/js/config_editor.js b/app/static/js/config_editor.js new file mode 100644 index 0000000..850db49 --- /dev/null +++ b/app/static/js/config_editor.js @@ -0,0 +1,555 @@ +document.addEventListener('DOMContentLoaded', function() { + // 初始化配置 + initConfig(); + + // 标签切换 + const tabButtons = document.querySelectorAll('.tab-btn'); + tabButtons.forEach(button => { + button.addEventListener('click', function(e) { + // 防止事件冒泡 + e.stopPropagation(); + const tabId = this.getAttribute('data-tab'); + switchTab(tabId); + }); + }); + + // 上传提供商切换 + const uploadProviderSelect = document.getElementById('UPLOAD_PROVIDER'); + if (uploadProviderSelect) { + uploadProviderSelect.addEventListener('change', function() { + toggleProviderConfig(this.value); + }); + } + + // 切换按钮事件 + const toggleSwitches = document.querySelectorAll('.toggle-switch'); + toggleSwitches.forEach(toggleSwitch => { + toggleSwitch.addEventListener('click', function(e) { + // 防止事件冒泡 + e.stopPropagation(); + const checkbox = this.querySelector('input[type="checkbox"]'); + if (checkbox) { + checkbox.checked = !checkbox.checked; + } + }); + }); + + // 保存按钮 + const saveBtn = document.getElementById('saveBtn'); + if (saveBtn) { + saveBtn.addEventListener('click', saveConfig); + } + + // 重置按钮 + const resetBtn = document.getElementById('resetBtn'); + if (resetBtn) { + resetBtn.addEventListener('click', resetConfig); + } + + // 滚动按钮 + window.addEventListener('scroll', toggleScrollButtons); + + // --- 新增:API Key 模态框和搜索相关 --- + const apiKeyModal = document.getElementById('apiKeyModal'); + const addApiKeyBtn = document.getElementById('addApiKeyBtn'); + const closeApiKeyModalBtn = document.getElementById('closeApiKeyModalBtn'); + const cancelAddApiKeyBtn = document.getElementById('cancelAddApiKeyBtn'); + const confirmAddApiKeyBtn = document.getElementById('confirmAddApiKeyBtn'); + const apiKeyBulkInput = document.getElementById('apiKeyBulkInput'); + const apiKeySearchInput = document.getElementById('apiKeySearchInput'); + + // --- 新增:重置确认模态框相关 --- + const resetConfirmModal = document.getElementById('resetConfirmModal'); + const closeResetModalBtn = document.getElementById('closeResetModalBtn'); + const cancelResetBtn = document.getElementById('cancelResetBtn'); + const confirmResetBtn = document.getElementById('confirmResetBtn'); + // --- 结束:新增 --- + + + // 打开模态框 + if (addApiKeyBtn) { + addApiKeyBtn.addEventListener('click', () => { + if (apiKeyModal) { + apiKeyModal.classList.add('show'); + } + if (apiKeyBulkInput) apiKeyBulkInput.value = ''; // 清空输入框 + }); + } + + // 关闭模态框 (X 按钮) + if (closeApiKeyModalBtn) { + closeApiKeyModalBtn.addEventListener('click', () => { + if (apiKeyModal) { + apiKeyModal.classList.remove('show'); + } + }); + } + + // 关闭模态框 (取消按钮) + if (cancelAddApiKeyBtn) { + cancelAddApiKeyBtn.addEventListener('click', () => { + if (apiKeyModal) { + apiKeyModal.classList.remove('show'); + } + }); + } + + // 点击模态框外部关闭 (处理两个模态框) + window.addEventListener('click', (event) => { + if (event.target == apiKeyModal) { + apiKeyModal.classList.remove('show'); + } + if (event.target == resetConfirmModal) { // 新增对重置模态框的处理 + resetConfirmModal.classList.remove('show'); + } + }); + + // 确认添加 API Key + if (confirmAddApiKeyBtn) { + confirmAddApiKeyBtn.addEventListener('click', handleBulkAddApiKeys); + } + + // API Key 搜索 (稍后实现具体逻辑) + if (apiKeySearchInput) { + apiKeySearchInput.addEventListener('input', handleApiKeySearch); + } + // --- 结束:API Key 相关 --- + + // --- 新增:重置确认模态框事件监听 (移到 DOMContentLoaded 内部) --- + if (closeResetModalBtn) { + closeResetModalBtn.addEventListener('click', () => { + if (resetConfirmModal) { + resetConfirmModal.classList.remove('show'); + } + }); + } + if (cancelResetBtn) { + cancelResetBtn.addEventListener('click', () => { + if (resetConfirmModal) { + resetConfirmModal.classList.remove('show'); + } + }); + } + if (confirmResetBtn) { + // 调用之前定义的 executeReset 函数 + confirmResetBtn.addEventListener('click', () => { + if (resetConfirmModal) { + resetConfirmModal.classList.remove('show'); // 关闭模态框 + } + executeReset(); // 执行重置逻辑 + }); + } + // --- 结束:重置相关 --- + +}); // <-- DOMContentLoaded 结束括号 + +// 初始化配置 +async function initConfig() { + try { + showNotification('正在加载配置...', 'info'); + const response = await fetch('/api/config'); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const config = await response.json(); + + // 确保数组字段有默认值 + if (!config.API_KEYS || !Array.isArray(config.API_KEYS) || config.API_KEYS.length === 0) { + config.API_KEYS = ['请在此处输入 API 密钥']; + } + + if (!config.ALLOWED_TOKENS || !Array.isArray(config.ALLOWED_TOKENS) || config.ALLOWED_TOKENS.length === 0) { + config.ALLOWED_TOKENS = ['']; + } + + if (!config.IMAGE_MODELS || !Array.isArray(config.IMAGE_MODELS) || config.IMAGE_MODELS.length === 0) { + config.IMAGE_MODELS = ['gemini-1.5-pro-latest']; + } + + if (!config.SEARCH_MODELS || !Array.isArray(config.SEARCH_MODELS) || config.SEARCH_MODELS.length === 0) { + config.SEARCH_MODELS = ['gemini-1.5-flash-latest']; + } + + if (!config.FILTERED_MODELS || !Array.isArray(config.FILTERED_MODELS) || config.FILTERED_MODELS.length === 0) { + config.FILTERED_MODELS = ['gemini-1.0-pro-latest']; + } + + populateForm(config); + + // 确保上传提供商有默认值 + const uploadProvider = document.getElementById('UPLOAD_PROVIDER'); + if (uploadProvider && !uploadProvider.value) { + uploadProvider.value = 'smms'; // 设置默认值为 smms + toggleProviderConfig('smms'); + } + + showNotification('配置加载成功', 'success'); + } catch (error) { + console.error('加载配置失败:', error); + showNotification('加载配置失败: ' + error.message, 'error'); + + // 加载失败时,使用默认配置 + const defaultConfig = { + API_KEYS: [''], + ALLOWED_TOKENS: [''], + IMAGE_MODELS: ['gemini-1.5-pro-latest'], + SEARCH_MODELS: ['gemini-1.5-flash-latest'], + FILTERED_MODELS: ['gemini-1.0-pro-latest'], + UPLOAD_PROVIDER: 'smms' + }; + + populateForm(defaultConfig); + toggleProviderConfig('smms'); + } +} + +// 填充表单 +function populateForm(config) { + for (const [key, value] of Object.entries(config)) { + // 首先检查是否是数组类型 + if (Array.isArray(value)) { + const container = document.getElementById(`${key}_container`); + if (container) { + // 清除现有项 + const existingItems = container.querySelectorAll('.array-item'); + existingItems.forEach(item => item.remove()); + // 添加数组项 + value.forEach(item => { + // 确保只添加非空字符串项(如果需要) + // if (item && typeof item === 'string' && item.trim() !== '') { + addArrayItemWithValue(key, item); + // } + }); + } + // 处理完数组后,跳过本次循环的剩余部分 + continue; + } + + // 如果不是数组,再尝试查找对应的单个元素 + const element = document.getElementById(key); + if (element) { + if (typeof value === 'boolean') { + element.checked = value; + } else { + // 处理其他类型 (确保 value 不是 null 或 undefined) + element.value = value ?? ''; // 使用空字符串作为默认值 + } + } + // 如果既不是数组,也找不到对应 ID 的元素,则忽略该配置项 + } + + // 初始化上传提供商配置 (保持不变) + const uploadProvider = document.getElementById('UPLOAD_PROVIDER'); + if (uploadProvider) { + toggleProviderConfig(uploadProvider.value); + } +} + +// --- 新增:处理批量添加 API Key 的逻辑 --- +function handleBulkAddApiKeys() { + const apiKeyBulkInput = document.getElementById('apiKeyBulkInput'); + const apiKeyContainer = document.getElementById('API_KEYS_container'); + const apiKeyModal = document.getElementById('apiKeyModal'); + + if (!apiKeyBulkInput || !apiKeyContainer || !apiKeyModal) return; + + const bulkText = apiKeyBulkInput.value; + const keyRegex = /AIzaSy\S{33}/g; // 全局匹配 + const extractedKeys = bulkText.match(keyRegex) || []; + + // 获取当前已有的 keys + const currentKeyInputs = apiKeyContainer.querySelectorAll('.array-input'); + const currentKeys = Array.from(currentKeyInputs).map(input => input.value).filter(key => key.trim() !== ''); + + // 合并并去重 + const combinedKeys = new Set([...currentKeys, ...extractedKeys]); + const uniqueKeys = Array.from(combinedKeys); + + // 清空现有列表显示 + const existingItems = apiKeyContainer.querySelectorAll('.array-item'); + existingItems.forEach(item => item.remove()); + + // 重新填充列表 + uniqueKeys.forEach(key => { + addArrayItemWithValue('API_KEYS', key); + }); + + // 关闭模态框 + apiKeyModal.classList.remove('show'); + showNotification(`添加/更新了 ${uniqueKeys.length} 个唯一密钥`, 'success'); +} + +// --- 新增:处理 API Key 搜索的逻辑 --- +function handleApiKeySearch() { + const apiKeySearchInput = document.getElementById('apiKeySearchInput'); + const apiKeyContainer = document.getElementById('API_KEYS_container'); + + if (!apiKeySearchInput || !apiKeyContainer) return; + + const searchTerm = apiKeySearchInput.value.toLowerCase(); + const keyItems = apiKeyContainer.querySelectorAll('.array-item'); + + keyItems.forEach(item => { + const input = item.querySelector('.array-input'); + if (input) { + const key = input.value.toLowerCase(); + if (key.includes(searchTerm)) { + item.style.display = 'flex'; // 或者 'block',取决于你的布局 + } else { + item.style.display = 'none'; + } + } + }); +} + +// 切换标签 +function switchTab(tabId) { + // 更新标签按钮状态 + const tabButtons = document.querySelectorAll('.tab-btn'); + tabButtons.forEach(button => { + if (button.getAttribute('data-tab') === tabId) { + button.classList.add('active'); + } else { + button.classList.remove('active'); + } + }); + + // 更新内容区域 + const sections = document.querySelectorAll('.config-section'); + sections.forEach(section => { + if (section.id === `${tabId}-section`) { + section.classList.add('active'); + } else { + section.classList.remove('active'); + } + }); +} + +// 切换上传提供商配置 +function toggleProviderConfig(provider) { + const providerConfigs = document.querySelectorAll('.provider-config'); + providerConfigs.forEach(config => { + if (config.getAttribute('data-provider') === provider) { + config.classList.add('active'); + } else { + config.classList.remove('active'); + } + }); +} + + +// 添加数组项 +function addArrayItem(key) { + const container = document.getElementById(`${key}_container`); + if (!container) return; + + addArrayItemWithValue(key, ''); +} + +// 添加带值的数组项 +function addArrayItemWithValue(key, value) { + const container = document.getElementById(`${key}_container`); + if (!container) return; + + const arrayItem = document.createElement('div'); + arrayItem.className = 'array-item'; + + const input = document.createElement('input'); + input.type = 'text'; + input.name = `${key}[]`; + input.value = value; + input.className = 'array-input'; + + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'remove-btn'; + removeBtn.innerHTML = ''; + removeBtn.addEventListener('click', function() { + arrayItem.remove(); + }); + + arrayItem.appendChild(input); + arrayItem.appendChild(removeBtn); + + // 插入到添加按钮之前 + const controls = container.querySelector('.array-controls'); + container.insertBefore(arrayItem, controls); +} + +// 收集表单数据 +function collectFormData() { + const formData = {}; + + // 处理普通输入 + const inputs = document.querySelectorAll('input[type="text"], input[type="number"], select'); + inputs.forEach(input => { + if (!input.name.includes('[]')) { + if (input.type === 'number') { + formData[input.name] = parseFloat(input.value); + } else { + formData[input.name] = input.value; + } + } + }); + + // 处理复选框 + const checkboxes = document.querySelectorAll('input[type="checkbox"]'); + checkboxes.forEach(checkbox => { + formData[checkbox.name] = checkbox.checked; + }); + + // 处理数组 + const arrayContainers = document.querySelectorAll('.array-container'); + arrayContainers.forEach(container => { + const key = container.id.replace('_container', ''); + const arrayInputs = container.querySelectorAll('.array-input'); + formData[key] = Array.from(arrayInputs).map(input => input.value).filter(value => value.trim() !== ''); + }); + + return formData; +} + +// 保存配置 +async function saveConfig() { + try { + const formData = collectFormData(); + + showNotification('正在保存配置...', 'info'); + + const response = await fetch('/api/config', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(formData) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || `HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + + // 显示保存状态 + const saveStatus = document.getElementById('saveStatus'); + saveStatus.classList.add('show'); + + setTimeout(() => { + saveStatus.classList.remove('show'); + }, 3000); + + showNotification('配置保存成功', 'success'); + } catch (error) { + console.error('保存配置失败:', error); + + // 显示错误状态 + const saveStatus = document.getElementById('saveStatus'); + saveStatus.classList.add('show', 'error'); + saveStatus.querySelector('.status-icon i').className = 'fas fa-times-circle'; + saveStatus.querySelector('.status-text').textContent = '配置保存失败'; + + setTimeout(() => { + saveStatus.classList.remove('show', 'error'); + }, 3000); + + showNotification('保存配置失败: ' + error.message, 'error'); + } +} + +// 重置配置 (现在只负责打开模态框) +function resetConfig(event) { + // 阻止事件冒泡和默认行为 + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + + console.log('resetConfig called. Event target:', event ? event.target.id : 'No event'); + + // 确保只有当事件来自重置按钮时才显示模态框 + if (!event || event.target.id === 'resetBtn' || event.currentTarget.id === 'resetBtn') { + const resetConfirmModal = document.getElementById('resetConfirmModal'); + if (resetConfirmModal) { + resetConfirmModal.classList.add('show'); + } else { + // Fallback if modal doesn't exist for some reason + console.error("Reset confirmation modal not found! Falling back to default confirm."); + // Fallback to original confirm behavior + if (!confirm('确定要重置所有配置吗?这将恢复到默认值。')) { + return; + } + // If confirmed, proceed with the reset logic directly (less ideal) + executeReset(); + } + } +} + +// --- 新增:将实际重置逻辑提取到一个单独的函数 --- +async function executeReset() { + try { + showNotification('正在重置配置...', 'info'); + const response = await fetch('/api/config/reset', { method: 'POST' }); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const config = await response.json(); + populateForm(config); + showNotification('配置已重置为默认值', 'success'); + } catch (error) { + console.error('重置配置失败:', error); + showNotification('重置配置失败: ' + error.message, 'error'); + } +} + +// 显示通知 +function showNotification(message, type = 'info') { + const notification = document.getElementById('notification'); + notification.textContent = message; + notification.className = 'notification show'; + + if (type) { + notification.classList.add(type); + } + + setTimeout(() => { + notification.classList.remove('show'); + }, 3000); +} + +// 刷新页面 +function refreshPage(button) { + button.classList.add('loading'); + location.reload(); +} + +// 滚动到顶部 +function scrollToTop() { + const container = document.querySelector('.container'); + container.scrollTo({ + top: 0, + behavior: 'smooth' + }); +} + +// 滚动到底部 +function scrollToBottom() { + const container = document.querySelector('.container'); + container.scrollTo({ + top: container.scrollHeight, + behavior: 'smooth' + }); +} + +// 切换滚动按钮显示 +function toggleScrollButtons() { + const scrollButtons = document.querySelector('.scroll-buttons'); + + if (window.scrollY > 200) { + scrollButtons.style.display = 'flex'; + } else { + scrollButtons.style.display = 'none'; + } +} diff --git a/app/templates/config_editor.html b/app/templates/config_editor.html new file mode 100644 index 0000000..7bbdfc6 --- /dev/null +++ b/app/templates/config_editor.html @@ -0,0 +1,327 @@ + + + + + + 配置编辑器 + + + + + + + + + + + +
+ +

Gemini Balance

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

API相关配置

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

模型相关配置

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

图像生成配置

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

流式输出优化器

+ +
+ +
+ + +
+
+ +
+ + + 流式输出的最小延迟时间 +
+ +
+ + + 流式输出的最大延迟时间 +
+ +
+ + + 短文本的字符阈值 +
+ +
+ + + 长文本的字符阈值 +
+ +
+ + + 流式输出的分块大小 +
+
+ +
+ + +
+
+
+ +
+ + +
+ +
+ + + + + + diff --git a/app/templates/keys_status.html b/app/templates/keys_status.html index d81a9e5..5aca554 100644 --- a/app/templates/keys_status.html +++ b/app/templates/keys_status.html @@ -19,7 +19,15 @@ -

API密钥状态

+

Gemini Balance

+