@wenbin_wb/dsh-bridge 2.11.2 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.en.md +25 -25
  3. package/README.md +25 -25
  4. package/client/client.js +235 -10
  5. package/client/index.js +124 -12
  6. package/client/locales.mjs +174 -0
  7. package/lib/bridge-rpc-constants.js +2 -0
  8. package/lib/bridge-rpc.js +33 -2
  9. package/lib/index.js +192 -11
  10. package/lib/platform/conversation-bridge.js +21 -6
  11. package/lib/platform/dsh-storage.js +64 -10
  12. package/locale/en.json +6 -0
  13. package/locale/zh.json +6 -0
  14. package/package.json +6 -5
  15. package/docs/CODE_REVIEW.md +0 -220
  16. package/docs/banner.jpg +0 -0
  17. package/docs/cloudflare-fixed-domain.md +0 -158
  18. package/docs/custom-tunnel.md +0 -220
  19. package/docs/feishu-usage.md +0 -116
  20. package/docs/qq-usage.md +0 -387
  21. package/docs/release-process.md +0 -45
  22. package/docs/screenshots/admin-lock-screen.jpg +0 -0
  23. package/docs/screenshots/feishu-bot-config.jpg +0 -0
  24. package/docs/screenshots/feishu-chat.jpg +0 -0
  25. package/docs/screenshots/lan-access.jpg +0 -0
  26. package/docs/screenshots/mobile-chat.jpg +0 -0
  27. package/docs/screenshots/mobile-drawer.jpg +0 -0
  28. package/docs/screenshots/mobile-remote-settings.jpg +0 -0
  29. package/docs/screenshots/mobile-settings-im.jpg +0 -0
  30. package/docs/screenshots/mobile-settings-lan.jpg +0 -0
  31. package/docs/screenshots/mobile-settings-security.jpg +0 -0
  32. package/docs/screenshots/mobile-settings-tunnel.jpg +0 -0
  33. package/docs/screenshots/mobile-workspace-picker.jpg +0 -0
  34. package/docs/screenshots/qq-bot-config.jpg +0 -0
  35. package/docs/screenshots/qq-chat.jpg +0 -0
  36. package/docs/screenshots/qq-group.jpg +0 -0
  37. package/docs/screenshots/qr-scan.jpg +0 -0
  38. package/docs/screenshots/remote-auth-login.jpg +0 -0
  39. package/docs/screenshots/remote-web-mobile.jpg +0 -0
  40. package/docs/screenshots/security-auth-config.jpg +0 -0
  41. package/docs/screenshots/telegram-bot-config.jpg +0 -0
  42. package/docs/screenshots/tunnel-access.jpg +0 -0
  43. package/docs/screenshots/wechat-bot-config.jpg +0 -0
  44. package/docs/screenshots/wechat-chat.jpg +0 -0
  45. package/docs/telegram-usage.md +0 -93
  46. package/docs/wechat-usage.md +0 -104
package/client/client.js CHANGED
@@ -1568,6 +1568,148 @@ function registerSettingsNavIcon(resolveLabel = () => "\u8FDC\u7A0B\u8BBF\u95EE"
1568
1568
  };
1569
1569
  }
1570
1570
 
1571
+ // client/locales.mjs
1572
+ var LOCALE_NAMESPACE = "@wenbin_wb/dsh-bridge";
1573
+ var zh = {
1574
+ // 移动端顶栏
1575
+ "menu.open": "\u6253\u5F00\u83DC\u5355",
1576
+ "menu.newSession": "\u65B0\u5EFA\u4F1A\u8BDD",
1577
+ "title.fallback": "\u65B0\u4F1A\u8BDD",
1578
+ // 移动端输入区
1579
+ "composer.placeholder": "\u270D\uFE0F \u70B9\u51FB\u8F93\u5165\u6D88\u606F\u2026",
1580
+ "composer.expand": "\u5C55\u5F00\u8F93\u5165\u6846",
1581
+ "composer.collapse": "\u6536\u8D77\u8F93\u5165\u6846\uFF0C\u6700\u5927\u5316\u5BF9\u8BDD\u9605\u8BFB\u533A",
1582
+ "panel.backToChat": "\u8FD4\u56DE\u5BF9\u8BDD",
1583
+ // 移动端设置面板 / 抽屉(dsh-bridge 自绘部分)
1584
+ "workspace.choose": "\u9009\u62E9\u7535\u8111\u5DE5\u4F5C\u533A",
1585
+ "workspace.reading": "\u6B63\u5728\u8BFB\u53D6\u76EE\u5F55\u5185\u5BB9\u2026",
1586
+ "workspace.currentDir": "\u5F53\u524D\u76EE\u5F55:",
1587
+ "workspace.registeredHint": "\u5DF2\u6CE8\u518C\u5DE5\u4F5C\u533A (${count} \u4E2A\uFF0C\u70B9\u51FB\u76F4\u63A5\u5207\u6362)\uFF1A",
1588
+ "workspace.enterHint": "\u70B9\u51FB\u8FDB\u5165\u6587\u4EF6\u5939\uFF0C\u6216\u70B9\u51FB\u300C+ \u9009\u4E3A\u5DE5\u4F5C\u533A\u300D\u76F4\u63A5\u6DFB\u52A0\u5E76\u5207\u6362",
1589
+ "workspace.unlockHint": "\u8FDC\u7A0B\u8BBF\u95EE\u65F6\u6D4F\u89C8/\u6DFB\u52A0\u5DE5\u4F5C\u533A\u9700\u8F93\u5165\u540E\u53F0\u7BA1\u7406\u5BC6\u7801\u89E3\u9501\uFF08\u4E0E\u8BBF\u95EE\u5BC6\u7801\u4E0D\u540C\uFF09\u3002",
1590
+ "workspace.chooseFolder": "\u9009\u62E9\u5DE5\u4F5C\u533A",
1591
+ "workspace.addFolder": "\u6DFB\u52A0",
1592
+ "workspace.refresh": "\u5237\u65B0\u76EE\u5F55",
1593
+ "workspace.back": "\u8FD4\u56DE\u4E0A\u4E00\u7EA7",
1594
+ "workspace.filter": "\u8FC7\u6EE4\u5B50\u6587\u4EF6\u5939\u2026",
1595
+ "workspace.more": "\u8FD8\u6709\u66F4\u591A",
1596
+ "workspace.addSubfolder": "\u76F4\u63A5\u6DFB\u52A0\u6B64\u5B50\u6587\u4EF6\u5939\u4E3A\u5DE5\u4F5C\u533A\u5E76\u8FDB\u5165",
1597
+ "workspace.onlyAdmin": "\u4EC5\u9650\u672C\u673A\u7BA1\u7406",
1598
+ "workspace.isCurrent": "\u5F53\u524D\u4E3B\u5165\u53E3",
1599
+ "workspace.isFallback": "\u4E3B\u5165\u53E3",
1600
+ // 远程访问面板
1601
+ "access.copyLink": "\u590D\u5236\u94FE\u63A5",
1602
+ "access.copied": "\u2713 \u5DF2\u590D\u5236",
1603
+ "access.hideQr": "\u9690\u85CF\u4E8C\u7EF4\u7801",
1604
+ "access.showQr": "\u663E\u793A\u4E8C\u7EF4\u7801",
1605
+ "access.privateEnv": "\u8BF7\u5728\u79C1\u5BC6\u73AF\u5883\u4E0B\u4F7F\u7528",
1606
+ "access.switching": "\u5207\u6362\u4E2D\u2026",
1607
+ "access.publicEntry": "\u516C\u7F51\u8BBF\u95EE\u5165\u53E3",
1608
+ "access.copy": "\u590D\u5236",
1609
+ "access.stopReconnect": "\u505C\u6B62\u91CD\u8FDE",
1610
+ "access.close": "\u5173\u95ED",
1611
+ "access.connecting": "\u8FDE\u63A5\u4E2D\u2026",
1612
+ "access.downloading": "\u4E0B\u8F7D\u4E2D\u2026",
1613
+ "access.enable": "\u5F00\u542F\u516C\u7F51\u96A7\u9053",
1614
+ "access.scanHint": "\u8BF7\u5728\u79C1\u5BC6\u73AF\u5883\u4E0B\u626B\u7801\u4F7F\u7528",
1615
+ "access.autoStart": "\u968F DSH \u542F\u52A8\u81EA\u52A8\u5F00\u542F",
1616
+ "access.tunnelConfig": "\u96A7\u9053\u914D\u7F6E",
1617
+ "access.expand": "\u5C55\u5F00 \u25BE",
1618
+ "access.collapse": "\u6536\u8D77 \u25B4",
1619
+ "access.saveError": "\u4FDD\u5B58\u914D\u7F6E\u5931\u8D25",
1620
+ "access.serverConfig": "\u96A7\u9053\u670D\u52A1\u5668\u914D\u7F6E",
1621
+ "access.saving": "\u4FDD\u5B58\u4E2D\u2026",
1622
+ "access.saved": "\u2713 \u5DF2\u4FDD\u5B58",
1623
+ "access.saveConfig": "\u4FDD\u5B58\u914D\u7F6E",
1624
+ "access.clear": "\u6E05\u9664",
1625
+ "access.externalTunnel": "\u5916\u90E8\u5DF2\u90E8\u7F72\u96A7\u9053",
1626
+ "access.saveFixed": "\u4FDD\u5B58\u56FA\u5B9A\u57DF\u540D\u914D\u7F6E",
1627
+ "access.updateFailed": "\u66F4\u65B0\u5931\u8D25",
1628
+ "access.securityHint": "\u8BBE\u7F6E\u5916\u90E8\u8BBF\u5BA2\u8BBF\u95EE\u5BC6\u7801",
1629
+ "access.goSetup": "\u53BB\u5F00\u542F \u2794",
1630
+ "access.gotoSettings": "\u8BBE\u7F6E \u2794",
1631
+ "access.tutorial": "\u67E5\u770B\u81EA\u5EFA\u96A7\u9053\u670D\u52A1\u5668\u642D\u5EFA\u6559\u7A0B"
1632
+ };
1633
+ var en = {
1634
+ "menu.open": "Open menu",
1635
+ "menu.newSession": "New session",
1636
+ "title.fallback": "New Session",
1637
+ "composer.placeholder": "\u270D\uFE0F Tap to type a message\u2026",
1638
+ "composer.expand": "Expand input",
1639
+ "composer.collapse": "Collapse input to maximize chat view",
1640
+ "panel.backToChat": "Back to chat",
1641
+ "workspace.choose": "Choose a workspace",
1642
+ "workspace.reading": "Reading directory\u2026",
1643
+ "workspace.currentDir": "Current directory:",
1644
+ "workspace.registeredHint": "Registered workspaces (${count}, click to switch):",
1645
+ "workspace.enterHint": "Enter a folder, or tap \u201C+ Select as workspace\u201D to add & switch",
1646
+ "workspace.unlockHint": "Browsing/adding workspaces remotely requires the admin password to unlock (different from the access password).",
1647
+ "workspace.chooseFolder": "Choose workspace",
1648
+ "workspace.addFolder": "Add",
1649
+ "workspace.refresh": "Refresh",
1650
+ "workspace.back": "Back",
1651
+ "workspace.filter": "Filter subfolders\u2026",
1652
+ "workspace.more": "More",
1653
+ "workspace.addSubfolder": "Add this subfolder as a workspace and enter",
1654
+ "workspace.onlyAdmin": "Local admin only",
1655
+ "workspace.isCurrent": "Current entry",
1656
+ "workspace.isFallback": "Main entry",
1657
+ "access.copyLink": "Copy link",
1658
+ "access.copied": "\u2713 Copied",
1659
+ "access.hideQr": "Hide QR",
1660
+ "access.showQr": "Show QR",
1661
+ "access.privateEnv": "Use in a private environment",
1662
+ "access.switching": "Switching\u2026",
1663
+ "access.publicEntry": "Public access entry",
1664
+ "access.copy": "Copy",
1665
+ "access.stopReconnect": "Stop reconnecting",
1666
+ "access.close": "Close",
1667
+ "access.connecting": "Connecting\u2026",
1668
+ "access.downloading": "Downloading\u2026",
1669
+ "access.enable": "Enable public tunnel",
1670
+ "access.scanHint": "Scan in a private environment",
1671
+ "access.autoStart": "Start automatically with DSH",
1672
+ "access.tunnelConfig": "Tunnel configuration",
1673
+ "access.expand": "Expand \u25BE",
1674
+ "access.collapse": "Collapse \u25B4",
1675
+ "access.saveError": "Failed to save configuration",
1676
+ "access.serverConfig": "Tunnel server configuration",
1677
+ "access.saving": "Saving\u2026",
1678
+ "access.saved": "\u2713 Saved",
1679
+ "access.saveConfig": "Save configuration",
1680
+ "access.clear": "Clear",
1681
+ "access.externalTunnel": "External tunnel",
1682
+ "access.saveFixed": "Save fixed-domain configuration",
1683
+ "access.updateFailed": "Update failed",
1684
+ "access.securityHint": "Set an external visitor access password",
1685
+ "access.goSetup": "Set up \u2794",
1686
+ "access.gotoSettings": "Settings \u2794",
1687
+ "access.tutorial": "View the self-hosted tunnel server tutorial"
1688
+ };
1689
+ function createTranslator(ctx) {
1690
+ const active = () => {
1691
+ const lang = (typeof document !== "undefined" ? document.documentElement.getAttribute("lang") : "") || "";
1692
+ return lang.toLowerCase().startsWith("zh") ? "zh" : "en";
1693
+ };
1694
+ const entryOf = (key) => ({ zh: zh[key], en: en[key] });
1695
+ const t = (key, vars) => {
1696
+ const e = entryOf(key);
1697
+ let text = e[active()] ?? e.en ?? e.zh ?? "";
1698
+ if (vars && text) {
1699
+ for (const [k, v] of Object.entries(vars)) text = text.replaceAll(`\${${k}}`, String(v));
1700
+ }
1701
+ return text;
1702
+ };
1703
+ try {
1704
+ const locale = ctx && (typeof ctx.get === "function" ? ctx.get("locale") : ctx.locale);
1705
+ if (locale && typeof locale.register === "function") {
1706
+ locale.register(LOCALE_NAMESPACE, { zh, en });
1707
+ }
1708
+ } catch {
1709
+ }
1710
+ return { t, active };
1711
+ }
1712
+
1571
1713
  // lib/bridge-rpc-constants.js
1572
1714
  var BRIDGE_RPC_CHANNEL = "/dsh-bridge";
1573
1715
  var BRIDGE_ENDPOINTS = {
@@ -1600,6 +1742,8 @@ var BRIDGE_ENDPOINTS = {
1600
1742
  authRegenerateToken: "authRegenerateToken",
1601
1743
  authAdminUnlock: "authAdminUnlock",
1602
1744
  authAdminLock: "authAdminLock",
1745
+ // 首次启用引导:用户确认已了解后置位,避免重复打扰
1746
+ dismissFirstRunGuide: "dismissFirstRunGuide",
1603
1747
  // 平台管理器(多 IM 平台统一接口)
1604
1748
  listPlatforms: "listPlatforms",
1605
1749
  platformLogin: "platformLogin",
@@ -1758,6 +1902,7 @@ var RELEASES_URL = "https://github.com/wenbin-wb/dsh-bridge/releases";
1758
1902
  var ISSUES_URL = "https://github.com/wenbin-wb/dsh-bridge/issues/new";
1759
1903
  var TUNNEL_DOCS_URL = "https://github.com/wenbin-wb/dsh-bridge/blob/main/docs/custom-tunnel.md";
1760
1904
  var CLOUDFLARE_TUTORIAL_URL = "https://github.com/wenbin-wb/dsh-bridge/blob/main/docs/cloudflare-fixed-domain.md";
1905
+ var localeT = (key) => key;
1761
1906
  function upgradeCommands(latest) {
1762
1907
  const spec = `@wenbin_wb/dsh-bridge@${latest}`;
1763
1908
  return [
@@ -4280,6 +4425,38 @@ function BackupRestoreWidget({ rpcCall, onUpdate }) {
4280
4425
  }, msg.text)
4281
4426
  );
4282
4427
  }
4428
+ function formatActiveWorkWarning(work) {
4429
+ if (!work || !work.total) return null;
4430
+ const lines = [];
4431
+ if (work.sessions?.length) {
4432
+ lines.push(`\u2022 \u8FDB\u884C\u4E2D\u7684\u4F1A\u8BDD ${work.sessions.length} \u4E2A\uFF1A`);
4433
+ for (const s2 of work.sessions.slice(0, 5)) {
4434
+ const detail = s2.tools > 0 ? `${s2.tools} \u6B21\u5DE5\u5177\u8C03\u7528${s2.lastTool ? `\uFF08\u6700\u8FD1\uFF1A${s2.lastTool}\uFF09` : ""}` : "\u601D\u8003\u4E2D";
4435
+ lines.push(` - ${s2.title}\uFF08\u7B2C ${s2.turn} \u8F6E\uFF0C${detail}\uFF09`);
4436
+ }
4437
+ if (work.sessions.length > 5) lines.push(` \u2026\u7B49\u5171 ${work.sessions.length} \u4E2A`);
4438
+ }
4439
+ if (work.subagentSessions?.length) {
4440
+ lines.push(`\u2022 \u8FDB\u884C\u4E2D\u7684\u5B50\u4EE3\u7406/agent \u4EFB\u52A1 ${work.subagentSessions.length} \u4E2A\uFF1A`);
4441
+ for (const s2 of work.subagentSessions.slice(0, 5)) {
4442
+ const detail = s2.tools > 0 ? `${s2.tools} \u6B21\u5DE5\u5177\u8C03\u7528` : "\u601D\u8003\u4E2D";
4443
+ lines.push(` - ${s2.title}\uFF08\u7B2C ${s2.turn} \u8F6E\uFF0C${detail}\uFF09`);
4444
+ }
4445
+ if (work.subagentSessions.length > 5) lines.push(` \u2026\u7B49\u5171 ${work.subagentSessions.length} \u4E2A`);
4446
+ }
4447
+ if (work.pendingApprovals?.length) {
4448
+ lines.push(`\u2022 \u5F85\u5BA1\u6279\u8BF7\u6C42 ${work.pendingApprovals.length} \u4E2A\uFF1A`);
4449
+ for (const a of work.pendingApprovals.slice(0, 5)) {
4450
+ lines.push(` - [${a.platform}] ${a.summary}`);
4451
+ }
4452
+ if (work.pendingApprovals.length > 5) lines.push(` \u2026\u7B49\u5171 ${work.pendingApprovals.length} \u4E2A`);
4453
+ }
4454
+ return `\u26A0\uFE0F \u68C0\u6D4B\u5230\u6709\u4EFB\u52A1\u6B63\u5728\u8FD0\u884C\uFF0C\u91CD\u542F\u4F1A\u4E2D\u65AD\u5B83\u4EEC\uFF1A
4455
+
4456
+ ${lines.join("\n")}
4457
+
4458
+ \u4E2D\u65AD\u540E\u53EF\u80FD\u9700\u8981\u91CD\u65B0\u53D1\u8D77\u3002\u786E\u5B9A\u8981\u7EE7\u7EED\u91CD\u542F\u5417\uFF1F`;
4459
+ }
4283
4460
  function useDshRestart({ rpcCall, maxAttempts = 30, texts = {} } = {}) {
4284
4461
  const T = {
4285
4462
  restarting: "\u6B63\u5728\u5411 DSH \u670D\u52A1\u53D1\u9001\u91CD\u542F\u6307\u4EE4\u2026",
@@ -4301,8 +4478,21 @@ function useDshRestart({ rpcCall, maxAttempts = 30, texts = {} } = {}) {
4301
4478
  setStatus({ phase: "restarting", text: T.restarting });
4302
4479
  try {
4303
4480
  const r = await rpcCall(BRIDGE_ENDPOINTS.restartDsh, {});
4304
- if (r && r.ok === false) {
4305
- setStatus({ phase: "timeout", text: `${T.timeout}\uFF1A${r.error || "\u672A\u77E5\u539F\u56E0"}` });
4481
+ if (r && r.ok === false && r.error?.code === "busy") {
4482
+ const warning = formatActiveWorkWarning(r.error?.details?.activeWork);
4483
+ if (!window.confirm(warning || "\u5F53\u524D\u6709\u4EFB\u52A1\u6B63\u5728\u8FD0\u884C\uFF0C\u91CD\u542F\u4F1A\u4E2D\u65AD\u5B83\u4EEC\u3002\u786E\u5B9A\u7EE7\u7EED\u5417\uFF1F")) {
4484
+ setStatus(null);
4485
+ setRestarting(false);
4486
+ return;
4487
+ }
4488
+ const r2 = await rpcCall(BRIDGE_ENDPOINTS.restartDsh, { confirm: true });
4489
+ if (r2 && r2.ok === false) {
4490
+ setStatus({ phase: "timeout", text: `${T.timeout}\uFF1A${r2.error?.message || "\u672A\u77E5\u539F\u56E0"}` });
4491
+ setRestarting(false);
4492
+ return;
4493
+ }
4494
+ } else if (r && r.ok === false) {
4495
+ setStatus({ phase: "timeout", text: `${T.timeout}\uFF1A${r.error?.message || r.error || "\u672A\u77E5\u539F\u56E0"}` });
4306
4496
  setRestarting(false);
4307
4497
  return;
4308
4498
  }
@@ -5909,7 +6099,7 @@ function setupMobileExperience(rpcCall, ctx) {
5909
6099
  header.className = "dsh-mobile-app-header";
5910
6100
  const leftBtn = document.createElement("button");
5911
6101
  leftBtn.className = "dsh-header-menu-btn";
5912
- leftBtn.title = "\u6253\u5F00\u83DC\u5355";
6102
+ leftBtn.title = localeT("menu.open");
5913
6103
  leftBtn.innerHTML = `
5914
6104
  <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round">
5915
6105
  <line x1="3" y1="8" x2="21" y2="8"></line>
@@ -5935,7 +6125,7 @@ function setupMobileExperience(rpcCall, ctx) {
5935
6125
  };
5936
6126
  const rightBtn = document.createElement("button");
5937
6127
  rightBtn.className = "dsh-header-new-btn";
5938
- rightBtn.title = "\u65B0\u5EFA\u4F1A\u8BDD";
6128
+ rightBtn.title = localeT("menu.newSession");
5939
6129
  rightBtn.innerHTML = `
5940
6130
  <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
5941
6131
  <circle cx="12" cy="12" r="9.5"></circle>
@@ -5954,22 +6144,55 @@ function setupMobileExperience(rpcCall, ctx) {
5954
6144
  header.appendChild(rightBtn);
5955
6145
  document.body.appendChild(header);
5956
6146
  }
6147
+ const MOBILE_TITLE_FALLBACK_TEXT = () => localeT("title.fallback");
6148
+ const readHostSessionTitle = () => {
6149
+ if (typeof document === "undefined") return "";
6150
+ const nav = document.querySelector('nav[aria-label="Session hierarchy"], nav[aria-label="\u4F1A\u8BDD\u5C42\u7EA7"]');
6151
+ if (!nav) return "";
6152
+ const segs = [...nav.querySelectorAll('[class*="crumbSeg"]')];
6153
+ const lastSeg = segs[segs.length - 1];
6154
+ const lastCrumb = lastSeg ? lastSeg.querySelector('[class*="crumbCurrent"]') || lastSeg.querySelector("span, button") : null;
6155
+ if (!lastCrumb) return "";
6156
+ const text = (lastCrumb.getAttribute?.("aria-label") || lastCrumb.textContent || "").trim();
6157
+ if (!text || /^\d+\s+(item|items|项|个)/i.test(text)) return "";
6158
+ return text;
6159
+ };
6160
+ const setTitleText = (next) => {
6161
+ if (titleEl.textContent === next) return;
6162
+ titleEl.innerText = next;
6163
+ };
5957
6164
  const syncMobileTitle = () => {
5958
6165
  if (!titleEl) titleEl = document.querySelector(".dsh-mobile-header-title");
5959
6166
  if (!titleEl) return;
6167
+ const hostTitle = readHostSessionTitle();
6168
+ if (hostTitle) {
6169
+ setTitleText(hostTitle);
6170
+ return;
6171
+ }
5960
6172
  const snap = ctx?.sessions?.list?.getSnapshot?.();
5961
6173
  if (!snap) return;
5962
6174
  const cur = snap.current ? snap.byId?.[snap.current] : null;
5963
6175
  if (!cur || cur.blank) {
5964
- titleEl.innerText = "\u65B0\u4F1A\u8BDD";
6176
+ setTitleText(MOBILE_TITLE_FALLBACK_TEXT());
5965
6177
  } else {
5966
- titleEl.innerText = cur.displayTitle || cur.title || "\u4F1A\u8BDD";
6178
+ setTitleText(cur.displayTitle || cur.title || MOBILE_TITLE_FALLBACK_TEXT());
5967
6179
  }
5968
6180
  };
5969
6181
  syncMobileTitle();
5970
6182
  if (typeof ctx?.sessions?.list?.subscribe === "function") {
5971
6183
  ctx.sessions.list.subscribe(syncMobileTitle);
5972
6184
  }
6185
+ if (typeof MutationObserver !== "undefined") {
6186
+ let mobileTitleRaf = 0;
6187
+ const mobileTitleObserver = new MutationObserver(() => {
6188
+ if (mobileTitleRaf) return;
6189
+ mobileTitleRaf = requestAnimationFrame(() => {
6190
+ mobileTitleRaf = 0;
6191
+ syncMobileTitle();
6192
+ });
6193
+ });
6194
+ mobileTitleObserver.observe(document.body, { childList: true, subtree: true, characterData: true });
6195
+ }
5973
6196
  if (typeof ctx?.sessions?.active?.subscribe === "function") {
5974
6197
  ctx.sessions.active.subscribe(() => {
5975
6198
  if (document.body.classList.contains("dsh-drawer-open")) {
@@ -5994,7 +6217,7 @@ function setupMobileExperience(rpcCall, ctx) {
5994
6217
  if (bar && !bar.querySelector(".dsh-mobile-panel-close-btn")) {
5995
6218
  const btn = document.createElement("button");
5996
6219
  btn.className = "dsh-mobile-panel-close-btn";
5997
- btn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg><span>\u8FD4\u56DE\u5BF9\u8BDD</span>`;
6220
+ btn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg><span>${localeT("panel.backToChat")}</span>`;
5998
6221
  btn.onclick = (e) => {
5999
6222
  e.stopPropagation();
6000
6223
  document.body.classList.remove("dsh-workbench-open");
@@ -6877,7 +7100,7 @@ function setupComposerCollapse() {
6877
7100
  if (!utils) return null;
6878
7101
  const btn = document.createElement("button");
6879
7102
  btn.className = "dsh-header-fold-btn";
6880
- btn.setAttribute("aria-label", "\u6536\u8D77/\u5C55\u5F00\u8F93\u5165\u6846");
7103
+ btn.setAttribute("aria-label", `${localeT("composer.expand")} / ${localeT("composer.collapse")}`);
6881
7104
  btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="18" x2="21" y2="18"></line><polyline points="6 9 12 15 18 9"></polyline></svg>';
6882
7105
  if (logBtn && logBtn.parentElement === utils) utils.insertBefore(btn, logBtn);
6883
7106
  else utils.appendChild(btn);
@@ -6898,7 +7121,7 @@ function setupComposerCollapse() {
6898
7121
  if (!scrollBody) return null;
6899
7122
  bar = document.createElement("div");
6900
7123
  bar.className = "dsh-composer-collapsed-bar";
6901
- bar.textContent = "\u270F\uFE0F \u70B9\u51FB\u8F93\u5165\u6D88\u606F\u2026";
7124
+ bar.textContent = localeT("composer.placeholder");
6902
7125
  scrollBody.insertBefore(bar, seat);
6903
7126
  bar.addEventListener("click", () => setCollapsed(false));
6904
7127
  return bar;
@@ -6933,7 +7156,7 @@ function setupComposerCollapse() {
6933
7156
  const updateButton = (collapsed) => {
6934
7157
  const btn = getFoldBtn();
6935
7158
  if (!btn) return;
6936
- btn.title = collapsed ? "\u5C55\u5F00\u8F93\u5165\u6846" : "\u6536\u8D77\u8F93\u5165\u6846\uFF0C\u6700\u5927\u5316\u5BF9\u8BDD\u9605\u8BFB\u533A";
7159
+ btn.title = collapsed ? localeT("composer.expand") : localeT("composer.collapse");
6937
7160
  const icon = collapsed ? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="6" width="20" height="12" rx="2"></rect><line x1="6" y1="10" x2="6.01" y2="10"></line><line x1="10" y1="10" x2="10.01" y2="10"></line><line x1="14" y1="10" x2="14.01" y2="10"></line><line x1="6" y1="14" x2="10" y2="14"></line><line x1="14" y1="14" x2="18" y2="14"></line></svg>' : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="18" x2="21" y2="18"></line><polyline points="6 9 12 15 18 9"></polyline></svg>';
6938
7161
  const existing = btn.querySelector("svg");
6939
7162
  if (!existing || existing.outerHTML !== icon) btn.innerHTML = icon;
@@ -6996,6 +7219,8 @@ function setupComposerCollapse() {
6996
7219
  }
6997
7220
  function apply(ctx) {
6998
7221
  window.__dshClientCtx = ctx;
7222
+ const intl = createTranslator(ctx);
7223
+ localeT = (key, vars) => intl.t(key, vars);
6999
7224
  setupSettingsDrilldown();
7000
7225
  if (typeof ctx.effect === "function") {
7001
7226
  ctx.effect(() => registerSettingsNavIcon(() => "\u8FDC\u7A0B\u8BBF\u95EE"), "dsh-bridge: settings nav icon");
package/client/index.js CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  getRuntimeFeatureOverrides,
14
14
  } from './mobile-yield.js'
15
15
  import { registerSettingsNavIcon } from './settings-nav-icon.js'
16
+ import { createTranslator } from './locales.mjs'
16
17
  // dsh-bridge 客户端插件:设置页「远程访问」面板
17
18
 
18
19
  // 兼容非 HTTPS 环境(如手机局域网 HTTP 访问):为非安全上下文补齐 crypto.randomUUID
@@ -121,6 +122,10 @@ const ISSUES_URL = 'https://github.com/wenbin-wb/dsh-bridge/issues/new';
121
122
  const TUNNEL_DOCS_URL = 'https://github.com/wenbin-wb/dsh-bridge/blob/main/docs/custom-tunnel.md';
122
123
  const CLOUDFLARE_TUTORIAL_URL = 'https://github.com/wenbin-wb/dsh-bridge/blob/main/docs/cloudflare-fixed-domain.md';
123
124
 
125
+ // 模块级翻译函数:apply() 里随 ctx.locale 初始化(跟随 DSH 主体语言),
126
+ // 供 setupMobileExperience / setupComposerCollapse 等各函数读取(不通过参数传递)。
127
+ let localeT = (key) => key;
128
+
124
129
  // 生成升级命令(拼接具体版本号;用 add 而非 update,update --latest 受已安装依赖版本约束可能无法升级到最新版)
125
130
  function upgradeCommands(latest) {
126
131
  const spec = `@wenbin_wb/dsh-bridge@${latest}`;
@@ -131,6 +136,9 @@ function upgradeCommands(latest) {
131
136
  }
132
137
 
133
138
  const name = 'dsh-bridge';
139
+ // 注:locale 服务不能通过 inject 声明(动态插件激活时该服务不在运行时上下文,
140
+ // 声明会导致「entry did not activate」);改用 apply 内 ctx.get('locale') 可选获取,
141
+ // 无则退化为本地 en/zh 词典(见 locales.mjs)。
134
142
  const inject = ['slots', 'connection', 'workspaces', 'sessions'];
135
143
 
136
144
  // semver 比较:a > b
@@ -2340,6 +2348,41 @@ function BackupRestoreWidget({ rpcCall, onUpdate }) {
2340
2348
  // 重启 + 健康轮询公共逻辑:定时器保存在 ref 中,组件卸载即清理。
2341
2349
  // 修复点:原先两处复制粘贴的 setInterval 不随组件卸载清理,用户切走 Tab 后
2342
2350
  // 轮询仍会继续运行,并在服务恢复时无条件执行 window.location.reload()。
2351
+
2352
+ /**
2353
+ * 把「正在运行的任务」格式化成二次确认文案。
2354
+ * 返回 null 表示没有任务在跑,调用方无需确认。
2355
+ * @param {{sessions?:Array, subagentSessions?:Array, pendingApprovals?:Array, total?:number}} work
2356
+ */
2357
+ function formatActiveWorkWarning(work) {
2358
+ if (!work || !work.total) return null;
2359
+ const lines = [];
2360
+ if (work.sessions?.length) {
2361
+ lines.push(`• 进行中的会话 ${work.sessions.length} 个:`);
2362
+ for (const s of work.sessions.slice(0, 5)) {
2363
+ const detail = s.tools > 0 ? `${s.tools} 次工具调用${s.lastTool ? `(最近:${s.lastTool})` : ''}` : '思考中';
2364
+ lines.push(` - ${s.title}(第 ${s.turn} 轮,${detail})`);
2365
+ }
2366
+ if (work.sessions.length > 5) lines.push(` …等共 ${work.sessions.length} 个`);
2367
+ }
2368
+ if (work.subagentSessions?.length) {
2369
+ lines.push(`• 进行中的子代理/agent 任务 ${work.subagentSessions.length} 个:`);
2370
+ for (const s of work.subagentSessions.slice(0, 5)) {
2371
+ const detail = s.tools > 0 ? `${s.tools} 次工具调用` : '思考中';
2372
+ lines.push(` - ${s.title}(第 ${s.turn} 轮,${detail})`);
2373
+ }
2374
+ if (work.subagentSessions.length > 5) lines.push(` …等共 ${work.subagentSessions.length} 个`);
2375
+ }
2376
+ if (work.pendingApprovals?.length) {
2377
+ lines.push(`• 待审批请求 ${work.pendingApprovals.length} 个:`);
2378
+ for (const a of work.pendingApprovals.slice(0, 5)) {
2379
+ lines.push(` - [${a.platform}] ${a.summary}`);
2380
+ }
2381
+ if (work.pendingApprovals.length > 5) lines.push(` …等共 ${work.pendingApprovals.length} 个`);
2382
+ }
2383
+ return `⚠️ 检测到有任务正在运行,重启会中断它们:\n\n${lines.join('\n')}\n\n中断后可能需要重新发起。确定要继续重启吗?`;
2384
+ }
2385
+
2343
2386
  function useDshRestart({ rpcCall, maxAttempts = 30, texts = {} } = {}) {
2344
2387
  const T = {
2345
2388
  restarting: '正在向 DSH 服务发送重启指令…',
@@ -2363,10 +2406,25 @@ function useDshRestart({ rpcCall, maxAttempts = 30, texts = {} } = {}) {
2363
2406
  setStatus({ phase: 'restarting', text: T.restarting });
2364
2407
  try {
2365
2408
  const r = await rpcCall(BRIDGE_ENDPOINTS.restartDsh, {});
2366
- // 宿主已明确告知"没能安排重启"(例如 systemd 调用失败、助手都派生不出来):
2367
- // 这时绝不能再假装"正在重连"——直接报错并停下,否则用户只会看到永远转圈。
2368
- if (r && r.ok === false) {
2369
- setStatus({ phase: 'timeout', text: `${T.timeout}:${r.error || '未知原因'}` });
2409
+ // 有任务在跑:宿主不会重启,而是把现场回给面板,由这里弹二次确认。
2410
+ // 确认后带 confirm:true 再请求一次(宿主才真正执行重启)。
2411
+ if (r && r.ok === false && r.error?.code === 'busy') {
2412
+ const warning = formatActiveWorkWarning(r.error?.details?.activeWork);
2413
+ if (!window.confirm(warning || '当前有任务正在运行,重启会中断它们。确定继续吗?')) {
2414
+ setStatus(null);
2415
+ setRestarting(false);
2416
+ return;
2417
+ }
2418
+ const r2 = await rpcCall(BRIDGE_ENDPOINTS.restartDsh, { confirm: true });
2419
+ if (r2 && r2.ok === false) {
2420
+ setStatus({ phase: 'timeout', text: `${T.timeout}:${r2.error?.message || '未知原因'}` });
2421
+ setRestarting(false);
2422
+ return;
2423
+ }
2424
+ } else if (r && r.ok === false) {
2425
+ // 宿主已明确告知"没能安排重启"(例如 systemd 调用失败、助手都派生不出来):
2426
+ // 这时绝不能再假装"正在重连"——直接报错并停下,否则用户只会看到永远转圈。
2427
+ setStatus({ phase: 'timeout', text: `${T.timeout}:${r.error?.message || r.error || '未知原因'}` });
2370
2428
  setRestarting(false);
2371
2429
  return;
2372
2430
  }
@@ -3939,7 +3997,7 @@ function setupMobileExperience(rpcCall, ctx) {
3939
3997
  // 左侧双横线菜单按钮 (DeepSeek App 原生图标):联动展开 DSH 原生侧边栏会话列表
3940
3998
  const leftBtn = document.createElement('button');
3941
3999
  leftBtn.className = 'dsh-header-menu-btn';
3942
- leftBtn.title = '打开菜单';
4000
+ leftBtn.title = localeT('menu.open');
3943
4001
  leftBtn.innerHTML = `
3944
4002
  <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round">
3945
4003
  <line x1="3" y1="8" x2="21" y2="8"></line>
@@ -3972,7 +4030,7 @@ function setupMobileExperience(rpcCall, ctx) {
3972
4030
  // 右侧 (+) 新建会话按钮 (DeepSeek App 圆形加号风格)
3973
4031
  const rightBtn = document.createElement('button');
3974
4032
  rightBtn.className = 'dsh-header-new-btn';
3975
- rightBtn.title = '新建会话';
4033
+ rightBtn.title = localeT('menu.newSession');
3976
4034
  rightBtn.innerHTML = `
3977
4035
  <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
3978
4036
  <circle cx="12" cy="12" r="9.5"></circle>
@@ -3995,16 +4053,52 @@ function setupMobileExperience(rpcCall, ctx) {
3995
4053
  }
3996
4054
 
3997
4055
  // 绑定会话标题实时同步 (切换会话或收到首条回复自动更新)
4056
+ //
4057
+ // 标题来源优先取宿主「会话层级」面包屑(PC 顶栏同源):宿主自己维护当前会话标题,
4058
+ // 随会话切换实时更新,0.1.7 的 sessions.list 快照没有 current 字段(旧方案取不到 →
4059
+ // 顶栏永远显示「新会话」)。crumbs 的 nav 带稳定 aria-label(中英双语),最后一级
4060
+ // crumb 就是当前会话标题;空会话时无 crumbs,回退「新会话」。
4061
+ const MOBILE_TITLE_FALLBACK_TEXT = () => localeT('title.fallback');
4062
+ // 取宿主会话层级面包屑的「当前会话标题」。稳定锚点是 nav 的 aria-label(中英双语);
4063
+ // 标题是最后一个 crumbSeg 里的「crumb(当前段)」,不是 nav 里任意最后一个 span——
4064
+ // 子 agent 会话会把末级 crumb 换成 dropdown(内含计数 span),取最后一个 span 会拿到
4065
+ // 计数文本。crumb 本身是 span/button 且带 crumbCurrent 类;取不到则回退空串。
4066
+ const readHostSessionTitle = () => {
4067
+ if (typeof document === 'undefined') return '';
4068
+ const nav = document.querySelector('nav[aria-label="Session hierarchy"], nav[aria-label="会话层级"]');
4069
+ if (!nav) return '';
4070
+ const segs = [...nav.querySelectorAll('[class*="crumbSeg"]')];
4071
+ const lastSeg = segs[segs.length - 1];
4072
+ const lastCrumb = lastSeg
4073
+ ? lastSeg.querySelector('[class*="crumbCurrent"]') || lastSeg.querySelector('span, button')
4074
+ : null;
4075
+ if (!lastCrumb) return '';
4076
+ const text = ((lastCrumb.getAttribute?.('aria-label') || lastCrumb.textContent) || '').trim();
4077
+ // 非标题节点(如子 agent 计数 "2 items"/"2 项")不应被当作标题
4078
+ if (!text || /^\d+\s+(item|items|项|个)/i.test(text)) return '';
4079
+ return text;
4080
+ };
4081
+ const setTitleText = (next) => {
4082
+ if (titleEl.textContent === next) return; // 写前比对:避免每帧重写触发 observer 自循环
4083
+ titleEl.innerText = next;
4084
+ };
3998
4085
  const syncMobileTitle = () => {
3999
4086
  if (!titleEl) titleEl = document.querySelector('.dsh-mobile-header-title');
4000
4087
  if (!titleEl) return;
4088
+ // 1) 宿主当前会话标题(最准确,随切换实时更新)
4089
+ const hostTitle = readHostSessionTitle();
4090
+ if (hostTitle) {
4091
+ setTitleText(hostTitle);
4092
+ return;
4093
+ }
4094
+ // 2) 兜底:从 sessions.list 快照尝试(旧宿主可能有 current;新宿主空会话时回退)
4001
4095
  const snap = ctx?.sessions?.list?.getSnapshot?.();
4002
4096
  if (!snap) return;
4003
4097
  const cur = snap.current ? snap.byId?.[snap.current] : null;
4004
4098
  if (!cur || cur.blank) {
4005
- titleEl.innerText = '新会话';
4099
+ setTitleText(MOBILE_TITLE_FALLBACK_TEXT());
4006
4100
  } else {
4007
- titleEl.innerText = cur.displayTitle || cur.title || '会话';
4101
+ setTitleText(cur.displayTitle || cur.title || MOBILE_TITLE_FALLBACK_TEXT());
4008
4102
  }
4009
4103
  };
4010
4104
 
@@ -4012,6 +4106,19 @@ function setupMobileExperience(rpcCall, ctx) {
4012
4106
  if (typeof ctx?.sessions?.list?.subscribe === 'function') {
4013
4107
  ctx.sessions.list.subscribe(syncMobileTitle);
4014
4108
  }
4109
+ // 宿主会话层级面包屑是 React 渲染,切换会话/标题变化未必走 ctx.sessions.list 通知;
4110
+ // 补一个 MutationObserver 守护顶栏标题始终与宿主当前会话一致。
4111
+ if (typeof MutationObserver !== 'undefined') {
4112
+ let mobileTitleRaf = 0;
4113
+ const mobileTitleObserver = new MutationObserver(() => {
4114
+ if (mobileTitleRaf) return;
4115
+ mobileTitleRaf = requestAnimationFrame(() => {
4116
+ mobileTitleRaf = 0;
4117
+ syncMobileTitle();
4118
+ });
4119
+ });
4120
+ mobileTitleObserver.observe(document.body, { childList: true, subtree: true, characterData: true });
4121
+ }
4015
4122
  if (typeof ctx?.sessions?.active?.subscribe === 'function') {
4016
4123
  ctx.sessions.active.subscribe(() => {
4017
4124
  if (document.body.classList.contains('dsh-drawer-open')) {
@@ -4039,7 +4146,7 @@ function setupMobileExperience(rpcCall, ctx) {
4039
4146
  if (bar && !bar.querySelector('.dsh-mobile-panel-close-btn')) {
4040
4147
  const btn = document.createElement('button');
4041
4148
  btn.className = 'dsh-mobile-panel-close-btn';
4042
- btn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg><span>返回对话</span>`;
4149
+ btn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg><span>${localeT('panel.backToChat')}</span>`;
4043
4150
  btn.onclick = (e) => {
4044
4151
  e.stopPropagation();
4045
4152
  document.body.classList.remove('dsh-workbench-open');
@@ -5111,7 +5218,7 @@ function setupComposerCollapse() {
5111
5218
  if (!utils) return null;
5112
5219
  const btn = document.createElement('button');
5113
5220
  btn.className = 'dsh-header-fold-btn';
5114
- btn.setAttribute('aria-label', '收起/展开输入框');
5221
+ btn.setAttribute('aria-label', `${localeT('composer.expand')} / ${localeT('composer.collapse')}`);
5115
5222
  btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="18" x2="21" y2="18"></line><polyline points="6 9 12 15 18 9"></polyline></svg>';
5116
5223
  // 插到 session 下载按钮左侧(紧邻原生工具钮)
5117
5224
  if (logBtn && logBtn.parentElement === utils) utils.insertBefore(btn, logBtn);
@@ -5128,7 +5235,7 @@ function setupComposerCollapse() {
5128
5235
  if (!scrollBody) return null;
5129
5236
  bar = document.createElement('div');
5130
5237
  bar.className = 'dsh-composer-collapsed-bar';
5131
- bar.textContent = '\u270f\ufe0f 点击输入消息…';
5238
+ bar.textContent = localeT('composer.placeholder');
5132
5239
  scrollBody.insertBefore(bar, seat);
5133
5240
  bar.addEventListener('click', () => setCollapsed(false));
5134
5241
  return bar;
@@ -5153,7 +5260,7 @@ function setupComposerCollapse() {
5153
5260
  const updateButton = (collapsed) => {
5154
5261
  const btn = getFoldBtn();
5155
5262
  if (!btn) return;
5156
- btn.title = collapsed ? '展开输入框' : '收起输入框,最大化对话阅读区';
5263
+ btn.title = collapsed ? localeT('composer.expand') : localeT('composer.collapse');
5157
5264
  const icon = collapsed
5158
5265
  ? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="6" width="20" height="12" rx="2"></rect><line x1="6" y1="10" x2="6.01" y2="10"></line><line x1="10" y1="10" x2="10.01" y2="10"></line><line x1="14" y1="10" x2="14.01" y2="10"></line><line x1="6" y1="14" x2="10" y2="14"></line><line x1="14" y1="14" x2="18" y2="14"></line></svg>'
5159
5266
  : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="18" x2="21" y2="18"></line><polyline points="6 9 12 15 18 9"></polyline></svg>';
@@ -5220,6 +5327,11 @@ function setupComposerCollapse() {
5220
5327
 
5221
5328
  function apply(ctx) {
5222
5329
  window.__dshClientCtx = ctx;
5330
+ // 国际化:创建翻译器(跟随 DSH 主体语言,读 <html lang>;有 locale 服务则顺带注册
5331
+ // 词典,拿不到/被门控也不抛错)。注:locale 服务不能声明进 inject——动态插件声明
5332
+ // 会导致激活等待(宿主 env 无该服务,entry did not activate)。
5333
+ const intl = createTranslator(ctx);
5334
+ localeT = (key, vars) => intl.t(key, vars);
5223
5335
  // 放在最前:交互层先就绪,后续任何初始化抛异常都不会留下「CSS 生效但监听器缺失」
5224
5336
  // 的状态(CSS 6.2 块另有 <html> 就绪开关双重兜底)。
5225
5337
  setupSettingsDrilldown();