Răsfoiți Sursa

fix(login): 补登录页端切换提示 + isMobile 改 computed

## 用户反馈
电脑打开显示 mobile 版式,缩放也是 mobile,且无「切到 PC」提示。

## 实测诊断(Playwright 4 场景复现)
- 根因1:localStorage 残留 fmode_ui_mode_override='mobile'
  (手动偏好优先于 UA 判定,FR-10 AC-10.1 设计如此,非缺陷)
- 根因2:🔴 登录页 /login 不在任何 shell 内 → 缺端切换提示
  (mobile-shell 有 .wide-mobile,登录页没有)

## 修复
① 登录页加端切换提示
   - pages/login/login.ts 模板末尾加 wide-login 提示层
   - isMobile() && wideViewport() 时显示「切换到桌面端」按钮
   - 新增 goDesktop()(复用 device.switchMode())
   - 样式走设计令牌,层级用 --z-mask
③ isMobile 改 computed
   - signal(...) 构造时求值一次后固定,无法反映 device.mode() 变化
   - computed(() => this.device.mode() === 'mobile') 自动跟随

## 实测验证
- 桌面 UA 1440 无 override → 桌面版式 ✅
- 移动 UA 390 无 override → 移动版式 ✅
- 移动 UA 390 + override=mobile → 移动版式,无提示 ✅
- 桌面 UA 1440 + override=mobile → 移动版式 + 切端提示 ✅(用户场景)

## 验证
ng test 484 / smoke 28/0 / verify:ui 11/11 / verify:docs 44 / build 通过

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 9 ore în urmă
părinte
comite
c21d771226
2 a modificat fișierele cu 104 adăugiri și 1 ștergeri
  1. 13 0
      docs/00-tracking/操作追踪.md
  2. 91 1
      src/app/pages/login/login.ts

+ 13 - 0
docs/00-tracking/操作追踪.md

@@ -750,3 +750,16 @@
 | 验证证据(全实测) | ① **分页完整性**:`skip=0/10/20/30/40/50` + `limit=10` → 累计 **54 条、去重 54、无重复、覆盖全部**;`total=54` 正确<br>② **边界**:`limit=9999` → 被限制 500 ✅;`limit=0` → 回退全量 54 ✅<br>③ **超管过滤**:`scope=all&companyId=rpnXZC1loU` → **6 个,与直查库逐条一致** ✅<br>④ **非法 companyId** → `400 companyId 无效` ✅<br>⑤ **三身份权限**(真实 sessionToken 端到端):超管 all=54/company=6/mine=11;公司管理员 all=**403**/company=1/mine=0;普通成员 all=**403**/company=1/mine=0 ✅<br>⑥ **回归**:`ng test` 484 / build / `verify:ui` 11 / `verify:docs` 44 / 云函数验收 **9/9** |
 | 恰实说明(前端分页) | 前端仍为「一次拉全量 + 前端切片」(`visibleAgents`),**未改为服务端分页**——因为当前数据量(54/6/0~11)远小于 500 上限,前端切片性能更好。**后端分页能力作为预留**,数据量增长时可直接启用 |
 | 备份 | `.local-backup/xlosi3m46q-v0.1.1-fallback-已发布备份.js`(回滚基线)|
+
+### 2026-10-20 — 登录页端切换补全 + isMobile 改 computed
+
+| 项 | 内容 |
+|---|---|
+| **用户反馈** | 「电脑进入直接是 mobile 端,缩放也是 mobile 端,不会提示说切换 pc 端」|
+| **实测诊断** | 用 Playwright 逐场景复现:<br>· **根因 1**:浏览器 `localStorage` 残留 `fmode_ui_mode_override='mobile'`(我此前做冒烟测试时写入)→ 手动偏好**优先于** UA 判定(FR-10 AC-10.1 设计)<br>· **根因 2**:🔴 **登录页 `/login` 不在任何 shell 内 → 缺「切到桌面端」提示**(mobile-shell 有,登录页没有)|
+| **修复 ① 登录页加端切换提示** | ✏️ `pages/login/login.ts`:模板末尾加 `wide-login` 提示层(`isMobile() && wideViewport()` 时显示)+ `goDesktop()` 方法(复用 `device.switchMode()`)+ 完整样式(令牌取值,`--z-mask` 层级)|
+| **修复 ③ `isMobile` 改 computed** | ✏️ `isMobile: signal(...)` → `computed(() => this.device.mode() === 'mobile')`。`signal()` 构造时求值一次后固定,无法反映 `device.mode()` 变化;`computed` 跟随 signal 自动更新 |
+| **实测验证(4 场景)** | · 桌面 UA 1440 无 override → 桌面版式 ✅<br>· 移动 UA 390 无 override → 移动版式 ✅<br>· 移动 UA 390 + override=mobile → 移动版式,无提示(窗口不够宽)✅<br>· **桌面 UA 1440 + override=mobile → 移动版式 + ✅「切换到桌面端」提示**(即用户遇到的场景)|
+| 根因 1 的处置 | `localStorage` 残留是**测试副作用**(冒烟脚本 `fixMode()` 写入)。已确认**代码无缺陷**(偏好优先是设计)。用户侧清理方式:DevTools → Application → localStorage → 删 `fmode_ui_mode_override`;或 App 内「我的 → 恢复自动识别」|
+| 验证证据 | `ng test` **484** / `smoke` **28/0** / `verify:ui` **11/11** / `verify:docs` **44** / `build` 通过 |
+| 未做(用户未选) | ② 冒烟脚本清理 localStorage 残留(用户未勾选)|

+ 91 - 1
src/app/pages/login/login.ts

@@ -199,6 +199,23 @@ type LoginMethod = 'mobile' | 'token';
         <p class="hint"><i class="fa-solid fa-circle-info" aria-hidden="true"></i>SessionToken 可在 Fmode 平台账号页查看</p>
       }
     </ng-template>
+
+    <!--
+      🔴 端切换提示(与 mobile-shell 同口径)。
+      场景:端判定为 mobile,但当前窗口已达桌面宽度 → 提示可切到桌面端。
+      此前 /login 不在任何 shell 内,**缺这个提示**,用户在电脑上遇到
+      mobile 版式时无从切换。
+    -->
+    @if (isMobile() && wideViewport()) {
+      <div class="wide-login" role="alertdialog" aria-label="窗口适合桌面端">
+        <div class="wide-card">
+          <span class="wide-icon" aria-hidden="true"><i class="fa-solid fa-display"></i></span>
+          <h2>当前窗口适合桌面端</h2>
+          <p>已检测到窗口宽度达到 768px,可以切换到桌面布局继续使用。</p>
+          <button type="button" class="wide-btn" (click)="goDesktop()">切换到桌面端</button>
+        </div>
+      </div>
+    }
   `,
   styles: [
     `
@@ -592,6 +609,56 @@ type LoginMethod = 'mobile' | 'token';
           display: none;
         }
       }
+
+      /* 端切换提示层(与 mobile-shell 的 .wide-mobile 同口径) */
+      .wide-login {
+        position: fixed;
+        inset: 0;
+        z-index: var(--z-mask);
+        display: grid;
+        place-items: center;
+        padding: var(--sp-6);
+        background: var(--canvas);
+      }
+      .wide-card {
+        display: grid;
+        justify-items: center;
+        gap: var(--sp-2);
+        max-width: 360px;
+        text-align: center;
+      }
+      .wide-icon {
+        display: grid;
+        place-items: center;
+        width: 56px;
+        height: 56px;
+        border-radius: var(--r-lg);
+        background: var(--primary-subtle);
+        color: var(--primary);
+        font-size: var(--fs-20);
+      }
+      .wide-card h2 {
+        margin: 0;
+        color: var(--ink);
+        font-size: var(--fs-20);
+      }
+      .wide-card p {
+        margin: 0;
+        color: var(--muted);
+        font-size: var(--fs-13);
+        line-height: 1.7;
+      }
+      .wide-btn {
+        min-height: var(--touch-min);
+        margin-top: var(--sp-2);
+        padding: 0 var(--sp-4);
+        border: 0;
+        border-radius: var(--r-sm);
+        background: var(--primary);
+        color: var(--primary-content);
+        font-size: var(--fs-13);
+        cursor: pointer;
+      }
     `,
   ],
 })
@@ -611,7 +678,19 @@ export class LoginComponent {
   readonly busy = signal(false);
   readonly sendingCode = signal(false);
   readonly countdown = signal(0);
-  readonly isMobile = signal(this.device.mode() === 'mobile');
+  /**
+   * 是否移动端版式。
+   *
+   * 🔴 用 `computed` 而非 `signal(...)` —— `signal()` 会在**构造时求值一次**并固定,
+   *    无法反映 `device.mode()` 的后续变化(如用户在页面上手动切端)。
+   *    `computed` 跟随 `device.mode()` 的 signal 自动更新。
+   */
+  readonly isMobile = computed(() => this.device.mode() === 'mobile');
+
+  /** 窗口是否达到桌面宽度(用于「切到桌面端」提示,与 mobile-shell 同口径) */
+  readonly wideViewport = signal(
+    typeof window !== 'undefined' && window.innerWidth >= 768,
+  );
   readonly mobileValid = computed(() => /^1\d{10}$/.test(this.mobile()));
   readonly codeValid = computed(() => /^\d{6}$/.test(this.code()));
   readonly tokenValid = computed(() => this.token().trim().length > 0);
@@ -770,6 +849,17 @@ export class LoginComponent {
     await this.router.navigateByUrl(this.device.normalize(target), { replaceUrl: true });
   }
 
+  /**
+   * 切换到桌面端(登录页的端切换提示用)。
+   *
+   * 与 `mobile-shell` 的 `goDesktop()` 同逻辑:写偏好 → 跳桌面落地页。
+   * 登录页不在 shell 内,故需自带这个出口。
+   */
+  goDesktop(): void {
+    const { path } = this.device.switchMode();
+    void this.router.navigateByUrl(path, { replaceUrl: true });
+  }
+
   private messageOf(e: unknown): string {
     return e instanceof Error && e.message ? e.message : '登录失败,请稍后重试';
   }