@mrrisega/dsh-remote 0.6.7-beta.3 → 0.6.7

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.
@@ -91,8 +91,16 @@ class El {
91
91
  get isConnected() { return true; }
92
92
  }
93
93
 
94
- /** 搭一个最小 dsh web 环境:frame(带官方 data 属性)内含 sidebar/center 两列 + 可选官方 toggle。 */
95
- function harness({ collapsedAttr = undefined, detailsAttr = undefined, sidebarRect = null, width = 390, withToggle = true } = {}) {
94
+ /**
95
+ * 搭一个最小 dsh web 环境:frame(带官方 data 属性)内含 sidebar/center 两列 + 可选官方 toggle。
96
+ *
97
+ * `variant: "rc2"` 复刻**官方 0.1.5-rc.2 起**的 DOM:第三列由 `details` 改名 `rightbar`
98
+ * (`data-details-collapsed` → `data-rightbar-collapsed`、`pI_x6G_detailsCol` → `pI_x6G_rightbarCol`),
99
+ * 且 `overlayLayer`(官方 shell.overlay 宿主,`inset:0` 全屏、`data-shell-overlay`)留在 frame 末尾、
100
+ * 折叠时**没有拖拽把手**——这正是 2026-09-19 线上"整屏遮罩"的现场结构。
101
+ */
102
+ function harness({ collapsedAttr = undefined, detailsAttr = undefined, sidebarRect = null, width = 390, withToggle = true,
103
+ variant = "rc1", rightbarAttr = undefined, overlayRect = null, rightbarRect = null } = {}) {
96
104
  const html = new El("html");
97
105
  // HOSTISH 门禁会读 documentElement.outerHTML 找官方特征(__ModuleLoader__ / DeepSeek Harness)
98
106
  html.outerHTML = '<html><head><script>window.__ModuleLoader__ = { mode: "queue" }</script>'
@@ -107,6 +115,17 @@ function harness({ collapsedAttr = undefined, detailsAttr = undefined, sidebarRe
107
115
  if (sidebarRect) sidebar._rect = sidebarRect;
108
116
  frame.appendChild(sidebar);
109
117
  frame.appendChild(center);
118
+ let rightbar = null;
119
+ let overlay = null;
120
+ if (variant === "rc2") {
121
+ if (rightbarAttr !== undefined) frame.setAttribute("data-rightbar-collapsed", rightbarAttr);
122
+ rightbar = new El("div", { class: "pI_x6G_rightbarCol" });
123
+ if (rightbarRect) rightbar._rect = rightbarRect;
124
+ frame.appendChild(rightbar);
125
+ overlay = new El("div", { class: "pI_x6G_overlayLayer", "data-shell-overlay": "" });
126
+ if (overlayRect) overlay._rect = overlayRect;
127
+ frame.appendChild(overlay);
128
+ }
110
129
  const toggle = new El("button");
111
130
  toggle.setAttribute("aria-label", "打开侧边栏");
112
131
 
@@ -127,7 +146,7 @@ function harness({ collapsedAttr = undefined, detailsAttr = undefined, sidebarRe
127
146
  addEventListener() {},
128
147
  querySelector(sel) {
129
148
  const s2 = String(sel);
130
- if (s2.includes(".pI_x6G_frame") || s2.includes("[data-sidebar-collapsed], [data-details-collapsed]") || s2.includes("[data-shell-overlay]")) return frame;
149
+ if (s2.includes(".pI_x6G_frame") || /\[data-(sidebar|details|rightbar)-collapsed\]/.test(s2) || s2.includes("[data-shell-overlay]")) return frame;
131
150
  if (s2.includes("aria-label=")) return withToggle ? toggle : null;
132
151
  if (s2.includes(".hHd-Xa_toggle")) return withToggle ? toggle : null;
133
152
  if (s2.includes("hHd-Xa")) return sidebar; // 子列识别用(真实页面里这是侧栏)
@@ -154,12 +173,15 @@ function harness({ collapsedAttr = undefined, detailsAttr = undefined, sidebarRe
154
173
  MutationObserver: class { observe() {} disconnect() {} },
155
174
  ResizeObserver: class { observe() {} disconnect() {} },
156
175
  };
176
+ // 真实浏览器里 window.location 必然存在(注入层用 location.assign/href 跳转),假 DOM 也要有
177
+ const loc = { href: "https://n.risegao.cn:13443/remote/dev-x/", host: "n.risegao.cn", pathname: "/remote/dev-x/", origin: "https://n.risegao.cn:13443" };
178
+ win.location = loc;
157
179
  const ctx = vm.createContext({
158
180
  window: win, document: doc, console, setTimeout: win.setTimeout, clearTimeout: win.clearTimeout,
159
181
  setInterval: () => 0, clearInterval() {}, MutationObserver: win.MutationObserver,
160
182
  ResizeObserver: win.ResizeObserver, requestAnimationFrame: win.requestAnimationFrame,
161
183
  navigator: { userAgent: "iPhone Safari", maxTouchPoints: 5, platform: "iPhone" },
162
- location: { href: "https://n.risegao.cn:13443/app/", host: "n.risegao.cn" },
184
+ location: loc,
163
185
  localStorage: win.localStorage, URL, Date, Math, JSON, Object, Array, String, Number, RegExp, Boolean, Promise, Error, isNaN, parseInt, parseFloat,
164
186
  HTMLElement: El, // 脚本用 `c instanceof HTMLElement` 过滤 frame 的子列
165
187
  });
@@ -170,7 +192,7 @@ function harness({ collapsedAttr = undefined, detailsAttr = undefined, sidebarRe
170
192
  ctx.console = dbgConsole;
171
193
  ctx.globalThis = ctx;
172
194
  ctx.self = win;
173
- return { ctx, html, body, frame, sidebar, toggle, win };
195
+ return { ctx, html, body, frame, sidebar, rightbar, overlay, toggle, win };
174
196
  }
175
197
 
176
198
  const ADAPTER_DEBUG = process.env.DSH_ADAPTER_DEBUG === "1";
@@ -260,48 +282,83 @@ test("运行时:能拿到官方 toggle 时,点遮罩仍走官方 toggle(
260
282
  assert.ok(env.toggle._clicked >= 1, "应点击官方 toggle");
261
283
  });
262
284
 
263
- // ── 字号档位(2026-09-16 需求:4 档滑块、仅手机端生效) ──────────────────────
285
+ // ── 远程控制悬浮按钮(2026-09-19:字号 + 加密小锁 + 返回设备列表,三合一) ──────
286
+ //
287
+ // 用户要求:「把字体的菜单和加密小锁的菜单合并成一个小悬浮按钮;点击后可以弹菜单调节字体,
288
+ // 也可以返回设备列表,让用户主动返回回去;手机端要有,电脑端也可以有。」
264
289
 
265
- const findFsBtn = (env) => env.body.children.find((c) => c._cls.has("dsh-ma-fsbtn"));
266
- const findFsPanel = (env) => env.body.children.find((c) => c._cls.has("dsh-ma-fspanel"));
290
+ const findFab = (env) => env.body.children.find((c) => c._cls.has("dsh-ma-fab"));
291
+ const findMenu = (env) => env.body.children.find((c) => c._cls.has("dsh-ma-menu"));
267
292
 
268
- test("字号:菜单/字号按钮与面板都应创建(面板默认收起)", () => {
293
+ test("悬浮菜单:按钮与菜单都创建,菜单默认收起", () => {
269
294
  const env = runAdapter(harness({}));
270
- const btn = findFsBtn(env);
271
- const panel = findFsPanel(env);
272
- assert.ok(btn, "应有字号按钮");
273
- assert.ok(panel, "应有字号面板");
274
- assert.equal(panel.hidden, true, "面板默认收起(点按钮才展开)");
275
- assert.ok((panel.querySelector ? panel : {}).querySelector === undefined || true);
295
+ const fab = findFab(env);
296
+ const menu = findMenu(env);
297
+ assert.ok(fab, "应有悬浮按钮");
298
+ assert.ok(menu, "应有菜单面板");
299
+ assert.equal(menu.hidden, true, "菜单默认收起(点按钮才展开)");
276
300
  });
277
301
 
278
- test("字号:默认档 = 最小(不加缩放属性,保持当前大小)", () => {
302
+ test("悬浮菜单:点按钮开合菜单", () => {
279
303
  const env = runAdapter(harness({}));
280
- assert.equal(env.html.getAttribute("data-dsh-ma-fs"), null, "默认不应带缩放属性");
281
- assert.equal(env.win.localStorage.getItem("dsh-ma-font-scale"), null);
304
+ const fab = findFab(env);
305
+ const menu = findMenu(env);
306
+ fab.dispatch("click");
307
+ assert.equal(menu.hidden, false, "点按钮应展开菜单");
308
+ fab.dispatch("click");
309
+ assert.equal(menu.hidden, true, "再点应收起");
282
310
  });
283
311
 
284
- test("字号:滑块改档 → 属性/正文变量/持久化都生效", () => {
312
+ test("悬浮菜单:字号档位仍生效(属性/正文变量/持久化)", () => {
285
313
  const env = runAdapter(harness({}));
286
- const panel = findFsPanel(env);
287
- const range = panel && panel.querySelector && panel.querySelector('input[type="range"]');
288
- assert.ok(range, "面板里应有 range 滑块");
289
- // 打到「大」(level 2)
314
+ const menu = findMenu(env);
315
+ const range = menu.querySelector('input[type="range"]');
316
+ assert.ok(range, "菜单里应有字号滑块");
290
317
  range.value = "2";
291
318
  range.dispatch("input");
292
319
  assert.equal(env.html.getAttribute("data-dsh-ma-fs"), "2", "应写入缩放属性(CSS 据此放大字号)");
293
- assert.equal(env.win.localStorage.getItem("dsh-ma-font-scale"), "2", "应持久化到 localStorage");
294
- const contentVar = env.html.style["--dsh-content-font-size"] || env.html._cssVars && env.html._cssVars["--dsh-content-font-size"];
295
- assert.ok(contentVar, "应同步官方正文变量 --dsh-content-font-size(聊天正文才会变大)");
320
+ assert.equal(env.win.localStorage.getItem("dsh-ma-font-scale"), "2", "应持久化");
321
+ const contentVar = (env.html._cssVars || {})["--dsh-content-font-size"];
322
+ assert.ok(contentVar, "应同步官方正文变量 --dsh-content-font-size");
323
+ range.value = "0";
324
+ range.dispatch("input");
325
+ assert.equal(env.html.getAttribute("data-dsh-ma-fs"), null, "回到「最小」应移除缩放属性");
296
326
  });
297
327
 
298
- test("字号:恢复「最小」时移除缩放属性(回到当前大小)", () => {
328
+ test("悬浮菜单:必须有「返回设备列表」,且回到 /app/(用户在镜像页的唯一主动退路)", () => {
299
329
  const env = runAdapter(harness({}));
300
- const panel = findFsPanel(env);
301
- const range = panel.querySelector('input[type="range"]');
302
- range.value = "3"; range.dispatch("input");
303
- assert.equal(env.html.getAttribute("data-dsh-ma-fs"), "3");
304
- range.value = "0"; range.dispatch("input");
305
- assert.equal(env.html.getAttribute("data-dsh-ma-fs"), null, "level 0 = 最小 → 不应留缩放属性");
306
- assert.equal(env.win.localStorage.getItem("dsh-ma-font-scale"), "0");
330
+ const menu = findMenu(env);
331
+ const back = menu.querySelector(".dsh-ma-menu-back");
332
+ assert.ok(back, "菜单里必须有返回设备列表按钮");
333
+ back.dispatch("click");
334
+ assert.equal(env.ctx.location.href, "/app/", "点击后应跳到 APP 外壳(绝对路径,不能依赖当前镜像路径)");
335
+ });
336
+
337
+ test("悬浮菜单:E2EE 状态可合并(shim 在场时隐藏独立药丸并读它的状态)", () => {
338
+ const env = harness({});
339
+ env.win.__dshE2eeBadge = { state: () => ({ mode: "warn", text: "未加密(刷新后需重新解锁)" }) };
340
+ runAdapter(env);
341
+ assert.equal(env.html.getAttribute("data-dsh-ma-merged-e2ee"), "1",
342
+ "shim 在场时应挂牌合并(CSS 据此隐藏官方那颗独立药丸)");
343
+ });
344
+
345
+ test("多端适配:悬浮按钮与字号样式的 CSS 必须在 @media(max-width:820px) 之外(电脑端也要有)", () => {
346
+ const { html } = injectMobileAdapter("<!doctype html><html><head></head><body></body></html>");
347
+ const css = /<style id="[^"]*-css"[^>]*>([\s\S]*?)<\/style>/.exec(html)[1];
348
+ // 只保留 @media 之外的规则(做一次括号配平剥离),再断言关键选择器还在
349
+ let out = "", i = 0, depth = 0, inMedia = false;
350
+ while (i < css.length) {
351
+ if (css.startsWith("@media", i)) {
352
+ const open = css.indexOf("{", i);
353
+ let d = 1, j = open + 1;
354
+ while (j < css.length && d > 0) { if (css[j] === "{") d++; else if (css[j] === "}") d--; j++; }
355
+ i = j;
356
+ continue;
357
+ }
358
+ out += css[i++];
359
+ }
360
+ assert.match(out, /button\.dsh-ma-fab\s*\{/, "悬浮按钮样式必须在媒体查询之外(电脑端可见)");
361
+ assert.match(out, /div\.dsh-ma-menu\s*\{/, "菜单样式必须在媒体查询之外");
362
+ assert.match(out, /html\[data-dsh-ma-fs="3"\]/, "字号档位样式也应在媒体查询之外(电脑端同样生效)");
363
+ assert.match(out, /z-index:\s*40[01]/, "悬浮按钮/菜单层级要高于遮罩(290),遮罩异常时仍是自救出口");
307
364
  });
package/dsh-setup.mjs CHANGED
@@ -863,11 +863,37 @@ dsh-remote:独立的本地设置页已移除。
863
863
  `);
864
864
  }
865
865
 
866
+ /**
867
+ * 账号指纹:账号 / 模式 / 设备身份任一变化都意味着"必须用新凭据重新登记"。
868
+ * 为什么需要:bridge 的 DSH_BRIDGE_PHONE/PASSWORD 是**进程启动时**从环境变量固化的,
869
+ * 配置改了并不会影响已在跑的进程 —— 这是"切换账号后设备仍留在旧账号"的根因之一。
870
+ */
871
+ function accountFingerprint(cfg) {
872
+ const c = cfg || {};
873
+ return [c.local_key ? "local" : "saas", c.phone || c.email || "", String(c.device_id || ""), String(c.local_key || "")].join("|");
874
+ }
875
+
876
+ /**
877
+ * 服务端登录限流的"最早可重试时刻"(bridge 在收到 429 时落盘)。
878
+ * 为什么需要:限流窗口是 15 分钟,而 watcher 每 10 秒就会重新拉起 bridge ——
879
+ * 不读这个文件就是 90 次空撞(日志刷屏、还可能拖长窗口)。它只是**退避提示**,绝不阻止正常启动。
880
+ */
881
+ const LOGIN_RATELIMIT_FILE = ".dsh-login-ratelimited";
882
+ function loginRateLimitUntil() {
883
+ const f = path.join(CONFIG_DIR, LOGIN_RATELIMIT_FILE);
884
+ try {
885
+ const until = Number(String(fs.readFileSync(f, "utf8")).trim());
886
+ if (Number.isFinite(until) && until > Date.now()) return until;
887
+ fs.rmSync(f, { force: true }); // 已过期:清掉,恢复正常启动
888
+ } catch { /* 无文件 = 未限流 */ }
889
+ return 0;
890
+ }
891
+
866
892
  // ---------- run:前台跑 bridge(带配置 + 自启动 watcher) ----------
867
893
  async function runBridge() {
868
- // Windows 去重:登录任务(Task Scheduler)与插件半的自愈都可能拉起 watcher,
869
- // 两个 bridge 会抢同一个设备登记 → 这里以 pid 文件为准,已有守护在跑就直接退出。
870
- if (IS_WIN) {
894
+ // 去重(所有平台):自启动服务、登录任务、插件半的脱离进程兜底都可能拉起 watcher,
895
+ // 两个 bridge 会抢同一个设备登记 → 以 pid 文件为准,已有守护在跑就直接退出。
896
+ {
871
897
  const other = readPidFile(WATCHER_PID_FILE);
872
898
  if (other && other !== process.pid && pidAlive(other)) {
873
899
  console.log(`[dsh-remote] 已有一个 bridge 守护在运行(pid=${other}),本进程直接退出,避免重复实例。`);
@@ -876,6 +902,10 @@ async function runBridge() {
876
902
  }
877
903
  let cfg = loadConfig();
878
904
  let warnedNoLogin = false;
905
+ /** 当前 bridge 子进程使用的账号指纹(账号/模式/设备身份任一变化 → 重启子进程)。 */
906
+ let bridgeAccountFp = "";
907
+ /** 限流提示只打一次,避免刷屏。 */
908
+ let warnedRateLimit = false;
879
909
 
880
910
  // watcher:检测 dsh web(127.0.0.1:3080)存活,存活才启动 bridge
881
911
  const checkUpstream = () => new Promise((resolve) => {
@@ -917,6 +947,28 @@ async function runBridge() {
917
947
  }
918
948
  const apiUrl = saas ? (cfg.api_url || DEFAULT_API) : "";
919
949
 
950
+ // 账号/模式/设备身份变了 → 必须重启 bridge(它的凭据是启动时从环境变量固化的)。
951
+ // 没有这一条时:面板切了账号、旧 bridge 却继续用旧账号跑,新账号的设备列表里看不到这台机器
952
+ //(2026-09-19 实测事故)。
953
+ const fp = accountFingerprint(cfg);
954
+ if (!childStopped(bridgeProc) && bridgeAccountFp && bridgeAccountFp !== fp) {
955
+ console.log("[dsh-remote] 账号配置已变化 → 重启 bridge 让新账号生效...");
956
+ try { bridgeProc.kill(); } catch { /* 已退出 */ }
957
+ bridgeAccountFp = "";
958
+ return; // 下一轮(10s 内)用新配置重新拉起
959
+ }
960
+
961
+ // 服务端登录限流窗口内不反复拉起 bridge(凭据没错,改了也没用)
962
+ const rlUntil = loginRateLimitUntil();
963
+ if (rlUntil > Date.now()) {
964
+ if (!warnedRateLimit) {
965
+ warnedRateLimit = true;
966
+ console.log(`[dsh-remote] 登录被服务端限流,约 ${Math.ceil((rlUntil - Date.now()) / 1000)} 秒后自动重试(凭据无需修改)。`);
967
+ }
968
+ return;
969
+ }
970
+ warnedRateLimit = false;
971
+
920
972
  const alive = await checkUpstream();
921
973
  if (alive && childStopped(bridgeProc)) {
922
974
  starting = true;
@@ -944,7 +996,8 @@ async function runBridge() {
944
996
  });
945
997
  // Windows:落 pid 文件,供插件半发现进程(Windows 没有 pgrep/ps)。
946
998
  // 插件半启动 bridge 走的就是本函数,所以这里写一次两边都覆盖到。
947
- if (IS_WIN) writePidFile(BRIDGE_PID_FILE, bridgeProc.pid);
999
+ writePidFile(BRIDGE_PID_FILE, bridgeProc.pid); // 供插件半发现进程(Windows 无 pgrep;POSIX 用于脱离进程兜底的去重)
1000
+ bridgeAccountFp = fp; // 记下这个子进程用的是哪套凭据,配置一变就重启它
948
1001
  setTimeout(() => { starting = false; }, 5000);
949
1002
  } else if (!alive && bridgeProc && bridgeProc.exitCode === null) {
950
1003
  console.log("[dsh-remote] dsh web 离线,停止 bridge...");
@@ -952,8 +1005,8 @@ async function runBridge() {
952
1005
  }
953
1006
  };
954
1007
 
955
- // Windows:watcher 自身的 pid 也要落盘(插件半据此判断"守护在跑"并避免重复拉起)
956
- if (IS_WIN) {
1008
+ // watcher 自身的 pid 也要落盘(插件半据此判断"守护在跑"并避免重复拉起)
1009
+ {
957
1010
  writePidFile(WATCHER_PID_FILE, process.pid);
958
1011
  const cleanup = () => { removePidFile(WATCHER_PID_FILE); removePidFile(BRIDGE_PID_FILE); };
959
1012
  process.on("exit", cleanup);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.6.7-beta.3",
3
+ "version": "0.6.7",
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",
@@ -39,7 +39,8 @@
39
39
  "clients/dsh-remote",
40
40
  "packages/dsh-remote-web",
41
41
  "docs/self-hosting.md",
42
- "docs/telemetry.md"
42
+ "docs/telemetry.md",
43
+ "scripts/test-net-guard.cjs"
43
44
  ],
44
45
  "workspaces": [
45
46
  "packages/*",
@@ -50,10 +51,10 @@
50
51
  },
51
52
  "scripts": {
52
53
  "test": "npm run test:router && npm run test:plugin && npm run test:bridge",
53
- "test:router": "node --test packages/relay-router/test/native-device-selection.test.mjs packages/relay-router/test/presence-report.test.mjs packages/relay-router/test/logger.test.mjs",
54
+ "test:router": "NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test packages/relay-router/test/native-device-selection.test.mjs packages/relay-router/test/native-app-entry.test.mjs packages/relay-router/test/presence-report.test.mjs packages/relay-router/test/logger.test.mjs",
54
55
  "_comment:test:plugin": "DSH_RELAY_SKIP_SERVICE=1 = 测试隔离:插件加载时不做真实系统操作(npx 补装 / launchctl / 重启 harness);需要真实分支的用例自行 unset(见 account-switch / runtime-provision / harness-restart 用例注释)",
55
- "test:plugin": "DSH_REMOTE_TELEMETRY=0 DSH_RELAY_SKIP_SERVICE=1 node --test --test-concurrency=1 packages/dsh-remote-web/test/*.test.mjs",
56
- "test:bridge": "node --test clients/dsh-remote/test/*.test.mjs",
56
+ "test:plugin": "DSH_REMOTE_TELEMETRY=0 DSH_RELAY_SKIP_SERVICE=1 NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test --test-concurrency=1 packages/dsh-remote-web/test/*.test.mjs",
57
+ "test:bridge": "NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test clients/dsh-remote/test/*.test.mjs",
57
58
  "check": "node --check clients/dsh-remote/dsh-bridge.mjs && node --check clients/dsh-remote/e2ee-shim.mjs && node --check dsh-setup.mjs && node --check packages/dsh-remote-web/lib/index.js && node --check packages/dsh-remote-web/lib/client.js && node --check packages/relay-router/src/index.mjs && node --check packages/relay-router/src/logger.mjs && bash -n deploy/install-open.sh && grep -q 'command -v setsid' deploy/install-open.sh && grep -q 'command -v ss' deploy/install-open.sh",
58
59
  "release:tarballs": "bash scripts/release-tarballs.sh"
59
60
  },
@@ -1727,6 +1727,25 @@ window.__ModuleLoader__.load({
1727
1727
  // ---------- 登录(密码/短信) ----------
1728
1728
  var doLogin = function () {
1729
1729
  if (!phone.trim() || !pass) { setMsg("err", "请填写手机号与密码"); return; }
1730
+ /**
1731
+ * 账号落盘(POST /dsh-remote/config)后的统一提示。
1732
+ *
1733
+ * 为什么单独抽出来:这次"换账号后设备不出现"的事故就是**静默失败**造成的 ——
1734
+ * /config 会顺带重启 bridge(让新账号生效),但其返回的 bridgeRestart 前端从来不看,
1735
+ * 于是"服务没重启成功"和"一切正常"在界面上长得一模一样。
1736
+ */
1737
+ function applyAccountSaved(cfg, okText) {
1738
+ setSt(cfg);
1739
+ var br = cfg && cfg.bridgeRestart;
1740
+ if (br && br.ok === false && br.status !== "running" && br.status !== "provisioning" && br.status !== "skipped") {
1741
+ setMsg("warn", okText + ";但后台服务(bridge)未能自动重启:"
1742
+ + ((br.detail || br.status) || "未知原因")
1743
+ + " —— 新账号可能收不到这台设备,请点下方「重启服务」或重启一次 dsh web。");
1744
+ return;
1745
+ }
1746
+ setMsg("ok", okText);
1747
+ }
1748
+
1730
1749
  if (!lcap || !lcap.id || !lcapTxt.trim()) { setMsg("err", "请输入图中验证码(点击图片可刷新)"); if (!lcap) loadCaptcha("login"); return; }
1731
1750
  var prevPhone = (st && st.config && st.config.phone) || "";
1732
1751
  setBusy("login");
@@ -1737,8 +1756,8 @@ window.__ModuleLoader__.load({
1737
1756
  // 切换连接账号(手机号与之前不同):清除旧账号留下的反馈线程凭据
1738
1757
  if (prevPhone !== phone.trim()) fbClearThreads();
1739
1758
  setPwdOpen(false); // 登录成功(可能从「忘记密码」返回)→ 收起重置表单
1740
- setSt(cfg); setPass(""); setLcapTxt(""); setLcap(null);
1741
- setMsg("ok", "✅ 登录成功,账号已保存");
1759
+ setPass(""); setLcapTxt(""); setLcap(null);
1760
+ applyAccountSaved(cfg, "✅ 登录成功,账号已保存");
1742
1761
  });
1743
1762
  }
1744
1763
  var relayBody = body.body || {};
@@ -1791,9 +1810,8 @@ window.__ModuleLoader__.load({
1791
1810
  if (body.ok || body.status === 201 || (body.body && body.body.token)) {
1792
1811
  return post("/dsh-remote/config", { phone: rphone.trim(), password: rpass }).then(function (cfg) {
1793
1812
  setPwdOpen(false);
1794
- setSt(cfg);
1795
1813
  setRphone(""); setRpass(""); setRpass2(""); setRsms(""); setRcap(null); setRcapTxt(""); setRInvite("");
1796
- setMsg("ok", "✅ 注册成功,已自动登录");
1814
+ applyAccountSaved(cfg, "✅ 注册成功,已自动登录");
1797
1815
  });
1798
1816
  }
1799
1817
  var b = body.body || {};