dsh-dock 0.14.0 → 0.15.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.
@@ -7,10 +7,14 @@
7
7
  // 2. 触控基础:去点按高亮、touch-action: manipulation(消双击缩放判定延迟),
8
8
  // 纯图标按钮(唯一子元素为 svg)面积兜底到 ≥36px(实测一批 28×28 的工具钮手机上很难点中);
9
9
  // 3. 代码块横向滚动 + 行内代码换行(窄屏不撑破版面);
10
- // 4. 手机端 chrome:底部 Tab 栏(会话/插件/设置)+ 侧栏抽屉遮罩 + 软键盘让位 +
10
+ // 4. 手机端 chrome:左上角汉堡菜单(展开左侧会话抽屉)+ 侧栏抽屉遮罩 + 软键盘让位 +
11
11
  // 窄屏排版(设置弹层纵排、侧栏浮层化)。这套 chrome 从【远程访问】整体迁到本模块
12
12
  // 整体迁到本模块——远程访问只负责远程入口本身,本机窄屏同样享有手机适配。
13
13
  // 顺带给 viewport meta 补 viewport-fit=cover,standalone 模式下 env(safe-area-*) 才有值。
14
+ // ⚠️ 底部 Tab 栏(会话/插件/设置三入口)已按用户要求移除:它常驻占掉视口底部一条
15
+ // (48px + 安全区),在 390×844 上约 8% 的纵向空间,且与抽屉本身语义重复。会话入口
16
+ // 改为左上角汉堡菜单——一个 44×44 的悬浮按钮,点开左侧会话抽屉;插件/设置等原生入口
17
+ // 本来就在抽屉里,展开抽屉即可达。
14
18
  // 5. 滚动兜底:所有纵向滚动体声明 touch-action:pan-y + iOS 惯性滚动;设置弹层内容列
15
19
  // 补 min-height:0(见 host.js 文件头对「哪都滑不动」两条根因的说明)。
16
20
  //
@@ -35,7 +39,7 @@
35
39
  // ⚠️ 命名空间用 dkmob- 前缀:dkm- 已被【模型设置】模块占用,撞了会互相覆盖样式。
36
40
  import react from "react";
37
41
  import { useEffect, useState } from "react";
38
- import { openPanel, subscribePanel, panelNav } from "../../src/shared.js";
42
+ import { panelNav, stateOf } from "../../src/shared.js";
39
43
 
40
44
  /** 视口命中条件(任一即视为手机形态)。 */
41
45
  const MOBILE_QUERY_NARROW = "(max-width: 820px)";
@@ -114,14 +118,14 @@ export function MobileOverlay() {
114
118
  return null;
115
119
  }
116
120
 
117
- // —— 手机端 chrome(客户端自建版):窄屏 ≤700px 的底部 Tab 栏 + 抽屉遮罩 + 行为 ——
121
+ // —— 手机端 chrome(客户端自建版):窄屏 ≤700px 的左上角汉堡菜单 + 抽屉遮罩 + 行为 ——
118
122
  // 直接操作 document.body 下的 DOM——若作为 overlayLayer 的子元素,会落入 overlayLayer
119
- // (z 90)的堆叠上下文,遮罩压不住侧栏抽屉(z 80)、Tab 栏也盖不住内容;挂到 body 下
120
- // z 序才是设计值(遮罩 75 < 侧栏 80 < overlayLayer 90)。
123
+ // (z 90)的堆叠上下文,遮罩压不住侧栏抽屉(z 80)、汉堡也盖不住内容;挂到 body 下
124
+ // z 序才是设计值(汉堡 66 < 遮罩 75 < 侧栏 80 < overlayLayer 90)。
121
125
  //
122
126
  // 与宿主 tapIndex 注入版(features/mobile/host.js,重启 dsh web 后才生效)互斥:
123
127
  // window.__dshDockMobileDrawer 先到先得——宿主脚本在 <head> 先跑并置 'host',本组件
124
- // 见旗标即整体让位(遮罩/底部 Tab/收起逻辑全归宿主);宿主未就位(旧版无注入、
128
+ // 见旗标即整体让位(遮罩/汉堡/收起逻辑全归宿主);宿主未就位(旧版无注入、
125
129
  // 或刷新早于重启)时本组件自建全套。本组件的价值是 client bundle 按内容 hash 服务、
126
130
  // 刷新页面即生效,无需重启。
127
131
  // ⚠️ 两个版本绝不能各建一套:曾出现双底部 Tab 栏(两套按钮叠在一起)与 CSS 互相覆盖。
@@ -155,115 +159,80 @@ function mobileChrome() {
155
159
  scrim.className = "dsh-mobile-scrim";
156
160
  document.body.appendChild(scrim);
157
161
 
158
- // ---- 仿 ZCode 手机端底部 Tab 栏:会话 / 插件 / 设置 ----
159
- // 「任务」「功能坞」合并为一个「插件」页签(用户反馈两个入口冗余):插件面板里
160
- // 本来就有运行状态页,任务状态以角标显示在「插件」上——进行中蓝色数量、等待确认
161
- // 红色脉冲;有任务在跑时点「插件」直接落到运行状态页,否则进面板首页。
162
- // 挂 body(避开 overlayLayer 堆叠上下文),z 序 65:
163
- // 低于遮罩 75 / 侧栏抽屉 80 / 功能坞面板 200,这些打开时自然盖住 Tab 栏。
164
- // 走到这里说明宿主未注入——兜底摘一次历史上可能残留的旧 Tab 栏,保证全程只有一份。
162
+ // ---- 左上角汉堡菜单:展开左侧会话抽屉 ----
163
+ // 用户要求移除底部 Tab 栏(三入口常驻占掉视口底部一条,390×844 上约 8% 纵向空间),
164
+ // 改为左上角「三条横线」折叠菜单。语义上它就是原「会话」页签:点开左侧抽屉。
165
+ // 插件/设置/运行状态等入口本来就在抽屉里(原生侧栏 rail),展开即可达,不重复提供。
166
+ // 挂 body(避开 overlayLayer 堆叠上下文),z 序 66:
167
+ // 高于正文(<65),低于遮罩 75 / 侧栏抽屉 80 / overlayLayer 90 / 功能坞面板 200,
168
+ // 这些浮层打开时自然盖住汉堡。走到这里说明宿主未注入——兜底摘一次历史上可能残留的
169
+ // 旧 chrome(Tab 栏),保证全程只有一份。
165
170
  document.querySelectorAll(".dsh-mobile-tabbar").forEach((el) => el.remove());
166
- const tabbar = document.createElement("nav");
167
- tabbar.className = "dsh-mobile-tabbar";
168
- tabbar.setAttribute("aria-label", "底部导航");
169
- // 最近一次角标轮询看到的任务数(驱动「插件」页签的直达目标与角标显隐)。
170
- let tasksActive = 0;
171
- let tasksWaiting = 0;
172
- const tabDefs = [
173
- {
174
- id: "sessions", label: "会话",
175
- icon: '<svg viewBox="0 0 24 24"><path d="M21 12a8 8 0 0 1-8 8H5l-2 2V12a8 8 0 0 1 8-8h2a8 8 0 0 1 8 8z"/></svg>',
176
- act: () => expand(),
177
- },
178
- {
179
- id: "dock", label: "插件",
180
- 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>',
181
- act: () => {
182
- // 始终打开、不再当开关用:若功能坞处于打开态但被原生全屏页/弹层(如从侧栏
183
- // 进的官方插件管理页)盖住,再点一下会把功能坞关掉、露出的反而是原生页——
184
- // 看起来就是「点插件出官方插件页」(用户实测截图)。关闭走面板自己的 ✕。
185
- // 配合手机端把功能坞面板 z 序抬到原生弹层之上(媒体查询内 2000)。
186
- openPanel((tasksActive > 0 || tasksWaiting > 0) ? "runstate" : "home");
187
- },
188
- },
189
- {
190
- id: "settings", label: "设置",
191
- icon: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3.2"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 1.55V21a2 2 0 1 1-4 0v-.09A1.7 1.7 0 0 0 9 19.4a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.55-1H3a2 2 0 1 1 0-4h.09A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-1.55V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1 1.51 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.23.6.86 1 1.51 1H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.51 1z"/></svg>',
192
- act: () => {
193
- // 中英文双匹配:aria-label 随 dsh 客户端语言本地化(英文环境是 "Settings"),
194
- // 只认「设置」会让英文环境的页签点不动(测试实例 en 环境实测)。
195
- const b = [...document.querySelectorAll("button")].find((x) => {
196
- const a = x.getAttribute("aria-label") || "";
197
- return a === "设置" || a === "Settings" || (x.textContent || "").trim() === "设置";
198
- });
199
- if (b) b.click()
200
- },
201
- },
202
- ];
203
- let tasksBadge = null;
204
- const tabButtons = tabDefs.map((def) => {
205
- const b = document.createElement("button");
206
- b.type = "button";
207
- b.className = "dsh-mobile-tab";
208
- b.dataset.tab = def.id;
209
- b.innerHTML = def.icon + "<span>" + def.label + "</span>";
210
- b.addEventListener("click", () => def.act());
211
- if (def.id === "dock") {
212
- tasksBadge = document.createElement("span");
213
- tasksBadge.className = "dsh-mobile-tab-badge";
214
- tasksBadge.style.display = "none";
215
- b.appendChild(tasksBadge);
216
- }
217
- tabbar.appendChild(b);
218
- return b;
219
- });
220
- document.body.appendChild(tabbar);
221
- const syncTabs = () => {
171
+ const burger = document.createElement("button");
172
+ burger.type = "button";
173
+ burger.className = "dsh-mobile-burger";
174
+ burger.setAttribute("aria-label", "打开会话列表");
175
+ burger.setAttribute("aria-expanded", "false");
176
+ burger.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/></svg>'
177
+ + '<span class="dsh-mobile-burger-badge" hidden></span>';
178
+ burger.addEventListener("click", () => expand());
179
+ document.body.appendChild(burger);
180
+ // 抽屉展开时按钮语义翻转(aria-expanded + 左侧展开态高亮),并让它让位(抽屉 z 80 盖住它)。
181
+ const syncBurger = () => {
222
182
  const expanded = !sidebarCollapsed();
223
- const settingsOpen = dialogOpen();
224
- for (const b of tabButtons) {
225
- const id = b.dataset.tab;
226
- const on = (id === "sessions" && expanded)
227
- || (id === "dock" && panelNav.open)
228
- || (id === "settings" && settingsOpen);
229
- b.classList.toggle("on", on);
230
- }
183
+ burger.setAttribute("aria-expanded", expanded ? "true" : "false");
184
+ burger.classList.toggle("on", expanded);
231
185
  };
232
- const unsubPanel = subscribePanel(syncTabs);
233
186
 
234
- // 「插件」角标:低频轮询 runstate 宿主路由(3s,仅窄屏 + 页面可见时发);
235
- // 进行中 = 蓝色数量,等待确认 = 红色数量 + 脉冲。路由不存在(runstate 未启用
236
- // 或宿主旧版)时静默停轮询并摘掉角标,不报错不打扰。
187
+ // ---- 待确认任务角标:挂在汉堡右上角 ----
188
+ // 底部 Tab 栏移除前,「插件」页签上有个角标(等待确认=红色脉冲 / 进行中=蓝色数量),
189
+ // 那是接力场景里最需要人介入的状态。改成汉堡角标保留这个一眼可见的能力,
190
+ // 代价约 0:一个小圆点锚在按钮右上角,不占布局、不破坏裸图标观感。
191
+ // 点汉堡只是展开抽屉,不直接跳运行状态——角标只承担「提醒」,处理仍在
192
+ // 抽屉 → 功能坞 → 运行状态里(避免汉堡按钮承担两种语义)。
193
+ const badge = burger.querySelector(".dsh-mobile-burger-badge");
194
+ // runstate 路由不存在(功能未启用/宿主旧版)时静默停轮询并摘掉角标,不报错不打扰。
195
+ // ⚠️ 先看功能开关再发请求:【运行状态】停用时宿主根本不注册该路由,无条件轮询会先打一次
196
+ // 必然 405/404 的请求(控制台一条噪音)。与【用量记录】chip 当年同一个毛病。
197
+ // 宿主侧还有一道旗标 window.__dshDockRunstate(features/runstate/host.js 注册时置位),
198
+ // 覆盖「客户端认为开着、宿主实际没注册」的情况——那才是 405 的真正来源。
237
199
  let runstateGone = false;
238
200
  const pollTasks = () => {
239
- if (runstateGone || !tasksBadge || !narrow() || document.visibilityState === "hidden" || !tabbar.isConnected) return;
201
+ if (runstateGone || !badge || !narrow() || document.visibilityState === "hidden" || !burger.isConnected) return;
202
+ const on = stateOf("runstate").enabled !== false;
203
+ if (!on && !window.__dshDockRunstate) { badge.hidden = true; return }
240
204
  fetch("dsh-dock/runstate/status", {
241
205
  method: "POST", headers: { "content-type": "application/json" }, body: "{}",
242
206
  }).then(async (res) => {
243
- if (res.status === 404 || res.status === 405) { runstateGone = true; tasksActive = 0; tasksWaiting = 0; tasksBadge.style.display = "none"; return null }
207
+ if (res.status === 404 || res.status === 405) { runstateGone = true; badge.hidden = true; return null }
244
208
  const body = await res.json().catch(() => null);
245
209
  if (!body) return null;
246
210
  const active = (body.data && body.data.active) || [];
247
- const waiting = active.filter((t) => t.approvals && t.approvals.length).length;
248
- tasksActive = active.length;
249
- tasksWaiting = waiting;
211
+ // 等待确认 = 有会话卡在工具审批上;这才是需要人介入的红色脉冲状态。
212
+ const waiting = active.reduce((n, t) => n + (Array.isArray(t.approvals) ? t.approvals.length : 0), 0);
250
213
  if (waiting > 0) {
251
- tasksBadge.textContent = String(waiting);
252
- tasksBadge.className = "dsh-mobile-tab-badge wait";
253
- tasksBadge.style.display = "";
214
+ badge.textContent = waiting > 99 ? "99+" : String(waiting);
215
+ badge.className = "dsh-mobile-burger-badge wait";
216
+ badge.hidden = false;
217
+ burger.setAttribute("title", waiting + " 个任务等待你确认");
254
218
  } else if (active.length > 0) {
255
- tasksBadge.textContent = String(active.length);
256
- tasksBadge.className = "dsh-mobile-tab-badge";
257
- tasksBadge.style.display = "";
219
+ badge.textContent = active.length > 99 ? "99+" : String(active.length);
220
+ badge.className = "dsh-mobile-burger-badge";
221
+ badge.hidden = false;
222
+ burger.setAttribute("title", active.length + " 个任务进行中");
258
223
  } else {
259
- tasksBadge.style.display = "none";
224
+ badge.hidden = true;
225
+ burger.removeAttribute("title");
260
226
  }
261
227
  }).catch(() => {});
262
228
  };
263
229
  const tasksTimer = setInterval(pollTasks, 3000);
230
+ const onVisible = () => { if (document.visibilityState === "visible") pollTasks() };
231
+ document.addEventListener("visibilitychange", onVisible);
232
+ pollTasks();
264
233
 
265
234
  // 软键盘判定:visualViewport 与布局视口高度差超阈值视为键盘弹出(挂
266
- // html.dsh-dock-kbd,样式表随之隐藏 Tab 栏并收窄滚动体留白——打字时
235
+ // html.dsh-dock-kbd,样式表随之隐藏汉堡并收窄滚动体留白——打字时
267
236
  // 这两样只会挤占键盘上方的一线空间)。老内核没有 visualViewport 就跳过。
268
237
  const vv = window.visualViewport || null;
269
238
  const kbdSync = () => {
@@ -274,22 +243,20 @@ function mobileChrome() {
274
243
 
275
244
  const sync = () => {
276
245
  kbdSync();
277
- // 兜底摘除历史上可能残留的旧 Tab 栏(正常路径下走到这里说明宿主版未注入,
246
+ // 兜底摘除历史上可能残留的旧 chrome(正常路径下走到这里说明宿主版未注入,
278
247
  // 不会有第二份;保险起见 sync 周期里也保持全局只有一份)。
279
- document.querySelectorAll(".dsh-mobile-tabbar").forEach((el) => { if (el !== tabbar) el.remove() });
248
+ document.querySelectorAll(".dsh-mobile-tabbar").forEach((el) => el.remove());
280
249
  const on = narrow() && !!frameEl() && !dialogOpen();
281
250
  const collapsed = sidebarCollapsed();
282
- // 右侧详情面板(窄屏原生全屏,z 40)打开时:Tab 栏 65 比它高会压边,整体让位,
283
- // 面板收起(它自带折叠钮)后再回来。
251
+ // 右侧详情面板(窄屏原生全屏,z 40)打开时:汉堡让位,面板收起(它自带折叠钮)后再回来。
284
252
  const right = rightOpen();
285
253
  // 遮罩只在抽屉真的展开时显示——它是全屏 touch-action:none 浮层,
286
254
  // 收起态残留会让整页滑不动。
287
255
  if (scrim) scrim.style.display = on && !collapsed ? "" : "none";
288
- tabbar.style.display = narrow() && !!frameEl() && !panelNav.open && !right ? "" : "none";
289
- syncTabs();
256
+ burger.style.display = narrow() && !!frameEl() && !panelNav.open && !right ? "" : "none";
257
+ syncBurger();
290
258
  };
291
259
  sync();
292
- pollTasks();
293
260
  // MutationObserver 跟属性翻转即时同步;1.5s 轮询覆盖路由重渲等一切边角
294
261
  const observer = typeof MutationObserver !== "undefined" ? new MutationObserver(sync) : null;
295
262
  if (observer) observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-sidebar-collapsed"], subtree: true });
@@ -299,17 +266,15 @@ function mobileChrome() {
299
266
  else if (mq.addListener) mq.addListener(onMq);
300
267
  window.addEventListener("resize", kbdSync);
301
268
  if (vv && vv.addEventListener) vv.addEventListener("resize", kbdSync);
302
- const onVisible = () => { if (document.visibilityState === "visible") pollTasks() };
303
- document.addEventListener("visibilitychange", onVisible);
304
269
 
305
270
  // 捕获阶段统一处理:
306
- // Tab 栏自身排除(否则页签动作会被「点外部收起」当场撤销);
271
+ // 汉堡自身排除(否则展开动作会被「点外部收起」当场撤销);
307
272
  // 遮罩不挂自己的 click——它的点击也走这里 collapse(),单一路径防止同一事件两次 toggle。
308
273
  const onClick = (e) => {
309
274
  if (!narrow()) return;
310
275
  const t = e.target;
311
276
  if (!t || !t.closest) return;
312
- if (t.closest(".dsh-mobile-tabbar")) return;
277
+ if (t.closest(".dsh-mobile-burger")) return;
313
278
  const expanded = !!document.querySelector('button[aria-label="收起侧边栏"],button[aria-label="Collapse sidebar"]');
314
279
  const col = document.querySelector('[class*="sidebarCol"]');
315
280
  const inSidebar = col && col.contains(t);
@@ -343,15 +308,14 @@ function mobileChrome() {
343
308
  if (observer) observer.disconnect();
344
309
  clearInterval(timer);
345
310
  clearInterval(tasksTimer);
346
- unsubPanel();
311
+ document.removeEventListener("visibilitychange", onVisible);
347
312
  if (mq.removeEventListener) mq.removeEventListener("change", onMq);
348
313
  else if (mq.removeListener) mq.removeListener(onMq);
349
314
  window.removeEventListener("resize", kbdSync);
350
315
  if (vv && vv.removeEventListener) vv.removeEventListener("resize", kbdSync);
351
- document.removeEventListener("visibilitychange", onVisible);
352
316
  document.documentElement.classList.remove("dsh-dock-kbd");
353
317
  if (scrim) scrim.remove();
354
- tabbar.remove();
318
+ burger.remove();
355
319
  if (window.__dshDockMobileDrawer === "client") delete window.__dshDockMobileDrawer;
356
320
  };
357
321
  }
@@ -412,7 +376,7 @@ function MobileView() {
412
376
  react.createElement("span", { className: "dkmob-label" }, "手机端 chrome"),
413
377
  react.createElement("span", { className: "dkmob-state" + (chrome ? " on" : "") }, chrome ? "≤700px,生效中" : "未命中"),
414
378
  react.createElement("span", { className: "dkmob-dim" },
415
- "底部 Tab 栏(会话 / 插件 / 设置)+ 侧栏抽屉遮罩 + 软键盘让位;会话入口在底部「会话」页签,不设左缘浮动按钮。",
379
+ "左上角汉堡菜单(展开左侧会话抽屉)+ 侧栏抽屉遮罩 + 软键盘让位;底部 Tab 栏已按用户要求移除,插件/设置等入口在抽屉里。",
416
380
  owner === "host" ? "当前由宿主注入版驱动(重启 dsh web 后生效的那套)。" : owner === "client" ? "当前由客户端自建版驱动(刷新即生效)。" : "")),
417
381
  react.createElement("div", { className: "dkmob-note" },
418
382
  "生效中的优化:输入类控件字号拉到 16px(避免 iOS 聚焦缩放整页)· 纯图标按钮面积兜底 ≥36px · ",
@@ -425,7 +389,7 @@ function MobileView() {
425
389
  function MobileStat() {
426
390
  const { active, chrome } = useMobileState();
427
391
  if (!active) return react.createElement("span", null, "当前视口未命中手机形态,样式未注入");
428
- return react.createElement("span", null, "手机模式生效中 · " + (chrome ? "底部 Tab 栏已接管会话入口" : "chrome 带宽未命中(≤700px 才启用)"));
392
+ return react.createElement("span", null, "手机模式生效中 · " + (chrome ? "左上角汉堡菜单已接管会话入口" : "chrome 带宽未命中(≤700px 才启用)"));
429
393
  }
430
394
 
431
395
  // —— 样式:本模块全部样式(随功能启停;全局部分依赖 body[data-dk-mobile="1"])。
@@ -442,7 +406,7 @@ const css = [
442
406
  'body[data-dk-mobile="1"] :is(button, [role="button"]){-webkit-tap-highlight-color:transparent;touch-action:manipulation;}',
443
407
  // 纯图标按钮面积兜底:实测窄屏下一批 28×28 的工具钮很难点中;36px 是不引起行高剧变的上限。
444
408
  // 只收「唯一子元素是 svg」的按钮,避免撑坏带文字的行内控件;排除本模块 chrome 按钮。
445
- 'body[data-dk-mobile="1"] button:has(> svg:only-child):not(.dsh-mobile-tab){min-width:36px;min-height:36px;}',
409
+ 'body[data-dk-mobile="1"] button:has(> svg:only-child):not(.dsh-mobile-burger){min-width:36px;min-height:36px;}',
446
410
  // 代码块横向滚动、行内代码换行:窄屏不撑破版面
447
411
  'body[data-dk-mobile="1"] pre{max-width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch;}',
448
412
  'body[data-dk-mobile="1"] code{overflow-wrap:anywhere;}',
@@ -459,19 +423,31 @@ const css = [
459
423
  ".dkmob-state.on{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary);}",
460
424
  ".dkmob-dim{color:var(--dsw-alias-label-secondary);font-size:12px;}",
461
425
  ".dkmob-note{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.7;border-top:1px solid var(--dsw-alias-border-l1);padding-top:10px;}",
462
- // 手机端 chrome:底部 Tab 栏 + 抽屉遮罩(窄屏 ≤700px;与宿主半部同款)
426
+ // 手机端 chrome:左上角汉堡菜单 + 抽屉遮罩(窄屏 ≤700px;与宿主半部同款)
463
427
  "@media (max-width:700px){",
464
428
  ".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}",
465
- ".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}",
466
- ".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;position:relative}",
467
- ".dsh-mobile-tab svg{width:21px;height:21px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}",
468
- ".dsh-mobile-tab.on{color:var(--dsw-alias-label-primary,#e6eaf2)}",
469
- ".dsh-mobile-tab.on svg{color:var(--dk-accent,#4d9fff)}",
470
- ".dsh-mobile-tab:active{transform:scale(.94)}",
471
- ".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}",
472
- ".dsh-mobile-tab-badge.wait{background:#ef4444;animation:dsh-badge-pulse 1.2s ease-in-out infinite}",
473
- "@keyframes dsh-badge-pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.18)}}",
474
- "@media (prefers-reduced-motion:reduce){.dsh-mobile-tab-badge.wait{animation:none}}",
429
+ // 左上角汉堡菜单(三条横线):44×44 触控面积,避让刘海/圆角安全区;z 66 高于正文、
430
+ // 低于遮罩 75 / 抽屉 80——抽屉展开时被自然盖住。
431
+ // ⚠️ 视觉上刻意「不贴合卡片」:页面左上角本来就是空的,原生同类控件(右上的侧栏开关)
432
+ // 是**裸图标**——无底色、无边框、无阴影。早期版本做成白底圆角卡片 + 阴影 + 模糊,
433
+ // 悬浮在白底页面上像一块贴上去的异物(用户实测反馈「没有贴合边缘,很突兀」)。
434
+ // 现在与原生同款:透明底、无边框无阴影,只留图标;点按才给极轻的底色反馈。
435
+ // 视觉重量靠图标本身撑住(22px、1.9px 描边、主文字色),不靠容器。
436
+ ".dsh-mobile-burger{position:fixed;left:env(safe-area-inset-left,0px);top:env(safe-area-inset-top,0px);z-index:66;box-sizing:border-box;width:44px;height:44px;display:flex;align-items:center;justify-content:center;padding:0;border:0;border-radius:0;background:transparent;color:var(--dsw-alias-label-primary,#e6eaf2);cursor:pointer;-webkit-tap-highlight-color:transparent;touch-action:manipulation;transition:color .15s var(--ds-ease-in-out,ease)}",
437
+ ".dsh-mobile-burger svg{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:1.9;stroke-linecap:round}",
438
+ // 展开态:图标换成强调色(与原生「已激活」的语义一致),不引入额外容器。
439
+ ".dsh-mobile-burger.on{color:var(--dk-accent,#4d9fff)}",
440
+ // 点按反馈走图标本身(用 :active 的底色透出,范围就是 44×44 触控区)。
441
+ ".dsh-mobile-burger:active{background:color-mix(in srgb,currentColor 12%,transparent)}",
442
+ // 待确认任务角标:锚在按钮右上角的小圆点/数字。刻意做成「贴边小圆点」而不是
443
+ // 底部 Tab 栏那种带容器的页签角标——汉堡是裸图标,角标再带容器就又突兀了。
444
+ // 等待确认(要人介入)= 红点 + 脉冲;进行中 = 蓝色数字(弱一级,不抢注意力)。
445
+ ".dsh-mobile-burger-badge{position:absolute;top:4px;right:4px;box-sizing:border-box;min-width:8px;height:8px;padding:0;border-radius:999px;background:#4d9fff;font:700 9px/8px inherit;font-variant-numeric:tabular-nums;text-align:center;color:#fff;pointer-events:none;box-shadow:0 0 0 2px var(--dsw-alias-bg-layer-1,#12151b)}",
446
+ ".dsh-mobile-burger-badge[hidden]{display:none}",
447
+ ".dsh-mobile-burger-badge:not([hidden]){min-width:16px;height:16px;line-height:16px;padding:0 4px}",
448
+ ".dsh-mobile-burger-badge.wait{background:#ef4444;animation:dsh-burger-badge-pulse 1.2s ease-in-out infinite}",
449
+ "@keyframes dsh-burger-badge-pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.18)}}",
450
+ "@media (prefers-reduced-motion:reduce){.dsh-mobile-burger-badge.wait{animation:none}}",
475
451
  // ⚠️ 「设置页滑不动」的关键兜底(客户端无条件携带 = 刷新即生效,不必等宿主重启):
476
452
  // 窄屏把设置弹层改成纵向堆叠后,内容列若是默认 min-height:auto 就会被内容撑高
477
453
  // (无头 390×844 实测列高 1216px 塞在 844px 视口里)、再被 .panel 的 overflow:hidden
@@ -479,19 +455,26 @@ const css = [
479
455
  // 都不动。补 min-height:0 后 scrollTop 0 → 425。宿主半部 MOBILE_LAYOUT_CSS 同款;
480
456
  // 两处都写是因为宿主注入要重启 dsh web 才更新,而客户端样式刷新即生效,覆盖窗口期。
481
457
  '[role="dialog"][class*="panel"]>[class*="content"]{min-height:0!important}',
482
- // 输入区吸附到 Tab 栏上方 + 滚动体留白(与宿主半部同款)。
458
+ // 输入区吸附到视口底部 + 滚动体留白(与宿主半部同款)。
459
+ // ⚠️ 底部留白只保留 iPhone home indicator 的安全区,不再额外留白。沿革:原 72px 是
460
+ // 「底部 Tab 栏高度 + 安全区」;Tab 栏移除后先收到 24px,但实测(真实实例 390×844
461
+ // 像素扫描)输入卡片下方仍有 **51px** 空白带,其中 24px 是这里的滚动体内边距、
462
+ // 6px 是下面 composerSeat 的内边距——两者都是 Tab 栏时代的遗留,在没有底栏的页面上
463
+ // 纯属浪费。收窄到「只有安全区」后卡片下沿更贴近视口底,这 30px 直接变成会话区
464
+ // 可显示的正文(滚动体内边距在 flex 容器里会把 composerSeat 连同消息区一起下移/增高)。
465
+ // dsh 自带的 ~21px 底部间距(卡片 margin)保留,视觉上仍有呼吸感,不会贴死屏幕边缘。
483
466
  // ⚠️ 绝不能给滚动体加 justify-content:flex-end/center 贴底:内容会被挤到滚动体
484
467
  // 起点之外,Chrome 不把起点方向溢出算进 scrollHeight(手机视口实测
485
468
  // scrollHeight==clientHeight、maxScroll==0),会话记录彻底滚不动、首条消息永远看不到。
486
469
  // 宿主半部旧版注入里还留着那条 flex-end(要重启 dsh web 才消失),这里用更高优先级
487
- // 的选择器显式复位:刷新页面即修好,不必等重启。
488
- '[class*="scrollBody"][data-conversation-scroll]{justify-content:flex-start !important;padding-bottom:calc(72px + env(safe-area-inset-bottom,0px)) !important}',
489
- 'html:not(.dsh-dock-kbd) [class*="scrollBody"]{padding-bottom:calc(72px + env(safe-area-inset-bottom,0px)) !important}',
490
- 'html.dsh-dock-kbd [class*="scrollBody"]{padding-bottom:12px !important}',
491
- '[class*="scrollBody"]>[class*="composerSeat"]{margin-top:auto;padding-bottom:6px}',
470
+ // 的选择器显式复位:刷新页面即修好,不必等重启。贴底只能用输入区自己的 margin-top:auto。
471
+ '[class*="scrollBody"][data-conversation-scroll]{justify-content:flex-start !important;padding-bottom:env(safe-area-inset-bottom,0px) !important}',
472
+ 'html:not(.dsh-dock-kbd) [class*="scrollBody"]{padding-bottom:env(safe-area-inset-bottom,0px) !important}',
473
+ 'html.dsh-dock-kbd [class*="scrollBody"]{padding-bottom:env(safe-area-inset-bottom,0px) !important}',
474
+ '[class*="scrollBody"]>[class*="composerSeat"]{margin-top:auto;padding-bottom:0}',
492
475
  // 输入区工具行上的 dsh-dock chip(用量/余额):窄屏下 28cqw 的宽度上限把它们挤成
493
476
  // 「…」「余..」(手机实测),既读不出数值又白占输入行空间。手机端整组收起,数值在
494
- // 底部「插件」里看(用量/余额页信息更全);桌面端不动。
477
+ // 功能坞(抽屉里进)看;桌面端不动。
495
478
  '.dockchip-row{display:none}',
496
479
  '[class*="sidebarCol"]:has([class*="collapsed"]){visibility:hidden}',
497
480
  // ⚠️ 列级 visibility:hidden 在 dsh 0.2.0 上挡不住:工作区浏览器给自己的
@@ -506,9 +489,9 @@ const css = [
506
489
  // 设置/插件管理弹层挂在侧栏列内部:上面隐藏整列会把它连坐隐藏(空气弹层)。
507
490
  // visibility 可被后代覆盖,弹层关闭态整体卸载,所以只在真正打开时命中。
508
491
  '[class*="sidebarCol"]:has([class*="collapsed"]) [role="dialog"]{visibility:visible}',
509
- 'html.dsh-dock-kbd .dsh-mobile-tabbar{display:none !important}',
510
- // 功能坞面板在手机端抬到原生弹层之上:从侧栏进的官方插件管理页等原生弹层 z 序高于
511
- // 面板默认的 200,功能坞开在下面会被盖住——「插件」页签点了像没反应/露出原页面。
492
+ 'html.dsh-dock-kbd .dsh-mobile-burger{display:none !important}',
493
+ // 功能坞面板在手机端抬到原生弹层之上:从抽屉进的官方插件管理页等原生弹层 z 序高于
494
+ // 面板默认的 200,功能坞开在下面会被盖住。抽屉展开时从侧栏 rail 点功能坞图标进入。
512
495
  '[class*="dockm-backdrop"]{z-index:2000 !important}',
513
496
  "}",
514
497
  ].join("\n");
@@ -518,7 +501,7 @@ export const feature = {
518
501
  name: "手机适配",
519
502
  order: 170,
520
503
  accent: "#34d399",
521
- description: "手机/窄屏排版与触控兜底:底部 Tab 栏 + 侧栏抽屉、16px 输入防 iOS 缩放、图标按钮触控面积、代码块滚动、滚动兜底",
504
+ description: "手机/窄屏排版与触控兜底:左上角汉堡菜单 + 侧栏抽屉、16px 输入防 iOS 缩放、图标按钮触控面积、代码块滚动、滚动兜底",
522
505
  defaultEnabled: true,
523
506
  css,
524
507
  Overlay: MobileOverlay,
@@ -20,6 +20,8 @@ export const feature = {
20
20
  setup(ctx) {
21
21
  const disposers = []
22
22
  const dispose = () => {
23
+ // 撤掉「本宿主注册了 runstate 路由」旗标(见下方注册处的说明)
24
+ try { if (typeof window !== 'undefined' && window.__dshDockRunstate) delete window.__dshDockRunstate } catch { /* ignore */ }
23
25
  while (disposers.length > 0) {
24
26
  const fn = disposers.pop()
25
27
  try { if (typeof fn === 'function') fn() } catch { /* 停用清理失败不阻断 */ }
@@ -33,6 +35,14 @@ export const feature = {
33
35
 
34
36
  // ===== RPC 路由 =====
35
37
  disposers.push(ctx.inject(['webServer'], (wsCtx) => {
38
+ // 路由注册成功后打旗标:【手机适配】的汉堡角标据此决定要不要轮询 status。
39
+ // 没有它的话,功能停用时角标会先打一次必然失败的请求 → 控制台一条 405/404,
40
+ // 与【用量记录】chip 当年同一个毛病(见 features/tokenlog/view.jsx 的 routeGone 闩锁)。
41
+ // 旗标与 __dshDockMobileDrawer / __dshDockWelcomeAck 同风格:宿主侧唯一事实,客户端只读。
42
+ wsCtx.effect(() => {
43
+ if (typeof window !== 'undefined') window.__dshDockRunstate = true
44
+ return () => { try { if (typeof window !== 'undefined' && window.__dshDockRunstate) delete window.__dshDockRunstate } catch { /* ignore */ } }
45
+ })
36
46
  wsCtx.effect(() => wsCtx.webServer.register({
37
47
  kind: 'prefix',
38
48
  path: '/dsh-dock/runstate',
@@ -13,7 +13,21 @@ import react, { useState, useEffect, useCallback, useMemo, useRef } from "react"
13
13
  import { openPanel } from "../../src/shared.js";
14
14
 
15
15
  // ---------- Host RPC 桥接 ----------
16
+ // 路由不可用(宿主没注册用量路由)的两种成因必须区分开,否则提示会误导人:
17
+ // - 404/405:宿主进程是旧版本,**或**本功能当前在宿主侧是停用的(多设备开关不一致、
18
+ // localStorage 与宿主 settings 不同步都会这样)。此时路由就是不存在,**重试无用**。
19
+ // - 其余状态码:网络/服务端错误,重试有意义。
20
+ // 因此 404/405 会置 routeGone 闩锁:所有调用方据此**停止轮询**,既不再刷控制台报错,
21
+ // 也不会每 10s 打一次无效请求(chip 原本会一直重试到页面关闭)。
22
+ let routeGone = false;
23
+ /** 路由是否已确认不可用(宿主未注册)。一旦为真,本次页面生命周期内不再重试。 */
24
+ export function isTokenlogRouteGone() { return routeGone; }
25
+ /** 把 404/405 的错误翻译成人能看懂、且不误导的提示。 */
26
+ function routeGoneMessage() {
27
+ return "宿主未启用「用量记录」路由(或宿主进程是旧版本),请在功能坞里启用该功能或重启 dsh web";
28
+ }
16
29
  function rpcCall(method, args) {
30
+ if (routeGone) return Promise.reject(new Error(routeGoneMessage()));
17
31
  return fetch("dsh-dock/tokenlog/" + method, {
18
32
  method: "POST",
19
33
  headers: { "content-type": "application/json" },
@@ -22,9 +36,13 @@ function rpcCall(method, args) {
22
36
  .then(async (res) => {
23
37
  const data = await res.json().catch(() => ({}));
24
38
  if (res.ok && data && data.ok === true) return data.data;
25
- // 旧宿主进程没有用量路由(405/404):给出可操作提示而非裸状态码
39
+ // 宿主没注册用量路由(405/404):置闩锁并给出可操作提示而非裸状态码。
40
+ // 注意这既可能是「宿主旧版本」,也可能是「功能在宿主侧被停用」——
41
+ // 实测后者更常见(多设备开关/localStorage 与宿主 settings 不一致),
42
+ // 所以文案两种都提到,且置闩锁后不再无谓重试。
26
43
  if (res.status === 405 || res.status === 404) {
27
- throw new Error("宿主进程是旧版本(没有用量记录路由),重启 dsh web 后重试");
44
+ routeGone = true;
45
+ throw new Error(routeGoneMessage());
28
46
  }
29
47
  if (data && data.ok === false) throw new Error((data.error && data.error.message) || ("HTTP " + res.status));
30
48
  throw new Error("HTTP " + res.status + (data && data.error && data.error.message ? ": " + data.error.message : ""));
@@ -1377,27 +1395,39 @@ export function TokenLogChip(props) {
1377
1395
  useEffect(() => {
1378
1396
  if (!sid) return;
1379
1397
  let cancel = false;
1380
- const load = () => rpcCall("query", { sessionId: sid })
1381
- .then((d) => { if (!cancel) setSnap({ totals: d && d.totals, rate: (d && d.rateUsdCny) || 7.2, err: "" }); })
1382
- .catch((e) => { if (!cancel) setSnap((s) => ({ totals: s.totals, rate: s.rate, err: String((e && e.message) || e) })); });
1398
+ let timer = null;
1399
+ const load = () => {
1400
+ // 路由不可用是**终态**(宿主没注册该路由):直接早退,不再每 10s 打一次
1401
+ // 无效请求、不再刷控制台报错。此前会一直重试到页面关闭(实测控制台连续 405)。
1402
+ if (cancel || isTokenlogRouteGone()) return null;
1403
+ return rpcCall("query", { sessionId: sid })
1404
+ .then((d) => { if (!cancel) setSnap({ totals: d && d.totals, rate: (d && d.rateUsdCny) || 7.2, err: "" }); })
1405
+ .catch((e) => { if (!cancel) setSnap((s) => ({ totals: s.totals, rate: s.rate, err: String((e && e.message) || e) })); });
1406
+ };
1383
1407
  load();
1384
- const timer = setInterval(load, 10000);
1385
- return () => { cancel = true; clearInterval(timer); };
1408
+ timer = setInterval(load, 10000);
1409
+ return () => { cancel = true; if (timer) clearInterval(timer); };
1386
1410
  }, [sid]);
1387
1411
  if (!sid) return null;
1388
1412
  const t = snap.totals;
1413
+ const gone = isTokenlogRouteGone();
1389
1414
  // chip 文案用紧凑人民币金额(2 位小数),避免撑爆输入卡工具行;
1390
1415
  // 完整金额在 title 悬浮提示里。
1391
- const label = snap.err && !t
1392
- ? (snap.err.includes("重启") ? "用量·需重启宿主" : "用量·失败")
1416
+ const label = gone
1417
+ ? "用量·未启用"
1418
+ : snap.err && !t
1419
+ ? (snap.err.includes("重启") || snap.err.includes("启用") ? "用量·未启用" : "用量·失败")
1393
1420
  : (t ? "⛁ " + fmtCompact(t.totalTokens) + (t.cost > 0 ? " · " + fmtCostCny(t.cost, snap.rate) : "") : "⛁ …");
1394
- const title = t
1421
+ const title = gone
1422
+ ? routeGoneMessage() + "\n(可在功能坞里启用「用量记录」)"
1423
+ : t
1395
1424
  ? "本会话 " + fmtNum(t.calls) + " 次调用 · " + fmtNum(t.totalTokens) + " Token · " + fmtCostCny(t.cost, snap.rate) + "(估算)\n点击在功能坞查看用量记录"
1396
1425
  : (snap.err ? snap.err + "\n" : "") + "点击在功能坞查看用量记录";
1426
+ // 路由不可用时不做成报错态(红色),它不是错误,是「这个功能当前没开」。
1397
1427
  return (
1398
- <button type="button" className={"dockchip" + (snap.err && !t ? " err" : "")} title={title} aria-label="会话用量"
1428
+ <button type="button" className={"dockchip" + (snap.err && !t && !gone ? " err" : "")} title={title} aria-label="会话用量"
1399
1429
  onClick={() => openPanel("tokenlog", { sessionId: sid, navAt: Date.now() })}>
1400
- <span className="dockchip-dot" style={{ background: "var(--dk-warn)" }} />
1430
+ <span className="dockchip-dot" style={{ background: gone ? "var(--dsw-alias-label-tertiary,#8b95ab)" : "var(--dk-warn)" }} />
1401
1431
  <span>{label}</span>
1402
1432
  </button>
1403
1433
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-dock",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "DeepSeek Harness 功能坞插件(dsh-dock):模块化功能中枢——每个功能一个 features/ 模块(可单独提取成包发布,又可经 dockBridge 装回面板)。含用量记录(LLM 调用记账/费用统计,支持各模型自定义单价、多段分时价与周末/节假日谷价)、模型设置、图片理解代理、模型余额、任务动画(19 种运行动画 + 桌面伙伴 3D 场景)、任务通知(完成/异常/需确认通知、提示音、系统通知、钉钉/飞书推送)、运行状态(进行中任务与最近完成一览);侧栏入口 + 可拖拽弹窗(最大化/最小化/缩放)。",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/src/client.jsx CHANGED
@@ -31,7 +31,7 @@ import { feature as fMobileRelay } from "../features/mobile-relay/view.jsx";
31
31
  import { feature as fMobile } from "../features/mobile/view.js";
32
32
 
33
33
  const name = "dsh-dock";
34
- const DOCK_VERSION = "0.14.0";
34
+ const DOCK_VERSION = "0.15.0";
35
35
 
36
36
  // ---- 内置功能注册表:新功能 = features/<id>/ 加模块 + 这里 import 一行 ----
37
37
  const BUILTIN_FEATURES = [fTokenlog, fModelconfig, fHeartbeat, fTheme, fBalance, fAnimation, fNotify, fRunState, fGames, fMobileRelay, fMobile];