mirror of
https://github.com/snailyp/gemini-balance.git
synced 2026-09-08 09:26:36 +08:00
feat(stats): 添加密钥使用详情统计功能
新增功能允许用户在 Keys 状态页面点击“详情”按钮,查看指定 API 密钥在过去 24 小时内按模型分类的请求次数统计。
主要变更包括:
后端:
- 新增 `app/router/stats_routes.py`,包含 `/api/key-usage-details/{key}` API 端点用于获取密钥使用详情。
- 重构 `app/service/stats_service.py`,将统计相关函数封装到 `StatsService` 类中,并添加 `get_key_usage_details_last_24h` 方法。
- 在 `app/router/routes.py` 中注册新的 `stats_routes`,并更新对 `stats_service` 的调用方式以使用类实例。
- 更新 `app/log/logger.py` 添加 `get_scheduler_routes` 日志记录器,并在 `app/router/scheduler_routes.py` 中使用它。
前端:
- 在 `app/templates/keys_status.html` 中为每个有效和无效密钥列表项添加“详情”按钮。
- 在 `app/templates/keys_status.html` 中添加用于显示密钥使用详情的模态框 HTML 结构。
- 在 `app/static/js/keys_status.js` 中添加 JavaScript 函数 (`showKeyUsageDetails`, `closeKeyUsageDetailsModal`, `renderKeyUsageDetails`) 来处理按钮点击事件、调用后端 API、控制模态框显示/隐藏以及渲染获取到的统计数据。
This commit is contained in:
+5
-1
@@ -203,4 +203,8 @@ def get_stats_logger():
|
|||||||
|
|
||||||
|
|
||||||
def get_update_logger():
|
def get_update_logger():
|
||||||
return Logger.setup_logger("update_service")
|
return Logger.setup_logger("update_service")
|
||||||
|
|
||||||
|
|
||||||
|
def get_scheduler_routes():
|
||||||
|
return Logger.setup_logger("scheduler_routes")
|
||||||
@@ -8,9 +8,9 @@ from fastapi.templating import Jinja2Templates
|
|||||||
|
|
||||||
from app.core.security import verify_auth_token
|
from app.core.security import verify_auth_token
|
||||||
from app.log.logger import get_routes_logger
|
from app.log.logger import get_routes_logger
|
||||||
from app.router import gemini_routes, openai_routes, config_routes, log_routes, scheduler_routes # 新增导入
|
from app.router import gemini_routes, openai_routes, config_routes, log_routes, scheduler_routes, stats_routes # 新增导入 stats_routes
|
||||||
from app.service.key.key_manager import get_key_manager_instance
|
from app.service.key.key_manager import get_key_manager_instance
|
||||||
from app.service.stats_service import get_api_usage_stats, get_api_call_details # <-- Import stats service and details function
|
from app.service.stats_service import StatsService
|
||||||
|
|
||||||
logger = get_routes_logger()
|
logger = get_routes_logger()
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ def setup_routers(app: FastAPI) -> None:
|
|||||||
app.include_router(config_routes.router)
|
app.include_router(config_routes.router)
|
||||||
app.include_router(log_routes.router)
|
app.include_router(log_routes.router)
|
||||||
app.include_router(scheduler_routes.router) # 新增包含 scheduler 路由
|
app.include_router(scheduler_routes.router) # 新增包含 scheduler 路由
|
||||||
|
app.include_router(stats_routes.router) # 包含 stats API 路由
|
||||||
|
|
||||||
# 添加页面路由
|
# 添加页面路由
|
||||||
setup_page_routes(app)
|
setup_page_routes(app)
|
||||||
@@ -92,8 +93,8 @@ def setup_page_routes(app: FastAPI) -> None:
|
|||||||
valid_key_count = len(keys_status["valid_keys"])
|
valid_key_count = len(keys_status["valid_keys"])
|
||||||
invalid_key_count = len(keys_status["invalid_keys"])
|
invalid_key_count = len(keys_status["invalid_keys"])
|
||||||
|
|
||||||
# Get API usage stats
|
stats_service = StatsService()
|
||||||
api_stats = await get_api_usage_stats()
|
api_stats = await stats_service.get_api_usage_stats()
|
||||||
logger.info(f"API stats retrieved: {api_stats}")
|
logger.info(f"API stats retrieved: {api_stats}")
|
||||||
|
|
||||||
logger.info(f"Keys status retrieved successfully. Total keys: {total_keys}")
|
logger.info(f"Keys status retrieved successfully. Total keys: {total_keys}")
|
||||||
@@ -180,7 +181,9 @@ def setup_api_stats_routes(app: FastAPI) -> None:
|
|||||||
return {"error": "Unauthorized"}, 401
|
return {"error": "Unauthorized"}, 401
|
||||||
|
|
||||||
logger.info(f"Fetching API call details for period: {period}")
|
logger.info(f"Fetching API call details for period: {period}")
|
||||||
details = await get_api_call_details(period)
|
# Use the service instance here as well
|
||||||
|
stats_service = StatsService() # Create an instance
|
||||||
|
details = await stats_service.get_api_call_details(period)
|
||||||
return details
|
return details
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning(f"Invalid period requested for API stats details: {period} - {str(e)}")
|
logger.warning(f"Invalid period requested for API stats details: {period} - {str(e)}")
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ from fastapi.responses import JSONResponse
|
|||||||
|
|
||||||
from app.core.security import verify_auth_token # 导入 verify_auth_token
|
from app.core.security import verify_auth_token # 导入 verify_auth_token
|
||||||
from app.scheduler.key_checker import start_scheduler, stop_scheduler
|
from app.scheduler.key_checker import start_scheduler, stop_scheduler
|
||||||
from app.log.logger import get_routes_logger # 使用路由日志记录器
|
from app.log.logger import get_scheduler_routes # 使用路由日志记录器
|
||||||
|
|
||||||
logger = get_routes_logger()
|
logger = get_scheduler_routes()
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/api/scheduler",
|
prefix="/api/scheduler",
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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 # 使用路由日志记录器
|
||||||
|
|
||||||
|
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):
|
||||||
|
logger.warning("Unauthorized access attempt to scheduler API")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Not authenticated",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/api",
|
||||||
|
tags=["stats"],
|
||||||
|
dependencies=[Depends(verify_token)] # Assuming API routes need authentication
|
||||||
|
)
|
||||||
|
|
||||||
|
stats_service = StatsService()
|
||||||
|
|
||||||
|
@router.get("/key-usage-details/{key}",
|
||||||
|
summary="获取指定密钥最近24小时的模型调用次数",
|
||||||
|
description="根据提供的 API 密钥,返回过去24小时内每个模型被调用的次数统计。")
|
||||||
|
async def get_key_usage_details(key: str):
|
||||||
|
"""
|
||||||
|
Retrieves the model usage count for a specific API key within the last 24 hours.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: The API key to get usage details for.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dictionary with model names as keys and their call counts as values.
|
||||||
|
Example: {"gemini-pro": 10, "gemini-1.5-pro-latest": 5}
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If an error occurs during data retrieval.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
usage_details = await stats_service.get_key_usage_details_last_24h(key)
|
||||||
|
if usage_details is None:
|
||||||
|
# Handle case where key might be valid but has no recent usage,
|
||||||
|
# or if the service layer explicitly returns None for other reasons.
|
||||||
|
# Returning an empty dict is usually fine for the frontend.
|
||||||
|
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}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"获取密钥使用详情时出错: {e}"
|
||||||
|
)
|
||||||
+148
-99
@@ -9,117 +9,166 @@ from app.log.logger import get_stats_logger
|
|||||||
|
|
||||||
logger = 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:
|
class StatsService:
|
||||||
"""获取过去 N 分钟内的调用次数 (包括成功和失败)"""
|
"""Service class for handling statistics related operations."""
|
||||||
return await get_calls_in_last_seconds(minutes * 60)
|
|
||||||
|
|
||||||
async def get_calls_in_last_hours(hours: int) -> int:
|
async def get_calls_in_last_seconds(self, seconds: int) -> int:
|
||||||
"""获取过去 N 小时内的调用次数 (包括成功和失败)"""
|
"""获取过去 N 秒内的调用次数 (包括成功和失败)"""
|
||||||
return await get_calls_in_last_seconds(hours * 3600)
|
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_current_month() -> int:
|
async def get_calls_in_last_minutes(self, minutes: int) -> int:
|
||||||
"""获取当前自然月内的调用次数 (包括成功和失败)"""
|
"""获取过去 N 分钟内的调用次数 (包括成功和失败)"""
|
||||||
try:
|
return await self.get_calls_in_last_seconds(minutes * 60)
|
||||||
|
|
||||||
|
async def get_calls_in_last_hours(self, hours: int) -> int:
|
||||||
|
"""获取过去 N 小时内的调用次数 (包括成功和失败)"""
|
||||||
|
return await self.get_calls_in_last_seconds(hours * 3600)
|
||||||
|
|
||||||
|
async def get_calls_in_current_month(self) -> 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(self) -> dict:
|
||||||
|
"""获取所有需要的 API 使用统计数据"""
|
||||||
|
try:
|
||||||
|
calls_1m = await self.get_calls_in_last_minutes(1)
|
||||||
|
calls_1h = await self.get_calls_in_last_hours(1)
|
||||||
|
calls_24h = await self.get_calls_in_last_hours(24)
|
||||||
|
calls_month = await self.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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_api_call_details(self, period: str) -> list[dict]:
|
||||||
|
"""
|
||||||
|
获取指定时间段内的 API 调用详情
|
||||||
|
|
||||||
|
Args:
|
||||||
|
period: 时间段标识 ('1m', '1h', '24h')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含调用详情的字典列表,每个字典包含 timestamp, key, model, status
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 如果 period 无效
|
||||||
|
"""
|
||||||
now = datetime.datetime.now()
|
now = datetime.datetime.now()
|
||||||
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
if period == '1m':
|
||||||
query = select(func.count(RequestLog.id)).where(
|
start_time = now - datetime.timedelta(minutes=1)
|
||||||
RequestLog.request_time >= start_of_month
|
elif period == '1h':
|
||||||
)
|
start_time = now - datetime.timedelta(hours=1)
|
||||||
count_result = await database.fetch_one(query)
|
elif period == '24h':
|
||||||
return count_result[0] if count_result else 0
|
start_time = now - datetime.timedelta(hours=24)
|
||||||
except Exception as e:
|
else:
|
||||||
logger.error(f"Failed to get calls in current month: {e}")
|
raise ValueError(f"无效的时间段标识: {period}")
|
||||||
return 0 # Return 0 on error
|
|
||||||
|
|
||||||
async def get_api_usage_stats() -> dict:
|
try:
|
||||||
"""获取所有需要的 API 使用统计数据"""
|
query = select(
|
||||||
try:
|
RequestLog.request_time.label("timestamp"),
|
||||||
calls_1m = await get_calls_in_last_minutes(1)
|
RequestLog.api_key.label("key"),
|
||||||
calls_1h = await get_calls_in_last_hours(1)
|
RequestLog.model_name.label("model"),
|
||||||
calls_24h = await get_calls_in_last_hours(24)
|
RequestLog.status_code # We might need to map this to 'success'/'failure' later
|
||||||
calls_month = await get_calls_in_current_month()
|
).where(
|
||||||
|
RequestLog.request_time >= start_time
|
||||||
|
).order_by(RequestLog.request_time.desc()) # Order by most recent first
|
||||||
|
|
||||||
return {
|
results = await database.fetch_all(query)
|
||||||
"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,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
# Convert results to list of dicts and map status_code
|
||||||
|
details = []
|
||||||
|
for row in results:
|
||||||
|
status = 'failure' # 默认状态为 failure,如果 status_code 有效且在 200-299 范围内则更新为 success
|
||||||
|
if row['status_code'] is not None: # 检查 status_code 是否为空
|
||||||
|
status = 'success' if 200 <= row['status_code'] < 300 else 'failure'
|
||||||
|
details.append({
|
||||||
|
"timestamp": row['timestamp'].isoformat(), # Use ISO format for JS compatibility
|
||||||
|
"key": row['key'],
|
||||||
|
"model": row['model'],
|
||||||
|
"status": status
|
||||||
|
})
|
||||||
|
logger.info(f"Retrieved {len(details)} API call details for period '{period}'")
|
||||||
|
return details
|
||||||
|
|
||||||
async def get_api_call_details(period: str) -> list[dict]:
|
except Exception as e:
|
||||||
"""
|
logger.error(f"Failed to get API call details for period '{period}': {e}")
|
||||||
获取指定时间段内的 API 调用详情
|
# Re-raise the exception to be handled by the route
|
||||||
|
raise
|
||||||
|
|
||||||
Args:
|
async def get_key_usage_details_last_24h(self, key: str) -> dict | None:
|
||||||
period: 时间段标识 ('1m', '1h', '24h')
|
"""
|
||||||
|
获取指定 API 密钥在过去 24 小时内按模型统计的调用次数。
|
||||||
|
|
||||||
Returns:
|
Args:
|
||||||
包含调用详情的字典列表,每个字典包含 timestamp, key, model, status
|
key: 要查询的 API 密钥。
|
||||||
|
|
||||||
Raises:
|
Returns:
|
||||||
ValueError: 如果 period 无效
|
一个字典,其中键是模型名称,值是调用次数。
|
||||||
"""
|
如果查询出错或没有找到记录,可能返回 None 或空字典。
|
||||||
now = datetime.datetime.now()
|
Example: {"gemini-pro": 10, "gemini-1.5-pro-latest": 5}
|
||||||
if period == '1m':
|
"""
|
||||||
start_time = now - datetime.timedelta(minutes=1)
|
logger.info(f"Fetching usage details for key ending in ...{key[-4:]} for the last 24h.")
|
||||||
elif period == '1h':
|
cutoff_time = datetime.datetime.now() - datetime.timedelta(hours=24)
|
||||||
start_time = now - datetime.timedelta(hours=1)
|
|
||||||
elif period == '24h':
|
|
||||||
start_time = now - datetime.timedelta(hours=24)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"无效的时间段标识: {period}")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
query = select(
|
query = select(
|
||||||
RequestLog.request_time.label("timestamp"),
|
RequestLog.model_name,
|
||||||
RequestLog.api_key.label("key"),
|
func.count(RequestLog.id).label("call_count")
|
||||||
RequestLog.model_name.label("model"),
|
).where(
|
||||||
RequestLog.status_code # We might need to map this to 'success'/'failure' later
|
RequestLog.api_key == key,
|
||||||
).where(
|
RequestLog.request_time >= cutoff_time,
|
||||||
RequestLog.request_time >= start_time
|
RequestLog.model_name.isnot(None) # Ensure model_name is not null
|
||||||
).order_by(RequestLog.request_time.desc()) # Order by most recent first
|
).group_by(
|
||||||
|
RequestLog.model_name
|
||||||
|
).order_by(
|
||||||
|
func.count(RequestLog.id).desc() # Order by count descending
|
||||||
|
)
|
||||||
|
|
||||||
results = await database.fetch_all(query)
|
results = await database.fetch_all(query)
|
||||||
|
|
||||||
# Convert results to list of dicts and map status_code
|
if not results:
|
||||||
details = []
|
logger.info(f"No usage details found for key ending in ...{key[-4:]} in the last 24h.")
|
||||||
for row in results:
|
return {} # Return empty dict if no records found
|
||||||
status = 'failure' # 默认状态为 failure,如果 status_code 有效且在 200-299 范围内则更新为 success
|
|
||||||
if row['status_code'] is not None: # 检查 status_code 是否为空
|
|
||||||
status = 'success' if 200 <= row['status_code'] < 300 else 'failure'
|
|
||||||
details.append({
|
|
||||||
"timestamp": row['timestamp'].isoformat(), # Use ISO format for JS compatibility
|
|
||||||
"key": row['key'],
|
|
||||||
"model": row['model'],
|
|
||||||
"status": status
|
|
||||||
})
|
|
||||||
logger.info(f"Retrieved {len(details)} API call details for period '{period}'")
|
|
||||||
return details
|
|
||||||
|
|
||||||
except Exception as e:
|
usage_details = {row['model_name']: row['call_count'] for row in results}
|
||||||
logger.error(f"Failed to get API call details for period '{period}': {e}")
|
logger.info(f"Successfully fetched usage details for key ending in ...{key[-4:]}: {usage_details}")
|
||||||
# Re-raise the exception to be handled by the route
|
return usage_details
|
||||||
raise
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get key usage details for key ending in ...{key[-4:]}: {e}", exc_info=True)
|
||||||
|
# Depending on requirements, you might return None or raise the exception
|
||||||
|
# Raising allows the route handler to return a 500 error.
|
||||||
|
raise # Re-raise the exception
|
||||||
@@ -914,3 +914,109 @@ function renderApiCallDetails(data, container) {
|
|||||||
|
|
||||||
container.innerHTML = tableHtml;
|
container.innerHTML = tableHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 密钥使用详情模态框逻辑 ---
|
||||||
|
|
||||||
|
// 显示密钥使用详情模态框
|
||||||
|
window.showKeyUsageDetails = async function(key) {
|
||||||
|
const modal = document.getElementById('keyUsageDetailsModal');
|
||||||
|
const contentArea = document.getElementById('keyUsageDetailsContent');
|
||||||
|
const titleElement = document.getElementById('keyUsageDetailsModalTitle');
|
||||||
|
const keyDisplay = key.substring(0, 4) + '...' + key.substring(key.length - 4);
|
||||||
|
|
||||||
|
if (!modal || !contentArea || !titleElement) {
|
||||||
|
console.error('无法找到密钥使用详情模态框元素');
|
||||||
|
showNotification('无法显示详情,页面元素缺失', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置标题
|
||||||
|
titleElement.textContent = `密钥 ${keyDisplay} - 最近24小时请求详情`;
|
||||||
|
|
||||||
|
// 显示模态框并设置加载状态
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
contentArea.innerHTML = `
|
||||||
|
<div class="text-center py-10">
|
||||||
|
<i class="fas fa-spinner fa-spin text-primary-600 text-3xl"></i>
|
||||||
|
<p class="text-gray-500 mt-2">加载中...</p>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 调用新的后端 API 获取数据
|
||||||
|
// 注意:后端需要实现 /api/key-usage-details/{key} 端点
|
||||||
|
const response = await fetch(`/api/key-usage-details/${key}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMsg = `服务器错误: ${response.status}`;
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
errorMsg = errorData.detail || errorMsg; // 假设后端错误信息在 detail 字段
|
||||||
|
} catch (e) { /* 忽略解析错误 */ }
|
||||||
|
throw new Error(errorMsg);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// 渲染数据
|
||||||
|
renderKeyUsageDetails(data, contentArea);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取密钥使用详情失败:', error);
|
||||||
|
contentArea.innerHTML = `
|
||||||
|
<div class="text-center py-10 text-danger-500">
|
||||||
|
<i class="fas fa-exclamation-triangle text-3xl"></i>
|
||||||
|
<p class="mt-2">加载失败: ${error.message}</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭密钥使用详情模态框
|
||||||
|
window.closeKeyUsageDetailsModal = function() {
|
||||||
|
const modal = document.getElementById('keyUsageDetailsModal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染密钥使用详情到模态框 (这个函数主要由 showKeyUsageDetails 调用,不一定需要全局,但保持一致性)
|
||||||
|
window.renderKeyUsageDetails = function(data, container) {
|
||||||
|
// data 预期格式: { "model_name1": count1, "model_name2": count2, ... }
|
||||||
|
if (!data || Object.keys(data).length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="text-center py-10 text-gray-500">
|
||||||
|
<i class="fas fa-info-circle text-3xl"></i>
|
||||||
|
<p class="mt-2">该密钥在最近24小时内没有调用记录。</p>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建表格
|
||||||
|
let tableHtml = `
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">模型名称</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">调用次数 (24h)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 排序模型(可选,按调用次数降序)
|
||||||
|
const sortedModels = Object.entries(data).sort(([, countA], [, countB]) => countB - countA);
|
||||||
|
|
||||||
|
// 填充表格行
|
||||||
|
sortedModels.forEach(([model, count]) => {
|
||||||
|
tableHtml += `
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${model}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${count}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
tableHtml += `
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.innerHTML = tableHtml;
|
||||||
|
}
|
||||||
|
|||||||
@@ -374,6 +374,10 @@
|
|||||||
<i class="fas fa-copy"></i>
|
<i class="fas fa-copy"></i>
|
||||||
复制
|
复制
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -443,6 +447,10 @@
|
|||||||
<i class="fas fa-copy"></i>
|
<i class="fas fa-copy"></i>
|
||||||
复制
|
复制
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -564,6 +572,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 密钥使用详情模态框 -->
|
||||||
|
<div id="keyUsageDetailsModal" 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-lg w-full animate-fade-in"> <!-- Adjusted max-width -->
|
||||||
|
<div class="flex items-center justify-between mb-4 border-b pb-3">
|
||||||
|
<h3 class="text-xl font-semibold text-gray-800" id="keyUsageDetailsModalTitle">密钥请求详情</h3>
|
||||||
|
<button onclick="closeKeyUsageDetailsModal()" class="text-gray-500 hover:text-gray-700 focus:outline-none text-xl">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="keyUsageDetailsContent" class="mb-6 max-h-[50vh] overflow-y-auto pr-2"> <!-- Adjusted max-height -->
|
||||||
|
<!-- 详细数据将加载到这里 -->
|
||||||
|
<div class="text-center py-10">
|
||||||
|
<i class="fas fa-spinner fa-spin text-primary-600 text-3xl"></i>
|
||||||
|
<p class="text-gray-500 mt-2">加载中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end pt-4 border-t">
|
||||||
|
<button onclick="closeKeyUsageDetailsModal()" class="px-5 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 rounded-lg transition-colors text-sm font-medium">
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Footer is now in base.html -->
|
<!-- Footer is now in base.html -->
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user