dsh-remote-plugin 0.6.12 → 0.6.13
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/apk/dsh-remote.apk +0 -0
- package/gateway.cjs +294 -17
- package/index.mjs +30 -5
- package/package.json +1 -1
- package/public/admin.html +157 -8
- package/public/admin.js +264 -5
- package/public/announcements.json +27 -0
- package/public/app.js +205 -20
- package/public/desktop/desktop.css +5 -0
- package/public/desktop/desktop.html +4 -2
- package/public/desktop/desktop.js +103 -14
- package/public/index.html +29 -4
- package/public/styles.css +23 -0
- package/public/update.json +8 -8
- package/public/version.json +1 -1
package/public/admin.html
CHANGED
|
@@ -79,6 +79,30 @@
|
|
|
79
79
|
.token-row code { flex: none; width: 100%; background: var(--dsr-bg); border: 1px solid var(--dsr-line); border-radius: 8px; padding: 8px 10px; font-size: 12px; white-space: nowrap; overflow-x: auto; }
|
|
80
80
|
.token-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
81
81
|
.token-actions .mini-btn { flex: 1 1 auto; text-align: center; padding: 6px 8px; font-size: 12.5px; }
|
|
82
|
+
.device-key-card { margin: 0 0 14px; }
|
|
83
|
+
.device-key-toggle { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:12px 13px; border:1px solid var(--dsr-line); border-radius:16px; background:var(--dsr-panel); }
|
|
84
|
+
.device-key-toggle-copy { min-width:0; }
|
|
85
|
+
.device-key-toggle-title { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:750; }
|
|
86
|
+
.device-key-toggle-desc { margin-top:3px; color:var(--dsr-muted); font-size:11px; line-height:1.45; }
|
|
87
|
+
.device-key-toggle-actions { display:flex; align-items:center; gap:8px; flex:none; }
|
|
88
|
+
.device-key-switch { position:relative; width:42px; height:24px; flex:none; }
|
|
89
|
+
.device-key-switch input { position:absolute; opacity:0; pointer-events:none; }
|
|
90
|
+
.device-key-switch span { position:absolute; inset:0; border:1px solid var(--dsr-line); border-radius:999px; background:var(--dsr-bg-2); cursor:pointer; transition:.18s ease; }
|
|
91
|
+
.device-key-switch span::after { content:''; position:absolute; width:18px; height:18px; left:2px; top:2px; border-radius:50%; background:var(--dsr-muted); transition:.18s ease; }
|
|
92
|
+
.device-key-switch input:checked + span { border-color:var(--dsr-accent-line); background:var(--dsr-accent-soft); }
|
|
93
|
+
.device-key-switch input:checked + span::after { transform:translateX(18px); background:var(--dsr-accent-strong); }
|
|
94
|
+
.device-key-switch input:focus-visible + span { outline:2px solid var(--dsr-accent-strong); outline-offset:2px; }
|
|
95
|
+
.device-key-panel { margin-top:10px; border:1px solid var(--dsr-line); border-radius:16px; overflow:hidden; background:var(--dsr-panel); }
|
|
96
|
+
.device-key-grid { display:grid; grid-template-columns:minmax(120px,.8fr) minmax(110px,.65fr) minmax(190px,1.4fr) auto; gap:10px; align-items:center; padding:10px 12px; }
|
|
97
|
+
.device-key-grid + .device-key-grid { border-top:1px solid var(--dsr-line); }
|
|
98
|
+
.device-key-grid.head { color:var(--dsr-muted); background:var(--dsr-bg-2); font-size:11px; font-weight:750; }
|
|
99
|
+
.device-key-grid code { display:block; min-width:0; overflow-x:auto; white-space:nowrap; padding:7px 9px; border:1px solid var(--dsr-line); border-radius:8px; background:var(--dsr-bg); font-size:11px; }
|
|
100
|
+
.device-key-note { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
101
|
+
.device-key-note button { margin-left:5px; padding:1px 6px; }
|
|
102
|
+
.device-key-actions { display:flex; gap:6px; flex-wrap:wrap; justify-content:flex-end; }
|
|
103
|
+
.device-key-actions .mini-btn { padding:5px 7px; font-size:11px; }
|
|
104
|
+
.device-key-ip { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; font-size:11px; white-space:nowrap; }
|
|
105
|
+
.device-key-empty { padding:16px; color:var(--dsr-muted); font-size:12px; text-align:center; }
|
|
82
106
|
.gateway-port-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 0 0 14px; padding: 10px 12px; background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: var(--dsr-radius); }
|
|
83
107
|
.gateway-port-row label { font-size: 12px; color: var(--dsr-muted); flex: none; }
|
|
84
108
|
.gateway-port-row input { width: 92px; background: var(--dsr-bg); color: var(--dsr-text); border: 1px solid var(--dsr-line); border-radius: 8px; padding: 6px 8px; font: inherit; font-size: 13px; outline: none; }
|
|
@@ -228,6 +252,31 @@
|
|
|
228
252
|
.admin-hero-action { min-height:36px; padding:7px 13px; border-color:var(--dsr-accent-strong); background:var(--dsr-accent-strong); color:var(--dsr-on-accent); font-weight:700; }
|
|
229
253
|
.admin-hero.plugin .admin-hero-action { border-color:var(--dsr-accent-strong); background:var(--dsr-accent-strong); }
|
|
230
254
|
.admin-hero.offline .admin-hero-action { border-color:var(--dsr-accent-line); background:var(--dsr-accent-soft); color:var(--dsr-accent-strong); }
|
|
255
|
+
.doctor-card { margin:0 0 16px; border:1px solid var(--dsr-line); border-radius:20px; background:linear-gradient(145deg,var(--dsr-panel),var(--dsr-bg-2)); box-shadow:0 12px 30px var(--dsr-shadow); overflow:hidden; }
|
|
256
|
+
.doctor-head { width:100%; display:flex; align-items:center; gap:12px; padding:14px 16px; border:0; background:transparent; color:var(--dsr-text); text-align:left; cursor:pointer; font:inherit; }
|
|
257
|
+
.doctor-head:focus-visible { outline:2px solid var(--dsr-accent-strong); outline-offset:-3px; }
|
|
258
|
+
.doctor-title { min-width:0; flex:1; }
|
|
259
|
+
.doctor-title strong { display:block; font-size:14px; }
|
|
260
|
+
.doctor-title span { display:block; margin-top:3px; color:var(--dsr-muted); font-size:11px; line-height:1.45; }
|
|
261
|
+
.doctor-progress { flex:none; padding:4px 9px; border:1px solid var(--dsr-accent-line); border-radius:999px; color:var(--dsr-accent-strong); background:var(--dsr-accent-soft); font-size:11px; font-weight:750; }
|
|
262
|
+
.doctor-chevron { flex:none; color:var(--dsr-muted); transition:transform .18s ease; }
|
|
263
|
+
.doctor-card.expanded .doctor-chevron { transform:rotate(180deg); }
|
|
264
|
+
.doctor-body { display:none; padding:0 16px 16px; }
|
|
265
|
+
.doctor-card.expanded .doctor-body { display:block; }
|
|
266
|
+
.doctor-summary { margin:0 0 10px; padding:9px 11px; border-radius:11px; color:var(--dsr-muted); background:var(--dsr-bg); font-size:12px; line-height:1.5; }
|
|
267
|
+
.doctor-summary.ok { color:var(--dsr-success); background:var(--dsr-success-soft); }
|
|
268
|
+
.doctor-steps { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
|
269
|
+
.doctor-step { display:grid; grid-template-columns:24px minmax(0,1fr) auto; gap:9px; align-items:start; padding:11px; border:1px solid var(--dsr-line); border-radius:13px; background:var(--dsr-panel-2); }
|
|
270
|
+
.doctor-mark { width:22px; height:22px; display:grid; place-items:center; border-radius:50%; color:var(--dsr-muted); background:var(--dsr-bg); font-size:12px; font-weight:800; }
|
|
271
|
+
.doctor-step.pass .doctor-mark { color:var(--dsr-success); background:var(--dsr-success-soft); }
|
|
272
|
+
.doctor-step.fail .doctor-mark { color:var(--dsr-error); background:var(--dsr-error-soft); }
|
|
273
|
+
.doctor-step.warn .doctor-mark { color:var(--dsr-warning); background:var(--dsr-warning-soft); }
|
|
274
|
+
.doctor-copy { min-width:0; }
|
|
275
|
+
.doctor-copy strong { display:block; font-size:12px; }
|
|
276
|
+
.doctor-copy span { display:block; margin-top:3px; color:var(--dsr-muted); font-size:11px; line-height:1.45; overflow-wrap:anywhere; }
|
|
277
|
+
.doctor-action { align-self:center; padding:5px 8px; font-size:11px; }
|
|
278
|
+
.doctor-foot { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-top:10px; color:var(--dsr-muted); font-size:11px; }
|
|
279
|
+
.doctor-foot-actions { display:flex; gap:7px; flex-wrap:wrap; }
|
|
231
280
|
#stats { grid-template-columns:repeat(4,minmax(0,1fr)); }
|
|
232
281
|
#stats .stat-card:nth-child(n+5) { background:transparent; box-shadow:none; }
|
|
233
282
|
#stats .stat-card:nth-child(n+5) .v { font-size:20px; }
|
|
@@ -236,6 +285,7 @@
|
|
|
236
285
|
.admin-hero h2 { font-size:23px; }
|
|
237
286
|
.admin-hero-side { align-items:stretch; flex-direction:row; justify-content:space-between; }
|
|
238
287
|
.admin-hero-action { flex:0 0 auto; }
|
|
288
|
+
.doctor-steps { grid-template-columns:1fr; }
|
|
239
289
|
#stats { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
|
240
290
|
#stats .stat-card:nth-child(n+5) { background:linear-gradient(145deg, var(--dsr-panel), var(--dsr-bg-2)); box-shadow:inset 0 1px var(--dsr-divider), 0 10px 26px var(--dsr-shadow); }
|
|
241
291
|
}
|
|
@@ -243,6 +293,13 @@
|
|
|
243
293
|
.token-row { display: flex; flex-direction: column; align-items: stretch; }
|
|
244
294
|
.token-actions { justify-content: stretch; }
|
|
245
295
|
.token-actions .mini-btn { flex: 1 1 auto; }
|
|
296
|
+
.device-key-toggle { align-items:flex-start; }
|
|
297
|
+
.device-key-toggle-actions { align-items:flex-end; flex-direction:column-reverse; }
|
|
298
|
+
.device-key-grid.head { display:none; }
|
|
299
|
+
.device-key-grid { grid-template-columns:1fr; gap:7px; padding:12px; }
|
|
300
|
+
.device-key-grid > *::before { content:attr(data-label); display:block; margin-bottom:3px; color:var(--dsr-muted); font:700 10px/1.3 system-ui,sans-serif; }
|
|
301
|
+
.device-key-actions { justify-content:stretch; }
|
|
302
|
+
.device-key-actions .mini-btn { flex:1 1 calc(50% - 6px); }
|
|
246
303
|
}
|
|
247
304
|
@media (max-width: 900px) {
|
|
248
305
|
.admin-title { display: grid; grid-template-columns: minmax(0, 1fr) auto; row-gap: 10px; }
|
|
@@ -315,14 +372,58 @@
|
|
|
315
372
|
</div>
|
|
316
373
|
</section>
|
|
317
374
|
|
|
318
|
-
<
|
|
319
|
-
<
|
|
320
|
-
|
|
321
|
-
<
|
|
322
|
-
<
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
<
|
|
375
|
+
<section id="doctor-card" class="doctor-card" aria-live="polite">
|
|
376
|
+
<button id="doctor-toggle" class="doctor-head" type="button" aria-expanded="false" aria-controls="doctor-body">
|
|
377
|
+
<span class="doctor-title"><strong data-i18n="doctor.title">首次连接向导 · Doctor</strong><span id="doctor-subtitle">正在检查连接链路</span></span>
|
|
378
|
+
<span id="doctor-progress" class="doctor-progress">0/6</span>
|
|
379
|
+
<span class="doctor-chevron" aria-hidden="true">⌄</span>
|
|
380
|
+
</button>
|
|
381
|
+
<div id="doctor-body" class="doctor-body">
|
|
382
|
+
<div id="doctor-summary" class="doctor-summary">正在读取诊断结果…</div>
|
|
383
|
+
<div id="doctor-steps" class="doctor-steps"></div>
|
|
384
|
+
<div class="doctor-foot">
|
|
385
|
+
<span data-i18n="doctor.security">安全提示:只在可信局域网或 Tailscale 中开放网关端口,不要把令牌公开。</span>
|
|
386
|
+
<span class="doctor-foot-actions">
|
|
387
|
+
<button id="doctor-copy-report" class="mini-btn" type="button" data-i18n="doctor.copyReport">复制诊断</button>
|
|
388
|
+
<button id="doctor-refresh" class="mini-btn" type="button" data-i18n="doctor.refresh">重新检查</button>
|
|
389
|
+
</span>
|
|
390
|
+
</div>
|
|
391
|
+
</div>
|
|
392
|
+
</section>
|
|
393
|
+
|
|
394
|
+
<div class="device-key-card">
|
|
395
|
+
<div id="device-key-toggle" class="device-key-toggle hidden">
|
|
396
|
+
<div class="device-key-toggle-copy">
|
|
397
|
+
<div class="device-key-toggle-title" data-i18n="deviceKeys.title">独立设备密钥</div>
|
|
398
|
+
<div class="device-key-toggle-desc" data-i18n="deviceKeys.desc">开启后每台设备使用独立令牌,可单独轮换或退出;原共享令牌仅用于网关管理。</div>
|
|
399
|
+
</div>
|
|
400
|
+
<div class="device-key-toggle-actions">
|
|
401
|
+
<button id="btn-gateway" class="mini-btn hidden" data-i18n="startGateway">启动网关</button>
|
|
402
|
+
<button id="btn-device-key-add" class="mini-btn hidden" type="button" data-i18n="deviceKeys.add">添加设备</button>
|
|
403
|
+
<label class="device-key-switch" title="独立设备密钥">
|
|
404
|
+
<input id="device-key-enabled" type="checkbox" role="switch" aria-label="独立设备密钥">
|
|
405
|
+
<span aria-hidden="true"></span>
|
|
406
|
+
</label>
|
|
407
|
+
<button id="btn-logout" class="mini-btn" data-i18n="signOut">退出</button>
|
|
408
|
+
</div>
|
|
409
|
+
</div>
|
|
410
|
+
<div id="shared-token-row" class="token-row">
|
|
411
|
+
<code id="token-full">—</code>
|
|
412
|
+
<div class="token-actions">
|
|
413
|
+
<button id="btn-qr" class="mini-btn hidden" data-i18n="qrCode">二维码</button>
|
|
414
|
+
<button id="btn-rotate" class="mini-btn hidden" data-i18n="rotateToken">轮换令牌</button>
|
|
415
|
+
<button id="btn-copy" class="mini-btn" data-i18n="copyToken">复制令牌</button>
|
|
416
|
+
</div>
|
|
417
|
+
</div>
|
|
418
|
+
<div id="device-key-panel" class="device-key-panel hidden">
|
|
419
|
+
<div class="device-key-grid head">
|
|
420
|
+
<span data-i18n="deviceKeys.note">备注内容</span>
|
|
421
|
+
<span data-i18n="deviceKeys.ip">IP</span>
|
|
422
|
+
<span>Token</span>
|
|
423
|
+
<span></span>
|
|
424
|
+
</div>
|
|
425
|
+
<div id="device-key-rows"></div>
|
|
426
|
+
<div id="device-key-empty" class="device-key-empty hidden" data-i18n="deviceKeys.empty">暂无设备密钥</div>
|
|
326
427
|
</div>
|
|
327
428
|
</div>
|
|
328
429
|
|
|
@@ -400,6 +501,17 @@
|
|
|
400
501
|
'admin': '管理',
|
|
401
502
|
'hero.eyebrow': 'GATEWAY CONTROL', 'hero.running': '网关运行正常', 'hero.attention': '网关需要关注', 'hero.plugin': 'DSH 插件已连接', 'hero.offline': '网关未运行', 'hero.checking': '正在检查网关',
|
|
402
503
|
'hero.runningDesc': '{online} 台设备在线 · {requests} 次请求', 'hero.attentionDesc': 'DSH 上游暂时不可达,请打开诊断', 'hero.pluginDesc': '插件已连接 DSH,网关可按需启动', 'hero.offlineDesc': '启动网关后即可接受 App 和桌面端连接', 'hero.openDevices': '查看设备', 'hero.startGateway': '启动网关', 'hero.copyToken': '复制令牌',
|
|
504
|
+
'doctor.title': '首次连接向导 · Doctor', 'doctor.checking': '正在检查连接链路', 'doctor.ready': '连接条件已就绪', 'doctor.needsWork': '还有 {n} 项需要处理',
|
|
505
|
+
'doctor.allGood': '所有关键检查均已通过,手机端可以稳定连接。', 'doctor.partial': '按顺序处理未通过项;页面每 5 秒自动复查。',
|
|
506
|
+
'doctor.security': '安全提示:只在可信局域网或 Tailscale 中开放网关端口,不要把令牌公开。', 'doctor.copyReport': '复制诊断', 'doctor.refresh': '重新检查',
|
|
507
|
+
'doctor.dsh': 'DSH 服务', 'doctor.dshPass': 'DSH 上游可访问', 'doctor.dshFail': 'DSH 上游不可达,请先启动或重启 DSH Web',
|
|
508
|
+
'doctor.gateway': '远程网关', 'doctor.gatewayPass': '网关正在 {host}:{port} 监听', 'doctor.gatewayFail': '网关尚未运行',
|
|
509
|
+
'doctor.network': '局域网地址', 'doctor.networkPass': '手机可填写 {base}', 'doctor.networkFail': '未发现可供手机访问的 IPv4 地址',
|
|
510
|
+
'doctor.firewall': '主机防火墙', 'doctor.firewallPass': '已有非管理设备成功接入,入站链路已验证', 'doctor.firewallWarn': '无法无权限确认规则;只对可信网络放行 TCP {port}',
|
|
511
|
+
'doctor.device': '终端配对', 'doctor.devicePass': '{n} 台 App / WebUI 在线', 'doctor.deviceWait': '扫码配对后,本项会自动变为通过',
|
|
512
|
+
'doctor.realtime': '实时消息通道', 'doctor.realtimePass': 'mux 与 host 通道均已连接', 'doctor.realtimeFail': '实时通道未全部建立;可先使用轮询,检查 DSH 后会自动恢复',
|
|
513
|
+
'doctor.start': '启动', 'doctor.showQr': '显示二维码', 'doctor.copyAddress': '复制地址', 'doctor.copyCommand': '复制命令',
|
|
514
|
+
'doctor.reportCopied': '诊断报告已复制', 'doctor.commandCopied': '防火墙命令已复制,请确认网络范围后手动执行', 'doctor.addressCopied': '服务器地址已复制',
|
|
403
515
|
'theme.default': '默认深空', 'theme.dark': '落日', 'theme.light': '易北爱乐厅', 'theme.neutral': '草原孤塔',
|
|
404
516
|
'theme.panelTitle': '选择配色', 'theme.close': '关闭',
|
|
405
517
|
'repo': '仓库',
|
|
@@ -425,6 +537,19 @@
|
|
|
425
537
|
'rotateToken': '轮换令牌',
|
|
426
538
|
'copyToken': '复制令牌',
|
|
427
539
|
'signOut': '退出',
|
|
540
|
+
'deviceKeys.title': '独立设备密钥', 'deviceKeys.desc': '开启后每台设备使用独立令牌,可单独轮换或退出;原共享令牌仅用于网关管理。',
|
|
541
|
+
'deviceKeys.add': '添加设备', 'deviceKeys.note': '备注内容', 'deviceKeys.ip': 'IP', 'deviceKeys.empty': '暂无设备密钥',
|
|
542
|
+
'deviceKeys.neverUsed': '尚未连接', 'deviceKeys.edit': '修改', 'deviceKeys.revoke': '退出',
|
|
543
|
+
'deviceKeys.enableConfirm': '开启后,当前手机和浏览器使用的共享令牌会失效,需要使用设备二维码重新配对。继续?',
|
|
544
|
+
'deviceKeys.disableConfirm': '关闭后,所有独立设备令牌会停止工作,需要重新使用共享令牌。继续?',
|
|
545
|
+
'deviceKeys.revokeConfirm': '让这个设备退出?该设备令牌会立即失效。',
|
|
546
|
+
'deviceKeys.rotateConfirm': '轮换这个设备的令牌?该设备会立即断开,需要重新扫码。',
|
|
547
|
+
'deviceKeys.notePrompt': '设备备注内容',
|
|
548
|
+
'deviceKeys.enabled': '独立设备密钥已开启,请使用设备二维码重新配对',
|
|
549
|
+
'deviceKeys.disabled': '独立设备密钥已关闭,客户端改用共享令牌',
|
|
550
|
+
'deviceKeys.created': '已创建设备密钥', 'deviceKeys.saved': '设备备注已保存',
|
|
551
|
+
'deviceKeys.rotated': '设备令牌已轮换', 'deviceKeys.revoked': '设备已退出',
|
|
552
|
+
'deviceKeys.failed': '设备密钥操作失败:{msg}',
|
|
428
553
|
'pairTitle': '手机 App 扫码配对',
|
|
429
554
|
'devices': '已连接设备',
|
|
430
555
|
'noDevices': '暂无设备记录',
|
|
@@ -485,6 +610,17 @@
|
|
|
485
610
|
'admin': 'Admin',
|
|
486
611
|
'hero.eyebrow': 'GATEWAY CONTROL', 'hero.running': 'Gateway is healthy', 'hero.attention': 'Gateway needs attention', 'hero.plugin': 'DSH plugin connected', 'hero.offline': 'Gateway is not running', 'hero.checking': 'Checking gateway',
|
|
487
612
|
'hero.runningDesc': '{online} devices online · {requests} requests', 'hero.attentionDesc': 'DSH upstream is unreachable; open diagnostics', 'hero.pluginDesc': 'The plugin is connected to DSH; start the gateway when needed', 'hero.offlineDesc': 'Start the gateway to accept App and desktop connections', 'hero.openDevices': 'View devices', 'hero.startGateway': 'Start gateway', 'hero.copyToken': 'Copy token',
|
|
613
|
+
'doctor.title': 'First connection · Doctor', 'doctor.checking': 'Checking the connection path', 'doctor.ready': 'Ready to connect', 'doctor.needsWork': '{n} checks need attention',
|
|
614
|
+
'doctor.allGood': 'All critical checks passed. Mobile clients can connect.', 'doctor.partial': 'Resolve the checks in order. This page rechecks every 5 seconds.',
|
|
615
|
+
'doctor.security': 'Security: expose the gateway only to a trusted LAN or Tailscale, and never publish the token.', 'doctor.copyReport': 'Copy report', 'doctor.refresh': 'Recheck',
|
|
616
|
+
'doctor.dsh': 'DSH service', 'doctor.dshPass': 'DSH upstream is reachable', 'doctor.dshFail': 'DSH upstream is unreachable. Start or restart DSH Web first.',
|
|
617
|
+
'doctor.gateway': 'Remote gateway', 'doctor.gatewayPass': 'Listening on {host}:{port}', 'doctor.gatewayFail': 'Gateway is not running',
|
|
618
|
+
'doctor.network': 'LAN address', 'doctor.networkPass': 'Use {base} on the phone', 'doctor.networkFail': 'No IPv4 address available to the phone',
|
|
619
|
+
'doctor.firewall': 'Host firewall', 'doctor.firewallPass': 'A non-admin client connected, confirming inbound access', 'doctor.firewallWarn': 'Rules cannot be confirmed without privilege. Allow TCP {port} only on trusted networks.',
|
|
620
|
+
'doctor.device': 'Client pairing', 'doctor.devicePass': '{n} App / WebUI clients online', 'doctor.deviceWait': 'Scan the QR code; this check updates automatically',
|
|
621
|
+
'doctor.realtime': 'Realtime channels', 'doctor.realtimePass': 'Both mux and host channels are connected', 'doctor.realtimeFail': 'Realtime channels are incomplete. Polling remains available and recovery is automatic.',
|
|
622
|
+
'doctor.start': 'Start', 'doctor.showQr': 'Show QR', 'doctor.copyAddress': 'Copy address', 'doctor.copyCommand': 'Copy command',
|
|
623
|
+
'doctor.reportCopied': 'Diagnostic report copied', 'doctor.commandCopied': 'Firewall command copied. Review the network scope before running it.', 'doctor.addressCopied': 'Server address copied',
|
|
488
624
|
'theme.default': 'Default', 'theme.dark': 'Sunset', 'theme.light': 'Elbphilharmonie', 'theme.neutral': 'Prairie Tower',
|
|
489
625
|
'theme.panelTitle': 'Choose theme', 'theme.close': 'Close',
|
|
490
626
|
'repo': 'Repo',
|
|
@@ -510,6 +646,19 @@
|
|
|
510
646
|
'rotateToken': 'Rotate token',
|
|
511
647
|
'copyToken': 'Copy token',
|
|
512
648
|
'signOut': 'Sign out',
|
|
649
|
+
'deviceKeys.title': 'Per-device keys', 'deviceKeys.desc': 'Give every device its own token so it can be rotated or signed out separately. The shared token remains admin-only.',
|
|
650
|
+
'deviceKeys.add': 'Add device', 'deviceKeys.note': 'Note', 'deviceKeys.ip': 'IP', 'deviceKeys.empty': 'No device keys',
|
|
651
|
+
'deviceKeys.neverUsed': 'Not connected yet', 'deviceKeys.edit': 'Edit', 'deviceKeys.revoke': 'Sign out',
|
|
652
|
+
'deviceKeys.enableConfirm': 'Existing phones and browsers using the shared token will disconnect and must pair again with a device QR code. Continue?',
|
|
653
|
+
'deviceKeys.disableConfirm': 'All per-device tokens will stop working and clients must use the shared token again. Continue?',
|
|
654
|
+
'deviceKeys.revokeConfirm': 'Sign out this device? Its token becomes invalid immediately.',
|
|
655
|
+
'deviceKeys.rotateConfirm': 'Rotate this device token? It will disconnect and must scan again.',
|
|
656
|
+
'deviceKeys.notePrompt': 'Device note',
|
|
657
|
+
'deviceKeys.enabled': 'Per-device keys enabled. Pair clients with a device QR code.',
|
|
658
|
+
'deviceKeys.disabled': 'Per-device keys disabled. Clients now use the shared token.',
|
|
659
|
+
'deviceKeys.created': 'Device key created', 'deviceKeys.saved': 'Device note saved',
|
|
660
|
+
'deviceKeys.rotated': 'Device token rotated', 'deviceKeys.revoked': 'Device signed out',
|
|
661
|
+
'deviceKeys.failed': 'Device key operation failed: {msg}',
|
|
513
662
|
'pairTitle': 'Pair with the mobile app',
|
|
514
663
|
'devices': 'Connected devices',
|
|
515
664
|
'noDevices': 'No devices yet',
|
package/public/admin.js
CHANGED
|
@@ -24,8 +24,77 @@ let gatewayBusy = false
|
|
|
24
24
|
let shownToken = token
|
|
25
25
|
let lastState = null
|
|
26
26
|
let qrShown = false
|
|
27
|
+
let qrToken = ''
|
|
28
|
+
let qrLabel = ''
|
|
29
|
+
let deviceKeyBusy = false
|
|
27
30
|
let gatewayPort = 8787
|
|
28
31
|
let gatewayPortLoaded = false
|
|
32
|
+
let doctorExpanded = store.get('dshAdminDoctorCollapsed') !== '1'
|
|
33
|
+
let doctorChecks = []
|
|
34
|
+
|
|
35
|
+
function onlineClientDevices(st) {
|
|
36
|
+
return (st.devices || []).filter(device => device.online && (device.kind === 'app' || device.kind === 'web'))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function firewallCommand(st) {
|
|
40
|
+
const port = Number(st.port || gatewayPort) || 8787
|
|
41
|
+
const ip = (st.lanIPs || []).find(value => /^10\.|^192\.168\.|^172\.(1[6-9]|2\d|3[01])\.|^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(value || ''))
|
|
42
|
+
let cidr = 'LocalSubnet'
|
|
43
|
+
if (/^10\./.test(ip || '')) cidr = '10.0.0.0/8'
|
|
44
|
+
else if (/^192\.168\./.test(ip || '')) cidr = '192.168.0.0/16'
|
|
45
|
+
else if (/^172\./.test(ip || '')) cidr = '172.16.0.0/12'
|
|
46
|
+
else if (/^100\./.test(ip || '')) cidr = '100.64.0.0/10'
|
|
47
|
+
if (st.platform === 'win32') return `New-NetFirewallRule -DisplayName "DSH Remote ${port}" -Direction Inbound -Protocol TCP -LocalPort ${port} -RemoteAddress ${cidr} -Action Allow -Profile Private`
|
|
48
|
+
if (st.platform === 'darwin') return `系统设置 → 网络 → 防火墙;仅允许 Node.js / DSH Remote 接受可信网络入站连接(TCP ${port})`
|
|
49
|
+
return `sudo ufw allow from ${cidr} to any port ${port} proto tcp\n# firewalld: sudo firewall-cmd --zone=home --add-source=${cidr} --permanent && sudo firewall-cmd --zone=home --add-port=${port}/tcp --permanent && sudo firewall-cmd --reload`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function buildDoctorChecks(st) {
|
|
53
|
+
const isGateway = st.mode === 'gateway'
|
|
54
|
+
const port = Number(st.port || gatewayPort) || 8787
|
|
55
|
+
const ip = (st.lanIPs || []).find(value => value && value !== '127.0.0.1' && value !== '0.0.0.0')
|
|
56
|
+
const base = ip ? `http://${ip}:${port}` : ''
|
|
57
|
+
const clients = onlineClientDevices(st)
|
|
58
|
+
const events = st.events || {}
|
|
59
|
+
const realtime = !!(events.mux?.connected && events.host?.connected)
|
|
60
|
+
return [
|
|
61
|
+
{ id: 'dsh', status: st.upstream?.reachable ? 'pass' : 'fail', title: t('doctor.dsh'), detail: t(st.upstream?.reachable ? 'doctor.dshPass' : 'doctor.dshFail') },
|
|
62
|
+
{ id: 'gateway', status: isGateway ? 'pass' : 'fail', title: t('doctor.gateway'), detail: isGateway ? t('doctor.gatewayPass', { host: st.host, port }) : t('doctor.gatewayFail'), action: !isGateway && pluginMode ? 'start' : '' },
|
|
63
|
+
{ id: 'network', status: isGateway && ip && st.host !== '127.0.0.1' ? 'pass' : 'fail', title: t('doctor.network'), detail: base ? t('doctor.networkPass', { base }) : t('doctor.networkFail'), action: base ? 'address' : '' },
|
|
64
|
+
{ id: 'firewall', status: clients.length ? 'pass' : 'warn', title: t('doctor.firewall'), detail: t(clients.length ? 'doctor.firewallPass' : 'doctor.firewallWarn', { port }), action: clients.length ? '' : 'firewall' },
|
|
65
|
+
{ id: 'device', status: clients.length ? 'pass' : 'warn', title: t('doctor.device'), detail: t(clients.length ? 'doctor.devicePass' : 'doctor.deviceWait', { n: clients.length }), action: isGateway && !clients.length ? 'qr' : '' },
|
|
66
|
+
{ id: 'realtime', status: realtime ? 'pass' : 'warn', title: t('doctor.realtime'), detail: t(realtime ? 'doctor.realtimePass' : 'doctor.realtimeFail') },
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function renderDoctor(st) {
|
|
71
|
+
doctorChecks = buildDoctorChecks(st)
|
|
72
|
+
const passed = doctorChecks.filter(check => check.status === 'pass').length
|
|
73
|
+
const remaining = doctorChecks.length - passed
|
|
74
|
+
const allGood = remaining === 0
|
|
75
|
+
const card = $('doctor-card')
|
|
76
|
+
card.classList.toggle('expanded', doctorExpanded)
|
|
77
|
+
$('doctor-toggle').setAttribute('aria-expanded', String(doctorExpanded))
|
|
78
|
+
$('doctor-progress').textContent = `${passed}/${doctorChecks.length}`
|
|
79
|
+
$('doctor-subtitle').textContent = t(allGood ? 'doctor.ready' : 'doctor.needsWork', { n: remaining })
|
|
80
|
+
$('doctor-summary').textContent = t(allGood ? 'doctor.allGood' : 'doctor.partial')
|
|
81
|
+
$('doctor-summary').classList.toggle('ok', allGood)
|
|
82
|
+
const actionText = { start: 'doctor.start', qr: 'doctor.showQr', address: 'doctor.copyAddress', firewall: 'doctor.copyCommand' }
|
|
83
|
+
$('doctor-steps').innerHTML = doctorChecks.map(check => `<div class="doctor-step ${check.status}">
|
|
84
|
+
<span class="doctor-mark" aria-hidden="true">${check.status === 'pass' ? '✓' : check.status === 'fail' ? '!' : '·'}</span>
|
|
85
|
+
<span class="doctor-copy"><strong>${esc(check.title)}</strong><span>${esc(check.detail)}</span></span>
|
|
86
|
+
${check.action ? `<button class="mini-btn doctor-action" type="button" data-doctor-action="${check.action}">${esc(t(actionText[check.action]))}</button>` : ''}
|
|
87
|
+
</div>`).join('')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function doctorCopy(value, messageKey) {
|
|
91
|
+
try {
|
|
92
|
+
await navigator.clipboard.writeText(value)
|
|
93
|
+
toast(t(messageKey), 'ok')
|
|
94
|
+
} catch {
|
|
95
|
+
toast(t('toast.copyFailed'), 'err')
|
|
96
|
+
}
|
|
97
|
+
}
|
|
29
98
|
|
|
30
99
|
const STATS_API = pluginMode ? API + '/stats' : '/stats'
|
|
31
100
|
let statsTimer = null
|
|
@@ -170,6 +239,53 @@ function fmtTime(ts) {
|
|
|
170
239
|
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
|
|
171
240
|
}
|
|
172
241
|
|
|
242
|
+
function esc(value) {
|
|
243
|
+
return String(value ?? '').replace(/[<>&"]/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[c]))
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function renderDeviceKeys(st, isGateway) {
|
|
247
|
+
const config = st.deviceKeys || { supported: false, enabled: false, entries: [] }
|
|
248
|
+
const supported = isGateway && config.supported !== false
|
|
249
|
+
const enabled = supported && config.enabled === true
|
|
250
|
+
const toggleWrap = $('device-key-toggle')
|
|
251
|
+
toggleWrap.classList.toggle('hidden', !isGateway && !pluginMode)
|
|
252
|
+
const toggle = $('device-key-enabled')
|
|
253
|
+
toggle.checked = enabled
|
|
254
|
+
toggle.disabled = !supported || deviceKeyBusy
|
|
255
|
+
$('btn-device-key-add').classList.toggle('hidden', !enabled)
|
|
256
|
+
$('btn-device-key-add').disabled = deviceKeyBusy
|
|
257
|
+
$('shared-token-row').classList.toggle('hidden', enabled)
|
|
258
|
+
$('device-key-panel').classList.toggle('hidden', !enabled)
|
|
259
|
+
if (!enabled) {
|
|
260
|
+
$('device-key-rows').innerHTML = ''
|
|
261
|
+
$('device-key-empty').classList.add('hidden')
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
const entries = Array.isArray(config.entries) ? config.entries : []
|
|
265
|
+
$('device-key-empty').classList.toggle('hidden', entries.length > 0)
|
|
266
|
+
$('device-key-rows').innerHTML = entries.map(entry => {
|
|
267
|
+
const note = entry.note || t('deviceKeys.neverUsed')
|
|
268
|
+
const ip = entry.lastIp || t('deviceKeys.neverUsed')
|
|
269
|
+
return `<div class="device-key-grid" data-device-key-id="${esc(entry.id)}">
|
|
270
|
+
<div class="device-key-note" data-label="${esc(t('deviceKeys.note'))}"><span>${esc(note)}</span><button class="mini-btn" type="button" data-device-key-note="${esc(entry.id)}">${esc(t('deviceKeys.edit'))}</button></div>
|
|
271
|
+
<div class="device-key-ip" data-label="${esc(t('deviceKeys.ip'))}">${esc(ip)}</div>
|
|
272
|
+
<code data-label="Token">${esc(entry.token)}</code>
|
|
273
|
+
<div class="device-key-actions">
|
|
274
|
+
<button class="mini-btn" type="button" data-device-key-qr="${esc(entry.id)}">${esc(t('qrCode'))}</button>
|
|
275
|
+
<button class="mini-btn" type="button" data-device-key-rotate="${esc(entry.id)}">${esc(t('rotateToken'))}</button>
|
|
276
|
+
<button class="mini-btn" type="button" data-device-key-copy="${esc(entry.id)}">${esc(t('copyToken'))}</button>
|
|
277
|
+
<button class="mini-btn danger" type="button" data-device-key-revoke="${esc(entry.id)}">${esc(t('deviceKeys.revoke'))}</button>
|
|
278
|
+
</div>
|
|
279
|
+
</div>`
|
|
280
|
+
}).join('')
|
|
281
|
+
const byId = id => entries.find(entry => entry.id === id)
|
|
282
|
+
document.querySelectorAll('[data-device-key-note]').forEach(button => button.addEventListener('click', () => editDeviceKeyNote(byId(button.dataset.deviceKeyNote))))
|
|
283
|
+
document.querySelectorAll('[data-device-key-qr]').forEach(button => button.addEventListener('click', () => showDeviceKeyQr(byId(button.dataset.deviceKeyQr))))
|
|
284
|
+
document.querySelectorAll('[data-device-key-rotate]').forEach(button => button.addEventListener('click', () => rotateDeviceKey(byId(button.dataset.deviceKeyRotate))))
|
|
285
|
+
document.querySelectorAll('[data-device-key-copy]').forEach(button => button.addEventListener('click', () => copyDeviceKey(byId(button.dataset.deviceKeyCopy))))
|
|
286
|
+
document.querySelectorAll('[data-device-key-revoke]').forEach(button => button.addEventListener('click', () => revokeDeviceKey(byId(button.dataset.deviceKeyRevoke))))
|
|
287
|
+
}
|
|
288
|
+
|
|
173
289
|
async function loadState() {
|
|
174
290
|
if (!token && !pluginMode) return
|
|
175
291
|
try {
|
|
@@ -205,7 +321,9 @@ function render(st) {
|
|
|
205
321
|
// 二维码与轮换只在网关模式下可用(二维码里有完整令牌, 不能在没有网关时生成)
|
|
206
322
|
$('btn-qr').classList.toggle('hidden', isGateway !== true || !shownToken)
|
|
207
323
|
$('btn-rotate').classList.toggle('hidden', isGateway !== true || !shownToken || !!st.tokenFromEnv)
|
|
324
|
+
renderDeviceKeys(st, isGateway)
|
|
208
325
|
renderQr(st)
|
|
326
|
+
renderDoctor(st)
|
|
209
327
|
// 网关开关: 仅插件内嵌页提供, 网关运行/停止两种状态
|
|
210
328
|
gatewayRunning = isGateway
|
|
211
329
|
$('btn-gateway').classList.toggle('hidden', !pluginMode)
|
|
@@ -305,30 +423,31 @@ function render(st) {
|
|
|
305
423
|
btn.addEventListener('click', () => setNote(btn.dataset.noteIp, btn.dataset.note)))
|
|
306
424
|
}
|
|
307
425
|
|
|
308
|
-
function pairTarget(st) {
|
|
426
|
+
function pairTarget(st, accessToken) {
|
|
309
427
|
const ip = (st.lanIPs || []).find(x => x && x !== '127.0.0.1' && x !== '0.0.0.0') || (st.lanIPs || [])[0]
|
|
310
428
|
const host = ip || (st.host && st.host !== '0.0.0.0' ? st.host : location.hostname)
|
|
311
429
|
const port = st.port || 8787
|
|
312
430
|
const base = `http://${host}:${port}`
|
|
313
431
|
return {
|
|
314
|
-
url: `dshremote://pair?token=${encodeURIComponent(
|
|
432
|
+
url: `dshremote://pair?token=${encodeURIComponent(accessToken)}&server=${encodeURIComponent(base)}`,
|
|
315
433
|
base
|
|
316
434
|
}
|
|
317
435
|
}
|
|
318
436
|
|
|
319
437
|
function renderQr(st) {
|
|
320
438
|
const box = $('pair-box')
|
|
321
|
-
|
|
439
|
+
const accessToken = qrToken || shownToken
|
|
440
|
+
if (!qrShown || !accessToken || st.mode !== 'gateway') {
|
|
322
441
|
box.classList.add('hidden')
|
|
323
442
|
return
|
|
324
443
|
}
|
|
325
444
|
try {
|
|
326
|
-
const pt = pairTarget(st)
|
|
445
|
+
const pt = pairTarget(st, accessToken)
|
|
327
446
|
const qr = window.qrcode(0, 'M')
|
|
328
447
|
qr.addData(pt.url)
|
|
329
448
|
qr.make()
|
|
330
449
|
$('pair-qr').innerHTML = qr.createSvgTag({ cellSize: 4, margin: 2, scalable: true })
|
|
331
|
-
$('pair-hint').textContent = t('pair.hint', { base: pt.base })
|
|
450
|
+
$('pair-hint').textContent = `${qrLabel ? qrLabel + ' · ' : ''}${t('pair.hint', { base: pt.base })}`
|
|
332
451
|
box.classList.remove('hidden')
|
|
333
452
|
} catch (e) {
|
|
334
453
|
$('pair-qr').textContent = t('pair.failed')
|
|
@@ -367,6 +486,110 @@ async function kick(ip) {
|
|
|
367
486
|
}
|
|
368
487
|
}
|
|
369
488
|
|
|
489
|
+
async function deviceKeyMutation(action, payload = {}) {
|
|
490
|
+
deviceKeyBusy = true
|
|
491
|
+
const toggle = $('device-key-enabled')
|
|
492
|
+
if (toggle) toggle.disabled = true
|
|
493
|
+
try {
|
|
494
|
+
const res = await fetch(`${API}/device-keys/${action}`, {
|
|
495
|
+
method: 'POST',
|
|
496
|
+
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' },
|
|
497
|
+
body: JSON.stringify(payload),
|
|
498
|
+
})
|
|
499
|
+
const out = await res.json().catch(() => ({}))
|
|
500
|
+
if (!res.ok || !out.ok) throw new Error(out.detail || out.error || `HTTP ${res.status}`)
|
|
501
|
+
return out
|
|
502
|
+
} catch (error) {
|
|
503
|
+
toast(t('deviceKeys.failed', { msg: error?.message || error }), 'err')
|
|
504
|
+
return null
|
|
505
|
+
} finally {
|
|
506
|
+
deviceKeyBusy = false
|
|
507
|
+
if (toggle) toggle.disabled = false
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async function setDeviceKeyMode(enabled) {
|
|
512
|
+
const toggle = $('device-key-enabled')
|
|
513
|
+
const confirmKey = enabled ? 'deviceKeys.enableConfirm' : 'deviceKeys.disableConfirm'
|
|
514
|
+
if (!confirm(t(confirmKey))) {
|
|
515
|
+
toggle.checked = !enabled
|
|
516
|
+
return
|
|
517
|
+
}
|
|
518
|
+
const out = await deviceKeyMutation('mode', { enabled })
|
|
519
|
+
if (!out) {
|
|
520
|
+
toggle.checked = !enabled
|
|
521
|
+
return
|
|
522
|
+
}
|
|
523
|
+
qrShown = false
|
|
524
|
+
qrToken = ''
|
|
525
|
+
qrLabel = ''
|
|
526
|
+
toast(t(enabled ? 'deviceKeys.enabled' : 'deviceKeys.disabled'), 'ok')
|
|
527
|
+
await loadState()
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function createDeviceKey() {
|
|
531
|
+
const note = prompt(t('deviceKeys.notePrompt'), '')
|
|
532
|
+
if (note === null) return
|
|
533
|
+
const out = await deviceKeyMutation('create', { note })
|
|
534
|
+
if (!out) return
|
|
535
|
+
toast(t('deviceKeys.created'), 'ok')
|
|
536
|
+
await loadState()
|
|
537
|
+
if (out.entry) showDeviceKeyQr(out.entry)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async function editDeviceKeyNote(entry) {
|
|
541
|
+
if (!entry) return
|
|
542
|
+
const note = prompt(t('deviceKeys.notePrompt'), entry.note || '')
|
|
543
|
+
if (note === null) return
|
|
544
|
+
const out = await deviceKeyMutation('note', { id: entry.id, note })
|
|
545
|
+
if (!out) return
|
|
546
|
+
toast(t('deviceKeys.saved'), 'ok')
|
|
547
|
+
await loadState()
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function showDeviceKeyQr(entry) {
|
|
551
|
+
if (!entry?.token) return
|
|
552
|
+
qrToken = entry.token
|
|
553
|
+
qrLabel = entry.note || ''
|
|
554
|
+
qrShown = true
|
|
555
|
+
renderQr(lastState || { mode: '', token: '' })
|
|
556
|
+
$('pair-box').scrollIntoView?.({ behavior: 'smooth', block: 'nearest' })
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async function copyDeviceKey(entry) {
|
|
560
|
+
if (!entry?.token) return
|
|
561
|
+
try {
|
|
562
|
+
await navigator.clipboard.writeText(entry.token)
|
|
563
|
+
toast(t('toast.tokenCopied'), 'ok')
|
|
564
|
+
} catch {
|
|
565
|
+
toast(t('toast.copyFailed'), 'err')
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function rotateDeviceKey(entry) {
|
|
570
|
+
if (!entry || !confirm(t('deviceKeys.rotateConfirm'))) return
|
|
571
|
+
const out = await deviceKeyMutation('rotate', { id: entry.id })
|
|
572
|
+
if (!out) return
|
|
573
|
+
qrShown = false
|
|
574
|
+
qrToken = ''
|
|
575
|
+
qrLabel = ''
|
|
576
|
+
toast(t('deviceKeys.rotated'), 'ok')
|
|
577
|
+
await loadState()
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async function revokeDeviceKey(entry) {
|
|
581
|
+
if (!entry || !confirm(t('deviceKeys.revokeConfirm'))) return
|
|
582
|
+
const out = await deviceKeyMutation('revoke', { id: entry.id })
|
|
583
|
+
if (!out) return
|
|
584
|
+
if (qrToken === entry.token) {
|
|
585
|
+
qrShown = false
|
|
586
|
+
qrToken = ''
|
|
587
|
+
qrLabel = ''
|
|
588
|
+
}
|
|
589
|
+
toast(t('deviceKeys.revoked'), 'ok')
|
|
590
|
+
await loadState()
|
|
591
|
+
}
|
|
592
|
+
|
|
370
593
|
function enter() {
|
|
371
594
|
const val = $('token-input').value.trim()
|
|
372
595
|
if (!val) return
|
|
@@ -402,6 +625,8 @@ function logout() {
|
|
|
402
625
|
$('btn-login').addEventListener('click', enter)
|
|
403
626
|
$('token-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') enter() })
|
|
404
627
|
$('btn-logout').addEventListener('click', logout)
|
|
628
|
+
$('device-key-enabled').addEventListener('change', (event) => setDeviceKeyMode(event.target.checked))
|
|
629
|
+
$('btn-device-key-add').addEventListener('click', createDeviceKey)
|
|
405
630
|
// 插件内嵌: 收起面板按钮 → postMessage 给父窗口(同源)关闭右侧抽屉
|
|
406
631
|
$('btn-close-drawer').addEventListener('click', () => {
|
|
407
632
|
window.parent.postMessage({ source: 'dsh-remote-admin', type: 'close' }, location.origin)
|
|
@@ -412,6 +637,38 @@ $('admin-hero-action').addEventListener('click', () => {
|
|
|
412
637
|
if (action === 'copy') return $('btn-copy').click()
|
|
413
638
|
$('device-rows').closest('.table-wrap')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
414
639
|
})
|
|
640
|
+
$('doctor-toggle').addEventListener('click', () => {
|
|
641
|
+
doctorExpanded = !doctorExpanded
|
|
642
|
+
store.set('dshAdminDoctorCollapsed', doctorExpanded ? '0' : '1')
|
|
643
|
+
if (lastState) renderDoctor(lastState)
|
|
644
|
+
})
|
|
645
|
+
$('doctor-refresh').addEventListener('click', () => {
|
|
646
|
+
$('doctor-subtitle').textContent = t('doctor.checking')
|
|
647
|
+
loadState()
|
|
648
|
+
})
|
|
649
|
+
$('doctor-copy-report').addEventListener('click', () => {
|
|
650
|
+
const report = doctorChecks.map(check => `[${check.status.toUpperCase()}] ${check.title}: ${check.detail}`).join('\n')
|
|
651
|
+
doctorCopy(`DSH Remote Doctor\n${report}`, 'doctor.reportCopied')
|
|
652
|
+
})
|
|
653
|
+
$('doctor-steps').addEventListener('click', (event) => {
|
|
654
|
+
const button = event.target.closest('[data-doctor-action]')
|
|
655
|
+
if (!button || !lastState) return
|
|
656
|
+
const action = button.dataset.doctorAction
|
|
657
|
+
if (action === 'start') return $('btn-gateway').click()
|
|
658
|
+
if (action === 'address') return doctorCopy(pairTarget(lastState, shownToken || token).base, 'doctor.addressCopied')
|
|
659
|
+
if (action === 'firewall') return doctorCopy(firewallCommand(lastState), 'doctor.commandCopied')
|
|
660
|
+
if (action === 'qr') {
|
|
661
|
+
const firstKey = lastState.deviceKeys?.enabled && lastState.deviceKeys.entries?.[0]
|
|
662
|
+
if (firstKey) showDeviceKeyQr(firstKey)
|
|
663
|
+
else {
|
|
664
|
+
qrToken = shownToken
|
|
665
|
+
qrLabel = ''
|
|
666
|
+
qrShown = true
|
|
667
|
+
renderQr(lastState)
|
|
668
|
+
$('pair-box').scrollIntoView?.({ behavior: 'smooth', block: 'nearest' })
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
})
|
|
415
672
|
$('btn-copy').addEventListener('click', async () => {
|
|
416
673
|
try {
|
|
417
674
|
await navigator.clipboard.writeText(shownToken || token)
|
|
@@ -422,6 +679,8 @@ $('btn-copy').addEventListener('click', async () => {
|
|
|
422
679
|
})
|
|
423
680
|
|
|
424
681
|
$('btn-qr').addEventListener('click', () => {
|
|
682
|
+
qrToken = shownToken
|
|
683
|
+
qrLabel = ''
|
|
425
684
|
qrShown = !qrShown
|
|
426
685
|
renderQr(lastState || { mode: '', token: shownToken })
|
|
427
686
|
})
|
|
@@ -23,6 +23,33 @@
|
|
|
23
23
|
"minVersion": "",
|
|
24
24
|
"maxVersion": "",
|
|
25
25
|
"publishedAt": "2026-08-23T19:04:26+08:00"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"id": "2026-08-24-update-rhythm-progress",
|
|
29
|
+
"title": "关于后续更新节奏及近期进展",
|
|
30
|
+
"content": "感谢大家一直以来对 dsh-Remote 的关注和反馈。\n\n后续版本将采用更加稳定、清晰的更新节奏:每个正式版本发布前,会先推出 RC 版本进行测试和验证,确认整体运行稳定后再发布正式版本。常规功能会集中到版本迭代中发布;影响正常使用的紧急问题,则会根据实际情况优先修复。\n\n下一个版本目前正在开发和测试中,主要更新内容包括:\n\n- 网关控制台新增可选的独立设备密钥功能,支持按设备管理备注、最近 IP 和访问令牌,并提供二维码配对、令牌轮换、复制及设备退出等操作。\n- 改进手机端的思考内容显示与推理强度选择,并进一步完善工作区、会话和文件管理体验。\n- 优化会话列表,避免子代理产生的内部碎片会话混入普通会话;同时增加 App 与网关版本不一致时的更新提醒。\n- 完善协议能力协商、首次连接引导和运行环境检测,并继续加强 DSH 远程启动、重启及异常恢复过程中的进度与错误提示。\n\n另外,反馈网络已经成功建立并投入使用。大家在使用过程中遇到的问题、改进建议以及功能需求,都可以通过反馈渠道提交,我们会持续整理和跟进。当前直接反馈功能仍在继续测试和完善,如果遇到提交不稳定的情况,也可以优先通过 GitHub Issues 或哔哩哔哩相关渠道联系我们。\n\n与此同时,我们也正在推进 ICP 备案相关工作,为未来建设个人博客网站及后续内容发布提前做好准备。备案完成后,计划逐步完善网站内容,为项目公告、开发记录、使用文档以及其他个人内容提供更加稳定的发布渠道。\n\n感谢大家的支持,也欢迎继续提出意见和建议。dsh-Remote 会继续保持稳定迭代,逐步完善功能与使用体验。",
|
|
31
|
+
"minVersion": "",
|
|
32
|
+
"maxVersion": "",
|
|
33
|
+
"publishedAt": "2026-08-24T13:01:41+08:00"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"id": "2026-08-24-workbench-retention-poll",
|
|
37
|
+
"title": "投票:工作台功能是否需要保留?",
|
|
38
|
+
"content": "“工作台”可以绑定一个项目总目录,自动将其中的子文件夹整理为 DSH 工作区,并按项目集中显示会话、创建新会话和管理文件。\n\n随着工作区筛选、会话分组和文件管理功能逐渐完善,工作台与现有功能出现了一定重叠。我们希望了解大家的实际使用情况,以决定后续继续优化、合并还是移除。\n\n投票结果仅作为版本规划参考,不会立即改变现有功能。",
|
|
39
|
+
"minVersion": "0.6.11",
|
|
40
|
+
"maxVersion": "",
|
|
41
|
+
"publishedAt": "2026-08-24T13:11:45+08:00",
|
|
42
|
+
"poll": {
|
|
43
|
+
"id": "workbench-retention-2026-08",
|
|
44
|
+
"question": "后续应该如何处理工作台功能?",
|
|
45
|
+
"options": [
|
|
46
|
+
{ "id": "keep-improve", "label": "保留并继续优化" },
|
|
47
|
+
{ "id": "keep-current", "label": "保留现有功能即可" },
|
|
48
|
+
{ "id": "merge-workspaces", "label": "合并到工作区功能" },
|
|
49
|
+
{ "id": "remove-gradually", "label": "可以逐步移除" },
|
|
50
|
+
{ "id": "not-used", "label": "尚未使用,暂时无法判断" }
|
|
51
|
+
]
|
|
52
|
+
}
|
|
26
53
|
}
|
|
27
54
|
]
|
|
28
55
|
}
|