@qzsy/dsh-bridge 3.2.1 → 3.3.1

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,37 @@
1
1
  # 更新日志
2
2
 
3
+ ## [v3.3.1] - 2026-11-02
4
+
5
+ ### 🐛 修复
6
+
7
+ - **全面屏安全区**:菜单按钮、内容区、抽屉都按 `safe-area-inset-*` 让位,刘海 / 状态栏 / 底部 home indicator 不再遮挡;横屏左右安全区同样生效。
8
+ - **首次加载闪动**:侧栏首帧先无动画隐藏,再启用 280ms 过渡,页面加载时不再看到侧栏从 0 滑出。
9
+
10
+ ### ✨ 改进
11
+
12
+ - **手势滑动**:支持左缘右滑打开、抽屉内左滑关闭;拖动时抽屉跟手、遮罩同步渐变,松手按距离/速度吸附。
13
+ - **手势防误触**:纵向滚动不触发抽屉;滑动后的合成点击会被吞掉,避免误开/误关会话。
14
+
15
+ ### 🧪 验证
16
+
17
+ - 模拟 47px 顶部 / 34px 底部安全区:按钮 y=57、内容 padding-top=99px,抽屉 padding 47/34 全部生效。
18
+ - 模拟触摸:拖动中 `translateX=-150px`、松手打开;抽屉内左滑关闭;纵向滑动不误开;9/9 通过。
19
+
20
+
21
+ ## [v3.3.0] - 2026-11-02
22
+
23
+ ### ✨ 新增
24
+
25
+ - **移动端抽屉导航**:手机访问 DSH 时,左侧栏(会话 / 工作区 / 插件入口)改为从左侧滑出的 sheet,内容区占满屏幕;此前展开侧栏会把内容挤到 110px。
26
+ - **顶部菜单按钮**:窄屏左上角固定一枚菜单按钮,点击打开/关闭抽屉;抽屉打开时按钮隐藏并显示半透明遮罩。
27
+ - **收起路径**:点遮罩、点会话行、点「新建会话 / 收起侧边栏」、按 Esc、转回桌面宽度都会自动收起;桌面端完全不启用。
28
+ - **宿主重渲染自愈**:按钮 / 遮罩 / 样式被宿主移除时,`MutationObserver` 会自动补回。
29
+
30
+ ### 🧪 验证
31
+
32
+ - 真实无头 Chrome + 运行中的 DSH 验证:390×844 下内容 390px 占满、抽屉宽度 300px、遮罩/按钮 aria 正确;遮罩 / Esc / 会话行 / 断点切换收起均通过;构建产物 `client.js` 的 `apply()` 安装验证通过。
33
+
34
+
3
35
  ## [v3.2.1] - 2026-11-02
4
36
 
5
37
  ### 🐛 修复
package/README.md CHANGED
@@ -42,6 +42,16 @@ dsh plugin --profile <name> add @qzsy/dsh-bridge
42
42
 
43
43
  面板会把 Host 路由(存在时)显示为主地址,并同时列出 Path 路由。
44
44
 
45
+ ## 手机访问
46
+
47
+ 窄屏(≤ 767px)访问 DSH 时,插件会自动把左侧栏改成抽屉,不需要额外配置:
48
+
49
+ - 内容区占满屏宽,左上角「菜单」按钮打开会话 / 工作区 / 插件入口;
50
+ - 支持手势:左缘向右滑打开、抽屉内向左滑关闭,拖动时跟手,松手吸附;
51
+ - 全面屏自动避开安全区:刘海 / 状态栏 / 底部 home indicator 不遮挡内容;
52
+ - 点遮罩、点会话行、按 Esc 或转回桌面宽度都会自动收起;
53
+ - 桌面端保持 DSH 原生布局,不受影响。
54
+
45
55
  ## 本地门禁与安全语义
46
56
 
47
57
  插件会在本机监听一个代理端口(默认 `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,465 @@ 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 DRAGGING_CLASS = "qzsy-drawer-dragging";
224
+ var HEADER_CLASS = "qzsy-mobile-header";
225
+ var MENU_CLASS = "qzsy-mobile-menu-btn";
226
+ var BACKDROP_CLASS = "qzsy-mobile-backdrop";
227
+ var STYLE_PLUGIN = "@qzsy/dsh-bridge";
228
+ var EDGE_START_ZONE = 28;
229
+ var DRAG_THRESHOLD = 8;
230
+ var MOBILE_DRAWER_CSS = `
231
+ :root {
232
+ --qzsy-safe-top: env(safe-area-inset-top, 0px);
233
+ --qzsy-safe-bottom: env(safe-area-inset-bottom, 0px);
234
+ --qzsy-safe-left: env(safe-area-inset-left, 0px);
235
+ --qzsy-safe-right: env(safe-area-inset-right, 0px);
236
+ }
237
+
238
+ .qzsy-mobile-header,
239
+ .qzsy-mobile-backdrop {
240
+ display: none;
241
+ }
242
+
243
+ /* \u83DC\u5355\u6309\u94AE\u5BB9\u5668\uFF1A\u59CB\u7EC8\u5728\u5BBF\u4E3B\u4E4B\u4E0A\uFF1B\u5BB9\u5668\u81EA\u8EAB\u4E0D\u62E6\u622A\u70B9\u51FB */
244
+ .qzsy-mobile-header {
245
+ position: fixed;
246
+ top: 0;
247
+ left: 0;
248
+ right: 0;
249
+ z-index: 65;
250
+ box-sizing: border-box;
251
+ align-items: flex-end;
252
+ padding-top: 10px;
253
+ padding-top: calc(10px + var(--qzsy-safe-top, 0px));
254
+ padding-right: 10px;
255
+ padding-right: calc(10px + var(--qzsy-safe-right, 0px));
256
+ padding-bottom: 2px;
257
+ padding-left: 10px;
258
+ padding-left: calc(10px + var(--qzsy-safe-left, 0px));
259
+ pointer-events: none;
260
+ }
261
+
262
+ @media (max-width: 767px) {
263
+ .qzsy-mobile-header { display: flex; }
264
+
265
+ /* 1. \u5DE6\u4FA7\u680F\u8131\u79BB grid\uFF0C\u5185\u5BB9\u5360\u6EE1\uFF1B\u9876\u90E8\u7ED9\u83DC\u5355\u6309\u94AE\u7559\u51FA\u5B89\u5168\u533A\u9AD8\u5EA6 */
266
+ [data-slot="root"] > div[class*="_frame"] {
267
+ grid-template-columns: 0px minmax(0px, 1fr) minmax(0px, 0px) !important;
268
+ box-sizing: border-box !important;
269
+ padding-top: 52px !important;
270
+ padding-top: calc(52px + var(--qzsy-safe-top, 0px)) !important;
271
+ padding-bottom: 0 !important;
272
+ padding-bottom: var(--qzsy-safe-bottom, 0px) !important;
273
+ }
274
+ div[class*="_centerCol"] { grid-column: 2 !important; }
275
+ div[class*="_rightbarCol"] { grid-column: 3 !important; }
276
+ div[class*="_overlayLayer"] { grid-column: 1 / -1 !important; }
277
+
278
+ /* 2. \u539F\u751F\u4FA7\u680F\u6539\u4E3A fixed \u62BD\u5C49\uFF08\u9ED8\u8BA4\u85CF\u5728\u5C4F\u5E55\u5DE6\u4FA7\uFF1B\u5185\u90E8\u907F\u5F00\u5B89\u5168\u533A\uFF09 */
279
+ div[class*="_sidebarCol"] {
280
+ position: fixed !important;
281
+ top: 0 !important;
282
+ left: 0 !important;
283
+ bottom: 0 !important;
284
+ width: min(300px, 84vw) !important;
285
+ max-width: 84vw !important;
286
+ height: 100vh !important;
287
+ height: 100dvh !important;
288
+ box-sizing: border-box !important;
289
+ padding-top: 0 !important;
290
+ padding-top: var(--qzsy-safe-top, 0px) !important;
291
+ padding-bottom: 0 !important;
292
+ padding-bottom: var(--qzsy-safe-bottom, 0px) !important;
293
+ padding-left: 0 !important;
294
+ padding-left: var(--qzsy-safe-left, 0px) !important;
295
+ z-index: 60 !important;
296
+ background: var(--dsw-alias-bg-layer-1, #ffffff) !important;
297
+ border-right: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.08)) !important;
298
+ box-shadow: none !important;
299
+ transform: translateX(-102%) !important;
300
+ overflow: hidden !important;
301
+ overscroll-behavior: contain;
302
+ will-change: transform;
303
+ }
304
+ /* \u9996\u5E27\u5148\u5C31\u4F4D\u3001\u4E0D\u64AD\u653E\u5165\u573A\u52A8\u753B\uFF0C\u907F\u514D\u9875\u9762\u52A0\u8F7D\u65F6\u4FA7\u680F\u4ECE 0 \u6ED1\u51FA */
305
+ body.qzsy-drawer-ready div[class*="_sidebarCol"] {
306
+ transition: transform 280ms cubic-bezier(0.16, 1, 0.3, 1) !important;
307
+ }
308
+ body.qzsy-drawer-open div[class*="_sidebarCol"] {
309
+ transform: translateX(0) !important;
310
+ box-shadow: 8px 0 32px rgba(0, 0, 0, 0.18) !important;
311
+ }
312
+ body.qzsy-drawer-open div[class*="_sidebarCol"] > div > div[class*="_root"] {
313
+ width: 100% !important;
314
+ }
315
+
316
+ /* \u62D6\u52A8\u4E2D\uFF1A\u5173\u95ED\u8FC7\u6E21\uFF0Ctransform/\u906E\u7F69\u76F4\u63A5\u8DDF\u624B */
317
+ body.qzsy-drawer-dragging div[class*="_sidebarCol"] {
318
+ transition: none !important;
319
+ transform: translateX(var(--qzsy-drawer-x, -102%)) !important;
320
+ }
321
+ body.qzsy-drawer-dragging .qzsy-mobile-backdrop {
322
+ transition: none !important;
323
+ opacity: var(--qzsy-drawer-progress, 0) !important;
324
+ pointer-events: auto !important;
325
+ }
326
+
327
+ /* 3. \u534A\u900F\u660E\u906E\u7F69\uFF1A\u70B9\u51FB\u6536\u8D77 */
328
+ .qzsy-mobile-backdrop {
329
+ display: block;
330
+ position: fixed;
331
+ inset: 0;
332
+ z-index: 55;
333
+ background: rgba(0, 0, 0, 0.42);
334
+ opacity: 0;
335
+ pointer-events: none;
336
+ transition: opacity 280ms ease;
337
+ touch-action: none;
338
+ -webkit-tap-highlight-color: transparent;
339
+ }
340
+ body.qzsy-drawer-open .qzsy-mobile-backdrop {
341
+ opacity: 1;
342
+ pointer-events: auto;
343
+ }
344
+
345
+ /* 4. \u83DC\u5355\u6309\u94AE\uFF1A\u4F4D\u4E8E\u5B89\u5168\u533A\u4E0B\u65B9\u7684 52px \u7559\u767D\u6761\u5185 */
346
+ .qzsy-mobile-menu-btn {
347
+ display: inline-flex;
348
+ align-items: center;
349
+ justify-content: center;
350
+ width: 40px;
351
+ height: 40px;
352
+ padding: 0;
353
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
354
+ border-radius: 12px;
355
+ background: var(--dsw-alias-bg-layer-1, #ffffff);
356
+ color: var(--dsw-alias-label-primary, #111827);
357
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
358
+ cursor: pointer;
359
+ pointer-events: auto;
360
+ -webkit-tap-highlight-color: transparent;
361
+ transition: opacity 180ms ease, transform 180ms ease;
362
+ }
363
+ .qzsy-mobile-menu-btn:active { transform: scale(0.94); }
364
+ .qzsy-mobile-menu-btn svg { display: block; }
365
+ body.qzsy-drawer-open .qzsy-mobile-menu-btn {
366
+ opacity: 0;
367
+ pointer-events: none;
368
+ }
369
+ }
370
+ `;
371
+ function isElement(value) {
372
+ return Boolean(value) && value.nodeType === 1;
373
+ }
374
+ function isMobileViewport() {
375
+ return typeof window !== "undefined" && Number(window.innerWidth) <= MOBILE_MAX_WIDTH;
376
+ }
377
+ function menuLabel(open) {
378
+ const t = createTranslator({});
379
+ return open ? t("menuClose") : t("menuOpen");
380
+ }
381
+ function injectStyles() {
382
+ let style = document.getElementById(MOBILE_DRAWER_CSS_ID);
383
+ if (!style) {
384
+ style = document.createElement("style");
385
+ style.id = MOBILE_DRAWER_CSS_ID;
386
+ style.dataset.plugin = STYLE_PLUGIN;
387
+ style.dataset.pluginCss = `${STYLE_PLUGIN}/mobile`;
388
+ document.head.appendChild(style);
389
+ }
390
+ if (style.textContent !== MOBILE_DRAWER_CSS) style.textContent = MOBILE_DRAWER_CSS;
391
+ }
392
+ function drawerWidth() {
393
+ if (typeof window === "undefined") return 300;
394
+ return Math.min(300, Math.round(window.innerWidth * 0.84));
395
+ }
396
+ function setupMobileDrawer() {
397
+ if (typeof document === "undefined" || typeof window === "undefined") return () => {
398
+ };
399
+ const root = document.documentElement;
400
+ if (!root || root.getAttribute(MOBILE_UI_FLAG) === "1") return () => {
401
+ };
402
+ root.setAttribute(MOBILE_UI_FLAG, "1");
403
+ let menuBtn = null;
404
+ let observer = null;
405
+ let disposed = false;
406
+ let gesture = null;
407
+ let suppressClickUntil = 0;
408
+ const ensureElements = () => {
409
+ if (disposed || !document.body) return;
410
+ injectStyles();
411
+ let header = document.querySelector(`.${HEADER_CLASS}`);
412
+ if (!header) {
413
+ header = document.createElement("div");
414
+ header.className = HEADER_CLASS;
415
+ document.body.appendChild(header);
416
+ }
417
+ let btn = header.querySelector(`.${MENU_CLASS}`);
418
+ if (!btn) {
419
+ btn = document.createElement("button");
420
+ btn.type = "button";
421
+ btn.className = MENU_CLASS;
422
+ 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>';
423
+ header.appendChild(btn);
424
+ }
425
+ let mask = document.querySelector(`.${BACKDROP_CLASS}`);
426
+ if (!mask) {
427
+ mask = document.createElement("div");
428
+ mask.className = BACKDROP_CLASS;
429
+ mask.setAttribute("aria-hidden", "true");
430
+ document.body.appendChild(mask);
431
+ }
432
+ menuBtn = btn;
433
+ syncAria();
434
+ };
435
+ const isOpen = () => Boolean(document.body?.classList.contains(OPEN_CLASS));
436
+ const syncAria = () => {
437
+ if (!menuBtn) return;
438
+ const label = menuLabel(isOpen());
439
+ if (menuBtn.getAttribute("aria-label") !== label) menuBtn.setAttribute("aria-label", label);
440
+ if (menuBtn.getAttribute("title") !== label) menuBtn.setAttribute("title", label);
441
+ menuBtn.setAttribute("aria-expanded", isOpen() ? "true" : "false");
442
+ };
443
+ const ensureSidebarExpanded = () => {
444
+ const expand = document.querySelector(
445
+ '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"]'
446
+ );
447
+ if (expand) {
448
+ try {
449
+ expand.click();
450
+ } catch {
451
+ }
452
+ }
453
+ };
454
+ const setDragProgress = (x, width) => {
455
+ document.body.style.setProperty("--qzsy-drawer-x", `${x}px`);
456
+ document.body.style.setProperty("--qzsy-drawer-progress", String(Math.max(0, Math.min(1, 1 - Math.abs(x) / width))));
457
+ };
458
+ const clearDrag = () => {
459
+ document.body.classList.remove(DRAGGING_CLASS);
460
+ document.body.style.removeProperty("--qzsy-drawer-x");
461
+ document.body.style.removeProperty("--qzsy-drawer-progress");
462
+ };
463
+ const openDrawer = () => {
464
+ if (disposed || !isMobileViewport() || !document.body) return;
465
+ ensureElements();
466
+ ensureSidebarExpanded();
467
+ document.body.classList.add(OPEN_CLASS);
468
+ syncAria();
469
+ };
470
+ const closeDrawer = ({ restoreFocus = true } = {}) => {
471
+ if (!document.body) return;
472
+ const wasOpen = isOpen();
473
+ document.body.classList.remove(OPEN_CLASS);
474
+ syncAria();
475
+ if (wasOpen && restoreFocus) {
476
+ try {
477
+ menuBtn?.focus?.({ preventScroll: true });
478
+ } catch {
479
+ }
480
+ }
481
+ };
482
+ const closeSoon = () => {
483
+ window.setTimeout(() => {
484
+ if (!disposed && isOpen()) closeDrawer({ restoreFocus: false });
485
+ }, 60);
486
+ };
487
+ const onClick = (event) => {
488
+ if (Date.now() < suppressClickUntil) {
489
+ event.preventDefault();
490
+ event.stopPropagation();
491
+ return;
492
+ }
493
+ const target = event.target;
494
+ if (!isElement(target)) return;
495
+ if (target.closest(`.${MENU_CLASS}`)) {
496
+ event.preventDefault();
497
+ if (isOpen()) closeDrawer();
498
+ else openDrawer();
499
+ return;
500
+ }
501
+ if (!isOpen()) return;
502
+ if (target.closest(`.${BACKDROP_CLASS}`)) {
503
+ closeDrawer();
504
+ return;
505
+ }
506
+ const sidebar = document.querySelector('div[class*="_sidebarCol"]');
507
+ if (!sidebar || !sidebar.contains(target)) return;
508
+ if (target.closest('input, textarea, select, [contenteditable="true"]')) return;
509
+ const sessionRow = target.closest('div[class*="sessionRow"]');
510
+ if (sessionRow && !target.closest('button, [role="button"]')) {
511
+ closeSoon();
512
+ return;
513
+ }
514
+ const action = target.closest('button, [role="button"], a');
515
+ if (!action) return;
516
+ const label = `${action.getAttribute("aria-label") || ""} ${action.getAttribute("title") || ""} ${action.innerText || ""}`;
517
+ if (/新建会话|New session|收起侧边栏|Collapse sidebar/.test(label)) {
518
+ closeSoon();
519
+ return;
520
+ }
521
+ if (action.closest('button[class*="panelRow"]') && !/更多|More/.test(label)) {
522
+ closeSoon();
523
+ }
524
+ };
525
+ const onKeyDown = (event) => {
526
+ if (event.key === "Escape" && isOpen()) closeDrawer();
527
+ };
528
+ const onResize = () => {
529
+ if (!isMobileViewport() && isOpen()) {
530
+ gesture = null;
531
+ clearDrag();
532
+ closeDrawer({ restoreFocus: false });
533
+ }
534
+ };
535
+ const onVisibilityChange = () => {
536
+ if (document.visibilityState === "hidden" && isOpen()) {
537
+ gesture = null;
538
+ clearDrag();
539
+ closeDrawer({ restoreFocus: false });
540
+ }
541
+ };
542
+ const onTouchStart = (event) => {
543
+ if (disposed || !isMobileViewport() || event.touches?.length !== 1) return;
544
+ const touch = event.touches[0];
545
+ const sidebar = document.querySelector('div[class*="_sidebarCol"]');
546
+ if (!sidebar) return;
547
+ const open = isOpen();
548
+ if (!open) {
549
+ if (touch.clientX > EDGE_START_ZONE) return;
550
+ ensureSidebarExpanded();
551
+ gesture = {
552
+ mode: "open",
553
+ startX: touch.clientX,
554
+ startY: touch.clientY,
555
+ startT: Date.now(),
556
+ width: drawerWidth(),
557
+ active: false,
558
+ dx: 0
559
+ };
560
+ return;
561
+ }
562
+ if (!sidebar.contains(event.target)) return;
563
+ if (event.target.closest?.('input, textarea, select, [contenteditable="true"]')) return;
564
+ gesture = {
565
+ mode: "close",
566
+ startX: touch.clientX,
567
+ startY: touch.clientY,
568
+ startT: Date.now(),
569
+ width: drawerWidth(),
570
+ active: false,
571
+ dx: 0
572
+ };
573
+ };
574
+ const onTouchMove = (event) => {
575
+ if (!gesture || disposed) return;
576
+ const touch = event.touches?.[0];
577
+ if (!touch) return;
578
+ const dx = touch.clientX - gesture.startX;
579
+ const dy = touch.clientY - gesture.startY;
580
+ if (!gesture.active) {
581
+ if (Math.abs(dx) < DRAG_THRESHOLD) return;
582
+ if (Math.abs(dx) < Math.abs(dy) * 1.2) {
583
+ gesture = null;
584
+ return;
585
+ }
586
+ if (gesture.mode === "open" && dx <= 0) {
587
+ gesture = null;
588
+ return;
589
+ }
590
+ if (gesture.mode === "close" && dx >= 0) {
591
+ gesture = null;
592
+ return;
593
+ }
594
+ gesture.active = true;
595
+ suppressClickUntil = Date.now() + 450;
596
+ document.body.classList.add(DRAGGING_CLASS);
597
+ }
598
+ const width = Math.max(1, gesture.width);
599
+ let x;
600
+ if (gesture.mode === "open") x = Math.max(-width, Math.min(0, -width + dx));
601
+ else x = Math.max(-width, Math.min(0, dx));
602
+ gesture.dx = dx;
603
+ setDragProgress(x, width);
604
+ };
605
+ const finishGesture = (touch) => {
606
+ if (!gesture) return;
607
+ const current = gesture;
608
+ gesture = null;
609
+ if (!current.active) return;
610
+ const endX = touch ? touch.clientX : current.startX + current.dx;
611
+ const dx = endX - current.startX;
612
+ const elapsed = Math.max(1, Date.now() - current.startT);
613
+ const velocity = dx / elapsed;
614
+ const threshold = Math.min(90, Math.max(60, current.width * 0.3));
615
+ const openNow = current.mode === "open" ? dx > threshold || velocity > 0.45 : !(dx < -Math.max(50, threshold * 0.7) || velocity < -0.35);
616
+ document.body.classList.toggle(OPEN_CLASS, openNow);
617
+ if (openNow) ensureSidebarExpanded();
618
+ syncAria();
619
+ suppressClickUntil = Date.now() + 250;
620
+ window.requestAnimationFrame(clearDrag);
621
+ };
622
+ const onTouchEnd = (event) => {
623
+ if (!gesture) return;
624
+ finishGesture(event.changedTouches?.[0]);
625
+ };
626
+ const onTouchCancel = () => {
627
+ if (!gesture) return;
628
+ finishGesture(null);
629
+ };
630
+ injectStyles();
631
+ ensureElements();
632
+ document.addEventListener("click", onClick, true);
633
+ document.addEventListener("keydown", onKeyDown, true);
634
+ document.addEventListener("visibilitychange", onVisibilityChange);
635
+ window.addEventListener("resize", onResize);
636
+ window.addEventListener("touchstart", onTouchStart, { passive: true });
637
+ window.addEventListener("touchmove", onTouchMove, { passive: true });
638
+ window.addEventListener("touchend", onTouchEnd, { passive: true });
639
+ window.addEventListener("touchcancel", onTouchCancel, { passive: true });
640
+ window.requestAnimationFrame(() => {
641
+ if (!disposed && document.body) document.body.classList.add("qzsy-drawer-ready");
642
+ });
643
+ if (typeof MutationObserver === "function" && document.body) {
644
+ observer = new MutationObserver(() => {
645
+ if (!document.querySelector(`.${HEADER_CLASS}`) || !document.querySelector(`.${MENU_CLASS}`) || !document.querySelector(`.${BACKDROP_CLASS}`) || !document.getElementById(MOBILE_DRAWER_CSS_ID)) {
646
+ ensureElements();
647
+ }
648
+ });
649
+ observer.observe(document.body, { childList: true });
650
+ }
651
+ syncAria();
652
+ return () => {
653
+ disposed = true;
654
+ gesture = null;
655
+ try {
656
+ observer?.disconnect();
657
+ } catch {
658
+ }
659
+ document.removeEventListener("click", onClick, true);
660
+ document.removeEventListener("keydown", onKeyDown, true);
661
+ document.removeEventListener("visibilitychange", onVisibilityChange);
662
+ window.removeEventListener("resize", onResize);
663
+ window.removeEventListener("touchstart", onTouchStart);
664
+ window.removeEventListener("touchmove", onTouchMove);
665
+ window.removeEventListener("touchend", onTouchEnd);
666
+ window.removeEventListener("touchcancel", onTouchCancel);
667
+ clearDrag();
668
+ document.body?.classList.remove(OPEN_CLASS);
669
+ document.body?.classList.remove("qzsy-drawer-ready");
670
+ document.querySelector(`.${HEADER_CLASS}`)?.remove();
671
+ document.querySelector(`.${BACKDROP_CLASS}`)?.remove();
672
+ document.getElementById(MOBILE_DRAWER_CSS_ID)?.remove();
673
+ root.removeAttribute(MOBILE_UI_FLAG);
674
+ };
675
+ }
676
+
214
677
  // client/index.js
215
678
  var name = PLUGIN_ID;
216
679
  var inject = ["slots", "connection"];
@@ -738,6 +1201,18 @@ var ctxRpc = async () => ({ ok: false, error: { code: "no-context", message: "RP
738
1201
  var serviceControl = null;
739
1202
  function apply(ctx) {
740
1203
  ctxRpc = (endpoint, payload, signal) => ctx.connection.rpc.call(RPC_CHANNEL, endpoint, payload, signal);
1204
+ let disposeMobile = () => {
1205
+ };
1206
+ try {
1207
+ disposeMobile = setupMobileDrawer();
1208
+ } catch {
1209
+ }
1210
+ if (typeof ctx.effect === "function") {
1211
+ try {
1212
+ ctx.effect(() => disposeMobile, `${PLUGIN_ID}: mobile drawer`);
1213
+ } catch {
1214
+ }
1215
+ }
741
1216
  const panel = (props = {}) => h(BridgePanel, { ...props, key: "qzsy-dsh-bridge-panel" });
742
1217
  let visible = true;
743
1218
  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,509 @@
1
+ // 移动端抽屉导航:把宿主左侧栏(会话/工作区菜单)改成从左侧滑出的 sheet。
2
+ //
3
+ // 设计目标(对齐 tmp/dsh-bridge 的移动端体验,但只保留本项目需要的部分):
4
+ // - 窄屏内容占满屏宽,左侧栏变成带遮罩的抽屉;
5
+ // - 支持全面屏安全区(刘海/状态栏/home indicator),菜单按钮和抽屉内容都不被遮挡;
6
+ // - 支持手势:左缘右滑打开、抽屉内左滑关闭,拖动时抽屉跟随手指,松手吸附;
7
+ // - 桌面端完全不受影响。
8
+ //
9
+ // 宿主 DOM 是 CSS Modules 哈希类名,本模块只依赖稳定结构/ARIA:
10
+ // div[class*="_frame"] / _sidebarCol / _centerCol / _rightbarCol / _overlayLayer
11
+ // button[aria-label*="打开侧边栏"](英文 Open sidebar)
12
+ // div[class*="sessionRow"](会话行;projectRow 是工作区分组,不能误关)
13
+ import { createTranslator } from './locales.mjs';
14
+
15
+ export const MOBILE_MAX_WIDTH = 767;
16
+ export const MOBILE_DRAWER_CSS_ID = 'qzsy-mobile-drawer-styles';
17
+ export const MOBILE_UI_FLAG = 'data-qzsy-mobile-drawer';
18
+ const OPEN_CLASS = 'qzsy-drawer-open';
19
+ const DRAGGING_CLASS = 'qzsy-drawer-dragging';
20
+ const HEADER_CLASS = 'qzsy-mobile-header';
21
+ const MENU_CLASS = 'qzsy-mobile-menu-btn';
22
+ const BACKDROP_CLASS = 'qzsy-mobile-backdrop';
23
+ const STYLE_PLUGIN = '@qzsy/dsh-bridge';
24
+ const EDGE_START_ZONE = 28;
25
+ const DRAG_THRESHOLD = 8;
26
+
27
+ export const MOBILE_DRAWER_CSS = `
28
+ :root {
29
+ --qzsy-safe-top: env(safe-area-inset-top, 0px);
30
+ --qzsy-safe-bottom: env(safe-area-inset-bottom, 0px);
31
+ --qzsy-safe-left: env(safe-area-inset-left, 0px);
32
+ --qzsy-safe-right: env(safe-area-inset-right, 0px);
33
+ }
34
+
35
+ .qzsy-mobile-header,
36
+ .qzsy-mobile-backdrop {
37
+ display: none;
38
+ }
39
+
40
+ /* 菜单按钮容器:始终在宿主之上;容器自身不拦截点击 */
41
+ .qzsy-mobile-header {
42
+ position: fixed;
43
+ top: 0;
44
+ left: 0;
45
+ right: 0;
46
+ z-index: 65;
47
+ box-sizing: border-box;
48
+ align-items: flex-end;
49
+ padding-top: 10px;
50
+ padding-top: calc(10px + var(--qzsy-safe-top, 0px));
51
+ padding-right: 10px;
52
+ padding-right: calc(10px + var(--qzsy-safe-right, 0px));
53
+ padding-bottom: 2px;
54
+ padding-left: 10px;
55
+ padding-left: calc(10px + var(--qzsy-safe-left, 0px));
56
+ pointer-events: none;
57
+ }
58
+
59
+ @media (max-width: 767px) {
60
+ .qzsy-mobile-header { display: flex; }
61
+
62
+ /* 1. 左侧栏脱离 grid,内容占满;顶部给菜单按钮留出安全区高度 */
63
+ [data-slot="root"] > div[class*="_frame"] {
64
+ grid-template-columns: 0px minmax(0px, 1fr) minmax(0px, 0px) !important;
65
+ box-sizing: border-box !important;
66
+ padding-top: 52px !important;
67
+ padding-top: calc(52px + var(--qzsy-safe-top, 0px)) !important;
68
+ padding-bottom: 0 !important;
69
+ padding-bottom: var(--qzsy-safe-bottom, 0px) !important;
70
+ }
71
+ div[class*="_centerCol"] { grid-column: 2 !important; }
72
+ div[class*="_rightbarCol"] { grid-column: 3 !important; }
73
+ div[class*="_overlayLayer"] { grid-column: 1 / -1 !important; }
74
+
75
+ /* 2. 原生侧栏改为 fixed 抽屉(默认藏在屏幕左侧;内部避开安全区) */
76
+ div[class*="_sidebarCol"] {
77
+ position: fixed !important;
78
+ top: 0 !important;
79
+ left: 0 !important;
80
+ bottom: 0 !important;
81
+ width: min(300px, 84vw) !important;
82
+ max-width: 84vw !important;
83
+ height: 100vh !important;
84
+ height: 100dvh !important;
85
+ box-sizing: border-box !important;
86
+ padding-top: 0 !important;
87
+ padding-top: var(--qzsy-safe-top, 0px) !important;
88
+ padding-bottom: 0 !important;
89
+ padding-bottom: var(--qzsy-safe-bottom, 0px) !important;
90
+ padding-left: 0 !important;
91
+ padding-left: var(--qzsy-safe-left, 0px) !important;
92
+ z-index: 60 !important;
93
+ background: var(--dsw-alias-bg-layer-1, #ffffff) !important;
94
+ border-right: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.08)) !important;
95
+ box-shadow: none !important;
96
+ transform: translateX(-102%) !important;
97
+ overflow: hidden !important;
98
+ overscroll-behavior: contain;
99
+ will-change: transform;
100
+ }
101
+ /* 首帧先就位、不播放入场动画,避免页面加载时侧栏从 0 滑出 */
102
+ body.qzsy-drawer-ready div[class*="_sidebarCol"] {
103
+ transition: transform 280ms cubic-bezier(0.16, 1, 0.3, 1) !important;
104
+ }
105
+ body.qzsy-drawer-open div[class*="_sidebarCol"] {
106
+ transform: translateX(0) !important;
107
+ box-shadow: 8px 0 32px rgba(0, 0, 0, 0.18) !important;
108
+ }
109
+ body.qzsy-drawer-open div[class*="_sidebarCol"] > div > div[class*="_root"] {
110
+ width: 100% !important;
111
+ }
112
+
113
+ /* 拖动中:关闭过渡,transform/遮罩直接跟手 */
114
+ body.qzsy-drawer-dragging div[class*="_sidebarCol"] {
115
+ transition: none !important;
116
+ transform: translateX(var(--qzsy-drawer-x, -102%)) !important;
117
+ }
118
+ body.qzsy-drawer-dragging .qzsy-mobile-backdrop {
119
+ transition: none !important;
120
+ opacity: var(--qzsy-drawer-progress, 0) !important;
121
+ pointer-events: auto !important;
122
+ }
123
+
124
+ /* 3. 半透明遮罩:点击收起 */
125
+ .qzsy-mobile-backdrop {
126
+ display: block;
127
+ position: fixed;
128
+ inset: 0;
129
+ z-index: 55;
130
+ background: rgba(0, 0, 0, 0.42);
131
+ opacity: 0;
132
+ pointer-events: none;
133
+ transition: opacity 280ms ease;
134
+ touch-action: none;
135
+ -webkit-tap-highlight-color: transparent;
136
+ }
137
+ body.qzsy-drawer-open .qzsy-mobile-backdrop {
138
+ opacity: 1;
139
+ pointer-events: auto;
140
+ }
141
+
142
+ /* 4. 菜单按钮:位于安全区下方的 52px 留白条内 */
143
+ .qzsy-mobile-menu-btn {
144
+ display: inline-flex;
145
+ align-items: center;
146
+ justify-content: center;
147
+ width: 40px;
148
+ height: 40px;
149
+ padding: 0;
150
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
151
+ border-radius: 12px;
152
+ background: var(--dsw-alias-bg-layer-1, #ffffff);
153
+ color: var(--dsw-alias-label-primary, #111827);
154
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
155
+ cursor: pointer;
156
+ pointer-events: auto;
157
+ -webkit-tap-highlight-color: transparent;
158
+ transition: opacity 180ms ease, transform 180ms ease;
159
+ }
160
+ .qzsy-mobile-menu-btn:active { transform: scale(0.94); }
161
+ .qzsy-mobile-menu-btn svg { display: block; }
162
+ body.qzsy-drawer-open .qzsy-mobile-menu-btn {
163
+ opacity: 0;
164
+ pointer-events: none;
165
+ }
166
+ }
167
+ `;
168
+
169
+ function isElement(value) {
170
+ return Boolean(value) && value.nodeType === 1;
171
+ }
172
+
173
+ function isMobileViewport() {
174
+ return typeof window !== 'undefined' && Number(window.innerWidth) <= MOBILE_MAX_WIDTH;
175
+ }
176
+
177
+ function menuLabel(open) {
178
+ const t = createTranslator({});
179
+ return open ? t('menuClose') : t('menuOpen');
180
+ }
181
+
182
+ function injectStyles() {
183
+ let style = document.getElementById(MOBILE_DRAWER_CSS_ID);
184
+ if (!style) {
185
+ style = document.createElement('style');
186
+ style.id = MOBILE_DRAWER_CSS_ID;
187
+ style.dataset.plugin = STYLE_PLUGIN;
188
+ style.dataset.pluginCss = `${STYLE_PLUGIN}/mobile`;
189
+ document.head.appendChild(style);
190
+ }
191
+ if (style.textContent !== MOBILE_DRAWER_CSS) style.textContent = MOBILE_DRAWER_CSS;
192
+ }
193
+
194
+ function drawerWidth() {
195
+ if (typeof window === 'undefined') return 300;
196
+ return Math.min(300, Math.round(window.innerWidth * 0.84));
197
+ }
198
+
199
+ /**
200
+ * 安装移动端抽屉导航。无 DOM 环境(单测/SSR)安全退化为 no-op。
201
+ * 返回 disposer;宿主若支持 ctx.effect,可注册为插件副作用。
202
+ */
203
+ export function setupMobileDrawer() {
204
+ if (typeof document === 'undefined' || typeof window === 'undefined') return () => {};
205
+ const root = document.documentElement;
206
+ if (!root || root.getAttribute(MOBILE_UI_FLAG) === '1') return () => {};
207
+ root.setAttribute(MOBILE_UI_FLAG, '1');
208
+
209
+ let menuBtn = null;
210
+ let observer = null;
211
+ let disposed = false;
212
+ let gesture = null;
213
+ let suppressClickUntil = 0;
214
+
215
+ const ensureElements = () => {
216
+ if (disposed || !document.body) return;
217
+ injectStyles();
218
+
219
+ let header = document.querySelector(`.${HEADER_CLASS}`);
220
+ if (!header) {
221
+ header = document.createElement('div');
222
+ header.className = HEADER_CLASS;
223
+ document.body.appendChild(header);
224
+ }
225
+ let btn = header.querySelector(`.${MENU_CLASS}`);
226
+ if (!btn) {
227
+ btn = document.createElement('button');
228
+ btn.type = 'button';
229
+ btn.className = MENU_CLASS;
230
+ 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>';
231
+ header.appendChild(btn);
232
+ }
233
+ let mask = document.querySelector(`.${BACKDROP_CLASS}`);
234
+ if (!mask) {
235
+ mask = document.createElement('div');
236
+ mask.className = BACKDROP_CLASS;
237
+ mask.setAttribute('aria-hidden', 'true');
238
+ document.body.appendChild(mask);
239
+ }
240
+ menuBtn = btn;
241
+ syncAria();
242
+ };
243
+
244
+ const isOpen = () => Boolean(document.body?.classList.contains(OPEN_CLASS));
245
+
246
+ const syncAria = () => {
247
+ if (!menuBtn) return;
248
+ const label = menuLabel(isOpen());
249
+ if (menuBtn.getAttribute('aria-label') !== label) menuBtn.setAttribute('aria-label', label);
250
+ if (menuBtn.getAttribute('title') !== label) menuBtn.setAttribute('title', label);
251
+ menuBtn.setAttribute('aria-expanded', isOpen() ? 'true' : 'false');
252
+ };
253
+
254
+ /** 宿主侧栏收起时先点它自己的「打开侧边栏」,让抽屉里有会话列表。 */
255
+ const ensureSidebarExpanded = () => {
256
+ const expand = document.querySelector(
257
+ 'button[aria-label*="打开侧边栏"], button[title*="打开侧边栏"], button[aria-label*="Open sidebar"], button[title*="Open sidebar"], div[class*="collapsed"] button[class*="toggle"]',
258
+ );
259
+ if (expand) {
260
+ try { expand.click(); } catch { /* 宿主没挂载完时忽略,下次打开再试 */ }
261
+ }
262
+ };
263
+
264
+ const setDragProgress = (x, width) => {
265
+ document.body.style.setProperty('--qzsy-drawer-x', `${x}px`);
266
+ document.body.style.setProperty('--qzsy-drawer-progress', String(Math.max(0, Math.min(1, 1 - Math.abs(x) / width))));
267
+ };
268
+
269
+ const clearDrag = () => {
270
+ document.body.classList.remove(DRAGGING_CLASS);
271
+ document.body.style.removeProperty('--qzsy-drawer-x');
272
+ document.body.style.removeProperty('--qzsy-drawer-progress');
273
+ };
274
+
275
+ const openDrawer = () => {
276
+ if (disposed || !isMobileViewport() || !document.body) return;
277
+ ensureElements();
278
+ ensureSidebarExpanded();
279
+ document.body.classList.add(OPEN_CLASS);
280
+ syncAria();
281
+ };
282
+
283
+ const closeDrawer = ({ restoreFocus = true } = {}) => {
284
+ if (!document.body) return;
285
+ const wasOpen = isOpen();
286
+ document.body.classList.remove(OPEN_CLASS);
287
+ syncAria();
288
+ if (wasOpen && restoreFocus) {
289
+ try { menuBtn?.focus?.({ preventScroll: true }); } catch { /* 忽略焦点异常 */ }
290
+ }
291
+ };
292
+
293
+ const closeSoon = () => {
294
+ window.setTimeout(() => {
295
+ if (!disposed && isOpen()) closeDrawer({ restoreFocus: false });
296
+ }, 60);
297
+ };
298
+
299
+ const onClick = (event) => {
300
+ if (Date.now() < suppressClickUntil) {
301
+ event.preventDefault();
302
+ event.stopPropagation();
303
+ return;
304
+ }
305
+ const target = event.target;
306
+ if (!isElement(target)) return;
307
+
308
+ if (target.closest(`.${MENU_CLASS}`)) {
309
+ event.preventDefault();
310
+ if (isOpen()) closeDrawer();
311
+ else openDrawer();
312
+ return;
313
+ }
314
+ if (!isOpen()) return;
315
+
316
+ if (target.closest(`.${BACKDROP_CLASS}`)) {
317
+ closeDrawer();
318
+ return;
319
+ }
320
+
321
+ const sidebar = document.querySelector('div[class*="_sidebarCol"]');
322
+ if (!sidebar || !sidebar.contains(target)) return;
323
+ if (target.closest('input, textarea, select, [contenteditable="true"]')) return;
324
+
325
+ // 会话行点击后收起抽屉;行内的操作按钮(归档/置顶等)不收起
326
+ const sessionRow = target.closest('div[class*="sessionRow"]');
327
+ if (sessionRow && !target.closest('button, [role="button"]')) {
328
+ closeSoon();
329
+ return;
330
+ }
331
+
332
+ const action = target.closest('button, [role="button"], a');
333
+ if (!action) return;
334
+ const label = `${action.getAttribute('aria-label') || ''} ${action.getAttribute('title') || ''} ${action.innerText || ''}`;
335
+ if (/新建会话|New session|收起侧边栏|Collapse sidebar/.test(label)) {
336
+ closeSoon();
337
+ return;
338
+ }
339
+ // 顶部一级菜单(插件/自动化任务/命令面板/…)打开新面板后收起抽屉;
340
+ // 「更多」往往是在抽屉内展开二级菜单,保持打开。
341
+ if (action.closest('button[class*="panelRow"]') && !/更多|More/.test(label)) {
342
+ closeSoon();
343
+ }
344
+ };
345
+
346
+ const onKeyDown = (event) => {
347
+ if (event.key === 'Escape' && isOpen()) closeDrawer();
348
+ };
349
+
350
+ const onResize = () => {
351
+ if (!isMobileViewport() && isOpen()) {
352
+ gesture = null;
353
+ clearDrag();
354
+ closeDrawer({ restoreFocus: false });
355
+ }
356
+ };
357
+
358
+ const onVisibilityChange = () => {
359
+ if (document.visibilityState === 'hidden' && isOpen()) {
360
+ gesture = null;
361
+ clearDrag();
362
+ closeDrawer({ restoreFocus: false });
363
+ }
364
+ };
365
+
366
+ // ---- 手势:左缘右滑打开;抽屉内左滑关闭;拖动跟手 ----
367
+ const onTouchStart = (event) => {
368
+ if (disposed || !isMobileViewport() || event.touches?.length !== 1) return;
369
+ const touch = event.touches[0];
370
+ const sidebar = document.querySelector('div[class*="_sidebarCol"]');
371
+ if (!sidebar) return;
372
+ const open = isOpen();
373
+
374
+ if (!open) {
375
+ if (touch.clientX > EDGE_START_ZONE) return;
376
+ // 先把宿主侧栏展开,保证拖动时抽屉里有完整会话列表
377
+ ensureSidebarExpanded();
378
+ gesture = {
379
+ mode: 'open',
380
+ startX: touch.clientX,
381
+ startY: touch.clientY,
382
+ startT: Date.now(),
383
+ width: drawerWidth(),
384
+ active: false,
385
+ dx: 0,
386
+ };
387
+ return;
388
+ }
389
+
390
+ if (!sidebar.contains(event.target)) return;
391
+ if (event.target.closest?.('input, textarea, select, [contenteditable="true"]')) return;
392
+ gesture = {
393
+ mode: 'close',
394
+ startX: touch.clientX,
395
+ startY: touch.clientY,
396
+ startT: Date.now(),
397
+ width: drawerWidth(),
398
+ active: false,
399
+ dx: 0,
400
+ };
401
+ };
402
+
403
+ const onTouchMove = (event) => {
404
+ if (!gesture || disposed) return;
405
+ const touch = event.touches?.[0];
406
+ if (!touch) return;
407
+ const dx = touch.clientX - gesture.startX;
408
+ const dy = touch.clientY - gesture.startY;
409
+
410
+ if (!gesture.active) {
411
+ if (Math.abs(dx) < DRAG_THRESHOLD) return;
412
+ // 纵向为主:交还给宿主滚动,本次不拦截
413
+ if (Math.abs(dx) < Math.abs(dy) * 1.2) { gesture = null; return; }
414
+ if (gesture.mode === 'open' && dx <= 0) { gesture = null; return; }
415
+ if (gesture.mode === 'close' && dx >= 0) { gesture = null; return; }
416
+ gesture.active = true;
417
+ suppressClickUntil = Date.now() + 450;
418
+ document.body.classList.add(DRAGGING_CLASS);
419
+ }
420
+
421
+ const width = Math.max(1, gesture.width);
422
+ let x;
423
+ if (gesture.mode === 'open') x = Math.max(-width, Math.min(0, -width + dx));
424
+ else x = Math.max(-width, Math.min(0, dx));
425
+ gesture.dx = dx;
426
+ setDragProgress(x, width);
427
+ };
428
+
429
+ const finishGesture = (touch) => {
430
+ if (!gesture) return;
431
+ const current = gesture;
432
+ gesture = null;
433
+ if (!current.active) return;
434
+
435
+ const endX = touch ? touch.clientX : current.startX + current.dx;
436
+ const dx = endX - current.startX;
437
+ const elapsed = Math.max(1, Date.now() - current.startT);
438
+ const velocity = dx / elapsed;
439
+ const threshold = Math.min(90, Math.max(60, current.width * 0.3));
440
+ const openNow = current.mode === 'open'
441
+ ? dx > threshold || velocity > 0.45
442
+ : !(dx < -Math.max(50, threshold * 0.7) || velocity < -0.35);
443
+
444
+ document.body.classList.toggle(OPEN_CLASS, openNow);
445
+ if (openNow) ensureSidebarExpanded();
446
+ syncAria();
447
+ suppressClickUntil = Date.now() + 250;
448
+ // 等一帧让浏览器接住当前拖动位置,再撤掉拖动样式做吸附动画
449
+ window.requestAnimationFrame(clearDrag);
450
+ };
451
+
452
+ const onTouchEnd = (event) => {
453
+ if (!gesture) return;
454
+ finishGesture(event.changedTouches?.[0]);
455
+ };
456
+
457
+ const onTouchCancel = () => {
458
+ if (!gesture) return;
459
+ finishGesture(null);
460
+ };
461
+
462
+ injectStyles();
463
+ ensureElements();
464
+ document.addEventListener('click', onClick, true);
465
+ document.addEventListener('keydown', onKeyDown, true);
466
+ document.addEventListener('visibilitychange', onVisibilityChange);
467
+ window.addEventListener('resize', onResize);
468
+ window.addEventListener('touchstart', onTouchStart, { passive: true });
469
+ window.addEventListener('touchmove', onTouchMove, { passive: true });
470
+ window.addEventListener('touchend', onTouchEnd, { passive: true });
471
+ window.addEventListener('touchcancel', onTouchCancel, { passive: true });
472
+
473
+ // 等侧栏先无动画地藏到屏幕外,再启用过渡(否则首次加载会看到 280ms 滑出)
474
+ window.requestAnimationFrame(() => {
475
+ if (!disposed && document.body) document.body.classList.add('qzsy-drawer-ready');
476
+ });
477
+
478
+ if (typeof MutationObserver === 'function' && document.body) {
479
+ observer = new MutationObserver(() => {
480
+ if (!document.querySelector(`.${HEADER_CLASS}`) || !document.querySelector(`.${MENU_CLASS}`) || !document.querySelector(`.${BACKDROP_CLASS}`) || !document.getElementById(MOBILE_DRAWER_CSS_ID)) {
481
+ ensureElements();
482
+ }
483
+ });
484
+ observer.observe(document.body, { childList: true });
485
+ }
486
+
487
+ syncAria();
488
+
489
+ return () => {
490
+ disposed = true;
491
+ gesture = null;
492
+ try { observer?.disconnect(); } catch { /* 忽略 */ }
493
+ document.removeEventListener('click', onClick, true);
494
+ document.removeEventListener('keydown', onKeyDown, true);
495
+ document.removeEventListener('visibilitychange', onVisibilityChange);
496
+ window.removeEventListener('resize', onResize);
497
+ window.removeEventListener('touchstart', onTouchStart);
498
+ window.removeEventListener('touchmove', onTouchMove);
499
+ window.removeEventListener('touchend', onTouchEnd);
500
+ window.removeEventListener('touchcancel', onTouchCancel);
501
+ clearDrag();
502
+ document.body?.classList.remove(OPEN_CLASS);
503
+ document.body?.classList.remove('qzsy-drawer-ready');
504
+ document.querySelector(`.${HEADER_CLASS}`)?.remove();
505
+ document.querySelector(`.${BACKDROP_CLASS}`)?.remove();
506
+ document.getElementById(MOBILE_DRAWER_CSS_ID)?.remove();
507
+ root.removeAttribute(MOBILE_UI_FLAG);
508
+ };
509
+ }
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.1",
4
4
  "description": "DSH 远程隧道云官方客户端插件:一条命令把本机 DeepSeek Harness 接入云端隧道(设备码绑定、短期令牌、本地门禁代理、HTTP/SSE/WebSocket 反向隧道)。",
5
- "releaseNotes": "【v3.2.1】网关发布不再掉线\\n• 网关 drain(滚动重启)后自动重连,不再需要人工点「连接」\\n• 网关在线时面板只显示一行「网关:地址 + 新窗口打开」,掉线才展开详情\\n• npm 仓库地址补 git+ 前缀",
5
+ "releaseNotes": "【v3.3.1】移动端安全区与手势修复\\n• 全面屏:菜单按钮/内容/抽屉按 safe-area-inset 让位,刘海、状态栏、home indicator 不再遮挡\\n• 手势:左缘右滑打开、抽屉内左滑关闭;拖动跟手、遮罩渐变,松手按距离/速度吸附\\n• 纵向滚动不误触抽屉;滑动后的合成点击会被吞掉\\n• 首次加载不再播放侧栏滑出动画",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
8
8
  "exports": {