| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209 |
- import { computed, inject, Injectable, signal } from '@angular/core';
- import { Agent, Person } from './api/models';
- import { isMatrixReady } from './format';
- import { LiveService } from './live.service';
- import { SessionService } from './session.service';
- import { ContainerGrant, Visibility, resolveVisibility } from './acl';
- export type ContactTab = 'all' | 'ai' | 'people' | 'colleagues' | 'mine';
- /**
- * 通讯录分组(2026-11-04)。
- *
- * ## 产品口径(用户裁决)
- *
- * · 通讯录**只展示本公司**的人与容器,不支持跨公司交流
- * · 同公司的人归到「同事」,其名下容器作为子项
- * · 部门维度**先预留**:数据层暂无 `department`,有值时按部门细分,
- * 无值统一归入「未分组」
- *
- * ## 为什么按「公司 → 部门」两级结构组织
- *
- * 后续要做「部门内共享容器」「企业管理员按部门授权」,
- * 现在把层级建好,将来只需填数据,不改视图结构。
- */
- export interface ContactGroup {
- /** 公司 id(undefined = 未归属公司) */
- companyId?: string;
- /** 公司名(缺失时显示「未归属」) */
- companyName: string;
- /** 部门分组(含「未分组」) */
- departments: {
- name: string;
- /** 该部门下的同事 */
- people: Person[];
- /** 该部门下的容器(按 owner 归入) */
- agents: Agent[];
- }[];
- }
- /**
- * 通讯录服务 —— 通讯录域的**唯一 owner**
- *
- * 依据 SPEC FR-03 与《数据访问层设计.md》§四.1:
- * · 三类 Tab:全部 / AI 数字生命 / 同事
- * · 同事列表由 `Agent.owner` **去重聚合**派生(不是独立实体)
- * · 同事行可展开其名下的数字生命
- */
- @Injectable({ providedIn: 'root' })
- export class ContactsService {
- private readonly live = inject(LiveService);
- private readonly session = inject(SessionService);
- readonly tab = signal<ContactTab>('all');
- readonly keyword = signal('');
- readonly expandedColleague = signal<string | null>(null);
- readonly state = computed(() => this.live.state());
- readonly error = computed(() => this.live.error());
- private readonly agents = computed(() => this.live.agents());
- /** 同事(由 owner 去重聚合) */
- readonly people = computed<Person[]>(() => {
- const map = new Map<string, Person>();
- for (const a of this.agents()) {
- const owner = a.owner;
- if (!owner) continue;
- if (!map.has(owner.id)) {
- map.set(owner.id, { ...owner, agents: [] });
- }
- map.get(owner.id)!.agents!.push(a);
- }
- return [...map.values()];
- });
- readonly aiAgents = computed(() => this.agents());
- /**
- * 按「公司 → 部门」组织(**仅企业管理页使用**)。
- *
- * 通讯录页面不用它 —— 通讯录是「同事 + 容器」两段式,
- * 只有企业管理员需要按组织架构看全貌。
- *
- * 部门维度为**预留**:数据层暂无 `department`,有值才分部门,
- * 无值统一归「未分组」,不伪造。
- */
- readonly companyGroups = computed<ContactGroup[]>(() => {
- const byCompany = new Map<string | undefined, ContactGroup>();
- const ensure = (companyId?: string): ContactGroup => {
- let g = byCompany.get(companyId);
- if (!g) {
- g = {
- companyId,
- companyName: companyId ? this.companyNameOf(companyId) : '未归属公司',
- departments: [],
- };
- byCompany.set(companyId, g);
- }
- return g;
- };
- const ensureDept = (g: ContactGroup, dept?: string) => {
- const name = dept?.trim() || '未分组';
- let d = g.departments.find((x) => x.name === name);
- if (!d) {
- d = { name, people: [], agents: [] };
- g.departments.push(d);
- }
- return d;
- };
- for (const p of this.people()) {
- ensureDept(ensure(p.companyId), p.department).people.push(p);
- }
- for (const a of this.agents()) {
- ensureDept(ensure(a.companyId ?? a.owner?.companyId), a.department ?? a.owner?.department).agents.push(a);
- }
- const myCompany = this.session.me()?.companyId;
- const list = [...byCompany.values()].filter(
- (g) => !g.companyId || !myCompany || g.companyId === myCompany,
- );
- list.forEach((g) =>
- g.departments.sort((x, y) =>
- x.name === '未分组' ? 1 : y.name === '未分组' ? -1 : x.name.localeCompare(y.name),
- ),
- );
- return list.sort((x, y) => (x.companyId ? 0 : 1) - (y.companyId ? 0 : 1));
- });
- /** 公司 id → 名称(管理员页展示用) */
- private companyNameOf(id: string): string {
- const me = this.session.me();
- if (me?.companyId === id && me.companyName) return me.companyName;
- return `公司 ${id.slice(-6)}`;
- }
- /**
- * 某容器的访问档位(2026-11-04 预留)。
- *
- * `resolveVisibility` 现在按身份给默认权限,服务端授权表就绪后
- * 只需替换它的实现 —— 通讯录这里不用改。
- */
- visibilityOf(agent: Agent): Visibility {
- return resolveVisibility(this.session.me(), agent, this.grants());
- }
- /** 显式授权规则(服务端就绪前恒为空) */
- private grants(): ContainerGrant[] {
- return [];
- }
- /** 三类 Tab 的计数(列表头/Tab 徽标用) */
- readonly counts = computed(() => ({
- all: this.agents().length + this.people().length,
- ai: this.agents().length,
- people: this.people().length,
- }));
- private readonly match = (name: string, extra: (string | undefined)[] = []) => {
- const q = this.keyword().trim().toLowerCase();
- if (!q) return true;
- return name.toLowerCase().includes(q) || extra.some((s) => (s ?? '').toLowerCase().includes(q));
- };
- /** 按 Tab + 关键字过滤后的数字生命 */
- readonly visibleAgents = computed(() => {
- if (this.tab() === 'people') return [];
- return this.aiAgents().filter((a) => this.match(a.name, [a.specialty, a.owner?.name]));
- });
- readonly visiblePeople = computed(() => {
- if (this.tab() === 'ai') return [];
- return this.people().filter((p) => this.match(p.name, [p.mobile]));
- });
- /**
- * 整体是否为空(用于空态判定)。
- *
- * 🔴 必须同时认 `ready` 与 `empty` 两种状态 —— LiveService 在列表为空时
- * 设的是 `state='empty'`,只判断 'ready' 会让空态永远显示不出来。
- * 也不能把 `error` 算进来(失败要显示错误态,不能伪装成空)。
- */
- readonly isEmpty = computed(
- () =>
- (this.state() === 'ready' || this.state() === 'empty') &&
- this.visibleAgents().length === 0 &&
- this.visiblePeople().length === 0,
- );
- setTab(tab: ContactTab): void {
- this.tab.set(tab);
- this.expandedColleague.set(null);
- }
- toggleColleague(id: string): void {
- this.expandedColleague.update((cur) => (cur === id ? null : id));
- }
- /** 某同事名下、且匹配当前关键字的数字生命 */
- agentsOfPerson(person: Person): Agent[] {
- return (person.agents ?? []).filter((a) => this.match(a.name, [a.specialty]));
- }
- isMatrixReady = isMatrixReady;
- async ensureLoaded(): Promise<void> {
- await this.live.ensureLoaded();
- }
- }
|