@feiyang666/dsh-mobile-remote 1.0.1 → 1.1.0

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,503 +1,292 @@
1
- // dsh-mobile-remote — 手机端远程控制 DeepSeek Harness(Client 半)
2
- // 职责:
3
- // 1. 移动端 UI 适配:窄屏强制单列布局、侧栏抽屉化(滑入滑出 + 遮罩)、
4
- // 触控目标优化(仅作用于 ≤768px 的移动端,桌面端完全不受影响)。
5
- // 2. shell.overlay「浮动控制条」:会话列表开关 + 远程连接状态指示(每 15s
6
- // 向 /api/dsh-mobile-remote 轮询状态)。
7
- // 3. settings.section「远程控制」设置页:展示开关状态、局域网访问地址、
8
- // 二维码、连接测试与使用说明。
9
- // 格式遵循 dsh client 插件规范(window.__ModuleLoader__.load + exports.apply)。
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
10
 
11
11
  window.__ModuleLoader__.load({
12
- id: '@feiyang666/dsh-mobile-remote',
12
+ id: 'dsh-mobile-remote',
13
13
  factory: (require) => {
14
- var module = { exports: {} }
15
- var exports = module.exports
16
- Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
17
- var React = require('react')
18
- var el = React.createElement
14
+ var module = { exports: {} };
15
+ var exports = module.exports;
16
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
19
17
 
20
- // ---------------------------------------------------------------
21
- // 移动端适配样式:只作用于 ≤768px 的窄屏(手机),桌面端不受影响。
22
- // 用属性选择器定位 AppFrame(frame 是 [data-shell-overlay] 的父元素),
23
- // 不依赖任何 CSS-module 哈希类名。
24
- // ---------------------------------------------------------------
25
- var MOBILE_CSS = [
26
- '@media (max-width: 768px) {',
27
- '/* 1) 三列 → 单列:隐藏侧栏 rail 与 details 列 */',
28
- 'div:has(> [data-shell-overlay]) {',
29
- ' grid-template-columns: 0px minmax(0, 1fr) 0px !important;',
30
- '}',
31
- '/* 2) 侧栏改为抽屉:默认滑出屏幕,打开时滑入(JS 维护 data-mr-open) */',
32
- 'div:has(> [data-shell-overlay]) > div:first-child {',
33
- ' position: fixed !important;',
34
- ' top: 0 !important;',
35
- ' left: 0 !important;',
36
- ' bottom: 0 !important;',
37
- ' height: 100% !important;',
38
- ' height: 100dvh !important;',
39
- ' width: min(84vw, 280px) !important;',
40
- ' margin: 0 !important;',
41
- ' z-index: 120 !important;',
42
- ' background: var(--dsw-alias-bg-base, #ffffff) !important;',
43
- ' border-right: 1px solid rgba(128,128,128,.2) !important;',
44
- ' box-shadow: 0 0 36px rgba(0,0,0,.35) !important;',
45
- ' transform: translateX(-106%);',
46
- ' transition: transform .22s ease !important;',
47
- ' -webkit-overflow-scrolling: touch;',
48
- '}',
49
- 'div:has(> [data-shell-overlay]) > div:first-child[data-mr-open="true"] {',
50
- ' transform: translateX(0) !important;',
51
- '}',
52
- '/* 3) 抽屉遮罩(JS 创建) */',
53
- 'body > [data-mr-mask] {',
54
- ' position: fixed !important;',
55
- ' inset: 0 !important;',
56
- ' z-index: 110 !important;',
57
- ' background: rgba(0,0,0,.35) !important;',
58
- '}',
59
- '/* 4) 触控优化 */',
60
- 'button, [role="button"], input, select, textarea {',
61
- ' touch-action: manipulation;',
62
- ' -webkit-tap-highlight-color: transparent;',
63
- '}',
64
- 'body { -webkit-text-size-adjust: 100%; }',
65
- '}'
66
- ].join('\n')
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;
67
23
 
68
- ;(function injectCss() {
69
- if (typeof document === 'undefined') return
70
- var existing = document.querySelector('style[data-plugin-css="dsh-mobile-remote/mobile"]')
71
- if (existing) return
72
- var tag = document.createElement('style')
73
- tag.setAttribute('data-plugin', 'dsh-mobile-remote')
74
- tag.setAttribute('data-plugin-css', 'dsh-mobile-remote/mobile')
75
- tag.textContent = MOBILE_CSS
76
- document.head.appendChild(tag)
77
- })()
24
+ /* ------------------------------------------------------------------ */
25
+ /* 内联样式(跟随宿主主题 token,深色/浅色自动适配) */
26
+ /* ------------------------------------------------------------------ */
78
27
 
79
- // ---------------------------------------------------------------
80
- // 工具函数
81
- // ---------------------------------------------------------------
82
- var API_PATH = '/api/dsh-mobile-remote'
83
- var NS = 'dsh-mobile-remote'
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
+ };
84
107
 
85
- function callRemote(args) {
86
- return fetch(API_PATH, {
87
- method: 'POST',
88
- headers: { 'Content-Type': 'application/json' },
89
- body: JSON.stringify(args || {}),
90
- }).then(function (r) { return r.json() })
91
- }
108
+ /* ------------------------------------------------------------------ */
109
+ /* 远程控制面板 */
110
+ /* ------------------------------------------------------------------ */
92
111
 
93
- function isMobileViewport() {
94
- if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
95
- var w = window.innerWidth || (document.documentElement && document.documentElement.clientWidth) || 0
96
- var coarse = window.matchMedia('(pointer: coarse)').matches
97
- return w <= 768 || (coarse && w <= 1024)
98
- }
112
+ var STATUS_URL = '/__dsh_remote/status';
113
+ var TOGGLE_URL = '/__dsh_remote/toggle';
99
114
 
100
- /** 当前页面是否通过局域网(手机/其他设备)访问,而非本机。 */
101
- function isRemoteAccess() {
102
- if (typeof location === 'undefined') return false
103
- var h = String(location.hostname || '').toLowerCase()
104
- return !(h === '' || h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '[::1]')
115
+ function fmtUrl(url) {
116
+ return url || '未检测到局域网地址';
105
117
  }
106
118
 
107
- // ---- 复制到剪贴板(含降级) ----
108
- function copyText(text, cb) {
109
- function fallback() {
110
- try {
111
- var ta = document.createElement('textarea')
112
- ta.value = text
113
- ta.style.position = 'fixed'
114
- ta.style.opacity = '0'
115
- document.body.appendChild(ta)
116
- ta.select()
117
- var ok = document.execCommand('copy')
118
- document.body.removeChild(ta)
119
- cb(ok)
120
- } catch (e) { cb(false) }
121
- }
122
- if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
123
- navigator.clipboard.writeText(text).then(function () { cb(true) }).catch(fallback)
124
- } else {
125
- fallback()
126
- }
127
- }
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];
128
129
 
129
- // ---------------------------------------------------------------
130
- // 抽屉化:监听 frame 的 grid-template-columns,同步抽屉开合与遮罩。
131
- // 侧栏开关由 AppFrame 的 narrow 模式控制(ctx.layout.toggleSidebar()),
132
- // 切换后 frame 内联 grid 首列会在 0 ↔ 280px 之间变化,这里据此反映。
133
- // ---------------------------------------------------------------
134
- function startDrawer(layout) {
135
- if (typeof MutationObserver === 'undefined' || typeof document === 'undefined') return
136
- var frame = null
137
- var sidebar = null
138
- var mask = null
139
- var styleObserver = null
140
- var enabled = false // 仅在移动视口下启用抽屉逻辑(桌面端绝不干预)
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
+ }, []);
141
136
 
142
- function ensureMask() {
143
- if (mask) return
144
- mask = document.createElement('div')
145
- mask.setAttribute('data-mr-mask', 'true')
146
- mask.addEventListener('click', function () {
147
- if (layout) layout.toggleSidebar()
148
- })
149
- document.body.appendChild(mask)
150
- }
151
- function removeMask() {
152
- if (mask) { mask.remove(); mask = null }
153
- }
154
- function applyOpen(open) {
155
- if (!sidebar) return
156
- if (open) {
157
- sidebar.setAttribute('data-mr-open', 'true')
158
- ensureMask()
159
- } else {
160
- sidebar.removeAttribute('data-mr-open')
161
- removeMask()
162
- }
163
- }
164
- function syncFromFrame() {
165
- if (!frame || !enabled) { applyOpen(false); return }
166
- var cols = String(frame.style.gridTemplateColumns || '').trim()
167
- var m = /^(-?\d+(?:\.\d+)?)/.exec(cols)
168
- var first = m ? parseFloat(m[1]) : 0
169
- applyOpen(first > 0)
170
- }
171
- function setup() {
172
- var overlay = document.querySelector('[data-shell-overlay]')
173
- if (!overlay) return
174
- var f = overlay.parentElement
175
- if (!f || f === frame) return
176
- frame = f
177
- sidebar = f.firstElementChild
178
- if (styleObserver) styleObserver.disconnect()
179
- styleObserver = new MutationObserver(syncFromFrame)
180
- styleObserver.observe(frame, { attributes: true, attributeFilter: ['style'] })
181
- syncFromFrame()
182
- }
183
- // 视口变化时切换启用/停用抽屉逻辑
184
- function syncEnabled() {
185
- var on = isMobileViewport()
186
- if (on === enabled) return
187
- enabled = on
188
- if (!on) applyOpen(false)
189
- else syncFromFrame()
190
- }
191
- function watchViewport() {
192
- if (typeof window === 'undefined') return
193
- var mq = window.matchMedia('(max-width: 768px)')
194
- var mq2 = window.matchMedia('(pointer: coarse)')
195
- function update() { syncEnabled() }
196
- if (mq.addEventListener) mq.addEventListener('change', update)
197
- if (mq2.addEventListener) mq2.addEventListener('change', update)
198
- window.addEventListener('resize', update)
199
- }
200
- var wait = new MutationObserver(function () { setup() })
201
- if (document.body) wait.observe(document.body, { childList: true, subtree: true })
202
- syncEnabled()
203
- watchViewport()
204
- setup()
205
- }
206
-
207
- // ---------------------------------------------------------------
208
- // 浮动控制条(shell.overlay):只在移动端渲染。
209
- // - ☰ 打开会话列表(侧栏抽屉)
210
- // - ● 远程控制状态指示(绿=局域网已连,黄=已开启本机访问,灰=未开启)
211
- // - 点击状态点弹出信息卡:当前访问方式 / 手机地址 / 复制
212
- // ---------------------------------------------------------------
213
- var DOCK_STYLE = {
214
- position: 'fixed',
215
- top: 'calc(10px + env(safe-area-inset-top, 0px))',
216
- right: 'calc(10px + env(safe-area-inset-right, 0px))',
217
- display: 'flex',
218
- flexDirection: 'column',
219
- gap: 8,
220
- alignItems: 'center',
221
- zIndex: 200,
222
- pointerEvents: 'none',
223
- }
224
- var DOCK_BTN = {
225
- width: 44,
226
- height: 44,
227
- borderRadius: 22,
228
- border: '1px solid rgba(128,128,128,.3)',
229
- background: 'var(--dsw-alias-bg-base, #ffffff)',
230
- color: 'var(--dsw-alias-label-primary, #111111)',
231
- fontSize: 20,
232
- lineHeight: 1,
233
- display: 'flex',
234
- alignItems: 'center',
235
- justifyContent: 'center',
236
- cursor: 'pointer',
237
- boxShadow: '0 2px 10px rgba(0,0,0,.18)',
238
- pointerEvents: 'auto',
239
- padding: 0,
240
- boxSizing: 'border-box',
241
- }
242
- var INFO_CARD = {
243
- position: 'absolute',
244
- top: 104,
245
- right: 0,
246
- width: 240,
247
- background: 'var(--dsw-alias-bg-base, #ffffff)',
248
- border: '1px solid rgba(128,128,128,.25)',
249
- borderRadius: 12,
250
- boxShadow: '0 6px 24px rgba(0,0,0,.2)',
251
- padding: '10px 12px',
252
- fontSize: 12,
253
- color: 'var(--dsw-alias-label-primary, #111111)',
254
- pointerEvents: 'auto',
255
- boxSizing: 'border-box',
256
- }
137
+ useEffect(function () {
138
+ refresh();
139
+ var t = setInterval(refresh, 5000);
140
+ return function () { clearInterval(t); };
141
+ }, [refresh]);
257
142
 
258
- var Dock = function (props) {
259
- var layout = props && props.layout ? props.layout : null
260
- var [isMobile, setIsMobile] = React.useState(isMobileViewport())
261
- var [status, setStatus] = React.useState(null)
262
- var [infoOpen, setInfoOpen] = React.useState(false)
263
- var [copied, setCopied] = React.useState(false)
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]);
264
163
 
265
- React.useEffect(function () {
266
- function update() { setIsMobile(isMobileViewport()) }
267
- var mq = window.matchMedia('(max-width: 768px)')
268
- var mq2 = window.matchMedia('(pointer: coarse)')
269
- if (mq.addEventListener) mq.addEventListener('change', update)
270
- if (mq2.addEventListener) mq2.addEventListener('change', update)
271
- return function () {
272
- if (mq.removeEventListener) mq.removeEventListener('change', update)
273
- if (mq2.removeEventListener) mq2.removeEventListener('change', update)
274
- }
275
- }, [])
164
+ // 状态徽标
165
+ var on = !!(status && status.remoteEnabled);
166
+ var badge = el('span', { style: Object.assign({}, st.badge, on ? st.badgeOn : st.badgeOff) },
167
+ on ? '已开启' : '未开启');
276
168
 
277
- React.useEffect(function () {
278
- if (!isMobile) return
279
- var timer = setInterval(function () {
280
- callRemote({ action: 'status' }).then(function (r) { setStatus(r) }).catch(function () { /* ignore */ })
281
- }, 15000)
282
- callRemote({ action: 'status' }).then(function (r) { setStatus(r) }).catch(function () { /* ignore */ })
283
- return function () { clearInterval(timer) }
284
- }, [isMobile])
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) }));
285
179
 
286
- if (!isMobile) return null
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
+ }
287
194
 
288
- var remote = isRemoteAccess()
289
- var enabled = !!(status && status.enabled)
290
- var urls = (status && status.urls) || []
291
- var dotColor = (enabled && remote) ? '#22c55e' : (enabled ? '#eab308' : '#9ca3af')
292
- var statusTitle = (enabled && remote)
293
- ? '已通过局域网连接电脑'
294
- : (enabled ? '远程控制已开启(当前为本机访问)' : '远程控制未开启(见设置页「远程控制」)')
195
+ // 设备数
196
+ var count = status ? status.deviceCount : 0;
295
197
 
296
- return el('div', { style: DOCK_STYLE },
297
- el('button', {
298
- style: DOCK_BTN,
299
- onClick: function () { if (layout) layout.toggleSidebar() },
300
- 'aria-label': '打开会话列表',
301
- title: '会话列表',
302
- }, '\u2630'),
303
- el('button', {
304
- style: DOCK_BTN,
305
- onClick: function () { setInfoOpen(!infoOpen); setCopied(false) },
306
- 'aria-label': '远程控制状态',
307
- title: statusTitle,
308
- }, el('span', { style: { width: 14, height: 14, borderRadius: 7, background: dotColor, display: 'block' } })),
309
- infoOpen
310
- ? el('div', { style: INFO_CARD },
311
- el('div', { style: { fontWeight: 600, marginBottom: 6, fontSize: 13 } }, '远程控制'),
312
- el('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 8, padding: '2px 0' } },
313
- el('span', { style: { opacity: .65 } }, '连接状态'),
314
- el('span', {}, (enabled && remote) ? '局域网已连接' : (enabled ? '已开启(本机访问)' : '未开启'))),
315
- el('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 8, padding: '2px 0' } },
316
- el('span', { style: { opacity: .65 } }, '当前访问'),
317
- el('span', {}, remote ? '局域网' : '本机')),
318
- urls.length
319
- ? el('div', { style: { padding: '6px 0 2px', wordBreak: 'break-all', opacity: .85 } }, urls[0])
320
- : el('div', { style: { padding: '6px 0 2px', opacity: .65 } }, '未检测到局域网地址'),
321
- el('div', { style: { display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap' } },
322
- urls.length
323
- ? el('button', {
324
- style: { border: '1px solid rgba(128,128,128,.4)', background: 'transparent', borderRadius: 6, padding: '4px 10px', fontSize: 12, cursor: 'pointer', color: 'inherit' },
325
- onClick: function () { copyText(urls[0], function (ok) { setCopied(ok) }) },
326
- }, copied ? '已复制' : '复制地址')
327
- : null,
328
- ),
329
- )
330
- : null,
331
- )
332
- }
333
-
334
- // ---------------------------------------------------------------
335
- // 设置页(settings.section):远程控制状态 / 访问地址 / 二维码 / 使用说明
336
- // ---------------------------------------------------------------
337
- var BASE_FS = 13
338
- function fs(n) { return (Math.round(n / BASE_FS * 100) / 100).toFixed(2) + 'em' }
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
+ }
339
215
 
340
- var RemoteSection = function () {
341
- var [status, setStatus] = React.useState(null)
342
- var [copied, setCopied] = React.useState('')
343
- var [pinging, setPinging] = React.useState(false)
344
- var [pingResult, setPingResult] = React.useState('')
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)),
345
224
 
346
- function refresh() {
347
- callRemote({ action: 'status' }).then(setStatus).catch(function (e) { setStatus({ error: String(e) }) })
348
- }
349
- React.useEffect(function () { refresh() }, [])
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))),
350
234
 
351
- function doPing() {
352
- setPinging(true)
353
- setPingResult('')
354
- callRemote({ action: 'ping' }).then(function (r) {
355
- if (r && r.ok) {
356
- var t = r.serverTimeIso ? new Date(r.serverTimeIso).toLocaleTimeString() : String(r.serverTime)
357
- setPingResult('✓ 连接正常(服务器时间 ' + t + ')')
358
- } else {
359
- setPingResult('✗ ' + ((r && r.error) || '无响应'))
360
- }
361
- }).catch(function (e) { setPingResult('✗ ' + String(e)) }).finally(function () { setPinging(false) })
362
- }
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 }, '局域网地址'))),
363
243
 
364
- var err = status && status.error
365
- var enabled = !!(status && status.enabled)
366
- var host = status && status.host
367
- var port = status && status.port
368
- var lanAddresses = (status && status.lanAddresses) || []
369
- var urls = (status && status.urls) || []
370
- var sessionCount = status && typeof status.sessionCount === 'number' ? status.sessionCount : null
371
- var qrUrl = urls.length
372
- ? ('https://api.qrserver.com/v1/create-qr-code/?size=220x220&margin=8&data=' + encodeURIComponent(urls[0]))
373
- : ''
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,
374
250
 
375
- var st = {
376
- root: { display: 'flex', flexDirection: 'column', gap: 10, padding: '4px 0', width: '100%', minWidth: 0, boxSizing: 'border-box' },
377
- title: { fontWeight: 600, fontSize: fs(15) },
378
- sub: { fontSize: fs(11), opacity: .55 },
379
- card: { border: '1px solid rgba(128,128,128,.25)', borderRadius: 10, padding: '12px 14px', minWidth: 0 },
380
- row: { display: 'flex', justifyContent: 'space-between', gap: 10, padding: '2px 0', fontSize: fs(12), flexWrap: 'wrap' },
381
- rowKey: { opacity: .65 },
382
- statusLine: { padding: '9px 11px', borderRadius: 8, fontSize: fs(12), fontWeight: 600 },
383
- urlRow: { display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', flexWrap: 'wrap' },
384
- urlText: { fontSize: fs(12), wordBreak: 'break-all', flex: '1 1 180px', minWidth: 0 },
385
- btn: { border: '1px solid rgba(128,128,128,.4)', background: 'transparent', borderRadius: 6, padding: '5px 12px', fontSize: fs(12), cursor: 'pointer', color: 'inherit', whiteSpace: 'nowrap' },
386
- btnPrimary: { border: '1px solid rgba(90,140,255,.6)', background: 'rgba(90,140,255,.22)', borderRadius: 6, padding: '5px 12px', fontSize: fs(12), cursor: 'pointer', color: 'inherit', fontWeight: 600 },
387
- section: { fontWeight: 600, fontSize: fs(13), marginTop: 4 },
388
- qr: { display: 'flex', justifyContent: 'center', margin: '8px 0' },
389
- qrImg: { width: 220, height: 220, borderRadius: 8, border: '1px solid rgba(128,128,128,.2)' },
390
- qrFallback: { fontSize: fs(11), opacity: .6, textAlign: 'center', padding: '12px 0' },
391
- msg: { fontSize: fs(11), opacity: .8, wordBreak: 'break-all' },
392
- note: { fontSize: fs(11), opacity: .55, lineHeight: 1.6 },
393
- }
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,
394
256
 
395
- return el('div', { style: st.root },
396
- el('div', { style: st.title }, '手机端远程控制'),
397
- el('div', { style: st.sub }, '手机与电脑连接同一 Wi-Fi 时,用手机浏览器打开下方地址,即可实时同步对话记录、会话列表与设置,并直接发送消息远程操控电脑端 DeepSeek Harness。'),
257
+ cliHint,
398
258
 
399
- err
400
- ? el('div', { style: st.msg }, '状态获取失败:' + err)
401
- : el(React.Fragment, null,
402
- // 状态卡片
403
- el('div', { style: st.card },
404
- el('div', { style: st.statusLine, background: enabled ? 'rgba(34,197,94,.16)' : 'rgba(156,163,175,.14)', color: enabled ? '#16a34a' : 'inherit', borderRadius: 8 },
405
- enabled ? '✓ 远程控制已开启:服务正在监听局域网' : '○ 远程控制未开启:当前仅本机可访问'),
406
- el('div', { style: { height: 8 } }),
407
- el('div', { style: st.row }, el('span', { style: st.rowKey }, '监听地址'), el('span', {}, host + ':' + port)),
408
- el('div', { style: st.row }, el('span', { style: st.rowKey }, '会话数'), el('span', {}, sessionCount === null ? '…' : String(sessionCount))),
409
- el('div', { style: st.row }, el('span', { style: st.rowKey }, '本机访问'), el('span', {}, 'http://127.0.0.1:' + port)),
410
- ),
411
- // 手机访问地址
412
- el('div', { style: st.section }, '手机访问地址'),
413
- el('div', { style: st.card },
414
- lanAddresses.length === 0
415
- ? el('div', { style: st.msg }, '未检测到局域网 IPv4 地址。请检查电脑是否连接 Wi-Fi / 有线网络,或在 dsh-desktop 中重新开启「移动端远程控制」。')
416
- : urls.map(function (u) {
417
- return el('div', { key: u, style: st.urlRow },
418
- el('span', { style: st.urlText }, u),
419
- el('button', { style: st.btn, onClick: function () { copyText(u, function (ok) { setCopied(ok ? u : '') }) } }, copied === u ? '✓ 已复制' : '复制'),
420
- )
421
- }),
422
- el('div', { style: { height: 4 } }),
423
- el('div', { style: st.note }, '打开 dsh-desktop 设置页的「移动端远程控制」开关后,重启服务即生效(服务监听 0.0.0.0,手机可访问)。'),
424
- ),
425
- // 二维码
426
- urls.length
427
- ? el(React.Fragment, null,
428
- el('div', { style: st.section }, '扫码访问'),
429
- el('div', { style: st.card },
430
- el('div', { style: st.qr },
431
- el('img', {
432
- src: qrUrl,
433
- alt: '手机访问二维码',
434
- style: st.qrImg,
435
- onError: function (e) {
436
- // 外部二维码服务不可用时降级为文本
437
- e.currentTarget.style.display = 'none'
438
- },
439
- }),
440
- ),
441
- el('div', { style: st.qrFallback }, '用手机相机 / 浏览器扫码打开。'),
442
- ),
443
- )
444
- : null,
445
- // 连接测试
446
- el('div', { style: st.section }, '连接测试'),
447
- el('div', { style: st.card },
448
- el('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
449
- el('button', { style: st.btnPrimary, disabled: pinging, onClick: doPing }, pinging ? '测试中…' : '发送测试请求'),
450
- el('span', { style: st.msg }, '用于确认本页面与电脑端 Harness 服务的通信链路'),
451
- ),
452
- pingResult ? el('div', { style: { fontSize: fs(11), opacity: 1, wordBreak: 'break-all', marginTop: 8 } }, pingResult) : null,
453
- ),
454
- // 使用说明
455
- el('div', { style: st.section }, '使用说明'),
456
- el('div', { style: st.card },
457
- el('div', { style: st.note }, [
458
- '1. 电脑与手机连接到同一个 Wi-Fi;',
459
- '2. 在 dsh-desktop 设置中开启「移动端远程控制」,重启服务;',
460
- '3. 用手机浏览器打开上方任一地址(或扫码);',
461
- '4. 手机端与电脑端共用同一份会话数据,对话、列表、设置实时双向同步,任何一端的操作都会即时反映到另一端;',
462
- '5. 手机端已针对触控优化:侧栏自动变为抽屉,右上角浮动按钮可随时打开会话列表并查看连接状态。',
463
- ].join('\n')),
464
- ),
465
- ),
466
- )
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);
467
263
  }
468
264
 
469
- // ---------------------------------------------------------------
470
- // 插件入口
471
- // ---------------------------------------------------------------
472
- var inject = ['slots', 'layout']
265
+ /* ------------------------------------------------------------------ */
266
+ /* 注册到设置页 */
267
+ /* ------------------------------------------------------------------ */
473
268
 
474
- function apply(ctx) {
475
- var slots = ctx.get('slots')
476
- if (slots === undefined) return
477
- var layout = ctx.get('layout')
478
-
479
- // 移动端适配:抽屉化(内部仅在移动视口下生效,桌面端绝不干预)
480
- startDrawer(layout)
269
+ var inject = ['slots'];
481
270
 
482
- // 浮动控制条(shell.overlay)
483
- slots.inject('shell.overlay', function () {
484
- return slots.register(
485
- { name: 'shell.overlay', id: 'mobile-remote-dock', order: 80 },
486
- function () { return el(Dock, { layout: layout }) },
487
- )
488
- })
271
+ function apply(ctx) {
272
+ var slots = ctx.get('slots');
273
+ if (slots === undefined) return;
489
274
 
490
- // 远程控制设置页(settings.section)
491
275
  slots.inject('settings.section', function () {
492
276
  return slots.register(
493
- { name: 'settings.section', id: 'mobile-remote', order: 90, label: function () { return '远程控制' } },
494
- function () { return el(RemoteSection, null) },
495
- )
496
- })
277
+ {
278
+ name: 'settings.section',
279
+ id: 'remote-control',
280
+ order: 40,
281
+ label: '远程控制',
282
+ },
283
+ function () { return el(RemoteControlPanel, null); }
284
+ );
285
+ });
497
286
  }
498
287
 
499
- exports.apply = apply
500
- exports.inject = inject
501
- return module.exports
288
+ exports.inject = inject;
289
+ exports.apply = apply;
290
+ return module.exports;
502
291
  },
503
- })
292
+ });