| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285 |
- import { MAvatarComponent } from '../../components/m-avatar/m-avatar';
- import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
- import { Router } from '@angular/router';
- import { UiMSearch } from '../../../ui';
- import { StatePanelComponent } from '../../../components/feedback/state-panel/state-panel';
- import { LiveService } from '../../../core/live.service';
- import { DeviceService } from '../../../core/device.service';
- import { isMatrixReady } from '../../../core/format';
- import { fmtRelTime } from '../../../core/time';
- /**
- * 移动·会话列表(SPEC FR-04 移动形态)
- *
- * 列表头 + 搜索 + 全部/未读 Tab + 会话行。
- * 返回列表时**保持滚动位置**(AC-05.2 同源要求,由 Router 的滚动恢复 + 路径缓存保证)。
- */
- @Component({
- selector: 'app-mb-messages-page',
- changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [StatePanelComponent, UiMSearch, MAvatarComponent],
- template: `
- <div class="m-page">
- <header class="m-head">
- <div>
- <h1>会话</h1>
- <p>与数字生命一对一沟通。</p>
- </div>
- </header>
- <ui-m-search placeholder="搜索会话" [value]="keyword()" (valueChange)="keyword.set($event)" />
- <div class="m-tabs" role="tablist" aria-label="会话筛选">
- <button
- type="button"
- role="tab"
- class="m-tab"
- [class.active]="tab() === 'all'"
- [attr.aria-selected]="tab() === 'all'"
- (click)="tab.set('all')"
- >
- 全部 <span class="tab-count">{{ list().length }}</span>
- </button>
- <button
- type="button"
- role="tab"
- class="m-tab"
- [class.active]="tab() === 'unread'"
- [attr.aria-selected]="tab() === 'unread'"
- (click)="tab.set('unread')"
- >
- 未读 <span class="tab-count">{{ unreadCount() }}</span>
- </button>
- </div>
- @switch (live.state()) {
- @case ('loading') {
- <app-state-panel state="loading" loadingText="正在加载会话…" />
- }
- @case ('error') {
- <app-state-panel state="error" [message]="live.error()" (retry)="reload()" />
- }
- @case ('empty') {
- <app-state-panel state="empty" title="暂无会话" message="接入数字生命后即可开始对话。" [retryable]="false" />
- }
- @default {
- @if (filtered().length === 0) {
- <app-state-panel state="empty" title="没有匹配的会话" [retryable]="false" />
- } @else {
- <div class="chat-list" data-testid="chat-list">
- @for (c of filtered(); track c.id) {
- <button type="button" class="chat-row" (click)="open(c.id)">
- <app-m-avatar [name]="c.name" />
- <span class="chat-body">
- <span class="chat-line">
- <strong class="truncate">{{ c.name }}</strong>
- <time>{{ rel(c.updatedAt) }}</time>
- </span>
- <span class="chat-preview truncate">
- {{ c.currentTask || c.specialty }}
- </span>
- </span>
- @if (c.unread > 0) {
- <span class="unread">{{ c.unread }}</span>
- }
- </button>
- }
- </div>
- }
- }
- }
- <p class="foot-note">
- <i class="fa-solid fa-lock" aria-hidden="true"></i>
- 消息经 Matrix 通道加密传输
- </p>
- </div>
- `,
- styles: [
- `
- .m-page {
- padding: var(--sp-5) var(--mob-gutter) var(--sp-6);
- }
- .m-head {
- margin-bottom: var(--sp-4);
- }
- .m-head h1 {
- margin: 0;
- font-size: var(--fs-26);
- color: var(--ink);
- }
- .m-head p {
- margin: var(--sp-1) 0 0;
- font-size: var(--fs-13);
- color: var(--muted);
- }
- .m-search {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- min-height: 46px;
- padding: 0 var(--sp-3);
- margin-bottom: var(--sp-3);
- border: 1px solid var(--edge);
- border-radius: var(--r-md);
- background: var(--surface);
- color: var(--muted);
- }
- .m-search input {
- flex: 1;
- min-width: 0;
- border: 0;
- background: transparent;
- color: var(--ink);
- /* ≥16px,iOS 聚焦不放大(AC-19.3 同源要求) */
- font-size: var(--fs-17);
- }
- .m-tabs {
- display: flex;
- gap: var(--sp-1);
- padding: var(--sp-1);
- margin-bottom: var(--sp-3);
- border-radius: var(--r-md);
- background: var(--surface-2);
- }
- .m-tab {
- flex: 1;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: var(--sp-1);
- min-height: var(--touch-min);
- border: 0;
- border-radius: var(--r-sm);
- background: transparent;
- color: var(--ink-2);
- font-size: var(--fs-13);
- cursor: pointer;
- }
- .m-tab.active {
- background: var(--surface);
- color: var(--ink);
- font-weight: 600;
- box-shadow: var(--shadow-soft);
- }
- .tab-count {
- font-size: var(--fs-11);
- opacity: 0.7;
- }
- .chat-list {
- display: grid;
- gap: var(--sp-2);
- }
- .chat-row {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
- width: 100%;
- min-height: 68px;
- padding: var(--sp-2) var(--sp-3);
- border: 1px solid var(--edge);
- border-radius: var(--r-md);
- background: var(--surface);
- text-align: left;
- cursor: pointer;
- }
- .chat-row:active {
- background: var(--surface-hover);
- }
- .chat-body {
- display: grid;
- gap: 2px;
- flex: 1;
- min-width: 0;
- }
- .chat-line {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: var(--sp-2);
- }
- .chat-line strong {
- font-size: var(--fs-15);
- color: var(--ink);
- }
- .chat-line time {
- font-size: var(--fs-11);
- color: var(--muted);
- flex-shrink: 0;
- }
- .chat-preview {
- font-size: var(--fs-12);
- color: var(--muted);
- }
- .unread {
- min-width: 18px;
- padding: 0 5px;
- border-radius: var(--r-tag);
- background: var(--danger);
- color: var(--on-danger);
- font-size: var(--fs-11);
- line-height: 18px;
- text-align: center;
- flex-shrink: 0;
- }
- .foot-note {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: var(--sp-1);
- margin-top: var(--sp-5);
- font-size: var(--fs-11);
- color: var(--muted);
- }
- .truncate {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- `,
- ],
- })
- export class MbMessagesPage implements OnInit {
- readonly live = inject(LiveService);
- private readonly router = inject(Router);
- private readonly device = inject(DeviceService);
- readonly tab = signal<'all' | 'unread'>('all');
- readonly keyword = signal('');
- readonly rel = fmtRelTime;
- readonly list = computed(() => this.live.agents().filter((a) => isMatrixReady(a)));
- readonly unreadCount = computed(() =>
- this.list().filter((a) => a.unread > 0).length,
- );
- readonly filtered = computed(() => {
- const q = this.keyword().trim().toLowerCase();
- return this.list().filter((a) => {
- if (this.tab() === 'unread' && a.unread === 0) return false;
- if (!q) return true;
- return (
- a.name.toLowerCase().includes(q) || (a.specialty ?? '').toLowerCase().includes(q)
- );
- });
- });
- async ngOnInit(): Promise<void> {
- await this.live.ensureLoaded();
- }
- async reload(): Promise<void> {
- await this.live.refresh();
- }
- onSearch(e: Event): void {
- this.keyword.set((e.target as HTMLInputElement).value);
- }
- open(id: string): void {
- void this.router.navigate(['/', this.device.mode(), 'chat', id]);
- }
- }
|