dsh-dock 0.8.0 → 0.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.
@@ -1,42 +1,20 @@
1
- // dsh-dock · 功能模块【手机接力】· 客户端视图
2
- // 手机和电脑均轮询同一个短时配对记录;业务会话继续由 DSH 原生会话服务负责同步。
1
+ // dsh-dock · 功能模块【远程访问】· 客户端视图
2
+ // 单实例架构:主 DSH 只监听 127.0.0.1;局域网/虚拟网设备经账号密码登录的网关
3
+ // 访问同一个 DSH(会话、任务进度实时一致)。本视图管理:开启/关闭入口、账号密码、
4
+ // 访问地址二维码;远程设备上经 window.__DSH_REMOTE__ 显示退出登录。
3
5
  import { useCallback, useEffect, useMemo, useState } from 'react'
4
6
  import QRCode from 'qrcode'
5
7
 
6
- const DEVICE_KEY = 'dsh-dock/mobile-relay/device/v1'
7
- const SESSION_KEY = 'dsh-dock/mobile-relay/session/v1'
8
- const LAUNCH_KEY = 'dsh-dock/mobile-relay/launch/v1'
9
-
10
8
  function rpc(method, payload) {
11
9
  return fetch('/dsh-dock/mobile-relay/' + method, {
12
10
  method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload || {}),
13
11
  }).then(async (res) => {
14
12
  const body = await res.json().catch(() => ({}))
15
13
  if (res.ok && body && body.ok) return body.data
16
- if (res.status === 404 || res.status === 405) throw new Error('宿主进程仍是旧版本,请重启 dsh web 后再开启局域网连接')
14
+ if (res.status === 404 || res.status === 405) throw new Error('宿主进程仍是旧版本,请重启 dsh web 后再试')
17
15
  throw new Error((body && body.error && body.error.message) || ('请求失败(' + res.status + ')'))
18
16
  })
19
17
  }
20
- function deviceId() {
21
- let id = ''
22
- try { id = localStorage.getItem(DEVICE_KEY) || '' } catch { /* memory only */ }
23
- if (!id) {
24
- id = (globalThis.crypto && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2))
25
- try { localStorage.setItem(DEVICE_KEY, id) } catch { /* memory only */ }
26
- }
27
- return id
28
- }
29
- function readSession() {
30
- try { const raw = sessionStorage.getItem(SESSION_KEY); return raw ? JSON.parse(raw) : null } catch { return null }
31
- }
32
- function saveSession(value) { try { sessionStorage.setItem(SESSION_KEY, JSON.stringify(value)) } catch { /* memory state still works */ } }
33
- function clearSession() { try { sessionStorage.removeItem(SESSION_KEY) } catch { /* ignore */ } }
34
- function fmtDuration(ms) {
35
- const sec = Math.max(0, Math.floor((ms || 0) / 1000)); const min = Math.floor(sec / 60); const rest = sec % 60
36
- return min > 0 ? min + ' 分 ' + String(rest).padStart(2, '0') + ' 秒' : rest + ' 秒'
37
- }
38
- function fmtTime(value) { return value ? new Date(value).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '' }
39
- function phaseLabel(phase) { return ({ think: '正在思考', write: '正在输出', code: '正在开发', search: '正在查资料' })[phase] || '进行中' }
40
18
  function copy(value) {
41
19
  if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(value)
42
20
  return Promise.reject(new Error('当前浏览器不支持复制,请长按或手动复制'))
@@ -44,12 +22,10 @@ function copy(value) {
44
22
 
45
23
  function RelayIcon({ name, size = 18 }) {
46
24
  const common = { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true }
47
- if (name === 'phone') return <svg {...common}><rect x="7" y="2.5" width="10" height="19" rx="2.2"/><path d="M10 18.5h4"/></svg>
48
25
  if (name === 'link') return <svg {...common}><path d="M10.5 13.5a4 4 0 0 0 5.66.01l2-2a4 4 0 0 0-5.66-5.66l-1.15 1.14"/><path d="M13.5 10.5a4 4 0 0 0-5.66-.01l-2 2a4 4 0 0 0 5.66 5.66l1.15-1.14"/></svg>
49
26
  if (name === 'copy') return <svg {...common}><rect x="8" y="8" width="11" height="12" rx="1.5"/><path d="M16 8V5.5A1.5 1.5 0 0 0 14.5 4h-9A1.5 1.5 0 0 0 4 5.5v9A1.5 1.5 0 0 0 5.5 16H8"/></svg>
50
- if (name === 'check') return <svg {...common}><path d="m5 12 4.2 4.2L19 6.5"/></svg>
51
- if (name === 'arrow') return <svg {...common}><path d="M5 12h13"/><path d="m14 7 5 5-5 5"/></svg>
52
27
  if (name === 'close') return <svg {...common}><path d="m6 6 12 12M18 6 6 18"/></svg>
28
+ if (name === 'check') return <svg {...common}><path d="m5 12 4.2 4.2L19 6.5"/></svg>
53
29
  return <svg {...common}><circle cx="12" cy="12" r="8"/><path d="M12 8v4l2.5 2"/></svg>
54
30
  }
55
31
 
@@ -63,145 +39,136 @@ function useCompact() {
63
39
  return compact
64
40
  }
65
41
 
66
- export function MobileRelayView() {
42
+ /** 远程访问卡片:入口开关、账号密码、访问地址二维码、退出登录。 */
43
+ function RemoteCard() {
67
44
  const compact = useCompact()
68
- const [session, setSession] = useState(readSession)
69
- const [pair, setPair] = useState(null)
70
- const [network, setNetwork] = useState(null)
71
- const [port, setPort] = useState(3081)
72
- const [selectedAddress, setSelectedAddress] = useState('')
73
- const [qrDataUrl, setQrDataUrl] = useState('')
74
- const [note, setNote] = useState('')
45
+ const [lan, setLan] = useState(null)
46
+ const [username, setUsername] = useState('')
47
+ const [password, setPassword] = useState('')
48
+ const [newPassword, setNewPassword] = useState('')
49
+ const [port, setPort] = useState('')
50
+ const [address, setAddress] = useState('')
51
+ const [qr, setQr] = useState('')
75
52
  const [busy, setBusy] = useState('')
76
- const [notice, setNotice] = useState('')
77
- const [error, setError] = useState('')
78
- const mobile = session && session.role === 'mobile'
53
+ const [message, setMessage] = useState('')
54
+ const [messageKind, setMessageKind] = useState('error')
55
+ const [changing, setChanging] = useState(false)
56
+ const [remote, setRemote] = useState(false)
79
57
 
80
- const setCurrent = useCallback((next) => { setSession(next); next ? saveSession(next) : clearSession() }, [])
81
- useEffect(() => {
82
- if (session) return
83
- rpc('network').then((data) => {
84
- setNetwork(data)
85
- if (data && data.defaultPort) setPort(data.defaultPort)
86
- const first = data && data.addresses && data.addresses[0]
87
- if (first) setSelectedAddress(first.address)
88
- }).catch((e) => setError(e && e.message ? e.message : String(e)))
89
- }, [session])
90
- const refresh = useCallback(async (candidate = session) => {
91
- if (!candidate) return
92
- try {
93
- const result = await rpc('status', candidate)
94
- setPair(result.pair); setError('')
95
- } catch (e) {
96
- const message = e && e.message ? e.message : String(e)
97
- setError(message)
98
- if (/过期|未完成配对/.test(message)) setCurrent(null)
99
- }
100
- }, [session, setCurrent])
58
+ const notify = useCallback((text, kind) => { setMessage(text); setMessageKind(kind || 'error') }, [])
101
59
 
102
- useEffect(() => { if (session) refresh(session) }, [session, refresh])
60
+ const refresh = useCallback(async () => {
61
+ try {
62
+ const data = await rpc('lan')
63
+ setLan(data)
64
+ setAddress((prev) => prev || (data.addresses && data.addresses[0] ? data.addresses[0].address : ''))
65
+ setUsername((prev) => prev || (data.username || ''))
66
+ } catch (e) { notify(e && e.message ? e.message : String(e)) }
67
+ }, [notify])
103
68
  useEffect(() => {
104
- if (!session) return
105
- const timer = setInterval(() => refresh(session), 2500)
69
+ refresh()
70
+ const timer = setInterval(refresh, 3000)
106
71
  return () => clearInterval(timer)
107
- }, [session, refresh])
72
+ }, [refresh])
108
73
  useEffect(() => {
109
- if (session) return
110
- let launch = ''
111
- try { launch = sessionStorage.getItem(LAUNCH_KEY) || '' } catch { /* ignore */ }
112
- if (!launch) return
113
- try { sessionStorage.removeItem(LAUNCH_KEY) } catch { /* ignore */ }
114
- const [pairId, code] = launch.split('.')
115
- if (!pairId || !code) return
116
- setBusy('join')
117
- rpc('join', { pairId, code, deviceId: deviceId(), label: '手机端' })
118
- .then((data) => { setCurrent({ pairId, secret: data.secret, role: 'mobile', deviceId: deviceId() }); setPair(data.pair); setNotice('已连接到电脑端,任务状态会持续同步。') })
119
- .catch((e) => setError(e && e.message ? e.message : String(e)))
120
- .finally(() => setBusy(''))
121
- }, [session, setCurrent])
74
+ // 页面经网关访问时(网关注入 health 探针可用),显示退出登录。
75
+ fetch('/__dsh_auth/health', { method: 'POST' }).then((r) => setRemote(Boolean(r.ok))).catch(() => setRemote(false))
76
+ }, [])
122
77
 
123
- const mobileLink = useMemo(() => {
124
- if (!session || !session.gatewayToken || !session.gatewayAddress || !session.gatewayPort) return ''
125
- return 'http://' + session.gatewayAddress + ':' + session.gatewayPort + '/__dsh_mobile/connect#' + session.gatewayToken
126
- }, [session])
78
+ const active = Boolean(lan && lan.gatewayActive)
79
+ const gatewayPort = lan && lan.gatewayPort ? lan.gatewayPort : 3081
80
+ // 未开启时的建议端口:跟随主实例端口(+1)。网关和主服务是同一台机器上的两个
81
+ // 监听,结构上不能同端口;主端口只留给本机,网关端口给其他设备登录用。
82
+ const suggestedPort = lan && lan.mainPort ? lan.mainPort + 1 : gatewayPort
83
+ const lanLink = useMemo(() => (address && active ? 'http://' + address + ':' + gatewayPort : ''), [address, active, gatewayPort])
127
84
  useEffect(() => {
128
- let active = true
129
- if (!mobileLink) { setQrDataUrl(''); return () => { active = false } }
130
- QRCode.toDataURL(mobileLink, {
131
- errorCorrectionLevel: 'M', margin: 2, width: 260,
132
- color: { dark: '#111827', light: '#ffffff' },
133
- }).then((value) => { if (active) setQrDataUrl(value) })
134
- .catch((e) => { if (active) setError('二维码生成失败:' + (e && e.message ? e.message : String(e))) })
135
- return () => { active = false }
136
- }, [mobileLink])
85
+ let live = true
86
+ if (!lanLink) { setQr(''); return () => { live = false } }
87
+ QRCode.toDataURL(lanLink, { errorCorrectionLevel: 'M', margin: 2, width: 220, color: { dark: '#111827', light: '#ffffff' } })
88
+ .then((value) => { if (live) setQr(value) }).catch(() => {})
89
+ return () => { live = false }
90
+ }, [lanLink])
137
91
 
138
- async function start() {
139
- setError(''); setNotice('')
140
- const numericPort = Number(port)
141
- if (!selectedAddress) { setError('没有检测到可用的局域网 IPv4 地址,请先连接 Wi-Fi 或有线网络。'); return }
142
- if (!Number.isInteger(numericPort) || numericPort < 1024 || numericPort > 65535) { setError('局域网端口需为 1024 到 65535 之间的整数。'); return }
92
+ async function enable() {
93
+ notify('')
94
+ if (!lan.accountSet && (!username.trim() || password.length < 6)) {
95
+ notify('首次开启请设置账号和至少 6 位的密码。'); return
96
+ }
143
97
  setBusy('start')
144
98
  try {
145
- const data = await rpc('start', { deviceId: deviceId(), port: numericPort })
146
- const next = {
147
- pairId: data.pairId, secret: data.secret, code: data.code, role: 'desktop', deviceId: deviceId(),
148
- gatewayToken: data.gatewayToken, gatewayPort: data.gateway.port, gatewayAddress: selectedAddress,
149
- }
150
- setCurrent(next); setPair(data.pair)
151
- setNotice('受保护的局域网反向代理已开启。手机扫码即可进入同一 DSH。')
152
- } catch (e) { setError(e && e.message ? e.message : String(e)) } finally { setBusy('') }
99
+ const payload = {}
100
+ if (username.trim() && newPassword) { payload.username = username.trim(); payload.password = newPassword }
101
+ else if (!lan.accountSet) { payload.username = username.trim(); payload.password = password }
102
+ const numericPort = Number(port)
103
+ if (Number.isInteger(numericPort) && numericPort >= 1024 && numericPort <= 65535) payload.port = numericPort
104
+ const data = await rpc('lan/start', payload)
105
+ setLan(data); setPassword(''); setNewPassword(''); setChanging(false)
106
+ notify('远程访问已开启:设备访问下方地址并用账号密码登录。' + (data.needsRestart ? '(工作区目录的浏览选择需重启 dsh web 后可用)' : ''), 'success')
107
+ } catch (e) { notify(e && e.message ? e.message : String(e)) } finally { setBusy('') }
108
+ }
109
+ async function disable() {
110
+ notify('')
111
+ setBusy('stop')
112
+ try {
113
+ const data = await rpc('lan/stop', {})
114
+ setLan(data)
115
+ notify('远程访问已关闭,已登录设备全部退出。' + (data.needsRestart ? '重启 dsh web 后恢复工作区原生选择器。' : ''), 'success')
116
+ } catch (e) { notify(e && e.message ? e.message : String(e)) } finally { setBusy('') }
117
+ }
118
+ async function saveAuth() {
119
+ notify('')
120
+ if (!username.trim() || newPassword.length < 6) { notify('请填写账号和至少 6 位的新密码。'); return }
121
+ setBusy('auth')
122
+ try {
123
+ const data = await rpc('auth/set', { username: username.trim(), password: newPassword })
124
+ setLan(data); setPassword(''); setNewPassword(''); setChanging(false)
125
+ notify('账号密码已更新,所有设备需重新登录。', 'success')
126
+ } catch (e) { notify(e && e.message ? e.message : String(e)) } finally { setBusy('') }
153
127
  }
154
- async function sendNote(event) {
155
- event.preventDefault(); if (!session || !note.trim()) return
156
- setBusy('note'); setError('')
157
- try { const data = await rpc('note', Object.assign({}, session, { note })); setPair(data.pair); setNote(''); setNotice('接力备注已同步。') }
158
- catch (e) { setError(e && e.message ? e.message : String(e)) } finally { setBusy('') }
128
+ function copyLink() {
129
+ copy(lanLink).then(() => notify('地址已复制。', 'success')).catch((e) => notify(e.message))
159
130
  }
160
- async function end() {
161
- if (!session) return
162
- setBusy('end')
163
- try { await rpc('end', session); setCurrent(null); setPair(null); setNotice('手机连接已关闭。') }
164
- catch (e) { setError(e && e.message ? e.message : String(e)) } finally { setBusy('') }
131
+ function logout() {
132
+ const info = typeof window !== 'undefined' ? window.__DSH_REMOTE__ : null
133
+ if (info && info.logout) window.location.href = info.logout
165
134
  }
166
- function copyLink() { copy(mobileLink).then(() => setNotice('连接链接已复制。')).catch((e) => setError(e.message)) }
167
135
 
168
- if (!session) return <section className={'dmr ' + (compact ? 'dmr-compact' : '')}>
169
- <div className="dmr-hero"><span className="dmr-hero-icon"><RelayIcon name="phone" size={22}/></span><div><h3>把开发任务接到手机</h3><p>主 DSH 继续仅限本机;开启局域网反向代理后,通过一次性二维码安全接入。</p></div></div>
170
- <ol className="dmr-steps"><li><span>1</span><div><strong>确认 IP 与端口</strong><small>自动识别电脑局域网 IPv4,默认使用独立端口 3081。</small></div></li><li><span>2</span><div><strong>开启反向代理</strong><small>代理 HTTP 与 WebSocket,未认证设备无法访问。</small></div></li><li><span>3</span><div><strong>手机扫码接力</strong><small>扫码进入原会话,电脑端同步看到开发进度。</small></div></li></ol>
136
+ return <section className={'dmr ' + (compact ? 'dmr-compact' : '')}>
137
+ <div className="dmr-status-head"><div><span className="dmr-eyebrow"><i/> 远程访问</span><h3>{active ? '入口已开启' : '从任何设备访问这个 DSH'}</h3><p>{active ? '设备访问下方地址,用账号密码登录即可使用完整 DSH(与本机同一实例,任务进度实时一致)。' : '开启后,局域网/虚拟网设备访问网关地址并登录,即可使用与本机完全一致的 DSH。'}</p></div>{remote && <button type="button" className="dmr-secondary" onClick={logout}><RelayIcon name="close"/>退出登录</button>}</div>
138
+ <ol className="dmr-steps"><li><span>1</span><div><strong>设置账号密码</strong><small>远程登录的唯一凭据,改密后所有设备重新登录。</small></div></li><li><span>2</span><div><strong>开启入口</strong><small>网关监听 0.0.0.0(默认主端口+1,可改),主实例保持仅本机。</small></div></li><li><span>3</span><div><strong>设备访问</strong><small>扫码或输入地址登录;异地组网(Tailscale 等)用组网 IP 直连。</small></div></li></ol>
139
+ {!active ? <div className="dmr-network-grid">
140
+ <label className="dmr-field"><span>账号</span><input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" placeholder="登录账号"/></label>
141
+ <label className="dmr-field"><span>密码</span><input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" placeholder={lan && lan.accountSet ? '已设置,留空沿用' : '至少 6 位'}/></label>
142
+ </div> : null}
171
143
  <div className="dmr-network-grid">
172
- <label className="dmr-field"><span>局域网 IP</span><select value={selectedAddress} onChange={(e) => setSelectedAddress(e.target.value)} disabled={!network || !network.addresses || !network.addresses.length}>{network && network.addresses && network.addresses.length ? network.addresses.map((item) => <option key={item.interface + item.address} value={item.address}>{item.address} · {item.interface}</option>) : <option value="">{network ? '未检测到地址' : '正在检测网络…'}</option>}</select><small>手机需与电脑位于可互访的同一局域网。</small></label>
173
- <label className="dmr-field"><span>监听端口</span><input value={port} onChange={(e) => setPort(e.target.value)} inputMode="numeric" type="number" min="1024" max="65535" aria-describedby="dmr-port-help"/><small id="dmr-port-help">主服务仍为 127.0.0.1:3080;手机入口建议使用 3081。</small></label>
144
+ <label className="dmr-field"><span>监听端口</span><input value={port} onChange={(e) => setPort(e.target.value)} inputMode="numeric" type="number" min="1024" max="65535" placeholder={'默认 ' + suggestedPort} aria-describedby="dmr-lan-port-help"/><small id="dmr-lan-port-help">网关和主服务是同一台机器上的两个端口,不能相同:主实例 {lan && lan.mainPort ? lan.mainPort : '…'} 只留给本机,其他设备走网关端口登录。留空即用 {suggestedPort}(主端口+1)。</small></label>
145
+ <div className="dmr-security"><strong>安全说明</strong><p>主实例保持仅监听 127.0.0.1:远程设备只能经这个登录网关进入,不存在免登录的直连路径。账号密码只发给自己;改密后所有设备需重新登录。</p></div>
174
146
  </div>
175
- <div className="dmr-security"><strong>安全说明</strong><p>链接相当于临时登录凭据,只发给自己的手机。验证令牌 10 分钟后失效;关闭连接会立即撤销手机会话并停止端口监听。</p></div>
176
- <button type="button" className="dmr-primary" onClick={start} disabled={busy === 'start' || !selectedAddress}>{busy === 'start' ? '正在开启反向代理…' : <><RelayIcon name="link"/>开启反向代理并生成二维码</>}</button>
177
- {error && <div className="dmr-message error" role="alert">{error}</div>}{notice && <div className="dmr-message success" aria-live="polite"><RelayIcon name="check"/>{notice}</div>}
147
+ {active ? <div className="dmr-share"><div className="dmr-share-layout"><div className="dmr-qr-card">{qr ? <img src={qr} alt="远程访问地址二维码" width="220" height="220"/> : <div className="dmr-qr-loading">正在生成二维码…</div>}<strong>扫码或输入地址</strong><small>打开后输入账号密码登录</small></div><div className="dmr-share-detail"><div><span className="dmr-eyebrow"><i/> 登录网关已运行</span><h4>{lanLink}</h4></div><div className="dmr-link" title={lanLink}>{lanLink}</div><div className="dmr-share-actions"><button type="button" className="dmr-primary" onClick={copyLink} disabled={!lanLink}><RelayIcon name="copy"/>复制地址</button><button type="button" className="dmr-secondary dmr-danger" onClick={disable} disabled={busy === 'stop'}><RelayIcon name="close"/>{busy === 'stop' ? '正在关闭…' : '关闭远程访问'}</button></div><small>主实例保持仅本机(结构上不存在免登录直连);账号密码只发给自己。若开启前已有旧服务器模式(0.0.0.0)配置,会一并移除并提示重启。</small></div></div></div>
148
+ : <button type="button" className="dmr-primary" onClick={enable} disabled={busy === 'start'}>{busy === 'start' ? '正在开启…' : <><RelayIcon name="link"/>开启远程访问</>}</button>}
149
+ {active ? <div className="dmr-section"><div className="dmr-section-head"><h4>修改账号密码</h4><button type="button" className="dmr-text-button" onClick={() => setChanging((v) => !v)}>{changing ? '收起' : '修改'}</button></div>{changing ? <div className="dmr-note"><div className="dmr-network-grid"><label className="dmr-field"><span>账号</span><input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username"/></label><label className="dmr-field"><span>新密码</span><input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} autoComplete="new-password" placeholder="至少 6 位"/></label></div><button type="button" className="dmr-secondary" onClick={saveAuth} disabled={busy === 'auth' || !username.trim() || newPassword.length < 6}>{busy === 'auth' ? '保存中…' : '保存(所有设备重新登录)'}</button></div> : <p className="dmr-note">当前账号:{lan.username || '—'}。修改后所有已登录设备将被强制退出。</p>}</div> : null}
150
+ {message ? <div className={'dmr-message ' + (messageKind === 'success' ? 'success' : 'error')} role="alert">{message}</div> : null}
178
151
  </section>
152
+ }
179
153
 
180
- const onlineMobiles = pair && pair.devices ? pair.devices.filter((device) => device.role === 'mobile') : []
181
- return <section className={'dmr ' + (compact ? 'dmr-compact' : '')}>
182
- <div className="dmr-status-head"><div><span className="dmr-eyebrow"><i/> {mobile ? '已连接到电脑' : '手机连接已开启'}</span><h3>{mobile ? '可以在这里继续开发' : '等待手机接入'}</h3><p>{mobile ? '任务状态和接力备注会自动同步。继续开发请回到当前 DSH 会话输入区。' : '将下面的安全链接发到手机;连接在 ' + fmtTime(pair && pair.expiresAt) + ' 前有效。'}</p></div><button type="button" className="dmr-icon-button" onClick={end} disabled={busy === 'end'} title="关闭手机连接" aria-label="关闭手机连接"><RelayIcon name="close"/></button></div>
183
- {!mobile && <div className="dmr-share"><div className="dmr-share-layout"><div className="dmr-qr-card">{qrDataUrl ? <img src={qrDataUrl} alt="手机接力一次性连接二维码" width="260" height="260"/> : <div className="dmr-qr-loading" aria-live="polite">正在生成二维码…</div>}<strong>使用手机相机扫码</strong><small>手机与电脑需在同一局域网</small></div><div className="dmr-share-detail"><div><span className="dmr-eyebrow"><i/> 反向代理已运行</span><h4>{session.gatewayAddress}:{session.gatewayPort} → 127.0.0.1:3080</h4></div><div className="dmr-link" title={mobileLink}>{mobileLink || '局域网入口未就绪'}</div><div className="dmr-share-actions"><button type="button" className="dmr-primary" onClick={copyLink} disabled={!mobileLink}><RelayIcon name="copy"/>复制连接链接</button></div><small>二维码和链接只可成功验证一次,10 分钟后失效。如需另一台手机,请关闭后重新开启。</small></div></div></div>}
184
- <div className="dmr-overview"><div className="dmr-overview-card"><span>设备状态</span><strong>{mobile ? '手机已接力' : onlineMobiles.length ? onlineMobiles.length + ' 台手机在线' : '等待手机'}</strong><small>{mobile ? '保持此页面打开即可同步状态' : onlineMobiles.length ? '状态每 2.5 秒刷新' : '连接码仅在当前配对窗口有效'}</small></div><div className="dmr-overview-card"><span>进行中的任务</span><strong>{pair && pair.tasks ? pair.tasks.length : '—'}</strong><small>{pair && pair.tasks && pair.tasks.length ? '与 DSH 当前会话共用同一任务' : '开始任务后会自动显示在这里'}</small></div></div>
185
- <div className="dmr-section"><div className="dmr-section-head"><h4>实时任务</h4><button type="button" className="dmr-text-button" onClick={() => refresh()} disabled={busy}>刷新</button></div>{pair && pair.tasks && pair.tasks.length ? <div className="dmr-task-list">{pair.tasks.map((task) => <article key={task.sessionId} className="dmr-task"><span className={'dmr-phase ' + task.phase}>{phaseLabel(task.phase)}</span><div><strong>{task.title}</strong><small>已运行 {fmtDuration(task.elapsed)} · 最近活动 {fmtTime(task.lastActivityAt)}</small></div></article>)}</div> : <div className="dmr-empty">当前没有正在运行的任务。启动任务后,手机和电脑都会看到同一份状态。</div>}</div>
186
- <form className="dmr-note" onSubmit={sendNote}><div className="dmr-section-head"><h4>接力备注</h4>{pair && pair.noteAt ? <small>{pair.noteFrom} · {fmtTime(pair.noteAt)}</small> : null}</div>{pair && pair.note ? <blockquote>{pair.note}</blockquote> : <p>把当前进度、下一步或需要注意的事项留给另一台设备。</p>}<label><span className="sr-only">接力备注</span><textarea value={note} onChange={(e) => setNote(e.target.value)} maxLength="1200" placeholder={mobile ? '例如:我已在手机查看到构建错误,下一步请在电脑运行测试。' : '例如:已经完成接口修改,手机端继续检查页面交互。'} /></label><button className="dmr-secondary" disabled={!note.trim() || busy === 'note'}>{busy === 'note' ? '正在同步…' : <><RelayIcon name="arrow"/>同步备注</>}</button></form>
187
- {error && <div className="dmr-message error" role="alert">{error}</div>}{notice && <div className="dmr-message success" aria-live="polite"><RelayIcon name="check"/>{notice}</div>}
188
- </section>
154
+ export function MobileRelayView() {
155
+ return <RemoteCard/>
189
156
  }
190
157
 
191
158
  export function MobileRelayHomeStat() {
192
- const [summary, setSummary] = useState('可在手机继续当前任务')
159
+ const [summary, setSummary] = useState('未开启,开启后可远程登录')
193
160
  useEffect(() => {
194
- const session = readSession(); if (!session) return
195
- rpc('status', session).then((data) => { const p = data.pair; setSummary((p.tasks || []).length ? (p.tasks.length + ' 个任务同步中') : '手机已连接,等待任务') }).catch(() => setSummary('连接已失效,请重新配对'))
161
+ rpc('lan').then((data) => {
162
+ setSummary(data.gatewayActive ? '入口运行中 · 端口 ' + data.gatewayPort : data.accountSet ? '账号已设置,入口未开启' : '未开启,开启后可远程登录')
163
+ }).catch(() => {})
196
164
  }, [])
197
165
  return <span>{summary}</span>
198
166
  }
199
167
 
200
168
  export const feature = {
201
- id: 'mobile-relay', name: '手机接力', order: 80, accent: '#38bdf8',
202
- description: '安全反向代理与扫码接力,同步当前任务状态和备注', defaultEnabled: true,
169
+ id: 'mobile-relay', name: '远程访问', order: 80, accent: '#38bdf8',
170
+ description: '账号密码登录的远程入口:所有设备访问同一个 DSH,任务进度实时一致', defaultEnabled: true,
203
171
  css: `
204
- .dmr{--dmr-accent:#38bdf8;--dmr-accent-soft:color-mix(in srgb,var(--dmr-accent) 13%,transparent);display:flex;flex-direction:column;gap:16px;max-width:720px;color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5}.dmr h3,.dmr h4,.dmr p{margin:0}.dmr h3{font-size:17px;line-height:1.25;letter-spacing:-.01em}.dmr h4{font-size:13px}.dmr-hero,.dmr-status-head{display:flex;align-items:flex-start;gap:12px}.dmr-hero>div,.dmr-status-head>div{min-width:0;display:flex;flex-direction:column;gap:4px}.dmr-hero p,.dmr-status-head p,.dmr-field small,.dmr-share small,.dmr-overview-card small,.dmr-task small,.dmr-note p{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.55}.dmr-hero-icon{width:42px;height:42px;display:inline-flex;align-items:center;justify-content:center;flex:none;border-radius:14px;background:var(--dmr-accent-soft);color:var(--dmr-accent);border:1px solid color-mix(in srgb,var(--dmr-accent) 35%,var(--dsw-alias-border-l1))}.dmr-steps{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin:0;padding:0;list-style:none}.dmr-steps li{display:flex;gap:9px;padding:11px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);border-radius:12px}.dmr-steps li>span{width:21px;height:21px;display:inline-flex;align-items:center;justify-content:center;flex:none;border-radius:50%;font-size:11px;font-weight:700;color:var(--dmr-accent);background:var(--dmr-accent-soft)}.dmr-steps div{display:flex;flex-direction:column;gap:3px;min-width:0}.dmr-steps strong{font-size:12px}.dmr-steps small{font-size:11px;color:var(--dsw-alias-label-secondary);line-height:1.45}.dmr-field{display:flex;flex-direction:column;gap:6px;font-weight:600}.dmr-field input,.dmr-field select,.dmr-note textarea{box-sizing:border-box;width:100%;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:10px;padding:10px 12px;min-height:44px;font:inherit;outline:none;transition:border-color .18s ease,box-shadow .18s ease}.dmr-field input:focus,.dmr-field select:focus,.dmr-note textarea:focus{border-color:var(--dmr-accent);box-shadow:0 0 0 3px var(--dmr-accent-soft)}.dmr-field small{font-weight:400}.dmr-network-grid{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(150px,.6fr);gap:12px}.dmr-security{padding:12px 13px;border:1px solid color-mix(in srgb,#f59e0b 38%,var(--dsw-alias-border-l1));border-radius:12px;background:color-mix(in srgb,#f59e0b 8%,transparent)}.dmr-security strong{display:block;margin-bottom:4px;color:#f59e0b;font-size:12px}.dmr-security p{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.55}.dmr-primary,.dmr-secondary,.dmr-text-button,.dmr-icon-button{font:inherit;touch-action:manipulation;cursor:pointer;transition:transform .15s ease,background .18s ease,border-color .18s ease,opacity .18s ease}.dmr-primary,.dmr-secondary{min-height:44px;display:inline-flex;align-items:center;justify-content:center;gap:8px;border-radius:10px;padding:9px 13px;font-weight:600}.dmr-primary{align-self:flex-start;border:1px solid var(--dmr-accent);background:var(--dmr-accent);color:#062238}.dmr-primary:hover{filter:brightness(1.05)}.dmr-secondary{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}.dmr-secondary:hover{border-color:var(--dmr-accent);background:var(--dmr-accent-soft)}.dmr-primary:active,.dmr-secondary:active,.dmr-icon-button:active{transform:scale(.98)}.dmr-primary:disabled,.dmr-secondary:disabled,.dmr-text-button:disabled,.dmr-icon-button:disabled{cursor:not-allowed;opacity:.5}.dmr-message{display:flex;align-items:flex-start;gap:7px;padding:10px 12px;border-radius:10px;font-size:12px}.dmr-message.success{color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 10%,transparent)}.dmr-message.error{color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent)}.dmr-status-head{justify-content:space-between}.dmr-eyebrow{display:inline-flex;align-items:center;gap:6px;font-size:11px;font-weight:600;color:var(--dmr-accent)}.dmr-eyebrow i{width:7px;height:7px;border-radius:50%;background:currentColor;box-shadow:0 0 0 4px var(--dmr-accent-soft)}.dmr-icon-button{display:inline-flex;align-items:center;justify-content:center;flex:none;width:44px;height:44px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:transparent;color:var(--dsw-alias-label-secondary)}.dmr-icon-button:hover{color:var(--dsw-alias-state-error-primary);border-color:currentColor}.dmr-share{display:flex;flex-direction:column;gap:9px;padding:13px;border:1px solid color-mix(in srgb,var(--dmr-accent) 32%,var(--dsw-alias-border-l1));border-radius:13px;background:var(--dmr-accent-soft)}.dmr-share-layout{display:grid;grid-template-columns:196px minmax(0,1fr);gap:16px;align-items:center}.dmr-qr-card{box-sizing:border-box;display:flex;flex-direction:column;align-items:center;gap:5px;padding:10px;border-radius:12px;background:#fff;color:#111827;text-align:center}.dmr-qr-card img{display:block;width:176px;height:176px;max-width:100%;object-fit:contain}.dmr-qr-card strong{font-size:12px}.dmr-qr-card small{color:#4b5563;font-size:10px}.dmr-qr-loading{display:grid;place-items:center;width:176px;height:176px;color:#64748b;font-size:12px}.dmr-share-detail{min-width:0;display:flex;flex-direction:column;gap:10px}.dmr-share-detail>div:first-child{display:flex;flex-direction:column;gap:4px}.dmr-link{padding:10px 11px;border-radius:8px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all;color:var(--dsw-alias-label-secondary)}.dmr-share-actions{display:flex;gap:8px;flex-wrap:wrap}.dmr-share-actions .dmr-primary{align-self:auto}.dmr-overview{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.dmr-overview-card{display:flex;flex-direction:column;gap:3px;padding:12px;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-layer-1)}.dmr-overview-card>span{font-size:11px;color:var(--dsw-alias-label-tertiary)}.dmr-overview-card strong{font-size:16px;letter-spacing:-.01em}.dmr-section{display:flex;flex-direction:column;gap:9px}.dmr-section-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.dmr-section-head small{font-size:11px;color:var(--dsw-alias-label-tertiary)}.dmr-text-button{border:0;background:transparent;color:var(--dmr-accent);padding:8px;min-height:36px;font-weight:600}.dmr-task-list{display:flex;flex-direction:column;gap:8px}.dmr-task{display:flex;align-items:flex-start;gap:9px;padding:10px 11px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1)}.dmr-task>div{display:flex;min-width:0;flex:1;flex-direction:column;gap:2px}.dmr-task strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dmr-phase{flex:none;border-radius:999px;padding:3px 8px;font-size:11px;background:var(--dmr-accent-soft);color:var(--dmr-accent)}.dmr-phase.write{color:var(--dsw-alias-state-success-primary)}.dmr-phase.code{color:#f59e0b}.dmr-phase.search{color:#14b8a6}.dmr-empty{padding:18px 12px;text-align:center;border:1px dashed var(--dsw-alias-border-l2);border-radius:10px;color:var(--dsw-alias-label-secondary);font-size:12px}.dmr-note{display:flex;flex-direction:column;gap:9px;padding-top:2px}.dmr-note blockquote{margin:0;padding:10px 12px;border-left:3px solid var(--dmr-accent);border-radius:0 9px 9px 0;background:var(--dmr-accent-soft);white-space:pre-wrap;font-size:12px}.dmr-note textarea{min-height:88px;resize:vertical;line-height:1.5}.dmr-note .dmr-secondary{align-self:flex-start}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media (max-width:680px){.dmr{gap:14px;font-size:14px}.dmr h3{font-size:18px}.dmr-steps{grid-template-columns:1fr;gap:8px}.dmr-steps li{padding:10px}.dmr-steps small,.dmr-hero p,.dmr-status-head p,.dmr-field small,.dmr-share small,.dmr-overview-card small,.dmr-task small,.dmr-note p{font-size:12px}.dmr-network-grid{grid-template-columns:1fr;gap:10px}.dmr-field input,.dmr-field select{font-size:16px}.dmr-primary,.dmr-secondary{width:100%;font-size:14px}.dmr-share-layout{grid-template-columns:1fr;gap:12px}.dmr-qr-card{width:min(240px,100%);margin:0 auto}.dmr-qr-card img,.dmr-qr-loading{width:210px;height:210px}.dmr-share-actions{flex-direction:column}.dmr-share-actions .dmr-primary{width:100%}.dmr-overview{gap:8px}.dmr-overview-card{padding:11px}.dmr-status-head{gap:8px}.dmr-link{font-size:11px}.dmr-note textarea{font-size:16px;min-height:104px}.dmr-note .dmr-secondary{align-self:stretch}.dmr-compact .dmr-hero-icon{width:40px;height:40px;border-radius:13px}}@media (prefers-reduced-motion:reduce){.dmr-primary,.dmr-secondary,.dmr-text-button,.dmr-icon-button,.dmr-field input,.dmr-field select,.dmr-note textarea{transition:none}}
205
- `,
172
+ .dmr{--dmr-accent:var(--dk-accent,#2f6fed);--dmr-accent-soft:color-mix(in srgb,var(--dmr-accent) 13%,transparent);display:flex;flex-direction:column;gap:16px;max-width:720px;color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5}.dmr h3,.dmr h4,.dmr p{margin:0}.dmr h3{font-size:17px;line-height:1.25;letter-spacing:-.01em}.dmr h4{font-size:13px}.dmr-hero,.dmr-status-head{display:flex;align-items:flex-start;gap:12px}.dmr-hero>div,.dmr-status-head>div{min-width:0;display:flex;flex-direction:column;gap:4px}.dmr-hero p,.dmr-status-head p,.dmr-field small,.dmr-share small,.dmr-overview-card small,.dmr-task small,.dmr-note p{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.55}.dmr-hero-icon{width:42px;height:42px;display:inline-flex;align-items:center;justify-content:center;flex:none;border-radius:14px;background:var(--dmr-accent-soft);color:var(--dmr-accent);border:1px solid color-mix(in srgb,var(--dmr-accent) 35%,var(--dsw-alias-border-l1))}.dmr-steps{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin:0;padding:0;list-style:none}.dmr-steps li{display:flex;gap:9px;padding:11px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);border-radius:12px}.dmr-steps li>span{width:21px;height:21px;display:inline-flex;align-items:center;justify-content:center;flex:none;border-radius:50%;font-size:11px;font-weight:700;color:var(--dmr-accent);background:var(--dmr-accent-soft)}.dmr-steps div{display:flex;flex-direction:column;gap:3px;min-width:0}.dmr-steps strong{font-size:12px}.dmr-steps small{font-size:11px;color:var(--dsw-alias-label-secondary);line-height:1.45}.dmr-field{display:flex;flex-direction:column;gap:6px;font-weight:600}.dmr-field input,.dmr-field select,.dmr-note textarea{box-sizing:border-box;width:100%;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:10px;padding:10px 12px;min-height:44px;font:inherit;outline:none;transition:border-color .18s ease,box-shadow .18s ease}.dmr-field input:focus,.dmr-field select:focus,.dmr-note textarea:focus{border-color:var(--dmr-accent);box-shadow:0 0 0 3px var(--dmr-accent-soft)}.dmr-field small{font-weight:400}.dmr-network-grid{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(150px,.6fr);gap:12px}.dmr-security{padding:12px 13px;border:1px solid color-mix(in srgb,var(--dk-warn) 48%,var(--dsw-alias-border-l1));border-radius:12px;background:color-mix(in srgb,var(--dk-warn) 9%,transparent)}.dmr-security strong{display:block;margin-bottom:4px;color:var(--dk-warn);font-size:12px}.dmr-security p{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.55}.dmr-primary,.dmr-secondary,.dmr-text-button,.dmr-icon-button{font:inherit;touch-action:manipulation;cursor:pointer;transition:transform .15s ease,background .18s ease,border-color .18s ease,opacity .18s ease}.dmr-primary,.dmr-secondary{min-height:44px;display:inline-flex;align-items:center;justify-content:center;gap:8px;border-radius:10px;padding:9px 13px;font-weight:600}.dmr-primary{align-self:flex-start;border:1px solid var(--dmr-accent);background:var(--dmr-accent);color:#fff}.dmr-primary:hover{filter:brightness(1.05)}.dmr-secondary{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}.dmr-secondary:hover{border-color:var(--dmr-accent);background:var(--dmr-accent-soft)}.dmr-primary:active,.dmr-secondary:active,.dmr-icon-button:active{transform:scale(.98)}.dmr-primary:disabled,.dmr-secondary:disabled,.dmr-text-button:disabled,.dmr-icon-button:disabled{cursor:not-allowed;opacity:.5}.dmr-message{display:flex;align-items:flex-start;gap:7px;padding:10px 12px;border-radius:10px;font-size:12px}.dmr-message.success{color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 10%,transparent)}.dmr-message.error{color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent)}.dmr-status-head{justify-content:space-between}.dmr-eyebrow{display:inline-flex;align-items:center;gap:6px;font-size:11px;font-weight:600;color:var(--dmr-accent)}.dmr-eyebrow i{width:7px;height:7px;border-radius:50%;background:currentColor;box-shadow:0 0 0 4px var(--dmr-accent-soft)}.dmr-icon-button{display:inline-flex;align-items:center;justify-content:center;flex:none;width:44px;height:44px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:transparent;color:var(--dsw-alias-label-secondary)}.dmr-icon-button:hover{color:var(--dsw-alias-state-error-primary);border-color:currentColor}.dmr-share{display:flex;flex-direction:column;gap:9px;padding:13px;border:1px solid color-mix(in srgb,var(--dmr-accent) 32%,var(--dsw-alias-border-l1));border-radius:13px;background:var(--dmr-accent-soft)}.dmr-share-layout{display:grid;grid-template-columns:196px minmax(0,1fr);gap:16px;align-items:center}.dmr-qr-card{box-sizing:border-box;display:flex;flex-direction:column;align-items:center;gap:5px;padding:10px;border-radius:12px;background:#fff;color:#111827;text-align:center}.dmr-qr-card img{display:block;width:176px;height:176px;max-width:100%;object-fit:contain}.dmr-qr-card strong{font-size:12px}.dmr-qr-card small{color:#4b5563;font-size:10px}.dmr-qr-loading{display:grid;place-items:center;width:176px;height:176px;color:#64748b;font-size:12px}.dmr-share-detail{min-width:0;display:flex;flex-direction:column;gap:10px}.dmr-share-detail>div:first-child{display:flex;flex-direction:column;gap:4px}.dmr-link{padding:10px 11px;border-radius:8px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all;color:var(--dsw-alias-label-secondary)}.dmr-share-actions{display:flex;gap:8px;flex-wrap:wrap}.dmr-share-actions .dmr-primary{align-self:auto}.dmr-overview{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.dmr-overview-card{display:flex;flex-direction:column;gap:3px;padding:12px;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-layer-1)}.dmr-overview-card>span{font-size:11px;color:var(--dsw-alias-label-tertiary)}.dmr-overview-card strong{font-size:16px;letter-spacing:-.01em}.dmr-section{display:flex;flex-direction:column;gap:9px}.dmr-section-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.dmr-section-head small{font-size:11px;color:var(--dsw-alias-label-tertiary)}.dmr-text-button{border:0;background:transparent;color:var(--dmr-accent);padding:8px;min-height:36px;font-weight:600}.dmr-task-list{display:flex;flex-direction:column;gap:8px}.dmr-task{display:flex;align-items:flex-start;gap:9px;padding:10px 11px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1)}.dmr-task>div{display:flex;min-width:0;flex:1;flex-direction:column;gap:2px}.dmr-task strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dmr-phase{flex:none;border-radius:999px;padding:3px 8px;font-size:11px;background:var(--dmr-accent-soft);color:var(--dmr-accent)}.dmr-phase.write{color:var(--dsw-alias-state-success-primary)}.dmr-phase.code{color:var(--dk-warn)}.dmr-phase.search{color:#0d9488}.dmr-empty{padding:18px 12px;text-align:center;border:1px dashed var(--dsw-alias-border-l2);border-radius:10px;color:var(--dsw-alias-label-secondary);font-size:12px}.dmr-note{display:flex;flex-direction:column;gap:9px;padding-top:2px}.dmr-note blockquote{margin:0;padding:10px 12px;border-left:3px solid var(--dmr-accent);border-radius:0 9px 9px 0;background:var(--dmr-accent-soft);white-space:pre-wrap;font-size:12px}.dmr-note textarea{min-height:88px;resize:vertical;line-height:1.5}.dmr-note .dmr-secondary{align-self:flex-start}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media (max-width:680px){.dmr{gap:14px;font-size:14px}.dmr h3{font-size:18px}.dmr-steps{grid-template-columns:1fr;gap:8px}.dmr-steps li{padding:10px}.dmr-steps small,.dmr-hero p,.dmr-status-head p,.dmr-field small,.dmr-share small,.dmr-overview-card small,.dmr-task small,.dmr-note p{font-size:12px}.dmr-network-grid{grid-template-columns:1fr;gap:10px}.dmr-field input,.dmr-field select{font-size:16px}.dmr-primary,.dmr-secondary{width:100%;font-size:14px}.dmr-share-layout{grid-template-columns:1fr;gap:12px}.dmr-qr-card{width:min(240px,100%);margin:0 auto}.dmr-qr-card img,.dmr-qr-loading{width:210px;height:210px}.dmr-share-actions{flex-direction:column}.dmr-share-actions .dmr-primary{width:100%}.dmr-overview{gap:8px}.dmr-overview-card{padding:11px}.dmr-status-head{gap:8px}.dmr-link{font-size:11px}.dmr-note textarea{font-size:16px;min-height:104px}.dmr-note .dmr-secondary{align-self:stretch}.dmr-compact .dmr-hero-icon{width:40px;height:40px;border-radius:13px}}@media (prefers-reduced-motion:reduce){.dmr-primary,.dmr-secondary,.dmr-text-button,.dmr-icon-button,.dmr-field input,.dmr-field select,.dmr-note textarea{transition:none}}.dmr-lan{flex:none;margin-top:6px;padding-top:16px;border-top:1px solid var(--dsw-alias-border-l1)}.dmr-lan-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.dmr-lan-title{min-width:0;display:flex;flex-direction:column;gap:4px}.dmr-lan-title p{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.55}.dmr-lan-badge{flex:none;display:inline-flex;align-items:center;gap:6px;border-radius:999px;padding:5px 10px;font-size:11px;font-weight:700;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-tertiary)}.dmr-lan-badge.on{border-color:color-mix(in srgb,var(--dmr-accent) 45%,transparent);background:var(--dmr-accent-soft);color:var(--dmr-accent)}.dmr-lan-active,.dmr-lan-idle{display:flex;flex-direction:column;gap:12px}.dmr-lan-active .dmr-share-detail small strong{color:var(--dsw-alias-state-error-primary)}.dmr-danger{border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 45%,var(--dsw-alias-border-l2));color:var(--dsw-alias-state-error-primary)}.dmr-danger:hover{border-color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent)}`,
206
173
  View: MobileRelayView, HomeStat: MobileRelayHomeStat,
207
174
  }
@@ -441,11 +441,11 @@ export const feature = {
441
441
  description: "编辑各 Provider 模型目录:输入类型(文本/图片 + 标注)与思考强度档位;写回官方配置热生效,会话模型选择器即时可选",
442
442
  css: [
443
443
  ".dkm-note{color:var(--dsw-alias-label-secondary);font-size:12px;}",
444
- ".dkm-warn{color:var(--dsw-alias-state-warning-primary);}",
444
+ ".dkm-warn{color:var(--dk-warn);}",
445
445
  ".dkm-error{color:var(--dsw-alias-state-error-primary);font-size:12px;}",
446
446
  ".dkm-chips{display:flex;flex-wrap:wrap;gap:6px;}",
447
447
  ".dkm-chip{cursor:pointer;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-secondary);border-radius:999px;padding:3px 12px;font-family:inherit;font-size:12px;}",
448
- ".dkm-chip.on{color:var(--dsw-alias-accent,#4d9fff);border-color:currentColor;}",
448
+ ".dkm-chip.on{color:var(--dk-accent);border-color:currentColor;}",
449
449
  ".dkm-prov{border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);border-radius:10px;padding:10px 12px;display:flex;flex-direction:column;gap:8px;}",
450
450
  ".dkm-prov-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}",
451
451
  ".dkm-name{font-weight:600;}",
@@ -461,7 +461,7 @@ export const feature = {
461
461
  ".dkm-input{box-sizing:border-box;width:100%;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:4px 8px;font-family:inherit;font-size:12px;}",
462
462
  ".dkm-checks{display:flex;align-items:center;gap:12px;flex-wrap:wrap;}",
463
463
  ".dkm-check{display:inline-flex;align-items:center;gap:5px;cursor:pointer;color:var(--dsw-alias-label-secondary);font-size:12px;}",
464
- ".dkm-check input{accent-color:var(--dsw-alias-accent,#4d9fff);margin:0;}",
464
+ ".dkm-check input{accent-color:var(--dk-accent);margin:0;}",
465
465
  ".dkm-tag{color:var(--dsw-alias-label-tertiary);}",
466
466
  ".dkm-efforts{display:flex;align-items:center;gap:10px;flex-wrap:wrap;}",
467
467
  ".dkm-del{margin-left:auto;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-tertiary);border-radius:8px;padding:2px 10px;font-family:inherit;font-size:12px;flex:none;}",
@@ -469,7 +469,7 @@ export const feature = {
469
469
  ".dkm-add{cursor:pointer;border:1px dashed var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-secondary);border-radius:10px;padding:6px 14px;font-family:inherit;font-size:12px;align-self:flex-start;}",
470
470
  ".dkm-add:hover{color:var(--dsw-alias-label-primary);}",
471
471
  ".dkm-savebar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;border-top:1px solid var(--dsw-alias-border-l1);padding-top:10px;}",
472
- ".dkm-save{cursor:pointer;border:1px solid var(--dsw-alias-accent,#4d9fff);color:var(--dsw-alias-accent,#4d9fff);background:transparent;border-radius:8px;padding:4px 14px;font-family:inherit;font-size:12px;flex:none;}",
472
+ ".dkm-save{cursor:pointer;border:1px solid var(--dk-accent);color:var(--dk-accent);background:transparent;border-radius:8px;padding:4px 14px;font-family:inherit;font-size:12px;flex:none;}",
473
473
  ".dkm-save:hover{background:var(--dsw-alias-interactive-bg-hover);}",
474
474
  ".dkm-save[disabled]{opacity:.5;cursor:default;}",
475
475
  ".dkm-msg{font-size:12px;color:var(--dsw-alias-label-secondary);}",
@@ -106,7 +106,7 @@ const css = `
106
106
  .dtok-btn{cursor:pointer;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);padding:4px 12px;font-size:12px;font-family:inherit;flex:none;}
107
107
  .dtok-btn:hover{background:var(--dsw-alias-interactive-bg-hover);}
108
108
  .dtok-btn[disabled]{opacity:.5;cursor:default;}
109
- .dtok-btn.primary{background:var(--dsw-alias-accent,#2f6fed);border-color:var(--dsw-alias-accent,#2f6fed);color:#fff;}
109
+ .dtok-btn.primary{background:var(--dk-accent);border-color:var(--dk-accent);color:#fff;}
110
110
  .dtok-btn.primary:hover{filter:brightness(1.1);}
111
111
  .dtok-body{display:flex;flex-direction:column;gap:8px;min-width:0;}
112
112
  .dtok-cards{display:flex;gap:8px;flex-wrap:wrap;}
@@ -118,22 +118,22 @@ const css = `
118
118
  .dtok-table{border-collapse:collapse;width:100%;font-size:12px;white-space:nowrap;color:var(--dsw-alias-label-primary);}
119
119
  .dtok-table th{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);text-align:left;padding:7px 10px;position:sticky;top:0;z-index:1;border-bottom:1px solid var(--dsw-alias-border-l1);font-weight:600;user-select:none;cursor:pointer;}
120
120
  .dtok-table th:hover{color:var(--dsw-alias-label-primary);}
121
- .dtok-table td{padding:6px 10px;border-bottom:1px solid rgba(122,132,152,0.12);}
122
- .dtok-table tr:hover td{background:rgba(80,110,180,0.10);}
121
+ .dtok-table td{padding:6px 10px;border-bottom:1px solid var(--dk-tdim);}
122
+ .dtok-table tr:hover td{background:var(--dk-hover-tint);}
123
123
  .dtok-empty{text-align:center;color:var(--dsw-alias-label-secondary);padding:32px 0;font-size:13px;}
124
- .dtok-sid{color:var(--dsw-alias-accent,#7ab8ff);cursor:pointer;text-decoration:underline dotted;}
124
+ .dtok-sid{color:var(--dk-accent);cursor:pointer;text-decoration:underline dotted;}
125
125
  .dtok-sid:hover{filter:brightness(1.25);}
126
126
  .dtok-code{font-weight:700;font-family:ui-monospace,monospace;}
127
127
  .dtok-code.ok{color:var(--dsw-alias-state-success-primary,#4ade80);}
128
- .dtok-code.warn{color:var(--dsw-alias-state-warning-primary,#fbbf24);}
128
+ .dtok-code.warn{color:var(--dk-warn);}
129
129
  .dtok-code.err{color:var(--dsw-alias-state-error-primary,#f87171);}
130
130
  .dtok-code.pend{color:var(--dsw-alias-label-tertiary,#94a3b8);}
131
- .dtok-detail-link{color:var(--dsw-alias-accent,#7ab8ff);cursor:pointer;font-size:11px;}
131
+ .dtok-detail-link{color:var(--dk-accent);cursor:pointer;font-size:11px;}
132
132
  .dtok-detail-link:hover{text-decoration:underline;}
133
133
  .dtok-detail{position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;}
134
134
  .dtok-detail-card{background:var(--dsw-alias-bg-layer-2,#1c212b);border:1px solid var(--dsw-alias-border-l2,#3a4150);border-radius:12px;padding:20px 24px;max-width:640px;width:92%;max-height:80vh;overflow:auto;color:var(--dsw-alias-label-primary,#e8eaf0);}
135
135
  .dtok-detail-card h3{margin:0 0 12px;font-size:15px;}
136
- .dtok-detail-row{display:flex;gap:8px;padding:4px 0;font-size:12px;border-bottom:1px solid rgba(122,132,152,0.1);}
136
+ .dtok-detail-row{display:flex;gap:8px;padding:4px 0;font-size:12px;border-bottom:1px solid var(--dk-tdim);}
137
137
  .dtok-detail-row .k{color:var(--dsw-alias-label-secondary,#9aa3b5);min-width:110px;flex-shrink:0;}
138
138
  .dtok-detail-row .v{word-break:break-all;}
139
139
  .dtok-sort-mark{opacity:0.6;margin-left:3px;}
@@ -542,7 +542,7 @@ export function TokenLogChip(props) {
542
542
  return (
543
543
  <button type="button" className={"dockchip" + (snap.err && !t ? " err" : "")} title={title} aria-label="会话用量"
544
544
  onClick={() => openPanel("tokenlog", { sessionId: sid })}>
545
- <span className="dockchip-dot" style={{ background: "#fbbf24" }} />
545
+ <span className="dockchip-dot" style={{ background: "var(--dk-warn)" }} />
546
546
  <span>{label}</span>
547
547
  </button>
548
548
  );
package/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // dsh-dock · Host 半部(Node 侧入口)· v0.4.0 模块化架构
1
+ // dsh-dock · Host 半部(Node 侧入口)· v0.9.0 模块化架构
2
2
  //
3
3
  // 功能坞 = hub + 一个个独立功能模块(像 dsh 本身由小包组成):
4
4
  // - 每个功能是一个 features/<id>/ 目录:host.js(宿主半部)+ view.js(x)(客户端视图),
@@ -13,12 +13,14 @@
13
13
  // - balance 模型余额(v0.2.0):各 Provider 账户余额/配额
14
14
  // - tokenlog 用量记录(v0.4.0):LLM 调用记账与统计(移植自 @wycto/dsh-token-usage)
15
15
  // - animation 任务动画(v0.5.0):会话任务追踪 + 动效/通知配置持久化(参照 @wycto/dsh-task-pulse)
16
+ // - mobile-relay 手机接力(未发布):扫码反向代理接力 + 局域网电脑直连(0.0.0.0)
16
17
  import { DOCK_NS, DockConfig } from './src/host-core.js'
17
18
  import { feature as fModels } from './features/modelconfig/host.js'
18
19
  import { feature as fVisionProxy } from './features/visionproxy/host.js'
19
20
  import { feature as fBalance } from './features/balance/host.js'
20
21
  import { feature as fTokenlog } from './features/tokenlog/host.js'
21
22
  import { feature as fAnimation } from './features/animation/host.js'
23
+ import { feature as fMobileRelay } from './features/mobile-relay/host.js'
22
24
 
23
25
  export const name = 'dsh-dock'
24
26
 
@@ -28,6 +30,22 @@ export const inject = ['webServer', 'llm', 'settings']
28
30
  /** 测试钩子:清空识别缓存(冒烟在用例间隔离;实现在 visionproxy 模块)。 */
29
31
  export { __clearDescribeCache } from './features/visionproxy/host.js'
30
32
 
33
+ // 宿主进程健壮性护栏:Node 对已半脱离 HTTP 管理的 socket 上到达的传输层重置
34
+ // 没有兜底监听——手机锁屏/切后台、页面跳转中断请求等日常 RST 会以未捕获异常
35
+ // 打崩整个 dsh web(实测:Error: read ECONNRESET, Emitted 'error' on Socket)。
36
+ // 这里只吞 ECONNRESET/EPIPE 这类纯传输层错误(连接本身已死,吞掉等价于 nginx
37
+ // 对客户端断连的处理),其余未捕获异常照常抛出。
38
+ if (typeof process !== 'undefined' && typeof process.on === 'function' && !process.listenerCount('uncaughtException')) {
39
+ process.on('uncaughtException', (err) => {
40
+ const code = err && err.code
41
+ if (code === 'ECONNRESET' || code === 'EPIPE') {
42
+ console.error('[dsh-dock] swallowed transport error:', code, (err && err.message) || err)
43
+ return
44
+ }
45
+ throw err
46
+ })
47
+ }
48
+
31
49
  export function apply(ctx) {
32
50
  // ---- 功能注册表(Host 侧):每个条目来自对应功能模块的 feature 描述符 ----
33
51
  // defaultEnabled:与 Client 半部保持一致。已接入的功能默认打开,
@@ -38,6 +56,7 @@ export function apply(ctx) {
38
56
  fBalance,
39
57
  fTokenlog,
40
58
  fAnimation,
59
+ fMobileRelay,
41
60
  ]
42
61
 
43
62
  const state = new Map()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-dock",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "DeepSeek Harness 功能坞插件(dsh-dock):模块化功能中枢——每个功能一个 features/ 模块(可单独提取成包发布,又可经 dockBridge 装回面板)。含用量记录(LLM 调用记账/费用统计)、模型设置、图片理解代理、模型余额、任务动画(8 种氛围动效 + 桌面伙伴 3D 场景、完成通知与提示音、钉钉/飞书推送,开关独立、配置持久化);侧栏入口 + 可拖拽弹窗(最大化/最小化/缩放)。",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -49,6 +49,7 @@
49
49
  "access": "public"
50
50
  },
51
51
  "dependencies": {
52
+ "js-yaml": "^4.1.0",
52
53
  "@deepseek-ai/dsh-credentials": "^0.1.0-rc.6",
53
54
  "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
54
55
  "@deepseek-ai/dsh-settings": "^0.1.1-rc.2",