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