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