@mrrisega/dsh-remote 0.6.17 → 0.6.18-beta.2

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.
Files changed (33) hide show
  1. package/clients/dsh-remote/e2ee-shim-script.js +203 -11
  2. package/clients/dsh-remote/mobile-adapter.mjs +404 -29
  3. package/clients/dsh-remote/test/e2ee-shim.test.mjs +43 -1
  4. package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +145 -4
  5. package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +95 -6
  6. package/clients/dsh-remote/wechat-runtime.mjs +25 -2
  7. package/docs/telemetry.md +7 -1
  8. package/dsh-setup.mjs +152 -3
  9. package/package.json +1 -1
  10. package/packages/dsh-remote-web/lib/client.js +754 -39
  11. package/packages/dsh-remote-web/lib/index.js +279 -6
  12. package/packages/dsh-remote-web/package.json +1 -1
  13. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +203 -11
  14. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +404 -29
  15. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +25 -2
  16. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +152 -3
  17. package/packages/dsh-remote-web/test/account-switch-device.test.mjs +20 -2
  18. package/packages/dsh-remote-web/test/announcements-proxy.test.mjs +310 -0
  19. package/packages/dsh-remote-web/test/announcements-ui.test.mjs +524 -0
  20. package/packages/dsh-remote-web/test/connect-stuck-visibility.test.mjs +4 -1
  21. package/packages/dsh-remote-web/test/diag-report.test.mjs +4 -1
  22. package/packages/dsh-remote-web/test/harness-restart.test.mjs +5 -1
  23. package/packages/dsh-remote-web/test/installer-cli.test.mjs +1 -1
  24. package/packages/dsh-remote-web/test/launchd-domain.test.mjs +26 -0
  25. package/packages/dsh-remote-web/test/picker-pin.test.mjs +32 -2
  26. package/packages/dsh-remote-web/test/plugin-link.test.mjs +3 -1
  27. package/packages/dsh-remote-web/test/plugin-manifest.test.mjs +2 -2
  28. package/packages/dsh-remote-web/test/runtime-bundle.test.mjs +4 -1
  29. package/packages/dsh-remote-web/test/setup-output.test.mjs +22 -0
  30. package/packages/dsh-remote-web/test/telemetry.test.mjs +29 -10
  31. package/packages/dsh-remote-web/test/uninstall-safety.test.mjs +3 -3
  32. package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +8 -3
  33. package/packages/dsh-remote-web/test/windows-compat.test.mjs +7 -4
@@ -463,6 +463,50 @@ window.__ModuleLoader__.load({
463
463
  // Phase-5:端到端加密(E2EE)状态行 —— 启用=绿字绿点,未启用=灰字(纯文字状态行,不打扰)
464
464
  ".dru-e2ee-line{display:flex;align-items:center;gap:7px;margin-top:6px;font-size:12px;line-height:1.5;color:var(--dru-fg-muted)}",
465
465
  ".dru-e2ee-line.ok{color:var(--dru-success)}",
466
+ // ── 📣 公告(0.6.18)──────────────────────────────────────────────────────
467
+ // 侧栏入口上的**未读数字徽标**:与首次引导红点(.dru-nav-remote-dot)同一落点。
468
+ // 两者同时出现会叠在一起,所以 JS 侧是"徽标优先"(有未读就不挂红点,见 mountNavEntry)。
469
+ // 数字用白字蓝底(不是红底):红点=「你还没点过我」,未读数=「有多少条内容」,
470
+ // 语义不同就不该长一样;红色在这个面板里已经被"危险/未读公告本身"占用了。
471
+ ".dru-nav-remote-badge{position:absolute;top:-7px;right:-7px;min-width:16px;height:16px;box-sizing:border-box;padding:0 4px;border-radius:999px;background:var(--dru-accent-fill);color:var(--dru-on-accent);font:700 10.5px/16px inherit;text-align:center;box-shadow:0 0 0 2px var(--dru-surface-2);pointer-events:none}",
472
+ // 状态 chip 行:把原来"各自独占一行"的状态描述收进一行(换行交给 flex-wrap,窄屏不挤)
473
+ ".dru-chiprow{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-top:8px}",
474
+ ".dru-chiprow .dru-status-line,.dru-chiprow .dru-e2ee-line{margin-top:0}",
475
+ ".dru-chip{display:inline-flex;align-items:center;gap:6px;font-size:12.5px;line-height:1.5;color:var(--dru-fg);background:var(--dru-surface-2);border:1px solid var(--dru-border-soft);border-radius:999px;padding:3px 10px;max-width:100%}",
476
+ // 二维码右列下方的**提醒区**(引导性内容集中在这里,而不是散落在卡片各处)
477
+ ".dru-access-tips{display:flex;flex-direction:column;gap:6px;margin-top:4px;padding-top:8px;border-top:1px dashed var(--dru-border-soft)}",
478
+ ".dru-access-tips-title{font-size:11.5px;font-weight:700;color:var(--dru-fg-muted);letter-spacing:.5px}",
479
+ ".dru-access-tip-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:12px;line-height:1.6;color:var(--dru-fg-muted)}",
480
+ ".dru-access-tip-row b{color:var(--dru-fg)}",
481
+ // 公告列表(设置面板「📣 公告」卡)
482
+ ".dru-ann-list{display:flex;flex-direction:column;gap:8px}",
483
+ ".dru-ann-item{display:flex;align-items:flex-start;gap:8px;width:100%;box-sizing:border-box;text-align:left;background:var(--dru-surface-2);border:1px solid var(--dru-border-soft);border-radius:8px;padding:10px 12px;cursor:pointer;font-family:inherit;color:var(--dru-fg)}",
484
+ ".dru-ann-item:hover{border-color:var(--dru-accent);background:var(--dru-hover)}",
485
+ ".dru-ann-item-main{flex:1;min-width:0}",
486
+ ".dru-ann-item-title{font-size:13px;font-weight:600;color:var(--dru-fg);word-break:break-word}",
487
+ ".dru-ann-item-body{font-size:12.5px;line-height:1.6;color:var(--dru-fg-muted);margin-top:3px;white-space:pre-wrap;word-break:break-word}",
488
+ ".dru-ann-item-time{font-size:11.5px;color:var(--dru-fg-muted);flex:none;white-space:nowrap}",
489
+ ".dru-ann-unread{font-size:11px;border-radius:999px;padding:1px 8px;flex:none;color:var(--dru-danger);background:var(--dru-danger-soft);border:1px solid var(--dru-danger-border)}",
490
+ ".dru-ann-pinned{font-size:11px;border-radius:999px;padding:1px 8px;flex:none;color:var(--dru-tip-fg);background:var(--dru-accent-tint);border:1px solid var(--dru-accent-tint-border)}",
491
+ ".dru-ann-img{display:block;width:100%;max-width:420px;margin:10px auto 0;border-radius:8px;border:1px solid var(--dru-border)}",
492
+ // 公告弹窗右上角的 ✕(视觉 28px,命中区用 ::after 补到 ≥44px,与 .dru-tip-close 同一手法)
493
+ ".dru-popup-x{position:absolute;top:6px;right:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;padding:0;border:none;background:none;color:var(--dru-fg-muted);font-size:13px;line-height:1;cursor:pointer;border-radius:6px;font-family:inherit}",
494
+ ".dru-popup-x::after{content:\"\";position:absolute;left:-8px;right:-8px;top:-8px;bottom:-8px}",
495
+ ".dru-popup-x:hover{background:var(--dru-hover);color:var(--dru-fg)}",
496
+ // 「更多」的行内变体:账号卡那一颗原来 width:100% 撑满整行,宽而丑;
497
+ // ⚠️ 只用于账号卡(`.dru-disclose-inline`),邀请页的规则披露仍用整行的 .dru-disclose。
498
+ ".dru-disclose-inline{width:auto;min-width:96px;justify-content:center;gap:8px}",
499
+ // 重启强提醒(kind="restart"):软提示升级为**实底 + 左侧色条**,让人一眼看到"必须动一下"。
500
+ // 保持基类 .dru-restart-alert(用例按这个类名定位),只叠加加强态。
501
+ ".dru-restart-alert-strong{background:var(--dru-warn-soft);border-color:var(--dru-warn-border-2);border-left:4px solid var(--dru-warn-border-2);box-shadow:0 2px 10px var(--dru-shadow)}",
502
+ ".dru-restart-alert-strong .dru-restart-alert-title{font-size:14.5px}",
503
+ // ── 🤖 微信机器人「两步走」操作引导(在 .dru-wx-intro 之上,先讲用途再讲怎么接)──
504
+ ".dru-wx-guide{display:flex;flex-direction:column;gap:6px;background:var(--dru-accent-tint-2);border:1px solid var(--dru-accent-tint-border);border-radius:10px;padding:11px 12px}",
505
+ ".dru-wx-guide-title{font-size:12.5px;font-weight:700;color:var(--dru-tip-fg-strong)}",
506
+ ".dru-wx-guide-step{font-size:12.5px;line-height:1.65;color:var(--dru-fg)}",
507
+ ".dru-wx-guide-step b{color:var(--dru-tip-fg-strong)}",
508
+ ".dru-wx-guide-cmds{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11.5px;line-height:1.7;color:var(--dru-fg-muted);background:var(--dru-surface-2);border:1px solid var(--dru-border-soft);border-radius:8px;padding:7px 9px;white-space:pre-wrap;word-break:break-word;margin:0}",
509
+ ".dru-wx-guide-note{font-size:11.5px;line-height:1.6;color:var(--dru-fg-muted)}",
466
510
  // 侧栏「远程访问」快捷按钮(2026-09 恢复注入,与官方「设置」按钮共存不遮挡):
467
511
  // 独立 button(非 clone),插在官方「设置」按钮之前,同源同布局不覆盖官方热区。
468
512
  ".dru-nav-remote{display:inline-flex;align-items:center;gap:6px;height:30px;margin:0 0 2px;padding:0 10px;border:1px solid var(--dru-border-ctl);border-radius:8px;background:var(--dru-surface-2);color:var(--dru-accent);font:500 12.5px/1 inherit;cursor:pointer;white-space:nowrap;user-select:none}",
@@ -808,11 +852,15 @@ window.__ModuleLoader__.load({
808
852
  openRemoteSettings();
809
853
  });
810
854
  wrap.insertBefore(btn, host);
811
- if (!dotSeen()) {
855
+ // 徽标优先于首次引导红点:两者落点相同(右上角),同时挂会叠在一起。
856
+ // 有未读公告时"有多少条内容"比"你还没点过我"更值得占那个位置;红点本来也只到用户点一次为止。
857
+ var badgeOn = !!(annState.ok && annState.bubble && annState.unread > 0);
858
+ if (!dotSeen() && !badgeOn) {
812
859
  var d = document.createElement("span");
813
860
  d.className = "dru-nav-remote-dot";
814
861
  btn.appendChild(d);
815
862
  }
863
+ annRenderNavBadge();
816
864
  } catch (e) { /* 忽略 */ }
817
865
  }
818
866
  /** 常驻确保:官方「设置」按钮出现后在它前面挂一次入口(去重;2s 心跳,成本极低)。 */
@@ -830,6 +878,304 @@ window.__ModuleLoader__.load({
830
878
  try { if (iv && typeof iv.unref === "function") iv.unref(); } catch (e) {} // node 测试环境不阻塞退出
831
879
  }
832
880
 
881
+ // ══ 📣 公告(0.6.18)模块级状态 ═══════════════════════════════════════════
882
+ //
883
+ // 契约:dsh-relay-enterprise/docs/announcements-contract.md(冻结 v1)+ 926 迭代 §1(`client=desktop`)。
884
+ // GET /dsh-remote/announcements?client=desktop → { bubble_enabled, items[], unread_count, popup|null }
885
+ // POST /dsh-remote/announcements/click { id } → 记 clicked_at(= 已读上报;现契约**没有** /read)
886
+ //
887
+ // 🔴 三条硬约束(每条都对应一个真实会踩的坑):
888
+ //
889
+ // ① **绝不轮询**:服务端 GET 是「判定即记账」—— 命中弹窗候选就写 shown_at。
890
+ // 轮询/重复拉取会把本来该弹给用户的公告**静默消耗掉**(用户永远看不到,后台却显示"已曝光")。
891
+ // 所以自动拉取用 sessionStorage 标记「每次页面加载只拉一次」;用户主动打开面板要看列表时
892
+ // 才允许再拉一次(那是他自己的动作,且**只用于列表,绝不用它的返回值去弹窗**)。
893
+ //
894
+ // ② **apply() 里绝不碰产品 DOM**:客户端插件入口跑在 app mount **之前**,那时页面里
895
+ // 还没有任何产品节点(连 body 结构都未必就绪)。所有 UI 都交给 slot;这里只做"拉一次数据"。
896
+ //
897
+ // ③ **服务端才是已读权威**:`dsh web` 没显式指定端口时 origin 每次都可能变,
898
+ // 而 localStorage 按 origin 隔离 —— 本地记忆**只当 60s 级防抖**,绝不能当"已读"。
899
+ // 同理,本地"同 id 不再弹"只是"服务端已经不给"的镜像,不是我们自己判的。
900
+ var ANN_PULLED_KEY = "dsh-remote-ann-pulled-v1"; // sessionStorage:本页已自动拉过
901
+ var ANN_POPUP_KEY = "dsh-remote-ann-popup-v1"; // localStorage:弹窗防抖记忆(作用域见 annScopeOf)
902
+ var ANN_REPEAT_MS = 60 * 1000; // popup.always 的本地去抖窗口(契约 §3 要求客户端兜底)
903
+ var ANN_ITEM_MAX = 50; // 单次渲染上限(防御:服务端异常下发巨量条目)
904
+ /**
905
+ * 公告的内存快照 + 订阅(用 useSyncExternalStore 暴露给面板与弹窗)。
906
+ * 之所以是模块级的:拉取发生在 apply() 里(组件还没挂载),而消费方有两个
907
+ * (设置面板的卡片 / shell.overlay 上的弹窗),模块级 store 才能让两边看到同一份数据。
908
+ */
909
+ var annState = {
910
+ ok: false, // 拉取成功且结构是我们认识的那一种(老服务端 404 / 结构不符 → 一直是 false)
911
+ bubble: false, // bubble_enabled === true 才置真(字段缺失一律当 false = fail closed)
912
+ items: [],
913
+ unread: 0,
914
+ popup: null, // 待弹出的那一条(仅自动拉取会置;面板主动刷新不置)
915
+ scope: "anon", // 本地防抖的作用域(账号/设备)——由面板拿到 st 后回填
916
+ };
917
+ var annVersion = 0;
918
+ var annListeners = new Set();
919
+ function annSnapshot() { return annVersion; }
920
+ function annSubscribe(cb) {
921
+ annListeners.add(cb);
922
+ return function () { annListeners.delete(cb); };
923
+ }
924
+ function annEmit() {
925
+ annVersion++;
926
+ // 订阅者(React 组件)出错绝不连坐其他人;侧栏徽标是 DOM 订阅者,也一并在这里刷新。
927
+ annListeners.forEach(function (l) { try { l(); } catch (e) { /* 忽略 */ } });
928
+ annRenderNavBadge();
929
+ }
930
+ /** 本地防抖记忆的作用域:按账号(掩码手机号)优先,其次设备,都没有才 anon。 */
931
+ function annScopeKey() {
932
+ return ANN_POPUP_KEY + ":" + (annState.scope || "anon");
933
+ }
934
+ function annSetScope(v) {
935
+ var next = String(v || "").trim() || "anon";
936
+ if (next === annState.scope) return;
937
+ annState.scope = next;
938
+ annEmit();
939
+ }
940
+ /**
941
+ * 读本地弹窗记忆。
942
+ *
943
+ * 🔒 关键细节:**扫全部作用域键**,取时间最新的一条 —— 而不是只读当前作用域那一个键。
944
+ * 原因:自动拉取发生在 apply() 里(app mount 之前),那一刻面板还没渲染,
945
+ * `annState.scope` 仍是默认的 "anon";等面板渲染出账号并回填 scope 时,弹窗可能早就判完了。
946
+ * 只看当前作用域键会导致"弹出决策用 anon、记已读用手机号"两把钥匙对不上 ——
947
+ * 表现为**同一条公告每次开页面都弹一遍**(防打扰彻底失效)。
948
+ * 扫全量则与作用域无关,代价是多次弹窗的选择变保守(宁可少弹),这正是我们要的方向。
949
+ * 坏数据/不可用一律当"没记过"(宁可多弹一次,也不要因为 localStorage 抛异常而静默失效)。
950
+ */
951
+ function annPopMemo() {
952
+ try {
953
+ var prefix = ANN_POPUP_KEY;
954
+ var n = 0;
955
+ try { n = Number(localStorage.length) || 0; } catch (e) { n = 0; }
956
+ var best = null;
957
+ for (var i = 0; i < n; i++) {
958
+ var k = null;
959
+ try { k = localStorage.key(i); } catch (e) { k = null; }
960
+ if (!k || k.indexOf(prefix) !== 0) continue;
961
+ var v = null;
962
+ try { v = JSON.parse(localStorage.getItem(k) || "null"); } catch (e) { v = null; }
963
+ if (!v || !isFinite(Number(v.id))) continue;
964
+ if (!best || Number(v.at || 0) > Number(best.at || 0)) best = v;
965
+ }
966
+ if (best) return best;
967
+ } catch (e) { /* 落到单键读 */ }
968
+ try { return JSON.parse(localStorage.getItem(annScopeKey()) || "null"); } catch (e) { return null; }
969
+ }
970
+ function annPopRemember(id) {
971
+ try { localStorage.setItem(annScopeKey(), JSON.stringify({ id: id, at: Date.now() })); } catch (e) { /* 忽略 */ }
972
+ }
973
+ /**
974
+ * 归一化服务端响应。**认识不了的一律返回 null**(fail closed):
975
+ * · 不是对象 / ok:false / 404 体 → null
976
+ * · bubble_enabled 缺失或非 true → false(后台没开入口,绝不猜成开)
977
+ * · items 缺失/非数组 → []
978
+ * · popup 缺失/没有 id / id 不是有限数 → null(**绝不能**把"缺字段"当成"有公告"弹一个空窗)
979
+ * unread_count 非法时退回"数 items 里 unread 的条数",而不是编一个 0(0 会让徽标消失)。
980
+ */
981
+ function annNormalize(b) {
982
+ if (!b || typeof b !== "object" || Array.isArray(b)) return null;
983
+ if (b.ok === false) return null;
984
+ var rawItems = Array.isArray(b.items) ? b.items : [];
985
+ var items = [];
986
+ for (var i = 0; i < rawItems.length && items.length < ANN_ITEM_MAX; i++) {
987
+ var it = rawItems[i];
988
+ if (!it || typeof it !== "object") continue;
989
+ var id = Number(it.id);
990
+ if (!isFinite(id)) continue; // 没有 id 的条目点不了、也上报不了 → 直接丢
991
+ items.push({
992
+ id: id,
993
+ title: typeof it.title === "string" ? it.title : "",
994
+ body: typeof it.body === "string" ? it.body : "",
995
+ image: typeof it.image === "string" ? it.image : "",
996
+ pinned: it.pinned === 1 || it.pinned === true,
997
+ created_at: Number(it.created_at) || 0,
998
+ // unread 缺失 → 当成**已读**(宁可不显示红点,也不要把已读的重新标成未读骗点击)
999
+ unread: it.unread === true,
1000
+ });
1001
+ }
1002
+ var unread = Number(b.unread_count);
1003
+ if (!isFinite(unread) || unread < 0) {
1004
+ unread = items.filter(function (x) { return x.unread; }).length;
1005
+ }
1006
+ var popup = null;
1007
+ var p = b.popup;
1008
+ if (p && typeof p === "object" && !Array.isArray(p)) {
1009
+ var pid = Number(p.id);
1010
+ if (isFinite(pid)) {
1011
+ popup = {
1012
+ id: pid,
1013
+ title: typeof p.title === "string" ? p.title : "",
1014
+ body: typeof p.body === "string" ? p.body : "",
1015
+ image: typeof p.image === "string" ? p.image : "",
1016
+ };
1017
+ }
1018
+ }
1019
+ return { bubble: b.bubble_enabled === true, items: items, unread: Math.max(0, Math.floor(unread)), popup: popup };
1020
+ }
1021
+ /**
1022
+ * 本次响应要不要弹?
1023
+ * · 服务端没给 popup → 不弹(老后台 / 运营没开 / once 模式已记账过)
1024
+ * · 本地记忆里**同一条**在 60s 内弹过 → 不弹(`always` 模式的防抖)
1025
+ *
1026
+ * ⚠️ 为什么用"id + 60s 时间窗"而不是"同 id 永不再弹":响应里**没有** `mode` 字段
1027
+ * (契约 §3 的 popup 只有 id/title/body/image),客户端根本区分不出 once / always。
1028
+ * 这个规则的语义是两者的**安全超集**:
1029
+ * · once:服务端记账后**不再返回**这条 → 本地永远没机会再弹(等价于"永不再弹");
1030
+ * · always:服务端每次都返回 → 本地把频率压到 ≥60s 一次。
1031
+ * 反过来说:**绝不能**把它做成"同 id 永久记住" —— 那会把服务端的 always
1032
+ * 静默压成 once(契约 §3 明确警告过服务端不得这么"优化",客户端同理)。
1033
+ */
1034
+ function annShouldPopup(popup) {
1035
+ if (!popup) return false;
1036
+ var memo = annPopMemo();
1037
+ if (memo && Number(memo.id) === popup.id && Date.now() - Number(memo.at || 0) < ANN_REPEAT_MS) return false;
1038
+ return true;
1039
+ }
1040
+ /** 侧栏「📱 远程访问」入口上的未读数字徽标(模块级 DOM 维护;徽标优先于首次引导红点)。 */
1041
+ function annRenderNavBadge() {
1042
+ try {
1043
+ var btn = document.getElementById(NAV_ENTRY_ID);
1044
+ if (!btn) return;
1045
+ var old = btn.querySelector(".dru-nav-remote-badge");
1046
+ var show = !!(annState.ok && annState.bubble && annState.unread > 0);
1047
+ if (!show) {
1048
+ if (old && old.parentNode) old.parentNode.removeChild(old);
1049
+ return;
1050
+ }
1051
+ var txt = annState.unread > 9 ? "9+" : String(annState.unread);
1052
+ if (old) { if (old.textContent !== txt) old.textContent = txt; return; }
1053
+ var b = document.createElement("span");
1054
+ b.className = "dru-nav-remote-badge";
1055
+ b.setAttribute("aria-hidden", "true"); // 纯装饰:可访问名字来自按钮文字
1056
+ b.textContent = txt;
1057
+ btn.appendChild(b);
1058
+ } catch (e) { /* 忽略:徽标失败绝不影响入口本身 */ }
1059
+ }
1060
+ /**
1061
+ * 主动拉一次公告。
1062
+ * @param {{openPopup?:boolean}} [opts] openPopup=true 仅用于**每次页面加载的自动拉取**;
1063
+ * 面板主动刷新时传 false —— 用它的返回值弹窗会让"用户主动看列表"变成一次打扰。
1064
+ */
1065
+ function annFetch(opts) {
1066
+ var withPopup = !!(opts && opts.openPopup);
1067
+ return api("/dsh-remote/announcements?client=desktop").then(function (b) {
1068
+ var norm = annNormalize(b);
1069
+ if (!norm) { annState.ok = false; annState.bubble = false; annState.items = []; annState.unread = 0; annEmit(); return false; }
1070
+ annState.ok = true;
1071
+ annState.bubble = norm.bubble;
1072
+ annState.items = norm.items;
1073
+ annState.unread = norm.unread;
1074
+ if (withPopup && annShouldPopup(norm.popup)) {
1075
+ annState.popup = norm.popup;
1076
+ }
1077
+ annEmit();
1078
+ return true;
1079
+ }).catch(function () {
1080
+ // 404(老服务端)/ 不可达 / 超时:fail closed —— 不显示入口、不弹窗,**绝不抛**
1081
+ annState.ok = false;
1082
+ annState.bubble = false;
1083
+ annState.items = [];
1084
+ annState.unread = 0;
1085
+ annEmit();
1086
+ return false;
1087
+ });
1088
+ }
1089
+ /** 每次页面加载的自动拉取(sessionStorage 一次性标记;已在 apply() 里整段 try/catch)。 */
1090
+ function annPullOnce() {
1091
+ try {
1092
+ if (sessionStorage.getItem(ANN_PULLED_KEY) === "1") return; // 本页已拉过 → 绝不重复("判定即记账")
1093
+ sessionStorage.setItem(ANN_PULLED_KEY, "1");
1094
+ } catch (e) {
1095
+ // sessionStorage 不可用(隐私模式):**宁可少拉一次也不要多拉**(重复拉会吃掉待弹公告),
1096
+ // 所以这里直接放弃这次自动拉取 —— 入口欠显示比"公告被静默消耗"代价小得多。
1097
+ return;
1098
+ }
1099
+ annFetch({ openPopup: true });
1100
+ }
1101
+ /** 已读上报(点击/关闭 = 同一次上报:现契约只有 /click,没有 /read)。 */
1102
+ function annReportClick(id) {
1103
+ try { post("/dsh-remote/announcements/click", { id: id }).catch(function () { /* 静默:服务端才是权威 */ }); } catch (e) { /* 忽略 */ }
1104
+ }
1105
+ /** 本地把某条标记为已读(只为了让徽标/标签立刻更新;真正的已读在服务端)。 */
1106
+ function annMarkReadLocal(id) {
1107
+ var hit = false;
1108
+ annState.items = annState.items.map(function (x) {
1109
+ if (x.id !== id || !x.unread) return x;
1110
+ hit = true;
1111
+ return Object.assign({}, x, { unread: false });
1112
+ });
1113
+ if (hit) { annState.unread = Math.max(0, annState.unread - 1); annEmit(); }
1114
+ }
1115
+
1116
+ // ── 🤖 微信机器人「命令帮助」镜像 ─────────────────────────────────────────
1117
+ // ⚠️ 真身是 clients/dsh-remote/wechat-channel.mjs 的 HELP_TEXT(用户在微信里看到的唯一说明书)。
1118
+ // 浏览器半与 bridge 是两个 bundle,**没有**可用的 require 通道,所以沿用本文件顶部
1119
+ // WECHAT_FREE_CAPS 那套既定做法:这里放一份**给面板看的镜像**,由测试逐条与 HELP_TEXT
1120
+ // 对账(wechat-bot-ui.test.mjs / announcements-ui.test.mjs)。面板多写一条命令,
1121
+ // 用户照着发就会拿到"不认识这条指令" —— 所以这条测试是硬约束,不是装饰。
1122
+ // 只列**指令类**(`/xxx`)那几行:`回数字` / `直接发一句话` 属于自然语言用法,
1123
+ // 由 .dru-wx-guide 的正文讲,不进这份命令清单。
1124
+ var WECHAT_HELP_CMDS = [
1125
+ "/new <任务> 开新任务并把任务发下去",
1126
+ "/ls 列出会话,用 /use <编号> 切换",
1127
+ "/use <编号> 切换到某个会话,之后你发的话都进它",
1128
+ "/stop 中断当前会话正在跑的回合",
1129
+ "/summary 重发当前会话的最近结论",
1130
+ "/status 查看绑定与推送状态",
1131
+ "/quiet 暂停推送(回复任意消息恢复)",
1132
+ "/unbind 解除微信绑定",
1133
+ "/help 显示本帮助"
1134
+ ];
1135
+
1136
+ // ══ 📶 网络延迟(0.6.18)——**绝不主动测速**,只在既有周期请求上量 ══════════
1137
+ //
1138
+ // 用户口径原话:「根据网络请求实际情况显示延迟。**不要主动测速**,可以根据最近一次心跳
1139
+ // 检测或者必要的本身有的请求来进行测算。」
1140
+ //
1141
+ // 所以这里**不新增任何请求**:面板本来就有 `/dsh-remote/status` 的周期轮询
1142
+ // (STATUS_POLL_MS)与打开面板时的一次 refresh —— 就在这两处用 performance.now() 量差值。
1143
+ // 这也是"用户真正感受到的延迟":一次真实的同源请求往返,而不是 ping 一个假地址。
1144
+ //
1145
+ // 三条纪律:
1146
+ // ① 只在**成功**拿到响应时记样本(失败/超时不是"延迟",是"没有网络",混进来会把均值拉爆);
1147
+ // ② 明显异常的值(>30s,多半是挂起后又被 AbortController 收掉)直接丢弃;
1148
+ // ③ 用 EWMA(指数滑动平均)而不是"最后一次"或"全部平均":前者会跟着一次抖动乱跳,
1149
+ // 后者对"网络刚刚变慢"反应太迟钝 —— 面板上要的是"现在大概多快"。
1150
+ var RTT_ALPHA = 0.3; // 新样本权重(越大越跟手,越小越平滑)
1151
+ var RTT_MAX_MS = 30000; // 超过这个值不当样本(挂起/超时的产物,不是延迟)
1152
+ var netLat = { ms: 0, samples: 0 };
1153
+ /** 高精度时钟;没有 performance 的旧环境退回 Date.now(精度差但不会抛)。 */
1154
+ function netNow() {
1155
+ try {
1156
+ if (typeof performance !== "undefined" && performance && typeof performance.now === "function") return performance.now();
1157
+ } catch (e) { /* 忽略 */ }
1158
+ return Date.now();
1159
+ }
1160
+ /** 记一次 RTT 样本(只在成功的请求上调用,见上面的纪律 ①)。 */
1161
+ function noteRtt(ms) {
1162
+ var v = Number(ms);
1163
+ if (!isFinite(v) || v <= 0 || v > RTT_MAX_MS) return;
1164
+ netLat.ms = netLat.samples === 0 ? v : (netLat.ms * (1 - RTT_ALPHA) + v * RTT_ALPHA);
1165
+ netLat.samples++;
1166
+ }
1167
+ /**
1168
+ * 延迟 → 人话。
1169
+ * 🔒 无样本时必须说「测速中…」而**不是 0ms**:0ms 是句谎话(这个页面根本还没量过),
1170
+ * 而"测速中"如实表达了"还没有样本"。分档:≤120 顺畅 / ≤300 一般 / >300 较慢。
1171
+ */
1172
+ function netLatText() {
1173
+ if (!netLat.samples) return "测速中…";
1174
+ var ms = Math.max(1, Math.round(netLat.ms));
1175
+ var tone = ms <= 120 ? "顺畅" : ms <= 300 ? "一般" : "较慢";
1176
+ return "网络延迟 " + ms + "ms · " + tone;
1177
+ }
1178
+
833
1179
  // ── 宿主 API ────────────────────────────────────────────────────────────
834
1180
  /**
835
1181
  * 默认请求超时(0.6.7-beta.3 新增)。
@@ -1637,6 +1983,70 @@ window.__ModuleLoader__.load({
1637
1983
  );
1638
1984
  }
1639
1985
 
1986
+ // ── 📣 电脑端公告弹窗(shell.overlay 的**第二个**槽位)────────────────────
1987
+ //
1988
+ // 为什么是 shell.overlay:官方**没有** notice / toast / dialog / modal 服务,也没有宿主→客户端的
1989
+ // 通用通知通道(客户端服务全集里没有这一类),`shell.overlay` 是唯一现成的弹窗落点 ——
1990
+ // 本插件自己已经用它挂过满意度弹窗(FeedbackPopup),第三方 @max-null/dsh-plugin-center 的
1991
+ // WhatsNewDialog 也是"打开弹一次 + 点任意按钮即已读 + 不频繁打扰"的完整先例。
1992
+ //
1993
+ // 行为(对齐需求与契约):
1994
+ // · 服务端没给 popup(老后台/运营关掉/once 已记账)→ 直接 return null,什么都不渲染;
1995
+ // · **任意按钮 / 遮罩 / ✕ 都算已读**:先记账(POST /click)+ 写本地防抖 + 关闭,
1996
+ // 顺序是"先关后报"—— 上报失败也**不能**把用户困在弹窗里(服务端才是权威,下次会再给);
1997
+ // · 整段逻辑包 try/catch:客户端插件抛异常 = 整个 web app 起不来("Failed to load plugins"),
1998
+ // 公告这种锦上添花的东西绝没有资格把门关上。
1999
+ function AnnouncementPopup() {
2000
+ var theme = useSyncExternalStore(themeSubscribe, themeGet);
2001
+ // 订阅模块级 store:公告是 apply() 里异步拉回来的,组件必须先挂上、数据到了再渲染。
2002
+ useSyncExternalStore(annSubscribe, annSnapshot);
2003
+ var closingArr = useState(false); var closing = closingArr[0]; var setClosing = closingArr[1];
2004
+ var popup = annState.popup;
2005
+ if (!popup) return null;
2006
+
2007
+ /** 关闭 + 记已读。无论上报成功与否都关闭(绝不因为网络问题把用户留在弹窗里)。 */
2008
+ var dismiss = function () {
2009
+ if (closing) return;
2010
+ setClosing(true);
2011
+ try { annPopRemember(popup.id); } catch (e) { /* 忽略 */ }
2012
+ try { annReportClick(popup.id); } catch (e) { /* 忽略 */ }
2013
+ try { annMarkReadLocal(popup.id); } catch (e) { /* 忽略 */ }
2014
+ try { annState.popup = null; } catch (e) { /* 忽略 */ }
2015
+ setClosing(false);
2016
+ annEmit();
2017
+ };
2018
+
2019
+ return h("div", {
2020
+ className: "dru-popup",
2021
+ "data-dru-theme": theme,
2022
+ // 点遮罩 = 已读(与 FeedbackPopup 同一交互;用户"看过并关掉"就是最诚实的已读信号)
2023
+ onMouseDown: function (e) { if (e.target === e.currentTarget) dismiss(); },
2024
+ },
2025
+ h("div", { className: "dru-popup-card", role: "dialog", "aria-modal": "true", "aria-label": "公告", style: { position: "relative" } },
2026
+ // ✕ 也是"已读":三个入口(✕ / 遮罩 / 底部按钮)必须走**同一个** dismiss,
2027
+ // 任何一个漏了都会造成"我明明看到了,后台却显示没看过"。
2028
+ h("button", {
2029
+ type: "button",
2030
+ className: "dru-popup-x",
2031
+ "aria-label": "关闭公告(记为已读)",
2032
+ title: "关闭并记为已读",
2033
+ onClick: dismiss,
2034
+ }, "✕"),
2035
+ h("div", { className: "dru-popup-body", style: { textAlign: "left" } },
2036
+ h("div", { className: "dru-popup-icon", style: { textAlign: "center" } }, "📣"),
2037
+ h("div", { className: "dru-popup-title", style: { textAlign: "center" } }, popup.title || "公告"),
2038
+ popup.body ? h("div", { className: "dru-ann-item-body", style: { marginTop: 8 } }, popup.body) : null,
2039
+ popup.image ? h("img", { className: "dru-ann-img", src: popup.image, alt: "公告图片" }) : null
2040
+ ),
2041
+ h("div", { className: "dru-popup-foot" },
2042
+ h("button", { type: "button", title: "关闭并记为已读", onClick: dismiss }, "关闭"),
2043
+ // 「已读」与「关闭」走**同一个**收尾函数(契约只有一个 /click,两个入口不该有两种行为)
2044
+ h("button", { type: "button", disabled: closing, onClick: dismiss }, closing ? "处理中…" : "已读")
2045
+ )
2046
+ )
2047
+ );
2048
+ }
2049
+
1640
2050
  // ── 图标 ────────────────────────────────────────────────────────────────
1641
2051
  // 「远程访问」栏目图标沿用面板 emoji 图标体系:📱 更贴合“手机/另一台电脑远程访问”语义
1642
2052
  // (原 🖥 已随入口迁移调整);栏目导航 label 与栏目头部均使用它。
@@ -1701,6 +2111,13 @@ window.__ModuleLoader__.load({
1701
2111
  var cxBusyArr = useState(false); var cxBusy = cxBusyArr[0]; var setCxBusy = cxBusyArr[1];
1702
2112
  var cxOkArr = useState(null); var cancelOk = cxOkArr[0]; var setCancelOk = cxOkArr[1]; // null=未知 true=可用 false=旧宿主无此接口
1703
2113
  var cxMsgArr = useState(null); var cxMsg = cxMsgArr[0]; var setCxMsg = cxMsgArr[1]; // {kind, text}
2114
+ // 【0.6.18】「查看最近一次更新日志」折叠态(追加在既有 state 之后,不动上面的 hook 序号)。
2115
+ // 它是"主动查看"的出路:日志自动展开只在更新中/卡住时发生(见 updShowLog),
2116
+ // 更新一结束黑框就收起来 —— 但用户事后想回看时必须还有地方点。
2117
+ var logOpenArr = useState(false); var logOpen = logOpenArr[0]; var setLogOpen = logOpenArr[1];
2118
+ // 最近一次拉到的日志尾巴(**与 log 分开存**的原因见 applyUpdLog 收尾分支:
2119
+ // log 只服务于"正在更新"的黑框,收尾即清空;lastLog 留给用户主动回看)。
2120
+ var lastLogArr = useState(""); var lastLog = lastLogArr[0]; var setLastLog = lastLogArr[1];
1704
2121
 
1705
2122
  var loadVer = useCallback(function () {
1706
2123
  api("/dsh-remote/self").then(function (b) {
@@ -1762,6 +2179,12 @@ window.__ModuleLoader__.load({
1762
2179
  } else {
1763
2180
  setUpdated(true);
1764
2181
  }
2182
+ // 【0.6.18】收尾时必须**主动收起黑框**:`log` 来自宿主那个只增不减的持久文件,
2183
+ // 不清掉的话下一次渲染(以及刷新页面后的 mount 探针)会把它原样拉回来 ——
2184
+ // 那就是"更新早就结束了、黑框却永远挂在面板上"的另一半原因。
2185
+ // 主动查看的路留在「查看最近一次更新日志」折叠里:把这份尾巴另存进 lastLog 再清空 log。
2186
+ setLastLog(nextLog);
2187
+ setLog("");
1765
2188
  loadVer();
1766
2189
  doCheck();
1767
2190
  }, [loadVer, doCheck]);
@@ -1902,6 +2325,19 @@ window.__ModuleLoader__.load({
1902
2325
  var stalled = !!(prog && prog.stalled);
1903
2326
  var canCancel = updating && cancelOk !== false && ((prog && prog.stalled) || idleShown >= cancelAfterMs);
1904
2327
  var idleHint = progRunning && !stalled && idleShown >= UPDATE_IDLE_HINT_MS;
2328
+ /**
2329
+ * 更新日志黑框**什么时候自动展开**(0.6.18 修的"永久不消失"):
2330
+ * 只有「更新真的在跑」或「服务端判定卡住」时才自动摊开 —— 那是用户此刻需要看的唯一信息。
2331
+ *
2332
+ * 为什么原来会永久显示:`log` 来自宿主持久文件 `.dsh-update.log` 的 24 行尾
2333
+ * (`/self/update-log` 的 `tailOf`),文件只 append 从不截断;而收尾分支
2334
+ * (applyUpdLog 的 running=false 路径)只 `setUpdating(false)`、**从不 `setLog("")`**。
2335
+ * 于是"更新结束"这个事实在界面上只体现为进度指标消失,黑框还在;刷新页面又会重新拉回旧尾巴。
2336
+ *
2337
+ * 注意判据里**保留 `prog.stalled`**:更新进程已退出但被判为卡住时,日志正是证据,
2338
+ * 不能跟着 running=false 一起收起来(那等于把失败现场藏了)。
2339
+ */
2340
+ var updShowLog = !!(updating || (prog && prog.stalled));
1905
2341
 
1906
2342
  return h("div", { className: "dru-card", style: { marginTop: 2 } },
1907
2343
  h("h3", null, "🔄 版本与更新"),
@@ -1988,8 +2424,31 @@ window.__ModuleLoader__.load({
1988
2424
  ? h("div", { className: "dru-msg dru-msg-ok" },
1989
2425
  "✅ 更新已完成,最新代码已就位。请", h("strong", null, "重启 dsh web"), "后生效;后台服务会随系统自启自动运行新版本。")
1990
2426
  : null,
1991
- // ④ 更新日志(失败时同样靠近上面的错误提示)
1992
- log ? h("div", { className: "dru-up-log", title: "更新日志(尾部)" }, log) : null,
2427
+ // ④ 更新日志:**只在真的在更新(或服务端判定卡住)时**自动摊开。
2428
+ // 旧实现是 `log ? … : null` —— 只看"日志非空",而日志来自宿主持久文件、
2429
+ // 收尾又不清空 → 更新结束后黑框仍在,刷新页面还把它拉回来(用户报的"永久不消失")。
2430
+ updShowLog && log ? h("div", { className: "dru-up-log", title: "更新日志(尾部)" }, log) : null,
2431
+ // ⑤ 主动回看:更新结束后黑框收起,但日志不该消失得无影无踪(排查还要用)。
2432
+ // 复用 .dru-disclose(账号卡那一处才是 .dru-disclose-inline,这里保持整行)。
2433
+ !updShowLog && h("button", {
2434
+ type: "button",
2435
+ className: "dru-disclose",
2436
+ style: { marginTop: 8 },
2437
+ "aria-expanded": logOpen ? "true" : "false",
2438
+ onClick: function () {
2439
+ var next = !logOpen;
2440
+ setLogOpen(next);
2441
+ if (next) logTimer(); // 展开时才去拉一次(不展开就不打扰宿主)
2442
+ }
2443
+ },
2444
+ h("span", null, "查看最近一次更新日志"),
2445
+ h("span", { className: "dru-disclose-caret", "aria-hidden": "true" }, logOpen ? "收起 ▲" : "展开 ▼")
2446
+ ),
2447
+ !updShowLog && logOpen
2448
+ ? (log || lastLog
2449
+ ? h("div", { className: "dru-up-log", title: "更新日志(尾部)" }, log || lastLog)
2450
+ : h("div", { className: "dru-hint", style: { marginTop: 6 } }, "还没有更新日志(本机没跑过在线更新)。"))
2451
+ : null,
1993
2452
  selfMsg ? h("div", { className: "dru-msg dru-msg-" + selfMsg.kind }, selfMsg.text) : null,
1994
2453
  h("div", { className: "dru-hint", style: { marginTop: 8 } },
1995
2454
  armed ? "⚠ 再次点击后即开始彻底卸载:① 移除 dsh web 配置中的插件引用与本地文件;② 停止并移除 bridge 自启动服务(即让「后台服务」不再开机自启:macOS com.dshremote.bridge / Linux dsh-bridge / Windows 任务计划程序 dsh-remote-bridge)并结束残留进程;③ 清空本地配置目录(~/.dsh-remote:账号、设备密钥、固化运行时等)。此操作不可撤销,如需再次使用:在插件市场搜索「dsh-remote-web」重新安装,或执行 npx @mrrisega/dsh-remote。" :
@@ -2126,7 +2585,9 @@ window.__ModuleLoader__.load({
2126
2585
 
2127
2586
  var refresh = useCallback(function () {
2128
2587
  setBusy("status");
2588
+ var t0 = netNow(); // 📶 延迟采样点①:打开面板时这一次 /status(既有请求,不新增)
2129
2589
  api("/dsh-remote/status").then(function (body) {
2590
+ noteRtt(netNow() - t0); // 先记样本再 setSt —— 这一次的延迟当轮就能显示出来
2130
2591
  setSt(body);
2131
2592
  setMode(body && body.config && body.config.mode === "local" ? "local" : "saas");
2132
2593
  setMessage(null);
@@ -2166,6 +2627,13 @@ window.__ModuleLoader__.load({
2166
2627
  // kind === "refresh" = 插件文件在运行中被改写(装完/更新完),只需刷新页面;
2167
2628
  // 其余情况(极为罕见)才提重启 dsh web。
2168
2629
  var isRefreshOnly = restartPending && restartInfo.kind === "refresh";
2630
+ // 【0.6.18】kind === "restart" = **在线更新已完成、必须重启 dsh web 才生效**(宿主在
2631
+ // runOnlineUpdate 成功收尾时写的)。它与 refresh 是两种不同动作,文案与主按钮必须一一对应
2632
+ // (混用就会出现"文案说刷新、状态其实是重启"这种自相矛盾)。
2633
+ // 🔴 这一种**绝不自动倒计时重启**:在线更新的收尾时刻,用户很可能正在一段会话中间 ——
2634
+ // 15 秒后替他把 dsh web 杀掉,正是代码里反复强调的"不能替他做的决定"。
2635
+ // 所以这里只给**强提醒 + 主按钮「立即重启」**,什么时候重启由他点。
2636
+ var isRestartHard = restartPending && restartInfo.kind === "restart";
2169
2637
  // 连接阶段(node 半下发):轮询拿到的新鲜结果优先,其次用 /status 里带的那一份;
2170
2638
  // 旧版 host(响应里没有 connect 字段)→ 回退到原来的 service.running 文案(行为不变)。
2171
2639
  var connInfo = conn || (st && st.connect) || null;
@@ -2205,7 +2673,9 @@ window.__ModuleLoader__.load({
2205
2673
  /** 轻量状态轮询:只更新 st,不改 mode(mode 由用户 Tab 选择决定,避免轮询把自建/云端来回切)。 */
2206
2674
  function pollStatus() {
2207
2675
  if (document.hidden) return; // 页面隐藏时不空转(审计:降低请求频率)
2676
+ var t0 = netNow(); // 📶 延迟采样点②:面板停留期间的周期心跳(30s 一次,既有请求)
2208
2677
  api("/dsh-remote/status").then(function (body) {
2678
+ noteRtt(netNow() - t0);
2209
2679
  if (!body || !body.ok) return;
2210
2680
  setSt(body);
2211
2681
  }).catch(function () {});
@@ -3332,6 +3802,18 @@ window.__ModuleLoader__.load({
3332
3802
  onClick: function () { setView("invite"); loadInvite(); }
3333
3803
  }, "🎁 带新用户换会员") : null,
3334
3804
  h("button", { type: "button", className: "dru-btn dru-btn-ghost", disabled: busy !== "", onClick: function () { setView("feedback"); } }, "💬 用户反馈"),
3805
+ // 💬 加入交流群(0.6.18 从「关于」卡搬到这里):它和「用户反馈」是同一类东西 ——
3806
+ // 「跟我们联系」的两个入口。原来它躺在最底下的「关于 dsh-remote」里,用户要读完
3807
+ // 六段技术说明才找得到,而那时的位置离"有事想找人问"的场景已经很远了。
3808
+ // 未配置二维码(后台上传才有)→ 整体不渲染,不留空壳按钮。
3809
+ community && community.qrcode
3810
+ ? h("button", {
3811
+ type: "button",
3812
+ className: "dru-btn dru-btn-ghost",
3813
+ disabled: busy !== "",
3814
+ onClick: function () { setCommOpen(true); }
3815
+ }, "💬 加入交流群")
3816
+ : null,
3335
3817
  h("button", { type: "button", className: "dru-btn dru-btn-ghost", disabled: busy !== "", onClick: togglePwdForm }, pwdOpen ? "收起修改密码" : "🔒 修改密码"),
3336
3818
  h("button", { type: "button", className: "dru-btn dru-btn-danger", disabled: busy !== "", onClick: function () { doLogout(false); } }, "退出登录")
3337
3819
  ),
@@ -3346,10 +3828,14 @@ window.__ModuleLoader__.load({
3346
3828
  // :focus-visible 基线),开合状态用「展开 ▼ / 收起 ▲」**文字**加箭头表达,不靠颜色区分。
3347
3829
  // 无障碍:aria-expanded 表达开合,aria-controls 指向下面的内容体(内容体始终在 DOM 里、
3348
3830
  // 收起时 hidden,所以 aria-controls 永远指向一个真实存在的元素,不指向空气)。
3831
+ // 【0.6.18】加 .dru-disclose-inline:原来它继承 .dru-disclose 的 width:100%,
3832
+ // 在账号卡里被撑成一条横贯整卡的宽条,和上面那排按钮完全不是一个视觉重量(业主:"宽而丑")。
3833
+ // ⚠️ 变体只加在**这一处**:邀请页的「活动规则」披露仍是整行,那是对的(它是那一屏的主折叠)。
3834
+ // id / aria-controls / aria-expanded 一个都不能动 —— remote-access-ui.test.mjs 按它们定位。
3349
3835
  h("button", {
3350
3836
  type: "button",
3351
3837
  id: MKT_MORE_TOGGLE_ID,
3352
- className: "dru-disclose",
3838
+ className: "dru-disclose dru-disclose-inline",
3353
3839
  style: { marginTop: 10 },
3354
3840
  "aria-expanded": moreOpen ? "true" : "false",
3355
3841
  "aria-controls": MKT_MORE_BODY_ID,
@@ -3874,12 +4360,26 @@ window.__ModuleLoader__.load({
3874
4360
  h("span", null, e.text));
3875
4361
  }
3876
4362
  return card("📱 远程访问", [
3877
- h("div", { className: "dru-status-line" },
3878
- h("span", { className: dotCls }),
3879
- h("span", null, statusTxt)
4363
+ // 【0.6.18 面板重排】状态性描述(连接状态 + E2EE)从"各自独占一行"收敛成**一行内的 chip**:
4364
+ // · 用 flex-wrap 而不是硬挤:窄屏(手机镜像页也是同一份面板)自动换行,绝不横向溢出;
4365
+ // · 文字标签始终在(点 + 文案),颜色只是辅助,不靠颜色单独表意;
4366
+ // · `.dru-e2ee-line` 这个 class **必须保留**(remote-access-ui.test.mjs 按它定位)。
4367
+ // E2EE 徽标仍留在这一层而不是搬进右列:未生成访问链接(hasKey=false)时右列不存在,
4368
+ // 搬过去会让"服务端说加密已开"这条信息凭空消失。
4369
+ h("div", { className: "dru-chiprow" },
4370
+ h("span", { className: "dru-chip" },
4371
+ h("span", { className: dotCls, "aria-hidden": "true" }),
4372
+ h("span", null, statusTxt)
4373
+ ),
4374
+ // 📶 网络延迟 chip:数字来自**既有** /status 请求的往返耗时(refresh + 30s 轮询),
4375
+ // 没有样本时显示「测速中…」而不是 0ms(见 netLatText)。
4376
+ h("span", { className: "dru-chip dru-net-chip", title: "最近一次面板请求的往返耗时(不额外测速)" },
4377
+ h("span", { "aria-hidden": "true" }, "📶"),
4378
+ h("span", null, netLatText())
4379
+ ),
4380
+ renderE2eeBadge()
3880
4381
  ),
3881
4382
  renderConnectBlock(),
3882
- renderE2eeBadge(),
3883
4383
  akeyMsg
3884
4384
  ? h("div", { className: "dru-msg dru-msg-" + akeyMsg.kind, style: { marginTop: 8 } },
3885
4385
  akeyMsg.text,
@@ -3907,12 +4407,9 @@ window.__ModuleLoader__.load({
3907
4407
  h("button", { type: "button", className: "dru-btn dru-btn-primary", disabled: akeyBusy, onClick: openKeyUrl }, "直接打开"),
3908
4408
  h("button", { type: "button", className: "dru-btn dru-btn-ghost", disabled: akeyBusy, onClick: loadAccessKey }, akeyBusy ? "生成中…" : "刷新二维码/访问链接")
3909
4409
  ),
3910
- h("div", { className: "dru-hint", style: { marginTop: 4 } },
3911
- !serviceRunning
3912
- ? (connInfo && connPhase !== "online"
3913
- ? "本机后台服务正在自动准备中(" + connText + "):连上后这个链接/二维码即可使用,无需其他操作。"
3914
- : "本机后台服务未运行:先在下方「🖥 后台服务」卡启动。")
3915
- : "打开链接/扫码进入即登录态;同设备重复扫码只更新授权,不新增设备。")
4410
+ // 【0.6.18】原来这里只有一行 hint,右列下方空着一大片 —— 现在改成**提醒区**:
4411
+ // 未读公告入口 / 重启提醒 / 使用提示集中在此(引导用户"下一步做什么",而不是散落各处)。
4412
+ renderAccessTips(hasKey)
3916
4413
  )
3917
4414
  )
3918
4415
  ]) : h("div", null, [
@@ -4090,6 +4587,9 @@ window.__ModuleLoader__.load({
4090
4587
  renderAccessCard(),
4091
4588
  // 已授权设备:仅登录后展示(登录前不显示,避免空列表/误导)
4092
4589
  loggedIn && st.config.phone ? renderDevicesCard() : null,
4590
+ // 📣 公告(0.6.18):插在「🔑 账号」之前 —— 未读公告与"远程访问/账号"同属首屏要紧信息;
4591
+ // 没有可显示的内容时整卡不存在(见 renderAnnouncementsCard 的 fail closed 说明)。
4592
+ renderAnnouncementsCard(),
4093
4593
  card("🔑 账号",
4094
4594
  st === null
4095
4595
  ? h("div", { className: "dru-hint" }, "正在读取远控状态…")
@@ -4200,7 +4700,14 @@ window.__ModuleLoader__.load({
4200
4700
  )
4201
4701
  : null
4202
4702
  ),
4203
- // 关于 dsh-remote(v0.5+ 远程访问价值说明卡片)
4703
+ // 版本与更新(自管理:检测新版 / 一键在线更新 / 彻底卸载)
4704
+ h(SelfManageCard, null),
4705
+ // 📖 关于 dsh-remote(**挪到最底**,0.6.18 面板重排):
4706
+ // 原来它夹在「🖥 后台服务」与「🔄 版本与更新」之间,六段技术说明把
4707
+ // "需要你动一下"(版本/重启)和"日常会用的入口"(账号/微信/公告)全挤到了它上面 ——
4708
+ // 用户为了找版本卡要先滚过一整屏说明文字。关于是**背景阅读**,天然属于最后一屏。
4709
+ // ⚠️ 整块必须留在 renderHome 的返回树里,**不能**塞进 SelfManageCard 的返回树 ——
4710
+ // 那会让它跟着那个组件的状态生命周期一起消失/重建。
4204
4711
  card("📖 关于 dsh-remote", [
4205
4712
  // 一句话定位(面向非技术用户先讲清楚"这是什么"),下面 4 条为既有技术说明(文案受源码契约锁定)
4206
4713
  h("div", { className: "dru-hint", style: { marginBottom: 6 } }, "📱 一句话:用手机随时接管这台电脑上的 DeepSeek Harness——装上即得专属加密地址,人在外面也能全功能操作(免内网穿透、免公网 IP、全程加密)。"),
@@ -4214,20 +4721,10 @@ window.__ModuleLoader__.load({
4214
4721
  h("div", { className: "dru-hint", style: { marginTop: 6 } },
4215
4722
  "📊 匿名装机统计:只上报「装机/连接是否成功」这类事件(安装开始与失败原因、后台服务是否注册成功、是否首次远程打通)——"
4216
4723
  + "不含任何账号、手机号、会话或文件内容、主机名、路径、设备指纹与 IP;标识是本机随机 ID(重装即变)。"
4217
- + "用环境变量 DSH_REMOTE_TELEMETRY=0 可完全关闭(不生成 ID、不发任何请求)。"),
4218
- // 加入交流群(后台上传二维码后出现;点击弹出二维码大图便于扫码)
4219
- community && community.qrcode
4220
- ? h("div", { className: "dru-actions", style: { marginTop: 10 } },
4221
- h("button", {
4222
- type: "button",
4223
- className: "dru-btn dru-btn-ghost",
4224
- style: { width: "100%" },
4225
- onClick: function () { setCommOpen(true); }
4226
- }, "💬 加入交流群"))
4227
- : null
4724
+ + "用环境变量 DSH_REMOTE_TELEMETRY=0 可完全关闭(不生成 ID、不发任何请求)。")
4725
+ // ⚠️ 「💬 加入交流群」按钮已搬到「🔑 账号」卡的功能区(与「💬 用户反馈」并列)——
4726
+ // 那里才是"有事想找人问"时会看的地方;这里只剩阅读性内容。
4228
4727
  ]),
4229
- // 版本与更新(自管理:检测新版 / 一键在线更新 / 彻底卸载)
4230
- h(SelfManageCard, null),
4231
4728
  message && h("div", { className: "dru-msg dru-msg-" + message.kind }, message.text)
4232
4729
  );
4233
4730
  }
@@ -4235,8 +4732,10 @@ window.__ModuleLoader__.load({
4235
4732
  // 【自动化】待重启时自动倒计时重启(见 autoRestartCancelled 说明);倒计时只在面板可见且无其他操作时推进。
4236
4733
  // ⚠️ isRefreshOnly(插件更新后只需刷新)时**不**自动重启:刷新是零风险动作,重启 dsh web 会打断
4237
4734
  // 用户正在进行的会话,不能替他做这个决定 —— 让他点「刷新页面」即可。
4735
+ // ⚠️ isRestartHard(在线更新完成,kind="restart")同理**不**自动重启:这一种现在会在每次
4736
+ // 在线更新成功后出现,若照旧 15 秒自动重启,等于"更新完必定打断用户正在做的事"。
4238
4737
  useEffect(function () {
4239
- if (!restartPending || isRefreshOnly || busy !== "" || restartCancelled) { setRestartCountdown(null); return undefined; }
4738
+ if (!restartPending || isRefreshOnly || isRestartHard || busy !== "" || restartCancelled) { setRestartCountdown(null); return undefined; }
4240
4739
  var at = restartInfo.at || 0;
4241
4740
  if (autoRestartCancelled(at)) { setRestartCountdown(null); return undefined; }
4242
4741
  var left = Math.round(AUTO_RESTART_DELAY_MS / 1000);
@@ -4250,7 +4749,20 @@ window.__ModuleLoader__.load({
4250
4749
  restartHarness(); // 已有实现:重启 + 轮询等它回来 + 自动刷新页面
4251
4750
  }, 1000);
4252
4751
  return function () { clearInterval(iv); };
4253
- }, [restartPending, isRefreshOnly, restartInfo.at, busy, restartCancelled]);
4752
+ }, [restartPending, isRefreshOnly, isRestartHard, restartInfo.at, busy, restartCancelled]);
4753
+
4754
+ // ── 📣 公告(0.6.18):本组件的 hook 全部追加在**最后一个既有 hook 之后** ──────
4755
+ // 🔒 hook 序号铁律(见本组件顶部 2029-2036 的注释):本组件大量 useState/useCallback/useEffect
4756
+ // 依赖稳定序号,新增 hook 只能接在**最后一个**既有 hook 后面。所以这两条放在这里
4757
+ // (而不是紧跟上面那堆 useState)—— React 的 hook 是一条**按调用顺序**的线性表,
4758
+ // 只有"顺序稳定"才是硬要求,而"位置在末尾"是最不容易被后人插队的地方。
4759
+ var annVer = useSyncExternalStore(annSubscribe, annSnapshot); // 订阅模块级公告 store(返回值只是版本号,仅用于触发重渲染)
4760
+ // 当前展开的公告详情(null = 不展开)。点列表里的一条 → 展开详情 + (未读则)上报已读。
4761
+ var annOpenArr = useState(null); var annOpenId = annOpenArr[0]; var setAnnOpenId = annOpenArr[1];
4762
+ // 本地防抖记忆的作用域随账号/设备变化:面板拿到 status 后回填一次(见 annScopeKey 的说明)。
4763
+ useEffect(function () { annSetScope(phoneKey); }, [phoneKey]);
4764
+ // annVer 只用于让 useSyncExternalStore 的重渲染生效,本身不参与任何计算 —— 显式丢弃,免得被读成漏用。
4765
+ void annVer;
4254
4766
 
4255
4767
  /** 取消本次自动重启(按事件持久化:同一 pending 事件不再自动重启)。 */
4256
4768
  var cancelAutoRestart = function () {
@@ -4282,6 +4794,130 @@ window.__ModuleLoader__.load({
4282
4794
  );
4283
4795
  }
4284
4796
 
4797
+ // ── 📣 公告(0.6.18):面板入口卡 / 详情弹窗 / 远程访问卡里的提醒入口 ──────────
4798
+ //
4799
+ // 数据来源只有一个:模块级的 annState(由 apply() 里的一次性拉取填充,见 annPull)。
4800
+ // 🔴 本卡**不主动拉取**:服务端 GET 是"判定即记账",每打开一次面板就拉一次会把
4801
+ // 还没被用户看到的弹窗候选静默消耗掉。用户要看最新的,点卡片里的「刷新」——
4802
+ // 那是明确的主动动作(且只用于列表,绝不用它的返回值弹窗)。
4803
+ /**
4804
+ * 打开发布项详情:置 annOpenId;**未读**才上报点击(已读的重复上报只会在服务端
4805
+ * 保留首次 clicked_at,没有副作用,但白白多一次请求 —— 契约 §3 的口径是"点击即已读证据")。
4806
+ */
4807
+ function openAnnItem(it) {
4808
+ if (!it) return;
4809
+ setAnnOpenId(it.id);
4810
+ if (it.unread) {
4811
+ annReportClick(it.id);
4812
+ annMarkReadLocal(it.id);
4813
+ }
4814
+ }
4815
+ /**
4816
+ * 📣 公告卡:插在「🔑 账号」卡**之前**(运营要用户看到的东西,与"远程访问/账号"同属首屏)。
4817
+ * fail closed 的全部情形一律**整卡不渲染**(绝不留一个点进去没内容的空壳入口):
4818
+ * · 拉取失败 / 老服务端 404 → annState.ok === false
4819
+ * · bubble_enabled !== true(含缺失)→ annState.bubble === false
4820
+ * · items 为空 → 没有可看的
4821
+ * 🔒 列表顺序**完全照搬服务端**(契约 §3:sort DESC, id DESC 即权威),客户端不得重排。
4822
+ */
4823
+ function renderAnnouncementsCard() {
4824
+ if (!annState.ok || !annState.bubble) return null;
4825
+ var items = annState.items;
4826
+ if (!items.length) return null;
4827
+ var unread = annState.unread;
4828
+ return card("📣 公告" + (unread > 0 ? "(" + unread + " 条未读)" : ""), [
4829
+ h("div", { className: "dru-ann-list" }, items.map(function (it) {
4830
+ return h("button", {
4831
+ key: it.id,
4832
+ type: "button",
4833
+ className: "dru-ann-item",
4834
+ onClick: function () { openAnnItem(it); }
4835
+ },
4836
+ h("span", { className: "dru-ann-item-main" },
4837
+ h("span", { className: "dru-ann-item-title" }, it.title || "(无标题公告)"),
4838
+ // 未读/置顶用**文字标签**表达(不靠颜色单独表意)
4839
+ h("span", { style: { display: "inline-flex", gap: 6, marginLeft: 8, verticalAlign: "1px" } },
4840
+ it.unread ? h("span", { className: "dru-ann-unread" }, "未读") : null,
4841
+ it.pinned ? h("span", { className: "dru-ann-pinned" }, "置顶") : null)
4842
+ ),
4843
+ h("span", { className: "dru-ann-item-time" }, it.created_at ? fmtDate(it.created_at) : "")
4844
+ );
4845
+ })),
4846
+ h("div", { className: "dru-hint", style: { marginTop: 8 } },
4847
+ "点开即视为已读(后台按已读与点击分别统计);公告顺序由后台设定。"),
4848
+ h("div", { className: "dru-actions", style: { marginTop: 8 } },
4849
+ h("button", {
4850
+ type: "button",
4851
+ className: "dru-btn dru-btn-ghost",
4852
+ onClick: function () { annFetch({ openPopup: false }); } // 主动刷新:只更新列表,不弹窗
4853
+ }, "刷新公告"))
4854
+ ]);
4855
+ }
4856
+ /** 公告详情(复用 .dru-popup 视觉;点遮罩/关闭即收起)。正文按**纯文本**渲染(一期不做富文本)。 */
4857
+ function renderAnnouncementDetail() {
4858
+ if (annOpenId === null) return null;
4859
+ var it = annState.items.filter(function (x) { return x.id === annOpenId; })[0];
4860
+ if (!it) return null; // 列表被刷新掉了 → 弹层跟着消失(不留一个指向不存在条目的空壳)
4861
+ var close = function () { setAnnOpenId(null); };
4862
+ return h("div", { className: "dru-popup", "data-dru-theme": theme, onMouseDown: function (e) { if (e.target === e.currentTarget) close(); } },
4863
+ h("div", { className: "dru-popup-card", role: "dialog", "aria-label": "公告详情" },
4864
+ h("div", { className: "dru-popup-body", style: { textAlign: "left" } },
4865
+ h("div", { className: "dru-popup-title", style: { textAlign: "center" } }, it.title || "(无标题公告)"),
4866
+ it.created_at ? h("div", { className: "dru-popup-sub", style: { textAlign: "center" } }, fmtDate(it.created_at)) : null,
4867
+ it.body ? h("div", { className: "dru-ann-item-body", style: { marginTop: 8 } }, it.body) : null,
4868
+ it.image ? h("img", { className: "dru-ann-img", src: it.image, alt: "公告图片" }) : null
4869
+ ),
4870
+ h("div", { className: "dru-popup-foot" },
4871
+ h("span", null, "已读状态已同步到后台"),
4872
+ h("button", { type: "button", onClick: close }, "关闭")
4873
+ )
4874
+ )
4875
+ );
4876
+ }
4877
+ /**
4878
+ * 「📱 远程访问」卡右列下方的**提醒区**(0.6.18 面板重排):
4879
+ * 这一块原来是一大片空白(左列二维码 180px,右列 flex:1 撑满,按钮下面只剩一行 hint)。
4880
+ * 现在把"引导用户下一步做什么"的东西集中摆在这里 —— 而不是在各个位置各占一行。
4881
+ * 内容按优先级:未读公告 → 重启提醒 → 使用提示;E2EE 徽标仍在卡片顶部(避免两处重复)。
4882
+ */
4883
+ function renderAccessTips(hasKey) {
4884
+ var rows = [];
4885
+ if (annState.ok && annState.bubble && annState.unread > 0) {
4886
+ var firstUnread = annState.items.filter(function (x) { return x.unread; })[0] || null;
4887
+ rows.push(h("div", { className: "dru-access-tip-row", key: "ann" },
4888
+ h("span", null, "📣 有 ", h("b", null, String(annState.unread)), " 条未读公告"),
4889
+ firstUnread
4890
+ ? h("button", { type: "button", className: "dru-linkbtn", onClick: function () { openAnnItem(firstUnread); } }, "查看")
4891
+ : null
4892
+ ));
4893
+ }
4894
+ if (restartPending) {
4895
+ rows.push(h("div", { className: "dru-access-tip-row", key: "restart" },
4896
+ h("span", null, isRestartHard ? "🔄 插件已更新:重启 dsh web 后生效" : "🔄 插件已更新:刷新页面即可生效"),
4897
+ h("button", {
4898
+ type: "button",
4899
+ className: "dru-linkbtn",
4900
+ disabled: busy !== "",
4901
+ onClick: function () {
4902
+ if (isRefreshOnly) { try { location.reload(); } catch (e) { /* 忽略 */ } }
4903
+ else restartHarness();
4904
+ }
4905
+ }, isRefreshOnly ? "刷新页面" : "立即重启")
4906
+ ));
4907
+ }
4908
+ rows.push(h("div", { className: "dru-access-tip-row", key: "hint" },
4909
+ h("span", null, !serviceRunning
4910
+ ? (connInfo && connPhase !== "online"
4911
+ ? "本机后台服务正在自动准备中(" + connText + "):连上后这个链接/二维码即可使用,无需其他操作。"
4912
+ : "本机后台服务未运行:先在下方「🖥 后台服务」卡启动。")
4913
+ : (hasKey ? "打开链接/扫码进入即登录态;同设备重复扫码只更新授权,不新增设备。" : "生成链接后即可扫码或直接打开。"))
4914
+ ));
4915
+ return h("div", { className: "dru-access-tips" },
4916
+ h("div", { className: "dru-access-tips-title" }, "接下来"),
4917
+ rows
4918
+ );
4919
+ }
4920
+
4285
4921
  return h("div", { className: "dru-settings-section", role: "region", "aria-label": "远程访问", "data-dru-theme": theme },
4286
4922
  h("div", { className: "dru-settings-head" },
4287
4923
  h("span", { className: "dru-settings-icon" }, "📱"),
@@ -4293,17 +4929,29 @@ window.__ModuleLoader__.load({
4293
4929
  ),
4294
4930
  h("div", { className: "dru-settings-body" },
4295
4931
  restartPending
4296
- ? h("div", { className: "dru-restart-alert", role: "status" },
4297
- h("span", { className: "dru-restart-alert-icon" }, "🔔"),
4932
+ // 【0.6.18】三种"待用户动一下"的情形,文案与主按钮**按 kind 一一对应**:
4933
+ // · refresh(插件文件在运行中被改写)→ 软提示 + 主按钮「刷新页面」(零风险动作)
4934
+ // · restart(在线更新刚换完插件包) → **强提醒**(实底 + 左侧色条)+ 主按钮「立即重启」
4935
+ // · 其它(历史遗留 kind) → 保持旧文案,不擅自改语义
4936
+ // 位置仍在 .dru-settings-body 的首位,也就是整屏最顶 —— 这是"置顶强提醒"的落点。
4937
+ ? h("div", {
4938
+ className: "dru-restart-alert" + (isRestartHard ? " dru-restart-alert-strong" : ""),
4939
+ role: isRestartHard ? "alert" : "status"
4940
+ },
4941
+ h("span", { className: "dru-restart-alert-icon" }, isRestartHard ? "⚠️" : "🔔"),
4298
4942
  h("div", { style: { flex: "1" } },
4299
4943
  h("div", { className: "dru-restart-alert-title" },
4300
4944
  // 插件已改为 patch 热加载:装插件/在线更新都不再需要重启 harness,
4301
4945
  // 唯一要用户动一下的情形是"插件文件在运行中被改写"→ 刷新页面即可。
4302
- isRefreshOnly ? "插件已更新,刷新页面即可生效" : "需要重启 dsh web 才能载入新插件"),
4946
+ isRestartHard ? "插件已更新,重启 dsh web 后生效"
4947
+ : isRefreshOnly ? "插件已更新,刷新页面即可生效"
4948
+ : "需要重启 dsh web 才能载入新插件"),
4303
4949
  h("div", { className: "dru-restart-alert-sub" },
4304
- isRefreshOnly
4305
- ? "刚装好/刚更新的插件已在磁盘上,浏览器里的面板还是旧版本。刷新一下这个页面即可(手机端连接与已登录状态都不受影响)。"
4306
- : "当前运行中的 dsh web 里还没有载入这个插件。先刷新页面;如果刷新后仍然看不到面板,再重启一次 dsh web。"),
4950
+ isRestartHard
4951
+ ? "新版本已经装到这台电脑上,但当前运行的 dsh web 还是旧代码(新的后台路由与面板要等它重新载入)。手机端连接与已登录状态都不受影响;重启期间页面会短暂断开,然后自动恢复。"
4952
+ : isRefreshOnly
4953
+ ? "刚装好/刚更新的插件已在磁盘上,浏览器里的面板还是旧版本。刷新一下这个页面即可(手机端连接与已登录状态都不受影响)。"
4954
+ : "当前运行中的 dsh web 里还没有载入这个插件。先刷新页面;如果刷新后仍然看不到面板,再重启一次 dsh web。"),
4307
4955
  // 【自动化】待重启时自动倒计时重启(用户零操作);倒计时期间可一键取消
4308
4956
  restartCountdown !== null && restartCountdown > 0
4309
4957
  ? h("div", { className: "dru-restart-auto", role: "status" },
@@ -4329,7 +4977,9 @@ window.__ModuleLoader__.load({
4329
4977
  className: "dru-btn " + (isRefreshOnly ? "dru-btn-ghost" : "dru-btn-primary"),
4330
4978
  disabled: busy !== "",
4331
4979
  onClick: function () { restartHarness(); }
4332
- }, busy === "restart" ? "重启中…" : (restartCountdown !== null && restartCountdown > 0 ? "立即重启(不用等)" : "重启 dsh web")),
4980
+ }, busy === "restart" ? "重启中…"
4981
+ : isRestartHard ? "立即重启"
4982
+ : (restartCountdown !== null && restartCountdown > 0 ? "立即重启(不用等)" : "重启 dsh web")),
4333
4983
  restartCountdown !== null && restartCountdown > 0
4334
4984
  ? h("button", {
4335
4985
  type: "button",
@@ -4361,10 +5011,11 @@ window.__ModuleLoader__.load({
4361
5011
  onClick: function () { restartHarness(); }
4362
5012
  }, busy === "restart" ? "重启中…" : "🔄 重启 dsh web")
4363
5013
  ),
4364
- renderCommunityModal()
5014
+ renderCommunityModal(),
5015
+ // 📣 公告详情弹窗(与交流群弹窗同一层:都挂在面板根上,点遮罩即收起)
5016
+ renderAnnouncementDetail()
4365
5017
  );
4366
5018
  }
4367
-
4368
5019
  // ══ 🤖 微信机器人(「📱 远程访问」面板里的第三个 tab:微信机器人通道) ═════════
4369
5020
  // 【0.6.11 业主口径】「不要给它单独弄一个菜单,直接放到面板里面。在面板里单开一个
4370
5021
  // table 页,命名为『微信机器人通道』,并增加上微信的绿泡泡小图标,点开后可以进行绑定设置。」
@@ -4694,6 +5345,43 @@ window.__ModuleLoader__.load({
4694
5345
  quotaRow
4695
5346
  );
4696
5347
 
5348
+ // ── 🧭 「这东西是干啥用的 + 两步怎么接」操作型引导(0.6.18)─────────────────────
5349
+ // 位置:**在 .dru-wx-intro 之上** —— 第一次进来的人先要知道"这功能解决什么问题、
5350
+ // 我要动手做哪两步",然后才是"我能用哪些能力"的清单(intro 是清单,不是说明书)。
5351
+ //
5352
+ // 命令帮助**不另写一份**:真身是 clients/dsh-remote/wechat-channel.mjs 的 HELP_TEXT
5353
+ // (用户在微信里发 /help 收到的就是它)。浏览器半与 bridge 是两个 bundle,没有可用的
5354
+ // require 通道,所以这里放一份**镜像**(WECHAT_HELP_CMDS,见顶层注释),由测试逐条对账。
5355
+ //
5356
+ // ClawBot 三条说明是"第二步"的正文(用户口径:把这一步讲清楚,否则他不知道去哪儿找入口):
5357
+ // · 微信 ClawBot = 在微信里与 OpenClaw 收发消息;
5358
+ // · 只接收 OpenClaw 在 24 小时内的回复;
5359
+ // · 有「我的 ClawBot」与「连接新的 OpenClaw」两个入口。
5360
+ // 截图资源走**后台上传 + public-config**(与 community.qrcode 同一条路,零发版可换图),
5361
+ // 因此这里**不打包任何图片**;图片可缺省,缺省时整块引导照样完整可读。
5362
+ var wxGuideImg = String((wx && (wx.guide_image || wx.clawbot_image)) || "");
5363
+ var guide = h("div", { className: "dru-wx-guide", role: "note", "aria-label": "怎么连接微信机器人(两步)" },
5364
+ h("div", { className: "dru-wx-guide-title" }, "微信机器人是做什么的"),
5365
+ h("div", { className: "dru-wx-guide-note" },
5366
+ "它把「电脑上跑着的 DeepSeek Harness」接到你的微信里:任务跑完/报错/需要你拍板时,"
5367
+ + "它主动给你发一条微信;你在微信里回一个数字或一句话,就能放行、拒绝或继续交代任务 —— "
5368
+ + "不用打开电脑,也不用一直盯着屏幕。"),
5369
+ h("div", { className: "dru-wx-guide-step" },
5370
+ h("b", null, "第一步:绑定(就在下面)"),
5371
+ " 点下面的「连接微信机器人」,用手机微信扫一扫,扫码后手机上会出现一串数字配对码,回到这里填进去即可。"),
5372
+ h("div", { className: "dru-wx-guide-step" },
5373
+ h("b", null, "第二步:在微信里找到 ClawBot 并连上这台电脑"),
5374
+ " 微信 ClawBot 就是在微信里与 OpenClaw 收发消息的入口 —— 你在这条会话里发给它的消息,"
5375
+ + "会转给 OpenClaw 处理。注意它**只接收 OpenClaw 在 24 小时内的回复**(更早的过期,"
5376
+ + "需要你重新发一次)。入口有两个:「我的 ClawBot」(已有的机器人)与「连接新的 OpenClaw」"
5377
+ + "(第一次接入时用这个)。"),
5378
+ wxGuideImg ? h("img", { className: "dru-ann-img", src: wxGuideImg, alt: "微信 ClawBot 入口示意" }) : null,
5379
+ h("div", { className: "dru-wx-guide-step" },
5380
+ h("b", null, "在微信里能发这些指令"),
5381
+ "(也可以直接发一句话,等于说给当前任务):"),
5382
+ h("pre", { className: "dru-wx-guide-cmds" }, WECHAT_HELP_CMDS.join("\n"))
5383
+ );
5384
+
4697
5385
  if (loading && !wx && !fail) {
4698
5386
  // 只在**第一次**读状态时转圈;失败或读到结果后立刻换成具体内容(不留永久 spinner)。
4699
5387
  body.push(h("div", { className: "dru-card" },
@@ -4908,6 +5596,7 @@ window.__ModuleLoader__.load({
4908
5596
  // 导览(intro)永远排在最前面 → 在「连接」按钮之前(DOM 顺序亦然),未绑定时先讲用途。
4909
5597
  if (embedded) {
4910
5598
  return h("div", { className: "dru-wx-embed", role: "region", "aria-label": "微信机器人通道", "data-dru-theme": theme },
5599
+ guide,
4911
5600
  intro,
4912
5601
  bodyNode,
4913
5602
  liveMsg
@@ -4922,6 +5611,7 @@ window.__ModuleLoader__.load({
4922
5611
  h("div", { className: "dru-settings-sub" }, "把关键节点推到微信,并让你在微信里回一个数字就完成放行 / 拒绝 / 选择。")
4923
5612
  )
4924
5613
  ),
5614
+ guide,
4925
5615
  intro,
4926
5616
  bodyNode,
4927
5617
  liveMsg
@@ -4951,6 +5641,31 @@ window.__ModuleLoader__.load({
4951
5641
  ctx.slots.inject("shell.overlay", function () {
4952
5642
  return ctx.slots.register({ name: "shell.overlay", id: "dsh-feedback-popup", order: 90 }, FeedbackPopup);
4953
5643
  });
5644
+ // 📣 公告弹窗 = shell.overlay 的**第二个**槽位(与反馈弹窗并列)。
5645
+ // order 91 > 90:两个弹窗万一同时出现,公告排在反馈**之后**(后注册的在上层)——
5646
+ // 公告是运营信息、反馈是用户自己的评价流程,不该被压在下面。
5647
+ // ⚠️ 老 DSH 本体没有 shell.overlay 时 ctx.slots.inject 对不存在的槽位**不会抛**
5648
+ // (ui-renderer 的注入表行为),所以这里不需要额外探测;面板与徽标照常工作,只是不弹窗。
5649
+ try {
5650
+ ctx.slots.inject("shell.overlay", function () {
5651
+ return ctx.slots.register({ name: "shell.overlay", id: "dsh-announcement-popup", order: 91 }, AnnouncementPopup);
5652
+ });
5653
+ } catch (e) {
5654
+ // 🔴 客户端插件抛异常 = 整个 web app 起不来(壳里的 assertEntriesActive 会 throw)。
5655
+ // 公告是锦上添花,**没有任何资格**把门关上 —— 任何异常都只是"这次没有弹窗"。
5656
+ }
5657
+
5658
+ // 📣 公告:每次页面加载**只拉一次**(sessionStorage 一次性标记,见 annPullOnce)。
5659
+ // 放在 apply() 里是刻意的:apply 跑在 app mount **之前**,所以这里**绝不能碰产品 DOM**
5660
+ // (那时还没有),只做数据拉取;UI 全部交给上面那个槽位与面板卡片。
5661
+ // 整段 try/catch:拉公告失败绝不能让插件装载失败。
5662
+ try {
5663
+ // 侧栏徽标的 DOM 订阅者(非 React):store 一变就重画徽标。
5664
+ // 即便入口按钮此刻还没注入(官方「设置」按钮尚未出现),订阅本身也是零成本的 ——
5665
+ // mountNavEntry 挂上按钮时会主动画一次。
5666
+ annSubscribe(annRenderNavBadge);
5667
+ annPullOnce();
5668
+ } catch (e) { /* 静默降级:不显示公告入口/徽标,页面照常 */ }
4954
5669
 
4955
5670
  // 首次安装引导小红点(localStorage dsh-remote-seen-dot;点击后不再显示)
4956
5671
  dotWatch();