@wendongfly/myhi 1.3.190 → 1.3.192

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
@@ -5,47 +5,73 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>myhi 管理</title>
7
7
  <script src="/lib/machine.js"></script>
8
+ <script src="/theme.js"></script>
9
+ <script>
10
+ // 主题与其他页面一致:浏览器选过的优先,否则服务端默认(新装浅色、老机器深色)
11
+ (function(){try{var t=localStorage.getItem('myhi_theme')||window.MYHI_DEFAULT_THEME||'dark';
12
+ if(t==='system') t=matchMedia('(prefers-color-scheme: light)').matches?'light':'dark';
13
+ if(t!=='dark') document.documentElement.setAttribute('data-theme',t);}catch(e){}})();
14
+ </script>
8
15
  <style>
16
+ :root { --bg:#0d1117; --surface:#161b22; --text:#e6edf3; --text2:#b1bac4; --text3:#8b949e; --muted:#6e7681;
17
+ --line:rgba(255,255,255,0.06); --border:rgba(255,255,255,0.12); --border2:#30363d; --accent:#7c3aed; }
18
+ :root[data-theme="light"] { --bg:#f6f8fa; --surface:#ffffff; --text:#1f2328; --text2:#424a53; --text3:#57606a; --muted:#6e7781;
19
+ --line:rgba(0,0,0,0.06); --border:#d0d7de; --border2:#d0d7de; --accent:#8250df; }
20
+ :root[data-theme="black"] { --bg:#000000; --surface:#0a0a0a; --border2:#262626; }
9
21
  * { 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; }
22
+ body { font-family: -apple-system, system-ui, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
11
23
  .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; }
24
+ .login-box { background: var(--surface); border: 1px solid var(--border); border-radius: 16px; padding: 2rem; width: 100%; max-width: 360px; }
13
25
  .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; }
26
+ .login-box input { width: 100%; padding: 0.8rem; background: var(--bg); border: 1px solid var(--border); border-radius: 10px; color: var(--text); font-size: 1rem; margin-bottom: 1rem; }
27
+ .login-box button { width: 100%; padding: 0.8rem; background: var(--accent); color: #fff; border: none; border-radius: 10px; font-size: 1rem; font-weight: 600; cursor: pointer; }
16
28
  .login-box .err { color: #f85149; font-size: 0.85rem; text-align: center; margin-bottom: 0.5rem; min-height: 1.2em; }
17
29
  .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; }
30
+ .header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 0; border-bottom: 1px solid var(--line); margin-bottom: 1.5rem; }
19
31
  .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; }
32
+ .header .logout { background: none; border: 1px solid var(--border); color: var(--text3); padding: 0.3rem 0.8rem; border-radius: 8px; cursor: pointer; font-size: 0.8rem; }
33
+ .ver-card { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 1.2rem 1.5rem; margin-bottom: 1.2rem; }
34
+ .ver-big { font-size: 2rem; font-weight: 800; color: var(--accent); }
35
+ .ver-sub { font-size: 0.85rem; color: var(--text3); margin-top: 0.5rem; }
24
36
  .ver-sub b { color: #3fb950; }
25
37
  .actions { display: flex; gap: 0.8rem; margin-bottom: 1.5rem; }
26
38
  .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; }
39
+ .btn-up { background: var(--accent); color: #fff; }
40
+ .btn-up:disabled { background: var(--border2); color: var(--muted); cursor: not-allowed; }
29
41
  .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; }
42
+ .btn-re { background: var(--line); color: var(--text); border: 1px solid var(--border) !important; }
31
43
  @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; }
44
+ .card { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 1rem 1.2rem; margin-bottom: 1rem; }
33
45
  .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); }
46
+ .row { font-size: 0.82rem; color: var(--text2); padding: 0.5rem 0; line-height: 1.5; display: flex; justify-content: space-between; align-items: center; }
47
+ .row + .row { border-top: 1px solid var(--line); }
36
48
  .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); }
49
+ .row-actions button { background: none; border: 1px solid var(--border); color: var(--text3); padding: 0.2rem 0.5rem; border-radius: 6px; cursor: pointer; font-size: 0.75rem; }
50
+ .row-actions button:hover { color: var(--text); border-color: var(--border2); }
39
51
  .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%; }
52
+ .inp { padding: 0.5rem 0.7rem; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; color: var(--text); font-size: 0.85rem; width: 100%; }
41
53
  .inp-row { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.5rem; }
42
54
  .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; }
55
+ .btn-sm { padding: 0.5rem 1rem; background: var(--accent); color: #fff; border: none; border-radius: 8px; cursor: pointer; font-size: 0.85rem; white-space: nowrap; }
56
+ .btn-sm.ghost { background: transparent; color: var(--text2); border: 1px solid var(--border); }
57
+ .mode-status { font-size: 0.85rem; line-height: 1.6; padding: 0.6rem 0.75rem; border-radius: 10px; background: rgba(124,58,237,0.1); border: 1px solid rgba(124,58,237,0.25); margin-bottom: 0.5rem; }
58
+ .mode-status b { color: var(--text); }
59
+ .mode-status small { display: block; color: var(--text3); font-size: 0.75rem; }
60
+ .sw { position: relative; width: 38px; height: 22px; flex-shrink: 0; }
61
+ .sw input { opacity: 0; width: 0; height: 0; }
62
+ .sw span { position: absolute; inset: 0; background: var(--border2); border-radius: 11px; cursor: pointer; transition: 0.2s; }
63
+ .sw span::before { content: ''; position: absolute; width: 16px; height: 16px; left: 3px; top: 3px; background: #fff; border-radius: 50%; transition: 0.2s; }
64
+ .sw input:checked + span { background: var(--accent); }
65
+ .sw input:checked + span::before { transform: translateX(16px); }
66
+ .dir-pick { border: 1px solid var(--border); border-radius: 8px; max-height: 220px; overflow-y: auto; margin-bottom: 0.5rem; font-size: 0.8rem; }
67
+ .dir-pick div { padding: 0.4rem 0.6rem; cursor: pointer; color: var(--text2); }
68
+ .dir-pick div:hover { background: var(--line); }
69
+ .dir-pick .cur { color: var(--text3); font-size: 0.72rem; cursor: default; border-bottom: 1px solid var(--line); }
44
70
  .msg { font-size: 0.8rem; margin-top: 0.3rem; min-height: 1.2em; }
45
71
  .msg-ok { color: #3fb950; }
46
72
  .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; }
73
+ .status-msg { text-align: center; padding: 1rem; color: var(--text3); font-size: 0.85rem; }
74
+ .uptime { font-size: 0.78rem; color: var(--muted); text-align: center; margin-top: 1rem; }
49
75
  </style>
50
76
  </head>
51
77
  <body>
@@ -67,7 +93,7 @@
67
93
 
68
94
  <!-- 版本 + 升级 -->
69
95
  <div class="ver-card">
70
- <div style="font-size:0.8rem;color:#8b949e">当前版本</div>
96
+ <div style="font-size:0.8rem;color:var(--text3)">当前版本</div>
71
97
  <div class="ver-big" id="v-cur">--</div>
72
98
  <div class="ver-sub" id="v-latest"></div>
73
99
  </div>
@@ -79,7 +105,7 @@
79
105
 
80
106
  <!-- Claude Code 版本 + 升级 -->
81
107
  <div class="ver-card">
82
- <div style="font-size:0.8rem;color:#8b949e">Claude Code 版本</div>
108
+ <div style="font-size:0.8rem;color:var(--text3)">Claude Code 版本</div>
83
109
  <div class="ver-big" id="cl-cur">--</div>
84
110
  <div class="ver-sub" id="cl-latest"></div>
85
111
  </div>
@@ -91,31 +117,43 @@
91
117
  <!-- 在线用户 -->
92
118
  <div class="card">
93
119
  <h3>在线用户</h3>
94
- <div id="online-users"><div class="row" style="color:#6e7681">加载中...</div></div>
120
+ <div id="online-users"><div class="row" style="color:var(--muted)">加载中...</div></div>
95
121
  </div>
96
122
 
97
123
  <!-- 活跃会话 -->
98
124
  <div class="card">
99
125
  <h3>活跃会话</h3>
100
- <div id="active-sessions"><div class="row" style="color:#6e7681">加载中...</div></div>
126
+ <div id="active-sessions"><div class="row" style="color:var(--muted)">加载中...</div></div>
101
127
  </div>
102
128
 
103
129
  <!-- 用户管理 -->
104
- <div class="card">
105
- <h3>用户管理</h3>
130
+ <div class="card" id="mode">
131
+ <h3>使用模式</h3>
132
+ <div id="mode-status" class="mode-status"></div>
106
133
  <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>
134
+ <div id="excl-row" class="row" style="display:none">
135
+ <span>独占模式<br><small style="color:var(--muted)">同一时间只允许一个人登录,别人登录会被挡住</small></span>
136
+ <label class="sw"><input type="checkbox" id="excl-toggle" onchange="setExclusive(this.checked)"><span></span></label>
137
+ </div>
138
+ <button class="btn-sm" id="add-open" onclick="openAddMember()" style="margin-top:0.6rem;width:100%"></button>
139
+ <div id="add-member" style="display:none;margin-top:0.7rem">
140
+ <div class="inp-row"><input class="inp" id="u-name" placeholder="成员名称(如:张三)"></div>
109
141
  <div class="inp-row">
110
- <input class="inp" id="u-pwd" placeholder="密码">
111
- <input class="inp" id="u-name" placeholder="名称">
142
+ <input class="inp" id="u-pwd" placeholder="登录密码(至少 4 位)" autocomplete="off">
143
+ <button class="btn-sm ghost" onclick="genPwd()">随机生成</button>
112
144
  </div>
113
145
  <div class="inp-row">
114
- <input class="inp" id="u-dir" placeholder="工作目录">
115
- <button class="btn-sm" onclick="addUser()">添加</button>
146
+ <input class="inp" id="u-dir" placeholder="工作目录(留空 = 不限制)">
147
+ <button class="btn-sm ghost" onclick="pickDir()">浏览…</button>
148
+ </div>
149
+ <div id="dir-pick" style="display:none" class="dir-pick"></div>
150
+ <div class="inp-row" style="margin-top:0.2rem">
151
+ <button class="btn-sm ghost" onclick="closeAddMember()" style="flex:1">取消</button>
152
+ <button class="btn-sm" onclick="addUser()" style="flex:2">添加</button>
116
153
  </div>
117
- <div class="msg" id="u-msg"></div>
118
154
  </div>
155
+ <div class="msg" id="u-msg"></div>
156
+ <div id="mode-foot" style="font-size:0.72rem;color:var(--muted);margin-top:0.5rem;line-height:1.5"></div>
119
157
  </div>
120
158
 
121
159
  <!-- 修改管理密码 -->
@@ -131,8 +169,8 @@
131
169
  <!-- 大模型网关 -->
132
170
  <div class="card">
133
171
  <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>
172
+ <div style="font-size:0.75rem;color:var(--muted);margin-bottom:0.6rem;line-height:1.5">
173
+ 配置后新建的 Claude 会话自动注入 <code style="background:var(--surface);padding:0.1em 0.3em;border-radius:3px">ANTHROPIC_BASE_URL</code> / <code style="background:var(--surface);padding:0.1em 0.3em;border-radius:3px">ANTHROPIC_API_KEY</code>
136
174
  </div>
137
175
  <div class="inp-row">
138
176
  <input type="text" class="inp" id="gw-url" placeholder="网关 URL(如 https://gateway.example.com)">
@@ -147,11 +185,11 @@
147
185
  <!-- 命名密钥库 -->
148
186
  <div class="card">
149
187
  <h3>密钥库(Secret Vault)</h3>
150
- <div style="font-size:0.75rem;color:#6e7681;margin-bottom:0.6rem;line-height:1.5">
188
+ <div style="font-size:0.75rem;color:var(--muted);margin-bottom:0.6rem;line-height:1.5">
151
189
  集中存 GitLab PAT / 部署 token / 网关 key / 证书等敏感值,spawn 会话时注入成环境变量,AI/命令用
152
- <code style="background:#161b22;padding:0.1em 0.3em;border-radius:3px">$名字</code>
190
+ <code style="background:var(--surface);padding:0.1em 0.3em;border-radius:3px">$名字</code>
153
191
  引用——密钥值不进聊天、不落 transcript。类型选「文件」(证书等)时注入的是路径
154
- <code style="background:#161b22;padding:0.1em 0.3em;border-radius:3px">$MYHI_SECRET_名字</code>。
192
+ <code style="background:var(--surface);padding:0.1em 0.3em;border-radius:3px">$MYHI_SECRET_名字</code>。
155
193
  作用域「项目」仅注给该 cwd 的会话(最小权限)。<b>密钥值绝不写进记忆/CLAUDE.md。</b>
156
194
  </div>
157
195
  <div id="sec-list" style="margin-bottom:0.6rem"></div>
@@ -219,22 +257,22 @@ async function loadSecrets() {
219
257
  const d = await r.json();
220
258
  const box = document.getElementById('sec-list');
221
259
  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>
260
+ const row = (s, scope, cwd) => `<div style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;border-bottom:1px solid var(--line);font-size:0.8rem">
261
+ <span style="color:var(--text);font-family:monospace">${escHtml(s.name)}</span>
262
+ <span style="color:var(--muted)">${s.type === 'file' ? '📄文件' : '🔑变量'}</span>
263
+ <span style="color:var(--muted);font-family:monospace;flex:1">${escHtml(s.preview)}</span>
226
264
  <button class="btn-sm" style="padding:0.1rem 0.5rem" onclick="delSecret('${escHtml(s.name)}','${scope}','${escHtml(cwd || '')}')">删</button>
227
265
  </div>`;
228
266
  if (d.global && d.global.length) {
229
- html += '<div style="color:#8b949e;font-size:0.72rem;margin:0.2rem 0">全局</div>';
267
+ html += '<div style="color:var(--text3);font-size:0.72rem;margin:0.2rem 0">全局</div>';
230
268
  html += d.global.map(s => row(s, 'global', '')).join('');
231
269
  }
232
270
  for (const p of (d.projects || [])) {
233
271
  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>`;
272
+ html += `<div style="color:var(--text3);font-size:0.72rem;margin:0.4rem 0 0.2rem;font-family:monospace">项目 ${escHtml(p.cwd)}</div>`;
235
273
  html += p.secrets.map(s => row(s, 'project', p.cwd)).join('');
236
274
  }
237
- box.innerHTML = html || '<div style="color:#6e7681;font-size:0.78rem">(暂无密钥)</div>';
275
+ box.innerHTML = html || '<div style="color:var(--muted);font-size:0.78rem">(暂无密钥)</div>';
238
276
  } catch {}
239
277
  }
240
278
 
@@ -330,11 +368,16 @@ async function refresh() {
330
368
  const cr = await fetch('/api/claude/version', { headers: H() });
331
369
  if (cr.ok) {
332
370
  const cd = await cr.json();
333
- document.getElementById('cl-cur').textContent = cd.current ? ('v' + cd.current) : '未检测到';
371
+ document.getElementById('cl-cur').textContent = cd.current ? ('v' + cd.current) : '未安装';
334
372
  const clBtn = document.getElementById('btn-cl-up');
373
+ _clInstalled = !!cd.current;
335
374
  if (cd.upgrade?.running) {
336
- document.getElementById('cl-latest').innerHTML = '<span style="color:#58a6ff">升级中...</span>';
337
- clBtn.disabled = true; clBtn.textContent = '升级中...';
375
+ document.getElementById('cl-latest').innerHTML = '<span style="color:#58a6ff">' + (cd.current ? '升级' : '安装') + '中...</span>';
376
+ clBtn.disabled = true; clBtn.textContent = (cd.current ? '升级' : '安装') + '中...';
377
+ } else if (!cd.current) {
378
+ // 没装(npm 装 myhi 不会顺带装它):别显示「已是最新」,给安装按钮
379
+ document.getElementById('cl-latest').innerHTML = '<span style="color:#f0883e">AI 任务需要它,这台电脑上还没有</span>' + (cd.latest ? ',最新版 v' + cd.latest : '');
380
+ clBtn.disabled = false; clBtn.classList.add('glow'); clBtn.textContent = '安装 Claude Code';
338
381
  } else if (cd.updateAvailable) {
339
382
  document.getElementById('cl-latest').innerHTML = '最新版本: <b>v' + cd.latest + '</b>';
340
383
  clBtn.disabled = false; clBtn.classList.add('glow'); clBtn.textContent = '升级到 v' + cd.latest;
@@ -348,28 +391,28 @@ async function refresh() {
348
391
  // 在线用户
349
392
  const ouEl = document.getElementById('online-users');
350
393
  if (!d.onlineUsers?.length) {
351
- ouEl.innerHTML = '<div class="row" style="color:#6e7681">无在线用户</div>';
394
+ ouEl.innerHTML = '<div class="row" style="color:var(--muted)">无在线用户</div>';
352
395
  } else {
353
396
  ouEl.innerHTML = d.onlineUsers.map(u =>
354
- `<div class="row"><span>${esc(u.name)}</span><span style="color:#6e7681">${esc(u.role)}</span></div>`
397
+ `<div class="row"><span>${esc(u.name)}</span><span style="color:var(--muted)">${esc(u.role)}</span></div>`
355
398
  ).join('');
356
399
  }
357
400
 
358
401
  // 活跃会话
359
402
  const asEl = document.getElementById('active-sessions');
360
403
  if (!d.sessions?.length) {
361
- asEl.innerHTML = '<div class="row" style="color:#6e7681">无活跃会话</div>';
404
+ asEl.innerHTML = '<div class="row" style="color:var(--muted)">无活跃会话</div>';
362
405
  } else {
363
406
  asEl.innerHTML = d.sessions.map(s => {
364
407
  const tag = s.mode === 'agent'
365
408
  ? '<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
409
  : '<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>`;
410
+ return `<div class="row"><span>${tag} ${esc(s.title)} <span style="color:var(--muted)">${esc(s.owner||'')}</span></span><span style="color:var(--muted)">${s.viewers}人</span></div>`;
368
411
  }).join('');
369
412
  }
370
413
 
371
414
  // 用户列表
372
- renderUsers(d.users || []);
415
+ renderUsers(d.users || [], d.exclusive);
373
416
 
374
417
  // 运行时长
375
418
  if (d.uptime) {
@@ -379,20 +422,70 @@ async function refresh() {
379
422
  } catch {}
380
423
  }
381
424
 
382
- function renderUsers(users) {
425
+ // 使用模式:有成员 = 多用户,没有 = 单用户(默认)
426
+ let _users = [];
427
+ function renderUsers(users, exclusive) {
428
+ _users = users;
429
+ const multi = users.length > 0;
430
+ document.getElementById('mode-status').innerHTML = multi
431
+ ? `<b>当前:多用户模式 · ${users.length} 个成员</b><small>成员用各自密码登录,可以升级 / 重启,不能改全局设置;你用管理密码或二维码登录,权限不变。</small>`
432
+ : `<b>当前:单用户模式</b><small>只有你在用,所有功能都能用。需要给别人用时再添加成员。</small>`;
433
+ document.getElementById('add-open').textContent = multi ? '+ 添加成员' : '+ 添加成员,开启多用户模式';
434
+ document.getElementById('excl-row').style.display = multi ? '' : 'none';
435
+ document.getElementById('excl-toggle').checked = !!exclusive;
436
+ document.getElementById('mode-foot').textContent = multi ? '删除全部成员即回到单用户模式。删除成员或重置密码后,他当前的登录会立即失效。' : '';
383
437
  const el = document.getElementById('user-list');
384
- if (!users.length) { el.innerHTML = '<div class="row" style="color:#6e7681">暂无用户</div>'; return; }
385
438
  el.innerHTML = users.map(u => `
386
439
  <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>
440
+ <span><b style="color:var(--text)">${esc(u.name)}</b> <span style="color:var(--muted);font-size:0.72rem">${u.dir ? '📁 ' + esc(u.dir) : '不限目录'}</span></span>
388
441
  <div class="row-actions">
389
- <button onclick="editUserPwd('${esc(u.name)}')">改密</button>
442
+ <button onclick="editUserPwd('${esc(u.name)}')">重置密码</button>
390
443
  <button class="del" onclick="delUser('${esc(u.name)}')">删除</button>
391
444
  </div>
392
445
  </div>
393
446
  `).join('');
394
447
  }
395
448
 
449
+ function openAddMember() {
450
+ document.getElementById('add-member').style.display = '';
451
+ document.getElementById('add-open').style.display = 'none';
452
+ document.getElementById('u-name').focus();
453
+ }
454
+ function closeAddMember() {
455
+ document.getElementById('add-member').style.display = 'none';
456
+ document.getElementById('add-open').style.display = '';
457
+ document.getElementById('dir-pick').style.display = 'none';
458
+ }
459
+ function genPwd() {
460
+ const cs = 'abcdefghjkmnpqrstuvwxyz23456789';
461
+ const a = crypto.getRandomValues(new Uint32Array(8));
462
+ document.getElementById('u-pwd').value = [...a].map(n => cs[n % cs.length]).join('');
463
+ }
464
+ // 目录点选:复用 /api/dirs(主站以管理员身份登录时可用;不行就手动输入)
465
+ async function pickDir(path) {
466
+ const box = document.getElementById('dir-pick');
467
+ box.style.display = '';
468
+ try {
469
+ const r = await fetch('/api/dirs' + (path ? '?path=' + encodeURIComponent(path) : ''));
470
+ const d = await r.json();
471
+ if (!r.ok) throw new Error(d.error || r.status);
472
+ document.getElementById('u-dir').value = d.current;
473
+ box.innerHTML = `<div class="cur">${esc(d.current)}</div>` +
474
+ (d.parent ? `<div data-p="${esc(d.parent)}">⬆ 上一级</div>` : '') +
475
+ (d.drives || []).map(x => `<div data-p="${esc(x)}">💽 ${esc(x)}</div>`).join('') +
476
+ d.dirs.map(x => `<div data-p="${esc(x.path)}">📁 ${esc(x.name)}</div>`).join('');
477
+ box.querySelectorAll('[data-p]').forEach(el => el.onclick = () => pickDir(el.dataset.p));
478
+ } catch (e) { box.innerHTML = `<div class="cur">无法浏览目录(${esc(e.message)}),请直接输入路径</div>`; }
479
+ }
480
+
481
+ async function setExclusive(on) {
482
+ try {
483
+ const r = await fetch('/api/admin/exclusive', { method: 'POST', headers: H(), body: JSON.stringify({ on }) });
484
+ const d = await r.json();
485
+ if (!d.ok) { alert(d.error || '失败'); refresh(); }
486
+ } catch { alert('连接失败'); refresh(); }
487
+ }
488
+
396
489
  function esc(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
397
490
 
398
491
  async function addUser() {
@@ -400,17 +493,27 @@ async function addUser() {
400
493
  const name = document.getElementById('u-name').value.trim();
401
494
  const dir = document.getElementById('u-dir').value.trim();
402
495
  const msg = document.getElementById('u-msg');
403
- if (!pwd || !name || !dir) { msg.className = 'msg msg-err'; msg.textContent = '密码、名称、目录都必填'; return; }
496
+ if (!name || !pwd) { msg.className = 'msg msg-err'; msg.textContent = '成员名称和登录密码必填'; return; }
497
+ // 第一个成员 = 从单用户切到多用户:先讲清楚会发生什么
498
+ if (!_users.length && !confirm(
499
+ '添加第一个成员后,myhi 进入「多用户模式」:\n\n' +
500
+ '· 成员用各自的密码登录' + (dir ? ',只能在绑定的目录里建任务' : '') + ';\n' +
501
+ '· 成员可以升级、重启,但不能改全局设置(本机显示名、语音、用户、网关等);\n' +
502
+ '· 你(管理员)照常用管理密码或二维码登录,权限不变。\n\n' +
503
+ '以后删除全部成员即回到单用户模式。确定添加?')) return;
404
504
  try {
405
505
  const r = await fetch('/api/admin/user', { method: 'POST', headers: H(), body: JSON.stringify({ password: pwd, name, dir }) });
406
506
  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 || '失败'; }
507
+ if (d.ok) {
508
+ msg.className = 'msg msg-ok'; msg.textContent = `已添加「${name}」,登录密码:${pwd}(请告知对方)`;
509
+ ['u-pwd', 'u-name', 'u-dir'].forEach(id => document.getElementById(id).value = '');
510
+ closeAddMember(); refresh();
511
+ } else { msg.className = 'msg msg-err'; msg.textContent = d.error || '失败'; }
409
512
  } catch { msg.className = 'msg msg-err'; msg.textContent = '连接失败'; }
410
513
  }
411
514
 
412
515
  async function editUserPwd(name) {
413
- const newPwd = prompt(`修改 ${name} 的密码:`);
516
+ const newPwd = prompt(`为 ${name} 设置新的登录密码(至少 4 位,设置后他当前的登录会失效):`);
414
517
  if (!newPwd) return;
415
518
  try {
416
519
  const r = await fetch('/api/admin/user/password', { method: 'POST', headers: H(), body: JSON.stringify({ name, password: newPwd }) });
@@ -421,7 +524,7 @@ async function editUserPwd(name) {
421
524
  }
422
525
 
423
526
  async function delUser(name) {
424
- if (!confirm(`确定删除用户 ${name}?`)) return;
527
+ if (!confirm(`确定删除成员「${name}」?他当前的登录会立即失效。` + (_users.length === 1 ? '\n\n这是最后一个成员,删除后回到单用户模式。' : ''))) return;
425
528
  try {
426
529
  const r = await fetch('/api/admin/user', { method: 'DELETE', headers: H(), body: JSON.stringify({ name }) });
427
530
  const d = await r.json();
@@ -476,10 +579,14 @@ async function doUpgrade(force) {
476
579
  } catch { fail('连接失败'); }
477
580
  }
478
581
 
582
+ let _clInstalled = true;
479
583
  async function doClaudeUpgrade() {
480
584
  const btn = document.getElementById('btn-cl-up'), msg = document.getElementById('cl-msg');
481
- if (!confirm('升级 Claude Code?\nmyhi 会用 npm --include=optional 装全平台二进制,避免占位符坑。\n不影响正在跑的会话,升级后新查询生效。')) return;
482
- btn.disabled = true; btn.textContent = '升级中...'; msg.className = 'status-msg'; msg.textContent = '正在下载安装(~260MB,可能几分钟)...'; msg.style.display = '';
585
+ const verb = _clInstalled ? '升级' : '安装';
586
+ if (!confirm(_clInstalled
587
+ ? '升级 Claude Code?\nmyhi 会用 npm --include=optional 装全平台二进制,避免占位符坑。\n不影响正在跑的会话,升级后新查询生效。'
588
+ : '安装 Claude Code(myhi 的 AI 引擎)?\n下载约 260MB,需要几分钟。装好后还要在这台电脑上运行一次 claude 登录,或配置下方的大模型网关。')) return;
589
+ btn.disabled = true; btn.textContent = verb + '中...'; msg.className = 'status-msg'; msg.textContent = '正在下载安装(~260MB,可能几分钟)...'; msg.style.display = '';
483
590
  try {
484
591
  const r = await fetch('/api/claude/update', { method: 'POST', headers: H() });
485
592
  const d = await r.json();
@@ -490,7 +597,7 @@ async function doClaudeUpgrade() {
490
597
  const s = await sr.json();
491
598
  if (!s.running) {
492
599
  clearInterval(poll);
493
- if (s.ok) { msg.textContent = '已升级到 v' + s.version; setTimeout(refresh, 500); }
600
+ if (s.ok) { msg.textContent = (_clInstalled ? '已升级到 v' : '已安装 v') + s.version + (_clInstalled ? '' : '。还差一步:在这台电脑上运行一次 claude 登录,或配置下方的大模型网关'); setTimeout(refresh, 500); }
494
601
  else { msg.textContent = '失败: ' + (s.error||''); btn.disabled = false; btn.textContent = '重试'; }
495
602
  }
496
603
  } catch {}
@@ -515,7 +622,15 @@ async function waitReload() {
515
622
  document.getElementById('op-msg').textContent = '重启超时,请手动刷新';
516
623
  }
517
624
 
518
- if (T) showAdmin();
625
+ // 主站已是管理员 → 直接换后台令牌免登录;否则沿用本标签页已有令牌(重启后会失效)或显示登录框
626
+ (async () => {
627
+ try {
628
+ const r = await fetch('/api/admin/sso', { method: 'POST' });
629
+ const d = r.ok ? await r.json() : null;
630
+ if (d?.ok) { T = d.token; sessionStorage.setItem('myhi_admin', T); return showAdmin(); }
631
+ } catch {}
632
+ if (T) showAdmin();
633
+ })();
519
634
  </script>
520
635
  </body>
521
636
  </html>