@feiyang666/dsh-mobile-remote 1.1.0 → 1.4.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/lib/client.js CHANGED
@@ -1,9 +1,10 @@
1
1
  // dsh-mobile-remote — DSH 移动端远程控制插件(client 侧)。
2
2
  //
3
3
  // 在 Web UI 的「设置」中注册一个「远程控制」section,提供:
4
- // 1. 开启 / 关闭开关(写入 / 移除 profile patch,经 HMR 热重载生效);
5
- // 2. 连接二维码(通过 host 的 /__dsh_remote/qr 接口返回 SVG);
6
- // 3. 当前连接的设备数量(host 侧心跳统计)。
4
+ // 1. 局域网开关(写入 / 移除 profile patch,经 HMR 热重载生效)+ 二维码 + 设备数;
5
+ // 2. 「外网访问」:通过中转服务器(dsh-update-server)启动 frpc 隧道,让外网设备
6
+ // 访问本机 DSH。含绑定码 / 中转服务地址输入、开启/关闭开关、外网链接 + 二维码 +
7
+ // 在线状态反馈(online / connecting / stopped / error)。
7
8
  //
8
9
  // 打包格式:浏览器 module loader(window.__ModuleLoader__.load)标准 bundle,
9
10
  // 与官方 client 插件一致。纯 React.createElement,无 JSX 构建依赖。
@@ -20,6 +21,7 @@ window.__ModuleLoader__.load({
20
21
  var useState = React.useState;
21
22
  var useEffect = React.useEffect;
22
23
  var useCallback = React.useCallback;
24
+ var useRef = React.useRef;
23
25
 
24
26
  /* ------------------------------------------------------------------ */
25
27
  /* 内联样式(跟随宿主主题 token,深色/浅色自动适配) */
@@ -38,7 +40,8 @@ window.__ModuleLoader__.load({
38
40
  border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.18))',
39
41
  boxSizing: 'border-box',
40
42
  },
41
- row: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 },
43
+ // flexWrap: 窄屏下内容放不下时允许换行(如外网隧道详情行的长文本),桌面端无感知
44
+ row: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' },
42
45
  title: { fontSize: 15, fontWeight: 600, margin: 0, lineHeight: 1.4 },
43
46
  sub: { fontSize: 13, color: 'var(--dsw-alias-label-tertiary, #767676)', lineHeight: 1.6, margin: 0 },
44
47
  hint: { fontSize: 13, color: 'var(--dsw-alias-label-tertiary, #767676)', lineHeight: 1.6, margin: 0 },
@@ -49,6 +52,8 @@ window.__ModuleLoader__.load({
49
52
  },
50
53
  badgeOn: { color: '#0d7a3f', background: 'rgba(18,150,78,.14)' },
51
54
  badgeOff: { color: '#8a8a8a', background: 'rgba(127,127,127,.12)' },
55
+ badgeBusy: { color: '#9a6a00', background: 'rgba(200,140,0,.14)' },
56
+ badgeErr: { color: '#c0272b', background: 'rgba(200,40,40,.13)' },
52
57
  switch: {
53
58
  position: 'relative', width: 46, height: 26, flex: 'none',
54
59
  borderRadius: 999, border: 'none', cursor: 'pointer',
@@ -95,14 +100,63 @@ window.__ModuleLoader__.load({
95
100
  overflowX: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-all', lineHeight: 1.5,
96
101
  color: 'var(--dsw-alias-label-primary, #1f1f1f)',
97
102
  },
98
- button: {
99
- alignSelf: 'flex-start', padding: '8px 16px', borderRadius: 8,
103
+ input: {
104
+ padding: '8px 10px', borderRadius: 8, fontSize: 13,
105
+ background: 'var(--dsw-alias-bg-base, #f0f0f1)',
100
106
  border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.18))',
101
- background: 'transparent', color: 'var(--dsw-alias-label-primary, #1f1f1f)',
102
- fontSize: 13, fontWeight: 500, cursor: 'pointer',
107
+ color: 'var(--dsw-alias-label-primary, #1f1f1f)',
108
+ outline: 'none',
103
109
  },
104
110
  divider: { height: 1, background: 'var(--dsw-alias-border-l2, rgba(127,127,127,.18))', border: 'none' },
105
111
  disabled: { opacity: 0.55 },
112
+ deviceList: { display: 'flex', flexDirection: 'column', gap: 8 },
113
+ deviceRow: {
114
+ display: 'flex', alignItems: 'center', gap: 10,
115
+ padding: '8px 10px', borderRadius: 8,
116
+ background: 'var(--dsw-alias-bg-base, #f0f0f1)',
117
+ border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.18))',
118
+ boxSizing: 'border-box', minWidth: 0,
119
+ },
120
+ dotOn: { width: 8, height: 8, borderRadius: 999, flex: 'none', background: '#18a058' },
121
+ deviceName: { fontSize: 13, fontWeight: 600, lineHeight: 1.4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
122
+ // minWidth + overflowWrap:窄屏下长文本(设备详情、外网状态行)不溢出卡片
123
+ meta: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary, #767676)', lineHeight: 1.5, minWidth: 0, overflowWrap: 'break-word' },
124
+ dotOff: { width: 8, height: 8, borderRadius: 999, flex: 'none', background: 'rgba(127,127,127,.38)' },
125
+ chevron: {
126
+ fontSize: 10, color: 'var(--dsw-alias-label-tertiary, #767676)', flex: 'none',
127
+ transition: 'transform .15s ease', lineHeight: 1,
128
+ },
129
+ chevronOpen: {
130
+ fontSize: 10, color: 'var(--dsw-alias-label-tertiary, #767676)', flex: 'none',
131
+ transform: 'rotate(180deg)', transition: 'transform .15s ease', lineHeight: 1,
132
+ },
133
+ deviceCard: {
134
+ display: 'flex', flexDirection: 'column', minWidth: 0,
135
+ borderRadius: 8, overflow: 'hidden',
136
+ background: 'var(--dsw-alias-bg-base, #f0f0f1)',
137
+ border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.18))',
138
+ boxSizing: 'border-box',
139
+ },
140
+ deviceHead: {
141
+ display: 'flex', alignItems: 'center', gap: 10,
142
+ padding: '8px 10px', cursor: 'pointer', width: '100%',
143
+ boxSizing: 'border-box', textAlign: 'left',
144
+ background: 'transparent', border: 'none', fontFamily: 'inherit', color: 'inherit',
145
+ },
146
+ deviceDetail: {
147
+ display: 'flex', flexDirection: 'column', gap: 4,
148
+ padding: '8px 12px 10px',
149
+ borderTop: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.18))',
150
+ },
151
+ detailRow: { display: 'flex', alignItems: 'baseline', gap: 8, fontSize: 12, lineHeight: 1.6 },
152
+ detailLabel: { flex: 'none', width: 92, color: 'var(--dsw-alias-label-tertiary, #767676)' },
153
+ detailValue: { flex: 1, minWidth: 0, wordBreak: 'break-all', overflowWrap: 'break-word' },
154
+ btn: {
155
+ padding: '6px 12px', borderRadius: 8, fontSize: 12, fontWeight: 600,
156
+ color: 'var(--dsw-alias-state-business-primary, #2a7de1)',
157
+ background: 'transparent', border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.25))',
158
+ cursor: 'pointer', whiteSpace: 'nowrap',
159
+ },
106
160
  };
107
161
 
108
162
  /* ------------------------------------------------------------------ */
@@ -111,11 +165,91 @@ window.__ModuleLoader__.load({
111
165
 
112
166
  var STATUS_URL = '/__dsh_remote/status';
113
167
  var TOGGLE_URL = '/__dsh_remote/toggle';
168
+ var EXT_START_URL = '/__dsh_remote/external/start';
169
+ var EXT_STOP_URL = '/__dsh_remote/external/stop';
170
+ var EXT_LOG_URL = '/__dsh_remote/external/log';
171
+ var SET_PASSWORD_URL = '/__dsh_remote/set-password';
172
+ var AUTH_STATUS_URL = '/__dsh_remote/auth-status';
173
+
174
+ // fetch 带超时:避免后端长时间无响应(如中转服务重启/网络慢)时,
175
+ // 开关按钮一直卡在"连接中"转圈。超时后走 catch 显示友好提示。
176
+ function fetchWithTimeout(url, opts, ms) {
177
+ var ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
178
+ var t = null;
179
+ if (ctrl) {
180
+ t = setTimeout(function () { ctrl.abort(); }, ms || 25000);
181
+ opts = opts || {};
182
+ opts.signal = ctrl.signal;
183
+ }
184
+ var p = fetch(url, opts);
185
+ if (t) p = p.then(function (r) { clearTimeout(t); return r; }, function (e) { clearTimeout(t); throw e; });
186
+ return p;
187
+ }
188
+
189
+ // 把 abort 超时错误转成用户可读文案
190
+ function fmtFetchError(e, fallback) {
191
+ var err = e && e.name === 'AbortError' ? null : (e && e.message) || e;
192
+ return err ? String(err) : fallback || '请求超时,请稍后重试';
193
+ }
114
194
 
115
195
  function fmtUrl(url) {
116
196
  return url || '未检测到局域网地址';
117
197
  }
118
198
 
199
+ function fmtAgo(ts) {
200
+ if (!ts) return '—';
201
+ var s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
202
+ if (s < 10) return '刚刚';
203
+ if (s < 60) return s + ' 秒前';
204
+ var m = Math.floor(s / 60);
205
+ if (m < 60) return m + ' 分钟前';
206
+ var h = Math.floor(m / 60);
207
+ if (h < 24) return h + ' 小时前';
208
+ return Math.floor(h / 24) + ' 天前';
209
+ }
210
+
211
+ function fmtDuration(s) {
212
+ if (s === null || s === undefined) return '—';
213
+ var sec = Math.max(0, Math.floor(s));
214
+ var d = Math.floor(sec / 86400);
215
+ var h = Math.floor((sec % 86400) / 3600);
216
+ var m = Math.floor((sec % 3600) / 60);
217
+ var ss = sec % 60;
218
+ if (d > 0) return d + ' 天 ' + h + ' 小时';
219
+ if (h > 0) return h + ' 小时 ' + m + ' 分';
220
+ if (m > 0) return m + ' 分 ' + ss + ' 秒';
221
+ return ss + ' 秒';
222
+ }
223
+
224
+ function fmtBytes(b) {
225
+ if (b === null || b === undefined) return '—';
226
+ var n = Number(b);
227
+ if (!isFinite(n)) return '—';
228
+ if (n < 1024) return n + ' B';
229
+ var units = ['KB', 'MB', 'GB', 'TB'];
230
+ var i = -1;
231
+ do { n /= 1024; i += 1; } while (n >= 1024 && i < units.length - 1);
232
+ return n.toFixed(n >= 100 ? 0 : 1) + ' ' + units[i];
233
+ }
234
+
235
+ function fmtDateTime(ts) {
236
+ if (!ts) return '—';
237
+ try {
238
+ var d = new Date(ts);
239
+ if (isNaN(d.getTime())) return String(ts);
240
+ return d.toLocaleString('zh-CN', { hour12: false });
241
+ } catch (e) { return String(ts); }
242
+ }
243
+
244
+ // 生成一组「标签: 值」行(key-value 行列表),供详情区渲染
245
+ function kvList(items) {
246
+ return items.map(function (it, idx) {
247
+ return el('div', { style: st.detailRow, key: idx },
248
+ el('span', { style: st.detailLabel }, it[0]),
249
+ el('span', { style: st.detailValue }, it[1] || '—'));
250
+ });
251
+ }
252
+
119
253
  function RemoteControlPanel() {
120
254
  var _s = useState(null);
121
255
  var status = _s[0];
@@ -126,6 +260,19 @@ window.__ModuleLoader__.load({
126
260
  var _e = useState(null);
127
261
  var error = _e[0];
128
262
  var setError = _e[1];
263
+ var _exp = useState({});
264
+ var expandedMap = _exp[0];
265
+ var setExpanded = _exp[1];
266
+ function toggleExpanded(id) {
267
+ setExpanded(function (m) {
268
+ var n = Object.assign({}, m);
269
+ n[id] = !n[id];
270
+ return n;
271
+ });
272
+ }
273
+ var _dshOpen = useState(false);
274
+ var dshOpen = _dshOpen[0];
275
+ var setDshOpen = _dshOpen[1];
129
276
 
130
277
  var refresh = useCallback(function () {
131
278
  fetch(STATUS_URL)
@@ -161,12 +308,12 @@ window.__ModuleLoader__.load({
161
308
  });
162
309
  }, [busy, status, refresh]);
163
310
 
164
- // 状态徽标
311
+ // 局域网状态徽标
165
312
  var on = !!(status && status.remoteEnabled);
166
313
  var badge = el('span', { style: Object.assign({}, st.badge, on ? st.badgeOn : st.badgeOff) },
167
314
  on ? '已开启' : '未开启');
168
315
 
169
- // 开关
316
+ // 局域网开关
170
317
  var sw = el('button', {
171
318
  type: 'button',
172
319
  role: 'switch',
@@ -177,7 +324,7 @@ window.__ModuleLoader__.load({
177
324
  style: Object.assign({}, st.switch, on ? st.switchOn : null),
178
325
  }, el('span', { style: Object.assign({}, st.knob, on ? st.knobOn : null) }));
179
326
 
180
- // 二维码
327
+ // 局域网二维码
181
328
  var qrEl;
182
329
  if (on && status && status.url) {
183
330
  var qrSrc = '/__dsh_remote/qr?url=' + encodeURIComponent(status.url);
@@ -195,7 +342,133 @@ window.__ModuleLoader__.load({
195
342
  // 设备数
196
343
  var count = status ? status.deviceCount : 0;
197
344
 
198
- // 命令提示(未开启时展示 CLI 启动方式)
345
+ // ---- 外网访问状态 + 操作 ----
346
+ var _bc = useState('');
347
+ var bindCode = _bc[0];
348
+ var setBindCode = _bc[1];
349
+ var _sb = useState('');
350
+ var serverBase = _sb[0];
351
+ var setServerBase = _sb[1];
352
+ var _xbusy = useState(false);
353
+ var extBusy = _xbusy[0];
354
+ var setExtBusy = _xbusy[1];
355
+ var _xerr = useState(null);
356
+ var extErr = _xerr[0];
357
+ var setExtErr = _xerr[1];
358
+ var _xlog = useState(null);
359
+ var extLog = _xlog[0];
360
+ var setExtLog = _xlog[1];
361
+ var _frpclog = useState(null);
362
+ var frpcLog = _frpclog[0];
363
+ var setFrpcLog = _frpclog[1];
364
+ function viewFrpcLog() {
365
+ if (frpcLog) { setFrpcLog(null); return; }
366
+ fetchWithTimeout(EXT_LOG_URL, null, 10000)
367
+ .then(function (r) {
368
+ var ct = String(r.headers.get('content-type') || '').toLowerCase();
369
+ return r.text().then(function (text) {
370
+ if (ct.indexOf('application/json') >= 0) {
371
+ try { return JSON.parse(text); } catch (e) { throw new Error('接口返回异常'); }
372
+ }
373
+ // 服务端返回 HTML(例如旧版 host 没有该路由,落到 SPA 回退页)
374
+ throw new Error('服务端未提供该接口(可能是 dsh 服务未重启)。请重启 dsh 服务后再试。');
375
+ });
376
+ })
377
+ .then(function (d) { setFrpcLog(d && d.log ? d.log : '(日志为空)'); })
378
+ .catch(function (err) { setFrpcLog('读取失败:' + String(err && err.message || err)); });
379
+ }
380
+
381
+ // 用户是否已手动编辑过绑定码 / 中转地址。一旦编辑过就停止自动回填,
382
+ // 否则 status 每 5 秒轮询刷新时会用持久化旧值覆盖用户刚清空的输入框。
383
+ var editedRef = useRef(false);
384
+ function markEdited() { editedRef.current = true; }
385
+
386
+ // 回填已持久化的绑定信息(dsh 重启后免重新输入验证码 / 中转地址)
387
+ useEffect(function () {
388
+ if (!status || !status.external || !status.external.persisted) return;
389
+ if (editedRef.current) return;
390
+ var p = status.external.persisted;
391
+ if (p.bindCode && bindCode === '') setBindCode(p.bindCode);
392
+ if (p.serverBase && serverBase === '') setServerBase(p.serverBase);
393
+ }, [status, bindCode, serverBase]);
394
+
395
+ // 外网状态:online / connecting / stopped / idle / error
396
+ var extSt = status && status.external ? status.external.status : 'idle';
397
+ var extUrl = status && status.external ? status.external.url : null;
398
+ var extStarted = extSt === 'online';
399
+ var extConnecting = extSt === 'connecting';
400
+ var extError = extSt === 'error';
401
+ // 后端探测到的错误(如鉴权失败 / token 不匹配),优先于请求时的临时错误
402
+ var extErrText = extErr || (extError && status && status.external && status.external.error ? status.external.error : null);
403
+
404
+ function externalStart() {
405
+ if (extBusy || extConnecting) return;
406
+ if (!bindCode || !bindCode.trim()) {
407
+ setExtErr('请先填写绑定码(留空无法开启外网访问)');
408
+ return;
409
+ }
410
+ setExtBusy(true);
411
+ setExtErr(null);
412
+ setExtLog(null);
413
+ fetchWithTimeout(EXT_START_URL, {
414
+ method: 'POST',
415
+ headers: { 'Content-Type': 'application/json' },
416
+ body: JSON.stringify({ bindCode: bindCode, serverBase: serverBase || undefined }),
417
+ }, 30000)
418
+ .then(function (r) { return r.json(); })
419
+ .then(function (data) {
420
+ if (!data.ok) {
421
+ setExtLog(data.log || null); // 展示 frpc 真实日志,便于定位根因
422
+ throw new Error(data.message || data.error || 'start failed');
423
+ }
424
+ setExtLog(null);
425
+ setTimeout(refresh, 1400);
426
+ })
427
+ .catch(function (e) {
428
+ setExtErr(fmtFetchError(e, '开启外网访问超时(后端响应过慢),请稍后重试或查看下方 frpc 日志'));
429
+ setTimeout(refresh, 600); // 立即刷新,让开关从"连接中"恢复可操作
430
+ })
431
+ .finally(function () { setExtBusy(false); });
432
+ }
433
+
434
+ function externalStop() {
435
+ if (extBusy) return;
436
+ setExtBusy(true);
437
+ setExtErr(null);
438
+ setExtLog(null);
439
+ fetchWithTimeout(EXT_STOP_URL, { method: 'POST' }, 10000)
440
+ .then(function () { setTimeout(refresh, 800); })
441
+ .catch(function (e) { setExtErr(fmtFetchError(e, '关闭超时,请稍后重试')); })
442
+ .finally(function () { setExtBusy(false); });
443
+ }
444
+
445
+ // 外网开关:offline / connecting / online / error 均可操作,连接中点击即取消
446
+ var extSwitch = el('button', {
447
+ type: 'button', role: 'switch',
448
+ 'aria-checked': String(extStarted),
449
+ 'aria-label': extStarted ? '关闭外网访问' : (extConnecting ? '取消连接' : '开启外网访问'),
450
+ onClick: (extStarted || extConnecting) ? externalStop : externalStart,
451
+ disabled: extBusy,
452
+ style: Object.assign({}, st.switch, (extStarted || extConnecting) ? st.switchOn : null),
453
+ }, el('span', { style: Object.assign({}, st.knob, (extStarted || extConnecting) ? st.knobOn : null) }));
454
+
455
+ var extBadge = el('span', {
456
+ style: Object.assign({}, st.badge,
457
+ extStarted ? st.badgeOn : (extConnecting ? st.badgeBusy : (extError ? st.badgeErr : st.badgeOff))),
458
+ }, extStarted ? '在线' : (extConnecting ? '连接中' : (extError ? '出错' : '未开启')));
459
+
460
+ // 外网二维码
461
+ var extQr;
462
+ if (extStarted && extUrl) {
463
+ extQr = el('div', { style: st.qrBox },
464
+ el('img', { src: '/__dsh_remote/qr?url=' + encodeURIComponent(extUrl), alt: '外网二维码', style: st.qrImg }));
465
+ } else {
466
+ extQr = el('div', { style: st.qrFallback },
467
+ extConnecting ? '正在连接中转服务器…'
468
+ : (extError ? '外网隧道异常,请查看上方错误信息' : '开启外网访问后,这里显示外网二维码'));
469
+ }
470
+
471
+ // 命令行提示(未开启时展示 CLI 启动方式)
199
472
  var cliHint = null;
200
473
  if (status && !status.remoteEnabled) {
201
474
  cliHint = el('div', { style: st.card },
@@ -213,7 +486,133 @@ window.__ModuleLoader__.load({
213
486
  );
214
487
  }
215
488
 
489
+ // ---- 远程访问密码管理 ----
490
+ var authOn = !!(status && status.auth && status.auth.enabled);
491
+ var authRequiredHere = !!(status && status.auth && status.auth.requiredHere);
492
+ var _p1 = useState('');
493
+ var pwd1 = _p1[0];
494
+ var setPwd1 = _p1[1];
495
+ var _p2 = useState('');
496
+ var pwd2 = _p2[0];
497
+ var setPwd2 = _p2[1];
498
+ var _msgbusy = useState(false);
499
+ var msgBusy = _msgbusy[0];
500
+ var setMsgBusy = _msgbusy[1];
501
+ var _msg = useState(null);
502
+ var msg = _msg[0];
503
+ var setMsg = _msg[1];
504
+
505
+ function savePassword() {
506
+ if (msgBusy) return;
507
+ if (pwd1 !== pwd2) {
508
+ setMsg({ type: 'err', text: '两次输入的密码不一致' });
509
+ return;
510
+ }
511
+ if (pwd1 && pwd1.length < 4) {
512
+ setMsg({ type: 'err', text: '密码至少 4 位' });
513
+ return;
514
+ }
515
+ setMsgBusy(true);
516
+ setMsg(null);
517
+ fetch(SET_PASSWORD_URL, {
518
+ method: 'POST',
519
+ headers: { 'Content-Type': 'application/json' },
520
+ body: JSON.stringify({ password: pwd1 }),
521
+ })
522
+ .then(function (r) { return r.json(); })
523
+ .then(function (data) {
524
+ if (!data.ok) throw new Error(data.message || data.error || '保存失败');
525
+ setMsg({ type: 'ok', text: data.enabled ? '已设置远程访问密码。' : '已清除远程访问密码。' });
526
+ setPwd1('');
527
+ setPwd2('');
528
+ setTimeout(refresh, 300);
529
+ })
530
+ .catch(function (e) { setMsg({ type: 'err', text: String(e && e.message || e) }); })
531
+ .finally(function () { setMsgBusy(false); });
532
+ }
533
+
534
+ var passwordCard = el('div', { style: st.card },
535
+ el('div', { style: st.row },
536
+ el('div', { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
537
+ el('p', { style: st.title }, '远程访问密码'),
538
+ el('p', { style: st.sub },
539
+ authOn
540
+ ? '已启用:远程访问需要输入密码'
541
+ : '未设置:远程访问无需密码')),
542
+ el('span', {
543
+ style: Object.assign({}, st.badge, authOn ? st.badgeOn : st.badgeOff),
544
+ }, authOn ? '已设置' : '未设置')),
545
+
546
+ el('p', { style: st.hint },
547
+ authRequiredHere
548
+ ? '当前设备通过远程访问进入,需输入密码。你在本机设置/修改即可。'
549
+ : '设置后,通过外网隧道(远程)访问本机 DSH 时,需先输入该密码才能进入页面;本机/内网访问不受影响。'),
550
+
551
+ el('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
552
+ el('div', { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
553
+ el('label', { style: st.hint }, authOn ? '新密码(留空并点击“清除”可关闭门禁)' : '设置远程访问密码'),
554
+ el('input', {
555
+ type: 'password', value: pwd1, placeholder: '至少 4 位',
556
+ onChange: function (e) { setPwd1(e.target.value); },
557
+ autoComplete: 'new-password', style: st.input,
558
+ })),
559
+ el('div', { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
560
+ el('label', { style: st.hint }, '确认密码'),
561
+ el('input', {
562
+ type: 'password', value: pwd2,
563
+ onChange: function (e) { setPwd2(e.target.value); },
564
+ autoComplete: 'new-password', style: st.input,
565
+ }))),
566
+
567
+ el('div', { style: { display: 'flex', alignItems: 'center', gap: 10 } },
568
+ el('button', {
569
+ type: 'button',
570
+ onClick: savePassword,
571
+ disabled: msgBusy,
572
+ style: {
573
+ padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 600,
574
+ color: '#fff', background: 'var(--dsw-alias-state-business-primary, #2a7de1)',
575
+ border: 'none', cursor: 'pointer',
576
+ },
577
+ }, authOn ? '修改密码' : '设置密码'),
578
+ authOn
579
+ ? el('button', {
580
+ type: 'button',
581
+ onClick: function () {
582
+ if (!window.confirm('确定清除远程访问密码吗?此后远程访问将不再要求输入密码。')) return;
583
+ setMsgBusy(true);
584
+ setMsg(null);
585
+ fetch(SET_PASSWORD_URL, {
586
+ method: 'POST',
587
+ headers: { 'Content-Type': 'application/json' },
588
+ body: JSON.stringify({ password: '' }),
589
+ })
590
+ .then(function (r) { return r.json(); })
591
+ .then(function (data) {
592
+ if (!data.ok) throw new Error(data.message || data.error || '清除失败');
593
+ setMsg({ type: 'ok', text: '已清除远程访问密码。' });
594
+ setPwd1(''); setPwd2('');
595
+ setTimeout(refresh, 300);
596
+ })
597
+ .catch(function (e) { setMsg({ type: 'err', text: String(e && e.message || e) }); })
598
+ .finally(function () { setMsgBusy(false); });
599
+ },
600
+ disabled: msgBusy,
601
+ style: {
602
+ padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 600,
603
+ color: '#c0272b', background: 'transparent',
604
+ border: '1px solid rgba(192,39,43,.4)', cursor: 'pointer',
605
+ },
606
+ }, '清除')
607
+ : null),
608
+
609
+ msg
610
+ ? el('p', { style: Object.assign({}, st.hint, { color: msg.type === 'ok' ? '#0d7a3f' : '#c0272b' }) }, msg.text)
611
+ : null);
612
+
216
613
  return el('div', { style: st.root },
614
+ // 远程访问密码门禁(第一个卡片,最醒目)
615
+ passwordCard,
217
616
  // 状态行
218
617
  el('div', { style: st.row },
219
618
  el('div', { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
@@ -222,7 +621,7 @@ window.__ModuleLoader__.load({
222
621
  el('div', { style: { display: 'flex', alignItems: 'center', gap: 10 } },
223
622
  badge, sw)),
224
623
 
225
- // 二维码卡片
624
+ // 局域网二维码卡片
226
625
  el('div', { style: st.card },
227
626
  el('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
228
627
  el('p', { style: st.title }, '手机连接'),
@@ -237,10 +636,75 @@ window.__ModuleLoader__.load({
237
636
  el('div', { style: st.stat },
238
637
  el('span', { style: st.statValue }, String(count)),
239
638
  el('span', { style: st.statLabel }, '当前连接设备')),
639
+ el('div', { style: st.stat },
640
+ el('span', { style: st.statValue }, String(status ? (status.totalDevicesEver || 0) : 0)),
641
+ el('span', { style: st.statLabel }, '累计设备')),
642
+ el('div', { style: st.stat },
643
+ el('span', { style: st.statValue }, String(status ? (status.totalHeartbeats || 0) : 0)),
644
+ el('span', { style: st.statLabel }, '累计心跳')),
240
645
  el('div', { style: st.stat },
241
646
  el('span', { style: st.statValue }, on ? String(status ? status.lanAddresses.length : 0) : '—'),
242
647
  el('span', { style: st.statLabel }, '局域网地址'))),
243
648
 
649
+ // 已连接设备列表(host 从心跳请求采集 IP / UA / 屏幕 / 网络等详情,可点击展开)
650
+ status && status.devices && status.devices.length
651
+ ? el('div', { style: st.card },
652
+ el('div', { style: st.row },
653
+ el('p', { style: st.title }, '已连接设备'),
654
+ el('span', { style: Object.assign({}, st.badge, st.badgeOn) },
655
+ String(status.devices.length) + ' 台在线')),
656
+ el('p', { style: st.hint }, '点击设备行可查看详细上报信息(屏幕 / 网络 / 页面等)'),
657
+ el('div', { style: st.deviceList },
658
+ status.devices.map(function (d) {
659
+ var open = !!expandedMap[d.id];
660
+ var connTxt = d.connection
661
+ ? ((d.connection.effectiveType || '') +
662
+ (d.connection.downlink ? ' · ' + d.connection.downlink + ' Mbps' : '') +
663
+ (d.connection.rtt !== null && d.connection.rtt !== undefined ? ' · ' + d.connection.rtt + 'ms' : ''))
664
+ : '';
665
+ return el('div', { style: st.deviceCard, key: d.id },
666
+ el('button', {
667
+ type: 'button',
668
+ onClick: function () { toggleExpanded(d.id); },
669
+ style: st.deviceHead,
670
+ },
671
+ el('span', { style: d.online ? st.dotOn : st.dotOff }),
672
+ el('div', { style: { display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0, flex: 1 } },
673
+ el('span', { style: st.deviceName }, d.name || '未知设备'),
674
+ el('span', { style: st.meta },
675
+ (d.ip || '未知 IP') +
676
+ (d.os && d.os !== '未知系统' ? ' · ' + d.os : '') +
677
+ (d.browser ? ' · ' + d.browser : '') +
678
+ (d.mobile ? ' · 移动端' : '') +
679
+ (connTxt ? ' · ' + connTxt : '') +
680
+ ' · 活跃于 ' + fmtAgo(d.lastSeen))),
681
+ el('span', { style: open ? st.chevronOpen : st.chevron }, '▼')),
682
+ open
683
+ ? el('div', { style: st.deviceDetail }, kvList([
684
+ ['IP 地址', d.ip || '未知'],
685
+ ['系统 / 浏览器', (d.os || '—') + ' / ' + (d.browser || '—')],
686
+ ['移动端标记', d.mobile ? '是' : '否'],
687
+ ['屏幕', d.screen || '—'],
688
+ ['视口', d.viewport || '—'],
689
+ ['像素密度', d.dpr ? d.dpr + 'x' : '—'],
690
+ ['触屏设备', d.touch ? '是' : '否'],
691
+ ['网络连接', connTxt || '—'],
692
+ ['设备内存', d.deviceMemory ? String(d.deviceMemory) + ' GB' : '—'],
693
+ ['CPU 核数', d.hardwareConcurrency ? String(d.hardwareConcurrency) : '—'],
694
+ ['电池', d.battery
695
+ ? (String(d.battery.level || 0) + '%' + (d.battery.charging ? '(充电中)' : ''))
696
+ : '—'],
697
+ ['在线状态', d.online ? '在线' : '离线'],
698
+ ['语言 / 平台', [d.lang, d.platform].filter(Boolean).join(' / ') || '—'],
699
+ ['当前页面', d.title ? (d.title + (d.path ? ' ' + d.path : '')) : (d.path || '—')],
700
+ ['首次连接', fmtDateTime(d.firstSeen)],
701
+ ['最后活跃', fmtDateTime(d.lastSeen)],
702
+ ['心跳次数', String(d.beatCount || 0)],
703
+ ]))
704
+ : null);
705
+ })))
706
+ : null,
707
+
244
708
  status && status.lanAddresses.length
245
709
  ? el('div', { style: st.card },
246
710
  el('p', { style: st.title }, '手机访问地址'),
@@ -254,6 +718,228 @@ window.__ModuleLoader__.load({
254
718
  el('pre', { style: st.code }, status.patchFile))
255
719
  : null,
256
720
 
721
+ // 运行时信息(进程 / 系统 / CPU / 内存,诊断用)
722
+ status && status.runtime
723
+ ? el('div', { style: st.card },
724
+ el('div', { style: st.row },
725
+ el('p', { style: st.title }, '运行时信息'),
726
+ el('span', { style: Object.assign({}, st.badge, st.badgeOff) },
727
+ 'PID ' + status.runtime.pid)),
728
+ el('div', { style: { display: 'flex', flexDirection: 'column', gap: 4 } }, kvList([
729
+ ['运行时长', fmtDuration(status.runtime.uptime)],
730
+ ['开机时长', fmtDuration(status.runtime.sysUptime)],
731
+ ['Node.js', status.runtime.nodeVersion],
732
+ ['主机名', status.runtime.hostname],
733
+ ['系统', (status.runtime.platform || '—') + ' ' + (status.runtime.release || '') + ' (' + (status.runtime.arch || '—') + ')'],
734
+ ['CPU 型号', status.runtime.cpuModel || '—'],
735
+ ['CPU', String(status.runtime.cpus) + ' 核' + (status.runtime.cpuSpeed ? ' @ ' + status.runtime.cpuSpeed + ' MHz' : '')],
736
+ ['系统负载', status.runtime.loadavg
737
+ ? ('1m ' + status.runtime.loadavg[0] + ' · 5m ' + status.runtime.loadavg[1] + ' · 15m ' + status.runtime.loadavg[2])
738
+ : '—'],
739
+ ['内存', fmtBytes(status.runtime.freemem) + ' 可用 / ' + fmtBytes(status.runtime.totalmem) + ' 总计' +
740
+ (status.runtime.memPct !== null && status.runtime.memPct !== undefined
741
+ ? '(已用 ' + status.runtime.memPct + '%)'
742
+ : '')],
743
+ ['RSS', fmtBytes(status.runtime.rss)],
744
+ ['堆内存', fmtBytes(status.runtime.heapUsed) + ' / ' + fmtBytes(status.runtime.heapTotal)],
745
+ ['Profile', status.runtime.profile],
746
+ ['DSH 数据目录', status.runtime.dshHome],
747
+ ])))
748
+ : null,
749
+
750
+ // DSH 应用层状态(版本 / 会话 / 工作区 / 插件 / 模型;后台缓存,服务不可用时不显示)
751
+ // 默认折叠,仅展示摘要行;点击标题展开 / 收起详情。
752
+ status && status.dsh
753
+ ? el('div', { style: st.card },
754
+ el('button', {
755
+ type: 'button',
756
+ onClick: function () { setDshOpen(!dshOpen); },
757
+ style: Object.assign({}, st.deviceHead, { padding: '4px 0' }),
758
+ },
759
+ el('div', { style: { display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0, flex: 1 } },
760
+ el('div', { style: st.row },
761
+ el('p', { style: st.title }, 'DSH 状态'),
762
+ el('span', { style: Object.assign({}, st.badge, st.badgeOff) },
763
+ status.dsh.version ? ('v' + status.dsh.version) : '版本未知')),
764
+ el('span', { style: st.meta },
765
+ '会话 ' + (status.dsh.sessions !== null && status.dsh.sessions !== undefined ? status.dsh.sessions : '—') +
766
+ ' · 工作区 ' + (status.dsh.workspaces ? status.dsh.workspaces.length : '—') +
767
+ ' · 插件 ' + (status.dsh.plugins ? status.dsh.plugins.length : '—') +
768
+ ' · 模型 ' + (status.dsh.llm ? status.dsh.llm.length : '—'))),
769
+ el('span', { style: dshOpen ? st.chevronOpen : st.chevron }, '▼')),
770
+ dshOpen
771
+ ? el('div', { style: { display: 'flex', flexDirection: 'column', gap: 4, marginTop: 4 } },
772
+ kvList([
773
+ ['版本号', status.dsh.version || '—'],
774
+ ['会话总数', status.dsh.sessions !== null && status.dsh.sessions !== undefined ? String(status.dsh.sessions) : '—'],
775
+ ['工作区数', status.dsh.workspaces ? String(status.dsh.workspaces.length) : '—'],
776
+ ['已装插件', status.dsh.plugins ? String(status.dsh.plugins.length) : '—'],
777
+ ['模型提供方', status.dsh.llm
778
+ ? String(status.dsh.llm.length) + '(' + status.dsh.llm.map(function (p) { return p.name || p.id; }).join('、') + ')'
779
+ : '—'],
780
+ ]),
781
+ status.dsh.workspaces && status.dsh.workspaces.length
782
+ ? el('div', { style: { display: 'flex', flexDirection: 'column', gap: 4, marginTop: 4 } },
783
+ el('span', { style: st.hint }, '工作区'),
784
+ status.dsh.workspaces.map(function (w, idx) {
785
+ return el('div', { style: st.deviceRow, key: 'ws' + idx },
786
+ el('span', { style: st.dotOn }),
787
+ el('div', { style: { display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0, flex: 1 } },
788
+ el('span', { style: st.deviceName }, w.title || w.path),
789
+ el('span', { style: st.meta }, w.path + ' · ' + String(w.sessionCount || 0) + ' 会话')));
790
+ }))
791
+ : null,
792
+ status.dsh.plugins && status.dsh.plugins.length
793
+ ? el('div', { style: { display: 'flex', flexDirection: 'column', gap: 4, marginTop: 4 } },
794
+ el('span', { style: st.hint }, '已安装插件'),
795
+ status.dsh.plugins.map(function (p, idx) {
796
+ return el('div', { style: st.deviceRow, key: 'pl' + idx },
797
+ el('span', { style: p.enabled ? st.dotOn : st.dotOff }),
798
+ el('div', { style: { display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0, flex: 1 } },
799
+ el('span', { style: st.deviceName }, p.name),
800
+ el('span', { style: st.meta }, (p.enabled ? '已启用' : '已停用') + (p.phase ? ' · ' + p.phase : ''))));
801
+ }))
802
+ : null)
803
+ : null)
804
+ : null,
805
+
806
+ // 网络接口详情(网卡名 / 地址 / 掩码 / MAC)
807
+ status && status.lanInterfaces && status.lanInterfaces.length
808
+ ? el('div', { style: st.card },
809
+ el('div', { style: st.row },
810
+ el('p', { style: st.title }, '网络接口'),
811
+ el('span', { style: Object.assign({}, st.badge, st.badgeOff) },
812
+ String(status.lanInterfaces.length) + ' 个 IPv4')),
813
+ el('div', { style: st.deviceList },
814
+ status.lanInterfaces.map(function (it) {
815
+ return el('div', { style: st.deviceRow, key: it.name + ':' + it.address },
816
+ el('span', { style: it.internal ? st.dotOff : st.dotOn }),
817
+ el('div', { style: { display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0, flex: 1 } },
818
+ el('span', { style: st.deviceName }, it.name + (it.internal ? '(回环)' : '')),
819
+ el('span', { style: st.meta },
820
+ it.address + (it.cidr ? ' / ' + it.cidr : '') +
821
+ ' · 掩码 ' + (it.netmask || '—') +
822
+ (it.mac ? ' · ' + it.mac : ''))));
823
+ })))
824
+ : null,
825
+
826
+ // 外网访问卡片
827
+ el('div', { style: st.card },
828
+ el('div', { style: st.row },
829
+ el('div', { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
830
+ el('p', { style: st.title }, '外网访问'),
831
+ el('p', { style: st.sub }, '通过中转服务器,让外网设备访问本电脑的 DSH')),
832
+ el('div', { style: { display: 'flex', alignItems: 'center', gap: 10 } },
833
+ extBadge, extSwitch)),
834
+
835
+ extStarted && extUrl
836
+ ? el('a', { href: extUrl, style: st.link, target: '_blank', rel: 'noreferrer' }, extUrl)
837
+ : null,
838
+
839
+ // 外网隧道详细状态(域名 / 端口 / frpc 版本 / 中转服务器信息)
840
+ extStarted && status.external
841
+ ? el('div', { style: { display: 'flex', flexDirection: 'column', gap: 6, padding: '10px 12px', borderRadius: 10, background: 'var(--dsw-alias-bg-base, #f0f0f1)', border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.18))' } },
842
+ el('div', { style: st.row },
843
+ el('span', { style: st.meta },
844
+ '域名:' + (status.external.domain || (status.external.server && status.external.server.externalDomain) || '—')),
845
+ el('span', { style: Object.assign({}, st.badge, st.badgeOn) }, '在线')),
846
+ el('div', { style: st.row },
847
+ el('span', { style: st.meta },
848
+ '隧道端口:' + (status.external.tunnelPort || (status.external.server && status.external.server.tunnelPort) || '—')),
849
+ status.external.frpcVersion
850
+ ? el('span', { style: st.meta }, 'frpc v' + status.external.frpcVersion)
851
+ : null),
852
+ el('div', { style: st.row },
853
+ el('span', { style: st.meta },
854
+ 'frpc 进程:' + (status.external.pid ? 'PID ' + status.external.pid : '—')),
855
+ status.external.startedAt
856
+ ? el('span', { style: st.meta }, '已运行 ' + fmtDuration(Math.floor((Date.now() - status.external.startedAt) / 1000)))
857
+ : null),
858
+ el('div', { style: st.row },
859
+ el('span', { style: st.meta },
860
+ '绑定码:' + (status.external.bindCodeShort || '—')),
861
+ null),
862
+ status.external.server && status.external.server.proxy
863
+ ? el('div', { style: st.row },
864
+ el('span', { style: st.meta },
865
+ '今日流量:↓' + fmtBytes(status.external.server.proxy.todayTrafficIn) +
866
+ ' · ↑' + fmtBytes(status.external.server.proxy.todayTrafficOut)),
867
+ el('span', { style: st.meta },
868
+ '当前连接:' + String(status.external.server.proxy.curConns || 0)))
869
+ : null,
870
+ status.external.server && status.external.server.proxy && status.external.server.proxy.localPort
871
+ ? el('div', { style: st.row },
872
+ el('span', { style: st.meta },
873
+ '本地转发:127.0.0.1:' + status.external.server.proxy.localPort +
874
+ (status.external.server.proxy.lastStartTime ? ' · 启动 ' + status.external.server.proxy.lastStartTime : '')),
875
+ null)
876
+ : null,
877
+ el('div', { style: st.row },
878
+ el('span', { style: st.meta },
879
+ '中转成员:' + (status.external.server ? (status.external.server.name || '—') : '—')),
880
+ status.external.server && status.external.server.last_seen
881
+ ? el('span', { style: st.meta }, '最后心跳:' + status.external.server.last_seen)
882
+ : null),
883
+ status.external.server && status.external.server.status
884
+ ? el('div', { style: st.row },
885
+ el('span', { style: st.meta },
886
+ '服务器状态:' + (status.external.server.status === 'online'
887
+ ? '在线'
888
+ : (status.external.server.status === 'offline' ? '离线' : '未知'))),
889
+ status.external.server.serverTime
890
+ ? el('span', { style: st.meta }, '服务器时间:' + status.external.server.serverTime)
891
+ : null)
892
+ : null,
893
+ el('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 2 } },
894
+ el('button', { type: 'button', onClick: viewFrpcLog, style: st.btn }, frpcLog ? '收起日志' : '查看 frpc 日志'),
895
+ el('button', { type: 'button', onClick: refresh, style: st.btn }, '刷新状态')))
896
+ : null,
897
+
898
+ // frpc 运行日志(可点击查看)
899
+ frpcLog
900
+ ? el('pre', { style: Object.assign({}, st.code, { marginTop: 8, maxHeight: 220, overflowY: 'auto' }) }, frpcLog)
901
+ : null,
902
+
903
+ // 服务端 FRP_TOKEN 仍是占位符 → 隧道必然 502,醒目提示(后端 /status /stats 返回)
904
+ status && status.external && status.external.server && status.external.server.frpTokenWarning
905
+ ? el('div', { style: Object.assign({}, st.card, { borderColor: 'rgba(200,150,0,.4)' }) },
906
+ el('p', { style: Object.assign({}, st.hint, { color: '#9a6a00' }) },
907
+ '⚠ ' + status.external.server.frpTokenWarning))
908
+ : null,
909
+
910
+ el('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
911
+ el('div', { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
912
+ el('label', { style: st.hint }, '绑定码'),
913
+ el('input', {
914
+ type: 'text', value: bindCode,
915
+ placeholder: '从中转平台获取的绑定码',
916
+ onChange: function (e) { markEdited(); setBindCode(e.target.value); },
917
+ style: st.input,
918
+ })),
919
+ el('div', { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
920
+ el('label', { style: st.hint }, '中转服务地址(可选,默认使用内置地址)'),
921
+ el('input', {
922
+ type: 'text', value: serverBase,
923
+ placeholder: (status && status.external && status.external.defaultServerBase)
924
+ ? status.external.defaultServerBase
925
+ : '如 https://api.deepseekharness.desktop.cwj666.top',
926
+ onChange: function (e) { markEdited(); setServerBase(e.target.value); },
927
+ style: st.input,
928
+ }))),
929
+
930
+ extQr,
931
+
932
+ extErrText
933
+ ? el('div', { style: Object.assign({}, st.card, { borderColor: 'rgba(200,40,40,.35)' }) },
934
+ el('p', { style: Object.assign({}, st.hint, { color: '#c0272b' }) }, '外网访问失败:' + extErrText),
935
+ extLog
936
+ ? el('pre', { style: Object.assign({}, st.code, { marginTop: 8, maxHeight: 180, overflowY: 'auto' }) }, extLog)
937
+ : null,
938
+ el('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 6 } },
939
+ el('button', { type: 'button', onClick: viewFrpcLog, style: st.btn }, frpcLog ? '收起日志' : '查看 frpc 日志'),
940
+ el('button', { type: 'button', onClick: refresh, style: st.btn }, '刷新状态')))
941
+ : null),
942
+
257
943
  cliHint,
258
944
 
259
945
  error