/** * 仿真试跑台 —— 装配一次试跑 → 起单 → 看穿全程。 * * 这里检测的是**后端的活动与流程**,不是这一屏自己:起单走的是流程包的调用接口 * (`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 { useGovChannels, useGovPackages, useGovStaff } from '@web-shared/gov/gov-api.ts'; import { Note, PageHead, QueryGate, St } from '@web-shared/gov/primitives.tsx'; import type { Nav } from '@web-shared/gov/gov-route.ts'; 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: '终点', }; export function SimulateRun({ nav }: { nav: Nav }): ReactNode { const pkgQ = useGovPackages(); const staffQ = useGovStaff(); const chanQ = useGovChannels(); /** 只列有生效版的:同一个包可能存着多版,能起单的只有 `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 inbox = (chanQ.data?.channels ?? []).find((ch) => ch.kind === 'mail' && ch.enabled); const [pickedId, setPicked] = useState(''); const [mail, setMail] = useState(SAMPLE_MAIL); 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 nodes = useMemo(() => (pkg?.nodes ?? []).filter(isAssignable), [pkg]); const to = mail.to === '' ? (inbox?.address ?? '') : mail.to; const ready = pkg !== undefined && mail.from.trim() !== '' && to !== '' && mail.subject.trim() !== ''; const run = useMutation({ mutationFn: (input: RunInput) => call(testRunRoute, { body: input }), 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, mail: { ...mail, to }, 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 ? (
没有可起单的流程包

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

) : ( /* 左选流,右配置 —— 一屏两大块,不再把装配单摞成一长条 */
01 输入配置 启动这条流的那封信
02 节点指派 {pinned}/{nodes.length} 已定 · 留空即照旧轮值
{nodes.map((n) => (
{n.id} {NODE_KIND_LABEL[n.kind] ?? n.kind} {n.label}
))}
{error !== undefined && ( 没起成: {error} )}
)}
); } /* ==================== 启动输入:一封信 ==================== */ type MailDraft = { from: string; to: string; subject: string; body: string; attachments: ReadonlyArray<{ name: string; contentType: string; size: number }>; }; type RunInput = { packageId: string; mail: MailDraft; assignments: Record }; const XLSX = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; /** * 预填的是一封**抽得出键**的信 —— 主题里带客户码与询价单号, * 通道的抽键正则就是照着这两样写的;改坏了起单会如实报「抽不出哪个」。 */ const SAMPLE_MAIL: MailDraft = { from: 'buyer@sz-machinery.com', to: '', subject: '【询价】C014 / RFQ-2607-001 工控主板 BOM 12 项', body: '你好,附件是本次询价的 BOM,共 12 项,请按标准交期报价。\n付款方式沿用上次合同。', attachments: [{ name: 'BOM-RFQ-2607-001.xlsx', contentType: XLSX, size: 28_416 }], }; function MailForm({ draft, onChange, inbox, }: { draft: MailDraft; onChange: (next: MailDraft) => void; inbox: string | undefined; }): ReactNode { const set = (k: K, v: MailDraft[K]): void => onChange({ ...draft, [k]: v }); return (