@mrrisega/dsh-remote 0.6.14 → 0.6.15

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 (34) hide show
  1. package/clients/dsh-remote/dsh-bridge.mjs +64 -13
  2. package/clients/dsh-remote/dsh-events.mjs +22 -2
  3. package/clients/dsh-remote/e2ee-client.mjs +97 -13
  4. package/clients/dsh-remote/e2ee-shim-script.js +31 -3
  5. package/clients/dsh-remote/mobile-adapter.mjs +172 -0
  6. package/clients/dsh-remote/test/dsh-events.test.mjs +89 -0
  7. package/clients/dsh-remote/test/e2ee-bridge.test.mjs +73 -3
  8. package/clients/dsh-remote/test/e2ee-client.test.mjs +42 -1
  9. package/clients/dsh-remote/test/e2ee-shim.test.mjs +10 -1
  10. package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +18 -1
  11. package/clients/dsh-remote/test/mobile-adapter-image.test.mjs +325 -0
  12. package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +3 -1
  13. package/clients/dsh-remote/test/wechat-runtime.test.mjs +268 -1
  14. package/clients/dsh-remote/upstream-discovery.mjs +186 -30
  15. package/clients/dsh-remote/wechat-channel.mjs +86 -3
  16. package/clients/dsh-remote/wechat-runtime.mjs +253 -10
  17. package/dsh-setup.mjs +161 -7
  18. package/package.json +1 -1
  19. package/packages/dsh-remote-web/lib/client.js +31 -1
  20. package/packages/dsh-remote-web/lib/index.js +392 -49
  21. package/packages/dsh-remote-web/package.json +1 -1
  22. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +64 -13
  23. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +22 -2
  24. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +97 -13
  25. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +31 -3
  26. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +172 -0
  27. package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +186 -30
  28. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +86 -3
  29. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +253 -10
  30. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +161 -7
  31. package/packages/dsh-remote-web/test/doctor-cli.test.mjs +117 -0
  32. package/packages/dsh-remote-web/test/linux-bridge.test.mjs +346 -0
  33. package/packages/dsh-remote-web/test/picker-pin.test.mjs +80 -27
  34. package/packages/dsh-remote-web/test/self-manage.test.mjs +12 -0
package/dsh-setup.mjs CHANGED
@@ -170,6 +170,7 @@ function printHelp() {
170
170
  dsh-remote status 查看配置与服务状态
171
171
  dsh-remote plugin 手动安装 dsh web 远程访问插件(--uninstall 卸载)
172
172
  dsh-remote repair 修复插件挂载(dsh web 起不来时用;只碰 profile,不联网)
173
+ dsh-remote doctor 一条命令自检「为什么连不上」(只读;含上游/进程/微信/日志尾部)
173
174
  dsh-remote --help 显示本用法(等同 dsh-remote help)
174
175
 
175
176
  自建模式(可选):
@@ -202,7 +203,11 @@ const KNOWN_FLAGS = new Set([
202
203
  process.exit(1);
203
204
  }
204
205
  }
205
- try { ensureRuntimeCopy(); } catch (e) { console.warn(`⚠️ 运行时固化跳过: ${e.message}`); }
206
+ // `doctor` 承诺"只读":它连运行时脚本同步都不做 —— 否则"自检"会先往用户磁盘写 9 个文件,
207
+ // 与承诺不符(也会把"我什么都没改却多了文件"变成新的困惑)。
208
+ if (process.argv[2] !== "doctor") {
209
+ try { ensureRuntimeCopy(); } catch (e) { console.warn(`⚠️ 运行时固化跳过: ${e.message}`); }
210
+ }
206
211
 
207
212
  /** 自启动服务应指向的 dsh-setup.mjs:优先配置目录内的固化副本,否则当前执行文件。 */
208
213
  function runtimeSetupPath() {
@@ -764,12 +769,32 @@ function restartBridgeService() {
764
769
  return startBridgeDarwin(plistPath);
765
770
  }
766
771
  if (process.platform === "linux") {
767
- const r = sh(`systemctl --user restart dsh-bridge`);
768
- if (!r.ok) return { ok: false, status: "failed", detail: (r.stderr || r.stdout).trim() || "systemctl restart 失败" };
769
- const a = sh(`systemctl --user is-active dsh-bridge`);
770
- return a.ok && a.stdout.trim() === "active"
771
- ? { ok: true, status: "running", pid: null }
772
- : { ok: false, status: "failed", detail: (a.stdout || a.stderr).trim() };
772
+ // 【0.6.15】飞牛 fnOS / 群晖这类 NAS 上**没有 user systemd 会话**(插件跑在系统服务里,
773
+ // 没有 XDG_RUNTIME_DIR)→ `systemctl --user` 只会报 "Failed to connect to bus",
774
+ // 于是这一支原本必然失败、且没有任何兜底。现在:有会话就用 systemd(可自愈),
775
+ // 没有就退到脱离进程(spawnDetachedBridge,与 macOS 的兜底同一条路径)。
776
+ const hasUserSystemd = Boolean(process.env.XDG_RUNTIME_DIR) && sh("systemctl --user show-environment").ok;
777
+ if (hasUserSystemd) {
778
+ const r = sh(`systemctl --user restart dsh-bridge`);
779
+ if (r.ok) {
780
+ const a = sh(`systemctl --user is-active dsh-bridge`);
781
+ if (a.ok && a.stdout.trim() === "active") return { ok: true, status: "running", pid: null };
782
+ return { ok: false, status: "failed", detail: (a.stdout || a.stderr).trim() };
783
+ }
784
+ // 有会话却重启失败:继续往下走脱离进程兜底,绝不留一个"重启失败但没有任何进程"的空档
785
+ }
786
+ const d = spawnDetachedBridge();
787
+ if (d.ok) {
788
+ return {
789
+ ok: true,
790
+ status: "degraded-detached",
791
+ pid: d.pid,
792
+ detail: hasUserSystemd
793
+ ? "systemd --user 重启失败,已改为后台进程运行(现在可用;systemd 恢复后下一次重启会回到托管)"
794
+ : "本机没有 user systemd 会话(NAS/容器常见),已改为后台进程运行(现在可用,但不会开机自启)",
795
+ };
796
+ }
797
+ return { ok: false, status: "failed", detail: d.error || "systemd 与后台进程均启动失败" };
773
798
  }
774
799
  return { ok: false, status: "unsupported", detail: `平台 ${process.platform} 不支持自启动` };
775
800
  }
@@ -2332,6 +2357,134 @@ async function pluginCmd(argv) {
2332
2357
  * resolve = dsh 的裸包名解析能不能走通(浏览器半注入面板就靠它)
2333
2358
  * client = dsh.client.platform 声明在不在
2334
2359
  */
2360
+ /**
2361
+ * `dsh-remote doctor` —— **一条命令回答「为什么连不上」**(只读,不改任何东西)。
2362
+ *
2363
+ * 【为什么产品上必须有它】2026-09-25 排查两条用户反馈(fb_7b4ee6ec9862 / fb_4cc2c9df749c)时,
2364
+ * 判断"bridge 到底有没有被拉起来"花了大量时间,而且**只能靠翻日志行数估时间** ——
2365
+ * 因为当时的日志连时间戳都没有,用户端也没有任何一个入口能把
2366
+ * 「上游端口 / 守护状态 / 事件订阅 / 待补发队列」一次性说清楚。
2367
+ * 那两个用户都是 linux + 「starting / bridge 未运行」,而我们的自愈当时在 Linux 上**根本没有实现**
2368
+ * (见 0.6.15 的 startBridgeLinux):如果有一份这样的自检,用户自己 10 秒就能看出问题在哪。
2369
+ *
2370
+ * 输出四段:① 身份与版本 ② 上游(含来源与探测结论) ③ 进程与自启动 ④ 通道(微信/加密/待补发)
2371
+ * ⑤ 日志尾部(带时间戳)。任何一项失败都只影响那一行,绝不中断整份自检。
2372
+ *
2373
+ * @param {string[]} argv 额外参数(--profile <dir> 透传给插件挂载诊断)
2374
+ */
2375
+ async function doctorCmd(argv = []) {
2376
+ const mask = (s) => (s ? String(s).slice(0, 3) + "****" + String(s).slice(-2) : "");
2377
+ const ms = (t) => (t ? new Date(Number(t)).toISOString().replace("T", " ").slice(0, 19) : "-");
2378
+ const readJson = (p) => { try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; } };
2379
+ const readText = (p) => { try { return fs.readFileSync(p, "utf8"); } catch { return ""; } };
2380
+ const alive = (pid) => {
2381
+ if (!Number.isInteger(pid) || pid <= 0) return false;
2382
+ try { process.kill(pid, 0); return true; } catch (e) { return e && e.code === "EPERM"; }
2383
+ };
2384
+
2385
+ console.log("dsh-remote 自检(doctor,只读,不改任何东西)");
2386
+ console.log("时间:", new Date().toISOString(), "| 平台:", `${process.platform}/${process.arch}`, "| node:", process.version);
2387
+ console.log("配置目录:", CONFIG_DIR);
2388
+
2389
+ // ── ① 插件与运行环境版本 ────────────────────────────────────────────────
2390
+ const pluginPkg = readJson(path.join(CONFIG_DIR, "node_modules", PLUGIN_ID, "package.json"))
2391
+ || readJson(new URL("./packages/dsh-remote-web/package.json", import.meta.url));
2392
+ const setupVersion = readText(path.join(CONFIG_DIR, ".dsh-setup-version")).trim();
2393
+ console.log("\n① 版本");
2394
+ console.log(` 插件 : ${pluginPkg && pluginPkg.version ? "v" + pluginPkg.version : "(读不到)"}`);
2395
+ console.log(` 运行环境 : ${setupVersion ? "v" + setupVersion : "(未固化 / 未知)"}`);
2396
+ console.log(` 运行环境入口 : ${fs.existsSync(runtimeSetupPath()) ? runtimeSetupPath() : "缺失!"}`);
2397
+ try {
2398
+ const profileDir = resolveProfileDir(argv);
2399
+ if (fs.existsSync(path.join(profileDir, "package.json"))) printPluginDiagnostics(profileDir);
2400
+ else console.log(` [plugin] 未找到 profile(${profileDir})`);
2401
+ } catch (e) { console.log(` [plugin] 诊断失败:${e.message}`); }
2402
+
2403
+ // ── ② 上游 dsh web(bridge 要连的那个本机地址) ──────────────────────────
2404
+ console.log("\n② 上游 dsh web");
2405
+ const hint = resolveUpstreamHint({ relayDir: CONFIG_DIR });
2406
+ const probed = await discoverUpstream({ relayDir: CONFIG_DIR, log: (m) => console.log(` ${m}`) });
2407
+ console.log(` 提示来源 : ${hint.url ? `${hint.url}(${hint.source})` : "(无)"}`);
2408
+ console.log(` 最终采用 : ${probed.url}(${probed.source})`);
2409
+ console.log(` 身份校验 : ${probed.source === "fallback" ? "⚠️ 未找到在听的 dsh web(会一直重试)" : "✅ 已确认为本机 dsh web"}`);
2410
+ if (probed.probed && probed.probed.length) console.log(` 探测过的端口 : ${probed.probed.join(", ")}`);
2411
+
2412
+ // ── ③ 守护与 bridge 进程 ───────────────────────────────────────────────
2413
+ console.log("\n③ 进程与自启动");
2414
+ const watcherPid = readPidFile(WATCHER_PID_FILE);
2415
+ const bridgePid = readPidFile(BRIDGE_PID_FILE);
2416
+ console.log(` watcher pid : ${watcherPid || "-"}${watcherPid ? (alive(watcherPid) ? "(在跑)" : "(已死,pid 文件过期)") : ""}`);
2417
+ console.log(` bridge pid : ${bridgePid || "-"}${bridgePid ? (alive(bridgePid) ? "(在跑)" : "(已死,pid 文件过期)") : ""}`);
2418
+ // 进程扫描(平台各自的方式):只报数量,不猜
2419
+ try {
2420
+ const r = process.platform === "win32"
2421
+ ? sh("tasklist /FI \"IMAGENAME eq node.exe\" /FO CSV 2>NUL")
2422
+ : sh("pgrep -fl 'dsh-setup.mjs|dsh-bridge.mjs' 2>/dev/null");
2423
+ const lines = String(r.stdout || "").split("\n").map((l) => l.trim()).filter((l) => l && !/pgrep/.test(l));
2424
+ const watchers = lines.filter((l) => /dsh-setup\.mjs/.test(l));
2425
+ const bridges = lines.filter((l) => /dsh-bridge\.mjs/.test(l)).filter((l) => !/dsh-setup/.test(l));
2426
+ console.log(` 实测进程 : watcher ${watchers.length} 个 / bridge ${bridges.length} 个`);
2427
+ if (watchers.length && !bridges.length) console.log(" ⚠️ 守护在跑、bridge 没起来 —— 这类情况看日志尾部(通常是在等 dsh web 或登录限流)");
2428
+ if (!watchers.length && !bridges.length) {
2429
+ console.log(" ⚠️ 一个进程都没在跑。手动起一次看报错:`dsh-remote run`(前台,能直接看到输出)");
2430
+ }
2431
+ } catch (e) { console.log(` 实测进程 : 扫描失败(${e.message})`); }
2432
+ if (process.platform === "linux") {
2433
+ const hasUser = Boolean(process.env.XDG_RUNTIME_DIR);
2434
+ console.log(` 自启动 : systemd --user ${hasUser ? "(有 runtime dir)" : "(无 XDG_RUNTIME_DIR → 本机没有 user systemd,插件会用后台进程兜底)"}`);
2435
+ if (hasUser) {
2436
+ const r = sh("systemctl --user is-active dsh-bridge");
2437
+ console.log(` systemd 状态 : ${r.ok ? r.stdout.trim() : (r.stderr || "").trim() || "查不到"}`);
2438
+ }
2439
+ }
2440
+
2441
+ // ── ④ 通道:E2EE / 微信 / 待补发 ───────────────────────────────────────
2442
+ console.log("\n④ 通道");
2443
+ const e2ee = readJson(path.join(CONFIG_DIR, ".e2ee-state.json"));
2444
+ console.log(` 端到端加密 : ${e2ee ? (e2ee.enabled ? `已启用(${e2ee.reason || "ok"})` : `未启用(${e2ee.reason || "?"})`) : "(无状态文件 = 明文)"}`);
2445
+ const wx = readJson(path.join(CONFIG_DIR, ".wechat-state.json"));
2446
+ if (!wx) {
2447
+ console.log(" 微信通道 : 未使用(无状态文件)");
2448
+ } else {
2449
+ const outbox = Array.isArray(wx.pending_outbox) ? wx.pending_outbox.length : 0;
2450
+ const faults = wx.last_fault && wx.last_fault.code ? `${wx.last_fault.code}(${ms(wx.last_fault.at)})` : "无";
2451
+ console.log(` 微信通道 : ${wx.bound ? "已绑定" : "未绑定"} | 最近成功推送 ${ms(wx.last_push_ok_at)}`);
2452
+ console.log(` 待补发通知 : ${outbox ? `⚠️ ${outbox} 条(下次在微信里发一句话就会补上)` : "0 条"}`);
2453
+ console.log(` 最近订阅异常 : ${faults}`);
2454
+ console.log(` 当前会话 : ${wx.current_session_title || "-"}(${wx.current_session_id || "-"})`);
2455
+ console.log(` 最近完成 : ${wx.last_completed_session_title || "-"}(${ms(wx.last_completed_at)})`);
2456
+ if (outbox) console.log(` ℹ️ 入站消息的形状(字段名,用于定位 context_token 缺失):${wx.inbound_shape || "(还没记到)"}`);
2457
+ }
2458
+ const cfg = loadConfig();
2459
+ console.log(` 账号 : ${mask(cfg.phone) || (cfg.local_key ? "自建(本地密钥)" : "(未登录)")}`);
2460
+ console.log(` 中继/API : ${cfg.tunnel_url || "-"} ${cfg.api_url ? `| ${cfg.api_url}` : ""}`);
2461
+
2462
+ // ── ⑤ 日志尾部(现在每行都有时间戳,直接能对时间) ─────────────────────
2463
+ console.log("\n⑤ 日志尾部(bridge + 安装)");
2464
+ for (const name of [".dsh-bridge.log", ".dsh-setup-install.log"]) {
2465
+ const p = path.join(CONFIG_DIR, name);
2466
+ if (!fs.existsSync(p)) { console.log(` ${name}: (无)`); continue; }
2467
+ let tail = "";
2468
+ try {
2469
+ const st = fs.statSync(p);
2470
+ const len = Math.min(st.size, 8 * 1024);
2471
+ const fd = fs.openSync(p, "r");
2472
+ const buf = Buffer.alloc(len);
2473
+ fs.readSync(fd, buf, 0, len, Math.max(0, st.size - len));
2474
+ fs.closeSync(fd);
2475
+ tail = buf.toString("utf8");
2476
+ } catch { /* 读不了就当没有 */ }
2477
+ const lines = tail.split("\n").map((l) => l.trim()).filter(Boolean).slice(-12);
2478
+ console.log(` --- ${name}(末尾 ${lines.length} 行 / ${p})---`);
2479
+ for (const l of lines) console.log(" " + l.slice(0, 220));
2480
+ console.log("");
2481
+ }
2482
+ console.log("结论怎么读:① 版本对不上 → 面板点「一键修复 / 重装运行环境」;");
2483
+ console.log(" ② 上游 fallback → 把 dsh web 起在本机可回环访问的端口(插件会自动发现);");
2484
+ console.log(" ③ 没有任何进程 → 先手动 `dsh-remote run` 看前台输出;");
2485
+ console.log(" ④ 待补发 > 0 → 在微信里随便发一句话即可补收。");
2486
+ }
2487
+
2335
2488
  function printPluginDiagnostics(profileDir) {
2336
2489
  const nmPlugin = path.join(profileDir, "node_modules", PLUGIN_ID);
2337
2490
  const link = describePluginLink(profileDir);
@@ -2417,6 +2570,7 @@ else if (cmd === "settings") settingsHint();
2417
2570
  else if (cmd === "run") await runBridge();
2418
2571
  else if (cmd === "plugin") await pluginCmd(process.argv.slice(3));
2419
2572
  else if (cmd === "repair") repairProfile(process.argv.slice(3));
2573
+ else if (cmd === "doctor") await doctorCmd(process.argv.slice(3));
2420
2574
  else if (cmd === "status") {
2421
2575
  const cfg = loadConfig();
2422
2576
  const local = Boolean(cfg.local_key);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.6.14",
3
+ "version": "0.6.15",
4
4
  "description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -1949,12 +1949,22 @@ window.__ModuleLoader__.load({
1949
1949
  "已 " + Math.round(idleShown / 1000) + " 秒没有新输出(总计 " + fmtDuration(elapsedShown) + "):安装过程偶尔会安静一会儿,超过 " + Math.round(cancelAfterMs / 1000) + " 秒没变化会出现「取消并重试」。")
1950
1950
  : null,
1951
1951
  // ③ 操作区(主操作 / 检查 / 取消并重试 / 彻底卸载)
1952
+ //
1953
+ // ★ 2026-09-25(用户反馈 fb_4cc2c9df749c 原话:「我的端口不是 3080,**没找到一键更新的按钮**」):
1954
+ // 已是最新版时按钮写的是「重新检查 / 修复」—— 用户按字面找「一键更新」,找不到就以为
1955
+ // 面板没有修复入口。现在:按钮直接自称「一键修复 / 重装运行环境」,并在下面补一句
1956
+ // 「已是最新版本」+ 该做什么,让"修不好"的出路不再藏在按钮文案里。
1957
+ !outdated
1958
+ ? h("div", { className: "dru-hint", style: { marginTop: 6 } },
1959
+ "已是最新版本 v" + (ver && ver.version ? ver.version : "(未知)")
1960
+ + "。若手机连不上或后台服务没起来,点「一键修复 / 重装运行环境」会重新补装并拉起后台服务,不需要其他操作。")
1961
+ : null,
1952
1962
  h("div", { className: "dru-actions", style: { marginTop: 10 } },
1953
1963
  outdated
1954
1964
  ? h("button", { type: "button", className: "dru-btn dru-btn-primary", disabled: upBusy || unBusy || chkBusy || updating, onClick: doUpdate },
1955
1965
  upBusy ? "更新启动中…" : updating ? "正在更新…" : "一键更新到 v" + chk.latest)
1956
1966
  : h("button", { type: "button", className: "dru-btn dru-btn-ghost", disabled: upBusy || unBusy || chkBusy || updating, onClick: doUpdate },
1957
- updating ? "正在更新…" : (ver && !ver.runtimeReady) ? "安装并启动(一键修复)" : "重新检查 / 修复"),
1967
+ updating ? "正在更新…" : (ver && !ver.runtimeReady) ? "安装并启动(一键修复)" : "一键修复 / 重装运行环境"),
1958
1968
  h("button", { type: "button", className: "dru-btn dru-btn-ghost", disabled: chkBusy || updating || upBusy, onClick: doCheck }, chkBusy ? "检查中…" : "检查更新"),
1959
1969
  canCancel
1960
1970
  ? h("button", {
@@ -4780,6 +4790,26 @@ window.__ModuleLoader__.load({
4780
4790
  !wx.channel_running
4781
4791
  ? h("div", { className: "dru-msg dru-msg-warn" }, "提示:后台服务的微信通道当前没有在运行(绑定状态不受影响)。到「📱 远程访问」面板重启后台服务后会自动恢复。")
4782
4792
  : null,
4793
+ // ── 事件订阅 / 推送健康(2026-09-25 事故加的)────────────────────────────
4794
+ // 那次「任务跑完一条推送都没收到、日志里也没有任何报错」的两个可用信号:
4795
+ // ① 待补发队列非空 = 平台侧刚才发不出去(现在会补发,但要如实告诉用户);
4796
+ // ② 事件订阅没就绪 / 最近有订阅异常 = 任务通知根本不会来("没收到推送"的另一种成因,
4797
+ // 以前完全没有界面入口,只能翻日志)。
4798
+ // 只在**真有问题**时才显示,正常时不占地方。
4799
+ wx.events_running && wx.events_state && wx.events_state !== "ready"
4800
+ ? h("div", { className: "dru-msg dru-msg-warn" },
4801
+ "提示:任务事件订阅未就绪(状态 " + String(wx.events_state) + "),这段时间任务跑完可能不会推给你。稍候会自动重连;持续如此请到「📱 远程访问」重启后台服务。")
4802
+ : null,
4803
+ (Number(wx.pending_outbox) || 0) > 0
4804
+ ? h("div", { className: "dru-msg dru-msg-warn" },
4805
+ "有 " + Number(wx.pending_outbox) + " 条通知刚才没能发出去(微信侧发送失败),已排在待补发队列里 —— 你下次在微信里任意发一句话,就会自动补给你。")
4806
+ : null,
4807
+ wx.last_fault && wx.last_fault.code
4808
+ ? h("div", { className: "dru-hint" },
4809
+ "最近一次事件订阅异常:" + String(wx.last_fault.code)
4810
+ + "(" + String(wx.last_fault.message || "").slice(0, 80) + ")"
4811
+ + (wx.last_fault.at ? " · " + new Date(Number(wx.last_fault.at)).toLocaleString("zh-CN") : ""))
4812
+ : null,
4783
4813
  wx.disabled
4784
4814
  ? h("div", { className: "dru-msg dru-msg-warn" }, "提示:微信通道已按配置关闭(环境变量 DSH_WECHAT=0)。凭据仍在,重新开启后会继续推送。")
4785
4815
  : null,