mirror of
https://github.com/snailyp/gemini-balance.git
synced 2026-09-05 23:56:37 +08:00
feat(api,ui): 新增按Key调用详情与错误日志查找并联动前端
引入按密钥维度的请求详情及错误日志关联,新增错误日志精确 查找接口,并扩展统计时间维度,提升故障定位与可观测性。 - 新增 /api/logs/errors/lookup 接口:支持按 gemini_key / timestamp / status_code 与时间窗口查找最接近的错误日志;ErrorLogDetailResponse 增加 error_code 字段 - Stats 接口增强:get_api_call_details 返回 status_code、latency_ms, 并在失败时尝试匹配 error_log_id;新增 /api/stats/key-details 获取指 定密钥调用详情;新增 8h 时间段 - DB 层:add_error_log 支持传入 request_datetime(默认使用 UTC);新增 find_error_log_by_info 封装按 key/时间窗口/状态码的查询 - 前端 keys_status:趋势图支持 8 小时区间;调用详情表新增状态码/耗时与 失败详情按钮;可按 key 查看期内调用详情并查看匹配错误日志;优化统计 摘要展示与模态层级(z-index) - OpenAIChatService:错误记录携带请求时间;改进日志与健壮性处理
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Dict, List, Optional, Union
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
|
||||||
from sqlalchemy import asc, delete, desc, func, insert, select, update
|
from sqlalchemy import asc, delete, desc, func, insert, select, update
|
||||||
@@ -107,6 +107,7 @@ async def add_error_log(
|
|||||||
error_log: Optional[str] = None,
|
error_log: Optional[str] = None,
|
||||||
error_code: Optional[int] = None,
|
error_code: Optional[int] = None,
|
||||||
request_msg: Optional[Union[Dict[str, Any], str]] = None,
|
request_msg: Optional[Union[Dict[str, Any], str]] = None,
|
||||||
|
request_datetime: Optional[datetime] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
添加错误日志
|
添加错误日志
|
||||||
@@ -140,7 +141,9 @@ async def add_error_log(
|
|||||||
model_name=model_name,
|
model_name=model_name,
|
||||||
error_code=error_code,
|
error_code=error_code,
|
||||||
request_msg=request_msg_json,
|
request_msg=request_msg_json,
|
||||||
request_time=datetime.now(),
|
request_time=(
|
||||||
|
request_datetime if request_datetime else datetime.now(timezone.utc)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
await database.execute(query)
|
await database.execute(query)
|
||||||
logger.info(f"Added error log for key: {redact_key_for_logging(gemini_key)}")
|
logger.info(f"Added error log for key: {redact_key_for_logging(gemini_key)}")
|
||||||
@@ -307,6 +310,78 @@ async def get_error_log_details(log_id: int) -> Optional[Dict[str, Any]]:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# 新增函数:通过 gemini_key / error_code / 时间窗口 查找最接近的错误日志
|
||||||
|
async def find_error_log_by_info(
|
||||||
|
gemini_key: str,
|
||||||
|
timestamp: datetime,
|
||||||
|
status_code: Optional[int] = None,
|
||||||
|
window_seconds: int = 1,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
在给定时间窗口内,根据 gemini_key(精确匹配)及可选的 status_code 查找最接近 timestamp 的错误日志。
|
||||||
|
|
||||||
|
假设错误日志的 error_code 存储的是 HTTP 状态码或等价错误码。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
gemini_key: 完整的 Gemini key 字符串。
|
||||||
|
timestamp: 目标时间(UTC 或本地,与存储一致)。
|
||||||
|
status_code: 可选的错误码,若提供则优先匹配该错误码。
|
||||||
|
window_seconds: 允许的时间偏差窗口,单位秒,默认为 1 秒。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optional[Dict[str, Any]]: 最匹配的一条错误日志的完整详情(字段与 get_error_log_details 一致),若未找到则返回 None。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
start_time = timestamp - timedelta(seconds=window_seconds)
|
||||||
|
end_time = timestamp + timedelta(seconds=window_seconds)
|
||||||
|
|
||||||
|
base_query = select(ErrorLog).where(
|
||||||
|
ErrorLog.gemini_key == gemini_key,
|
||||||
|
ErrorLog.request_time >= start_time,
|
||||||
|
ErrorLog.request_time <= end_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 若提供了状态码,先尝试按状态码过滤
|
||||||
|
if status_code is not None:
|
||||||
|
query = base_query.where(ErrorLog.error_code == status_code).order_by(
|
||||||
|
ErrorLog.request_time.desc()
|
||||||
|
)
|
||||||
|
candidates = await database.fetch_all(query)
|
||||||
|
if not candidates:
|
||||||
|
# 回退:不按状态码,仅按时间窗口
|
||||||
|
query2 = base_query.order_by(ErrorLog.request_time.desc())
|
||||||
|
candidates = await database.fetch_all(query2)
|
||||||
|
else:
|
||||||
|
query = base_query.order_by(ErrorLog.request_time.desc())
|
||||||
|
candidates = await database.fetch_all(query)
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 在 Python 中选择与 timestamp 最接近的一条
|
||||||
|
def _to_dict(row: Any) -> Dict[str, Any]:
|
||||||
|
d = dict(row)
|
||||||
|
if "request_msg" in d and d["request_msg"] is not None:
|
||||||
|
try:
|
||||||
|
d["request_msg"] = json.dumps(
|
||||||
|
d["request_msg"], ensure_ascii=False, indent=2
|
||||||
|
)
|
||||||
|
except TypeError:
|
||||||
|
d["request_msg"] = str(d["request_msg"])
|
||||||
|
return d
|
||||||
|
|
||||||
|
best = min(
|
||||||
|
candidates,
|
||||||
|
key=lambda r: abs((r["request_time"] - timestamp).total_seconds()),
|
||||||
|
)
|
||||||
|
return _to_dict(best)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(
|
||||||
|
f"Failed to find error log by info (key=***{gemini_key[-4:] if gemini_key else ''}, code={status_code}, ts={timestamp}, window={window_seconds}s): {str(e)}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
async def delete_error_logs_by_ids(log_ids: List[int]) -> int:
|
async def delete_error_logs_by_ids(log_ids: List[int]) -> int:
|
||||||
"""
|
"""
|
||||||
根据提供的 ID 列表批量删除错误日志 (异步)。
|
根据提供的 ID 列表批量删除错误日志 (异步)。
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ class ErrorLogDetailResponse(BaseModel):
|
|||||||
request_msg: Optional[str] = None
|
request_msg: Optional[str] = None
|
||||||
model_name: Optional[str] = None
|
model_name: Optional[str] = None
|
||||||
request_time: Optional[datetime] = None
|
request_time: Optional[datetime] = None
|
||||||
|
error_code: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/errors/{log_id}/details", response_model=ErrorLogDetailResponse)
|
@router.get("/errors/{log_id}/details", response_model=ErrorLogDetailResponse)
|
||||||
@@ -151,6 +152,43 @@ async def get_error_log_detail_api(request: Request, log_id: int = Path(..., ge=
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/errors/lookup", response_model=ErrorLogDetailResponse)
|
||||||
|
async def lookup_error_log_by_info(
|
||||||
|
request: Request,
|
||||||
|
gemini_key: str = Query(..., description="完整的 Gemini key"),
|
||||||
|
timestamp: datetime = Query(..., description="请求时间 (ISO8601)"),
|
||||||
|
status_code: Optional[int] = Query(None, description="错误码 (可选)"),
|
||||||
|
window_seconds: int = Query(
|
||||||
|
100, ge=1, le=300, description="时间窗口(秒), 默认100秒"
|
||||||
|
),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
通过 key / 错误码 / 时间窗口 查找最匹配的一条错误日志详情。
|
||||||
|
"""
|
||||||
|
auth_token = request.cookies.get("auth_token")
|
||||||
|
if not auth_token or not verify_auth_token(auth_token):
|
||||||
|
logger.warning("Unauthorized access attempt to lookup error log by info")
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
|
try:
|
||||||
|
detail = await error_log_service.process_find_error_log_by_info(
|
||||||
|
gemini_key=gemini_key,
|
||||||
|
timestamp=timestamp,
|
||||||
|
status_code=status_code,
|
||||||
|
window_seconds=window_seconds,
|
||||||
|
)
|
||||||
|
if not detail:
|
||||||
|
raise HTTPException(status_code=404, detail="No matching error log found")
|
||||||
|
return ErrorLogDetailResponse(**detail)
|
||||||
|
except HTTPException as http_exc:
|
||||||
|
raise http_exc
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(
|
||||||
|
f"Failed to lookup error log by info for key=***{gemini_key[-4:] if gemini_key else ''}: {str(e)}"
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/errors", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/errors", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_error_logs_bulk_api(
|
async def delete_error_logs_bulk_api(
|
||||||
request: Request, payload: Dict[str, List[int]] = Body(...)
|
request: Request, payload: Dict[str, List[int]] = Body(...)
|
||||||
|
|||||||
@@ -230,3 +230,27 @@ def setup_api_stats_routes(app: FastAPI) -> None:
|
|||||||
f"Error fetching API stats details for period {period}: {str(e)}"
|
f"Error fetching API stats details for period {period}: {str(e)}"
|
||||||
)
|
)
|
||||||
return {"error": "Internal server error"}, 500
|
return {"error": "Internal server error"}, 500
|
||||||
|
|
||||||
|
@app.get("/api/stats/key-details")
|
||||||
|
async def api_stats_key_details(request: Request, key: str, period: str):
|
||||||
|
"""获取指定密钥在指定时间段内的调用详情"""
|
||||||
|
try:
|
||||||
|
auth_token = request.cookies.get("auth_token")
|
||||||
|
if not auth_token or not verify_auth_token(auth_token):
|
||||||
|
logger.warning("Unauthorized access attempt to API key stats details")
|
||||||
|
return {"error": "Unauthorized"}, 401
|
||||||
|
|
||||||
|
logger.info(f"Fetching key call details for key=...{key[-4:] if key else ''}, period: {period}")
|
||||||
|
stats_service = StatsService()
|
||||||
|
details = await stats_service.get_key_call_details(key, period)
|
||||||
|
return details
|
||||||
|
except ValueError as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Invalid period requested for key stats details: {period} - {str(e)}"
|
||||||
|
)
|
||||||
|
return {"error": str(e)}, 400
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error fetching key stats details for period {period}: {str(e)}"
|
||||||
|
)
|
||||||
|
return {"error": "Internal server error"}, 500
|
||||||
|
|||||||
@@ -40,15 +40,31 @@ def _clean_json_schema_properties(obj: Any) -> Any:
|
|||||||
"""清理JSON Schema中Gemini API不支持的字段"""
|
"""清理JSON Schema中Gemini API不支持的字段"""
|
||||||
if not isinstance(obj, dict):
|
if not isinstance(obj, dict):
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
# Gemini API不支持的JSON Schema字段
|
# Gemini API不支持的JSON Schema字段
|
||||||
unsupported_fields = {
|
unsupported_fields = {
|
||||||
"exclusiveMaximum", "exclusiveMinimum", "const", "examples",
|
"exclusiveMaximum",
|
||||||
"contentEncoding", "contentMediaType", "if", "then", "else",
|
"exclusiveMinimum",
|
||||||
"allOf", "anyOf", "oneOf", "not", "definitions", "$schema",
|
"const",
|
||||||
"$id", "$ref", "$comment", "readOnly", "writeOnly"
|
"examples",
|
||||||
|
"contentEncoding",
|
||||||
|
"contentMediaType",
|
||||||
|
"if",
|
||||||
|
"then",
|
||||||
|
"else",
|
||||||
|
"allOf",
|
||||||
|
"anyOf",
|
||||||
|
"oneOf",
|
||||||
|
"not",
|
||||||
|
"definitions",
|
||||||
|
"$schema",
|
||||||
|
"$id",
|
||||||
|
"$ref",
|
||||||
|
"$comment",
|
||||||
|
"readOnly",
|
||||||
|
"writeOnly",
|
||||||
}
|
}
|
||||||
|
|
||||||
cleaned = {}
|
cleaned = {}
|
||||||
for key, value in obj.items():
|
for key, value in obj.items():
|
||||||
if key in unsupported_fields:
|
if key in unsupported_fields:
|
||||||
@@ -59,7 +75,7 @@ def _clean_json_schema_properties(obj: Any) -> Any:
|
|||||||
cleaned[key] = [_clean_json_schema_properties(item) for item in value]
|
cleaned[key] = [_clean_json_schema_properties(item) for item in value]
|
||||||
else:
|
else:
|
||||||
cleaned[key] = value
|
cleaned[key] = value
|
||||||
|
|
||||||
return cleaned
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
@@ -87,7 +103,7 @@ def _build_tools(
|
|||||||
|
|
||||||
if model.endswith("-search"):
|
if model.endswith("-search"):
|
||||||
tool["googleSearch"] = {}
|
tool["googleSearch"] = {}
|
||||||
|
|
||||||
real_model = _get_real_model(model)
|
real_model = _get_real_model(model)
|
||||||
if real_model in settings.URL_CONTEXT_MODELS and settings.URL_CONTEXT_ENABLED:
|
if real_model in settings.URL_CONTEXT_MODELS and settings.URL_CONTEXT_ENABLED:
|
||||||
tool["urlContext"] = {}
|
tool["urlContext"] = {}
|
||||||
@@ -116,7 +132,7 @@ def _build_tools(
|
|||||||
names, functions = set(), []
|
names, functions = set(), []
|
||||||
for fc in function_declarations:
|
for fc in function_declarations:
|
||||||
if fc.get("name") not in names:
|
if fc.get("name") not in names:
|
||||||
if fc.get("name")=="googleSearch":
|
if fc.get("name") == "googleSearch":
|
||||||
# cherry开启内置搜索时,添加googleSearch工具
|
# cherry开启内置搜索时,添加googleSearch工具
|
||||||
tool["googleSearch"] = {}
|
tool["googleSearch"] = {}
|
||||||
else:
|
else:
|
||||||
@@ -130,7 +146,7 @@ def _build_tools(
|
|||||||
if tool.get("functionDeclarations"):
|
if tool.get("functionDeclarations"):
|
||||||
tool.pop("googleSearch", None)
|
tool.pop("googleSearch", None)
|
||||||
tool.pop("codeExecution", None)
|
tool.pop("codeExecution", None)
|
||||||
tool.pop("urlContext",None)
|
tool.pop("urlContext", None)
|
||||||
|
|
||||||
return [tool] if tool else []
|
return [tool] if tool else []
|
||||||
|
|
||||||
@@ -160,17 +176,17 @@ def _get_safety_settings(model: str) -> List[Dict[str, str]]:
|
|||||||
|
|
||||||
|
|
||||||
def _validate_and_set_max_tokens(
|
def _validate_and_set_max_tokens(
|
||||||
payload: Dict[str, Any],
|
payload: Dict[str, Any], max_tokens: Optional[int], logger_instance
|
||||||
max_tokens: Optional[int],
|
|
||||||
logger_instance
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""验证并设置 max_tokens 参数"""
|
"""验证并设置 max_tokens 参数"""
|
||||||
if max_tokens is None:
|
if max_tokens is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
# 参数验证和处理
|
# 参数验证和处理
|
||||||
if max_tokens <= 0:
|
if max_tokens <= 0:
|
||||||
logger_instance.warning(f"Invalid max_tokens value: {max_tokens}, will not set maxOutputTokens")
|
logger_instance.warning(
|
||||||
|
f"Invalid max_tokens value: {max_tokens}, will not set maxOutputTokens"
|
||||||
|
)
|
||||||
# 不设置 maxOutputTokens,让 Gemini API 使用默认值
|
# 不设置 maxOutputTokens,让 Gemini API 使用默认值
|
||||||
else:
|
else:
|
||||||
payload["generationConfig"]["maxOutputTokens"] = max_tokens
|
payload["generationConfig"]["maxOutputTokens"] = max_tokens
|
||||||
@@ -193,31 +209,33 @@ def _build_payload(
|
|||||||
"tools": _build_tools(request, messages),
|
"tools": _build_tools(request, messages),
|
||||||
"safetySettings": _get_safety_settings(request.model),
|
"safetySettings": _get_safety_settings(request.model),
|
||||||
}
|
}
|
||||||
|
|
||||||
# 处理 max_tokens 参数
|
# 处理 max_tokens 参数
|
||||||
_validate_and_set_max_tokens(payload, request.max_tokens, logger)
|
_validate_and_set_max_tokens(payload, request.max_tokens, logger)
|
||||||
|
|
||||||
# 处理 n 参数
|
# 处理 n 参数
|
||||||
if request.n is not None and request.n > 0:
|
if request.n is not None and request.n > 0:
|
||||||
payload["generationConfig"]["candidateCount"] = request.n
|
payload["generationConfig"]["candidateCount"] = request.n
|
||||||
|
|
||||||
if request.model.endswith("-image") or request.model.endswith("-image-generation"):
|
if request.model.endswith("-image") or request.model.endswith("-image-generation"):
|
||||||
payload["generationConfig"]["responseModalities"] = ["Text", "Image"]
|
payload["generationConfig"]["responseModalities"] = ["Text", "Image"]
|
||||||
|
|
||||||
if request.model.endswith("-non-thinking"):
|
if request.model.endswith("-non-thinking"):
|
||||||
if "gemini-2.5-pro" in request.model:
|
if "gemini-2.5-pro" in request.model:
|
||||||
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 128}
|
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 128}
|
||||||
else:
|
else:
|
||||||
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
|
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
|
||||||
|
|
||||||
elif _get_real_model(request.model) in settings.THINKING_BUDGET_MAP:
|
elif _get_real_model(request.model) in settings.THINKING_BUDGET_MAP:
|
||||||
if settings.SHOW_THINKING_PROCESS:
|
if settings.SHOW_THINKING_PROCESS:
|
||||||
payload["generationConfig"]["thinkingConfig"] = {
|
payload["generationConfig"]["thinkingConfig"] = {
|
||||||
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(request.model, 1000),
|
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(request.model, 1000),
|
||||||
"includeThoughts": True
|
"includeThoughts": True,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": settings.THINKING_BUDGET_MAP.get(request.model, 1000)}
|
payload["generationConfig"]["thinkingConfig"] = {
|
||||||
|
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(request.model, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
instruction
|
instruction
|
||||||
@@ -284,13 +302,13 @@ class OpenAIChatService:
|
|||||||
is_success = False
|
is_success = False
|
||||||
status_code = None
|
status_code = None
|
||||||
response = None
|
response = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await self.api_client.generate_content(payload, model, api_key)
|
response = await self.api_client.generate_content(payload, model, api_key)
|
||||||
usage_metadata = response.get("usageMetadata", {})
|
usage_metadata = response.get("usageMetadata", {})
|
||||||
is_success = True
|
is_success = True
|
||||||
status_code = 200
|
status_code = 200
|
||||||
|
|
||||||
# 尝试处理响应,捕获可能的响应处理异常
|
# 尝试处理响应,捕获可能的响应处理异常
|
||||||
try:
|
try:
|
||||||
result = self.response_handler.handle_response(
|
result = self.response_handler.handle_response(
|
||||||
@@ -302,8 +320,10 @@ class OpenAIChatService:
|
|||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
except Exception as response_error:
|
except Exception as response_error:
|
||||||
logger.error(f"Response processing failed for model {model}: {str(response_error)}")
|
logger.error(
|
||||||
|
f"Response processing failed for model {model}: {str(response_error)}"
|
||||||
|
)
|
||||||
|
|
||||||
# 记录详细的错误信息
|
# 记录详细的错误信息
|
||||||
if "parts" in str(response_error):
|
if "parts" in str(response_error):
|
||||||
logger.error("Response structure issue - missing or invalid parts")
|
logger.error("Response structure issue - missing or invalid parts")
|
||||||
@@ -311,24 +331,26 @@ class OpenAIChatService:
|
|||||||
candidate = response["candidates"][0]
|
candidate = response["candidates"][0]
|
||||||
content = candidate.get("content", {})
|
content = candidate.get("content", {})
|
||||||
logger.error(f"Content structure: {content}")
|
logger.error(f"Content structure: {content}")
|
||||||
|
|
||||||
# 重新抛出异常
|
# 重新抛出异常
|
||||||
raise response_error
|
raise response_error
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
is_success = False
|
is_success = False
|
||||||
error_log_msg = str(e)
|
error_log_msg = str(e)
|
||||||
logger.error(f"API call failed for model {model}: {error_log_msg}")
|
logger.error(f"API call failed for model {model}: {error_log_msg}")
|
||||||
|
|
||||||
# 特别记录 max_tokens 相关的错误
|
# 特别记录 max_tokens 相关的错误
|
||||||
gen_config = payload.get('generationConfig', {})
|
gen_config = payload.get("generationConfig", {})
|
||||||
if "maxOutputTokens" in gen_config:
|
if "maxOutputTokens" in gen_config:
|
||||||
logger.error(f"Request had maxOutputTokens: {gen_config['maxOutputTokens']}")
|
logger.error(
|
||||||
|
f"Request had maxOutputTokens: {gen_config['maxOutputTokens']}"
|
||||||
|
)
|
||||||
|
|
||||||
# 如果是响应处理错误,记录更多信息
|
# 如果是响应处理错误,记录更多信息
|
||||||
if "parts" in error_log_msg:
|
if "parts" in error_log_msg:
|
||||||
logger.error("This is likely a response processing error")
|
logger.error("This is likely a response processing error")
|
||||||
|
|
||||||
match = re.search(r"status code (\d+)", error_log_msg)
|
match = re.search(r"status code (\d+)", error_log_msg)
|
||||||
status_code = int(match.group(1)) if match else 500
|
status_code = int(match.group(1)) if match else 500
|
||||||
|
|
||||||
@@ -339,13 +361,16 @@ class OpenAIChatService:
|
|||||||
error_log=error_log_msg,
|
error_log=error_log_msg,
|
||||||
error_code=status_code,
|
error_code=status_code,
|
||||||
request_msg=payload,
|
request_msg=payload,
|
||||||
|
request_datetime=request_datetime,
|
||||||
)
|
)
|
||||||
raise e
|
raise e
|
||||||
finally:
|
finally:
|
||||||
end_time = time.perf_counter()
|
end_time = time.perf_counter()
|
||||||
latency_ms = int((end_time - start_time) * 1000)
|
latency_ms = int((end_time - start_time) * 1000)
|
||||||
logger.info(f"Normal completion finished - Success: {is_success}, Latency: {latency_ms}ms")
|
logger.info(
|
||||||
|
f"Normal completion finished - Success: {is_success}, Latency: {latency_ms}ms"
|
||||||
|
)
|
||||||
|
|
||||||
await add_request_log(
|
await add_request_log(
|
||||||
model_name=model,
|
model_name=model,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
@@ -362,7 +387,7 @@ class OpenAIChatService:
|
|||||||
logger.info(
|
logger.info(
|
||||||
f"Fake streaming enabled for model: {model}. Calling non-streaming endpoint."
|
f"Fake streaming enabled for model: {model}. Calling non-streaming endpoint."
|
||||||
)
|
)
|
||||||
|
|
||||||
api_response_task = asyncio.create_task(
|
api_response_task = asyncio.create_task(
|
||||||
self.api_client.generate_content(payload, model, api_key)
|
self.api_client.generate_content(payload, model, api_key)
|
||||||
)
|
)
|
||||||
@@ -372,9 +397,15 @@ class OpenAIChatService:
|
|||||||
while not api_response_task.done():
|
while not api_response_task.done():
|
||||||
i = i + 1
|
i = i + 1
|
||||||
"""定期发送空数据以保持连接"""
|
"""定期发送空数据以保持连接"""
|
||||||
if i >= settings.FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS :
|
if i >= settings.FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS:
|
||||||
i = 0
|
i = 0
|
||||||
empty_chunk = self.response_handler.handle_response({}, model, stream=True, finish_reason='stop', usage_metadata=None)
|
empty_chunk = self.response_handler.handle_response(
|
||||||
|
{},
|
||||||
|
model,
|
||||||
|
stream=True,
|
||||||
|
finish_reason="stop",
|
||||||
|
usage_metadata=None,
|
||||||
|
)
|
||||||
yield f"data: {json.dumps(empty_chunk)}\n\n"
|
yield f"data: {json.dumps(empty_chunk)}\n\n"
|
||||||
logger.debug("Sent empty data chunk for fake stream heartbeat.")
|
logger.debug("Sent empty data chunk for fake stream heartbeat.")
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
@@ -382,14 +413,18 @@ class OpenAIChatService:
|
|||||||
response = await api_response_task
|
response = await api_response_task
|
||||||
|
|
||||||
if response and response.get("candidates"):
|
if response and response.get("candidates"):
|
||||||
response = self.response_handler.handle_response(response, model, stream=True, finish_reason='stop', usage_metadata=response.get("usageMetadata", {}))
|
response = self.response_handler.handle_response(
|
||||||
|
response,
|
||||||
|
model,
|
||||||
|
stream=True,
|
||||||
|
finish_reason="stop",
|
||||||
|
usage_metadata=response.get("usageMetadata", {}),
|
||||||
|
)
|
||||||
yield f"data: {json.dumps(response)}\n\n"
|
yield f"data: {json.dumps(response)}\n\n"
|
||||||
logger.info(f"Sent full response content for fake stream: {model}")
|
logger.info(f"Sent full response content for fake stream: {model}")
|
||||||
else:
|
else:
|
||||||
error_message = "Failed to get response from model"
|
error_message = "Failed to get response from model"
|
||||||
if (
|
if response and isinstance(response, dict) and response.get("error"):
|
||||||
response and isinstance(response, dict) and response.get("error")
|
|
||||||
):
|
|
||||||
error_details = response.get("error")
|
error_details = response.get("error")
|
||||||
if isinstance(error_details, dict):
|
if isinstance(error_details, dict):
|
||||||
error_message = error_details.get("message", error_message)
|
error_message = error_details.get("message", error_message)
|
||||||
@@ -397,7 +432,9 @@ class OpenAIChatService:
|
|||||||
logger.error(
|
logger.error(
|
||||||
f"No candidates or error in response for fake stream model {model}: {response}"
|
f"No candidates or error in response for fake stream model {model}: {response}"
|
||||||
)
|
)
|
||||||
error_chunk = self.response_handler.handle_response({}, model, stream=True, finish_reason='stop', usage_metadata=None)
|
error_chunk = self.response_handler.handle_response(
|
||||||
|
{}, model, stream=True, finish_reason="stop", usage_metadata=None
|
||||||
|
)
|
||||||
yield f"data: {json.dumps(error_chunk)}\n\n"
|
yield f"data: {json.dumps(error_chunk)}\n\n"
|
||||||
|
|
||||||
async def _real_stream_logic_impl(
|
async def _real_stream_logic_impl(
|
||||||
@@ -425,7 +462,11 @@ class OpenAIChatService:
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
openai_chunk = self.response_handler.handle_response(
|
openai_chunk = self.response_handler.handle_response(
|
||||||
chunk, model, stream=True, finish_reason=None, usage_metadata=usage_metadata
|
chunk,
|
||||||
|
model,
|
||||||
|
stream=True,
|
||||||
|
finish_reason=None,
|
||||||
|
usage_metadata=usage_metadata,
|
||||||
)
|
)
|
||||||
if openai_chunk:
|
if openai_chunk:
|
||||||
text = self._extract_text_from_openai_chunk(openai_chunk)
|
text = self._extract_text_from_openai_chunk(openai_chunk)
|
||||||
@@ -439,7 +480,9 @@ class OpenAIChatService:
|
|||||||
):
|
):
|
||||||
yield optimized_chunk_data
|
yield optimized_chunk_data
|
||||||
else:
|
else:
|
||||||
if openai_chunk.get("choices") and openai_chunk["choices"][0].get("delta", {}).get("tool_calls"):
|
if openai_chunk.get("choices") and openai_chunk["choices"][
|
||||||
|
0
|
||||||
|
].get("delta", {}).get("tool_calls"):
|
||||||
tool_call_flag = True
|
tool_call_flag = True
|
||||||
|
|
||||||
yield f"data: {json.dumps(openai_chunk)}\n\n"
|
yield f"data: {json.dumps(openai_chunk)}\n\n"
|
||||||
|
|||||||
@@ -121,6 +121,30 @@ async def process_get_error_log_details(log_id: int) -> Optional[Dict[str, Any]]
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def process_find_error_log_by_info(
|
||||||
|
gemini_key: str,
|
||||||
|
timestamp: datetime,
|
||||||
|
status_code: Optional[int] = None,
|
||||||
|
window_seconds: int = 100,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
根据 key/状态码/时间窗口 查询最匹配的一条错误日志,未找到则返回 None。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await db_services.find_error_log_by_info(
|
||||||
|
gemini_key=gemini_key,
|
||||||
|
timestamp=timestamp,
|
||||||
|
status_code=status_code,
|
||||||
|
window_seconds=window_seconds,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Service error in process_find_error_log_by_info: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
async def process_delete_error_logs_by_ids(log_ids: List[int]) -> int:
|
async def process_delete_error_logs_by_ids(log_ids: List[int]) -> int:
|
||||||
"""
|
"""
|
||||||
按 ID 批量删除错误日志。
|
按 ID 批量删除错误日志。
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ class StatsService:
|
|||||||
period: 时间段标识 ('1m', '1h', '24h')
|
period: 时间段标识 ('1m', '1h', '24h')
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
包含调用详情的字典列表,每个字典包含 timestamp, key, model, status
|
包含调用详情的字典列表,每个字典包含 timestamp, key, model, status, status_code, latency_ms, error_log_id(可选)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: 如果 period 无效
|
ValueError: 如果 period 无效
|
||||||
@@ -156,6 +156,8 @@ class StatsService:
|
|||||||
start_time = now - datetime.timedelta(minutes=1)
|
start_time = now - datetime.timedelta(minutes=1)
|
||||||
elif period == "1h":
|
elif period == "1h":
|
||||||
start_time = now - datetime.timedelta(hours=1)
|
start_time = now - datetime.timedelta(hours=1)
|
||||||
|
elif period == "8h":
|
||||||
|
start_time = now - datetime.timedelta(hours=8)
|
||||||
elif period == "24h":
|
elif period == "24h":
|
||||||
start_time = now - datetime.timedelta(hours=24)
|
start_time = now - datetime.timedelta(hours=24)
|
||||||
else:
|
else:
|
||||||
@@ -167,7 +169,8 @@ class StatsService:
|
|||||||
RequestLog.request_time.label("timestamp"),
|
RequestLog.request_time.label("timestamp"),
|
||||||
RequestLog.api_key.label("key"),
|
RequestLog.api_key.label("key"),
|
||||||
RequestLog.model_name.label("model"),
|
RequestLog.model_name.label("model"),
|
||||||
RequestLog.status_code,
|
RequestLog.status_code.label("status_code"),
|
||||||
|
RequestLog.latency_ms.label("latency_ms"),
|
||||||
)
|
)
|
||||||
.where(RequestLog.request_time >= start_time)
|
.where(RequestLog.request_time >= start_time)
|
||||||
.order_by(RequestLog.request_time.desc())
|
.order_by(RequestLog.request_time.desc())
|
||||||
@@ -175,29 +178,138 @@ class StatsService:
|
|||||||
|
|
||||||
results = await database.fetch_all(query)
|
results = await database.fetch_all(query)
|
||||||
|
|
||||||
details = []
|
# 为失败调用尝试查找匹配的错误日志ID(时间窗口 +/- 5 分钟)
|
||||||
|
from app.database.models import ErrorLog # 延迟导入避免循环依赖
|
||||||
|
|
||||||
|
details: list[dict] = []
|
||||||
for row in results:
|
for row in results:
|
||||||
status = "failure"
|
status = "failure"
|
||||||
if row["status_code"] is not None:
|
if row["status_code"] is not None:
|
||||||
status = "success" if 200 <= row["status_code"] < 300 else "failure"
|
status = "success" if 200 <= row["status_code"] < 300 else "failure"
|
||||||
details.append(
|
|
||||||
{
|
record = {
|
||||||
"timestamp": row[
|
"timestamp": row["timestamp"].isoformat(),
|
||||||
"timestamp"
|
"key": row["key"],
|
||||||
].isoformat(),
|
"model": row["model"],
|
||||||
"key": row["key"],
|
"status": status,
|
||||||
"model": row["model"],
|
"status_code": row["status_code"],
|
||||||
"status": status,
|
"latency_ms": row["latency_ms"],
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
# 如果失败,尝试附带一个相关的错误日志ID,便于前端拉取详情
|
||||||
|
if status == "failure" and row["key"]:
|
||||||
|
try:
|
||||||
|
ts = row["timestamp"]
|
||||||
|
start_win = ts - datetime.timedelta(minutes=5)
|
||||||
|
end_win = ts + datetime.timedelta(minutes=5)
|
||||||
|
err_query = (
|
||||||
|
select(ErrorLog.id)
|
||||||
|
.where(
|
||||||
|
ErrorLog.gemini_key == row["key"],
|
||||||
|
ErrorLog.request_time >= start_win,
|
||||||
|
ErrorLog.request_time <= end_win,
|
||||||
|
)
|
||||||
|
.order_by(ErrorLog.request_time.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
err = await database.fetch_one(err_query)
|
||||||
|
if err:
|
||||||
|
record["error_log_id"] = err["id"]
|
||||||
|
except Exception as _e:
|
||||||
|
logger.debug(
|
||||||
|
f"No matching error log found for key ending ...{row['key'][-4:] if row['key'] else ''}: {_e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
details.append(record)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Retrieved {len(details)} API call details for period '{period}'"
|
f"Retrieved {len(details)} API call details for period '{period}'"
|
||||||
)
|
)
|
||||||
return details
|
return details
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get API call details for period '{period}': {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def get_key_call_details(self, key: str, period: str) -> list[dict]:
|
||||||
|
"""获取指定密钥在指定时间段内的调用详情 (与 get_api_call_details 结构一致)"""
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
if period == "1m":
|
||||||
|
start_time = now - datetime.timedelta(minutes=1)
|
||||||
|
elif period == "1h":
|
||||||
|
start_time = now - datetime.timedelta(hours=1)
|
||||||
|
elif period == "8h":
|
||||||
|
start_time = now - datetime.timedelta(hours=8)
|
||||||
|
elif period == "24h":
|
||||||
|
start_time = now - datetime.timedelta(hours=24)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"无效的时间段标识: {period}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
query = (
|
||||||
|
select(
|
||||||
|
RequestLog.request_time.label("timestamp"),
|
||||||
|
RequestLog.api_key.label("key"),
|
||||||
|
RequestLog.model_name.label("model"),
|
||||||
|
RequestLog.status_code.label("status_code"),
|
||||||
|
RequestLog.latency_ms.label("latency_ms"),
|
||||||
|
)
|
||||||
|
.where(RequestLog.request_time >= start_time, RequestLog.api_key == key)
|
||||||
|
.order_by(RequestLog.request_time.desc())
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await database.fetch_all(query)
|
||||||
|
|
||||||
|
from app.database.models import ErrorLog
|
||||||
|
|
||||||
|
details: list[dict] = []
|
||||||
|
for row in results:
|
||||||
|
status = "failure"
|
||||||
|
if row["status_code"] is not None:
|
||||||
|
status = "success" if 200 <= row["status_code"] < 300 else "failure"
|
||||||
|
|
||||||
|
record = {
|
||||||
|
"timestamp": row["timestamp"].isoformat(),
|
||||||
|
"key": row["key"],
|
||||||
|
"model": row["model"],
|
||||||
|
"status": status,
|
||||||
|
"status_code": row["status_code"],
|
||||||
|
"latency_ms": row["latency_ms"],
|
||||||
|
}
|
||||||
|
|
||||||
|
if status == "failure" and row["key"]:
|
||||||
|
try:
|
||||||
|
ts = row["timestamp"]
|
||||||
|
start_win = ts - datetime.timedelta(minutes=5)
|
||||||
|
end_win = ts + datetime.timedelta(minutes=5)
|
||||||
|
err_query = (
|
||||||
|
select(ErrorLog.id)
|
||||||
|
.where(
|
||||||
|
ErrorLog.gemini_key == row["key"],
|
||||||
|
ErrorLog.request_time >= start_win,
|
||||||
|
ErrorLog.request_time < end_win,
|
||||||
|
)
|
||||||
|
.order_by(ErrorLog.request_time.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
err = await database.fetch_one(err_query)
|
||||||
|
if err:
|
||||||
|
record["error_log_id"] = err["id"]
|
||||||
|
except Exception as _e:
|
||||||
|
logger.debug(
|
||||||
|
f"No matching error log found for key ending ...{row['key'][-4:] if row['key'] else ''}: {_e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
details.append(record)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Retrieved {len(details)} key call details for key=...{key[-4:] if key else ''} period '{period}'"
|
||||||
|
)
|
||||||
|
return details
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Failed to get API call details for period '{period}': {e}")
|
f"Failed to get key call details for key=...{key[-4:] if key else ''} period '{period}': {e}"
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def get_key_usage_details_last_24h(self, key: str) -> Union[dict, None]:
|
async def get_key_usage_details_last_24h(self, key: str) -> Union[dict, None]:
|
||||||
@@ -220,8 +332,7 @@ class StatsService:
|
|||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
select(
|
select(
|
||||||
RequestLog.model_name, func.count(
|
RequestLog.model_name, func.count(RequestLog.id).label("call_count")
|
||||||
RequestLog.id).label("call_count")
|
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
RequestLog.api_key == key,
|
RequestLog.api_key == key,
|
||||||
@@ -240,8 +351,7 @@ class StatsService:
|
|||||||
)
|
)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
usage_details = {row["model_name"]: row["call_count"]
|
usage_details = {row["model_name"]: row["call_count"] for row in results}
|
||||||
for row in results}
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Successfully fetched usage details for key ending in ...{key[-4:]}: {usage_details}"
|
f"Successfully fetched usage details for key ending in ...{key[-4:]}: {usage_details}"
|
||||||
)
|
)
|
||||||
|
|||||||
+225
-66
@@ -346,7 +346,8 @@ function showResetModal(type) {
|
|||||||
// 设置确认按钮事件
|
// 设置确认按钮事件
|
||||||
confirmButton.onclick = () => executeResetAll(type);
|
confirmButton.onclick = () => executeResetAll(type);
|
||||||
|
|
||||||
// 显示模态框
|
// 显示模态框,确保位于最上层
|
||||||
|
modalElement.style.zIndex = '1001';
|
||||||
modalElement.classList.remove("hidden");
|
modalElement.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1508,6 +1509,12 @@ function bucketizeDetails(period, details) {
|
|||||||
const HH = String(d.getHours()).padStart(2,'0');
|
const HH = String(d.getHours()).padStart(2,'0');
|
||||||
const mm = String(d.getMinutes()).padStart(2,'0');
|
const mm = String(d.getMinutes()).padStart(2,'0');
|
||||||
return `${HH}:${mm}`;
|
return `${HH}:${mm}`;
|
||||||
|
} else if (period === '8h') {
|
||||||
|
// bucket by hour for 8h window (same as 24h)
|
||||||
|
const MM = String(d.getMonth()+1).padStart(2,'0');
|
||||||
|
const DD = String(d.getDate()).padStart(2,'0');
|
||||||
|
const HH = String(d.getHours()).padStart(2,'0');
|
||||||
|
return `${MM}-${DD} ${HH}:00`;
|
||||||
} else {
|
} else {
|
||||||
// 24h: bucket by hour
|
// 24h: bucket by hour
|
||||||
const MM = String(d.getMonth()+1).padStart(2,'0');
|
const MM = String(d.getMonth()+1).padStart(2,'0');
|
||||||
@@ -1553,11 +1560,11 @@ async function renderApiChart(period) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initChartControls() {
|
function initChartControls() {
|
||||||
const btn1m = document.getElementById('chartBtn1m');
|
|
||||||
const btn1h = document.getElementById('chartBtn1h');
|
const btn1h = document.getElementById('chartBtn1h');
|
||||||
|
const btn8h = document.getElementById('chartBtn8h');
|
||||||
const btn24h = document.getElementById('chartBtn24h');
|
const btn24h = document.getElementById('chartBtn24h');
|
||||||
const setActive = (activeBtn) => {
|
const setActive = (activeBtn) => {
|
||||||
[btn1m, btn1h, btn24h].forEach(btn => {
|
[btn1h, btn8h, btn24h].forEach(btn => {
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
if (btn === activeBtn) {
|
if (btn === activeBtn) {
|
||||||
btn.classList.remove('bg-gray-200');
|
btn.classList.remove('bg-gray-200');
|
||||||
@@ -1569,8 +1576,8 @@ function initChartControls() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (btn1m) btn1m.addEventListener('click', async () => { setActive(btn1m); await renderApiChart('1m'); });
|
|
||||||
if (btn1h) btn1h.addEventListener('click', async () => { setActive(btn1h); await renderApiChart('1h'); });
|
if (btn1h) btn1h.addEventListener('click', async () => { setActive(btn1h); await renderApiChart('1h'); });
|
||||||
|
if (btn8h) btn8h.addEventListener('click', async () => { setActive(btn8h); await renderApiChart('8h'); });
|
||||||
if (btn24h) btn24h.addEventListener('click', async () => { setActive(btn24h); await renderApiChart('24h'); });
|
if (btn24h) btn24h.addEventListener('click', async () => { setActive(btn24h); await renderApiChart('24h'); });
|
||||||
|
|
||||||
// default period
|
// default period
|
||||||
@@ -1844,6 +1851,82 @@ async function showApiCallDetails(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取并显示错误日志详情(通过日志ID)
|
||||||
|
async function fetchAndShowErrorDetail(logId) {
|
||||||
|
try {
|
||||||
|
const detail = await fetchAPI(`/api/logs/errors/${logId}/details`);
|
||||||
|
if (!detail) {
|
||||||
|
showResultModal(false, `未找到日志 ${logId}`, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'space-y-3 text-sm';
|
||||||
|
const basic = document.createElement('div');
|
||||||
|
basic.innerHTML = `
|
||||||
|
<div><span class="font-semibold">Key:</span> ${detail.gemini_key ? detail.gemini_key.substring(0,4)+'...'+detail.gemini_key.slice(-4) : 'N/A'}</div>
|
||||||
|
<div><span class="font-semibold">模型:</span> ${detail.model_name || 'N/A'}</div>
|
||||||
|
<div><span class="font-semibold">时间:</span> ${detail.request_time ? new Date(detail.request_time).toLocaleString() : 'N/A'}</div>
|
||||||
|
<div><span class="font-semibold">错误类型:</span> ${detail.error_type || 'N/A'}</div>
|
||||||
|
`;
|
||||||
|
const codeBlock = document.createElement('pre');
|
||||||
|
codeBlock.className = 'bg-red-50 border border-red-200 rounded p-3 whitespace-pre-wrap break-words text-red-700';
|
||||||
|
codeBlock.textContent = detail.error_log || '无错误日志内容';
|
||||||
|
const reqBlock = document.createElement('pre');
|
||||||
|
reqBlock.className = 'bg-gray-50 border border-gray-200 rounded p-3 whitespace-pre-wrap break-words';
|
||||||
|
reqBlock.textContent = detail.request_msg || '';
|
||||||
|
container.appendChild(basic);
|
||||||
|
container.appendChild(codeBlock);
|
||||||
|
if (detail.request_msg) container.appendChild(reqBlock);
|
||||||
|
showResultModal(false, container, false);
|
||||||
|
} catch (e) {
|
||||||
|
showResultModal(false, `加载日志详情失败: ${e.message}`, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:根据 key / 状态码 / 时间窗口(±100秒) 查询并显示错误日志详情
|
||||||
|
async function fetchAndShowErrorDetailByInfo(geminiKey, statusCode, timestampISO) {
|
||||||
|
try {
|
||||||
|
if (!geminiKey || !timestampISO) {
|
||||||
|
showResultModal(false, '缺少必要参数,无法查询错误详情', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('gemini_key', geminiKey);
|
||||||
|
params.set('timestamp', timestampISO);
|
||||||
|
if (statusCode !== null && statusCode !== undefined) {
|
||||||
|
params.set('status_code', String(statusCode));
|
||||||
|
}
|
||||||
|
params.set('window_seconds', '100');
|
||||||
|
const detail = await fetchAPI(`/api/logs/errors/lookup?${params.toString()}`);
|
||||||
|
if (!detail) {
|
||||||
|
showResultModal(false, '未找到匹配的错误日志', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'space-y-3 text-sm';
|
||||||
|
const basic = document.createElement('div');
|
||||||
|
basic.innerHTML = `
|
||||||
|
<div><span class="font-semibold">Key:</span> ${detail.gemini_key ? detail.gemini_key.substring(0,4)+'...'+detail.gemini_key.slice(-4) : 'N/A'}</div>
|
||||||
|
<div><span class="font-semibold">模型:</span> ${detail.model_name || 'N/A'}</div>
|
||||||
|
<div><span class="font-semibold">时间:</span> ${detail.request_time ? new Date(detail.request_time).toLocaleString() : 'N/A'}</div>
|
||||||
|
<div><span class="font-semibold">错误码:</span> ${detail.error_code ?? 'N/A'}</div>
|
||||||
|
<div><span class="font-semibold">错误类型:</span> ${detail.error_type || 'N/A'}</div>
|
||||||
|
`;
|
||||||
|
const codeBlock = document.createElement('pre');
|
||||||
|
codeBlock.className = 'bg-red-50 border border-red-200 rounded p-3 whitespace-pre-wrap break-words text-red-700';
|
||||||
|
codeBlock.textContent = detail.error_log || '无错误日志内容';
|
||||||
|
const reqBlock = document.createElement('pre');
|
||||||
|
reqBlock.className = 'bg-gray-50 border border-gray-200 rounded p-3 whitespace-pre-wrap break-words';
|
||||||
|
reqBlock.textContent = detail.request_msg || '';
|
||||||
|
container.appendChild(basic);
|
||||||
|
container.appendChild(codeBlock);
|
||||||
|
if (detail.request_msg) container.appendChild(reqBlock);
|
||||||
|
showResultModal(false, container, false);
|
||||||
|
} catch (e) {
|
||||||
|
showResultModal(false, `加载日志详情失败: ${e.message}`, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 关闭 API 调用详情模态框
|
// 关闭 API 调用详情模态框
|
||||||
function closeApiCallDetailsModal() {
|
function closeApiCallDetailsModal() {
|
||||||
const modal = document.getElementById("apiCallDetailsModal");
|
const modal = document.getElementById("apiCallDetailsModal");
|
||||||
@@ -1867,23 +1950,33 @@ function renderApiCallDetails(
|
|||||||
successCalls !== undefined &&
|
successCalls !== undefined &&
|
||||||
failureCalls !== undefined
|
failureCalls !== undefined
|
||||||
) {
|
) {
|
||||||
|
const total = Number(totalCalls) || 0;
|
||||||
|
const succ = Number(successCalls) || 0;
|
||||||
|
const fail = Number(failureCalls) || 0;
|
||||||
|
const denom = total > 0 ? total : succ + fail;
|
||||||
|
const succRate = denom > 0 ? ((succ / denom) * 100).toFixed(1) : '0.0';
|
||||||
|
const failRate = denom > 0 ? ((fail / denom) * 100).toFixed(1) : '0.0';
|
||||||
|
|
||||||
summaryHtml = `
|
summaryHtml = `
|
||||||
<div class="mb-4 p-3 bg-white dark:bg-gray-700 rounded-lg">
|
<div class="mb-4">
|
||||||
<h4 class="font-semibold text-gray-700 dark:text-gray-200 mb-2 text-md border-b pb-1.5 dark:border-gray-600">期间调用概览:</h4>
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 rounded-lg overflow-hidden">
|
||||||
<div class="grid grid-cols-3 gap-2 text-center">
|
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||||
<div>
|
<tr>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">总计</p>
|
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">总计</th>
|
||||||
<p class="text-lg font-bold text-primary-600 dark:text-primary-400">${totalCalls}</p>
|
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">成功</th>
|
||||||
</div>
|
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">失败</th>
|
||||||
<div>
|
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">成功率</th>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">成功</p>
|
</tr>
|
||||||
<p class="text-lg font-bold text-success-600 dark:text-success-400">${successCalls}</p>
|
</thead>
|
||||||
</div>
|
<tbody class="bg-white dark:bg-gray-800">
|
||||||
<div>
|
<tr>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">失败</p>
|
<td class="px-4 py-2 whitespace-nowrap text-sm font-bold text-primary-600 dark:text-primary-400">${totalCalls}</td>
|
||||||
<p class="text-lg font-bold text-danger-600 dark:text-danger-400">${failureCalls}</p>
|
<td class="px-4 py-2 whitespace-nowrap text-sm font-bold text-success-600 dark:text-success-400">${successCalls}</td>
|
||||||
</div>
|
<td class="px-4 py-2 whitespace-nowrap text-sm font-bold text-danger-600 dark:text-danger-400">${failureCalls}</td>
|
||||||
</div>
|
<td class="px-4 py-2 whitespace-nowrap text-sm font-bold text-success-600 dark:text-success-400">${succRate}%</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -1907,7 +2000,10 @@ function renderApiCallDetails(
|
|||||||
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">时间</th>
|
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">时间</th>
|
||||||
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">密钥 (部分)</th>
|
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">密钥 (部分)</th>
|
||||||
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">模型</th>
|
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">模型</th>
|
||||||
|
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">状态码</th>
|
||||||
|
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">耗时(ms)</th>
|
||||||
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">状态</th>
|
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">状态</th>
|
||||||
|
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">详情</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
@@ -1928,17 +2024,25 @@ function renderApiCallDetails(
|
|||||||
const statusIcon =
|
const statusIcon =
|
||||||
call.status === "success" ? "fa-check-circle" : "fa-times-circle";
|
call.status === "success" ? "fa-check-circle" : "fa-times-circle";
|
||||||
|
|
||||||
|
const detailsBtn =
|
||||||
|
call.status === "failure"
|
||||||
|
? `<button class="px-2 py-1 rounded bg-red-100 hover:bg-red-200 text-red-700 text-xs" onclick="fetchAndShowErrorDetailByInfo('${call.key}', ${call.status_code ?? 'null'}, '${call.timestamp}')">
|
||||||
|
<i class="fas fa-info-circle mr-1"></i>详情
|
||||||
|
</button>`
|
||||||
|
: "-";
|
||||||
|
|
||||||
tableHtml += `
|
tableHtml += `
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30">
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30">
|
||||||
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">${timestamp}</td>
|
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">${timestamp}</td>
|
||||||
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 font-mono">${keyDisplay}</td>
|
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 font-mono">${keyDisplay}</td>
|
||||||
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">${
|
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">${call.model || "N/A"}</td>
|
||||||
call.model || "N/A"
|
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">${call.status_code ?? "-"}</td>
|
||||||
}</td>
|
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">${call.latency_ms ?? "-"}</td>
|
||||||
<td class="px-4 py-2 whitespace-nowrap text-sm ${statusClass}">
|
<td class="px-4 py-2 whitespace-nowrap text-sm ${statusClass}">
|
||||||
<i class="fas ${statusIcon} mr-1"></i>
|
<i class="fas ${statusIcon} mr-1"></i>
|
||||||
${call.status}
|
${call.status}
|
||||||
</td>
|
</td>
|
||||||
|
<td class="px-4 py-2 whitespace-nowrap text-sm">${detailsBtn}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
@@ -1967,67 +2071,122 @@ window.showKeyUsageDetails = async function (key) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderKeyUsageDetails 变为 showKeyUsageDetails 的局部函数
|
// 构建内容框架(时间范围按钮 + 图表 + 表格容器)
|
||||||
function renderKeyUsageDetails(data, container) {
|
const controlsHtml = `
|
||||||
if (!data || Object.keys(data).length === 0) {
|
<div class="flex items-center gap-2 mb-3 text-xs">
|
||||||
|
<button id="keyBtn1h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">1小时</button>
|
||||||
|
<button id="keyBtn8h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">8小时</button>
|
||||||
|
<button id="keyBtn24h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">24小时</button>
|
||||||
|
</div>
|
||||||
|
<div class="h-48 mb-4">
|
||||||
|
<canvas id="keyUsageChart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div id="keyUsageTable"></div>`;
|
||||||
|
contentArea.innerHTML = controlsHtml;
|
||||||
|
|
||||||
|
// 设置标题
|
||||||
|
titleElement.textContent = `密钥 ${keyDisplay} - 请求详情`;
|
||||||
|
|
||||||
|
// 显示模态框
|
||||||
|
modal.classList.remove("hidden");
|
||||||
|
|
||||||
|
let keyUsageChart = null;
|
||||||
|
function buildKeyChartConfig(labels, successData, failureData) {
|
||||||
|
return buildChartConfig(labels, successData, failureData);
|
||||||
|
}
|
||||||
|
function bucketizeKeyDetails(period, details) {
|
||||||
|
return bucketizeDetails(period, details);
|
||||||
|
}
|
||||||
|
function renderKeyUsageTable(data) {
|
||||||
|
const container = document.getElementById('keyUsageTable');
|
||||||
|
if (!container) return;
|
||||||
|
if (!data || data.length === 0) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="text-center py-10 text-gray-500">
|
<div class="text-center py-10 text-gray-500">
|
||||||
<i class="fas fa-info-circle text-3xl"></i>
|
<i class="fas fa-info-circle text-3xl"></i>
|
||||||
<p class="mt-2">该密钥在最近24小时内没有调用记录。</p>
|
<p class="mt-2">该时间段内没有 API 调用记录。</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let tableHtml = `
|
let tableHtml = `
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">模型名称</th>
|
<th 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>
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">模型</th>
|
||||||
</tr>
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态码</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">耗时(ms)</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">详情</th>
|
||||||
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="bg-white divide-y divide-gray-200">`;
|
<tbody class="bg-white divide-y divide-gray-200">`;
|
||||||
const sortedModels = Object.entries(data).sort(
|
data.forEach((row) => {
|
||||||
([, countA], [, countB]) => countB - countA
|
const timestamp = new Date(row.timestamp).toLocaleString();
|
||||||
);
|
const statusClass = row.status === 'success' ? 'text-success-600' : 'text-danger-600';
|
||||||
sortedModels.forEach(([model, count]) => {
|
const statusIcon = row.status === 'success' ? 'fa-check-circle' : 'fa-times-circle';
|
||||||
|
const detailsBtn = row.status === 'failure'
|
||||||
|
? `<button class="px-2 py-1 rounded bg-red-100 hover:bg-red-200 text-red-700 text-xs" onclick="fetchAndShowErrorDetailByInfo('${row.key}', ${row.status_code ?? 'null'}, '${row.timestamp}')">
|
||||||
|
<i class="fas fa-info-circle mr-1"></i>详情
|
||||||
|
</button>`
|
||||||
|
: '-';
|
||||||
tableHtml += `
|
tableHtml += `
|
||||||
<tr>
|
<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-900">${timestamp}</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${count}</td>
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${row.model || 'N/A'}</td>
|
||||||
</tr>`;
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${row.status_code ?? '-'}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${row.latency_ms ?? '-'}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm ${statusClass}"><i class="fas ${statusIcon} mr-1"></i>${row.status}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm">${detailsBtn}</td>
|
||||||
|
</tr>`;
|
||||||
});
|
});
|
||||||
tableHtml += `
|
tableHtml += `</tbody></table>`;
|
||||||
</tbody>
|
|
||||||
</table>`;
|
|
||||||
container.innerHTML = tableHtml;
|
container.innerHTML = tableHtml;
|
||||||
}
|
}
|
||||||
|
async function renderForPeriod(period) {
|
||||||
// 设置标题
|
try {
|
||||||
titleElement.textContent = `密钥 ${keyDisplay} - 最近24小时请求详情`;
|
const details = await fetchAPI(`/api/stats/key-details?key=${encodeURIComponent(key)}&period=${period}`);
|
||||||
|
const { labels, successData, failureData } = bucketizeKeyDetails(period, details || []);
|
||||||
// 显示模态框并设置加载状态
|
const canvas = document.getElementById('keyUsageChart');
|
||||||
modal.classList.remove("hidden");
|
if (canvas && typeof Chart !== 'undefined') {
|
||||||
contentArea.innerHTML = `
|
const cfg = buildKeyChartConfig(labels, successData, failureData);
|
||||||
<div class="text-center py-10">
|
if (keyUsageChart) keyUsageChart.destroy();
|
||||||
<i class="fas fa-spinner fa-spin text-primary-600 text-3xl"></i>
|
keyUsageChart = new Chart(canvas.getContext('2d'), cfg);
|
||||||
<p class="text-gray-500 mt-2">加载中...</p>
|
}
|
||||||
</div>`;
|
renderKeyUsageTable(details || []);
|
||||||
|
} catch (e) {
|
||||||
try {
|
console.error('加载密钥期内详情失败:', e);
|
||||||
const data = await fetchAPI(`/api/key-usage-details/${key}`);
|
const tableContainer = document.getElementById('keyUsageTable');
|
||||||
if (data) {
|
if (tableContainer) {
|
||||||
renderKeyUsageDetails(data, contentArea);
|
tableContainer.innerHTML = `<div class="text-center py-10 text-danger-500">
|
||||||
} else {
|
|
||||||
renderKeyUsageDetails({}, contentArea); // Show empty state if no data
|
|
||||||
}
|
|
||||||
} catch (apiError) {
|
|
||||||
console.error("获取密钥使用详情失败:", apiError);
|
|
||||||
contentArea.innerHTML = `
|
|
||||||
<div class="text-center py-10 text-danger-500">
|
|
||||||
<i class="fas fa-exclamation-triangle text-3xl"></i>
|
<i class="fas fa-exclamation-triangle text-3xl"></i>
|
||||||
<p class="mt-2">加载失败: ${apiError.message}</p>
|
<p class="mt-2">加载失败: ${e.message}</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 绑定按钮事件与默认加载
|
||||||
|
const btn1h = document.getElementById('keyBtn1h');
|
||||||
|
const btn8h = document.getElementById('keyBtn8h');
|
||||||
|
const btn24h = document.getElementById('keyBtn24h');
|
||||||
|
const setActive = (activeBtn) => {
|
||||||
|
[btn1h, btn8h, btn24h].forEach((btn) => {
|
||||||
|
if (!btn) return;
|
||||||
|
if (btn === activeBtn) {
|
||||||
|
btn.classList.remove('bg-gray-200');
|
||||||
|
btn.classList.add('bg-primary-600','text-white');
|
||||||
|
} else {
|
||||||
|
btn.classList.add('bg-gray-200');
|
||||||
|
btn.classList.remove('bg-primary-600','text-white');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (btn1h) btn1h.addEventListener('click', () => { setActive(btn1h); renderForPeriod('1h'); });
|
||||||
|
if (btn8h) btn8h.addEventListener('click', () => { setActive(btn8h); renderForPeriod('8h'); });
|
||||||
|
if (btn24h) btn24h.addEventListener('click', () => { setActive(btn24h); renderForPeriod('24h'); });
|
||||||
|
if (btn1h) setActive(btn1h);
|
||||||
|
renderForPeriod('1h');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 关闭密钥使用详情模态框
|
// 关闭密钥使用详情模态框
|
||||||
|
|||||||
@@ -1268,8 +1268,8 @@ endblock %} {% block head_extra_styles %}
|
|||||||
<span>调用趋势图</span>
|
<span>调用趋势图</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="flex items-center gap-2 text-xs">
|
<div class="flex items-center gap-2 text-xs">
|
||||||
<button id="chartBtn1m" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">1分钟</button>
|
|
||||||
<button id="chartBtn1h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">1小时</button>
|
<button id="chartBtn1h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">1小时</button>
|
||||||
|
<button id="chartBtn8h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">8小时</button>
|
||||||
<button id="chartBtn24h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">24小时</button>
|
<button id="chartBtn24h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">24小时</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1887,7 +1887,8 @@ endblock %} {% block head_extra_styles %}
|
|||||||
<!-- 操作结果模态框 -->
|
<!-- 操作结果模态框 -->
|
||||||
<div
|
<div
|
||||||
id="resultModal"
|
id="resultModal"
|
||||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden"
|
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center hidden"
|
||||||
|
style="z-index: 1001;"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="bg-white rounded-2xl p-0 shadow-2xl max-w-lg w-full animate-fade-in border border-gray-200"
|
class="bg-white rounded-2xl p-0 shadow-2xl max-w-lg w-full animate-fade-in border border-gray-200"
|
||||||
|
|||||||
Reference in New Issue
Block a user