@wendongfly/myhi 1.3.188 → 1.3.191

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
@@ -41,6 +41,20 @@
41
41
  .inp-row { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.5rem; }
42
42
  .inp-row .inp { flex: 1; }
43
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
+ .btn-sm.ghost { background: transparent; color: #b1bac4; border: 1px solid rgba(255,255,255,0.15); }
45
+ .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; }
46
+ .mode-status b { color: #e6edf3; }
47
+ .mode-status small { display: block; color: #8b949e; font-size: 0.75rem; }
48
+ .sw { position: relative; width: 38px; height: 22px; flex-shrink: 0; }
49
+ .sw input { opacity: 0; width: 0; height: 0; }
50
+ .sw span { position: absolute; inset: 0; background: #30363d; border-radius: 11px; cursor: pointer; transition: 0.2s; }
51
+ .sw span::before { content: ''; position: absolute; width: 16px; height: 16px; left: 3px; top: 3px; background: #fff; border-radius: 50%; transition: 0.2s; }
52
+ .sw input:checked + span { background: #7c3aed; }
53
+ .sw input:checked + span::before { transform: translateX(16px); }
54
+ .dir-pick { border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; max-height: 220px; overflow-y: auto; margin-bottom: 0.5rem; font-size: 0.8rem; }
55
+ .dir-pick div { padding: 0.4rem 0.6rem; cursor: pointer; color: #b1bac4; }
56
+ .dir-pick div:hover { background: rgba(255,255,255,0.05); }
57
+ .dir-pick .cur { color: #8b949e; font-size: 0.72rem; cursor: default; border-bottom: 1px solid rgba(255,255,255,0.06); }
44
58
  .msg { font-size: 0.8rem; margin-top: 0.3rem; min-height: 1.2em; }
45
59
  .msg-ok { color: #3fb950; }
46
60
  .msg-err { color: #f85149; }
@@ -101,21 +115,33 @@
101
115
  </div>
102
116
 
103
117
  <!-- 用户管理 -->
104
- <div class="card">
105
- <h3>用户管理</h3>
118
+ <div class="card" id="mode">
119
+ <h3>使用模式</h3>
120
+ <div id="mode-status" class="mode-status"></div>
106
121
  <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>
122
+ <div id="excl-row" class="row" style="display:none">
123
+ <span>独占模式<br><small style="color:#6e7681">同一时间只允许一个人登录,别人登录会被挡住</small></span>
124
+ <label class="sw"><input type="checkbox" id="excl-toggle" onchange="setExclusive(this.checked)"><span></span></label>
125
+ </div>
126
+ <button class="btn-sm" id="add-open" onclick="openAddMember()" style="margin-top:0.6rem;width:100%"></button>
127
+ <div id="add-member" style="display:none;margin-top:0.7rem">
128
+ <div class="inp-row"><input class="inp" id="u-name" placeholder="成员名称(如:张三)"></div>
109
129
  <div class="inp-row">
110
- <input class="inp" id="u-pwd" placeholder="密码">
111
- <input class="inp" id="u-name" placeholder="名称">
130
+ <input class="inp" id="u-pwd" placeholder="登录密码(至少 4 位)" autocomplete="off">
131
+ <button class="btn-sm ghost" onclick="genPwd()">随机生成</button>
112
132
  </div>
113
133
  <div class="inp-row">
114
- <input class="inp" id="u-dir" placeholder="工作目录">
115
- <button class="btn-sm" onclick="addUser()">添加</button>
134
+ <input class="inp" id="u-dir" placeholder="工作目录(留空 = 不限制)">
135
+ <button class="btn-sm ghost" onclick="pickDir()">浏览…</button>
136
+ </div>
137
+ <div id="dir-pick" style="display:none" class="dir-pick"></div>
138
+ <div class="inp-row" style="margin-top:0.2rem">
139
+ <button class="btn-sm ghost" onclick="closeAddMember()" style="flex:1">取消</button>
140
+ <button class="btn-sm" onclick="addUser()" style="flex:2">添加</button>
116
141
  </div>
117
- <div class="msg" id="u-msg"></div>
118
142
  </div>
143
+ <div class="msg" id="u-msg"></div>
144
+ <div id="mode-foot" style="font-size:0.72rem;color:#6e7681;margin-top:0.5rem;line-height:1.5"></div>
119
145
  </div>
120
146
 
121
147
  <!-- 修改管理密码 -->
@@ -369,7 +395,7 @@ async function refresh() {
369
395
  }
370
396
 
371
397
  // 用户列表
372
- renderUsers(d.users || []);
398
+ renderUsers(d.users || [], d.exclusive);
373
399
 
374
400
  // 运行时长
375
401
  if (d.uptime) {
@@ -379,20 +405,70 @@ async function refresh() {
379
405
  } catch {}
380
406
  }
381
407
 
382
- function renderUsers(users) {
408
+ // 使用模式:有成员 = 多用户,没有 = 单用户(默认)
409
+ let _users = [];
410
+ function renderUsers(users, exclusive) {
411
+ _users = users;
412
+ const multi = users.length > 0;
413
+ document.getElementById('mode-status').innerHTML = multi
414
+ ? `<b>当前:多用户模式 · ${users.length} 个成员</b><small>成员用各自密码登录,可以升级 / 重启,不能改全局设置;你用管理密码或二维码登录,权限不变。</small>`
415
+ : `<b>当前:单用户模式</b><small>只有你在用,所有功能都能用。需要给别人用时再添加成员。</small>`;
416
+ document.getElementById('add-open').textContent = multi ? '+ 添加成员' : '+ 添加成员,开启多用户模式';
417
+ document.getElementById('excl-row').style.display = multi ? '' : 'none';
418
+ document.getElementById('excl-toggle').checked = !!exclusive;
419
+ document.getElementById('mode-foot').textContent = multi ? '删除全部成员即回到单用户模式。删除成员或重置密码后,他当前的登录会立即失效。' : '';
383
420
  const el = document.getElementById('user-list');
384
- if (!users.length) { el.innerHTML = '<div class="row" style="color:#6e7681">暂无用户</div>'; return; }
385
421
  el.innerHTML = users.map(u => `
386
422
  <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>
423
+ <span><b style="color:#e6edf3">${esc(u.name)}</b> <span style="color:#6e7681;font-size:0.72rem">${u.dir ? '📁 ' + esc(u.dir) : '不限目录'}</span></span>
388
424
  <div class="row-actions">
389
- <button onclick="editUserPwd('${esc(u.name)}')">改密</button>
425
+ <button onclick="editUserPwd('${esc(u.name)}')">重置密码</button>
390
426
  <button class="del" onclick="delUser('${esc(u.name)}')">删除</button>
391
427
  </div>
392
428
  </div>
393
429
  `).join('');
394
430
  }
395
431
 
432
+ function openAddMember() {
433
+ document.getElementById('add-member').style.display = '';
434
+ document.getElementById('add-open').style.display = 'none';
435
+ document.getElementById('u-name').focus();
436
+ }
437
+ function closeAddMember() {
438
+ document.getElementById('add-member').style.display = 'none';
439
+ document.getElementById('add-open').style.display = '';
440
+ document.getElementById('dir-pick').style.display = 'none';
441
+ }
442
+ function genPwd() {
443
+ const cs = 'abcdefghjkmnpqrstuvwxyz23456789';
444
+ const a = crypto.getRandomValues(new Uint32Array(8));
445
+ document.getElementById('u-pwd').value = [...a].map(n => cs[n % cs.length]).join('');
446
+ }
447
+ // 目录点选:复用 /api/dirs(主站以管理员身份登录时可用;不行就手动输入)
448
+ async function pickDir(path) {
449
+ const box = document.getElementById('dir-pick');
450
+ box.style.display = '';
451
+ try {
452
+ const r = await fetch('/api/dirs' + (path ? '?path=' + encodeURIComponent(path) : ''));
453
+ const d = await r.json();
454
+ if (!r.ok) throw new Error(d.error || r.status);
455
+ document.getElementById('u-dir').value = d.current;
456
+ box.innerHTML = `<div class="cur">${esc(d.current)}</div>` +
457
+ (d.parent ? `<div data-p="${esc(d.parent)}">⬆ 上一级</div>` : '') +
458
+ (d.drives || []).map(x => `<div data-p="${esc(x)}">💽 ${esc(x)}</div>`).join('') +
459
+ d.dirs.map(x => `<div data-p="${esc(x.path)}">📁 ${esc(x.name)}</div>`).join('');
460
+ box.querySelectorAll('[data-p]').forEach(el => el.onclick = () => pickDir(el.dataset.p));
461
+ } catch (e) { box.innerHTML = `<div class="cur">无法浏览目录(${esc(e.message)}),请直接输入路径</div>`; }
462
+ }
463
+
464
+ async function setExclusive(on) {
465
+ try {
466
+ const r = await fetch('/api/admin/exclusive', { method: 'POST', headers: H(), body: JSON.stringify({ on }) });
467
+ const d = await r.json();
468
+ if (!d.ok) { alert(d.error || '失败'); refresh(); }
469
+ } catch { alert('连接失败'); refresh(); }
470
+ }
471
+
396
472
  function esc(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
397
473
 
398
474
  async function addUser() {
@@ -400,17 +476,27 @@ async function addUser() {
400
476
  const name = document.getElementById('u-name').value.trim();
401
477
  const dir = document.getElementById('u-dir').value.trim();
402
478
  const msg = document.getElementById('u-msg');
403
- if (!pwd || !name || !dir) { msg.className = 'msg msg-err'; msg.textContent = '密码、名称、目录都必填'; return; }
479
+ if (!name || !pwd) { msg.className = 'msg msg-err'; msg.textContent = '成员名称和登录密码必填'; return; }
480
+ // 第一个成员 = 从单用户切到多用户:先讲清楚会发生什么
481
+ if (!_users.length && !confirm(
482
+ '添加第一个成员后,myhi 进入「多用户模式」:\n\n' +
483
+ '· 成员用各自的密码登录' + (dir ? ',只能在绑定的目录里建任务' : '') + ';\n' +
484
+ '· 成员可以升级、重启,但不能改全局设置(本机显示名、语音、用户、网关等);\n' +
485
+ '· 你(管理员)照常用管理密码或二维码登录,权限不变。\n\n' +
486
+ '以后删除全部成员即回到单用户模式。确定添加?')) return;
404
487
  try {
405
488
  const r = await fetch('/api/admin/user', { method: 'POST', headers: H(), body: JSON.stringify({ password: pwd, name, dir }) });
406
489
  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 || '失败'; }
490
+ if (d.ok) {
491
+ msg.className = 'msg msg-ok'; msg.textContent = `已添加「${name}」,登录密码:${pwd}(请告知对方)`;
492
+ ['u-pwd', 'u-name', 'u-dir'].forEach(id => document.getElementById(id).value = '');
493
+ closeAddMember(); refresh();
494
+ } else { msg.className = 'msg msg-err'; msg.textContent = d.error || '失败'; }
409
495
  } catch { msg.className = 'msg msg-err'; msg.textContent = '连接失败'; }
410
496
  }
411
497
 
412
498
  async function editUserPwd(name) {
413
- const newPwd = prompt(`修改 ${name} 的密码:`);
499
+ const newPwd = prompt(`为 ${name} 设置新的登录密码(至少 4 位,设置后他当前的登录会失效):`);
414
500
  if (!newPwd) return;
415
501
  try {
416
502
  const r = await fetch('/api/admin/user/password', { method: 'POST', headers: H(), body: JSON.stringify({ name, password: newPwd }) });
@@ -421,7 +507,7 @@ async function editUserPwd(name) {
421
507
  }
422
508
 
423
509
  async function delUser(name) {
424
- if (!confirm(`确定删除用户 ${name}?`)) return;
510
+ if (!confirm(`确定删除成员「${name}」?他当前的登录会立即失效。` + (_users.length === 1 ? '\n\n这是最后一个成员,删除后回到单用户模式。' : ''))) return;
425
511
  try {
426
512
  const r = await fetch('/api/admin/user', { method: 'DELETE', headers: H(), body: JSON.stringify({ name }) });
427
513
  const d = await r.json();
@@ -442,15 +528,38 @@ async function changePwd() {
442
528
  } catch { msg.className = 'msg msg-err'; msg.textContent = '连接失败'; }
443
529
  }
444
530
 
445
- async function doUpgrade() {
531
+ // 等升级落地:轮询 /api/version(重启/停服期间连不上是正常的)。
532
+ // 成功 = 版本变了(且等于目标);失败 = 服务端报了本次升级的失败原因;10 分钟兜底超时。
533
+ async function waitUpgrade(from, target, onTick) {
534
+ const t0 = Date.now();
535
+ while (Date.now() - t0 < 10 * 60 * 1000) {
536
+ await new Promise(r => setTimeout(r, 3000));
537
+ let v = null;
538
+ try { const r = await fetch('/api/version', { cache: 'no-store' }); if (r.ok) v = await r.json(); } catch {}
539
+ onTick?.(!!v, Math.round((Date.now() - t0) / 1000));
540
+ if (!v) continue;
541
+ if (v.current !== from && (!target || v.current === target)) return { ok: true, version: v.current };
542
+ const last = v.upgrade?.last;
543
+ if (!v.upgrade?.running && last && !last.ok && last.at >= t0 - 5000) return { ok: false, error: last.error };
544
+ }
545
+ return { ok: false, error: '等了 10 分钟仍未完成,请稍后刷新页面查看版本,或到服务器上查看 myhi log' };
546
+ }
547
+ async function doUpgrade(force) {
446
548
  const btn = document.getElementById('btn-up'), msg = document.getElementById('op-msg');
447
- btn.disabled = true; btn.textContent = '升级中...'; msg.textContent = '正在下载安装...'; msg.style.display = '';
549
+ const fail = (t) => { msg.textContent = '失败: ' + t; btn.disabled = false; btn.textContent = '重试'; };
550
+ btn.disabled = true; btn.textContent = '升级中...'; msg.textContent = '正在提交升级...'; msg.style.display = '';
448
551
  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 = '重试'; }
552
+ const r = await fetch('/api/admin/upgrade', { method: 'POST', headers: { ...H(), 'Content-Type': 'application/json' }, body: JSON.stringify({ force: !!force }) });
553
+ const d = await r.json().catch(() => ({}));
554
+ if (!d.ok) {
555
+ if (d.busy?.length && confirm(d.error + '\n\n仍然现在升级吗?')) return doUpgrade(true);
556
+ return fail(d.error || ('HTTP ' + r.status));
557
+ }
558
+ msg.textContent = d.message || '升级中...';
559
+ const res = await waitUpgrade(d.from, d.target, (up, sec) => { msg.textContent = (up ? '正在安装新版本… ' : '服务重启中… ') + sec + 's'; });
560
+ if (res.ok) { msg.textContent = '已升级到 v' + res.version + ',即将刷新'; setTimeout(() => location.reload(), 800); }
561
+ else fail(res.error);
562
+ } catch { fail('连接失败'); }
454
563
  }
455
564
 
456
565
  async function doClaudeUpgrade() {
@@ -492,7 +601,15 @@ async function waitReload() {
492
601
  document.getElementById('op-msg').textContent = '重启超时,请手动刷新';
493
602
  }
494
603
 
495
- if (T) showAdmin();
604
+ // 主站已是管理员 → 直接换后台令牌免登录;否则沿用本标签页已有令牌(重启后会失效)或显示登录框
605
+ (async () => {
606
+ try {
607
+ const r = await fetch('/api/admin/sso', { method: 'POST' });
608
+ const d = r.ok ? await r.json() : null;
609
+ if (d?.ok) { T = d.token; sessionStorage.setItem('myhi_admin', T); return showAdmin(); }
610
+ } catch {}
611
+ if (T) showAdmin();
612
+ })();
496
613
  </script>
497
614
  </body>
498
615
  </html>