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(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}`; } }