contacts.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. import { ChatService } from '../../../core/chat.service';
  2. import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
  3. import { Router } from '@angular/router';
  4. import { ContactTab, ContactsService } from '../../../core/contacts.service';
  5. import { LiveService } from '../../../core/live.service';
  6. import { Agent, Person } from '../../../core/api/models';
  7. import { MAppBarComponent } from '../../components/m-app-bar/m-app-bar';
  8. import { MAvatarComponent } from '../../components/m-avatar/m-avatar';
  9. import { AGENT_TONE, BadgeTone, MBadgeComponent } from '../../components/m-badge/m-badge';
  10. import { MDrawerComponent } from '../../components/m-drawer/m-drawer';
  11. import { MEmptyComponent } from '../../components/m-empty/m-empty';
  12. import { MSegmentedComponent } from '../../components/m-segmented/m-segmented';
  13. import { MSearchComponent } from '../../components/m-search/m-search';
  14. /**
  15. * 通讯录(Demo 09)
  16. *
  17. * 三段:全部 / 数字生命 / 同事。同事行可原地展开其名下数字生命。
  18. * 点数字生命进入**容器设置**(2026-11-04 用户裁决;原为「进会话 vs 进详情」自动分流)。
  19. */
  20. @Component({
  21. selector: 'app-mb-contacts-page',
  22. changeDetection: ChangeDetectionStrategy.OnPush,
  23. imports: [
  24. MAppBarComponent,
  25. MAvatarComponent,
  26. MBadgeComponent,
  27. MDrawerComponent,
  28. MEmptyComponent,
  29. MSegmentedComponent,
  30. MSearchComponent,
  31. ],
  32. template: `
  33. <div class="m-page m-page--fixed">
  34. <app-m-app-bar title="通讯录" [menu]="true" (menuTap)="drawerOpen.set(true)" />
  35. <!--
  36. 固定头部(2026-11-04):分段筛选 + 搜索不随列表滚动。
  37. 与工作台同一套结构 —— 外壳 flex 列,头部 flex:none,内容区独立滚。
  38. -->
  39. <div class="m-seg-row">
  40. <app-m-segmented [options]="tabs" [value]="contacts.tab()" (valueChange)="onTab($event)" />
  41. </div>
  42. <div class="m-search-row">
  43. <app-m-search
  44. placeholder="搜索名称或专长"
  45. [value]="contacts.keyword()"
  46. (valueChange)="contacts.keyword.set($event)"
  47. />
  48. </div>
  49. <div class="m-body">
  50. @switch (contacts.state()) {
  51. @case ('loading') {
  52. <app-m-empty icon="fa-solid fa-spinner fa-spin" title="正在加载…" />
  53. }
  54. @case ('error') {
  55. <app-m-empty
  56. icon="fa-solid fa-triangle-exclamation"
  57. title="通讯录加载失败"
  58. [description]="contacts.error() ?? ''"
  59. [retryable]="true"
  60. (retry)="reload()"
  61. />
  62. }
  63. @default {
  64. <!--
  65. 同事在上、容器在下(2026-11-04 用户裁决)。
  66. 与 PC 通讯录同一结构:同事行可展开其名下容器;
  67. 容器区平铺全公司容器,点进即会话或详情。
  68. -->
  69. @if (contacts.visiblePeople().length > 0) {
  70. <section class="m-section m-section--first">
  71. <h2 class="m-section-title">同事 · {{ contacts.visiblePeople().length }}</h2>
  72. <ul class="m-list">
  73. @for (p of contacts.visiblePeople(); track p.id) {
  74. <li>
  75. <button
  76. type="button"
  77. class="m-row"
  78. [attr.aria-expanded]="isOpen(p)"
  79. (click)="togglePerson(p)"
  80. >
  81. <app-m-avatar [name]="p.name" tone="person" />
  82. <span class="m-row-body">
  83. <span class="m-row-t1">{{ p.name }}</span>
  84. <span class="m-row-t2">
  85. {{ p.mobile || '—' }} · {{ p.agents?.length || 0 }} 个容器
  86. </span>
  87. </span>
  88. <i
  89. class="fa-solid"
  90. [class.fa-chevron-down]="isOpen(p)"
  91. [class.fa-chevron-right]="!isOpen(p)"
  92. aria-hidden="true"
  93. ></i>
  94. </button>
  95. @if (isOpen(p)) {
  96. @for (a of p.agents ?? []; track a.id) {
  97. <button type="button" class="m-row m-row--sub" (click)="openAgent(a)">
  98. <app-m-avatar [name]="a.name" size="sm" />
  99. <span class="m-row-body">
  100. <span class="m-row-t1">{{ a.name }}</span>
  101. <span class="m-row-t2">{{ a.specialty || '数字生命' }}</span>
  102. </span>
  103. <app-m-badge [tone]="toneOf(a)" [dot]="true">{{ statusText(a) }}</app-m-badge>
  104. </button>
  105. } @empty {
  106. <p class="m-dept-empty">该同事暂无可对话的容器</p>
  107. }
  108. }
  109. </li>
  110. }
  111. </ul>
  112. </section>
  113. }
  114. @if (contacts.visibleAgents().length > 0) {
  115. <section class="m-section">
  116. <h2 class="m-section-title">数字生命 · {{ contacts.visibleAgents().length }}</h2>
  117. <ul class="m-list">
  118. @for (a of contacts.visibleAgents(); track a.id) {
  119. <li>
  120. <button type="button" class="m-row" (click)="openAgent(a)">
  121. <app-m-avatar [name]="a.name" />
  122. <span class="m-row-body">
  123. <span class="m-row-t1">{{ a.name }}</span>
  124. <span class="m-row-t2">
  125. {{ a.specialty || '数字生命' }}
  126. @if (a.owner?.name) {
  127. · 归属 {{ a.owner?.name }}
  128. }
  129. </span>
  130. </span>
  131. <app-m-badge [tone]="toneOf(a)" [dot]="true">{{ statusText(a) }}</app-m-badge>
  132. </button>
  133. </li>
  134. }
  135. </ul>
  136. </section>
  137. }
  138. @if (contacts.isEmpty()) {
  139. <app-m-empty title="暂无联系人" icon="fa-solid fa-address-book" />
  140. }
  141. }
  142. }
  143. </div>
  144. </div>
  145. <app-m-drawer [activeSessionId]="activeSessionId()"
  146. [open]="drawerOpen()" (close)="drawerOpen.set(false)" (newChat)="drawerOpen.set(false)" (newChatWithWs)="onNewChatIn($event)" />
  147. `,
  148. styles: [
  149. `
  150. :host {
  151. display: block;
  152. }
  153. .m-page {
  154. display: flex;
  155. flex-direction: column;
  156. height: 100dvh;
  157. background: var(--canvas);
  158. }
  159. .m-body {
  160. flex: 1;
  161. min-height: 0;
  162. overflow-y: auto;
  163. padding-bottom: calc(var(--mob-tabbar-h) + var(--mob-safe-bottom) + var(--sp-4));
  164. }
  165. /* 固定头部:不参与内容区滚动 */
  166. .m-seg-row {
  167. flex: none;
  168. padding: var(--sp-2) 0 var(--sp-1);
  169. }
  170. .m-search-row {
  171. flex: none;
  172. padding-bottom: var(--sp-2);
  173. }
  174. /* 公司 → 部门分组(2026-11-04) */
  175. .m-company {
  176. margin-top: var(--sp-4);
  177. }
  178. .m-company-head {
  179. display: flex;
  180. align-items: center;
  181. gap: var(--sp-2);
  182. margin: 0 0 var(--sp-2);
  183. padding: 0 var(--sp-1);
  184. color: var(--ink-2);
  185. font-size: var(--fs-15);
  186. font-weight: 600;
  187. }
  188. .m-company-head > i {
  189. color: var(--muted);
  190. font-size: var(--fs-13);
  191. }
  192. .m-dept + .m-dept {
  193. margin-top: var(--sp-3);
  194. }
  195. .m-dept-head {
  196. display: flex;
  197. align-items: center;
  198. gap: var(--sp-2);
  199. margin: 0 0 var(--sp-2);
  200. padding: 0 var(--sp-2);
  201. color: var(--muted);
  202. font-size: var(--fs-12);
  203. font-weight: 600;
  204. }
  205. .m-dept-head > i {
  206. font-size: var(--fs-10);
  207. }
  208. .m-dept-empty {
  209. margin: 0;
  210. padding: var(--sp-3);
  211. border: 1px dashed var(--edge-strong);
  212. border-radius: var(--r-lg);
  213. color: var(--muted);
  214. font-size: var(--fs-12);
  215. text-align: center;
  216. }
  217. .m-list--gap {
  218. margin-top: var(--sp-2);
  219. }
  220. .m-section {
  221. padding: 0 var(--mob-gutter);
  222. }
  223. .m-section + .m-section {
  224. margin-top: var(--sp-6);
  225. }
  226. .m-section:first-of-type {
  227. margin-top: var(--sp-3);
  228. }
  229. .m-section-title {
  230. margin: 0 0 var(--sp-3);
  231. color: var(--muted);
  232. font-size: var(--fs-13);
  233. font-weight: 400;
  234. }
  235. .m-list {
  236. margin: 0;
  237. padding: 0;
  238. list-style: none;
  239. border: 1px solid var(--edge);
  240. border-radius: var(--r-lg);
  241. background: var(--surface);
  242. overflow: hidden;
  243. }
  244. .m-list > li + li {
  245. border-top: 1px solid var(--edge);
  246. }
  247. .m-row {
  248. display: flex;
  249. align-items: center;
  250. gap: var(--sp-3);
  251. width: 100%;
  252. min-height: 56px;
  253. padding: var(--sp-3) var(--sp-4);
  254. border: 0;
  255. background: var(--surface);
  256. color: var(--ink);
  257. font: inherit;
  258. text-align: left;
  259. cursor: pointer;
  260. }
  261. .m-row:active {
  262. background: var(--surface-hover);
  263. }
  264. .m-row--sub {
  265. padding-left: var(--sp-8);
  266. border-top: 1px solid var(--edge);
  267. }
  268. .m-row-body {
  269. flex: 1;
  270. min-width: 0;
  271. }
  272. .m-row-t1,
  273. .m-row-t2 {
  274. display: block;
  275. overflow: hidden;
  276. text-overflow: ellipsis;
  277. white-space: nowrap;
  278. }
  279. .m-row-t1 {
  280. font-size: var(--fs-15);
  281. }
  282. .m-row-t2 {
  283. color: var(--muted);
  284. font-size: var(--fs-13);
  285. }
  286. .fa-chevron-down,
  287. .fa-chevron-right {
  288. color: var(--muted);
  289. font-size: var(--fs-12);
  290. }
  291. `,
  292. ],
  293. })
  294. export class MbContactsPage {
  295. private readonly chat = inject(ChatService);
  296. /** 当前打开的会话(抽屉高亮用) */
  297. protected readonly activeSessionId = computed(() => this.chat.sessionId());
  298. private readonly router = inject(Router);
  299. protected readonly contacts = inject(ContactsService);
  300. private readonly live = inject(LiveService);
  301. protected readonly drawerOpen = signal(false);
  302. protected readonly tabs = [
  303. { value: 'all' as const, label: '全部' },
  304. { value: 'ai' as const, label: '数字生命' },
  305. { value: 'people' as const, label: '同事' },
  306. ];
  307. /** Tab 状态落到 service(单一真源,页面不自持副本) */
  308. protected onTab(v: ContactTab | undefined): void {
  309. if (v) this.contacts.tab.set(v);
  310. }
  311. /** 展开中的同事(直接用 service 的单一状态,避免两份展开集合打架) */
  312. protected readonly expandedId = this.contacts.expandedColleague;
  313. protected isOpen(p: Person): boolean {
  314. return this.expandedId() === p.id;
  315. }
  316. protected togglePerson(p: Person): void {
  317. this.contacts.toggleColleague(p.id);
  318. }
  319. protected toneOf(a: Agent): BadgeTone {
  320. return AGENT_TONE[a.status] ?? 'neutral';
  321. }
  322. protected statusText(a: Agent): string {
  323. return a.statusText ?? a.status;
  324. }
  325. /**
  326. * 打开容器设置(2026-11-04 用户裁决)。
  327. *
  328. * 通讯录的语义是「管理有哪些容器」,点进去应当做设置而不是直接开始聊天。
  329. * 原实现走 `AgentRouteService.routeFor()`(已接入→会话 / 未接入→详情),
  330. * 与设置意图不符。
  331. */
  332. protected openAgent(a: Agent): void {
  333. void this.router.navigateByUrl(`/mobile/agent/${a.id}`);
  334. }
  335. protected reload(): void {
  336. void this.contacts.ensureLoaded();
  337. void this.live.refresh();
  338. }
  339. /**
  340. * 在指定工作区中新建对话。
  341. *
  342. * 这几页没有新建对话弹层,统一跳到会话列表页并带上工作区参数,
  343. * 由那里打开选择容器的弹层(避免每个页面各做一套)。
  344. */
  345. protected onNewChatIn(workspaceId: string): void {
  346. void this.router.navigateByUrl(`/mobile/messages?newIn=${encodeURIComponent(workspaceId)}`);
  347. }
  348. }