mirror of
https://github.com/snailyp/gemini-balance.git
synced 2026-07-03 22:04:18 +08:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb40848c04 | ||
|
|
7098c8755f | ||
|
|
705d602dee | ||
|
|
cd257a9406 | ||
|
|
cd54650431 | ||
|
|
a5602c602e | ||
|
|
dd70fd4c44 | ||
|
|
dbe50628b3 | ||
|
|
83ed0527d3 | ||
|
|
ab31f4bb98 | ||
|
|
734a8c4bc4 | ||
|
|
fea3af4692 | ||
|
|
9302cf295e | ||
|
|
b4f040e77a | ||
|
|
defabf4355 | ||
|
|
f3ed3168e4 | ||
|
|
01765b1731 | ||
|
|
f83f0fa768 | ||
|
|
a7085964e8 | ||
|
|
d3cd2856b7 | ||
|
|
353d22cc70 | ||
|
|
eb96474c19 | ||
|
|
0c48a2d74d |
@@ -2,6 +2,8 @@
|
||||
|
||||
> ⚠️ 本项目采用 CC BY-NC 4.0(署名-非商业性使用)协议,禁止任何形式的商业倒卖服务,详见 LICENSE 文件。
|
||||
|
||||
> 本人从未在各个平台售卖服务,如有遇到售卖此服务者,那一定是倒卖狗,大家切记不要上当受骗。
|
||||
|
||||
[](https://www.python.org/)
|
||||
[](https://fastapi.tiangolo.com/)
|
||||
[](https://www.uvicorn.org/)
|
||||
@@ -224,6 +226,10 @@ app/
|
||||
|
||||
* **[OneLine](https://github.com/chengtx809/OneLine)** by [chengtx809](https://github.com/chengtx809) - OneLine一线:AI驱动的热点事件时间轴生成工具
|
||||
|
||||
## 🎁 项目支持
|
||||
|
||||
如果你觉得这个项目对你有帮助,可以考虑通过 [爱发电](https://afdian.com/a/snaily) 支持我。
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目采用 CC BY-NC 4.0(署名-非商业性使用)协议,禁止任何形式的商业倒卖服务,详见 LICENSE 文件。
|
||||
|
||||
@@ -11,13 +11,6 @@ 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 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() # 移除顶层初始化
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@@ -206,7 +199,7 @@ async def sync_initial_settings():
|
||||
|
||||
if type_match:
|
||||
setattr(settings, key, parsed_db_value)
|
||||
logger.info(f"Updated setting '{key}' in memory from database value ({target_type}).")
|
||||
logger.debug(f"Updated setting '{key}' in memory from database value ({target_type}).")
|
||||
updated_in_memory = True
|
||||
else:
|
||||
logger.warning(f"Parsed DB value type mismatch for key '{key}'. Expected {target_type}, got {type(parsed_db_value)}. Skipping update.")
|
||||
@@ -308,10 +301,7 @@ async def sync_initial_settings():
|
||||
finally:
|
||||
if database.is_connected:
|
||||
try:
|
||||
# Don't disconnect if it's managed elsewhere (e.g., FastAPI lifespan)
|
||||
# await database.disconnect()
|
||||
# logger.info("Database connection closed after initial sync.")
|
||||
pass # Assume connection lifecycle is managed by the application lifespan
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Error disconnecting database after initial sync: {e}")
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""
|
||||
应用程序工厂模块,负责创建和配置FastAPI应用程序实例
|
||||
"""
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path # Add pathlib import
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
@@ -12,32 +10,22 @@ from app.middleware.middleware import setup_middlewares
|
||||
from app.exception.exceptions import setup_exception_handlers
|
||||
from app.router.routes import setup_routers
|
||||
from app.service.key.key_manager import get_key_manager_instance
|
||||
from app.core.initialization import initialize_app
|
||||
from app.database.connection import connect_to_db, disconnect_from_db
|
||||
from app.utils.helpers import get_current_version # Import from helpers
|
||||
from app.database.initialization import initialize_database
|
||||
from app.scheduler.key_checker import start_scheduler, stop_scheduler # 导入调度器函数
|
||||
from app.service.update.update_service import check_for_updates # 导入更新检查服务
|
||||
from app.scheduler.key_checker import start_scheduler, stop_scheduler
|
||||
from app.service.update.update_service import check_for_updates
|
||||
|
||||
logger = get_application_logger()
|
||||
|
||||
VERSION_FILE_PATH = "VERSION" # Path relative to project root
|
||||
# Define project paths using pathlib
|
||||
# Assuming this file is at app/core/application.py
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
# VERSION_FILE_PATH = PROJECT_ROOT / "VERSION" # Removed: Defined in helpers.py
|
||||
STATIC_DIR = PROJECT_ROOT / "app" / "static"
|
||||
TEMPLATES_DIR = PROJECT_ROOT / "app" / "templates"
|
||||
|
||||
def _get_current_version(default_version: str = "0.0.0") -> str:
|
||||
"""Reads the current version from the VERSION file."""
|
||||
try:
|
||||
# Assuming execution from project root d:/develop/pythonProjects/gemini-balance
|
||||
with open(VERSION_FILE_PATH, 'r', encoding='utf-8') as f:
|
||||
version = f.read().strip()
|
||||
if not version:
|
||||
logger.warning(f"VERSION file ('{VERSION_FILE_PATH}') is empty. Using default version '{default_version}'.")
|
||||
return default_version
|
||||
return version
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"VERSION file not found at '{VERSION_FILE_PATH}'. Using default version '{default_version}'.")
|
||||
return default_version
|
||||
except IOError as e:
|
||||
logger.error(f"Error reading VERSION file ('{VERSION_FILE_PATH}'): {e}. Using default version '{default_version}'.")
|
||||
return default_version
|
||||
# Removed _get_current_version function definition, moved to helpers.py
|
||||
|
||||
# 初始化模板引擎,并添加全局变量
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
@@ -51,67 +39,85 @@ def update_template_globals(app: FastAPI, update_info: dict):
|
||||
logger.info(f"Update info stored in app.state: {update_info}")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""
|
||||
应用程序生命周期管理器
|
||||
|
||||
Args:
|
||||
app: FastAPI应用实例
|
||||
"""
|
||||
# 启动事件
|
||||
logger.info("Application starting up...")
|
||||
try:
|
||||
# 初始化数据库
|
||||
initialize_database()
|
||||
logger.info("Database initialized successfully")
|
||||
|
||||
# 连接到数据库
|
||||
await connect_to_db()
|
||||
|
||||
# 同步初始配置(DB优先,然后同步回DB)
|
||||
await sync_initial_settings()
|
||||
# --- Helper functions for lifespan ---
|
||||
|
||||
# 初始化KeyManager (使用可能已从DB更新的settings)
|
||||
await get_key_manager_instance(settings.API_KEYS)
|
||||
logger.info("KeyManager initialized successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize application: {str(e)}")
|
||||
# 不重新抛出,允许应用继续运行,但记录错误
|
||||
# raise # 取消注释以在初始化失败时停止应用
|
||||
async def _setup_database_and_config(app_settings):
|
||||
"""Initializes database, syncs settings, and initializes KeyManager."""
|
||||
initialize_database()
|
||||
logger.info("Database initialized successfully")
|
||||
await connect_to_db()
|
||||
await sync_initial_settings()
|
||||
# Initialize KeyManager using potentially updated settings
|
||||
await get_key_manager_instance(app_settings.API_KEYS)
|
||||
logger.info("Database, config sync, and KeyManager initialized successfully")
|
||||
|
||||
# 检查更新 (在核心初始化之后)
|
||||
update_available, latest_version, error_message = await check_for_updates()
|
||||
update_info = {
|
||||
"update_available": update_available,
|
||||
"latest_version": latest_version,
|
||||
"error_message": error_message,
|
||||
"current_version": _get_current_version() # Read from VERSION file
|
||||
}
|
||||
# 将更新信息存储在 app.state 中
|
||||
app.state.update_info = update_info
|
||||
logger.info(f"Update check completed. Info: {update_info}")
|
||||
async def _shutdown_database():
|
||||
"""Disconnects from the database."""
|
||||
await disconnect_from_db()
|
||||
|
||||
|
||||
# 启动调度器 (如果初始化成功)
|
||||
def _start_scheduler():
|
||||
"""Starts the background scheduler."""
|
||||
try:
|
||||
start_scheduler()
|
||||
logger.info("Scheduler started successfully.")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start scheduler: {e}")
|
||||
|
||||
|
||||
yield # 应用程序运行期间
|
||||
|
||||
# 关闭事件
|
||||
logger.info("Application shutting down...")
|
||||
|
||||
# 停止调度器
|
||||
def _stop_scheduler():
|
||||
"""Stops the background scheduler."""
|
||||
stop_scheduler()
|
||||
logger.info("Scheduler stopped.")
|
||||
|
||||
# 断开数据库连接
|
||||
await disconnect_from_db()
|
||||
async def _perform_update_check(app: FastAPI):
|
||||
"""Checks for updates and stores the info in app.state."""
|
||||
update_available, latest_version, error_message = await check_for_updates()
|
||||
current_version = get_current_version() # Use imported function
|
||||
update_info = {
|
||||
"update_available": update_available,
|
||||
"latest_version": latest_version,
|
||||
"error_message": error_message,
|
||||
"current_version": current_version
|
||||
}
|
||||
# Ensure app.state exists and store update info
|
||||
if not hasattr(app, "state"):
|
||||
from starlette.datastructures import State
|
||||
app.state = State()
|
||||
app.state.update_info = update_info
|
||||
logger.info(f"Update check completed. Info: {update_info}")
|
||||
|
||||
# --- Application Lifespan ---
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""
|
||||
Manages the application startup and shutdown events.
|
||||
|
||||
Args:
|
||||
app: FastAPI应用实例
|
||||
"""
|
||||
# Startup events
|
||||
logger.info("Application starting up...")
|
||||
try:
|
||||
# Setup database, config, and KeyManager
|
||||
await _setup_database_and_config(settings) # Pass settings object
|
||||
|
||||
# Perform update check after core components are ready
|
||||
# await _perform_update_check(app) # Removed: Version check moved to frontend API call
|
||||
|
||||
# Start the scheduler
|
||||
_start_scheduler()
|
||||
|
||||
except Exception as e:
|
||||
logger.critical(f"Critical error during application startup: {str(e)}", exc_info=True)
|
||||
# Depending on the severity, you might want to prevent the app from fully starting
|
||||
# For now, we log critically and let it yield, potentially in a broken state.
|
||||
# Consider adding more robust error handling here if startup failures should halt the app.
|
||||
|
||||
yield # Application runs
|
||||
|
||||
# Shutdown events
|
||||
logger.info("Application shutting down...")
|
||||
_stop_scheduler()
|
||||
await _shutdown_database()
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""
|
||||
@@ -120,28 +126,33 @@ def create_app() -> FastAPI:
|
||||
Returns:
|
||||
FastAPI: 配置好的FastAPI应用程序实例
|
||||
"""
|
||||
# 初始化应用程序
|
||||
initialize_app()
|
||||
|
||||
# Removed: initialize_app() call
|
||||
|
||||
# 创建FastAPI应用
|
||||
# Read version from file for consistency
|
||||
current_version = get_current_version() # Use imported function
|
||||
app = FastAPI(
|
||||
title="Gemini Balance API",
|
||||
description="Gemini API代理服务,支持负载均衡和密钥管理",
|
||||
version="1.0.0",
|
||||
version=current_version,
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# 初始化 app.state (如果尚未存在)
|
||||
# Initialize app.state early to ensure it exists before lifespan potentially uses it
|
||||
if not hasattr(app, "state"):
|
||||
from starlette.datastructures import State
|
||||
app.state = State()
|
||||
# 确保 update_info 即使在 lifespan 之前访问也不会出错
|
||||
app.state.update_info = {"update_available": False, "latest_version": None, "error_message": "Checking...", "current_version": _get_current_version()} # Read from VERSION file for initial state
|
||||
|
||||
# Set a default/initial state for update_info
|
||||
app.state.update_info = {
|
||||
"update_available": False,
|
||||
"latest_version": None,
|
||||
"error_message": "Initializing...",
|
||||
"current_version": current_version # Use version read earlier
|
||||
}
|
||||
|
||||
# 配置静态文件
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
# 配置中间件
|
||||
setup_middlewares(app)
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
"""
|
||||
应用程序初始化模块
|
||||
"""
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from app.log.logger import get_initialization_logger
|
||||
|
||||
logger = get_initialization_logger()
|
||||
|
||||
|
||||
def ensure_directories_exist(directories: List[str]) -> None:
|
||||
"""
|
||||
确保指定的目录存在,如果不存在则创建
|
||||
|
||||
Args:
|
||||
directories: 要确保存在的目录列表
|
||||
"""
|
||||
for directory in directories:
|
||||
try:
|
||||
Path(directory).mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f"Ensured directory exists: {directory}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create directory {directory}: {str(e)}")
|
||||
|
||||
|
||||
def initialize_app() -> None:
|
||||
"""
|
||||
初始化应用程序,确保所需的目录和文件都存在
|
||||
"""
|
||||
# 确保必要的目录存在
|
||||
required_directories = [
|
||||
"app/static/css",
|
||||
"app/static/js",
|
||||
"app/static/icons",
|
||||
"app/templates",
|
||||
]
|
||||
|
||||
ensure_directories_exist(required_directories)
|
||||
logger.info("core initialization completed")
|
||||
@@ -3,6 +3,7 @@
|
||||
"""
|
||||
from databases import Database
|
||||
from sqlalchemy import create_engine, MetaData
|
||||
# from sqlalchemy.orm import sessionmaker # 不再需要
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
|
||||
from app.config.config import settings
|
||||
@@ -31,7 +32,9 @@ Base = declarative_base(metadata=metadata)
|
||||
# databases 库会自动处理连接失效后的重连尝试。
|
||||
database = Database(DATABASE_URL, min_size=5, max_size=20, pool_recycle=1800) # Reduced recycle time to 30 mins
|
||||
|
||||
# 移除了 SessionLocal 和 get_db 函数
|
||||
|
||||
# --- Async connection functions for lifespan/async routes ---
|
||||
async def connect_to_db():
|
||||
"""
|
||||
连接到数据库
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
"""
|
||||
数据库服务模块
|
||||
"""
|
||||
from typing import List, Optional, Dict, Any, Union
|
||||
from datetime import datetime
|
||||
from sqlalchemy import func, desc, asc, select, insert, update, delete
|
||||
import json
|
||||
from typing import Dict, List, Optional, Any, Union
|
||||
from datetime import datetime # Keep this import
|
||||
|
||||
from sqlalchemy import select, insert, update, func
|
||||
|
||||
from app.database.connection import database
|
||||
from app.database.models import Settings, ErrorLog, RequestLog # Import RequestLog
|
||||
from app.database.models import Settings, ErrorLog, RequestLog
|
||||
from app.log.logger import get_database_logger
|
||||
|
||||
logger = get_database_logger()
|
||||
@@ -157,19 +155,25 @@ async def get_error_logs(
|
||||
offset: int = 0,
|
||||
key_search: Optional[str] = None,
|
||||
error_search: Optional[str] = None,
|
||||
error_code_search: Optional[str] = None,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None
|
||||
end_date: Optional[datetime] = None,
|
||||
sort_by: str = 'id', # 新增排序字段
|
||||
sort_order: str = 'desc' # 新增排序顺序 ('asc' or 'desc')
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取错误日志,支持搜索和日期过滤
|
||||
获取错误日志,支持搜索、日期过滤和排序
|
||||
|
||||
Args:
|
||||
limit (int): 限制数量
|
||||
offset (int): 偏移量
|
||||
key_search (Optional[str]): Gemini密钥搜索词 (模糊匹配)
|
||||
error_search (Optional[str]): 错误类型或日志内容搜索词 (模糊匹配)
|
||||
error_code_search (Optional[str]): 错误码搜索词 (精确匹配)
|
||||
start_date (Optional[datetime]): 开始日期时间
|
||||
end_date (Optional[datetime]): 结束日期时间
|
||||
sort_by (str): 排序字段 (例如 'id', 'request_time')
|
||||
sort_order (str): 排序顺序 ('asc' or 'desc')
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 错误日志列表
|
||||
@@ -198,10 +202,28 @@ async def get_error_logs(
|
||||
if end_date:
|
||||
# Use the datetime object directly for comparison
|
||||
query = query.where(ErrorLog.request_time < end_date)
|
||||
if error_code_search:
|
||||
try:
|
||||
# Attempt to convert search string to integer for exact match
|
||||
error_code_int = int(error_code_search)
|
||||
query = query.where(ErrorLog.error_code == error_code_int)
|
||||
except ValueError:
|
||||
# If conversion fails, log a warning and potentially skip this filter
|
||||
# or handle as needed (e.g., return no results for invalid code format)
|
||||
logger.warning(f"Invalid format for error_code_search: '{error_code_search}'. Expected an integer. Skipping error code filter.")
|
||||
# Optionally, force no results if the format is invalid:
|
||||
# query = query.where(False) # This ensures no rows are returned
|
||||
|
||||
# 添加排序逻辑
|
||||
sort_column = getattr(ErrorLog, sort_by, ErrorLog.id) # 获取排序字段,默认为 id
|
||||
if sort_order.lower() == 'asc':
|
||||
query = query.order_by(asc(sort_column))
|
||||
else:
|
||||
query = query.order_by(desc(sort_column))
|
||||
|
||||
# Apply limit and offset
|
||||
query = query.limit(limit).offset(offset)
|
||||
|
||||
# Apply ordering, limit, and 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]
|
||||
except Exception as e:
|
||||
@@ -212,6 +234,7 @@ async def get_error_logs(
|
||||
async def get_error_logs_count(
|
||||
key_search: Optional[str] = None,
|
||||
error_search: Optional[str] = None,
|
||||
error_code_search: Optional[str] = None, # Added error code search
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None
|
||||
) -> int:
|
||||
@@ -221,6 +244,7 @@ async def get_error_logs_count(
|
||||
Args:
|
||||
key_search (Optional[str]): Gemini密钥搜索词 (模糊匹配)
|
||||
error_search (Optional[str]): 错误类型或日志内容搜索词 (模糊匹配)
|
||||
error_code_search (Optional[str]): 错误码搜索词 (精确匹配)
|
||||
start_date (Optional[datetime]): 开始日期时间
|
||||
end_date (Optional[datetime]): 结束日期时间
|
||||
|
||||
@@ -243,6 +267,16 @@ async def get_error_logs_count(
|
||||
if end_date:
|
||||
# Use the datetime object directly for comparison
|
||||
query = query.where(ErrorLog.request_time < end_date)
|
||||
if error_code_search:
|
||||
try:
|
||||
# Attempt to convert search string to integer for exact match
|
||||
error_code_int = int(error_code_search)
|
||||
query = query.where(ErrorLog.error_code == error_code_int)
|
||||
except ValueError:
|
||||
# If conversion fails, log a warning and potentially skip this filter
|
||||
logger.warning(f"Invalid format for error_code_search in count: '{error_code_search}'. Expected an integer. Skipping error code filter.")
|
||||
# Optionally, force count to 0 if the format is invalid:
|
||||
# return 0 # Or query = query.where(False) before fetching
|
||||
|
||||
count_result = await database.fetch_one(query)
|
||||
return count_result[0] if count_result else 0
|
||||
@@ -281,6 +315,68 @@ async def get_error_log_details(log_id: int) -> Optional[Dict[str, Any]]:
|
||||
logger.exception(f"Failed to get error log details for ID {log_id}: {str(e)}")
|
||||
raise
|
||||
|
||||
# --- 异步删除函数 (使用 databases 库) ---
|
||||
|
||||
async def delete_error_logs_by_ids(log_ids: List[int]) -> int:
|
||||
"""
|
||||
根据提供的 ID 列表批量删除错误日志 (异步)。
|
||||
|
||||
Args:
|
||||
log_ids: 要删除的错误日志 ID 列表。
|
||||
|
||||
Returns:
|
||||
int: 实际删除的日志数量。
|
||||
"""
|
||||
if not log_ids:
|
||||
return 0
|
||||
try:
|
||||
# 使用 databases 执行删除
|
||||
query = delete(ErrorLog).where(ErrorLog.id.in_(log_ids))
|
||||
# execute 返回受影响的行数,但 databases 库的 execute 不直接返回 rowcount
|
||||
# 我们需要先查询是否存在,或者依赖数据库约束/触发器(如果适用)
|
||||
# 或者,我们可以执行删除并假设成功,除非抛出异常
|
||||
# 为了简单起见,我们执行删除并记录日志,不精确返回删除数量
|
||||
# 如果需要精确数量,需要先执行 SELECT COUNT(*)
|
||||
await database.execute(query)
|
||||
# 注意:databases 的 execute 不返回 rowcount,所以我们不能直接返回删除的数量
|
||||
# 返回 log_ids 的长度作为尝试删除的数量,或者返回 0/1 表示操作尝试
|
||||
logger.info(f"Attempted bulk deletion for error logs with IDs: {log_ids}")
|
||||
return len(log_ids) # 返回尝试删除的数量
|
||||
except Exception as e:
|
||||
# 数据库连接或执行错误
|
||||
logger.error(f"Error during bulk deletion of error logs {log_ids}: {e}", exc_info=True)
|
||||
raise # Re-raise the exception for the router to handle
|
||||
|
||||
async def delete_error_log_by_id(log_id: int) -> bool:
|
||||
"""
|
||||
根据 ID 删除单个错误日志 (异步)。
|
||||
|
||||
Args:
|
||||
log_id: 要删除的错误日志 ID。
|
||||
|
||||
Returns:
|
||||
bool: 如果成功删除返回 True,否则返回 False。
|
||||
"""
|
||||
try:
|
||||
# 先检查是否存在 (可选,但更明确)
|
||||
check_query = select(ErrorLog.id).where(ErrorLog.id == log_id)
|
||||
exists = await database.fetch_one(check_query)
|
||||
|
||||
if not exists:
|
||||
logger.warning(f"Attempted to delete non-existent error log with ID: {log_id}")
|
||||
return False # 或者可以抛出 404 异常,由路由处理
|
||||
|
||||
# 执行删除
|
||||
delete_query = delete(ErrorLog).where(ErrorLog.id == log_id)
|
||||
await database.execute(delete_query)
|
||||
logger.info(f"Successfully deleted error log with ID: {log_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting error log with ID {log_id}: {e}", exc_info=True)
|
||||
raise # Re-raise the exception for the router to handle
|
||||
|
||||
# --- RequestLog Services (保持异步) ---
|
||||
|
||||
# 新增函数:添加请求日志
|
||||
async def add_request_log(
|
||||
model_name: Optional[str],
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
from typing import List, Optional, Dict, Any, Literal, Union
|
||||
from pydantic import BaseModel
|
||||
from typing import Any, Dict, List, Literal, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.core.constants import DEFAULT_TEMPERATURE, DEFAULT_TOP_K, DEFAULT_TOP_P
|
||||
|
||||
|
||||
class SafetySetting(BaseModel):
|
||||
category: Optional[Literal["HARM_CATEGORY_HATE_SPEECH", "HARM_CATEGORY_DANGEROUS_CONTENT", "HARM_CATEGORY_HARASSMENT", "HARM_CATEGORY_SEXUALLY_EXPLICIT", "HARM_CATEGORY_CIVIC_INTEGRITY"]] = None
|
||||
threshold: Optional[Literal["HARM_BLOCK_THRESHOLD_UNSPECIFIED", "BLOCK_LOW_AND_ABOVE", "BLOCK_MEDIUM_AND_ABOVE", "BLOCK_ONLY_HIGH", "BLOCK_NONE", "OFF"]] = None
|
||||
category: Optional[
|
||||
Literal[
|
||||
"HARM_CATEGORY_HATE_SPEECH",
|
||||
"HARM_CATEGORY_DANGEROUS_CONTENT",
|
||||
"HARM_CATEGORY_HARASSMENT",
|
||||
"HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
||||
"HARM_CATEGORY_CIVIC_INTEGRITY",
|
||||
]
|
||||
] = None
|
||||
threshold: Optional[
|
||||
Literal[
|
||||
"HARM_BLOCK_THRESHOLD_UNSPECIFIED",
|
||||
"BLOCK_LOW_AND_ABOVE",
|
||||
"BLOCK_MEDIUM_AND_ABOVE",
|
||||
"BLOCK_ONLY_HIGH",
|
||||
"BLOCK_NONE",
|
||||
"OFF",
|
||||
]
|
||||
] = None
|
||||
|
||||
|
||||
class GenerationConfig(BaseModel):
|
||||
@@ -26,7 +44,7 @@ class GenerationConfig(BaseModel):
|
||||
|
||||
class SystemInstruction(BaseModel):
|
||||
role: str = "system"
|
||||
parts: List[Dict[str, Any]]
|
||||
parts: List[Dict[str, Any]] | Dict[str, Any]
|
||||
|
||||
|
||||
class GeminiContent(BaseModel):
|
||||
@@ -37,9 +55,18 @@ class GeminiContent(BaseModel):
|
||||
class GeminiRequest(BaseModel):
|
||||
contents: List[GeminiContent] = []
|
||||
tools: Optional[Union[List[Dict[str, Any]], Dict[str, Any]]] = []
|
||||
safetySettings: Optional[List[SafetySetting]] = None
|
||||
generationConfig: Optional[GenerationConfig] = None
|
||||
systemInstruction: Optional[SystemInstruction] = None
|
||||
safetySettings: Optional[List[SafetySetting]] = Field(
|
||||
default=None, alias="safety_settings"
|
||||
)
|
||||
generationConfig: Optional[GenerationConfig] = Field(
|
||||
default=None, alias="generation_config"
|
||||
)
|
||||
systemInstruction: Optional[SystemInstruction] = Field(
|
||||
default=None, alias="system_instruction"
|
||||
)
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
|
||||
|
||||
class ResetSelectedKeysRequest(BaseModel):
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# app/services/chat/message_converter.py
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
import json
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# app/services/chat/response_handler.py
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# app/services/chat/retry_handler.py
|
||||
|
||||
from functools import wraps
|
||||
from typing import Callable, TypeVar
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# app/services/chat/stream_optimizer.py
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
@@ -107,15 +106,11 @@ class StreamOptimizer:
|
||||
|
||||
# 计算智能延迟时间
|
||||
delay = self.calculate_delay(len(text))
|
||||
# if self.logger:
|
||||
# self.logger.info(f"Text length: {len(text)}, delay: {delay:.4f}s")
|
||||
|
||||
# 根据文本长度决定输出方式
|
||||
if len(text) >= self.long_text_threshold:
|
||||
# 长文本:分块输出
|
||||
chunks = self.split_text_into_chunks(text)
|
||||
# if self.logger:
|
||||
# self.logger.info(f"Long text: splitting into {len(chunks)} chunks")
|
||||
for chunk_text in chunks:
|
||||
chunk_response = create_response_chunk(chunk_text)
|
||||
yield format_chunk(chunk_response)
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import logging
|
||||
import platform
|
||||
import sys
|
||||
from typing import Dict, Optional
|
||||
import platform
|
||||
|
||||
# ANSI转义序列颜色代码
|
||||
COLORS = {
|
||||
'DEBUG': '\033[34m', # 蓝色
|
||||
'INFO': '\033[32m', # 绿色
|
||||
'WARNING': '\033[33m', # 黄色
|
||||
'ERROR': '\033[31m', # 红色
|
||||
'CRITICAL': '\033[1;31m' # 红色加粗
|
||||
"DEBUG": "\033[34m", # 蓝色
|
||||
"INFO": "\033[32m", # 绿色
|
||||
"WARNING": "\033[33m", # 黄色
|
||||
"ERROR": "\033[31m", # 红色
|
||||
"CRITICAL": "\033[1;31m", # 红色加粗
|
||||
}
|
||||
|
||||
# Windows系统启用ANSI支持
|
||||
if platform.system() == 'Windows':
|
||||
if platform.system() == "Windows":
|
||||
import ctypes
|
||||
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
@@ -27,15 +27,17 @@ class ColoredFormatter(logging.Formatter):
|
||||
|
||||
def format(self, record):
|
||||
# 获取对应级别的颜色代码
|
||||
color = COLORS.get(record.levelname, '')
|
||||
color = COLORS.get(record.levelname, "")
|
||||
# 添加颜色代码和重置代码
|
||||
record.levelname = f"{color}{record.levelname}\033[0m"
|
||||
# 创建包含文件名和行号的固定宽度字符串
|
||||
record.fileloc = f"[{record.filename}:{record.lineno}]"
|
||||
return super().format(record)
|
||||
|
||||
|
||||
# 日志格式
|
||||
# 日志格式 - 使用 fileloc 并设置固定宽度 (例如 30)
|
||||
FORMATTER = ColoredFormatter(
|
||||
"%(asctime)s - %(name)s - %(levelname)s - [%(filename)s:%(lineno)d] - %(message)s"
|
||||
"%(asctime)s | %(levelname)-17s | %(fileloc)-30s | %(message)s"
|
||||
)
|
||||
|
||||
# 日志级别映射
|
||||
@@ -55,9 +57,7 @@ class Logger:
|
||||
_loggers: Dict[str, logging.Logger] = {}
|
||||
|
||||
@staticmethod
|
||||
def setup_logger(
|
||||
name: str
|
||||
) -> logging.Logger:
|
||||
def setup_logger(name: str) -> logging.Logger:
|
||||
"""
|
||||
设置并获取logger
|
||||
:param name: logger名称
|
||||
@@ -65,6 +65,7 @@ class Logger:
|
||||
"""
|
||||
# 导入 settings 对象
|
||||
from app.config.config import settings
|
||||
|
||||
# 从全局配置获取日志级别
|
||||
log_level_str = settings.LOG_LEVEL.lower()
|
||||
level = LOG_LEVELS.get(log_level_str, logging.INFO)
|
||||
@@ -97,7 +98,6 @@ class Logger:
|
||||
"""
|
||||
return Logger._loggers.get(name)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def update_log_levels(log_level: str):
|
||||
"""
|
||||
@@ -113,8 +113,6 @@ class Logger:
|
||||
# 可选:记录级别变更日志,但注意避免在日志模块内部产生过多日志
|
||||
# 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
|
||||
@@ -207,4 +205,4 @@ def get_update_logger():
|
||||
|
||||
|
||||
def get_scheduler_routes():
|
||||
return Logger.setup_logger("scheduler_routes")
|
||||
return Logger.setup_logger("scheduler_routes")
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
"""
|
||||
应用程序入口模块
|
||||
"""
|
||||
|
||||
import uvicorn
|
||||
|
||||
from app.core.application import create_app
|
||||
from app.log.logger import get_main_logger
|
||||
|
||||
# 创建应用程序实例
|
||||
app = create_app()
|
||||
|
||||
# 配置日志
|
||||
logger = get_main_logger()
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger = get_main_logger()
|
||||
logger.info("Starting application server...")
|
||||
uvicorn.run(app, host="0.0.0.0", port=8001)
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
"""
|
||||
日志路由模块
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from typing import List, Optional, Dict
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, HTTPException, Request, Query, Path
|
||||
from fastapi import APIRouter, HTTPException, Request, Query, Path, Body, Response, status
|
||||
|
||||
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, get_error_log_details
|
||||
from app.database.services import (
|
||||
get_error_logs,
|
||||
get_error_logs_count,
|
||||
get_error_log_details,
|
||||
delete_error_logs_by_ids, # 新增导入
|
||||
delete_error_log_by_id # 新增导入
|
||||
)
|
||||
# Removed get_db import comment as it's fully removed now
|
||||
|
||||
# 创建路由
|
||||
router = APIRouter(prefix="/api/logs", tags=["logs"])
|
||||
@@ -38,11 +45,14 @@ async def get_error_logs_api(
|
||||
offset: int = Query(0, ge=0),
|
||||
key_search: Optional[str] = Query(None, description="Search term for Gemini key (partial match)"),
|
||||
error_search: Optional[str] = Query(None, description="Search term for error type or log message"), # 数据库查询需处理
|
||||
error_code_search: Optional[str] = Query(None, description="Search term for error code"), # Added error code search parameter
|
||||
start_date: Optional[datetime] = Query(None, description="Start datetime for filtering"),
|
||||
end_date: Optional[datetime] = Query(None, description="End datetime for filtering")
|
||||
end_date: Optional[datetime] = Query(None, description="End datetime for filtering"),
|
||||
sort_by: str = Query('id', description="Field to sort by (e.g., 'id', 'request_time')"), # 新增排序参数
|
||||
sort_order: str = Query('desc', description="Sort order ('asc' or 'desc')") # 新增排序参数
|
||||
):
|
||||
"""
|
||||
获取错误日志列表 (返回错误码)
|
||||
获取错误日志列表 (返回错误码),支持过滤和排序
|
||||
|
||||
Args:
|
||||
request: 请求对象
|
||||
@@ -50,8 +60,11 @@ async def get_error_logs_api(
|
||||
offset: 偏移量
|
||||
key_search: 密钥搜索
|
||||
error_search: 错误搜索 (可能搜索类型或日志内容,由DB层决定)
|
||||
error_code_search: 错误码搜索
|
||||
start_date: 开始日期
|
||||
end_date: 结束日期
|
||||
sort_by: 排序字段
|
||||
sort_order: 排序顺序
|
||||
|
||||
Returns:
|
||||
ErrorLogListResponse: An object containing the list of logs (with error_code) and the total count.
|
||||
@@ -70,14 +83,17 @@ async def get_error_logs_api(
|
||||
offset=offset,
|
||||
key_search=key_search,
|
||||
error_search=error_search, # 数据库查询需要处理这个
|
||||
error_code_search=error_code_search, # Pass error code search to DB function
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
# include_error_code=True # 如果需要显式传递
|
||||
sort_by=sort_by, # 传递排序参数
|
||||
sort_order=sort_order # 传递排序参数
|
||||
)
|
||||
# Fetch total count with the same search parameters
|
||||
total_count = await get_error_logs_count(
|
||||
key_search=key_search,
|
||||
error_search=error_search,
|
||||
error_code_search=error_code_search, # Pass error code search to DB count function
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
)
|
||||
@@ -123,3 +139,63 @@ async def get_error_log_detail_api(request: Request, log_id: int = Path(..., ge=
|
||||
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)}")
|
||||
|
||||
|
||||
# 新增:批量删除错误日志
|
||||
@router.delete("/errors", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_error_logs_bulk_api(
|
||||
request: Request,
|
||||
payload: Dict[str, List[int]] = Body(...) # Expects {"ids": [1, 2, 3]}
|
||||
# Ensure db dependency is fully removed
|
||||
):
|
||||
"""
|
||||
批量删除错误日志 (异步)
|
||||
"""
|
||||
auth_token = request.cookies.get("auth_token")
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning("Unauthorized access attempt to bulk delete error logs")
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
log_ids = payload.get("ids")
|
||||
if not log_ids:
|
||||
raise HTTPException(status_code=400, detail="No log IDs provided for deletion.")
|
||||
|
||||
try:
|
||||
# 调用异步服务函数
|
||||
deleted_count = await delete_error_logs_by_ids(log_ids)
|
||||
# 注意:异步函数返回的是尝试删除的数量,可能不是精确值
|
||||
logger.info(f"Attempted bulk deletion for {deleted_count} error logs with IDs: {log_ids}")
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error bulk deleting error logs with IDs {log_ids}: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error during bulk deletion")
|
||||
|
||||
|
||||
# 新增:删除单个错误日志
|
||||
@router.delete("/errors/{log_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_error_log_api(
|
||||
request: Request,
|
||||
log_id: int = Path(..., ge=1)
|
||||
# Ensure db dependency is fully removed
|
||||
):
|
||||
"""
|
||||
删除单个错误日志 (异步)
|
||||
"""
|
||||
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 delete error log ID: {log_id}")
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
try:
|
||||
# 调用异步服务函数
|
||||
success = await delete_error_log_by_id(log_id)
|
||||
if not success:
|
||||
# 服务层现在在未找到时返回 False,我们在这里转换为 404
|
||||
raise HTTPException(status_code=404, detail=f"Error log with ID {log_id} not found")
|
||||
logger.info(f"Successfully deleted error log with ID: {log_id}")
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
except HTTPException as http_exc:
|
||||
raise http_exc # Re-raise 404 or other HTTP exceptions
|
||||
except Exception as e:
|
||||
logger.exception(f"Error deleting error log with ID {log_id}: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error during deletion")
|
||||
@@ -325,13 +325,12 @@ async def verify_selected_keys(
|
||||
if not keys_to_verify:
|
||||
return JSONResponse({"success": False, "message": "没有提供需要验证的密钥"}, status_code=400)
|
||||
|
||||
valid_count = 0
|
||||
invalid_count = 0
|
||||
verification_errors = {} # 存储验证过程中的错误
|
||||
successful_keys = []
|
||||
failed_keys = {} # 存储失败的 key 和错误信息
|
||||
|
||||
async def _verify_single_key(api_key: str):
|
||||
"""内部函数,用于验证单个密钥并处理异常"""
|
||||
nonlocal valid_count, invalid_count # 允许修改外部计数器
|
||||
nonlocal successful_keys, failed_keys # 允许修改外部列表和字典
|
||||
try:
|
||||
# 重用单密钥验证逻辑的核心部分
|
||||
gemini_request = GeminiRequest(
|
||||
@@ -344,7 +343,7 @@ async def verify_selected_keys(
|
||||
api_key
|
||||
)
|
||||
# 如果上面没有抛出异常,则认为密钥有效
|
||||
valid_count += 1
|
||||
successful_keys.append(api_key)
|
||||
return api_key, "valid", None
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
@@ -358,7 +357,7 @@ async def verify_selected_keys(
|
||||
# 如果密钥不在计数中(可能刚添加或从未失败),初始化为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
|
||||
failed_keys[api_key] = error_message # 记录失败的 key 和错误信息
|
||||
return api_key, "invalid", error_message
|
||||
|
||||
# 并发执行所有密钥的验证
|
||||
@@ -373,28 +372,39 @@ async def verify_selected_keys(
|
||||
# 可以选择如何处理这种任务级别的错误,这里我们简单记录
|
||||
# 也可以将其计入 invalid_count 或单独记录
|
||||
elif result:
|
||||
key, status, error = result
|
||||
if status == "invalid" and error:
|
||||
verification_errors[key] = error # 记录具体的验证错误信息
|
||||
# result 可能是 (key, status, error) 或 Exception
|
||||
if not isinstance(result, Exception) and result:
|
||||
key, status, error = result
|
||||
# 失败信息已在 _verify_single_key 中记录到 failed_keys
|
||||
elif isinstance(result, Exception):
|
||||
# 记录任务本身的异常,可以关联到一个特定的 key 如果可能的话
|
||||
# 这里简化处理,只记录日志
|
||||
logger.error(f"Task execution error during bulk verification: {result}")
|
||||
|
||||
valid_count = len(successful_keys)
|
||||
invalid_count = len(failed_keys)
|
||||
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 '任务执行异常'}"
|
||||
# 根据是否有失败的 key 决定最终消息和状态
|
||||
if failed_keys:
|
||||
message = f"批量验证完成。成功: {valid_count}, 失败: {invalid_count}。"
|
||||
# 即使有失败也认为是部分成功,返回 200 OK,让前端处理详细结果
|
||||
return JSONResponse({
|
||||
"success": False, # 标记为失败,因为有错误
|
||||
"success": True, # 表示请求处理完成,具体结果看内容
|
||||
"message": message,
|
||||
"valid_count": valid_count,
|
||||
"invalid_count": invalid_count,
|
||||
"errors": verification_errors
|
||||
}, status_code=207) # 207 Multi-Status 表示部分成功/失败
|
||||
"successful_keys": successful_keys,
|
||||
"failed_keys": failed_keys,
|
||||
"valid_count": valid_count, # 保留计数方便前端快速展示
|
||||
"invalid_count": invalid_count
|
||||
})
|
||||
else:
|
||||
# 完全成功
|
||||
message = f"批量验证成功完成。所有 {valid_count} 个密钥均有效。"
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"message": f"批量验证成功完成。有效: {valid_count}, 无效: {invalid_count}",
|
||||
"message": message,
|
||||
"successful_keys": successful_keys,
|
||||
"failed_keys": {},
|
||||
"valid_count": valid_count,
|
||||
"invalid_count": invalid_count
|
||||
"invalid_count": 0
|
||||
})
|
||||
@@ -8,7 +8,7 @@ from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.core.security import verify_auth_token
|
||||
from app.log.logger import get_routes_logger
|
||||
from app.router import gemini_routes, openai_routes, config_routes, log_routes, scheduler_routes, stats_routes # 新增导入 stats_routes
|
||||
from app.router import error_log_routes, gemini_routes, openai_routes, config_routes, scheduler_routes, stats_routes, version_routes # 新增导入 version_routes
|
||||
from app.service.key.key_manager import get_key_manager_instance
|
||||
from app.service.stats_service import StatsService
|
||||
|
||||
@@ -30,9 +30,10 @@ def setup_routers(app: FastAPI) -> None:
|
||||
app.include_router(gemini_routes.router)
|
||||
app.include_router(gemini_routes.router_v1beta)
|
||||
app.include_router(config_routes.router)
|
||||
app.include_router(log_routes.router)
|
||||
app.include_router(error_log_routes.router)
|
||||
app.include_router(scheduler_routes.router) # 新增包含 scheduler 路由
|
||||
app.include_router(stats_routes.router) # 包含 stats API 路由
|
||||
app.include_router(version_routes.router) # 包含 version API 路由
|
||||
|
||||
# 添加页面路由
|
||||
setup_page_routes(app)
|
||||
|
||||
@@ -2,12 +2,11 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from starlette import status
|
||||
from app.core.security import verify_auth_token
|
||||
from app.service.stats_service import StatsService
|
||||
from app.log.logger import get_stats_logger # 使用路由日志记录器
|
||||
from app.log.logger import get_stats_logger
|
||||
|
||||
logger = get_stats_logger()
|
||||
|
||||
|
||||
# 认证检查的辅助函数
|
||||
async def verify_token(request: Request):
|
||||
auth_token = request.cookies.get("auth_token")
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
@@ -21,7 +20,7 @@ async def verify_token(request: Request):
|
||||
router = APIRouter(
|
||||
prefix="/api",
|
||||
tags=["stats"],
|
||||
dependencies=[Depends(verify_token)] # Assuming API routes need authentication
|
||||
dependencies=[Depends(verify_token)]
|
||||
)
|
||||
|
||||
stats_service = StatsService()
|
||||
@@ -52,8 +51,7 @@ async def get_key_usage_details(key: str):
|
||||
return {}
|
||||
return usage_details
|
||||
except Exception as e:
|
||||
# Log the exception details here if needed
|
||||
print(f"Error fetching key usage details for key {key[:4]}...: {e}")
|
||||
logger.error(f"Error fetching key usage details for key {key[:4]}...: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取密钥使用详情时出错: {e}"
|
||||
|
||||
38
app/router/version_routes.py
Normal file
38
app/router/version_routes.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
from app.service.update.update_service import check_for_updates
|
||||
from app.utils.helpers import get_current_version
|
||||
from app.log.logger import get_update_logger
|
||||
|
||||
router = APIRouter(prefix="/api/version", tags=["Version"])
|
||||
logger = get_update_logger()
|
||||
|
||||
class VersionInfo(BaseModel):
|
||||
current_version: str = Field(..., description="当前应用程序版本")
|
||||
latest_version: Optional[str] = Field(None, description="可用的最新版本")
|
||||
update_available: bool = Field(False, description="是否有可用更新")
|
||||
error_message: Optional[str] = Field(None, description="检查更新时发生的错误信息")
|
||||
|
||||
@router.get("/check", response_model=VersionInfo, summary="检查应用程序更新")
|
||||
async def get_version_info():
|
||||
"""
|
||||
检查当前应用程序版本与最新的 GitHub release 版本。
|
||||
"""
|
||||
try:
|
||||
current_version = get_current_version() # Use imported function
|
||||
update_available, latest_version, error_message = await check_for_updates()
|
||||
|
||||
# Log the result for debugging
|
||||
logger.info(f"Version check API result: current={current_version}, latest={latest_version}, available={update_available}, error='{error_message}'")
|
||||
|
||||
return VersionInfo(
|
||||
current_version=current_version,
|
||||
latest_version=latest_version,
|
||||
update_available=update_available,
|
||||
error_message=error_message
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in /api/version/check endpoint: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="检查版本信息时发生内部错误")
|
||||
@@ -90,8 +90,10 @@ scheduler_instance = None
|
||||
|
||||
def start_scheduler():
|
||||
global scheduler_instance
|
||||
if scheduler_instance is None:
|
||||
if scheduler_instance is None or not scheduler_instance.running:
|
||||
logger.info("Starting scheduler...")
|
||||
scheduler_instance = setup_scheduler()
|
||||
logger.info("Scheduler is already running.")
|
||||
|
||||
def stop_scheduler():
|
||||
global scheduler_instance
|
||||
|
||||
@@ -411,7 +411,6 @@ class OpenAIChatService:
|
||||
error_log_msg = f"Stream image completion failed for model {model}: {e}"
|
||||
logger.error(error_log_msg)
|
||||
status_code = 500 # Default error code
|
||||
# Call add_error_log using the passed api_key
|
||||
await add_error_log(
|
||||
gemini_key=api_key,
|
||||
model_name=model,
|
||||
@@ -427,7 +426,6 @@ class OpenAIChatService:
|
||||
end_time = time.perf_counter()
|
||||
latency_ms = int((end_time - start_time) * 1000)
|
||||
logger.info(f"Stream image completion for model {model} took {latency_ms} ms. Success: {is_success}")
|
||||
# Call add_request_log using the passed api_key
|
||||
await add_request_log(
|
||||
model_name=model,
|
||||
api_key=api_key,
|
||||
@@ -460,7 +458,6 @@ class OpenAIChatService:
|
||||
error_log_msg = f"Normal image completion failed for model {model}: {e}"
|
||||
logger.error(error_log_msg)
|
||||
status_code = 500 # Default error code
|
||||
# Call add_error_log using the passed api_key
|
||||
await add_error_log(
|
||||
gemini_key=api_key,
|
||||
model_name=model,
|
||||
@@ -475,7 +472,6 @@ class OpenAIChatService:
|
||||
end_time = time.perf_counter()
|
||||
latency_ms = int((end_time - start_time) * 1000)
|
||||
logger.info(f"Normal image completion for model {model} took {latency_ms} ms. Success: {is_success}")
|
||||
# Call add_request_log using the passed api_key
|
||||
await add_request_log(
|
||||
model_name=model,
|
||||
api_key=api_key,
|
||||
|
||||
@@ -31,7 +31,7 @@ class ConfigService:
|
||||
for key, value in config_data.items():
|
||||
if hasattr(settings, key):
|
||||
setattr(settings, key, value)
|
||||
logger.info(f"Updated setting in memory: {key}")
|
||||
logger.debug(f"Updated setting in memory: {key}")
|
||||
|
||||
# 获取现有设置
|
||||
existing_settings_raw: List[Dict[str, Any]] = await get_all_settings()
|
||||
|
||||
@@ -88,7 +88,6 @@ class ImageCreateService:
|
||||
aspect_ratio=self.aspect_ratio,
|
||||
safety_filter_level="BLOCK_LOW_AND_ABOVE",
|
||||
person_generation="ALLOW_ADULT",
|
||||
# language="auto"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -17,9 +17,14 @@ let currentPage = 1;
|
||||
let pageSize = 10;
|
||||
// let totalPages = 1; // totalPages will be calculated dynamically based on API response if available, or based on fetched data length
|
||||
let errorLogs = []; // Store fetched logs for details view
|
||||
let currentSort = { // 新增:存储当前排序状态
|
||||
field: 'id', // 默认按 ID 排序
|
||||
order: 'desc' // 默认降序
|
||||
};
|
||||
let currentSearch = { // Store current search parameters
|
||||
key: '',
|
||||
error: '',
|
||||
errorCode: '', // Added error code search
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
};
|
||||
@@ -36,11 +41,24 @@ let logDetailModal;
|
||||
let modalCloseBtns; // Collection of close buttons for the modal
|
||||
let keySearchInput;
|
||||
let errorSearchInput;
|
||||
let errorCodeSearchInput; // Added error code input
|
||||
let startDateInput;
|
||||
let endDateInput;
|
||||
let searchBtn;
|
||||
let pageInput; // 新增:页码输入框
|
||||
let goToPageBtn; // 新增:跳转按钮
|
||||
let pageInput;
|
||||
let goToPageBtn;
|
||||
let selectAllCheckbox; // 新增:全选复选框
|
||||
let copySelectedKeysBtn; // 新增:复制选中按钮
|
||||
let deleteSelectedBtn; // 新增:批量删除按钮
|
||||
let sortByIdHeader; // 新增:ID 排序表头
|
||||
let sortIcon; // 新增:排序图标
|
||||
let selectedCountSpan; // 新增:选中计数显示
|
||||
let deleteConfirmModal; // 新增:删除确认模态框
|
||||
let closeDeleteConfirmModalBtn; // 新增:关闭删除模态框按钮
|
||||
let cancelDeleteBtn; // 新增:取消删除按钮
|
||||
let confirmDeleteBtn; // 新增:确认删除按钮
|
||||
let deleteConfirmMessage; // 新增:删除确认消息元素
|
||||
let idsToDeleteGlobally = []; // 新增:存储待删除的ID
|
||||
|
||||
// 页面加载完成后执行
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
@@ -57,11 +75,25 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
modalCloseBtns = document.querySelectorAll('#closeLogDetailModalBtn, #closeModalFooterBtn');
|
||||
keySearchInput = document.getElementById('keySearch');
|
||||
errorSearchInput = document.getElementById('errorSearch');
|
||||
errorCodeSearchInput = document.getElementById('errorCodeSearch'); // Get error code input
|
||||
startDateInput = document.getElementById('startDate');
|
||||
endDateInput = document.getElementById('endDate');
|
||||
searchBtn = document.getElementById('searchBtn');
|
||||
pageInput = document.getElementById('pageInput'); // 新增
|
||||
goToPageBtn = document.getElementById('goToPageBtn'); // 新增
|
||||
pageInput = document.getElementById('pageInput');
|
||||
goToPageBtn = document.getElementById('goToPageBtn');
|
||||
selectAllCheckbox = document.getElementById('selectAllCheckbox'); // 新增
|
||||
copySelectedKeysBtn = document.getElementById('copySelectedKeysBtn'); // 新增
|
||||
deleteSelectedBtn = document.getElementById('deleteSelectedBtn'); // 新增
|
||||
sortByIdHeader = document.getElementById('sortById'); // 新增
|
||||
if (sortByIdHeader) {
|
||||
sortIcon = sortByIdHeader.querySelector('i'); // 新增
|
||||
}
|
||||
selectedCountSpan = document.getElementById('selectedCount'); // 新增
|
||||
deleteConfirmModal = document.getElementById('deleteConfirmModal'); // 新增
|
||||
closeDeleteConfirmModalBtn = document.getElementById('closeDeleteConfirmModalBtn'); // 新增
|
||||
cancelDeleteBtn = document.getElementById('cancelDeleteBtn'); // 新增
|
||||
confirmDeleteBtn = document.getElementById('confirmDeleteBtn'); // 新增
|
||||
deleteConfirmMessage = document.getElementById('deleteConfirmMessage'); // 新增
|
||||
|
||||
// Initialize page size selector
|
||||
if (pageSizeSelector) {
|
||||
@@ -81,6 +113,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Update search parameters from input fields
|
||||
currentSearch.key = keySearchInput ? keySearchInput.value.trim() : '';
|
||||
currentSearch.error = errorSearchInput ? errorSearchInput.value.trim() : '';
|
||||
currentSearch.errorCode = errorCodeSearchInput ? errorCodeSearchInput.value.trim() : ''; // Get error code value
|
||||
currentSearch.startDate = startDateInput ? startDateInput.value : '';
|
||||
currentSearch.endDate = endDateInput ? endDateInput.value : '';
|
||||
currentPage = 1; // Reset to first page on new search
|
||||
@@ -104,8 +137,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initial load of error logs
|
||||
loadErrorLogs();
|
||||
|
||||
// Add event listeners for copy buttons inside the modal
|
||||
setupCopyButtons();
|
||||
// Add event listeners for copy buttons inside the modal and table
|
||||
setupCopyButtons(); // This will now also handle table copy buttons if called after render
|
||||
|
||||
// Add event listeners for bulk selection
|
||||
setupBulkSelectionListeners(); // 新增:设置批量选择监听器
|
||||
|
||||
// 新增:为页码跳转按钮添加事件监听器
|
||||
if (goToPageBtn && pageInput) {
|
||||
@@ -132,8 +168,63 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 新增:为批量删除按钮添加事件监听器
|
||||
if (deleteSelectedBtn) {
|
||||
deleteSelectedBtn.addEventListener('click', handleDeleteSelected);
|
||||
}
|
||||
|
||||
// 新增:为 ID 排序表头添加事件监听器
|
||||
if (sortByIdHeader) {
|
||||
sortByIdHeader.addEventListener('click', handleSortById);
|
||||
}
|
||||
|
||||
// 新增:为删除确认模态框按钮添加事件监听器
|
||||
if (closeDeleteConfirmModalBtn) {
|
||||
closeDeleteConfirmModalBtn.addEventListener('click', hideDeleteConfirmModal);
|
||||
}
|
||||
if (cancelDeleteBtn) {
|
||||
cancelDeleteBtn.addEventListener('click', hideDeleteConfirmModal);
|
||||
}
|
||||
if (confirmDeleteBtn) {
|
||||
confirmDeleteBtn.addEventListener('click', handleConfirmDelete);
|
||||
}
|
||||
// Optional: Close modal if clicking outside the content
|
||||
if (deleteConfirmModal) {
|
||||
deleteConfirmModal.addEventListener('click', function(event) {
|
||||
if (event.target === deleteConfirmModal) {
|
||||
hideDeleteConfirmModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 新增:显示删除确认模态框
|
||||
function showDeleteConfirmModal(message) {
|
||||
if (deleteConfirmModal && deleteConfirmMessage) {
|
||||
deleteConfirmMessage.textContent = message;
|
||||
deleteConfirmModal.classList.add('show');
|
||||
document.body.style.overflow = 'hidden'; // Prevent body scrolling
|
||||
}
|
||||
}
|
||||
|
||||
// 新增:隐藏删除确认模态框
|
||||
function hideDeleteConfirmModal() {
|
||||
if (deleteConfirmModal) {
|
||||
deleteConfirmModal.classList.remove('show');
|
||||
document.body.style.overflow = ''; // Restore body scrolling
|
||||
idsToDeleteGlobally = []; // 清空待删除ID
|
||||
}
|
||||
}
|
||||
|
||||
// 新增:处理确认删除按钮点击
|
||||
function handleConfirmDelete() {
|
||||
if (idsToDeleteGlobally.length > 0) {
|
||||
performActualDelete(idsToDeleteGlobally);
|
||||
}
|
||||
hideDeleteConfirmModal(); // 关闭模态框
|
||||
}
|
||||
|
||||
// Fallback copy function using document.execCommand
|
||||
function fallbackCopyTextToClipboard(text) {
|
||||
const textArea = document.createElement("textarea");
|
||||
@@ -174,44 +265,315 @@ function handleCopyResult(buttonElement, success) {
|
||||
setTimeout(() => { iconElement.className = originalIcon; }, success ? 2000 : 3000); // Restore original icon class
|
||||
}
|
||||
|
||||
// Function to set up copy button listeners (using modern API with fallback)
|
||||
function setupCopyButtons() {
|
||||
const copyButtons = document.querySelectorAll('.copy-btn');
|
||||
// Function to set up copy button listeners (using modern API with fallback) - Updated to handle table copy buttons
|
||||
function setupCopyButtons(containerSelector = 'body') {
|
||||
// Find buttons within the specified container (defaults to body)
|
||||
const container = document.querySelector(containerSelector);
|
||||
if (!container) return;
|
||||
|
||||
const copyButtons = container.querySelectorAll('.copy-btn');
|
||||
copyButtons.forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const targetId = this.getAttribute('data-target');
|
||||
const targetElement = document.getElementById(targetId);
|
||||
|
||||
if (targetElement) {
|
||||
const textToCopy = targetElement.textContent;
|
||||
let copySuccess = false;
|
||||
|
||||
// Try modern clipboard API first (requires HTTPS or localhost)
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
handleCopyResult(this, true); // Use helper for feedback
|
||||
}).catch(err => {
|
||||
console.error('Clipboard API failed, attempting fallback:', err);
|
||||
// Attempt fallback if modern API fails
|
||||
copySuccess = fallbackCopyTextToClipboard(textToCopy);
|
||||
handleCopyResult(this, copySuccess); // Use helper for feedback
|
||||
});
|
||||
} else {
|
||||
// Use fallback if modern API is not available or context is insecure
|
||||
console.warn("Clipboard API not available or context insecure. Using fallback copy method.");
|
||||
copySuccess = fallbackCopyTextToClipboard(textToCopy);
|
||||
handleCopyResult(this, copySuccess); // Use helper for feedback
|
||||
}
|
||||
} else {
|
||||
console.error('Target element not found:', targetId);
|
||||
showNotification('复制出错:找不到目标元素', 'error');
|
||||
}
|
||||
});
|
||||
// Remove existing listener to prevent duplicates if called multiple times
|
||||
button.removeEventListener('click', handleCopyButtonClick);
|
||||
// Add the listener
|
||||
button.addEventListener('click', handleCopyButtonClick);
|
||||
});
|
||||
}
|
||||
|
||||
// Extracted click handler logic for reusability and removing listeners
|
||||
function handleCopyButtonClick() {
|
||||
const button = this; // 'this' refers to the button clicked
|
||||
const targetId = button.getAttribute('data-target');
|
||||
const textToCopyDirect = button.getAttribute('data-copy-text'); // For direct text copy (e.g., table key)
|
||||
let textToCopy = '';
|
||||
|
||||
if (textToCopyDirect) {
|
||||
textToCopy = textToCopyDirect;
|
||||
} else if (targetId) {
|
||||
const targetElement = document.getElementById(targetId);
|
||||
if (targetElement) {
|
||||
textToCopy = targetElement.textContent;
|
||||
} else {
|
||||
console.error('Target element not found:', targetId);
|
||||
showNotification('复制出错:找不到目标元素', 'error');
|
||||
return; // Exit if target element not found
|
||||
}
|
||||
} else {
|
||||
console.error('No data-target or data-copy-text attribute found on button:', button);
|
||||
showNotification('复制出错:未指定复制内容', 'error');
|
||||
return; // Exit if no source specified
|
||||
}
|
||||
|
||||
|
||||
if (textToCopy) {
|
||||
let copySuccess = false;
|
||||
// Try modern clipboard API first (requires HTTPS or localhost)
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
handleCopyResult(button, true); // Use helper for feedback
|
||||
}).catch(err => {
|
||||
console.error('Clipboard API failed, attempting fallback:', err);
|
||||
// Attempt fallback if modern API fails
|
||||
copySuccess = fallbackCopyTextToClipboard(textToCopy);
|
||||
handleCopyResult(button, copySuccess); // Use helper for feedback
|
||||
});
|
||||
} else {
|
||||
// Use fallback if modern API is not available or context is insecure
|
||||
console.warn("Clipboard API not available or context insecure. Using fallback copy method.");
|
||||
copySuccess = fallbackCopyTextToClipboard(textToCopy);
|
||||
handleCopyResult(button, copySuccess); // Use helper for feedback
|
||||
}
|
||||
} else {
|
||||
console.warn('No text found to copy for target:', targetId || 'direct text');
|
||||
showNotification('没有内容可复制', 'warning');
|
||||
}
|
||||
} // End of handleCopyButtonClick function
|
||||
|
||||
// Function to set up copy button listeners (using modern API with fallback) - Updated to handle table copy buttons
|
||||
function setupCopyButtons(containerSelector = 'body') {
|
||||
// Find buttons within the specified container (defaults to body)
|
||||
const container = document.querySelector(containerSelector);
|
||||
if (!container) return;
|
||||
|
||||
const copyButtons = container.querySelectorAll('.copy-btn');
|
||||
copyButtons.forEach(button => {
|
||||
// Remove existing listener to prevent duplicates if called multiple times
|
||||
button.removeEventListener('click', handleCopyButtonClick);
|
||||
// Add the listener
|
||||
button.addEventListener('click', handleCopyButtonClick);
|
||||
});
|
||||
}
|
||||
|
||||
// 新增:设置批量选择相关的事件监听器
|
||||
function setupBulkSelectionListeners() {
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.addEventListener('change', handleSelectAllChange);
|
||||
}
|
||||
|
||||
if (tableBody) {
|
||||
// 使用事件委托处理行复选框的点击
|
||||
tableBody.addEventListener('change', handleRowCheckboxChange);
|
||||
}
|
||||
|
||||
if (copySelectedKeysBtn) {
|
||||
copySelectedKeysBtn.addEventListener('click', handleCopySelectedKeys);
|
||||
}
|
||||
|
||||
// 新增:为批量删除按钮添加事件监听器 (如果尚未添加)
|
||||
// 通常在 DOMContentLoaded 中添加一次即可
|
||||
// if (deleteSelectedBtn && !deleteSelectedBtn.hasListener) {
|
||||
// deleteSelectedBtn.addEventListener('click', handleDeleteSelected);
|
||||
// deleteSelectedBtn.hasListener = true; // 标记已添加
|
||||
// }
|
||||
}
|
||||
|
||||
// 新增:处理“全选”复选框变化的函数
|
||||
function handleSelectAllChange() {
|
||||
const isChecked = selectAllCheckbox.checked;
|
||||
const rowCheckboxes = tableBody.querySelectorAll('.row-checkbox');
|
||||
rowCheckboxes.forEach(checkbox => {
|
||||
checkbox.checked = isChecked;
|
||||
});
|
||||
updateSelectedState();
|
||||
}
|
||||
|
||||
// 新增:处理行复选框变化的函数 (事件委托)
|
||||
function handleRowCheckboxChange(event) {
|
||||
if (event.target.classList.contains('row-checkbox')) {
|
||||
updateSelectedState();
|
||||
}
|
||||
}
|
||||
|
||||
// 新增:更新选中状态(计数、按钮状态、全选框状态)
|
||||
function updateSelectedState() {
|
||||
const rowCheckboxes = tableBody.querySelectorAll('.row-checkbox');
|
||||
const selectedCheckboxes = tableBody.querySelectorAll('.row-checkbox:checked');
|
||||
const selectedCount = selectedCheckboxes.length;
|
||||
|
||||
// 移除了数字显示,不再更新selectedCountSpan
|
||||
// 仍然更新复制按钮的禁用状态
|
||||
if (copySelectedKeysBtn) {
|
||||
copySelectedKeysBtn.disabled = selectedCount === 0;
|
||||
|
||||
// 可选:根据选中项数量更新按钮标题属性
|
||||
copySelectedKeysBtn.setAttribute('title', `复制${selectedCount}项选中密钥`);
|
||||
}
|
||||
// 新增:更新批量删除按钮的禁用状态
|
||||
if (deleteSelectedBtn) {
|
||||
deleteSelectedBtn.disabled = selectedCount === 0;
|
||||
deleteSelectedBtn.setAttribute('title', `删除${selectedCount}项选中日志`);
|
||||
}
|
||||
|
||||
// 更新“全选”复选框的状态
|
||||
if (selectAllCheckbox) {
|
||||
if (rowCheckboxes.length > 0 && selectedCount === rowCheckboxes.length) {
|
||||
selectAllCheckbox.checked = true;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
} else if (selectedCount > 0) {
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = true; // 部分选中状态
|
||||
} else {
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 新增:处理“复制选中密钥”按钮点击的函数
|
||||
function handleCopySelectedKeys() {
|
||||
const selectedCheckboxes = tableBody.querySelectorAll('.row-checkbox:checked');
|
||||
const keysToCopy = [];
|
||||
selectedCheckboxes.forEach(checkbox => {
|
||||
const key = checkbox.getAttribute('data-key');
|
||||
if (key) {
|
||||
keysToCopy.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
if (keysToCopy.length > 0) {
|
||||
const textToCopy = keysToCopy.join('\n'); // 每行一个密钥
|
||||
copyTextToClipboard(textToCopy, copySelectedKeysBtn); // 使用通用复制函数
|
||||
} else {
|
||||
showNotification('没有选中的密钥可复制', 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
// 新增:通用的文本复制函数(结合现有逻辑)
|
||||
function copyTextToClipboard(text, buttonElement = null) {
|
||||
let copySuccess = false;
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
if (buttonElement) handleCopyResult(buttonElement, true);
|
||||
else showNotification('已复制到剪贴板', 'success');
|
||||
}).catch(err => {
|
||||
console.error('Clipboard API failed, attempting fallback:', err);
|
||||
copySuccess = fallbackCopyTextToClipboard(text);
|
||||
if (buttonElement) handleCopyResult(buttonElement, copySuccess);
|
||||
else showNotification(copySuccess ? '已复制到剪贴板' : '复制失败', copySuccess ? 'success' : 'error');
|
||||
});
|
||||
} else {
|
||||
console.warn("Clipboard API not available or context insecure. Using fallback copy method.");
|
||||
copySuccess = fallbackCopyTextToClipboard(text);
|
||||
if (buttonElement) handleCopyResult(buttonElement, copySuccess);
|
||||
else showNotification(copySuccess ? '已复制到剪贴板' : '复制失败', copySuccess ? 'success' : 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 修改:处理批量删除按钮点击的函数 - 改为显示模态框
|
||||
function handleDeleteSelected() {
|
||||
const selectedCheckboxes = tableBody.querySelectorAll('.row-checkbox:checked');
|
||||
const logIdsToDelete = [];
|
||||
selectedCheckboxes.forEach(checkbox => {
|
||||
const logId = checkbox.getAttribute('data-log-id'); // 需要在渲染时添加 data-log-id
|
||||
if (logId) {
|
||||
logIdsToDelete.push(parseInt(logId));
|
||||
}
|
||||
});
|
||||
|
||||
if (logIdsToDelete.length === 0) {
|
||||
showNotification('没有选中的日志可删除', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (logIdsToDelete.length === 0) {
|
||||
showNotification('没有选中的日志可删除', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// 存储待删除ID并显示模态框
|
||||
idsToDeleteGlobally = logIdsToDelete;
|
||||
const message = `确定要删除选中的 ${logIdsToDelete.length} 条日志吗?此操作不可恢复!`;
|
||||
showDeleteConfirmModal(message);
|
||||
}
|
||||
|
||||
// 新增:执行实际的删除操作(提取自原 handleDeleteSelected 和 handleDeleteLogRow)
|
||||
async function performActualDelete(logIds) {
|
||||
if (!logIds || logIds.length === 0) return;
|
||||
|
||||
const isSingleDelete = logIds.length === 1;
|
||||
const url = isSingleDelete ? `/api/logs/errors/${logIds[0]}` : '/api/logs/errors';
|
||||
const method = 'DELETE';
|
||||
const body = isSingleDelete ? null : JSON.stringify({ ids: logIds });
|
||||
const headers = isSingleDelete ? {} : { 'Content-Type': 'application/json' };
|
||||
|
||||
try {
|
||||
// Rename 'response' to 'deleteResponse' and remove duplicate fetch
|
||||
const deleteResponse = await fetch(url, {
|
||||
method: method,
|
||||
headers: headers,
|
||||
body: body,
|
||||
});
|
||||
// Removed duplicate fetch call below
|
||||
|
||||
if (!deleteResponse.ok) {
|
||||
let errorData;
|
||||
try { errorData = await deleteResponse.json(); } catch (e) { /* ignore */ }
|
||||
const actionText = isSingleDelete ? `删除该条日志` : `批量删除 ${logIds.length} 条日志`;
|
||||
throw new Error(errorData?.detail || `${actionText}失败: ${deleteResponse.statusText}`);
|
||||
}
|
||||
|
||||
const successMessage = isSingleDelete ? `成功删除该日志` : `成功删除 ${logIds.length} 条日志`;
|
||||
showNotification(successMessage, 'success');
|
||||
// 取消全选
|
||||
if (selectAllCheckbox) selectAllCheckbox.checked = false;
|
||||
// 重新加载当前页数据
|
||||
loadErrorLogs();
|
||||
} catch (error) {
|
||||
console.error('批量删除错误日志失败:', error);
|
||||
showNotification(`批量删除失败: ${error.message}`, 'error', 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// 修改:处理单行删除按钮点击的函数 - 改为显示模态框
|
||||
function handleDeleteLogRow(logId) {
|
||||
if (!logId) return;
|
||||
|
||||
// 存储待删除ID并显示模态框
|
||||
idsToDeleteGlobally = [parseInt(logId)]; // 存储为数组
|
||||
// 使用通用确认消息,不显示具体ID
|
||||
const message = `确定要删除这条日志吗?此操作不可恢复!`;
|
||||
showDeleteConfirmModal(message);
|
||||
}
|
||||
|
||||
// 新增:处理 ID 排序点击的函数
|
||||
function handleSortById() {
|
||||
if (currentSort.field === 'id') {
|
||||
// 如果当前是按 ID 排序,切换顺序
|
||||
currentSort.order = currentSort.order === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
// 如果当前不是按 ID 排序,切换到按 ID 排序,默认为降序
|
||||
currentSort.field = 'id';
|
||||
currentSort.order = 'desc';
|
||||
}
|
||||
// 更新图标
|
||||
updateSortIcon();
|
||||
// 重新加载第一页数据
|
||||
currentPage = 1;
|
||||
loadErrorLogs();
|
||||
}
|
||||
|
||||
// 新增:更新排序图标的函数
|
||||
function updateSortIcon() {
|
||||
if (!sortIcon) return;
|
||||
// 移除所有可能的排序类
|
||||
sortIcon.classList.remove('fa-sort', 'fa-sort-up', 'fa-sort-down', 'text-gray-400', 'text-primary-600');
|
||||
|
||||
if (currentSort.field === 'id') {
|
||||
sortIcon.classList.add(currentSort.order === 'asc' ? 'fa-sort-up' : 'fa-sort-down');
|
||||
sortIcon.classList.add('text-primary-600'); // 高亮显示
|
||||
} else {
|
||||
// 如果不是按 ID 排序,显示默认图标
|
||||
sortIcon.classList.add('fa-sort', 'text-gray-400');
|
||||
}
|
||||
}
|
||||
|
||||
// 加载错误日志数据
|
||||
async function loadErrorLogs() {
|
||||
// 重置选择状态
|
||||
if (selectAllCheckbox) selectAllCheckbox.checked = false;
|
||||
if (selectAllCheckbox) selectAllCheckbox.indeterminate = false;
|
||||
updateSelectedState(); // 更新按钮状态和计数
|
||||
|
||||
showLoading(true);
|
||||
showError(false);
|
||||
showNoData(false);
|
||||
@@ -219,14 +581,21 @@ async function loadErrorLogs() {
|
||||
const offset = (currentPage - 1) * pageSize;
|
||||
|
||||
try {
|
||||
// Construct the API URL with search parameters
|
||||
// Construct the API URL with search and sort parameters
|
||||
let apiUrl = `/api/logs/errors?limit=${pageSize}&offset=${offset}`;
|
||||
// 添加排序参数
|
||||
apiUrl += `&sort_by=${currentSort.field}&sort_order=${currentSort.order}`;
|
||||
|
||||
// 添加搜索参数
|
||||
if (currentSearch.key) {
|
||||
apiUrl += `&key_search=${encodeURIComponent(currentSearch.key)}`;
|
||||
}
|
||||
if (currentSearch.error) {
|
||||
apiUrl += `&error_search=${encodeURIComponent(currentSearch.error)}`;
|
||||
}
|
||||
if (currentSearch.errorCode) { // Add error code to API request
|
||||
apiUrl += `&error_code_search=${encodeURIComponent(currentSearch.errorCode)}`;
|
||||
}
|
||||
if (currentSearch.startDate) {
|
||||
apiUrl += `&start_date=${encodeURIComponent(currentSearch.startDate)}`;
|
||||
}
|
||||
@@ -274,6 +643,12 @@ function renderErrorLogs(logs) {
|
||||
if (!tableBody) return;
|
||||
tableBody.innerHTML = ''; // Clear previous entries
|
||||
|
||||
// 重置全选复选框状态(在清空表格后)
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
}
|
||||
|
||||
if (!logs || logs.length === 0) {
|
||||
// Handled by showNoData
|
||||
return;
|
||||
@@ -306,17 +681,30 @@ function renderErrorLogs(logs) {
|
||||
return `${key.substring(0, 4)}...${key.substring(key.length - 4)}`;
|
||||
};
|
||||
const maskedKey = maskKey(log.gemini_key);
|
||||
const fullKey = log.gemini_key || ''; // Store the full key
|
||||
|
||||
row.innerHTML = `
|
||||
<td>${sequentialId}</td> <!-- Use sequential ID -->
|
||||
<td title="${log.gemini_key || ''}">${maskedKey}</td>
|
||||
<td class="text-center px-3 py-3"> <!-- Checkbox column -->
|
||||
<input type="checkbox" class="row-checkbox form-checkbox h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500" data-key="${fullKey}" data-log-id="${log.id}"> <!-- 添加 data-log-id -->
|
||||
</td>
|
||||
<td>${sequentialId}</td> <!-- 显示从1开始的序号 -->
|
||||
<td class="relative group" title="${fullKey}"> <!-- Added relative/group for button positioning -->
|
||||
${maskedKey}
|
||||
<!-- Added copy button for the key in the table row -->
|
||||
<button class="copy-btn absolute top-1/2 right-2 transform -translate-y-1/2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity text-xs" data-copy-text="${log.gemini_key || ''}" title="复制完整密钥">
|
||||
<i class="far fa-copy"></i>
|
||||
</button>
|
||||
</td>
|
||||
<td>${log.error_type || '未知'}</td>
|
||||
<td class="error-code-content" title="${log.error_code || ''}">${errorCodeContent}</td>
|
||||
<td>${log.model_name || '未知'}</td>
|
||||
<td>${formattedTime}</td>
|
||||
<td>
|
||||
<button class="btn-view-details" data-log-id="${log.id}">
|
||||
查看详情
|
||||
<button class="btn-view-details mr-2" data-log-id="${log.id}"> <!-- 添加 mr-2 -->
|
||||
<i class="fas fa-eye mr-1"></i>详情
|
||||
</button>
|
||||
<button class="btn-delete-row text-danger-600 hover:text-danger-800" data-log-id="${log.id}" title="删除此日志">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
@@ -331,6 +719,19 @@ function renderErrorLogs(logs) {
|
||||
showLogDetails(logId);
|
||||
});
|
||||
});
|
||||
|
||||
// 新增:为新渲染的删除按钮添加事件监听器
|
||||
document.querySelectorAll('.btn-delete-row').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const logId = this.getAttribute('data-log-id');
|
||||
handleDeleteLogRow(logId);
|
||||
});
|
||||
});
|
||||
|
||||
// Re-initialize copy buttons specifically for the newly rendered table rows
|
||||
setupCopyButtons('#errorLogsTable');
|
||||
// Update selected state after rendering
|
||||
updateSelectedState();
|
||||
}
|
||||
|
||||
// 显示错误日志详情 (从 API 获取)
|
||||
@@ -403,6 +804,9 @@ async function showLogDetails(logId) {
|
||||
document.getElementById('modalModelName').textContent = logDetails.model_name || '未知';
|
||||
document.getElementById('modalRequestTime').textContent = formattedTime;
|
||||
|
||||
// Re-initialize copy buttons specifically for the modal after content is loaded
|
||||
setupCopyButtons('#logDetailModal');
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取日志详情失败:', error);
|
||||
// Show error in modal
|
||||
@@ -547,10 +951,17 @@ function showError(show, message = '加载错误日志失败,请稍后重试
|
||||
|
||||
// Function to show temporary status notifications (like copy success)
|
||||
function showNotification(message, type = 'success', duration = 3000) {
|
||||
const notificationElement = document.getElementById('copyStatus'); // Or a more generic ID if needed
|
||||
if (!notificationElement) return;
|
||||
const notificationElement = document.getElementById('notification'); // Use the correct ID from base.html
|
||||
if (!notificationElement) {
|
||||
console.error("Notification element with ID 'notification' not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set message and type class
|
||||
notificationElement.textContent = message;
|
||||
// Remove previous type classes before adding the new one
|
||||
notificationElement.classList.remove('success', 'error', 'warning', 'info');
|
||||
notificationElement.classList.add(type); // Add the type class for styling
|
||||
notificationElement.className = `notification ${type} show`; // Add 'show' class
|
||||
|
||||
// Hide after duration
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -141,7 +141,7 @@
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
font-weight: 500; /* font-medium */
|
||||
z-index: 50;
|
||||
z-index: 1000; /* Increased z-index */
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
|
||||
}
|
||||
@@ -184,7 +184,6 @@
|
||||
{% block head_extra_scripts %}{% endblock %}
|
||||
</head>
|
||||
<body class="bg-gradient min-h-screen text-gray-800 pt-6 pb-16">
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
<!-- 底部版权 -->
|
||||
@@ -195,21 +194,17 @@
|
||||
</a> |
|
||||
<a href="https://github.com/snailyp/gemini-balance" target="_blank" class="text-primary-600 hover:text-primary-800 transition duration-300">
|
||||
<i class="fab fa-github"></i> GitHub
|
||||
</a> |
|
||||
<a href="https://afdian.com/a/snaily" target="_blank" class="text-primary-600 hover:text-primary-800 transition duration-300">
|
||||
<i class="fas fa-drumstick-bite text-yellow-600"></i> 给作者加鸡腿
|
||||
</a>
|
||||
{% if request and request.app.state.update_info %}
|
||||
{% set update_info = request.app.state.update_info %}
|
||||
<span class="mx-1">|</span>
|
||||
<span class="text-xs text-gray-500">v{{ update_info.current_version }}</span>
|
||||
{% if update_info.update_available %}
|
||||
<span class="mx-1">|</span>
|
||||
<a href="https://github.com/snailyp/gemini-balance/releases/latest" target="_blank" class="text-yellow-600 hover:text-yellow-800 transition duration-300 animate-pulse">
|
||||
<i class="fas fa-arrow-up"></i> 新版本: v{{ update_info.latest_version }}
|
||||
</a>
|
||||
{% elif update_info.error_message and update_info.error_message != 'Checking...' %}
|
||||
<span class="mx-1">|</span>
|
||||
<span class="text-xs text-red-500" title="{{ update_info.error_message }}">更新检查失败</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<span class="mx-1">|</span>
|
||||
<span class="text-xs text-yellow-600 font-semibold">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>免费项目,谨防诈骗
|
||||
</span>
|
||||
<span id="version-info-container" class="inline-block">
|
||||
<!-- Version info will be loaded here by JavaScript -->
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 通用JS -->
|
||||
@@ -273,6 +268,48 @@
|
||||
}, 300); // Short delay to show spinner
|
||||
}
|
||||
|
||||
// --- Version Check ---
|
||||
const versionInfoContainer = document.getElementById('version-info-container');
|
||||
|
||||
async function fetchVersionInfo() {
|
||||
if (!versionInfoContainer) return;
|
||||
versionInfoContainer.innerHTML = '<span class="mx-1">|</span><span class="text-xs text-gray-400">检查更新中...</span>'; // Initial loading state
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/version/check');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
let versionHtml = `<span class="mx-1">|</span><span class="text-xs text-gray-500">v${data.current_version}</span>`;
|
||||
if (data.update_available) {
|
||||
versionHtml += `
|
||||
<span class="mx-1">|</span>
|
||||
<a href="https://github.com/snailyp/gemini-balance/releases/latest" target="_blank" class="text-yellow-600 hover:text-yellow-800 transition duration-300 animate-pulse">
|
||||
<i class="fas fa-arrow-up"></i> 新版本: v${data.latest_version}
|
||||
</a>`;
|
||||
} else if (data.error_message) {
|
||||
versionHtml += `
|
||||
<span class="mx-1">|</span>
|
||||
<span class="text-xs text-red-500" title="${data.error_message}">更新检查失败</span>`;
|
||||
} else {
|
||||
versionHtml += `<span class="mx-1">|</span><span class="text-xs text-green-500">已是最新</span>`; // Indicate up-to-date
|
||||
}
|
||||
versionInfoContainer.innerHTML = versionHtml;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching version info:', error);
|
||||
versionInfoContainer.innerHTML = `<span class="mx-1">|</span><span class="text-xs text-red-500" title="无法连接到服务器或解析响应">更新检查失败</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch immediately on load
|
||||
fetchVersionInfo();
|
||||
|
||||
// Fetch periodically (e.g., every hour)
|
||||
setInterval(fetchVersionInfo, 3600000); // 3600000 ms = 1 hour
|
||||
|
||||
</script>
|
||||
{% block body_scripts %}{% endblock %}
|
||||
</body>
|
||||
|
||||
@@ -48,6 +48,26 @@
|
||||
}
|
||||
}
|
||||
/* Modal styles are in base.html */
|
||||
|
||||
/* 确保输入框和按钮高度一致 */
|
||||
input[type="text"], input[type="datetime-local"], select, button {
|
||||
height: 36px !important;
|
||||
}
|
||||
|
||||
/* 日期选择器样式优化 */
|
||||
.date-range-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* 确保所有输入框在小屏幕上正确显示 */
|
||||
@media (max-width: 640px) {
|
||||
input[type="datetime-local"] {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -83,32 +103,56 @@
|
||||
<!-- 控制区域 (Refresh button removed, page size moved below) -->
|
||||
<!-- Removed the original controls div -->
|
||||
|
||||
<!-- 搜索控件 -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3 mb-6">
|
||||
<input type="text" id="keySearch" placeholder="搜索密钥 (部分)" class="px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 col-span-1 lg:col-span-1">
|
||||
<input type="text" id="errorSearch" placeholder="搜索错误类型/日志" class="px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 col-span-1 lg:col-span-1">
|
||||
<div class="flex items-center gap-2 col-span-1 lg:col-span-2">
|
||||
<input type="datetime-local" id="startDate" class="px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 flex-1 text-sm">
|
||||
<span class="text-gray-700">至</span>
|
||||
<input type="datetime-local" id="endDate" class="px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 flex-1 text-sm">
|
||||
<!-- 搜索与操作控件 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[1fr_auto] items-center gap-4 mb-6"> <!-- 修改为items-center -->
|
||||
<!-- Left side: Search inputs and date range -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 w-full"> <!-- 修改为3列布局 -->
|
||||
<input type="text" id="keySearch" placeholder="搜索密钥 (部分)" style="height: 36px;" class="px-3 py-1 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<input type="text" id="errorSearch" placeholder="搜索错误类型/日志" style="height: 36px;" class="px-3 py-1 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<input type="text" id="errorCodeSearch" placeholder="搜索错误码" style="height: 36px;" class="px-3 py-1 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
|
||||
<!-- 日期选择器单独一行 -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 col-span-1 sm:col-span-2 lg:col-span-3 mt-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-700 whitespace-nowrap">开始时间:</label>
|
||||
<input type="datetime-local" id="startDate" style="height: 36px;" class="px-3 py-1 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 text-sm w-full">
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-700 whitespace-nowrap">结束时间:</label>
|
||||
<input type="datetime-local" id="endDate" style="height: 36px;" class="px-3 py-1 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 text-sm w-full">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right side: Action buttons -->
|
||||
<div class="flex items-center gap-3 flex-shrink-0"> <!-- 移除上边距 -->
|
||||
<button id="searchBtn" class="flex items-center justify-center px-4 py-1.5 bg-primary-600 hover:bg-primary-700 text-white rounded-lg font-medium transition-all duration-200 shadow-sm hover:shadow-md whitespace-nowrap" style="height: 36px;">
|
||||
<i class="fas fa-search mr-1.5"></i>搜索
|
||||
</button>
|
||||
<button id="copySelectedKeysBtn" class="flex items-center justify-center px-4 py-1.5 bg-success-600 hover:bg-success-700 text-white rounded-lg font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm hover:shadow-md whitespace-nowrap" style="height: 36px;" disabled>
|
||||
<i class="far fa-copy mr-1.5"></i>复制
|
||||
</button>
|
||||
<button id="deleteSelectedBtn" class="flex items-center justify-center px-4 py-1.5 bg-danger-600 hover:bg-danger-700 text-white rounded-lg font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm hover:shadow-md whitespace-nowrap" style="height: 36px;" disabled>
|
||||
<i class="fas fa-trash-alt mr-1.5"></i>删除
|
||||
</button>
|
||||
</div>
|
||||
<button id="searchBtn" class="flex items-center justify-center gap-2 bg-primary-600 hover:bg-primary-700 text-white px-4 py-3 rounded-lg font-medium transition-all duration-200 col-span-1">
|
||||
<i class="fas fa-search"></i> 搜索
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 表格容器 - Enhanced Styling -->
|
||||
<div class="overflow-x-auto rounded-lg border border-gray-200 mb-6 bg-white"> <!-- Removed shadow, added border -->
|
||||
<table class="styled-table w-full min-w-full text-sm"> <!-- Added text-sm -->
|
||||
<thead>
|
||||
<tr class="bg-primary-50 text-left text-primary-800"> <!-- Changed header background and text color -->
|
||||
<th class="px-5 py-3 font-semibold rounded-tl-lg">ID</th> <!-- Increased padding, adjusted rounding -->
|
||||
<th class="px-3 py-3 font-semibold rounded-tl-lg w-12 text-center"> <!-- Adjusted padding and width -->
|
||||
<input type="checkbox" id="selectAllCheckbox" class="form-checkbox h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500">
|
||||
</th>
|
||||
<th class="px-5 py-3 font-semibold cursor-pointer" id="sortById">
|
||||
ID <i class="fas fa-sort ml-1 text-gray-400"></i>
|
||||
</th>
|
||||
<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 rounded-tr-lg">操作</th> <!-- Adjusted rounding -->
|
||||
<th class="px-5 py-3 font-semibold rounded-tr-lg text-center">操作</th> <!-- Adjusted rounding and centered -->
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="errorLogsTable" class="divide-y divide-gray-200">
|
||||
@@ -186,17 +230,23 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 max-h-[60vh] overflow-y-auto p-1">
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<div class="bg-gray-50 p-4 rounded-lg relative group"> <!-- Added relative and group -->
|
||||
<h6 class="text-sm font-semibold text-gray-600 mb-1">Gemini密钥:</h6>
|
||||
<pre id="modalGeminiKey" class="font-mono text-sm bg-gray-100 p-3 rounded overflow-x-auto"></pre>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<h6 class="text-sm font-semibold text-gray-600 mb-1">错误类型:</h6>
|
||||
<p id="modalErrorType" class="text-danger-600 font-medium"></p>
|
||||
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalGeminiKey" title="复制密钥">
|
||||
<i class="far fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg relative group"> <!-- Added relative and group -->
|
||||
<h6 class="text-sm font-semibold text-gray-600 mb-1">错误类型:</h6>
|
||||
<p id="modalErrorType" class="text-danger-600 font-medium pr-8"></p> <!-- Added padding right for button -->
|
||||
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalErrorType" title="复制错误类型">
|
||||
<i class="far fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg relative group">
|
||||
<h6 class="text-sm font-semibold text-gray-600 mb-1">错误日志:</h6>
|
||||
<pre id="modalErrorLog" class="font-mono text-sm bg-gray-100 p-3 rounded overflow-x-auto whitespace-pre-wrap"></pre>
|
||||
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalErrorLog" title="复制错误日志">
|
||||
@@ -204,7 +254,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg relative group"> <!-- Added relative and group -->
|
||||
<div class="bg-gray-50 p-4 rounded-lg relative group">
|
||||
<h6 class="text-sm font-semibold text-gray-600 mb-1">请求消息:</h6>
|
||||
<pre id="modalRequestMsg" class="font-mono text-sm bg-gray-100 p-3 rounded overflow-x-auto whitespace-pre-wrap"></pre>
|
||||
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalRequestMsg" title="复制请求消息">
|
||||
@@ -212,14 +262,20 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<div class="bg-gray-50 p-4 rounded-lg relative group"> <!-- Added relative and group -->
|
||||
<h6 class="text-sm font-semibold text-gray-600 mb-1">模型名称:</h6>
|
||||
<p id="modalModelName" class="font-medium"></p>
|
||||
<p id="modalModelName" class="font-medium pr-8"></p> <!-- Added padding right for button -->
|
||||
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalModelName" title="复制模型名称">
|
||||
<i class="far fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<div class="bg-gray-50 p-4 rounded-lg relative group"> <!-- Added relative and group -->
|
||||
<h6 class="text-sm font-semibold text-gray-600 mb-1">请求时间:</h6>
|
||||
<p id="modalRequestTime" class="font-medium"></p>
|
||||
<p id="modalRequestTime" class="font-medium pr-8"></p> <!-- Added padding right for button -->
|
||||
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalRequestTime" title="复制请求时间">
|
||||
<i class="far fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -230,6 +286,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 删除确认模态框 -->
|
||||
<div id="deleteConfirmModal" class="modal">
|
||||
<div class="w-full max-w-md mx-auto bg-white rounded-xl shadow-xl overflow-hidden animate-fade-in">
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center border-b border-gray-200 pb-3 mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-800">确认删除</h2>
|
||||
<button id="closeDeleteConfirmModalBtn" class="text-gray-400 hover:text-gray-600 text-xl">×</button>
|
||||
</div>
|
||||
<p id="deleteConfirmMessage" class="text-gray-700 mb-6">你确定要删除选中的项目吗?此操作不可恢复!</p>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button id="cancelDeleteBtn" type="button" class="bg-gray-200 hover:bg-gray-300 text-gray-800 px-5 py-2 rounded-lg font-medium transition">取消</button>
|
||||
<button id="confirmDeleteBtn" type="button" class="bg-danger-600 hover:bg-danger-700 text-white px-5 py-2 rounded-lg font-medium transition">确认删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block body_scripts %}
|
||||
|
||||
@@ -6,12 +6,15 @@
|
||||
<style>
|
||||
/* keys_status.html specific styles */
|
||||
.key-content {
|
||||
transition: max-height 0.3s ease-in-out, opacity 0.3s ease-in-out;
|
||||
transition: max-height 0.3s ease-in-out, opacity 0.3s ease-in-out, padding 0.3s ease-in-out; /* Added padding transition */
|
||||
overflow: hidden; /* Keep hidden initially and during collapse */
|
||||
}
|
||||
.key-content.collapsed {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
max-height: 0 !important; /* Use important to override inline style during transition */
|
||||
opacity: 0;
|
||||
padding-top: 0 !important; /* Collapse padding */
|
||||
padding-bottom: 0 !important; /* Collapse padding */
|
||||
/* overflow: hidden; */ /* Already set above */
|
||||
}
|
||||
.toggle-icon {
|
||||
transition: transform 0.3s ease;
|
||||
@@ -30,13 +33,13 @@
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.stats-dashboard {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* 统计卡片样式 */
|
||||
.stats-card {
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
@@ -48,11 +51,11 @@
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
|
||||
.stats-card:hover {
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
|
||||
.stats-card-header {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
padding: 0.75rem 1rem;
|
||||
@@ -60,8 +63,10 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap; /* Allow wrapping for smaller screens */
|
||||
gap: 0.5rem; /* Add gap between items */
|
||||
}
|
||||
|
||||
|
||||
.stats-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -69,19 +74,19 @@
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
|
||||
.stats-card-title i {
|
||||
margin-right: 0.5rem;
|
||||
color: #4F46E5;
|
||||
}
|
||||
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
/* 统计项样式 */
|
||||
.stat-item {
|
||||
padding: 0.75rem;
|
||||
@@ -95,7 +100,7 @@
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
.stat-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -105,15 +110,15 @@
|
||||
z-index: 0;
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
|
||||
.stat-item:hover::before {
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
|
||||
.stat-item:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
@@ -121,7 +126,7 @@
|
||||
position: relative;
|
||||
color: #1F2937;
|
||||
}
|
||||
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
@@ -132,7 +137,7 @@
|
||||
position: relative;
|
||||
color: #6B7280;
|
||||
}
|
||||
|
||||
|
||||
.stat-icon {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
@@ -142,63 +147,30 @@
|
||||
transform: rotate(12deg);
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
|
||||
.stat-item:hover .stat-icon {
|
||||
opacity: 0.2;
|
||||
transform: scale(1.1) rotate(0deg);
|
||||
}
|
||||
|
||||
|
||||
/* 统计类型样式 */
|
||||
.stat-primary {
|
||||
color: #4F46E5;
|
||||
background-color: rgba(238, 242, 255, 0.5);
|
||||
}
|
||||
|
||||
.stat-success {
|
||||
color: #10B981;
|
||||
background-color: rgba(236, 253, 245, 0.5);
|
||||
}
|
||||
|
||||
.stat-danger {
|
||||
color: #EF4444;
|
||||
background-color: rgba(254, 242, 242, 0.5);
|
||||
}
|
||||
|
||||
.stat-warning {
|
||||
color: #F59E0B;
|
||||
background-color: rgba(255, 251, 235, 0.5);
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
color: #3B82F6;
|
||||
background-color: rgba(239, 246, 255, 0.5);
|
||||
}
|
||||
|
||||
.stat-primary { color: #4F46E5; background-color: rgba(238, 242, 255, 0.5); }
|
||||
.stat-success { color: #10B981; background-color: rgba(236, 253, 245, 0.5); }
|
||||
.stat-danger { color: #EF4444; background-color: rgba(254, 242, 242, 0.5); }
|
||||
.stat-warning { color: #F59E0B; background-color: rgba(255, 251, 235, 0.5); }
|
||||
.stat-info { color: #3B82F6; background-color: rgba(239, 246, 255, 0.5); }
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 640px) {
|
||||
.stats-dashboard {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
.stats-dashboard { gap: 1rem; }
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); gap: 0.5rem; padding: 0.5rem; }
|
||||
.stat-item { padding: 0.5rem; }
|
||||
.stat-value { font-size: 1.25rem; }
|
||||
.stat-label { font-size: 0.625rem; }
|
||||
.stats-card-header { padding: 0.5rem 0.75rem; } /* Adjust header padding */
|
||||
.key-content ul { grid-template-columns: 1fr; } /* Stack keys vertically on small screens */
|
||||
}
|
||||
/* Tailwind Toggle Switch Helper CSS from config_editor.html */
|
||||
/* Tailwind Toggle Switch Helper CSS */
|
||||
.toggle-checkbox:checked {
|
||||
@apply: right-0 border-primary-600;
|
||||
right: 0;
|
||||
@@ -209,6 +181,34 @@
|
||||
background-color: #4F46E5;
|
||||
}
|
||||
|
||||
/* Pagination Controls */
|
||||
#validPaginationControls, #invalidPaginationControls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 1rem; /* mt-4 */
|
||||
gap: 0.5rem; /* space-x-2 */
|
||||
}
|
||||
|
||||
/* Ensure list items are flex for alignment */
|
||||
#validKeys li, #invalidKeys li {
|
||||
display: flex;
|
||||
align-items: flex-start; /* Align checkbox with top of content */
|
||||
gap: 0.75rem; /* gap-3 */
|
||||
}
|
||||
/* Ensure grid layout for key lists */
|
||||
#validKeys, #invalidKeys {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr; /* Default single column */
|
||||
gap: 0.75rem; /* gap-3 */
|
||||
}
|
||||
@media (min-width: 768px) { /* md breakpoint */
|
||||
#validKeys, #invalidKeys {
|
||||
grid-template-columns: repeat(2, 1fr); /* Two columns on medium screens and up */
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -282,7 +282,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- API调用统计卡片 -->
|
||||
<div class="stats-card">
|
||||
<div class="stats-card-header">
|
||||
@@ -311,161 +311,217 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 有效密钥区域 -->
|
||||
<div class="stats-card mb-6 animate-fade-in" style="animation-delay: 0.2s">
|
||||
<div class="stats-card-header cursor-pointer" onclick="toggleSection(this, 'validKeys')">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Left side: Title and Toggle Icon -->
|
||||
<div class="flex items-center gap-3 flex-shrink-0"> <!-- Prevent shrinking -->
|
||||
<i class="fas fa-chevron-down toggle-icon text-primary-600"></i>
|
||||
<i class="fas fa-check-circle text-success-500"></i>
|
||||
<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>
|
||||
<h2 class="text-lg font-semibold whitespace-nowrap">有效密钥列表 ({{ valid_key_count }})</h2>
|
||||
</div>
|
||||
<!-- Middle: Filters and Search (Allow wrapping) -->
|
||||
<div class="flex items-center gap-x-4 gap-y-2 flex-grow flex-wrap justify-start md:justify-center"> <!-- Allow wrapping, center on medium+ -->
|
||||
<!-- 失败次数筛选 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<label for="failCountThreshold" class="text-sm text-gray-600 select-none whitespace-nowrap">失败次数≥</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" onclick="event.stopPropagation();">
|
||||
</div>
|
||||
<!-- 密钥搜索 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<label for="keySearchInput" class="text-sm text-gray-600 select-none whitespace-nowrap"><i class="fas fa-search mr-1"></i>搜索</label>
|
||||
<input type="search" id="keySearchInput" placeholder="输入密钥..." class="form-input h-7 w-32 px-2 py-1 text-sm border border-gray-300 rounded focus:ring-primary-500 focus:border-primary-500" onclick="event.stopPropagation();">
|
||||
</div>
|
||||
<!-- 每页显示数量 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<label for="itemsPerPageSelect" class="text-sm text-gray-600 select-none whitespace-nowrap">每页</label>
|
||||
<select id="itemsPerPageSelect" class="form-select h-7 px-2 py-1 text-sm border border-gray-300 rounded focus:ring-primary-500 focus:border-primary-500 bg-white" onclick="event.stopPropagation();">
|
||||
<option value="10">10</option>
|
||||
<option value="20">20</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
<span class="text-sm text-gray-600 select-none">项</span>
|
||||
</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>
|
||||
批量重置
|
||||
</button>
|
||||
<button class="flex items-center gap-2 bg-primary-600 hover:bg-primary-700 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="event.stopPropagation(); copyKeys('valid')">
|
||||
<i class="fas fa-copy"></i>
|
||||
批量复制
|
||||
</button>
|
||||
<!-- Right side: Select All -->
|
||||
<div class="flex items-center gap-1 flex-shrink-0" onclick="event.stopPropagation();"> <!-- Prevent shrinking -->
|
||||
<input type="checkbox" id="selectAllValid" class="form-checkbox h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500" onchange="toggleSelectAll('valid', this.checked)">
|
||||
<label for="selectAllValid" class="text-sm text-gray-600 select-none whitespace-nowrap">全选</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 批量操作按钮组 (仅在选中时显示) -->
|
||||
<div id="validBatchActions" class="p-3 bg-gray-50 border-t border-gray-200 hidden flex items-center flex-wrap gap-3"> <!-- Added flex-wrap -->
|
||||
<span class="text-sm font-medium text-gray-700 whitespace-nowrap">已选择 <span id="validSelectedCount">0</span> 项</span>
|
||||
<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 disabled:opacity-50 disabled:cursor-not-allowed" onclick="event.stopPropagation(); showVerifyModal('valid', event)" disabled>
|
||||
<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 disabled:opacity-50 disabled:cursor-not-allowed" onclick="event.stopPropagation(); resetAllKeysFailCount('valid', event)" data-reset-type="valid" disabled>
|
||||
<i class="fas fa-redo-alt"></i> 批量重置
|
||||
</button>
|
||||
<button class="flex items-center gap-2 bg-primary-600 hover:bg-primary-700 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" onclick="event.stopPropagation(); copySelectedKeys('valid')" disabled>
|
||||
<i class="fas fa-copy"></i> 批量复制
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="key-content p-4 bg-white bg-opacity-40">
|
||||
<!-- Key list will be populated by JS -->
|
||||
<ul id="validKeys" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{# Initial keys rendered by server-side for non-JS users or initial load #}
|
||||
{# JS will replace this content with paginated/filtered results #}
|
||||
{% if valid_keys %}
|
||||
{% for key, fail_count in valid_keys.items() %}
|
||||
<li class="bg-white rounded-lg p-3 shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100 hover:border-success-300 transform hover:-translate-y-1" data-fail-count="{{ fail_count }}">
|
||||
<div class="flex flex-col justify-between h-full gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success-50 text-success-600">
|
||||
<i class="fas fa-check mr-1"></i> 有效
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="key-text font-mono text-gray-700" data-full-key="{{ key }}">{{ key[:4] + '...' + key[-4:] }}</span>
|
||||
<button class="text-gray-500 hover:text-primary-600 transition-colors" onclick="toggleKeyVisibility(this)">
|
||||
<i class="fas fa-eye"></i>
|
||||
<li class="bg-white rounded-lg p-3 shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100 hover:border-success-300 transform hover:-translate-y-1" data-fail-count="{{ fail_count }}" data-key="{{ key }}">
|
||||
<!-- Checkbox -->
|
||||
<input type="checkbox" class="form-checkbox h-5 w-5 text-primary-600 border-gray-300 rounded focus:ring-primary-500 mt-1 key-checkbox" data-key-type="valid" value="{{ key }}">
|
||||
<!-- Key Info -->
|
||||
<div class="flex-grow">
|
||||
<div class="flex flex-col justify-between h-full gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success-50 text-success-600">
|
||||
<i class="fas fa-check mr-1"></i> 有效
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="key-text font-mono text-gray-700" data-full-key="{{ key }}">{{ key[:4] + '...' + key[-4:] }}</span>
|
||||
<button class="text-gray-500 hover:text-primary-600 transition-colors" onclick="toggleKeyVisibility(this)" title="显示/隐藏密钥">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-600">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
失败: {{ fail_count }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button class="flex items-center gap-1 bg-success-500 hover:bg-success-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="verifyKey('{{ key }}', this)">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
验证
|
||||
</button>
|
||||
<button class="flex items-center gap-1 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="resetKeyFailCount('{{ key }}', this)">
|
||||
<i class="fas fa-redo-alt"></i>
|
||||
重置
|
||||
</button>
|
||||
<button class="flex items-center gap-1 bg-gray-500 hover:bg-gray-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="copyKey('{{ key }}')">
|
||||
<i class="fas fa-copy"></i>
|
||||
复制
|
||||
</button>
|
||||
<button class="flex items-center gap-1 bg-purple-500 hover:bg-purple-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="showKeyUsageDetails('{{ key }}')">
|
||||
<i class="fas fa-chart-pie"></i>
|
||||
详情
|
||||
</button>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-600">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
失败: {{ fail_count }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button class="flex items-center gap-1 bg-success-500 hover:bg-success-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="verifyKey('{{ key }}', this)">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
验证
|
||||
</button>
|
||||
<button class="flex items-center gap-1 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="resetKeyFailCount('{{ key }}', this)">
|
||||
<i class="fas fa-redo-alt"></i>
|
||||
重置
|
||||
</button>
|
||||
<button class="flex items-center gap-1 bg-gray-500 hover:bg-gray-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="copyKey('{{ key }}')">
|
||||
<i class="fas fa-copy"></i>
|
||||
复制
|
||||
</button>
|
||||
<button class="flex items-center gap-1 bg-purple-500 hover:bg-purple-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="showKeyUsageDetails('{{ key }}')">
|
||||
<i class="fas fa-chart-pie"></i>
|
||||
详情
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<li class="text-center text-gray-500 py-4">暂无有效密钥</li>
|
||||
<li class="text-center text-gray-500 py-4 col-span-full">暂无有效密钥</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<!-- 有效密钥分页控件容器 -->
|
||||
<div id="validPaginationControls" class="flex justify-center items-center mt-4 space-x-2">
|
||||
<!-- Pagination controls will be generated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 无效密钥区域 -->
|
||||
<div class="stats-card mb-6 animate-fade-in" style="animation-delay: 0.4s">
|
||||
<div class="stats-card-header cursor-pointer" onclick="toggleSection(this, 'invalidKeys')">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Left side: Title and Toggle Icon -->
|
||||
<div class="flex items-center gap-3 flex-shrink-0"> <!-- Prevent shrinking -->
|
||||
<i class="fas fa-chevron-down toggle-icon text-primary-600"></i>
|
||||
<i class="fas fa-times-circle text-danger-500"></i>
|
||||
<h2 class="text-lg font-semibold">无效密钥列表 ({{ invalid_key_count }})</h2>
|
||||
<h2 class="text-lg font-semibold whitespace-nowrap">无效密钥列表 ({{ 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>
|
||||
批量重置
|
||||
</button>
|
||||
<button class="flex items-center gap-2 bg-primary-600 hover:bg-primary-700 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="event.stopPropagation(); copyKeys('invalid')">
|
||||
<i class="fas fa-copy"></i>
|
||||
批量复制
|
||||
</button>
|
||||
<!-- Right side: Select All -->
|
||||
<div class="flex items-center gap-1 ml-auto flex-shrink-0" onclick="event.stopPropagation();"> <!-- Use ml-auto, Prevent shrinking -->
|
||||
<input type="checkbox" id="selectAllInvalid" class="form-checkbox h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500" onchange="toggleSelectAll('invalid', this.checked)">
|
||||
<label for="selectAllInvalid" class="text-sm text-gray-600 select-none whitespace-nowrap">全选</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 批量操作按钮组 (仅在选中时显示) -->
|
||||
<div id="invalidBatchActions" class="p-3 bg-gray-50 border-t border-gray-200 hidden flex items-center flex-wrap gap-3"> <!-- Added flex-wrap -->
|
||||
<span class="text-sm font-medium text-gray-700 whitespace-nowrap">已选择 <span id="invalidSelectedCount">0</span> 项</span>
|
||||
<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 disabled:opacity-50 disabled:cursor-not-allowed" onclick="event.stopPropagation(); showVerifyModal('invalid', event)" disabled>
|
||||
<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 disabled:opacity-50 disabled:cursor-not-allowed" onclick="event.stopPropagation(); resetAllKeysFailCount('invalid', event)" data-reset-type="invalid" disabled>
|
||||
<i class="fas fa-redo-alt"></i> 批量重置
|
||||
</button>
|
||||
<button class="flex items-center gap-2 bg-primary-600 hover:bg-primary-700 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" onclick="event.stopPropagation(); copySelectedKeys('invalid')" disabled>
|
||||
<i class="fas fa-copy"></i> 批量复制
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="key-content p-4 bg-white bg-opacity-40">
|
||||
<!-- Key list will be populated by JS -->
|
||||
<ul id="invalidKeys" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{# Initial keys rendered by server-side #}
|
||||
{# JS will replace this content with paginated results #}
|
||||
{% if invalid_keys %}
|
||||
{% for key, fail_count in invalid_keys.items() %}
|
||||
<li class="bg-white rounded-lg p-3 shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100 hover:border-danger-300 transform hover:-translate-y-1">
|
||||
<div class="flex flex-col justify-between h-full gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-danger-50 text-danger-600">
|
||||
<i class="fas fa-times mr-1"></i> 无效
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="key-text font-mono text-gray-700" data-full-key="{{ key }}">{{ key[:4] + '...' + key[-4:] }}</span>
|
||||
<button class="text-gray-500 hover:text-primary-600 transition-colors" onclick="toggleKeyVisibility(this)">
|
||||
<i class="fas fa-eye"></i>
|
||||
<li class="bg-white rounded-lg p-3 shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100 hover:border-danger-300 transform hover:-translate-y-1" data-key="{{ key }}">
|
||||
<!-- Checkbox -->
|
||||
<input type="checkbox" class="form-checkbox h-5 w-5 text-primary-600 border-gray-300 rounded focus:ring-primary-500 mt-1 key-checkbox" data-key-type="invalid" value="{{ key }}">
|
||||
<!-- Key Info -->
|
||||
<div class="flex-grow">
|
||||
<div class="flex flex-col justify-between h-full gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-danger-50 text-danger-600">
|
||||
<i class="fas fa-times mr-1"></i> 无效
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="key-text font-mono text-gray-700" data-full-key="{{ key }}">{{ key[:4] + '...' + key[-4:] }}</span>
|
||||
<button class="text-gray-500 hover:text-primary-600 transition-colors" onclick="toggleKeyVisibility(this)" title="显示/隐藏密钥">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-600">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
失败: {{ fail_count }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button class="flex items-center gap-1 bg-success-500 hover:bg-success-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="verifyKey('{{ key }}', this)">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
验证
|
||||
</button>
|
||||
<button class="flex items-center gap-1 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="resetKeyFailCount('{{ key }}', this)">
|
||||
<i class="fas fa-redo-alt"></i>
|
||||
重置
|
||||
</button>
|
||||
<button class="flex items-center gap-1 bg-gray-500 hover:bg-gray-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="copyKey('{{ key }}')">
|
||||
<i class="fas fa-copy"></i>
|
||||
复制
|
||||
</button>
|
||||
<button class="flex items-center gap-1 bg-purple-500 hover:bg-purple-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="showKeyUsageDetails('{{ key }}')">
|
||||
<i class="fas fa-chart-pie"></i>
|
||||
详情
|
||||
</button>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-600">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
失败: {{ fail_count }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button class="flex items-center gap-1 bg-success-500 hover:bg-success-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="verifyKey('{{ key }}', this)">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
验证
|
||||
</button>
|
||||
<button class="flex items-center gap-1 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="resetKeyFailCount('{{ key }}', this)">
|
||||
<i class="fas fa-redo-alt"></i>
|
||||
重置
|
||||
</button>
|
||||
<button class="flex items-center gap-1 bg-gray-500 hover:bg-gray-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="copyKey('{{ key }}')">
|
||||
<i class="fas fa-copy"></i>
|
||||
复制
|
||||
</button>
|
||||
<button class="flex items-center gap-1 bg-purple-500 hover:bg-purple-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="showKeyUsageDetails('{{ key }}')">
|
||||
<i class="fas fa-chart-pie"></i>
|
||||
详情
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<li class="text-center text-gray-500 py-4">暂无无效密钥</li>
|
||||
<li class="text-center text-gray-500 py-4 col-span-full">暂无无效密钥</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<!-- 无效密钥分页控件容器 -->
|
||||
<div id="invalidPaginationControls" class="flex justify-center items-center mt-4 space-x-2">
|
||||
<!-- Pagination controls will be generated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Removed old total keys display -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Scroll buttons are now in base.html -->
|
||||
<div class="scroll-buttons">
|
||||
<button class="scroll-button" onclick="scrollToTop()" title="回到顶部">
|
||||
@@ -475,7 +531,7 @@
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Notification component is now in base.html (use id="notification") -->
|
||||
<div id="notification" class="notification"></div>
|
||||
<!-- 重置确认模态框 -->
|
||||
@@ -500,7 +556,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 验证确认模态框移到 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">
|
||||
@@ -522,7 +578,7 @@
|
||||
</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">
|
||||
@@ -537,8 +593,9 @@
|
||||
</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"
|
||||
class="text-gray-700 text-base leading-relaxed break-words whitespace-pre-line max-h-80 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';">
|
||||
<!-- Content is dynamically generated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center px-8 pb-6 pt-2">
|
||||
@@ -596,71 +653,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Footer is now in base.html -->
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block body_scripts %}
|
||||
<script>
|
||||
// keys_status.html specific JavaScript initialization
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Filter functionality based on fail count threshold
|
||||
const thresholdInput = document.getElementById('failCountThreshold');
|
||||
const validKeysList = document.getElementById('validKeys');
|
||||
|
||||
function filterValidKeys() {
|
||||
const threshold = parseInt(thresholdInput.value, 10);
|
||||
if (isNaN(threshold)) return; // Do nothing if input is not a number
|
||||
|
||||
const keys = validKeysList.querySelectorAll('li');
|
||||
let visibleCount = 0;
|
||||
keys.forEach(keyItem => {
|
||||
// Check if it's a key item (has data-fail-count) before processing
|
||||
if (keyItem.hasAttribute('data-fail-count')) {
|
||||
const failCount = parseInt(keyItem.getAttribute('data-fail-count'), 10);
|
||||
if (failCount >= threshold) {
|
||||
keyItem.style.display = ''; // Show item
|
||||
visibleCount++;
|
||||
} else {
|
||||
keyItem.style.display = 'none'; // Hide item
|
||||
}
|
||||
}
|
||||
});
|
||||
// Optional: Show a message if no keys match the filter
|
||||
const noMatchMsgId = 'no-valid-keys-msg';
|
||||
let noMatchMsg = validKeysList.querySelector(`#${noMatchMsgId}`);
|
||||
if (visibleCount === 0 && keys.length > 0) { // Only show if there were keys initially
|
||||
if (!noMatchMsg) {
|
||||
noMatchMsg = document.createElement('li');
|
||||
noMatchMsg.id = noMatchMsgId;
|
||||
noMatchMsg.className = 'text-center text-gray-500 py-4';
|
||||
noMatchMsg.textContent = '没有符合条件的有效密钥';
|
||||
validKeysList.appendChild(noMatchMsg);
|
||||
}
|
||||
noMatchMsg.style.display = '';
|
||||
} else if (noMatchMsg) {
|
||||
noMatchMsg.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
if (thresholdInput && validKeysList) {
|
||||
thresholdInput.addEventListener('input', filterValidKeys);
|
||||
// Initial filter on load
|
||||
filterValidKeys();
|
||||
}
|
||||
|
||||
// Initialize other elements or event listeners if needed
|
||||
// The main logic (verifyKey, resetKeyFailCount, copyKey, etc.) is in keys_status.js
|
||||
// The toggleSection logic is now specific to this page
|
||||
window.toggleSection = function(header, sectionId) {
|
||||
const toggleIcon = header.querySelector('.toggle-icon');
|
||||
const content = header.nextElementSibling; // Assumes content is immediately after header
|
||||
if (toggleIcon && content) {
|
||||
toggleIcon.classList.toggle('collapsed');
|
||||
content.classList.toggle('collapsed');
|
||||
}
|
||||
}
|
||||
});
|
||||
// keys_status.html specific JavaScript initialization is now handled by keys_status.js
|
||||
// The DOMContentLoaded listener in keys_status.js will execute after the DOM is ready.
|
||||
// No inline script needed here anymore.
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -6,9 +6,19 @@ import re
|
||||
import base64
|
||||
import requests
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
from pathlib import Path
|
||||
import logging # Import logging
|
||||
|
||||
from app.core.constants import DATA_URL_PATTERN, IMAGE_URL_PATTERN, VALID_IMAGE_RATIOS
|
||||
|
||||
# Define logger for helper functions if needed, or use specific loggers
|
||||
helper_logger = logging.getLogger("app.utils") # Or use a more specific logger if available
|
||||
|
||||
# Define project root and version file path here for get_current_version
|
||||
# Assuming this file is at app/utils/helpers.py
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
VERSION_FILE_PATH = PROJECT_ROOT / "VERSION"
|
||||
|
||||
|
||||
def extract_mime_type_and_data(base64_string: str) -> Tuple[Optional[str], str]:
|
||||
"""
|
||||
@@ -146,3 +156,21 @@ def is_valid_api_key(key: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
|
||||
def get_current_version(default_version: str = "0.0.0") -> str:
|
||||
"""Reads the current version from the VERSION file."""
|
||||
version_file = VERSION_FILE_PATH # Use Path object defined above
|
||||
try:
|
||||
# Use Path object's open method
|
||||
with version_file.open('r', encoding='utf-8') as f:
|
||||
version = f.read().strip()
|
||||
if not version:
|
||||
helper_logger.warning(f"VERSION file ('{version_file}') is empty. Using default version '{default_version}'.")
|
||||
return default_version
|
||||
return version
|
||||
except FileNotFoundError:
|
||||
helper_logger.warning(f"VERSION file not found at '{version_file}'. Using default version '{default_version}'.")
|
||||
return default_version
|
||||
except IOError as e:
|
||||
helper_logger.error(f"Error reading VERSION file ('{version_file}'): {e}. Using default version '{default_version}'.")
|
||||
return default_version
|
||||
|
||||
Reference in New Issue
Block a user