@mrrisega/dsh-remote 0.7.2-beta.1 → 0.7.2-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -554,6 +554,67 @@ div.dsh-ma-menu .dsh-ma-load-warn[hidden] { display: none !important; }
554
554
  div.dsh-ma-menu .dsh-ma-load-warn .ico { flex: none; }
555
555
  div.dsh-ma-menu .dsh-ma-load-warn .txt { min-width: 0; overflow-wrap: anywhere; }
556
556
 
557
+ /* ===== 加载疑似卡住时的**可操作**底部提示条(2026-10-17 事故新增) =====
558
+ 事故现场:用户本机 ~/.dsh-remote(bridge 运行时 + 账号配对信息/设备密钥)被误删 → bridge 起不来、
559
+ 手机端镜像页第一次加载**干等 47 秒**才报错;页面上只有悬浮菜单里一行"已等 N 秒…复制给客服",
560
+ 没有任何可点的操作。这里补一条**底部轻量提示条**(不是全屏层、不遮挡主界面):
561
+ · 只在 12 秒看门狗判定 slow 之后才**懒创建**(解析期一个节点都不往页面塞);
562
+ · 只占视口底部一条、高度受限(max-height),浮层外的区域照常可点 → 用户还能继续操作;
563
+ · 三个按钮:重试 / 返回设备列表 / 复制诊断信息,外加 ✕ 关闭;关掉后本页不再弹;
564
+ · 认出官方界面后自动收起(判断错了也不会一直挂着)——失败方向仍然安全:
565
+ 最坏只是底部多一条**可关闭**的提示,而不是"永远盖住整片界面"。
566
+ 动画只在「支持 prefers-reduced-motion 且用户没要求减少动效」时挂上:
567
+ 老浏览器不认识这个媒体查询 → 不匹配 → 无动画(无需用 matchMedia 判断,也不会有兼容异常)。 */
568
+ div.dsh-ma-loadbar {
569
+ position: fixed;
570
+ left: max(10px, env(safe-area-inset-left));
571
+ right: max(10px, env(safe-area-inset-right));
572
+ bottom: calc(10px + env(safe-area-inset-bottom));
573
+ /* 低于 FAB(400) 与菜单(401):提示条不得压住"自救出口"(悬浮按钮永远点得到) */
574
+ z-index: 399;
575
+ display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
576
+ box-sizing: border-box;
577
+ width: auto;
578
+ max-height: min(38vh, 260px);
579
+ overflow-y: auto;
580
+ -webkit-overflow-scrolling: touch;
581
+ padding: 10px 12px;
582
+ border-radius: 12px;
583
+ background: var(--dsw-alias-bg-elevated, #fff);
584
+ border: .5px solid var(--dsw-alias-border-l3, rgba(127,127,127,.3));
585
+ box-shadow: 0 6px 24px rgba(0,0,0,.24);
586
+ color: var(--dsw-alias-label-primary, #111);
587
+ font: 400 13px/1.5 -apple-system, system-ui, sans-serif;
588
+ }
589
+ /* ⚠️ 必须显式写 [hidden]:UA 的 [hidden]{display:none} 会被上面的 display:flex 盖过(本仓踩过)。 */
590
+ div.dsh-ma-loadbar[hidden] { display: none !important; }
591
+ div.dsh-ma-loadbar .dsh-ma-loadbar-txt { flex: 1 1 160px; min-width: 0; overflow-wrap: anywhere; }
592
+ div.dsh-ma-loadbar .dsh-ma-loadbar-actions { display: flex; flex-wrap: wrap; gap: 6px; }
593
+ div.dsh-ma-loadbar button {
594
+ flex: none; padding: 8px 12px; border-radius: 9px; cursor: pointer;
595
+ border: .5px solid var(--dsw-alias-border-l3, rgba(127,127,127,.35));
596
+ background: var(--dsw-alias-button-elevated-fill, rgba(127,127,127,.08));
597
+ color: var(--dsw-alias-label-primary, #111);
598
+ font: 600 13px/1 -apple-system, system-ui, sans-serif;
599
+ touch-action: manipulation; -webkit-tap-highlight-color: transparent;
600
+ }
601
+ div.dsh-ma-loadbar button:active { transform: scale(.98); }
602
+ div.dsh-ma-loadbar button.dsh-ma-loadbar-close {
603
+ padding: 6px 9px; border-color: transparent; background: transparent;
604
+ color: var(--dsw-alias-label-tertiary, #6b7280);
605
+ }
606
+ @media (prefers-reduced-motion: no-preference) {
607
+ div.dsh-ma-loadbar { animation: dsh-ma-loadbar-in .18s ease-out; }
608
+ @keyframes dsh-ma-loadbar-in {
609
+ from { opacity: 0; transform: translateY(6px); }
610
+ to { opacity: 1; transform: none; }
611
+ }
612
+ }
613
+ /* 双保险:显式要求减少动效时一律无动画(老浏览器不匹配上面的 no-preference → 天然无动画) */
614
+ @media (prefers-reduced-motion: reduce) {
615
+ div.dsh-ma-loadbar { animation: none !important; transition: none !important; }
616
+ }
617
+
557
618
  /* 会话过期引导条:镜像页在会话过期后只会"连不上",这里给一句能照做的话 + 一个出口。
558
619
  非阻塞(顶部细条),点关闭即消失;不拦截任何请求。 */
559
620
  div.dsh-ma-sessionbar {
@@ -656,21 +717,33 @@ const SCRIPT = `(() => {
656
717
  }
657
718
  })();
658
719
 
659
- /* ===== 「加载疑似没完成」看门狗(2026-09-25,替代被整块删除的首屏提示层)=====
660
- 那个提示层是全屏覆盖 + 启发式退出 —— 判断错一次就把**已经加载完**的界面永久盖住
661
- (用户实测:秒表走到 187 秒,刷新也一样)。所以这里换一条**安全的失败方向**:
720
+ /* ===== 「加载疑似没完成」看门狗(2026-09-25 起;2026-10-17 提速并改成**可操作**提示)=====
721
+ 历史:0.6.14 的首屏提示层是全屏覆盖 + 启发式退出 —— 判断错一次就把**已经加载完**的界面永久盖住
722
+ (用户实测:秒表走到 187 秒,刷新也一样)。所以 0.6.16 换了安全的失败方向:只置 slow 标记,
723
+ 在**已有的**悬浮菜单里多一行字。这个方向本身是对的,但**提示太晚、且没有操作入口**——
724
+ 2026-10-17 真实事故:用户本机 ~/.dsh-remote(运行时 + 账号配对信息/设备密钥)被误删后,
725
+ 手机端镜像页第一次加载**干等 47 秒**才报错,页面上只有一行"已等 N 秒…复制给客服",
726
+ 用户完全不知道该重新安装还是重新登录。
662
727
 
663
- · 解析期**不创建任何 DOM**(只有几个变量 + 一个定时器)—— 页面还没过主机门,
728
+ 现在(2026-10-17):
729
+ · 解析期仍然**不创建任何 DOM**(只有几个变量 + 一个定时器)—— 页面还没过主机门,
664
730
  更不该有任何覆盖层;
665
- · 25 秒后仍认不出官方界面 → 只置一个 slow 标记,**不往页面塞任何东西**;
666
- · 标记生效时,往**已有的**「远程控制」悬浮菜单里多一行字(点一下复制一句话给客服),
667
- 并建议点菜单里本来就有的「返回设备列表」重来;
668
- · 判断错了的代价 = 菜单里多一行字(用户不点开菜单完全看不到);不会再挡住任何人。
669
-
670
- ⚠️ 刻意**不**做成页面顶部/底部的固定细条:那种细条同样会盖住官方 UI 的一部分,
671
- 而且同样有"永不消失"的失败方向。菜单是被动展开的,天然没有这个问题。 */
731
+ · **12 秒**后仍认不出官方界面 → 置 slow + 显示**底部轻量提示条**(懒创建):
732
+ 文案「电脑端可能未连接 / 页面加载较慢」+ 已等待秒数;
733
+ 按钮:重试(location.reload)/ 返回设备列表(/app/)/ 复制诊断信息 / ✕ 关闭;
734
+ · 提示条**不是全屏层**:只占底部一条、高度受限、浮层外照常可点、可关闭,
735
+ 关掉后本页不再弹;认出官方界面后自动收起 —— 失败方向仍然安全;
736
+ · 菜单里那一行字**保留**(被动展开,不打扰,也仍可复制诊断话术)。
737
+
738
+ ⚠️ 12 秒是"开始提示"的阈值,不是"判定失败":限速放开后正常首屏 1~3 秒,
739
+ 12 秒既能及时帮到用户,也不至于刚打开就吓人(宁可晚一点,也别误报)。
740
+ ⚠️ 注入体是模板字符串:本段注释/文案**一律用「」**,不得出现反引号。 */
672
741
  var maLoadWatch = { startedAt: Date.now(), slow: false, wired: false, bound: false, warnEl: null, warnTxt: null,
673
- reported: false, reportMs: 0 };
742
+ reported: false, reportMs: 0,
743
+ // 2026-10-17:底部可操作提示条(barEl = 懒创建的节点;barTimer = 秒数刷新)
744
+ barEl: null, barTxt: null, barTimer: 0, dismissed: false };
745
+ /** 看门狗阈值(毫秒):12 秒仍认不出官方界面就开始提示。测试按常量断言,不要散落魔法数。 */
746
+ var MA_LOAD_WATCH_MS = 12 * 1000;
674
747
  /** 官方界面是否已经可用(只看最强信号;只用于决定"要不要多显示一行字")。 */
675
748
  function maLoadReadyNow() {
676
749
  try {
@@ -718,8 +791,8 @@ const SCRIPT = `(() => {
718
791
  * ② **全程静默**:任何异常都吞掉,失败绝不影响页面,也绝不做重试;
719
792
  * ③ 只在**已有的**同一批请求之外发这一次(一次 POST,几十字节),不是测速、不轮询。
720
793
  * body = { ms, outcome, at }:ms=耗时毫秒(截到 [0,600000],与契约一致);outcome ∈ {ok,slow}
721
- * (slow = 25 秒看门狗兜底,是"删失下限",后台只把 ok 计入 P50/P90);at=事件发生的 epoch ms。
722
- */
794
+ * (slow = 12 秒看门狗兜底,是"删失下限",后台只把 ok 计入 P50/P90);at=事件发生的 epoch ms。
795
+ * ⚠️ 2026-10-17:阈值 25s→12s,**字段与一次语义一律不变**(后台契约不动)。 */
723
796
  function maReportMirrorLoad(outcome) {
724
797
  if (maLoadWatch.reported) return;
725
798
  maLoadWatch.reported = true;
@@ -748,10 +821,140 @@ const SCRIPT = `(() => {
748
821
  try { ua = String((navigator && navigator.userAgent) || "").slice(0, 120); } catch (e) { /* 忽略 */ }
749
822
  return "dsh-remote 手机端加载疑似卡住:等待 " + maLoadWaitedSec() + " 秒后仍未出现对话界面。UA=" + ua;
750
823
  }
824
+
825
+ /* ===== 底部可操作提示条(2026-10-17,见上方看门狗注释)=====
826
+ 纪律:
827
+ · **懒创建**:只有 slow 且用户没关过时才创建节点;
828
+ · **绝不阻塞**:只占底部一条(CSS 限高),浮层外照常可点;按钮动作全部就地完成;
829
+ · **任何异常都吞掉**:提示层坏掉也绝不影响页面(每个入口都有 try/catch);
830
+ · **只提示一次**:用户点 ✕ 后 dismissed=true,本页不再弹(不骚扰)。 */
831
+ /** 提示条主文案:电脑端未连接/加载慢 + 已等待秒数(秒数由 barTimer 每秒刷新)。 */
832
+ function maLoadBarText() {
833
+ return "电脑端可能未连接 / 页面加载较慢(已等待 " + maLoadWaitedSec() + " 秒)";
834
+ }
835
+ /** 收起提示条并停掉秒数刷新(不改变 dismissed:用于"认出界面后自动收起"这类临时收起)。 */
836
+ function maLoadHideBar() {
837
+ try { if (maLoadWatch.barEl) maLoadWatch.barEl.hidden = true; } catch (e) { /* 忽略 */ }
838
+ try {
839
+ if (maLoadWatch.barTimer) { clearInterval(maLoadWatch.barTimer); maLoadWatch.barTimer = 0; }
840
+ } catch (e2) { /* 忽略 */ }
841
+ }
842
+ /** 用户点 ✕:本页不再弹(dismissed 是**本页**语义;刷新页面会重新开始一次提示机会)。 */
843
+ function maLoadDismissBar() {
844
+ try {
845
+ maLoadWatch.dismissed = true;
846
+ maLoadHideBar();
847
+ maLoadRenderWarn(); // 菜单里那一行也一起收起(关闭 = 本页只提示一次)
848
+ } catch (e) { /* 忽略 */ }
849
+ }
850
+ /** 导航(返回设备列表):assign 失败退回 href。 */
851
+ function maLoadGoto(to) {
852
+ try { window.location.assign(to); return; } catch (e) { /* 继续 */ }
853
+ try { window.location.href = to; } catch (e2) { /* 忽略 */ }
854
+ }
855
+ /** 复制诊断信息(沿用 maLoadCopyLine:含 UA 与耗时);剪贴板不可用时把整句显示在提示条上让用户截图。 */
856
+ function maLoadCopyDiag() {
857
+ var line = maLoadCopyLine();
858
+ var show = function (t) {
859
+ try { if (maLoadWatch.barTxt) maLoadWatch.barTxt.textContent = t; } catch (e) { /* 忽略 */ }
860
+ };
861
+ try {
862
+ if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
863
+ var p = navigator.clipboard.writeText(line);
864
+ if (p && typeof p.then === "function") {
865
+ p.then(function () { show("已复制诊断信息(含等待时长与机型),可直接发给客服。"); },
866
+ function () { show(line); });
867
+ return;
868
+ }
869
+ }
870
+ } catch (e) { /* 落到显示 */ }
871
+ show(line);
872
+ }
873
+ /** 懒创建提示条(只在 slow 之后调用;解析期绝不碰 DOM)。 */
874
+ function maLoadMountBar() {
875
+ try {
876
+ if (maLoadWatch.barEl || maLoadWatch.dismissed) return maLoadWatch.barEl;
877
+ var body = document.body || document.documentElement;
878
+ if (!body || !body.appendChild) return null;
879
+ var bar = document.createElement("div");
880
+ bar.className = "dsh-ma-loadbar";
881
+ /* role=status + aria-live=polite:读屏软件会把"加载较慢"念出来,但不会抢焦点;
882
+ 它**不是** dialog/alert,不抢键盘、不锁滚动。 */
883
+ bar.setAttribute("role", "status");
884
+ bar.setAttribute("aria-live", "polite");
885
+ bar.innerHTML =
886
+ '<div class="dsh-ma-loadbar-txt" data-role="loadbar-txt"></div>' +
887
+ '<div class="dsh-ma-loadbar-actions">' +
888
+ '<button type="button" class="dsh-ma-loadbar-btn" data-role="loadbar-retry">重试</button>' +
889
+ '<button type="button" class="dsh-ma-loadbar-btn" data-role="loadbar-back">返回设备列表</button>' +
890
+ '<button type="button" class="dsh-ma-loadbar-btn" data-role="loadbar-copy">复制诊断信息</button>' +
891
+ '</div>' +
892
+ '<button type="button" class="dsh-ma-loadbar-close" data-role="loadbar-close" aria-label="关闭提示">✕</button>';
893
+ bar.hidden = true;
894
+ maLoadWatch.barEl = bar;
895
+ maLoadWatch.barTxt = bar.querySelector('[data-role="loadbar-txt"]');
896
+ bar.addEventListener("click", function (ev) {
897
+ // 事件委托:真实浏览器里目标可能是按钮内的文字节点;从 target 往上找到 [data-role]
898
+ try {
899
+ if (ev && typeof ev.preventDefault === "function") ev.preventDefault();
900
+ } catch (e0) { /* 忽略 */ }
901
+ try {
902
+ var t = ev && ev.target ? ev.target : null;
903
+ var role = "";
904
+ var guard = 0;
905
+ while (t && t !== bar && guard < 16 && !role) {
906
+ guard += 1;
907
+ try {
908
+ if (t.getAttribute) role = String(t.getAttribute("data-role") || "");
909
+ } catch (e1) { role = ""; }
910
+ t = t.parentNode || null;
911
+ }
912
+ if (role === "loadbar-retry") {
913
+ try { window.location.reload(); } catch (e2) { /* 忽略 */ }
914
+ } else if (role === "loadbar-back") {
915
+ maLoadGoto("/app/"); // 绝对路径:镜像页当前路径可能是 /remote/<deviceId>/…
916
+ } else if (role === "loadbar-copy") {
917
+ maLoadCopyDiag();
918
+ } else if (role === "loadbar-close") {
919
+ maLoadDismissBar();
920
+ }
921
+ } catch (e3) { /* 提示条失败绝不影响页面 */ }
922
+ });
923
+ body.appendChild(bar);
924
+ return bar;
925
+ } catch (e) { return null; }
926
+ }
927
+ /** 按当前状态渲染提示条(slow + 未关闭 → 显示;否则收起)。 */
928
+ function maLoadRenderBar() {
929
+ try {
930
+ if (!maLoadWatch.slow || maLoadWatch.dismissed) { maLoadHideBar(); return; }
931
+ var bar = maLoadWatch.barEl || maLoadMountBar();
932
+ if (!bar) return;
933
+ if (maLoadWatch.barTxt) maLoadWatch.barTxt.textContent = maLoadBarText();
934
+ bar.hidden = false;
935
+ if (!maLoadWatch.barTimer) {
936
+ // 每秒刷新「已等待 N 秒」;同时复核"是不是其实早就好了"——好了就自动收起(不长期挂着)。
937
+ maLoadWatch.barTimer = setInterval(function () {
938
+ try {
939
+ if (!maLoadWatch.slow || maLoadWatch.dismissed) { maLoadHideBar(); return; }
940
+ if (maLoadReadyNow()) {
941
+ maLoadWatch.slow = false;
942
+ maLoadHideBar();
943
+ maLoadRenderWarn(); // 菜单那一行也一起收起
944
+ return;
945
+ }
946
+ if (maLoadWatch.barTxt) maLoadWatch.barTxt.textContent = maLoadBarText();
947
+ } catch (e) { /* 忽略 */ }
948
+ }, 1000);
949
+ try { if (maLoadWatch.barTimer && maLoadWatch.barTimer.unref) maLoadWatch.barTimer.unref(); } catch (e2) { /* 忽略 */ }
950
+ }
951
+ } catch (e) { /* 提示条失败绝不影响页面 */ }
952
+ }
751
953
  function maLoadRenderWarn() {
752
954
  try {
753
955
  if (!maLoadWatch.warnEl) return;
754
- if (!maLoadWatch.slow) { maLoadWatch.warnEl.hidden = true; return; }
956
+ // 用户点过 ✕ = 本页不想再被这事打扰:菜单里那一行也一起收起("只提示一次")
957
+ if (!maLoadWatch.slow || maLoadWatch.dismissed) { maLoadWatch.warnEl.hidden = true; return; }
755
958
  if (maLoadWatch.warnTxt) {
756
959
  maLoadWatch.warnTxt.textContent = "本次页面可能没加载完整(已等 " + maLoadWaitedSec()
757
960
  + " 秒)。点这里复制一句话发给客服,或点下方「返回设备列表」重来。";
@@ -785,18 +988,18 @@ const SCRIPT = `(() => {
785
988
  }
786
989
  } catch (e) { /* 忽略 */ }
787
990
  }
788
- // 25 秒后仍认不出官方界面 → 置慢标记(**只改状态,不碰 DOM**)。
789
- // 阈值取得很宽松:宁可漏报(什么都不显示,与今天一致),也不要误报吓人。
790
- // 同一处顺手把 mirror_load 的**兜底样本**发出去(outcome="slow"):25 秒没就绪的页面上报的
791
- // 必然是"≥25 秒",这是**删失下限**而不是真实耗时,后台只把它单列,不混进 P50/P90。
991
+ // 12 秒后仍认不出官方界面 → 置慢标记 + 懒创建底部提示条(**只在这时才碰 DOM**)。
992
+ // 同一处顺手把 mirror_load 的**兜底样本**发出去(outcome="slow"):12 秒没就绪的页面上报的
993
+ // 必然是"≥12 秒",这是**删失下限**而不是真实耗时,后台只把它单列,不混进 P50/P90。
792
994
  setTimeout(function () {
793
995
  try {
794
996
  if (maLoadReadyNow()) return;
795
997
  maLoadWatch.slow = true;
796
998
  if (maLoadWatch.wired) maLoadRenderWarn();
999
+ maLoadRenderBar(); // 底部可操作提示条(重试 / 返回设备列表 / 复制诊断 / 关闭)
797
1000
  maReportMirrorLoad("slow");
798
1001
  } catch (e) { /* 忽略 */ }
799
- }, 25 * 1000);
1002
+ }, MA_LOAD_WATCH_MS);
800
1003
 
801
1004
  try {
802
1005
  const HOST_RE = new RegExp(${JSON.stringify(RUNTIME_HOST_RE.source)});
@@ -2511,13 +2714,24 @@ const SCRIPT = `(() => {
2511
2714
  maObserver = null;
2512
2715
  try { if (maInterval) clearInterval(maInterval); } catch (eD2) { /* 忽略 */ }
2513
2716
  maInterval = 0;
2717
+ // 加载提示条的秒数刷新也一起停掉(页面卸载/进 bfcache 后不留悬挂回调)
2718
+ try { if (maLoadWatch.barTimer) { clearInterval(maLoadWatch.barTimer); maLoadWatch.barTimer = 0; } } catch (eD3) { /* 忽略 */ }
2514
2719
  };
2515
2720
  try { window.addEventListener("pagehide", maTeardown); } catch (ePh) { /* 忽略 */ }
2516
2721
  try { window.addEventListener("unload", maTeardown); } catch (eUl) { /* 忽略 */ }
2517
2722
  /* 手机上"切到别的 App 再切回来"走的常是 bfcache:pagehide(persisted=true) → pageshow(persisted=true)。
2518
- DOM 原样保留但计时器/观察者已断 —— 这里重新武装,否则回到页面后看门狗就永久失灵了。 */
2723
+ DOM 原样保留但计时器/观察者已断 —— 这里重新武装,否则回到页面后看门狗就永久失灵了。
2724
+ 2026-10-17:加载提示条的秒数刷新也在 maTeardown 里停了,回到前台要按现状重新挂上
2725
+ (否则用户切回来看的是"冻结的已等待 N 秒")。 */
2519
2726
  try {
2520
- window.addEventListener("pageshow", (ev) => { try { if (ev && ev.persisted) maArm(); } catch (ePs) { /* 忽略 */ } });
2727
+ window.addEventListener("pageshow", (ev) => {
2728
+ try {
2729
+ if (ev && ev.persisted) {
2730
+ maArm();
2731
+ if (maLoadWatch.slow && !maLoadWatch.dismissed) maLoadRenderBar();
2732
+ }
2733
+ } catch (ePs) { /* 忽略 */ }
2734
+ });
2521
2735
  } catch (ePs2) { /* 忽略 */ }
2522
2736
  } catch (e) { if (window.console) console.warn("[dsh-mobile-adapter]", e && e.message); }
2523
2737
  })();`;
@@ -357,7 +357,8 @@ test("点一条未读公告 → 立刻 POST /dsh-remote/announcements/click(
357
357
  assert.ok(click, "点公告必须上报点击(现契约只有 /click,没有 /read)");
358
358
  assert.equal(click.method, "POST", "点击上报必须是 POST");
359
359
  assert.equal(JSON.parse(click.body).id, 3, "请求体必须带该条公告的 id");
360
- assert.ok(textHas(tree, "已读状态已同步到后台"), "点击后应展开详情弹窗");
360
+ // 2026-10-17(业主):点开**不再**当已读 —— 弹窗页脚不再出现「已读状态已同步到后台」。
361
+ assert.ok(!textHas(tree, "已读状态已同步到后台"), "点开不等于已读:页脚不得再出现「已读状态已同步到后台」");
361
362
  assert.ok(textHas(tree, "扫码登录更快了"), "详情应展示公告正文");
362
363
  });
363
364
 
@@ -471,9 +472,11 @@ test("绝不轮询:每次页面加载只自动拉一次公告(重复拉会
471
472
  assert.ok(gets[0].path.includes("client=desktop"), "必须显式声明 client=desktop");
472
473
  });
473
474
 
474
- test("源码级:面板卡片里的「刷新公告」是唯一的手动重拉入口,且**不**用于弹窗", () => {
475
+ test("源码级:公告只在打开面板时拉一次(2026-10-17 删掉「刷新公告」按钮)", () => {
476
+ // 业主约定:删除手动刷新按钮 —— 打开看板时自动拉一次即可,避免用户重复点击消耗"待弹公告"。
477
+ assert.doesNotMatch(SOURCE, /刷新公告/, "「刷新公告」按钮必须已删除");
475
478
  assert.match(SOURCE, /annPullOnce/, "必须有 sessionStorage 一次性拉取标记");
476
- assert.match(SOURCE, /openPopup:\s*false/, "手动刷新必须显式关闭弹窗(openPopup:false)");
479
+ assert.match(SOURCE, /openPopup:\s*true/, "自动拉取那一次按需弹窗(openPopup:true)");
477
480
  // 一次性标记必须写在 sessionStorage(localStorage 的 origin 会随端口变,挡不住同页重复拉)
478
481
  const fn = SOURCE.slice(SOURCE.indexOf("function annPullOnce("), SOURCE.indexOf("function annReportClick("));
479
482
  assert.match(fn, /sessionStorage/, "一次性标记必须在 sessionStorage 上");
@@ -167,14 +167,20 @@ test("桌面端:面板不得自动倒计时重启,且不摆点了必然失
167
167
  "自动倒计时重启必须在桌面端短路(Host 由 Electron 托管,不能替用户重启)");
168
168
  assert.match(CLIENT_SRC, /\(desktopHost && !isRefreshOnly\) \? null : h\("button"/,
169
169
  "桌面端且非'刷新即可'时不得渲染重启按钮");
170
- // 旧版 host 不下发该字段 → undefined → 行为必须与改动前一致
171
- assert.match(CLIENT_SRC, /\? "🔄 插件已更新:重启 dsh web 后生效"[\s\S]{0,40}: "🔄 插件已更新:刷新页面即可生效"\);/,
172
- "非桌面端文案必须保留原有判据 isRestartHard(不得顺手改发布版文案)");
170
+ // 2026-10-17(业主):桌面端**不再**出现"重启 DeepSeek Harness"这类引导,
171
+ // 统一只说「刷新页面即可生效」;非桌面端保留原有 isRestartHard 判据(发布版文案不变)。
172
+ assert.doesNotMatch(CLIENT_SRC, /退出并重新打开 DeepSeek Harness/,
173
+ "桌面端不得再引导用户重启应用(运行环境由应用托管、更新自动热加载)");
174
+ assert.match(CLIENT_SRC, /插件已更新:刷新页面即可生效/);
173
175
  });
174
176
 
175
- test("桌面端:面板文案改为引导'退出并重开应用',且不影响 Web 版", () => {
176
- assert.match(CLIENT_SRC, /退出并重新打开 DeepSeek Harness 后生效/, "桌面端要有明确引导");
177
- assert.match(CLIENT_SRC, /桌面端由应用本身托管运行环境/, "底部常驻按钮位置要有桌面端说明");
177
+ test("桌面端:删除「重启 DeepSeek Harness」整段说明(2026-10-17 业主约定)", () => {
178
+ // 原因:桌面端运行环境由应用本身托管、插件安装/更新自动热加载 —— 让用户"退出并重开应用"
179
+ // 既没必要也增加负担。底部常驻重启区在桌面端整块不渲染(非桌面端才保留重启入口)。
180
+ assert.doesNotMatch(CLIENT_SRC, /桌面端由应用本身托管运行环境/, "整段重启说明必须删除");
181
+ assert.doesNotMatch(CLIENT_SRC, /请直接退出并重新打开应用/, "不得再引导重启应用");
182
+ assert.match(CLIENT_SRC, /desktopHost \? null : h\("div", \{ className: "dru-restart-foot" \}/,
183
+ "桌面端不渲染底部重启区(非桌面端保留)");
178
184
  });
179
185
 
180
186
  // ─────────────── ④ 安装:--profile desktop 必须真的生效 ───────────────
@@ -436,13 +436,23 @@ test("「更多」只收纳营销入口:档位规格数字(Mbps / GB / 价
436
436
  assert.ok(!textHas(tree, "Mbps"), "账号卡里不得出现 Mbps");
437
437
  assert.ok(!textHas(tree, "¥"), "账号卡里不得出现价格");
438
438
 
439
- // 但两条**折叠的营销入口**仍在(用户只要数字走,入口留着)
440
- const upsell = find(body, (n) => n.type === "button" && (n.children || []).some((c) => typeof c === "string" && c.includes("升级 PRO")));
439
+ // 2026-10-17 版式简化(业主):正面只留「升级/续费 + 用户反馈 + 更多」;
440
+ // 「带新用户换会员 / 修改密码」收进「更多」。升级入口**永远在正面**(不再随收纳状态搬家)。
441
+ const upsell = find(tree, (n) => n.type === "button" && (n.children || []).some((c) => typeof c === "string" && c.includes("升级 PRO")));
441
442
  const invite = find(body, (n) => n.type === "button" && (n.children || []).some((c) => typeof c === "string" && c.includes("带新用户换会员")));
442
- assert.ok(upsell, "「更多」里应保留「🚀 升级 PRO」入口");
443
+ assert.ok(upsell, "正面应保留「🚀 升级 PRO」入口(主行动,位置稳定)");
443
444
  assert.ok(invite, "「更多」里应保留「🎁 带新用户换会员」入口");
444
445
  assert.equal(typeof upsell.props.onClick, "function");
445
446
  assert.equal(typeof invite.props.onClick, "function");
447
+ // 正面不应再出现「带新用户换会员」(已收进「更多」)
448
+ const actions = find(tree, (n) => n.props?.className === "dru-actions");
449
+ assert.ok(actions, "应有主按钮行");
450
+ assert.ok(!textHas(actions, "带新用户换会员"), "带新用户入口不再占正面按钮行");
451
+ // 「更多」现在与主行动同排:按钮本体带 .dru-btn(原来的整宽 .dru-disclose 披露条已去掉)
452
+ const moreBtn = find(tree, (n) => n.props?.id === "dru-account-more");
453
+ assert.ok(moreBtn, "「更多」按钮应存在");
454
+ assert.match(String(moreBtn.props.className || ""), /dru-btn/, "「更多」必须与主行动同排(按钮样式,不再是整宽披露条)");
455
+ assert.doesNotMatch(String(moreBtn.props.className || ""), /dru-disclose(?!-inline)/, "不得再继承整宽披露条样式");
446
456
 
447
457
  // 源码级:函数与其调用都删掉了(不留死代码)。
448
458
  // ⚠️ 断言「代码形态」而不是裸标识符 —— 注释里**故意**留着「这里原本是什么、为什么删」的说明
@@ -473,3 +483,106 @@ test("E2EE 徽标(源码级约束):client 含徽标字段/文案与 reason
473
483
  assert.match(SOURCE, /describeE2ee/);
474
484
  assert.match(SOURCE, /dru-e2ee-line/);
475
485
  });
486
+
487
+ // ── 2026-10-17 版式与文案精简(业主 UX 反馈)──────────────────────────────────
488
+ // 动线:账号(我是谁 / 退出)→ 主行动(升级或续费)→ 次要(反馈)→ 更多(其它入口)。
489
+
490
+ test("★ 正面按钮收敛为三个:升级/续费 + 用户反馈 + 更多(不再一排按钮疯狂换行)", () => {
491
+ const plugin = loadPlugin();
492
+ plugin.states[0] = { config: { phone: "13800000000", deviceId: "dev-x" }, service: { running: true } };
493
+ const tree = plugin.render();
494
+ // 面板里可能有多处 .dru-actions(访问密钥卡等);账号卡那一处用「更多」按钮定位。
495
+ const actions = find(tree, (n) => {
496
+ if (n.props?.className !== "dru-actions") return false;
497
+ let hit = false;
498
+ walk(n, (x) => { if (x.props?.id === "dru-account-more") hit = true; });
499
+ return hit;
500
+ });
501
+ assert.ok(actions, "应有账号卡主按钮行");
502
+
503
+ const btns = [];
504
+ walk(actions, (n) => { if (n.type === "button") btns.push(n); });
505
+ // 三个按钮:主行动、用户反馈、更多
506
+ assert.equal(btns.length, 3, `正面按钮应为 3 个,实际 ${btns.length} 个(多了就是又回到"按钮墙")`);
507
+ const labels = btns.map((b) => JSON.stringify(b.children || []));
508
+ assert.ok(labels.some((t) => t.includes("升级 PRO") || t.includes("续费会员")), "第一个是主行动");
509
+ assert.ok(labels.some((t) => t.includes("用户反馈")), "用户反馈留在正面");
510
+ assert.ok(labels.some((t) => t.includes("更多")), "「更多」与主行动同排");
511
+
512
+ // 退出登录**不在**主按钮行里,而在账号行(.dru-user)右侧
513
+ const logoutInActions = btns.some((b) => (b.children || []).some((c) => c === "退出登录"));
514
+ assert.equal(logoutInActions, false, "退出登录不得再占主按钮行");
515
+ const userRow = find(tree, (n) => n.props?.className === "dru-user");
516
+ assert.ok(userRow, "应有账号行");
517
+ const logout = find(userRow, (n) => n.type === "button" && (n.children || []).includes("退出登录"));
518
+ assert.ok(logout, "退出登录应在账号行里(业主:放到账号那一行旁边)");
519
+ assert.match(String(logout.props.className || ""), /dru-user-logout/);
520
+ });
521
+
522
+ test("★ 次要入口全部在「更多」里:带新用户 / 修改密码 / 加入交流群(展开后可见)", () => {
523
+ const plugin = loadPlugin();
524
+ plugin.states[0] = { config: { phone: "13800000000", deviceId: "dev-x" }, service: { running: true } };
525
+ let tree = plugin.render();
526
+ const toggle = find(tree, (n) => n.props?.id === "dru-account-more");
527
+ assert.ok(toggle, "应有「更多」按钮");
528
+ toggle.props.onClick(); // 展开
529
+ tree = plugin.render();
530
+ const body = find(tree, (n) => n.props?.className === "dru-more-body");
531
+ assert.ok(body && body.props.hidden === false, "展开后「更多」内容体可见");
532
+ const btns = [];
533
+ walk(body, (n) => { if (n.type === "button") btns.push(n); });
534
+ const labels = btns.map((b) => JSON.stringify(b.children || []));
535
+ assert.ok(labels.some((t) => t.includes("带新用户换会员")), "带新用户换会员在「更多」里");
536
+ assert.ok(labels.some((t) => t.includes("修改密码")), "修改密码在「更多」里");
537
+ });
538
+
539
+ test("★ 删除了两段解释性文案:升级带登录态说明 / 重启 DeepSeek Harness 说明", () => {
540
+ assert.doesNotMatch(SOURCE, /升级\/续费以带登录态方式打开/, "升级说明必须删除(用户点一下就知道,不需要解释机制)");
541
+ assert.doesNotMatch(SOURCE, /桌面端由应用本身托管运行环境/, "重启说明必须删除");
542
+ assert.doesNotMatch(SOURCE, /请直接退出并重新打开应用/, "不得再引导退出重开应用");
543
+ });
544
+
545
+ // ── 2026-10-17 安装/配对自检(真机事故:~/.dsh-remote 被删后面板没有明确提示)────────
546
+ test("★ setup=配对缺失:面板顶部明确提示 + 可一键恢复历史配对", async () => {
547
+ const plugin = loadPlugin();
548
+ plugin.states[0] = {
549
+ config: {},
550
+ service: { running: false },
551
+ setup: {
552
+ kind: "pairing_missing", runtimePresent: true, configPresent: false,
553
+ text: "未配对:需要在面板里登录一次(账号密码),登录后会自动重建运行环境并启动 bridge",
554
+ detail: "本机有运行环境,但缺少账号配对信息",
555
+ relayDir: "/tmp/relay",
556
+ backup: {
557
+ available: true, name: "dsh-config-20261017.json", at: 1792200000000,
558
+ text: "检测到历史配对备份(2026-10-17 12:34),可一键恢复",
559
+ action: { kind: "restore_config", method: "POST", path: "/dsh-remote/config/restore", body: { backup: "dsh-config-20261017.json" } },
560
+ },
561
+ },
562
+ };
563
+ const tree = plugin.render();
564
+ assert.ok(textHas(tree, "未配对:需要在面板里登录一次"), "必须显示明确的配对缺失结论");
565
+ const restore = find(tree, (n) => n.type === "button" && (n.children || []).includes("恢复历史配对"));
566
+ assert.ok(restore, "有备份时必须给「恢复历史配对」按钮(不自动恢复,防误用旧账号)");
567
+ restore.props.onClick();
568
+ await flush();
569
+ await flush();
570
+ await flush();
571
+ const call = plugin.requests.find((r) => r.path === "/dsh-remote/config/restore");
572
+ assert.ok(call, "点击后必须调用恢复接口");
573
+ assert.equal(call.method, "POST");
574
+ assert.equal(call.body.backup, "dsh-config-20261017.json", "必须带上选定的备份文件名");
575
+ });
576
+
577
+ test("★ setup=ok / 缺字段:不渲染自检提示(绝不无病呻吟)", () => {
578
+ const plugin = loadPlugin();
579
+ plugin.states[0] = { config: { phone: "13800000000", deviceId: "dev-x" }, service: { running: true }, setup: { kind: "ok", runtimePresent: true, configPresent: true } };
580
+ const tree = plugin.render();
581
+ assert.equal(textHas(tree, "未配对"), false);
582
+ assert.equal(textHas(tree, "运行环境缺失"), false);
583
+ // 缺 setup 字段(旧 node 半)同样安静
584
+ const plugin2 = loadPlugin();
585
+ plugin2.states[0] = { config: { phone: "13800000000", deviceId: "dev-x" }, service: { running: true } };
586
+ const tree2 = plugin2.render();
587
+ assert.equal(textHas(tree2, "未配对"), false);
588
+ });