feat(ohos): 运行态面板 —— 阶段管道 + 中断队列(落在设置页二级明细顶部)

接上一条腿:桌面版已同步,这次补鸿蒙端缺的运行态面板。
落点按之前的建议放在「设置 → 运行状态」二级明细页最前:明细卡回答「内核有哪些
东西、多少」,运行态回答「现在在干什么」,后者是进这个页面最先想看的。

## 新增

- `common/StageTrail.ets`:阶段轨迹单例(SSE 驱动)。由 ChatSse 的 stage 分支
  喂入,面板读取。**不并进 StatusStore**:轨迹来自 SSE 流,与 /status、/kernel
  的轮询是两条独立数据源,生命周期与失败模式都不同(SSE 断连不该让状态卡变空,
  状态轮询失败也不该清掉轨迹)。七阶段归并成五格,同阶段同一条累加计数,
  2.5s 无新事件自动回空闲。
- `components/RuntimePanel.ets`:等大表框面板。
  - 四个数字块(排队/中断/栈/子代理)
  - 阶段管道:每格 = 阶段名 + 本阶段本轮事件;当前阶段整框点亮
  - 中断队列:5 格(L4/L3/L2/L1 + 排队),级别色贯穿框头/槽位/描边;
    有积压整框描边点亮;排队队列虚线框区分(另一类别,不是另一优先级)
  - 格槽固定可见:深度为 0 时也有形状,不会剩一片空白

## 改动

- `StatusStore`:新增 `/runtime` 采集与 `RuntimeSnapshot`/`RuntimeQueue`
  (明细与运行态分开取、分开存);失败保留上一次快照,旧后端无此端点时
  面板显示「运行态数据不可用」。
- `ChatSse`:stage 分支先喂轨迹,再管聊天侧角标。
- `SettingsPage`:`stageTrail.init()`。
- `StatusCards`:二级明细顶部渲染 `RuntimePanel()`。

## 关于宽度

模拟器(API 24,1256x2760 ≈ 360vp 宽)上 5 框一行会让「内核独占」这类标签被
挤成省略号,所以按项目已有的 `isWideScreen` 分两支:宽屏 5 框一行,手机 3+2
(补一个占位格保证框宽对齐)。两档都是等大框。

## 验证

- `hvigorw assembleHap` → **BUILD SUCCESSFUL**;`clean` 后全量重建,
  本次新增/改动的 6 个文件 **零 ArkTS 告警**。
- 未上机实测:本机签名 profile 无法授予 `ohos.permission.READ_PASTEBOARD`
  (module.json5 里已有的一项,非本次改动),`hdc install -r` 报
  `error: install failed due to grant request permissions failed`。
  没有为此卸载设备上的应用(会丢用户已存的连接配置),也没有改权限列表
  (属产品决定)。要上机的话,我可以临时去掉那一条权限打个一次性包验证。
This commit is contained in:
JianFeeeee
2026-09-14 19:05:46 +08:00
parent 2163a7a092
commit f3fa0e8f5b
6 changed files with 674 additions and 0 deletions

View File

@ -10,6 +10,7 @@ import { ChatMessage, ToolCallInfo, ChatAttachment } from '../model/Model';
import { attachmentFromChannelOutput } from './AttachmentMeta';
import { SseEvent } from './SseClient';
import { stringifyField } from './ChatFormat';
import { stageTrail } from './StageTrail';
import { hilog } from '@kit.PerformanceAnalysisKit';
/** 聊天流状态机对外暴露的最小写入面(由 common/ChatStore.ets 实现) */
@ -168,6 +169,9 @@ export function applyChatSse(ev: SseEvent, sink: ChatStreamSink): void {
const channel: string = payload['channel'] as string ?? '';
const stageTool: string = payload['tool'] as string ?? '';
if (channel !== '_consolidation_') {
// 运行态面板的阶段管道靠这条轨迹活着:先喂轨迹,再管聊天侧的角标。
// 两者是独立消费者,轨迹不依赖任何聊天状态。
stageTrail.onStage(phase, stageTool);
if (phase === 'pre_action') {
sink.setStage('AI 思考中...');
} else if (phase === 'before_toolcall') {

View File

@ -0,0 +1,180 @@
/**
* 阶段轨迹(单例):本轮对话在七个内核阶段里真实发生过什么。
*
* 由 ChatSse 在收到 `stage` 事件时喂入,运行态面板读取。
* 为什么不放进 StatusStore轨迹来自 SSE 流、与 /status、/kernel 的轮询无关,
* 两者的生命周期和失败模式都不一样,混在一个数据源里会互相拖累
* SSE 断连不该让状态卡变空,状态轮询失败也不该清掉轨迹)。
*
* 七阶段归并成五格(与内核 sdk.Stage 的顺序一致):
* 一轮里工具调用会反复回到「行动后」,线性滑块本身就是错的表述,
* 所以画成 输入 → 行动 ⇄(工具) → 输出 → 结束,工具那格带循环标记。
*/
/** 一个阶段组 */
export interface StageGroup {
/** 0 输入 / 1 行动 / 2 工具 / 3 输出 / 4 结束 */
group: number;
label: string;
en: string;
}
/** 轨迹里的一条事件 */
export interface StageEvent {
group: number;
/** 'stage' | 'tool' | 'output' */
kind: string;
label: string;
short: string;
count: number;
}
/** 阶段组定义,索引即 group */
export const STAGE_GROUPS: StageGroup[] = [
{ group: 0, label: '输入', en: 'in' },
{ group: 1, label: '行动', en: 'act' },
{ group: 2, label: '工具', en: 'tool' },
{ group: 3, label: '输出', en: 'out' },
{ group: 4, label: '结束', en: 'done' },
];
/** 当前阶段SSE 驱动)。空串 = 空闲。 */
export const K_STAGE_PHASE: string = 'stagePhase';
/** 轨迹版本号:数组不进 AppStorage靠它触发订阅组件重取快照 */
export const K_STAGE_REV: string = 'stageRev';
/** 阶段停留多久后回「空闲」——否则会留下一个永远停在 after_output 的假状态 */
const IDLE_AFTER_MS: number = 2500;
const MAX_ITEMS: number = 24;
/** rtShortTool 把 `qq_get_message` / `output_send__qq` 压成尾段短名 */export function shortTool(name: string): string {
let n: string = name;
const i: number = n.lastIndexOf('__');
if (i >= 0) {
n = n.substring(i + 2);
}
return n.length > 14 ? n.substring(0, 13) + '…' : n;
}
class StageTrail {
private items: StageEvent[] = [];
private timerId: number = -1;
init(): void {
AppStorage.setOrCreate<string>(K_STAGE_PHASE, '');
AppStorage.setOrCreate<number>(K_STAGE_REV, 0);
}
/** 只给订阅组件读;调用方不要持有它 */
snapshot(): StageEvent[] {
return this.items;
}
currentPhase(): string {
return AppStorage.get<string>(K_STAGE_PHASE) ?? '';
}
/** 按阶段分组取条目 */
eventsOf(group: number): StageEvent[] {
const out: StageEvent[] = [];
for (let i = 0; i < this.items.length; i++) {
const e: StageEvent = this.items[i];
if (e.group === group) {
out.push(e);
}
}
return out;
}
/**
* 收到一条 stage 事件。phase 取值与内核一致:
* on_input / pre_action / post_action / before_toolcall / after_toolcall /
* before_output / after_output。
*/
onStage(phase: string, tool: string): void {
if (phase.length === 0) {
return;
}
if (phase === 'on_input') {
// 新的一轮:清空上一轮的轨迹
this.items = [];
this.push(0, 'stage', '输入', '输入');
} else if (phase === 'pre_action') {
this.push(1, 'stage', '组装上下文并思考', '思考');
} else if (phase === 'before_toolcall' && tool.length > 0) {
// output_* 是输出通道工具,与普通工具用不同配色区分
const kind: string = tool.indexOf('output_') === 0 ? 'output' : 'tool';
this.push(2, kind, tool, shortTool(tool));
} else if (phase === 'before_output') {
this.push(3, 'stage', '生成回复', '生成');
} else if (phase === 'after_output') {
this.push(4, 'stage', '本轮完成', '完成');
} else {
// post_action / after_toolcall 不单独记:它们与相邻格重复,
// 逐条记会把轨迹刷成噪音。
AppStorage.set<string>(K_STAGE_PHASE, phase);
this.armIdleTimer();
return;
}
AppStorage.set<string>(K_STAGE_PHASE, phase);
this.armIdleTimer();
this.bump();
}
private push(group: number, kind: string, label: string, short: string): void {
// 同一阶段重复出现的同一条(如同一工具连调 3 次)只累加计数,不刷屏
const n: number = this.items.length;
if (n > 0) {
const last: StageEvent = this.items[n - 1];
if (last.group === group && last.kind === kind && last.short === short) {
last.count = last.count + 1;
return;
}
}
this.items.push({ group: group, kind: kind, label: label, short: short, count: 1 });
if (this.items.length > MAX_ITEMS) {
this.items.shift();
}
}
private armIdleTimer(): void {
if (this.timerId !== -1) {
clearTimeout(this.timerId);
}
this.timerId = setTimeout(() => {
this.timerId = -1;
AppStorage.set<string>(K_STAGE_PHASE, '');
this.bump();
}, IDLE_AFTER_MS);
}
private bump(): void {
const cur: number = AppStorage.get<number>(K_STAGE_REV) ?? 0;
AppStorage.set<number>(K_STAGE_REV, cur + 1);
}
}
export const stageTrail: StageTrail = new StageTrail();
/**
* 内核的七个阶段归并到五个展示格。顺序与 sdk.Stage 一致,
* 所以「当前阶段」直接看返回值是不是当前格。空闲返回 -1。
*/
export function phaseGroup(phase: string): number {
if (phase === 'on_input') {
return 0;
}
if (phase === 'pre_action' || phase === 'post_action') {
return 1;
}
if (phase === 'before_toolcall' || phase === 'after_toolcall') {
return 2;
}
if (phase === 'before_output') {
return 3;
}
if (phase === 'after_output') {
return 4;
}
return -1;
}

View File

@ -27,6 +27,68 @@ export interface StatGroup {
fields: StatField[];
}
// ===== 运行态快照(/runtime=====
//
// 与 StatGroup 的分工:明细卡回答「内核有哪些东西、多少」,
// 运行态回答「现在在干什么」——四级中断队列积压多少、有几个驻留子。
// 两者数据源不同(/kernel vs /runtime所以分开取、分开存。
/** 一条队列(四级中断之一,或排队队列) */
export interface RuntimeQueue {
/** 4/3/2/10 表示排队队列(无级别) */
lv: number;
name: string;
desc: string;
depth: number;
registered: number;
preempted: number;
}
/** 一次刷新的运行态快照 */
export interface RuntimeSnapshot {
/** 排队队列深度(无级别,纯 FIFO */
ready: number;
pending: number;
stack: number;
maxStack: number;
subagents: number;
/** 格槽数(按全场最大深度缩放,至少 5 */
slots: number;
/** 五条队列L4/L3/L2/L1 + 排队 */
queues: RuntimeQueue[];
}
interface LevelDef {
lv: number;
name: string;
desc: string;
}
/** 四级中断的定义(顺序即 L4→L1 */
const LEVEL_DEFS: LevelDef[] = [
{ lv: 4, name: 'L4', desc: '内核独占' },
{ lv: 3, name: 'L3', desc: '交互' },
{ lv: 2, name: 'L2', desc: '消息' },
{ lv: 1, name: 'L1', desc: '后台' },
];
/** JSON 数组里按下标取数:越界/类型不符都当 0 */
function numAt(arr: Object[] | undefined, i: number): number {
if (arr === undefined || arr === null || i < 0 || i >= arr.length) {
return 0;
}
const v: number = arr[i] as number;
return isNaN(v) ? 0 : v;
}
/** 格槽数:按全场最大深度缩放,至少 5 格0 时也要有可见形状)、最多 16 格 */
function clampSlots(maxQ: number): number {
if (maxQ < 5) {
return 5;
}
return maxQ > 16 ? 16 : maxQ;
}
// ===== AppStorage 键:摘要卡与明细页共用 =====
export const K_UP: string = 'statUp';
export const K_VERSION: string = 'statVersion';
@ -43,6 +105,8 @@ export const K_BUILD: string = 'statBuildSub';
class StatusStore {
/** 明细分组:只有明细页读它,不进 AppStorage数组同步语义太脆 */
private groups: StatGroup[] = [];
/** 运行态快照:同上,靠 K_REV 触发订阅组件重取 */
private runtimeSnapshot: RuntimeSnapshot | undefined = undefined;
init(): void {
AppStorage.setOrCreate<boolean>(K_UP, false);
@ -61,6 +125,10 @@ class StatusStore {
return this.groups;
}
getRuntime(): RuntimeSnapshot | undefined {
return this.runtimeSnapshot;
}
isUp(): boolean {
return AppStorage.get<boolean>(K_UP) ?? false;
}
@ -104,6 +172,7 @@ class StatusStore {
];
await this.collectKernel(groups);
await this.collectRuntime();
this.groups = groups;
this.bump();
} catch (e) {
@ -282,11 +351,91 @@ class StatusStore {
}
}
/**
* 取运行态快照(/runtime。失败不影响已经取到的明细分组
* 旧后端可能没有这个端点,那时面板显示「运行态数据不可用」即可。
*/
private async collectRuntime(): Promise<void> {
try {
const resp = await apiClient.getWithTimeout('/runtime', 8000);
const r: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
const sc: Record<string, Object> | undefined = r['scheduler'] as Record<string, Object>;
const residents: Object[] | undefined = r['residents'] as Object[];
let ready: number = 0;
let pending: number = 0;
let stack: number = 0;
let maxStack: number = 4;
let qArr: Object[] | undefined = undefined;
let regArr: Object[] | undefined = undefined;
let preArr: Object[] | undefined = undefined;
if (sc !== undefined && sc !== null) {
ready = sc['ready_queue_depth'] as number ?? 0;
pending = sc['pending_interrupts'] as number ?? 0;
stack = sc['suspend_stack'] as number ?? 0;
maxStack = sc['max_suspend_depth'] as number ?? 4;
qArr = sc['interrupt_queues'] as Object[];
regArr = sc['interrupts_by_level'] as Object[];
preArr = sc['preempts_by_level'] as Object[];
}
// 四级语义引用内核的定义internal/agent/core/scheduler.go
// 前端只负责把它们画出来,不自己起名字。
const queues: RuntimeQueue[] = [];
let maxQ: number = 1;
for (let i = 0; i < LEVEL_DEFS.length; i++) {
const d: LevelDef = LEVEL_DEFS[i];
const depth: number = numAt(qArr, d.lv);
if (depth > maxQ) {
maxQ = depth;
}
queues.push({
lv: d.lv,
name: d.name,
desc: d.desc,
depth: depth,
registered: numAt(regArr, d.lv),
preempted: numAt(preArr, d.lv),
});
}
if (ready > maxQ) {
maxQ = ready;
}
// 第五条:排队队列。它不是优先级,而是另一**类别**(排队 vs 中断),
// 所以 lv 用 0 标记「无级别」。
queues.push({
lv: 0,
name: '排队',
desc: 'FIFO',
depth: ready,
registered: 0,
preempted: 0,
});
const subagents: number = residents !== undefined && residents !== null
? residents.length : 0;
this.runtimeSnapshot = {
ready: ready,
pending: pending,
stack: stack,
maxStack: maxStack,
subagents: subagents,
slots: clampSlots(maxQ),
queues: queues,
};
} catch (e) {
// /runtime 不可用(旧后端):保留上一次快照,不清空
}
}
private fail(msg: string): void {
AppStorage.set<string>(K_ERR, msg);
AppStorage.set<boolean>(K_UP, false);
AppStorage.set<boolean>(K_LOADING, false);
this.groups = [];
this.runtimeSnapshot = undefined;
this.bump();
}

View File

@ -0,0 +1,332 @@
import {
ThemePalette, DARK_PALETTE, LIGHT_PALETTE,
RADIUS_SM, RADIUS_MD, ANIM_FAST,
COLOR_ACCENT, COLOR_EMERALD, COLOR_FROST_300,
} from '../common/Constants';
import { statusStore, RuntimeSnapshot, RuntimeQueue, K_REV } from '../common/StatusStore';
import {
stageTrail, StageEvent, StageGroup, STAGE_GROUPS, phaseGroup,
K_STAGE_PHASE, K_STAGE_REV,
} from '../common/StageTrail';
/** 四级中断 + 排队的配色:与 WebUI 总览同一套(级别色贯穿框头、槽位、描边) */
const LV_COLORS: string[] = ['#A3BE8C', '#4A90D9', '#0A59F7', '#FFA657', '#FF5C7A'];
/** 索引 = lv0 排队 / 1 L1 / 2 L2 / 3 L3 / 4 L4 */
function lvColor(lv: number): string {
if (lv < 0 || lv > 4) {
return COLOR_ACCENT;
}
return LV_COLORS[lv];
}
/**
* 运行态面板:阶段管道 + 中断队列。
*
* 与 WebUI / 桌面版同一套设计语言:**等大表框**。
* 此前鸿蒙端完全没有运行态展示「状态」Tab 已并入设置页 + 二级明细),
* 这里补在二级明细页顶部。
*
* 两块数据来源不同:
* - 队列 / 计数StatusStore 轮询 /runtime靠 K_REV 通知;
* - 阶段 + 本轮轨迹StageTrail 由 SSE 的 stage 事件喂入,靠 K_STAGE_REV 通知。
*/
@Component
export struct RuntimePanel {
@StorageProp('themeIsDark') private isDark: boolean = true;
/** 宽屏(平板/折叠5 框一行;手机 3+2。窄屏硬塞 5 框会把标签挤成省略号。 */
@StorageProp('isWideScreen') private isWide: boolean = false;
@StorageProp(K_REV) @Watch('onStatusRev') private statusRev: number = 0;
@StorageProp(K_STAGE_REV) @Watch('onStageRev') private stageRev: number = 0;
@StorageProp(K_STAGE_PHASE) private phase: string = '';
@State private snap: RuntimeSnapshot | undefined = undefined;
/** 格槽下标ArkUI 的 ForEach 遍历的是数组,所以把「几格」摊成下标数组 */
@State private slotIdx: number[] = [];
@State private trail: StageEvent[] = [];
aboutToAppear(): void {
this.pull();
}
private onStatusRev(): void {
this.pull();
}
private onStageRev(): void {
this.trail = stageTrail.snapshot();
}
private pull(): void {
const s: RuntimeSnapshot | undefined = statusStore.getRuntime();
this.snap = s;
if (s !== undefined) {
const idx: number[] = [];
for (let i = 0; i < s.slots; i++) {
idx.push(i);
}
this.slotIdx = idx;
} else {
this.slotIdx = [];
}
this.trail = stageTrail.snapshot();
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
/** 该阶段本轮发生的事件 */
private eventsOf(group: number): StageEvent[] {
const out: StageEvent[] = [];
for (let i = 0; i < this.trail.length; i++) {
const e: StageEvent = this.trail[i];
if (e.group === group) {
out.push(e);
}
}
return out;
}
@Builder
tile(label: string, value: string, warn: boolean) {
Column({ space: 2 }) {
Text(value)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(warn ? this.palette().accent : this.palette().textPrimary)
.maxLines(1)
Text(label)
.fontSize(11)
.fontColor(this.palette().textMuted)
.maxLines(1)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 10, right: 10, top: 9, bottom: 9 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().bgHover)
}
/** 阶段管道的一格 */
@Builder
stageCell(g: StageGroup, active: boolean) {
Column({ space: 6 }) {
Row({ space: 5 }) {
// 用一个几何圆点而非图标:本项目没有为「阶段」准备的图形资源,
// 而猜 sys.media.* 名称会直接编译不过;也不允许用 emoji 充当图标。
// 不用 Circle().fill():那是 SDK 26 起的 API本工程兼容版本是 6.1.1(24)。
Row()
.width(7)
.height(7)
.borderRadius(4)
.backgroundColor(active ? this.palette().accent : this.palette().textMuted)
Text(g.label)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(active ? this.palette().accent : this.palette().textSecondary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
// 本阶段本轮发生的事件;没有就给一个弱化的「无」,让框不空着
Column({ space: 3 }) {
ForEach(this.eventsOf(g.group), (e: StageEvent) => {
Row({ space: 3 }) {
Text(e.short)
.fontSize(9)
.fontColor(e.kind === 'output' ? this.palette().accent
: (e.kind === 'tool' ? COLOR_FROST_300 : this.palette().textSecondary))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (e.count > 1) {
Text('x' + e.count.toString())
.fontSize(9)
.fontColor(this.palette().textMuted)
}
}
.width('100%')
}, (e: StageEvent, i: number) => i.toString() + ':' + e.short)
if (this.eventsOf(g.group).length === 0) {
Text('无')
.fontSize(9)
.fontColor(this.palette().textMuted)
.opacity(0.5)
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8, right: 8, top: 9, bottom: 9 })
.borderRadius(RADIUS_SM)
.backgroundColor(active ? this.palette().accentBg : this.palette().bgHover)
.border({ width: 1, color: active ? this.palette().accent : this.palette().glassBorder })
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
}
/** 中断队列的一格 */
@Builder
queueCell(q: RuntimeQueue) {
Column({ space: 5 }) {
Row({ space: 4 }) {
Text(q.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(lvColor(q.lv))
.maxLines(1)
Text(q.desc)
.fontSize(9)
.fontColor(this.palette().textMuted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.alignItems(VerticalAlign.Bottom)
Text(q.depth.toString())
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(this.palette().textPrimary)
.maxLines(1)
// 格槽:固定可见的「车位」。用进度条时深度为 0 宽度就是 0
// 整格只剩文字,看上去就是「这块空着」。
Row({ space: 2 }) {
ForEach(this.slotIdx, (i: number) => {
Row()
.layoutWeight(1)
.height(11)
.borderRadius(2)
.backgroundColor(i < q.depth ? lvColor(q.lv) : this.palette().bgHover)
}, (i: number) => q.lv.toString() + '-' + i.toString())
}
.width('100%')
if (q.lv > 0) {
Text(q.registered.toString() + ' 登记 · ' + q.preempted.toString() + ' 抢占')
.fontSize(9)
.fontColor(this.palette().textMuted)
.maxLines(1)
} else {
Text('无级别')
.fontSize(9)
.fontColor(this.palette().textMuted)
.maxLines(1)
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8, right: 8, top: 9, bottom: 9 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().bgHover)
.border({
width: 1,
color: q.depth > 0 ? lvColor(q.lv) : this.palette().glassBorder,
style: q.lv === 0 ? BorderStyle.Dashed : BorderStyle.Solid,
})
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
}
build() {
Column() {
Text('运行态')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textPrimary)
.margin({ bottom: 8 })
if (this.snap === undefined) {
Text('运行态数据不可用')
.fontSize(12)
.fontColor(this.palette().textMuted)
.padding({ top: 4, bottom: 4 })
} else {
// 四个数字块
Row({ space: 8 }) {
this.tile('排队', this.snap.ready.toString(), this.snap.ready > 0)
this.tile('中断', this.snap.pending.toString(), this.snap.pending > 0)
this.tile('栈', this.snap.stack.toString() + '/' + this.snap.maxStack.toString(),
this.snap.stack > 0)
this.tile('子代理', this.snap.subagents.toString(), false)
}
.width('100%')
// 阶段管道:等大表框,事件落在所属阶段那一格
Text('阶段管道')
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textSecondary)
.margin({ top: 14, bottom: 6 })
if (this.isWide) {
Row({ space: 6 }) {
ForEach(STAGE_GROUPS, (g: StageGroup) => {
this.stageCell(g, phaseGroup(this.phase) === g.group)
}, (g: StageGroup) => 'w' + g.group.toString())
}
.width('100%')
.alignItems(VerticalAlign.Top)
} else {
Row({ space: 6 }) {
ForEach(STAGE_GROUPS.slice(0, 3), (g: StageGroup) => {
this.stageCell(g, phaseGroup(this.phase) === g.group)
}, (g: StageGroup) => 'n0' + g.group.toString())
}
.width('100%')
.alignItems(VerticalAlign.Top)
Row({ space: 6 }) {
ForEach(STAGE_GROUPS.slice(3), (g: StageGroup) => {
this.stageCell(g, phaseGroup(this.phase) === g.group)
}, (g: StageGroup) => 'n1' + g.group.toString())
// 占位:第二行只有 2 格,补一格位置让框宽与第一行对齐
Row().layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Top)
.margin({ top: 6 })
}
// 中断队列五个等大表框L4/L3/L2/L1 + 排队)
Text('队列')
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textSecondary)
.margin({ top: 14, bottom: 6 })
if (this.isWide) {
Row({ space: 6 }) {
ForEach(this.snap.queues, (q: RuntimeQueue) => {
this.queueCell(q)
}, (q: RuntimeQueue) => 'wq' + q.lv.toString())
}
.width('100%')
.alignItems(VerticalAlign.Top)
} else {
Row({ space: 6 }) {
ForEach(this.snap.queues.slice(0, 3), (q: RuntimeQueue) => {
this.queueCell(q)
}, (q: RuntimeQueue) => 'nq0' + q.lv.toString())
}
.width('100%')
.alignItems(VerticalAlign.Top)
Row({ space: 6 }) {
ForEach(this.snap.queues.slice(3), (q: RuntimeQueue) => {
this.queueCell(q)
}, (q: RuntimeQueue) => 'nq1' + q.lv.toString())
Row().layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Top)
.margin({ top: 6 })
}
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding(16)
.margin({ bottom: 14 })
.borderRadius(RADIUS_MD)
.backgroundColor(this.palette().bgCard)
.border({ width: 1, color: this.palette().glassBorder })
}
}

View File

@ -2,6 +2,7 @@ import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_SM,
ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
import { COLOR_ACCENT, COLOR_SUCCESS, COLOR_CYAN, COLOR_ERROR } from '../common/Constants';
import { MotionBase } from './MotionBase';
import { RuntimePanel } from './RuntimePanel';
import {
statusStore, StatGroup, StatField, compactDuration,
K_UP, K_VERSION, K_STARTED, K_AGENTS, K_PLUGINS, K_TOOLS, K_ERR, K_LOADING, K_REV, K_BUILD,
@ -283,6 +284,11 @@ export struct StatusDetailContent {
// 于是 31 个插件在 203 个工具旁边只剩一条短线 —— 读数没有意义。
// 数量本身已在摘要卡上以图标+数字直观呈现,这里不再重复。
// 运行态面板(阶段管道 + 中断队列)放在明细页最前:
// 明细卡回答「内核有哪些东西、多少」,运行态回答「现在在干什么」,
// 后者是进这个页面最先想看的。
RuntimePanel()
ForEach(this.groups, (g: StatGroup) => {
Column() {
Text(g.title)

View File

@ -17,6 +17,7 @@ import {
SUB_NONE, SUB_STATUS, SUB_CONNECTIONS, SUB_APPEARANCE, SUB_BACKEND, SUB_SECTION,
} from '../common/SettingsModel';
import { statusStore } from '../common/StatusStore';
import { stageTrail } from '../common/StageTrail';
interface SaveSettingBody {
key: string;
@ -72,6 +73,8 @@ export struct SettingsPage {
this.loadBackendSettings();
// 状态 Tab 已删除,运行状态并入本页:顶部摘要卡 + 二级明细页
statusStore.init();
// 阶段轨迹由 SSE 喂入,与状态轮询是两条独立的数据源
stageTrail.init();
statusStore.refresh();
}