Archived
资料文件真产出来了;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:
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user