Compare commits

..

19 Commits

Author SHA1 Message Date
snaily
f593d97381 Merge pull request #49 from toddyoe/main
chore: typo fixed for missing param
2025-04-18 23:43:54 +08:00
Toddy
053ef631c4 chore: typo fixed for missing param 2025-04-18 15:38:16 +00:00
snaily
075d20c62d chore: 已在 README.md 文件中添加了 LOG_LEVEL 环境变量的说明。 2025-04-18 22:03:23 +08:00
snaily
0768aed179 Merge branch 'main' of https://github.com/snailyp/gemini-balance 2025-04-18 21:54:04 +08:00
snaily
c2eac24175 feat: 添加可配置的日志级别
引入可配置的日志级别功能,允许用户通过配置编辑器和 `.env` 文件设置所需的日志详细程度。

主要变化:
- 在 `.env.example` 和 `app/config/config.py` 中添加了 `LOG_LEVEL` 设置。
- 修改了 `app/log/logger.py`,使其从设置中读取日志级别,并实现了对现有 logger 进行动态日志级别更新的功能。
- 更新了 `app/router/config_routes.py`,以便在保存配置后触发日志级别更新。
- 在 `app/templates/config_editor.html` 和 `app/static/js/config_editor.js` 中添加了日志级别选择的 UI 元素。
- 将 `app/router/gemini_routes.py` 和 `app/router/openai_routes.py` 中的一些日志调用从 `info` 调整为 `debug`,以降低默认输出的详细程度。
- 在 `README.md` 的“特别鸣谢”部分添加了 🎉 表情符号。
2025-04-18 21:53:54 +08:00
snaily
1c6dabcea7 更新 docker-compose.yml 2025-04-17 23:13:41 +08:00
snaily
76937aa24f chore:
增强文档: 在 README.md 文件中,新增了“特别鸣谢”部分,以感谢 PicGo、SM.MS 和 CloudFlare-ImgBed 为本项目提供的图床服务。同时,添加了“ Star History”部分,用于展示项目的 Star 历史,增强了文档的信息量和项目展示效果。
配置更正: 在配置编辑器 config_editor.html 中,更正了 Cloudflare 图床的 provider 名称。将原先的 cloudflare 更正为 cloudflare_imgbed,确保配置项名称的准确性和一致性。
2025-04-17 17:42:42 +08:00
snaily
b96ce8f15a Merge branch 'main' of https://github.com/snailyp/gemini-balance 2025-04-17 09:26:45 +08:00
snaily
87d60117c5 refactor:将 config_editor 页面中的提示(notification)样式完全统一为与 keys_status 页面一致的黑色半透明风格,无论提示类型均不会再出现绿色等色块。 2025-04-17 09:19:41 +08:00
snaily
a53a30fd38 Merge pull request #44 from yanhao98/0415-docker-compose 2025-04-16 13:57:12 +08:00
严浩
98e7fb62d5 feat(docker): 更新 MySQL 服务配置,添加健康检查 2025-04-16 10:19:40 +08:00
snaily
6a59b4f847 feat: 更新许可证为 CC BY-NC 4.0 并补充相关说明
- README.md 中将原 MIT 许可证声明修改为 CC BY-NC 4.0(署名-非商业性使用),并在开头和结尾增加了相关说明,明确禁止任何形式的商业倒卖服务,详情见 LICENSE 文件。
- 新增 LICENSE 文件,补充项目完整的 CC BY-NC 4.0 许可证内容。
2025-04-16 00:19:51 +08:00
snaily
d1ba2c4ae9 feat(config): 认证令牌输入框支持一键生成随机令牌
- 新增“生成随机令牌”按钮,优化认证令牌输入体验
- 支持自动生成并填充认证令牌,提升交互便捷性
2025-04-15 23:56:35 +08:00
snaily
0693a5c245 feat(keys_status): 支持批量验证密钥与选定密钥失败计数重置,增强自动刷新
- 后端新增 ResetSelectedKeysRequest、VerifySelectedKeysRequest 数据模型及相关 API 路由,实现批量重置选定密钥失败计数功能
- 前端 keys_status.js/keys_status.html 新增批量验证按钮、批量验证弹窗及交互逻辑,支持对筛选后密钥进行批量验证
- 自动刷新功能支持开关,优化用户体验
- UI 细节优化,提升密钥管理便捷性
2025-04-15 23:15:29 +08:00
snaily
742db744d1 feat(config_editor): 新增批量删除 API 密钥及令牌生成功能
- 实现 API 密钥的批量删除功能:
  - 在配置编辑器中添加“删除密钥”按钮和批量删除模态框。
  - 用户可以在模态框中粘贴密钥列表进行批量删除。
  - JavaScript 逻辑负责提取、匹配并移除列表中的密钥。
- 为 ALLOWED_TOKENS 字段添加内联随机令牌生成按钮,方便快速生成。
- 优化配置编辑器中数组项(如 API Key, Allowed Token)的 UI 布局和样式。
2025-04-14 23:29:51 +08:00
snaily
12a84921c1 refactor: 更新贡献者展示方式并添加友情项目链接 2025-04-13 17:22:14 +08:00
snaily
73e98a185d fix:修复gemini格式不能查询模型列表的问题 2025-04-13 12:45:23 +08:00
snaily
73a7c81f85 feat(logs): 添加错误日志详情查看功能并优化列表显示
本次提交主要围绕错误日志模块进行了功能增强和优化:

- **后端 (`database/services.py`, `router/log_routes.py`):**
    - 新增了根据日志 ID 获取单个错误日志完整详情(包括 `error_log` 和 `request_msg`)的数据库服务函数 (`get_error_log_details`) 和对应的 API 路由 (`/api/logs/errors/{log_id}/details`)。
    - 修改了获取错误日志列表的 API (`/api/logs/errors`):
        - 在返回数据中增加了 `error_code` 字段,以便前端展示。
        - 优化了数据库查询,明确指定需要选择的列,提升性能。
        - 将默认排序方式从按请求时间改为按日志 ID 降序排列,使最新的错误优先显示。
        - 改进了未授权访问时的处理,返回标准的 401 HTTP 状态码。
    - 更新了相关的 Pydantic 模型以匹配新的数据结构。

- **前端 (`static/js/error_logs.js`, `templates/error_logs.html`):**
    - 在错误日志列表页面,将原先显示部分错误日志内容的列修改为显示 "错误码"。
    - 实现了点击 "详情" 按钮时,通过异步请求新的详情 API 获取并展示完整的错误日志信息(包括详细错误日志和请求消息)的功能。
    - 在详情模态框中添加了加载状态提示和获取数据失败时的错误处理逻辑。
2025-04-13 04:36:34 +08:00
snaily
86dba93974 fix: 修复 error_logs.html 中的脚本路径错误 2025-04-13 01:16:59 +08:00
19 changed files with 1156 additions and 219 deletions

View File

@@ -1,8 +1,8 @@
# MySQL数据库配置
MYSQL_HOST=
MYSQL_PORT=
MYSQL_USER=
MYSQL_PASSWORD=
MYSQL_HOST=gemini-balance-mysql
MYSQL_PORT=3306
MYSQL_USER=gemini
MYSQL_PASSWORD=change_me
MYSQL_DATABASE=default_db
API_KEYS=["AIzaSyxxxxxxxxxxxxxxxxxxx","AIzaSyxxxxxxxxxxxxxxxxxxx"]
ALLOWED_TOKENS=["sk-123456"]
@@ -38,3 +38,7 @@ STREAM_SHORT_TEXT_THRESHOLD=10
STREAM_LONG_TEXT_THRESHOLD=50
STREAM_CHUNK_SIZE=5
##########################################################################
######################### 日志配置 #######################################
# 日志级别 (debug, info, warning, error, critical),默认为 info
LOG_LEVEL=info
##########################################################################

17
LICENSE Normal file
View File

@@ -0,0 +1,17 @@
知识共享署名-非商业性使用 4.0 国际 (CC BY-NC 4.0) 协议
您可以自由地:
- 共享 — 在任何媒介以任何形式复制、发行本作品
- 演绎 — 修改、转换或以本作品为基础进行创作
惟须遵守下列条件:
- 署名 — 您必须给出适当的署名,提供指向本协议的链接,并指明是否(对原作)作了修改。您可以以任何合理方式进行,但不得以任何方式暗示许可方认可您或您的使用。
- 非商业性使用 — 您不得将本作品用于商业目的包括但不限于任何形式的商业倒卖、SaaS、API 付费接口、二次销售、打包出售、收费分发或其他直接或间接盈利行为。
如需商业授权,请联系原作者获得书面许可。违者将承担相应法律责任。
Creative Commons Attribution-NonCommercial 4.0 International Public License
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
Full license text: https://creativecommons.org/licenses/by-nc/4.0/legalcode

View File

@@ -1,5 +1,7 @@
# Gemini Balance - Gemini API 代理和负载均衡器
> ⚠️ 本项目采用 CC BY-NC 4.0(署名-非商业性使用)协议,禁止任何形式的商业倒卖服务,详见 LICENSE 文件。
[![Python](https://img.shields.io/badge/Python-3.9%2B-blue.svg)](https://www.python.org/)
[![FastAPI](https://img.shields.io/badge/FastAPI-0.100%2B-green.svg)](https://fastapi.tiangolo.com/)
[![Uvicorn](https://img.shields.io/badge/Uvicorn-running-purple.svg)](https://www.uvicorn.org/)
@@ -160,13 +162,14 @@ app/
| `CHECK_INTERVAL_HOURS` | 可选,检查禁用 Key 是否恢复的时间间隔 (小时) | `1` |
| `TIMEZONE` | 可选,应用程序使用的时区 | `Asia/Shanghai` |
| `TIME_OUT` | 可选,请求超时时间 (秒) | `300` |
| `LOG_LEVEL` | 可选,日志级别,例如 DEBUG, INFO, WARNING, ERROR, CRITICAL | `INFO` |
| **图像生成相关** | | |
| `PAID_KEY` | 可选付费版API Key用于图片生成等高级功能 | `your-paid-api-key` |
| `CREATE_IMAGE_MODEL` | 可选,图片生成模型 | `imagen-3.0-generate-002` |
| `UPLOAD_PROVIDER` | 可选,图片上传提供商: `smms`, `picgo`, `cloudflare_imgbed` | `smms` |
| `SMMS_SECRET_TOKEN` | 可选SM.MS图床的API Token | `your-smms-token` |
| `PICGO_API_KEY` | 可选PicoGo图床的API Key | `your-picogo-apikey` |
| `CLOUDFLARE_IMGBED_URL` | 可选CloudFlare 图床上传地址 | `https://xxxxxxx.pages.dev/upload` |
| `PICGO_API_KEY` | 可选,[PicoGo](https://www.picgo.net/)图床的API Key | `your-picogo-apikey` |
| `CLOUDFLARE_IMGBED_URL` | 可选,[CloudFlare](https://github.com/MarSeventh/CloudFlare-ImgBed) 图床上传地址 | `https://xxxxxxx.pages.dev/upload` |
| `CLOUDFLARE_IMGBED_AUTH_CODE`| 可选CloudFlare图床的鉴权key | `your-cloudflare-imgber-auth-code` |
| **流式优化器相关** | | |
| `STREAM_OPTIMIZER_ENABLED` | 可选,是否启用流式输出优化 | `false` |
@@ -197,17 +200,28 @@ app/
欢迎提交 Pull Request 或 Issue。
## 🎉 特别鸣谢
特别鸣谢以下项目和平台为本项目提供图床服务:
* [PicGo](https://www.picgo.net/)
* [SM.MS](https://smms.app/)
* [CloudFlare-ImgBed](https://github.com/MarSeventh/CloudFlare-ImgBed) 开源项目
## 🙏 感谢贡献者
感谢所有为本项目做出贡献的开发者!
<a href="https://github.com/toddyoe" title="toddyoe"><img src="https://avatars.githubusercontent.com/u/167494546?s=64&v=4" width="64" height="64"></a>
<a href="https://github.com/yangtb2024" title="yangtb2024"><img src="https://avatars.githubusercontent.com/u/164613316?s=64&v=4" width="64" height="64"></a>
<a href="https://github.com/cr-zhichen" title="cr-zhichen"><img src="https://avatars.githubusercontent.com/u/57337795?s=64&v=4" width="64" height="64"></a>
<a href="https://github.com/BetterAndBetterII" title="BetterAndBetterII"><img src="https://avatars.githubusercontent.com/u/141388234?s=96&v=4" width="64" height="64"></a>
<a href="https://github.com/yanhao98" title="yanhao98"><img src="https://avatars.githubusercontent.com/u/37316281?s=64&v=4" width="64" height="64"></a>
<a href="https://github.com/Haoyu99" title="Haoyu99"><img src="https://avatars.githubusercontent.com/u/93185981?s=60&v=4" width="64" height="64"></a>
[![Contributors](https://contrib.rocks/image?repo=snailyp/gemini-balance)](https://github.com/snailyp/gemini-balance/graphs/contributors)
## 📄 许可证
## ⭐ Star History
本项目采用 MIT 许可证。
[![Star History Chart](https://api.star-history.com/svg?repos=snailyp/gemini-balance&type=Date)](https://star-history.com/#snailyp/gemini-balance&Date)
## 💖 友情项目
* **[OneLine](https://github.com/chengtx809/OneLine)** by [chengtx809](https://github.com/chengtx809) - OneLine一线AI驱动的热点事件时间轴生成工具
## 许可证
本项目采用 CC BY-NC 4.0(署名-非商业性使用)协议,禁止任何形式的商业倒卖服务,详见 LICENSE 文件。

View File

@@ -10,13 +10,14 @@ from pydantic_settings import BaseSettings
from sqlalchemy import insert, update, select
from app.core.constants import API_VERSION, DEFAULT_CREATE_IMAGE_MODEL, DEFAULT_FILTER_MODELS, DEFAULT_MODEL, DEFAULT_STREAM_CHUNK_SIZE, DEFAULT_STREAM_LONG_TEXT_THRESHOLD, DEFAULT_STREAM_MAX_DELAY, DEFAULT_STREAM_MIN_DELAY, DEFAULT_STREAM_SHORT_TEXT_THRESHOLD, DEFAULT_TIMEOUT, MAX_RETRIES
from app.log.logger import get_config_logger
from app.log.logger import Logger
# from app.log.logger import get_config_logger # 移除顶层导入
# 延迟导入以避免循环依赖,仅在 sync_initial_settings 中使用
# from app.database.connection import database
# from app.database.models import Settings as SettingsModel
# from app.database.services import get_all_settings # get_all_settings 可能不适合启动时调用,直接查询
logger = get_config_logger()
# logger = get_config_logger() # 移除顶层初始化
class Settings(BaseSettings):
@@ -67,6 +68,9 @@ class Settings(BaseSettings):
CHECK_INTERVAL_HOURS: int = 1 # 默认检查间隔为1小时
TIMEZONE: str = "Asia/Shanghai" # 默认时区
# 日志配置
LOG_LEVEL: str = "INFO" # 默认日志级别
def __init__(self, **kwargs):
super().__init__(**kwargs)
# 设置默认AUTH_TOKEN如果未提供
@@ -78,6 +82,8 @@ settings = Settings()
def _parse_db_value(key: str, db_value: str, target_type: Type) -> Any:
"""尝试将数据库字符串值解析为目标 Python 类型"""
from app.log.logger import get_config_logger # 函数内导入
logger = get_config_logger() # 函数内初始化
try:
if target_type == List[str]:
# 尝试解析 JSON 列表,如果失败则按逗号分割
@@ -110,6 +116,8 @@ async def sync_initial_settings():
2. 将数据库设置合并到内存 settings (数据库优先)。
3. 将最终的内存 settings 同步回数据库。
"""
from app.log.logger import get_config_logger # 函数内导入
logger = get_config_logger() # 函数内初始化
# 延迟导入以避免循环依赖和确保数据库连接已初始化
from app.database.connection import database
from app.database.models import Settings as SettingsModel
@@ -258,6 +266,9 @@ async def sync_initial_settings():
else:
logger.info("No setting changes detected between memory and database during initial sync.")
# 刷新日志等级
Logger.update_log_levels(final_memory_settings.get("LOG_LEVEL"))
except Exception as e:
logger.error(f"An unexpected error occurred during initial settings sync: {e}")
finally:

View File

@@ -175,7 +175,15 @@ async def get_error_logs(
List[Dict[str, Any]]: 错误日志列表
"""
try:
query = select(ErrorLog)
query = select(
ErrorLog.id,
ErrorLog.gemini_key,
ErrorLog.model_name,
ErrorLog.error_type,
ErrorLog.error_log,
ErrorLog.error_code,
ErrorLog.request_time
)
# Apply filters
if key_search:
@@ -192,7 +200,7 @@ async def get_error_logs(
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)
query = query.order_by(ErrorLog.id.desc()).limit(limit).offset(offset)
result = await database.fetch_all(query)
return [dict(row) for row in result]
@@ -242,6 +250,37 @@ async def get_error_logs_count(
logger.exception(f"Failed to count error logs with filters: {str(e)}") # Use exception for stack trace
raise
# 新增函数:获取单条错误日志详情
async def get_error_log_details(log_id: int) -> Optional[Dict[str, Any]]:
"""
根据 ID 获取单个错误日志的详细信息
Args:
log_id (int): 错误日志的 ID
Returns:
Optional[Dict[str, Any]]: 包含日志详细信息的字典,如果未找到则返回 None
"""
try:
query = select(ErrorLog).where(ErrorLog.id == log_id)
result = await database.fetch_one(query)
if result:
# 将 request_msg (JSONB) 转换为字符串以便在 API 中返回
log_dict = dict(result)
if 'request_msg' in log_dict and log_dict['request_msg'] is not None:
# 确保即使是 None 或非 JSON 数据也能处理
try:
log_dict['request_msg'] = json.dumps(log_dict['request_msg'], ensure_ascii=False, indent=2)
except TypeError:
log_dict['request_msg'] = str(log_dict['request_msg']) # Fallback to string
return log_dict
else:
return None
except Exception as e:
logger.exception(f"Failed to get error log details for ID {log_id}: {str(e)}")
raise
# 新增函数:添加请求日志
async def add_request_log(
model_name: Optional[str],

View File

@@ -40,3 +40,12 @@ class GeminiRequest(BaseModel):
safetySettings: Optional[List[SafetySetting]] = None
generationConfig: Optional[GenerationConfig] = None
systemInstruction: Optional[SystemInstruction] = None
class ResetSelectedKeysRequest(BaseModel):
keys: List[str]
key_type: str
class VerifySelectedKeysRequest(BaseModel):
keys: List[str]

View File

@@ -35,7 +35,7 @@ class RetryHandler:
key_manager = kwargs.get("key_manager")
if key_manager:
old_key = kwargs.get(self.key_arg)
new_key = await key_manager.handle_api_failure(old_key)
new_key = await key_manager.handle_api_failure(old_key, attempt)
kwargs[self.key_arg] = new_key
logger.info(f"Switched to new API key: {new_key}")

View File

@@ -56,20 +56,28 @@ class Logger:
@staticmethod
def setup_logger(
name: str,
level: str = "debug",
name: str
) -> logging.Logger:
"""
设置并获取logger
:param name: logger名称
:param level: 日志级别
:return: logger实例
"""
# 导入 settings 对象
from app.config.config import settings
# 从全局配置获取日志级别
log_level_str = settings.LOG_LEVEL.lower()
level = LOG_LEVELS.get(log_level_str, logging.INFO)
if name in Logger._loggers:
return Logger._loggers[name]
# 如果 logger 已存在,检查并更新其级别(如果需要)
existing_logger = Logger._loggers[name]
if existing_logger.level != level:
existing_logger.setLevel(level)
return existing_logger
logger = logging.getLogger(name)
logger.setLevel(LOG_LEVELS.get(level.lower(), logging.INFO))
logger.setLevel(level)
logger.propagate = False
# 添加控制台输出
@@ -90,6 +98,25 @@ class Logger:
return Logger._loggers.get(name)
@staticmethod
def update_log_levels(log_level: str):
"""
根据当前的全局配置更新所有已创建 logger 的日志级别。
"""
log_level_str = log_level.lower()
new_level = LOG_LEVELS.get(log_level_str, logging.INFO)
updated_count = 0
for logger_name, logger_instance in Logger._loggers.items():
if logger_instance.level != new_level:
logger_instance.setLevel(new_level)
# 可选:记录级别变更日志,但注意避免在日志模块内部产生过多日志
# print(f"Updated log level for logger '{logger_name}' to {log_level_str.upper()}")
updated_count += 1
# if updated_count > 0:
# print(f"Updated log level for {updated_count} loggers to {log_level_str.upper()}.")
# 预定义的loggers
def get_openai_logger():
return Logger.setup_logger("openai")

View File

@@ -6,7 +6,7 @@ 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.log.logger import get_config_routes_logger, Logger # 导入 Logger 类
from app.service.config.config_service import ConfigService
# 创建路由
@@ -31,8 +31,13 @@ async def update_config(config_data: Dict[str, Any], request: Request):
logger.warning("Unauthorized access attempt to config page")
return RedirectResponse(url="/", status_code=302)
try:
return await ConfigService.update_config(config_data)
result = await ConfigService.update_config(config_data)
# 配置更新成功后,立即更新所有 logger 的级别
Logger.update_log_levels(config_data["LOG_LEVEL"])
logger.info("Log levels updated after configuration change.") # 添加日志记录
return result
except Exception as e:
logger.error(f"Error updating config or log levels: {e}", exc_info=True) # 记录详细错误
raise HTTPException(status_code=400, detail=str(e))

View File

@@ -4,7 +4,8 @@ from copy import deepcopy
from app.config.config import settings
from app.log.logger import get_gemini_logger
from app.core.security import SecurityService
from app.domain.gemini_models import GeminiContent, GeminiRequest
import asyncio # 导入 asyncio
from app.domain.gemini_models import GeminiContent, GeminiRequest, ResetSelectedKeysRequest, VerifySelectedKeysRequest # 添加导入
from app.service.chat.gemini_chat_service import GeminiChatService
from app.service.key.key_manager import KeyManager, get_key_manager_instance
from app.service.model.model_service import ModelService
@@ -53,8 +54,8 @@ async def list_models(
model_mapping = {x.get("name", "").split("/", maxsplit=1)[1]: x for x in models_json["models"]}
# 添加搜索模型
if model_service.search_models:
for name in model_service.search_models:
if settings.SEARCH_MODELS:
for name in settings.SEARCH_MODELS:
model = model_mapping.get(name)
if not model:
continue
@@ -68,8 +69,8 @@ async def list_models(
models_json["models"].append(item)
# 添加图像生成模型
if model_service.image_models:
for name in model_service.image_models:
if settings.IMAGE_MODELS:
for name in settings.IMAGE_MODELS:
model = model_mapping.get(name)
if not model:
continue
@@ -98,7 +99,7 @@ async def generate_content(
"""非流式生成内容"""
logger.info("-" * 50 + "gemini_generate_content" + "-" * 50)
logger.info(f"Handling Gemini content generation request for model: {model_name}")
logger.info(f"Request: \n{request.model_dump_json(indent=2)}")
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
logger.info(f"Using API key: {api_key}")
if not model_service.check_model_support(model_name):
@@ -129,7 +130,7 @@ async def stream_generate_content(
"""流式生成内容"""
logger.info("-" * 50 + "gemini_stream_generate_content" + "-" * 50)
logger.info(f"Handling Gemini streaming content generation for model: {model_name}")
logger.info(f"Request: \n{request.model_dump_json(indent=2)}")
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
logger.info(f"Using API key: {api_key}")
if not model_service.check_model_support(model_name):
@@ -183,6 +184,62 @@ async def reset_all_key_fail_counts(key_type: str = None, key_manager: KeyManage
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-selected-fail-counts")
async def reset_selected_key_fail_counts(
request: ResetSelectedKeysRequest,
key_manager: KeyManager = Depends(get_key_manager)
):
"""批量重置选定Gemini API密钥的失败计数"""
logger.info("-" * 50 + "reset_selected_gemini_key_fail_counts" + "-" * 50)
keys_to_reset = request.keys
key_type = request.key_type # 获取类型用于日志记录和响应消息
logger.info(f"Received reset request for {len(keys_to_reset)} selected {key_type} keys.")
if not keys_to_reset:
return JSONResponse({"success": False, "message": "没有提供需要重置的密钥"}, status_code=400)
reset_count = 0
errors = []
try:
for key in keys_to_reset:
try:
result = await key_manager.reset_key_failure_count(key)
if result:
reset_count += 1
else:
# 记录未找到的密钥,但不视为致命错误
logger.warning(f"Key not found during selective reset: {key}")
except Exception as key_error:
# 记录单个密钥重置时的错误
logger.error(f"Error resetting key {key}: {str(key_error)}")
errors.append(f"Key {key}: {str(key_error)}")
if errors:
# 如果有错误,报告部分成功或完全失败
error_message = f"批量重置完成,但出现错误: {'; '.join(errors)}"
# 确定最终状态码和成功标志
final_success = reset_count > 0
status_code = 207 if final_success and errors else 500 # 207 Multi-Status if partially successful, 500 if completely failed
return JSONResponse({
"success": final_success,
"message": error_message,
"reset_count": reset_count
}, status_code=status_code)
# 完全成功的情况
return JSONResponse({
"success": True,
"message": f"成功重置 {reset_count} 个选定 {key_type} 密钥的失败计数",
"reset_count": reset_count
})
except Exception as e:
# 捕获循环外的意外错误
logger.error(f"Failed to process reset selected key failure counts request: {str(e)}")
return JSONResponse({"success": False, "message": f"批量重置处理失败: {str(e)}"}, status_code=500)
@router.post("/reset-fail-count/{api_key}")
@@ -234,4 +291,93 @@ async def verify_key(api_key: str, chat_service: GeminiChatService = Depends(get
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)})
return JSONResponse({"status": "invalid", "error": str(e)})
@router.post("/verify-selected-keys")
async def verify_selected_keys(
request: VerifySelectedKeysRequest,
chat_service: GeminiChatService = Depends(get_chat_service),
key_manager: KeyManager = Depends(get_key_manager)
):
"""批量验证选定Gemini API密钥的有效性"""
logger.info("-" * 50 + "verify_selected_gemini_keys" + "-" * 50)
keys_to_verify = request.keys
logger.info(f"Received verification request for {len(keys_to_verify)} selected keys.")
if not keys_to_verify:
return JSONResponse({"success": False, "message": "没有提供需要验证的密钥"}, status_code=400)
valid_count = 0
invalid_count = 0
verification_errors = {} # 存储验证过程中的错误
async def _verify_single_key(api_key: str):
"""内部函数,用于验证单个密钥并处理异常"""
nonlocal valid_count, invalid_count # 允许修改外部计数器
try:
# 重用单密钥验证逻辑的核心部分
gemini_request = GeminiRequest(
contents=[GeminiContent(role="user", parts=[{"text": "hi"}])]
)
# 注意:这里直接调用 chat_service.generate_content不依赖于 key_manager 获取密钥
await chat_service.generate_content(
settings.TEST_MODEL,
gemini_request,
api_key
)
# 如果上面没有抛出异常,则认为密钥有效
valid_count += 1
return api_key, "valid", None
except Exception as e:
error_message = str(e)
logger.warning(f"Key verification failed for {api_key}: {error_message}")
# 验证失败时增加失败计数 (使用与 /verify-key 一致的逻辑)
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"Bulk verification exception for key: {api_key}, incrementing failure count")
else:
# 如果密钥不在计数中可能刚添加或从未失败初始化为1
key_manager.key_failure_counts[api_key] = 1
logger.warning(f"Bulk verification exception for key: {api_key}, initializing failure count to 1")
invalid_count += 1
return api_key, "invalid", error_message
# 并发执行所有密钥的验证
tasks = [_verify_single_key(key) for key in keys_to_verify]
results = await asyncio.gather(*tasks, return_exceptions=True) # return_exceptions=True 捕获任务本身的异常
# 处理并发执行的结果
for result in results:
if isinstance(result, Exception):
# 捕获 asyncio.gather 可能遇到的异常(例如任务被取消)
logger.error(f"An unexpected error occurred during bulk verification task: {result}")
# 可以选择如何处理这种任务级别的错误,这里我们简单记录
# 也可以将其计入 invalid_count 或单独记录
elif result:
key, status, error = result
if status == "invalid" and error:
verification_errors[key] = error # 记录具体的验证错误信息
logger.info(f"Bulk verification finished. Valid: {valid_count}, Invalid: {invalid_count}")
# 根据是否有错误决定最终消息和状态
if verification_errors or valid_count + invalid_count != len(keys_to_verify): # 检查是否有错误或任务异常
error_summary = "; ".join([f"{k}: {v}" for k, v in verification_errors.items()])
message = f"批量验证完成,但出现问题。有效: {valid_count}, 无效: {invalid_count}。错误详情: {error_summary or '任务执行异常'}"
return JSONResponse({
"success": False, # 标记为失败,因为有错误
"message": message,
"valid_count": valid_count,
"invalid_count": invalid_count,
"errors": verification_errors
}, status_code=207) # 207 Multi-Status 表示部分成功/失败
else:
# 完全成功
return JSONResponse({
"success": True,
"message": f"批量验证成功完成。有效: {valid_count}, 无效: {invalid_count}",
"valid_count": valid_count,
"invalid_count": invalid_count
})

View File

@@ -1,15 +1,15 @@
"""
日志路由模块
"""
from typing import Any, Dict, List, Optional
from typing import List, Optional
from datetime import datetime
from pydantic import BaseModel
from fastapi import APIRouter, HTTPException, Request, Query
from fastapi.responses import RedirectResponse
from fastapi import APIRouter, HTTPException, Request, Query, Path
from app.core.security import verify_auth_token
from app.log.logger import get_log_routes_logger
from app.database.services import get_error_logs, get_error_logs_count
# 假设这些服务函数已更新或添加
from app.database.services import get_error_logs, get_error_logs_count, get_error_log_details
# 创建路由
router = APIRouter(prefix="/api/logs", tags=["logs"])
@@ -18,45 +18,61 @@ logger = get_log_routes_logger()
# Define a response model that includes the total count for pagination
class ErrorLogResponse(BaseModel):
logs: List[Dict[str, Any]]
# 用于列表响应的模型,假设 get_error_logs 返回包含 error_code 的字典
class ErrorLogListItem(BaseModel):
id: int
gemini_key: Optional[str] = None
error_type: Optional[str] = None
error_code: Optional[int] = None # 列表显示错误码 (应为整数)
model_name: Optional[str] = None
request_time: Optional[datetime] = None
class ErrorLogListResponse(BaseModel):
logs: List[ErrorLogListItem] # 使用定义的模型列表
total: int
@router.get("/errors", response_model=ErrorLogResponse)
@router.get("/errors", response_model=ErrorLogListResponse)
async def get_error_logs_api(
request: Request,
limit: int = Query(20, ge=1, le=1000), # Default to 20 to match frontend
limit: int = Query(10, ge=1, le=1000),
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[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)")
error_search: Optional[str] = Query(None, description="Search term for error type or log message"), # 数据库查询需处理
start_date: Optional[datetime] = Query(None, description="Start datetime for filtering"),
end_date: Optional[datetime] = Query(None, description="End datetime for filtering")
):
"""
获取错误日志
获取错误日志列表 (返回错误码)
Args:
request: 请求对象
limit: 限制数量
offset: 偏移量
key_search: 密钥搜索
error_search: 错误搜索 (可能搜索类型或日志内容由DB层决定)
start_date: 开始日期
end_date: 结束日期
Returns:
ErrorLogResponse: An object containing the list of logs and the total count.
ErrorLogListResponse: An object containing the list of logs (with error_code) and the total count.
"""
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to error logs")
return RedirectResponse(url="/", status_code=302)
logger.warning("Unauthorized access attempt to error logs list")
# API 返回 401 更合适
raise HTTPException(status_code=401, detail="Not authenticated")
try:
# Fetch logs with search parameters
logs = await get_error_logs(
# 假设 get_error_logs 现在返回包含 error_code 的字典列表
# 并且可以接受 include_error_code 参数 (如果需要显式指定)
logs_data = await get_error_logs(
limit=limit,
offset=offset,
key_search=key_search,
error_search=error_search,
error_search=error_search, # 数据库查询需要处理这个
start_date=start_date,
end_date=end_date
end_date=end_date,
# include_error_code=True # 如果需要显式传递
)
# Fetch total count with the same search parameters
total_count = await get_error_logs_count(
@@ -65,7 +81,45 @@ async def get_error_logs_api(
start_date=start_date,
end_date=end_date
)
return ErrorLogResponse(logs=logs, total=total_count)
# 验证并转换数据以匹配 Pydantic 模型
validated_logs = [ErrorLogListItem(**log) for log in logs_data]
return ErrorLogListResponse(logs=validated_logs, total=total_count)
except Exception as e:
logger.exception(f"Failed to get error logs: {str(e)}") # Use logger.exception for stack trace
raise HTTPException(status_code=500, detail=f"Failed to get error logs: {str(e)}")
logger.exception(f"Failed to get error logs list: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to get error logs list: {str(e)}")
# 新增:获取错误日志详情的路由
class ErrorLogDetailResponse(BaseModel):
id: int
gemini_key: Optional[str] = None
error_type: Optional[str] = None
error_log: Optional[str] = None # 详情接口返回完整的 error_log
request_msg: Optional[str] = None # 详情接口返回 request_msg
model_name: Optional[str] = None
request_time: Optional[datetime] = None
@router.get("/errors/{log_id}/details", response_model=ErrorLogDetailResponse)
async def get_error_log_detail_api(request: Request, log_id: int = Path(..., ge=1)):
"""
根据日志 ID 获取错误日志的详细信息 (包括 error_log 和 request_msg)
"""
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning(f"Unauthorized access attempt to error log details for ID: {log_id}")
raise HTTPException(status_code=401, detail="Not authenticated")
try:
# 假设存在一个函数 get_error_log_details(log_id) 来获取完整信息
log_details = await get_error_log_details(log_id=log_id)
if not log_details:
raise HTTPException(status_code=404, detail="Error log not found")
# 假设 get_error_log_details 返回一个字典或兼容 Pydantic 的对象
return ErrorLogDetailResponse(**log_details)
except HTTPException as http_exc:
# Re-raise HTTPException (like 404)
raise http_exc
except Exception as e:
logger.exception(f"Failed to get error log details for ID {log_id}: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to get error log details: {str(e)}")

View File

@@ -75,7 +75,7 @@ async def chat_completion(
api_key = await key_manager.get_paid_key()
logger.info("-" * 50 + "chat_completion" + "-" * 50)
logger.info(f"Handling chat completion request for model: {request.model}")
logger.info(f"Request: \n{request.model_dump_json(indent=2)}")
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
logger.info(f"Using API key: {api_key}")
if not model_service.check_model_support(request.model):

View File

@@ -57,6 +57,12 @@ document.addEventListener('DOMContentLoaded', function() {
const confirmAddApiKeyBtn = document.getElementById('confirmAddApiKeyBtn');
const apiKeyBulkInput = document.getElementById('apiKeyBulkInput');
const apiKeySearchInput = document.getElementById('apiKeySearchInput');
const bulkDeleteApiKeyBtn = document.getElementById('bulkDeleteApiKeyBtn'); // 新增
const bulkDeleteApiKeyModal = document.getElementById('bulkDeleteApiKeyModal'); // 新增
const closeBulkDeleteModalBtn = document.getElementById('closeBulkDeleteModalBtn'); // 新增
const cancelBulkDeleteApiKeyBtn = document.getElementById('cancelBulkDeleteApiKeyBtn'); // 新增
const confirmBulkDeleteApiKeyBtn = document.getElementById('confirmBulkDeleteApiKeyBtn'); // 新增
const bulkDeleteApiKeyInput = document.getElementById('bulkDeleteApiKeyInput'); // 新增
// --- 新增:重置确认模态框相关 ---
const resetConfirmModal = document.getElementById('resetConfirmModal');
@@ -99,9 +105,12 @@ document.addEventListener('DOMContentLoaded', function() {
if (event.target == apiKeyModal) {
apiKeyModal.classList.remove('show');
}
if (event.target == resetConfirmModal) { // 新增对重置模态框的处理
if (event.target == resetConfirmModal) {
resetConfirmModal.classList.remove('show');
}
if (event.target == bulkDeleteApiKeyModal) { // 新增对批量删除模态框的处理
bulkDeleteApiKeyModal.classList.remove('show');
}
});
// 确认添加 API Key
@@ -113,6 +122,41 @@ document.addEventListener('DOMContentLoaded', function() {
if (apiKeySearchInput) {
apiKeySearchInput.addEventListener('input', handleApiKeySearch);
}
// --- 新增:批量删除 API Key 相关事件 ---
// 打开批量删除模态框
if (bulkDeleteApiKeyBtn) {
bulkDeleteApiKeyBtn.addEventListener('click', () => {
if (bulkDeleteApiKeyModal) {
bulkDeleteApiKeyModal.classList.add('show');
}
if (bulkDeleteApiKeyInput) bulkDeleteApiKeyInput.value = ''; // 清空输入框
});
}
// 关闭批量删除模态框 (X 按钮)
if (closeBulkDeleteModalBtn) {
closeBulkDeleteModalBtn.addEventListener('click', () => {
if (bulkDeleteApiKeyModal) {
bulkDeleteApiKeyModal.classList.remove('show');
}
});
}
// 关闭批量删除模态框 (取消按钮)
if (cancelBulkDeleteApiKeyBtn) {
cancelBulkDeleteApiKeyBtn.addEventListener('click', () => {
if (bulkDeleteApiKeyModal) {
bulkDeleteApiKeyModal.classList.remove('show');
}
});
}
// 确认批量删除 API Key
if (confirmBulkDeleteApiKeyBtn) {
confirmBulkDeleteApiKeyBtn.addEventListener('click', handleBulkDeleteApiKeys);
}
// --- 结束:批量删除 API Key 相关 ---
// --- 结束API Key 相关 ---
// --- 新增:重置确认模态框事件监听 (移到 DOMContentLoaded 内部) ---
@@ -141,6 +185,18 @@ document.addEventListener('DOMContentLoaded', function() {
}
// --- 结束:重置相关 ---
// 移除了静态生成令牌按钮的事件监听器,现在按钮是动态生成的
// 认证令牌生成按钮事件绑定
const generateAuthTokenBtn = document.getElementById('generateAuthTokenBtn');
const authTokenInput = document.getElementById('AUTH_TOKEN');
if (generateAuthTokenBtn && authTokenInput) {
generateAuthTokenBtn.addEventListener('click', function() {
const newToken = generateRandomToken();
authTokenInput.value = newToken;
showNotification('已生成新认证令牌', 'success');
});
}
}); // <-- DOMContentLoaded 结束括号
// 初始化配置
@@ -234,7 +290,12 @@ function populateForm(config) {
element.checked = value;
} else {
// 处理其他类型 (确保 value 不是 null 或 undefined)
element.value = value ?? ''; // 使用空字符串作为默认值
// 特别处理 LOG_LEVEL确保大小写匹配 option 的 value
if (key === 'LOG_LEVEL' && typeof value === 'string') {
element.value = value.toUpperCase();
} else {
element.value = value ?? ''; // 使用空字符串作为默认值
}
}
}
// 如果既不是数组,也找不到对应 ID 的元素,则忽略该配置项
@@ -304,6 +365,58 @@ function handleApiKeySearch() {
});
}
// --- 新增:处理批量删除 API Key 的逻辑 ---
function handleBulkDeleteApiKeys() {
const bulkDeleteTextarea = document.getElementById('bulkDeleteApiKeyInput'); // Use the textarea ID
const apiKeyContainer = document.getElementById('API_KEYS_container');
const bulkDeleteModal = document.getElementById('bulkDeleteApiKeyModal');
if (!bulkDeleteTextarea || !apiKeyContainer || !bulkDeleteModal) return;
const bulkText = bulkDeleteTextarea.value;
if (!bulkText.trim()) {
showNotification('请粘贴需要删除的 API 密钥', 'warning');
return;
}
// Use the same regex as for adding keys to extract keys to delete
const keyRegex = /AIzaSy\S{33}/g;
const keysToDelete = new Set(bulkText.match(keyRegex) || []); // Create a Set for efficient lookup
if (keysToDelete.size === 0) {
showNotification('未在输入内容中提取到有效的 API 密钥格式', 'warning');
// Optionally clear the textarea or keep it as is
// bulkDeleteTextarea.value = '';
return;
}
const keyItems = apiKeyContainer.querySelectorAll('.array-item');
let deleteCount = 0;
keyItems.forEach(item => {
const input = item.querySelector('.array-input');
// Check if the input exists and its value is in the set of keys to delete
if (input && keysToDelete.has(input.value)) {
item.remove(); // Remove the entire array item element
deleteCount++;
}
});
// Close the modal
bulkDeleteModal.classList.remove('show');
// Provide feedback
if (deleteCount > 0) {
showNotification(`成功删除了 ${deleteCount} 个匹配的密钥`, 'success');
} else {
// This message implies keys were extracted but not found in the current list
showNotification('列表中未找到您输入的任何密钥进行删除', 'info');
}
// Clear the textarea after processing
bulkDeleteTextarea.value = '';
}
// 切换标签
function switchTab(tabId) {
// 更新标签按钮状态
@@ -358,29 +471,58 @@ function addArrayItemWithValue(key, value) {
if (!container) return;
const arrayItem = document.createElement('div');
arrayItem.className = 'array-item flex justify-between items-center mb-2'; // 使用 Flexbox 布局,垂直居中,底部增加间距
// 主容器使用 Flexbox
arrayItem.className = 'array-item flex items-center mb-2 gap-2'; // 添加 gap-2 来分隔元素
// 创建一个包装器 div 来包含输入框和生成按钮
const inputWrapper = document.createElement('div');
// 这个包装器占据主要空间,并使用 Flexbox
inputWrapper.className = 'flex items-center flex-grow border border-gray-300 rounded-md focus-within:border-primary-500 focus-within:ring focus-within:ring-primary-200 focus-within:ring-opacity-50';
const input = document.createElement('input');
input.type = 'text';
input.name = `${key}[]`;
input.value = value;
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'; // 输入框占据大部分空间,添加样式和右边距
// 输入框占据包装器内的主要空间,移除边框和圆角,因为包装器已有
input.className = 'array-input flex-grow px-3 py-2 border-none rounded-l-md focus:outline-none'; // 移除右侧圆角
inputWrapper.appendChild(input); // 将输入框添加到包装器
// 只为 ALLOWED_TOKENS 添加生成按钮
if (key === 'ALLOWED_TOKENS') {
const generateBtn = document.createElement('button');
generateBtn.type = 'button';
// 按钮样式,放在输入框右侧,有背景和内边距,调整颜色
generateBtn.className = 'generate-btn px-2 py-2 text-gray-500 hover:text-primary-600 focus:outline-none rounded-r-md bg-gray-100 hover:bg-gray-200 transition-colors'; // 添加背景和右侧圆角
generateBtn.innerHTML = '<i class="fas fa-dice"></i>';
generateBtn.title = '生成随机令牌';
generateBtn.addEventListener('click', function() {
const newToken = generateRandomToken();
input.value = newToken;
showNotification('已生成新令牌', 'success');
});
inputWrapper.appendChild(generateBtn); // 将生成按钮添加到包装器
} else {
// 如果不是 ALLOWED_TOKENS确保输入框有右侧圆角
input.classList.add('rounded-r-md');
}
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'remove-btn text-gray-400 hover:text-red-500 focus:outline-none transition-colors duration-150 ml-2'; // 新的 Tailwind 样式
removeBtn.innerHTML = '<i class="fas fa-trash-alt"></i>'; // 改用垃圾桶图标
removeBtn.title = '删除'; // 添加悬停提示
// 删除按钮样式,保持不变
removeBtn.className = 'remove-btn text-gray-400 hover:text-red-500 focus:outline-none transition-colors duration-150';
removeBtn.innerHTML = '<i class="fas fa-trash-alt"></i>';
removeBtn.title = '删除';
removeBtn.addEventListener('click', function() {
arrayItem.remove();
});
arrayItem.appendChild(input);
// 将包装器(包含输入框和可能的生成按钮)和删除按钮添加到主容器
arrayItem.appendChild(inputWrapper);
arrayItem.appendChild(removeBtn);
// 插入到添加按钮之前
const controls = container.querySelector('.array-controls');
container.insertBefore(arrayItem, controls);
// 插入到容器末尾
container.appendChild(arrayItem);
}
// 收集表单数据
@@ -394,6 +536,7 @@ function collectFormData() {
if (input.type === 'number') {
formData[input.name] = parseFloat(input.value);
} else {
// 确保 select 元素的值也被正确收集
formData[input.name] = input.value;
}
}
@@ -543,29 +686,17 @@ async function executeReset() {
function showNotification(message, type = 'info') {
const notification = document.getElementById('notification');
notification.textContent = message;
// 设置适当的样式
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中一致
// 统一样式为黑色半透明,与 keys_status.js 保持一致
notification.classList.remove('bg-danger-500');
notification.classList.add('bg-black');
notification.style.backgroundColor = 'rgba(0,0,0,0.8)';
notification.style.color = '#fff';
// 应用过渡效果
notification.style.opacity = "1";
notification.style.transform = "translate(-50%, 0)";
// 设置自动消失
setTimeout(() => {
notification.style.opacity = "0";
@@ -605,3 +736,16 @@ function toggleScrollButtons() {
scrollButtons.style.display = 'none';
}
}
// --- 新增:生成随机令牌函数 ---
function generateRandomToken() {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_';
const length = 48;
let result = 'sk-';
const charactersLength = characters.length;
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
}
// --- 结束:生成随机令牌函数 ---

View File

@@ -246,18 +246,13 @@ async function loadErrorLogs() {
throw new Error(errorData?.detail || `网络响应异常: ${response.statusText}`);
}
const data = await response.json();
// Assuming the API returns an object like { logs: [], total: count }
// If it only returns an array, we can't get the total count accurately for pagination
if (Array.isArray(data)) {
errorLogs = data;
renderErrorLogs(errorLogs); // Pass data directly
updatePagination(errorLogs.length, -1); // Indicate unknown total
} else if (data && Array.isArray(data.logs)) {
errorLogs = data.logs;
renderErrorLogs(errorLogs); // Pass logs array
updatePagination(errorLogs.length, data.total || -1); // Pass total count if available
// API 现在返回 { logs: [], total: count }
if (data && Array.isArray(data.logs)) {
errorLogs = data.logs; // Store the list data (contains error_code)
renderErrorLogs(errorLogs);
updatePagination(errorLogs.length, data.total || -1);
} else {
throw new Error('无法识别的API响应格式');
throw new Error('无法识别的API响应格式');
}
@@ -302,8 +297,8 @@ function renderErrorLogs(logs) {
} catch (e) { console.error("Error formatting date:", e); }
// Truncate error log content for display
const errorLogContent = log.error_log ? log.error_log.substring(0, 100) + (log.error_log.length > 100 ? '...' : '') : '无';
// Display error code instead of truncated log
const errorCodeContent = log.error_code || '无';
// Mask the Gemini key for display in the table
const maskKey = (key) => {
@@ -316,7 +311,7 @@ function renderErrorLogs(logs) {
<td>${sequentialId}</td> <!-- Use sequential ID -->
<td title="${log.gemini_key || ''}">${maskedKey}</td>
<td>${log.error_type || '未知'}</td>
<td class="error-log-content" title="${log.error_log || ''}">${errorLogContent}</td>
<td class="error-code-content" title="${log.error_code || ''}">${errorCodeContent}</td>
<td>${log.model_name || '未知'}</td>
<td>${formattedTime}</td>
<td>
@@ -338,57 +333,88 @@ function renderErrorLogs(logs) {
});
}
// 显示错误日志详情 (Custom Modal Logic)
function showLogDetails(logId) {
const log = errorLogs.find(l => l.id === logId);
if (!log || !logDetailModal) return;
// 显示错误日志详情 (从 API 获取)
async function showLogDetails(logId) {
if (!logDetailModal) return;
// Format date
let formattedTime = 'N/A';
try {
const requestTime = new Date(log.request_time);
if (!isNaN(requestTime)) {
formattedTime = requestTime.toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
});
}
} catch (e) { console.error("Error formatting date:", e); }
// Show loading state in modal (optional)
// Clear previous content and show a spinner or message
document.getElementById('modalGeminiKey').textContent = '加载中...';
document.getElementById('modalErrorType').textContent = '加载中...';
document.getElementById('modalErrorLog').textContent = '加载中...';
document.getElementById('modalRequestMsg').textContent = '加载中...';
document.getElementById('modalModelName').textContent = '加载中...';
document.getElementById('modalRequestTime').textContent = '加载中...';
// Format request message (handle potential JSON)
let formattedRequestMsg = '无';
if (log.request_msg) {
try {
// Check if it's already an object/array
if (typeof log.request_msg === 'object' && log.request_msg !== null) {
formattedRequestMsg = JSON.stringify(log.request_msg, null, 2);
}
// Check if it's a JSON string
else if (typeof log.request_msg === 'string' && log.request_msg.trim().startsWith('{') || log.request_msg.trim().startsWith('[')) {
formattedRequestMsg = JSON.stringify(JSON.parse(log.request_msg), null, 2);
}
else {
formattedRequestMsg = String(log.request_msg);
}
} catch (e) {
formattedRequestMsg = String(log.request_msg); // Fallback to string
console.warn("Could not parse request_msg as JSON:", e);
}
}
// Populate modal content (show full key in modal)
document.getElementById('modalGeminiKey').textContent = log.gemini_key || '无';
document.getElementById('modalErrorType').textContent = log.error_type || '未知';
document.getElementById('modalErrorLog').textContent = log.error_log || '无';
document.getElementById('modalRequestMsg').textContent = formattedRequestMsg;
document.getElementById('modalModelName').textContent = log.model_name || '未知';
document.getElementById('modalRequestTime').textContent = formattedTime;
// Show the modal
logDetailModal.classList.add('show');
// Optional: Prevent body scrolling when modal is open
document.body.style.overflow = 'hidden';
document.body.style.overflow = 'hidden'; // Prevent body scrolling
try {
const response = await fetch(`/api/logs/errors/${logId}/details`);
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch (e) { /* ignore */ }
throw new Error(errorData?.detail || `获取日志详情失败: ${response.statusText}`);
}
const logDetails = await response.json();
// Format date
let formattedTime = 'N/A';
try {
const requestTime = new Date(logDetails.request_time);
if (!isNaN(requestTime)) {
formattedTime = requestTime.toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
});
}
} catch (e) { console.error("Error formatting date:", e); }
// Format request message (handle potential JSON)
let formattedRequestMsg = '无';
if (logDetails.request_msg) {
try {
if (typeof logDetails.request_msg === 'object' && logDetails.request_msg !== null) {
formattedRequestMsg = JSON.stringify(logDetails.request_msg, null, 2);
} else if (typeof logDetails.request_msg === 'string') {
// Try parsing if it looks like JSON, otherwise display as string
const trimmedMsg = logDetails.request_msg.trim();
if (trimmedMsg.startsWith('{') || trimmedMsg.startsWith('[')) {
formattedRequestMsg = JSON.stringify(JSON.parse(logDetails.request_msg), null, 2);
} else {
formattedRequestMsg = logDetails.request_msg;
}
} else {
formattedRequestMsg = String(logDetails.request_msg);
}
} catch (e) {
formattedRequestMsg = String(logDetails.request_msg); // Fallback
console.warn("Could not parse request_msg as JSON:", e);
}
}
// Populate modal content with fetched details
document.getElementById('modalGeminiKey').textContent = logDetails.gemini_key || '无';
document.getElementById('modalErrorType').textContent = logDetails.error_type || '未知';
document.getElementById('modalErrorLog').textContent = logDetails.error_log || '无'; // Full error log
document.getElementById('modalRequestMsg').textContent = formattedRequestMsg; // Full request message
document.getElementById('modalModelName').textContent = logDetails.model_name || '未知';
document.getElementById('modalRequestTime').textContent = formattedTime;
} catch (error) {
console.error('获取日志详情失败:', error);
// Show error in modal
document.getElementById('modalGeminiKey').textContent = '错误';
document.getElementById('modalErrorType').textContent = '错误';
document.getElementById('modalErrorLog').textContent = `加载失败: ${error.message}`;
document.getElementById('modalRequestMsg').textContent = '错误';
document.getElementById('modalModelName').textContent = '错误';
document.getElementById('modalRequestTime').textContent = '错误';
// Optionally show a notification
showNotification(`加载日志详情失败: ${error.message}`, 'error', 5000);
}
}
// Close Log Detail Modal

View File

@@ -52,18 +52,20 @@ function initStatItemAnimations() {
}
function copyKeys(type) {
const keys = Array.from(document.querySelectorAll(`#${type}Keys .key-text`)).map(span => span.dataset.fullKey);
// 选择对应区域内所有可见的 li 元素下的 key-text span
const visibleKeyItems = document.querySelectorAll(`#${type}Keys li:not([style*="display: none"]) .key-text`);
const keys = Array.from(visibleKeyItems).map(span => span.dataset.fullKey);
if (keys.length === 0) {
showNotification('没有可复制的密钥', 'error');
showNotification('没有可复制的筛选后密钥', 'warning'); // 修改提示信息
return;
}
const keysText = keys.join('\n');
copyToClipboard(keysText)
.then(() => {
showNotification(`已成功复制${keys.length}${type === 'valid' ? '有效' : '无效'}密钥`);
showNotification(`已成功复制 ${keys.length} 个筛选后的${type === 'valid' ? '有效' : '无效'}密钥`); // 修改提示信息
})
.catch((err) => {
console.error('无法复制文本: ', err);
@@ -186,14 +188,35 @@ function showResetModal(type) {
const titleElement = document.getElementById('resetModalTitle');
const messageElement = document.getElementById('resetModalMessage');
const confirmButton = document.getElementById('confirmResetBtn');
// 获取当前筛选后可见的、且包含 data-fail-count 属性的密钥数量
// 根据密钥类型选择合适的选择器
let keySelector;
if (type === 'valid') {
// 对于有效密钥,可能需要基于失败次数筛选,保留 data-fail-count (虽然批量重置通常不需要筛选)
// 如果批量重置有效密钥也应重置所有可见的,可以将此行改为下面 else 中的选择器
keySelector = `#${type}Keys li[data-fail-count]:not([style*="display: none"])`;
} else {
// 对于无效密钥,我们想要重置所有可见的无效密钥,不依赖 data-fail-count
keySelector = `#${type}Keys li:not([style*="display: none"])`;
}
const visibleKeyItems = document.querySelectorAll(keySelector);
const count = visibleKeyItems.length;
// 设置标题和消息
titleElement.textContent = '批量重置失败次数';
messageElement.textContent = `确定要批量重置${type === 'valid' ? '有效' : '无效'}密钥的失败次数吗?`;
if (count > 0) {
messageElement.textContent = `确定要批量重置筛选出的 ${count}${type === 'valid' ? '有效' : '无效'}密钥的失败次数吗?`;
confirmButton.disabled = false; // 确保按钮可用
} else {
messageElement.textContent = `当前没有筛选出可重置的${type === 'valid' ? '有效' : '无效'}密钥。`;
confirmButton.disabled = true; // 没有可重置的密钥时禁用确认按钮
}
// 设置确认按钮事件
confirmButton.onclick = () => executeResetAll(type);
// 显示模态框
modalElement.classList.remove('hidden');
}
@@ -243,7 +266,17 @@ function showResultModal(success, message, autoReload = true) {
}
// 设置消息
messageElement.textContent = message;
// 支持长文本和换行内容插入到div而不是p
if (typeof message === 'string') {
// 如果内容包含换行或长文本,自动转为可滚动
messageElement.textContent = '';
messageElement.innerText = message;
} else if (message instanceof Node) {
messageElement.innerHTML = '';
messageElement.appendChild(message);
} else {
messageElement.textContent = String(message);
}
// 设置确认按钮点击事件
confirmButton.onclick = () => closeResultModal(autoReload);
@@ -256,54 +289,75 @@ 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;
}
// 获取筛选后可见的密钥
const visibleKeyItems = document.querySelectorAll(`#${type}Keys li:not([style*="display: none"]) .key-text`);
const keysToReset = Array.from(visibleKeyItems).map(span => span.dataset.fullKey);
if (keysToReset.length === 0) {
showNotification(`没有需要重置的筛选后${type === 'valid' ? '有效' : '无效'}密钥`, 'warning');
return;
}
// 禁用按钮并显示加载状态
resetButton.disabled = true;
const originalHtml = resetButton.innerHTML;
resetButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 重置中';
try {
// 调用API传递类型参数
const response = await fetch(`/gemini/v1beta/reset-all-fail-counts?key_type=${type}`, {
method: 'POST'
// 调用新的后端 API 来重置选定的密钥
const response = await fetch(`/gemini/v1beta/reset-selected-fail-counts`, { // 假设的新 API 端点
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ keys: keysToReset, key_type: type }) // 发送密钥列表和类型
});
if (!response.ok) {
throw new Error(`服务器返回错误: ${response.status}`);
// 尝试解析错误信息
let errorMsg = `服务器返回错误: ${response.status}`;
try {
const errorData = await response.json();
errorMsg = errorData.message || errorMsg;
} catch (e) {
// 如果解析失败,使用原始错误信息
}
throw new Error(errorMsg);
}
const data = await response.json();
// 根据重置结果显示模态框
if (data.success) {
const message = data.reset_count ?
`成功重置${data.reset_count}${type === 'valid' ? '有效' : '无效'}密钥的失败次数` :
'所有失败次数重置成功';
showResultModal(true, message);
const message = data.reset_count !== undefined ? // 检查 reset_count 是否存在
`成功重置 ${data.reset_count} 个筛选后的${type === 'valid' ? '有效' : '无效'}密钥的失败次数` :
`成功重置 ${keysToReset.length} 个筛选后的密钥`; // 如果后端没返回数量,使用前端计算的数量
showResultModal(true, message); // 成功后刷新页面
} else {
const errorMsg = data.message || '批量重置失败';
showResultModal(false, '批量重置失败: ' + errorMsg);
// 失败后不自动刷新页面,让用户看到错误信息
showResultModal(false, '批量重置失败: ' + errorMsg, false);
}
} catch (fetchError) {
console.error('API请求失败:', fetchError);
showResultModal(false, '批量重置请求失败: ' + fetchError.message);
showResultModal(false, '批量重置请求失败: ' + fetchError.message, false); // 失败后不自动刷新
} finally {
// 立即恢复按钮状态
// 恢复按钮状态
resetButton.innerHTML = originalHtml;
resetButton.disabled = false;
}
} catch (error) {
console.error('批量重置失败:', error);
showResultModal(false, '批量重置处理失败: ' + error.message);
console.error('批量重置处理失败:', error);
showResultModal(false, '批量重置处理失败: ' + error.message, false); // 失败后不自动刷新
}
}
@@ -419,6 +473,111 @@ document.addEventListener('DOMContentLoaded', () => {
// 恢复为原始值,以确保准确性
valueElement.textContent = valueElement.dataset.originalValue;
}
window.showVerifyModal = function(type, event) {
// 阻止事件冒泡(如果从按钮点击触发)
if (event) {
event.stopPropagation();
}
const modalElement = document.getElementById('verifyModal');
const titleElement = document.getElementById('verifyModalTitle');
const messageElement = document.getElementById('verifyModalMessage');
const confirmButton = document.getElementById('confirmVerifyBtn');
// 获取当前筛选后可见的、且包含 data-fail-count 属性的密钥数量
// 注意:对于验证,我们可能想验证所有筛选出的密钥,无论其 data-fail-count 如何,
// 但为了与重置保持一致,并且通常只验证有效/无效列表中的项,我们保留 data-fail-count 检查。
// 如果要验证所有可见项(包括没有 data-fail-count 的),可以移除 [data-fail-count] 选择器。
const visibleKeyItems = document.querySelectorAll(`#${type}Keys li[data-fail-count]:not([style*="display: none"])`);
const count = visibleKeyItems.length;
// 设置标题和消息
titleElement.textContent = '批量验证密钥';
if (count > 0) {
messageElement.textContent = `确定要批量验证筛选出的 ${count}${type === 'valid' ? '有效' : '无效'}密钥吗?此操作可能需要一些时间。`;
confirmButton.disabled = false; // 确保按钮可用
} else {
messageElement.textContent = `当前没有筛选出可验证的${type === 'valid' ? '有效' : '无效'}密钥。`;
confirmButton.disabled = true; // 没有可验证的密钥时禁用确认按钮
}
// 设置确认按钮事件
confirmButton.onclick = () => executeVerifyAll(type);
// 显示模态框
modalElement.classList.remove('hidden');
}
window.closeVerifyModal = function() {
document.getElementById('verifyModal').classList.add('hidden');
}
window.executeVerifyAll = async function(type) {
try {
// 关闭确认模态框
closeVerifyModal();
// 找到对应的验证按钮以显示加载状态 (需要给按钮添加 data-verify-type 属性)
// 或者,我们可以暂时禁用所有按钮或显示一个全局加载指示器
// 这里我们暂时只记录日志实际UI反馈可以后续增强
console.log(`Starting bulk verification for ${type} keys...`);
// 获取筛选后可见的密钥
const visibleKeyItems = document.querySelectorAll(`#${type}Keys li[data-fail-count]:not([style*="display: none"]) .key-text`);
const keysToVerify = Array.from(visibleKeyItems).map(span => span.dataset.fullKey);
if (keysToVerify.length === 0) {
showNotification(`没有需要验证的筛选后${type === 'valid' ? '有效' : '无效'}密钥`, 'warning');
return;
}
// 显示一个通用的加载提示
showNotification('开始批量验证,请稍候...', 'info');
// 调用新的后端 API 来验证选定的密钥
const response = await fetch(`/gemini/v1beta/verify-selected-keys`, { // 假设的新 API 端点
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ keys: keysToVerify }) // 只发送密钥列表
});
if (!response.ok) {
let errorMsg = `服务器返回错误: ${response.status}`;
try {
const errorData = await response.json();
errorMsg = errorData.message || errorMsg;
} catch (e) { /*忽略解析错误*/ }
throw new Error(errorMsg);
}
const data = await response.json();
// 根据验证结果显示模态框
if (data.success) {
// 可以在这里构建更详细的消息,例如显示多少有效多少无效
const message = `批量验证完成。有效: ${data.valid_count}, 无效: ${data.invalid_count}。页面即将刷新。`;
// 验证成功后通常需要刷新页面以更新状态
showResultModal(true, message, true); // autoReload = true
} else {
const errorMsg = data.message || '批量验证失败';
// 失败后不自动刷新
showResultModal(false, '批量验证失败: ' + errorMsg, false);
}
} catch (error) {
console.error('批量验证处理失败:', error);
// 失败后不自动刷新
showResultModal(false, '批量验证处理失败: ' + error.message, false);
} finally {
// 可以在这里移除加载指示器
console.log("Bulk verification process finished.");
}
}
};
requestAnimationFrame(updateCounter);
@@ -460,13 +619,154 @@ document.addEventListener('DOMContentLoaded', () => {
// 初始加载时应用一次筛选
filterValidKeys();
}
// --- 批量验证相关函数 (明确挂载到 window) ---
window.showVerifyModal = function(type, event) {
// 阻止事件冒泡(如果从按钮点击触发)
if (event) {
event.stopPropagation();
}
const modalElement = document.getElementById('verifyModal');
const titleElement = document.getElementById('verifyModalTitle');
const messageElement = document.getElementById('verifyModalMessage');
const confirmButton = document.getElementById('confirmVerifyBtn');
// 获取当前筛选后可见的、且包含 data-fail-count 属性的密钥数量
// 注意:对于验证,我们可能想验证所有筛选出的密钥,无论其 data-fail-count 如何,
// 但为了与重置保持一致,并且通常只验证有效/无效列表中的项,我们保留 data-fail-count 检查。
// 如果要验证所有可见项(包括没有 data-fail-count 的),可以移除 [data-fail-count] 选择器。
const visibleKeyItems = document.querySelectorAll(`#${type}Keys li[data-fail-count]:not([style*="display: none"])`);
const count = visibleKeyItems.length;
// 设置标题和消息
titleElement.textContent = '批量验证密钥';
if (count > 0) {
messageElement.textContent = `确定要批量验证筛选出的 ${count}${type === 'valid' ? '有效' : '无效'}密钥吗?此操作可能需要一些时间。`;
confirmButton.disabled = false; // 确保按钮可用
} else {
messageElement.textContent = `当前没有筛选出可验证的${type === 'valid' ? '有效' : '无效'}密钥。`;
confirmButton.disabled = true; // 没有可验证的密钥时禁用确认按钮
}
// 设置确认按钮事件
confirmButton.onclick = () => executeVerifyAll(type);
// 显示模态框
modalElement.classList.remove('hidden');
}
window.closeVerifyModal = function() {
document.getElementById('verifyModal').classList.add('hidden');
}
window.executeVerifyAll = async function(type) {
try {
// 关闭确认模态框
closeVerifyModal();
// 找到对应的验证按钮以显示加载状态 (需要给按钮添加 data-verify-type 属性)
// 或者,我们可以暂时禁用所有按钮或显示一个全局加载指示器
// 这里我们暂时只记录日志实际UI反馈可以后续增强
console.log(`Starting bulk verification for ${type} keys...`);
// 获取筛选后可见的密钥
const visibleKeyItems = document.querySelectorAll(`#${type}Keys li[data-fail-count]:not([style*="display: none"]) .key-text`);
const keysToVerify = Array.from(visibleKeyItems).map(span => span.dataset.fullKey);
if (keysToVerify.length === 0) {
showNotification(`没有需要验证的筛选后${type === 'valid' ? '有效' : '无效'}密钥`, 'warning');
return;
}
// 显示一个通用的加载提示
showNotification('开始批量验证,请稍候...', 'info');
// 调用新的后端 API 来验证选定的密钥
const response = await fetch(`/gemini/v1beta/verify-selected-keys`, { // 假设的新 API 端点
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ keys: keysToVerify }) // 只发送密钥列表
});
if (!response.ok) {
let errorMsg = `服务器返回错误: ${response.status}`;
try {
const errorData = await response.json();
errorMsg = errorData.message || errorMsg;
} catch (e) { /*忽略解析错误*/ }
throw new Error(errorMsg);
}
const data = await response.json();
// 根据验证结果显示模态框
if (data.success) {
// 可以在这里构建更详细的消息,例如显示多少有效多少无效
const message = `批量验证完成。有效: ${data.valid_count}, 无效: ${data.invalid_count}。页面即将刷新。`;
// 验证成功后通常需要刷新页面以更新状态
showResultModal(true, message, true); // autoReload = true
} else {
const errorMsg = data.message || '批量验证失败';
// 失败后不自动刷新
showResultModal(false, '批量验证失败: ' + errorMsg, false);
}
} catch (error) {
console.error('批量验证处理失败:', error);
// 失败后不自动刷新
showResultModal(false, '批量验证处理失败: ' + error.message, false);
} finally {
// 可以在这里移除加载指示器
console.log("Bulk verification process finished.");
}
}
// 添加自动刷新功能每60秒刷新一次
const autoRefreshInterval = 60000; // 60秒
setInterval(() => {
console.log('自动刷新 keys_status 页面...');
location.reload();
}, autoRefreshInterval);
// --- 滚动和页面控制 ---
// --- 自动刷新控制 ---
const autoRefreshToggle = document.getElementById('autoRefreshToggle');
const autoRefreshIntervalTime = 60000; // 60秒
let autoRefreshTimer = null;
function startAutoRefresh() {
if (autoRefreshTimer) return; // 防止重复启动
console.log('启动自动刷新...');
autoRefreshTimer = setInterval(() => {
console.log('自动刷新 keys_status 页面...');
location.reload();
}, autoRefreshIntervalTime);
}
function stopAutoRefresh() {
if (autoRefreshTimer) {
console.log('停止自动刷新...');
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
}
if (autoRefreshToggle) {
// 从 localStorage 读取状态并初始化
const isAutoRefreshEnabled = localStorage.getItem('autoRefreshEnabled') === 'true';
autoRefreshToggle.checked = isAutoRefreshEnabled;
if (isAutoRefreshEnabled) {
startAutoRefresh();
}
// 添加事件监听器
autoRefreshToggle.addEventListener('change', () => {
if (autoRefreshToggle.checked) {
localStorage.setItem('autoRefreshEnabled', 'true');
startAutoRefresh();
} else {
localStorage.setItem('autoRefreshEnabled', 'false');
stopAutoRefresh();
}
});
}
});
// Service Worker registration

View File

@@ -42,6 +42,11 @@
@apply: bg-primary-600;
background-color: #4F46E5;
}
/* 统一通知样式为黑色半透明,确保与 keys_status 一致 */
.notification {
background: rgba(0,0,0,0.8) !important;
color: #fff !important;
}
</style>
{% endblock %}
@@ -87,6 +92,9 @@
<button class="tab-btn bg-white bg-opacity-50 text-gray-700 px-5 py-2 rounded-full font-medium text-sm hover:bg-opacity-70 transition-all duration-200" data-tab="scheduler">
定时任务
</button>
<button class="tab-btn bg-white bg-opacity-50 text-gray-700 px-5 py-2 rounded-full font-medium text-sm hover:bg-opacity-70 transition-all duration-200" data-tab="logging">
日志配置
</button>
</div>
<!-- Save Status Banner (Removed - using notification component now) -->
@@ -108,7 +116,10 @@
<div class="array-container bg-white rounded-lg border border-gray-200 p-4 mb-2" id="API_KEYS_container">
<!-- 数组项将在这里动态添加 -->
</div>
<div class="flex justify-end">
<div class="flex justify-end gap-2">
<button type="button" class="bg-danger-600 hover:bg-danger-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2" id="bulkDeleteApiKeyBtn">
<i class="fas fa-trash-alt"></i> 删除密钥
</button>
<button type="button" class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2" id="addApiKeyBtn">
<i class="fas fa-plus"></i> 添加密钥
</button>
@@ -133,7 +144,14 @@
<!-- 认证令牌 -->
<div class="mb-6">
<label for="AUTH_TOKEN" class="block font-semibold mb-2 text-gray-700">认证令牌</label>
<input type="text" id="AUTH_TOKEN" name="AUTH_TOKEN" placeholder="默认使用ALLOWED_TOKENS中的第一个" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
<div class="flex items-center">
<div class="flex items-center flex-grow border border-gray-300 rounded-md focus-within:border-primary-500 focus-within:ring focus-within:ring-primary-200 focus-within:ring-opacity-50">
<input type="text" id="AUTH_TOKEN" name="AUTH_TOKEN" placeholder="默认使用ALLOWED_TOKENS中的第一个" class="array-input flex-grow px-3 py-2 border-none rounded-l-md focus:outline-none">
<button type="button" id="generateAuthTokenBtn" class="generate-btn px-2 py-2 text-gray-500 hover:text-primary-600 focus:outline-none rounded-r-md bg-gray-100 hover:bg-gray-200 transition-colors" title="生成随机令牌">
<i class="fas fa-dice"></i>
</button>
</div>
</div>
<small class="text-gray-500 mt-1 block">用于API认证的令牌</small>
</div>
@@ -275,7 +293,7 @@
<select id="UPLOAD_PROVIDER" name="UPLOAD_PROVIDER" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 bg-white">
<option value="smms" selected>SM.MS</option>
<option value="picgo">PicGo</option>
<option value="cloudflare">Cloudflare</option>
<option value="cloudflare_imgbed">Cloudflare</option>
</select>
<small class="text-gray-500 mt-1 block">图片上传服务提供商</small>
</div>
@@ -295,14 +313,14 @@
</div>
<!-- Cloudflare图床URL -->
<div class="mb-6 provider-config" data-provider="cloudflare">
<div class="mb-6 provider-config" data-provider="cloudflare_imgbed">
<label for="CLOUDFLARE_IMGBED_URL" class="block font-semibold mb-2 text-gray-700">Cloudflare图床URL</label>
<input type="text" id="CLOUDFLARE_IMGBED_URL" name="CLOUDFLARE_IMGBED_URL" placeholder="https://xxxxxxx.pages.dev/upload" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
<small class="text-gray-500 mt-1 block">Cloudflare图床的URL</small>
</div>
<!-- Cloudflare认证码 -->
<div class="mb-6 provider-config" data-provider="cloudflare">
<div class="mb-6 provider-config" data-provider="cloudflare_imgbed">
<label for="CLOUDFLARE_IMGBED_AUTH_CODE" class="block font-semibold mb-2 text-gray-700">Cloudflare认证码</label>
<input type="text" id="CLOUDFLARE_IMGBED_AUTH_CODE" name="CLOUDFLARE_IMGBED_AUTH_CODE" placeholder="xxxxxxxxx" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
<small class="text-gray-500 mt-1 block">Cloudflare图床的认证码</small>
@@ -380,6 +398,26 @@
<small class="text-gray-500 mt-1 block">定时任务使用的时区,格式如 "Asia/Shanghai" 或 "UTC"</small>
</div>
</div>
<!-- 日志配置 -->
<div class="config-section bg-white bg-opacity-70 rounded-xl p-6 mb-6 shadow-lg" id="logging-section">
<h2 class="text-xl font-bold mb-6 pb-3 border-b border-gray-200 flex items-center gap-2">
<i class="fas fa-file-alt text-primary-600"></i> 日志配置
</h2>
<!-- 日志级别 -->
<div class="mb-6">
<label for="LOG_LEVEL" class="block font-semibold mb-2 text-gray-700">日志级别</label>
<select id="LOG_LEVEL" name="LOG_LEVEL" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 bg-white">
<option value="DEBUG">DEBUG</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
<option value="CRITICAL">CRITICAL</option>
</select>
<small class="text-gray-500 mt-1 block">设置应用程序的日志记录详细程度</small>
</div>
</div>
<!-- Action Buttons -->
<div class="flex flex-col md:flex-row justify-center gap-4 mt-8">
@@ -425,7 +463,24 @@
</div>
</div>
</div>
<!-- Bulk Delete API Key Modal -->
<div id="bulkDeleteApiKeyModal" class="modal">
<div class="w-full max-w-lg mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden animate-fade-in">
<div class="p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-gray-800">批量删除 API 密钥</h2>
<button id="closeBulkDeleteModalBtn" class="text-gray-400 hover:text-gray-600 text-xl">&times;</button>
</div>
<p class="text-gray-600 mb-4">每行粘贴一个或多个密钥,将自动提取有效密钥并从列表中删除。</p>
<textarea id="bulkDeleteApiKeyInput" rows="10" placeholder="在此处粘贴要删除的 API 密钥..." class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-danger-500 focus:ring focus:ring-danger-200 focus:ring-opacity-50 font-mono text-sm"></textarea>
<div class="flex justify-end gap-3 mt-6">
<button type="button" id="confirmBulkDeleteApiKeyBtn" class="bg-danger-600 hover:bg-danger-700 text-white px-6 py-2 rounded-lg font-medium transition">确认删除</button>
<button type="button" id="cancelBulkDeleteApiKeyBtn" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-6 py-2 rounded-lg font-medium transition">取消</button>
</div>
</div>
</div>
</div>
<!-- Reset Confirmation Modal -->
<div id="resetConfirmModal" class="modal">
<div class="w-full max-w-md mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden animate-fade-in">

View File

@@ -105,7 +105,7 @@
<th class="px-5 py-3 font-semibold rounded-tl-lg">ID</th> <!-- Increased padding, adjusted rounding -->
<th class="px-5 py-3 font-semibold">Gemini密钥</th>
<th class="px-5 py-3 font-semibold">错误类型</th>
<th class="px-5 py-3 font-semibold">错误日志</th>
<th class="px-5 py-3 font-semibold">错误</th>
<th class="px-5 py-3 font-semibold">模型名称</th>
<th class="px-5 py-3 font-semibold">请求时间</th>
<th class="px-5 py-3 font-semibold rounded-tr-lg">操作</th> <!-- Adjusted rounding -->
@@ -233,7 +233,7 @@
{% endblock %}
{% block body_scripts %}
<script src="/static/js/error_logs.js') }}"></script>
<script src="/static/js/error_logs.js"></script>
<script>
// error_logs.html specific JS initialization (if any)
// e.g., initialize date pickers or other elements if needed

View File

@@ -198,6 +198,16 @@
font-size: 0.625rem;
}
}
/* Tailwind Toggle Switch Helper CSS from config_editor.html */
.toggle-checkbox:checked {
@apply: right-0 border-primary-600;
right: 0;
border-color: #4F46E5;
}
.toggle-checkbox:checked + .toggle-label {
@apply: bg-primary-600;
background-color: #4F46E5;
}
</style>
{% endblock %}
@@ -210,9 +220,20 @@
{% block content %}
<div class="container max-w-6xl mx-auto px-4"> <!-- Increased max-width -->
<div class="glass-card rounded-2xl shadow-xl p-6 md:p-8">
<button class="absolute top-6 right-6 bg-white bg-opacity-20 hover:bg-opacity-30 rounded-full w-8 h-8 flex items-center justify-center text-primary-600 transition-all duration-300" onclick="refreshPage(this)">
<i class="fas fa-sync-alt"></i>
</button>
<div class="absolute top-6 right-6 flex items-center gap-3">
<!-- 自动刷新开关 -->
<div class="flex items-center text-sm text-gray-600 select-none">
<span class="mr-2">自动刷新</span>
<div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
<input type="checkbox" name="autoRefreshToggle" id="autoRefreshToggle" class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer"/>
<label for="autoRefreshToggle" class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"></label>
</div>
</div>
<!-- 手动刷新按钮 -->
<button class="bg-white bg-opacity-20 hover:bg-opacity-30 rounded-full w-8 h-8 flex items-center justify-center text-primary-600 transition-all duration-300" onclick="refreshPage(this)" title="手动刷新">
<i class="fas fa-sync-alt"></i>
</button>
</div>
<h1 class="text-3xl font-extrabold text-center text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-primary-700 mb-4">
<img src="/static/icons/logo.png" alt="Gemini Balance Logo" class="h-9 inline-block align-middle mr-2">
@@ -300,10 +321,14 @@
<h2 class="text-lg font-semibold">有效密钥列表 ({{ valid_key_count }})</h2>
<div class="flex items-center gap-2 ml-4">
<label for="failCountThreshold" class="text-sm text-gray-600 select-none">失败次数≥</label>
<input type="number" id="failCountThreshold" value="0" min="0" class="form-input h-7 w-16 px-2 py-1 text-sm border border-gray-300 rounded focus:ring-primary-500 focus:border-primary-500">
<input type="number" id="failCountThreshold" value="0" min="0" class="form-input h-7 w-16 px-2 py-1 text-sm border border-gray-300 rounded focus:ring-primary-500 focus:border-primary-500" onclick="event.stopPropagation();">
</div>
</div>
<div class="flex gap-2">
<button class="flex items-center gap-2 bg-teal-500 hover:bg-teal-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="event.stopPropagation(); showVerifyModal('valid', event)"> <!-- 新增批量验证按钮 -->
<i class="fas fa-check-double"></i>
批量验证
</button>
<button class="flex items-center gap-2 bg-blue-500 hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="event.stopPropagation(); resetAllKeysFailCount('valid', event)" data-reset-type="valid">
<i class="fas fa-redo-alt"></i>
批量重置
@@ -369,6 +394,10 @@
<h2 class="text-lg font-semibold">无效密钥列表 ({{ invalid_key_count }})</h2>
</div>
<div class="flex gap-2">
<button class="flex items-center gap-2 bg-teal-500 hover:bg-teal-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="event.stopPropagation(); showVerifyModal('invalid', event)"> <!-- 新增批量验证按钮 -->
<i class="fas fa-check-double"></i>
批量验证
</button>
<button class="flex items-center gap-2 bg-blue-500 hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="event.stopPropagation(); resetAllKeysFailCount('invalid', event)" data-reset-type="invalid">
<i class="fas fa-redo-alt"></i>
批量重置
@@ -463,22 +492,49 @@
</div>
</div>
</div>
<!-- 操作结果模态框 -->
<div id="resultModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<!-- 验证确认模态框移到 resetModal 外部,避免嵌套导致显示异常 -->
<div id="verifyModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-white rounded-lg p-6 shadow-xl max-w-md w-full animate-fade-in">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-800" id="resultModalTitle">操作结果</h3>
<button onclick="closeResultModal()" class="text-gray-500 hover:text-gray-700 focus:outline-none">
<h3 class="text-lg font-semibold text-gray-800" id="verifyModalTitle">批量验证密钥</h3>
<button onclick="closeVerifyModal()" class="text-gray-500 hover:text-gray-700 focus:outline-none">
<i class="fas fa-times"></i>
</button>
</div>
<div class="mb-6 text-center">
<div id="resultIcon" class="text-5xl mb-3"></div>
<p class="text-gray-600" id="resultModalMessage"></p>
<div class="mb-6">
<p class="text-gray-600" id="verifyModalMessage"></p>
</div>
<div class="flex justify-center">
<button id="resultModalConfirmBtn" onclick="closeResultModal()" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition-colors">
<div class="flex justify-end gap-3">
<button onclick="closeVerifyModal()" class="px-4 py-2 bg-gray-300 hover:bg-gray-400 text-gray-800 rounded-lg transition-colors">
取消
</button>
<button id="confirmVerifyBtn" class="px-4 py-2 bg-teal-500 hover:bg-teal-600 text-white rounded-lg transition-colors">
确认验证
</button>
</div>
</div>
</div>
<!-- 操作结果模态框 -->
<div id="resultModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-white rounded-2xl p-0 shadow-2xl max-w-lg w-full animate-fade-in border border-gray-200">
<div class="flex items-center justify-between px-6 pt-6 pb-2 border-b">
<h3 class="text-xl font-bold text-gray-800 text-center w-full" id="resultModalTitle" style="letter-spacing:0.05em;">操作结果</h3>
<button onclick="closeResultModal()" class="absolute right-6 top-6 text-gray-400 hover:text-gray-700 focus:outline-none text-2xl">
<i class="fas fa-times"></i>
</button>
</div>
<div class="flex flex-col items-center px-8 pt-6 pb-2">
<div id="resultIcon" class="text-6xl mb-3"></div>
</div>
<div class="px-8 pb-2 w-full">
<div id="resultModalMessage"
class="text-gray-700 text-base leading-relaxed break-all whitespace-pre-line max-h-60 overflow-y-auto border border-gray-100 rounded-lg bg-gray-50 p-4 shadow-inner"
style="font-family: 'JetBrains Mono', 'Fira Mono', 'Consolas', 'monospace';">
</div>
</div>
<div class="flex justify-center px-8 pb-6 pt-2">
<button id="resultModalConfirmBtn" onclick="closeResultModal()" class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg font-semibold text-base shadow transition-colors">
确定
</button>
</div>

View File

@@ -1,9 +1,39 @@
version: '3'
volumes:
mysql_data:
services:
gemini-balance:
build: .
image: ghcr.io/snailyp/gemini-balance:latest
container_name: gemini-balance
restart: unless-stopped
ports:
- "8000:8000"
env_file:
- .env
depends_on:
mysql:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "python -c \"import requests; exit(0) if requests.get('http://localhost:8000/health').status_code == 200 else exit(1)\""]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
mysql:
image: mysql:8
container_name: gemini-balance-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: your_root_password
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
# ports:
# - "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1"]
interval: 10s # 每隔10秒检查一次
timeout: 5s # 每次检查的超时时间为5秒
retries: 3 # 重试3次失败后标记为 unhealthy
start_period: 30s # 容器启动后等待30秒再开始第一次健康检查