feat(monitoring): 添加 API 请求统计和监控面板

本次提交引入了 API 请求统计功能,并将原“密钥状态”页面重构为功能更全面的“监控面板”。

主要变更包括:

- **数据库与服务层:**
    - 新增 `RequestLog` 数据模型 (`app/database/models.py`),用于存储 API 请求的详细信息(时间、模型、密钥、成功状态、状态码、耗时)。
    - 在 `app/database/services.py` 中添加 `add_request_log` 和 `get_request_stats` 函数,分别用于记录单次请求和获取时间窗口内的统计数据。
    - 新增 `app/service/stats_service.py`,封装了获取 API 调用统计逻辑。

- **API 请求日志记录:**
    - 在 Gemini (`gemini_chat_service.py`) 和 OpenAI (`openai_chat_service.py`) 聊天服务中,于 API 调用前后添加了 `add_request_log` 调用,以记录请求的成功与否及耗时。

- **前端监控面板:**
    - 将 `/keys` 路由对应的页面 (`keys_status.html`) 从“密钥状态”重构为“监控面板”。
    - 页面顶部新增统计卡片区域,展示:
        - 密钥统计:总数、有效数、无效数。
        - API 调用统计:1分钟内、1小时内、24小时内、本月调用次数。
    - 密钥列表(有效/无效)采用响应式网格布局 (`grid`),并增加了悬停动效和边框高亮。
    - 优化了有效密钥列表的筛选逻辑,在无匹配项时显示提示信息。
    - 为新的统计卡片和列表项添加了相应的 CSS 样式。
    - 更新了 `keys_status.js` 以支持筛选无结果时的提示。

- **路由与导航:**
    - 在 `app/router/routes.py` 中添加了 `/stats` 端点,用于获取 API 统计数据。
    - 更新了 `config_editor.html` 和 `error_logs.html` 中的导航链接,使其指向新的“监控面板”。

- **日志配置:**
    - 在 `app/log/logger.py` 中,为 `sqlalchemy.exc` 设置了 WARNING 日志级别。

这些更改旨在提供更好的系统可观测性,方便用户监控 API 密钥状态和请求频率。
This commit is contained in:
snaily
2025-04-11 14:45:03 +08:00
parent 51bb71bdb5
commit 7b4652c802
12 changed files with 879 additions and 283 deletions

View File

@@ -2,7 +2,7 @@
数据库模型模块
"""
import datetime
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, Boolean # 添加 Boolean
from app.database.connection import Base
@@ -41,3 +41,21 @@ class ErrorLog(Base):
def __repr__(self):
return f"<ErrorLog(id='{self.id}', gemini_key='{self.gemini_key}')>"
# 新增 RequestLog 模型
class RequestLog(Base):
"""
API 请求日志表
"""
__tablename__ = "t_request_log"
id = Column(Integer, primary_key=True, autoincrement=True)
request_time = Column(DateTime, default=datetime.datetime.now, comment="请求时间")
model_name = Column(String(100), nullable=True, comment="模型名称")
api_key = Column(String(100), nullable=True, comment="使用的API密钥") # 考虑安全性,后续可优化
is_success = Column(Boolean, nullable=False, comment="请求是否成功")
status_code = Column(Integer, nullable=True, comment="API响应状态码")
latency_ms = Column(Integer, nullable=True, comment="请求耗时(毫秒)")
def __repr__(self):
return f"<RequestLog(id='{self.id}', key='{self.api_key[:4]}...', success='{self.is_success}')>"

View File

@@ -3,12 +3,12 @@
"""
import json
from typing import Dict, List, Optional, Any, Union
from datetime import datetime
from datetime import datetime # Keep this import
from sqlalchemy import select, insert, update, func # Import func for COUNT
from sqlalchemy import select, insert, update, func
from app.database.connection import database
from app.database.models import Settings, ErrorLog
from app.database.models import Settings, ErrorLog, RequestLog # Import RequestLog
from app.log.logger import get_database_logger
logger = get_database_logger()
@@ -73,7 +73,7 @@ async def update_setting(key: str, value: str, description: Optional[str] = None
.values(
value=value,
description=description if description else setting["description"],
updated_at=datetime.datetime.now()
updated_at=datetime.now() # Use datetime.now()
)
)
await database.execute(query)
@@ -87,8 +87,8 @@ async def update_setting(key: str, value: str, description: Optional[str] = None
key=key,
value=value,
description=description,
created_at=datetime.datetime.now(),
updated_at=datetime.datetime.now()
created_at=datetime.now(), # Use datetime.now()
updated_at=datetime.now() # Use datetime.now()
)
)
await database.execute(query)
@@ -241,3 +241,44 @@ async def get_error_logs_count(
except Exception as e:
logger.exception(f"Failed to count error logs with filters: {str(e)}") # Use exception for stack trace
raise
# 新增函数:添加请求日志
async def add_request_log(
model_name: Optional[str],
api_key: Optional[str],
is_success: bool,
status_code: Optional[int] = None,
latency_ms: Optional[int] = None,
request_time: Optional[datetime] = None
) -> bool:
"""
添加 API 请求日志
Args:
model_name: 模型名称
api_key: 使用的 API 密钥
is_success: 请求是否成功
status_code: API 响应状态码
latency_ms: 请求耗时(毫秒)
request_time: 请求发生时间 (如果为 None, 则使用当前时间)
Returns:
bool: 是否添加成功
"""
try:
log_time = request_time if request_time else datetime.now()
query = insert(RequestLog).values(
request_time=log_time,
model_name=model_name,
api_key=api_key,
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms
)
await database.execute(query)
# logger.debug(f"Added request log: key={api_key[:4]}..., success={is_success}, model={model_name}") # Use debug level
return True
except Exception as e:
logger.error(f"Failed to add request log: {str(e)}")
return False

View File

@@ -169,3 +169,7 @@ def get_database_logger():
def get_log_routes_logger():
return Logger.setup_logger("log_routes")
def get_stats_logger():
return Logger.setup_logger("stats")

View File

@@ -10,6 +10,7 @@ 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 # 新增导入
from app.service.key.key_manager import get_key_manager_instance
from app.service.stats_service import get_api_usage_stats # <-- Import stats service
logger = get_routes_logger()
@@ -86,19 +87,31 @@ def setup_page_routes(app: FastAPI) -> None:
key_manager = await get_key_manager_instance()
keys_status = await key_manager.get_keys_by_status()
total = len(keys_status["valid_keys"]) + len(keys_status["invalid_keys"])
logger.info(f"Keys status retrieved successfully. Total keys: {total}")
total_keys = len(keys_status["valid_keys"]) + len(keys_status["invalid_keys"])
valid_key_count = len(keys_status["valid_keys"])
invalid_key_count = len(keys_status["invalid_keys"])
# Get API usage stats
api_stats = await get_api_usage_stats()
logger.info(f"API stats retrieved: {api_stats}")
logger.info(f"Keys status retrieved successfully. Total keys: {total_keys}")
return templates.TemplateResponse(
"keys_status.html",
{
"request": request,
"valid_keys": keys_status["valid_keys"],
"invalid_keys": keys_status["invalid_keys"],
"total": total,
"total_keys": total_keys, # Renamed for clarity
"valid_key_count": valid_key_count, # Added count
"invalid_key_count": invalid_key_count, # Added count
"api_stats": api_stats, # <-- Pass stats to template
},
)
except Exception as e:
logger.error(f"Error retrieving keys status: {str(e)}")
logger.error(f"Error retrieving keys status or API stats: {str(e)}")
# Optionally, render template with error or default stats
# For now, re-raise to show error page
raise
@app.get("/config", response_class=HTMLResponse)

View File

@@ -2,8 +2,9 @@
import json
import re
import datetime # Add datetime import
import time # Add time import
from typing import Any, AsyncGenerator, Dict, List
from app.config.config import settings
from app.domain.gemini_models import GeminiRequest
from app.handler.response_handler import GeminiResponseHandler
@@ -11,7 +12,7 @@ from app.handler.stream_optimizer import gemini_optimizer
from app.log.logger import get_gemini_logger
from app.service.client.api_client import GeminiApiClient
from app.service.key.key_manager import KeyManager
from app.database.services import add_error_log
from app.database.services import add_error_log, add_request_log # Import add_request_log
logger = get_gemini_logger()
@@ -147,27 +148,54 @@ class GeminiChatService:
) -> Dict[str, Any]:
"""生成内容"""
payload = _build_payload(model, request)
start_time = time.perf_counter()
request_datetime = datetime.datetime.now() # Record request time
is_success = False
status_code = None
response = None
try:
response = await self.api_client.generate_content(payload, model, api_key)
# Assuming success if no exception is raised and response is received
# The actual status code might be within the response structure or headers,
# but api_client doesn't seem to expose it directly here.
# We'll assume 200 for success if no exception.
is_success = True
status_code = 200 # Assume 200 on success
return self.response_handler.handle_response(response, model, stream=False)
except Exception as e:
logger.error(f"Normal API call failed with error: {str(e)}")
error_code = None
is_success = False
error_log_msg = str(e)
# 尝试从异常消息中解析状态码
logger.error(f"Normal API call failed with error: {error_log_msg}")
# Try to parse status code from exception
match = re.search(r"status code (\d+)", error_log_msg)
if match:
error_code = int(match.group(1))
status_code = int(match.group(1))
else:
status_code = 500 # Default to 500 if parsing fails
# Log error to error log table
await add_error_log(
gemini_key=api_key,
model_name=model,
error_type="gemini_chat_service",
error_log=error_log_msg,
error_code=error_code,
error_code=status_code,
request_msg=payload
)
raise e # 重新抛出异常,以便上层处理
raise e # Re-throw exception for upstream handling
finally:
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
# Log request to request log table
await add_request_log(
model_name=model,
api_key=api_key,
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime
)
async def stream_generate_content(
self, model: str, request: GeminiRequest, api_key: str
@@ -176,60 +204,96 @@ class GeminiChatService:
retries = 0
max_retries = settings.MAX_RETRIES
payload = _build_payload(model, request)
while retries < max_retries:
try:
async for line in self.api_client.stream_generate_content(
payload, model, api_key
):
# print(line)
if line.startswith("data:"):
line = line[6:]
response_data = self.response_handler.handle_response(
json.loads(line), model, stream=True
)
text = self._extract_text_from_response(response_data)
# 如果有文本内容,且开启了流式输出优化器,则使用流式输出优化器处理
if text and settings.STREAM_OPTIMIZER_ENABLED:
# 使用流式输出优化器处理文本输出
async for (
optimized_chunk
) in gemini_optimizer.optimize_stream_output(
text,
lambda t: self._create_char_response(response_data, t),
lambda c: "data: " + json.dumps(c) + "\n\n",
):
yield optimized_chunk
else:
# 如果没有文本内容(如工具调用等),整块输出
yield "data: " + json.dumps(response_data) + "\n\n"
logger.info("Streaming completed successfully")
break
except Exception as e:
retries += 1
error_log_msg = str(e)
logger.warning(
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
)
# 解析错误信息并记录到数据库
error_code = None
match = re.search(r"status code (\d+)", error_log_msg)
if match:
error_code = int(match.group(1))
await add_error_log(
gemini_key=api_key,
model_name=model,
error_log=error_log_msg,
error_code=error_code,
request_msg=payload
)
# 尝试切换 API Key
api_key = await self.key_manager.handle_api_failure(api_key,retries)
if api_key:
logger.info(f"Switched to new API key: {api_key}")
if retries >= max_retries:
logger.error(
f"Max retries ({max_retries}) reached for streaming. Raising error"
start_time = time.perf_counter() # Record start time before loop
request_datetime = datetime.datetime.now()
is_success = False
status_code = None
final_api_key = api_key # Store the initial key
try:
while retries < max_retries:
current_attempt_key = api_key # Key used for this attempt
final_api_key = current_attempt_key # Update final key used
try:
async for line in self.api_client.stream_generate_content(
payload, model, current_attempt_key
):
# print(line)
if line.startswith("data:"):
line = line[6:]
response_data = self.response_handler.handle_response(
json.loads(line), model, stream=True
)
text = self._extract_text_from_response(response_data)
# 如果有文本内容,且开启了流式输出优化器,则使用流式输出优化器处理
if text and settings.STREAM_OPTIMIZER_ENABLED:
# 使用流式输出优化器处理文本输出
async for (
optimized_chunk
) in gemini_optimizer.optimize_stream_output(
text,
lambda t: self._create_char_response(response_data, t),
lambda c: "data: " + json.dumps(c) + "\n\n",
):
yield optimized_chunk
else:
# 如果没有文本内容(如工具调用等),整块输出
yield "data: " + json.dumps(response_data) + "\n\n"
logger.info("Streaming completed successfully")
is_success = True
status_code = 200 # Assume 200 on success
break # Exit loop on success
except Exception as e:
retries += 1
is_success = False # Mark as failed for this attempt
error_log_msg = str(e)
logger.warning(
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
)
break
# Parse error code for logging
match = re.search(r"status code (\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
else:
status_code = 500 # Default if parsing fails
# Log error to error log table
await add_error_log(
gemini_key=current_attempt_key, # Log key used for this failed attempt
model_name=model,
error_log=error_log_msg,
error_code=status_code,
request_msg=payload
)
# Attempt to switch API Key
api_key = await self.key_manager.handle_api_failure(current_attempt_key, retries)
if api_key:
logger.info(f"Switched to new API key: {api_key}")
else: # No more keys or retries exceeded by handle_api_failure logic
logger.error(f"No valid API key available after {retries} retries.")
break # Exit loop if no key available
if retries >= max_retries:
logger.error(
f"Max retries ({max_retries}) reached for streaming."
)
break # Exit loop after max retries
finally:
# Log the final outcome of the streaming request
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
await add_request_log(
model_name=model,
api_key=final_api_key, # Log the last key used
is_success=is_success, # Log the final success status
status_code=status_code, # Log the last known status code
latency_ms=latency_ms, # Log total time including retries
request_time=request_datetime
)
# If the loop finished due to failure, ensure an exception is raised if not already handled
if not is_success and retries >= max_retries:
# We need to raise an exception here if the loop exited due to max retries failure
# However, the original code structure doesn't explicitly raise here after the loop.
# For now, we just log. Consider raising HTTPException if needed.
pass

View File

@@ -2,6 +2,8 @@
import json
import re
import datetime # Add datetime import
import time # Add time import
from copy import deepcopy
from typing import Any, AsyncGenerator, Dict, List, Optional, Union
@@ -14,7 +16,7 @@ from app.log.logger import get_openai_logger
from app.service.client.api_client import GeminiApiClient
from app.service.image.image_create_service import ImageCreateService
from app.service.key.key_manager import KeyManager
from app.database.services import add_error_log
from app.database.services import add_error_log, add_request_log # Import add_request_log
logger = get_openai_logger()
@@ -191,28 +193,49 @@ class OpenAIChatService:
self, model: str, payload: Dict[str, Any], api_key: str
) -> Dict[str, Any]:
"""处理普通聊天完成"""
start_time = time.perf_counter()
request_datetime = datetime.datetime.now()
is_success = False
status_code = None
response = None
try:
response = await self.api_client.generate_content(payload, model, api_key)
is_success = True
status_code = 200 # Assume 200 on success
return self.response_handler.handle_response(
response, model, stream=False, finish_reason="stop"
)
except Exception as e:
logger.error(f"Normal API call failed with error: {str(e)}")
error_code = None
is_success = False
error_log_msg = str(e)
# 尝试从异常消息中解析状态码
logger.error(f"Normal API call failed with error: {error_log_msg}")
# Try to parse status code from exception
match = re.search(r"status code (\d+)", error_log_msg)
if match:
error_code = int(match.group(1))
status_code = int(match.group(1))
else:
status_code = 500 # Default if parsing fails
await add_error_log(
gemini_key=api_key,
gemini_key=api_key, # Note: Parameter name is gemini_key in add_error_log
model_name=model,
error_type="openai_chat_service", # Indicate service type
error_log=error_log_msg,
error_code=error_code,
error_code=status_code,
request_msg=payload
)
raise e # 重新抛出异常,以便上层处理
raise e # Re-throw exception
finally:
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
await add_request_log(
model_name=model,
api_key=api_key,
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime
)
async def _handle_stream_completion(
self, model: str, payload: Dict[str, Any], api_key: str
@@ -220,77 +243,114 @@ class OpenAIChatService:
"""处理流式聊天完成,添加重试逻辑"""
retries = 0
max_retries = settings.MAX_RETRIES
while retries < max_retries:
try:
tool_call_flag = False
async for line in self.api_client.stream_generate_content(
payload, model, api_key
):
# print(line)
if line.startswith("data:"):
chunk = json.loads(line[6:])
openai_chunk = self.response_handler.handle_response(
chunk, model, stream=True, finish_reason=None
)
if openai_chunk:
# 提取文本内容
text = self._extract_text_from_openai_chunk(openai_chunk)
if text and settings.STREAM_OPTIMIZER_ENABLED:
# 使用流式输出优化器处理文本输出
async for (
optimized_chunk
) in openai_optimizer.optimize_stream_output(
text,
lambda t: self._create_char_openai_chunk(
openai_chunk, t
),
lambda c: f"data: {json.dumps(c)}\n\n",
):
yield optimized_chunk
else:
# 如果没有文本内容(如工具调用等),整块输出
if "tool_calls" in json.dumps(openai_chunk):
tool_call_flag = True
yield f"data: {json.dumps(openai_chunk)}\n\n"
if tool_call_flag:
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='tool_calls'))}\n\n"
else:
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='stop'))}\n\n"
yield "data: [DONE]\n\n"
logger.info("Streaming completed successfully")
break # 成功后退出循环
except Exception as e:
retries += 1
error_log_msg = str(e)
logger.warning(
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
)
# 解析错误信息并记录到数据库
error_code = None
match = re.search(r"status code (\d+)", error_log_msg)
if match:
error_code = int(match.group(1))
print(model)
await add_error_log(
gemini_key=api_key,
model_name=model,
error_type="openai_chat_service",
error_log=error_log_msg,
error_code=error_code,
request_msg=payload
)
# 尝试切换 API Key
api_key = await self.key_manager.handle_api_failure(api_key,retries)
if api_key:
logger.info(f"Switched to new API key: {api_key}")
if retries >= max_retries:
logger.error(
f"Max retries ({max_retries}) reached for streaming. Raising error"
)
yield f"data: {json.dumps({'error': 'Streaming failed after retries'})}\n\n"
start_time = time.perf_counter() # Record start time before loop
request_datetime = datetime.datetime.now()
is_success = False
status_code = None
final_api_key = api_key # Store the initial key
try:
while retries < max_retries:
current_attempt_key = api_key # Key used for this attempt
final_api_key = current_attempt_key # Update final key used
try:
tool_call_flag = False
async for line in self.api_client.stream_generate_content(
payload, model, current_attempt_key
):
# print(line)
if line.startswith("data:"):
chunk = json.loads(line[6:])
openai_chunk = self.response_handler.handle_response(
chunk, model, stream=True, finish_reason=None
)
if openai_chunk:
# 提取文本内容
text = self._extract_text_from_openai_chunk(openai_chunk)
if text and settings.STREAM_OPTIMIZER_ENABLED:
# 使用流式输出优化器处理文本输出
async for (
optimized_chunk
) in openai_optimizer.optimize_stream_output(
text,
lambda t: self._create_char_openai_chunk(
openai_chunk, t
),
lambda c: f"data: {json.dumps(c)}\n\n",
):
yield optimized_chunk
else:
# 如果没有文本内容(如工具调用等),整块输出
if "tool_calls" in json.dumps(openai_chunk):
tool_call_flag = True
yield f"data: {json.dumps(openai_chunk)}\n\n"
if tool_call_flag:
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='tool_calls'))}\n\n"
else:
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='stop'))}\n\n"
yield "data: [DONE]\n\n"
break
logger.info("Streaming completed successfully")
is_success = True
status_code = 200 # Assume 200 on success
break # 成功后退出循环
except Exception as e:
retries += 1
is_success = False # Mark as failed for this attempt
error_log_msg = str(e)
logger.warning(
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
)
# Parse error code for logging
match = re.search(r"status code (\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
else:
status_code = 500 # Default if parsing fails
# Log error to error log table
await add_error_log(
gemini_key=current_attempt_key, # Note: Parameter name is gemini_key
model_name=model,
error_type="openai_chat_service", # Indicate service type
error_log=error_log_msg,
error_code=status_code,
request_msg=payload
)
# Attempt to switch API Key
# Ensure key_manager is available (might need adjustment if not always passed)
if self.key_manager:
api_key = await self.key_manager.handle_api_failure(current_attempt_key, retries)
if api_key:
logger.info(f"Switched to new API key: {api_key}")
else:
logger.error(f"No valid API key available after {retries} retries.")
break # Exit loop if no key available
else:
logger.error("KeyManager not available for retry logic.")
break # Exit loop if key manager is missing
if retries >= max_retries:
logger.error(
f"Max retries ({max_retries}) reached for streaming."
)
break # Exit loop after max retries
finally:
# Log the final outcome of the streaming request
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
await add_request_log(
model_name=model,
api_key=final_api_key, # Log the last key used
is_success=is_success, # Log the final success status
status_code=status_code, # Log the last known status code
latency_ms=latency_ms, # Log total time including retries
request_time=request_datetime
)
# If the loop finished due to failure, yield error and DONE
if not is_success and retries >= max_retries:
yield f"data: {json.dumps({'error': 'Streaming failed after retries'})}\n\n"
yield "data: [DONE]\n\n"
async def create_image_chat_completion(
self,

View File

@@ -0,0 +1,69 @@
# app/service/stats_service.py
import datetime
from sqlalchemy import select, func
from app.database.connection import database
from app.database.models import RequestLog
from app.log.logger import get_stats_logger
logger = get_stats_logger()
async def get_calls_in_last_seconds(seconds: int) -> int:
"""获取过去 N 秒内的调用次数 (包括成功和失败)"""
try:
cutoff_time = datetime.datetime.now() - datetime.timedelta(seconds=seconds)
query = select(func.count(RequestLog.id)).where(
RequestLog.request_time >= cutoff_time
)
count_result = await database.fetch_one(query)
return count_result[0] if count_result else 0
except Exception as e:
logger.error(f"Failed to get calls in last {seconds} seconds: {e}")
return 0 # Return 0 on error
async def get_calls_in_last_minutes(minutes: int) -> int:
"""获取过去 N 分钟内的调用次数 (包括成功和失败)"""
return await get_calls_in_last_seconds(minutes * 60)
async def get_calls_in_last_hours(hours: int) -> int:
"""获取过去 N 小时内的调用次数 (包括成功和失败)"""
return await get_calls_in_last_seconds(hours * 3600)
async def get_calls_in_current_month() -> int:
"""获取当前自然月内的调用次数 (包括成功和失败)"""
try:
now = datetime.datetime.now()
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
query = select(func.count(RequestLog.id)).where(
RequestLog.request_time >= start_of_month
)
count_result = await database.fetch_one(query)
return count_result[0] if count_result else 0
except Exception as e:
logger.error(f"Failed to get calls in current month: {e}")
return 0 # Return 0 on error
async def get_api_usage_stats() -> dict:
"""获取所有需要的 API 使用统计数据"""
try:
calls_1m = await get_calls_in_last_minutes(1)
calls_1h = await get_calls_in_last_hours(1)
calls_24h = await get_calls_in_last_hours(24)
calls_month = await get_calls_in_current_month()
return {
"calls_1m": calls_1m,
"calls_1h": calls_1h,
"calls_24h": calls_24h,
"calls_month": calls_month,
}
except Exception as e:
logger.error(f"Failed to get API usage stats: {e}")
# Return default values on error
return {
"calls_1m": 0,
"calls_1h": 0,
"calls_24h": 0,
"calls_month": 0,
}

View File

@@ -469,15 +469,7 @@ async function saveConfig() {
const result = await response.json();
// 显示保存状态
const saveStatus = document.getElementById('saveStatus');
saveStatus.style.opacity = "1";
saveStatus.style.transform = "translate(-50%, -50%) scale(1.1)";
setTimeout(() => {
saveStatus.style.opacity = "0";
saveStatus.style.transform = "translate(-50%, -50%) scale(0.95)";
}, 3000);
// 移除居中的 saveStatus 提示
showNotification('配置保存成功', 'success');
@@ -488,21 +480,7 @@ async function saveConfig() {
console.error('保存配置失败:', error);
// 保存失败时,也尝试重启定时任务,以防万一
await startScheduler();
// 显示错误状态
const saveStatus = document.getElementById('saveStatus');
saveStatus.style.backgroundColor = "#ef4444"; // 红色背景
saveStatus.style.opacity = "1";
saveStatus.style.transform = "translate(-50%, -50%) scale(1.1)";
saveStatus.querySelector('.status-icon i').className = 'fas fa-times-circle';
saveStatus.querySelector('.status-text').textContent = '配置保存失败';
setTimeout(() => {
saveStatus.style.opacity = "0";
saveStatus.style.transform = "translate(-50%, -50%) scale(0.95)";
setTimeout(() => {
saveStatus.style.backgroundColor = "#22c55e"; // 恢复绿色背景
}, 300);
}, 3000);
// 移除居中的 saveStatus 提示
showNotification('保存配置失败: ' + error.message, 'error');
}

View File

@@ -1,3 +1,5 @@
// 统计数据可视化交互效果
function copyToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
@@ -25,11 +27,35 @@ function copyToClipboard(text) {
}
}
// 添加统计项动画效果
function initStatItemAnimations() {
const statItems = document.querySelectorAll('.stat-item');
statItems.forEach(item => {
item.addEventListener('mouseenter', () => {
item.style.transform = 'scale(1.05)';
const icon = item.querySelector('.stat-icon');
if (icon) {
icon.style.opacity = '0.2';
icon.style.transform = 'scale(1.1) rotate(0deg)';
}
});
item.addEventListener('mouseleave', () => {
item.style.transform = '';
const icon = item.querySelector('.stat-icon');
if (icon) {
icon.style.opacity = '';
icon.style.transform = '';
}
});
});
}
function copyKeys(type) {
const keys = Array.from(document.querySelectorAll(`#${type}Keys .key-text`)).map(span => span.dataset.fullKey);
if (keys.length === 0) {
showCopyStatus('没有可复制的密钥', true);
showNotification('没有可复制的密钥', 'error');
return;
}
@@ -37,48 +63,26 @@ function copyKeys(type) {
copyToClipboard(keysText)
.then(() => {
showCopyStatus(`已成功复制${keys.length}${type === 'valid' ? '有效' : '无效'}密钥到剪贴板`);
showNotification(`已成功复制${keys.length}${type === 'valid' ? '有效' : '无效'}密钥`);
})
.catch((err) => {
console.error('无法复制文本: ', err);
showCopyStatus('复制失败,请重试', true);
showNotification('复制失败,请重试', 'error');
});
}
function copyKey(key) {
copyToClipboard(key)
.then(() => {
showCopyStatus(`已成功复制密钥到剪贴板`);
showNotification(`已成功复制密钥`);
})
.catch((err) => {
console.error('无法复制文本: ', err);
showCopyStatus('复制失败,请重试', true);
showNotification('复制失败,请重试', 'error');
});
}
function showCopyStatus(message, isError = false) {
const statusElement = document.getElementById('copyStatus');
statusElement.textContent = message;
// 添加适当的样式类
if (isError) {
statusElement.classList.add('bg-danger-500');
statusElement.classList.remove('bg-black');
} else {
statusElement.classList.remove('bg-danger-500');
statusElement.classList.add('bg-black');
}
// 应用过渡效果
statusElement.style.opacity = "1";
statusElement.style.transform = "translate(-50%, 0)";
// 设置自动消失
setTimeout(() => {
statusElement.style.opacity = "0";
statusElement.style.transform = "translate(-50%, 10px)";
}, 3000);
}
// 移除 showCopyStatus 函数,因为它已被 showNotification 替代
async function verifyKey(key, button) {
try {
@@ -140,25 +144,38 @@ async function resetKeyFailCount(key, button) {
// 根据重置结果更新UI
if (data.success) {
showCopyStatus('失败计数重置成功');
showNotification('失败计数重置成功');
// 成功时保留绿色背景一会儿
button.style.backgroundColor = '#27ae60';
setTimeout(() => location.reload(), 1500);
// 稍后刷新页面
setTimeout(() => location.reload(), 1000);
} else {
const errorMsg = data.message || '重置失败';
showCopyStatus('重置失败: ' + errorMsg, true);
showNotification('重置失败: ' + errorMsg, 'error');
// 失败时保留红色背景一会儿
button.style.backgroundColor = '#e74c3c';
}
// 1秒后恢复按钮原始状态
setTimeout(() => {
button.innerHTML = originalHtml;
button.disabled = false;
button.style.backgroundColor = '';
}, 1000);
// 立即恢复按钮状态,除非成功或失败时需要短暂显示颜色
if (!data.success) {
// 如果失败1秒后恢复按钮
setTimeout(() => {
button.innerHTML = originalHtml;
button.disabled = false;
button.style.backgroundColor = '';
}, 1000);
} else {
// 如果成功,在刷新前恢复按钮(虽然用户可能看不到)
button.innerHTML = originalHtml;
button.disabled = false;
// 背景色会在刷新时重置
}
} catch (error) {
console.error('重置失败:', error);
showCopyStatus('重置请求失败: ' + error.message, true);
showNotification('重置请求失败: ' + error.message, 'error');
// 确保在捕获到错误时恢复按钮状态
button.innerHTML = originalHtml; // 需要确保 originalHtml 在此作用域可用
button.disabled = false;
button.innerHTML = '<i class="fas fa-redo-alt"></i> 重置';
}
@@ -280,11 +297,9 @@ async function executeResetAll(type) {
console.error('API请求失败:', fetchError);
showResultModal(false, '批量重置请求失败: ' + fetchError.message);
} finally {
// 恢复按钮原始状态
setTimeout(() => {
resetButton.innerHTML = originalHtml;
resetButton.disabled = false;
}, 500);
// 立即恢复按钮状态
resetButton.innerHTML = originalHtml;
resetButton.disabled = false;
}
} catch (error) {
console.error('批量重置失败:', error);
@@ -315,12 +330,40 @@ function refreshPage(button) {
}, 300);
}
// 重写切换区域显示/隐藏函数,以更好地支持新样式
function toggleSection(header, sectionId) {
const toggleIcon = header.querySelector('.toggle-icon');
const content = header.nextElementSibling;
toggleIcon.classList.toggle('collapsed');
content.classList.toggle('collapsed');
if (toggleIcon && content) {
// 添加旋转动画
toggleIcon.classList.toggle('collapsed');
// 控制内容区域的可见性
if (!content.classList.contains('collapsed')) {
// 收起内容
content.style.maxHeight = '0px';
content.style.opacity = '0';
content.style.overflow = 'hidden';
content.classList.add('collapsed');
// 为动画添加延迟
setTimeout(() => {
content.style.padding = '0';
}, 100);
} else {
// 展开内容
content.classList.remove('collapsed');
content.style.padding = '1rem';
content.style.maxHeight = '2000px'; // 使用足够大的高度
content.style.opacity = '1';
// 为动画添加延迟
setTimeout(() => {
content.style.overflow = 'visible';
}, 300);
}
}
}
// 筛选有效密钥(根据失败次数阈值)
@@ -344,28 +387,77 @@ function filterValidKeys() {
// 初始化
document.addEventListener('DOMContentLoaded', () => {
// 移除对滚动按钮显示的控制让它们由HTML/CSS控制
// 初始化统计区块动画
initStatItemAnimations();
// 监听展开/折叠事件
document.querySelectorAll('.key-list h2').forEach(header => {
header.addEventListener('click', () => {
// 不再调用updateScrollButtons
// 添加数字滚动动画效果
const animateCounters = () => {
const statValues = document.querySelectorAll('.stat-value');
statValues.forEach(valueElement => {
const finalValue = parseInt(valueElement.textContent, 10);
if (!isNaN(finalValue)) {
// 保存原始值以便稍后恢复
if (!valueElement.dataset.originalValue) {
valueElement.dataset.originalValue = valueElement.textContent;
}
// 数字滚动动画
let startValue = 0;
const duration = 1500;
const startTime = performance.now();
const updateCounter = (currentTime) => {
const elapsedTime = currentTime - startTime;
if (elapsedTime < duration) {
const progress = elapsedTime / duration;
// 使用缓动函数使动画更自然
const easeOutValue = 1 - Math.pow(1 - progress, 3);
const currentValue = Math.floor(easeOutValue * finalValue);
valueElement.textContent = currentValue;
requestAnimationFrame(updateCounter);
} else {
// 恢复为原始值,以确保准确性
valueElement.textContent = valueElement.dataset.originalValue;
}
};
requestAnimationFrame(updateCounter);
}
});
};
// 在页面加载后启动数字动画
setTimeout(animateCounters, 300);
// 添加卡片悬停效果
document.querySelectorAll('.stats-card').forEach(card => {
card.addEventListener('mouseenter', () => {
card.classList.add('shadow-lg');
card.style.transform = 'translateY(-2px)';
});
card.addEventListener('mouseleave', () => {
card.classList.remove('shadow-lg');
card.style.transform = '';
});
});
// 更新版权年份
const copyrightYearElement = document.querySelector('.copyright script');
if (copyrightYearElement && copyrightYearElement.parentNode.classList.contains('copyright')) {
// 确保只更新版权部分的年份
copyrightYearElement.textContent = new Date().getFullYear();
}
// 监听展开/折叠事件
document.querySelectorAll('.stats-card-title').forEach(header => {
header.addEventListener('click', () => {
const card = header.closest('.stats-card');
if (card) {
card.classList.toggle('active');
}
});
});
// 添加筛选输入框事件监听
const thresholdInput = document.getElementById('failCountThreshold');
if (thresholdInput) {
// 使用 'input' 事件实时响应输入变化
thresholdInput.addEventListener('input', filterValidKeys);
// 初始加载时应用一次筛选基于默认值1
// 初始加载时应用一次筛选
filterValidKeys();
}
});

View File

@@ -54,7 +54,7 @@
<h1 class="text-3xl font-extrabold text-center text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-primary-700 mb-4">
<img src="/static/icons/logo.png" alt="Gemini Balance Logo" class="h-9 inline-block align-middle mr-2">
Gemini Balance
Gemini Balance - 配置编辑
</h1>
<!-- Navigation Tabs -->
@@ -63,7 +63,7 @@
<i class="fas fa-cog"></i> 配置编辑
</a>
<a href="/keys" class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-white bg-opacity-50 hover:bg-opacity-70 text-gray-700 transition-all duration-200">
<i class="fas fa-key"></i> 密钥状态
<i class="fas fa-tachometer-alt"></i> 监控面板
</a>
<a href="/logs" class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-white bg-opacity-50 hover:bg-opacity-70 text-gray-700 transition-all duration-200">
<i class="fas fa-exclamation-triangle"></i> 错误日志
@@ -89,11 +89,7 @@
</button>
</div>
<!-- Save Status Banner -->
<div class="save-status fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-green-500 text-white px-8 py-4 rounded-xl font-medium flex items-center gap-3 shadow-xl z-50 opacity-0 transition-all duration-300 scale-105" id="saveStatus">
<span class="status-icon text-xl"><i class="fas fa-check-circle"></i></span>
<span class="status-text text-lg">配置已保存</span>
</div>
<!-- Save Status Banner (Removed - using notification component now) -->
<!-- Configuration Form -->
<form id="configForm" class="mt-6">

View File

@@ -58,7 +58,7 @@
<h1 class="text-3xl font-extrabold text-center text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-primary-700 mb-4">
<img src="/static/icons/logo.png" alt="Gemini Balance Logo" class="h-9 inline-block align-middle mr-2">
Gemini Balance
Gemini Balance - 错误日志
</h1>
<!-- Navigation Tabs -->
@@ -67,7 +67,7 @@
<i class="fas fa-cog"></i> 配置编辑
</a>
<a href="/keys" class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-white bg-opacity-50 hover:bg-opacity-70 text-gray-700 transition-all duration-200">
<i class="fas fa-key"></i> 密钥状态
<i class="fas fa-tachometer-alt"></i> 监控面板
</a>
<a href="/logs" class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-primary-600 text-white shadow-md">
<i class="fas fa-exclamation-triangle"></i> 错误日志

View File

@@ -20,6 +20,185 @@
transform: rotate(-90deg);
}
/* Copy status styling is handled by base.html's notification */
/* 现代数据统计样式 */
.stats-dashboard {
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
margin-bottom: 2rem;
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);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-radius: 0.75rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
border: 1px solid rgba(255, 255, 255, 0.4);
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;
border-bottom: 1px solid rgba(243, 244, 246, 0.5);
display: flex;
align-items: center;
justify-content: space-between;
}
.stats-card-title {
display: flex;
align-items: center;
font-size: 1rem;
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;
border-radius: 0.5rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
transition: all 0.3s ease-in-out;
position: relative;
overflow: hidden;
}
.stat-item::before {
content: '';
position: absolute;
inset: 0;
opacity: 0.05;
background-color: currentColor;
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;
z-index: 10;
position: relative;
color: #1F2937;
}
.stat-label {
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 0.25rem;
z-index: 10;
position: relative;
color: #6B7280;
}
.stat-icon {
position: absolute;
right: 0.5rem;
bottom: 0.25rem;
opacity: 0.1;
font-size: 1.875rem;
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);
}
/* 响应式调整 */
@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;
}
}
</style>
{% endblock %}
@@ -29,40 +208,99 @@
{% endblock %}
{% block content %}
<div class="container max-w-4xl mx-auto px-4">
<div class="container max-w-6xl mx-auto px-4"> <!-- Increased max-width -->
<div class="glass-card rounded-2xl shadow-xl p-6 md:p-8">
<button class="absolute top-6 right-6 bg-white bg-opacity-20 hover:bg-opacity-30 rounded-full w-8 h-8 flex items-center justify-center text-primary-600 transition-all duration-300" onclick="refreshPage(this)">
<i class="fas fa-sync-alt"></i>
</button>
<h1 class="text-3xl font-extrabold text-center text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-primary-700 mb-4">
<img src="/static/icons/logo.png" alt="Gemini Balance Logo" class="h-9 inline-block align-middle mr-2">
Gemini Balance
Gemini Balance - 监控面板
</h1>
<!-- Navigation Tabs -->
<div class="flex justify-center mb-8 overflow-x-auto pb-2 gap-2">
<a href="/config" class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-white bg-opacity-50 hover:bg-opacity-70 text-gray-700 transition-all duration-200">
<i class="fas fa-cog"></i> 配置编辑
</a>
<a href="/keys" class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-primary-600 text-white shadow-md">
<i class="fas fa-key"></i> 密钥状态
<i class="fas fa-tachometer-alt"></i> 监控面板
</a>
<a href="/logs" class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-white bg-opacity-50 hover:bg-opacity-70 text-gray-700 transition-all duration-200">
<i class="fas fa-exclamation-triangle"></i> 错误日志
</a>
</div>
<!-- 现代化统计面板 -->
<div class="stats-dashboard animate-fade-in" style="animation-delay: 0.1s">
<!-- 密钥统计卡片 -->
<div class="stats-card">
<div class="stats-card-header">
<h3 class="stats-card-title">
<i class="fas fa-key"></i>
<span>密钥统计</span>
</h3>
<span class="text-xs text-gray-500">总计: {{ total_keys }}</span>
</div>
<div class="stats-grid">
<div class="stat-item stat-primary" title="总密钥数">
<div class="stat-value">{{ total_keys }}</div>
<div class="stat-label">总密钥数</div>
<i class="stat-icon fas fa-key"></i>
</div>
<div class="stat-item stat-success" title="有效密钥">
<div class="stat-value">{{ valid_key_count }}</div>
<div class="stat-label">有效密钥</div>
<i class="stat-icon fas fa-check-circle"></i>
</div>
<div class="stat-item stat-danger" title="无效密钥">
<div class="stat-value">{{ invalid_key_count }}</div>
<div class="stat-label">无效密钥</div>
<i class="stat-icon fas fa-times-circle"></i>
</div>
</div>
</div>
<!-- API调用统计卡片 -->
<div class="stats-card">
<div class="stats-card-header">
<h3 class="stats-card-title">
<i class="fas fa-chart-line"></i>
<span>API调用统计</span>
</h3>
<span class="text-xs text-gray-500">本月: {{ api_stats.calls_month }}</span>
</div>
<div class="stats-grid">
<div class="stat-item stat-warning" title="1分钟内API调用">
<div class="stat-value">{{ api_stats.calls_1m }}</div>
<div class="stat-label">1分钟调用</div>
<i class="stat-icon fas fa-stopwatch"></i>
</div>
<div class="stat-item stat-info" title="1小时内API调用">
<div class="stat-value">{{ api_stats.calls_1h }}</div>
<div class="stat-label">1小时调用</div>
<i class="stat-icon fas fa-hourglass-half"></i>
</div>
<div class="stat-item stat-primary" title="24小时内API调用">
<div class="stat-value">{{ api_stats.calls_24h }}</div>
<div class="stat-label">24小时调用</div>
<i class="stat-icon fas fa-calendar-day"></i>
</div>
</div>
</div>
</div>
<!-- 有效密钥区域 -->
<div class="bg-white bg-opacity-70 rounded-xl shadow-md overflow-hidden mb-6 animate-fade-in">
<div class="flex justify-between items-center p-4 bg-white bg-opacity-80 cursor-pointer" onclick="toggleSection(this, 'validKeys')">
<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">
<i class="fas fa-chevron-down toggle-icon text-primary-600"></i>
<i class="fas fa-check-circle text-success-500 text-xl"></i>
<h2 class="text-lg font-semibold">有效密钥</h2>
<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>
<input type="number" id="failCountThreshold" value="0" min="0" class="form-input h-7 w-16 px-2 py-1 text-sm border-gray-300 rounded focus:ring-primary-500 focus:border-primary-500">
<input type="number" id="failCountThreshold" value="0" min="0" class="form-input h-7 w-16 px-2 py-1 text-sm border border-gray-300 rounded focus:ring-primary-500 focus:border-primary-500">
</div>
</div>
<div class="flex gap-2">
@@ -78,10 +316,11 @@
</div>
<div class="key-content p-4 bg-white bg-opacity-40">
<ul id="validKeys" class="space-y-3">
{% for key, fail_count in valid_keys.items() %}
<li class="bg-white rounded-lg p-3 shadow-sm hover:shadow-md transition-shadow duration-200 border border-gray-100" data-fail-count="{{ fail_count }}">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<ul id="validKeys" class="grid grid-cols-1 md:grid-cols-2 gap-3">
{% 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> 有效
@@ -97,7 +336,7 @@
失败: {{ fail_count }}
</span>
</div>
<div class="flex items-center gap-2">
<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>
验证
@@ -114,17 +353,20 @@
</div>
</li>
{% endfor %}
{% else %}
<li class="text-center text-gray-500 py-4">暂无有效密钥</li>
{% endif %}
</ul>
</div>
</div>
<!-- 无效密钥区域 -->
<div class="bg-white bg-opacity-70 rounded-xl shadow-md overflow-hidden mb-6 animate-fade-in" style="animation-delay: 0.2s">
<div class="flex justify-between items-center p-4 bg-white bg-opacity-80 cursor-pointer" onclick="toggleSection(this, 'invalidKeys')">
<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">
<i class="fas fa-chevron-down toggle-icon text-primary-600"></i>
<i class="fas fa-times-circle text-danger-500 text-xl"></i>
<h2 class="text-lg font-semibold">无效密钥</h2>
<i class="fas fa-times-circle text-danger-500"></i>
<h2 class="text-lg font-semibold">无效密钥列表 ({{ invalid_key_count }})</h2>
</div>
<div class="flex gap-2">
<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">
@@ -139,10 +381,11 @@
</div>
<div class="key-content p-4 bg-white bg-opacity-40">
<ul id="invalidKeys" class="space-y-3">
{% for key, fail_count in invalid_keys.items() %}
<li class="bg-white rounded-lg p-3 shadow-sm hover:shadow-md transition-shadow duration-200 border border-gray-100">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<ul id="invalidKeys" class="grid grid-cols-1 md:grid-cols-2 gap-3">
{% 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> 无效
@@ -158,7 +401,7 @@
失败: {{ fail_count }}
</span>
</div>
<div class="flex items-center gap-2">
<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>
验证
@@ -175,16 +418,14 @@
</div>
</li>
{% endfor %}
{% else %}
<li class="text-center text-gray-500 py-4">暂无无效密钥</li>
{% endif %}
</ul>
</div>
</div>
<!-- 总密钥数显示 -->
<div class="bg-white bg-opacity-70 rounded-xl shadow-md p-4 text-center animate-fade-in" style="animation-delay: 0.4s">
<div class="flex items-center justify-center gap-2 text-primary-700 font-semibold text-lg">
<i class="fas fa-key"></i> 总密钥数:{{ total }}
</div>
</div>
<!-- Removed old total keys display -->
</div>
</div>
@@ -261,14 +502,34 @@
if (isNaN(threshold)) return; // Do nothing if input is not a number
const keys = validKeysList.querySelectorAll('li');
let visibleCount = 0;
keys.forEach(keyItem => {
const failCount = parseInt(keyItem.getAttribute('data-fail-count'), 10);
if (failCount >= threshold) {
keyItem.style.display = ''; // Show item
} else {
keyItem.style.display = 'none'; // Hide item
// 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) {