import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal, viewChild } from '@angular/core'; import { Router } from '@angular/router'; import { UiAvatar, UiButton, UiCard, UiDrawer, UiEmpty, UiInput, UiProgress, UiSkeleton, UiStatistic, UiTooltip, } from '../../../ui'; import { PageComponent } from '../../../components/shell/page/page'; import { InfiniteScrollDirective } from '../../../shared/infinite-scroll.directive'; import { FilterChipsComponent } from '../../../components/domain/filter-chips/filter-chips'; import { StatusBadgeComponent } from '../../../components/domain/status-badge/status-badge'; import { Agent } from '../../../core/api/models'; import { openInNewTab } from '../../../core/open-link'; import { AlertService } from '../../../core/alert.service'; import { isReporterSelfTalk, livenessOf } from '../../../core/agent-liveness'; import { DeviceService } from '../../../core/device.service'; import { hasMetrics as hasMetricsFn, fmtBalance, fmtPercent, fmtTokens, isMatrixReady, usageOf } from '../../../core/format'; import { LiveService } from '../../../core/live.service'; import { TasksService } from '../../../core/tasks.service'; import { fmtDuration } from '../../../core/time'; /** * 桌面·工作台(SPEC FR-07 + FR-07b) * * 实现纪律(2026-10-15 返工): * · **用 ui-kit 组件拼装**,不手写替代品 —— 卡片/统计/进度/抽屉全部走底座 * · **流动布局**:卡片网格 `auto-fill + minmax`,不写死像素;由 `app-page` 管 max-width 与居中 * · **不出现 demo 件**:无演示控制台、无演示提示条、无「演示数据」文案 * * AC 覆盖:07.1 字段齐全 / 07.2 双端同源 / 07.3 失败显示错误态 / 07.4 余额外链 * 07b.3 阻塞主动上浮 / 07b.6 一期只读 */ @Component({ selector: 'app-dk-workbench-page', changeDetection: ChangeDetectionStrategy.OnPush, imports: [ UiAvatar, UiButton, UiCard, UiDrawer, UiEmpty, UiInput, UiProgress, UiSkeleton, UiStatistic, UiTooltip, InfiniteScrollDirective, FilterChipsComponent, PageComponent, StatusBadgeComponent, ], template: `
刷新实况 任务看板 @switch (live.state()) { @case ('loading') {
@for (i of [1, 2, 3, 4]; track i) { }
@for (i of [1, 2, 3, 4, 5, 6]; track i) { }
} @case ('error') {
重新加载
} @case ('empty') { } @default {
@if (blockedTasks().length > 0) {
{{ blockedTasks().length }} 个任务已阻塞 查看原因
}
@for (a of visibleAgents(); track a.id) {
{{ a.name }} {{ a.specialty || '数字生命' }}
当前任务

{{ liveTaskOf(a) }}

@if (hasMetrics(a)) {
CPU {{ pctText(a.host?.cpuPercent) }}
内存 {{ pctText(a.host?.memPercent) }}
Token {{ tokens(a) }} 账号余额 {{ balance(a) }}
} @else if (ready(a)) {

容器已接入但尚未上报运行指标(CPU/内存)。

}
通道详情 查看任务
}
} } @if (live.state() === 'ready') { @if (hasMore()) {
加载更多
} @else if (filteredAgents().length > 0) {

已加载全部 {{ filteredAgents().length }} 个数字生命

} }
@if (channelAgent(); as a) {
{{ a.name }}

{{ a.specialty }}

连接状态
{{ ready(a) ? '已接入' : '未接入' }}
通道类型
{{ a.channels?.matrix ? '在线通道' : '—' }}
接入状态
{{ ready(a) ? '正常' : '未接入' }}
最近心跳
{{ a.heartbeatAt || '—' }}

此处仅展示连接信息,不展示任何凭证。

}
`, styles: [ ` /* 编排样式:仅放底座未覆盖的布局,视觉一律交给令牌与底座 */ :host { display: block; } /* 统计区:流动列宽 —— 窗口变宽自然增加列数,不写死 px */ .overview { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--sp-3); margin-bottom: var(--sp-5); } /* 卡片网格:同上,minmax 决定断点,无需媒体查询 */ .wb-toolbar { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; margin-bottom: var(--sp-4); } .scroll-sentinel { display: flex; justify-content: center; padding: var(--sp-4) 0; } .list-end { margin: var(--sp-4) 0 0; text-align: center; font-size: var(--fs-12); color: var(--muted); } .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: var(--sp-4); } .alerts { display: block; margin-bottom: var(--sp-5); border-color: var(--danger); } .alert-head { display: flex; align-items: center; gap: var(--sp-2); color: var(--danger); margin-bottom: var(--sp-2); } .alert-head strong { flex: 1; min-width: 0; font-size: var(--fs-13); } .alert-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--sp-1); } .alert-list li { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); font-size: var(--fs-12); color: var(--danger); } .alert-agent { font-weight: 600; } .alert-title { opacity: 0.9; } .alert-meta { font-size: var(--fs-11); opacity: 0.75; } .card-head { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-3); } .card-title { display: grid; gap: 2px; flex: 1; min-width: 0; } .card-title strong { font-size: var(--fs-15); color: var(--ink); } .card-title small { font-size: var(--fs-12); color: var(--muted); } .card-task { padding: var(--sp-3); border-radius: var(--r-sm); background: var(--surface-2); margin-bottom: var(--sp-3); } .eyebrow { font-family: var(--mono, monospace); font-size: var(--fs-11); letter-spacing: 1.2px; color: var(--muted); text-transform: uppercase; } .card-task p { margin: var(--sp-1) 0 0; font-size: var(--fs-13); color: var(--ink); } .metrics { display: grid; gap: var(--sp-2); margin-bottom: var(--sp-3); } .metric { display: grid; grid-template-columns: 32px minmax(0, 1fr) 44px; align-items: center; gap: var(--sp-2); } .metric-label { font-size: var(--fs-11); color: var(--muted); } .metric-value { font-size: var(--fs-11); color: var(--ink-2); text-align: right; } .card-foot { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); margin-bottom: var(--sp-2); } .foot-item { font-size: var(--fs-11); color: var(--muted); } .foot-item strong { color: var(--ink-2); } .not-ready { display: flex; align-items: center; gap: var(--sp-2); margin: 0 0 var(--sp-3); font-size: var(--fs-12); color: var(--muted); } .card-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-2); } .empty-action { display: flex; justify-content: center; margin-top: var(--sp-3); } .drawer-block { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-4); } .drawer-sub { margin: 2px 0 0; font-size: var(--fs-12); color: var(--muted); } .kv { margin: 0; display: grid; gap: var(--sp-2); } .kv > div { display: flex; justify-content: space-between; gap: var(--sp-3); font-size: var(--fs-13); } .kv dt { color: var(--muted); flex-shrink: 0; } .kv dd { margin: 0; color: var(--ink); text-align: right; min-width: 0; } .drawer-note { margin: var(--sp-4) 0 0; font-size: var(--fs-11); color: var(--muted); } .ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } `, ], }) export class DkWorkbenchPage implements OnInit { /** * Fmode Studio 充值页(2026-11-11)。 * * 与云函数 `xlosi3m46q` 取余额的 `billing/query` 同源, * 所以这里显示的金额和点进去看到的余额是同一口径。 */ private static readonly BALANCE_URL = 'https://app.fmode.cn/dev/studio/balance'; readonly live = inject(LiveService); readonly tasks = inject(TasksService); private readonly router = inject(Router); private readonly device = inject(DeviceService); private readonly alert = inject(AlertService); readonly channelAgent = signal(null); readonly blockedTasks = computed(() => this.tasks.blocked()); /** * 在线容器数(2026-11-11 用户裁决:工作台只显示「在线 / 离线」)。 * * 判据统一走 `livenessOf()`(心跳 80 分钟 + Matrix 通道), * 不再用 `resolveAgentState` 的「执行中 / 空闲」这套运行态。 */ readonly onlineCount = computed( () => this.live.agents().filter((a) => livenessOf(a, Date.now(), this.live.fetchedAt()).online).length, ); /** 离线容器数(总数 − 在线) */ readonly offlineCount = computed(() => this.live.agents().length - this.onlineCount()); /** * 卡片上「当前任务」的文案(2026-11-11)。 * * 容器上报器会把 `status: running` + `currentTask: "periodic status reporter"` * 硬编码上报,直接展示等于告诉用户「它在干活」,其实什么业务都没做。 * 这里只认**真实业务任务**。 */ readonly liveTaskOf = (a: Agent): string => { if (a.status !== 'running' || isReporterSelfTalk(a)) return '暂无进行中的任务'; return a.currentTask?.trim() || a.currentAction?.trim() || '暂无进行中的任务'; }; async ngOnInit(): Promise { await this.live.ensureLoaded(); await this.tasks.ensureAllLoaded(); } /* * 工作台列表:搜索 + 状态筛选 + 分页(用户要求统一为「搜索+参数筛选+分页」)。 */ readonly keyword = signal(''); /** 2026-11-11:筛选收成两态(在线 / 离线) */ readonly statusFilter = signal<'all' | 'online' | 'offline'>('all'); private readonly PAGE_SIZE = 12; private readonly shown = signal(this.PAGE_SIZE); private readonly infiniteScroll = viewChild(InfiniteScrollDirective); /** 筛选后的全集(未分页) */ readonly filteredAgents = computed(() => { const q = this.keyword().trim().toLowerCase(); const f = this.statusFilter(); const fetchedAt = this.live.fetchedAt(); return this.live.agents().filter((a) => { /* 判据统一走 livenessOf(心跳 80 分钟 + 通道),与徽标同源 */ const online = livenessOf(a, Date.now(), fetchedAt).online; if (f === 'online' && !online) return false; if (f === 'offline' && online) return false; if (!q) return true; return ( a.name.toLowerCase().includes(q) || (a.specialty ?? '').toLowerCase().includes(q) ); }); }); /** 分页后可见的数字生命 */ readonly visibleAgents = computed(() => this.filteredAgents().slice(0, this.shown())); readonly hasMore = computed(() => this.shown() < this.filteredAgents().length); readonly statusChips = computed(() => [ { key: 'all', label: '全部', count: this.live.agents().length }, { key: 'online', label: '在线', count: this.onlineCount() }, { key: 'offline', label: '离线', count: this.offlineCount() }, ]); onSearch(v: string): void { this.keyword.set(v); this.resetPaging(); } onStatusSelect(key: string): void { this.statusFilter.set(key as 'all' | 'online' | 'offline'); this.resetPaging(); } loadMore(): void { if (!this.hasMore()) return; this.shown.update((n) => n + this.PAGE_SIZE); this.infiniteScroll()?.reset(); } resetPaging(): void { this.shown.set(this.PAGE_SIZE); } async refresh(): Promise { await this.live.refresh(); } ready(a: Agent): boolean { return isMatrixReady(a); } /** 是否有可展示的运行指标(已接入 **且** 容器上报过 host);模板判定用 */ hasMetrics(a: Agent): boolean { return hasMetricsFn(a); } /** 进度条需要 0–100 的数值;未接入或无数据时归零 */ raw(v?: number): number { return Math.min(100, Math.max(0, v ?? 0)); } pctText(v?: number): string { return fmtPercent(v); } tokens(a: Agent): string { return fmtTokens(usageOf(a).totalTokens); } balance(a: Agent): string { return fmtBalance(a.balance); } /** 余额悬浮说明:讲清「谁的余额 / 已用多少」 */ balanceTip(a: Agent): string { const used = a.balance?.usedCny; const usedText = used == null ? '' : `;已用 ${fmtBalance({ remainCny: used })}`; const owner = a.balance?.sharedAccount ? '主人账号' : '账号'; return `${owner}共用余额(容器账单挂在主人账号,非本容器独立额度)${usedText}。点击前往充值`; } dur(h?: number): string { return fmtDuration(h); } agentName(id: string): string { return this.live.agentById(id)?.name ?? '数字生命'; } /** * 余额 → 充值页(2026-11-11 修复)。 * * 🔴 原来只在 `a.balance?.pageUrl` 有值时跳转,而云函数 `xlosi3m46q` * **根本不返回 `pageUrl`**(只返回 remainCny/usedCny/source)—— * 于是点击余额调用走到 else 分支什么都不做,用户看到的就是「点了没反应」。 * * 现在:有 `pageUrl` 就用它(后端将来若下发,优先尊重); * 没有就回落到 Fmode Studio 充值页(与云函数取余额的 `billing/query` 同源)。 */ showBalance(a: Agent): void { const url = a.balance?.pageUrl || DkWorkbenchPage.BALANCE_URL; openInNewTab(url); } openChannel(a: Agent): void { this.channelAgent.set(a); } /** * 任务入口(2026-11-11 改)。 * * 「任务」页路由已随用户裁决下线(入口和路由一起关),原来跳 * `/desktop/tasks`、`/desktop/tasks/:id` 会落到会话页,看起来像点坏了。 * 工作台上仍保留这两个按钮,但改为**如实告知**:不假装还能跳过去。 */ openTasks(): void { this.alert.show('任务看板暂未开放', '任务入口已下线,可在会话右栏「任务」页签查看该容器的全部任务。', 'warning'); } openAgentTasks(a: Agent): void { this.alert.show( `「${a.name}」的任务`, '任务看板暂未开放,进入该容器的会话后,可在右栏「任务」页签查看。', 'warning', ); } }