dsh-remote-plugin 0.4.9 → 0.5.2

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/README.md CHANGED
@@ -9,7 +9,7 @@ DSH Remote 的 DSH bundle 插件:在 DSH 左侧原生边栏注册入口,点
9
9
  ```sh
10
10
  dsh plugin --profile web add dsh-remote-plugin
11
11
  # 或 pin 版本
12
- dsh plugin --profile web add dsh-remote-plugin@0.4.8
12
+ dsh plugin --profile web add dsh-remote-plugin@0.5.0
13
13
  ```
14
14
 
15
15
  重启 DSH Web 后 Ctrl+F5,左侧边栏底部出现 App 图标入口。
@@ -24,13 +24,13 @@ dsh plugin --profile web add "github:Blank-not-black/dsh-Remote#main&path:/packa
24
24
 
25
25
  - 默认**自动启动**:DSH 启动或抽屉刷新时,插件会拉起内置 `gateway.cjs`(`0.0.0.0:8787`),独立于 DSH 进程。
26
26
  - 开关持久化在 `~/.dsh-remote/gateway.enabled`;抽屉内可停止/启动。
27
- - 令牌在 `~/.dsh-remote/token`(首次自动生成,重复使用不覆盖),抽屉里显示并可复制。
27
+ - 令牌在 `~/.dsh-remote/token`(首次自动生成,重复使用不覆盖),抽屉里显示并可复制;支持**二维码扫码配对**与**一键轮换**。
28
28
  - 环境变量 `DSH_REMOTE_AUTOSTART=0` 可关闭自动管理。
29
- - 文件端点:`/fs/list`(列目录)、`/fs/file`(下载,支持 Range)、`/fs/upload`(上传,默认上限 2GB);默认根目录 `~`,`DSH_REMOTE_FS_ROOT` 可开多根(`:` 分隔)。
29
+ - 文件端点:`/fs/list`(列目录)、`/fs/file`(下载,支持 Range)、`/fs/upload`(分块续传,支持暂停/取消,落盘前 SHA-256 校验);默认根目录 `~`,`DSH_REMOTE_FS_ROOT` 可开多根(`:` 分隔)。
30
30
 
31
31
  ## 手机 App
32
32
 
33
- 从 [Releases](https://github.com/Blank-not-black/dsh-Remote/releases/latest) 下载 `dsh-remote.apk`,App「设置」里可添加多个服务器地址(局域网 + Tailscale),点「测速」自动选最快;填抽屉里的令牌即可。聊天记录会本地缓存,断网也能看历史;下载的文件统一放系统「下载/dsh-remote」。
33
+ 从 [Releases](https://github.com/Blank-not-black/dsh-Remote/releases/latest) 下载 `dsh-remote.apk`。推荐在抽屉点「二维码」,App「设置 → 扫码连接」直接配对;也可手动添加多个服务器地址(局域网 + Tailscale),点「测速」自动选最快。聊天记录会本地缓存,断网也能看历史;下载的文件统一放系统「下载/dsh-remote」。
34
34
 
35
35
  ## License
36
36
 
Binary file
package/client.js CHANGED
@@ -19,11 +19,6 @@ window.__ModuleLoader__.load({
19
19
  transform: 'translateX(102%)', transition: 'transform .22s ease',
20
20
  display: 'flex', flexDirection: 'column',
21
21
  }
22
- var HEAD_STYLE = {
23
- display: 'flex', alignItems: 'center', justifyContent: 'space-between',
24
- gap: 8, padding: '9px 12px', background: '#0f766e', color: '#fff',
25
- font: '600 13px/1 system-ui, sans-serif', flexShrink: 0,
26
- }
27
22
  // DSH 原生 footerActions 容器默认 flex-row(官方类名已编译定型);
28
23
  // 动态注入列布局, 让各 footer action 上下堆叠。
29
24
  var FOOTER_FIX_ID = 'dsh-remote-footer-stack'
@@ -87,13 +82,6 @@ window.__ModuleLoader__.load({
87
82
  'aria-hidden': !open,
88
83
  style: Object.assign({}, DRAWER_STYLE, { transform: open ? 'translateX(0)' : 'translateX(102%)' }),
89
84
  },
90
- React.createElement('div', { style: HEAD_STYLE },
91
- React.createElement('span', null, '🖥 DSH Remote'),
92
- React.createElement('button', {
93
- type: 'button', 'aria-label': '关闭',
94
- onClick: function () { props.actions.close() },
95
- style: { border: 'none', background: 'transparent', color: '#fff', font: '600 16px/1 system-ui, sans-serif', cursor: 'pointer', padding: '2px 6px' },
96
- }, '✕')),
97
85
  loaded.current ? React.createElement('iframe', {
98
86
  src: '/remote/admin/?embedded=1',
99
87
  title: 'DSH Remote',
package/gateway.cjs CHANGED
@@ -143,7 +143,22 @@ function loadToken() {
143
143
  return token
144
144
  }
145
145
 
146
- const TOKEN = loadToken()
146
+ const TOKEN_FROM_ENV = !!process.env.TOKEN
147
+ let TOKEN = loadToken()
148
+
149
+ /** 一键轮换令牌: 写回 TOKEN_FILE 并立即生效(旧令牌/旧连接全部失效)。 */
150
+ function rotateToken() {
151
+ if (TOKEN_FROM_ENV) return { error: 'token-from-env', detail: '令牌来自 TOKEN 环境变量, 请修改环境变量后重启' }
152
+ const next = crypto.randomBytes(24).toString('base64url')
153
+ try {
154
+ fs.mkdirSync(path.dirname(TOKEN_FILE), { recursive: true })
155
+ fs.writeFileSync(TOKEN_FILE, next + '\n', { mode: 0o600 })
156
+ } catch (err) {
157
+ return { error: 'write-failed', detail: err.message }
158
+ }
159
+ TOKEN = next
160
+ return { ok: true, token: next }
161
+ }
147
162
 
148
163
  function tokenOf(req, url) {
149
164
  const auth = req.headers.authorization || ''
@@ -240,13 +255,29 @@ function kickDevice(ip) {
240
255
  }
241
256
 
242
257
  // ---------- GitHub/镜像 更新检查 ----------
258
+ function parseVersion(v) {
259
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(v || '').trim())
260
+ if (!m) return { core: [0, 0, 0], pre: null }
261
+ return { core: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] || null }
262
+ }
243
263
  function cmpVersion(a, b) {
244
- const pa = String(a || '').split('.').map(Number)
245
- const pb = String(b || '').split('.').map(Number)
246
- for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
247
- const d = (pa[i] || 0) - (pb[i] || 0)
264
+ const pa = parseVersion(a), pb = parseVersion(b)
265
+ for (let i = 0; i < 3; i++) {
266
+ const d = pa.core[i] - pb.core[i]
248
267
  if (d) return d
249
268
  }
269
+ if (!pa.pre && !pb.pre) return 0
270
+ if (!pa.pre) return 1
271
+ if (!pb.pre) return -1
272
+ const sa = String(pa.pre).split('.'), sb = String(pb.pre).split('.')
273
+ for (let i = 0; i < Math.max(sa.length, sb.length); i++) {
274
+ const x = sa[i] ?? '', y = sb[i] ?? ''
275
+ if (x === y) continue
276
+ const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y)
277
+ if (nx && ny) { const d = Number(x) - Number(y); if (d) return d }
278
+ else if (nx !== ny) return nx ? -1 : 1
279
+ else { const d = x.localeCompare(y); if (d) return d }
280
+ }
250
281
  return 0
251
282
  }
252
283
 
@@ -379,6 +410,26 @@ function serveStatic(req, res, url) {
379
410
  }
380
411
  if (pathname === '/') pathname = '/index.html'
381
412
  if (pathname === '/admin') pathname = '/admin.html'
413
+ // 兼容旧版 App(版本比较不认 -rc): 无 local 参数的请求把 0.5.2-rc.1 显示为 0.5.2,
414
+ // 引导升级到新 APK; 新 App 带 ?local= 拿到真实 rc 版本, 不会循环提示。
415
+ if (pathname === '/update.json') {
416
+ fs.readFile(path.join(PUBLIC_DIR, 'update.json'), 'utf8', (err, raw) => {
417
+ if (err) {
418
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
419
+ res.end('404 Not Found')
420
+ return
421
+ }
422
+ let json = {}
423
+ try { json = JSON.parse(raw) } catch {}
424
+ if (!url.searchParams.has('local')) {
425
+ json = { ...json, version: String(json.version || '').replace(/-.*$/, '') }
426
+ }
427
+ cors(res)
428
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
429
+ res.end(JSON.stringify(json))
430
+ })
431
+ return
432
+ }
382
433
  const apkOverride = pathname === '/dsh-remote.apk'
383
434
  const baseDir = apkOverride ? path.join(ROOT, 'apk') : PUBLIC_DIR
384
435
  const filePath = path.normalize(path.join(baseDir, pathname))
@@ -436,6 +487,7 @@ function serveAdminApi(req, res, url) {
436
487
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
437
488
  res.end(JSON.stringify({
438
489
  ok: true,
490
+ mode: 'gateway',
439
491
  version: VERSION,
440
492
  pid: process.pid,
441
493
  hostname: os.hostname(),
@@ -453,6 +505,8 @@ function serveAdminApi(req, res, url) {
453
505
  error: latestState.error,
454
506
  newer: !!(latestState.version && cmpVersion(latestState.version, VERSION) > 0)
455
507
  },
508
+ token: TOKEN,
509
+ tokenFromEnv: TOKEN_FROM_ENV,
456
510
  tokenMasked: TOKEN.slice(0, 4) + '…' + TOKEN.slice(-4),
457
511
  tokenLength: TOKEN.length,
458
512
  totalRequests,
@@ -464,6 +518,29 @@ function serveAdminApi(req, res, url) {
464
518
  })
465
519
  return
466
520
  }
521
+ if (sub === '/token/rotate' && req.method === 'POST') {
522
+ if (!authorized(req, url)) {
523
+ authFailures++
524
+ touchDevice(req, { failedAuth: true })
525
+ res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
526
+ res.end(JSON.stringify({ error: 'unauthorized' }))
527
+ return
528
+ }
529
+ const r = rotateToken()
530
+ if (!r.ok) {
531
+ res.writeHead(409, { 'content-type': 'application/json; charset=utf-8' })
532
+ res.end(JSON.stringify({ error: r.error, detail: r.detail }))
533
+ return
534
+ }
535
+ // 旧令牌立即失效: 断开已连接的 App/浏览器, 让它们重新扫码/输入
536
+ for (const d of devices.values()) {
537
+ if (d.kind !== 'admin') kickDevice(d.ip)
538
+ }
539
+ touchDevice(req)
540
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
541
+ res.end(JSON.stringify({ ok: true, token: r.token, tokenMasked: r.token.slice(0, 4) + '…' + r.token.slice(-4) }))
542
+ return
543
+ }
467
544
  if (sub === '/shutdown' && req.method === 'POST') {
468
545
  if (!authorized(req, url)) {
469
546
  authFailures++
@@ -708,6 +785,27 @@ function fsValidName(name) {
708
785
  return true
709
786
  }
710
787
 
788
+ /** 流式计算文件 SHA-256(十六进制)。2GB 也就一次顺序读, 落盘前校验足够快。 */
789
+ function sha256FileHex(file, cb) {
790
+ const hash = crypto.createHash('sha256')
791
+ let stream
792
+ try {
793
+ stream = fs.createReadStream(file)
794
+ } catch (err) {
795
+ cb(err)
796
+ return
797
+ }
798
+ stream.on('error', (err) => cb(err))
799
+ stream.on('data', (chunk) => hash.update(chunk))
800
+ stream.on('end', () => cb(null, hash.digest('hex')))
801
+ }
802
+
803
+ /* 进行中的续传写流: 取消时先 destroy 再删分片, 避免“先删后写”竞态 */
804
+ const activeUploads = new Map()
805
+ function fsActiveKey(dirReal, name, session) {
806
+ return dirReal + '\n' + name + '\n' + (session || '')
807
+ }
808
+
711
809
  /** 打开上传目标: 同名冲突/符号链接/临时文件都在这层判定。 */
712
810
  function fsOpenUploadTarget(res, url, dirLex, dirReal, name) {
713
811
  if (!fsValidName(name)) {
@@ -943,6 +1041,10 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
943
1041
  }
944
1042
  const finish = url.searchParams.get('finish') === '1' || url.searchParams.get('complete') === '1'
945
1043
  const overwrite = url.searchParams.get('overwrite') === '1' || url.searchParams.get('overwrite') === 'true'
1044
+ const sha256Expected = (url.searchParams.get('sha256') || '').trim().toLowerCase()
1045
+ if (sha256Expected && !/^[0-9a-f]{64}$/.test(sha256Expected)) {
1046
+ return fsJson(res, 400, { error: 'bad-sha256', detail: 'sha256 必须是 64 位十六进制' })
1047
+ }
946
1048
  const part = fsPartPath(dirReal, name, session)
947
1049
  const target = path.join(dirReal, name)
948
1050
 
@@ -982,12 +1084,15 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
982
1084
  } catch (err) {
983
1085
  return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
984
1086
  }
1087
+ const activeKey = fsActiveKey(dirReal, name, session)
1088
+ activeUploads.set(activeKey, stream)
985
1089
 
986
1090
  let bytes = 0
987
1091
  let finished = false
988
1092
  const abort = (status, msg, extra = {}) => {
989
1093
  if (finished) return
990
1094
  finished = true
1095
+ activeUploads.delete(activeKey)
991
1096
  try { stream.destroy() } catch {}
992
1097
  // 网络中断时保留分片, 客户端 probe 后续传; 只有超限/写失败才删
993
1098
  if (status === 413 || status === 500) { try { fs.unlinkSync(part) } catch {} }
@@ -1013,6 +1118,7 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
1013
1118
  if (finished) return
1014
1119
  finished = true
1015
1120
  stream.end(() => {
1121
+ activeUploads.delete(activeKey)
1016
1122
  const total = offset + bytes
1017
1123
  try {
1018
1124
  const st = fs.statSync(part)
@@ -1021,12 +1127,31 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
1021
1127
  fsJson(res, 200, { ok: true, partial: true, name, size: total, offset: total, session })
1022
1128
  return
1023
1129
  }
1024
- const ts = fsTargetState(target)
1025
- if (ts.status) return fsJson(res, ts.status, { error: ts.error, detail: ts.detail })
1026
- if (ts.exists && !overwrite) return fsJson(res, 409, { error: 'conflict', detail: '文件已存在, overwrite=1 可覆盖' })
1027
- if (ts.exists) fs.rmSync(target, { force: true })
1028
- fs.renameSync(part, target)
1029
- fsJson(res, 201, { ok: true, name, path: path.join(dirLex, name), size: total, resumed: offset > 0, session })
1130
+ const commit = (actualSha256) => {
1131
+ try {
1132
+ const ts = fsTargetState(target)
1133
+ if (ts.status) return fsJson(res, ts.status, { error: ts.error, detail: ts.detail })
1134
+ if (ts.exists && !overwrite) return fsJson(res, 409, { error: 'conflict', detail: '文件已存在, overwrite=1 可覆盖' })
1135
+ if (ts.exists) fs.rmSync(target, { force: true })
1136
+ fs.renameSync(part, target)
1137
+ fsJson(res, 201, { ok: true, name, path: path.join(dirLex, name), size: total, resumed: offset > 0, session, ...(actualSha256 ? { sha256: actualSha256 } : {}) })
1138
+ } catch (err) {
1139
+ if (!res.headersSent) fsJson(res, 403, { error: 'write-failed', detail: err.message })
1140
+ else try { res.destroy() } catch {}
1141
+ }
1142
+ }
1143
+ if (sha256Expected) {
1144
+ // 落盘前校验: 不匹配保留分片并返回 422, 客户端可重传或取消
1145
+ sha256FileHex(part, (err, actual) => {
1146
+ if (err) return fsJson(res, 403, { error: 'checksum-failed', detail: err.message })
1147
+ if (actual !== sha256Expected) {
1148
+ return fsJson(res, 422, { error: 'checksum-mismatch', expected: sha256Expected, actual, partialSize: total, session })
1149
+ }
1150
+ commit(actual)
1151
+ })
1152
+ } else {
1153
+ commit(null)
1154
+ }
1030
1155
  } catch (err) {
1031
1156
  if (!res.headersSent) fsJson(res, 403, { error: 'write-failed', detail: err.message })
1032
1157
  else try { res.destroy() } catch {}
@@ -1035,6 +1160,42 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
1035
1160
  })
1036
1161
  }
1037
1162
 
1163
+ /* POST /fs/upload-control?path&name&session&action=cancel
1164
+ * 取消续传: 停止在途写流并删除分片(暂停由客户端 abort 完成, 分片保留)。 */
1165
+ function fsUploadControl(req, res, url) {
1166
+ if (req.method !== 'POST') {
1167
+ res.writeHead(405, { allow: 'POST' })
1168
+ res.end()
1169
+ return
1170
+ }
1171
+ if (!fsAuthorized(req, url, res)) return
1172
+ touchDevice(req)
1173
+ const resolved = fsResolve(url.searchParams.get('path') ?? '')
1174
+ if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
1175
+ const checked = fsRealChecked(resolved.abs)
1176
+ if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
1177
+ const name = url.searchParams.get('name') || ''
1178
+ if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name' })
1179
+ const session = url.searchParams.get('session') || 'default'
1180
+ const action = url.searchParams.get('action') || ''
1181
+ if (action !== 'cancel' && action !== 'abort') return fsJson(res, 400, { error: 'bad-action', detail: 'action 只支持 cancel' })
1182
+
1183
+ const part = fsPartPath(checked.abs, name, session)
1184
+ const active = activeUploads.get(fsActiveKey(checked.abs, name, session))
1185
+ if (active) {
1186
+ try { active.destroy() } catch {}
1187
+ activeUploads.delete(fsActiveKey(checked.abs, name, session))
1188
+ }
1189
+ // 等写流关闭后再删, 防止 write 把分片重新创建出来
1190
+ setTimeout(() => {
1191
+ let removed = false
1192
+ try { fs.unlinkSync(part); removed = true } catch (err) {
1193
+ if (err.code !== 'ENOENT') return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
1194
+ }
1195
+ fsJson(res, 200, { ok: true, cancelled: true, removed, session })
1196
+ }, 80)
1197
+ }
1198
+
1038
1199
  function serveFs(req, res, url) {
1039
1200
  const sub = url.pathname.slice('/fs'.length)
1040
1201
 
@@ -1049,6 +1210,7 @@ function serveFs(req, res, url) {
1049
1210
  if (sub === '/list') return fsList(req, res, url)
1050
1211
  if (sub === '/file') return fsFile(req, res, url)
1051
1212
  if (sub === '/upload-probe') return fsUploadProbe(req, res, url)
1213
+ if (sub === '/upload-control') return fsUploadControl(req, res, url)
1052
1214
 
1053
1215
  if (sub === '/upload') {
1054
1216
  if (req.method !== 'POST') {
package/index.mjs CHANGED
@@ -317,14 +317,16 @@ async function serveStatic(req, res) {
317
317
  })
318
318
  return
319
319
  }
320
- if (pathname === `${MOUNT}/admin/api/note` || pathname === `${MOUNT}/admin/api/kick`) {
320
+ if (pathname === `${MOUNT}/admin/api/note` || pathname === `${MOUNT}/admin/api/kick` || pathname === `${MOUNT}/admin/api/token/rotate`) {
321
321
  if (req.method !== 'POST') {
322
322
  res.writeHead(405, { allow: 'POST' })
323
323
  res.end()
324
324
  return
325
325
  }
326
326
  const body = await readBody(req, 4096)
327
- const sub = pathname.endsWith('/note') ? '/note' : '/kick'
327
+ const sub = pathname.endsWith('/note') ? '/note'
328
+ : pathname.endsWith('/kick') ? '/kick'
329
+ : '/token/rotate'
328
330
  const proxied = await proxyGateway(`/admin/api${sub}`, 'POST', body)
329
331
  if (proxied !== null) {
330
332
  sendJson(res, proxied.status, proxied.json)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.4.9",
3
+ "version": "0.5.2",
4
4
  "description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
package/public/admin.html CHANGED
@@ -32,6 +32,11 @@
32
32
  .ua { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
33
33
  .token-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 10px 0 14px; }
34
34
  .token-row code { flex: 1; background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; font-size: 12px; word-break: break-all; }
35
+ .pair-box { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px; margin-bottom: 14px; text-align: center; }
36
+ .pair-box .pair-title { font-weight: 600; margin-bottom: 8px; }
37
+ .pair-qr { display: inline-block; background: #fff; border-radius: 8px; padding: 6px; line-height: 0; }
38
+ .pair-qr svg { display: block; width: 180px; height: 180px; }
39
+ .pair-box .muted { margin-top: 8px; font-size: 12px; }
35
40
  .empty { text-align: center; color: var(--muted); padding: 30px 0; }
36
41
  .empty-actions { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; margin-top: 12px; }
37
42
  a.mini-btn { text-decoration: none; display: inline-flex; align-items: center; }
@@ -53,7 +58,7 @@
53
58
  <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8a8.01 8.01 0 0 0 5.47 7.59c.4.08.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg>
54
59
  仓库
55
60
  </a>
56
- <span id="conn-badge" class="conn-badge off">未认证</span>
61
+ <button id="conn-badge" class="conn-badge off" title="网关面板">未认证</button>
57
62
  </div>
58
63
  </div>
59
64
 
@@ -66,10 +71,18 @@
66
71
  <div class="token-row">
67
72
  <code id="token-full">—</code>
68
73
  <button id="btn-gateway" class="mini-btn hidden">启动网关</button>
74
+ <button id="btn-qr" class="mini-btn hidden">二维码</button>
75
+ <button id="btn-rotate" class="mini-btn hidden">轮换令牌</button>
69
76
  <button id="btn-copy" class="mini-btn">复制令牌</button>
70
77
  <button id="btn-logout" class="mini-btn">退出</button>
71
78
  </div>
72
79
 
80
+ <div id="pair-box" class="pair-box hidden">
81
+ <div class="pair-title">手机 App 扫码配对</div>
82
+ <div class="pair-qr" id="pair-qr"></div>
83
+ <div class="muted" id="pair-hint">用 App 内「设置 → 扫码连接」,或系统相机扫一扫自动打开 App</div>
84
+ </div>
85
+
73
86
  <div class="stat-grid" id="stats"></div>
74
87
 
75
88
  <div class="section-head"><span>已连接设备</span><span id="device-summary" class="muted"></span></div>
@@ -84,6 +97,7 @@
84
97
  </div>
85
98
 
86
99
  <div id="toast" class="toast hidden"></div>
100
+ <script src="../qrcode.min.js"></script>
87
101
  <script src="../admin.js"></script>
88
102
  </body>
89
103
  </html>
package/public/admin.js CHANGED
@@ -19,6 +19,8 @@ let timer = null
19
19
  let gatewayRunning = false
20
20
  let gatewayBusy = false
21
21
  let shownToken = token
22
+ let lastState = null
23
+ let qrShown = false
22
24
 
23
25
  function toast(text, kind = '') {
24
26
  const el = $('toast')
@@ -62,15 +64,21 @@ async function loadState() {
62
64
  }
63
65
 
64
66
  function render(st) {
67
+ lastState = st
65
68
  const isPlugin = st.mode === 'plugin'
66
69
  const isGateway = st.mode === 'gateway'
67
- shownToken = st.token || (isGateway ? '' : token)
70
+ shownToken = st.token || token
68
71
  $('conn-badge').textContent = isPlugin ? '内嵌' : isGateway ? '网关' : '已连接'
69
- $('conn-badge').className = 'conn-badge on'
72
+ $('conn-badge').className = 'conn-badge ' + (isPlugin || isGateway ? 'on' : 'off')
73
+ $('conn-badge').title = isGateway ? '新标签页打开网关管理面板' : '网关未运行'
70
74
  $('token-full').textContent = shownToken || (isPlugin ? '插件模式 · 未接网关, 无需令牌' : '未获取到令牌')
71
75
  // 主机端插件模式: 显示真实令牌(复制可用), 只隐藏退出按钮; 令牌门禁本身不存在
72
76
  $('btn-copy').classList.toggle('hidden', !shownToken)
73
77
  $('btn-logout').classList.toggle('hidden', pluginMode)
78
+ // 二维码与轮换只在网关模式下可用(二维码里有完整令牌, 不能在没有网关时生成)
79
+ $('btn-qr').classList.toggle('hidden', isGateway !== true || !shownToken)
80
+ $('btn-rotate').classList.toggle('hidden', isGateway !== true || !shownToken || !!st.tokenFromEnv)
81
+ renderQr(st)
74
82
  // 网关开关: 仅插件内嵌页提供, 网关运行/停止两种状态
75
83
  gatewayRunning = isGateway
76
84
  $('btn-gateway').classList.toggle('hidden', !pluginMode)
@@ -141,6 +149,37 @@ function render(st) {
141
149
  btn.addEventListener('click', () => setNote(btn.dataset.noteIp, btn.dataset.note)))
142
150
  }
143
151
 
152
+ function pairTarget(st) {
153
+ const ip = (st.lanIPs || []).find(x => x && x !== '127.0.0.1' && x !== '0.0.0.0') || (st.lanIPs || [])[0]
154
+ const host = ip || (st.host && st.host !== '0.0.0.0' ? st.host : location.hostname)
155
+ const port = st.port || 8787
156
+ const base = `http://${host}:${port}`
157
+ return {
158
+ url: `dshremote://pair?token=${encodeURIComponent(shownToken)}&server=${encodeURIComponent(base)}`,
159
+ base
160
+ }
161
+ }
162
+
163
+ function renderQr(st) {
164
+ const box = $('pair-box')
165
+ if (!qrShown || !shownToken || st.mode !== 'gateway') {
166
+ box.classList.add('hidden')
167
+ return
168
+ }
169
+ try {
170
+ const t = pairTarget(st)
171
+ const qr = window.qrcode(0, 'M')
172
+ qr.addData(t.url)
173
+ qr.make()
174
+ $('pair-qr').innerHTML = qr.createSvgTag({ cellSize: 4, margin: 2, scalable: true })
175
+ $('pair-hint').textContent = `${t.base} · App「设置 → 扫码连接」,或系统相机扫一扫自动打开 App`
176
+ box.classList.remove('hidden')
177
+ } catch (e) {
178
+ $('pair-qr').textContent = '二维码生成失败'
179
+ box.classList.remove('hidden')
180
+ }
181
+ }
182
+
144
183
  async function setNote(ip, current) {
145
184
  const name = prompt('给 ' + ip + ' 设置备注(留空清除):', current || '')
146
185
  if (name === null) return
@@ -215,6 +254,46 @@ $('btn-copy').addEventListener('click', async () => {
215
254
  }
216
255
  })
217
256
 
257
+ $('btn-qr').addEventListener('click', () => {
258
+ qrShown = !qrShown
259
+ renderQr(lastState || { mode: '', token: shownToken })
260
+ })
261
+
262
+ /* 右上角「网关」徽章: 新标签页打开独立网关管理面板(带 token 免登录) */
263
+ $('conn-badge').addEventListener('click', () => {
264
+ const st = lastState
265
+ if (!st || st.mode !== 'gateway') { toast('网关未运行', 'err'); return }
266
+ const host = location.hostname || '127.0.0.1'
267
+ const port = st.port || 8787
268
+ const url = `http://${host}:${port}/admin?token=${encodeURIComponent(shownToken || token)}`
269
+ try {
270
+ window.open(url, '_blank', 'noopener')
271
+ } catch {
272
+ toast('浏览器阻止了新窗口,请允许弹窗', 'err')
273
+ }
274
+ })
275
+
276
+ $('btn-rotate').addEventListener('click', async () => {
277
+ if (!confirm('轮换后旧令牌立即失效,手机与浏览器都需要重新扫码/输入。继续?')) return
278
+ try {
279
+ const res = await fetch(`${API}/token/rotate`, {
280
+ method: 'POST',
281
+ headers: { authorization: 'Bearer ' + (token || shownToken), 'x-dsh-remote-client': 'admin' }
282
+ })
283
+ const out = await res.json().catch(() => ({}))
284
+ if (out.ok && out.token) {
285
+ token = out.token
286
+ store.set('dshAdminToken', out.token)
287
+ toast('令牌已轮换,请重新给设备配对', 'ok')
288
+ setTimeout(loadState, 300)
289
+ } else {
290
+ toast(out.detail || out.error || '轮换失败', 'err')
291
+ }
292
+ } catch (e) {
293
+ toast('轮换失败:' + (e.message || e), 'err')
294
+ }
295
+ })
296
+
218
297
  $('btn-gateway').addEventListener('click', async () => {
219
298
  if (gatewayBusy) return
220
299
  gatewayBusy = true