| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715 |
- 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: `
- <app-page [fixedHeader]="true" title="工作台" subtitle="所有数字生命的运行实况与任务进展。">
- <!-- 工具条:搜索 + 状态筛选(固定区,不随卡片滚动) -->
- <div pageToolbar class="wb-toolbar">
- <ui-input
- prefixIcon="fa-solid fa-magnifying-glass"
- [clearable]="true"
- placeholder="搜索数字生命"
- [value]="keyword()"
- (valueChange)="onSearch($any($event))"
- />
- <app-filter-chips
- ariaLabel="运行状态"
- [chips]="statusChips()"
- [active]="statusFilter()"
- (select)="onStatusSelect($event)"
- />
- </div>
- <ng-container pageActions>
- <ui-button
- variant="outline"
- icon="fa-solid fa-rotate-right"
- [loading]="live.state() === 'loading'"
- (click)="refresh()"
- >
- 刷新实况
- </ui-button>
- <ui-button variant="primary" icon="fa-solid fa-list-check" (click)="openTasks()">
- 任务看板
- </ui-button>
- </ng-container>
- @switch (live.state()) {
- @case ('loading') {
- <div class="overview">
- @for (i of [1, 2, 3, 4]; track i) {
- <ui-skeleton variant="rect" height="88px" />
- }
- </div>
- <div class="grid">
- @for (i of [1, 2, 3, 4, 5, 6]; track i) {
- <ui-skeleton variant="rect" height="220px" />
- }
- </div>
- }
- @case ('error') {
- <!-- AC-07.3:加载失败必须显示错误态,禁止伪装为空态或权限问题 -->
- <ui-card>
- <ui-empty
- icon="fa-solid fa-triangle-exclamation"
- title="实况数据加载失败"
- [description]="live.error() ?? '请检查网络后重试。'"
- />
- <div class="empty-action">
- <ui-button variant="outline" (click)="refresh()">重新加载</ui-button>
- </div>
- </ui-card>
- }
- @case ('empty') {
- <ui-card>
- <ui-empty
- title="暂无数字生命"
- description="当前账号下还没有可用的数字生命。"
- />
- </ui-card>
- }
- @default {
- <!-- 总览统计:底座 ui-statistic,不再手写统计块 -->
- <div class="overview">
- <ui-statistic title="数字生命" [value]="live.agents().length" icon="fa-solid fa-cube" />
- <!--
- 2026-11-11 用户裁决:工作台只表达「在线 / 离线」,
- 不再显示「运行中」「需要关注」这类运行态汇总。
- -->
- <ui-statistic
- title="在线"
- [value]="onlineCount()"
- color="success"
- icon="fa-solid fa-signal"
- />
- <ui-statistic
- title="离线"
- [value]="offlineCount()"
- [color]="offlineCount() > 0 ? 'danger' : 'default'"
- icon="fa-solid fa-plug-circle-xmark"
- />
- <ui-statistic
- title="阻塞任务"
- [value]="blockedTasks().length"
- [color]="blockedTasks().length > 0 ? 'danger' : 'default'"
- icon="fa-solid fa-ban"
- />
- </div>
- <!-- AC-07b.3:阻塞主动上浮,不藏在详情页 -->
- @if (blockedTasks().length > 0) {
- <ui-card class="alerts">
- <div class="alert-head">
- <i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
- <strong>{{ blockedTasks().length }} 个任务已阻塞</strong>
- <ui-button variant="outline" size="sm" (click)="openTasks()">查看原因</ui-button>
- </div>
- <ul class="alert-list">
- @for (t of blockedTasks(); track t.id) {
- <li>
- <span class="alert-agent">{{ agentName(t.agentId) }}</span>
- <span class="alert-title">{{ t.title }}</span>
- @if (t.blockedHours) {
- <span class="alert-meta">已阻塞 {{ dur(t.blockedHours) }}</span>
- }
- @if (t.consecutiveFailures) {
- <span class="alert-meta">连续失败 {{ t.consecutiveFailures }} 次</span>
- }
- </li>
- }
- </ul>
- </ui-card>
- }
- <!-- 实况卡片网格:流动列宽,窗口变宽就多排几列 -->
- <div class="grid">
- @for (a of visibleAgents(); track a.id) {
- <ui-card>
- <div class="card-head">
- <ui-avatar [name]="a.name" />
- <div class="card-title">
- <strong class="ellipsis">{{ a.name }}</strong>
- <small class="ellipsis">{{ a.specialty || '数字生命' }}</small>
- </div>
- <app-status-badge [agent]="a" />
- </div>
- <div class="card-task">
- <span class="eyebrow">当前任务</span>
- <!--
- 2026-11-11 用户裁决:工作台只表达「是否在线」。
- 原先是 currentTask 直出,上报器自指时会把
- periodic status reporter (xlosi3m46q) 印在卡片上。
- -->
- <p class="ellipsis">{{ liveTaskOf(a) }}</p>
- </div>
- @if (hasMetrics(a)) {
- <div class="metrics">
- <div class="metric">
- <span class="metric-label">CPU</span>
- <ui-progress [value]="raw(a.host?.cpuPercent)" size="sm" />
- <span class="metric-value">{{ pctText(a.host?.cpuPercent) }}</span>
- </div>
- <div class="metric">
- <span class="metric-label">内存</span>
- <ui-progress [value]="raw(a.host?.memPercent)" size="sm" />
- <span class="metric-value">{{ pctText(a.host?.memPercent) }}</span>
- </div>
- </div>
- <div class="card-foot">
- <span class="foot-item">Token <strong>{{ tokens(a) }}</strong></span>
- <!--
- 余额口径(2026-11-11):这是**主人账号**的共用余额,不是本容器独立额度。
- 容器账单挂在主人账号上,同一主人的所有容器显示同一个数 —— 必须标注,避免误读。
- -->
- <ui-tooltip [content]="balanceTip(a)" position="top">
- <ui-button
- variant="ghost"
- size="sm"
- iconRight="fa-solid fa-arrow-up-right-from-square"
- (click)="showBalance(a)"
- >
- <span class="balance-label">账号余额</span> {{ balance(a) }}
- </ui-button>
- </ui-tooltip>
- </div>
- } @else if (ready(a)) {
- <!--
- 已接入但容器**未上报 host 指标**。
- 🔴 此前直接落到「尚未接入」或显示 CPU 0%(误导)——
- 0% 与「未上报」是两回事,如实区分(不编造数据)。
- -->
- <p class="not-ready">
- <i class="fa-solid fa-clock-rotate-left" aria-hidden="true"></i>
- 容器已接入但尚未上报运行指标(CPU/内存)。
- </p>
- }
- <div class="card-actions">
- <ui-button variant="outline" size="sm" block (click)="openChannel(a)">
- 通道详情
- </ui-button>
- <ui-button variant="primary" size="sm" block (click)="openAgentTasks(a)">
- 查看任务
- </ui-button>
- </div>
- </ui-card>
- }
- </div>
- }
- }
- <!-- 滚到底加载更多(分页) -->
- @if (live.state() === 'ready') {
- @if (hasMore()) {
- <div appInfiniteScroll (reachedBottom)="loadMore()" class="scroll-sentinel">
- <ui-button variant="ghost" size="sm" (click)="loadMore()">加载更多</ui-button>
- </div>
- } @else if (filteredAgents().length > 0) {
- <p class="list-end">已加载全部 {{ filteredAgents().length }} 个数字生命</p>
- }
- }
- </app-page>
- <!-- 通道详情:底座 ui-drawer(内置遮罩/Esc/焦点陷阱) -->
- <ui-drawer
- [open]="!!channelAgent()"
- title="通道详情"
- placement="right"
- (openChange)="channelAgent.set(null)"
- >
- @if (channelAgent(); as a) {
- <div class="drawer-block">
- <ui-avatar [name]="a.name" size="lg" />
- <div>
- <strong>{{ a.name }}</strong>
- <p class="drawer-sub">{{ a.specialty }}</p>
- </div>
- </div>
- <dl class="kv">
- <div><dt>连接状态</dt><dd>{{ ready(a) ? '已接入' : '未接入' }}</dd></div>
- <div><dt>通道类型</dt><dd>{{ a.channels?.matrix ? '在线通道' : '—' }}</dd></div>
- <div><dt>接入状态</dt><dd class="ellipsis">{{ ready(a) ? '正常' : '未接入' }}</dd></div>
- <div><dt>最近心跳</dt><dd>{{ a.heartbeatAt || '—' }}</dd></div>
- </dl>
- <p class="drawer-note">此处仅展示连接信息,不展示任何凭证。</p>
- }
- </ui-drawer>
- `,
- 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<Agent | null>(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<void> {
- 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<void> {
- 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',
- );
- }
- }
|