资料文件真产出来了;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
This commit is contained in:
xcs
2026-08-24 10:26:45 +08:00
co-authored by Claude Opus 5
parent 70e83d0b09
commit 0aa2fc11b0
92 changed files with 13984 additions and 370 deletions
@@ -14,7 +14,7 @@
import { useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { TERMINAL_STATE_LABEL, testActivityStateRoute, testRunActivityRoute, testStopRoute, uploadFileRoute } from '@contracts';
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';
@@ -22,6 +22,7 @@ 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>;
@@ -89,17 +90,61 @@ export function ActivityRun({
});
if (taskId !== undefined) {
return <ActivityObserve taskId={taskId} strip={strip} stopping={stop.isPending} onStop={() => stop.mutate(taskId)} />;
return (
<ActivityObserve
taskId={taskId}
strip={strip}
stopping={stop.isPending}
onStop={() => stop.mutate(taskId)}
/* 服务端说这一单没了:不调销毁接口(没有可销毁的),只让上层松手、重取「当下挂着哪一单」 */
onGone={onStopped}
/>
);
}
const fields = orderFields(picked?.contract.inputFields ?? [], picked?.contract.requiredInputs ?? []);
// 必填缺一即不可起 —— 门口这道校验与后端同一口径(契约的 required)
const missing = (picked?.contract.inputFields ?? [])
.filter((f) => f.required && (data[f.name] ?? '').trim() === '')
.map((f) => f.label ?? f.name);
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"]` 这串字面量当字符串送进去,
@@ -108,8 +153,11 @@ export function ActivityRun({
*/
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') {
@@ -197,23 +245,36 @@ export function ActivityRun({
<FilePicker
label={f.label ?? f.name}
value={data[f.name] ?? ''}
onChange={(next) => setData((prev) => ({ ...prev, [f.name]: next }))}
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.type}
onChange={(e) => setData((prev) => ({ ...prev, [f.name]: e.target.value }))}
placeholder={f.example ?? f.type}
onChange={(e) => setField(f.name, e.target.value)}
/>
) : (
<input
className="tb-pp-i"
value={data[f.name] ?? ''}
placeholder={f.type}
onChange={(e) => setData((prev) => ({ ...prev, [f.name]: e.target.value }))}
placeholder={f.example ?? f.type}
onChange={(e) => setField(f.name, e.target.value)}
/>
)}
</label>
@@ -301,8 +362,36 @@ type InputField = {
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;
}
/** 长文本:正文、对象、数组 —— 一行输入框装不下的东西 */
/* ==================== 真文件那一格 ==================== */
@@ -484,14 +573,6 @@ function Row({ label, values, empty }: { label: string; values: readonly string[
/* ==================== 观测态 ==================== */
const RUNTIME_LAMP: Readonly<Record<string, 'live' | 'wait' | 'done' | 'bad'>> = {
running: 'live',
'awaiting-human': 'wait',
'awaiting-event': 'wait',
'landing-blocked': 'bad',
terminal: 'done',
};
/**
* 单活动试跑的观测面 —— 一屏看穿这一单。
*
@@ -504,11 +585,14 @@ function ActivityObserve({
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],
@@ -519,17 +603,33 @@ function ActivityObserve({
* 轮询留着是为了推送漏掉的那种情形(断线、别的进程推进的单)。
* 到终态就停:已经不动的东西没必要一直问。
*/
refetchInterval: (query) => (query.state.data?.terminal === undefined ? 2000 : false),
/*
* **取不到不是活着**(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 live = s !== undefined && s.terminal === undefined;
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: s === undefined ? 'live' : (RUNTIME_LAMP[s.runtime] ?? 'live'),
breathe: live,
word: s?.terminal === undefined ? (s?.runtime ?? '取数中') : (TERMINAL_STATE_LABEL[s.terminal] ?? s.terminal),
lamp: face.lamp,
breathe: face.breathe,
word: face.word,
detail: (
<>
{s?.title ?? taskId.slice(0, 8)}