contacts.service.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. import { computed, inject, Injectable, signal } from '@angular/core';
  2. import { Agent, Person } from './api/models';
  3. import { isMatrixReady } from './format';
  4. import { LiveService } from './live.service';
  5. import { SessionService } from './session.service';
  6. import { ContainerGrant, Visibility, resolveVisibility } from './acl';
  7. export type ContactTab = 'all' | 'ai' | 'people' | 'colleagues' | 'mine';
  8. /**
  9. * 通讯录分组(2026-11-04)。
  10. *
  11. * ## 产品口径(用户裁决)
  12. *
  13. * · 通讯录**只展示本公司**的人与容器,不支持跨公司交流
  14. * · 同公司的人归到「同事」,其名下容器作为子项
  15. * · 部门维度**先预留**:数据层暂无 `department`,有值时按部门细分,
  16. * 无值统一归入「未分组」
  17. *
  18. * ## 为什么按「公司 → 部门」两级结构组织
  19. *
  20. * 后续要做「部门内共享容器」「企业管理员按部门授权」,
  21. * 现在把层级建好,将来只需填数据,不改视图结构。
  22. */
  23. export interface ContactGroup {
  24. /** 公司 id(undefined = 未归属公司) */
  25. companyId?: string;
  26. /** 公司名(缺失时显示「未归属」) */
  27. companyName: string;
  28. /** 部门分组(含「未分组」) */
  29. departments: {
  30. name: string;
  31. /** 该部门下的同事 */
  32. people: Person[];
  33. /** 该部门下的容器(按 owner 归入) */
  34. agents: Agent[];
  35. }[];
  36. }
  37. /**
  38. * 通讯录服务 —— 通讯录域的**唯一 owner**
  39. *
  40. * 依据 SPEC FR-03 与《数据访问层设计.md》§四.1:
  41. * · 三类 Tab:全部 / AI 数字生命 / 同事
  42. * · 同事列表由 `Agent.owner` **去重聚合**派生(不是独立实体)
  43. * · 同事行可展开其名下的数字生命
  44. */
  45. @Injectable({ providedIn: 'root' })
  46. export class ContactsService {
  47. private readonly live = inject(LiveService);
  48. private readonly session = inject(SessionService);
  49. readonly tab = signal<ContactTab>('all');
  50. readonly keyword = signal('');
  51. readonly expandedColleague = signal<string | null>(null);
  52. readonly state = computed(() => this.live.state());
  53. readonly error = computed(() => this.live.error());
  54. private readonly agents = computed(() => this.live.agents());
  55. /** 同事(由 owner 去重聚合) */
  56. readonly people = computed<Person[]>(() => {
  57. const map = new Map<string, Person>();
  58. for (const a of this.agents()) {
  59. const owner = a.owner;
  60. if (!owner) continue;
  61. if (!map.has(owner.id)) {
  62. map.set(owner.id, { ...owner, agents: [] });
  63. }
  64. map.get(owner.id)!.agents!.push(a);
  65. }
  66. return [...map.values()];
  67. });
  68. readonly aiAgents = computed(() => this.agents());
  69. /**
  70. * 按「公司 → 部门」组织(**仅企业管理页使用**)。
  71. *
  72. * 通讯录页面不用它 —— 通讯录是「同事 + 容器」两段式,
  73. * 只有企业管理员需要按组织架构看全貌。
  74. *
  75. * 部门维度为**预留**:数据层暂无 `department`,有值才分部门,
  76. * 无值统一归「未分组」,不伪造。
  77. */
  78. readonly companyGroups = computed<ContactGroup[]>(() => {
  79. const byCompany = new Map<string | undefined, ContactGroup>();
  80. const ensure = (companyId?: string): ContactGroup => {
  81. let g = byCompany.get(companyId);
  82. if (!g) {
  83. g = {
  84. companyId,
  85. companyName: companyId ? this.companyNameOf(companyId) : '未归属公司',
  86. departments: [],
  87. };
  88. byCompany.set(companyId, g);
  89. }
  90. return g;
  91. };
  92. const ensureDept = (g: ContactGroup, dept?: string) => {
  93. const name = dept?.trim() || '未分组';
  94. let d = g.departments.find((x) => x.name === name);
  95. if (!d) {
  96. d = { name, people: [], agents: [] };
  97. g.departments.push(d);
  98. }
  99. return d;
  100. };
  101. for (const p of this.people()) {
  102. ensureDept(ensure(p.companyId), p.department).people.push(p);
  103. }
  104. for (const a of this.agents()) {
  105. ensureDept(ensure(a.companyId ?? a.owner?.companyId), a.department ?? a.owner?.department).agents.push(a);
  106. }
  107. const myCompany = this.session.me()?.companyId;
  108. const list = [...byCompany.values()].filter(
  109. (g) => !g.companyId || !myCompany || g.companyId === myCompany,
  110. );
  111. list.forEach((g) =>
  112. g.departments.sort((x, y) =>
  113. x.name === '未分组' ? 1 : y.name === '未分组' ? -1 : x.name.localeCompare(y.name),
  114. ),
  115. );
  116. return list.sort((x, y) => (x.companyId ? 0 : 1) - (y.companyId ? 0 : 1));
  117. });
  118. /** 公司 id → 名称(管理员页展示用) */
  119. private companyNameOf(id: string): string {
  120. const me = this.session.me();
  121. if (me?.companyId === id && me.companyName) return me.companyName;
  122. return `公司 ${id.slice(-6)}`;
  123. }
  124. /**
  125. * 某容器的访问档位(2026-11-04 预留)。
  126. *
  127. * `resolveVisibility` 现在按身份给默认权限,服务端授权表就绪后
  128. * 只需替换它的实现 —— 通讯录这里不用改。
  129. */
  130. visibilityOf(agent: Agent): Visibility {
  131. return resolveVisibility(this.session.me(), agent, this.grants());
  132. }
  133. /** 显式授权规则(服务端就绪前恒为空) */
  134. private grants(): ContainerGrant[] {
  135. return [];
  136. }
  137. /** 三类 Tab 的计数(列表头/Tab 徽标用) */
  138. readonly counts = computed(() => ({
  139. all: this.agents().length + this.people().length,
  140. ai: this.agents().length,
  141. people: this.people().length,
  142. }));
  143. private readonly match = (name: string, extra: (string | undefined)[] = []) => {
  144. const q = this.keyword().trim().toLowerCase();
  145. if (!q) return true;
  146. return name.toLowerCase().includes(q) || extra.some((s) => (s ?? '').toLowerCase().includes(q));
  147. };
  148. /** 按 Tab + 关键字过滤后的数字生命 */
  149. readonly visibleAgents = computed(() => {
  150. if (this.tab() === 'people') return [];
  151. return this.aiAgents().filter((a) => this.match(a.name, [a.specialty, a.owner?.name]));
  152. });
  153. readonly visiblePeople = computed(() => {
  154. if (this.tab() === 'ai') return [];
  155. return this.people().filter((p) => this.match(p.name, [p.mobile]));
  156. });
  157. /**
  158. * 整体是否为空(用于空态判定)。
  159. *
  160. * 🔴 必须同时认 `ready` 与 `empty` 两种状态 —— LiveService 在列表为空时
  161. * 设的是 `state='empty'`,只判断 'ready' 会让空态永远显示不出来。
  162. * 也不能把 `error` 算进来(失败要显示错误态,不能伪装成空)。
  163. */
  164. readonly isEmpty = computed(
  165. () =>
  166. (this.state() === 'ready' || this.state() === 'empty') &&
  167. this.visibleAgents().length === 0 &&
  168. this.visiblePeople().length === 0,
  169. );
  170. setTab(tab: ContactTab): void {
  171. this.tab.set(tab);
  172. this.expandedColleague.set(null);
  173. }
  174. toggleColleague(id: string): void {
  175. this.expandedColleague.update((cur) => (cur === id ? null : id));
  176. }
  177. /** 某同事名下、且匹配当前关键字的数字生命 */
  178. agentsOfPerson(person: Person): Agent[] {
  179. return (person.agents ?? []).filter((a) => this.match(a.name, [a.specialty]));
  180. }
  181. isMatrixReady = isMatrixReady;
  182. async ensureLoaded(): Promise<void> {
  183. await this.live.ensureLoaded();
  184. }
  185. }