dsh-dock 0.12.0 → 0.13.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.
@@ -4,7 +4,7 @@
4
4
  // 访问地址二维码;远程设备上经 window.__DSH_REMOTE__ 显示退出登录。
5
5
  import { useCallback, useEffect, useMemo, useState } from 'react'
6
6
  import QRCode from 'qrcode'
7
- import { openPanel, setPanelOpen, subscribePanel, panelNav } from '../../src/shared.js'
7
+ import { openPanel, subscribePanel, panelNav } from '../../src/shared.js'
8
8
 
9
9
  function rpc(method, payload) {
10
10
  return fetch('/dsh-dock/mobile-relay/' + method, {
@@ -145,7 +145,7 @@ function RemoteCard() {
145
145
  <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>
146
146
  <div className="dmr-security"><strong>安全说明</strong><p>主实例保持仅监听 127.0.0.1:远程设备只能经这个登录网关进入,不存在免登录的直连路径。账号密码只发给自己;改密后所有设备需重新登录。</p></div>
147
147
  </div>
148
- {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
+ {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>已登录设备:{typeof lan.devices === 'number' ? lan.devices : 0} 台(改密或关闭入口即全部下线)</small><small>主实例保持仅本机(结构上不存在免登录直连);账号密码只发给自己。若开启前已有旧服务器模式(0.0.0.0)配置,会一并移除并提示重启。</small></div></div></div>
149
149
  : <button type="button" className="dmr-primary" onClick={enable} disabled={busy === 'start'}>{busy === 'start' ? '正在开启…' : <><RelayIcon name="link"/>开启远程访问</>}</button>}
150
150
  {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}
151
151
  {message ? <div className={'dmr-message ' + (messageKind === 'success' ? 'success' : 'error')} role="alert">{message}</div> : null}
@@ -157,66 +157,110 @@ export function MobileRelayView() {
157
157
  }
158
158
 
159
159
  /**
160
- * 手机端会话抽屉(窄屏 ≤700px):悬浮按钮拉出侧边栏 + 遮罩点击收起。
160
+ * 手机端会话抽屉(窄屏 ≤700px):底部 Tab 栏「会话」页签拉出侧边栏 + 遮罩点击收起。
161
161
  *
162
162
  * 直接操作 document.body 下的 DOM(渲染返回 null)——若作为 overlayLayer 的子元素,
163
- * 会落入 overlayLayer(z 90)的堆叠上下文,遮罩压不住侧栏抽屉(z 80)、按钮也盖不住内容;
164
- * 挂到 body 下 z 序才是设计值(按钮 70 < 遮罩 75 < 侧栏 80 < overlayLayer 90)。
163
+ * 会落入 overlayLayer(z 90)的堆叠上下文,遮罩压不住侧栏抽屉(z 80)、Tab 栏也盖不住内容;
164
+ * 挂到 body 下 z 序才是设计值(遮罩 75 < 侧栏 80 < overlayLayer 90)。
165
+ * 左缘浮动「抽屉把手」已移除(与底部 Tab 栏「会话」页签功能重复,还悬浮在内容上)。
165
166
  *
166
167
  * 与宿主 tapIndex 注入版(features/mobile-relay/host.js,重启 dsh web 后才生效)互斥:
167
- * window.__dshDockMobileDrawer 先到先得——宿主脚本在 <head> 先跑,重启后总是宿主版接管,
168
- * 本组件自动让位;本组件的价值是 client bundle 按内容 hash 服务、刷新页面即生效,无需重启。
169
- * 与旧版宿主行为脚本(无抽屉按钮、只有自动收起)并存是安全的:收起链路同一事件内
170
- * 第二次 collapse() 因 aria-label 已翻转而自然空转。
168
+ * window.__dshDockMobileDrawer 先到先得——宿主脚本在 <head> 先跑并置 'host',本组件
169
+ * 见旗标即整体让位(遮罩/底部 Tab/收起逻辑全归宿主);宿主未就位(旧版无注入、
170
+ * 或刷新早于重启)时本组件自建全套。本组件的价值是 client bundle 按内容 hash 服务、
171
+ * 刷新页面即生效,无需重启。
172
+ * ⚠️ 两个版本绝不能各建一套:曾出现双底部 Tab 栏(两套按钮叠在一起)与客户端 CSS
173
+ * 覆盖宿主 place() 定位的回归。
174
+ * 与旧版宿主行为脚本并存是安全的:收起链路同一事件内第二次 collapse() 因 aria-label
175
+ * 已翻转而自然空转。
171
176
  */
172
177
  export function MobileRelayOverlay() {
178
+ // —— 预览版说明自动确认(独立 effect,与下方抽屉 chrome 的宿主互斥无关)——
179
+ // 根因(dsh 0.2.0-rc.2 ui-settings-models/src/client/welcome-store.ts,文件头注释原文):
180
+ // 回环浏览器的确认走宿主持久化,一次确认永久生效;**远程(非回环)浏览器的设置 scope
181
+ // 是 memory 模式,确认只存页面进程内存,刷新即丢**——所以从局域网/组网 IP 打开时
182
+ // 「预览版说明」每次刷新都重弹,profile patch 里钉 welcomeNoticeVersion 也救不了
183
+ // (memory 分支根本不读持久值)。这里在远程访问场景下替用户点一次「继续」:
184
+ // 每次页面加载最多点一次,回环地址不介入(让 dsh 原生的一次性持久确认自然工作)。
185
+ useEffect(() => {
186
+ if (typeof window === 'undefined' || typeof document === 'undefined') return undefined
187
+ const host = (window.location && window.location.hostname) || ''
188
+ const loopback = host === '127.0.0.1' || host === 'localhost' || host === '::1' || host.endsWith('.localhost')
189
+ if (loopback) return undefined
190
+ if (typeof MutationObserver === 'undefined') return undefined
191
+ let done = false
192
+ const tryDismiss = () => {
193
+ if (done) return
194
+ const dialogs = document.querySelectorAll('[role="dialog"]')
195
+ for (const dialog of dialogs) {
196
+ const text = (dialog.textContent || '')
197
+ if (!/预览版说明|Preview Notice/.test(text)) continue
198
+ const btn = [...dialog.querySelectorAll('button')].find((b) => {
199
+ const t = (b.textContent || '').trim()
200
+ return t === '继续' || t === 'Continue'
201
+ })
202
+ if (btn) {
203
+ done = true
204
+ btn.click()
205
+ observer.disconnect()
206
+ }
207
+ break
208
+ }
209
+ }
210
+ const observer = new MutationObserver(tryDismiss)
211
+ observer.observe(document.body, { childList: true, subtree: true })
212
+ tryDismiss()
213
+ // 兜底停止:说明若 60 秒内没出现(未来版本改版/被官方移除),观察器自动退场
214
+ const stopTimer = setTimeout(() => observer.disconnect(), 60000)
215
+ return () => { clearTimeout(stopTimer); observer.disconnect() }
216
+ }, [])
217
+
173
218
  useEffect(() => {
174
219
  if (typeof window === 'undefined' || typeof document === 'undefined') return undefined
175
- // 宿主版(<head> 注入,重启后接管)就位时,抽屉把手/遮罩/收起逻辑由宿主负责,
176
- // 本组件只补宿主旧版没有的底部 Tab 栏;宿主未就位(旧版无抽屉 DOM)时全套自建。
177
- const hostPresent = !!(window.__dshDockMobileDrawer && document.querySelector('.dsh-mobile-drawer-btn'))
178
220
  const mq = window.matchMedia ? window.matchMedia('(max-width:700px)') : null
179
221
  if (!mq) return undefined
180
- if (!hostPresent) window.__dshDockMobileDrawer = 'client'
222
+ // 宿主版接管中:整套 chrome 由宿主负责,本组件零副作用退出。
223
+ if (window.__dshDockMobileDrawer === 'host') return undefined
224
+ window.__dshDockMobileDrawer = 'client'
181
225
 
182
226
  const narrow = () => mq.matches
183
227
  const frameEl = () => document.querySelector('[class$="frame"]')
184
228
  const sidebarCollapsed = () => { const f = frameEl(); return !!f && f.hasAttribute('data-sidebar-collapsed') }
185
229
  const dialogOpen = () => !!document.querySelector('[role="dialog"][class*="panel"]')
230
+ // 右侧详情面板:dsh 0.1.7 起 detailsCol 改名 rightbarCol,窄屏原生全屏展开
231
+ // (data-sidebar-right-panel / data-sidebar-right-open,折叠钮 data-sidebar-right-toggle)。
232
+ const rightOpen = () => !!document.querySelector('[data-sidebar-right-panel][data-sidebar-right-open]')
186
233
  const collapse = () => { const b = document.querySelector('button[aria-label="收起侧边栏"],button[aria-label="Collapse sidebar"]'); if (b) b.click() }
187
234
  const expand = () => { const b = document.querySelector('button[aria-label="打开侧边栏"],button[aria-label="Open sidebar"]'); if (b) b.click() }
188
235
  const closeDetails = () => {
189
- const d = document.querySelector('[class*="detailsCol"]')
190
- if (!d || !d.getBoundingClientRect().width) return
191
- const c = d.querySelector('button[aria-label="关闭详情"]')
192
- if (c) c.click()
236
+ const panel = document.querySelector('[data-sidebar-right-panel][data-sidebar-right-open]')
237
+ if (!panel) return
238
+ const toggle = panel.querySelector('[data-sidebar-right-toggle]')
239
+ if (toggle) toggle.click()
193
240
  }
194
241
 
195
- // 抽屉按钮:窄屏 + 侧栏收起 + 无设置弹窗时才显示(见 sync)。
196
- // 视觉是贴左边缘的「抽屉把手」(与趣味游戏浮标 dgfab 同款边缘吸附语言),
197
- // 位置动态放在趣味游戏浮标正上方(place()),游戏浮标不在时兜底左侧中部。
198
- // 宿主就位时把手/遮罩已由宿主创建,这里置 null 走纯 Tab 栏模式。
199
- let fab = null
200
- let scrim = null
201
- if (!hostPresent) {
202
- fab = document.createElement('button')
203
- fab.type = 'button'
204
- fab.className = 'dsh-mobile-drawer-btn'
205
- fab.setAttribute('aria-label', '打开会话列表')
206
- fab.innerHTML = '<svg viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h10"/></svg>'
207
- fab.addEventListener('click', expand)
208
- scrim = document.createElement('div')
209
- scrim.className = 'dsh-mobile-scrim'
210
- document.body.appendChild(fab)
211
- document.body.appendChild(scrim)
212
- }
242
+ // 抽屉遮罩:侧栏展开(浮层态)时盖住页面,点击经全局捕获 handler 收起。
243
+ // 左缘的浮动「抽屉把手」已移除:底部 Tab 栏的「会话」页签就是会话抽屉的唯一入口,
244
+ // 把手悬浮在内容上还压过输入区左下角(用户实测截图),纯属重复。
245
+ const scrim = document.createElement('div')
246
+ scrim.className = 'dsh-mobile-scrim'
247
+ document.body.appendChild(scrim)
213
248
 
214
- // ---- 仿 ZCode 手机端底部 Tab 栏:会话 / 功能坞 / 设置 ----
215
- // 挂 body(与抽屉把手同理,避开 overlayLayer 堆叠上下文),z 序 65:
249
+ // ---- 仿 ZCode 手机端底部 Tab 栏:会话 / 插件 / 设置 ----
250
+ // 「任务」「功能坞」合并为一个「插件」页签(用户反馈两个入口冗余):插件面板里
251
+ // 本来就有运行状态页,任务状态以角标显示在「插件」上——进行中蓝色数量、等待确认
252
+ // 红色脉冲;有任务在跑时点「插件」直接落到运行状态页,否则进面板首页。
253
+ // 挂 body(避开 overlayLayer 堆叠上下文),z 序 65:
216
254
  // 低于遮罩 75 / 侧栏抽屉 80 / 功能坞面板 200,这些打开时自然盖住 Tab 栏。
255
+ // 走到这里说明宿主旗标不存在(宿主版未注入)——宿主版自建的 Tab 栏不会出现;
256
+ // 兜底摘一次历史上可能残留的旧 Tab 栏,保证全程只有一份。
257
+ document.querySelectorAll('.dsh-mobile-tabbar').forEach((el) => el.remove())
217
258
  const tabbar = document.createElement('nav')
218
259
  tabbar.className = 'dsh-mobile-tabbar'
219
260
  tabbar.setAttribute('aria-label', '底部导航')
261
+ // 最近一次角标轮询看到的任务数(驱动「插件」页签的直达目标与角标显隐)。
262
+ let tasksActive = 0
263
+ let tasksWaiting = 0
220
264
  const tabDefs = [
221
265
  {
222
266
  id: 'sessions', label: '会话',
@@ -224,9 +268,15 @@ export function MobileRelayOverlay() {
224
268
  act: () => expand(),
225
269
  },
226
270
  {
227
- id: 'dock', label: '功能坞',
271
+ id: 'dock', label: '插件',
228
272
  icon: '<svg viewBox="0 0 24 24"><rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/><rect x="4" y="13" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/></svg>',
229
- act: () => { if (panelNav.open) setPanelOpen(false); else openPanel('home') },
273
+ act: () => {
274
+ // 始终打开、不再当开关用:若功能坞处于打开态但被原生全屏页/弹层(如从侧栏
275
+ // 进的官方插件管理页)盖住,再点一下会把功能坞关掉、露出的反而是原生页——
276
+ // 看起来就是「点插件出官方插件页」(用户实测截图)。关闭走面板自己的 ✕。
277
+ // 配合手机端把功能坞面板 z 序抬到原生弹层之上(媒体查询内 2000)。
278
+ openPanel((tasksActive > 0 || tasksWaiting > 0) ? 'runstate' : 'home')
279
+ },
230
280
  },
231
281
  {
232
282
  id: 'settings', label: '设置',
@@ -234,6 +284,7 @@ export function MobileRelayOverlay() {
234
284
  act: () => { const b = [...document.querySelectorAll('button')].find((x) => (x.getAttribute('aria-label') || '') === '设置' || (x.textContent || '').trim() === '设置'); if (b) b.click() },
235
285
  },
236
286
  ]
287
+ let tasksBadge = null
237
288
  const tabButtons = tabDefs.map((def) => {
238
289
  const b = document.createElement('button')
239
290
  b.type = 'button'
@@ -241,6 +292,12 @@ export function MobileRelayOverlay() {
241
292
  b.dataset.tab = def.id
242
293
  b.innerHTML = def.icon + '<span>' + def.label + '</span>'
243
294
  b.addEventListener('click', () => def.act())
295
+ if (def.id === 'dock') {
296
+ tasksBadge = document.createElement('span')
297
+ tasksBadge.className = 'dsh-mobile-tab-badge'
298
+ tasksBadge.style.display = 'none'
299
+ b.appendChild(tasksBadge)
300
+ }
244
301
  tabbar.appendChild(b)
245
302
  return b
246
303
  })
@@ -250,46 +307,73 @@ export function MobileRelayOverlay() {
250
307
  const settingsOpen = dialogOpen()
251
308
  for (const b of tabButtons) {
252
309
  const id = b.dataset.tab
253
- const on = (id === 'sessions' && expanded) || (id === 'dock' && panelNav.open) || (id === 'settings' && settingsOpen)
310
+ const on = (id === 'sessions' && expanded)
311
+ || (id === 'dock' && panelNav.open)
312
+ || (id === 'settings' && settingsOpen)
254
313
  b.classList.toggle('on', on)
255
314
  }
256
315
  }
257
316
  const unsubPanel = subscribePanel(syncTabs)
258
317
 
259
- // 把手定位:贴着趣味游戏浮标(.dgfab,可拖拽、位置内联在 style 上)正上方;
260
- // 浮标被拖到顶/隐藏/不存在时兜底左侧中部(38% 视高)。sync 每 1.5s 复测,
261
- // 拖拽浮标后把手最迟一个周期跟上;resize 立即复测。
262
- const place = () => {
263
- if (!fab) return
264
- const vh = window.innerHeight
265
- let top = null
266
- const g = document.querySelector('.dgfab')
267
- if (g && !g.classList.contains('dgfab-hide') && g.getBoundingClientRect) {
268
- const r = g.getBoundingClientRect()
269
- if (r && r.height > 0 && r.top > 0) {
270
- top = r.top - fab.offsetHeight - 8
271
- if (top < 64) top = r.bottom + 8 // 浮标太靠上时改放它下面,避开顶栏标题
272
- if (top + fab.offsetHeight > vh - 16) top = null
318
+ // 「任务」角标:低频轮询 runstate 宿主路由(3s,仅窄屏 + 页面可见时发);
319
+ // 进行中 = 蓝色数量,等待确认 = 红色数量 + 脉冲。路由不存在(runstate 未启用
320
+ // 或宿主旧版)时静默停轮询并摘掉角标,不报错不打扰。
321
+ let runstateGone = false
322
+ const pollTasks = () => {
323
+ if (runstateGone || !tasksBadge || !narrow() || document.visibilityState === 'hidden' || !tabbar.isConnected) return
324
+ fetch('/dsh-dock/runstate/status', {
325
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}',
326
+ }).then(async (res) => {
327
+ if (res.status === 404 || res.status === 405) { runstateGone = true; tasksActive = 0; tasksWaiting = 0; tasksBadge.style.display = 'none'; return null }
328
+ const body = await res.json().catch(() => null)
329
+ if (!body) return null
330
+ const active = (body.data && body.data.active) || []
331
+ const waiting = active.filter((t) => t.approvals && t.approvals.length).length
332
+ tasksActive = active.length
333
+ tasksWaiting = waiting
334
+ if (waiting > 0) {
335
+ tasksBadge.textContent = String(waiting)
336
+ tasksBadge.className = 'dsh-mobile-tab-badge wait'
337
+ tasksBadge.style.display = ''
338
+ } else if (active.length > 0) {
339
+ tasksBadge.textContent = String(active.length)
340
+ tasksBadge.className = 'dsh-mobile-tab-badge'
341
+ tasksBadge.style.display = ''
342
+ } else {
343
+ tasksBadge.style.display = 'none'
273
344
  }
274
- }
275
- if (top == null) top = Math.round(vh * 0.38)
276
- fab.style.top = top + 'px'
345
+ }).catch(() => {})
346
+ }
347
+ const tasksTimer = setInterval(pollTasks, 3000)
348
+
349
+ // 软键盘判定:visualViewport 与布局视口高度差超阈值视为键盘弹出(挂
350
+ // html.dsh-dock-kbd,样式表随之隐藏 Tab 栏并收窄滚动体留白——打字时
351
+ // 这两样只会挤占键盘上方的一线空间)。老内核没有 visualViewport 就跳过
352
+ // (那些内核键盘行为是整页缩放,原生命中适配)。
353
+ const vv = window.visualViewport || null
354
+ const kbdSync = () => {
355
+ if (!vv) return
356
+ const kbd = vv.height > 0 && (window.innerHeight - vv.height) > 120
357
+ document.documentElement.classList.toggle('dsh-dock-kbd', kbd)
277
358
  }
278
359
 
279
360
  const sync = () => {
361
+ kbdSync()
362
+ // 兜底摘除历史上可能残留的旧 Tab 栏(正常路径下走到这里说明宿主版未注入,
363
+ // 不会有第二份;保险起见 sync 周期里也保持全局只有一份)。
364
+ document.querySelectorAll('.dsh-mobile-tabbar').forEach((el) => { if (el !== tabbar) el.remove() })
280
365
  const on = narrow() && !!frameEl() && !dialogOpen()
281
366
  const collapsed = sidebarCollapsed()
282
- if (fab) {
283
- const showFab = on && collapsed && !panelNav.open
284
- fab.style.display = showFab ? '' : 'none'
285
- if (showFab) place()
286
- }
367
+ // 右侧详情面板(窄屏原生全屏,z 40)打开时:Tab 栏 65 比它高会压边,整体让位,
368
+ // 面板收起(它自带折叠钮)后再回来。
369
+ const right = rightOpen()
287
370
  if (scrim) scrim.style.display = on && !collapsed ? '' : 'none'
288
- // Tab 栏:窄屏且页面骨架就绪后常驻;功能坞面板/宿主设置弹窗打开时隐藏(它们的遮罩更高,避免露边)
289
- tabbar.style.display = narrow() && !!frameEl() && !panelNav.open ? '' : 'none'
371
+ // Tab 栏:窄屏且页面骨架就绪后常驻;功能坞面板/设置弹窗/右侧详情面板打开时隐藏(避免露边压内容)
372
+ tabbar.style.display = narrow() && !!frameEl() && !panelNav.open && !right ? '' : 'none'
290
373
  syncTabs()
291
374
  }
292
375
  sync()
376
+ pollTasks()
293
377
  // MutationObserver 跟属性翻转即时同步;1.5s 轮询覆盖路由重渲等一切边角
294
378
  const observer = typeof MutationObserver !== 'undefined' ? new MutationObserver(sync) : null
295
379
  if (observer) observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-sidebar-collapsed'], subtree: true })
@@ -297,41 +381,59 @@ export function MobileRelayOverlay() {
297
381
  const onMq = () => sync()
298
382
  if (mq.addEventListener) mq.addEventListener('change', onMq)
299
383
  else if (mq.addListener) mq.addListener(onMq)
300
- window.addEventListener('resize', place)
384
+ window.addEventListener('resize', kbdSync)
385
+ if (vv && vv.addEventListener) vv.addEventListener('resize', kbdSync)
386
+ const onVisible = () => { if (document.visibilityState === 'visible') pollTasks() }
387
+ document.addEventListener('visibilitychange', onVisible)
301
388
 
302
389
  // 捕获阶段统一处理(仅自建模式;宿主就位时宿主已有同款全局捕获):
303
- // 抽屉按钮自身排除(否则展开动作会被"点外部收起"当场撤销);
390
+ // Tab 栏自身排除(否则页签动作会被"点外部收起"当场撤销);
304
391
  // 遮罩不挂自己的 click——它的点击也走这里 collapse(),单一路径防止同一事件两次 toggle。
305
392
  const onClick = (e) => {
306
393
  if (!narrow()) return
307
394
  const t = e.target
308
395
  if (!t || !t.closest) return
309
- if (t.closest('.dsh-mobile-drawer-btn') || t.closest('.dsh-mobile-tabbar')) return
396
+ if (t.closest('.dsh-mobile-tabbar')) return
310
397
  const expanded = !!document.querySelector('button[aria-label="收起侧边栏"],button[aria-label="Collapse sidebar"]')
311
398
  const col = document.querySelector('[class*="sidebarCol"]')
312
399
  const inSidebar = col && col.contains(t)
313
400
  if (!expanded && !inSidebar) return
314
401
  if (inSidebar) {
402
+ // 只有点「会话」才收抽屉:会话行的 data-row-key 以 session: 开头(0.1.7 结构,
403
+ // ui-workspace Rows.tsx);工作区文件夹行是 workspace: 开头——点它只是展开/收拢
404
+ // 分组,不能收抽屉(旧的「无子 treeitem 即叶子」判定会误伤折叠状态的文件夹:
405
+ // 折叠时其子节点不在 DOM 里)。搜索结果行无 data-row-key,回退按无子叶判定。
315
406
  const row = t.closest('[role="treeitem"]')
316
- const leaf = row && !row.querySelector('[role="treeitem"]') && row.closest('[role="tree"]')
407
+ const rowKey = (row && row.getAttribute('data-row-key')) || ''
408
+ const sessionRow = (rowKey && rowKey.startsWith('session:'))
409
+ || (row && !rowKey && !row.querySelector('[role="treeitem"]') && row.closest('[role="tree"]'))
317
410
  const fresh = t.closest('[class*="newSession"]')
318
- if (leaf || fresh) setTimeout(() => { collapse(); closeDetails() }, 300)
411
+ if (sessionRow || fresh) setTimeout(() => { collapse(); closeDetails() }, 300)
412
+ else {
413
+ // 侧栏入口(插件/自动化任务/设置/记忆/自动进化…)点击后会弹出面板,而面板
414
+ // z 序常低于展开的抽屉——被盖住等于「点了没反应」(用户实测截图)。稍候
415
+ // 探测:有面板弹出就收起抽屉让它露出来;目录折叠/搜索等不弹面板的点击不受影响。
416
+ setTimeout(() => { if (document.querySelector('[role="dialog"]')) collapse() }, 260)
417
+ }
319
418
  return
320
419
  }
321
- if (t.closest('[role="dialog"],[class*="dockm"],[class*="dgfab"],[class*="dgwin"],[class*="dgame"],[class*="detailsCol"]')) return
420
+ if (t.closest('[role="dialog"],[class*="dockm"],[class*="dgfab"],[class*="dgwin"],[class*="dgame"],[data-sidebar-right-panel]')) return
322
421
  collapse()
323
422
  }
324
- if (!hostPresent) document.addEventListener('click', onClick, true)
423
+ document.addEventListener('click', onClick, true)
325
424
 
326
425
  return () => {
327
- if (!hostPresent) document.removeEventListener('click', onClick, true)
426
+ document.removeEventListener('click', onClick, true)
328
427
  if (observer) observer.disconnect()
329
428
  clearInterval(timer)
429
+ clearInterval(tasksTimer)
330
430
  unsubPanel()
331
431
  if (mq.removeEventListener) mq.removeEventListener('change', onMq)
332
432
  else if (mq.removeListener) mq.removeListener(onMq)
333
- window.removeEventListener('resize', place)
334
- if (fab) fab.remove()
433
+ window.removeEventListener('resize', kbdSync)
434
+ if (vv && vv.removeEventListener) vv.removeEventListener('resize', kbdSync)
435
+ document.removeEventListener('visibilitychange', onVisible)
436
+ document.documentElement.classList.remove('dsh-dock-kbd')
335
437
  if (scrim) scrim.remove()
336
438
  tabbar.remove()
337
439
  if (window.__dshDockMobileDrawer === 'client') delete window.__dshDockMobileDrawer
@@ -343,9 +445,15 @@ export function MobileRelayOverlay() {
343
445
  export function MobileRelayHomeStat() {
344
446
  const [summary, setSummary] = useState('未开启,开启后可远程登录')
345
447
  useEffect(() => {
346
- rpc('lan').then((data) => {
347
- setSummary(data.gatewayActive ? '入口运行中 · 端口 ' + data.gatewayPort : data.accountSet ? '账号已设置,入口未开启' : '未开启,开启后可远程登录')
348
- }).catch(() => {})
448
+ const refresh = () => {
449
+ rpc('lan').then((data) => {
450
+ const devices = typeof data.devices === 'number' && data.devices > 0 ? ' · ' + data.devices + ' 台设备在线' : ''
451
+ setSummary(data.gatewayActive ? '入口运行中 · 端口 ' + data.gatewayPort + devices : data.accountSet ? '账号已设置,入口未开启' : '未开启,开启后可远程登录')
452
+ }).catch(() => {})
453
+ }
454
+ refresh()
455
+ const timer = setInterval(refresh, 10000)
456
+ return () => clearInterval(timer)
349
457
  }, [])
350
458
  return <span>{summary}</span>
351
459
  }
@@ -355,9 +463,9 @@ export const feature = {
355
463
  description: '账号密码登录的远程入口:所有设备访问同一个 DSH,任务进度实时一致', defaultEnabled: false,
356
464
  css: `
357
465
  .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)}
358
- /* 抽屉把手:贴左边缘(同趣味游戏浮标的边缘吸附语言),top 由 place() 动态设定;
359
- * z 序 70 < 遮罩 75 < 侧栏 80 < overlayLayer 90,面板/弹窗打开时自然被盖。 */
360
- @media (max-width:700px){.dsh-mobile-drawer-btn{position:fixed;left:0;top:38vh;z-index:70;box-sizing:border-box;width:30px;height:48px;padding:0;display:flex;align-items:center;justify-content:center;border:1px solid var(--dsw-alias-border-l1,rgba(127,139,161,.35));border-left:none;border-radius:0 12px 12px 0;background:color-mix(in srgb,var(--dsw-alias-bg-layer-2,#1c2230) 88%,transparent);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);color:var(--dsw-alias-label-primary,#e6eaf2);box-shadow:4px 0 18px rgba(0,0,0,.28);cursor:pointer;touch-action:manipulation;-webkit-tap-highlight-color:transparent}.dsh-mobile-drawer-btn:active{transform:scale(.94)}.dsh-mobile-drawer-btn svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}.dsh-mobile-scrim{position:fixed;inset:0;z-index:75;background:rgba(8,10,14,.45);backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px);touch-action:none}
466
+ /* 抽屉遮罩:侧栏浮层展开时盖住页面。左缘浮动「抽屉把手」已移除——会话入口
467
+ * 统一走底部 Tab 栏「会话」页签,把手悬浮在内容上还压过输入区(用户实测)。 */
468
+ @media (max-width:700px){.dsh-mobile-scrim{position:fixed;inset:0;z-index:75;background:rgba(8,10,14,.45);backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px);touch-action:none}
361
469
  /* ---- 仿 ZCode 手机端:底部 Tab 栏 + 输入区吸附底部 ---- */
362
470
  .dsh-mobile-tabbar{position:fixed;left:0;right:0;bottom:0;z-index:65;display:flex;align-items:stretch;justify-content:space-around;padding:6px 8px calc(8px + env(safe-area-inset-bottom,0px));background:color-mix(in srgb,var(--dsw-alias-bg-layer-2,#1c2230) 92%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-top:1px solid var(--dsw-alias-border-l1,rgba(127,139,161,.25));touch-action:manipulation}
363
471
  .dsh-mobile-tab{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;min-height:48px;padding:4px 0;border:0;background:transparent;color:var(--dsw-alias-label-tertiary,#8b95ab);font:600 11px/1.2 inherit;cursor:pointer;-webkit-tap-highlight-color:transparent;border-radius:10px}
@@ -365,16 +473,39 @@ export const feature = {
365
473
  .dsh-mobile-tab.on{color:var(--dsw-alias-label-primary,#e6eaf2)}
366
474
  .dsh-mobile-tab.on svg{color:var(--dk-accent,#4d9fff)}
367
475
  .dsh-mobile-tab:active{transform:scale(.94)}
476
+ /* 「任务」页签角标:进行中=蓝(数量),等待确认=红(数量+脉冲)——接力场景里
477
+ * 等待确认是最需要人介入的状态,一眼可见;数据来自 runstate 宿主路由低频轮询。 */
478
+ .dsh-mobile-tab{position:relative}
479
+ .dsh-mobile-tab-badge{position:absolute;top:0;right:calc(50% - 26px);box-sizing:border-box;min-width:16px;height:16px;padding:0 4px;border-radius:9px;display:flex;align-items:center;justify-content:center;background:#4d9fff;color:#fff;font-size:10px;font-weight:700;line-height:1;font-variant-numeric:tabular-nums;box-shadow:0 2px 8px rgba(0,0,0,.35);pointer-events:none}
480
+ .dsh-mobile-tab-badge.wait{background:#ef4444;animation:dsh-badge-pulse 1.2s ease-in-out infinite}
481
+ @keyframes dsh-badge-pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.18)}}
482
+ @media (prefers-reduced-motion:reduce){.dsh-mobile-tab-badge.wait{animation:none}}
368
483
  /* 输入区吸附到 Tab 栏上方:宿主把欢迎内容 justify-content:center 垂直居中,
369
- * 手机视口下输入卡片悬在中部、下方留出大片空白。改为 flex-end 贴底,并给
370
- * 滚动体留出 Tab 栏高度的内边距,避免内容被 Tab 栏遮挡。 */
371
- [class*="scrollBody"]{justify-content:flex-end !important;padding-bottom:calc(72px + env(safe-area-inset-bottom,0px)) !important}
372
- [class*="scrollBody"]>[class*="composerSeat"]{padding-bottom:6px}
484
+ * 手机视口下输入卡片悬在中部、下方留出大片空白。改用「输入区 margin-top:auto」
485
+ * 贴底,并给滚动体留出 Tab 栏高度的内边距,避免内容被 Tab 栏遮挡。
486
+ * ⚠️ 绝不能给滚动体加 justify-content:flex-end/center 贴底:内容会被挤到滚动体
487
+ * 起点之外,Chrome 不把起点方向溢出算进 scrollHeight(手机视口实测
488
+ * scrollHeight==clientHeight、maxScroll==0),会话记录彻底滚不动、首条消息永远
489
+ * 看不到。宿主半部旧版注入里还留着那条 flex-end(要重启 dsh web 才消失),这里
490
+ * 用更高优先级的选择器显式复位:刷新页面即修好,不必等重启。 */
491
+ [class*="scrollBody"][data-conversation-scroll]{justify-content:flex-start !important;padding-bottom:calc(72px + env(safe-area-inset-bottom,0px)) !important}
492
+ [class*="scrollBody"]{padding-bottom:calc(72px + env(safe-area-inset-bottom,0px)) !important}
493
+ [class*="scrollBody"]>[class*="composerSeat"]{margin-top:auto;padding-bottom:6px}
494
+ /* 输入区工具行上的 dsh-dock chip(用量/余额):窄屏下 28cqw 的宽度上限把它们挤成
495
+ * 「…」「余..」(手机实测),既读不出数值又白占输入行空间。手机端整组收起,数值在
496
+ * 底部「功能坞」里看(用量/余额页信息更全);桌面端不动。 */
497
+ .dockchip-row{display:none}
373
498
  /* 收起的侧栏轨道(collapsed rail)在窄屏只剩 1px 占位,但轨道里的按钮
374
499
  * 仍会溢出贴在左边缘(功能坞/进化/设置三枚露出半边的圆钮)。隐藏整列,
375
500
  * 这些入口已由底部 Tab 栏接管;展开抽屉(z 80)不受影响。 */
376
501
  [class*="sidebarCol"]:has([class*="collapsed"]){visibility:hidden}
377
- /* 抽屉把手从屏幕中部挪到 Tab 栏上方一点,免得孤零零悬在半空 */
378
- .dsh-mobile-drawer-btn{top:auto !important;bottom:calc(96px + env(safe-area-inset-bottom,0px))}}`,
502
+ /* 软键盘弹出(visualViewport 判定,挂 html.dsh-dock-kbd):Tab 栏整体让位(打字时
503
+ * 它只会挤占键盘上方的一线空间),滚动体留白收窄。选择器特异性高于上面的 72px
504
+ * 留白规则,能压过去。宿主接管期间由宿主脚本同步置/摘这个类。 */
505
+ html.dsh-dock-kbd .dsh-mobile-tabbar{display:none !important}
506
+ html.dsh-dock-kbd [class*="scrollBody"]{padding-bottom:12px !important}
507
+ /* 功能坞面板在手机端抬到原生弹层之上:从侧栏进的官方插件管理页等原生弹层 z 序高于
508
+ * 面板默认的 200,功能坞开在下面会被盖住——「插件」页签点了像没反应/露出原页面。 */
509
+ [class*="dockm-backdrop"]{z-index:2000 !important}}`,
379
510
  View: MobileRelayView, HomeStat: MobileRelayHomeStat, Overlay: MobileRelayOverlay,
380
511
  }
package/index.js CHANGED
@@ -16,7 +16,7 @@
16
16
  // - notify 任务通知:完成/异常/需确认通知 + 提示音/系统通知/钉钉飞书推送(从任务动画拆出)
17
17
  // - runstate 运行状态:进行中任务与最近完成一览(从任务动画拆出;只读,无配置段)
18
18
  // - mobile-relay 手机接力(未发布):扫码反向代理接力 + 局域网电脑直连(0.0.0.0)
19
- import { DockConfig, sendJson, readBody, migrateNotifyConfig, migrateModelsFeatureId, migrateImportedFeatures, bindDockConfig, readDockRoot, mutateDockSection } from './src/host-core.js'
19
+ import { DockConfig, sendJson, readBody, migrateNotifyConfig, migrateModelsFeatureId, migrateImportedFeatures, migrateImportedSections, bindDockConfig, readDockRoot, mutateDockSection } from './src/host-core.js'
20
20
  import { feature as fModels } from './features/modelconfig/host.js'
21
21
  import { feature as fVisionProxy } from './features/visionproxy/host.js'
22
22
  import { feature as fBalance } from './features/balance/host.js'
@@ -132,6 +132,10 @@ export function apply(ctx, config) {
132
132
  ctx.inject(['settings'], (sctx) => {
133
133
  // 0.1.7-alpha.1 起 settings.register 已移除——不再调用;导出 Config 即注册。
134
134
  Promise.resolve()
135
+ // settings.yaml.imported 整段恢复(账号 remoteAuth / notify / animation /
136
+ // visionProxy——Config 导出前导入失败停在 imported 文件里的那批)。必须最先跑:
137
+ // 后面的迁移与功能启用都要读到恢复后的配置(远程访问账号丢了网关就开不起来)。
138
+ .then(() => migrateImportedSections(sctx))
135
139
  // 通知配置从 animation 段搬到 notify 段(【任务通知】独立成模块)。
136
140
  // 必须在任何面板保存动作之前跑——animation 模块保存时整段写回,旧字段会被覆盖丢失。
137
141
  .then(() => migrateNotifyConfig(sctx))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-dock",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "DeepSeek Harness 功能坞插件(dsh-dock):模块化功能中枢——每个功能一个 features/ 模块(可单独提取成包发布,又可经 dockBridge 装回面板)。含用量记录(LLM 调用记账/费用统计,支持各模型自定义单价、多段分时价与周末/节假日谷价)、模型设置、图片理解代理、模型余额、任务动画(19 种运行动画 + 桌面伙伴 3D 场景)、任务通知(完成/异常/需确认通知、提示音、系统通知、钉钉/飞书推送)、运行状态(进行中任务与最近完成一览);侧栏入口 + 可拖拽弹窗(最大化/最小化/缩放)。",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -43,7 +43,7 @@
43
43
  "scripts": {
44
44
  "build:client": "node scripts/build-client.mjs",
45
45
  "test:client": "node scripts/test-client-views.mjs",
46
- "test:host": "node scripts/test-task-host.mjs && node scripts/test-tokenlog-host.mjs",
46
+ "test:host": "node scripts/test-task-host.mjs && node scripts/test-tokenlog-host.mjs && node scripts/test-legacy-import.mjs && node scripts/test-mobile-relay-host.mjs && node scripts/test-mobile-relay.mjs",
47
47
  "dev:client": "node scripts/watch-client.mjs",
48
48
  "dev:link": "node scripts/dev-link-deps.mjs"
49
49
  },
package/src/client.jsx CHANGED
@@ -28,12 +28,13 @@ import { feature as fNotify } from "../features/notify/view.jsx";
28
28
  import { feature as fRunState } from "../features/runstate/view.jsx";
29
29
  import { feature as fGames } from "../features/games/view.jsx";
30
30
  import { feature as fMobileRelay } from "../features/mobile-relay/view.jsx";
31
+ import { feature as fMobile } from "../features/mobile/view.js";
31
32
 
32
33
  const name = "dsh-dock";
33
- const DOCK_VERSION = "0.12.0";
34
+ const DOCK_VERSION = "0.13.0";
34
35
 
35
36
  // ---- 内置功能注册表:新功能 = features/<id>/ 加模块 + 这里 import 一行 ----
36
- const BUILTIN_FEATURES = [fTokenlog, fModelconfig, fHeartbeat, fTheme, fBalance, fAnimation, fNotify, fRunState, fGames, fMobileRelay];
37
+ const BUILTIN_FEATURES = [fTokenlog, fModelconfig, fHeartbeat, fTheme, fBalance, fAnimation, fNotify, fRunState, fGames, fMobileRelay, fMobile];
37
38
  // 规划占位(路线图):接入后移除并建 features/<id>/ 模块
38
39
  const PLANNED_FEATURES = [];
39
40
  const PLANNED_NOTES = {};