| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227 |
- import { Injectable, signal } from '@angular/core';
- export type UiMode = 'desktop' | 'mobile';
- /** 端偏好持久化键 —— 沿用存量同名键(FR-16 AC-16.1:旧键能正确读取) */
- const MODE_KEY = 'fmode_ui_mode_override';
- /** 窄屏阈值 —— 与 SPEC NFR-05 断点定值的 768 一致,避免两套窄屏判定 */
- export const NARROW_PX = 768;
- /**
- * 推断当前该用哪端(纯函数,2026-11-10 导出)。
- *
- * 🔴 抽成导出的纯函数是**为了消除重复**:`app.routes.ts` 曾逐字复制一份,
- * 两处分支一旦不同步,就会出现「登录页判移动、入口判桌面」。
- * 现在路由与 `DeviceService` 都调这一个。
- *
- * ## 2026-11-10 用户裁决:去掉手动切换,纯自动识别
- *
- * 用户原话:「不需要当前还有一个页面手动切换,而是自动识别它的比例和屏幕大小,
- * 进行自动切换效果。」
- *
- * 判定顺序 —— **视口尺寸优先,UA 兜底**:
- * ① 视口窄于 `NARROW_PX` → 移动端
- * ② 宽度够但 UA 明确是手机(横屏手机 innerWidth 可达 844)→ 仍是移动端
- * ③ 无窗口环境(SSR / 测试)→ 回落 UA 判定
- * ④ 其余 → 桌面端
- *
- * 🔴 为什么视口优先于 UA:
- * UA 描述的是**设备**,不是**当前这个窗口**。用户在电脑上把窗口拖窄、
- * 或在平板上分屏,UA 仍然是桌面/平板,但可用宽度只有手机那么宽 ——
- * 此时应该给移动端布局,而不是硬塞桌面端(用户反馈的「切换异常」)。
- */
- export function detectUiMode(): UiMode {
- if (typeof window !== 'undefined' && window.innerWidth > 0) {
- /* ① 视口宽度 —— 主判据 */
- if (window.innerWidth < NARROW_PX) return 'mobile';
- /* ② 横屏手机:宽度够但仍是手机 */
- if (isPhoneUa()) return 'mobile';
- return 'desktop';
- }
- /* ③ 无窗口环境(SSR / 单测未铺视口)→ 回落 UA */
- if (typeof navigator === 'undefined') return 'desktop';
- if (isPhoneUa()) return 'mobile';
- // ④ 平板(含 iPadOS 伪装的桌面 UA)→ 桌面端
- return 'desktop';
- }
- /** UA 是否明确是手机 */
- function isPhoneUa(): boolean {
- if (typeof navigator === 'undefined') return false;
- return /iPhone|iPod|Android.*Mobile|Windows Phone|BlackBerry|webOS|Opera Mini/i.test(
- navigator.userAgent,
- );
- }
- /**
- * 当前视口是不是「窄屏」(该给移动端)。
- *
- * 与 `detectUiMode()` 的区别:**只看宽度,不看 UA**。
- * 用于「桌面浏览器把窗口拖窄」的场景 —— 这种情形 UA 是桌面,
- * 但布局已经装不下桌面端,应该提示用户切到移动端。
- */
- export function isNarrowViewport(): boolean {
- return typeof window !== 'undefined' && window.innerWidth < NARROW_PX;
- }
- /**
- * 端判定与路径归一 —— **全项目唯一事实源**
- *
- * 依据 ADR-004(双端双套路由树)+ SPEC FR-01 / FR-16。约束:
- * · 端判定**一次性**发生在入口页,之后不随窗口缩放变化(避免布局跳变)
- * · `normalize()` 是**旧路径重定向的唯一事实源**(FR-16 AC-16.2 引用的清单实现在此)
- *
- * ## 2026-11-10 用户裁决:去掉手动切换,纯自动识别
- *
- * 因此:
- * · **不再读写** `fmode_ui_mode_override`(保留 `force()` 仅为兼容旧调用点)
- * · 判定改为 **视口尺寸优先**:窄屏(< 768px)走移动端,否则桌面端
- * · 跨端切换由双端外壳自行处理:**防抖后静默跳转,不弹确认**
- */
- @Injectable({ providedIn: 'root' })
- export class DeviceService {
- /** 当前端 —— 纯自动识别,不再读手动偏好 */
- private readonly _mode = signal<UiMode>(this.detect());
- readonly mode = this._mode.asReadonly();
- /** 兼容字段:自动识别模式下永远为 false */
- readonly isForced = signal(false);
- /**
- * 依据 UA + 视口推断当前该用哪端(纯自动识别)。
- *
- * 判定口径全部收敛在 `detectUiMode()`,这里只做实例代理。
- */
- detect(): UiMode {
- return detectUiMode();
- }
- /**
- * 窗口尺寸变化时重新判定(2026-11-10)。
- *
- * 用户切换浏览器窗口大小、旋转设备、分屏时调用。
- * 返回 `true` 表示端发生了变化,调用方应导航到对应落点。
- *
- * ⚠️ 刻意**不自动导航**:只在调用方明确要切(例如入口页或外壳)时才用,
- * 避免用户正在输入时页面突然跳走。
- */
- recheck(): { changed: boolean; mode: UiMode } {
- const next = this.detect();
- const changed = next !== this._mode();
- if (changed) this._mode.set(next);
- return { changed, mode: next };
- }
- /** 入口页落点 */
- entryPath(): string {
- return this._mode() === 'mobile' ? '/mobile/messages' : '/desktop/chat';
- }
- /**
- * 强制某端。
- *
- * @deprecated 2026-11-10 用户裁决去掉手动切换。保留方法只为不破坏既有调用点,
- * **不再写入 localStorage**,仅当次生效,刷新后回到自动识别。
- */
- force(mode: UiMode): void {
- this._mode.set(mode);
- this.isForced.set(true);
- }
- /**
- * 恢复自动识别。
- *
- * @deprecated 2026-11-10 起永远处于自动识别,此方法只做重新判定。
- */
- resetToAuto(): UiMode {
- try {
- localStorage.removeItem(MODE_KEY);
- } catch {
- /* 忽略 */
- }
- const mode = this.detect();
- this._mode.set(mode);
- this.isForced.set(false);
- return mode;
- }
- /**
- * 切换端并返回目标落点(自动跳转用)。
- *
- * 2026-11-10:不再写持久化偏好 —— 端由屏幕尺寸决定,
- * 手动「钉住」会让用户在小屏看到桌面布局、很别扭。
- */
- switchMode(): { mode: UiMode; path: string } {
- const next: UiMode = this._mode() === 'mobile' ? 'desktop' : 'mobile';
- this._mode.set(next);
- return { mode: next, path: next === 'mobile' ? '/mobile/messages' : '/desktop/chat' };
- }
- /**
- * 生成当前端下的绝对路径:`path('/chat/x')` → `/desktop/chat/x`
- *
- * 🔴 `normalize()` 对「旧路径」返回**已带端前缀**的路径,
- * 故此处必须先判重,否则 `/chat/x` 会被拼成 `/desktop/desktop/chat/x`。
- */
- path(logical: string): string {
- const n = this.normalize(logical);
- return n.startsWith('/desktop') || n.startsWith('/mobile') ? n : `/${this._mode()}${n}`;
- }
- /**
- * 旧路径 → 当前端逻辑路径(FR-16 AC-16.2 的**唯一实现**)
- *
- * 覆盖清单:/messages /workbench /deliverables /mine /chat/:id /agent/:id /call/:agentId
- * `/call/:agentId` 的落点由 SPEC FR-11 裁决:**重定向到数字生命详情**,
- * 由详情页给出「语音通话功能暂未开放」提示;**禁止**为通话建路由或空壳页面。
- */
- normalize(input: string): string {
- if (!input) return this.entryPath();
- // 已带端前缀则原样返回
- if (input.startsWith('/desktop') || input.startsWith('/mobile')) return input;
- const [rawPath, query = ''] = input.split('?');
- const segs = rawPath.split('/').filter(Boolean);
- const qs = query ? `?${query}` : '';
- if (segs.length === 0) return this.entryPath();
- const [head, id] = segs;
- switch (head) {
- case 'messages':
- case 'tabs':
- // 桌面端无独立会话列表页,落聊天页
- return this._mode() === 'mobile' ? `/mobile/messages${qs}` : `/desktop/chat${qs}`;
- case 'chat':
- return this.pathWithId('/chat', id, qs);
- case 'agent':
- return this.pathWithId('/agent', id, qs);
- case 'call':
- // FR-11:通话暂不实现,落详情页(由详情页提示「暂未开放」)
- return this.pathWithId('/agent', id, qs);
- /*
- * 工作台 2026-11-11 下线:这里仍产出 `/desktop/workbench`,
- * 由各端路由的 redirectTo 接到会话页(chat / messages),归一逻辑本身不必特判。
- */
- case 'workbench':
- case 'deliverables':
- case 'mine':
- case 'contacts':
- case 'projects':
- case 'events':
- case 'home':
- case 'tasks':
- return `/${this._mode()}/${head}${qs}`;
- default:
- return this.entryPath();
- }
- }
- private pathWithId(base: string, id: string | undefined, qs: string): string {
- return id ? `/${this._mode()}${base}/${id}${qs}` : `/${this._mode()}${base}${qs}`;
- }
- }
|