dsh-dock 0.6.0 → 0.7.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.
@@ -0,0 +1,207 @@
1
+ // dsh-dock · 功能模块【手机接力】· 客户端视图
2
+ // 手机和电脑均轮询同一个短时配对记录;业务会话继续由 DSH 原生会话服务负责同步。
3
+ import { useCallback, useEffect, useMemo, useState } from 'react'
4
+ import QRCode from 'qrcode'
5
+
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
+ function rpc(method, payload) {
11
+ return fetch('/dsh-dock/mobile-relay/' + method, {
12
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload || {}),
13
+ }).then(async (res) => {
14
+ const body = await res.json().catch(() => ({}))
15
+ if (res.ok && body && body.ok) return body.data
16
+ if (res.status === 404 || res.status === 405) throw new Error('宿主进程仍是旧版本,请重启 dsh web 后再开启局域网连接')
17
+ throw new Error((body && body.error && body.error.message) || ('请求失败(' + res.status + ')'))
18
+ })
19
+ }
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
+ function copy(value) {
41
+ if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(value)
42
+ return Promise.reject(new Error('当前浏览器不支持复制,请长按或手动复制'))
43
+ }
44
+
45
+ function RelayIcon({ name, size = 18 }) {
46
+ 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
+ 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
+ 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
+ if (name === 'close') return <svg {...common}><path d="m6 6 12 12M18 6 6 18"/></svg>
53
+ return <svg {...common}><circle cx="12" cy="12" r="8"/><path d="M12 8v4l2.5 2"/></svg>
54
+ }
55
+
56
+ function useCompact() {
57
+ const [compact, setCompact] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 680px)').matches)
58
+ useEffect(() => {
59
+ const media = window.matchMedia('(max-width: 680px)')
60
+ const update = () => setCompact(media.matches); update(); media.addEventListener('change', update)
61
+ return () => media.removeEventListener('change', update)
62
+ }, [])
63
+ return compact
64
+ }
65
+
66
+ export function MobileRelayView() {
67
+ 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('')
75
+ const [busy, setBusy] = useState('')
76
+ const [notice, setNotice] = useState('')
77
+ const [error, setError] = useState('')
78
+ const mobile = session && session.role === 'mobile'
79
+
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])
101
+
102
+ useEffect(() => { if (session) refresh(session) }, [session, refresh])
103
+ useEffect(() => {
104
+ if (!session) return
105
+ const timer = setInterval(() => refresh(session), 2500)
106
+ return () => clearInterval(timer)
107
+ }, [session, refresh])
108
+ 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])
122
+
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])
127
+ 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])
137
+
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 }
143
+ setBusy('start')
144
+ 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('') }
153
+ }
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('') }
159
+ }
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('') }
165
+ }
166
+ function copyLink() { copy(mobileLink).then(() => setNotice('连接链接已复制。')).catch((e) => setError(e.message)) }
167
+
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>
171
+ <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>
174
+ </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>}
178
+ </section>
179
+
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>
189
+ }
190
+
191
+ export function MobileRelayHomeStat() {
192
+ const [summary, setSummary] = useState('可在手机继续当前任务')
193
+ 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('连接已失效,请重新配对'))
196
+ }, [])
197
+ return <span>{summary}</span>
198
+ }
199
+
200
+ export const feature = {
201
+ id: 'mobile-relay', name: '手机接力', order: 80, accent: '#38bdf8',
202
+ description: '安全反向代理与扫码接力,同步当前任务状态和备注', defaultEnabled: true,
203
+ 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
+ `,
206
+ View: MobileRelayView, HomeStat: MobileRelayHomeStat,
207
+ }
package/index.js CHANGED
@@ -19,6 +19,7 @@ import { feature as fVisionProxy } from './features/visionproxy/host.js'
19
19
  import { feature as fBalance } from './features/balance/host.js'
20
20
  import { feature as fTokenlog } from './features/tokenlog/host.js'
21
21
  import { feature as fAnimation } from './features/animation/host.js'
22
+ import { feature as fMobileRelay } from './features/mobile-relay/host.js'
22
23
 
23
24
  export const name = 'dsh-dock'
24
25
 
@@ -38,6 +39,7 @@ export function apply(ctx) {
38
39
  fBalance,
39
40
  fTokenlog,
40
41
  fAnimation,
42
+ fMobileRelay,
41
43
  ]
42
44
 
43
45
  const state = new Map()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-dock",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "DeepSeek Harness 功能坞插件(dsh-dock):模块化功能中枢——每个功能一个 features/ 模块(可单独提取成包发布,又可经 dockBridge 装回面板)。含用量记录(LLM 调用记账/费用统计)、模型设置、图片理解代理、模型余额、任务动画(8 种氛围动效 + 桌面伙伴 3D 场景、完成通知与提示音、钉钉/飞书推送,开关独立、配置持久化);侧栏入口 + 可拖拽弹窗(最大化/最小化/缩放)。",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -52,6 +52,7 @@
52
52
  "@deepseek-ai/dsh-credentials": "^0.1.0-rc.6",
53
53
  "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
54
54
  "@deepseek-ai/dsh-settings": "^0.1.1-rc.2",
55
- "@deepseek-ai/schemastery": "^3.18.1"
55
+ "@deepseek-ai/schemastery": "^3.18.1",
56
+ "qrcode": "^1.5.4"
56
57
  }
57
58
  }
Binary file
Binary file
package/src/client.jsx CHANGED
@@ -24,12 +24,13 @@ import { feature as fHeartbeat } from "../features/heartbeat/view.js";
24
24
  import { feature as fTheme } from "../features/theme/view.js";
25
25
  import { feature as fBalance } from "../features/balance/view.js";
26
26
  import { feature as fAnimation } from "../features/animation/view.jsx";
27
+ import { feature as fMobileRelay } from "../features/mobile-relay/view.jsx";
27
28
 
28
29
  const name = "dsh-dock";
29
- const DOCK_VERSION = "0.5.0";
30
+ const DOCK_VERSION = "0.7.0";
30
31
 
31
32
  // ---- 内置功能注册表:新功能 = features/<id>/ 加模块 + 这里 import 一行 ----
32
- const BUILTIN_FEATURES = [fTokenlog, fModelconfig, fHeartbeat, fTheme, fBalance, fAnimation];
33
+ const BUILTIN_FEATURES = [fTokenlog, fModelconfig, fHeartbeat, fTheme, fBalance, fAnimation, fMobileRelay];
33
34
  // 规划占位(路线图):接入后移除并建 features/<id>/ 模块
34
35
  const PLANNED_FEATURES = [];
35
36
  const PLANNED_NOTES = {};
@@ -172,7 +173,11 @@ const SHELL_CSS = [
172
173
  // 弹层页脚:文字标签 + 开关 成组
173
174
  ".dockm-foot-sw{margin-left:auto;flex:none;display:inline-flex;align-items:center;gap:8px;}",
174
175
  ".dockm-foot-swlabel{font-size:11px;color:var(--dsw-alias-label-tertiary);}",
175
- ".dockm-foot-swlabel.on{color:var(--dsw-alias-state-success-primary);}",
176
+ ".dockm-foot-swlabel.on{color:var(--dsw-alias-state-success-primary);}",
177
+ // 窄屏:功能坞是一级工作台而不是被压扁的桌面弹窗。全屏承接安全区,导航改横向,
178
+ // 禁用桌面拖拽/缩放,所有导航和关闭动作保持至少 44px 的可点击面积。
179
+ "@media (max-width:680px){.dockm-backdrop{align-items:stretch;justify-content:stretch;background:var(--dsw-alias-bg-layer-2);backdrop-filter:none}.dockm-dialog,.dockm-dialog.dockm-max{position:fixed!important;left:0!important;top:0!important;right:auto!important;bottom:auto!important;width:100dvw!important;height:100dvh!important;min-height:100dvh;border:0;border-radius:0;box-shadow:none}.dockm-dialog.dockm-min .dockm-body{display:flex}.dockm-head{min-height:60px;box-sizing:border-box;padding:calc(env(safe-area-inset-top) + 8px) 12px 8px;cursor:default;touch-action:manipulation}.dockm-sub,.dockm-win,.dockm-resize{display:none}.dockm-title{font-size:16px}.dockm-close{width:44px;height:44px;font-size:17px}.dockm-body{flex-direction:column;min-height:0}.dockm-nav{box-sizing:border-box;width:auto;max-width:100%;min-height:56px;flex-direction:row;gap:6px;padding:6px 10px;overflow-x:auto;overflow-y:hidden;border-right:0;border-bottom:1px solid var(--dsw-alias-border-l1);scrollbar-width:none;overscroll-behavior-x:contain}.dockm-nav::-webkit-scrollbar{display:none}.dockm-nav-item{flex:none;min-height:44px;padding:0 12px;font-size:13px;touch-action:manipulation}.dockm-nav-item .dockm-badge{display:none}.dockm-content{padding:16px max(16px,env(safe-area-inset-right)) calc(20px + env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left));gap:14px;overscroll-behavior:contain}.dockm-content-head{gap:5px}.dockm-name{font-size:16px}.dockm-desc{font-size:13px;line-height:1.55}.dockm-foot{padding-top:12px;font-size:11px}.dockm-foot-sw{width:100%;margin-left:0;justify-content:space-between}.dock-sw{width:44px;height:26px}.dock-sw::after{top:3px;left:3px;width:18px;height:18px}.dock-sw.on::after{transform:translateX(18px)}.dockh-grid{grid-template-columns:1fr;gap:10px}.dockh-card{padding:12px}.dockh-desc{font-size:13px}.dockh-go{font-size:12px}}",
180
+ "@media (prefers-reduced-motion:reduce){.dockm-backdrop,.dockm-dialog{animation:none}.dockm-nav-item,.dockm-close,.dockm-win,.dock-sw{transition:none}}",
176
181
  ".dockh-desc{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5;}",
177
182
  ".dockh-stat{color:var(--dsw-alias-label-tertiary);font-size:12px;border-top:1px solid var(--dsw-alias-border-l1);padding-top:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
178
183
  ".dockh-foot{display:flex;align-items:center;gap:8px;}",
@@ -611,7 +616,22 @@ export function apply(ctx) {
611
616
  slots.inject("conversation.input.left", () => slots.register(
612
617
  { name: "conversation.input.left", id: "dsh-dock-chips", order: 10, label: "功能坞" },
613
618
  (zone) => react.createElement(DockChips, Object.assign({}, zone, { ctx: ctxRef.current }))));
619
+ // 手机接力链接使用 fragment,避免配对码进入服务器日志或 Referer。插件加载后立即消费并
620
+ // 清掉 fragment,再自动打开对应功能;DSH 会话本身仍由宿主原生会话服务继续承接。
621
+ try {
622
+ if (typeof window !== "undefined") {
623
+ const match = window.location.hash.match(/(?:^#|&)dsh-mobile-relay=([^&]+)/);
624
+ if (match) {
625
+ const launch = decodeURIComponent(match[1]);
626
+ if (/^[A-Za-z0-9_-]+\.[A-F0-9]{10}$/i.test(launch)) {
627
+ sessionStorage.setItem("dsh-dock/mobile-relay/launch/v1", launch);
628
+ window.history.replaceState(null, "", window.location.pathname + window.location.search);
629
+ setTimeout(() => openPanel("mobile-relay"), 0);
630
+ }
631
+ }
632
+ }
633
+ } catch { /* 私密模式禁用 storage/history 时保持手动入口可用 */ }
614
634
  }
615
635
 
616
636
  export const inject = ["timer"];
617
- export { dockBridge };
637
+ export { dockBridge };
Binary file