@qzsy/dsh-bridge 3.2.1 → 3.3.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # 更新日志
2
2
 
3
+ ## [v3.3.0] - 2026-11-02
4
+
5
+ ### ✨ 新增
6
+
7
+ - **移动端抽屉导航**:手机访问 DSH 时,左侧栏(会话 / 工作区 / 插件入口)改为从左侧滑出的 sheet,内容区占满屏幕;此前展开侧栏会把内容挤到 110px。
8
+ - **顶部菜单按钮**:窄屏左上角固定一枚菜单按钮,点击打开/关闭抽屉;抽屉打开时按钮隐藏并显示半透明遮罩。
9
+ - **收起路径**:点遮罩、点会话行、点「新建会话 / 收起侧边栏」、按 Esc、转回桌面宽度都会自动收起;桌面端完全不启用。
10
+ - **宿主重渲染自愈**:按钮 / 遮罩 / 样式被宿主移除时,`MutationObserver` 会自动补回。
11
+
12
+ ### 🧪 验证
13
+
14
+ - 真实无头 Chrome + 运行中的 DSH 验证:390×844 下内容 390px 占满、抽屉宽度 300px、遮罩/按钮 aria 正确;遮罩 / Esc / 会话行 / 断点切换收起均通过;构建产物 `client.js` 的 `apply()` 安装验证通过。
15
+
16
+
3
17
  ## [v3.2.1] - 2026-11-02
4
18
 
5
19
  ### 🐛 修复
package/README.md CHANGED
@@ -42,6 +42,14 @@ dsh plugin --profile <name> add @qzsy/dsh-bridge
42
42
 
43
43
  面板会把 Host 路由(存在时)显示为主地址,并同时列出 Path 路由。
44
44
 
45
+ ## 手机访问
46
+
47
+ 窄屏(≤ 767px)访问 DSH 时,插件会自动把左侧栏改成抽屉,不需要额外配置:
48
+
49
+ - 内容区占满屏宽,左上角「菜单」按钮打开会话 / 工作区 / 插件入口;
50
+ - 点遮罩、点会话行、按 Esc 或转回桌面宽度都会自动收起;
51
+ - 桌面端保持 DSH 原生布局,不受影响。
52
+
45
53
  ## 本地门禁与安全语义
46
54
 
47
55
  插件会在本机监听一个代理端口(默认 `3092`),把请求转发给 DSH 本体并注入 DSH 原生
package/client/client.js CHANGED
@@ -119,7 +119,9 @@ var zh = {
119
119
  durationHours: "${hours} \u5C0F\u65F6",
120
120
  durationHoursMinutes: "${hours} \u5C0F\u65F6 ${minutes} \u5206",
121
121
  durationMinutes: "${minutes} \u5206\u949F",
122
- durationUnderMinute: "\u4E0D\u8DB3 1 \u5206\u949F"
122
+ durationUnderMinute: "\u4E0D\u8DB3 1 \u5206\u949F",
123
+ menuOpen: "\u6253\u5F00\u83DC\u5355",
124
+ menuClose: "\u5173\u95ED\u83DC\u5355"
123
125
  };
124
126
  var en = {
125
127
  title: "Remote Access",
@@ -186,7 +188,9 @@ var en = {
186
188
  durationHours: "${hours} h",
187
189
  durationHoursMinutes: "${hours} h ${minutes} min",
188
190
  durationMinutes: "${minutes} min",
189
- durationUnderMinute: "< 1 min"
191
+ durationUnderMinute: "< 1 min",
192
+ menuOpen: "Open menu",
193
+ menuClose: "Close menu"
190
194
  };
191
195
  function detectLang(host = {}) {
192
196
  try {
@@ -211,6 +215,276 @@ function createTranslator(host = {}) {
211
215
  };
212
216
  }
213
217
 
218
+ // client/mobile.js
219
+ var MOBILE_MAX_WIDTH = 767;
220
+ var MOBILE_DRAWER_CSS_ID = "qzsy-mobile-drawer-styles";
221
+ var MOBILE_UI_FLAG = "data-qzsy-mobile-drawer";
222
+ var OPEN_CLASS = "qzsy-drawer-open";
223
+ var MENU_CLASS = "qzsy-mobile-menu-btn";
224
+ var BACKDROP_CLASS = "qzsy-mobile-backdrop";
225
+ var STYLE_PLUGIN = "@qzsy/dsh-bridge";
226
+ var MOBILE_DRAWER_CSS = `
227
+ .qzsy-mobile-menu-btn,
228
+ .qzsy-mobile-backdrop {
229
+ display: none;
230
+ }
231
+
232
+ @media (max-width: 767px) {
233
+ /* 1. \u5DE6\u4FA7\u680F\u8131\u79BB grid\uFF0C\u5185\u5BB9\u5360\u6EE1\uFF1B\u9876\u90E8\u7559\u51FA 52px \u7ED9\u83DC\u5355\u6309\u94AE */
234
+ [data-slot="root"] > div[class*="_frame"] {
235
+ grid-template-columns: 0px minmax(0px, 1fr) minmax(0px, 0px) !important;
236
+ padding-top: 52px !important;
237
+ box-sizing: border-box !important;
238
+ }
239
+ div[class*="_centerCol"] { grid-column: 2 !important; }
240
+ div[class*="_rightbarCol"] { grid-column: 3 !important; }
241
+ div[class*="_overlayLayer"] { grid-column: 1 / -1 !important; }
242
+
243
+ /* 2. \u539F\u751F\u4FA7\u680F\u6539\u4E3A fixed \u62BD\u5C49\uFF08\u9ED8\u8BA4\u85CF\u5728\u5C4F\u5E55\u5DE6\u4FA7\uFF09 */
244
+ div[class*="_sidebarCol"] {
245
+ position: fixed !important;
246
+ top: 0 !important;
247
+ left: 0 !important;
248
+ bottom: 0 !important;
249
+ width: min(300px, 84vw) !important;
250
+ max-width: 84vw !important;
251
+ height: 100vh !important;
252
+ height: 100dvh !important;
253
+ z-index: 60 !important;
254
+ background: var(--dsw-alias-bg-layer-1, #ffffff) !important;
255
+ border-right: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.08)) !important;
256
+ box-shadow: none !important;
257
+ transform: translateX(-102%) !important;
258
+ transition: transform 280ms cubic-bezier(0.16, 1, 0.3, 1) !important;
259
+ overflow: hidden !important;
260
+ overscroll-behavior: contain;
261
+ will-change: transform;
262
+ }
263
+ body.qzsy-drawer-open div[class*="_sidebarCol"] {
264
+ transform: translateX(0) !important;
265
+ box-shadow: 8px 0 32px rgba(0, 0, 0, 0.18) !important;
266
+ }
267
+ body.qzsy-drawer-open div[class*="_sidebarCol"] > div > div[class*="_root"] {
268
+ width: 100% !important;
269
+ }
270
+
271
+ /* 3. \u534A\u900F\u660E\u906E\u7F69\uFF1A\u70B9\u51FB\u6536\u8D77 */
272
+ .qzsy-mobile-backdrop {
273
+ display: block;
274
+ position: fixed;
275
+ inset: 0;
276
+ z-index: 55;
277
+ background: rgba(0, 0, 0, 0.42);
278
+ opacity: 0;
279
+ pointer-events: none;
280
+ transition: opacity 280ms ease;
281
+ touch-action: none;
282
+ -webkit-tap-highlight-color: transparent;
283
+ }
284
+ body.qzsy-drawer-open .qzsy-mobile-backdrop {
285
+ opacity: 1;
286
+ pointer-events: auto;
287
+ }
288
+
289
+ /* 4. \u9876\u90E8\u83DC\u5355\u6309\u94AE\uFF08\u4F4D\u4E8E 52px \u7559\u767D\u6761\u5185\uFF0C\u907F\u514D\u906E\u6321\u4F1A\u8BDD\u6807\u9898\uFF09 */
290
+ .qzsy-mobile-menu-btn {
291
+ display: inline-flex;
292
+ position: fixed;
293
+ top: 10px;
294
+ left: 10px;
295
+ width: 40px;
296
+ height: 40px;
297
+ z-index: 65;
298
+ align-items: center;
299
+ justify-content: center;
300
+ padding: 0;
301
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
302
+ border-radius: 12px;
303
+ background: var(--dsw-alias-bg-layer-1, #ffffff);
304
+ color: var(--dsw-alias-label-primary, #111827);
305
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
306
+ cursor: pointer;
307
+ -webkit-tap-highlight-color: transparent;
308
+ transition: opacity 180ms ease, transform 180ms ease;
309
+ }
310
+ .qzsy-mobile-menu-btn:active { transform: scale(0.94); }
311
+ .qzsy-mobile-menu-btn svg { display: block; }
312
+ body.qzsy-drawer-open .qzsy-mobile-menu-btn {
313
+ opacity: 0;
314
+ pointer-events: none;
315
+ }
316
+ }
317
+ `;
318
+ function isElement(value) {
319
+ return Boolean(value) && value.nodeType === 1;
320
+ }
321
+ function isMobileViewport() {
322
+ return typeof window !== "undefined" && Number(window.innerWidth) <= MOBILE_MAX_WIDTH;
323
+ }
324
+ function menuLabel(open) {
325
+ const t = createTranslator({});
326
+ return open ? t("menuClose") : t("menuOpen");
327
+ }
328
+ function injectStyles() {
329
+ let style = document.getElementById(MOBILE_DRAWER_CSS_ID);
330
+ if (!style) {
331
+ style = document.createElement("style");
332
+ style.id = MOBILE_DRAWER_CSS_ID;
333
+ style.dataset.plugin = STYLE_PLUGIN;
334
+ style.dataset.pluginCss = `${STYLE_PLUGIN}/mobile`;
335
+ document.head.appendChild(style);
336
+ }
337
+ if (style.textContent !== MOBILE_DRAWER_CSS) style.textContent = MOBILE_DRAWER_CSS;
338
+ }
339
+ function setupMobileDrawer() {
340
+ if (typeof document === "undefined" || typeof window === "undefined") return () => {
341
+ };
342
+ const root = document.documentElement;
343
+ if (!root || root.getAttribute(MOBILE_UI_FLAG) === "1") return () => {
344
+ };
345
+ root.setAttribute(MOBILE_UI_FLAG, "1");
346
+ let menuBtn = null;
347
+ let observer = null;
348
+ let disposed = false;
349
+ const ensureElements = () => {
350
+ if (disposed || !document.body) return;
351
+ injectStyles();
352
+ let btn = document.querySelector(`.${MENU_CLASS}`);
353
+ if (!btn) {
354
+ btn = document.createElement("button");
355
+ btn.type = "button";
356
+ btn.className = MENU_CLASS;
357
+ btn.innerHTML = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><line x1="3" y1="8" x2="21" y2="8"></line><line x1="3" y1="15" x2="15" y2="15"></line></svg>';
358
+ document.body.appendChild(btn);
359
+ }
360
+ let mask = document.querySelector(`.${BACKDROP_CLASS}`);
361
+ if (!mask) {
362
+ mask = document.createElement("div");
363
+ mask.className = BACKDROP_CLASS;
364
+ mask.setAttribute("aria-hidden", "true");
365
+ document.body.appendChild(mask);
366
+ }
367
+ menuBtn = btn;
368
+ syncAria();
369
+ };
370
+ const isOpen = () => Boolean(document.body?.classList.contains(OPEN_CLASS));
371
+ const syncAria = () => {
372
+ if (!menuBtn) return;
373
+ const label = menuLabel(isOpen());
374
+ if (menuBtn.getAttribute("aria-label") !== label) menuBtn.setAttribute("aria-label", label);
375
+ if (menuBtn.getAttribute("title") !== label) menuBtn.setAttribute("title", label);
376
+ menuBtn.setAttribute("aria-expanded", isOpen() ? "true" : "false");
377
+ };
378
+ const ensureSidebarExpanded = () => {
379
+ const expand = document.querySelector(
380
+ 'button[aria-label*="\u6253\u5F00\u4FA7\u8FB9\u680F"], button[title*="\u6253\u5F00\u4FA7\u8FB9\u680F"], button[aria-label*="Open sidebar"], button[title*="Open sidebar"], div[class*="collapsed"] button[class*="toggle"]'
381
+ );
382
+ if (expand) {
383
+ try {
384
+ expand.click();
385
+ } catch {
386
+ }
387
+ }
388
+ };
389
+ const openDrawer = () => {
390
+ if (disposed || !isMobileViewport() || !document.body) return;
391
+ ensureElements();
392
+ ensureSidebarExpanded();
393
+ document.body.classList.add(OPEN_CLASS);
394
+ syncAria();
395
+ };
396
+ const closeDrawer = ({ restoreFocus = true } = {}) => {
397
+ if (!document.body) return;
398
+ const wasOpen = isOpen();
399
+ document.body.classList.remove(OPEN_CLASS);
400
+ syncAria();
401
+ if (wasOpen && restoreFocus) {
402
+ try {
403
+ menuBtn?.focus?.({ preventScroll: true });
404
+ } catch {
405
+ }
406
+ }
407
+ };
408
+ const closeSoon = () => {
409
+ window.setTimeout(() => {
410
+ if (!disposed && isOpen()) closeDrawer({ restoreFocus: false });
411
+ }, 60);
412
+ };
413
+ const onClick = (event) => {
414
+ const target = event.target;
415
+ if (!isElement(target)) return;
416
+ if (target.closest(`.${MENU_CLASS}`)) {
417
+ event.preventDefault();
418
+ if (isOpen()) closeDrawer();
419
+ else openDrawer();
420
+ return;
421
+ }
422
+ if (!isOpen()) return;
423
+ if (target.closest(`.${BACKDROP_CLASS}`)) {
424
+ closeDrawer();
425
+ return;
426
+ }
427
+ const sidebar = document.querySelector('div[class*="_sidebarCol"]');
428
+ if (!sidebar || !sidebar.contains(target)) return;
429
+ if (target.closest('input, textarea, select, [contenteditable="true"]')) return;
430
+ const sessionRow = target.closest('div[class*="sessionRow"]');
431
+ if (sessionRow && !target.closest('button, [role="button"]')) {
432
+ closeSoon();
433
+ return;
434
+ }
435
+ const action = target.closest('button, [role="button"], a');
436
+ if (!action) return;
437
+ const label = `${action.getAttribute("aria-label") || ""} ${action.getAttribute("title") || ""} ${action.innerText || ""}`;
438
+ if (/新建会话|New session|收起侧边栏|Collapse sidebar/.test(label)) {
439
+ closeSoon();
440
+ return;
441
+ }
442
+ if (action.closest('button[class*="panelRow"]') && !/更多|More/.test(label)) {
443
+ closeSoon();
444
+ }
445
+ };
446
+ const onKeyDown = (event) => {
447
+ if (event.key === "Escape" && isOpen()) closeDrawer();
448
+ };
449
+ const onResize = () => {
450
+ if (!isMobileViewport() && isOpen()) closeDrawer({ restoreFocus: false });
451
+ };
452
+ const onVisibilityChange = () => {
453
+ if (document.visibilityState === "hidden" && isOpen()) closeDrawer({ restoreFocus: false });
454
+ };
455
+ injectStyles();
456
+ ensureElements();
457
+ document.addEventListener("click", onClick, true);
458
+ document.addEventListener("keydown", onKeyDown, true);
459
+ document.addEventListener("visibilitychange", onVisibilityChange);
460
+ window.addEventListener("resize", onResize);
461
+ if (typeof MutationObserver === "function" && document.body) {
462
+ observer = new MutationObserver(() => {
463
+ if (!document.querySelector(`.${MENU_CLASS}`) || !document.querySelector(`.${BACKDROP_CLASS}`) || !document.getElementById(MOBILE_DRAWER_CSS_ID)) {
464
+ ensureElements();
465
+ }
466
+ });
467
+ observer.observe(document.body, { childList: true });
468
+ }
469
+ syncAria();
470
+ return () => {
471
+ disposed = true;
472
+ try {
473
+ observer?.disconnect();
474
+ } catch {
475
+ }
476
+ document.removeEventListener("click", onClick, true);
477
+ document.removeEventListener("keydown", onKeyDown, true);
478
+ document.removeEventListener("visibilitychange", onVisibilityChange);
479
+ window.removeEventListener("resize", onResize);
480
+ document.body?.classList.remove(OPEN_CLASS);
481
+ document.querySelector(`.${MENU_CLASS}`)?.remove();
482
+ document.querySelector(`.${BACKDROP_CLASS}`)?.remove();
483
+ document.getElementById(MOBILE_DRAWER_CSS_ID)?.remove();
484
+ root.removeAttribute(MOBILE_UI_FLAG);
485
+ };
486
+ }
487
+
214
488
  // client/index.js
215
489
  var name = PLUGIN_ID;
216
490
  var inject = ["slots", "connection"];
@@ -738,6 +1012,18 @@ var ctxRpc = async () => ({ ok: false, error: { code: "no-context", message: "RP
738
1012
  var serviceControl = null;
739
1013
  function apply(ctx) {
740
1014
  ctxRpc = (endpoint, payload, signal) => ctx.connection.rpc.call(RPC_CHANNEL, endpoint, payload, signal);
1015
+ let disposeMobile = () => {
1016
+ };
1017
+ try {
1018
+ disposeMobile = setupMobileDrawer();
1019
+ } catch {
1020
+ }
1021
+ if (typeof ctx.effect === "function") {
1022
+ try {
1023
+ ctx.effect(() => disposeMobile, `${PLUGIN_ID}: mobile drawer`);
1024
+ } catch {
1025
+ }
1026
+ }
741
1027
  const panel = (props = {}) => h(BridgePanel, { ...props, key: "qzsy-dsh-bridge-panel" });
742
1028
  let visible = true;
743
1029
  let registration = null;
package/client/index.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // 在线态与最近错误。所有网络操作走 Host 侧 RPC(同一 profile 的本地回环)。
5
5
  import { PLUGIN_ID, PACKAGE_NAME, RPC_CHANNEL, RPC_ENDPOINTS } from '../lib/identity.js';
6
6
  import { createTranslator } from './locales.mjs';
7
+ import { setupMobileDrawer } from './mobile.js';
7
8
 
8
9
  export const name = PLUGIN_ID;
9
10
  export const inject = ['slots', 'connection'];
@@ -460,6 +461,19 @@ let serviceControl = null;
460
461
 
461
462
  export function apply(ctx) {
462
463
  ctxRpc = (endpoint, payload, signal) => ctx.connection.rpc.call(RPC_CHANNEL, endpoint, payload, signal);
464
+
465
+ // 移动端增强:左侧栏改为抽屉。桌面端由 CSS 断点自动跳过;
466
+ // 失败只影响移动端布局,绝不能让设置页挂掉。
467
+ let disposeMobile = () => {};
468
+ try {
469
+ disposeMobile = setupMobileDrawer();
470
+ } catch {}
471
+ if (typeof ctx.effect === 'function') {
472
+ try {
473
+ ctx.effect(() => disposeMobile, `${PLUGIN_ID}: mobile drawer`);
474
+ } catch {}
475
+ }
476
+
463
477
  const panel = (props = {}) => h(BridgePanel, { ...props, key: 'qzsy-dsh-bridge-panel' });
464
478
  let visible = true;
465
479
  let registration = null;
@@ -65,6 +65,8 @@ export const zh = {
65
65
  durationHoursMinutes: '${hours} 小时 ${minutes} 分',
66
66
  durationMinutes: '${minutes} 分钟',
67
67
  durationUnderMinute: '不足 1 分钟',
68
+ menuOpen: '打开菜单',
69
+ menuClose: '关闭菜单',
68
70
  };
69
71
 
70
72
  export const en = {
@@ -133,6 +135,8 @@ export const en = {
133
135
  durationHoursMinutes: '${hours} h ${minutes} min',
134
136
  durationMinutes: '${minutes} min',
135
137
  durationUnderMinute: '< 1 min',
138
+ menuOpen: 'Open menu',
139
+ menuClose: 'Close menu',
136
140
  };
137
141
 
138
142
  export function detectLang(host = {}) {
@@ -0,0 +1,306 @@
1
+ // 移动端抽屉导航:把宿主左侧栏(会话/工作区菜单)改成从左侧滑出的 sheet。
2
+ //
3
+ // 背景:DSH 宿主在窄屏下仍把左侧栏压成 56px 图标轨道,展开后会把内容区挤到
4
+ // 110px(实测 390×844)。这里只做一件事:窄屏时内容占满,左侧栏变成带遮罩的
5
+ // 抽屉,由顶部菜单按钮打开;桌面端完全不受影响。
6
+ //
7
+ // 宿主 DOM 是 CSS Modules 哈希类名,本模块只依赖稳定结构/ARIA:
8
+ // div[class*="_frame"] / _sidebarCol / _centerCol / _rightbarCol / _overlayLayer
9
+ // button[aria-label*="打开侧边栏"](英文 Open sidebar)
10
+ // div[class*="sessionRow"](会话行;projectRow 是工作区分组,不能误关)
11
+ // 不写死任何宿主哈希,宿主升级换前缀时选择器仍有效。
12
+ import { createTranslator } from './locales.mjs';
13
+
14
+ export const MOBILE_MAX_WIDTH = 767;
15
+ export const MOBILE_DRAWER_CSS_ID = 'qzsy-mobile-drawer-styles';
16
+ export const MOBILE_UI_FLAG = 'data-qzsy-mobile-drawer';
17
+ const OPEN_CLASS = 'qzsy-drawer-open';
18
+ const MENU_CLASS = 'qzsy-mobile-menu-btn';
19
+ const BACKDROP_CLASS = 'qzsy-mobile-backdrop';
20
+ const STYLE_PLUGIN = '@qzsy/dsh-bridge';
21
+
22
+ export const MOBILE_DRAWER_CSS = `
23
+ .qzsy-mobile-menu-btn,
24
+ .qzsy-mobile-backdrop {
25
+ display: none;
26
+ }
27
+
28
+ @media (max-width: 767px) {
29
+ /* 1. 左侧栏脱离 grid,内容占满;顶部留出 52px 给菜单按钮 */
30
+ [data-slot="root"] > div[class*="_frame"] {
31
+ grid-template-columns: 0px minmax(0px, 1fr) minmax(0px, 0px) !important;
32
+ padding-top: 52px !important;
33
+ box-sizing: border-box !important;
34
+ }
35
+ div[class*="_centerCol"] { grid-column: 2 !important; }
36
+ div[class*="_rightbarCol"] { grid-column: 3 !important; }
37
+ div[class*="_overlayLayer"] { grid-column: 1 / -1 !important; }
38
+
39
+ /* 2. 原生侧栏改为 fixed 抽屉(默认藏在屏幕左侧) */
40
+ div[class*="_sidebarCol"] {
41
+ position: fixed !important;
42
+ top: 0 !important;
43
+ left: 0 !important;
44
+ bottom: 0 !important;
45
+ width: min(300px, 84vw) !important;
46
+ max-width: 84vw !important;
47
+ height: 100vh !important;
48
+ height: 100dvh !important;
49
+ z-index: 60 !important;
50
+ background: var(--dsw-alias-bg-layer-1, #ffffff) !important;
51
+ border-right: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.08)) !important;
52
+ box-shadow: none !important;
53
+ transform: translateX(-102%) !important;
54
+ transition: transform 280ms cubic-bezier(0.16, 1, 0.3, 1) !important;
55
+ overflow: hidden !important;
56
+ overscroll-behavior: contain;
57
+ will-change: transform;
58
+ }
59
+ body.qzsy-drawer-open div[class*="_sidebarCol"] {
60
+ transform: translateX(0) !important;
61
+ box-shadow: 8px 0 32px rgba(0, 0, 0, 0.18) !important;
62
+ }
63
+ body.qzsy-drawer-open div[class*="_sidebarCol"] > div > div[class*="_root"] {
64
+ width: 100% !important;
65
+ }
66
+
67
+ /* 3. 半透明遮罩:点击收起 */
68
+ .qzsy-mobile-backdrop {
69
+ display: block;
70
+ position: fixed;
71
+ inset: 0;
72
+ z-index: 55;
73
+ background: rgba(0, 0, 0, 0.42);
74
+ opacity: 0;
75
+ pointer-events: none;
76
+ transition: opacity 280ms ease;
77
+ touch-action: none;
78
+ -webkit-tap-highlight-color: transparent;
79
+ }
80
+ body.qzsy-drawer-open .qzsy-mobile-backdrop {
81
+ opacity: 1;
82
+ pointer-events: auto;
83
+ }
84
+
85
+ /* 4. 顶部菜单按钮(位于 52px 留白条内,避免遮挡会话标题) */
86
+ .qzsy-mobile-menu-btn {
87
+ display: inline-flex;
88
+ position: fixed;
89
+ top: 10px;
90
+ left: 10px;
91
+ width: 40px;
92
+ height: 40px;
93
+ z-index: 65;
94
+ align-items: center;
95
+ justify-content: center;
96
+ padding: 0;
97
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
98
+ border-radius: 12px;
99
+ background: var(--dsw-alias-bg-layer-1, #ffffff);
100
+ color: var(--dsw-alias-label-primary, #111827);
101
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
102
+ cursor: pointer;
103
+ -webkit-tap-highlight-color: transparent;
104
+ transition: opacity 180ms ease, transform 180ms ease;
105
+ }
106
+ .qzsy-mobile-menu-btn:active { transform: scale(0.94); }
107
+ .qzsy-mobile-menu-btn svg { display: block; }
108
+ body.qzsy-drawer-open .qzsy-mobile-menu-btn {
109
+ opacity: 0;
110
+ pointer-events: none;
111
+ }
112
+ }
113
+ `;
114
+
115
+ function isElement(value) {
116
+ return Boolean(value) && value.nodeType === 1;
117
+ }
118
+
119
+ function isMobileViewport() {
120
+ return typeof window !== 'undefined' && Number(window.innerWidth) <= MOBILE_MAX_WIDTH;
121
+ }
122
+
123
+ function menuLabel(open) {
124
+ const t = createTranslator({});
125
+ return open ? t('menuClose') : t('menuOpen');
126
+ }
127
+
128
+ function injectStyles() {
129
+ let style = document.getElementById(MOBILE_DRAWER_CSS_ID);
130
+ if (!style) {
131
+ style = document.createElement('style');
132
+ style.id = MOBILE_DRAWER_CSS_ID;
133
+ style.dataset.plugin = STYLE_PLUGIN;
134
+ style.dataset.pluginCss = `${STYLE_PLUGIN}/mobile`;
135
+ document.head.appendChild(style);
136
+ }
137
+ if (style.textContent !== MOBILE_DRAWER_CSS) style.textContent = MOBILE_DRAWER_CSS;
138
+ }
139
+
140
+ /**
141
+ * 安装移动端抽屉导航。无 DOM 环境(单测/SSR)安全退化为 no-op。
142
+ * 返回 disposer;宿主若支持 ctx.effect,可注册为插件副作用。
143
+ */
144
+ export function setupMobileDrawer() {
145
+ if (typeof document === 'undefined' || typeof window === 'undefined') return () => {};
146
+ const root = document.documentElement;
147
+ if (!root || root.getAttribute(MOBILE_UI_FLAG) === '1') return () => {};
148
+ root.setAttribute(MOBILE_UI_FLAG, '1');
149
+
150
+ let menuBtn = null;
151
+ let observer = null;
152
+ let disposed = false;
153
+
154
+ const ensureElements = () => {
155
+ if (disposed || !document.body) return;
156
+ injectStyles();
157
+
158
+ let btn = document.querySelector(`.${MENU_CLASS}`);
159
+ if (!btn) {
160
+ btn = document.createElement('button');
161
+ btn.type = 'button';
162
+ btn.className = MENU_CLASS;
163
+ btn.innerHTML = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><line x1="3" y1="8" x2="21" y2="8"></line><line x1="3" y1="15" x2="15" y2="15"></line></svg>';
164
+ document.body.appendChild(btn);
165
+ }
166
+ let mask = document.querySelector(`.${BACKDROP_CLASS}`);
167
+ if (!mask) {
168
+ mask = document.createElement('div');
169
+ mask.className = BACKDROP_CLASS;
170
+ mask.setAttribute('aria-hidden', 'true');
171
+ document.body.appendChild(mask);
172
+ }
173
+ menuBtn = btn;
174
+ syncAria();
175
+ };
176
+
177
+ const isOpen = () => Boolean(document.body?.classList.contains(OPEN_CLASS));
178
+
179
+ const syncAria = () => {
180
+ if (!menuBtn) return;
181
+ const label = menuLabel(isOpen());
182
+ if (menuBtn.getAttribute('aria-label') !== label) menuBtn.setAttribute('aria-label', label);
183
+ if (menuBtn.getAttribute('title') !== label) menuBtn.setAttribute('title', label);
184
+ menuBtn.setAttribute('aria-expanded', isOpen() ? 'true' : 'false');
185
+ };
186
+
187
+ /** 宿主侧栏收起时先点它自己的「打开侧边栏」,让抽屉里有会话列表。 */
188
+ const ensureSidebarExpanded = () => {
189
+ const expand = document.querySelector(
190
+ 'button[aria-label*="打开侧边栏"], button[title*="打开侧边栏"], button[aria-label*="Open sidebar"], button[title*="Open sidebar"], div[class*="collapsed"] button[class*="toggle"]',
191
+ );
192
+ if (expand) {
193
+ try { expand.click(); } catch { /* 宿主没挂载完时忽略,下次打开再试 */ }
194
+ }
195
+ };
196
+
197
+ const openDrawer = () => {
198
+ if (disposed || !isMobileViewport() || !document.body) return;
199
+ ensureElements();
200
+ ensureSidebarExpanded();
201
+ document.body.classList.add(OPEN_CLASS);
202
+ syncAria();
203
+ };
204
+
205
+ const closeDrawer = ({ restoreFocus = true } = {}) => {
206
+ if (!document.body) return;
207
+ const wasOpen = isOpen();
208
+ document.body.classList.remove(OPEN_CLASS);
209
+ syncAria();
210
+ if (wasOpen && restoreFocus) {
211
+ try { menuBtn?.focus?.({ preventScroll: true }); } catch { /* 忽略焦点异常 */ }
212
+ }
213
+ };
214
+
215
+ const closeSoon = () => {
216
+ window.setTimeout(() => {
217
+ if (!disposed && isOpen()) closeDrawer({ restoreFocus: false });
218
+ }, 60);
219
+ };
220
+
221
+ const onClick = (event) => {
222
+ const target = event.target;
223
+ if (!isElement(target)) return;
224
+
225
+ if (target.closest(`.${MENU_CLASS}`)) {
226
+ event.preventDefault();
227
+ if (isOpen()) closeDrawer();
228
+ else openDrawer();
229
+ return;
230
+ }
231
+ if (!isOpen()) return;
232
+
233
+ if (target.closest(`.${BACKDROP_CLASS}`)) {
234
+ closeDrawer();
235
+ return;
236
+ }
237
+
238
+ const sidebar = document.querySelector('div[class*="_sidebarCol"]');
239
+ if (!sidebar || !sidebar.contains(target)) return;
240
+ if (target.closest('input, textarea, select, [contenteditable="true"]')) return;
241
+
242
+ // 会话行点击后收起抽屉;行内的操作按钮(归档/置顶等)不收起
243
+ const sessionRow = target.closest('div[class*="sessionRow"]');
244
+ if (sessionRow && !target.closest('button, [role="button"]')) {
245
+ closeSoon();
246
+ return;
247
+ }
248
+
249
+ const action = target.closest('button, [role="button"], a');
250
+ if (!action) return;
251
+ const label = `${action.getAttribute('aria-label') || ''} ${action.getAttribute('title') || ''} ${action.innerText || ''}`;
252
+ if (/新建会话|New session|收起侧边栏|Collapse sidebar/.test(label)) {
253
+ closeSoon();
254
+ return;
255
+ }
256
+ // 顶部一级菜单(插件/自动化任务/命令面板/…)打开新面板后收起抽屉;
257
+ // 「更多」往往是在抽屉内展开二级菜单,保持打开。
258
+ if (action.closest('button[class*="panelRow"]') && !/更多|More/.test(label)) {
259
+ closeSoon();
260
+ }
261
+ };
262
+
263
+ const onKeyDown = (event) => {
264
+ if (event.key === 'Escape' && isOpen()) closeDrawer();
265
+ };
266
+
267
+ const onResize = () => {
268
+ if (!isMobileViewport() && isOpen()) closeDrawer({ restoreFocus: false });
269
+ };
270
+
271
+ const onVisibilityChange = () => {
272
+ if (document.visibilityState === 'hidden' && isOpen()) closeDrawer({ restoreFocus: false });
273
+ };
274
+
275
+ injectStyles();
276
+ ensureElements();
277
+ document.addEventListener('click', onClick, true);
278
+ document.addEventListener('keydown', onKeyDown, true);
279
+ document.addEventListener('visibilitychange', onVisibilityChange);
280
+ window.addEventListener('resize', onResize);
281
+
282
+ if (typeof MutationObserver === 'function' && document.body) {
283
+ observer = new MutationObserver(() => {
284
+ if (!document.querySelector(`.${MENU_CLASS}`) || !document.querySelector(`.${BACKDROP_CLASS}`) || !document.getElementById(MOBILE_DRAWER_CSS_ID)) {
285
+ ensureElements();
286
+ }
287
+ });
288
+ observer.observe(document.body, { childList: true });
289
+ }
290
+
291
+ syncAria();
292
+
293
+ return () => {
294
+ disposed = true;
295
+ try { observer?.disconnect(); } catch { /* 忽略 */ }
296
+ document.removeEventListener('click', onClick, true);
297
+ document.removeEventListener('keydown', onKeyDown, true);
298
+ document.removeEventListener('visibilitychange', onVisibilityChange);
299
+ window.removeEventListener('resize', onResize);
300
+ document.body?.classList.remove(OPEN_CLASS);
301
+ document.querySelector(`.${MENU_CLASS}`)?.remove();
302
+ document.querySelector(`.${BACKDROP_CLASS}`)?.remove();
303
+ document.getElementById(MOBILE_DRAWER_CSS_ID)?.remove();
304
+ root.removeAttribute(MOBILE_UI_FLAG);
305
+ };
306
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@qzsy/dsh-bridge",
3
- "version": "3.2.1",
3
+ "version": "3.3.0",
4
4
  "description": "DSH 远程隧道云官方客户端插件:一条命令把本机 DeepSeek Harness 接入云端隧道(设备码绑定、短期令牌、本地门禁代理、HTTP/SSE/WebSocket 反向隧道)。",
5
- "releaseNotes": "【v3.2.1】网关发布不再掉线\\n• 网关 drain(滚动重启)后自动重连,不再需要人工点「连接」\\n• 网关在线时面板只显示一行「网关:地址 + 新窗口打开」,掉线才展开详情\\n• npm 仓库地址补 git+ 前缀",
5
+ "releaseNotes": "【v3.3.0】移动端抽屉导航\\n• 手机访问时左侧栏改为从左侧滑出的 sheet,内容区占满屏幕,不再被 56px 图标栏挤窄\\n• 左上角新增菜单按钮:打开会话/工作区/插件入口,点遮罩、会话、Esc 或转回桌面自动收起\\n• 抽屉带 52px 顶部安全留白与 280ms 缓动,桌面端完全不受影响\\n• 宿主重渲染移除节点时自动补回",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
8
8
  "exports": {