This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/apps/web/src/features/gov-testbed/internal/activity-run.tsx
T
xcsandClaude Opus 5 0aa2fc11b0 资料文件真产出来了;E04/I01 补到验收线,顺手填了几个「墙拦住却不给路」的坑
**资料文件全链**(用户:审核资料这一步要真产出 word/excel/md/html,前端能渲、点资料名能开)
接手时这一步一个文件都产不出来 —— S3 剧本白纸黑字写着「file_ref 本 MVP 阶段可留空」。
文件是台账那一行的投影,不另存一份:
· 声明 `file:{titleColumn,bodyColumn,formatColumn}`,它同时是授权面(没声明就点不开)
· 现造真字节 —— docx/xlsx 是自己打的 OOXML 包(zip-write:store+CRC32,零依赖)
· 读回来渲 —— 解的就是造出来那份字节,渲得出来才证明文件是真的
· 件上 `data-file-of` → 工作区二级界面(Word 纸面/Excel 网格/渲好的 md/沙箱 html)+ 下载原件
放行落地前把每一份冻进 `sys.task_asset_file`(迁移 0029):归档件点开的必须是签字那一刻
那一份,而资料落在主数据上、试跑单不落正式表 —— 不存这一份,收工后一个都点不开。
工作成果里「产出的资料」自成一摞。Markdown 富文本渲染顺带销账(仍零依赖)。

**Harness**
· 画面按「这一拍办完之后」在办的那一步挑 —— 从前拿拍首的局面决定拍尾的事:
  收掉 S4、发出 S5 的卡,而屏上还摆着 S4 的只读件,人无处可填(墙要配路第四次现形)
· 验收「重做」真能退回重做,且打回后台账没动就不许再提交(软硬两段,不锁死单子)
· 立监听的授权面是清单声明;发卡的授权面是调人预算,不是计划项
· 读工具同参重调有兜底;空转写身后还有行动就不废串;finish 的 doneWhen 按写完后的草稿算

**界面**
· 翻件与翻页各归各位(从前二选一,多卡多页时页翻不动);没得翻写「—」不写 0/0
· 工作成果详情能翻份;表宽超出能横滑(E04/I01 抄版式时把 overflow-x 抄丢了)
· 查看面灰底滚过一屏变白 —— 两条独立的路都治了(沙箱内不同色 / 弹性子项不长个)
· 测试台「取不到」不再当成「还活着」

**活动**
E04 关系覆盖、I01 品牌内容各自补到验收线:判据全读基座数、判据指向的东西摆上卡面、
样例满载且名字 ≤14 字;提交前要裁的判据搬进说明书(模型会压拍,压过之后只有说明书还在)。

验收:e2e i01 4/4、e04 3/3 全部 released,资料在办中渲得出、放行后存了档点得开;
门禁 typecheck/lint/depcruise/1628 测全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
2026-08-24 10:26:45 +08:00

754 lines
32 KiB
TypeScript

/**
* 试跑**一个活动** —— 测试台的第二种试法,与试跑流程包并列。
*
* 为什么要单有一种:开发一个活动时,关心的是「这个活动自己跑不跑得动」——
* 案例模型推不推得开、判据取不取得齐、台账写不写得对、验收卡长什么样。
* 走流程包试,前面几个节点的噪音得先趟一遍,而且每改一次活动就要重跑整条流。
*
* 起出来的是**真任务**:照旧过装载两审、Harness 预算、案例模型推进、放行门 ——
* 试跑不等于假跑,假跑试不出任何东西。区别只有一个 `origin='testbed'`,好随时连根拔掉。
*
* 视觉沿用同一台仪器的语言:**机器说 mono**(读数、灯、编号),
* **外界伸进来的东西说无衬线**(起单入参就是外界给的那份料,所以它也是一张纸)。
*/
import { useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { testActivityStateRoute, testRunActivityRoute, testStopRoute, uploadFileRoute } from '@contracts';
import type { UploadedFile } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx';
import { call } from '@web-shared/typed-fetch.ts';
import { useGovActivities, useGovStaff } from '@web-shared/gov/gov-api.ts';
import { QueryGate } from '@web-shared/gov/primitives.tsx';
import type { Nav } from '@web-shared/gov/gov-route.ts';
import { DraftGroups } from './drafts.tsx';
import { observeFace } from './observe-face.ts';
type Parsed<S> = S extends { parse: (data: unknown) => infer O } ? O : never;
export type ActivityState = Parsed<typeof testActivityStateRoute.output>;
type ActivityCard = NonNullable<ReturnType<typeof useGovActivities>['data']>['activities'][number];
/* ==================== 装配态 ==================== */
export function ActivityRun({
taskId,
onStarted,
onStopped,
strip,
nav,
}: {
/** 已在跑的那一单(刷新页面也认得回来) */
taskId?: string;
onStarted: (id: string) => void;
onStopped: () => void;
strip: (input: { lamp: 'idle' | 'live' | 'wait' | 'done' | 'bad'; breathe: boolean; word: string; detail?: ReactNode; right?: ReactNode }) => ReactNode;
nav: Nav;
}): ReactNode {
const actQ = useGovActivities();
const staffQ = useGovStaff();
const staff = useMemo(() => (staffQ.data?.staff ?? []).filter((s) => s.state === 'active'), [staffQ.data]);
const activities = useMemo(() => (actQ.data?.activities ?? []).filter((a) => a.enabled), [actQ.data]);
const [pickedId, setPicked] = useState('');
const picked = activities.find((a) => a.activityId === pickedId) ?? activities[0];
const [data, setData] = useState<Readonly<Record<string, string>>>({});
const [ownerId, setOwner] = useState('');
const [error, setError] = useState<string | undefined>(undefined);
const queryClient = useQueryClient();
/*
* 换活动就换一套入参 —— 上一个活动填的值留在表单里毫无意义(字段名都不一样),
* 而且极易让人以为「我填过了」。有样例就照样例预填:包自带的样例本来就是
* 「这个活动一单长什么样」的权威说法,让人对着空表单编,编出来的多半跑不通。
*/
useEffect(() => {
if (picked === undefined) return;
setData(sampleValues(picked, 0));
}, [picked?.activityId]);
const start = useMutation({
mutationFn: (input: { activityId: string; data: Record<string, unknown>; ownerId: string }) =>
call<{ taskId: string; title: string }>(testRunActivityRoute, { body: input }),
onSuccess: (r) => {
setError(undefined);
onStarted(r.taskId);
void queryClient.invalidateQueries({ queryKey: ['gov', 'testbed'] });
},
// 后端的判词照抄:「活动未装」「入参不合契约」「模型未绑」都是要人改配置的话,不该被改写
onError: (e: unknown) => setError((e as { message?: string }).message ?? '起单失败'),
});
const stop = useMutation({
mutationFn: (id: string) => call<{ destroyed: boolean; tasks: number }>(testStopRoute, { body: { taskId: id } }),
onSuccess: () => {
onStopped();
start.reset();
void queryClient.invalidateQueries({ queryKey: ['gov', 'testbed'] });
void queryClient.invalidateQueries({ queryKey: ['desk'] });
},
});
if (taskId !== undefined) {
return (
<ActivityObserve
taskId={taskId}
strip={strip}
stopping={stop.isPending}
onStop={() => stop.mutate(taskId)}
/* 服务端说这一单没了:不调销毁接口(没有可销毁的),只让上层松手、重取「当下挂着哪一单」 */
onGone={onStopped}
/>
);
}
const allFields = orderFields(picked?.contract.inputFields ?? [], picked?.contract.requiredInputs ?? []);
// 藏起来的格子不算数 —— 没露面的必填(如未选「事件驱动」时的「事件类型」)不该拦起单
const fields = allFields.filter((f) => isVisible(f, data));
const missing = fields.filter((f) => f.required && (data[f.name] ?? '').trim() === '').map((f) => f.label ?? f.name);
const fireable = picked !== undefined && missing.length === 0;
const ai = staff.find((s) => s.name === 'AI');
/*
* 改一格顺手清掉因此藏起来的那几格 —— 触发方式从「事件驱动」切回「月度例行」,
* 「事件类型」那格就该跟着消失;它已经填的值若留在 state 里,人切回来又看得见,
* 也会被 `fire()` 悄悄当成这一单的入参一起交上去,而界面上早就看不见它了。
*
* 改的若是一格枚举选择(如「触发方式」「事件类型」),选完顺手看这组枚举组合
* 唯不唯一对得上一条样例 —— 唯一就把那条样例其余格子的内容套进来当默认值
* (「月度例行」/「事件驱动+某事件类型」各自的默认客户与说明,不用另外去点样例按钮)。
* 不唯一(比如刚选了「事件驱动」,「事件类型」还没选)就不动,留给人接着选。
*/
const setField = (name: string, value: string): void => {
setData((prev) => {
const clear = (obj: Record<string, string>): void => {
for (const f of allFields) if (!isVisible(f, obj)) delete obj[f.name];
};
let next: Record<string, string> = { ...prev, [name]: value };
clear(next);
const changed = allFields.find((f) => f.name === name);
if (picked !== undefined && changed?.options !== undefined) {
const idx = matchingSample(picked, next, allFields);
/*
* **整单换成这条样例,不是把它的字段叠上去** —— 样例的 `input` 本就是稀疏的
* (「月度例行」样例只有 `trigger` 一个键),叠加式的 `{...next, ...sampleValues}`
* 加得上样例给的值,却清不掉样例没提的值:从「重大商机」切回「月度例行」,
* 客户与说明会照抄上一条样例的内容原样卡在框里不消失。样例匹配到的这一刻,
* 它就该是这一单入参唯一的真相,照 sampleValues 整份重写。
*/
if (idx !== undefined) {
next = sampleValues(picked, idx);
clear(next); // 套模版也可能改变可见性,按同一条规矩再清一遍
}
}
return next;
});
};
/*
* 起单前**按契约声明归型**:表单格子里全是文本,而契约说得清哪个字段是数组、哪个是对象
* (`attachments: string[]`)。不归型就把 `["BOM.xlsx"]` 这串字面量当字符串送进去,
* 活动拿到的类型与自己声明的对不上 —— 这类错一路要到 Agent 读入参时才看得出来。
* 归不了型当场说,并且**照厂商原话说**:说「不是合法 JSON」,不含糊成「起单失败」。
*/
const fire = (card: ActivityCard): void => {
const typeOf = new Map(card.contract.inputFields.map((f) => [f.name, f.type]));
const visibleNames = new Set(fields.map((f) => f.name));
const shaped: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(data)) {
// 藏起来的格子不该跟着交上去 —— 与 `setField` 的清理是同一条规矩,这里再兜一道底
if (!visibleNames.has(key)) continue;
if (raw.trim() === '') continue;
const type = typeOf.get(key) ?? 'string';
if (!type.endsWith('[]') && type !== 'object') {
shaped[key] = raw;
continue;
}
try {
shaped[key] = JSON.parse(raw);
} catch {
setError(`${typeLabel(card, key)}要填 JSON(${type}),这串解析不了:${raw.slice(0, 40)}`);
return;
}
}
setError(undefined);
start.mutate({ activityId: card.activityId, data: shaped, ownerId });
};
return (
<>
{strip({
lamp: 'idle',
breathe: false,
word: '装配中',
detail: picked === undefined ? '活动库里还没有可试的活动' : `${picked.name} · 单活动仿真`,
right: picked === undefined ? undefined : `${picked.activityId}`,
})}
<QueryGate query={actQ}>
{activities.length === 0 ? (
<div className="tb-slot">
<Ico name="scan" s={26} />
<b>活动库里还没有可试的活动</b>
<p>装一个活动包回来,它就出现在这里 —— 试跑的是真任务,得先有个真活动。</p>
<button className="g-btn" onClick={() => nav.jump('library', 'shelf')}>
<Ico name="package" s={12} />
去活动库
</button>
</div>
) : (
<>
{activities.length > 1 && (
<div className="tb-pkrow">
{activities.map((a) => (
<button
key={a.activityId}
className={'g-pk' + (a.activityId === picked?.activityId ? ' on' : '')}
onClick={() => setPicked(a.activityId)}
>
<b>{a.name}</b>
<span className="id">{a.activityId}</span>
</button>
))}
</div>
)}
<div className="g-rig">
{/* ── 左:起单入参 —— 外界给的那份料,所以它也是一张纸 ── */}
<section className="g-rig-l">
<div className="g-rig-h">
<span className="n">01</span>
输入 · 起单入参
<span className="x">
{(picked?.contract.samples ?? []).map((s, i) => (
<button key={s.name} className="tb-ghost" onClick={() => picked && setData(sampleValues(picked, i))}>
<Ico name="rotate-ccw" s={10} />
{s.name.length > 14 ? `样例 ${i + 1}` : s.name}
</button>
))}
</span>
</div>
<div className="tb-paper">
<div className="tb-pp-h">
{fields.map((f) => (
<label key={f.name} className="tb-pp-r">
<em>
{f.label ?? f.name}
{/* 必填只打一枚星号 —— 「必填」二字在窄标签栏里把字段名挤到折行 */}
{f.required && (
<i className="req" aria-label="必填">
*
</i>
)}
</em>
{f.format === 'file' ? (
<FilePicker
label={f.label ?? f.name}
value={data[f.name] ?? ''}
onChange={(next) => setField(f.name, next)}
onError={setError}
/>
) : f.options !== undefined && f.options.length > 0 ? (
<select
className="tb-pp-i"
value={data[f.name] ?? ''}
onChange={(e) => setField(f.name, e.target.value)}
>
<option value="">{f.required ? '请选择…' : '(不填)'}</option>
{f.options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
) : isLong(f) ? (
<textarea
className="tb-pp-i"
rows={5}
value={data[f.name] ?? ''}
placeholder={f.example ?? f.type}
onChange={(e) => setField(f.name, e.target.value)}
/>
) : (
<input
className="tb-pp-i"
value={data[f.name] ?? ''}
placeholder={f.example ?? f.type}
onChange={(e) => setField(f.name, e.target.value)}
/>
)}
</label>
))}
</div>
</div>
</section>
{/* ── 右:派给谁 + 契约摘要 ── */}
<section className="g-rig-r">
<div className="g-rig-h">
<span className="n">02</span>
派给谁
<span className="x">
{ai !== undefined && (
<button className="tb-ghost" onClick={() => setOwner(ai.id)}>
<Ico name="bot" s={10} />
派给 AI
</button>
)}
</span>
</div>
<div className="tb-asn">
{/* solo:这一台只有一格插座,没有「活动/审批」那一列 —— 少一格,少一列 */}
<div className={'tb-asn-r solo' + (ownerId !== '' ? ' pin' : '')}>
<span className="rail">
<i />
</span>
<span className="nid">OWNER</span>
<span className="lb">任务负责人</span>
<select
className="tb-socket"
aria-label="这一单派给谁"
value={ownerId}
onChange={(e) => setOwner(e.target.value)}
>
<option value="">我自己</option>
{staff.map((s) => (
<option key={s.id} value={s.id}>
{s.name} · {s.position.code}
</option>
))}
</select>
</div>
</div>
<div className="g-rig-h">
<span className="n">03</span>
这个活动手上有什么
</div>
<ContractFace card={picked} />
</section>
<div className="tb-fire">
<span className="say">
{error !== undefined ? (
<b className="bad">{error}</b>
) : missing.length > 0 ? (
`还缺:${missing.join('、')}`
) : (
'起单会先销毁上一单;这是**真任务** —— 照旧过预算、案例模型与放行门,只是可以随时拔掉'
)}
</span>
<button
className={'tb-go' + (fireable ? ' armed' : '')}
disabled={!fireable || start.isPending}
onClick={() => picked && fire(picked)}
>
<Ico name="play" s={13} />
{start.isPending ? '起单中…' : '起单试跑'}
</button>
</div>
</div>
</>
)}
</QueryGate>
</>
);
}
type InputField = {
name: string;
type: string;
required: boolean;
label?: string | undefined;
/** `file` = 这一格收真文件(契约里的 `format`)—— 界面给文件选择器 */
format?: string | undefined;
/** 声明了枚举(契约里的 `enum`)—— 界面给下拉选,不给自由文本框猜合法值 */
options?: { value: string; label: string }[] | undefined;
/** 示例值(契约里的 `examples` 第一项)—— 占位提示用,比裸类型名能抄 */
example?: string | undefined;
/** 声明了 → 只在另一格(`showWhen.field`)当下等于 `showWhen.equals` 时才出现(级联下拉) */
showWhen?: { field: string; equals: string } | undefined;
};
/** 这一格该不该露面 —— 没声明 `showWhen` 恒可见,声明了就看那另一格当下的值 */
function isVisible(f: InputField, data: Readonly<Record<string, string>>): boolean {
return f.showWhen === undefined || (data[f.showWhen.field] ?? '') === f.showWhen.equals;
}
/**
* 当前选中的这组枚举字段(有 `options` 且可见的那几格,如「触发方式」「事件类型」)
* 如果唯一对应清单里的一条样例,回它的下标 —— 正是这条样例把其余格子的默认内容定下来。
* 不唯一(比如选了「事件驱动」但「事件类型」还没选,四条候选样例都对不上)就回
* `undefined`,不瞎填一条。这是通用能力:任何"选项 A 决定用哪套默认数据"的活动
* 都靠它自动套模版,不用专门写一次性逻辑。
*/
function matchingSample(card: ActivityCard, data: Readonly<Record<string, string>>, allFields: readonly InputField[]): number | undefined {
const enumFields = allFields.filter((f) => f.options !== undefined && f.options.length > 0 && isVisible(f, data));
if (enumFields.length === 0) return undefined;
const hit = card.contract.samples
.map((s, i) => ({ s, i }))
.filter(({ s }) => enumFields.every((f) => String((s.input as Record<string, unknown>)[f.name] ?? '') === (data[f.name] ?? '')))
.map(({ i }) => i);
return hit.length === 1 ? hit[0] : undefined;
}
/** 长文本:正文、对象、数组 —— 一行输入框装不下的东西 */
/* ==================== 真文件那一格 ==================== */
/**
* 附件格 —— **选的是真文件**,不是一串文件名。
*
* 选完当场传上去,基座在入口就把它读成文本(xlsx 读成行列),回传的描述件里带着
* `text` 与一句 `note`。这一格摆的就是那句 note:**读出来什么就说什么** ——
* 读不出的类型如实说读不出,不摆一个「已上传 ✓」让人以为活动看得见里面。
*
* 值仍以 JSON 串存在表单状态里(与其它格同一形状),起单时由 `fire` 统一归型。
*/
function FilePicker({
label,
value,
onChange,
onError,
}: {
label: string;
value: string;
onChange: (next: string) => void;
onError: (msg: string | undefined) => void;
}): ReactNode {
const [busy, setBusy] = useState(false);
const files: UploadedFile[] = (() => {
if (value.trim() === '') return [];
try {
const parsed: unknown = JSON.parse(value);
return Array.isArray(parsed) ? (parsed as UploadedFile[]) : [];
} catch {
return [];
}
})();
const take = async (picked: FileList | null): Promise<void> => {
if (picked === null || picked.length === 0) return;
setBusy(true);
onError(undefined);
try {
const added: UploadedFile[] = [];
for (const file of Array.from(picked)) {
const buf = new Uint8Array(await file.arrayBuffer());
let bin = '';
for (const b of buf) bin += String.fromCharCode(b);
added.push(
await call<UploadedFile>(uploadFileRoute, {
body: { name: file.name, contentType: file.type, dataBase64: btoa(bin) },
}),
);
}
onChange(JSON.stringify([...files, ...added]));
} catch (e: unknown) {
// 后端的判词照抄(「超过 8MB」「解出来是 0 字节」都是要人自己处置的话)
onError((e as { message?: string }).message ?? '上传失败');
} finally {
setBusy(false);
}
};
return (
<span className="tb-files">
{files.map((f, i) => (
<span key={f.ref} className={'tb-file' + (f.text === undefined ? ' blind' : '')}>
<Ico name={f.text === undefined ? 'alert-triangle' : 'file-text'} s={11} />
<b>{f.name}</b>
<i>{(f.size / 1024).toFixed(0)} KB · {f.note}</i>
<button
aria-label={`移除 ${f.name}`}
onClick={() => onChange(JSON.stringify(files.filter((_, j) => j !== i)))}
>
<Ico name="x" s={10} />
</button>
</span>
))}
<label className="tb-file-add">
<Ico name="paperclip" s={11} />
{busy ? '读取中…' : `选文件${files.length === 0 ? '' : '(再加)'}`}
<input
type="file"
multiple
aria-label={`选择${label}`}
disabled={busy}
onChange={(e) => {
void take(e.target.files);
e.target.value = '';
}}
/>
</label>
</span>
);
}
/** 报错要说人话:契约里写了中文名就用中文名 */
function typeLabel(card: ActivityCard, name: string): string {
const f = card.contract.inputFields.find((x) => x.name === name);
return f?.label ?? name;
}
/**
* 哪些格子要摊成大方框:正文、对象、对象数组。
* **一串字符串不算**(`string[]`,比如一行附件名)—— 给它五行高的框,
* 标签会孤零零吊在一片空白左边,而里面只有一行 `["BOM.xlsx"]`。
*/
function isLong(f: InputField): boolean {
return f.name === 'body' || f.type === 'object' || f.type === 'object[]';
}
/**
* 表单字段的顺序**不靠契约里的键序** —— 那个顺序到不了这里。
*
* 清单存进库是 jsonb,PG 读回时会把对象键重排(先短后长、再字典序):
* 一份写作「客户邮箱 / 主题 / 邮件内容 / 附件」的契约,读回来变成
* 「主题 / 客户邮箱 / 附件 / 邮件内容」—— 键序在这一层已经不可信。
*
* 唯一活着穿过 jsonb 的顺序是 **`required` 那个数组**(数组有序,对象无序)。
* 所以:必填照它排 —— 契约作者写下的次序就是他要人填的次序;选填跟在后面,长正文垫底。
*/
function orderFields(fields: readonly InputField[], requiredOrder: readonly string[]): InputField[] {
const rank = (f: InputField): number => {
const i = requiredOrder.indexOf(f.name);
return i === -1 ? requiredOrder.length : i;
};
return [...fields].sort((a, b) => {
if (a.required !== b.required) return a.required ? -1 : 1;
if (a.required) return rank(a) - rank(b);
if (isLong(a) !== isLong(b)) return isLong(a) ? 1 : -1;
return 0;
});
}
/** 样例 → 表单值:一律转成字符串(表单是文本,归型交给活动契约那一侧) */
function sampleValues(card: ActivityCard, index: number): Record<string, string> {
const sample = card.contract.samples[index];
if (sample === undefined) return {};
return Object.fromEntries(
Object.entries(sample.input).map(([k, v]) => [k, typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v ?? '')]),
);
}
/**
* 契约面读数 —— **起单前就该看见**这个活动手上有什么。
* 跑不动的原因八成在这几行里:工具没装、模型没绑、台账对象没物化、预算给太紧。
*/
function ContractFace({ card }: { card: ActivityCard | undefined }): ReactNode {
if (card === undefined) return null;
const c = card.contract;
return (
<div className="tb-face">
<Row label="工具" values={c.tools} empty="不调工具" />
<Row label="模型" values={c.models} empty="不问模型" />
<Row label="台账" values={c.objects} empty="不写台账" />
<Row label="出口" values={c.outputPorts.map((p) => p.name)} empty="无出口" />
<div className="tb-face-r">
<em>预算</em>
<span className="v mono">调人 ≤ {c.humanCallBudget}</span>
</div>
</div>
);
}
function Row({ label, values, empty }: { label: string; values: readonly string[]; empty: string }): ReactNode {
return (
<div className="tb-face-r">
<em>{label}</em>
{values.length === 0 ? <span className="v none">{empty}</span> : (
<span className="v">
{values.map((v) => (
<i key={v} className="chip">
{v}
</i>
))}
</span>
)}
</div>
);
}
/* ==================== 观测态 ==================== */
/**
* 单活动试跑的观测面 —— 一屏看穿这一单。
*
* 流程包那侧看 trace 树(节点 → 步骤);活动这侧要看的是**活动内部**:
* 每一拍干了什么、拿回了什么、被拒的理由、成果落到台账哪一行、卡问了谁什么。
* 拿 trace 树看不出「第三拍为什么被拒」—— 那句话在任务轨迹里。
*/
function ActivityObserve({
taskId,
strip,
stopping,
onStop,
onGone,
}: {
taskId: string;
strip: (input: { lamp: 'idle' | 'live' | 'wait' | 'done' | 'bad'; breathe: boolean; word: string; detail?: ReactNode; right?: ReactNode }) => ReactNode;
stopping: boolean;
onStop: () => void;
/** 服务端说这一单没了 —— 面板松手(别再钉着一个死号轮询) */
onGone: () => void;
}): ReactNode {
const q = useQuery({
queryKey: ['gov', 'testbed', 'activity', taskId],
queryFn: () => call<ActivityState>(testActivityStateRoute, { params: { taskId } }),
/*
* 轮询是**兜底**,不是主路:每一拍推进后端都会广播,订阅层按 `gov.testbed` 前缀
* 失效这把查询(见 `app/subscribe.ts`)—— 画面跟着推送走,比 2 秒快得多。
* 轮询留着是为了推送漏掉的那种情形(断线、别的进程推进的单)。
* 到终态就停:已经不动的东西没必要一直问。
*/
/*
* **取不到不是活着**(2026-08-23):从前这里只看 `data.terminal`,而取数失败时
* `data` 保留的是上一次成功的那份、`terminal` 恒 undefined —— 于是对着一个
* 已经不存在的任务 2s 一直打,屏上还举着「AWAITING-EVENT · 自动刷新」。
* 现在由 `observeFace` 一处判断该不该继续问(404 与终态都不必再问)。
*/
refetchInterval: (query) =>
observeFace({ data: query.state.data, error: query.state.error as { status?: number } | null ?? undefined }).poll
? 2000
: false,
// 任务没了就别重试 —— 重试只是把同一句「不存在」再问三遍
retry: (count, error) => (error as { status?: number } | undefined)?.status !== 404 && count < 2,
});
const s = q.data;
const face = observeFace({ data: q.data, ...(q.error === null ? {} : { error: q.error as { status?: number } }) });
const live = face.breathe;
/* 服务端说没了:松手一次(回装配台或切到当下那一单)—— 不在渲染里直接改父状态 */
useEffect(() => {
if (face.gone) onGone();
}, [face.gone, onGone]);
return (
<>
{strip({
lamp: face.lamp,
breathe: face.breathe,
word: face.word,
detail: (
<>
{s?.title ?? taskId.slice(0, 8)}
{live && <i className="auto"> · 自动刷新 2s</i>}
</>
),
right: (
/*
* 一个钮两种说法 —— **在跑时是「停」,跑完了是「清场」**:
* 跑完之后已经没有什么可停的,还写「停止仿真」会让人以为不点它这一单就还占着;
* 而它真正的作用是把这一单连根拔掉、腾出仪器再来一单。
*/
<button className="tb-stop" disabled={stopping} onClick={onStop}>
<Ico name={live ? 'square' : 'rotate-ccw'} s={11} />
{stopping ? '销毁中…' : live ? '停止仿真' : '清场 · 再来一单'}
</button>
),
})}
<QueryGate query={q}>
{s !== undefined && (
<>
{/* 读数屏:预算三格 —— Harness 在环外掐的就是这三个数 */}
<div className="tb-gauges">
<Gauge label="步数" used={s.used.steps} cap={s.caps.steps} />
<Gauge label="token" used={s.used.tokens} cap={s.caps.tokens} />
<Gauge label="调人" used={s.used.humanCalls} cap={s.caps.humanCalls} />
</div>
{s.reason !== undefined && <p className="tb-reason">{s.reason}</p>}
<div className="g-rig">
<section className="g-rig-l">
<div className="g-rig-h">
<Ico name="list" s={12} />
逐拍轨迹
<span className="x">{s.trail.length} 条</span>
</div>
<ol className="tb-trail">
{s.trail.map((t, i) => (
<li key={`${t.at}-${i}`} className={'k-' + t.kind}>
<span className="t">{new Date(t.at).toLocaleTimeString('zh-CN', { hour12: false })}</span>
<span className="b">
{t.title}
{detailLine(t.detail) !== '' && <i className="d">{detailLine(t.detail)}</i>}
</span>
</li>
))}
</ol>
</section>
<section className="g-rig-r">
<div className="g-rig-h">
<Ico name="database" s={12} />
台账草稿 · 成果就是这些行
<span className="x">{s.drafts.length} 行</span>
</div>
{s.drafts.length === 0 ? (
<p className="tb-none">还没有写下任何一行 —— 活动的成果就是台账里的行,一行没有就是还没交东西。</p>
) : (
<div className="tb-drafts">
<DraftGroups drafts={s.drafts} />
</div>
)}
<div className="g-rig-h">
<Ico name="inbox" s={12} />
卡 · 问了谁什么
<span className="x">{s.cards.length} 张</span>
</div>
{s.cards.length === 0 ? (
<p className="tb-none">还没有发过卡。</p>
) : (
<div className="tb-cards">
{s.cards.map((card) => (
<div key={card.id} className={'row' + (card.done ? ' done' : '')}>
<span className="m">{card.mode === 'acceptance' ? '验收' : '执行'}</span>
<span className="t">{card.title}</span>
<span className="s">{card.done ? (card.result ?? '已答') : '待答'}</span>
</div>
))}
</div>
)}
</section>
</div>
</>
)}
</QueryGate>
</>
);
}
function Gauge({ label, used, cap }: { label: string; used: number; cap: number }): ReactNode {
const pct = cap === 0 ? 0 : Math.min(100, Math.round((used / cap) * 100));
return (
<div className={'tb-g' + (pct >= 90 ? ' hot' : pct >= 70 ? ' warm' : '')}>
<em>{label}</em>
<b>
{used.toLocaleString('zh-CN')}
<i> / {cap.toLocaleString('zh-CN')}</i>
</b>
<span className="bar">
<i style={{ width: `${pct}%` }} />
</span>
</div>
);
}
/** 轨迹行的附注:回填值 / 工具出参 / 错误 —— 与喂给 Agent 的那一份同源 */
function detailLine(detail: Record<string, unknown> | undefined): string {
if (detail === undefined) return '';
const parts: string[] = [];
const values = detail['values'];
if (typeof values === 'object' && values !== null) parts.push(`回填 ${JSON.stringify(values)}`);
const output = detail['output'];
if (output !== undefined && output !== null) parts.push(`出参 ${JSON.stringify(output)}`);
const error = detail['error'];
if (typeof error === 'string' && error !== '') parts.push(error);
const text = parts.join(' · ');
return text.length > 180 ? `${text.slice(0, 180)}…` : text;
}