@wendongfly/myhi 1.3.175 → 1.3.180

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/admin.html CHANGED
@@ -1,497 +1,498 @@
1
- <!DOCTYPE html>
2
- <html lang="zh-CN">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>myhi 管理</title>
7
- <style>
8
- * { margin: 0; padding: 0; box-sizing: border-box; }
9
- body { font-family: -apple-system, system-ui, sans-serif; background: #0d1117; color: #e6edf3; min-height: 100vh; }
10
- .login-wrap { display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 1rem; }
11
- .login-box { background: #161b22; border: 1px solid rgba(255,255,255,0.1); border-radius: 16px; padding: 2rem; width: 100%; max-width: 360px; }
12
- .login-box h2 { text-align: center; margin-bottom: 1.5rem; font-size: 1.2rem; }
13
- .login-box input { width: 100%; padding: 0.8rem; background: #0d1117; border: 1px solid rgba(255,255,255,0.15); border-radius: 10px; color: #e6edf3; font-size: 1rem; margin-bottom: 1rem; }
14
- .login-box button { width: 100%; padding: 0.8rem; background: #7c3aed; color: #fff; border: none; border-radius: 10px; font-size: 1rem; font-weight: 600; cursor: pointer; }
15
- .login-box .err { color: #f85149; font-size: 0.85rem; text-align: center; margin-bottom: 0.5rem; min-height: 1.2em; }
16
- .wrap { max-width: 640px; margin: 0 auto; padding: 1rem; }
17
- .header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 0; border-bottom: 1px solid rgba(255,255,255,0.08); margin-bottom: 1.5rem; }
18
- .header h1 { font-size: 1.1rem; }
19
- .header .logout { background: none; border: 1px solid rgba(255,255,255,0.15); color: #8b949e; padding: 0.3rem 0.8rem; border-radius: 8px; cursor: pointer; font-size: 0.8rem; }
20
- .ver-card { background: #161b22; border: 1px solid rgba(255,255,255,0.1); border-radius: 14px; padding: 1.2rem 1.5rem; margin-bottom: 1.2rem; }
21
- .ver-big { font-size: 2rem; font-weight: 800; color: #7c3aed; }
22
- .ver-sub { font-size: 0.85rem; color: #8b949e; margin-top: 0.5rem; }
23
- .ver-sub b { color: #3fb950; }
24
- .actions { display: flex; gap: 0.8rem; margin-bottom: 1.5rem; }
25
- .actions button { flex: 1; padding: 0.8rem; border: none; border-radius: 10px; font-size: 0.9rem; font-weight: 600; cursor: pointer; }
26
- .btn-up { background: #7c3aed; color: #fff; }
27
- .btn-up:disabled { background: #333; color: #666; cursor: not-allowed; }
28
- .btn-up.glow { background: #3fb950; animation: pulse 2s infinite; }
29
- .btn-re { background: rgba(255,255,255,0.08); color: #e6edf3; border: 1px solid rgba(255,255,255,0.15) !important; }
30
- @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.7} }
31
- .card { background: #161b22; border: 1px solid rgba(255,255,255,0.1); border-radius: 14px; padding: 1rem 1.2rem; margin-bottom: 1rem; }
32
- .card h3 { font-size: 0.9rem; font-weight: 700; margin-bottom: 0.8rem; }
33
- .row { font-size: 0.82rem; color: #b1bac4; padding: 0.5rem 0; line-height: 1.5; display: flex; justify-content: space-between; align-items: center; }
34
- .row + .row { border-top: 1px solid rgba(255,255,255,0.04); }
35
- .row-actions { display: flex; gap: 0.4rem; }
36
- .row-actions button { background: none; border: 1px solid rgba(255,255,255,0.12); color: #8b949e; padding: 0.2rem 0.5rem; border-radius: 6px; cursor: pointer; font-size: 0.75rem; }
37
- .row-actions button:hover { color: #e6edf3; border-color: rgba(255,255,255,0.3); }
38
- .row-actions .del { color: #f85149; border-color: rgba(248,81,73,0.3); }
39
- .inp { padding: 0.5rem 0.7rem; background: #0d1117; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; color: #e6edf3; font-size: 0.85rem; width: 100%; }
40
- .inp-row { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.5rem; }
41
- .inp-row .inp { flex: 1; }
42
- .btn-sm { padding: 0.5rem 1rem; background: #7c3aed; color: #fff; border: none; border-radius: 8px; cursor: pointer; font-size: 0.85rem; white-space: nowrap; }
43
- .msg { font-size: 0.8rem; margin-top: 0.3rem; min-height: 1.2em; }
44
- .msg-ok { color: #3fb950; }
45
- .msg-err { color: #f85149; }
46
- .status-msg { text-align: center; padding: 1rem; color: #8b949e; font-size: 0.85rem; }
47
- .uptime { font-size: 0.78rem; color: #6e7681; text-align: center; margin-top: 1rem; }
48
- </style>
49
- </head>
50
- <body>
51
-
52
- <div class="login-wrap" id="pg-login">
53
- <div class="login-box">
54
- <h2>myhi 管理</h2>
55
- <div class="err" id="login-err"></div>
56
- <input type="password" id="admin-pwd" placeholder="输入管理密码" autofocus>
57
- <button onclick="doLogin()">登录</button>
58
- </div>
59
- </div>
60
-
61
- <div class="wrap" id="pg-admin" style="display:none">
62
- <div class="header">
63
- <h1>myhi 管理</h1>
64
- <button class="logout" onclick="doLogout()">退出</button>
65
- </div>
66
-
67
- <!-- 版本 + 升级 -->
68
- <div class="ver-card">
69
- <div style="font-size:0.8rem;color:#8b949e">当前版本</div>
70
- <div class="ver-big" id="v-cur">--</div>
71
- <div class="ver-sub" id="v-latest"></div>
72
- </div>
73
- <div class="actions">
74
- <button class="btn-up" id="btn-up" onclick="doUpgrade()" disabled>已是最新</button>
75
- <button class="btn-re" onclick="doRestart()">重启</button>
76
- </div>
77
- <div id="op-msg" class="status-msg" style="display:none"></div>
78
-
79
- <!-- Claude Code 版本 + 升级 -->
80
- <div class="ver-card">
81
- <div style="font-size:0.8rem;color:#8b949e">Claude Code 版本</div>
82
- <div class="ver-big" id="cl-cur">--</div>
83
- <div class="ver-sub" id="cl-latest"></div>
84
- </div>
85
- <div class="actions">
86
- <button class="btn-up" id="btn-cl-up" onclick="doClaudeUpgrade()" disabled>已是最新</button>
87
- </div>
88
- <div id="cl-msg" class="status-msg" style="display:none"></div>
89
-
90
- <!-- 在线用户 -->
91
- <div class="card">
92
- <h3>在线用户</h3>
93
- <div id="online-users"><div class="row" style="color:#6e7681">加载中...</div></div>
94
- </div>
95
-
96
- <!-- 活跃会话 -->
97
- <div class="card">
98
- <h3>活跃会话</h3>
99
- <div id="active-sessions"><div class="row" style="color:#6e7681">加载中...</div></div>
100
- </div>
101
-
102
- <!-- 用户管理 -->
103
- <div class="card">
104
- <h3>用户管理</h3>
105
- <div id="user-list"></div>
106
- <div style="border-top:1px solid rgba(255,255,255,0.08);margin-top:0.5rem;padding-top:0.8rem">
107
- <div style="font-size:0.82rem;color:#8b949e;margin-bottom:0.5rem">添加用户</div>
108
- <div class="inp-row">
109
- <input class="inp" id="u-pwd" placeholder="密码">
110
- <input class="inp" id="u-name" placeholder="名称">
111
- </div>
112
- <div class="inp-row">
113
- <input class="inp" id="u-dir" placeholder="工作目录">
114
- <button class="btn-sm" onclick="addUser()">添加</button>
115
- </div>
116
- <div class="msg" id="u-msg"></div>
117
- </div>
118
- </div>
119
-
120
- <!-- 修改管理密码 -->
121
- <div class="card">
122
- <h3>修改管理密码</h3>
123
- <div class="inp-row">
124
- <input type="password" class="inp" id="new-pwd" placeholder="新密码">
125
- <button class="btn-sm" onclick="changePwd()">保存</button>
126
- </div>
127
- <div class="msg" id="pwd-msg"></div>
128
- </div>
129
-
130
- <!-- 大模型网关 -->
131
- <div class="card">
132
- <h3>大模型网关</h3>
133
- <div style="font-size:0.75rem;color:#6e7681;margin-bottom:0.6rem;line-height:1.5">
134
- 配置后新建的 Claude 会话自动注入 <code style="background:#161b22;padding:0.1em 0.3em;border-radius:3px">ANTHROPIC_BASE_URL</code> / <code style="background:#161b22;padding:0.1em 0.3em;border-radius:3px">ANTHROPIC_API_KEY</code>
135
- </div>
136
- <div class="inp-row">
137
- <input type="text" class="inp" id="gw-url" placeholder="网关 URL(如 https://gateway.example.com)">
138
- </div>
139
- <div class="inp-row" style="margin-top:0.4rem">
140
- <input type="password" class="inp" id="gw-key" placeholder="Token / API Key(留空保持不变)">
141
- <button class="btn-sm" onclick="saveGateway()">保存</button>
142
- </div>
143
- <div class="msg" id="gw-msg"></div>
144
- </div>
145
-
146
- <!-- 命名密钥库 -->
147
- <div class="card">
148
- <h3>密钥库(Secret Vault)</h3>
149
- <div style="font-size:0.75rem;color:#6e7681;margin-bottom:0.6rem;line-height:1.5">
150
- 集中存 GitLab PAT / 部署 token / 网关 key / 证书等敏感值,spawn 会话时注入成环境变量,AI/命令用
151
- <code style="background:#161b22;padding:0.1em 0.3em;border-radius:3px">$名字</code>
152
- 引用——密钥值不进聊天、不落 transcript。类型选「文件」(证书等)时注入的是路径
153
- <code style="background:#161b22;padding:0.1em 0.3em;border-radius:3px">$MYHI_SECRET_名字</code>。
154
- 作用域「项目」仅注给该 cwd 的会话(最小权限)。<b>密钥值绝不写进记忆/CLAUDE.md。</b>
155
- </div>
156
- <div id="sec-list" style="margin-bottom:0.6rem"></div>
157
- <div class="inp-row" style="gap:0.4rem;flex-wrap:wrap">
158
- <input type="text" class="inp" id="sec-name" placeholder="名字(如 GITLAB_PAT,作环境变量名)" style="flex:1;min-width:150px">
159
- <select class="inp" id="sec-type" style="max-width:90px"><option value="env">变量</option><option value="file">文件</option></select>
160
- <select class="inp" id="sec-scope" style="max-width:100px" onchange="toggleSecScope()"><option value="global">全局</option><option value="project">项目</option></select>
161
- </div>
162
- <div class="inp-row" id="sec-cwd-row" style="margin-top:0.4rem;display:none">
163
- <input type="text" class="inp" id="sec-cwd" placeholder="项目 cwd(如 D:\project\myhi)">
164
- </div>
165
- <div class="inp-row" style="margin-top:0.4rem">
166
- <input type="password" class="inp" id="sec-value" placeholder="密钥值 / 证书内容(编辑时留空保持不变)">
167
- <button class="btn-sm" onclick="addSecret()">保存</button>
168
- </div>
169
- <div class="msg" id="sec-msg"></div>
170
- </div>
171
-
172
- <div class="uptime" id="uptime"></div>
173
- </div>
174
-
175
- <script>
176
- let T = sessionStorage.getItem('myhi_admin');
177
- const H = () => ({ 'Authorization': 'Bearer ' + T, 'Content-Type': 'application/json' });
178
-
179
- document.getElementById('admin-pwd').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
180
-
181
- async function doLogin() {
182
- const pwd = document.getElementById('admin-pwd').value;
183
- if (!pwd) return;
184
- try {
185
- const r = await fetch('/api/admin/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pwd }) });
186
- const d = await r.json();
187
- if (d.ok) { T = d.token; sessionStorage.setItem('myhi_admin', T); showAdmin(); }
188
- else document.getElementById('login-err').textContent = d.error || '密码错误';
189
- } catch { document.getElementById('login-err').textContent = '连接失败'; }
190
- }
191
-
192
- function doLogout() {
193
- sessionStorage.removeItem('myhi_admin'); T = null;
194
- document.getElementById('pg-admin').style.display = 'none';
195
- document.getElementById('pg-login').style.display = '';
196
- }
197
-
198
- async function showAdmin() {
199
- document.getElementById('pg-login').style.display = 'none';
200
- document.getElementById('pg-admin').style.display = '';
201
- await refresh();
202
- setInterval(refresh, 10000);
203
- loadGateway();
204
- loadSecrets();
205
- }
206
-
207
- function toggleSecScope() {
208
- const isProj = document.getElementById('sec-scope').value === 'project';
209
- document.getElementById('sec-cwd-row').style.display = isProj ? '' : 'none';
210
- }
211
-
212
- function escHtml(s) { return String(s).replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c])); }
213
-
214
- async function loadSecrets() {
215
- try {
216
- const r = await fetch('/api/secrets', { headers: H() });
217
- if (!r.ok) return;
218
- const d = await r.json();
219
- const box = document.getElementById('sec-list');
220
- let html = '';
221
- const row = (s, scope, cwd) => `<div style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;border-bottom:1px solid #21262d;font-size:0.8rem">
222
- <span style="color:#e6edf3;font-family:monospace">${escHtml(s.name)}</span>
223
- <span style="color:#6e7681">${s.type === 'file' ? '📄文件' : '🔑变量'}</span>
224
- <span style="color:#6e7681;font-family:monospace;flex:1">${escHtml(s.preview)}</span>
225
- <button class="btn-sm" style="padding:0.1rem 0.5rem" onclick="delSecret('${escHtml(s.name)}','${scope}','${escHtml(cwd || '')}')">删</button>
226
- </div>`;
227
- if (d.global && d.global.length) {
228
- html += '<div style="color:#8b949e;font-size:0.72rem;margin:0.2rem 0">全局</div>';
229
- html += d.global.map(s => row(s, 'global', '')).join('');
230
- }
231
- for (const p of (d.projects || [])) {
232
- if (!p.secrets.length) continue;
233
- html += `<div style="color:#8b949e;font-size:0.72rem;margin:0.4rem 0 0.2rem;font-family:monospace">项目 ${escHtml(p.cwd)}</div>`;
234
- html += p.secrets.map(s => row(s, 'project', p.cwd)).join('');
235
- }
236
- box.innerHTML = html || '<div style="color:#6e7681;font-size:0.78rem">(暂无密钥)</div>';
237
- } catch {}
238
- }
239
-
240
- async function addSecret() {
241
- const name = document.getElementById('sec-name').value.trim();
242
- const value = document.getElementById('sec-value').value;
243
- const type = document.getElementById('sec-type').value;
244
- const scope = document.getElementById('sec-scope').value;
245
- const cwd = document.getElementById('sec-cwd').value.trim();
246
- const msg = document.getElementById('sec-msg');
247
- try {
248
- const r = await fetch('/api/secrets', { method: 'POST', headers: H(), body: JSON.stringify({ name, value, type, scope, cwd }) });
249
- const d = await r.json();
250
- if (d.ok) {
251
- msg.className = 'msg msg-ok';
252
- msg.textContent = '已保存,新建(该作用域的)会话自动生效';
253
- document.getElementById('sec-name').value = '';
254
- document.getElementById('sec-value').value = '';
255
- loadSecrets();
256
- } else {
257
- msg.className = 'msg msg-err';
258
- msg.textContent = d.error || '失败';
259
- }
260
- } catch {
261
- msg.className = 'msg msg-err';
262
- msg.textContent = '连接失败';
263
- }
264
- }
265
-
266
- async function delSecret(name, scope, cwd) {
267
- if (!confirm(`删除密钥 ${name}?`)) return;
268
- try {
269
- await fetch('/api/secrets', { method: 'DELETE', headers: H(), body: JSON.stringify({ name, scope, cwd }) });
270
- loadSecrets();
271
- } catch {}
272
- }
273
-
274
- async function loadGateway() {
275
- try {
276
- const r = await fetch('/api/gateway', { headers: H() });
277
- if (!r.ok) return;
278
- const d = await r.json();
279
- document.getElementById('gw-url').value = d.baseUrl || '';
280
- document.getElementById('gw-key').placeholder = d.hasKey ? `已设置(${d.apiKey}),留空保持不变` : 'Token / API Key';
281
- } catch {}
282
- }
283
-
284
- async function saveGateway() {
285
- const baseUrl = document.getElementById('gw-url').value.trim();
286
- const apiKey = document.getElementById('gw-key').value.trim();
287
- const msg = document.getElementById('gw-msg');
288
- try {
289
- const r = await fetch('/api/gateway', {
290
- method: 'POST',
291
- headers: H(),
292
- body: JSON.stringify({ baseUrl, apiKey }),
293
- });
294
- const d = await r.json();
295
- if (d.ok) {
296
- msg.className = 'msg msg-ok';
297
- msg.textContent = '已保存,新建 Claude 会话自动生效';
298
- document.getElementById('gw-key').value = '';
299
- loadGateway();
300
- } else {
301
- msg.className = 'msg msg-err';
302
- msg.textContent = d.error || '失败';
303
- }
304
- } catch {
305
- msg.className = 'msg msg-err';
306
- msg.textContent = '连接失败';
307
- }
308
- }
309
-
310
- async function refresh() {
311
- try {
312
- const r = await fetch('/api/admin/status', { headers: H() });
313
- if (r.status === 401) { doLogout(); return; }
314
- const d = await r.json();
315
-
316
- // 版本
317
- document.getElementById('v-cur').textContent = 'v' + d.version.current;
318
- const btn = document.getElementById('btn-up');
319
- if (d.version.updateAvailable) {
320
- document.getElementById('v-latest').innerHTML = '最新版本: <b>v' + d.version.latest + '</b>';
321
- btn.disabled = false; btn.classList.add('glow'); btn.textContent = '升级到 v' + d.version.latest;
322
- } else {
323
- document.getElementById('v-latest').innerHTML = '<span style="color:#3fb950">已是最新版本</span>';
324
- btn.disabled = true; btn.classList.remove('glow'); btn.textContent = '已是最新';
325
- }
326
-
327
- // Claude Code 版本(独立端点)
328
- try {
329
- const cr = await fetch('/api/claude/version', { headers: H() });
330
- if (cr.ok) {
331
- const cd = await cr.json();
332
- document.getElementById('cl-cur').textContent = cd.current ? ('v' + cd.current) : '未检测到';
333
- const clBtn = document.getElementById('btn-cl-up');
334
- if (cd.upgrade?.running) {
335
- document.getElementById('cl-latest').innerHTML = '<span style="color:#58a6ff">升级中...</span>';
336
- clBtn.disabled = true; clBtn.textContent = '升级中...';
337
- } else if (cd.updateAvailable) {
338
- document.getElementById('cl-latest').innerHTML = '最新版本: <b>v' + cd.latest + '</b>';
339
- clBtn.disabled = false; clBtn.classList.add('glow'); clBtn.textContent = '升级到 v' + cd.latest;
340
- } else {
341
- document.getElementById('cl-latest').innerHTML = '<span style="color:#3fb950">已是最新版本</span>';
342
- clBtn.disabled = true; clBtn.classList.remove('glow'); clBtn.textContent = '已是最新';
343
- }
344
- }
345
- } catch {}
346
-
347
- // 在线用户
348
- const ouEl = document.getElementById('online-users');
349
- if (!d.onlineUsers?.length) {
350
- ouEl.innerHTML = '<div class="row" style="color:#6e7681">无在线用户</div>';
351
- } else {
352
- ouEl.innerHTML = d.onlineUsers.map(u =>
353
- `<div class="row"><span>${esc(u.name)}</span><span style="color:#6e7681">${esc(u.role)}</span></div>`
354
- ).join('');
355
- }
356
-
357
- // 活跃会话
358
- const asEl = document.getElementById('active-sessions');
359
- if (!d.sessions?.length) {
360
- asEl.innerHTML = '<div class="row" style="color:#6e7681">无活跃会话</div>';
361
- } else {
362
- asEl.innerHTML = d.sessions.map(s => {
363
- const tag = s.mode === 'agent'
364
- ? '<span style="display:inline-block;font-size:0.7rem;padding:0.1rem 0.4rem;border-radius:4px;background:rgba(124,58,237,0.2);color:#a78bfa">Agent</span>'
365
- : '<span style="display:inline-block;font-size:0.7rem;padding:0.1rem 0.4rem;border-radius:4px;background:rgba(63,185,80,0.2);color:#3fb950">PTY</span>';
366
- return `<div class="row"><span>${tag} ${esc(s.title)} <span style="color:#6e7681">${esc(s.owner||'')}</span></span><span style="color:#6e7681">${s.viewers}人</span></div>`;
367
- }).join('');
368
- }
369
-
370
- // 用户列表
371
- renderUsers(d.users || []);
372
-
373
- // 运行时长
374
- if (d.uptime) {
375
- const h = Math.floor(d.uptime / 3600), m = Math.floor((d.uptime % 3600) / 60);
376
- document.getElementById('uptime').textContent = `服务运行 ${h}小时${m}分钟`;
377
- }
378
- } catch {}
379
- }
380
-
381
- function renderUsers(users) {
382
- const el = document.getElementById('user-list');
383
- if (!users.length) { el.innerHTML = '<div class="row" style="color:#6e7681">暂无用户</div>'; return; }
384
- el.innerHTML = users.map(u => `
385
- <div class="row">
386
- <span><b style="color:#e6edf3">${esc(u.name)}</b> <span style="color:#6e7681;font-size:0.75rem">${esc(u.password)}</span> <span style="color:#6e7681;font-size:0.72rem">${esc(u.dir)}</span></span>
387
- <div class="row-actions">
388
- <button onclick="editUserPwd('${esc(u.name)}')">改密</button>
389
- <button class="del" onclick="delUser('${esc(u.name)}')">删除</button>
390
- </div>
391
- </div>
392
- `).join('');
393
- }
394
-
395
- function esc(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
396
-
397
- async function addUser() {
398
- const pwd = document.getElementById('u-pwd').value.trim();
399
- const name = document.getElementById('u-name').value.trim();
400
- const dir = document.getElementById('u-dir').value.trim();
401
- const msg = document.getElementById('u-msg');
402
- if (!pwd || !name || !dir) { msg.className = 'msg msg-err'; msg.textContent = '密码、名称、目录都必填'; return; }
403
- try {
404
- const r = await fetch('/api/admin/user', { method: 'POST', headers: H(), body: JSON.stringify({ password: pwd, name, dir }) });
405
- const d = await r.json();
406
- if (d.ok) { msg.className = 'msg msg-ok'; msg.textContent = '已添加'; document.getElementById('u-pwd').value = ''; document.getElementById('u-name').value = ''; document.getElementById('u-dir').value = ''; refresh(); }
407
- else { msg.className = 'msg msg-err'; msg.textContent = d.error || '失败'; }
408
- } catch { msg.className = 'msg msg-err'; msg.textContent = '连接失败'; }
409
- }
410
-
411
- async function editUserPwd(name) {
412
- const newPwd = prompt(`修改 ${name} 的密码:`);
413
- if (!newPwd) return;
414
- try {
415
- const r = await fetch('/api/admin/user/password', { method: 'POST', headers: H(), body: JSON.stringify({ name, password: newPwd }) });
416
- const d = await r.json();
417
- if (d.ok) { alert('密码已修改'); refresh(); }
418
- else alert(d.error || '失败');
419
- } catch { alert('连接失败'); }
420
- }
421
-
422
- async function delUser(name) {
423
- if (!confirm(`确定删除用户 ${name}?`)) return;
424
- try {
425
- const r = await fetch('/api/admin/user', { method: 'DELETE', headers: H(), body: JSON.stringify({ name }) });
426
- const d = await r.json();
427
- if (d.ok) refresh();
428
- else alert(d.error || '失败');
429
- } catch { alert('连接失败'); }
430
- }
431
-
432
- async function changePwd() {
433
- const pwd = document.getElementById('new-pwd').value.trim();
434
- const msg = document.getElementById('pwd-msg');
435
- if (!pwd || pwd.length < 4) { msg.className = 'msg msg-err'; msg.textContent = '密码至少4位'; return; }
436
- try {
437
- const r = await fetch('/api/admin/password', { method: 'POST', headers: H(), body: JSON.stringify({ password: pwd }) });
438
- const d = await r.json();
439
- if (d.ok) { msg.className = 'msg msg-ok'; msg.textContent = '已修改'; document.getElementById('new-pwd').value = ''; }
440
- else { msg.className = 'msg msg-err'; msg.textContent = d.error || '失败'; }
441
- } catch { msg.className = 'msg msg-err'; msg.textContent = '连接失败'; }
442
- }
443
-
444
- async function doUpgrade() {
445
- const btn = document.getElementById('btn-up'), msg = document.getElementById('op-msg');
446
- btn.disabled = true; btn.textContent = '升级中...'; msg.textContent = '正在下载安装...'; msg.style.display = '';
447
- try {
448
- const r = await fetch('/api/admin/upgrade', { method: 'POST', headers: H() });
449
- const d = await r.json();
450
- if (d.ok) { msg.textContent = '升级完成,重启中...'; waitReload(); }
451
- else { msg.textContent = '失败: ' + (d.error||''); btn.disabled = false; btn.textContent = '重试'; }
452
- } catch { msg.textContent = '连接失败'; btn.disabled = false; btn.textContent = '重试'; }
453
- }
454
-
455
- async function doClaudeUpgrade() {
456
- const btn = document.getElementById('btn-cl-up'), msg = document.getElementById('cl-msg');
457
- if (!confirm('升级 Claude Code?\nmyhi 会用 npm --include=optional 装全平台二进制,避免占位符坑。\n不影响正在跑的会话,升级后新查询生效。')) return;
458
- btn.disabled = true; btn.textContent = '升级中...'; msg.className = 'status-msg'; msg.textContent = '正在下载安装(~260MB,可能几分钟)...'; msg.style.display = '';
459
- try {
460
- const r = await fetch('/api/claude/update', { method: 'POST', headers: H() });
461
- const d = await r.json();
462
- if (!d.ok) { msg.textContent = '失败: ' + (d.error||''); btn.disabled = false; btn.textContent = '重试'; return; }
463
- const poll = setInterval(async () => {
464
- try {
465
- const sr = await fetch('/api/claude/update-status', { headers: H() });
466
- const s = await sr.json();
467
- if (!s.running) {
468
- clearInterval(poll);
469
- if (s.ok) { msg.textContent = '已升级到 v' + s.version; setTimeout(refresh, 500); }
470
- else { msg.textContent = '失败: ' + (s.error||''); btn.disabled = false; btn.textContent = '重试'; }
471
- }
472
- } catch {}
473
- }, 4000);
474
- } catch { msg.textContent = '连接失败'; btn.disabled = false; btn.textContent = '重试'; }
475
- }
476
-
477
- async function doRestart() {
478
- if (!confirm('确定重启?')) return;
479
- const msg = document.getElementById('op-msg');
480
- msg.textContent = '重启中...'; msg.style.display = '';
481
- try { await fetch('/api/admin/restart', { method: 'POST', headers: H() }); } catch {}
482
- waitReload();
483
- }
484
-
485
- async function waitReload() {
486
- await new Promise(r => setTimeout(r, 3000));
487
- for (let i = 0; i < 30; i++) {
488
- try { const r = await fetch('/api/version'); if (r.ok) { location.reload(); return; } } catch {}
489
- await new Promise(r => setTimeout(r, 2000));
490
- }
491
- document.getElementById('op-msg').textContent = '重启超时,请手动刷新';
492
- }
493
-
494
- if (T) showAdmin();
495
- </script>
496
- </body>
497
- </html>
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>myhi 管理</title>
7
+ <script src="/lib/machine.js"></script>
8
+ <style>
9
+ * { margin: 0; padding: 0; box-sizing: border-box; }
10
+ body { font-family: -apple-system, system-ui, sans-serif; background: #0d1117; color: #e6edf3; min-height: 100vh; }
11
+ .login-wrap { display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 1rem; }
12
+ .login-box { background: #161b22; border: 1px solid rgba(255,255,255,0.1); border-radius: 16px; padding: 2rem; width: 100%; max-width: 360px; }
13
+ .login-box h2 { text-align: center; margin-bottom: 1.5rem; font-size: 1.2rem; }
14
+ .login-box input { width: 100%; padding: 0.8rem; background: #0d1117; border: 1px solid rgba(255,255,255,0.15); border-radius: 10px; color: #e6edf3; font-size: 1rem; margin-bottom: 1rem; }
15
+ .login-box button { width: 100%; padding: 0.8rem; background: #7c3aed; color: #fff; border: none; border-radius: 10px; font-size: 1rem; font-weight: 600; cursor: pointer; }
16
+ .login-box .err { color: #f85149; font-size: 0.85rem; text-align: center; margin-bottom: 0.5rem; min-height: 1.2em; }
17
+ .wrap { max-width: 640px; margin: 0 auto; padding: 1rem; }
18
+ .header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 0; border-bottom: 1px solid rgba(255,255,255,0.08); margin-bottom: 1.5rem; }
19
+ .header h1 { font-size: 1.1rem; }
20
+ .header .logout { background: none; border: 1px solid rgba(255,255,255,0.15); color: #8b949e; padding: 0.3rem 0.8rem; border-radius: 8px; cursor: pointer; font-size: 0.8rem; }
21
+ .ver-card { background: #161b22; border: 1px solid rgba(255,255,255,0.1); border-radius: 14px; padding: 1.2rem 1.5rem; margin-bottom: 1.2rem; }
22
+ .ver-big { font-size: 2rem; font-weight: 800; color: #7c3aed; }
23
+ .ver-sub { font-size: 0.85rem; color: #8b949e; margin-top: 0.5rem; }
24
+ .ver-sub b { color: #3fb950; }
25
+ .actions { display: flex; gap: 0.8rem; margin-bottom: 1.5rem; }
26
+ .actions button { flex: 1; padding: 0.8rem; border: none; border-radius: 10px; font-size: 0.9rem; font-weight: 600; cursor: pointer; }
27
+ .btn-up { background: #7c3aed; color: #fff; }
28
+ .btn-up:disabled { background: #333; color: #666; cursor: not-allowed; }
29
+ .btn-up.glow { background: #3fb950; animation: pulse 2s infinite; }
30
+ .btn-re { background: rgba(255,255,255,0.08); color: #e6edf3; border: 1px solid rgba(255,255,255,0.15) !important; }
31
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.7} }
32
+ .card { background: #161b22; border: 1px solid rgba(255,255,255,0.1); border-radius: 14px; padding: 1rem 1.2rem; margin-bottom: 1rem; }
33
+ .card h3 { font-size: 0.9rem; font-weight: 700; margin-bottom: 0.8rem; }
34
+ .row { font-size: 0.82rem; color: #b1bac4; padding: 0.5rem 0; line-height: 1.5; display: flex; justify-content: space-between; align-items: center; }
35
+ .row + .row { border-top: 1px solid rgba(255,255,255,0.04); }
36
+ .row-actions { display: flex; gap: 0.4rem; }
37
+ .row-actions button { background: none; border: 1px solid rgba(255,255,255,0.12); color: #8b949e; padding: 0.2rem 0.5rem; border-radius: 6px; cursor: pointer; font-size: 0.75rem; }
38
+ .row-actions button:hover { color: #e6edf3; border-color: rgba(255,255,255,0.3); }
39
+ .row-actions .del { color: #f85149; border-color: rgba(248,81,73,0.3); }
40
+ .inp { padding: 0.5rem 0.7rem; background: #0d1117; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; color: #e6edf3; font-size: 0.85rem; width: 100%; }
41
+ .inp-row { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.5rem; }
42
+ .inp-row .inp { flex: 1; }
43
+ .btn-sm { padding: 0.5rem 1rem; background: #7c3aed; color: #fff; border: none; border-radius: 8px; cursor: pointer; font-size: 0.85rem; white-space: nowrap; }
44
+ .msg { font-size: 0.8rem; margin-top: 0.3rem; min-height: 1.2em; }
45
+ .msg-ok { color: #3fb950; }
46
+ .msg-err { color: #f85149; }
47
+ .status-msg { text-align: center; padding: 1rem; color: #8b949e; font-size: 0.85rem; }
48
+ .uptime { font-size: 0.78rem; color: #6e7681; text-align: center; margin-top: 1rem; }
49
+ </style>
50
+ </head>
51
+ <body>
52
+
53
+ <div class="login-wrap" id="pg-login">
54
+ <div class="login-box">
55
+ <h2>myhi 管理</h2>
56
+ <div class="err" id="login-err"></div>
57
+ <input type="password" id="admin-pwd" placeholder="输入管理密码" autofocus>
58
+ <button onclick="doLogin()">登录</button>
59
+ </div>
60
+ </div>
61
+
62
+ <div class="wrap" id="pg-admin" style="display:none">
63
+ <div class="header">
64
+ <h1>myhi 管理</h1>
65
+ <button class="logout" onclick="doLogout()">退出</button>
66
+ </div>
67
+
68
+ <!-- 版本 + 升级 -->
69
+ <div class="ver-card">
70
+ <div style="font-size:0.8rem;color:#8b949e">当前版本</div>
71
+ <div class="ver-big" id="v-cur">--</div>
72
+ <div class="ver-sub" id="v-latest"></div>
73
+ </div>
74
+ <div class="actions">
75
+ <button class="btn-up" id="btn-up" onclick="doUpgrade()" disabled>已是最新</button>
76
+ <button class="btn-re" onclick="doRestart()">重启</button>
77
+ </div>
78
+ <div id="op-msg" class="status-msg" style="display:none"></div>
79
+
80
+ <!-- Claude Code 版本 + 升级 -->
81
+ <div class="ver-card">
82
+ <div style="font-size:0.8rem;color:#8b949e">Claude Code 版本</div>
83
+ <div class="ver-big" id="cl-cur">--</div>
84
+ <div class="ver-sub" id="cl-latest"></div>
85
+ </div>
86
+ <div class="actions">
87
+ <button class="btn-up" id="btn-cl-up" onclick="doClaudeUpgrade()" disabled>已是最新</button>
88
+ </div>
89
+ <div id="cl-msg" class="status-msg" style="display:none"></div>
90
+
91
+ <!-- 在线用户 -->
92
+ <div class="card">
93
+ <h3>在线用户</h3>
94
+ <div id="online-users"><div class="row" style="color:#6e7681">加载中...</div></div>
95
+ </div>
96
+
97
+ <!-- 活跃会话 -->
98
+ <div class="card">
99
+ <h3>活跃会话</h3>
100
+ <div id="active-sessions"><div class="row" style="color:#6e7681">加载中...</div></div>
101
+ </div>
102
+
103
+ <!-- 用户管理 -->
104
+ <div class="card">
105
+ <h3>用户管理</h3>
106
+ <div id="user-list"></div>
107
+ <div style="border-top:1px solid rgba(255,255,255,0.08);margin-top:0.5rem;padding-top:0.8rem">
108
+ <div style="font-size:0.82rem;color:#8b949e;margin-bottom:0.5rem">添加用户</div>
109
+ <div class="inp-row">
110
+ <input class="inp" id="u-pwd" placeholder="密码">
111
+ <input class="inp" id="u-name" placeholder="名称">
112
+ </div>
113
+ <div class="inp-row">
114
+ <input class="inp" id="u-dir" placeholder="工作目录">
115
+ <button class="btn-sm" onclick="addUser()">添加</button>
116
+ </div>
117
+ <div class="msg" id="u-msg"></div>
118
+ </div>
119
+ </div>
120
+
121
+ <!-- 修改管理密码 -->
122
+ <div class="card">
123
+ <h3>修改管理密码</h3>
124
+ <div class="inp-row">
125
+ <input type="password" class="inp" id="new-pwd" placeholder="新密码">
126
+ <button class="btn-sm" onclick="changePwd()">保存</button>
127
+ </div>
128
+ <div class="msg" id="pwd-msg"></div>
129
+ </div>
130
+
131
+ <!-- 大模型网关 -->
132
+ <div class="card">
133
+ <h3>大模型网关</h3>
134
+ <div style="font-size:0.75rem;color:#6e7681;margin-bottom:0.6rem;line-height:1.5">
135
+ 配置后新建的 Claude 会话自动注入 <code style="background:#161b22;padding:0.1em 0.3em;border-radius:3px">ANTHROPIC_BASE_URL</code> / <code style="background:#161b22;padding:0.1em 0.3em;border-radius:3px">ANTHROPIC_API_KEY</code>
136
+ </div>
137
+ <div class="inp-row">
138
+ <input type="text" class="inp" id="gw-url" placeholder="网关 URL(如 https://gateway.example.com)">
139
+ </div>
140
+ <div class="inp-row" style="margin-top:0.4rem">
141
+ <input type="password" class="inp" id="gw-key" placeholder="Token / API Key(留空保持不变)">
142
+ <button class="btn-sm" onclick="saveGateway()">保存</button>
143
+ </div>
144
+ <div class="msg" id="gw-msg"></div>
145
+ </div>
146
+
147
+ <!-- 命名密钥库 -->
148
+ <div class="card">
149
+ <h3>密钥库(Secret Vault)</h3>
150
+ <div style="font-size:0.75rem;color:#6e7681;margin-bottom:0.6rem;line-height:1.5">
151
+ 集中存 GitLab PAT / 部署 token / 网关 key / 证书等敏感值,spawn 会话时注入成环境变量,AI/命令用
152
+ <code style="background:#161b22;padding:0.1em 0.3em;border-radius:3px">$名字</code>
153
+ 引用——密钥值不进聊天、不落 transcript。类型选「文件」(证书等)时注入的是路径
154
+ <code style="background:#161b22;padding:0.1em 0.3em;border-radius:3px">$MYHI_SECRET_名字</code>。
155
+ 作用域「项目」仅注给该 cwd 的会话(最小权限)。<b>密钥值绝不写进记忆/CLAUDE.md。</b>
156
+ </div>
157
+ <div id="sec-list" style="margin-bottom:0.6rem"></div>
158
+ <div class="inp-row" style="gap:0.4rem;flex-wrap:wrap">
159
+ <input type="text" class="inp" id="sec-name" placeholder="名字(如 GITLAB_PAT,作环境变量名)" style="flex:1;min-width:150px">
160
+ <select class="inp" id="sec-type" style="max-width:90px"><option value="env">变量</option><option value="file">文件</option></select>
161
+ <select class="inp" id="sec-scope" style="max-width:100px" onchange="toggleSecScope()"><option value="global">全局</option><option value="project">项目</option></select>
162
+ </div>
163
+ <div class="inp-row" id="sec-cwd-row" style="margin-top:0.4rem;display:none">
164
+ <input type="text" class="inp" id="sec-cwd" placeholder="项目 cwd(如 D:\project\myhi)">
165
+ </div>
166
+ <div class="inp-row" style="margin-top:0.4rem">
167
+ <input type="password" class="inp" id="sec-value" placeholder="密钥值 / 证书内容(编辑时留空保持不变)">
168
+ <button class="btn-sm" onclick="addSecret()">保存</button>
169
+ </div>
170
+ <div class="msg" id="sec-msg"></div>
171
+ </div>
172
+
173
+ <div class="uptime" id="uptime"></div>
174
+ </div>
175
+
176
+ <script>
177
+ let T = sessionStorage.getItem('myhi_admin');
178
+ const H = () => ({ 'Authorization': 'Bearer ' + T, 'Content-Type': 'application/json' });
179
+
180
+ document.getElementById('admin-pwd').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
181
+
182
+ async function doLogin() {
183
+ const pwd = document.getElementById('admin-pwd').value;
184
+ if (!pwd) return;
185
+ try {
186
+ const r = await fetch('/api/admin/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pwd }) });
187
+ const d = await r.json();
188
+ if (d.ok) { T = d.token; sessionStorage.setItem('myhi_admin', T); showAdmin(); }
189
+ else document.getElementById('login-err').textContent = d.error || '密码错误';
190
+ } catch { document.getElementById('login-err').textContent = '连接失败'; }
191
+ }
192
+
193
+ function doLogout() {
194
+ sessionStorage.removeItem('myhi_admin'); T = null;
195
+ document.getElementById('pg-admin').style.display = 'none';
196
+ document.getElementById('pg-login').style.display = '';
197
+ }
198
+
199
+ async function showAdmin() {
200
+ document.getElementById('pg-login').style.display = 'none';
201
+ document.getElementById('pg-admin').style.display = '';
202
+ await refresh();
203
+ setInterval(refresh, 10000);
204
+ loadGateway();
205
+ loadSecrets();
206
+ }
207
+
208
+ function toggleSecScope() {
209
+ const isProj = document.getElementById('sec-scope').value === 'project';
210
+ document.getElementById('sec-cwd-row').style.display = isProj ? '' : 'none';
211
+ }
212
+
213
+ function escHtml(s) { return String(s).replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c])); }
214
+
215
+ async function loadSecrets() {
216
+ try {
217
+ const r = await fetch('/api/secrets', { headers: H() });
218
+ if (!r.ok) return;
219
+ const d = await r.json();
220
+ const box = document.getElementById('sec-list');
221
+ let html = '';
222
+ const row = (s, scope, cwd) => `<div style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;border-bottom:1px solid #21262d;font-size:0.8rem">
223
+ <span style="color:#e6edf3;font-family:monospace">${escHtml(s.name)}</span>
224
+ <span style="color:#6e7681">${s.type === 'file' ? '📄文件' : '🔑变量'}</span>
225
+ <span style="color:#6e7681;font-family:monospace;flex:1">${escHtml(s.preview)}</span>
226
+ <button class="btn-sm" style="padding:0.1rem 0.5rem" onclick="delSecret('${escHtml(s.name)}','${scope}','${escHtml(cwd || '')}')">删</button>
227
+ </div>`;
228
+ if (d.global && d.global.length) {
229
+ html += '<div style="color:#8b949e;font-size:0.72rem;margin:0.2rem 0">全局</div>';
230
+ html += d.global.map(s => row(s, 'global', '')).join('');
231
+ }
232
+ for (const p of (d.projects || [])) {
233
+ if (!p.secrets.length) continue;
234
+ html += `<div style="color:#8b949e;font-size:0.72rem;margin:0.4rem 0 0.2rem;font-family:monospace">项目 ${escHtml(p.cwd)}</div>`;
235
+ html += p.secrets.map(s => row(s, 'project', p.cwd)).join('');
236
+ }
237
+ box.innerHTML = html || '<div style="color:#6e7681;font-size:0.78rem">(暂无密钥)</div>';
238
+ } catch {}
239
+ }
240
+
241
+ async function addSecret() {
242
+ const name = document.getElementById('sec-name').value.trim();
243
+ const value = document.getElementById('sec-value').value;
244
+ const type = document.getElementById('sec-type').value;
245
+ const scope = document.getElementById('sec-scope').value;
246
+ const cwd = document.getElementById('sec-cwd').value.trim();
247
+ const msg = document.getElementById('sec-msg');
248
+ try {
249
+ const r = await fetch('/api/secrets', { method: 'POST', headers: H(), body: JSON.stringify({ name, value, type, scope, cwd }) });
250
+ const d = await r.json();
251
+ if (d.ok) {
252
+ msg.className = 'msg msg-ok';
253
+ msg.textContent = '已保存,新建(该作用域的)会话自动生效';
254
+ document.getElementById('sec-name').value = '';
255
+ document.getElementById('sec-value').value = '';
256
+ loadSecrets();
257
+ } else {
258
+ msg.className = 'msg msg-err';
259
+ msg.textContent = d.error || '失败';
260
+ }
261
+ } catch {
262
+ msg.className = 'msg msg-err';
263
+ msg.textContent = '连接失败';
264
+ }
265
+ }
266
+
267
+ async function delSecret(name, scope, cwd) {
268
+ if (!confirm(`删除密钥 ${name}?`)) return;
269
+ try {
270
+ await fetch('/api/secrets', { method: 'DELETE', headers: H(), body: JSON.stringify({ name, scope, cwd }) });
271
+ loadSecrets();
272
+ } catch {}
273
+ }
274
+
275
+ async function loadGateway() {
276
+ try {
277
+ const r = await fetch('/api/gateway', { headers: H() });
278
+ if (!r.ok) return;
279
+ const d = await r.json();
280
+ document.getElementById('gw-url').value = d.baseUrl || '';
281
+ document.getElementById('gw-key').placeholder = d.hasKey ? `已设置(${d.apiKey}),留空保持不变` : 'Token / API Key';
282
+ } catch {}
283
+ }
284
+
285
+ async function saveGateway() {
286
+ const baseUrl = document.getElementById('gw-url').value.trim();
287
+ const apiKey = document.getElementById('gw-key').value.trim();
288
+ const msg = document.getElementById('gw-msg');
289
+ try {
290
+ const r = await fetch('/api/gateway', {
291
+ method: 'POST',
292
+ headers: H(),
293
+ body: JSON.stringify({ baseUrl, apiKey }),
294
+ });
295
+ const d = await r.json();
296
+ if (d.ok) {
297
+ msg.className = 'msg msg-ok';
298
+ msg.textContent = '已保存,新建 Claude 会话自动生效';
299
+ document.getElementById('gw-key').value = '';
300
+ loadGateway();
301
+ } else {
302
+ msg.className = 'msg msg-err';
303
+ msg.textContent = d.error || '失败';
304
+ }
305
+ } catch {
306
+ msg.className = 'msg msg-err';
307
+ msg.textContent = '连接失败';
308
+ }
309
+ }
310
+
311
+ async function refresh() {
312
+ try {
313
+ const r = await fetch('/api/admin/status', { headers: H() });
314
+ if (r.status === 401) { doLogout(); return; }
315
+ const d = await r.json();
316
+
317
+ // 版本
318
+ document.getElementById('v-cur').textContent = 'v' + d.version.current;
319
+ const btn = document.getElementById('btn-up');
320
+ if (d.version.updateAvailable) {
321
+ document.getElementById('v-latest').innerHTML = '最新版本: <b>v' + d.version.latest + '</b>';
322
+ btn.disabled = false; btn.classList.add('glow'); btn.textContent = '升级到 v' + d.version.latest;
323
+ } else {
324
+ document.getElementById('v-latest').innerHTML = '<span style="color:#3fb950">已是最新版本</span>';
325
+ btn.disabled = true; btn.classList.remove('glow'); btn.textContent = '已是最新';
326
+ }
327
+
328
+ // Claude Code 版本(独立端点)
329
+ try {
330
+ const cr = await fetch('/api/claude/version', { headers: H() });
331
+ if (cr.ok) {
332
+ const cd = await cr.json();
333
+ document.getElementById('cl-cur').textContent = cd.current ? ('v' + cd.current) : '未检测到';
334
+ const clBtn = document.getElementById('btn-cl-up');
335
+ if (cd.upgrade?.running) {
336
+ document.getElementById('cl-latest').innerHTML = '<span style="color:#58a6ff">升级中...</span>';
337
+ clBtn.disabled = true; clBtn.textContent = '升级中...';
338
+ } else if (cd.updateAvailable) {
339
+ document.getElementById('cl-latest').innerHTML = '最新版本: <b>v' + cd.latest + '</b>';
340
+ clBtn.disabled = false; clBtn.classList.add('glow'); clBtn.textContent = '升级到 v' + cd.latest;
341
+ } else {
342
+ document.getElementById('cl-latest').innerHTML = '<span style="color:#3fb950">已是最新版本</span>';
343
+ clBtn.disabled = true; clBtn.classList.remove('glow'); clBtn.textContent = '已是最新';
344
+ }
345
+ }
346
+ } catch {}
347
+
348
+ // 在线用户
349
+ const ouEl = document.getElementById('online-users');
350
+ if (!d.onlineUsers?.length) {
351
+ ouEl.innerHTML = '<div class="row" style="color:#6e7681">无在线用户</div>';
352
+ } else {
353
+ ouEl.innerHTML = d.onlineUsers.map(u =>
354
+ `<div class="row"><span>${esc(u.name)}</span><span style="color:#6e7681">${esc(u.role)}</span></div>`
355
+ ).join('');
356
+ }
357
+
358
+ // 活跃会话
359
+ const asEl = document.getElementById('active-sessions');
360
+ if (!d.sessions?.length) {
361
+ asEl.innerHTML = '<div class="row" style="color:#6e7681">无活跃会话</div>';
362
+ } else {
363
+ asEl.innerHTML = d.sessions.map(s => {
364
+ const tag = s.mode === 'agent'
365
+ ? '<span style="display:inline-block;font-size:0.7rem;padding:0.1rem 0.4rem;border-radius:4px;background:rgba(124,58,237,0.2);color:#a78bfa">Agent</span>'
366
+ : '<span style="display:inline-block;font-size:0.7rem;padding:0.1rem 0.4rem;border-radius:4px;background:rgba(63,185,80,0.2);color:#3fb950">PTY</span>';
367
+ return `<div class="row"><span>${tag} ${esc(s.title)} <span style="color:#6e7681">${esc(s.owner||'')}</span></span><span style="color:#6e7681">${s.viewers}人</span></div>`;
368
+ }).join('');
369
+ }
370
+
371
+ // 用户列表
372
+ renderUsers(d.users || []);
373
+
374
+ // 运行时长
375
+ if (d.uptime) {
376
+ const h = Math.floor(d.uptime / 3600), m = Math.floor((d.uptime % 3600) / 60);
377
+ document.getElementById('uptime').textContent = `服务运行 ${h}小时${m}分钟`;
378
+ }
379
+ } catch {}
380
+ }
381
+
382
+ function renderUsers(users) {
383
+ const el = document.getElementById('user-list');
384
+ if (!users.length) { el.innerHTML = '<div class="row" style="color:#6e7681">暂无用户</div>'; return; }
385
+ el.innerHTML = users.map(u => `
386
+ <div class="row">
387
+ <span><b style="color:#e6edf3">${esc(u.name)}</b> <span style="color:#6e7681;font-size:0.75rem">${esc(u.password)}</span> <span style="color:#6e7681;font-size:0.72rem">${esc(u.dir)}</span></span>
388
+ <div class="row-actions">
389
+ <button onclick="editUserPwd('${esc(u.name)}')">改密</button>
390
+ <button class="del" onclick="delUser('${esc(u.name)}')">删除</button>
391
+ </div>
392
+ </div>
393
+ `).join('');
394
+ }
395
+
396
+ function esc(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
397
+
398
+ async function addUser() {
399
+ const pwd = document.getElementById('u-pwd').value.trim();
400
+ const name = document.getElementById('u-name').value.trim();
401
+ const dir = document.getElementById('u-dir').value.trim();
402
+ const msg = document.getElementById('u-msg');
403
+ if (!pwd || !name || !dir) { msg.className = 'msg msg-err'; msg.textContent = '密码、名称、目录都必填'; return; }
404
+ try {
405
+ const r = await fetch('/api/admin/user', { method: 'POST', headers: H(), body: JSON.stringify({ password: pwd, name, dir }) });
406
+ const d = await r.json();
407
+ if (d.ok) { msg.className = 'msg msg-ok'; msg.textContent = '已添加'; document.getElementById('u-pwd').value = ''; document.getElementById('u-name').value = ''; document.getElementById('u-dir').value = ''; refresh(); }
408
+ else { msg.className = 'msg msg-err'; msg.textContent = d.error || '失败'; }
409
+ } catch { msg.className = 'msg msg-err'; msg.textContent = '连接失败'; }
410
+ }
411
+
412
+ async function editUserPwd(name) {
413
+ const newPwd = prompt(`修改 ${name} 的密码:`);
414
+ if (!newPwd) return;
415
+ try {
416
+ const r = await fetch('/api/admin/user/password', { method: 'POST', headers: H(), body: JSON.stringify({ name, password: newPwd }) });
417
+ const d = await r.json();
418
+ if (d.ok) { alert('密码已修改'); refresh(); }
419
+ else alert(d.error || '失败');
420
+ } catch { alert('连接失败'); }
421
+ }
422
+
423
+ async function delUser(name) {
424
+ if (!confirm(`确定删除用户 ${name}?`)) return;
425
+ try {
426
+ const r = await fetch('/api/admin/user', { method: 'DELETE', headers: H(), body: JSON.stringify({ name }) });
427
+ const d = await r.json();
428
+ if (d.ok) refresh();
429
+ else alert(d.error || '失败');
430
+ } catch { alert('连接失败'); }
431
+ }
432
+
433
+ async function changePwd() {
434
+ const pwd = document.getElementById('new-pwd').value.trim();
435
+ const msg = document.getElementById('pwd-msg');
436
+ if (!pwd || pwd.length < 4) { msg.className = 'msg msg-err'; msg.textContent = '密码至少4位'; return; }
437
+ try {
438
+ const r = await fetch('/api/admin/password', { method: 'POST', headers: H(), body: JSON.stringify({ password: pwd }) });
439
+ const d = await r.json();
440
+ if (d.ok) { msg.className = 'msg msg-ok'; msg.textContent = '已修改'; document.getElementById('new-pwd').value = ''; }
441
+ else { msg.className = 'msg msg-err'; msg.textContent = d.error || '失败'; }
442
+ } catch { msg.className = 'msg msg-err'; msg.textContent = '连接失败'; }
443
+ }
444
+
445
+ async function doUpgrade() {
446
+ const btn = document.getElementById('btn-up'), msg = document.getElementById('op-msg');
447
+ btn.disabled = true; btn.textContent = '升级中...'; msg.textContent = '正在下载安装...'; msg.style.display = '';
448
+ try {
449
+ const r = await fetch('/api/admin/upgrade', { method: 'POST', headers: H() });
450
+ const d = await r.json();
451
+ if (d.ok) { msg.textContent = '升级完成,重启中...'; waitReload(); }
452
+ else { msg.textContent = '失败: ' + (d.error||''); btn.disabled = false; btn.textContent = '重试'; }
453
+ } catch { msg.textContent = '连接失败'; btn.disabled = false; btn.textContent = '重试'; }
454
+ }
455
+
456
+ async function doClaudeUpgrade() {
457
+ const btn = document.getElementById('btn-cl-up'), msg = document.getElementById('cl-msg');
458
+ if (!confirm('升级 Claude Code?\nmyhi 会用 npm --include=optional 装全平台二进制,避免占位符坑。\n不影响正在跑的会话,升级后新查询生效。')) return;
459
+ btn.disabled = true; btn.textContent = '升级中...'; msg.className = 'status-msg'; msg.textContent = '正在下载安装(~260MB,可能几分钟)...'; msg.style.display = '';
460
+ try {
461
+ const r = await fetch('/api/claude/update', { method: 'POST', headers: H() });
462
+ const d = await r.json();
463
+ if (!d.ok) { msg.textContent = '失败: ' + (d.error||''); btn.disabled = false; btn.textContent = '重试'; return; }
464
+ const poll = setInterval(async () => {
465
+ try {
466
+ const sr = await fetch('/api/claude/update-status', { headers: H() });
467
+ const s = await sr.json();
468
+ if (!s.running) {
469
+ clearInterval(poll);
470
+ if (s.ok) { msg.textContent = '已升级到 v' + s.version; setTimeout(refresh, 500); }
471
+ else { msg.textContent = '失败: ' + (s.error||''); btn.disabled = false; btn.textContent = '重试'; }
472
+ }
473
+ } catch {}
474
+ }, 4000);
475
+ } catch { msg.textContent = '连接失败'; btn.disabled = false; btn.textContent = '重试'; }
476
+ }
477
+
478
+ async function doRestart() {
479
+ if (!confirm('确定重启?')) return;
480
+ const msg = document.getElementById('op-msg');
481
+ msg.textContent = '重启中...'; msg.style.display = '';
482
+ try { await fetch('/api/admin/restart', { method: 'POST', headers: H() }); } catch {}
483
+ waitReload();
484
+ }
485
+
486
+ async function waitReload() {
487
+ await new Promise(r => setTimeout(r, 3000));
488
+ for (let i = 0; i < 30; i++) {
489
+ try { const r = await fetch('/api/version'); if (r.ok) { location.reload(); return; } } catch {}
490
+ await new Promise(r => setTimeout(r, 2000));
491
+ }
492
+ document.getElementById('op-msg').textContent = '重启超时,请手动刷新';
493
+ }
494
+
495
+ if (T) showAdmin();
496
+ </script>
497
+ </body>
498
+ </html>