/** * 仿真试跑台 —— 装配一次试跑 → 起单 → 看穿全程。 * * 这里检测的是**后端的活动与流程**,不是这一屏自己:起单走的是流程包的调用接口 * (`POST /api/gov/testbed/run`),与触发器起单同一条路;起出来的是**真任务**, * 照旧落到被指派人的工作台上。测试台不代答 —— 它只管两件事: * ① 把流跑起来(装配态) ② 看穿它怎么跑的(观测态) */ import { useMemo, useState } from 'react'; import type { ReactNode } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { ProcessNode, ProcessPackage } from '@contracts'; import { TERMINAL_STATE_LABEL, WORKFLOW_STATE_LABEL, testCurrentRoute, testRunRoute, testStopRoute } from '@contracts'; import { Ico } from '@web-shared/Ico.tsx'; import { call } from '@web-shared/typed-fetch.ts'; import { useGovPackages, useGovStaff } from '@web-shared/gov/gov-api.ts'; import { Acts, Field, Note, PageHead, QueryGate, St } from '@web-shared/gov/primitives.tsx'; import type { Nav } from '@web-shared/gov/gov-route.ts'; import { CallForm, buildCallData, callFieldsOf, placeholderValues } from './call-form.tsx'; import type { CallValues } from './call-form.tsx'; import { isLive, useRunTrace, useRunWorkflow } from './run-api.ts'; import { TraceSheet } from './trace-sheet.tsx'; type RunOutput = { workflowId: string; state: string }; /** 只有活动点与审批点会解析负责人 —— 起点终点没有「派给谁」这回事 */ const isAssignable = (n: ProcessNode): boolean => n.kind === 'activity' || n.kind === 'approval'; const NODE_KIND_LABEL: Readonly> = { activity: '活动', approval: '审批', start: '起点', end: '终点', }; function Sheet({ code, title, ico, extra, tight, children, }: { code: string; title: string; ico: string; extra?: ReactNode; tight?: boolean; children: ReactNode; }): ReactNode { return (
{code} — {title} {extra !== undefined && {extra}}
{children}
); } export function SimulateRun({ nav }: { nav: Nav }): ReactNode { const pkgQ = useGovPackages(); const staffQ = useGovStaff(); /** 只列有生效版的:同一个包可能存着多版,能起单的只有 `active` 那一版 */ const livePkgs = useMemo( () => (pkgQ.data?.packages ?? []).filter((p) => p.state === 'active'), [pkgQ.data], ); const staff = useMemo(() => (staffQ.data?.staff ?? []).filter((s) => s.state === 'active'), [staffQ.data]); const staffName = (id: string): string => staff.find((s) => s.id === id)?.name ?? id; const [pickedId, setPicked] = useState(''); const [subject, setSubject] = useState('测试台试跑'); const [values, setValues] = useState({}); const [assignments, setAssignments] = useState>>({}); const queryClient = useQueryClient(); const [workflowId, setWorkflowId] = useState(undefined); /* * **一次只跑一条**:刷新页面也认得回来 —— 不然仿真在后台跑着,界面上却像没起过, * 人会再起一单(而上一单的任务还在别人工作台上挂着)。 */ const current = useQuery({ queryKey: ['gov', 'testbed', 'current'], queryFn: () => call<{ workflowId?: string; subject?: string }>(testCurrentRoute), }); const running = workflowId ?? current.data?.workflowId; /** 停止仿真 = **销毁**:工作流、活动任务、工单、实例、trace 一并拔掉 */ const stop = useMutation({ mutationFn: (id: string) => call<{ destroyed: boolean; tasks: number }>(testStopRoute, { body: { workflowId: id } }), onSuccess: () => { setWorkflowId(undefined); run.reset(); void queryClient.invalidateQueries({ queryKey: ['gov', 'testbed'] }); void queryClient.invalidateQueries({ queryKey: ['desk'] }); }, }); const [error, setError] = useState(undefined); // 没选就落到第一个有生效版的包 —— 常见情形只有一个包,不该逼人先点一下 const pkg = livePkgs.find((p) => p.id === pickedId) ?? livePkgs[0]; const fields = useMemo(() => callFieldsOf(pkg?.callInterface.input), [pkg]); const nodes = useMemo(() => (pkg?.nodes ?? []).filter(isAssignable), [pkg]); const built = useMemo(() => buildCallData(fields, values), [fields, values]); const ready = pkg !== undefined && subject.trim() !== '' && built.errors.length === 0; const run = useMutation({ mutationFn: (body: { packageId: string; subject: string; data: Record; assignments: Record }) => call(testRunRoute, { body }), onSuccess: (r) => { setWorkflowId(r.workflowId); setError(undefined); }, // 后端的判词照抄:「输入不合调用接口契约」「流程包无生效版本」都是要人改配置的话,不该被我改写 onError: (e: unknown) => setError((e as { message?: string }).message ?? '起单失败'), }); const fire = (): void => { if (pkg === undefined) return; run.mutate({ packageId: pkg.id, subject: subject.trim(), data: built.data, assignments: Object.fromEntries(Object.entries(assignments).filter(([, v]) => v !== '')), }); }; if (running !== undefined) { return ( stop.mutate(running)} /> ); } const pinned = Object.values(assignments).filter((v) => v !== '').length; return ( <> {pkg === undefined ? '没有可起单的流程包' : `${pkg.name} v${pkg.version}`} 指派 {pinned}/{nodes.length} } acts={[ { label: run.isPending ? '起单中…' : '起单试跑', ico: 'play', primary: true, disabled: !ready || run.isPending, onClick: fire, }, ]} />
{livePkgs.length === 0 ? (
没有可起单的流程包

试跑要有一个生效版才起得来。去「流程包」里保存一版 —— 存得进去的版本就是生效的。

) : ( <> {/* 一张装配单读下来就是一次试跑的次序:选包 → 填参 → 派人 → 起单 */}
{livePkgs.map((p) => ( ))}
只列有生效版的包。在途的流按起单时那一版跑完,改包不影响它们。
{fields.length === 0 ? ( 这个包的调用接口没声明入参 —— 直接起单即可。 ) : ( <>
setValues((prev) => ({ ...prev, [k]: v }))} />
setValues(placeholderValues(fields)) }, { label: '清空', ico: 'trash-2', onClick: () => setValues({}) }, ]} /> )}
{/* 分两栏排:一条流七八个节点,单栏会把这张卡拉得比整块装配单还长 */}
{[nodes.slice(0, Math.ceil(nodes.length / 2)), nodes.slice(Math.ceil(nodes.length / 2))] .filter((half) => half.length > 0) .map((half, i) => (
节点 类型 名称 派给谁
{half.map((n) => (
{n.id} {NODE_KIND_LABEL[n.kind] ?? n.kind} {n.label}
))}
))}
留空即照旧轮值(有员工组按组轮,没有按全员最闲者先)。指派只在那个人接得了这个节点 时生效,接不了会退回轮值 —— 实际受理人以 TRACE 里那一行为准。
setSubject(e.target.value)} />
{built.errors.length > 0 && ( 还差 {built.errors.length} 项才起得来: {built.errors.join(' · ')} )} {error !== undefined && ( 没起成: {error} )}
)}
); } /* ── 观测态 ─────────────────────────────────────────────────────────── */ function Observe({ id, staffName, nav, onBack, stopping, }: { id: string; staffName: (sid: string) => string; nav: Nav; onBack: () => void; stopping: boolean; }): ReactNode { const wf = useRunWorkflow(id); const state = wf.data?.workflow.state; // 到终态就停轮询:已经不动的东西没必要每两秒问一遍 const live = isLive(state); const trQ = useRunTrace(id, live); const spans = trQ.data?.spans ?? []; const w = wf.data; const nodeLabel = (nid: string): string => w?.pkg.nodes.find((n) => n.id === nid)?.label ?? nid; /** 节点实例出参里没有受理人,受理人只在 span 的 attributes 上 —— 从 TRACE 反查 */ const assigneeOfNode = (nid: string): string | undefined => { const s = spans.find((x) => x.kind === 'node' && x.nodeId === nid); const a = s?.attributes?.['assigneeId']; return typeof a === 'string' ? staffName(a) : undefined; }; const started = w === undefined ? undefined : new Date(w.workflow.startedAt); const refresh = (): void => { void wf.refetch(); void trQ.refetch(); }; return ( <> {state === undefined ? '取数中' : (WORKFLOW_STATE_LABEL[state] ?? state)} {id.slice(0, 8)} {live && ( 每 2 秒自动刷新 )} } acts={[ { label: '刷新', ico: 'refresh-cw', onClick: refresh }, { label: stopping ? '销毁中…' : '停止仿真', ico: 'square', primary: true, onClick: onBack }, ]} />
{w !== undefined && ( <> {/* 图签栏:工程图右下角那张表 —— 一条流的身份,一格一件事 */}
WF — 工作流 {w.workflow.source.kind} · {w.workflow.source.ref}
工作流号 {id.slice(0, 8)}
状态 {WORKFLOW_STATE_LABEL[w.workflow.state] ?? w.workflow.state}
流程包 {w.workflow.packageId}@v{w.workflow.packageVersion}
起单时刻 {started?.toLocaleTimeString('zh-CN', { hour12: false }) ?? '—'}
起单人 {w.workflow.source.by === undefined ? '—' : staffName(w.workflow.source.by)}
节点 / 边 {w.pkg.nodes.length} / {w.pkg.edges.length}
{/* 派工带:测试台不代答,这句必须说满 */}
DSK — 任务落到了谁的工作台
{w.tasks.length === 0 ? (

还没有任务落到任何工位 —— 流刚起,或者卡在审批点上(审批单不是任务,见下面的节点实例)。

) : ( w.tasks.map((t) => (
{t.nodeId} {t.title} → {t.ownerName} 的工作台
)) )}

测试台不代答:这些是真任务,要那个人在自己的工作台上答,这条流才会往下走。

{trQ.isFetching ? '取数中' : '刷新'} } />
NOD — 节点实例 {w.instances.length} 个已进入
{w.instances.length === 0 ? (
还没有节点进入

起点已过,第一个节点正在派工。

) : (
节点 名称 状态 终态 受理人
{w.instances.map((it) => { const bad = it.terminal !== undefined && it.terminal !== 'released'; const cls = bad ? 'bad' : it.running ? 'run' : it.waiting ? 'wait' : it.terminal === undefined ? '' : 'done'; const who = assigneeOfNode(it.nodeId); return (
{it.nodeId} {nodeLabel(it.nodeId)} {bad ? '异常' : it.running ? '在跑' : it.waiting ? '等人' : it.terminal === undefined ? '已进入' : '已放行'} {it.terminal === undefined ? '—' : (TERMINAL_STATE_LABEL[it.terminal] ?? it.terminal)} {who === undefined ? '—' : {who}} {it.instance > 1 && 第 {it.instance} 次}
); })}
)}
)}
); }