bussiness2api / core /templates.py
yu
feat: 管理面板新增 API 接口信息展示
569acd6
"""
模板生成模块
此模块包含 HTML 页面生成函数,用于管理界面和日志查看器
注意:
- 这些函数需要通过 import main 动态获取全局变量
- 避免在模块顶层导入 main,防止循环依赖
"""
from fastapi import Request, Header, HTTPException
from fastapi.responses import HTMLResponse
def generate_admin_html(request: Request, multi_account_mgr, show_hide_tip: bool = False) -> str:
"""生成管理页面HTML - 端点带Key参数完整版"""
# 动态导入 main 模块的变量(避免循环依赖)
import main
# 获取当前页面的完整URL
current_url = main.get_base_url(request)
# 获取错误统计
error_count = 0
with main.log_lock:
for log in main.log_buffer:
if log.get("level") in ["ERROR", "CRITICAL"]:
error_count += 1
# --- 1. 构建提示信息 ---
hide_tip = ""
if show_hide_tip:
hide_tip = """
<div class="alert alert-info">
<div class="alert-icon">💡</div>
<div class="alert-content">
<strong>提示</strong>:此页面默认在首页显示。如需隐藏,请设置环境变量:<br>
<code style="margin-top:4px; display:inline-block;">main.HIDE_HOME_PAGE=true</code>
</div>
</div>
"""
api_key_status = ""
if main.API_KEY:
api_key_status = """
<div class="alert alert-success">
<div class="alert-icon">🔒</div>
<div class="alert-content">
<strong>安全模式已启用</strong>
<div class="alert-desc">请求 Header 需携带 Authorization 密钥。</div>
</div>
</div>
"""
else:
api_key_status = """
<div class="alert alert-warning">
<div class="alert-icon">⚠️</div>
<div class="alert-content">
<strong>API Key 未设置</strong>
<div class="alert-desc">API 当前允许公开访问,建议配置 main.API_KEY。</div>
</div>
</div>
"""
error_alert = ""
if error_count > 0:
error_alert = f"""
<div class="alert alert-error">
<div class="alert-icon">🚨</div>
<div class="alert-content">
<strong>检测到 {error_count} 条错误日志</strong>
<a href="/public/log/html" class="alert-link">查看详情 &rarr;</a>
</div>
</div>
"""
# API接口信息提示
api_endpoint = f"{current_url}/{main.PATH_PREFIX}/v1/chat/completions"
api_key_display = main.API_KEY if main.API_KEY else '<span style="color: #ff9500;">未设置(公开访问)</span>'
api_info_tip = f"""
<div class="alert alert-primary">
<div class="alert-icon">🔗</div>
<div class="alert-content">
<strong>API 接口信息</strong>
<div style="margin-top: 10px;">
<div style="margin-bottom: 12px;">
<div style="color: #86868b; font-size: 11px; margin-bottom: 4px;">聊天接口</div>
<code style="font-size: 11px; background: rgba(0,0,0,0.05); padding: 4px 8px; border-radius: 4px; display: inline-block; word-break: break-all;">{api_endpoint}</code>
</div>
<div style="margin-bottom: 12px;">
<div style="color: #86868b; font-size: 11px; margin-bottom: 4px;">API 密钥</div>
<code style="font-size: 11px; background: rgba(0,0,0,0.05); padding: 4px 8px; border-radius: 4px; display: inline-block;">{api_key_display}</code>
</div>
<div style="margin-bottom: 12px;">
<div style="color: #86868b; font-size: 11px; margin-bottom: 6px;">支持的模型</div>
<div style="display: flex; flex-wrap: wrap; gap: 6px;">
<span style="background: #f0f0f2; color: #1d1d1f; padding: 3px 8px; border-radius: 4px; font-size: 11px; font-family: 'SF Mono', SFMono-Regular, Consolas, monospace;">gemini-auto</span>
<span style="background: #f0f0f2; color: #1d1d1f; padding: 3px 8px; border-radius: 4px; font-size: 11px; font-family: 'SF Mono', SFMono-Regular, Consolas, monospace;">gemini-2.5-flash</span>
<span style="background: #f0f0f2; color: #1d1d1f; padding: 3px 8px; border-radius: 4px; font-size: 11px; font-family: 'SF Mono', SFMono-Regular, Consolas, monospace;">gemini-2.5-pro</span>
<span style="background: #f0f0f2; color: #1d1d1f; padding: 3px 8px; border-radius: 4px; font-size: 11px; font-family: 'SF Mono', SFMono-Regular, Consolas, monospace;">gemini-3-flash-preview</span>
<span style="background: #eef7ff; color: #0071e3; padding: 3px 8px; border-radius: 4px; font-size: 11px; font-family: 'SF Mono', SFMono-Regular, Consolas, monospace; border: 1px solid #dcebfb; font-weight: 500;">gemini-3-pro-preview</span>
</div>
</div>
<div style="background: rgba(0,0,0,0.03); padding: 10px; border-radius: 6px;">
<div style="font-size: 11px; color: #1d1d1f; margin-bottom: 4px; font-weight: 600;">📸 图片生成说明</div>
<div style="font-size: 11px; color: #86868b; line-height: 1.6;">
• 仅 <code style="font-size: 10px; background: rgba(0,0,0,0.08); padding: 1px 4px; border-radius: 3px;">gemini-3-pro-preview</code> 支持绘图<br>
• 存储路径:<code style="font-size: 10px; background: rgba(0,0,0,0.08); padding: 1px 4px; border-radius: 3px;">./images</code><br>
• 存储类型:临时(服务重启后丢失)
</div>
</div>
</div>
</div>
</div>
"""
# --- 2. 构建账户卡片 ---
accounts_html = ""
for account_id, account_manager in multi_account_mgr.accounts.items():
config = account_manager.config
remaining_hours = config.get_remaining_hours()
expire_status_text, _, expire_display = main.format_account_expiration(remaining_hours)
# 检查账户是否过期或被手动禁用
is_expired = config.is_expired()
is_disabled = config.disabled
# 使用AccountManager的方法获取冷却信息
cooldown_seconds, cooldown_reason = account_manager.get_cooldown_info()
# 确定账户状态和颜色
if is_expired:
status_text = "过期禁用"
status_color = "#9e9e9e"
dot_color = "#9e9e9e"
card_opacity = "0.5"
action_buttons = f'<button onclick="deleteAccount(\'{config.account_id}\')" class="delete-btn" title="删除账户">删除</button>'
elif is_disabled:
status_text = "手动禁用"
status_color = "#9e9e9e"
dot_color = "#9e9e9e"
card_opacity = "0.5"
action_buttons = f'''
<button onclick="enableAccount('{config.account_id}')" class="enable-btn" title="启用账户">启用</button>
<button onclick="deleteAccount('{config.account_id}')" class="delete-btn" title="删除账户">删除</button>
'''
elif cooldown_seconds == -1:
# 错误永久禁用
status_text = cooldown_reason # "错误禁用"
status_color = "#f44336"
dot_color = "#f44336"
card_opacity = "0.5"
action_buttons = f'''
<button onclick="enableAccount('{config.account_id}')" class="enable-btn" title="启用账户">启用</button>
<button onclick="deleteAccount('{config.account_id}')" class="delete-btn" title="删除账户">删除</button>
'''
elif cooldown_seconds > 0:
# 429限流(冷却中)
status_text = cooldown_reason # "429限流"
status_color = "#ff9800"
dot_color = "#ff9800"
card_opacity = "1"
action_buttons = f'''
<button onclick="disableAccount('{config.account_id}')" class="disable-btn" title="禁用账户">禁用</button>
<button onclick="deleteAccount('{config.account_id}')" class="delete-btn" title="删除账户">删除</button>
'''
else:
# 正常状态
is_avail = account_manager.is_available
if is_avail:
status_text = expire_status_text # "正常", "即将过期", "紧急"
if expire_status_text == "正常":
status_color = "#4caf50"
dot_color = "#34c759"
elif expire_status_text == "即将过期":
status_color = "#ff9800"
dot_color = "#ff9800"
else: # 紧急
status_color = "#f44336"
dot_color = "#f44336"
else:
status_text = "不可用"
status_color = "#f44336"
dot_color = "#ff3b30"
card_opacity = "1"
action_buttons = f'''
<button onclick="disableAccount('{config.account_id}')" class="disable-btn" title="禁用账户">禁用</button>
<button onclick="deleteAccount('{config.account_id}')" class="delete-btn" title="删除账户">删除</button>
'''
# 构建卡片内容
accounts_html += f"""
<div class="card account-card" style="opacity: {card_opacity}; background: {'#f5f5f5' if float(card_opacity) < 1 else '#fafaf9'};">
<div class="acc-header">
<div class="acc-title">
<span class="status-dot" style="background-color: {dot_color};"></span>
<span>{config.account_id}</span>
</div>
<span class="acc-status" style="color: {status_color};">{status_text}</span>
</div>
<div class="acc-actions">
{action_buttons}
</div>
<div class="acc-body">
<div class="acc-row">
<span>过期时间</span>
<span class="font-mono">{config.expires_at or '未设置'}</span>
</div>
<div class="acc-row">
<span>剩余时长</span>
<span style="color: {status_color};">{expire_display}</span>
</div>
<div class="acc-row">
<span>累计对话</span>
<span style="color: #2563eb; font-weight: 600;">{account_manager.conversation_count} 次</span>
</div>
{'<div class="acc-row cooldown-row"><span>冷却倒计时</span><span class="cooldown-text" style="color: ' + status_color + ';">' + str(cooldown_seconds) + '秒 (' + cooldown_reason + ')</span></div>' if cooldown_seconds > 0 else ''}
</div>
</div>
"""
# --- 3. 构建 HTML ---
html_content = f"""
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>系统管理 - Gemini Business API</title>
<style>
:root {{
--bg-body: #f5f5f7;
--text-main: #1d1d1f;
--text-sec: #86868b;
--border: #d2d2d7;
--border-light: #e5e5ea;
--blue: #0071e3;
--red: #ff3b30;
--green: #34c759;
--orange: #ff9500;
}}
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Helvetica, Arial, sans-serif;
background-color: var(--bg-body);
color: var(--text-main);
font-size: 13px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
padding: 30px 20px;
cursor: default;
}}
.container {{ max-width: 1100px; margin: 0 auto; }}
/* Header */
.header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
}}
.header-info h1 {{
font-size: 24px;
font-weight: 600;
letter-spacing: -0.5px;
color: var(--text-main);
margin-bottom: 4px;
}}
.header-info .subtitle {{ font-size: 14px; color: var(--text-sec); }}
.header-actions {{ display: flex; gap: 10px; }}
/* Buttons */
.btn {{
display: inline-flex;
align-items: center;
padding: 8px 16px;
background: #ffffff;
border: 1px solid var(--border-light);
border-radius: 8px;
color: var(--text-main);
font-weight: 500;
text-decoration: none;
transition: all 0.2s;
font-size: 13px;
cursor: pointer;
box-shadow: 0 1px 2px rgba(0,0,0,0.03);
}}
.btn:hover {{ background: #fafafa; border-color: var(--border); text-decoration: none; }}
.btn-primary {{ background: var(--blue); color: white; border: none; }}
.btn-primary:hover {{ background: #0077ed; border: none; text-decoration: none; }}
/* Alerts */
.alert {{
padding: 12px 16px;
border-radius: 10px;
display: flex;
align-items: flex-start;
gap: 12px;
font-size: 13px;
border: 1px solid transparent;
margin-bottom: 12px;
}}
.alert-icon {{ font-size: 16px; margin-top: 1px; flex-shrink: 0; }}
.alert-content {{ flex: 1; }}
.alert-desc {{ color: inherit; opacity: 0.9; margin-top: 2px; font-size: 12px; }}
.alert-link {{ color: inherit; text-decoration: underline; margin-left: 10px; font-weight: 600; cursor: pointer; }}
.alert-info {{ background: #eef7fe; border-color: #dcebfb; color: #1c5b96; }}
.alert-success {{ background: #eafbf0; border-color: #d3f3dd; color: #15682e; }}
.alert-warning {{ background: #fff8e6; border-color: #fcebc2; color: #9c6e03; }}
.alert-error {{ background: #ffebeb; border-color: #fddddd; color: #c41e1e; }}
.alert-primary {{ background: #f9fafb; border-color: #e5e7eb; color: #374151; }}
/* Sections & Grids */
.section {{ margin-bottom: 30px; }}
.section-title {{
font-size: 15px;
font-weight: 600;
color: var(--text-main);
margin-bottom: 12px;
padding-left: 4px;
}}
.grid-3 {{ display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; align-items: start; }}
.grid-env {{ display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start; }}
.account-grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }}
.stack-col {{ display: flex; flex-direction: column; gap: 16px; }}
/* Cards */
.card {{
background: #fafaf9;
padding: 20px;
border: 1px solid #e5e5e5;
border-radius: 12px;
transition: all 0.15s ease;
}}
.card:hover {{ border-color: #d4d4d4; box-shadow: 0 0 8px rgba(0,0,0,0.08); }}
.card h3 {{
font-size: 13px;
font-weight: 600;
color: var(--text-sec);
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #f5f5f5;
text-transform: uppercase;
letter-spacing: 0.5px;
}}
/* Account & Env Styles */
.account-card .acc-header {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid #f5f5f5; }}
.acc-title {{ font-weight: 600; font-size: 14px; display: flex; align-items: center; gap: 8px; }}
.status-dot {{ width: 8px; height: 8px; border-radius: 50%; }}
.acc-status {{ font-size: 12px; font-weight: 600; }}
.acc-actions {{ display: flex; gap: 8px; margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid #f5f5f5; }}
.acc-body {{ }}
.acc-row {{ display: flex; justify-content: space-between; font-size: 12px; margin-top: 6px; color: var(--text-sec); }}
.cooldown-row {{ background: #fff8e6; padding: 8px; border-radius: 6px; margin-top: 8px; }}
.cooldown-text {{ color: #f59e0b; font-weight: 600; }}
/* Delete Button */
.delete-btn {{
background: #fff;
color: #dc2626;
border: 1px solid #fecaca;
padding: 4px 12px;
border-radius: 6px;
font-size: 11px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}}
.delete-btn:hover {{
background: #dc2626;
color: white;
border-color: #dc2626;
}}
/* Disable Button */
.disable-btn {{
background: #fff;
color: #f59e0b;
border: 1px solid #fed7aa;
padding: 4px 12px;
border-radius: 6px;
font-size: 11px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}}
.disable-btn:hover {{
background: #f59e0b;
color: white;
border-color: #f59e0b;
}}
/* Enable Button */
.enable-btn {{
background: #fff;
color: #10b981;
border: 1px solid #a7f3d0;
padding: 4px 12px;
border-radius: 6px;
font-size: 11px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}}
.enable-btn:hover {{
background: #10b981;
color: white;
border-color: #10b981;
}}
/* Modal */
.modal {{
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}}
.modal.show {{ display: flex; }}
.modal-content {{
background: white;
border-radius: 12px;
width: 90%;
max-width: 800px;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}}
.modal-header {{
padding: 20px 24px;
border-bottom: 1px solid #e5e5e5;
display: flex;
justify-content: space-between;
align-items: center;
}}
.modal-title {{ font-size: 18px; font-weight: 600; color: #1a1a1a; }}
.modal-close {{
background: none;
border: none;
font-size: 24px;
color: #6b6b6b;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: all 0.2s;
}}
.modal-close:hover {{ background: #f5f5f5; color: #1a1a1a; }}
.modal-body {{
padding: 24px;
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}}
.modal-footer {{
padding: 16px 24px;
border-top: 1px solid #e5e5e5;
display: flex;
justify-content: flex-end;
gap: 12px;
}}
/* JSON Editor */
.json-editor {{
width: 100%;
flex: 1;
min-height: 300px;
font-family: "SF Mono", SFMono-Regular, ui-monospace, Menlo, Consolas, monospace;
font-size: 13px;
padding: 16px;
border: 1px solid #e5e5e5;
border-radius: 8px;
background: #fafaf9;
color: #1a1a1a;
line-height: 1.6;
overflow-y: auto;
resize: none;
scrollbar-width: thin;
scrollbar-color: rgba(0,0,0,0.15) transparent;
}}
.json-editor::-webkit-scrollbar {{
width: 4px;
}}
.json-editor::-webkit-scrollbar-track {{
background: transparent;
}}
.json-editor::-webkit-scrollbar-thumb {{
background: rgba(0,0,0,0.15);
border-radius: 2px;
}}
.json-editor::-webkit-scrollbar-thumb:hover {{
background: rgba(0,0,0,0.3);
}}
.json-editor:focus {{
outline: none;
border-color: #0071e3;
box-shadow: 0 0 0 3px rgba(0,113,227,0.1);
}}
.json-error {{
color: #dc2626;
font-size: 12px;
margin-top: 8px;
padding: 8px 12px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 6px;
display: none;
}}
.json-error.show {{ display: block; }}
.btn-secondary {{
background: #f5f5f5;
color: #1a1a1a;
border: 1px solid #e5e5e5;
}}
.btn-secondary:hover {{ background: #e5e5e5; }}
.env-var {{ display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid #f5f5f5; }}
.env-var:last-child {{ border-bottom: none; }}
.env-name {{ font-family: "SF Mono", SFMono-Regular, ui-monospace, Menlo, Consolas, monospace; font-size: 12px; color: var(--text-main); font-weight: 600; }}
.env-desc {{ font-size: 11px; color: var(--text-sec); margin-top: 2px; }}
.env-value {{ font-family: "SF Mono", SFMono-Regular, ui-monospace, Menlo, Consolas, monospace; font-size: 12px; color: var(--text-sec); text-align: right; max-width: 50%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
.badge {{ display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 10px; font-weight: 600; vertical-align: middle; margin-left: 6px; }}
.badge-required {{ background: #ffebeb; color: #c62828; }}
.badge-optional {{ background: #e8f5e9; color: #2e7d32; }}
code {{ font-family: "SF Mono", SFMono-Regular, ui-monospace, Menlo, Consolas, monospace; background: #f5f5f7; padding: 2px 6px; border-radius: 4px; font-size: 12px; color: var(--blue); }}
a {{ color: var(--blue); text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
.font-mono {{ font-family: "SF Mono", SFMono-Regular, ui-monospace, Menlo, Consolas, monospace; }}
/* --- Service Info Styles --- */
.model-grid {{ display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }}
.model-tag {{
background: #f0f0f2;
color: #1d1d1f;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
font-family: "SF Mono", SFMono-Regular, ui-monospace, Menlo, Consolas, monospace;
border: 1px solid transparent;
}}
.model-tag.highlight {{ background: #eef7ff; color: #0071e3; border-color: #dcebfb; font-weight: 500; }}
.info-box {{ background: #f9f9f9; border: 1px solid #e5e5ea; border-radius: 8px; padding: 14px; }}
.info-box-title {{ font-weight: 600; font-size: 12px; color: #1d1d1f; margin-bottom: 6px; }}
.info-box-text {{ font-size: 12px; color: #86868b; line-height: 1.5; }}
.ep-table {{ width: 100%; border-collapse: collapse; font-size: 13px; }}
.ep-table tr {{ border-bottom: 1px solid #f5f5f5; }}
.ep-table tr:last-child {{ border-bottom: none; }}
.ep-table td {{ padding: 10px 0; vertical-align: middle; }}
.method {{
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
min-width: 48px;
text-align: center;
margin-right: 8px;
}}
.m-post {{ background: #eafbf0; color: #166534; border: 1px solid #dcfce7; }}
.m-get {{ background: #eff6ff; color: #1e40af; border: 1px solid #dbeafe; }}
.m-del {{ background: #fef2f2; color: #991b1b; border: 1px solid #fee2e2; }}
.ep-path {{ font-family: "SF Mono", SFMono-Regular, ui-monospace, Menlo, Consolas, monospace; color: #1d1d1f; margin-right: 8px; font-size: 12px; }}
.ep-desc {{ color: #86868b; font-size: 12px; margin-left: auto; }}
.current-url-row {{
display: flex;
align-items: center;
padding: 10px 12px;
background: #f2f7ff;
border-radius: 8px;
margin-bottom: 16px;
border: 1px solid #e1effe;
}}
@media (max-width: 800px) {{
.grid-3, .grid-env {{ grid-template-columns: 1fr; }}
.header {{ flex-direction: column; align-items: flex-start; gap: 16px; }}
.header-actions {{ width: 100%; justify-content: flex-start; }}
.ep-table td {{ display: flex; flex-direction: column; align-items: flex-start; gap: 4px; }}
.ep-desc {{ margin-left: 0; }}
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="header-info">
<h1>Gemini-Business2api</h1>
<div class="subtitle">多账户代理面板</div>
</div>
<div class="header-actions">
<a href="/public/log/html" class="btn" target="_blank">📄 公开日志</a>
<a href="/{main.PATH_PREFIX}/admin/log/html?key={main.ADMIN_KEY}" class="btn" target="_blank">🔧 管理日志</a>
<button class="btn" onclick="showEditConfig()" id="edit-btn">✏️ 编辑配置</button>
</div>
</div>
{hide_tip}
{api_key_status}
{error_alert}
{api_info_tip}
<div class="section">
<div class="section-title">账户状态 ({len(multi_account_mgr.accounts)} 个)</div>
<div style="color: #6b6b6b; font-size: 12px; margin-bottom: 12px; padding-left: 4px;">过期时间为12小时,可以自行修改时间,脚本可能有误差。</div>
<div class="account-grid">
{accounts_html if accounts_html else '<div class="card"><p style="color: #6b6b6b; font-size: 14px; text-align:center;">暂无账户</p></div>'}
</div>
</div>
<div class="section">
<div class="section-title">环境变量配置</div>
<div class="grid-env">
<div class="stack-col">
<div class="card">
<h3>必需变量 <span class="badge badge-required">REQUIRED</span></h3>
<div style="margin-top: 12px;">
<div class="env-var">
<div><div class="env-name">ACCOUNTS_CONFIG</div><div class="env-desc">JSON格式账户列表</div></div>
</div>
<div class="env-var">
<div><div class="env-name">PATH_PREFIX</div><div class="env-desc">API路径前缀</div></div>
<div class="env-value">当前: {main.PATH_PREFIX}</div>
</div>
<div class="env-var">
<div><div class="env-name">ADMIN_KEY</div><div class="env-desc">管理员密钥</div></div>
<div class="env-value">已设置</div>
</div>
</div>
</div>
<div class="card">
<h3>重试配置 <span class="badge badge-optional">OPTIONAL</span></h3>
<div style="margin-top: 12px;">
<div class="env-var">
<div><div class="env-name">MAX_NEW_SESSION_TRIES</div><div class="env-desc">新会话尝试账户数</div></div>
<div class="env-value">{main.MAX_NEW_SESSION_TRIES}</div>
</div>
<div class="env-var">
<div><div class="env-name">MAX_REQUEST_RETRIES</div><div class="env-desc">请求失败重试次数</div></div>
<div class="env-value">{main.MAX_REQUEST_RETRIES}</div>
</div>
<div class="env-var">
<div><div class="env-name">MAX_ACCOUNT_SWITCH_TRIES</div><div class="env-desc">每次重试查找账户次数</div></div>
<div class="env-value">{main.MAX_ACCOUNT_SWITCH_TRIES}</div>
</div>
<div class="env-var">
<div><div class="env-name">ACCOUNT_FAILURE_THRESHOLD</div><div class="env-desc">账户失败阈值</div></div>
<div class="env-value">{main.ACCOUNT_FAILURE_THRESHOLD} 次</div>
</div>
<div class="env-var">
<div><div class="env-name">RATE_LIMIT_COOLDOWN_SECONDS</div><div class="env-desc">429限流冷却时间</div></div>
<div class="env-value">{main.RATE_LIMIT_COOLDOWN_SECONDS} 秒</div>
</div>
</div>
</div>
</div>
<div class="card">
<h3>可选变量 <span class="badge badge-optional">OPTIONAL</span></h3>
<div style="margin-top: 12px;">
<div class="env-var">
<div><div class="env-name">API_KEY</div><div class="env-desc">API访问密钥</div></div>
<div class="env-value">{'已设置' if main.API_KEY else '未设置'}</div>
</div>
<div class="env-var">
<div><div class="env-name">BASE_URL</div><div class="env-desc">图片URL生成(推荐设置)</div></div>
<div class="env-value">{'已设置' if main.BASE_URL else '未设置(自动检测)'}</div>
</div>
<div class="env-var">
<div><div class="env-name">PROXY</div><div class="env-desc">代理地址</div></div>
<div class="env-value">{'已设置' if main.PROXY else '未设置'}</div>
</div>
<div class="env-var">
<div><div class="env-name">SESSION_CACHE_TTL_SECONDS</div><div class="env-desc">会话缓存过期时间</div></div>
<div class="env-value">{main.SESSION_CACHE_TTL_SECONDS} 秒</div>
</div>
<div class="env-var">
<div><div class="env-name">LOGO_URL</div><div class="env-desc">Logo URL(公开,为空则不显示)</div></div>
<div class="env-value">{'已设置' if main.LOGO_URL else '未设置'}</div>
</div>
<div class="env-var">
<div><div class="env-name">CHAT_URL</div><div class="env-desc">开始对话链接(公开,为空则不显示)</div></div>
<div class="env-value">{'已设置' if main.CHAT_URL else '未设置'}</div>
</div>
<div class="env-var">
<div><div class="env-name">MODEL_NAME</div><div class="env-desc">模型名称(公开)</div></div>
<div class="env-value">{main.MODEL_NAME}</div>
</div>
<div class="env-var">
<div><div class="env-name">HIDE_HOME_PAGE</div><div class="env-desc">隐藏首页管理面板</div></div>
<div class="env-value">{'已隐藏' if main.HIDE_HOME_PAGE else '未隐藏'}</div>
</div>
</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">服务信息</div>
<div class="grid-3">
<div class="card">
<h3>支持的模型</h3>
<div class="model-grid">
<span class="model-tag">gemini-auto</span>
<span class="model-tag">gemini-2.5-flash</span>
<span class="model-tag">gemini-2.5-pro</span>
<span class="model-tag">gemini-3-flash-preview</span>
<span class="model-tag highlight">gemini-3-pro-preview</span>
</div>
<div class="info-box">
<div class="info-box-title">📸 图片生成说明</div>
<div class="info-box-text">
仅 <code style="background:none;padding:0;color:#0071e3;">gemini-3-pro-preview</code> 支持绘图。<br>
路径: <code>{main.IMAGE_DIR}</code><br>
类型: {'<span style="color: #34c759; font-weight: 600;">持久化(重启保留)</span>' if main.IMAGE_DIR == '/data/images' else '<span style="color: #ff3b30; font-weight: 600;">临时(重启丢失)</span>'}
</div>
</div>
</div>
<div class="card" style="grid-column: span 2;">
<h3>API 端点</h3>
<div class="current-url-row">
<span style="font-size:12px; font-weight:600; color:#0071e3; margin-right:8px;">当前页面:</span>
<code style="background:none; padding:0; color:#1d1d1f;">{current_url}</code>
</div>
<table class="ep-table">
<tr>
<td width="70"><span class="method m-post">POST</span></td>
<td><span class="ep-path">/{main.PATH_PREFIX}/v1/chat/completions</span></td>
<td><span class="ep-desc">OpenAI 兼容对话接口</span></td>
</tr>
<tr>
<td><span class="method m-get">GET</span></td>
<td><span class="ep-path">/{main.PATH_PREFIX}/v1/models</span></td>
<td><span class="ep-desc">获取模型列表</span></td>
</tr>
<tr>
<td><span class="method m-get">GET</span></td>
<td><span class="ep-path">/{main.PATH_PREFIX}/admin</span></td>
<td><span class="ep-desc">管理首页</span></td>
</tr>
<tr>
<td><span class="method m-get">GET</span></td>
<td><span class="ep-path">/{main.PATH_PREFIX}/admin/health?key={{main.ADMIN_KEY}}</span></td>
<td><span class="ep-desc">健康检查 (需 Key)</span></td>
</tr>
<tr>
<td><span class="method m-get">GET</span></td>
<td><span class="ep-path">/{main.PATH_PREFIX}/admin/accounts?key={{main.ADMIN_KEY}}</span></td>
<td><span class="ep-desc">账户状态 JSON (需 Key)</span></td>
</tr>
<tr>
<td><span class="method m-get">GET</span></td>
<td><span class="ep-path">/{main.PATH_PREFIX}/admin/log?key={{main.ADMIN_KEY}}</span></td>
<td><span class="ep-desc">获取日志 JSON (需 Key)</span></td>
</tr>
<tr>
<td><span class="method m-get">GET</span></td>
<td><span class="ep-path">/{main.PATH_PREFIX}/admin/log/html?key={{main.ADMIN_KEY}}</span></td>
<td><span class="ep-desc">日志查看器 HTML (需 Key)</span></td>
</tr>
<tr>
<td><span class="method m-del">DEL</span></td>
<td><span class="ep-path">/{main.PATH_PREFIX}/admin/log?confirm=yes&key={{main.ADMIN_KEY}}</span></td>
<td><span class="ep-desc">清空系统日志 (需 Key)</span></td>
</tr>
<tr>
<td><span class="method m-get">GET</span></td>
<td><span class="ep-path">/public/stats</span></td>
<td><span class="ep-desc">公开统计数据</span></td>
</tr>
<tr>
<td><span class="method m-get">GET</span></td>
<td><span class="ep-path">/public/log</span></td>
<td><span class="ep-desc">公开日志 (JSON, 脱敏)</span></td>
</tr>
<tr>
<td><span class="method m-get">GET</span></td>
<td><span class="ep-path">/public/log/html</span></td>
<td><span class="ep-desc">公开日志查看器 (HTML)</span></td>
</tr>
<tr>
<td><span class="method m-get">GET</span></td>
<td><span class="ep-path">/docs</span></td>
<td><span class="ep-desc">Swagger API 文档</span></td>
</tr>
<tr>
<td><span class="method m-get">GET</span></td>
<td><span class="ep-path">/redoc</span></td>
<td><span class="ep-desc">ReDoc API 文档</span></td>
</tr>
</table>
</div>
</div>
</div>
</div>
<!-- JSON 编辑器模态框 -->
<div id="jsonModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title">编辑账户配置</div>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<textarea id="jsonEditor" class="json-editor" placeholder="在此编辑 JSON 配置..."></textarea>
<div id="jsonError" class="json-error"></div>
<div style="margin-top: 12px; font-size: 12px; color: #6b6b6b;">
<strong>提示:</strong>编辑完成后点击"保存"按钮。JSON 格式错误时无法保存。<br>
配置立即生效。重启后将从环境变量重新加载,建议同步更新 ACCOUNTS_CONFIG。
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal()">取消</button>
<button class="btn btn-primary" onclick="saveConfig()">保存配置</button>
</div>
</div>
</div>
<script>
let currentConfig = null;
// 统一的页面刷新函数(避免缓存)
function refreshPage() {{
window.location.href = window.location.pathname + '?t=' + Date.now();
}}
// 统一的错误处理函数
async function handleApiResponse(response) {{
if (!response.ok) {{
const errorText = await response.text();
let errorMsg;
try {{
const errorJson = JSON.parse(errorText);
errorMsg = errorJson.detail || errorJson.message || errorText;
}} catch {{
errorMsg = errorText;
}}
throw new Error(`HTTP ${{response.status}}: ${{errorMsg}}`);
}}
return await response.json();
}}
async function showEditConfig() {{
const config = await fetch('/{main.PATH_PREFIX}/admin/accounts-config?key={main.ADMIN_KEY}').then(r => r.json());
currentConfig = config.accounts;
const json = JSON.stringify(config.accounts, null, 2);
document.getElementById('jsonEditor').value = json;
document.getElementById('jsonError').classList.remove('show');
document.getElementById('jsonModal').classList.add('show');
// 实时验证 JSON
document.getElementById('jsonEditor').addEventListener('input', validateJSON);
}}
function validateJSON() {{
const editor = document.getElementById('jsonEditor');
const errorDiv = document.getElementById('jsonError');
try {{
JSON.parse(editor.value);
errorDiv.classList.remove('show');
errorDiv.textContent = '';
return true;
}} catch (e) {{
errorDiv.classList.add('show');
errorDiv.textContent = '❌ JSON 格式错误: ' + e.message;
return false;
}}
}}
function closeModal() {{
document.getElementById('jsonModal').classList.remove('show');
document.getElementById('jsonEditor').removeEventListener('input', validateJSON);
}}
async function saveConfig() {{
if (!validateJSON()) {{
alert('JSON 格式错误,请修正后再保存');
return;
}}
const newJson = document.getElementById('jsonEditor').value;
const originalJson = JSON.stringify(currentConfig, null, 2);
if (newJson === originalJson) {{
closeModal();
return;
}}
try {{
const data = JSON.parse(newJson);
const response = await fetch('/{main.PATH_PREFIX}/admin/accounts-config?key={main.ADMIN_KEY}', {{
method: 'PUT',
headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify(data)
}});
const result = await handleApiResponse(response);
alert(`配置已更新!\\n当前账户数: ${{result.account_count}}`);
closeModal();
setTimeout(refreshPage, 1000);
}} catch (error) {{
console.error('保存失败:', error);
alert('更新失败: ' + error.message);
}}
}}
async function deleteAccount(accountId) {{
if (!confirm(`确定删除账户 ${{accountId}}?`)) return;
try {{
const response = await fetch('/{main.PATH_PREFIX}/admin/accounts/' + accountId + '?key={main.ADMIN_KEY}', {{
method: 'DELETE'
}});
const result = await handleApiResponse(response);
alert(`账户已删除!\\n剩余账户数: ${{result.account_count}}`);
refreshPage();
}} catch (error) {{
console.error('删除失败:', error);
alert('删除失败: ' + error.message);
}}
}}
async function disableAccount(accountId) {{
if (!confirm(`确定禁用账户 ${{accountId}}?`)) return;
try {{
const response = await fetch('/{main.PATH_PREFIX}/admin/accounts/' + accountId + '/disable?key={main.ADMIN_KEY}', {{
method: 'PUT'
}});
const result = await handleApiResponse(response);
alert(`账户已禁用!`);
refreshPage();
}} catch (error) {{
console.error('禁用失败:', error);
alert('禁用失败: ' + error.message);
}}
}}
async function enableAccount(accountId) {{
if (!confirm(`确定启用账户 ${{accountId}}?`)) return;
try {{
const response = await fetch('/{main.PATH_PREFIX}/admin/accounts/' + accountId + '/enable?key={main.ADMIN_KEY}', {{
method: 'PUT'
}});
const result = await handleApiResponse(response);
alert(`账户已启用!`);
refreshPage();
}} catch (error) {{
console.error('启用失败:', error);
alert('启用失败: ' + error.message);
}}
}}
// 点击模态框外部关闭
document.getElementById('jsonModal').addEventListener('click', function(e) {{
if (e.target === this) {{
closeModal();
}}
}});
</script>
</body>
</html>
"""
return html_content
async def admin_logs_html(path_prefix: str, key: str = None, authorization: str = Header(None)):
"""返回美化的 HTML 日志查看界面"""
# 动态导入 main 模块的变量(避免循环依赖)
import main
# 验证路径前缀
if path_prefix != main.PATH_PREFIX:
raise HTTPException(404, "Not Found")
# 验证管理员密钥
admin_key = key or (authorization.replace("Bearer ", "") if authorization and authorization.startswith("Bearer ") else authorization)
if admin_key != main.ADMIN_KEY:
raise HTTPException(404, "Not Found")
html_content = r"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>日志查看器</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; overflow: hidden; }
body {
font-family: 'Consolas', 'Monaco', monospace;
background: #fafaf9;
display: flex;
align-items: center;
justify-content: center;
padding: 15px;
}
.container {
width: 100%;
max-width: 1400px;
height: calc(100vh - 30px);
background: white;
border-radius: 16px;
padding: 30px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
display: flex;
flex-direction: column;
}
h1 { color: #1a1a1a; font-size: 22px; font-weight: 600; margin-bottom: 20px; text-align: center; }
.stats {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.stat {
background: #fafaf9;
padding: 12px;
border: 1px solid #e5e5e5;
border-radius: 8px;
text-align: center;
transition: all 0.15s ease;
}
.stat:hover { border-color: #d4d4d4; }
.stat-label { color: #6b6b6b; font-size: 11px; margin-bottom: 4px; }
.stat-value { color: #1a1a1a; font-size: 18px; font-weight: 600; }
.controls {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.controls input, .controls select, .controls button {
padding: 6px 10px;
border: 1px solid #e5e5e5;
border-radius: 8px;
font-size: 13px;
}
.controls select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg width='12' height='12' viewBox='0 0 12 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M3 5L6 8L9 5' stroke='%236b6b6b' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 32px;
}
.controls input[type="text"] { flex: 1; min-width: 150px; }
.controls button {
background: #1a73e8;
color: white;
border: none;
cursor: pointer;
font-weight: 500;
transition: background 0.15s ease;
display: flex;
align-items: center;
gap: 6px;
}
.controls button:hover { background: #1557b0; }
.controls button.danger { background: #dc2626; }
.controls button.danger:hover { background: #b91c1c; }
.controls button svg { flex-shrink: 0; }
.log-container {
flex: 1;
background: #fafaf9;
border: 1px solid #e5e5e5;
border-radius: 8px;
padding: 12px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(0,0,0,0.15) transparent;
}
/* Webkit 滚动条样式 - 更窄且不占位 */
.log-container::-webkit-scrollbar {
width: 4px;
}
.log-container::-webkit-scrollbar-track {
background: transparent;
}
.log-container::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.15);
border-radius: 2px;
}
.log-container::-webkit-scrollbar-thumb:hover {
background: rgba(0,0,0,0.3);
}
.log-entry {
padding: 8px 10px;
margin-bottom: 4px;
background: white;
border-radius: 6px;
border: 1px solid #e5e5e5;
font-size: 12px;
color: #1a1a1a;
display: flex;
align-items: center;
gap: 8px;
word-break: break-word;
}
.log-entry > div:first-child {
display: flex;
align-items: center;
gap: 8px;
}
.log-message {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.log-entry:hover { border-color: #d4d4d4; }
.log-time { color: #6b6b6b; }
.log-level {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
}
.log-level::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
}
.log-level.INFO { background: #e3f2fd; color: #1976d2; }
.log-level.INFO::before { background: #1976d2; }
.log-level.WARNING { background: #fff3e0; color: #f57c00; }
.log-level.WARNING::before { background: #f57c00; }
.log-level.ERROR { background: #ffebee; color: #d32f2f; }
.log-level.ERROR::before { background: #d32f2f; }
.log-level.DEBUG { background: #f3e5f5; color: #7b1fa2; }
.log-level.DEBUG::before { background: #7b1fa2; }
.log-group {
margin-bottom: 8px;
border: 1px solid #e5e5e5;
border-radius: 8px;
background: white;
}
.log-group-header {
padding: 10px 12px;
background: #f9f9f9;
border-radius: 8px 8px 0 0;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: background 0.15s ease;
}
.log-group-header:hover {
background: #f0f0f0;
}
.log-group-content {
padding: 8px;
}
.log-group .log-entry {
margin-bottom: 4px;
}
.log-group .log-entry:last-child {
margin-bottom: 0;
}
.toggle-icon {
display: inline-block;
transition: transform 0.2s ease;
}
.toggle-icon.collapsed {
transform: rotate(-90deg);
}
@media (max-width: 768px) {
body { padding: 0; }
.container { padding: 15px; height: 100vh; border-radius: 0; max-width: 100%; }
h1 { font-size: 18px; margin-bottom: 12px; }
.stats { grid-template-columns: repeat(3, 1fr); gap: 8px; }
.stat { padding: 8px; }
.controls { gap: 6px; }
.controls input, .controls select { min-height: 38px; }
.controls select { flex: 0 0 auto; }
.controls input[type="text"] { flex: 1 1 auto; min-width: 80px; }
.controls input[type="number"] { flex: 0 0 60px; }
.controls button { padding: 10px 8px; font-size: 12px; flex: 1 1 22%; justify-content: center; min-height: 38px; }
.log-entry {
font-size: 12px;
padding: 10px;
gap: 8px;
flex-direction: column;
align-items: flex-start;
}
.log-entry > div:first-child {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
flex-wrap: wrap;
}
.log-time { font-size: 11px; color: #9e9e9e; }
.log-level { font-size: 10px; }
.log-message {
width: 100%;
white-space: normal;
word-break: break-word;
line-height: 1.5;
margin-top: 4px;
}
}
</style>
</head>
<body>
<div class="container">
<h1>Gemini API 日志查看器</h1>
<div class="stats">
<div class="stat">
<div class="stat-label">总数</div>
<div class="stat-value" id="total-count">-</div>
</div>
<div class="stat">
<div class="stat-label">对话</div>
<div class="stat-value" id="chat-count">-</div>
</div>
<div class="stat">
<div class="stat-label">INFO</div>
<div class="stat-value" id="info-count">-</div>
</div>
<div class="stat">
<div class="stat-label">WARNING</div>
<div class="stat-value" id="warning-count">-</div>
</div>
<div class="stat">
<div class="stat-label">ERROR</div>
<div class="stat-value" id="error-count">-</div>
</div>
<div class="stat">
<div class="stat-label">更新</div>
<div class="stat-value" id="last-update" style="font-size: 11px;">-</div>
</div>
</div>
<div class="controls">
<select id="level-filter">
<option value="">全部</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
</select>
<input type="text" id="search-input" placeholder="搜索...">
<input type="number" id="limit-input" value="1500" min="10" max="3000" step="100" style="width: 80px;">
<button onclick="loadLogs()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
查询
</button>
<button onclick="exportJSON()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
导出
</button>
<button id="auto-refresh-btn" onclick="toggleAutoRefresh()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
</svg>
自动刷新
</button>
<button onclick="clearAllLogs()" class="danger">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
清空
</button>
</div>
<div class="log-container" id="log-container">
<div style="color: #6b6b6b;">正在加载...</div>
</div>
</div>
<script>
let autoRefreshTimer = null;
async function loadLogs() {
const level = document.getElementById('level-filter').value;
const search = document.getElementById('search-input').value;
const limit = document.getElementById('limit-input').value;
// 从当前 URL 获取 key 参数
const urlParams = new URLSearchParams(window.location.search);
const key = urlParams.get('key');
// 构建 API URL(使用当前路径的前缀)
const pathPrefix = window.location.pathname.split('/')[1];
let url = `/${pathPrefix}/admin/log?limit=${limit}`;
if (key) url += `&key=${key}`;
if (level) url += `&level=${level}`;
if (search) url += `&search=${encodeURIComponent(search)}`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
if (data && data.logs) {
displayLogs(data.logs);
updateStats(data.stats);
document.getElementById('last-update').textContent = new Date().toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'});
} else {
throw new Error('Invalid data format');
}
} catch (error) {
document.getElementById('log-container').innerHTML = '<div class="log-entry ERROR">加载失败: ' + error.message + '</div>';
}
}
function updateStats(stats) {
document.getElementById('total-count').textContent = stats.memory.total;
document.getElementById('info-count').textContent = stats.memory.by_level.INFO || 0;
document.getElementById('warning-count').textContent = stats.memory.by_level.WARNING || 0;
const errorCount = document.getElementById('error-count');
errorCount.textContent = stats.memory.by_level.ERROR || 0;
if (stats.errors && stats.errors.count > 0) errorCount.style.color = '#dc2626';
document.getElementById('chat-count').textContent = stats.chat_count || 0;
}
// 分类颜色配置(提取到外部避免重复定义)
const CATEGORY_COLORS = {
'SYSTEM': '#9e9e9e',
'CONFIG': '#607d8b',
'LOG': '#9e9e9e',
'AUTH': '#4caf50',
'SESSION': '#00bcd4',
'FILE': '#ff9800',
'CHAT': '#2196f3',
'API': '#8bc34a',
'CACHE': '#9c27b0',
'ACCOUNT': '#f44336',
'MULTI': '#673ab7'
};
// 账户颜色配置(提取到外部避免重复定义)
const ACCOUNT_COLORS = {
'account_1': '#9c27b0',
'account_2': '#e91e63',
'account_3': '#00bcd4',
'account_4': '#4caf50',
'account_5': '#ff9800'
};
function getCategoryColor(category) {
return CATEGORY_COLORS[category] || '#757575';
}
function getAccountColor(accountId) {
return ACCOUNT_COLORS[accountId] || '#757575';
}
function displayLogs(logs) {
const container = document.getElementById('log-container');
if (logs.length === 0) {
container.innerHTML = '<div class="log-entry">暂无日志</div>';
return;
}
// 按请求ID分组
const groups = {};
const ungrouped = [];
logs.forEach(log => {
const msg = escapeHtml(log.message);
const reqMatch = msg.match(/\[req_([a-z0-9]+)\]/);
if (reqMatch) {
const reqId = reqMatch[1];
if (!groups[reqId]) {
groups[reqId] = [];
}
groups[reqId].push(log);
} else {
ungrouped.push(log);
}
});
// 渲染分组
let html = '';
// 先渲染未分组的日志
ungrouped.forEach(log => {
html += renderLogEntry(log);
});
// 读取折叠状态
const foldState = JSON.parse(localStorage.getItem('log-fold-state') || '{}');
// 按请求ID分组渲染(最新的组在下面)
Object.keys(groups).forEach(reqId => {
const groupLogs = groups[reqId];
const firstLog = groupLogs[0];
const lastLog = groupLogs[groupLogs.length - 1];
// 判断状态
let status = 'in_progress';
let statusColor = '#ff9800';
let statusText = '进行中';
if (lastLog.message.includes('响应完成') || lastLog.message.includes('非流式响应完成')) {
status = 'success';
statusColor = '#4caf50';
statusText = '成功';
} else if (lastLog.level === 'ERROR' || lastLog.message.includes('失败')) {
status = 'error';
statusColor = '#f44336';
statusText = '失败';
} else {
// 检查超时(最后日志超过 5 分钟)
const lastLogTime = new Date(lastLog.time);
const now = new Date();
const diffMinutes = (now - lastLogTime) / 1000 / 60;
if (diffMinutes > 5) {
status = 'timeout';
statusColor = '#ffc107';
statusText = '超时';
}
}
// 提取账户ID和模型
const accountMatch = firstLog.message.match(/\[account_(\d+)\]/);
const modelMatch = firstLog.message.match(/收到请求: ([^ ]+)/);
const accountId = accountMatch ? `account_${accountMatch[1]}` : '';
const model = modelMatch ? modelMatch[1] : '';
// 检查折叠状态
const isCollapsed = foldState[reqId] === true;
const contentStyle = isCollapsed ? 'style="display: none;"' : '';
const iconClass = isCollapsed ? 'class="toggle-icon collapsed"' : 'class="toggle-icon"';
html += `
<div class="log-group" data-req-id="${reqId}">
<div class="log-group-header" onclick="toggleGroup('${reqId}')">
<span style="color: ${statusColor}; font-weight: 600; font-size: 11px;">⬤ ${statusText}</span>
<span style="color: #666; font-size: 11px; margin-left: 8px;">req_${reqId}</span>
${accountId ? `<span style="color: ${getAccountColor(accountId)}; font-size: 11px; margin-left: 8px;">${accountId}</span>` : ''}
${model ? `<span style="color: #999; font-size: 11px; margin-left: 8px;">${model}</span>` : ''}
<span style="color: #999; font-size: 11px; margin-left: 8px;">${groupLogs.length}条日志</span>
<span ${iconClass} style="margin-left: auto; color: #999;">▼</span>
</div>
<div class="log-group-content" ${contentStyle}>
${groupLogs.map(log => renderLogEntry(log)).join('')}
</div>
</div>
`;
});
container.innerHTML = html;
// 自动滚动到底部,显示最新日志
container.scrollTop = container.scrollHeight;
}
function renderLogEntry(log) {
const msg = escapeHtml(log.message);
let displayMsg = msg;
let categoryTags = [];
let accountId = null;
// 解析所有标签:[CATEGORY1] [CATEGORY2] [account_X] [req_X] message
let remainingMsg = msg;
const tagRegex = /^\[([A-Z_a-z0-9]+)\]/;
while (true) {
const match = remainingMsg.match(tagRegex);
if (!match) break;
const tag = match[1];
remainingMsg = remainingMsg.substring(match[0].length).trim();
// 跳过req_标签(已在组头部显示)
if (tag.startsWith('req_')) {
continue;
}
// 判断是否为账户ID
else if (tag.startsWith('account_')) {
accountId = tag;
} else {
// 普通分类标签
categoryTags.push(tag);
}
}
displayMsg = remainingMsg;
// 生成分类标签HTML
const categoryTagsHtml = categoryTags.map(cat =>
`<span class="log-category" style="background: ${getCategoryColor(cat)}; color: white; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 2px;">${cat}</span>`
).join('');
// 生成账户标签HTML
const accountTagHtml = accountId
? `<span style="color: ${getAccountColor(accountId)}; font-size: 11px; font-weight: 600; margin-left: 2px;">${accountId}</span>`
: '';
return `
<div class="log-entry ${log.level}">
<div>
<span class="log-time">${log.time}</span>
<span class="log-level ${log.level}">${log.level}</span>
${categoryTagsHtml}
${accountTagHtml}
</div>
<div class="log-message">${displayMsg}</div>
</div>
`;
}
function toggleGroup(reqId) {
const group = document.querySelector(`.log-group[data-req-id="${reqId}"]`);
const content = group.querySelector('.log-group-content');
const icon = group.querySelector('.toggle-icon');
const isCollapsed = content.style.display === 'none';
if (isCollapsed) {
content.style.display = 'block';
icon.classList.remove('collapsed');
} else {
content.style.display = 'none';
icon.classList.add('collapsed');
}
// 保存折叠状态到 localStorage
const foldState = JSON.parse(localStorage.getItem('log-fold-state') || '{}');
foldState[reqId] = !isCollapsed;
localStorage.setItem('log-fold-state', JSON.stringify(foldState));
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function exportJSON() {
try {
const urlParams = new URLSearchParams(window.location.search);
const key = urlParams.get('key');
const pathPrefix = window.location.pathname.split('/')[1];
let url = `/${pathPrefix}/admin/log?limit=3000`;
if (key) url += `&key=${key}`;
const response = await fetch(url);
const data = await response.json();
const blob = new Blob([JSON.stringify({exported_at: new Date().toISOString(), logs: data.logs}, null, 2)], {type: 'application/json'});
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = 'logs_' + new Date().toISOString().slice(0, 19).replace(/:/g, '-') + '.json';
a.click();
URL.revokeObjectURL(blobUrl);
alert('导出成功');
} catch (error) {
alert('导出失败: ' + error.message);
}
}
async function clearAllLogs() {
if (!confirm('确定清空所有日志?')) return;
try {
const urlParams = new URLSearchParams(window.location.search);
const key = urlParams.get('key');
const pathPrefix = window.location.pathname.split('/')[1];
let url = `/${pathPrefix}/admin/log?confirm=yes`;
if (key) url += `&key=${key}`;
const response = await fetch(url, {method: 'DELETE'});
if (response.ok) {
alert('已清空');
loadLogs();
} else {
alert('清空失败');
}
} catch (error) {
alert('清空失败: ' + error.message);
}
}
let autoRefreshEnabled = true;
function toggleAutoRefresh() {
autoRefreshEnabled = !autoRefreshEnabled;
const btn = document.getElementById('auto-refresh-btn');
if (autoRefreshEnabled) {
btn.style.background = '#1a73e8';
autoRefreshTimer = setInterval(loadLogs, 5000);
} else {
btn.style.background = '#6b6b6b';
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
}
}
document.addEventListener('DOMContentLoaded', () => {
loadLogs();
autoRefreshTimer = setInterval(loadLogs, 5000);
document.getElementById('search-input').addEventListener('keypress', (e) => {
if (e.key === 'Enter') loadLogs();
});
document.getElementById('level-filter').addEventListener('change', loadLogs);
document.getElementById('limit-input').addEventListener('change', loadLogs);
});
</script>
</body>
</html>
"""
return HTMLResponse(content=html_content)
async def get_public_logs_html():
"""公开的脱敏日志查看器"""
# 动态导入 main 模块的变量(避免循环依赖)
import main
html_content = r"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>服务状态</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; overflow: hidden; }
body {
font-family: 'Consolas', 'Monaco', monospace;
background: #fafaf9;
display: flex;
align-items: center;
justify-content: center;
padding: 15px;
}
.container {
width: 100%;
max-width: 1200px;
height: calc(100vh - 30px);
background: white;
border-radius: 16px;
padding: 30px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
display: flex;
flex-direction: column;
}
h1 {
color: #1a1a1a;
font-size: 22px;
font-weight: 600;
margin-bottom: 20px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
h1 img {
width: 32px;
height: 32px;
border-radius: 8px;
}
.info-bar {
background: #f9f9f9;
border: 1px solid #e5e5e5;
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 16px;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
.info-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #6b6b6b;
}
.info-item strong { color: #1a1a1a; }
.info-item a {
color: #1a73e8;
text-decoration: none;
font-weight: 500;
}
.info-item a:hover { text-decoration: underline; }
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.stat {
background: #fafaf9;
padding: 12px;
border: 1px solid #e5e5e5;
border-radius: 8px;
text-align: center;
transition: all 0.15s ease;
}
.stat:hover { border-color: #d4d4d4; }
.stat-label { color: #6b6b6b; font-size: 11px; margin-bottom: 4px; }
.stat-value { color: #1a1a1a; font-size: 18px; font-weight: 600; }
.log-container {
flex: 1;
background: #fafaf9;
border: 1px solid #e5e5e5;
border-radius: 8px;
padding: 12px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(0,0,0,0.15) transparent;
}
.log-container::-webkit-scrollbar { width: 4px; }
.log-container::-webkit-scrollbar-track { background: transparent; }
.log-container::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.15);
border-radius: 2px;
}
.log-container::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.3); }
.log-group {
margin-bottom: 8px;
border: 1px solid #e5e5e5;
border-radius: 8px;
background: white;
}
.log-group-header {
padding: 10px 12px;
background: #f9f9f9;
border-radius: 8px 8px 0 0;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: background 0.15s ease;
}
.log-group-header:hover { background: #f0f0f0; }
.log-group-content { padding: 8px; }
.log-entry {
padding: 8px 10px;
margin-bottom: 4px;
background: white;
border: 1px solid #e5e5e5;
border-radius: 6px;
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
transition: all 0.15s ease;
}
.log-entry:hover { border-color: #d4d4d4; }
.log-time { color: #6b6b6b; font-size: 12px; min-width: 140px; }
.log-status {
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
min-width: 60px;
text-align: center;
}
.status-success { background: #d1fae5; color: #065f46; }
.status-error { background: #fee2e2; color: #991b1b; }
.status-in_progress { background: #fef3c7; color: #92400e; }
.status-timeout { background: #fef3c7; color: #92400e; }
.log-info { flex: 1; color: #374151; }
.toggle-icon {
display: inline-block;
transition: transform 0.2s ease;
}
.toggle-icon.collapsed { transform: rotate(-90deg); }
.subtitle-public {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
@media (max-width: 768px) {
body { padding: 0; }
.container {
padding: 15px;
height: 100vh;
border-radius: 0;
max-width: 100%;
}
h1 { font-size: 18px; margin-bottom: 12px; }
.subtitle-public {
flex-direction: column;
gap: 6px;
}
.subtitle-public span {
font-size: 11px;
line-height: 1.6;
}
.subtitle-public a {
font-size: 12px;
font-weight: 600;
}
.info-bar {
padding: 10px 12px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.info-item { font-size: 12px; }
.stats {
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-bottom: 12px;
}
.stat { padding: 8px; }
.stat-label { font-size: 10px; }
.stat-value { font-size: 16px; }
.log-container { padding: 8px; }
.log-group { margin-bottom: 6px; }
.log-group-header {
padding: 8px 10px;
font-size: 11px;
flex-wrap: wrap;
}
.log-group-header span { font-size: 10px !important; }
.log-entry {
padding: 6px 8px;
font-size: 11px;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.log-time {
min-width: auto;
font-size: 10px;
}
.log-info {
font-size: 11px;
word-break: break-word;
}
}
</style>
</head>
<body>
<div class="container">
<h1>
""" + (f'<img src="{main.LOGO_URL}" alt="Logo">' if main.LOGO_URL else '') + r"""
Gemini服务状态
</h1>
<div style="text-align: center; color: #999; font-size: 12px; margin-bottom: 16px;" class="subtitle-public">
<span>展示最近1000条对话日志 · 每5秒自动更新</span>
""" + (f'<a href="{main.CHAT_URL}" target="_blank" style="color: #1a73e8; text-decoration: none;">开始对话</a>' if main.CHAT_URL else '<span style="color: #999;">开始对话</span>') + r"""
</div>
<div class="stats">
<div class="stat">
<div class="stat-label">总访问</div>
<div class="stat-value" id="stat-visitors">0</div>
</div>
<div class="stat">
<div class="stat-label">每分钟请求</div>
<div class="stat-value" id="stat-load">0</div>
</div>
<div class="stat">
<div class="stat-label">平均响应</div>
<div class="stat-value" id="stat-avg-time">-</div>
</div>
<div class="stat">
<div class="stat-label">成功率</div>
<div class="stat-value" id="stat-success-rate" style="color: #10b981;">-</div>
</div>
<div class="stat">
<div class="stat-label">对话次数</div>
<div class="stat-value" id="stat-total">0</div>
</div>
<div class="stat">
<div class="stat-label">成功</div>
<div class="stat-value" id="stat-success" style="color: #10b981;">0</div>
</div>
<div class="stat">
<div class="stat-label">失败</div>
<div class="stat-value" id="stat-error" style="color: #ef4444;">0</div>
</div>
<div class="stat">
<div class="stat-label">更新时间</div>
<div class="stat-value" id="stat-update-time" style="font-size: 14px; color: #6b6b6b;">--:--</div>
</div>
</div>
<div class="log-container" id="log-container">
<div style="text-align: center; color: #999; padding: 20px;">加载中...</div>
</div>
</div>
<script>
async function loadData() {
try {
// 并行加载日志和统计数据
const [logsResponse, statsResponse] = await Promise.all([
fetch('/public/log?limit=1000'),
fetch('/public/stats')
]);
const logsData = await logsResponse.json();
const statsData = await statsResponse.json();
displayLogs(logsData.logs);
updateStats(logsData.logs, statsData);
} catch (error) {
document.getElementById('log-container').innerHTML = '<div style="text-align: center; color: #f44336; padding: 20px;">加载失败: ' + error.message + '</div>';
}
}
function displayLogs(logs) {
const container = document.getElementById('log-container');
if (logs.length === 0) {
container.innerHTML = '<div style="text-align: center; color: #999; padding: 20px;">暂无日志</div>';
return;
}
// 读取折叠状态
const foldState = JSON.parse(localStorage.getItem('public-log-fold-state') || '{}');
let html = '';
logs.forEach(log => {
const reqId = log.request_id;
// 状态图标和颜色
let statusColor = '#ff9800';
let statusText = '进行中';
if (log.status === 'success') {
statusColor = '#4caf50';
statusText = '成功';
} else if (log.status === 'error') {
statusColor = '#f44336';
statusText = '失败';
} else if (log.status === 'timeout') {
statusColor = '#ffc107';
statusText = '超时';
}
// 检查折叠状态
const isCollapsed = foldState[reqId] === true;
const contentStyle = isCollapsed ? 'style="display: none;"' : '';
const iconClass = isCollapsed ? 'class="toggle-icon collapsed"' : 'class="toggle-icon"';
// 构建事件列表
let eventsHtml = '';
log.events.forEach(event => {
let eventClass = 'log-entry';
let eventLabel = '';
if (event.type === 'start') {
eventLabel = '<span style="color: #2563eb; font-weight: 600;">开始对话</span>';
} else if (event.type === 'select') {
eventLabel = '<span style="color: #8b5cf6; font-weight: 600;">选择</span>';
} else if (event.type === 'retry') {
eventLabel = '<span style="color: #f59e0b; font-weight: 600;">重试</span>';
} else if (event.type === 'switch') {
eventLabel = '<span style="color: #06b6d4; font-weight: 600;">切换</span>';
} else if (event.type === 'complete') {
if (event.status === 'success') {
eventLabel = '<span style="color: #10b981; font-weight: 600;">完成</span>';
} else if (event.status === 'error') {
eventLabel = '<span style="color: #ef4444; font-weight: 600;">失败</span>';
} else if (event.status === 'timeout') {
eventLabel = '<span style="color: #f59e0b; font-weight: 600;">超时</span>';
}
}
eventsHtml += `
<div class="${eventClass}">
<div class="log-time">${event.time}</div>
<div style="min-width: 60px;">${eventLabel}</div>
<div class="log-info">${event.content}</div>
</div>
`;
});
html += `
<div class="log-group" data-req-id="${reqId}">
<div class="log-group-header" onclick="toggleGroup('${reqId}')">
<span style="color: ${statusColor}; font-weight: 600; font-size: 11px;">⬤ ${statusText}</span>
<span style="color: #666; font-size: 11px; margin-left: 8px;">req_${reqId}</span>
<span style="color: #999; font-size: 11px; margin-left: 8px;">${log.events.length}条事件</span>
<span ${iconClass} style="margin-left: auto; color: #999;">▼</span>
</div>
<div class="log-group-content" ${contentStyle}>
${eventsHtml}
</div>
</div>
`;
});
container.innerHTML = html;
}
function updateStats(logs, statsData) {
const total = logs.length;
const successLogs = logs.filter(log => log.status === 'success');
const success = successLogs.length;
const error = logs.filter(log => log.status === 'error').length;
// 计算平均响应时间
let avgTime = '-';
if (success > 0) {
let totalDuration = 0;
let count = 0;
successLogs.forEach(log => {
log.events.forEach(event => {
if (event.type === 'complete' && event.content.includes('耗时')) {
const match = event.content.match(/([\d.]+)s/);
if (match) {
totalDuration += parseFloat(match[1]);
count++;
}
}
});
});
if (count > 0) {
avgTime = (totalDuration / count).toFixed(1) + 's';
}
}
// 计算成功率
const totalCompleted = success + error;
const successRate = totalCompleted > 0 ? ((success / totalCompleted) * 100).toFixed(1) + '%' : '-';
// 更新日志统计
document.getElementById('stat-total').textContent = total;
document.getElementById('stat-success').textContent = success;
document.getElementById('stat-error').textContent = error;
document.getElementById('stat-success-rate').textContent = successRate;
document.getElementById('stat-avg-time').textContent = avgTime;
// 更新全局统计
document.getElementById('stat-visitors').textContent = statsData.total_visitors;
// 更新负载状态(带颜色)
const loadElement = document.getElementById('stat-load');
loadElement.textContent = statsData.requests_per_minute;
loadElement.style.color = statsData.load_color;
// 更新时间
document.getElementById('stat-update-time').textContent = new Date().toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit', second: '2-digit'});
}
function toggleGroup(reqId) {
const group = document.querySelector(`.log-group[data-req-id="${reqId}"]`);
const content = group.querySelector('.log-group-content');
const icon = group.querySelector('.toggle-icon');
const isCollapsed = content.style.display === 'none';
if (isCollapsed) {
content.style.display = 'block';
icon.classList.remove('collapsed');
} else {
content.style.display = 'none';
icon.classList.add('collapsed');
}
// 保存折叠状态
const foldState = JSON.parse(localStorage.getItem('public-log-fold-state') || '{}');
foldState[reqId] = !isCollapsed;
localStorage.setItem('public-log-fold-state', JSON.stringify(foldState));
}
// 初始加载
loadData();
// 自动刷新(每5秒)
setInterval(loadData, 5000);
</script>
</body>
</html>
"""
return HTMLResponse(content=html_content)