xiaoyukkkk commited on
Commit
a9295a6
·
verified ·
1 Parent(s): 467090a

Upload 3 files

Browse files
Files changed (3) hide show
  1. core/__init__.py +6 -0
  2. core/auth.py +122 -0
  3. core/templates.py +1910 -0
core/__init__.py CHANGED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """
2
+ Core 模块
3
+ 包含认证、模板生成等核心功能
4
+ """
5
+
6
+ __all__ = ['auth', 'templates']
core/auth.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 认证相关装饰器和工具函数
3
+ """
4
+ from functools import wraps
5
+ from fastapi import HTTPException, Header
6
+ from typing import Optional
7
+
8
+
9
+ def extract_admin_key(key: Optional[str] = None, authorization: Optional[str] = None) -> Optional[str]:
10
+ """
11
+ 统一提取管理员密钥
12
+
13
+ 优先级:
14
+ 1. URL 参数 ?key=xxx
15
+ 2. Authorization Header (支持 Bearer token 格式)
16
+
17
+ Args:
18
+ key: URL 查询参数中的密钥
19
+ authorization: Authorization Header 中的密钥
20
+
21
+ Returns:
22
+ 提取到的密钥,如果都为空则返回 None
23
+ """
24
+ if key:
25
+ return key
26
+ if authorization:
27
+ # 支持 Bearer token 格式
28
+ return authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
29
+ return None
30
+
31
+
32
+ def require_path_prefix(path_prefix_value: str):
33
+ """
34
+ 验证路径前缀的装饰器
35
+
36
+ Args:
37
+ path_prefix_value: 正确的路径前缀值
38
+
39
+ Returns:
40
+ 装饰器函数
41
+
42
+ Example:
43
+ @app.get("/{path_prefix}/admin")
44
+ @require_path_prefix(PATH_PREFIX)
45
+ async def admin_home(path_prefix: str, ...):
46
+ ...
47
+ """
48
+ def decorator(func):
49
+ @wraps(func)
50
+ async def wrapper(*args, path_prefix: str, **kwargs):
51
+ if path_prefix != path_prefix_value:
52
+ # 返回 404 而不是 401,假装端点不存在(安全性考虑)
53
+ raise HTTPException(404, "Not Found")
54
+ return await func(*args, path_prefix=path_prefix, **kwargs)
55
+ return wrapper
56
+ return decorator
57
+
58
+
59
+ def require_admin_auth(admin_key_value: str):
60
+ """
61
+ 验证管理员权限的装饰器
62
+
63
+ 支持两种认证方式:
64
+ 1. URL 参数:?key=xxx
65
+ 2. Authorization Header:Bearer xxx 或直接传密钥
66
+
67
+ Args:
68
+ admin_key_value: 正确的管理员密钥
69
+
70
+ Returns:
71
+ 装饰器函数
72
+
73
+ Example:
74
+ @app.get("/{path_prefix}/admin")
75
+ @require_admin_auth(ADMIN_KEY)
76
+ async def admin_home(key: str = None, authorization: str = Header(None), ...):
77
+ ...
78
+ """
79
+ def decorator(func):
80
+ @wraps(func)
81
+ async def wrapper(*args, key: str = None, authorization: str = Header(None), **kwargs):
82
+ admin_key = extract_admin_key(key, authorization)
83
+ if admin_key != admin_key_value:
84
+ # 返回 404 而不是 401,假装端点不存在(安全性考虑)
85
+ raise HTTPException(404, "Not Found")
86
+ return await func(*args, key=key, authorization=authorization, **kwargs)
87
+ return wrapper
88
+ return decorator
89
+
90
+
91
+ def require_path_and_admin(path_prefix_value: str, admin_key_value: str):
92
+ """
93
+ 同时验证路径前缀和管理员权限的组合装饰器
94
+
95
+ Args:
96
+ path_prefix_value: 正确的路径前缀值
97
+ admin_key_value: 正确的管理员密钥
98
+
99
+ Returns:
100
+ 装饰器函数
101
+
102
+ Example:
103
+ @app.get("/{path_prefix}/admin")
104
+ @require_path_and_admin(PATH_PREFIX, ADMIN_KEY)
105
+ async def admin_home(path_prefix: str, key: str = None, authorization: str = Header(None), ...):
106
+ ...
107
+ """
108
+ def decorator(func):
109
+ @wraps(func)
110
+ async def wrapper(*args, path_prefix: str, key: str = None, authorization: str = Header(None), **kwargs):
111
+ # 验证路径前缀
112
+ if path_prefix != path_prefix_value:
113
+ raise HTTPException(404, "Not Found")
114
+
115
+ # 验证管理员密钥
116
+ admin_key = extract_admin_key(key, authorization)
117
+ if admin_key != admin_key_value:
118
+ raise HTTPException(404, "Not Found")
119
+
120
+ return await func(*args, path_prefix=path_prefix, key=key, authorization=authorization, **kwargs)
121
+ return wrapper
122
+ return decorator
core/templates.py ADDED
@@ -0,0 +1,1910 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 模板生成模块
3
+ 此模块包含 HTML 页面生成函数,用于管理界面和日志查看器
4
+
5
+ 注意:
6
+ - 这些函数需要通过 import main 动态获取全局变量
7
+ - 避免在模块顶层导入 main,防止循环依赖
8
+ """
9
+
10
+ from fastapi import Request, Header, HTTPException
11
+ from fastapi.responses import HTMLResponse
12
+
13
+
14
+ def generate_admin_html(request: Request, multi_account_mgr, show_hide_tip: bool = False) -> str:
15
+ """生成管理页面HTML - 端点带Key参数完整版"""
16
+ # 动态导入 main 模块的变量(避免循环依赖)
17
+ import main
18
+
19
+ # 获取当前页面的完整URL
20
+ current_url = main.get_base_url(request)
21
+
22
+ # 获取错误统计
23
+ error_count = 0
24
+ with main.log_lock:
25
+ for log in main.log_buffer:
26
+ if log.get("level") in ["ERROR", "CRITICAL"]:
27
+ error_count += 1
28
+
29
+ # --- 1. 构建提示信息 ---
30
+ hide_tip = ""
31
+ if show_hide_tip:
32
+ hide_tip = """
33
+ <div class="alert alert-info">
34
+ <div class="alert-icon">💡</div>
35
+ <div class="alert-content">
36
+ <strong>提示</strong>:此页面默认在首页显示。如需隐藏,请设置环境变量:<br>
37
+ <code style="margin-top:4px; display:inline-block;">main.HIDE_HOME_PAGE=true</code>
38
+ </div>
39
+ </div>
40
+ """
41
+
42
+ api_key_status = ""
43
+ if main.API_KEY:
44
+ api_key_status = """
45
+ <div class="alert alert-success">
46
+ <div class="alert-icon">🔒</div>
47
+ <div class="alert-content">
48
+ <strong>安全模式已启用</strong>
49
+ <div class="alert-desc">请求 Header 需携带 Authorization 密钥。</div>
50
+ </div>
51
+ </div>
52
+ """
53
+ else:
54
+ api_key_status = """
55
+ <div class="alert alert-warning">
56
+ <div class="alert-icon">⚠️</div>
57
+ <div class="alert-content">
58
+ <strong>API Key 未设置</strong>
59
+ <div class="alert-desc">API 当前允许公开访问,建议配置 main.API_KEY。</div>
60
+ </div>
61
+ </div>
62
+ """
63
+
64
+ error_alert = ""
65
+ if error_count > 0:
66
+ error_alert = f"""
67
+ <div class="alert alert-error">
68
+ <div class="alert-icon">🚨</div>
69
+ <div class="alert-content">
70
+ <strong>检测到 {error_count} 条错误日志</strong>
71
+ <a href="/public/log/html" class="alert-link">查看详情 &rarr;</a>
72
+ </div>
73
+ </div>
74
+ """
75
+
76
+ # --- 2. 构建账户卡片 ---
77
+ accounts_html = ""
78
+ for account_id, account_manager in multi_account_mgr.accounts.items():
79
+ config = account_manager.config
80
+ remaining_hours = config.get_remaining_hours()
81
+ status_text, status_color, expire_display = main.format_account_expiration(remaining_hours)
82
+
83
+ is_avail = account_manager.is_available
84
+ dot_color = "#34c759" if is_avail else "#ff3b30"
85
+ dot_title = "可用" if is_avail else "不可用"
86
+
87
+ accounts_html += f"""
88
+ <div class="card account-card">
89
+ <div class="acc-header">
90
+ <div class="acc-title">
91
+ <span class="status-dot" style="background-color: {dot_color};" title="{dot_title}"></span>
92
+ {config.account_id}
93
+ </div>
94
+ <div style="display: flex; align-items: center; gap: 8px;">
95
+ <span class="acc-status-text" style="color: {status_color}">{status_text}</span>
96
+ <button onclick="deleteAccount('{config.account_id}')" class="delete-btn" title="删除账户">删除</button>
97
+ </div>
98
+ </div>
99
+ <div class="acc-body">
100
+ <div class="acc-row">
101
+ <span>过期时间</span>
102
+ <span class="font-mono">{config.expires_at or '未设置'}</span>
103
+ </div>
104
+ <div class="acc-row">
105
+ <span>剩余时长</span>
106
+ <span style="color: {status_color}; font-weight: 600;">{expire_display}</span>
107
+ </div>
108
+ </div>
109
+ </div>
110
+ """
111
+
112
+ # --- 3. 构建 HTML ---
113
+ html_content = f"""
114
+ <!DOCTYPE html>
115
+ <html lang="zh-CN">
116
+ <head>
117
+ <meta charset="utf-8">
118
+ <meta name="viewport" content="width=device-width, initial-scale=1">
119
+ <title>系统管理 - Gemini Business API</title>
120
+ <style>
121
+ :root {{
122
+ --bg-body: #f5f5f7;
123
+ --text-main: #1d1d1f;
124
+ --text-sec: #86868b;
125
+ --border: #d2d2d7;
126
+ --border-light: #e5e5ea;
127
+ --blue: #0071e3;
128
+ --red: #ff3b30;
129
+ --green: #34c759;
130
+ --orange: #ff9500;
131
+ }}
132
+
133
+ * {{ margin: 0; padding: 0; box-sizing: border-box; }}
134
+
135
+ body {{
136
+ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Helvetica, Arial, sans-serif;
137
+ background-color: var(--bg-body);
138
+ color: var(--text-main);
139
+ font-size: 13px;
140
+ line-height: 1.5;
141
+ -webkit-font-smoothing: antialiased;
142
+ padding: 30px 20px;
143
+ cursor: default;
144
+ }}
145
+
146
+ .container {{ max-width: 1100px; margin: 0 auto; }}
147
+
148
+ /* Header */
149
+ .header {{
150
+ display: flex;
151
+ justify-content: space-between;
152
+ align-items: center;
153
+ margin-bottom: 24px;
154
+ flex-wrap: wrap;
155
+ gap: 16px;
156
+ }}
157
+ .header-info h1 {{
158
+ font-size: 24px;
159
+ font-weight: 600;
160
+ letter-spacing: -0.5px;
161
+ color: var(--text-main);
162
+ margin-bottom: 4px;
163
+ }}
164
+ .header-info .subtitle {{ font-size: 14px; color: var(--text-sec); }}
165
+ .header-actions {{ display: flex; gap: 10px; }}
166
+
167
+ /* Buttons */
168
+ .btn {{
169
+ display: inline-flex;
170
+ align-items: center;
171
+ padding: 8px 16px;
172
+ background: #ffffff;
173
+ border: 1px solid var(--border-light);
174
+ border-radius: 8px;
175
+ color: var(--text-main);
176
+ font-weight: 500;
177
+ text-decoration: none;
178
+ transition: all 0.2s;
179
+ font-size: 13px;
180
+ cursor: pointer;
181
+ box-shadow: 0 1px 2px rgba(0,0,0,0.03);
182
+ }}
183
+ .btn:hover {{ background: #fafafa; border-color: var(--border); text-decoration: none; }}
184
+ .btn-primary {{ background: var(--blue); color: white; border: none; }}
185
+ .btn-primary:hover {{ background: #0077ed; border: none; text-decoration: none; }}
186
+
187
+ /* Alerts */
188
+ .alert {{
189
+ padding: 12px 16px;
190
+ border-radius: 10px;
191
+ display: flex;
192
+ align-items: flex-start;
193
+ gap: 12px;
194
+ font-size: 13px;
195
+ border: 1px solid transparent;
196
+ margin-bottom: 12px;
197
+ }}
198
+ .alert-icon {{ font-size: 16px; margin-top: 1px; flex-shrink: 0; }}
199
+ .alert-content {{ flex: 1; }}
200
+ .alert-desc {{ color: inherit; opacity: 0.9; margin-top: 2px; font-size: 12px; }}
201
+ .alert-link {{ color: inherit; text-decoration: underline; margin-left: 10px; font-weight: 600; cursor: pointer; }}
202
+ .alert-info {{ background: #eef7fe; border-color: #dcebfb; color: #1c5b96; }}
203
+ .alert-success {{ background: #eafbf0; border-color: #d3f3dd; color: #15682e; }}
204
+ .alert-warning {{ background: #fff8e6; border-color: #fcebc2; color: #9c6e03; }}
205
+ .alert-error {{ background: #ffebeb; border-color: #fddddd; color: #c41e1e; }}
206
+
207
+ /* Sections & Grids */
208
+ .section {{ margin-bottom: 30px; }}
209
+ .section-title {{
210
+ font-size: 15px;
211
+ font-weight: 600;
212
+ color: var(--text-main);
213
+ margin-bottom: 12px;
214
+ padding-left: 4px;
215
+ }}
216
+ .grid-3 {{ display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; align-items: start; }}
217
+ .grid-env {{ display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start; }}
218
+ .account-grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }}
219
+ .stack-col {{ display: flex; flex-direction: column; gap: 16px; }}
220
+
221
+ /* Cards */
222
+ .card {{
223
+ background: #fafaf9;
224
+ padding: 20px;
225
+ border: 1px solid #e5e5e5;
226
+ border-radius: 12px;
227
+ transition: all 0.15s ease;
228
+ }}
229
+ .card:hover {{ border-color: #d4d4d4; box-shadow: 0 0 8px rgba(0,0,0,0.08); }}
230
+ .card h3 {{
231
+ font-size: 13px;
232
+ font-weight: 600;
233
+ color: var(--text-sec);
234
+ margin-bottom: 12px;
235
+ padding-bottom: 8px;
236
+ border-bottom: 1px solid #f5f5f5;
237
+ text-transform: uppercase;
238
+ letter-spacing: 0.5px;
239
+ }}
240
+
241
+ /* Account & Env Styles */
242
+ .account-card .acc-header {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid #f5f5f5; }}
243
+ .acc-title {{ font-weight: 600; font-size: 14px; display: flex; align-items: center; gap: 8px; }}
244
+ .status-dot {{ width: 8px; height: 8px; border-radius: 50%; }}
245
+ .acc-status-text {{ font-size: 12px; font-weight: 500; }}
246
+ .acc-row {{ display: flex; justify-content: space-between; font-size: 12px; margin-top: 6px; color: var(--text-sec); }}
247
+
248
+ /* Delete Button */
249
+ .delete-btn {{
250
+ background: #fff;
251
+ color: #dc2626;
252
+ border: 1px solid #fecaca;
253
+ padding: 4px 12px;
254
+ border-radius: 6px;
255
+ font-size: 11px;
256
+ cursor: pointer;
257
+ font-weight: 500;
258
+ transition: all 0.2s;
259
+ }}
260
+ .delete-btn:hover {{
261
+ background: #dc2626;
262
+ color: white;
263
+ border-color: #dc2626;
264
+ }}
265
+
266
+ /* Modal */
267
+ .modal {{
268
+ display: none;
269
+ position: fixed;
270
+ top: 0;
271
+ left: 0;
272
+ width: 100%;
273
+ height: 100%;
274
+ background: rgba(0,0,0,0.5);
275
+ z-index: 1000;
276
+ align-items: center;
277
+ justify-content: center;
278
+ }}
279
+ .modal.show {{ display: flex; }}
280
+ .modal-content {{
281
+ background: white;
282
+ border-radius: 12px;
283
+ width: 90%;
284
+ max-width: 800px;
285
+ max-height: 90vh;
286
+ display: flex;
287
+ flex-direction: column;
288
+ box-shadow: 0 20px 60px rgba(0,0,0,0.3);
289
+ }}
290
+ .modal-header {{
291
+ padding: 20px 24px;
292
+ border-bottom: 1px solid #e5e5e5;
293
+ display: flex;
294
+ justify-content: space-between;
295
+ align-items: center;
296
+ }}
297
+ .modal-title {{ font-size: 18px; font-weight: 600; color: #1a1a1a; }}
298
+ .modal-close {{
299
+ background: none;
300
+ border: none;
301
+ font-size: 24px;
302
+ color: #6b6b6b;
303
+ cursor: pointer;
304
+ padding: 0;
305
+ width: 32px;
306
+ height: 32px;
307
+ display: flex;
308
+ align-items: center;
309
+ justify-content: center;
310
+ border-radius: 6px;
311
+ transition: all 0.2s;
312
+ }}
313
+ .modal-close:hover {{ background: #f5f5f5; color: #1a1a1a; }}
314
+ .modal-body {{
315
+ padding: 24px;
316
+ flex: 1;
317
+ display: flex;
318
+ flex-direction: column;
319
+ overflow: hidden;
320
+ }}
321
+ .modal-footer {{
322
+ padding: 16px 24px;
323
+ border-top: 1px solid #e5e5e5;
324
+ display: flex;
325
+ justify-content: flex-end;
326
+ gap: 12px;
327
+ }}
328
+
329
+ /* JSON Editor */
330
+ .json-editor {{
331
+ width: 100%;
332
+ flex: 1;
333
+ min-height: 300px;
334
+ font-family: "SF Mono", SFMono-Regular, ui-monospace, Menlo, Consolas, monospace;
335
+ font-size: 13px;
336
+ padding: 16px;
337
+ border: 1px solid #e5e5e5;
338
+ border-radius: 8px;
339
+ background: #fafaf9;
340
+ color: #1a1a1a;
341
+ line-height: 1.6;
342
+ overflow-y: auto;
343
+ resize: none;
344
+ scrollbar-width: thin;
345
+ scrollbar-color: rgba(0,0,0,0.15) transparent;
346
+ }}
347
+ .json-editor::-webkit-scrollbar {{
348
+ width: 4px;
349
+ }}
350
+ .json-editor::-webkit-scrollbar-track {{
351
+ background: transparent;
352
+ }}
353
+ .json-editor::-webkit-scrollbar-thumb {{
354
+ background: rgba(0,0,0,0.15);
355
+ border-radius: 2px;
356
+ }}
357
+ .json-editor::-webkit-scrollbar-thumb:hover {{
358
+ background: rgba(0,0,0,0.3);
359
+ }}
360
+ .json-editor:focus {{
361
+ outline: none;
362
+ border-color: #0071e3;
363
+ box-shadow: 0 0 0 3px rgba(0,113,227,0.1);
364
+ }}
365
+ .json-error {{
366
+ color: #dc2626;
367
+ font-size: 12px;
368
+ margin-top: 8px;
369
+ padding: 8px 12px;
370
+ background: #fef2f2;
371
+ border: 1px solid #fecaca;
372
+ border-radius: 6px;
373
+ display: none;
374
+ }}
375
+ .json-error.show {{ display: block; }}
376
+
377
+ .btn-secondary {{
378
+ background: #f5f5f5;
379
+ color: #1a1a1a;
380
+ border: 1px solid #e5e5e5;
381
+ }}
382
+ .btn-secondary:hover {{ background: #e5e5e5; }}
383
+
384
+ .env-var {{ display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid #f5f5f5; }}
385
+ .env-var:last-child {{ border-bottom: none; }}
386
+ .env-name {{ font-family: "SF Mono", SFMono-Regular, ui-monospace, Menlo, Consolas, monospace; font-size: 12px; color: var(--text-main); font-weight: 600; }}
387
+ .env-desc {{ font-size: 11px; color: var(--text-sec); margin-top: 2px; }}
388
+ .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; }}
389
+
390
+ .badge {{ display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 10px; font-weight: 600; vertical-align: middle; margin-left: 6px; }}
391
+ .badge-required {{ background: #ffebeb; color: #c62828; }}
392
+ .badge-optional {{ background: #e8f5e9; color: #2e7d32; }}
393
+
394
+ 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); }}
395
+ a {{ color: var(--blue); text-decoration: none; }}
396
+ a:hover {{ text-decoration: underline; }}
397
+ .font-mono {{ font-family: "SF Mono", SFMono-Regular, ui-monospace, Menlo, Consolas, monospace; }}
398
+
399
+ /* --- Service Info Styles --- */
400
+ .model-grid {{ display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }}
401
+ .model-tag {{
402
+ background: #f0f0f2;
403
+ color: #1d1d1f;
404
+ padding: 4px 10px;
405
+ border-radius: 6px;
406
+ font-size: 12px;
407
+ font-family: "SF Mono", SFMono-Regular, ui-monospace, Menlo, Consolas, monospace;
408
+ border: 1px solid transparent;
409
+ }}
410
+ .model-tag.highlight {{ background: #eef7ff; color: #0071e3; border-color: #dcebfb; font-weight: 500; }}
411
+
412
+ .info-box {{ background: #f9f9f9; border: 1px solid #e5e5ea; border-radius: 8px; padding: 14px; }}
413
+ .info-box-title {{ font-weight: 600; font-size: 12px; color: #1d1d1f; margin-bottom: 6px; }}
414
+ .info-box-text {{ font-size: 12px; color: #86868b; line-height: 1.5; }}
415
+
416
+ .ep-table {{ width: 100%; border-collapse: collapse; font-size: 13px; }}
417
+ .ep-table tr {{ border-bottom: 1px solid #f5f5f5; }}
418
+ .ep-table tr:last-child {{ border-bottom: none; }}
419
+ .ep-table td {{ padding: 10px 0; vertical-align: middle; }}
420
+
421
+ .method {{
422
+ display: inline-block;
423
+ padding: 2px 6px;
424
+ border-radius: 4px;
425
+ font-size: 10px;
426
+ font-weight: 700;
427
+ text-transform: uppercase;
428
+ min-width: 48px;
429
+ text-align: center;
430
+ margin-right: 8px;
431
+ }}
432
+ .m-post {{ background: #eafbf0; color: #166534; border: 1px solid #dcfce7; }}
433
+ .m-get {{ background: #eff6ff; color: #1e40af; border: 1px solid #dbeafe; }}
434
+ .m-del {{ background: #fef2f2; color: #991b1b; border: 1px solid #fee2e2; }}
435
+
436
+ .ep-path {{ font-family: "SF Mono", SFMono-Regular, ui-monospace, Menlo, Consolas, monospace; color: #1d1d1f; margin-right: 8px; font-size: 12px; }}
437
+ .ep-desc {{ color: #86868b; font-size: 12px; margin-left: auto; }}
438
+
439
+ .current-url-row {{
440
+ display: flex;
441
+ align-items: center;
442
+ padding: 10px 12px;
443
+ background: #f2f7ff;
444
+ border-radius: 8px;
445
+ margin-bottom: 16px;
446
+ border: 1px solid #e1effe;
447
+ }}
448
+
449
+ @media (max-width: 800px) {{
450
+ .grid-3, .grid-env {{ grid-template-columns: 1fr; }}
451
+ .header {{ flex-direction: column; align-items: flex-start; gap: 16px; }}
452
+ .header-actions {{ width: 100%; justify-content: flex-start; }}
453
+ .ep-table td {{ display: flex; flex-direction: column; align-items: flex-start; gap: 4px; }}
454
+ .ep-desc {{ margin-left: 0; }}
455
+ }}
456
+ </style>
457
+ </head>
458
+ <body>
459
+ <div class="container">
460
+ <div class="header">
461
+ <div class="header-info">
462
+ <h1>Gemini-Business2api</h1>
463
+ <div class="subtitle">多账户代理面板</div>
464
+ </div>
465
+ <div class="header-actions">
466
+ <a href="/public/log/html" class="btn" target="_blank">📄 公开日志</a>
467
+ <a href="/{main.PATH_PREFIX}/admin/log/html?key={main.ADMIN_KEY}" class="btn" target="_blank">🔧 管理日志</a>
468
+ <button class="btn" onclick="showEditConfig()" id="edit-btn">✏️ 编辑配置</button>
469
+ </div>
470
+ </div>
471
+
472
+ {hide_tip}
473
+ {api_key_status}
474
+ {error_alert}
475
+
476
+ <div class="section">
477
+ <div class="section-title">账户状态 ({len(multi_account_mgr.accounts)} 个)</div>
478
+ <div style="color: #6b6b6b; font-size: 12px; margin-bottom: 12px; padding-left: 4px;">过期时间为12小时,可以自行修改时间,脚本可能有误差。</div>
479
+ <div class="account-grid">
480
+ {accounts_html if accounts_html else '<div class="card"><p style="color: #6b6b6b; font-size: 14px; text-align:center;">暂无账户</p></div>'}
481
+ </div>
482
+ </div>
483
+
484
+ <div class="section">
485
+ <div class="section-title">环境变量配置</div>
486
+ <div class="grid-env">
487
+ <div class="stack-col">
488
+ <div class="card">
489
+ <h3>必需变量 <span class="badge badge-required">REQUIRED</span></h3>
490
+ <div style="margin-top: 12px;">
491
+ <div class="env-var">
492
+ <div><div class="env-name">ACCOUNTS_CONFIG</div><div class="env-desc">JSON格式账户列表</div></div>
493
+ </div>
494
+ <div class="env-var">
495
+ <div><div class="env-name">PATH_PREFIX</div><div class="env-desc">API路径前缀</div></div>
496
+ <div class="env-value">当前: {main.PATH_PREFIX}</div>
497
+ </div>
498
+ <div class="env-var">
499
+ <div><div class="env-name">ADMIN_KEY</div><div class="env-desc">管理员密钥</div></div>
500
+ <div class="env-value">已设置</div>
501
+ </div>
502
+ </div>
503
+ </div>
504
+
505
+ <div class="card">
506
+ <h3>重试配置 <span class="badge badge-optional">OPTIONAL</span></h3>
507
+ <div style="margin-top: 12px;">
508
+ <div class="env-var">
509
+ <div><div class="env-name">MAX_NEW_SESSION_TRIES</div><div class="env-desc">新会话尝试账户数</div></div>
510
+ <div class="env-value">{main.MAX_NEW_SESSION_TRIES}</div>
511
+ </div>
512
+ <div class="env-var">
513
+ <div><div class="env-name">MAX_REQUEST_RETRIES</div><div class="env-desc">请求失败重试次数</div></div>
514
+ <div class="env-value">{main.MAX_REQUEST_RETRIES}</div>
515
+ </div>
516
+ <div class="env-var">
517
+ <div><div class="env-name">MAX_ACCOUNT_SWITCH_TRIES</div><div class="env-desc">每次重试查找账户次数</div></div>
518
+ <div class="env-value">{main.MAX_ACCOUNT_SWITCH_TRIES}</div>
519
+ </div>
520
+ <div class="env-var">
521
+ <div><div class="env-name">ACCOUNT_FAILURE_THRESHOLD</div><div class="env-desc">账户失败阈值</div></div>
522
+ <div class="env-value">{main.ACCOUNT_FAILURE_THRESHOLD} 次</div>
523
+ </div>
524
+ <div class="env-var">
525
+ <div><div class="env-name">ACCOUNT_COOLDOWN_SECONDS</div><div class="env-desc">账户冷却时间</div></div>
526
+ <div class="env-value">{main.ACCOUNT_COOLDOWN_SECONDS} 秒</div>
527
+ </div>
528
+ </div>
529
+ </div>
530
+ </div>
531
+
532
+ <div class="card">
533
+ <h3>可选变量 <span class="badge badge-optional">OPTIONAL</span></h3>
534
+ <div style="margin-top: 12px;">
535
+ <div class="env-var">
536
+ <div><div class="env-name">API_KEY</div><div class="env-desc">API访问密钥</div></div>
537
+ <div class="env-value">{'已设置' if main.API_KEY else '未设置'}</div>
538
+ </div>
539
+ <div class="env-var">
540
+ <div><div class="env-name">BASE_URL</div><div class="env-desc">图片URL生成(推荐设置)</div></div>
541
+ <div class="env-value">{'已设置' if main.BASE_URL else '未设置(自动检测)'}</div>
542
+ </div>
543
+ <div class="env-var">
544
+ <div><div class="env-name">PROXY</div><div class="env-desc">代理地址</div></div>
545
+ <div class="env-value">{'已设置' if main.PROXY else '未设置'}</div>
546
+ </div>
547
+ <div class="env-var">
548
+ <div><div class="env-name">SESSION_CACHE_TTL_SECONDS</div><div class="env-desc">会话缓存过期时间</div></div>
549
+ <div class="env-value">{main.SESSION_CACHE_TTL_SECONDS} 秒</div>
550
+ </div>
551
+ <div class="env-var">
552
+ <div><div class="env-name">LOGO_URL</div><div class="env-desc">Logo URL(公开,为空则不显示)</div></div>
553
+ <div class="env-value">{'已设置' if main.LOGO_URL else '未设置'}</div>
554
+ </div>
555
+ <div class="env-var">
556
+ <div><div class="env-name">CHAT_URL</div><div class="env-desc">开始对话链接(公开,为空则不显示)</div></div>
557
+ <div class="env-value">{'已设置' if main.CHAT_URL else '未设置'}</div>
558
+ </div>
559
+ <div class="env-var">
560
+ <div><div class="env-name">MODEL_NAME</div><div class="env-desc">模型名称(公开)</div></div>
561
+ <div class="env-value">{main.MODEL_NAME}</div>
562
+ </div>
563
+ <div class="env-var">
564
+ <div><div class="env-name">HIDE_HOME_PAGE</div><div class="env-desc">隐藏首页管理面板</div></div>
565
+ <div class="env-value">{'已隐藏' if main.HIDE_HOME_PAGE else '未隐藏'}</div>
566
+ </div>
567
+ </div>
568
+ </div>
569
+ </div>
570
+ </div>
571
+
572
+ <div class="section">
573
+ <div class="section-title">服务信息</div>
574
+ <div class="grid-3">
575
+ <div class="card">
576
+ <h3>支持的模型</h3>
577
+ <div class="model-grid">
578
+ <span class="model-tag">gemini-auto</span>
579
+ <span class="model-tag">gemini-2.5-flash</span>
580
+ <span class="model-tag">gemini-2.5-pro</span>
581
+ <span class="model-tag">gemini-3-flash-preview</span>
582
+ <span class="model-tag highlight">gemini-3-pro-preview</span>
583
+ </div>
584
+
585
+ <div class="info-box">
586
+ <div class="info-box-title">📸 图片生成说明</div>
587
+ <div class="info-box-text">
588
+ 仅 <code style="background:none;padding:0;color:#0071e3;">gemini-3-pro-preview</code> 支持绘图。<br>
589
+ 路径: <code>{main.IMAGE_DIR}</code><br>
590
+ 类型: {'<span style="color: #34c759; font-weight: 600;">持久化(重启保留)</span>' if main.IMAGE_DIR == '/data/images' else '<span style="color: #ff3b30; font-weight: 600;">临时(重启丢失)</span>'}
591
+ </div>
592
+ </div>
593
+ </div>
594
+
595
+ <div class="card" style="grid-column: span 2;">
596
+ <h3>API 端点</h3>
597
+
598
+ <div class="current-url-row">
599
+ <span style="font-size:12px; font-weight:600; color:#0071e3; margin-right:8px;">当前页面:</span>
600
+ <code style="background:none; padding:0; color:#1d1d1f;">{current_url}</code>
601
+ </div>
602
+
603
+ <table class="ep-table">
604
+ <tr>
605
+ <td width="70"><span class="method m-post">POST</span></td>
606
+ <td><span class="ep-path">/{main.PATH_PREFIX}/v1/chat/completions</span></td>
607
+ <td><span class="ep-desc">OpenAI 兼容对话接口</span></td>
608
+ </tr>
609
+ <tr>
610
+ <td><span class="method m-get">GET</span></td>
611
+ <td><span class="ep-path">/{main.PATH_PREFIX}/v1/models</span></td>
612
+ <td><span class="ep-desc">获取模型列表</span></td>
613
+ </tr>
614
+ <tr>
615
+ <td><span class="method m-get">GET</span></td>
616
+ <td><span class="ep-path">/{main.PATH_PREFIX}/admin</span></td>
617
+ <td><span class="ep-desc">管理首页</span></td>
618
+ </tr>
619
+ <tr>
620
+ <td><span class="method m-get">GET</span></td>
621
+ <td><span class="ep-path">/{main.PATH_PREFIX}/admin/health?key={{main.ADMIN_KEY}}</span></td>
622
+ <td><span class="ep-desc">健康检查 (需 Key)</span></td>
623
+ </tr>
624
+ <tr>
625
+ <td><span class="method m-get">GET</span></td>
626
+ <td><span class="ep-path">/{main.PATH_PREFIX}/admin/accounts?key={{main.ADMIN_KEY}}</span></td>
627
+ <td><span class="ep-desc">账户状态 JSON (需 Key)</span></td>
628
+ </tr>
629
+ <tr>
630
+ <td><span class="method m-get">GET</span></td>
631
+ <td><span class="ep-path">/{main.PATH_PREFIX}/admin/log?key={{main.ADMIN_KEY}}</span></td>
632
+ <td><span class="ep-desc">获取日志 JSON (需 Key)</span></td>
633
+ </tr>
634
+ <tr>
635
+ <td><span class="method m-get">GET</span></td>
636
+ <td><span class="ep-path">/{main.PATH_PREFIX}/admin/log/html?key={{main.ADMIN_KEY}}</span></td>
637
+ <td><span class="ep-desc">日志查看器 HTML (需 Key)</span></td>
638
+ </tr>
639
+ <tr>
640
+ <td><span class="method m-del">DEL</span></td>
641
+ <td><span class="ep-path">/{main.PATH_PREFIX}/admin/log?confirm=yes&key={{main.ADMIN_KEY}}</span></td>
642
+ <td><span class="ep-desc">清空系统日志 (需 Key)</span></td>
643
+ </tr>
644
+ <tr>
645
+ <td><span class="method m-get">GET</span></td>
646
+ <td><span class="ep-path">/public/stats</span></td>
647
+ <td><span class="ep-desc">公开统计数据</span></td>
648
+ </tr>
649
+ <tr>
650
+ <td><span class="method m-get">GET</span></td>
651
+ <td><span class="ep-path">/public/log</span></td>
652
+ <td><span class="ep-desc">公开日志 (JSON, 脱敏)</span></td>
653
+ </tr>
654
+ <tr>
655
+ <td><span class="method m-get">GET</span></td>
656
+ <td><span class="ep-path">/public/log/html</span></td>
657
+ <td><span class="ep-desc">公开日志查看器 (HTML)</span></td>
658
+ </tr>
659
+ <tr>
660
+ <td><span class="method m-get">GET</span></td>
661
+ <td><span class="ep-path">/docs</span></td>
662
+ <td><span class="ep-desc">Swagger API 文档</span></td>
663
+ </tr>
664
+ <tr>
665
+ <td><span class="method m-get">GET</span></td>
666
+ <td><span class="ep-path">/redoc</span></td>
667
+ <td><span class="ep-desc">ReDoc API 文档</span></td>
668
+ </tr>
669
+ </table>
670
+ </div>
671
+ </div>
672
+ </div>
673
+ </div>
674
+
675
+ <!-- JSON 编辑器模态框 -->
676
+ <div id="jsonModal" class="modal">
677
+ <div class="modal-content">
678
+ <div class="modal-header">
679
+ <div class="modal-title">编辑账户配置</div>
680
+ <button class="modal-close" onclick="closeModal()">&times;</button>
681
+ </div>
682
+ <div class="modal-body">
683
+ <textarea id="jsonEditor" class="json-editor" placeholder="在此编辑 JSON 配置..."></textarea>
684
+ <div id="jsonError" class="json-error"></div>
685
+ <div style="margin-top: 12px; font-size: 12px; color: #6b6b6b;">
686
+ <strong>提示:</strong>编辑完成后点击"保存"按钮。JSON 格式错误时无法保存。<br>
687
+ 配置立即生效。重启后将从环境变量重新加载,建议同步更新 ACCOUNTS_CONFIG。
688
+ </div>
689
+ </div>
690
+ <div class="modal-footer">
691
+ <button class="btn btn-secondary" onclick="closeModal()">取消</button>
692
+ <button class="btn btn-primary" onclick="saveConfig()">保存配置</button>
693
+ </div>
694
+ </div>
695
+ </div>
696
+
697
+ <script>
698
+ let currentConfig = null;
699
+
700
+ // 统一的页面刷新函数(避免缓存)
701
+ function refreshPage() {{
702
+ window.location.href = window.location.pathname + '?t=' + Date.now();
703
+ }}
704
+
705
+ // 统一的错误处理函数
706
+ async function handleApiResponse(response) {{
707
+ if (!response.ok) {{
708
+ const errorText = await response.text();
709
+ let errorMsg;
710
+ try {{
711
+ const errorJson = JSON.parse(errorText);
712
+ errorMsg = errorJson.detail || errorJson.message || errorText;
713
+ }} catch {{
714
+ errorMsg = errorText;
715
+ }}
716
+ throw new Error(`HTTP ${{response.status}}: ${{errorMsg}}`);
717
+ }}
718
+ return await response.json();
719
+ }}
720
+
721
+ async function showEditConfig() {{
722
+ const config = await fetch('/{main.PATH_PREFIX}/admin/accounts-config?key={main.ADMIN_KEY}').then(r => r.json());
723
+ currentConfig = config.accounts;
724
+ const json = JSON.stringify(config.accounts, null, 2);
725
+ document.getElementById('jsonEditor').value = json;
726
+ document.getElementById('jsonError').classList.remove('show');
727
+ document.getElementById('jsonModal').classList.add('show');
728
+
729
+ // 实时验证 JSON
730
+ document.getElementById('jsonEditor').addEventListener('input', validateJSON);
731
+ }}
732
+
733
+ function validateJSON() {{
734
+ const editor = document.getElementById('jsonEditor');
735
+ const errorDiv = document.getElementById('jsonError');
736
+ try {{
737
+ JSON.parse(editor.value);
738
+ errorDiv.classList.remove('show');
739
+ errorDiv.textContent = '';
740
+ return true;
741
+ }} catch (e) {{
742
+ errorDiv.classList.add('show');
743
+ errorDiv.textContent = '❌ JSON 格式错误: ' + e.message;
744
+ return false;
745
+ }}
746
+ }}
747
+
748
+ function closeModal() {{
749
+ document.getElementById('jsonModal').classList.remove('show');
750
+ document.getElementById('jsonEditor').removeEventListener('input', validateJSON);
751
+ }}
752
+
753
+ async function saveConfig() {{
754
+ if (!validateJSON()) {{
755
+ alert('JSON 格式错误,请修正后再保存');
756
+ return;
757
+ }}
758
+
759
+ const newJson = document.getElementById('jsonEditor').value;
760
+ const originalJson = JSON.stringify(currentConfig, null, 2);
761
+
762
+ if (newJson === originalJson) {{
763
+ closeModal();
764
+ return;
765
+ }}
766
+
767
+ try {{
768
+ const data = JSON.parse(newJson);
769
+ const response = await fetch('/{main.PATH_PREFIX}/admin/accounts-config?key={main.ADMIN_KEY}', {{
770
+ method: 'PUT',
771
+ headers: {{'Content-Type': 'application/json'}},
772
+ body: JSON.stringify(data)
773
+ }});
774
+
775
+ const result = await handleApiResponse(response);
776
+ alert(`配置已更新!\\n当前账户数: ${{result.account_count}}`);
777
+ closeModal();
778
+ setTimeout(refreshPage, 1000);
779
+ }} catch (error) {{
780
+ console.error('保存失败:', error);
781
+ alert('更新失败: ' + error.message);
782
+ }}
783
+ }}
784
+
785
+ async function deleteAccount(accountId) {{
786
+ if (!confirm(`确定删除账户 ${{accountId}}?`)) return;
787
+
788
+ try {{
789
+ const response = await fetch('/{main.PATH_PREFIX}/admin/accounts/' + accountId + '?key={main.ADMIN_KEY}', {{
790
+ method: 'DELETE'
791
+ }});
792
+
793
+ const result = await handleApiResponse(response);
794
+ alert(`账户已删除!\\n剩余账户数: ${{result.account_count}}`);
795
+ refreshPage();
796
+ }} catch (error) {{
797
+ console.error('删除失败:', error);
798
+ alert('删除失败: ' + error.message);
799
+ }}
800
+ }}
801
+
802
+ // 点击模态框外部关闭
803
+ document.getElementById('jsonModal').addEventListener('click', function(e) {{
804
+ if (e.target === this) {{
805
+ closeModal();
806
+ }}
807
+ }});
808
+ </script>
809
+ </body>
810
+ </html>
811
+ """
812
+ return html_content
813
+
814
+
815
+ async def admin_logs_html(path_prefix: str, key: str = None, authorization: str = Header(None)):
816
+ """返回美化的 HTML 日志查看界面"""
817
+ # 动态导入 main 模块的变量(避免循环依赖)
818
+ import main
819
+
820
+ # 验证路径前缀
821
+ if path_prefix != main.PATH_PREFIX:
822
+ raise HTTPException(404, "Not Found")
823
+
824
+ # 验证管理员密钥
825
+ admin_key = key or (authorization.replace("Bearer ", "") if authorization and authorization.startswith("Bearer ") else authorization)
826
+ if admin_key != main.ADMIN_KEY:
827
+ raise HTTPException(404, "Not Found")
828
+
829
+ html_content = r"""
830
+ <!DOCTYPE html>
831
+ <html>
832
+ <head>
833
+ <meta charset="utf-8">
834
+ <meta name="viewport" content="width=device-width, initial-scale=1">
835
+ <title>日志查看器</title>
836
+ <style>
837
+ * { margin: 0; padding: 0; box-sizing: border-box; }
838
+ html, body { height: 100%; overflow: hidden; }
839
+ body {
840
+ font-family: 'Consolas', 'Monaco', monospace;
841
+ background: #fafaf9;
842
+ display: flex;
843
+ align-items: center;
844
+ justify-content: center;
845
+ padding: 15px;
846
+ }
847
+ .container {
848
+ width: 100%;
849
+ max-width: 1400px;
850
+ height: calc(100vh - 30px);
851
+ background: white;
852
+ border-radius: 16px;
853
+ padding: 30px;
854
+ box-shadow: 0 2px 8px rgba(0,0,0,0.08);
855
+ display: flex;
856
+ flex-direction: column;
857
+ }
858
+ h1 { color: #1a1a1a; font-size: 22px; font-weight: 600; margin-bottom: 20px; text-align: center; }
859
+ .stats {
860
+ display: grid;
861
+ grid-template-columns: repeat(6, 1fr);
862
+ gap: 12px;
863
+ margin-bottom: 16px;
864
+ }
865
+ .stat {
866
+ background: #fafaf9;
867
+ padding: 12px;
868
+ border: 1px solid #e5e5e5;
869
+ border-radius: 8px;
870
+ text-align: center;
871
+ transition: all 0.15s ease;
872
+ }
873
+ .stat:hover { border-color: #d4d4d4; }
874
+ .stat-label { color: #6b6b6b; font-size: 11px; margin-bottom: 4px; }
875
+ .stat-value { color: #1a1a1a; font-size: 18px; font-weight: 600; }
876
+ .controls {
877
+ display: flex;
878
+ gap: 8px;
879
+ margin-bottom: 16px;
880
+ flex-wrap: wrap;
881
+ }
882
+ .controls input, .controls select, .controls button {
883
+ padding: 6px 10px;
884
+ border: 1px solid #e5e5e5;
885
+ border-radius: 8px;
886
+ font-size: 13px;
887
+ }
888
+ .controls select {
889
+ appearance: none;
890
+ -webkit-appearance: none;
891
+ -moz-appearance: none;
892
+ 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");
893
+ background-repeat: no-repeat;
894
+ background-position: right 12px center;
895
+ padding-right: 32px;
896
+ }
897
+ .controls input[type="text"] { flex: 1; min-width: 150px; }
898
+ .controls button {
899
+ background: #1a73e8;
900
+ color: white;
901
+ border: none;
902
+ cursor: pointer;
903
+ font-weight: 500;
904
+ transition: background 0.15s ease;
905
+ display: flex;
906
+ align-items: center;
907
+ gap: 6px;
908
+ }
909
+ .controls button:hover { background: #1557b0; }
910
+ .controls button.danger { background: #dc2626; }
911
+ .controls button.danger:hover { background: #b91c1c; }
912
+ .controls button svg { flex-shrink: 0; }
913
+ .log-container {
914
+ flex: 1;
915
+ background: #fafaf9;
916
+ border: 1px solid #e5e5e5;
917
+ border-radius: 8px;
918
+ padding: 12px;
919
+ overflow-y: auto;
920
+ scrollbar-width: thin;
921
+ scrollbar-color: rgba(0,0,0,0.15) transparent;
922
+ }
923
+ /* Webkit 滚动条样式 - 更窄且不占位 */
924
+ .log-container::-webkit-scrollbar {
925
+ width: 4px;
926
+ }
927
+ .log-container::-webkit-scrollbar-track {
928
+ background: transparent;
929
+ }
930
+ .log-container::-webkit-scrollbar-thumb {
931
+ background: rgba(0,0,0,0.15);
932
+ border-radius: 2px;
933
+ }
934
+ .log-container::-webkit-scrollbar-thumb:hover {
935
+ background: rgba(0,0,0,0.3);
936
+ }
937
+ .log-entry {
938
+ padding: 8px 10px;
939
+ margin-bottom: 4px;
940
+ background: white;
941
+ border-radius: 6px;
942
+ border: 1px solid #e5e5e5;
943
+ font-size: 12px;
944
+ color: #1a1a1a;
945
+ display: flex;
946
+ align-items: center;
947
+ gap: 8px;
948
+ word-break: break-word;
949
+ }
950
+ .log-entry > div:first-child {
951
+ display: flex;
952
+ align-items: center;
953
+ gap: 8px;
954
+ }
955
+ .log-message {
956
+ flex: 1;
957
+ overflow: hidden;
958
+ text-overflow: ellipsis;
959
+ }
960
+ .log-entry:hover { border-color: #d4d4d4; }
961
+ .log-time { color: #6b6b6b; }
962
+ .log-level {
963
+ display: flex;
964
+ align-items: center;
965
+ gap: 4px;
966
+ padding: 2px 6px;
967
+ border-radius: 3px;
968
+ font-size: 10px;
969
+ font-weight: 600;
970
+ }
971
+ .log-level::before {
972
+ content: '';
973
+ width: 6px;
974
+ height: 6px;
975
+ border-radius: 50%;
976
+ }
977
+ .log-level.INFO { background: #e3f2fd; color: #1976d2; }
978
+ .log-level.INFO::before { background: #1976d2; }
979
+ .log-level.WARNING { background: #fff3e0; color: #f57c00; }
980
+ .log-level.WARNING::before { background: #f57c00; }
981
+ .log-level.ERROR { background: #ffebee; color: #d32f2f; }
982
+ .log-level.ERROR::before { background: #d32f2f; }
983
+ .log-level.DEBUG { background: #f3e5f5; color: #7b1fa2; }
984
+ .log-level.DEBUG::before { background: #7b1fa2; }
985
+ .log-group {
986
+ margin-bottom: 8px;
987
+ border: 1px solid #e5e5e5;
988
+ border-radius: 8px;
989
+ background: white;
990
+ }
991
+ .log-group-header {
992
+ padding: 10px 12px;
993
+ background: #f9f9f9;
994
+ border-radius: 8px 8px 0 0;
995
+ cursor: pointer;
996
+ display: flex;
997
+ align-items: center;
998
+ gap: 8px;
999
+ transition: background 0.15s ease;
1000
+ }
1001
+ .log-group-header:hover {
1002
+ background: #f0f0f0;
1003
+ }
1004
+ .log-group-content {
1005
+ padding: 8px;
1006
+ }
1007
+ .log-group .log-entry {
1008
+ margin-bottom: 4px;
1009
+ }
1010
+ .log-group .log-entry:last-child {
1011
+ margin-bottom: 0;
1012
+ }
1013
+ .toggle-icon {
1014
+ display: inline-block;
1015
+ transition: transform 0.2s ease;
1016
+ }
1017
+ .toggle-icon.collapsed {
1018
+ transform: rotate(-90deg);
1019
+ }
1020
+ @media (max-width: 768px) {
1021
+ body { padding: 0; }
1022
+ .container { padding: 15px; height: 100vh; border-radius: 0; max-width: 100%; }
1023
+ h1 { font-size: 18px; margin-bottom: 12px; }
1024
+ .stats { grid-template-columns: repeat(3, 1fr); gap: 8px; }
1025
+ .stat { padding: 8px; }
1026
+ .controls { gap: 6px; }
1027
+ .controls input, .controls select { min-height: 38px; }
1028
+ .controls select { flex: 0 0 auto; }
1029
+ .controls input[type="text"] { flex: 1 1 auto; min-width: 80px; }
1030
+ .controls input[type="number"] { flex: 0 0 60px; }
1031
+ .controls button { padding: 10px 8px; font-size: 12px; flex: 1 1 22%; justify-content: center; min-height: 38px; }
1032
+ .log-entry {
1033
+ font-size: 12px;
1034
+ padding: 10px;
1035
+ gap: 8px;
1036
+ flex-direction: column;
1037
+ align-items: flex-start;
1038
+ }
1039
+ .log-entry > div:first-child {
1040
+ display: flex;
1041
+ align-items: center;
1042
+ gap: 6px;
1043
+ width: 100%;
1044
+ flex-wrap: wrap;
1045
+ }
1046
+ .log-time { font-size: 11px; color: #9e9e9e; }
1047
+ .log-level { font-size: 10px; }
1048
+ .log-message {
1049
+ width: 100%;
1050
+ white-space: normal;
1051
+ word-break: break-word;
1052
+ line-height: 1.5;
1053
+ margin-top: 4px;
1054
+ }
1055
+ }
1056
+ </style>
1057
+ </head>
1058
+ <body>
1059
+ <div class="container">
1060
+ <h1>Gemini API 日志查看器</h1>
1061
+ <div class="stats">
1062
+ <div class="stat">
1063
+ <div class="stat-label">总数</div>
1064
+ <div class="stat-value" id="total-count">-</div>
1065
+ </div>
1066
+ <div class="stat">
1067
+ <div class="stat-label">对话</div>
1068
+ <div class="stat-value" id="chat-count">-</div>
1069
+ </div>
1070
+ <div class="stat">
1071
+ <div class="stat-label">INFO</div>
1072
+ <div class="stat-value" id="info-count">-</div>
1073
+ </div>
1074
+ <div class="stat">
1075
+ <div class="stat-label">WARNING</div>
1076
+ <div class="stat-value" id="warning-count">-</div>
1077
+ </div>
1078
+ <div class="stat">
1079
+ <div class="stat-label">ERROR</div>
1080
+ <div class="stat-value" id="error-count">-</div>
1081
+ </div>
1082
+ <div class="stat">
1083
+ <div class="stat-label">更新</div>
1084
+ <div class="stat-value" id="last-update" style="font-size: 11px;">-</div>
1085
+ </div>
1086
+ </div>
1087
+ <div class="controls">
1088
+ <select id="level-filter">
1089
+ <option value="">全部</option>
1090
+ <option value="INFO">INFO</option>
1091
+ <option value="WARNING">WARNING</option>
1092
+ <option value="ERROR">ERROR</option>
1093
+ </select>
1094
+ <input type="text" id="search-input" placeholder="搜索...">
1095
+ <input type="number" id="limit-input" value="1500" min="10" max="3000" step="100" style="width: 80px;">
1096
+ <button onclick="loadLogs()">
1097
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1098
+ <circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
1099
+ </svg>
1100
+ 查询
1101
+ </button>
1102
+ <button onclick="exportJSON()">
1103
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1104
+ <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"/>
1105
+ </svg>
1106
+ 导出
1107
+ </button>
1108
+ <button id="auto-refresh-btn" onclick="toggleAutoRefresh()">
1109
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1110
+ <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"/>
1111
+ </svg>
1112
+ 自动刷新
1113
+ </button>
1114
+ <button onclick="clearAllLogs()" class="danger">
1115
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1116
+ <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"/>
1117
+ </svg>
1118
+ 清空
1119
+ </button>
1120
+ </div>
1121
+ <div class="log-container" id="log-container">
1122
+ <div style="color: #6b6b6b;">正在加载...</div>
1123
+ </div>
1124
+ </div>
1125
+ <script>
1126
+ let autoRefreshTimer = null;
1127
+ async function loadLogs() {
1128
+ const level = document.getElementById('level-filter').value;
1129
+ const search = document.getElementById('search-input').value;
1130
+ const limit = document.getElementById('limit-input').value;
1131
+ // 从当前 URL 获取 key 参数
1132
+ const urlParams = new URLSearchParams(window.location.search);
1133
+ const key = urlParams.get('key');
1134
+ // 构建 API URL(使用当前路径的前缀)
1135
+ const pathPrefix = window.location.pathname.split('/')[1];
1136
+ let url = `/${pathPrefix}/admin/log?limit=${limit}`;
1137
+ if (key) url += `&key=${key}`;
1138
+ if (level) url += `&level=${level}`;
1139
+ if (search) url += `&search=${encodeURIComponent(search)}`;
1140
+ try {
1141
+ const response = await fetch(url);
1142
+ if (!response.ok) {
1143
+ throw new Error(`HTTP ${response.status}`);
1144
+ }
1145
+ const data = await response.json();
1146
+ if (data && data.logs) {
1147
+ displayLogs(data.logs);
1148
+ updateStats(data.stats);
1149
+ document.getElementById('last-update').textContent = new Date().toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'});
1150
+ } else {
1151
+ throw new Error('Invalid data format');
1152
+ }
1153
+ } catch (error) {
1154
+ document.getElementById('log-container').innerHTML = '<div class="log-entry ERROR">加载失败: ' + error.message + '</div>';
1155
+ }
1156
+ }
1157
+ function updateStats(stats) {
1158
+ document.getElementById('total-count').textContent = stats.memory.total;
1159
+ document.getElementById('info-count').textContent = stats.memory.by_level.INFO || 0;
1160
+ document.getElementById('warning-count').textContent = stats.memory.by_level.WARNING || 0;
1161
+ const errorCount = document.getElementById('error-count');
1162
+ errorCount.textContent = stats.memory.by_level.ERROR || 0;
1163
+ if (stats.errors && stats.errors.count > 0) errorCount.style.color = '#dc2626';
1164
+ document.getElementById('chat-count').textContent = stats.chat_count || 0;
1165
+ }
1166
+ // 分类颜色配置(提取到外部避免重复定义)
1167
+ const CATEGORY_COLORS = {
1168
+ 'SYSTEM': '#9e9e9e',
1169
+ 'CONFIG': '#607d8b',
1170
+ 'LOG': '#9e9e9e',
1171
+ 'AUTH': '#4caf50',
1172
+ 'SESSION': '#00bcd4',
1173
+ 'FILE': '#ff9800',
1174
+ 'CHAT': '#2196f3',
1175
+ 'API': '#8bc34a',
1176
+ 'CACHE': '#9c27b0',
1177
+ 'ACCOUNT': '#f44336',
1178
+ 'MULTI': '#673ab7'
1179
+ };
1180
+
1181
+ // 账户颜色配置(提取到外部避免重复定义)
1182
+ const ACCOUNT_COLORS = {
1183
+ 'account_1': '#9c27b0',
1184
+ 'account_2': '#e91e63',
1185
+ 'account_3': '#00bcd4',
1186
+ 'account_4': '#4caf50',
1187
+ 'account_5': '#ff9800'
1188
+ };
1189
+
1190
+ function getCategoryColor(category) {
1191
+ return CATEGORY_COLORS[category] || '#757575';
1192
+ }
1193
+
1194
+ function getAccountColor(accountId) {
1195
+ return ACCOUNT_COLORS[accountId] || '#757575';
1196
+ }
1197
+
1198
+ function displayLogs(logs) {
1199
+ const container = document.getElementById('log-container');
1200
+ if (logs.length === 0) {
1201
+ container.innerHTML = '<div class="log-entry">暂无日志</div>';
1202
+ return;
1203
+ }
1204
+
1205
+ // 按请求ID分组
1206
+ const groups = {};
1207
+ const ungrouped = [];
1208
+
1209
+ logs.forEach(log => {
1210
+ const msg = escapeHtml(log.message);
1211
+ const reqMatch = msg.match(/\[req_([a-z0-9]+)\]/);
1212
+
1213
+ if (reqMatch) {
1214
+ const reqId = reqMatch[1];
1215
+ if (!groups[reqId]) {
1216
+ groups[reqId] = [];
1217
+ }
1218
+ groups[reqId].push(log);
1219
+ } else {
1220
+ ungrouped.push(log);
1221
+ }
1222
+ });
1223
+
1224
+ // 渲染分组
1225
+ let html = '';
1226
+
1227
+ // 先渲染未分组的日志
1228
+ ungrouped.forEach(log => {
1229
+ html += renderLogEntry(log);
1230
+ });
1231
+
1232
+ // 读取折叠状态
1233
+ const foldState = JSON.parse(localStorage.getItem('log-fold-state') || '{}');
1234
+
1235
+ // 按请求ID分组渲染(最新的组在下面)
1236
+ Object.keys(groups).forEach(reqId => {
1237
+ const groupLogs = groups[reqId];
1238
+ const firstLog = groupLogs[0];
1239
+ const lastLog = groupLogs[groupLogs.length - 1];
1240
+
1241
+ // 判断状态
1242
+ let status = 'in_progress';
1243
+ let statusColor = '#ff9800';
1244
+ let statusText = '进行中';
1245
+
1246
+ if (lastLog.message.includes('响应完成') || lastLog.message.includes('非流式响应完成')) {
1247
+ status = 'success';
1248
+ statusColor = '#4caf50';
1249
+ statusText = '成功';
1250
+ } else if (lastLog.level === 'ERROR' || lastLog.message.includes('失败')) {
1251
+ status = 'error';
1252
+ statusColor = '#f44336';
1253
+ statusText = '失败';
1254
+ } else {
1255
+ // 检查超时(最后日志超过 5 分钟)
1256
+ const lastLogTime = new Date(lastLog.time);
1257
+ const now = new Date();
1258
+ const diffMinutes = (now - lastLogTime) / 1000 / 60;
1259
+ if (diffMinutes > 5) {
1260
+ status = 'timeout';
1261
+ statusColor = '#ffc107';
1262
+ statusText = '超时';
1263
+ }
1264
+ }
1265
+
1266
+ // 提取账户ID和模型
1267
+ const accountMatch = firstLog.message.match(/\[account_(\d+)\]/);
1268
+ const modelMatch = firstLog.message.match(/收到请求: ([^ ]+)/);
1269
+ const accountId = accountMatch ? `account_${accountMatch[1]}` : '';
1270
+ const model = modelMatch ? modelMatch[1] : '';
1271
+
1272
+ // 检查折叠状态
1273
+ const isCollapsed = foldState[reqId] === true;
1274
+ const contentStyle = isCollapsed ? 'style="display: none;"' : '';
1275
+ const iconClass = isCollapsed ? 'class="toggle-icon collapsed"' : 'class="toggle-icon"';
1276
+
1277
+ html += `
1278
+ <div class="log-group" data-req-id="${reqId}">
1279
+ <div class="log-group-header" onclick="toggleGroup('${reqId}')">
1280
+ <span style="color: ${statusColor}; font-weight: 600; font-size: 11px;">⬤ ${statusText}</span>
1281
+ <span style="color: #666; font-size: 11px; margin-left: 8px;">req_${reqId}</span>
1282
+ ${accountId ? `<span style="color: ${getAccountColor(accountId)}; font-size: 11px; margin-left: 8px;">${accountId}</span>` : ''}
1283
+ ${model ? `<span style="color: #999; font-size: 11px; margin-left: 8px;">${model}</span>` : ''}
1284
+ <span style="color: #999; font-size: 11px; margin-left: 8px;">${groupLogs.length}条日志</span>
1285
+ <span ${iconClass} style="margin-left: auto; color: #999;">▼</span>
1286
+ </div>
1287
+ <div class="log-group-content" ${contentStyle}>
1288
+ ${groupLogs.map(log => renderLogEntry(log)).join('')}
1289
+ </div>
1290
+ </div>
1291
+ `;
1292
+ });
1293
+
1294
+ container.innerHTML = html;
1295
+
1296
+ // 自动滚动到底部,显示最新日志
1297
+ container.scrollTop = container.scrollHeight;
1298
+ }
1299
+
1300
+ function renderLogEntry(log) {
1301
+ const msg = escapeHtml(log.message);
1302
+ let displayMsg = msg;
1303
+ let categoryTags = [];
1304
+ let accountId = null;
1305
+
1306
+ // 解析所有标签:[CATEGORY1] [CATEGORY2] [account_X] [req_X] message
1307
+ let remainingMsg = msg;
1308
+ const tagRegex = /^\[([A-Z_a-z0-9]+)\]/;
1309
+
1310
+ while (true) {
1311
+ const match = remainingMsg.match(tagRegex);
1312
+ if (!match) break;
1313
+
1314
+ const tag = match[1];
1315
+ remainingMsg = remainingMsg.substring(match[0].length).trim();
1316
+
1317
+ // 跳过req_标签(已在组头部显示)
1318
+ if (tag.startsWith('req_')) {
1319
+ continue;
1320
+ }
1321
+ // 判断是否为账户ID
1322
+ else if (tag.startsWith('account_')) {
1323
+ accountId = tag;
1324
+ } else {
1325
+ // 普通分类标签
1326
+ categoryTags.push(tag);
1327
+ }
1328
+ }
1329
+
1330
+ displayMsg = remainingMsg;
1331
+
1332
+ // 生成分类标签HTML
1333
+ const categoryTagsHtml = categoryTags.map(cat =>
1334
+ `<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>`
1335
+ ).join('');
1336
+
1337
+ // 生成账户标签HTML
1338
+ const accountTagHtml = accountId
1339
+ ? `<span style="color: ${getAccountColor(accountId)}; font-size: 11px; font-weight: 600; margin-left: 2px;">${accountId}</span>`
1340
+ : '';
1341
+
1342
+ return `
1343
+ <div class="log-entry ${log.level}">
1344
+ <div>
1345
+ <span class="log-time">${log.time}</span>
1346
+ <span class="log-level ${log.level}">${log.level}</span>
1347
+ ${categoryTagsHtml}
1348
+ ${accountTagHtml}
1349
+ </div>
1350
+ <div class="log-message">${displayMsg}</div>
1351
+ </div>
1352
+ `;
1353
+ }
1354
+
1355
+ function toggleGroup(reqId) {
1356
+ const group = document.querySelector(`.log-group[data-req-id="${reqId}"]`);
1357
+ const content = group.querySelector('.log-group-content');
1358
+ const icon = group.querySelector('.toggle-icon');
1359
+
1360
+ const isCollapsed = content.style.display === 'none';
1361
+ if (isCollapsed) {
1362
+ content.style.display = 'block';
1363
+ icon.classList.remove('collapsed');
1364
+ } else {
1365
+ content.style.display = 'none';
1366
+ icon.classList.add('collapsed');
1367
+ }
1368
+
1369
+ // 保存折叠状态到 localStorage
1370
+ const foldState = JSON.parse(localStorage.getItem('log-fold-state') || '{}');
1371
+ foldState[reqId] = !isCollapsed;
1372
+ localStorage.setItem('log-fold-state', JSON.stringify(foldState));
1373
+ }
1374
+ function escapeHtml(text) {
1375
+ const div = document.createElement('div');
1376
+ div.textContent = text;
1377
+ return div.innerHTML;
1378
+ }
1379
+ async function exportJSON() {
1380
+ try {
1381
+ const urlParams = new URLSearchParams(window.location.search);
1382
+ const key = urlParams.get('key');
1383
+ const pathPrefix = window.location.pathname.split('/')[1];
1384
+ let url = `/${pathPrefix}/admin/log?limit=3000`;
1385
+ if (key) url += `&key=${key}`;
1386
+ const response = await fetch(url);
1387
+ const data = await response.json();
1388
+ const blob = new Blob([JSON.stringify({exported_at: new Date().toISOString(), logs: data.logs}, null, 2)], {type: 'application/json'});
1389
+ const blobUrl = URL.createObjectURL(blob);
1390
+ const a = document.createElement('a');
1391
+ a.href = blobUrl;
1392
+ a.download = 'logs_' + new Date().toISOString().slice(0, 19).replace(/:/g, '-') + '.json';
1393
+ a.click();
1394
+ URL.revokeObjectURL(blobUrl);
1395
+ alert('导出成功');
1396
+ } catch (error) {
1397
+ alert('导出失败: ' + error.message);
1398
+ }
1399
+ }
1400
+ async function clearAllLogs() {
1401
+ if (!confirm('确定清空所有日志?')) return;
1402
+ try {
1403
+ const urlParams = new URLSearchParams(window.location.search);
1404
+ const key = urlParams.get('key');
1405
+ const pathPrefix = window.location.pathname.split('/')[1];
1406
+ let url = `/${pathPrefix}/admin/log?confirm=yes`;
1407
+ if (key) url += `&key=${key}`;
1408
+ const response = await fetch(url, {method: 'DELETE'});
1409
+ if (response.ok) {
1410
+ alert('已清空');
1411
+ loadLogs();
1412
+ } else {
1413
+ alert('清空失败');
1414
+ }
1415
+ } catch (error) {
1416
+ alert('清空失败: ' + error.message);
1417
+ }
1418
+ }
1419
+ let autoRefreshEnabled = true;
1420
+ function toggleAutoRefresh() {
1421
+ autoRefreshEnabled = !autoRefreshEnabled;
1422
+ const btn = document.getElementById('auto-refresh-btn');
1423
+ if (autoRefreshEnabled) {
1424
+ btn.style.background = '#1a73e8';
1425
+ autoRefreshTimer = setInterval(loadLogs, 5000);
1426
+ } else {
1427
+ btn.style.background = '#6b6b6b';
1428
+ if (autoRefreshTimer) {
1429
+ clearInterval(autoRefreshTimer);
1430
+ autoRefreshTimer = null;
1431
+ }
1432
+ }
1433
+ }
1434
+ document.addEventListener('DOMContentLoaded', () => {
1435
+ loadLogs();
1436
+ autoRefreshTimer = setInterval(loadLogs, 5000);
1437
+ document.getElementById('search-input').addEventListener('keypress', (e) => {
1438
+ if (e.key === 'Enter') loadLogs();
1439
+ });
1440
+ document.getElementById('level-filter').addEventListener('change', loadLogs);
1441
+ document.getElementById('limit-input').addEventListener('change', loadLogs);
1442
+ });
1443
+ </script>
1444
+ </body>
1445
+ </html>
1446
+ """
1447
+ return HTMLResponse(content=html_content)
1448
+
1449
+
1450
+ async def get_public_logs_html():
1451
+ """公开的脱敏日志查看器"""
1452
+ # 动态导入 main 模块的变量(避免循环依赖)
1453
+ import main
1454
+
1455
+ html_content = r"""
1456
+ <!DOCTYPE html>
1457
+ <html>
1458
+ <head>
1459
+ <meta charset="utf-8">
1460
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1461
+ <title>服务状态</title>
1462
+ <style>
1463
+ * { margin: 0; padding: 0; box-sizing: border-box; }
1464
+ html, body { height: 100%; overflow: hidden; }
1465
+ body {
1466
+ font-family: 'Consolas', 'Monaco', monospace;
1467
+ background: #fafaf9;
1468
+ display: flex;
1469
+ align-items: center;
1470
+ justify-content: center;
1471
+ padding: 15px;
1472
+ }
1473
+ .container {
1474
+ width: 100%;
1475
+ max-width: 1200px;
1476
+ height: calc(100vh - 30px);
1477
+ background: white;
1478
+ border-radius: 16px;
1479
+ padding: 30px;
1480
+ box-shadow: 0 2px 8px rgba(0,0,0,0.08);
1481
+ display: flex;
1482
+ flex-direction: column;
1483
+ }
1484
+ h1 {
1485
+ color: #1a1a1a;
1486
+ font-size: 22px;
1487
+ font-weight: 600;
1488
+ margin-bottom: 20px;
1489
+ display: flex;
1490
+ align-items: center;
1491
+ justify-content: center;
1492
+ gap: 12px;
1493
+ }
1494
+ h1 img {
1495
+ width: 32px;
1496
+ height: 32px;
1497
+ border-radius: 8px;
1498
+ }
1499
+ .info-bar {
1500
+ background: #f9f9f9;
1501
+ border: 1px solid #e5e5e5;
1502
+ border-radius: 8px;
1503
+ padding: 12px 16px;
1504
+ margin-bottom: 16px;
1505
+ display: flex;
1506
+ align-items: center;
1507
+ justify-content: space-between;
1508
+ flex-wrap: wrap;
1509
+ gap: 12px;
1510
+ }
1511
+ .info-item {
1512
+ display: flex;
1513
+ align-items: center;
1514
+ gap: 6px;
1515
+ font-size: 13px;
1516
+ color: #6b6b6b;
1517
+ }
1518
+ .info-item strong { color: #1a1a1a; }
1519
+ .info-item a {
1520
+ color: #1a73e8;
1521
+ text-decoration: none;
1522
+ font-weight: 500;
1523
+ }
1524
+ .info-item a:hover { text-decoration: underline; }
1525
+ .stats {
1526
+ display: grid;
1527
+ grid-template-columns: repeat(4, 1fr);
1528
+ gap: 12px;
1529
+ margin-bottom: 16px;
1530
+ }
1531
+ .stat {
1532
+ background: #fafaf9;
1533
+ padding: 12px;
1534
+ border: 1px solid #e5e5e5;
1535
+ border-radius: 8px;
1536
+ text-align: center;
1537
+ transition: all 0.15s ease;
1538
+ }
1539
+ .stat:hover { border-color: #d4d4d4; }
1540
+ .stat-label { color: #6b6b6b; font-size: 11px; margin-bottom: 4px; }
1541
+ .stat-value { color: #1a1a1a; font-size: 18px; font-weight: 600; }
1542
+ .log-container {
1543
+ flex: 1;
1544
+ background: #fafaf9;
1545
+ border: 1px solid #e5e5e5;
1546
+ border-radius: 8px;
1547
+ padding: 12px;
1548
+ overflow-y: auto;
1549
+ scrollbar-width: thin;
1550
+ scrollbar-color: rgba(0,0,0,0.15) transparent;
1551
+ }
1552
+ .log-container::-webkit-scrollbar { width: 4px; }
1553
+ .log-container::-webkit-scrollbar-track { background: transparent; }
1554
+ .log-container::-webkit-scrollbar-thumb {
1555
+ background: rgba(0,0,0,0.15);
1556
+ border-radius: 2px;
1557
+ }
1558
+ .log-container::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.3); }
1559
+ .log-group {
1560
+ margin-bottom: 8px;
1561
+ border: 1px solid #e5e5e5;
1562
+ border-radius: 8px;
1563
+ background: white;
1564
+ }
1565
+ .log-group-header {
1566
+ padding: 10px 12px;
1567
+ background: #f9f9f9;
1568
+ border-radius: 8px 8px 0 0;
1569
+ cursor: pointer;
1570
+ display: flex;
1571
+ align-items: center;
1572
+ gap: 8px;
1573
+ transition: background 0.15s ease;
1574
+ }
1575
+ .log-group-header:hover { background: #f0f0f0; }
1576
+ .log-group-content { padding: 8px; }
1577
+ .log-entry {
1578
+ padding: 8px 10px;
1579
+ margin-bottom: 4px;
1580
+ background: white;
1581
+ border: 1px solid #e5e5e5;
1582
+ border-radius: 6px;
1583
+ display: flex;
1584
+ align-items: center;
1585
+ gap: 10px;
1586
+ font-size: 13px;
1587
+ transition: all 0.15s ease;
1588
+ }
1589
+ .log-entry:hover { border-color: #d4d4d4; }
1590
+ .log-time { color: #6b6b6b; font-size: 12px; min-width: 140px; }
1591
+ .log-status {
1592
+ padding: 2px 8px;
1593
+ border-radius: 4px;
1594
+ font-size: 11px;
1595
+ font-weight: 600;
1596
+ min-width: 60px;
1597
+ text-align: center;
1598
+ }
1599
+ .status-success { background: #d1fae5; color: #065f46; }
1600
+ .status-error { background: #fee2e2; color: #991b1b; }
1601
+ .status-in_progress { background: #fef3c7; color: #92400e; }
1602
+ .status-timeout { background: #fef3c7; color: #92400e; }
1603
+ .log-info { flex: 1; color: #374151; }
1604
+ .toggle-icon {
1605
+ display: inline-block;
1606
+ transition: transform 0.2s ease;
1607
+ }
1608
+ .toggle-icon.collapsed { transform: rotate(-90deg); }
1609
+ .subtitle-public {
1610
+ display: flex;
1611
+ justify-content: center;
1612
+ align-items: center;
1613
+ gap: 8px;
1614
+ flex-wrap: wrap;
1615
+ }
1616
+
1617
+ @media (max-width: 768px) {
1618
+ body { padding: 0; }
1619
+ .container {
1620
+ padding: 15px;
1621
+ height: 100vh;
1622
+ border-radius: 0;
1623
+ max-width: 100%;
1624
+ }
1625
+ h1 { font-size: 18px; margin-bottom: 12px; }
1626
+ .subtitle-public {
1627
+ flex-direction: column;
1628
+ gap: 6px;
1629
+ }
1630
+ .subtitle-public span {
1631
+ font-size: 11px;
1632
+ line-height: 1.6;
1633
+ }
1634
+ .subtitle-public a {
1635
+ font-size: 12px;
1636
+ font-weight: 600;
1637
+ }
1638
+ .info-bar {
1639
+ padding: 10px 12px;
1640
+ flex-direction: column;
1641
+ align-items: flex-start;
1642
+ gap: 8px;
1643
+ }
1644
+ .info-item { font-size: 12px; }
1645
+ .stats {
1646
+ grid-template-columns: repeat(2, 1fr);
1647
+ gap: 8px;
1648
+ margin-bottom: 12px;
1649
+ }
1650
+ .stat { padding: 8px; }
1651
+ .stat-label { font-size: 10px; }
1652
+ .stat-value { font-size: 16px; }
1653
+ .log-container { padding: 8px; }
1654
+ .log-group { margin-bottom: 6px; }
1655
+ .log-group-header {
1656
+ padding: 8px 10px;
1657
+ font-size: 11px;
1658
+ flex-wrap: wrap;
1659
+ }
1660
+ .log-group-header span { font-size: 10px !important; }
1661
+ .log-entry {
1662
+ padding: 6px 8px;
1663
+ font-size: 11px;
1664
+ flex-direction: column;
1665
+ align-items: flex-start;
1666
+ gap: 4px;
1667
+ }
1668
+ .log-time {
1669
+ min-width: auto;
1670
+ font-size: 10px;
1671
+ }
1672
+ .log-info {
1673
+ font-size: 11px;
1674
+ word-break: break-word;
1675
+ }
1676
+ }
1677
+ </style>
1678
+ </head>
1679
+ <body>
1680
+ <div class="container">
1681
+ <h1>
1682
+ """ + (f'<img src="{main.LOGO_URL}" alt="Logo">' if main.LOGO_URL else '') + r"""
1683
+ Gemini服务状态
1684
+ </h1>
1685
+ <div style="text-align: center; color: #999; font-size: 12px; margin-bottom: 16px;" class="subtitle-public">
1686
+ <span>展示最近1000条对话日志 · 每5秒自动更新</span>
1687
+ """ + (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"""
1688
+ </div>
1689
+ <div class="stats">
1690
+ <div class="stat">
1691
+ <div class="stat-label">总访问</div>
1692
+ <div class="stat-value" id="stat-visitors">0</div>
1693
+ </div>
1694
+ <div class="stat">
1695
+ <div class="stat-label">每分钟请求</div>
1696
+ <div class="stat-value" id="stat-load">0</div>
1697
+ </div>
1698
+ <div class="stat">
1699
+ <div class="stat-label">平均响应</div>
1700
+ <div class="stat-value" id="stat-avg-time">-</div>
1701
+ </div>
1702
+ <div class="stat">
1703
+ <div class="stat-label">成功率</div>
1704
+ <div class="stat-value" id="stat-success-rate" style="color: #10b981;">-</div>
1705
+ </div>
1706
+ <div class="stat">
1707
+ <div class="stat-label">对话次数</div>
1708
+ <div class="stat-value" id="stat-total">0</div>
1709
+ </div>
1710
+ <div class="stat">
1711
+ <div class="stat-label">成功</div>
1712
+ <div class="stat-value" id="stat-success" style="color: #10b981;">0</div>
1713
+ </div>
1714
+ <div class="stat">
1715
+ <div class="stat-label">失败</div>
1716
+ <div class="stat-value" id="stat-error" style="color: #ef4444;">0</div>
1717
+ </div>
1718
+ <div class="stat">
1719
+ <div class="stat-label">更新时间</div>
1720
+ <div class="stat-value" id="stat-update-time" style="font-size: 14px; color: #6b6b6b;">--:--</div>
1721
+ </div>
1722
+ </div>
1723
+ <div class="log-container" id="log-container">
1724
+ <div style="text-align: center; color: #999; padding: 20px;">加载中...</div>
1725
+ </div>
1726
+ </div>
1727
+ <script>
1728
+ async function loadData() {
1729
+ try {
1730
+ // 并行加载日志和统计数据
1731
+ const [logsResponse, statsResponse] = await Promise.all([
1732
+ fetch('/public/log?limit=1000'),
1733
+ fetch('/public/stats')
1734
+ ]);
1735
+
1736
+ const logsData = await logsResponse.json();
1737
+ const statsData = await statsResponse.json();
1738
+
1739
+ displayLogs(logsData.logs);
1740
+ updateStats(logsData.logs, statsData);
1741
+ } catch (error) {
1742
+ document.getElementById('log-container').innerHTML = '<div style="text-align: center; color: #f44336; padding: 20px;">加载失败: ' + error.message + '</div>';
1743
+ }
1744
+ }
1745
+
1746
+ function displayLogs(logs) {
1747
+ const container = document.getElementById('log-container');
1748
+ if (logs.length === 0) {
1749
+ container.innerHTML = '<div style="text-align: center; color: #999; padding: 20px;">暂无日志</div>';
1750
+ return;
1751
+ }
1752
+
1753
+ // 读取折叠状态
1754
+ const foldState = JSON.parse(localStorage.getItem('public-log-fold-state') || '{}');
1755
+
1756
+ let html = '';
1757
+ logs.forEach(log => {
1758
+ const reqId = log.request_id;
1759
+
1760
+ // 状态图标和颜色
1761
+ let statusColor = '#ff9800';
1762
+ let statusText = '进行中';
1763
+
1764
+ if (log.status === 'success') {
1765
+ statusColor = '#4caf50';
1766
+ statusText = '成功';
1767
+ } else if (log.status === 'error') {
1768
+ statusColor = '#f44336';
1769
+ statusText = '失败';
1770
+ } else if (log.status === 'timeout') {
1771
+ statusColor = '#ffc107';
1772
+ statusText = '超时';
1773
+ }
1774
+
1775
+ // 检查折叠状态
1776
+ const isCollapsed = foldState[reqId] === true;
1777
+ const contentStyle = isCollapsed ? 'style="display: none;"' : '';
1778
+ const iconClass = isCollapsed ? 'class="toggle-icon collapsed"' : 'class="toggle-icon"';
1779
+
1780
+ // 构建事件列表
1781
+ let eventsHtml = '';
1782
+ log.events.forEach(event => {
1783
+ let eventClass = 'log-entry';
1784
+ let eventLabel = '';
1785
+
1786
+ if (event.type === 'start') {
1787
+ eventLabel = '<span style="color: #2563eb; font-weight: 600;">开始对话</span>';
1788
+ } else if (event.type === 'select') {
1789
+ eventLabel = '<span style="color: #8b5cf6; font-weight: 600;">选择</span>';
1790
+ } else if (event.type === 'retry') {
1791
+ eventLabel = '<span style="color: #f59e0b; font-weight: 600;">重试</span>';
1792
+ } else if (event.type === 'switch') {
1793
+ eventLabel = '<span style="color: #06b6d4; font-weight: 600;">切换</span>';
1794
+ } else if (event.type === 'complete') {
1795
+ if (event.status === 'success') {
1796
+ eventLabel = '<span style="color: #10b981; font-weight: 600;">完成</span>';
1797
+ } else if (event.status === 'error') {
1798
+ eventLabel = '<span style="color: #ef4444; font-weight: 600;">失败</span>';
1799
+ } else if (event.status === 'timeout') {
1800
+ eventLabel = '<span style="color: #f59e0b; font-weight: 600;">超时</span>';
1801
+ }
1802
+ }
1803
+
1804
+ eventsHtml += `
1805
+ <div class="${eventClass}">
1806
+ <div class="log-time">${event.time}</div>
1807
+ <div style="min-width: 60px;">${eventLabel}</div>
1808
+ <div class="log-info">${event.content}</div>
1809
+ </div>
1810
+ `;
1811
+ });
1812
+
1813
+ html += `
1814
+ <div class="log-group" data-req-id="${reqId}">
1815
+ <div class="log-group-header" onclick="toggleGroup('${reqId}')">
1816
+ <span style="color: ${statusColor}; font-weight: 600; font-size: 11px;">⬤ ${statusText}</span>
1817
+ <span style="color: #666; font-size: 11px; margin-left: 8px;">req_${reqId}</span>
1818
+ <span style="color: #999; font-size: 11px; margin-left: 8px;">${log.events.length}条事件</span>
1819
+ <span ${iconClass} style="margin-left: auto; color: #999;">▼</span>
1820
+ </div>
1821
+ <div class="log-group-content" ${contentStyle}>
1822
+ ${eventsHtml}
1823
+ </div>
1824
+ </div>
1825
+ `;
1826
+ });
1827
+
1828
+ container.innerHTML = html;
1829
+ }
1830
+
1831
+ function updateStats(logs, statsData) {
1832
+ const total = logs.length;
1833
+ const successLogs = logs.filter(log => log.status === 'success');
1834
+ const success = successLogs.length;
1835
+ const error = logs.filter(log => log.status === 'error').length;
1836
+
1837
+ // 计算平均响应时间
1838
+ let avgTime = '-';
1839
+ if (success > 0) {
1840
+ let totalDuration = 0;
1841
+ let count = 0;
1842
+ successLogs.forEach(log => {
1843
+ log.events.forEach(event => {
1844
+ if (event.type === 'complete' && event.content.includes('耗时')) {
1845
+ const match = event.content.match(/([\d.]+)s/);
1846
+ if (match) {
1847
+ totalDuration += parseFloat(match[1]);
1848
+ count++;
1849
+ }
1850
+ }
1851
+ });
1852
+ });
1853
+ if (count > 0) {
1854
+ avgTime = (totalDuration / count).toFixed(1) + 's';
1855
+ }
1856
+ }
1857
+
1858
+ // 计算成功率
1859
+ const totalCompleted = success + error;
1860
+ const successRate = totalCompleted > 0 ? ((success / totalCompleted) * 100).toFixed(1) + '%' : '-';
1861
+
1862
+ // 更新日志统计
1863
+ document.getElementById('stat-total').textContent = total;
1864
+ document.getElementById('stat-success').textContent = success;
1865
+ document.getElementById('stat-error').textContent = error;
1866
+ document.getElementById('stat-success-rate').textContent = successRate;
1867
+ document.getElementById('stat-avg-time').textContent = avgTime;
1868
+
1869
+ // 更新全局统计
1870
+ document.getElementById('stat-visitors').textContent = statsData.total_visitors;
1871
+
1872
+ // 更新负载状态(带颜色)
1873
+ const loadElement = document.getElementById('stat-load');
1874
+ loadElement.textContent = statsData.requests_per_minute;
1875
+ loadElement.style.color = statsData.load_color;
1876
+
1877
+ // 更新时间
1878
+ document.getElementById('stat-update-time').textContent = new Date().toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit', second: '2-digit'});
1879
+ }
1880
+
1881
+ function toggleGroup(reqId) {
1882
+ const group = document.querySelector(`.log-group[data-req-id="${reqId}"]`);
1883
+ const content = group.querySelector('.log-group-content');
1884
+ const icon = group.querySelector('.toggle-icon');
1885
+
1886
+ const isCollapsed = content.style.display === 'none';
1887
+ if (isCollapsed) {
1888
+ content.style.display = 'block';
1889
+ icon.classList.remove('collapsed');
1890
+ } else {
1891
+ content.style.display = 'none';
1892
+ icon.classList.add('collapsed');
1893
+ }
1894
+
1895
+ // 保存折叠状态
1896
+ const foldState = JSON.parse(localStorage.getItem('public-log-fold-state') || '{}');
1897
+ foldState[reqId] = !isCollapsed;
1898
+ localStorage.setItem('public-log-fold-state', JSON.stringify(foldState));
1899
+ }
1900
+
1901
+ // 初始加载
1902
+ loadData();
1903
+
1904
+ // 自动刷新(每5秒)
1905
+ setInterval(loadData, 5000);
1906
+ </script>
1907
+ </body>
1908
+ </html>
1909
+ """
1910
+ return HTMLResponse(content=html_content)