/** * 试跑**一个活动** —— 测试台的第二种试法,与试跑流程包并列。 * * 为什么要单有一种:开发一个活动时,关心的是「这个活动自己跑不跑得动」—— * 案例模型推不推得开、判据取不取得齐、台账写不写得对、验收卡长什么样。 * 走流程包试,前面几个节点的噪音得先趟一遍,而且每改一次活动就要重跑整条流。 * * 起出来的是**真任务**:照旧过装载两审、Harness 预算、案例模型推进、放行门 —— * 试跑不等于假跑,假跑试不出任何东西。区别只有一个 `origin='testbed'`,好随时连根拔掉。 * * 视觉沿用同一台仪器的语言:**机器说 mono**(读数、灯、编号), * **外界伸进来的东西说无衬线**(起单入参就是外界给的那份料,所以它也是一张纸)。 */ 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 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'; type Parsed = S extends { parse: (data: unknown) => infer O } ? O : never; export type ActivityState = Parsed; type ActivityCard = NonNullable['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>>({}); const [ownerId, setOwner] = useState(''); const [error, setError] = useState(undefined); const queryClient = useQueryClient(); /* * 换活动就换一套入参 —— 上一个活动填的值留在表单里毫无意义(字段名都不一样), * 而且极易让人以为「我填过了」。有样例就照样例预填:包自带的样例本来就是 * 「这个活动一单长什么样」的权威说法,让人对着空表单编,编出来的多半跑不通。 */ useEffect(() => { if (picked === undefined) return; setData(sampleValues(picked, 0)); }, [picked?.activityId]); const start = useMutation({ mutationFn: (input: { activityId: string; data: Record; 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 stop.mutate(taskId)} />; } 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 fireable = picked !== undefined && missing.length === 0; const ai = staff.find((s) => s.name === 'AI'); /* * 起单前**按契约声明归型**:表单格子里全是文本,而契约说得清哪个字段是数组、哪个是对象 * (`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 shaped: Record = {}; for (const [key, raw] of Object.entries(data)) { 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}`, })} {activities.length === 0 ? (
活动库里还没有可试的活动

装一个活动包回来,它就出现在这里 —— 试跑的是真任务,得先有个真活动。

) : ( <> {activities.length > 1 && (
{activities.map((a) => ( ))}
)}
{/* ── 左:起单入参 —— 外界给的那份料,所以它也是一张纸 ── */}
01 输入 · 起单入参 {(picked?.contract.samples ?? []).map((s, i) => ( ))}
{fields.map((f) => (