dsh-dock 0.14.0 → 0.16.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,51 @@ 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}}",
451
+ // ⚠️ 给标题让出汉堡宽度(用户实测:标题被汉堡压住——截图里「更新所有仓库代码」的首字
452
+ // 直接叠在三条横线上)。汉堡是 fixed 定位(贴安全区、44×44),**不占布局**,而原生标题
453
+ // 从头部的 padding-left:20px 起排——真实实例 390×844 逐字实测:会话标题首字 x=28、
454
+ // 汉堡右缘 44 → **重叠 16px**;插件页/自动化任务页的页头(自身 padding-left:0,靠外层
455
+ // 容器给 24px)首字 x=24 → 重叠 20px。
456
+ // 改法照原生排版习惯走:**给容器补内边距**,而不是给汉堡加底色/加宽(那是把问题搬到视觉上)。
457
+ // · 会话页头 header:has([class*="titleRow"])(唯一含 titleRow 的 header):左内边距
458
+ // 20px → 44px,正好等于汉堡宽度;标题自带 8px 内边距 → 首字落在 x=52,与汉堡留 8px。
459
+ // · 插件页/自动化任务页头 [class$="_pageHead"] / [class$="_pageHeading"]
460
+ // (DSH 0.2.0-rc.2 实测:plugin-manager 的 ZVcBiW_pageHead、schedule 的
461
+ // CxUija_pageHeading):左内边距 0 → 28px → 首字 x=52,同样留 8px。
462
+ // 这两条页头是 flex + gap:16px(标题块 | 工具栏),补内边距会压缩内容宽度——实测
463
+ // 430/390/360/320px 下「添加插件」按钮与描述文案会被挤到换行(按钮从单行变两行)。
464
+ // 故同时把 gap 收到 4px 补回 12px:四个宽度实测行数零回归、无横向溢出。
465
+ // 用 `[class$=…]`(语义后缀)而非 `[class*=…]`:DSH 类名是「哈希前缀 + 语义后缀」,
466
+ // 后缀稳定、前缀随版本变;`$=` 也避开 `pageHeadActions` 这类同前缀的无关元素。
467
+ // 挂 body[data-dk-mobile="1"]:本模块 css 字段会被无条件并入全局样式,停用本功能即摘掉
468
+ // body 属性 → 规则空转、原生排版还原(与文件头「开关协议」一致)。
469
+ 'body[data-dk-mobile="1"] header:has([class*="titleRow"]){padding-left:calc(44px + env(safe-area-inset-left,0px))}',
470
+ 'body[data-dk-mobile="1"] [class$="_pageHead"],body[data-dk-mobile="1"] [class$="_pageHeading"]{padding-left:calc(28px + env(safe-area-inset-left,0px));gap:4px}',
475
471
  // ⚠️ 「设置页滑不动」的关键兜底(客户端无条件携带 = 刷新即生效,不必等宿主重启):
476
472
  // 窄屏把设置弹层改成纵向堆叠后,内容列若是默认 min-height:auto 就会被内容撑高
477
473
  // (无头 390×844 实测列高 1216px 塞在 844px 视口里)、再被 .panel 的 overflow:hidden
@@ -479,19 +475,26 @@ const css = [
479
475
  // 都不动。补 min-height:0 后 scrollTop 0 → 425。宿主半部 MOBILE_LAYOUT_CSS 同款;
480
476
  // 两处都写是因为宿主注入要重启 dsh web 才更新,而客户端样式刷新即生效,覆盖窗口期。
481
477
  '[role="dialog"][class*="panel"]>[class*="content"]{min-height:0!important}',
482
- // 输入区吸附到 Tab 栏上方 + 滚动体留白(与宿主半部同款)。
478
+ // 输入区吸附到视口底部 + 滚动体留白(与宿主半部同款)。
479
+ // ⚠️ 底部留白只保留 iPhone home indicator 的安全区,不再额外留白。沿革:原 72px 是
480
+ // 「底部 Tab 栏高度 + 安全区」;Tab 栏移除后先收到 24px,但实测(真实实例 390×844
481
+ // 像素扫描)输入卡片下方仍有 **51px** 空白带,其中 24px 是这里的滚动体内边距、
482
+ // 6px 是下面 composerSeat 的内边距——两者都是 Tab 栏时代的遗留,在没有底栏的页面上
483
+ // 纯属浪费。收窄到「只有安全区」后卡片下沿更贴近视口底,这 30px 直接变成会话区
484
+ // 可显示的正文(滚动体内边距在 flex 容器里会把 composerSeat 连同消息区一起下移/增高)。
485
+ // dsh 自带的 ~21px 底部间距(卡片 margin)保留,视觉上仍有呼吸感,不会贴死屏幕边缘。
483
486
  // ⚠️ 绝不能给滚动体加 justify-content:flex-end/center 贴底:内容会被挤到滚动体
484
487
  // 起点之外,Chrome 不把起点方向溢出算进 scrollHeight(手机视口实测
485
488
  // scrollHeight==clientHeight、maxScroll==0),会话记录彻底滚不动、首条消息永远看不到。
486
489
  // 宿主半部旧版注入里还留着那条 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}',
490
+ // 的选择器显式复位:刷新页面即修好,不必等重启。贴底只能用输入区自己的 margin-top:auto。
491
+ '[class*="scrollBody"][data-conversation-scroll]{justify-content:flex-start !important;padding-bottom:env(safe-area-inset-bottom,0px) !important}',
492
+ 'html:not(.dsh-dock-kbd) [class*="scrollBody"]{padding-bottom:env(safe-area-inset-bottom,0px) !important}',
493
+ 'html.dsh-dock-kbd [class*="scrollBody"]{padding-bottom:env(safe-area-inset-bottom,0px) !important}',
494
+ '[class*="scrollBody"]>[class*="composerSeat"]{margin-top:auto;padding-bottom:0}',
492
495
  // 输入区工具行上的 dsh-dock chip(用量/余额):窄屏下 28cqw 的宽度上限把它们挤成
493
496
  // 「…」「余..」(手机实测),既读不出数值又白占输入行空间。手机端整组收起,数值在
494
- // 底部「插件」里看(用量/余额页信息更全);桌面端不动。
497
+ // 功能坞(抽屉里进)看;桌面端不动。
495
498
  '.dockchip-row{display:none}',
496
499
  '[class*="sidebarCol"]:has([class*="collapsed"]){visibility:hidden}',
497
500
  // ⚠️ 列级 visibility:hidden 在 dsh 0.2.0 上挡不住:工作区浏览器给自己的
@@ -506,9 +509,9 @@ const css = [
506
509
  // 设置/插件管理弹层挂在侧栏列内部:上面隐藏整列会把它连坐隐藏(空气弹层)。
507
510
  // visibility 可被后代覆盖,弹层关闭态整体卸载,所以只在真正打开时命中。
508
511
  '[class*="sidebarCol"]:has([class*="collapsed"]) [role="dialog"]{visibility:visible}',
509
- 'html.dsh-dock-kbd .dsh-mobile-tabbar{display:none !important}',
510
- // 功能坞面板在手机端抬到原生弹层之上:从侧栏进的官方插件管理页等原生弹层 z 序高于
511
- // 面板默认的 200,功能坞开在下面会被盖住——「插件」页签点了像没反应/露出原页面。
512
+ 'html.dsh-dock-kbd .dsh-mobile-burger{display:none !important}',
513
+ // 功能坞面板在手机端抬到原生弹层之上:从抽屉进的官方插件管理页等原生弹层 z 序高于
514
+ // 面板默认的 200,功能坞开在下面会被盖住。抽屉展开时从侧栏 rail 点功能坞图标进入。
512
515
  '[class*="dockm-backdrop"]{z-index:2000 !important}',
513
516
  "}",
514
517
  ].join("\n");
@@ -518,7 +521,7 @@ export const feature = {
518
521
  name: "手机适配",
519
522
  order: 170,
520
523
  accent: "#34d399",
521
- description: "手机/窄屏排版与触控兜底:底部 Tab 栏 + 侧栏抽屉、16px 输入防 iOS 缩放、图标按钮触控面积、代码块滚动、滚动兜底",
524
+ description: "手机/窄屏排版与触控兜底:左上角汉堡菜单 + 侧栏抽屉、16px 输入防 iOS 缩放、图标按钮触控面积、代码块滚动、滚动兜底",
522
525
  defaultEnabled: true,
523
526
  css,
524
527
  Overlay: MobileOverlay,
@@ -20,7 +20,7 @@ import { stat } from 'node:fs/promises'
20
20
  import { homedir } from 'node:os'
21
21
  import { dirname, join } from 'node:path'
22
22
  import yaml from 'js-yaml'
23
- import { DOCK_NS, readBody, sendJson, readDockRoot, mutateDockSection } from '../../src/host-core.js'
23
+ import { DOCK_NS, readBody, sendJson, rejectUntrustedRequest, readDockRoot, mutateDockSection } from '../../src/host-core.js'
24
24
  import { lanAddresses, startProtectedLanGateway } from './gateway.js'
25
25
 
26
26
  function dshHome() { return process.env.DSH_HOME || join(homedir(), '.dsh') }
@@ -389,6 +389,7 @@ export const feature = {
389
389
  kind: 'prefix',
390
390
  path: '/dsh-dock/mobile-relay',
391
391
  async handler(req, res) {
392
+ if (rejectUntrustedRequest(ctx, req, res)) return
392
393
  try {
393
394
  const method = routeMethod(req)
394
395
  const payload = await readBody(req)