contacts.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. import { MAvatarComponent } from '../../components/m-avatar/m-avatar';
  2. import { ChangeDetectionStrategy, Component, computed, inject, OnInit } from '@angular/core';
  3. import { Router } from '@angular/router';
  4. import { UiMCell, UiMSearch, UiMTabs } from '../../../ui';
  5. import { StatePanelComponent } from '../../../components/feedback/state-panel/state-panel';
  6. import { StatusBadgeComponent } from '../../../components/domain/status-badge/status-badge';
  7. import { Agent } from '../../../core/api/models';
  8. import { ContactsService } from '../../../core/contacts.service';
  9. import { LiveService } from '../../../core/live.service';
  10. import { DeviceService } from '../../../core/device.service';
  11. import { isMatrixReady } from '../../../core/format';
  12. /**
  13. * 移动·通讯录(SPEC FR-03)
  14. * 三 Tab + 搜索 + 同事展开;行操作触达 ≥44px。
  15. */
  16. @Component({
  17. selector: 'app-mb-contacts-page',
  18. changeDetection: ChangeDetectionStrategy.OnPush,
  19. imports: [StatePanelComponent, StatusBadgeComponent, UiMSearch, MAvatarComponent, UiMCell, UiMTabs],
  20. template: `
  21. <div class="m-page">
  22. <header class="m-head">
  23. <h1>通讯录</h1>
  24. </header>
  25. <ui-m-search placeholder="搜索名称或专长" [value]="contacts.keyword()" (valueChange)="contacts.keyword.set($event)" />
  26. <ui-m-tabs [items]="tabItems" [active]="tabIndex()" (activeChange)="onTabIndex($event)" />
  27. @switch (contacts.state()) {
  28. @case ('loading') {
  29. <app-state-panel state="loading" loadingText="正在加载…" />
  30. }
  31. @case ('error') {
  32. <app-state-panel state="error" [message]="contacts.error()" (retry)="reload()" />
  33. }
  34. @case ('empty') {
  35. <app-state-panel state="empty" title="暂无数字生命" [retryable]="false" />
  36. }
  37. @default {
  38. @if (contacts.isEmpty()) {
  39. <app-state-panel state="empty" title="没有匹配的结果" [retryable]="false" />
  40. } @else {
  41. @if (contacts.tab() !== 'people') {
  42. <h2 class="section-title">AI 数字生命</h2>
  43. <div class="rows">
  44. @for (a of contacts.visibleAgents(); track a.id) {
  45. <button type="button" class="row" (click)="open(a)">
  46. <app-m-avatar [name]="a.name" />
  47. <span class="row-body">
  48. <strong class="truncate">{{ a.name }}</strong>
  49. <small class="truncate">{{ a.specialty }}</small>
  50. </span>
  51. <app-status-badge [agent]="a" />
  52. </button>
  53. }
  54. </div>
  55. }
  56. @if (contacts.tab() !== 'ai') {
  57. <h2 class="section-title">同事</h2>
  58. <div class="rows">
  59. @for (p of contacts.visiblePeople(); track p.id) {
  60. <ui-m-cell
  61. [title]="p.name"
  62. [label]="(p.agents?.length || 0) + ' 位数字生命'"
  63. icon="fa-solid fa-user"
  64. [isLink]="true"
  65. (click)="contacts.toggleColleague(p.id)"
  66. />
  67. @if (contacts.expandedColleague() === p.id) {
  68. @for (a of contacts.agentsOfPerson(p); track a.id) {
  69. <button type="button" class="row child" (click)="open(a)">
  70. <app-m-avatar [name]="a.name" size="sm" />
  71. <span class="row-body">
  72. <strong class="truncate">{{ a.name }}</strong>
  73. <small class="truncate">{{ a.specialty }}</small>
  74. </span>
  75. <app-status-badge [agent]="a" />
  76. </button>
  77. }
  78. }
  79. }
  80. </div>
  81. }
  82. }
  83. }
  84. }
  85. </div>
  86. `,
  87. styles: [
  88. `
  89. .m-page {
  90. padding: var(--sp-5) var(--mob-gutter) var(--sp-6);
  91. }
  92. .m-head h1 {
  93. margin: 0 0 var(--sp-4);
  94. font-size: var(--fs-26);
  95. color: var(--ink);
  96. }
  97. .m-search {
  98. display: flex;
  99. align-items: center;
  100. gap: var(--sp-2);
  101. min-height: 46px;
  102. padding: 0 var(--sp-3);
  103. margin-bottom: var(--sp-3);
  104. border: 1px solid var(--edge);
  105. border-radius: var(--r-md);
  106. background: var(--surface);
  107. color: var(--muted);
  108. }
  109. .m-search input {
  110. flex: 1;
  111. min-width: 0;
  112. border: 0;
  113. background: transparent;
  114. color: var(--ink);
  115. font-size: var(--fs-17);
  116. }
  117. .m-tabs {
  118. display: flex;
  119. gap: var(--sp-1);
  120. padding: var(--sp-1);
  121. margin-bottom: var(--sp-4);
  122. border-radius: var(--r-md);
  123. background: var(--surface-2);
  124. }
  125. .m-tab {
  126. flex: 1;
  127. display: inline-flex;
  128. align-items: center;
  129. justify-content: center;
  130. gap: var(--sp-1);
  131. min-height: var(--touch-min);
  132. border: 0;
  133. border-radius: var(--r-sm);
  134. background: transparent;
  135. color: var(--ink-2);
  136. font-size: var(--fs-13);
  137. cursor: pointer;
  138. }
  139. .m-tab.active {
  140. background: var(--surface);
  141. color: var(--ink);
  142. font-weight: 600;
  143. box-shadow: var(--shadow-soft);
  144. }
  145. .tab-count {
  146. font-size: var(--fs-11);
  147. opacity: 0.7;
  148. }
  149. .section-title {
  150. margin: 0 0 var(--sp-3);
  151. font-size: var(--fs-13);
  152. color: var(--ink-2);
  153. }
  154. .section-title:not(:first-of-type) {
  155. margin-top: var(--sp-5);
  156. }
  157. .rows {
  158. display: grid;
  159. gap: var(--sp-2);
  160. }
  161. .row {
  162. display: flex;
  163. align-items: center;
  164. gap: var(--sp-3);
  165. width: 100%;
  166. min-height: 64px;
  167. padding: var(--sp-2) var(--sp-3);
  168. border: 1px solid var(--edge);
  169. border-radius: var(--r-md);
  170. background: var(--surface);
  171. text-align: left;
  172. cursor: pointer;
  173. }
  174. .row:active {
  175. background: var(--surface-hover);
  176. }
  177. .row.child {
  178. margin-left: var(--sp-4);
  179. width: calc(100% - var(--sp-4));
  180. background: var(--surface-2);
  181. }
  182. .row-body {
  183. display: grid;
  184. gap: 2px;
  185. flex: 1;
  186. min-width: 0;
  187. }
  188. .row-body strong {
  189. font-size: var(--fs-15);
  190. color: var(--ink);
  191. }
  192. .row-body small {
  193. font-size: var(--fs-12);
  194. color: var(--muted);
  195. }
  196. .truncate {
  197. overflow: hidden;
  198. text-overflow: ellipsis;
  199. white-space: nowrap;
  200. }
  201. `,
  202. ],
  203. })
  204. export class MbContactsPage implements OnInit {
  205. readonly contacts = inject(ContactsService);
  206. private readonly live = inject(LiveService);
  207. private readonly router = inject(Router);
  208. private readonly device = inject(DeviceService);
  209. readonly tabs = [
  210. { key: 'all' as const, label: '全部' },
  211. { key: 'ai' as const, label: 'AI' },
  212. { key: 'people' as const, label: '同事' },
  213. ];
  214. readonly tabItems = [{ label: '全部' }, { label: 'AI 数字生命' }, { label: '同事' }];
  215. readonly tabIndex = computed(() => ({ all: 0, ai: 1, people: 2 })[this.contacts.tab()]);
  216. onTabIndex(i: number): void {
  217. this.contacts.setTab((['all', 'ai', 'people'] as const)[i] ?? 'all');
  218. }
  219. readonly counts = computed(() => {
  220. const c = this.contacts.counts();
  221. return { all: c.all, ai: c.ai, people: c.people };
  222. });
  223. async ngOnInit(): Promise<void> {
  224. await this.contacts.ensureLoaded();
  225. }
  226. async reload(): Promise<void> {
  227. await this.live.refresh();
  228. }
  229. onSearch(e: Event): void {
  230. this.contacts.keyword.set((e.target as HTMLInputElement).value);
  231. }
  232. open(a: Agent): void {
  233. void this.router.navigate(
  234. isMatrixReady(a)
  235. ? ['/', this.device.mode(), 'chat', a.id]
  236. : ['/', this.device.mode(), 'agent', a.id],
  237. );
  238. }
  239. }