@feiyang666/dsh-mobile-remote 1.7.0 → 1.7.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # 更新日志 / Changelog
2
2
 
3
+ ## v1.7.1 (2026-08-29)
4
+
5
+ ### 新增 / New
6
+ - **外网访问面板显示数据通道状态**(`lib/index.js` + `lib/client.js`):`/status` 新增 `external.ws` 字段(`available` = ws 依赖是否可用 / `connected` = WS 长连接是否建立 / `source` = 当前数据来源 `ws` | `http` | `none` / `lastPushAt` = 最后推送时刻),设置页「外网访问」卡片新增「数据通道」行徽标,让用户一眼看清当前是 **WS 实时推送**(绿色)、**HTTP 兜底(退让模式)**(未安装 ws 依赖)、**WS 已连接等待推送**、**WS 已断开 / 重连中**(含最后推送时间)中的哪一种。
7
+ - **运行时长本地每秒刷新**(`lib/client.js`):`runtimeInfo()` 增加采集时刻 `at`,前端新增 `LiveDuration` 组件——基于「采样秒数 + 采集时刻」在浏览器本地每秒累加,让「运行时长 / 开机时长 / 隧道已运行」等秒级数字每秒跳动(秒表效果),不再依赖 5s 的 `/status` 轮询刷新(此前数字每 5s 才跳一次且跳幅不连续)。
8
+ - **远程访问密码界面美化**(`lib/index.js` GATE_CSS / GATE_JS):按 DeepSeek 官网风格重做门禁界面——深蓝黑背景 + 细微光斑纹理、全套圆角元素(卡片 / 胶囊 logo 条 / 输入框 / 按钮 / 服务信息框)、DSH FishLogo + 「deepseek | Harness」文字 logo 组合(复刻官网);`/__dsh_remote/auth-status` 新增 `service` 摘要(服务名 / 本机主机名 / DSH 版本 / 当前访问域名),门禁界面据此展示"访问地址 / 提供服务者 / 版本"信息。
9
+ - **输入密码后的加载动画**(`lib/index.js` GATE_JS):点击「进入」立即显示按钮内旋转 spinner + 文案切换为「正在进入…」,并禁用防重复提交,用户明确知道系统正在处理;验证失败自动恢复。
10
+ - **清除 / 修改密码需验证当前密码**(`lib/index.js` + `lib/client.js`):`/__dsh_remote/set-password` 在已设置密码后,修改或清除必须携带 `currentPassword` 并通过校验,防止门禁被未授权一键关闭;设置页新增「当前密码」输入框引导。
11
+
12
+ ### 修复 / Fixed
13
+ - **WS 推送一直显示"等待推送"**(`lib/ws-client.js`):兼容中转服务器 `send()` 对已序列化 payload 二次 `JSON.stringify` 造成的双重序列化消息(`"{\"type\":...}"`),首层解析得到 JSON 字符串时二次解析,插件端恢复正常接收 `tunnel:status` 推送(中转服务器 `dsh-update-server` 的 `send()` 也已修复不再重复序列化,需部署后生效)。
14
+
15
+ ### 变更 / Changed
16
+ - 无 API 破坏;旧版 host / 新版 client 混用时,缺少 `at` / `ws` / `service` 字段会自动降级为原行为。
17
+
3
18
  ## v1.7.0 (2026-08-28)
4
19
 
5
20
  ### 新增 / New
package/lib/client.js CHANGED
@@ -225,6 +225,23 @@ window.__ModuleLoader__.load({
225
225
  return ss + ' 秒';
226
226
  }
227
227
 
228
+ // 实时时长组件:基于「一次采样的秒数 + 采集时刻」在浏览器本地每秒累加刷新,
229
+ // 让"运行时长 / 开机时长 / 隧道已运行"等秒级数字每秒跳动(秒表效果),
230
+ // 不再依赖 5s 的 /status HTTP 轮询(那会导致数字每 5s 才跳一次,且跳幅不连续)。
231
+ // 只要 host 在采集时带上 at 时间戳,两次 /status 之间也能精确走到下一秒。
232
+ function LiveDuration(props) {
233
+ var _n = useState(Date.now());
234
+ var now = _n[0];
235
+ var setNow = _n[1];
236
+ useEffect(function () {
237
+ var t = setInterval(function () { setNow(Date.now()); }, 1000);
238
+ return function () { clearInterval(t); };
239
+ }, []);
240
+ var at = props.at || now;
241
+ var sec = props.baseSec + Math.max(0, Math.floor((now - at) / 1000));
242
+ return fmtDuration(sec);
243
+ }
244
+
228
245
  function fmtBytes(b) {
229
246
  if (b === null || b === undefined) return '—';
230
247
  var n = Number(b);
@@ -405,6 +422,32 @@ window.__ModuleLoader__.load({
405
422
  // 后端探测到的错误(如鉴权失败 / token 不匹配),优先于请求时的临时错误
406
423
  var extErrText = extErr || (extError && status && status.external && status.external.error ? status.external.error : null);
407
424
 
425
+ // ---- WS 状态推送通道(host 连接中转服务器的数据通道)----
426
+ // 让用户明确看到当前外网状态数据是经 WS 长连接实时推送,还是退让为 HTTP 低频轮询。
427
+ var wsInfo = status && status.external && status.external.ws ? status.external.ws : null;
428
+ var wsBadge = null;
429
+ var wsHint = null;
430
+ if (extStarted && wsInfo) {
431
+ if (wsInfo.connected && wsInfo.source === 'ws') {
432
+ wsBadge = el('span', { style: Object.assign({}, st.badge, st.badgeOn) }, 'WS 实时推送');
433
+ wsHint = '隧道状态经 WebSocket 长连接实时更新(零 HTTP 轮询)';
434
+ } else if (wsInfo.available === false) {
435
+ // 退让版本:ws 依赖缺失(如离线安装),WS 客户端 no-op,走 HTTP 低频兜底
436
+ wsBadge = el('span', { style: Object.assign({}, st.badge, st.badgeBusy) }, 'HTTP 兜底(退让模式)');
437
+ wsHint = '未安装 ws 依赖,已自动退让为 HTTP 低频查询(60s 节流),功能不受影响';
438
+ } else if (wsInfo.connected) {
439
+ wsBadge = el('span', { style: Object.assign({}, st.badge, st.badgeBusy) }, 'WS 已连接,等待推送');
440
+ wsHint = 'WS 长连接已建立,收到首条推送后自动切换为实时模式';
441
+ } else if (wsInfo.source === 'ws') {
442
+ wsBadge = el('span', { style: Object.assign({}, st.badge, st.badgeBusy) }, 'WS 已断开');
443
+ wsHint = 'WS 长连接已断开,正在自动重连;当前显示最近一次推送的缓存数据' +
444
+ (wsInfo.lastPushAt ? '(最后推送 ' + fmtAgo(wsInfo.lastPushAt) + ')' : '');
445
+ } else {
446
+ wsBadge = el('span', { style: Object.assign({}, st.badge, st.badgeBusy) }, 'WS 未连接');
447
+ wsHint = 'WS 长连接尚未建立(连接中 / 自动重连),期间数据走 HTTP 兜底';
448
+ }
449
+ }
450
+
408
451
  function externalStart() {
409
452
  if (extBusy || extConnecting) return;
410
453
  if (!bindCode || !bindCode.trim()) {
@@ -499,6 +542,10 @@ window.__ModuleLoader__.load({
499
542
  var _p2 = useState('');
500
543
  var pwd2 = _p2[0];
501
544
  var setPwd2 = _p2[1];
545
+ // 当前密码:已设置密码后,修改 / 清除都必须先验证当前密码,防止误改或未授权篡改
546
+ var _cp = useState('');
547
+ var currentPwd = _cp[0];
548
+ var setCurrentPwd = _cp[1];
502
549
  var _msgbusy = useState(false);
503
550
  var msgBusy = _msgbusy[0];
504
551
  var setMsgBusy = _msgbusy[1];
@@ -516,12 +563,17 @@ window.__ModuleLoader__.load({
516
563
  setMsg({ type: 'err', text: '密码至少 4 位' });
517
564
  return;
518
565
  }
566
+ // 已设置过密码时,修改必须验证当前密码
567
+ if (authOn && !currentPwd) {
568
+ setMsg({ type: 'err', text: '请输入当前密码以确认修改' });
569
+ return;
570
+ }
519
571
  setMsgBusy(true);
520
572
  setMsg(null);
521
573
  fetch(SET_PASSWORD_URL, {
522
574
  method: 'POST',
523
575
  headers: { 'Content-Type': 'application/json' },
524
- body: JSON.stringify({ password: pwd1 }),
576
+ body: JSON.stringify({ password: pwd1, currentPassword: authOn ? currentPwd : undefined }),
525
577
  })
526
578
  .then(function (r) { return r.json(); })
527
579
  .then(function (data) {
@@ -529,6 +581,7 @@ window.__ModuleLoader__.load({
529
581
  setMsg({ type: 'ok', text: data.enabled ? '已设置远程访问密码。' : '已清除远程访问密码。' });
530
582
  setPwd1('');
531
583
  setPwd2('');
584
+ setCurrentPwd('');
532
585
  setTimeout(refresh, 300);
533
586
  })
534
587
  .catch(function (e) { setMsg({ type: 'err', text: String(e && e.message || e) }); })
@@ -553,8 +606,17 @@ window.__ModuleLoader__.load({
553
606
  : '设置后,通过外网隧道(远程)访问本机 DSH 时,需先输入该密码才能进入页面;本机/内网访问不受影响。'),
554
607
 
555
608
  el('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
609
+ authOn
610
+ ? el('div', { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
611
+ el('label', { style: st.hint }, '当前密码(修改 / 清除需验证)'),
612
+ el('input', {
613
+ type: 'password', value: currentPwd, placeholder: '输入当前密码',
614
+ onChange: function (e) { setCurrentPwd(e.target.value); },
615
+ autoComplete: 'current-password', style: st.input,
616
+ }))
617
+ : null,
556
618
  el('div', { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
557
- el('label', { style: st.hint }, authOn ? '新密码(留空并点击“清除”可关闭门禁)' : '设置远程访问密码'),
619
+ el('label', { style: st.hint }, authOn ? '新密码' : '设置远程访问密码'),
558
620
  el('input', {
559
621
  type: 'password', value: pwd1, placeholder: '至少 4 位',
560
622
  onChange: function (e) { setPwd1(e.target.value); },
@@ -583,19 +645,24 @@ window.__ModuleLoader__.load({
583
645
  ? el('button', {
584
646
  type: 'button',
585
647
  onClick: function () {
648
+ // 清除门禁必须先验证当前密码(host 侧同样校验,前端只是引导)
649
+ if (!currentPwd) {
650
+ setMsg({ type: 'err', text: '请输入当前密码以确认清除' });
651
+ return;
652
+ }
586
653
  if (!window.confirm('确定清除远程访问密码吗?此后远程访问将不再要求输入密码。')) return;
587
654
  setMsgBusy(true);
588
655
  setMsg(null);
589
656
  fetch(SET_PASSWORD_URL, {
590
657
  method: 'POST',
591
658
  headers: { 'Content-Type': 'application/json' },
592
- body: JSON.stringify({ password: '' }),
659
+ body: JSON.stringify({ password: '', currentPassword: currentPwd }),
593
660
  })
594
661
  .then(function (r) { return r.json(); })
595
662
  .then(function (data) {
596
663
  if (!data.ok) throw new Error(data.message || data.error || '清除失败');
597
664
  setMsg({ type: 'ok', text: '已清除远程访问密码。' });
598
- setPwd1(''); setPwd2('');
665
+ setPwd1(''); setPwd2(''); setCurrentPwd('');
599
666
  setTimeout(refresh, 300);
600
667
  })
601
668
  .catch(function (e) { setMsg({ type: 'err', text: String(e && e.message || e) }); })
@@ -730,8 +797,8 @@ window.__ModuleLoader__.load({
730
797
  el('span', { style: Object.assign({}, st.badge, st.badgeOff) },
731
798
  'PID ' + status.runtime.pid)),
732
799
  el('div', { style: { display: 'flex', flexDirection: 'column', gap: 4 } }, kvList([
733
- ['运行时长', fmtDuration(status.runtime.uptime)],
734
- ['开机时长', fmtDuration(status.runtime.sysUptime)],
800
+ ['运行时长', el(LiveDuration, { baseSec: status.runtime.uptime || 0, at: status.runtime.at })],
801
+ ['开机时长', el(LiveDuration, { baseSec: status.runtime.sysUptime || 0, at: status.runtime.at })],
735
802
  ['Node.js', status.runtime.nodeVersion],
736
803
  ['主机名', status.runtime.hostname],
737
804
  ['系统', (status.runtime.platform || '—') + ' ' + (status.runtime.release || '') + ' (' + (status.runtime.arch || '—') + ')'],
@@ -857,8 +924,16 @@ window.__ModuleLoader__.load({
857
924
  el('span', { style: st.meta },
858
925
  'frpc 进程:' + (status.external.pid ? 'PID ' + status.external.pid : '—')),
859
926
  status.external.startedAt
860
- ? el('span', { style: st.meta }, '已运行 ' + fmtDuration(Math.floor((Date.now() - status.external.startedAt) / 1000)))
927
+ ? el('span', { style: st.meta }, '已运行 ',
928
+ el(LiveDuration, { baseSec: 0, at: status.external.startedAt }))
861
929
  : null),
930
+ wsBadge
931
+ ? el('div', { style: st.row },
932
+ el('span', { style: st.meta }, '数据通道'),
933
+ el('div', { style: { display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2 } },
934
+ wsBadge,
935
+ wsHint ? el('span', { style: st.hint }, wsHint) : null))
936
+ : null,
862
937
  el('div', { style: st.row },
863
938
  el('span', { style: st.meta },
864
939
  '绑定码:' + (status.external.bindCodeShort || '—')),
package/lib/index.js CHANGED
@@ -43,6 +43,7 @@ import {
43
43
  hasPassword, verifyPassword, setPassword,
44
44
  isEnabled, login, verifyToken, revokeToken, clearSessions,
45
45
  } from './password.js'
46
+ import { wsAvailable } from './ws-client.js'
46
47
 
47
48
  /** 插件名(loader 诊断用)。 */
48
49
  export const name = 'dsh-mobile-remote'
@@ -214,11 +215,14 @@ function getLanInterfaces() {
214
215
 
215
216
  /** 当前进程运行时信息(诊断用,设置页展示)。 */
216
217
  function runtimeInfo() {
218
+ // 采集时刻:前端可据此在两次 /status 轮询之间本地累加,让"运行时长"每秒跳动。
219
+ const at = Date.now()
217
220
  const mem = process.memoryUsage()
218
221
  const cpu = cpus()
219
222
  const load = loadavg()
220
223
  const total = totalmem()
221
224
  return {
225
+ at,
222
226
  pid: process.pid,
223
227
  hostname: hostname(),
224
228
  platform: platform(),
@@ -445,7 +449,9 @@ function handleEvents() {
445
449
  }
446
450
  }
447
451
 
448
- /** GET /__dsh_remote/auth-status:返回远程访问密码的启用状态与本次请求是否需鉴权。 */
452
+ /** GET /__dsh_remote/auth-status:返回远程访问密码的启用状态与本次请求是否需鉴权。
453
+ * 额外返回 service 摘要(服务名 / 本机主机名 / dsh 版本 / 当前访问域名),
454
+ * 供密码门禁界面展示"当前是谁在提供服务"等更多信息。 */
449
455
  function handleAuthStatus() {
450
456
  return async (req, res) => {
451
457
  json(res, 200, {
@@ -455,6 +461,13 @@ function handleAuthStatus() {
455
461
  required: isRemoteRequest(req) && isEnabled(),
456
462
  authenticated: authorized(req),
457
463
  remote: isRemoteRequest(req),
464
+ // 服务信息:密码界面上展示("服务者"以本机主机名标识)
465
+ service: {
466
+ name: 'DeepSeek Harness',
467
+ hostname: hostname(),
468
+ version: detectDshVersion() || '',
469
+ host: String(req.headers.host || ''),
470
+ },
458
471
  })
459
472
  }
460
473
  }
@@ -493,8 +506,10 @@ function handleLogout() {
493
506
  }
494
507
 
495
508
  /**
496
- * POST /__dsh_remote/set-password { password }(仅本机/内网来源可用):
497
- * 设置或修改远程访问密码。远程来源被拒绝,防止外部任意篡改。空密码=清除。
509
+ * POST /__dsh_remote/set-password { password, currentPassword }(仅本机/内网来源可用):
510
+ * 设置或修改远程访问密码。远程来源被拒绝,防止外部任意篡改。
511
+ * 安全要求:已设置过密码时,修改或清除都必须携带 currentPassword 并验证通过,
512
+ * 防止"改密码/清密码"接口被无鉴权滥用。
498
513
  */
499
514
  function handleSetPassword() {
500
515
  return async (req, res) => {
@@ -504,6 +519,14 @@ function handleSetPassword() {
504
519
  }
505
520
  const body = await readBody(req)
506
521
  const password = typeof body.password === 'string' ? body.password : ''
522
+ const current = typeof body.currentPassword === 'string' ? body.currentPassword : ''
523
+ // 已设置密码时:修改 / 清除都必须先验证当前密码(防未授权篡改门禁)
524
+ if (isEnabled()) {
525
+ if (!verifyPassword(current)) {
526
+ json(res, 401, { ok: false, code: 'BAD_CURRENT_PASSWORD', message: '当前密码不正确,无法修改或清除' })
527
+ return
528
+ }
529
+ }
507
530
  // 密码强度检查
508
531
  if (password && password.length < 4) {
509
532
  json(res, 400, { ok: false, code: 'WEAK_PASSWORD', message: '密码至少 4 位' })
@@ -741,9 +764,18 @@ function handleStatus(ctx, devices, external, tracker, dshCache) {
741
764
  // 外网隧道在线时,优先读 WS 推送缓存(经长连接实时更新,零中转服务器请求);
742
765
  // 仅当从未收到推送(ws 依赖缺失 / WS 未建立)时才回退 HTTP 低频查询(60s 节流)。
743
766
  let server = null
767
+ let serverSource = 'none'
744
768
  if (extRunning && persisted) {
745
- server = external.serverStatus || await queryServerStatus(persisted.serverBase || undefined, persisted.bindCode)
769
+ if (external.serverStatus) {
770
+ server = external.serverStatus
771
+ serverSource = 'ws'
772
+ } else {
773
+ server = await queryServerStatus(persisted.serverBase || undefined, persisted.bindCode)
774
+ serverSource = 'http'
775
+ }
746
776
  }
777
+ // WS 长连接是否已建立(供前端判断当前处于"实时推送"还是"退让/重连")
778
+ const wsConnected = !!(external.wsClient && external.wsClient.isOpen())
747
779
  json(res, 200, {
748
780
  host: ctx.webServer.host,
749
781
  port: ctx.webServer.port,
@@ -785,6 +817,19 @@ function handleStatus(ctx, devices, external, tracker, dshCache) {
785
817
  : null,
786
818
  // 中转服务器返回的成员状态(外网在线时才有)
787
819
  server,
820
+ // WS 状态推送通道信息:让前端明确当前数据是 WS 实时推送还是 HTTP 兜底退让
821
+ ws: {
822
+ // ws 依赖是否可用(离线安装未带依赖时为 false,自动退让 HTTP)
823
+ available: wsAvailable(),
824
+ // WS 长连接是否已建立
825
+ connected: wsConnected,
826
+ // 当前 server 数据来源:'ws'(实时推送缓存)/ 'http'(兜底查询)/ 'none'
827
+ source: serverSource,
828
+ // 最近一次收到 WS 推送的时刻(未收到过为 null)
829
+ lastPushAt: external.serverStatus && external.serverStatus._at
830
+ ? external.serverStatus._at
831
+ : null,
832
+ },
788
833
  },
789
834
  // 远程访问密码门禁状态(供设置面板展示)
790
835
  auth: {
@@ -961,44 +1006,109 @@ function injectIntoIndex(html, remoteEnabled) {
961
1006
  /* ------------------------------------------------------------------ */
962
1007
 
963
1008
  const GATE_CSS = `
1009
+ /* DeepSeek 官网风格:深蓝黑底 + 极简白字 + 细边框 + 下划线输入 + 大留白 */
964
1010
  #dsh-remote-gate {
965
1011
  position: fixed; inset: 0; z-index: 2147483647;
966
1012
  display: flex; align-items: center; justify-content: center;
967
- background: rgba(15, 17, 26, 0.92);
968
- backdrop-filter: blur(6px);
969
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
970
- color: #e8e8ea;
1013
+ padding: 24px; box-sizing: border-box;
1014
+ background: #06101e;
1015
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif;
1016
+ color: #fff;
1017
+ }
1018
+ /* 背景光斑纹理(参考官网深蓝氛围) */
1019
+ #dsh-remote-gate::before {
1020
+ content: '';
1021
+ position: absolute; inset: 0;
1022
+ background:
1023
+ radial-gradient(800px 500px at 18% 8%, rgba(70, 100, 170, 0.12), transparent 60%),
1024
+ radial-gradient(700px 420px at 88% 92%, rgba(50, 70, 140, 0.10), transparent 60%);
1025
+ pointer-events: none;
971
1026
  }
972
1027
  #dsh-remote-gate .gate-card {
973
- width: min(360px, 88vw); box-sizing: border-box;
974
- background: #1d2030; border: 1px solid rgba(255,255,255,.12);
975
- border-radius: 14px; padding: 24px 22px;
976
- box-shadow: 0 18px 50px rgba(0,0,0,.45);
1028
+ position: relative;
1029
+ width: min(360px, 92vw); box-sizing: border-box;
1030
+ background: rgba(10, 18, 32, 0.85);
1031
+ border: 1px solid rgba(255,255,255,.14);
1032
+ border-radius: 14px; padding: 44px 36px 30px;
1033
+ backdrop-filter: blur(10px);
1034
+ }
1035
+ /* Logo 组合:FishLogo + 文字 "deepseek | Harness",胶囊圆角条(复刻官网 logo 与药丸按钮) */
1036
+ #dsh-remote-gate .gate-logo-wrap {
1037
+ display: flex; align-items: center; justify-content: center; gap: 10px;
1038
+ width: max-content; margin: 0 auto 28px;
1039
+ padding: 10px 18px;
1040
+ border: 1px solid rgba(255,255,255,.16);
1041
+ border-radius: 999px;
1042
+ background: rgba(255,255,255,.04);
1043
+ }
1044
+ #dsh-remote-gate .gate-logo {
1045
+ width: 26px; height: 19px; color: #fff; display: block; flex: none;
1046
+ }
1047
+ #dsh-remote-gate .gate-wordmark {
1048
+ font-size: 18px; line-height: 1; letter-spacing: .3px;
1049
+ display: flex; align-items: center; gap: 8px;
977
1050
  }
1051
+ #dsh-remote-gate .gate-wordmark .w-name { color: #fff; font-weight: 500; }
1052
+ #dsh-remote-gate .gate-wordmark .w-sep { color: #555; font-weight: 300; }
1053
+ #dsh-remote-gate .gate-wordmark .w-prod { color: #d0d0d0; font-weight: 400; }
1054
+ /* 标题 + 副标题 */
978
1055
  #dsh-remote-gate .gate-title {
979
- font-size: 17px; font-weight: 700; margin: 0 0 6px;
1056
+ font-size: 16px; font-weight: 600; margin: 0 0 8px; text-align: center; letter-spacing: .2px;
980
1057
  }
981
1058
  #dsh-remote-gate .gate-sub {
982
- font-size: 13px; color: #9aa0b5; margin: 0 0 16px; line-height: 1.5;
1059
+ font-size: 13px; color: #8a8a8a; margin: 0 0 28px; line-height: 1.6; text-align: center;
983
1060
  }
1061
+ /* 服务信息(访问地址 / 提供服务者 / 版本):细边框、黑白灰 */
1062
+ #dsh-remote-gate .gate-service {
1063
+ margin: 0 0 24px; padding: 12px 14px;
1064
+ border: 1px solid rgba(255,255,255,.12);
1065
+ border-radius: 10px;
1066
+ font-size: 12px; color: #8a8a8a; line-height: 1.9;
1067
+ }
1068
+ #dsh-remote-gate .gate-service .sv-row {
1069
+ display: flex; justify-content: space-between; align-items: baseline; gap: 12px; min-width: 0;
1070
+ }
1071
+ #dsh-remote-gate .gate-service .sv-label { flex: none; color: #6a6a6a; }
1072
+ #dsh-remote-gate .gate-service .sv-value {
1073
+ flex: 1; min-width: 0; text-align: right; color: #e8e8e8;
1074
+ word-break: break-all; overflow-wrap: anywhere; font-weight: 500;
1075
+ }
1076
+ /* 圆角输入框:深色底 + 细白边 + 圆角,与整体圆角风格一致 */
984
1077
  #dsh-remote-gate .gate-input {
985
1078
  width: 100%; box-sizing: border-box;
986
- padding: 10px 12px; font-size: 14px; color: #e8e8ea;
987
- background: #14171f; border: 1px solid rgba(255,255,255,.18);
988
- border-radius: 9px; outline: none;
989
- }
990
- #dsh-remote-gate .gate-input:focus { border-color: #4c8dff; }
1079
+ padding: 11px 13px; font-size: 14px; color: #fff;
1080
+ background: rgba(255,255,255,.05);
1081
+ border: 1px solid rgba(255,255,255,.18);
1082
+ border-radius: 10px; outline: none;
1083
+ transition: border-color .15s ease, background .15s ease;
1084
+ }
1085
+ #dsh-remote-gate .gate-input:focus { border-color: #fff; background: rgba(255,255,255,.08); }
1086
+ #dsh-remote-gate .gate-input::placeholder { color: #555; }
1087
+ /* 主按钮:白底黑字(强对比 CTA) */
991
1088
  #dsh-remote-gate .gate-btn {
992
- width: 100%; margin-top: 12px; padding: 10px 12px;
993
- font-size: 14px; font-weight: 600; color: #fff;
994
- background: #2a7de1; border: none; border-radius: 9px; cursor: pointer;
995
- }
996
- #dsh-remote-gate .gate-btn:hover { background: #3a8df0; }
997
- #dsh-remote-gate .gate-btn:disabled { opacity: .6; cursor: default; }
1089
+ width: 100%; margin-top: 24px; padding: 12px;
1090
+ font-size: 14px; font-weight: 600; color: #000;
1091
+ background: #fff; border: none; border-radius: 10px; cursor: pointer;
1092
+ display: flex; align-items: center; justify-content: center; gap: 6px;
1093
+ transition: background .15s ease, opacity .15s ease;
1094
+ }
1095
+ #dsh-remote-gate .gate-btn:hover { background: #e8e8e8; }
1096
+ #dsh-remote-gate .gate-btn:disabled { opacity: .5; cursor: default; }
1097
+ /* 加载动画:提交后按钮内旋转 spinner + 文案切换 */
1098
+ #dsh-remote-gate .gate-spinner {
1099
+ display: none; width: 15px; height: 15px; flex: none;
1100
+ border: 2px solid rgba(0,0,0,.25); border-top-color: #000;
1101
+ border-radius: 50%; box-sizing: border-box;
1102
+ animation: dsh-gate-spin .7s linear infinite;
1103
+ }
1104
+ #dsh-remote-gate .gate-btn.gate-loading .gate-spinner { display: inline-block; }
1105
+ @keyframes dsh-gate-spin { to { transform: rotate(360deg); } }
998
1106
  #dsh-remote-gate .gate-err {
999
- font-size: 12px; color: #ff6b6b; margin-top: 10px; min-height: 16px;
1107
+ font-size: 12px; color: #ff5f56; margin-top: 10px; min-height: 16px; line-height: 1.4;
1108
+ }
1109
+ #dsh-remote-gate .gate-foot {
1110
+ margin-top: 20px; font-size: 11px; color: #555; text-align: center; letter-spacing: .2px;
1000
1111
  }
1001
- #dsh-remote-gate .gate-loading { opacity: .6; }
1002
1112
  `
1003
1113
 
1004
1114
  const GATE_JS = `
@@ -1041,17 +1151,35 @@ const GATE_JS = `
1041
1151
  function canCover() {
1042
1152
  try { return document.getElementById('dsh-remote-gate') === null; } catch (e) { return true; }
1043
1153
  }
1044
- function createGate() {
1154
+ /* DSH FishLogo(来自 @deepseek-ai/dsh-client-ui-primitives FishLogo,SVG path 直出) */
1155
+ var GATE_FISH = '<svg class="gate-logo" viewBox="0 0 23.16 17.04" fill="currentColor" aria-hidden="true"><path d="M22.9168 1.43018C22.6713 1.31018 22.5658 1.53918 22.4223 1.65519C22.3733 1.69269 22.3318 1.74169 22.2903 1.78669C21.9317 2.1697 21.5127 2.42121 20.9657 2.39121C20.1657 2.34621 19.4827 2.59771 18.8787 3.20973C18.7502 2.45521 18.3236 2.0047 17.6746 1.71569C17.3351 1.56568 16.9916 1.41518 16.7536 1.08867C16.5876 0.856163 16.5421 0.597155 16.4591 0.341647C16.4061 0.187643 16.3536 0.0301382 16.1761 0.00363739C15.9836 -0.0263635 15.9081 0.135141 15.8326 0.270145C15.5306 0.822162 15.4136 1.43018 15.4251 2.0462C15.4516 3.43174 16.0366 4.53527 17.1991 5.3203C17.3311 5.4103 17.3651 5.5003 17.3236 5.63181C17.2441 5.90231 17.1501 6.16482 17.0671 6.43533C17.0141 6.60784 16.9351 6.64584 16.7501 6.57033C16.1121 6.30383 15.5611 5.90931 15.074 5.4328C14.2475 4.63328 13.5 3.75075 12.568 3.05973C12.349 2.89822 12.13 2.74822 11.9034 2.60522C10.9524 1.68169 12.028 0.923165 12.277 0.833162C12.5375 0.739159 12.3675 0.41615 11.5259 0.42015C10.6844 0.42365 9.91439 0.705658 8.93286 1.08117C8.78935 1.13767 8.63835 1.17867 8.48384 1.21267C7.59332 1.04367 6.66829 1.00617 5.70226 1.11517C3.88321 1.31768 2.43016 2.1777 1.36213 3.64575C0.0790928 5.4103 -0.222916 7.41536 0.146595 9.50642C0.535106 11.7105 1.66014 13.535 3.38869 14.9616C5.18125 16.4406 7.24581 17.1657 9.60138 17.0266C11.0319 16.9441 12.6245 16.7526 14.421 15.2321C14.874 15.4576 15.3496 15.5476 16.1381 15.6151C16.7456 15.6716 17.3306 15.5851 17.7836 15.4911C18.4931 15.3411 18.4441 14.6841 18.1876 14.5636C16.1081 13.595 16.5646 13.9891 16.1496 13.67C17.2061 12.42 18.8202 10.1979 19.3182 7.17235C19.3672 6.83834 19.4297 6.36783 19.4222 6.09732C19.4182 5.93231 19.4562 5.86831 19.6447 5.84931C20.1657 5.78931 20.6712 5.64681 21.1357 5.3913C22.4833 4.65528 23.0268 3.44624 23.1548 1.9972C23.1738 1.77569 23.1508 1.54668 22.9168 1.43018ZM11.1749 14.4736C9.15936 12.889 8.18184 12.3675 7.77832 12.39C7.40081 12.4125 7.46881 12.8445 7.55182 13.126C7.63882 13.404 7.75182 13.5955 7.91033 13.8396C8.01983 14.0011 8.09533 14.2411 7.80083 14.4216C7.15181 14.8231 6.02327 14.2866 5.97027 14.2601C4.65673 13.4865 3.5587 12.4655 2.78467 11.069C2.03715 9.72493 1.60314 8.28289 1.53164 6.74384C1.51264 6.37233 1.62214 6.24082 1.99215 6.17332C2.47916 6.08332 2.98118 6.06432 3.46769 6.13582C5.52476 6.43633 7.27581 7.35586 8.74385 8.8129C9.58188 9.64243 10.2159 10.634 10.8689 11.6025C11.5634 12.631 12.3105 13.611 13.262 14.4146C13.598 14.6961 13.866 14.9101 14.1225 15.0681C13.349 15.1546 12.058 15.1731 11.1749 14.4746L11.1749 14.4736ZM12.141 8.25988C12.141 8.09488 12.273 7.96338 12.439 7.96338C12.4765 7.96338 12.5105 7.97088 12.541 7.98188C12.5825 7.99688 12.6205 8.01938 12.6505 8.05338C12.7035 8.10588 12.7335 8.18088 12.7335 8.25988C12.7335 8.42489 12.6015 8.55639 12.4355 8.55639C12.2695 8.55639 12.141 8.42489 12.141 8.25988ZM15.1415 9.79893C14.949 9.87793 14.7565 9.94544 14.5715 9.95294C14.2845 9.96794 13.9715 9.85143 13.8015 9.70893C13.5375 9.48742 13.3485 9.36342 13.2695 8.97691C13.2355 8.8119 13.2545 8.55639 13.2845 8.40989C13.3525 8.09438 13.277 7.89187 13.0545 7.70787C12.8735 7.55786 12.643 7.51636 12.39 7.51636C12.2955 7.51636 12.209 7.47486 12.1445 7.44136C12.039 7.38886 11.9519 7.25735 12.035 7.09585C12.0615 7.04335 12.19 6.91584 12.22 6.89334C12.5635 6.69784 12.9595 6.76184 13.326 6.90834C13.6655 7.04735 13.9225 7.30236 14.292 7.66287C14.6695 8.09838 14.7375 8.21838 14.9525 8.54539C15.1225 8.8009 15.277 9.06341 15.3831 9.36392C15.4471 9.55142 15.3641 9.70493 15.1415 9.79893Z"/></svg>';
1156
+ /* HTML 转义,防止服务信息中的特殊字符破坏结构 */
1157
+ function esc(s) {
1158
+ return String(s == null ? '' : s)
1159
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
1160
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
1161
+ }
1162
+ function createGate(service) {
1045
1163
  if (!canCover()) return;
1164
+ var svc = service || {};
1165
+ var rows = '';
1166
+ if (svc.host) rows += '<div class="sv-row"><span class="sv-label">访问地址</span><span class="sv-value">' + esc(svc.host) + '</span></div>';
1167
+ if (svc.hostname) rows += '<div class="sv-row"><span class="sv-label">提供服务</span><span class="sv-value">' + esc(svc.hostname) + '</span></div>';
1168
+ if (svc.version) rows += '<div class="sv-row"><span class="sv-label">版本</span><span class="sv-value">' + esc(svc.version) + '</span></div>';
1046
1169
  var g = document.createElement('div');
1047
1170
  g.id = 'dsh-remote-gate';
1048
1171
  g.innerHTML =
1049
1172
  '<div class="gate-card">' +
1173
+ '<div class="gate-logo-wrap">' + GATE_FISH +
1174
+ '<div class="gate-wordmark"><span class="w-name">deepseek</span><span class="w-sep">|</span><span class="w-prod">Harness</span></div>' +
1175
+ '</div>' +
1050
1176
  '<p class="gate-title">远程访问已锁定</p>' +
1051
1177
  '<p class="gate-sub">请输入管理员设置的远程访问密码以继续使用。</p>' +
1178
+ (rows ? '<div class="gate-service">' + rows + '</div>' : '') +
1052
1179
  '<input class="gate-input" type="password" autocomplete="current-password" placeholder="远程访问密码" />' +
1053
- '<button class="gate-btn" type="button">进入</button>' +
1180
+ '<button class="gate-btn" type="button"><span class="gate-spinner"></span><span class="gate-btn-text">进入</span></button>' +
1054
1181
  '<p class="gate-err"></p>' +
1182
+ '<p class="gate-foot">DeepSeek Harness · 移动端远程控制</p>' +
1055
1183
  '</div>';
1056
1184
  document.documentElement.appendChild(g);
1057
1185
  return g;
@@ -1060,7 +1188,10 @@ const GATE_JS = `
1060
1188
  function submit(g, input, btn) {
1061
1189
  var pwd = input.value;
1062
1190
  if (!pwd) { g.querySelector('.gate-err').textContent = '请输入密码'; return; }
1191
+ var btnText = btn.querySelector('.gate-btn-text');
1192
+ // 立即进入加载态:禁用按钮 + spinner 旋转 + 文案切换,让用户明确知道正在进入
1063
1193
  btn.disabled = true; btn.classList.add('gate-loading');
1194
+ if (btnText) btnText.textContent = '正在进入…';
1064
1195
  fetch('/__dsh_remote/auth', {
1065
1196
  method: 'POST',
1066
1197
  headers: { 'Content-Type': 'application/json' },
@@ -1072,6 +1203,7 @@ const GATE_JS = `
1072
1203
  window.location.reload();
1073
1204
  }).catch(function (e) {
1074
1205
  btn.disabled = false; btn.classList.remove('gate-loading');
1206
+ if (btnText) btnText.textContent = '进入';
1075
1207
  g.querySelector('.gate-err').textContent = String(e && e.message || e);
1076
1208
  input.select();
1077
1209
  });
@@ -1082,7 +1214,7 @@ const GATE_JS = `
1082
1214
  .then(function (r) { return r.json(); })
1083
1215
  .then(function (st) {
1084
1216
  if (!st || !st.required || st.authenticated) return;
1085
- var g = createGate();
1217
+ var g = createGate(st.service);
1086
1218
  if (!g) return;
1087
1219
  var input = g.querySelector('.gate-input');
1088
1220
  var btn = g.querySelector('.gate-btn');
package/lib/ws-client.js CHANGED
@@ -85,7 +85,10 @@ export function createWsStatusClient({ serverBase, bindCode, onStatus, logger })
85
85
 
86
86
  client.on('message', (buf) => {
87
87
  try {
88
- const msg = JSON.parse(buf.toString())
88
+ let msg = JSON.parse(buf.toString())
89
+ // 兼容旧服务器对 payload 二次 JSON.stringify 的双重序列化消息:
90
+ // 首层解析得到的是 JSON 字符串(如 "{\"type\":...}"),再解析一次拿到真实对象。
91
+ if (typeof msg === 'string') msg = JSON.parse(msg)
89
92
  if (msg && msg.type === 'tunnel:status' && msg.data) {
90
93
  msg.data._at = Date.now() // 缓存新鲜度标记
91
94
  lastStatus = msg.data
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feiyang666/dsh-mobile-remote",
3
- "version": "1.7.0",
3
+ "version": "1.7.1",
4
4
  "description": "DeepSeek Harness mobile remote-control plugin: LAN/external remote control (QR / on-off toggle / remote-access password gate), rich telemetry (expandable device details, system CPU/memory/load, DSH app state: version/sessions/workspaces/plugins/models), real external-tunnel status detection (frpc token / reconnect diagnostics), mobile-first UI polish (composer image upload entry via system picker, settings single-column layout) and faster remote loading.",
5
5
  "keywords": [
6
6
  "deepseek",