| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367 |
- import { ChatService } from '../../../core/chat.service';
- import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
- import { Router } from '@angular/router';
- import { ContactTab, ContactsService } from '../../../core/contacts.service';
- import { LiveService } from '../../../core/live.service';
- import { Agent, Person } from '../../../core/api/models';
- import { MAppBarComponent } from '../../components/m-app-bar/m-app-bar';
- import { MAvatarComponent } from '../../components/m-avatar/m-avatar';
- import { AGENT_TONE, BadgeTone, MBadgeComponent } from '../../components/m-badge/m-badge';
- import { MDrawerComponent } from '../../components/m-drawer/m-drawer';
- import { MEmptyComponent } from '../../components/m-empty/m-empty';
- import { MSegmentedComponent } from '../../components/m-segmented/m-segmented';
- import { MSearchComponent } from '../../components/m-search/m-search';
- /**
- * 通讯录(Demo 09)
- *
- * 三段:全部 / 数字生命 / 同事。同事行可原地展开其名下数字生命。
- * 点数字生命进入**容器设置**(2026-11-04 用户裁决;原为「进会话 vs 进详情」自动分流)。
- */
- @Component({
- selector: 'app-mb-contacts-page',
- changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [
- MAppBarComponent,
- MAvatarComponent,
- MBadgeComponent,
- MDrawerComponent,
- MEmptyComponent,
- MSegmentedComponent,
- MSearchComponent,
- ],
- template: `
- <div class="m-page m-page--fixed">
- <app-m-app-bar title="通讯录" [menu]="true" (menuTap)="drawerOpen.set(true)" />
- <!--
- 固定头部(2026-11-04):分段筛选 + 搜索不随列表滚动。
- 与工作台同一套结构 —— 外壳 flex 列,头部 flex:none,内容区独立滚。
- -->
- <div class="m-seg-row">
- <app-m-segmented [options]="tabs" [value]="contacts.tab()" (valueChange)="onTab($event)" />
- </div>
- <div class="m-search-row">
- <app-m-search
- placeholder="搜索名称或专长"
- [value]="contacts.keyword()"
- (valueChange)="contacts.keyword.set($event)"
- />
- </div>
- <div class="m-body">
- @switch (contacts.state()) {
- @case ('loading') {
- <app-m-empty icon="fa-solid fa-spinner fa-spin" title="正在加载…" />
- }
- @case ('error') {
- <app-m-empty
- icon="fa-solid fa-triangle-exclamation"
- title="通讯录加载失败"
- [description]="contacts.error() ?? ''"
- [retryable]="true"
- (retry)="reload()"
- />
- }
- @default {
- <!--
- 同事在上、容器在下(2026-11-04 用户裁决)。
- 与 PC 通讯录同一结构:同事行可展开其名下容器;
- 容器区平铺全公司容器,点进即会话或详情。
- -->
- @if (contacts.visiblePeople().length > 0) {
- <section class="m-section m-section--first">
- <h2 class="m-section-title">同事 · {{ contacts.visiblePeople().length }}</h2>
- <ul class="m-list">
- @for (p of contacts.visiblePeople(); track p.id) {
- <li>
- <button
- type="button"
- class="m-row"
- [attr.aria-expanded]="isOpen(p)"
- (click)="togglePerson(p)"
- >
- <app-m-avatar [name]="p.name" tone="person" />
- <span class="m-row-body">
- <span class="m-row-t1">{{ p.name }}</span>
- <span class="m-row-t2">
- {{ p.mobile || '—' }} · {{ p.agents?.length || 0 }} 个容器
- </span>
- </span>
- <i
- class="fa-solid"
- [class.fa-chevron-down]="isOpen(p)"
- [class.fa-chevron-right]="!isOpen(p)"
- aria-hidden="true"
- ></i>
- </button>
- @if (isOpen(p)) {
- @for (a of p.agents ?? []; track a.id) {
- <button type="button" class="m-row m-row--sub" (click)="openAgent(a)">
- <app-m-avatar [name]="a.name" size="sm" />
- <span class="m-row-body">
- <span class="m-row-t1">{{ a.name }}</span>
- <span class="m-row-t2">{{ a.specialty || '数字生命' }}</span>
- </span>
- <app-m-badge [tone]="toneOf(a)" [dot]="true">{{ statusText(a) }}</app-m-badge>
- </button>
- } @empty {
- <p class="m-dept-empty">该同事暂无可对话的容器</p>
- }
- }
- </li>
- }
- </ul>
- </section>
- }
- @if (contacts.visibleAgents().length > 0) {
- <section class="m-section">
- <h2 class="m-section-title">数字生命 · {{ contacts.visibleAgents().length }}</h2>
- <ul class="m-list">
- @for (a of contacts.visibleAgents(); track a.id) {
- <li>
- <button type="button" class="m-row" (click)="openAgent(a)">
- <app-m-avatar [name]="a.name" />
- <span class="m-row-body">
- <span class="m-row-t1">{{ a.name }}</span>
- <span class="m-row-t2">
- {{ a.specialty || '数字生命' }}
- @if (a.owner?.name) {
- · 归属 {{ a.owner?.name }}
- }
- </span>
- </span>
- <app-m-badge [tone]="toneOf(a)" [dot]="true">{{ statusText(a) }}</app-m-badge>
- </button>
- </li>
- }
- </ul>
- </section>
- }
- @if (contacts.isEmpty()) {
- <app-m-empty title="暂无联系人" icon="fa-solid fa-address-book" />
- }
- }
- }
- </div>
- </div>
- <app-m-drawer [activeSessionId]="activeSessionId()"
- [open]="drawerOpen()" (close)="drawerOpen.set(false)" (newChat)="drawerOpen.set(false)" (newChatWithWs)="onNewChatIn($event)" />
- `,
- styles: [
- `
- :host {
- display: block;
- }
- .m-page {
- display: flex;
- flex-direction: column;
- height: 100dvh;
- background: var(--canvas);
- }
- .m-body {
- flex: 1;
- min-height: 0;
- overflow-y: auto;
- padding-bottom: calc(var(--mob-tabbar-h) + var(--mob-safe-bottom) + var(--sp-4));
- }
- /* 固定头部:不参与内容区滚动 */
- .m-seg-row {
- flex: none;
- padding: var(--sp-2) 0 var(--sp-1);
- }
- .m-search-row {
- flex: none;
- padding-bottom: var(--sp-2);
- }
- /* 公司 → 部门分组(2026-11-04) */
- .m-company {
- margin-top: var(--sp-4);
- }
- .m-company-head {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- margin: 0 0 var(--sp-2);
- padding: 0 var(--sp-1);
- color: var(--ink-2);
- font-size: var(--fs-15);
- font-weight: 600;
- }
- .m-company-head > i {
- color: var(--muted);
- font-size: var(--fs-13);
- }
- .m-dept + .m-dept {
- margin-top: var(--sp-3);
- }
- .m-dept-head {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- margin: 0 0 var(--sp-2);
- padding: 0 var(--sp-2);
- color: var(--muted);
- font-size: var(--fs-12);
- font-weight: 600;
- }
- .m-dept-head > i {
- font-size: var(--fs-10);
- }
- .m-dept-empty {
- margin: 0;
- padding: var(--sp-3);
- border: 1px dashed var(--edge-strong);
- border-radius: var(--r-lg);
- color: var(--muted);
- font-size: var(--fs-12);
- text-align: center;
- }
- .m-list--gap {
- margin-top: var(--sp-2);
- }
- .m-section {
- padding: 0 var(--mob-gutter);
- }
- .m-section + .m-section {
- margin-top: var(--sp-6);
- }
- .m-section:first-of-type {
- margin-top: var(--sp-3);
- }
- .m-section-title {
- margin: 0 0 var(--sp-3);
- color: var(--muted);
- font-size: var(--fs-13);
- font-weight: 400;
- }
- .m-list {
- margin: 0;
- padding: 0;
- list-style: none;
- border: 1px solid var(--edge);
- border-radius: var(--r-lg);
- background: var(--surface);
- overflow: hidden;
- }
- .m-list > li + li {
- border-top: 1px solid var(--edge);
- }
- .m-row {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
- width: 100%;
- min-height: 56px;
- padding: var(--sp-3) var(--sp-4);
- border: 0;
- background: var(--surface);
- color: var(--ink);
- font: inherit;
- text-align: left;
- cursor: pointer;
- }
- .m-row:active {
- background: var(--surface-hover);
- }
- .m-row--sub {
- padding-left: var(--sp-8);
- border-top: 1px solid var(--edge);
- }
- .m-row-body {
- flex: 1;
- min-width: 0;
- }
- .m-row-t1,
- .m-row-t2 {
- display: block;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .m-row-t1 {
- font-size: var(--fs-15);
- }
- .m-row-t2 {
- color: var(--muted);
- font-size: var(--fs-13);
- }
- .fa-chevron-down,
- .fa-chevron-right {
- color: var(--muted);
- font-size: var(--fs-12);
- }
- `,
- ],
- })
- export class MbContactsPage {
- private readonly chat = inject(ChatService);
- /** 当前打开的会话(抽屉高亮用) */
- protected readonly activeSessionId = computed(() => this.chat.sessionId());
- private readonly router = inject(Router);
- protected readonly contacts = inject(ContactsService);
- private readonly live = inject(LiveService);
- protected readonly drawerOpen = signal(false);
- protected readonly tabs = [
- { value: 'all' as const, label: '全部' },
- { value: 'ai' as const, label: '数字生命' },
- { value: 'people' as const, label: '同事' },
- ];
- /** Tab 状态落到 service(单一真源,页面不自持副本) */
- protected onTab(v: ContactTab | undefined): void {
- if (v) this.contacts.tab.set(v);
- }
- /** 展开中的同事(直接用 service 的单一状态,避免两份展开集合打架) */
- protected readonly expandedId = this.contacts.expandedColleague;
- protected isOpen(p: Person): boolean {
- return this.expandedId() === p.id;
- }
- protected togglePerson(p: Person): void {
- this.contacts.toggleColleague(p.id);
- }
- protected toneOf(a: Agent): BadgeTone {
- return AGENT_TONE[a.status] ?? 'neutral';
- }
- protected statusText(a: Agent): string {
- return a.statusText ?? a.status;
- }
- /**
- * 打开容器设置(2026-11-04 用户裁决)。
- *
- * 通讯录的语义是「管理有哪些容器」,点进去应当做设置而不是直接开始聊天。
- * 原实现走 `AgentRouteService.routeFor()`(已接入→会话 / 未接入→详情),
- * 与设置意图不符。
- */
- protected openAgent(a: Agent): void {
- void this.router.navigateByUrl(`/mobile/agent/${a.id}`);
- }
- protected reload(): void {
- void this.contacts.ensureLoaded();
- void this.live.refresh();
- }
- /**
- * 在指定工作区中新建对话。
- *
- * 这几页没有新建对话弹层,统一跳到会话列表页并带上工作区参数,
- * 由那里打开选择容器的弹层(避免每个页面各做一套)。
- */
- protected onNewChatIn(workspaceId: string): void {
- void this.router.navigateByUrl(`/mobile/messages?newIn=${encodeURIComponent(workspaceId)}`);
- }
- }
|