ohmyapi commited on
Commit
be70ddd
·
1 Parent(s): bdc62c7

Deploy emergent2api

Browse files
Dockerfile CHANGED
@@ -2,11 +2,7 @@ FROM python:3.12-slim
2
 
3
  WORKDIR /app
4
 
5
- RUN apt-get update && apt-get install -y --no-install-recommends \
6
- gcc libpq-dev && \
7
- rm -rf /var/lib/apt/lists/*
8
-
9
- COPY requirements.txt requirements.txt
10
  RUN pip install --no-cache-dir -r requirements.txt
11
 
12
  COPY . .
 
2
 
3
  WORKDIR /app
4
 
5
+ COPY requirements.txt .
 
 
 
 
6
  RUN pip install --no-cache-dir -r requirements.txt
7
 
8
  COPY . .
emergent2api/__init__.py CHANGED
@@ -1 +0,0 @@
1
-
 
 
emergent2api/app.py CHANGED
@@ -128,16 +128,9 @@ async def root():
128
 
129
  @app.get("/admin")
130
  @app.get("/admin/")
131
- async def admin_index():
132
- return FileResponse(_STATIC_DIR / "login.html")
133
-
134
-
135
  @app.get("/admin/{page}")
136
- async def admin_page(page: str):
137
- html = _STATIC_DIR / f"{page}.html"
138
- if html.is_file():
139
- return FileResponse(html)
140
- return FileResponse(_STATIC_DIR / "login.html")
141
 
142
 
143
  @app.get("/health")
 
128
 
129
  @app.get("/admin")
130
  @app.get("/admin/")
 
 
 
 
131
  @app.get("/admin/{page}")
132
+ async def admin_page(page: str = ""):
133
+ return FileResponse(_STATIC_DIR / "index.html")
 
 
 
134
 
135
 
136
  @app.get("/health")
emergent2api/backends/__init__.py CHANGED
@@ -1 +0,0 @@
1
-
 
 
emergent2api/routes/__init__.py CHANGED
@@ -1 +0,0 @@
1
-
 
 
emergent2api/static/admin/index.html ADDED
@@ -0,0 +1,404 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
5
+ <title>Emergent2API</title>
6
+ <style>
7
+ :root{--bg:#f8f9fa;--card:#fff;--border:#e8e8e8;--text:#1a1a1a;--text2:#666;--text3:#999;--primary:#1a1a1a;--green:#22c55e;--red:#ef4444;--orange:#f59e0b;--blue:#3b82f6;--radius:8px}
8
+ *{margin:0;padding:0;box-sizing:border-box}
9
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);font-size:14px;line-height:1.5}
10
+ a{color:var(--primary);text-decoration:none}
11
+
12
+ /* NAV */
13
+ .nav{background:var(--card);border-bottom:1px solid var(--border);height:48px;display:flex;align-items:center;padding:0 24px;position:sticky;top:0;z-index:100}
14
+ .nav-brand{font-weight:700;font-size:16px;margin-right:8px}
15
+ .nav-user{color:var(--text3);font-size:13px;margin-right:28px}
16
+ .nav-links{display:flex;gap:0}
17
+ .nav-link{padding:12px 16px;font-size:14px;color:var(--text2);cursor:pointer;border-bottom:2px solid transparent;transition:.15s}
18
+ .nav-link:hover{color:var(--text)}
19
+ .nav-link.active{color:var(--text);font-weight:600;border-bottom-color:var(--text)}
20
+ .nav-right{margin-left:auto;display:flex;gap:12px;align-items:center}
21
+ .nav-right span{font-size:13px;color:var(--text3);cursor:pointer}
22
+ .nav-right span:hover{color:var(--red)}
23
+
24
+ /* PAGE */
25
+ .page{display:none;max-width:1100px;margin:0 auto;padding:24px}
26
+ .page.active{display:block}
27
+ .page-title{font-size:22px;font-weight:700;margin-bottom:4px}
28
+ .page-desc{color:var(--text3);font-size:13px;margin-bottom:20px}
29
+
30
+ /* STATS */
31
+ .stats{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px}
32
+ .stat{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:16px 20px}
33
+ .stat-val{font-size:26px;font-weight:700}
34
+ .stat-val.green{color:var(--green)}.stat-val.red{color:var(--red)}.stat-val.orange{color:var(--orange)}
35
+ .stat-label{font-size:12px;color:var(--text3);margin-top:2px}
36
+
37
+ /* FILTERS */
38
+ .filters{display:flex;align-items:center;gap:8px;margin-bottom:16px;flex-wrap:wrap}
39
+ .filter-btn{padding:6px 14px;border:1px solid var(--border);border-radius:16px;font-size:13px;cursor:pointer;background:var(--card);color:var(--text2);transition:.15s}
40
+ .filter-btn:hover{border-color:var(--text3)}
41
+ .filter-btn.active{background:var(--primary);color:#fff;border-color:var(--primary)}
42
+ .filter-count{font-weight:600;margin-left:2px}
43
+ .filter-spacer{flex:1}
44
+ .action-btn{padding:6px 16px;border:none;border-radius:6px;font-size:13px;cursor:pointer;font-weight:500;transition:.15s}
45
+ .btn-dark{background:var(--primary);color:#fff}.btn-dark:hover{opacity:.85}
46
+ .btn-outline{background:var(--card);color:var(--text);border:1px solid var(--border)}.btn-outline:hover{background:var(--bg)}
47
+ .btn-green{background:var(--green);color:#fff}.btn-green:hover{opacity:.85}
48
+ .btn-red{background:var(--red);color:#fff}.btn-red:hover{opacity:.85}
49
+
50
+ /* TABLE */
51
+ .table-wrap{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);overflow-x:auto}
52
+ table{width:100%;border-collapse:collapse;min-width:700px}
53
+ th{text-align:left;padding:10px 14px;font-size:12px;color:var(--text3);font-weight:500;border-bottom:1px solid var(--border);white-space:nowrap}
54
+ td{padding:10px 14px;font-size:13px;border-bottom:1px solid #f5f5f5;white-space:nowrap}
55
+ tr:last-child td{border-bottom:none}
56
+ tr:hover td{background:#fafafa}
57
+ .email-cell{font-family:monospace;font-size:12px;max-width:220px;overflow:hidden;text-overflow:ellipsis}
58
+ .badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600}
59
+ .badge-active{background:#dcfce7;color:#16a34a}.badge-inactive{background:#fee2e2;color:#dc2626}
60
+ .ops{display:flex;gap:6px}
61
+ .op-btn{width:28px;height:28px;border:1px solid var(--border);border-radius:4px;background:var(--card);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:14px;transition:.15s}
62
+ .op-btn:hover{background:var(--bg)}
63
+ .pagination{display:flex;align-items:center;gap:8px;justify-content:center;padding:12px;font-size:13px;color:var(--text3)}
64
+ .pagination button{padding:4px 10px;border:1px solid var(--border);border-radius:4px;background:var(--card);cursor:pointer;font-size:12px}
65
+ .pagination button:hover{background:var(--bg)}
66
+ .pagination button:disabled{opacity:.4;cursor:default}
67
+ .chk{width:15px;height:15px;cursor:pointer}
68
+
69
+ /* MODAL */
70
+ .modal-bg{display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:200;align-items:center;justify-content:center}
71
+ .modal-bg.open{display:flex}
72
+ .modal{background:var(--card);border-radius:12px;padding:24px;width:480px;max-height:80vh;overflow-y:auto;box-shadow:0 8px 32px rgba(0,0,0,.12)}
73
+ .modal h3{font-size:16px;margin-bottom:12px}
74
+ .modal textarea{width:100%;height:180px;padding:10px;border:1px solid var(--border);border-radius:6px;font-family:monospace;font-size:12px;resize:vertical}
75
+ .modal-footer{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}
76
+
77
+ /* CONFIG */
78
+ .config-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:24px;margin-bottom:16px}
79
+ .config-card h3{font-size:15px;font-weight:600;margin-bottom:14px;padding-bottom:10px;border-bottom:1px solid #f0f0f0}
80
+ .field{margin-bottom:14px}
81
+ .field label{display:block;font-size:13px;font-weight:500;color:var(--text2);margin-bottom:4px}
82
+ .field input,.field select{width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:6px;font-size:13px;outline:none}
83
+ .field input:focus,.field select:focus{border-color:var(--text3)}
84
+ .field-row{display:flex;gap:10px;align-items:end}
85
+ .field-row .field{flex:1}
86
+ .hint{font-size:11px;color:var(--text3);margin-top:2px}
87
+
88
+ /* DOCS */
89
+ .doc-section{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:24px;margin-bottom:16px}
90
+ .doc-section h3{font-size:15px;font-weight:600;margin-bottom:10px}
91
+ .doc-section p{font-size:13px;color:var(--text2);margin-bottom:8px}
92
+ pre{background:#1e1e2e;color:#cdd6f4;padding:14px;border-radius:6px;overflow-x:auto;font-size:12px;line-height:1.5;margin:8px 0 12px}
93
+ .doc-table{width:100%;border-collapse:collapse;font-size:13px;margin:8px 0}
94
+ .doc-table th{text-align:left;padding:6px 10px;background:#f9fafb;font-weight:500;border-bottom:1px solid var(--border)}
95
+ .doc-table td{padding:6px 10px;border-bottom:1px solid #f5f5f5}
96
+ .doc-table code{background:#f1f5f9;padding:1px 5px;border-radius:3px;font-size:12px}
97
+
98
+ /* LOGIN */
99
+ .login-wrap{display:flex;align-items:center;justify-content:center;min-height:100vh;background:var(--bg)}
100
+ .login-card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:40px;width:360px;text-align:center}
101
+ .login-card h1{font-size:20px;margin-bottom:4px}
102
+ .login-card p{color:var(--text3);font-size:13px;margin-bottom:24px}
103
+ .login-card input{width:100%;padding:10px 14px;border:1px solid var(--border);border-radius:6px;font-size:14px;outline:none;margin-bottom:14px}
104
+ .login-card input:focus{border-color:var(--text3)}
105
+ .login-card button{width:100%;padding:10px;background:var(--primary);color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
106
+ .login-card button:hover{opacity:.85}
107
+ .login-err{color:var(--red);font-size:12px;display:none;margin-top:8px}
108
+
109
+ /* TOAST */
110
+ .toast{position:fixed;bottom:20px;right:20px;background:var(--primary);color:#fff;padding:10px 18px;border-radius:8px;font-size:13px;z-index:300;opacity:0;transition:opacity .25s;pointer-events:none}
111
+ .toast.show{opacity:1}
112
+
113
+ @media(max-width:768px){
114
+ .stats{grid-template-columns:repeat(2,1fr)}
115
+ .nav-links{font-size:13px}
116
+ .page{padding:16px}
117
+ }
118
+ </style>
119
+ </head>
120
+ <body>
121
+
122
+ <!-- LOGIN -->
123
+ <div id="view-login" class="login-wrap">
124
+ <div class="login-card">
125
+ <h1>Emergent2API</h1>
126
+ <p>管理面板</p>
127
+ <input type="password" id="pwd" placeholder="管理员密码" autofocus>
128
+ <button onclick="doLogin()">登录</button>
129
+ <div class="login-err" id="login-err">密码错误</div>
130
+ </div>
131
+ </div>
132
+
133
+ <!-- MAIN -->
134
+ <div id="view-main" style="display:none">
135
+ <nav class="nav">
136
+ <span class="nav-brand">Emergent2API</span>
137
+ <div class="nav-links">
138
+ <span class="nav-link active" data-page="tokens" onclick="showPage('tokens')">Token管理</span>
139
+ <span class="nav-link" data-page="config" onclick="showPage('config')">配置管理</span>
140
+ <span class="nav-link" data-page="docs" onclick="showPage('docs')">使用文档</span>
141
+ </div>
142
+ <div class="nav-right">
143
+ <span onclick="doLogout()">退出</span>
144
+ </div>
145
+ </nav>
146
+
147
+ <!-- TOKEN PAGE -->
148
+ <div id="page-tokens" class="page active">
149
+ <div class="page-title">Token 列表</div>
150
+ <div class="page-desc">管理 Emergent2API 的 Token 服务号池。</div>
151
+ <div class="stats">
152
+ <div class="stat"><div class="stat-val" id="s-total">0</div><div class="stat-label">Token 总数</div></div>
153
+ <div class="stat"><div class="stat-val green" id="s-active">0</div><div class="stat-label">Token 正常</div></div>
154
+ <div class="stat"><div class="stat-val red" id="s-inactive">0</div><div class="stat-label">Token 失效</div></div>
155
+ <div class="stat"><div class="stat-val" id="s-usage">0</div><div class="stat-label">总调用次数</div></div>
156
+ </div>
157
+ <div class="filters">
158
+ <span class="filter-btn active" data-f="all" onclick="setFilter('all')">全部 <b class="filter-count" id="fc-all">0</b></span>
159
+ <span class="filter-btn" data-f="active" onclick="setFilter('active')">正常 <b class="filter-count" id="fc-active">0</b></span>
160
+ <span class="filter-btn" data-f="inactive" onclick="setFilter('inactive')">失效 <b class="filter-count" id="fc-inactive">0</b></span>
161
+ <span class="filter-spacer"></span>
162
+ <button class="action-btn btn-outline" onclick="openImport()">导入</button>
163
+ <button class="action-btn btn-outline" onclick="doExport()">导出</button>
164
+ <button class="action-btn btn-green" onclick="batchOp('test')">测试</button>
165
+ <button class="action-btn btn-red" onclick="batchOp('delete')">删除选中</button>
166
+ </div>
167
+ <div class="table-wrap">
168
+ <table>
169
+ <thead><tr>
170
+ <th><input type="checkbox" class="chk" id="chk-all" onchange="toggleAll(this.checked)"></th>
171
+ <th>Token</th><th>状态</th><th>余额</th><th>最后使用</th><th>操作</th>
172
+ </tr></thead>
173
+ <tbody id="tbody"></tbody>
174
+ </table>
175
+ <div class="pagination" id="pager"></div>
176
+ </div>
177
+ </div>
178
+
179
+ <!-- CONFIG PAGE -->
180
+ <div id="page-config" class="page">
181
+ <div class="page-title">配置管理</div>
182
+ <div class="page-desc">管理 API 密钥、后端设置和管理员密码。</div>
183
+ <div class="config-card">
184
+ <h3>API 设置</h3>
185
+ <div class="field-row">
186
+ <div class="field"><label>API Key</label><input id="cfg-api_key" placeholder="sk-..."></div>
187
+ <div style="padding-bottom:14px"><button class="action-btn btn-dark" onclick="genKey()">生成</button></div>
188
+ </div>
189
+ <div class="field">
190
+ <label>后端</label>
191
+ <select id="cfg-backend"><option value="jobs">Jobs API(无 IP 限制)</option><option value="integrations">Integrations API(更快,需要代理)</option></select>
192
+ </div>
193
+ <div class="field"><label>代理</label><input id="cfg-proxy" placeholder="http://user:pass@host:port"><div class="hint">Integrations 后端必填;Jobs 可选</div></div>
194
+ </div>
195
+ <div class="config-card">
196
+ <h3>管理员设置</h3>
197
+ <div class="field"><label>管理员密码</label><input id="cfg-admin_password" type="password"></div>
198
+ </div>
199
+ <div style="display:flex;gap:8px;justify-content:flex-end">
200
+ <button class="action-btn btn-outline" onclick="loadConfig()">重置</button>
201
+ <button class="action-btn btn-dark" onclick="saveConfig()">保存</button>
202
+ </div>
203
+ </div>
204
+
205
+ <!-- DOCS PAGE -->
206
+ <div id="page-docs" class="page">
207
+ <div class="page-title">使用文档</div>
208
+ <div class="page-desc">API 端点文档和使用示例。</div>
209
+ <div class="doc-section">
210
+ <h3>可用模型</h3>
211
+ <table class="doc-table"><tr><th>Model ID</th><th>说明</th></tr>
212
+ <tr><td><code>claude-opus-4-6</code></td><td>Claude Opus 4.6(满血版)</td></tr>
213
+ <tr><td><code>claude-sonnet-4-5</code></td><td>Claude Sonnet 4.5</td></tr>
214
+ <tr><td><code>claude-sonnet-4-5-thinking</code></td><td>Claude Sonnet 4.5(深度思考)</td></tr>
215
+ </table>
216
+ </div>
217
+ <div class="doc-section">
218
+ <h3>API 端点</h3>
219
+ <table class="doc-table"><tr><th>方法</th><th>端点</th><th>格式</th></tr>
220
+ <tr><td>POST</td><td><code>/v1/chat/completions</code></td><td>OpenAI Chat</td></tr>
221
+ <tr><td>POST</td><td><code>/v1/messages</code></td><td>Anthropic Messages</td></tr>
222
+ <tr><td>POST</td><td><code>/v1/responses</code></td><td>OpenAI Response API</td></tr>
223
+ <tr><td>GET</td><td><code>/v1/models</code></td><td>模型列表</td></tr>
224
+ </table>
225
+ </div>
226
+ <div class="doc-section">
227
+ <h3>curl 示例</h3>
228
+ <pre>curl -X POST https://your-host/v1/chat/completions \
229
+ -H "Authorization: Bearer YOUR_API_KEY" \
230
+ -H "Content-Type: application/json" \
231
+ -d '{
232
+ "model": "claude-opus-4-6",
233
+ "messages": [{"role": "user", "content": "Hello!"}],
234
+ "stream": true
235
+ }'</pre>
236
+ </div>
237
+ <div class="doc-section">
238
+ <h3>Python 示例(OpenAI SDK)</h3>
239
+ <pre>from openai import OpenAI
240
+
241
+ client = OpenAI(api_key="YOUR_API_KEY", base_url="https://your-host/v1")
242
+ resp = client.chat.completions.create(
243
+ model="claude-opus-4-6",
244
+ messages=[{"role": "user", "content": "Hello!"}],
245
+ stream=True
246
+ )
247
+ for chunk in resp:
248
+ print(chunk.choices[0].delta.content or "", end="")</pre>
249
+ </div>
250
+ </div>
251
+
252
+ </div><!-- /view-main -->
253
+
254
+ <!-- IMPORT MODAL -->
255
+ <div class="modal-bg" id="modal-import">
256
+ <div class="modal">
257
+ <h3>导入 Token</h3>
258
+ <p style="font-size:13px;color:var(--text3);margin-bottom:8px">粘贴 JSONL 数据(每行一个账号:email, password, jwt 必填)</p>
259
+ <textarea id="import-text" placeholder='{"email":"...","password":"...","jwt":"..."}'></textarea>
260
+ <div class="modal-footer">
261
+ <button class="action-btn btn-outline" onclick="closeImport()">取消</button>
262
+ <button class="action-btn btn-dark" onclick="doImport()">导入</button>
263
+ </div>
264
+ </div>
265
+ </div>
266
+
267
+ <div class="toast" id="toast"></div>
268
+
269
+ <script>
270
+ const API='/v1/admin';
271
+ let tokens=[],filter='all',selected=new Set(),currentPage=1,pageSize=50;
272
+
273
+ async function api(p,o={}){const r=await fetch(API+p,o);if(r.status===401){showLogin();return null}return r}
274
+ function toast(m,d=2500){const t=document.getElementById('toast');t.textContent=m;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),d)}
275
+ function showLogin(){document.getElementById('view-login').style.display='';document.getElementById('view-main').style.display='none'}
276
+ function showMain(){document.getElementById('view-login').style.display='none';document.getElementById('view-main').style.display=''}
277
+
278
+ document.getElementById('pwd').addEventListener('keydown',e=>{if(e.key==='Enter')doLogin()});
279
+
280
+ async function doLogin(){
281
+ const p=document.getElementById('pwd').value;
282
+ const r=await fetch(API+'/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:p})});
283
+ if(r.ok){showMain();load();loadConfig()}
284
+ else document.getElementById('login-err').style.display='block';
285
+ }
286
+ async function doLogout(){await api('/logout',{method:'POST'});showLogin()}
287
+
288
+ async function checkAuth(){
289
+ const r=await fetch(API+'/verify');
290
+ if(r.ok){showMain();load();loadConfig()}
291
+ }
292
+ checkAuth();
293
+
294
+ function showPage(p){
295
+ document.querySelectorAll('.page').forEach(el=>el.classList.remove('active'));
296
+ document.getElementById('page-'+p).classList.add('active');
297
+ document.querySelectorAll('.nav-link').forEach(el=>el.classList.toggle('active',el.dataset.page===p));
298
+ }
299
+
300
+ async function load(){
301
+ const r=await api('/tokens');if(!r)return;
302
+ const d=await r.json();
303
+ tokens=d.tokens||[];
304
+ const act=d.active,tot=d.total,inact=tot-act;
305
+ document.getElementById('s-total').textContent=tot;
306
+ document.getElementById('s-active').textContent=act;
307
+ document.getElementById('s-inactive').textContent=inact;
308
+ document.getElementById('s-usage').textContent=tokens.reduce((s,t)=>s+(t.use_count||0),0);
309
+ document.getElementById('fc-all').textContent=tot;
310
+ document.getElementById('fc-active').textContent=act;
311
+ document.getElementById('fc-inactive').textContent=inact;
312
+ currentPage=1;
313
+ render();
314
+ }
315
+
316
+ function getFiltered(){
317
+ if(filter==='active')return tokens.filter(t=>t.is_active);
318
+ if(filter==='inactive')return tokens.filter(t=>!t.is_active);
319
+ return tokens;
320
+ }
321
+
322
+ function render(){
323
+ const list=getFiltered();
324
+ const totalPages=Math.max(1,Math.ceil(list.length/pageSize));
325
+ if(currentPage>totalPages)currentPage=totalPages;
326
+ const start=(currentPage-1)*pageSize,end=start+pageSize;
327
+ const page=list.slice(start,end);
328
+ const tbody=document.getElementById('tbody');
329
+ tbody.innerHTML=page.map(t=>{
330
+ const em=t.email||'';
331
+ const short=em.length>28?em.slice(0,12)+'...'+em.slice(-12):em;
332
+ return `<tr>
333
+ <td><input type="checkbox" class="chk" data-id="${t.id}" ${selected.has(t.id)?'checked':''} onchange="toggleSel(${t.id},this.checked)"></td>
334
+ <td class="email-cell" title="${em}">${short}</td>
335
+ <td><span class="badge ${t.is_active?'badge-active':'badge-inactive'}">${t.is_active?'active':'inactive'}</span></td>
336
+ <td>$${(t.balance||0).toFixed(2)}</td>
337
+ <td style="color:var(--text3);font-size:12px">${t.last_used?new Date(t.last_used).toLocaleString('zh-CN'):'-'}</td>
338
+ <td class="ops">
339
+ <span class="op-btn" title="刷新" onclick="refreshOne(${t.id})">↻</span>
340
+ <span class="op-btn" title="${t.is_active?'停用':'启用'}" onclick="toggleOne(${t.id})">×</span>
341
+ <span class="op-btn" title="删除" onclick="deleteOne(${t.id})">🗑</span>
342
+ </td>
343
+ </tr>`}).join('');
344
+ document.getElementById('pager').innerHTML=list.length>pageSize?`
345
+ <button ${currentPage<=1?'disabled':''} onclick="goPage(${currentPage-1})">‹</button>
346
+ <span>第 ${currentPage}/${totalPages} 页 · 共 ${list.length} 条</span>
347
+ <button ${currentPage>=totalPages?'disabled':''} onclick="goPage(${currentPage+1})">›</button>`:'';
348
+ }
349
+
350
+ function goPage(p){currentPage=p;render()}
351
+ function setFilter(f){filter=f;currentPage=1;document.querySelectorAll('.filter-btn').forEach(el=>el.classList.toggle('active',el.dataset.f===f));render()}
352
+ function toggleSel(id,c){if(c)selected.add(id);else selected.delete(id)}
353
+ function toggleAll(c){const list=getFiltered();const start=(currentPage-1)*pageSize;list.slice(start,start+pageSize).forEach(t=>{if(c)selected.add(t.id);else selected.delete(t.id)});render()}
354
+
355
+ async function toggleOne(id){
356
+ const t=tokens.find(x=>x.id===id);if(!t)return;
357
+ await api('/tokens/toggle',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids:[id],active:!t.is_active})});
358
+ load();
359
+ }
360
+ async function deleteOne(id){if(!confirm('确定删除?'))return;await api('/tokens/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids:[id]})});load()}
361
+ async function refreshOne(id){toast('刷新中...');const r=await api('/tokens/refresh',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids:[id]})});if(r){const d=await r.json();toast(d.refreshed?'刷新成功':'刷新失败')}load()}
362
+
363
+ async function batchOp(action){
364
+ const ids=[...selected];if(!ids.length){toast('请先选择 Token');return}
365
+ if(action==='delete'&&!confirm(`确定删除 ${ids.length} 个 Token?`))return;
366
+ toast(`处理中 (${ids.length})...`);
367
+ const r=await api(`/tokens/${action}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids})});
368
+ if(r){const d=await r.json();toast(JSON.stringify(d))}
369
+ selected.clear();load();
370
+ }
371
+
372
+ function openImport(){document.getElementById('modal-import').classList.add('open')}
373
+ function closeImport(){document.getElementById('modal-import').classList.remove('open')}
374
+ async function doImport(){
375
+ const t=document.getElementById('import-text').value.trim();
376
+ if(!t){toast('请粘贴 JSONL 数据');return}
377
+ const r=await api('/tokens/import',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({jsonl:t})});
378
+ if(r){const d=await r.json();toast(`导入 ${d.imported} 个`+(d.errors.length?`,${d.errors.length} 个错误`:''));closeImport();load()}
379
+ }
380
+ async function doExport(){
381
+ const r=await api('/tokens/export');if(!r)return;
382
+ const b=await r.blob();const a=document.createElement('a');a.href=URL.createObjectURL(b);a.download='emergent_tokens.zip';a.click();
383
+ }
384
+
385
+ async function loadConfig(){
386
+ const r=await api('/config');if(!r)return;
387
+ const d=await r.json();
388
+ document.getElementById('cfg-api_key').value=d.api_key||'';
389
+ document.getElementById('cfg-backend').value=d.backend||'jobs';
390
+ document.getElementById('cfg-proxy').value=d.proxy||'';
391
+ document.getElementById('cfg-admin_password').value=d.admin_password||'';
392
+ }
393
+ async function saveConfig(){
394
+ const b={api_key:document.getElementById('cfg-api_key').value,backend:document.getElementById('cfg-backend').value,proxy:document.getElementById('cfg-proxy').value,admin_password:document.getElementById('cfg-admin_password').value};
395
+ const r=await api('/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)});
396
+ if(r){const d=await r.json();toast(`已保存: ${d.saved.join(', ')}`)}
397
+ }
398
+ async function genKey(){
399
+ const r=await api('/config/generate-key',{method:'POST'});
400
+ if(r){const d=await r.json();document.getElementById('cfg-api_key').value=d.api_key;toast('已生成新密钥')}
401
+ }
402
+ </script>
403
+ </body>
404
+ </html>