messages.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. import { MAvatarComponent } from '../../components/m-avatar/m-avatar';
  2. import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
  3. import { Router } from '@angular/router';
  4. import { UiMSearch } from '../../../ui';
  5. import { StatePanelComponent } from '../../../components/feedback/state-panel/state-panel';
  6. import { LiveService } from '../../../core/live.service';
  7. import { DeviceService } from '../../../core/device.service';
  8. import { isMatrixReady } from '../../../core/format';
  9. import { fmtRelTime } from '../../../core/time';
  10. /**
  11. * 移动·会话列表(SPEC FR-04 移动形态)
  12. *
  13. * 列表头 + 搜索 + 全部/未读 Tab + 会话行。
  14. * 返回列表时**保持滚动位置**(AC-05.2 同源要求,由 Router 的滚动恢复 + 路径缓存保证)。
  15. */
  16. @Component({
  17. selector: 'app-mb-messages-page',
  18. changeDetection: ChangeDetectionStrategy.OnPush,
  19. imports: [StatePanelComponent, UiMSearch, MAvatarComponent],
  20. template: `
  21. <div class="m-page">
  22. <header class="m-head">
  23. <div>
  24. <h1>会话</h1>
  25. <p>与数字生命一对一沟通。</p>
  26. </div>
  27. </header>
  28. <ui-m-search placeholder="搜索会话" [value]="keyword()" (valueChange)="keyword.set($event)" />
  29. <div class="m-tabs" role="tablist" aria-label="会话筛选">
  30. <button
  31. type="button"
  32. role="tab"
  33. class="m-tab"
  34. [class.active]="tab() === 'all'"
  35. [attr.aria-selected]="tab() === 'all'"
  36. (click)="tab.set('all')"
  37. >
  38. 全部 <span class="tab-count">{{ list().length }}</span>
  39. </button>
  40. <button
  41. type="button"
  42. role="tab"
  43. class="m-tab"
  44. [class.active]="tab() === 'unread'"
  45. [attr.aria-selected]="tab() === 'unread'"
  46. (click)="tab.set('unread')"
  47. >
  48. 未读 <span class="tab-count">{{ unreadCount() }}</span>
  49. </button>
  50. </div>
  51. @switch (live.state()) {
  52. @case ('loading') {
  53. <app-state-panel state="loading" loadingText="正在加载会话…" />
  54. }
  55. @case ('error') {
  56. <app-state-panel state="error" [message]="live.error()" (retry)="reload()" />
  57. }
  58. @case ('empty') {
  59. <app-state-panel state="empty" title="暂无会话" message="接入数字生命后即可开始对话。" [retryable]="false" />
  60. }
  61. @default {
  62. @if (filtered().length === 0) {
  63. <app-state-panel state="empty" title="没有匹配的会话" [retryable]="false" />
  64. } @else {
  65. <div class="chat-list" data-testid="chat-list">
  66. @for (c of filtered(); track c.id) {
  67. <button type="button" class="chat-row" (click)="open(c.id)">
  68. <app-m-avatar [name]="c.name" />
  69. <span class="chat-body">
  70. <span class="chat-line">
  71. <strong class="truncate">{{ c.name }}</strong>
  72. <time>{{ rel(c.updatedAt) }}</time>
  73. </span>
  74. <span class="chat-preview truncate">
  75. {{ c.currentTask || c.specialty }}
  76. </span>
  77. </span>
  78. @if (c.unread > 0) {
  79. <span class="unread">{{ c.unread }}</span>
  80. }
  81. </button>
  82. }
  83. </div>
  84. }
  85. }
  86. }
  87. <p class="foot-note">
  88. <i class="fa-solid fa-lock" aria-hidden="true"></i>
  89. 消息经 Matrix 通道加密传输
  90. </p>
  91. </div>
  92. `,
  93. styles: [
  94. `
  95. .m-page {
  96. padding: var(--sp-5) var(--mob-gutter) var(--sp-6);
  97. }
  98. .m-head {
  99. margin-bottom: var(--sp-4);
  100. }
  101. .m-head h1 {
  102. margin: 0;
  103. font-size: var(--fs-26);
  104. color: var(--ink);
  105. }
  106. .m-head p {
  107. margin: var(--sp-1) 0 0;
  108. font-size: var(--fs-13);
  109. color: var(--muted);
  110. }
  111. .m-search {
  112. display: flex;
  113. align-items: center;
  114. gap: var(--sp-2);
  115. min-height: 46px;
  116. padding: 0 var(--sp-3);
  117. margin-bottom: var(--sp-3);
  118. border: 1px solid var(--edge);
  119. border-radius: var(--r-md);
  120. background: var(--surface);
  121. color: var(--muted);
  122. }
  123. .m-search input {
  124. flex: 1;
  125. min-width: 0;
  126. border: 0;
  127. background: transparent;
  128. color: var(--ink);
  129. /* ≥16px,iOS 聚焦不放大(AC-19.3 同源要求) */
  130. font-size: var(--fs-17);
  131. }
  132. .m-tabs {
  133. display: flex;
  134. gap: var(--sp-1);
  135. padding: var(--sp-1);
  136. margin-bottom: var(--sp-3);
  137. border-radius: var(--r-md);
  138. background: var(--surface-2);
  139. }
  140. .m-tab {
  141. flex: 1;
  142. display: inline-flex;
  143. align-items: center;
  144. justify-content: center;
  145. gap: var(--sp-1);
  146. min-height: var(--touch-min);
  147. border: 0;
  148. border-radius: var(--r-sm);
  149. background: transparent;
  150. color: var(--ink-2);
  151. font-size: var(--fs-13);
  152. cursor: pointer;
  153. }
  154. .m-tab.active {
  155. background: var(--surface);
  156. color: var(--ink);
  157. font-weight: 600;
  158. box-shadow: var(--shadow-soft);
  159. }
  160. .tab-count {
  161. font-size: var(--fs-11);
  162. opacity: 0.7;
  163. }
  164. .chat-list {
  165. display: grid;
  166. gap: var(--sp-2);
  167. }
  168. .chat-row {
  169. display: flex;
  170. align-items: center;
  171. gap: var(--sp-3);
  172. width: 100%;
  173. min-height: 68px;
  174. padding: var(--sp-2) var(--sp-3);
  175. border: 1px solid var(--edge);
  176. border-radius: var(--r-md);
  177. background: var(--surface);
  178. text-align: left;
  179. cursor: pointer;
  180. }
  181. .chat-row:active {
  182. background: var(--surface-hover);
  183. }
  184. .chat-body {
  185. display: grid;
  186. gap: 2px;
  187. flex: 1;
  188. min-width: 0;
  189. }
  190. .chat-line {
  191. display: flex;
  192. align-items: center;
  193. justify-content: space-between;
  194. gap: var(--sp-2);
  195. }
  196. .chat-line strong {
  197. font-size: var(--fs-15);
  198. color: var(--ink);
  199. }
  200. .chat-line time {
  201. font-size: var(--fs-11);
  202. color: var(--muted);
  203. flex-shrink: 0;
  204. }
  205. .chat-preview {
  206. font-size: var(--fs-12);
  207. color: var(--muted);
  208. }
  209. .unread {
  210. min-width: 18px;
  211. padding: 0 5px;
  212. border-radius: var(--r-tag);
  213. background: var(--danger);
  214. color: var(--on-danger);
  215. font-size: var(--fs-11);
  216. line-height: 18px;
  217. text-align: center;
  218. flex-shrink: 0;
  219. }
  220. .foot-note {
  221. display: flex;
  222. align-items: center;
  223. justify-content: center;
  224. gap: var(--sp-1);
  225. margin-top: var(--sp-5);
  226. font-size: var(--fs-11);
  227. color: var(--muted);
  228. }
  229. .truncate {
  230. overflow: hidden;
  231. text-overflow: ellipsis;
  232. white-space: nowrap;
  233. }
  234. `,
  235. ],
  236. })
  237. export class MbMessagesPage implements OnInit {
  238. readonly live = inject(LiveService);
  239. private readonly router = inject(Router);
  240. private readonly device = inject(DeviceService);
  241. readonly tab = signal<'all' | 'unread'>('all');
  242. readonly keyword = signal('');
  243. readonly rel = fmtRelTime;
  244. readonly list = computed(() => this.live.agents().filter((a) => isMatrixReady(a)));
  245. readonly unreadCount = computed(() =>
  246. this.list().filter((a) => a.unread > 0).length,
  247. );
  248. readonly filtered = computed(() => {
  249. const q = this.keyword().trim().toLowerCase();
  250. return this.list().filter((a) => {
  251. if (this.tab() === 'unread' && a.unread === 0) return false;
  252. if (!q) return true;
  253. return (
  254. a.name.toLowerCase().includes(q) || (a.specialty ?? '').toLowerCase().includes(q)
  255. );
  256. });
  257. });
  258. async ngOnInit(): Promise<void> {
  259. await this.live.ensureLoaded();
  260. }
  261. async reload(): Promise<void> {
  262. await this.live.refresh();
  263. }
  264. onSearch(e: Event): void {
  265. this.keyword.set((e.target as HTMLInputElement).value);
  266. }
  267. open(id: string): void {
  268. void this.router.navigate(['/', this.device.mode(), 'chat', id]);
  269. }
  270. }