device.service.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. import { Injectable, signal } from '@angular/core';
  2. export type UiMode = 'desktop' | 'mobile';
  3. /** 端偏好持久化键 —— 沿用存量同名键(FR-16 AC-16.1:旧键能正确读取) */
  4. const MODE_KEY = 'fmode_ui_mode_override';
  5. /** 窄屏阈值 —— 与 SPEC NFR-05 断点定值的 768 一致,避免两套窄屏判定 */
  6. export const NARROW_PX = 768;
  7. /**
  8. * 推断当前该用哪端(纯函数,2026-11-10 导出)。
  9. *
  10. * 🔴 抽成导出的纯函数是**为了消除重复**:`app.routes.ts` 曾逐字复制一份,
  11. * 两处分支一旦不同步,就会出现「登录页判移动、入口判桌面」。
  12. * 现在路由与 `DeviceService` 都调这一个。
  13. *
  14. * ## 2026-11-10 用户裁决:去掉手动切换,纯自动识别
  15. *
  16. * 用户原话:「不需要当前还有一个页面手动切换,而是自动识别它的比例和屏幕大小,
  17. * 进行自动切换效果。」
  18. *
  19. * 判定顺序 —— **视口尺寸优先,UA 兜底**:
  20. * ① 视口窄于 `NARROW_PX` → 移动端
  21. * ② 宽度够但 UA 明确是手机(横屏手机 innerWidth 可达 844)→ 仍是移动端
  22. * ③ 无窗口环境(SSR / 测试)→ 回落 UA 判定
  23. * ④ 其余 → 桌面端
  24. *
  25. * 🔴 为什么视口优先于 UA:
  26. * UA 描述的是**设备**,不是**当前这个窗口**。用户在电脑上把窗口拖窄、
  27. * 或在平板上分屏,UA 仍然是桌面/平板,但可用宽度只有手机那么宽 ——
  28. * 此时应该给移动端布局,而不是硬塞桌面端(用户反馈的「切换异常」)。
  29. */
  30. export function detectUiMode(): UiMode {
  31. if (typeof window !== 'undefined' && window.innerWidth > 0) {
  32. /* ① 视口宽度 —— 主判据 */
  33. if (window.innerWidth < NARROW_PX) return 'mobile';
  34. /* ② 横屏手机:宽度够但仍是手机 */
  35. if (isPhoneUa()) return 'mobile';
  36. return 'desktop';
  37. }
  38. /* ③ 无窗口环境(SSR / 单测未铺视口)→ 回落 UA */
  39. if (typeof navigator === 'undefined') return 'desktop';
  40. if (isPhoneUa()) return 'mobile';
  41. // ④ 平板(含 iPadOS 伪装的桌面 UA)→ 桌面端
  42. return 'desktop';
  43. }
  44. /** UA 是否明确是手机 */
  45. function isPhoneUa(): boolean {
  46. if (typeof navigator === 'undefined') return false;
  47. return /iPhone|iPod|Android.*Mobile|Windows Phone|BlackBerry|webOS|Opera Mini/i.test(
  48. navigator.userAgent,
  49. );
  50. }
  51. /**
  52. * 当前视口是不是「窄屏」(该给移动端)。
  53. *
  54. * 与 `detectUiMode()` 的区别:**只看宽度,不看 UA**。
  55. * 用于「桌面浏览器把窗口拖窄」的场景 —— 这种情形 UA 是桌面,
  56. * 但布局已经装不下桌面端,应该提示用户切到移动端。
  57. */
  58. export function isNarrowViewport(): boolean {
  59. return typeof window !== 'undefined' && window.innerWidth < NARROW_PX;
  60. }
  61. /**
  62. * 端判定与路径归一 —— **全项目唯一事实源**
  63. *
  64. * 依据 ADR-004(双端双套路由树)+ SPEC FR-01 / FR-16。约束:
  65. * · 端判定**一次性**发生在入口页,之后不随窗口缩放变化(避免布局跳变)
  66. * · `normalize()` 是**旧路径重定向的唯一事实源**(FR-16 AC-16.2 引用的清单实现在此)
  67. *
  68. * ## 2026-11-10 用户裁决:去掉手动切换,纯自动识别
  69. *
  70. * 因此:
  71. * · **不再读写** `fmode_ui_mode_override`(保留 `force()` 仅为兼容旧调用点)
  72. * · 判定改为 **视口尺寸优先**:窄屏(< 768px)走移动端,否则桌面端
  73. * · 跨端切换由双端外壳自行处理:**防抖后静默跳转,不弹确认**
  74. */
  75. @Injectable({ providedIn: 'root' })
  76. export class DeviceService {
  77. /** 当前端 —— 纯自动识别,不再读手动偏好 */
  78. private readonly _mode = signal<UiMode>(this.detect());
  79. readonly mode = this._mode.asReadonly();
  80. /** 兼容字段:自动识别模式下永远为 false */
  81. readonly isForced = signal(false);
  82. /**
  83. * 依据 UA + 视口推断当前该用哪端(纯自动识别)。
  84. *
  85. * 判定口径全部收敛在 `detectUiMode()`,这里只做实例代理。
  86. */
  87. detect(): UiMode {
  88. return detectUiMode();
  89. }
  90. /**
  91. * 窗口尺寸变化时重新判定(2026-11-10)。
  92. *
  93. * 用户切换浏览器窗口大小、旋转设备、分屏时调用。
  94. * 返回 `true` 表示端发生了变化,调用方应导航到对应落点。
  95. *
  96. * ⚠️ 刻意**不自动导航**:只在调用方明确要切(例如入口页或外壳)时才用,
  97. * 避免用户正在输入时页面突然跳走。
  98. */
  99. recheck(): { changed: boolean; mode: UiMode } {
  100. const next = this.detect();
  101. const changed = next !== this._mode();
  102. if (changed) this._mode.set(next);
  103. return { changed, mode: next };
  104. }
  105. /** 入口页落点 */
  106. entryPath(): string {
  107. return this._mode() === 'mobile' ? '/mobile/messages' : '/desktop/chat';
  108. }
  109. /**
  110. * 强制某端。
  111. *
  112. * @deprecated 2026-11-10 用户裁决去掉手动切换。保留方法只为不破坏既有调用点,
  113. * **不再写入 localStorage**,仅当次生效,刷新后回到自动识别。
  114. */
  115. force(mode: UiMode): void {
  116. this._mode.set(mode);
  117. this.isForced.set(true);
  118. }
  119. /**
  120. * 恢复自动识别。
  121. *
  122. * @deprecated 2026-11-10 起永远处于自动识别,此方法只做重新判定。
  123. */
  124. resetToAuto(): UiMode {
  125. try {
  126. localStorage.removeItem(MODE_KEY);
  127. } catch {
  128. /* 忽略 */
  129. }
  130. const mode = this.detect();
  131. this._mode.set(mode);
  132. this.isForced.set(false);
  133. return mode;
  134. }
  135. /**
  136. * 切换端并返回目标落点(自动跳转用)。
  137. *
  138. * 2026-11-10:不再写持久化偏好 —— 端由屏幕尺寸决定,
  139. * 手动「钉住」会让用户在小屏看到桌面布局、很别扭。
  140. */
  141. switchMode(): { mode: UiMode; path: string } {
  142. const next: UiMode = this._mode() === 'mobile' ? 'desktop' : 'mobile';
  143. this._mode.set(next);
  144. return { mode: next, path: next === 'mobile' ? '/mobile/messages' : '/desktop/chat' };
  145. }
  146. /**
  147. * 生成当前端下的绝对路径:`path('/chat/x')` → `/desktop/chat/x`
  148. *
  149. * 🔴 `normalize()` 对「旧路径」返回**已带端前缀**的路径,
  150. * 故此处必须先判重,否则 `/chat/x` 会被拼成 `/desktop/desktop/chat/x`。
  151. */
  152. path(logical: string): string {
  153. const n = this.normalize(logical);
  154. return n.startsWith('/desktop') || n.startsWith('/mobile') ? n : `/${this._mode()}${n}`;
  155. }
  156. /**
  157. * 旧路径 → 当前端逻辑路径(FR-16 AC-16.2 的**唯一实现**)
  158. *
  159. * 覆盖清单:/messages /workbench /deliverables /mine /chat/:id /agent/:id /call/:agentId
  160. * `/call/:agentId` 的落点由 SPEC FR-11 裁决:**重定向到数字生命详情**,
  161. * 由详情页给出「语音通话功能暂未开放」提示;**禁止**为通话建路由或空壳页面。
  162. */
  163. normalize(input: string): string {
  164. if (!input) return this.entryPath();
  165. // 已带端前缀则原样返回
  166. if (input.startsWith('/desktop') || input.startsWith('/mobile')) return input;
  167. const [rawPath, query = ''] = input.split('?');
  168. const segs = rawPath.split('/').filter(Boolean);
  169. const qs = query ? `?${query}` : '';
  170. if (segs.length === 0) return this.entryPath();
  171. const [head, id] = segs;
  172. switch (head) {
  173. case 'messages':
  174. case 'tabs':
  175. // 桌面端无独立会话列表页,落聊天页
  176. return this._mode() === 'mobile' ? `/mobile/messages${qs}` : `/desktop/chat${qs}`;
  177. case 'chat':
  178. return this.pathWithId('/chat', id, qs);
  179. case 'agent':
  180. return this.pathWithId('/agent', id, qs);
  181. case 'call':
  182. // FR-11:通话暂不实现,落详情页(由详情页提示「暂未开放」)
  183. return this.pathWithId('/agent', id, qs);
  184. /*
  185. * 工作台 2026-11-11 下线:这里仍产出 `/desktop/workbench`,
  186. * 由各端路由的 redirectTo 接到会话页(chat / messages),归一逻辑本身不必特判。
  187. */
  188. case 'workbench':
  189. case 'deliverables':
  190. case 'mine':
  191. case 'contacts':
  192. case 'projects':
  193. case 'events':
  194. case 'home':
  195. case 'tasks':
  196. return `/${this._mode()}/${head}${qs}`;
  197. default:
  198. return this.entryPath();
  199. }
  200. }
  201. private pathWithId(base: string, id: string | undefined, qs: string): string {
  202. return id ? `/${this._mode()}${base}/${id}${qs}` : `/${this._mode()}${base}${qs}`;
  203. }
  204. }