@mrrisega/dsh-remote 0.6.0-beta.10 → 0.6.0-beta.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.6.0-beta.10",
3
+ "version": "0.6.0-beta.11",
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",
@@ -5,15 +5,18 @@
5
5
  // factory 内只能 require shell 种子模块(react / react/jsx-runtime 等)。
6
6
  //
7
7
  // 功能:
8
- // - settings.section:设置页「远程访问」栏目(位于「Agent 预设」下方,官方扩展点)
8
+ // - 入口=官方「设置」页内的 settings.section「远程访问」栏目(位于「Agent 预设」下方,
9
+ // 官方扩展点);不向官方侧边栏注入任何按钮,官方「设置」按钮原样、不被遮挡
9
10
  // - 栏目内联渲染配置面板(浅色高对比 UI,遵循主流登录体验)
10
11
  // · 登录态:已登录显示账号 + 退出登录;未登录显示 登录/注册 tabs
11
- // · 🔒 修改密码:已登录账号经 图形验证码+短信验证码 重置(/dsh-remote/password/reset 代理);
12
+ // · 🔒 修改密码(账号卡):已登录账号经 图形验证码+短信验证码 重置(/dsh-remote/password/reset 代理);
12
13
  // 成功后全部已授权设备/会话失效 → 本地登出并提示用新密码重新登录(覆盖本机 config 密码)
14
+ // · 忘记密码(登录卡):未登录时登录表单内「忘记密码?」→ 同一套
15
+ // 图形验证码+短信验证码 表单(手机号预填当前输入、可改),成功后本地登出并提示「请用新密码登录」
13
16
  // · 登录要求图形验证码;注册要求两次密码 + 图形验证码
14
17
  // · 📱 远程访问卡:一次性访问密钥(扫码/直接打开/复制 + 到期倒计时自动刷新)与已授权设备管理
15
18
  // · bridge 状态与启停开关 + 关于 dsh-remote 说明卡片
16
- // - 首次安装引导:设置页栏目旁小红点(localStorage dsh-remote-seen-dot 控制)
19
+ // - 首次安装引导:设置页「远程访问」栏目(官方导航 navCell)旁小红点(localStorage dsh-remote-seen-dot 控制)
17
20
  // - shell.overlay:满意度弹窗(安装体验至少 1 小时后弹出,只弹一次)
18
21
  // 所有数据经同源 /dsh-remote/* 宿主路由读写(node 半提供)。
19
22
  window.__ModuleLoader__.load({
@@ -63,6 +66,8 @@ window.__ModuleLoader__.load({
63
66
  ".dru-btn-ghost:hover:not(:disabled){background:#f6f8fa}",
64
67
  ".dru-btn-danger{background:#ffffff;color:#cf222e;border-color:#cf222e}",
65
68
  ".dru-btn-danger:hover:not(:disabled){background:#fff0f1}",
69
+ ".dru-linkbtn{display:inline-block;padding:0;border:none;background:none;color:#0969da;font-size:12.5px;line-height:1.6;cursor:pointer;font-family:inherit;text-decoration:none}",
70
+ ".dru-linkbtn:hover{text-decoration:underline}",
66
71
  ".dru-tabs{display:flex;gap:8px;margin-bottom:12px}",
67
72
  ".dru-tab{flex:1;padding:7px 0;text-align:center;border-radius:8px;cursor:pointer;font-size:13px;font-weight:600;color:#57606a;background:#f6f8fa;border:1px solid #d0d7de;user-select:none}",
68
73
  ".dru-tab.active{color:#0969da;background:#ffffff;border-color:#0969da}",
@@ -151,306 +156,9 @@ window.__ModuleLoader__.load({
151
156
  // Phase-5:端到端加密(E2EE)状态行 —— 启用=绿字绿点,未启用=灰字(纯文字状态行,不打扰)
152
157
  ".dru-e2ee-line{display:flex;align-items:center;gap:7px;margin-top:6px;font-size:12px;line-height:1.5;color:#57606a}",
153
158
  ".dru-e2ee-line.ok{color:#1a7f37}",
154
- // 左下角「远程访问」快捷小手机图标(安装后常驻;首次点击前带红点)
155
- ".dru-fab{position:fixed;left:18px;bottom:18px;z-index:2147482100;width:46px;height:46px;border-radius:50%;background:#0969da;color:#fff;border:none;display:flex;align-items:center;justify-content:center;font-size:22px;line-height:1;box-shadow:0 6px 18px rgba(0,0,0,.28);cursor:pointer;font-family:inherit;padding:0}",
156
- ".dru-fab:hover{background:#0860bd}",
157
- ".dru-fab-dot{position:absolute;top:2px;right:2px;width:9px;height:9px;border-radius:50%;background:#e5484d;box-shadow:0 0 0 2px #fff;pointer-events:none}",
158
159
  ].join("\n");
159
160
  document.head.appendChild(styleEl);
160
161
 
161
- // ── 侧边栏「远程访问」快捷入口(独立按钮,挂在官方「设置」上方;Bug A 修复) ──
162
- // 用户实测 Bug A:入口视觉在左侧(官方「设置」上方),点击却落在工作区会话行——
163
- // 会话标题含「远程访问」(如“分析竞品远程访问功能”)被旧逻辑当成导航项点中。
164
- // 根因:旧实现用 document 级 querySelectorAll + textContent「包含匹配」扫全文档,
165
- // 会话标题也命中;且注入项 clone 官方按钮可能继承官方事件委托/点击热区。
166
- // 修复(本段全部导航点击逻辑):
167
- // 1) 候选扫描一律限定在「侧栏/主导航容器」内([role=navigation]/<nav>/class 含
168
- // sidebar|navRail|navPanel|navigation|sidenav|appnav 等;兜底:含「设置」导航项或
169
- // navCell 的元素向上找最近容器)。绝不 document 全量扫可点元素去点击。
170
- // 2) 命中收紧:aria-label/title 等于或 startsWith(token);textContent 仅当短文本
171
- // (trim ≤ NAV_TEXT_MAX=10 且不含换行——长会话标题天然被拒)且 等于/以 token 开头/
172
- // 含 token 但短;同时排除 class 带 menu/context/more/kebab 等“菜单/更多”语义的节点。
173
- // 3) 注入项改为自有独立 <button id=dru-nav-remote>(不再复制官方按钮元素,避免继承
174
- // 官方委托与热区),自带 click → openRemoteSettings()。
175
- // 4) openRemoteSettings:栏目已在 DOM 直接返回 → 各容器内点「远程访问」栏目项 →
176
- // 容器内点「设置」→ 轮询 ≤3s(20×150ms)容器内补点「远程访问」;绝不点工作区内容。
177
- var NAV_SEEN_KEY = "dsh-remote-nav-seen";
178
- var NAV_ENTRY_ID = "dru-nav-remote";
179
- var NAV_MARK = "data-dru-remote";
180
- /** 导航候选(仅在导航容器内查询):navCell(shell hashed 类)/tab/menuitem/button/a/aria-label。 */
181
- var NAV_CAND_SEL = '[class*="navCell"], [role="tab"], [role="menuitem"], [role="button"], button, a, [aria-label]';
182
- /** 侧栏/主导航容器定位选择器(先定位容器,再在容器内做候选查询)。 */
183
- var NAV_CONTAINER_SEL = '[role="navigation"], nav, [class*="sidebar"], [class*="navRail"], [class*="navPanel"], [class*="sidenav"], [class*="appnav"], [class*="navigation"]';
184
- /** 候选节点 class 命中这些语义(菜单/更多/下拉…)→ 视为会话行“⋯”等,绝不点击。 */
185
- var NAV_BAD_CLS_RE = /(^|[^a-z0-9])(menu|context|more|kebab|dropdown|popover|toolbar)($|[^a-z0-9])/i;
186
- /** textContent 短文本上限:超过即视为长文本(会话标题等),textContent 命中一律拒绝。 */
187
- var NAV_TEXT_MAX = 10;
188
- /** openRemoteSettings 轮询上限:150ms × 20 = 3s(≤3s),到时放弃,不再无限轮询。 */
189
- var NAV_POLL_MAX = 20;
190
- /** 向上找“最近容器”的最大步数(防误上到整页)。 */
191
- var NAV_UP_MAX = 8;
192
-
193
- /** 候选是否“可点导航项”:按钮/链接/tab/menuitem/navCell(排除无交互的装饰容器)。 */
194
- function isNavClickable(el) {
195
- try {
196
- var tag = String(el.tagName || "").toLowerCase();
197
- if (tag === "button" || tag === "a") return true;
198
- var role = el.getAttribute && el.getAttribute("role") || "";
199
- if (role === "tab" || role === "menuitem" || role === "button" || role === "link") return true;
200
- return String(el.className || "").indexOf("navCell") !== -1;
201
- } catch (e) { return false; }
202
- }
203
- /** 节点 class 是否带“菜单/更多”语义(会话行的 ⋯/右键菜单等)→ 不可作为导航候选。 */
204
- function isBadNavClass(el) {
205
- try { return NAV_BAD_CLS_RE.test(String(el.className || "")); } catch (e) { return false; }
206
- }
207
- /** 是否语义化导航容器:role=navigation/menu 或 class 含 sidebar/navRail/navPanel 等。 */
208
- function isNavContainerEl(el) {
209
- try {
210
- var role = el.getAttribute && el.getAttribute("role") || "";
211
- if (role === "navigation" || role === "menu") return true;
212
- return NAV_CONTAINER_RE.test(String(el.className || ""));
213
- } catch (e) { return false; }
214
- }
215
- var NAV_CONTAINER_RE = /sidebar|navRail|navPanel|sidenav|appnav|navigation/i;
216
- /** 候选可用性:可点 + 非“菜单/更多”语义 class。 */
217
- function isUsableNavCandidate(el) {
218
- try {
219
- if (!isNavClickable(el)) return false;
220
- if (isBadNavClass(el)) return false;
221
- return true;
222
- } catch (e) { return false; }
223
- }
224
- /**
225
- * 收紧后的命中规则(aria/title 与可见短文本任一命中即可,语义更贴近官方按钮:
226
- * 官方按钮可能带 aria-label/title,也可能只有可见文字):
227
- * - aria-label/title:等于 token 或以 token 开头(长度不限,纯图标导航常见);
228
- * - textContent:仅短文本(trim ≤10 且不含换行——长会话标题被拒)且
229
- * 等于 / 以 token 开头 / 含 token 但文本短。
230
- */
231
- function navTextHits(el, token) {
232
- try {
233
- var al = el.getAttribute && el.getAttribute("aria-label");
234
- var attr = al && String(al).trim() ? String(al).trim() : "";
235
- if (!attr) { var tt = el.getAttribute && el.getAttribute("title"); if (tt && String(tt).trim()) attr = String(tt).trim(); }
236
- if (attr && (attr === token || attr.indexOf(token) === 0)) return true;
237
- } catch (e) {}
238
- var t = "";
239
- try { t = (el.textContent || "").replace(/\s+/g, " ").trim(); } catch (e2) {}
240
- if (!t || /\r|\n/.test(t) || t.length > NAV_TEXT_MAX) return false; // 长文本/多行 → 拒绝
241
- return t === token || t.indexOf(token) === 0 || t.indexOf(token) !== -1;
242
- }
243
- /**
244
- * 在候选列表里找命中 token 的导航项(命中「远程访问」时打 data-dru-remote 标记,
245
- * 二次命中直接走标记,无需重扫)。始终排除注入项自身(id=dru-nav-remote)防递归。
246
- */
247
- function pickNavHit(nodes, token, excludeId, preferMarked) {
248
- try {
249
- for (var i = 0; i < nodes.length; i++) {
250
- var el = nodes[i];
251
- if (excludeId && el.id === excludeId) continue; // 排除注入项自身,防递归
252
- if (!isUsableNavCandidate(el)) continue;
253
- var marked = !!el.getAttribute && el.getAttribute(NAV_MARK) === "1";
254
- if (preferMarked && !marked) continue;
255
- if (!preferMarked && marked) continue;
256
- if (!navTextHits(el, token)) continue;
257
- if (token === "远程访问") { try { el.setAttribute(NAV_MARK, "1"); } catch (e) {} }
258
- return el;
259
- }
260
- } catch (e) { /* 忽略 */ }
261
- return null;
262
- }
263
- /** 统计容器内可用导航候选数量(用于向上定位“最近容器”)。 */
264
- function navLikeCount(container) {
265
- try {
266
- var nodes = container.querySelectorAll(NAV_CAND_SEL) || [];
267
- var n = 0;
268
- for (var i = 0; i < nodes.length; i++) { if (isUsableNavCandidate(nodes[i])) n++; }
269
- return n;
270
- } catch (e) { return 0; }
271
- }
272
- /** 从某导航叶子向上找“最近容器”(语义容器或 ≥2 个可用候选的层)。 */
273
- function navContainerAbove(leaf) {
274
- var p = leaf && leaf.parentNode ? leaf.parentNode : null;
275
- var steps = 0;
276
- while (p && p !== document.documentElement && steps < NAV_UP_MAX) {
277
- if (isNavContainerEl(p)) return p;
278
- if (navLikeCount(p) >= 2) return p;
279
- p = p.parentNode;
280
- steps++;
281
- }
282
- return null;
283
- }
284
- /** 全文档找一个命中 token 的短导航叶子(仅用于“定位容器”,不点击)。 */
285
- function navLeafByToken(token) {
286
- try {
287
- var all = document.querySelectorAll(NAV_CAND_SEL) || [];
288
- for (var i = 0; i < all.length; i++) {
289
- var el = all[i];
290
- if (el.id === NAV_ENTRY_ID) continue;
291
- if (!isUsableNavCandidate(el)) continue;
292
- if (!navTextHits(el, token)) continue;
293
- return el;
294
- }
295
- } catch (e) {}
296
- return null;
297
- }
298
- /** 全文档找一个 navCell 叶子(shell 设置页导航按钮;用于兜底定位容器)。 */
299
- function navCellLeaf() {
300
- try {
301
- var all = document.querySelectorAll('[class*="navCell"]') || [];
302
- for (var i = 0; i < all.length; i++) {
303
- var el = all[i];
304
- if (el.id === NAV_ENTRY_ID) continue;
305
- if (!isUsableNavCandidate(el)) continue;
306
- return el;
307
- }
308
- } catch (e) {}
309
- return null;
310
- }
311
- /**
312
- * 定位侧栏/主导航容器(此后所有候选命中都在这些容器内进行,绝不扫 main/内容区/会话列表):
313
- * ① 语义容器:[role=navigation]/<nav>/class 含 sidebar|navRail|navPanel|sidenav|appnav|navigation;
314
- * ② 兜底:从全文档命中「设置」的短导航叶子向上找最近容器;
315
- * ③ 再兜底:从任意 navCell 叶子向上找最近容器(设置页栏目导航)。
316
- * 返回去嵌套后的容器数组(保持调用次序,document 不在容器内)。
317
- */
318
- function navContainers() {
319
- var out = [];
320
- var push = function (el) {
321
- if (!el || typeof el.querySelectorAll !== "function") return;
322
- if (out.indexOf(el) !== -1) return;
323
- var nested = false;
324
- for (var j = 0; j < out.length; j++) {
325
- try { if (out[j].contains && out[j].contains(el)) { nested = true; break; } } catch (e) {}
326
- }
327
- if (!nested) out.push(el);
328
- };
329
- try {
330
- var sem = document.querySelectorAll(NAV_CONTAINER_SEL) || [];
331
- for (var i = 0; i < sem.length; i++) push(sem[i]);
332
- } catch (e) {}
333
- var seed = navLeafByToken("设置");
334
- if (seed) push(navContainerAbove(seed));
335
- var cell = navCellLeaf();
336
- if (cell) push(navContainerAbove(cell));
337
- return out;
338
- }
339
- /**
340
- * 在全部侧栏/导航容器内点击“命中 token”的导航项(②:导航文字可能不同名/纯图标,
341
- * 交给命中器)。只查容器内候选,绝不 document 全量点击。全部失败返回 false。
342
- */
343
- function clickNavToken(token, excludeId) {
344
- var containers = navContainers();
345
- for (var c = 0; c < containers.length; c++) {
346
- var nodes = null;
347
- try { nodes = containers[c].querySelectorAll(NAV_CAND_SEL); } catch (e) { nodes = null; }
348
- if (!nodes) continue;
349
- if (token === "远程访问") {
350
- var hitMarked = pickNavHit(nodes, token, excludeId, true);
351
- if (hitMarked) { try { hitMarked.click(); } catch (e2) {} return true; }
352
- }
353
- var hit = pickNavHit(nodes, token, excludeId, false);
354
- if (hit) { try { hit.click(); } catch (e3) {} return true; }
355
- }
356
- return false;
357
- }
358
- /** 「远程访问」栏目内容区是否已在 DOM(设置页已选中该栏目)。 */
359
- function remoteSectionVisible() {
360
- try {
361
- var el = document.querySelector(".dru-settings-section");
362
- if (!el) return false;
363
- if (document.body && !document.body.contains(el)) return false;
364
- if (el.getAttribute && (el.getAttribute("aria-hidden") === "true" || el.getAttribute("hidden") !== null)) return false;
365
- return true;
366
- } catch (e) { return false; }
367
- }
368
- /**
369
- * 打开设置页「远程访问」栏目(侧栏注入项/面板内“去设置”等共用入口):
370
- * 栏目已在 DOM → 直接返回;否则容器内点「远程访问」栏目项 → 容器内点「设置」进设置页,
371
- * 再轮询(≤3s:20×150ms,NAV_POLL_MAX)容器内补点「远程访问」。全程不点工作区内容。
372
- */
373
- function openRemoteSettings() {
374
- if (remoteSectionVisible()) return;
375
- if (clickNavToken("远程访问", NAV_ENTRY_ID)) return;
376
- clickNavToken("设置", NAV_ENTRY_ID); // 进设置页(导航文字可能不同名/纯图标,交给命中器)
377
- var tries = 0;
378
- var iv = setInterval(function () {
379
- if (remoteSectionVisible() || clickNavToken("远程访问", NAV_ENTRY_ID)) { clearInterval(iv); return; }
380
- if (++tries >= NAV_POLL_MAX) clearInterval(iv); // ≤3s 轮询上限
381
- }, 150);
382
- if (typeof iv.unref === "function") iv.unref();
383
- }
384
- // 暴露给宿主/自动化(同一入口,避免重复实现;测试沙箱经此驱动点击流)
385
- try { window.__dshRemoteNav = { open: openRemoteSettings }; } catch (e) {}
386
-
387
- function injectSidebarRemoteEntry() {
388
- try {
389
- if (document.getElementById(NAV_ENTRY_ID)) return;
390
- if (!document.body) { setTimeout(injectSidebarRemoteEntry, 300); return; }
391
- var tries = 0;
392
- var iv = setInterval(function () {
393
- try {
394
- if (document.getElementById(NAV_ENTRY_ID)) { clearInterval(iv); return; }
395
- // 只在导航容器内找「设置」按钮(可点 + 短文本/aria 命中),绝不 document 全量找。
396
- var containers = navContainers();
397
- var settingsBtn = null;
398
- for (var c = 0; c < containers.length; c++) {
399
- var nodes = null;
400
- try { nodes = containers[c].querySelectorAll(NAV_CAND_SEL); } catch (e) { nodes = null; }
401
- if (!nodes) continue;
402
- var hit = pickNavHit(nodes, "设置", NAV_ENTRY_ID, false);
403
- if (hit) { settingsBtn = hit; break; }
404
- }
405
- if (!settingsBtn || !settingsBtn.parentNode) {
406
- if (++tries > 80) clearInterval(iv);
407
- return;
408
- }
409
- // 独立按钮:不复用官方按钮元素(避免继承官方事件委托/点击热区 → Bug A 误点会话行)。
410
- var entry = document.createElement("button");
411
- entry.id = NAV_ENTRY_ID;
412
- entry.setAttribute("type", "button");
413
- entry.setAttribute("role", "button");
414
- entry.setAttribute("tabindex", "0");
415
- entry.setAttribute("aria-label", "远程访问");
416
- entry.textContent = "📱 远程访问";
417
- entry.style.cssText = "display:flex;align-items:center;gap:6px;box-sizing:border-box;width:100%;" +
418
- "padding:8px 10px;margin:2px 0;border:1px solid transparent;background:transparent;" +
419
- "border-radius:8px;color:inherit;font-family:inherit;font-size:13px;line-height:1.4;" +
420
- "cursor:pointer;text-align:left;position:relative";
421
- var seen = false;
422
- try { seen = !!localStorage.getItem(NAV_SEEN_KEY); } catch (e) {}
423
- if (!seen) {
424
- var dot = document.createElement("span");
425
- dot.setAttribute("aria-hidden", "true");
426
- dot.style.cssText = "position:absolute;top:4px;right:10px;width:8px;height:8px;border-radius:50%;background:#e5484d;pointer-events:none";
427
- entry.appendChild(dot);
428
- }
429
- entry.addEventListener("click", function () {
430
- try { localStorage.setItem(NAV_SEEN_KEY, "1"); } catch (e) {}
431
- var dots = entry.children || [];
432
- for (var i = 0; i < dots.length; i++) {
433
- var d = dots[i];
434
- if (d.getAttribute && d.getAttribute("aria-hidden") === "true" && /background:#e5484d/.test(d.style && d.style.cssText || "")) {
435
- try { entry.removeChild(d); } catch (e2) {}
436
- break;
437
- }
438
- }
439
- openRemoteSettings();
440
- });
441
- settingsBtn.parentNode.insertBefore(entry, settingsBtn); // 官方「设置」在入口下方
442
- clearInterval(iv);
443
- } catch (e) { /* 忽略单次失败,继续重试 */ }
444
- }, 300);
445
- if (typeof iv.unref === "function") iv.unref();
446
- } catch (e) { /* 非关键 */ }
447
- }
448
- if (document.readyState === "loading") {
449
- document.addEventListener("DOMContentLoaded", injectSidebarRemoteEntry);
450
- } else {
451
- setTimeout(injectSidebarRemoteEntry, 600);
452
- }
453
-
454
162
  // ── 首次安装引导小红点(设置页「远程访问」栏目,localStorage 控制) ────
455
163
  // 无 dsh-remote-seen-dot key 视为首次:在设置页导航栏目右上角显示 CSS 圆点;
456
164
  // 点击栏目/红点后写入 key(之后不再显示),重启 DSH Web 不复发。
@@ -1313,8 +1021,9 @@ window.__ModuleLoader__.load({
1313
1021
 
1314
1022
  useEffect(function () { refresh(); }, [refresh]);
1315
1023
  useEffect(function () {
1316
- if (st !== null && !loggedIn && mode === "saas" && authTab === "login" && !lcap) loadCaptcha("login");
1317
- }, [st, loggedIn, mode, authTab, lcap]);
1024
+ // 展开「忘记密码」重置表单时不用预载登录表单验证码(收起的 reset 表单用独立 pwdCap)
1025
+ if (st !== null && !loggedIn && mode === "saas" && authTab === "login" && !pwdOpen && !lcap) loadCaptcha("login");
1026
+ }, [st, loggedIn, mode, authTab, pwdOpen, lcap]);
1318
1027
 
1319
1028
  // ── 📱 远程访问卡:常量 / 轮询 / 一次性访问密钥 / 已授权设备 ─────────────────
1320
1029
  var KEY_AUTO_REFRESH_MS = 25000; // 停留栏目时约每 25s 自动轮换一把新的一次性密钥(防已用/过期)
@@ -1538,6 +1247,7 @@ window.__ModuleLoader__.load({
1538
1247
  return post("/dsh-remote/config", { phone: phone.trim(), password: pass }).then(function (cfg) {
1539
1248
  // 切换连接账号(手机号与之前不同):清除旧账号留下的反馈线程凭据
1540
1249
  if (prevPhone !== phone.trim()) fbClearThreads();
1250
+ setPwdOpen(false); // 登录成功(可能从「忘记密码」返回)→ 收起重置表单
1541
1251
  setSt(cfg); setPass(""); setLcapTxt(""); setLcap(null);
1542
1252
  setMsg("ok", "✅ 登录成功,账号已保存");
1543
1253
  });
@@ -1588,6 +1298,7 @@ window.__ModuleLoader__.load({
1588
1298
  .then(function (body) {
1589
1299
  if (body.ok || body.status === 201 || (body.body && body.body.token)) {
1590
1300
  return post("/dsh-remote/config", { phone: rphone.trim(), password: rpass }).then(function (cfg) {
1301
+ setPwdOpen(false);
1591
1302
  setSt(cfg);
1592
1303
  setRphone(""); setRpass(""); setRpass2(""); setRsms(""); setRcap(null); setRcapTxt(""); setRInvite("");
1593
1304
  setMsg("ok", "✅ 注册成功,已自动登录");
@@ -1609,17 +1320,23 @@ window.__ModuleLoader__.load({
1609
1320
  // 退出登录:清除本机保存的用户反馈线程凭据(thread_token 见 FB_THREADS_KEY),
1610
1321
  // 反馈历史保留在服务端(按账号校验),账号身份变化后本机不再可见旧线程。
1611
1322
  fbClearThreads();
1323
+ setPwdOpen(false); // 退出登录后回到登录卡,收起重置/修改密码表单
1612
1324
  if (clearForm) { setPhone(""); setPass(""); setLcapTxt(""); setLcap(null); setRphone(""); setRpass(""); setRpass2(""); setRsms(""); setRsmsBtn("获取验证码"); setRcapTxt(""); setRcap(null); }
1613
1325
  setMessage(null);
1614
1326
  }).catch(function (e) { setMsg("err", "退出失败: " + e.message); })
1615
1327
  .finally(function () { setBusy(""); });
1616
1328
  };
1617
1329
 
1618
- // ---------- 🔒 修改密码(已登录 SaaS,短信重置;企业端公开 POST /api/password/reset) ----------
1330
+ // ---------- 🔒 修改密码 / 忘记密码(共用:短信验证码重置;企业端公开 POST /api/password/reset) ----------
1619
1331
  // 图形验证码 + 短信验证码复用既有 /dsh-remote/captcha、/dsh-remote/sms-code 防刷路径;
1620
- // 新密码 ≥8 位。成功后企业端使全部授权设备/会话失效(含 E2EE 派生口令),
1621
- // 且桌面端配置 config.phone/password 里的旧口令已不可用 → 本地登出(清 threads/会话),
1332
+ // 新密码 ≥8 位。成功后企业端使全部授权设备/会话失效(含 E2EE 派生口令)→ 本地登出,
1622
1333
  // 提示用新密码重新登录(登录成功会把新密码写回本地 config,覆盖旧口令并重建 E2EE 派生)。
1334
+ // 两处入口共用同一组字段/函数(pwdOpen/pwdCap/pwdCapTxt/pwdSms/pwdSmsBtn/pwdNew 与
1335
+ // togglePwdForm/sendPwdSms/doResetPwd/renderResetPwdForm,避免重复实现):
1336
+ // - 账号卡「🔒 修改密码」:fromLogin=false,手机号以当前登录账号为准(只读);
1337
+ // - 登录卡「忘记密码?」:fromLogin=true,手机号预填登录输入、可改;成功后请用新密码登录。
1338
+ var PWD_RESET_WARN = "修改后所有已授权设备/会话将失效,需重新登录与解锁";
1339
+
1623
1340
  function pwdLoadCaptcha() {
1624
1341
  setPwdCap({ id: null, svg: '<span class="dru-hint">加载中</span>' });
1625
1342
  fetch("/dsh-remote/captcha").then(function (r) {
@@ -1632,10 +1349,18 @@ window.__ModuleLoader__.load({
1632
1349
  });
1633
1350
  }).catch(function () { setPwdCap({ id: null, svg: null }); });
1634
1351
  }
1635
- /** 发送修改密码用的短信验证码(图形验证码防刷;captcha_invalid → 重载验证码提示重试)。 */
1636
- var sendPwdSms = function () {
1637
- var ph = (st && st.config && st.config.phone) || "";
1638
- if (!/^1\d{10}$/.test(ph)) { setMsg("err", "请先登录手机号账号再修改密码(以当前账号为准)"); return; }
1352
+ /** 展开/收起重置密码表单(账号卡「修改密码」与登录卡「忘记密码」共用开关)。 */
1353
+ var togglePwdForm = function () {
1354
+ var next = !pwdOpen;
1355
+ setPwdOpen(next);
1356
+ setMessage(null);
1357
+ if (next && !pwdCap) pwdLoadCaptcha();
1358
+ };
1359
+ /** 发送短信验证码(图形验证码防刷;captcha_invalid → 重载验证码提示重试)。ph 由调用方给出:
1360
+ * 账号卡=当前登录手机号;登录卡「忘记密码」=表单输入手机号。 */
1361
+ var sendPwdSms = function (ph) {
1362
+ ph = String(ph || "").trim();
1363
+ if (!/^1\d{10}$/.test(ph)) { setMsg("err", "请输入正确的手机号"); return; }
1639
1364
  if (!pwdCap || !pwdCap.id || !pwdCapTxt.trim()) { setMsg("err", "请输入图中验证码(点击图片可刷新)"); return; }
1640
1365
  setBusy("pwd-sms");
1641
1366
  post("/dsh-remote/sms-code", { phone: ph, captcha_id: pwdCap.id, captcha_answer: pwdCapTxt.trim() })
@@ -1656,10 +1381,14 @@ window.__ModuleLoader__.load({
1656
1381
  })
1657
1382
  .finally(function () { setBusy(""); });
1658
1383
  };
1659
- /** 确认修改密码:POST /dsh-remote/password/reset → 成功后本地登出,提示用新密码登录。 */
1660
- var doChangePwd = function () {
1661
- var ph = (st && st.config && st.config.phone) || "";
1662
- if (!/^1\d{10}$/.test(ph)) { setMsg("err", "请先登录手机号账号(修改密码以当前账号为准)"); return; }
1384
+ /**
1385
+ * 确认重置密码:POST /dsh-remote/password/reset 成功后本地登出,提示用新密码登录。
1386
+ * @param {string} ph - 手机号(账号卡=当前登录账号;登录卡「忘记密码」=表单输入)。
1387
+ * @param {boolean} fromLogin - true=登录卡「忘记密码」;false=账号卡「修改密码」。
1388
+ */
1389
+ var doResetPwd = function (ph, fromLogin) {
1390
+ ph = String(ph || "").trim();
1391
+ if (!/^1\d{10}$/.test(ph)) { setMsg("err", fromLogin ? "请输入正确的手机号" : "请先登录手机号账号(修改密码以当前账号为准)"); return; }
1663
1392
  if (!pwdSms.trim()) { setMsg("err", "请填写短信验证码"); return; }
1664
1393
  if (pwdNew.length < 8) { setMsg("err", "新密码至少 8 位"); return; }
1665
1394
  setBusy("pwd-reset");
@@ -1670,7 +1399,7 @@ window.__ModuleLoader__.load({
1670
1399
  var relayBody = (body && body.body) || body || {};
1671
1400
  var errText = (relayBody.error && (relayBody.error.message || relayBody.error))
1672
1401
  || relayBody.detail || ("修改失败(" + ((body && body.status) || "?") + ")");
1673
- setMsg("err", "修改失败:" + String(errText));
1402
+ setMsg("err", (fromLogin ? "重置失败:" : "修改失败:") + String(errText));
1674
1403
  setPwdSms(""); // 短信验证码一次性:失败后需重新获取
1675
1404
  return undefined;
1676
1405
  }
@@ -1682,13 +1411,19 @@ window.__ModuleLoader__.load({
1682
1411
  setAkey(null); setAkeyMsg(null);
1683
1412
  fbClearThreads();
1684
1413
  setPwdOpen(false); setPwdSms(""); setPwdNew(""); setPwdCap(null); setPwdCapTxt(""); setPwdSmsBtn("获取验证码");
1685
- setMsg("ok", "✅ 密码已修改成功:所有已授权设备与会话已失效,本机已退出登录。" +
1686
- "请在下方账号卡用「新密码」重新登录——登录会更新本机保存的密码并重新启用端到端加密(E2EE)。");
1414
+ if (fromLogin) {
1415
+ // 「忘记密码」成功:本机登出完成 → 回到登录表单提示用新密码登录(手机号保留,便于直接重登)
1416
+ setPass(""); setLcapTxt(""); setLcap(null); loadCaptcha("login");
1417
+ setMsg("ok", "✅ 密码已重置成功:" + PWD_RESET_WARN + ",本机已退出登录。请用新密码登录。");
1418
+ } else {
1419
+ setMsg("ok", "✅ 密码已修改成功:所有已授权设备与会话已失效,本机已退出登录。" +
1420
+ "请在下方账号卡用「新密码」重新登录——登录会更新本机保存的密码并重新启用端到端加密(E2EE)。");
1421
+ }
1687
1422
  }).catch(function (e) {
1688
- setMsg("err", "密码已修改成功,但本机退出登录失败:" + e.message + "(建议手动「退出登录」后用新密码重新登录,以更新本机配置密码)");
1423
+ setMsg("err", "密码已" + (fromLogin ? "重置" : "修改") + "成功,但本机退出登录失败:" + e.message + "(建议手动「退出登录」后用新密码重新登录,以更新本机配置密码)");
1689
1424
  });
1690
1425
  })
1691
- .catch(function (e) { setMsg("err", "修改失败: " + e.message); })
1426
+ .catch(function (e) { setMsg("err", (fromLogin ? "重置失败: " : "修改失败: ") + e.message); })
1692
1427
  .finally(function () { setBusy(""); });
1693
1428
  };
1694
1429
 
@@ -1710,6 +1445,7 @@ window.__ModuleLoader__.load({
1710
1445
  .then(function (body) {
1711
1446
  // 切换到自建服务(连接账号上下文变为本地无账号):清除 SaaS 账号的反馈线程凭据
1712
1447
  fbClearThreads();
1448
+ setPwdOpen(false);
1713
1449
  setSt(body); setSelfHost(""); setLocalKey("");
1714
1450
  setMsg(body.ok ? "ok" : "err", body.ok ? "✅ 已切换到自建服务,bridge 已重启" : (body.error || (body.body && body.body.error) || "保存失败"));
1715
1451
  })
@@ -1771,29 +1507,30 @@ window.__ModuleLoader__.load({
1771
1507
  busy === "upgrade" ? "生成链接中…" : (!isMember ? "🚀 升级 PRO" : source === "trial" ? "🚀 转正式 PRO" : "🔄 续费会员")),
1772
1508
  h("button", { type: "button", className: "dru-btn dru-btn-ghost", disabled: busy !== "", onClick: function () { setView("invite"); loadInvite(); } }, "🎯 邀请好友赚会员"),
1773
1509
  h("button", { type: "button", className: "dru-btn dru-btn-ghost", disabled: busy !== "", onClick: function () { setView("feedback"); } }, "💬 用户反馈"),
1774
- h("button", { type: "button", className: "dru-btn dru-btn-ghost", disabled: busy !== "", onClick: function () {
1775
- var next = !pwdOpen;
1776
- setPwdOpen(next);
1777
- setMessage(null);
1778
- if (next && !pwdCap) pwdLoadCaptcha();
1779
- } }, pwdOpen ? "收起修改密码" : "🔒 修改密码"),
1510
+ h("button", { type: "button", className: "dru-btn dru-btn-ghost", disabled: busy !== "", onClick: togglePwdForm }, pwdOpen ? "收起修改密码" : "🔒 修改密码"),
1780
1511
  h("button", { type: "button", className: "dru-btn dru-btn-danger", disabled: busy !== "", onClick: function () { doLogout(false); } }, "退出登录")
1781
1512
  ),
1782
1513
  h("div", { className: "dru-hint", style: { marginTop: 8 } },
1783
1514
  "升级/续费以带登录态方式打开:点击后生成一次性访问链接并直接跳转,无需重新登录。" +
1784
1515
  (endsAt && isMember ? "到期后如需继续使用会员权益,请在到期前续费。" : "")),
1785
- pwdOpen ? renderChangePwdForm() : null
1516
+ pwdOpen ? renderResetPwdForm(false) : null
1786
1517
  );
1787
1518
  }
1788
1519
 
1789
- // ---------- 🔒 修改密码小表单(登录态账号卡内展开;短信验证码重置) ----------
1790
- function renderChangePwdForm() {
1791
- var ph = (st && st.config && st.config.phone) || "";
1520
+ // ---------- 🔒 修改密码 / 忘记密码 共用重置表单(图形验证码 + 短信验证码 + 新密码≥8) ----------
1521
+ // fromLogin=false:账号卡「修改密码」手机号以当前登录账号为准(只读);
1522
+ // fromLogin=true:登录卡「忘记密码」手机号预填登录输入、可改(成功后返回登录表单请用新密码登录)。
1523
+ function renderResetPwdForm(fromLogin) {
1524
+ var ph = fromLogin ? phone : ((st && st.config && st.config.phone) || "");
1792
1525
  return h("div", { className: "dru-card", style: { marginTop: 10, border: "1px dashed #d0d7de" } },
1793
- h("h3", null, "🔒 修改密码(短信验证)"),
1526
+ h("h3", null, fromLogin ? "忘记密码(短信重置)" : "🔒 修改密码(短信验证)"),
1794
1527
  h("div", { className: "dru-hint", style: { marginBottom: 8 } },
1795
- "修改后所有已授权设备与会话将失效,需重新登录与解锁(E2EE 用新密码重新派生)。"),
1796
- field("手机号", h("input", { className: "dru-input", type: "tel", value: ph, disabled: true, readOnly: true, title: "以当前登录账号为准" })),
1528
+ fromLogin
1529
+ ? "手机号预填当前输入,可改为其它账号;" + PWD_RESET_WARN + ""
1530
+ : PWD_RESET_WARN + "(E2EE 用新密码重新派生)。"),
1531
+ field("手机号", fromLogin
1532
+ ? input({ type: "tel", value: ph, placeholder: "11 位手机号", autoComplete: "tel", onChange: function (e) { setPhone(e.target.value); } })
1533
+ : h("input", { className: "dru-input", type: "tel", value: ph, disabled: true, readOnly: true, title: "以当前登录账号为准" })),
1797
1534
  field("图形验证码", h("div", { className: "dru-captcha" },
1798
1535
  input({ value: pwdCapTxt, placeholder: "图中数字", autoComplete: "off", inputMode: "numeric", maxLength: 6, onChange: function (e) { setPwdCapTxt(e.target.value); } }),
1799
1536
  h("div", { className: "dru-captcha-box", title: "看不清?点击刷新", onClick: function () { pwdLoadCaptcha(); }, dangerouslySetInnerHTML: pwdCap && pwdCap.svg && pwdCap.svg.indexOf("<svg") === 0 ? { __html: pwdCap.svg } : void 0 },
@@ -1801,13 +1538,19 @@ window.__ModuleLoader__.load({
1801
1538
  )),
1802
1539
  field("短信验证码", h("div", { className: "dru-captcha" },
1803
1540
  input({ value: pwdSms, placeholder: "6 位验证码", autoComplete: "off", inputMode: "numeric", maxLength: 6, onChange: function (e) { setPwdSms(e.target.value); } }),
1804
- h("button", { type: "button", className: "dru-btn dru-btn-ghost", style: { flex: "none", padding: "0 14px" }, disabled: busy !== "", onClick: sendPwdSms }, pwdSmsBtn)
1541
+ h("button", { type: "button", className: "dru-btn dru-btn-ghost", style: { flex: "none", padding: "0 14px" }, disabled: busy !== "", onClick: function () { sendPwdSms(ph); } }, pwdSmsBtn)
1805
1542
  )),
1806
1543
  field("新密码(至少 8 位)", input({ type: "password", value: pwdNew, minLength: 8, autoComplete: "new-password", placeholder: "至少 8 位", onChange: function (e) { setPwdNew(e.target.value); } })),
1807
1544
  h("div", { className: "dru-hint", style: { marginBottom: 8 } },
1808
- "修改成功后,本机保存的旧密码会被清除并退出登录;请用新密码重新登录(登录会更新本机配置密码并重新启用远程访问与端到端加密)。"),
1809
- h("button", { type: "button", className: "dru-btn dru-btn-danger", style: { width: "100%" }, disabled: busy !== "", onClick: doChangePwd },
1810
- busy === "pwd-reset" ? "提交中…" : busy === "pwd-logout" ? "已修改,正在退出本地登录…" : "确认修改密码")
1545
+ fromLogin
1546
+ ? "重置成功后本机会退出登录(如有已保存的旧账号),请用新密码登录。"
1547
+ : "修改成功后,本机保存的旧密码会被清除并退出登录;请用新密码重新登录(登录会更新本机配置密码并重新启用远程访问与端到端加密)。"),
1548
+ h("button", { type: "button", className: "dru-btn dru-btn-danger", style: { width: "100%" }, disabled: busy !== "", onClick: function () { doResetPwd(ph, fromLogin); } },
1549
+ busy === "pwd-reset" ? "提交中…" : busy === "pwd-logout" ? "已重置,正在退出本地登录…" : (fromLogin ? "确认重置密码" : "确认修改密码")),
1550
+ fromLogin
1551
+ ? h("div", { style: { textAlign: "right", marginTop: 6 } },
1552
+ h("button", { type: "button", className: "dru-linkbtn", onClick: function () { togglePwdForm(); } }, "← 返回登录"))
1553
+ : null
1811
1554
  );
1812
1555
  }
1813
1556
 
@@ -2039,19 +1782,26 @@ window.__ModuleLoader__.load({
2039
1782
  ? renderAccount()
2040
1783
  : h("div", null,
2041
1784
  h("div", { className: "dru-tabs" },
2042
- h("div", { className: "dru-tab" + (authTab === "login" ? " active" : ""), onClick: function () { setAuthTab("login"); setMessage(null); } }, "登录"),
2043
- h("div", { className: "dru-tab" + (authTab === "register" ? " active" : ""), onClick: function () { setAuthTab("register"); setMessage(null); } }, "注册")
1785
+ h("div", { className: "dru-tab" + (authTab === "login" ? " active" : ""), onClick: function () { setAuthTab("login"); setPwdOpen(false); setMessage(null); } }, "登录"),
1786
+ h("div", { className: "dru-tab" + (authTab === "register" ? " active" : ""), onClick: function () { setAuthTab("register"); setPwdOpen(false); setMessage(null); } }, "注册")
2044
1787
  ),
2045
1788
  authTab === "login"
2046
- ? h("div", null,
2047
- field("手机号", input({ type: "tel", value: phone, placeholder: "11 位手机号", autoComplete: "tel", onChange: function (e) { setPhone(e.target.value); } })),
2048
- field("密码", input({ type: "password", value: pass, placeholder: "密码", autoComplete: "current-password", onChange: function (e) { setPass(e.target.value); } })),
2049
- field("验证码", h("div", { className: "dru-captcha" },
2050
- input({ value: lcapTxt, placeholder: "图中数字", autoComplete: "off", inputMode: "numeric", maxLength: 6, onChange: function (e) { setLcapTxt(e.target.value); } }),
2051
- h("div", { className: "dru-captcha-box", title: "看不清?点击刷新", onClick: function () { loadCaptcha("login"); }, dangerouslySetInnerHTML: lcap && lcap.svg && lcap.svg.indexOf("<svg") === 0 ? { __html: lcap.svg } : void 0 },
2052
- lcap && lcap.svg && lcap.svg.indexOf("<svg") !== 0 ? lcap.svg : null)
2053
- )),
2054
- h("button", { type: "button", className: "dru-btn dru-btn-primary", style: { width: "100%" }, disabled: busy !== "", onClick: doLogin }, busy === "login" ? "登录中…" : "登录")
1789
+ ? (pwdOpen
1790
+ // 「忘记密码」展开:与账号卡「修改密码」共用同一重置表单(手机号可编辑、预填登录输入)
1791
+ ? renderResetPwdForm(true)
1792
+ : h("div", null,
1793
+ field("手机号", input({ type: "tel", value: phone, placeholder: "11 位手机号", autoComplete: "tel", onChange: function (e) { setPhone(e.target.value); } })),
1794
+ field("密码", input({ type: "password", value: pass, placeholder: "密码", autoComplete: "current-password", onChange: function (e) { setPass(e.target.value); } })),
1795
+ field("验证码", h("div", { className: "dru-captcha" },
1796
+ input({ value: lcapTxt, placeholder: "图中数字", autoComplete: "off", inputMode: "numeric", maxLength: 6, onChange: function (e) { setLcapTxt(e.target.value); } }),
1797
+ h("div", { className: "dru-captcha-box", title: "看不清?点击刷新", onClick: function () { loadCaptcha("login"); }, dangerouslySetInnerHTML: lcap && lcap.svg && lcap.svg.indexOf("<svg") === 0 ? { __html: lcap.svg } : void 0 },
1798
+ lcap && lcap.svg && lcap.svg.indexOf("<svg") !== 0 ? lcap.svg : null)
1799
+ )),
1800
+ h("button", { type: "button", className: "dru-btn dru-btn-primary", style: { width: "100%" }, disabled: busy !== "", onClick: doLogin }, busy === "login" ? "登录中…" : "登录"),
1801
+ // 忘记密码:小字入口,点击展开短信验证码重置表单(不抢占任何官方按钮)
1802
+ h("div", { style: { display: "flex", justifyContent: "flex-end", marginTop: 8 } },
1803
+ h("button", { type: "button", className: "dru-linkbtn", disabled: busy !== "", onClick: togglePwdForm }, "忘记密码?"))
1804
+ )
2055
1805
  )
2056
1806
  : h("div", null,
2057
1807
  field("手机号", input({ type: "tel", value: rphone, placeholder: "11 位手机号", autoComplete: "tel", onChange: function (e) { setRphone(e.target.value); } })),
@@ -1020,7 +1020,7 @@ const PLUGIN_ID = "dsh-remote-web";
1020
1020
  const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
1021
1021
  const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
1022
1022
  /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
1023
- const PLUGIN_VERSION = "0.6.0-beta.10";
1023
+ const PLUGIN_VERSION = "0.6.0-beta.11";
1024
1024
  const UPDATE_LOG = ".dsh-update.log";
1025
1025
  const UPDATE_MARKER = ".dsh-update-running";
1026
1026
 
@@ -1,6 +1,7 @@
1
- // 🔒 修改密码(已登录 SaaS,短信验证码重置):
1
+ // 🔒 修改密码 / 忘记密码(短信验证码重置):
2
2
  // - node 半代理:POST /dsh-remote/password/reset → 企业端公开 /api/password/reset(无需 Bearer,透传 ok/error);
3
- // - 浏览器半:账号卡「修改密码」小表单(手机号以当前账号为准 + 图形验证码 + 短信验证码 + 新密码≥8),
3
+ // - 浏览器半:账号卡「🔒 修改密码」小表单 + 登录卡「忘记密码?」共用同一重置表单
4
+ // (手机号[账号卡只读/登录卡可编辑预填] + 图形验证码 + 短信验证码 + 新密码≥8),
4
5
  // 成功后本地登出(清反馈线程凭据)并提示用新密码重新登录(登录会覆盖桌面端 config 密码)。
5
6
  import assert from "node:assert/strict";
6
7
  import http from "node:http";
@@ -238,8 +239,8 @@ async function runResetFlow(opts = {}) {
238
239
  await flush();
239
240
  tree = plugin.render();
240
241
 
241
- // 小表单展开:图形验证码 + 手机号(当前账号,不可改) + 新密码提示
242
- assert.ok(textHas(tree, "修改后所有已授权设备与会话将失效,需重新登录与解锁"), "应提示设备/会话失效与重新登录解锁");
242
+ // 小表单展开:图形验证码 + 手机号(当前账号,不可改) + 新密码提示(与登录卡「忘记密码」共用表单/文案)
243
+ assert.ok(textHas(tree, "修改后所有已授权设备/会话将失效,需重新登录与解锁"), "应提示设备/会话失效与重新登录解锁");
243
244
  const phoneBox = find(tree, (n) => n.props?.value === "13800000000" && n.props?.disabled === true);
244
245
  assert.ok(phoneBox, "手机号应预填当前账号且不可修改");
245
246
 
@@ -299,19 +300,126 @@ test("修改密码失败路径:企业端错误透传为可读提示,不登
299
300
  assert.ok(textHas(tree, "修改失败"), "错误应带「修改失败」前缀");
300
301
  });
301
302
 
302
- test("源码约束:账号区含「修改密码」入口、reset 代理路由、新密码≥8 与重新登录提示", () => {
303
- // 浏览器半
303
+ test("忘记密码(登录卡):「忘记密码?」展开共用重置表单(预填手机号)→ sms-code → password/reset 本地登出并提示请用新密码登录", async () => {
304
+ const plugin = loadPlugin({ localStorageSeed: { "dsh-feedback-threads": '[{"id":"fb_1","token":"tok-abc","at":1}]' } });
305
+ plugin.states[0] = { config: { phone: "", deviceId: "dev-x", mode: "saas" }, service: { running: false } };
306
+ let tree = plugin.render();
307
+
308
+ // 登录 tab(SaaS 登录表单)应提供小字「忘记密码?」入口(紧邻登录按钮,不抢占官方按钮)
309
+ const forgetLink = find(tree, (n) => (n.children || []).some((c) => typeof c === "string" && c.includes("忘记密码")));
310
+ assert.ok(forgetLink, "登录表单内应提供「忘记密码?」入口");
311
+ assert.ok(!textHas(tree, "确认重置密码"), "未点击前不应展开重置表单");
312
+
313
+ // 先在登录手机号输入当前号码 → 展开后应预填
314
+ find(tree, (n) => n.props?.placeholder === "11 位手机号").props.onChange({ target: { value: "13800000000" } });
315
+ tree = plugin.render();
316
+ find(tree, (n) => (n.children || []).some((c) => typeof c === "string" && c.includes("忘记密码"))).props.onClick();
317
+ await flush();
318
+ await flush();
319
+ tree = plugin.render();
320
+
321
+ // 展开表单与账号卡「修改密码」共用字段/文案/函数
322
+ assert.ok(textHas(tree, "修改后所有已授权设备/会话将失效,需重新登录与解锁"), "应提示设备/会话失效与重新登录解锁");
323
+ assert.ok(find(tree, (n) => n.props?.value === "13800000000"), "重置表单手机号应预填当前输入(可改)");
324
+ const capInput = find(tree, (n) => n.props?.placeholder === "图中数字");
325
+ assert.ok(capInput, "应展示图形验证码输入框(现有 captcha 通道)");
326
+ capInput.props.onChange({ target: { value: "654321" } });
327
+ tree = plugin.render();
328
+ find(tree, (n) => (n.children || []).some((c) => typeof c === "string" && c.includes("获取验证码"))).props.onClick();
329
+ await flush();
330
+ await flush();
331
+
332
+ const smsReq = plugin.requests.find((r) => r.path === "/dsh-remote/sms-code");
333
+ assert.ok(smsReq, "应请求 /dsh-remote/sms-code(与修改密码同一短信防刷路径)");
334
+ assert.deepEqual(smsReq.body, { phone: "13800000000", captcha_id: "cap-pwd-1", captcha_answer: "654321" });
335
+
336
+ tree = plugin.render();
337
+ find(tree, (n) => n.props?.placeholder === "6 位验证码").props.onChange({ target: { value: "123456" } });
338
+ tree = plugin.render();
339
+ find(tree, (n) => n.props?.placeholder === "至少 8 位").props.onChange({ target: { value: "newpass123" } });
340
+ tree = plugin.render();
341
+ find(tree, (n) => (n.children || []).some((c) => typeof c === "string" && c.includes("确认重置密码"))).props.onClick();
342
+ await flush();
343
+ await flush();
344
+ await flush();
345
+ await flush();
346
+
347
+ const resetReq = plugin.requests.find((r) => r.path === "/dsh-remote/password/reset");
348
+ assert.ok(resetReq, "应 POST /dsh-remote/password/reset(复用已有 node 半代理)");
349
+ assert.equal(resetReq.method, "POST");
350
+ assert.deepEqual(resetReq.body, { phone: "13800000000", sms_code: "123456", new_password: "newpass123" },
351
+ "应提交 {phone,sms_code,new_password}");
352
+
353
+ assert.ok(plugin.requests.some((r) => r.path === "/dsh-remote/logout"), "重置成功后应本地登出");
354
+ assert.ok(plugin.removed.includes("dsh-feedback-threads"), "重置成功登出应清除本机反馈线程凭据");
355
+
356
+ tree = plugin.render();
357
+ assert.ok(textHas(tree, "密码已重置成功"), "应给出重置成功提示");
358
+ assert.ok(textHas(tree, "请用新密码登录"), "应提示「请用新密码登录」");
359
+ assert.ok(!find(tree, (n) => (n.children || []).some((c) => typeof c === "string" && c.includes("确认重置密码"))),
360
+ "成功后重置表单应收起");
361
+ assert.ok(find(tree, (n) => n.props?.placeholder === "密码"), "应回到登录表单(可直接输入新密码登录)");
362
+ });
363
+
364
+ test("忘记密码失败路径:企业端错误透传为可读提示(重置失败前缀),不本地登出", async () => {
365
+ const plugin = loadPlugin({ resetFail: true });
366
+ plugin.states[0] = { config: { phone: "", deviceId: "dev-x", mode: "saas" }, service: { running: false } };
367
+ let tree = plugin.render();
368
+
369
+ find(tree, (n) => (n.children || []).some((c) => typeof c === "string" && c.includes("忘记密码"))).props.onClick();
370
+ await flush();
371
+ await flush();
372
+ tree = plugin.render();
373
+ find(tree, (n) => n.props?.placeholder === "11 位手机号").props.onChange({ target: { value: "13800000000" } });
374
+ tree = plugin.render();
375
+ find(tree, (n) => n.props?.placeholder === "图中数字").props.onChange({ target: { value: "654321" } });
376
+ tree = plugin.render();
377
+ find(tree, (n) => (n.children || []).some((c) => typeof c === "string" && c.includes("获取验证码"))).props.onClick();
378
+ await flush();
379
+ await flush();
380
+ tree = plugin.render();
381
+ find(tree, (n) => n.props?.placeholder === "6 位验证码").props.onChange({ target: { value: "123456" } });
382
+ tree = plugin.render();
383
+ find(tree, (n) => n.props?.placeholder === "至少 8 位").props.onChange({ target: { value: "newpass123" } });
384
+ tree = plugin.render();
385
+ find(tree, (n) => (n.children || []).some((c) => typeof c === "string" && c.includes("确认重置密码"))).props.onClick();
386
+ await flush();
387
+ await flush();
388
+ await flush();
389
+ await flush();
390
+
391
+ assert.ok(!plugin.requests.some((r) => r.path === "/dsh-remote/logout"), "失败时不应本地登出");
392
+ tree = plugin.render();
393
+ assert.ok(textHas(tree, "短信验证码错误或已过期"), "应展示企业端透传的可读错误");
394
+ assert.ok(textHas(tree, "重置失败"), "错误应带「重置失败」前缀");
395
+ assert.ok(find(tree, (n) => (n.children || []).some((c) => typeof c === "string" && c.includes("确认重置密码"))),
396
+ "失败后重置表单保留,可修改后重试");
397
+ });
398
+
399
+ test("源码约束:账号区含「修改密码」/登录卡含「忘记密码」入口、共用重置逻辑、reset 代理路由、新密码≥8 与重新登录提示", () => {
400
+ // 浏览器半:账号卡修改密码入口
304
401
  assert.match(SOURCE, /修改密码/);
305
402
  assert.match(SOURCE, /🔒 修改密码/);
403
+ assert.match(SOURCE, /确认修改密码/);
404
+ // 浏览器半:登录卡「忘记密码」小字入口 → 展开共用重置表单(复用同一组 pwd* 字段/函数)
405
+ assert.match(SOURCE, /忘记密码/);
406
+ assert.match(SOURCE, /忘记密码?/);
407
+ assert.match(SOURCE, /确认重置密码/);
408
+ assert.match(SOURCE, /请用新密码登录/);
409
+ assert.match(SOURCE, /修改后所有已授权设备\/会话将失效,需重新登录与解锁/);
410
+ // 共用重置:同一 doResetPwd/sendPwdSms/renderResetPwdForm(fromLogin 分流手机号来源与提示)
411
+ assert.match(SOURCE, /doResetPwd\(ph, fromLogin\)/);
412
+ assert.match(SOURCE, /sendPwdSms\(ph\)/);
413
+ assert.match(SOURCE, /function renderResetPwdForm\(fromLogin\)/);
306
414
  assert.match(SOURCE, /dsh-remote\/password\/reset/);
307
415
  assert.match(SOURCE, /new_password/);
308
416
  assert.match(SOURCE, /新密码至少 8 位/);
309
417
  assert.match(SOURCE, /pwdNew\.length < 8/);
310
- assert.match(SOURCE, /所有已授权设备与会话将失效/);
418
+ assert.match(SOURCE, /所有已授权设备与会话已失效/);
311
419
  assert.match(SOURCE, /重新登录/);
312
420
  assert.match(SOURCE, /E2EE/);
313
421
  assert.match(SOURCE, /fbClearThreads\(\)/);
314
- // 修改密码的短信验证码复用 /dsh-remote/sms-code(含图形验证码防刷)
422
+ // 修改密码/忘记密码的短信验证码复用 /dsh-remote/sms-code(含图形验证码防刷)
315
423
  assert.match(SOURCE, /post\("\/dsh-remote\/sms-code"/);
316
424
  assert.match(SOURCE, /captcha_invalid/);
317
425
  // node 半代理路由
@@ -1,6 +1,6 @@
1
1
  // dsh-remote-web 浏览器半回归(2026-09 前名 dsh-remote-ui):面板入口迁移进「设置」页 settings.section 官方扩展点。
2
- // 覆盖:栏目注册(id/order/label)、侧边栏入口与浮动面板移除、账号区无「切换账号」、
3
- // 「关于 dsh-remote」说明卡片、退出登录/切换连接账号清理反馈线程凭据、首次安装红点引导。
2
+ // 覆盖:栏目注册(id/order/label)、侧边栏入口与浮动面板移除(不再向官方侧栏注入任何按钮/点击机器)、
3
+ // 账号区无「切换账号」、「关于 dsh-remote」说明卡片、退出登录/切换连接账号清理反馈线程凭据、首次安装红点引导。
4
4
  import assert from "node:assert/strict";
5
5
  import { readFileSync } from "node:fs";
6
6
  import test from "node:test";
@@ -98,7 +98,6 @@ function loadPlugin(opts = {}) {
98
98
  },
99
99
  querySelectorAll(sel) {
100
100
  if (sel === "button") return opts.navCells || [];
101
- if (typeof opts.navQuery === "function") return opts.navQuery(sel);
102
101
  return [];
103
102
  },
104
103
  };
@@ -158,7 +157,6 @@ function loadPlugin(opts = {}) {
158
157
  return {
159
158
  registered, metas, injects, requests, removed, states, localStorage,
160
159
  intervals,
161
- navWindow: sandbox.window,
162
160
  lastObserver: () => lastObserver,
163
161
  renderSection() { hook = 0; return registered.get("dsh-remote")({ close() {} }); },
164
162
  };
@@ -284,116 +282,49 @@ test("红点已看过(localStorage 有 key)时不注入,重启 DSH Web 不
284
282
  assert.equal(navCell.children.length, 0, "已看过时不应再注入红点");
285
283
  });
286
284
 
287
- // ---------- 侧栏入口「远程访问」点击流(openRemoteSettings,Bug A:容器限定 + 短文本 + 独立按钮) ----------
288
-
289
- /** 简易导航节点:带 aria-label/text/class/role 的“按钮”,记录点击并支持 data-dru-remote 标记。 */
290
- function navNode({ id = "", text = "", aria = "", cls = "", role = "", tag = "button" } = {}) {
291
- const node = {
292
- id,
293
- className: cls,
294
- textContent: text,
295
- tagName: tag,
296
- _attrs: {},
297
- _clicked: 0,
298
- getAttribute(k) { return k in this._attrs ? this._attrs[k] : null; },
299
- setAttribute(k, v) { this._attrs[k] = String(v); },
300
- click() { this._clicked++; },
301
- };
302
- if (aria) node._attrs["aria-label"] = aria;
303
- if (role) node._attrs.role = role;
304
- return node;
305
- }
306
-
307
- /** 简易“导航容器”:候选查询(NAV_CAND_SEL)一律返回给定候选列表(容器内行为仿真)。 */
308
- function navContainer(candidates) {
309
- return { querySelectorAll() { return candidates; } };
310
- }
311
- /** 容器定位选择器(NAV_CONTAINER_SEL)特征:client 只在语义容器选择器上全文档扫描。 */
312
- const isContainerSel = (sel) => /navigation|sidebar|navRail|navPanel|sidenav|appnav/.test(sel);
313
- /** 建一个“侧栏容器已存在”的沙箱导航环境:document 语义容器查询 → [container],其余 → []。 */
314
- function navHarness(candidates) {
315
- const container = navContainer(candidates);
316
- return { container, navQuery: (sel) => (isContainerSel(sel) ? [container] : []) };
317
- }
318
-
319
- test("侧栏入口点击流:远程栏目项已存在(navCell/aria-label,含 emoji 前缀)→ 容器内直接命中栏目,不点注入项自身", () => {
320
- const injected = navNode({ id: "dru-nav-remote", text: "📱 远程访问", cls: "navCell-clone" }); // 注入的侧栏项
321
- const settingsNav = navNode({ text: "设置", cls: "navCell-set" });
322
- const remoteCell = navNode({ aria: "远程访问", cls: "VOzbGW_navCell", tag: "button" }); // 纯图标+aria-label 的栏目项
323
- const candidates = [injected, settingsNav, remoteCell];
324
- const env = navHarness(candidates);
325
- const plugin = loadPlugin({ navQuery: env.navQuery });
326
- plugin.navWindow.__dshRemoteNav.open();
327
- assert.equal(remoteCell._clicked, 1, "应命中「远程访问」栏目项(aria-label 亦可读)");
328
- assert.equal(remoteCell.getAttribute("data-dru-remote"), "1", "命中后应打 data-dru-remote 标记(加速二次命中)");
329
- assert.equal(injected._clicked, 0, "不得点击注入的侧栏项自身(防递归)");
330
- assert.equal(settingsNav._clicked, 0, "栏目已存在时无需退化点「设置」");
331
- assert.equal(plugin.intervals.filter((x) => x.ms === 150).length, 0, "直接命中后不应启动轮询");
332
- });
285
+ test("红点只挂在设置页「远程访问」栏目导航(navCell):普通按钮即使文本含「远程访问」也不注入,不遮挡官方入口", () => {
286
+ const navCells = [];
287
+ const officialCell = makeEl("button");
288
+ officialCell.className = "VOzbGW_navCell"; // shell hashed 类名(含 navCell 子串)=设置页栏目导航
289
+ officialCell.textContent = "📱 远程访问";
290
+ const plainBtn = makeEl("button");
291
+ plainBtn.className = "some-other-btn"; // 非 navCell(官方侧栏/内容区按钮):即使含「远程访问」也不得注入
292
+ plainBtn.textContent = "远程访问";
293
+ const plugin = loadPlugin({ navCells });
333
294
 
334
- test("侧栏入口点击流:栏目项尚未挂载 → 容器内先点「设置」,轮询(≤3s 上限)到点后再补点「远程访问」(全程不点注入项)", () => {
335
- const injected = navNode({ id: "dru-nav-remote", text: "📱 远程访问", cls: "navCell-clone" });
336
- const settingsNav = navNode({ text: "设置", cls: "navCell-set" });
337
- const remoteCell = navNode({ text: "📱 远程访问", cls: "VOzbGW_navCell" }); // 设置页打开后才挂载
338
- const candidates = [injected, settingsNav]; // 初始只有注入项 + 「设置」
339
- const env = navHarness(candidates);
340
- const plugin = loadPlugin({ navQuery: env.navQuery });
341
- plugin.navWindow.__dshRemoteNav.open();
342
-
343
- assert.equal(settingsNav._clicked, 1, "栏目不可达时应点「设置」进入设置页");
344
- assert.equal(injected._clicked, 0, "不得点击注入的侧栏项自身");
345
- const poll = plugin.intervals.find((x) => x.ms === 150);
346
- assert.ok(poll, "应启动 150ms 轮询等待栏目挂载");
347
-
348
- candidates.push(remoteCell); // 设置页已打开,栏目项挂载
349
- poll.cb();
350
- assert.equal(remoteCell._clicked, 1, "轮询到点后应补点「远程访问」栏目");
351
- assert.equal(remoteCell.getAttribute("data-dru-remote"), "1", "栏目命中后应打标记");
352
- assert.equal(settingsNav._clicked, 1, "「设置」只应点一次");
353
- assert.equal(injected._clicked, 0, "轮询过程也不得点注入项自身");
354
- });
295
+ navCells.push(officialCell, plainBtn);
296
+ plugin.lastObserver().cb([]);
355
297
 
356
- test("Bug A:会话标题式长文本(含「远程访问」12+ 字)不被命中,也不点击「设置」以外内容", () => {
357
- // 会话标题被误当导航项是 Bug A 根因之一:长文本必须被短文本规则拒绝
358
- const injected = navNode({ id: "dru-nav-remote", text: "📱 远程访问", cls: "navCell-clone" });
359
- const settingsNav = navNode({ text: "设置", cls: "navCell-set" });
360
- const sessionLike = navNode({ text: "分析竞品远程访问功能完整报告", cls: "chat-session-title", tag: "button" }); // 13 字会话标题(可点 button,考验文本长度规则)
361
- const candidates = [injected, settingsNav, sessionLike];
362
- const env = navHarness(candidates);
363
- const plugin = loadPlugin({ navQuery: env.navQuery });
364
- plugin.navWindow.__dshRemoteNav.open();
365
-
366
- assert.equal(sessionLike._clicked, 0, "长文本(会话标题)候选绝不能被点击");
367
- assert.equal(settingsNav._clicked, 1, "只能退化到容器内「设置」");
368
- const poll = plugin.intervals.find((x) => x.ms === 150);
369
- assert.ok(poll, "进「设置」后应轮询等待远程栏目");
370
- // 轮询上限(20 次 × 150ms = 3s):跑满上限后不再产生任何点击
371
- for (let i = 0; i < 20; i++) poll.cb();
372
- assert.equal(sessionLike._clicked, 0, "长文本候选在轮询全程都不应被点击");
373
- assert.equal(injected._clicked, 0, "注入项全程不应被点击");
374
- assert.equal(settingsNav._clicked, 1, "「设置」只点一次(轮询只找「远程访问」,不重复点设置)");
298
+ assert.equal(officialCell.children.length, 1, "官方设置页「远程访问」栏目按钮应注入红点");
299
+ assert.equal(officialCell.children[0].className, "dru-reddot");
300
+ assert.equal(plainBtn.children.length, 0, "非栏目导航按钮不得注入红点(不遮挡官方按钮)");
375
301
  });
376
302
 
377
- test("Bug A(源码约束):容器限定选择器存在、无 document 级全量点击/克隆注入项、open 流程含轮询上限", () => {
378
- // 1) 容器定位选择器与容器内候选查询必须存在(不应再出现 document 级全量导航点击)
379
- assert.match(SOURCE, /NAV_CONTAINER_SEL/);
380
- assert.match(SOURCE, /\[role="navigation"\]/);
381
- assert.match(SOURCE, /sidebar\|navRail\|navPanel/);
382
- assert.match(SOURCE, /containers\[c\]\.querySelectorAll\(NAV_CAND_SEL\)/);
383
- assert.doesNotMatch(SOURCE, /NAV_ANY_SEL/, "旧的 document 全量兜底选择器应移除");
384
- // 2) 短文本上限与命中收紧(长会话标题被拒)
385
- assert.match(SOURCE, /NAV_TEXT_MAX = 10/);
386
- assert.match(SOURCE, /\.length > NAV_TEXT_MAX/);
387
- assert.match(SOURCE, /menu\|context\|more\|kebab/);
388
- // 3) 注入项为自有独立 button,不 cloneNode 官方按钮
389
- assert.doesNotMatch(SOURCE, /cloneNode/, "不得再 clone 官方按钮(避免继承官方委托/热区)");
390
- assert.match(SOURCE, /createElement\("button"\)/);
391
- assert.match(SOURCE, /NAV_ENTRY_ID/);
392
- // 4) openRemoteSettings:容器内点击 + 轮询上限(20 × 150ms = 3s)
393
- assert.match(SOURCE, /function openRemoteSettings\(\)/);
394
- assert.match(SOURCE, /NAV_POLL_MAX = 20/);
395
- assert.match(SOURCE, /tries >= NAV_POLL_MAX/);
396
- assert.match(SOURCE, /__dshRemoteNav/);
303
+ // ---------- 入口收敛:不再向官方侧栏注入任何按钮 / 导航点击机器 ----------
304
+ // 用户从官方「设置」进入 settings.section「远程访问」栏目(官方扩展点,天然不冲突),
305
+ // 官方「设置」按钮保持原样、不被遮挡。旧 Bug A 侧栏注入实现(容器限定点击流 + 独立按钮 +
306
+ // 红点 + openRemoteSettings/__dshRemoteNav)已整段移除,红点只保留在设置页栏目导航 navCell 上。
307
+
308
+ test("源码约束:侧栏注入按钮/导航点击机器已移除,官方「设置」不被抢、红点仅保留在设置页栏目导航", () => {
309
+ // 不再存在:侧栏 DOM 注入 / 导航容器点击 / 暴露给宿主与自动化的 open 入口
310
+ assert.doesNotMatch(SOURCE, /injectSidebarRemoteEntry/);
311
+ assert.doesNotMatch(SOURCE, /dru-nav-remote/);
312
+ assert.doesNotMatch(SOURCE, /dsh-remote-nav-seen/);
313
+ assert.doesNotMatch(SOURCE, /openRemoteSettings/);
314
+ assert.doesNotMatch(SOURCE, /clickNavToken/);
315
+ assert.doesNotMatch(SOURCE, /remoteSectionVisible/);
316
+ assert.doesNotMatch(SOURCE, /__dshRemoteNav/);
317
+ assert.doesNotMatch(SOURCE, /data-dru-remote/);
318
+ assert.doesNotMatch(SOURCE, /NAV_ENTRY_ID/);
319
+ assert.doesNotMatch(SOURCE, /NAV_CONTAINER_SEL/);
320
+ assert.doesNotMatch(SOURCE, /NAV_TEXT_MAX/);
321
+ assert.doesNotMatch(SOURCE, /dru-fab/);
322
+ // 官方按钮不被克隆 / 包装 / 挪位(克隆曾继承官方事件委托与热区 → Bug A)
323
+ assert.doesNotMatch(SOURCE, /cloneNode/);
324
+ // 唯一保留的 DOM 注入 = 设置页栏目导航红点(.dru-reddot,navCell 右上角,pointer-events:none)
325
+ assert.match(SOURCE, /dru-reddot/);
326
+ assert.match(SOURCE, /navCell/);
327
+ assert.match(SOURCE, /dsh-remote-seen-dot/);
397
328
  });
398
329
 
399
330
  test("源码约束:无侧边栏入口/浮动面板/切换账号;命名统一为「远程访问」;新增访问密钥/设备路由", () => {
@@ -413,13 +344,6 @@ test("源码约束:无侧边栏入口/浮动面板/切换账号;命名统一
413
344
  assert.match(SOURCE, /dsh-remote\/access-key/);
414
345
  assert.match(SOURCE, /dsh-remote\/mobile-sessions/);
415
346
  assert.match(SOURCE, /order: 30/);
416
- // 侧栏入口导航加固:aria-label/role 扫描、data-dru-remote 标记、排除注入项防递归、进设置后轮询
417
- assert.match(SOURCE, /data-dru-remote/);
418
- assert.match(SOURCE, /dru-nav-remote/);
419
- assert.match(SOURCE, /aria-label/);
420
- assert.match(SOURCE, /remoteSectionVisible/);
421
- assert.match(SOURCE, /openRemoteSettings/);
422
- assert.match(SOURCE, /__dshRemoteNav/);
423
347
  // 清理时机:退出登录成功回调内、登录账号变化时、切换自建服务时
424
348
  assert.match(SOURCE, /post\("\/dsh-remote\/logout"\)\.then\(function \(body\) \{[\s\S]*?fbClearThreads\(\);/);
425
349
  assert.match(SOURCE, /if \(prevPhone !== phone\.trim\(\)\) fbClearThreads\(\);/);