/** * 仿真试跑台 —— 浅色桌面上的**一台仪器**。 * * 这里检测的是**后端的活动与流程**,不是这一屏自己:起单走的是流程包的调用接口 * (`POST /api/gov/testbed/run`),与触发器起单同一条路;起出来的是**真任务**, * 照旧落到被指派人的工作台上。测试台不代答 —— 它只管两件事: * ① 把流跑起来(装配态) ② 看穿它怎么跑的(观测态) * * 视觉语言分两个世界:**机器说 mono**(读数、灯、编号、TRACE), * **那封信说无衬线** —— 它是外界唯一伸进这台仪器的东西。 * 戏眼是左栏的「抽键预检」读数屏:通道的正则**实时**跑在正打的字上 * (与服务端同一套规则,契约原文回传,不另编一套)。 * 预检是**信息**,不是闸(宽进,2026-07-30):抽不全照样起 —— 缺的键进活动后 * 由受理归一派补全单收齐;灯只告诉你「哪些会当场带上、哪些要有人补」。 */ import { useMemo, useState } from 'react'; import type { ReactNode } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { GovChannelsOutput, 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 { QueryGate } 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 }; type Channel = GovChannelsOutput['channels'][number]; type ExtractRule = Channel['extract'][number]; /** 只有活动点与审批点会解析负责人 —— 起点终点没有「派给谁」这回事 */ const isAssignable = (n: ProcessNode): boolean => n.kind === 'activity' || n.kind === 'approval'; /* ==================== 启动输入:一封信 ==================== */ type MailDraft = { from: string; to: string; subject: string; body: string; attachments: ReadonlyArray<{ name: string; contentType: string; size: number }>; }; 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 }], }; /** * 抽键预检 —— **镜像服务端 `extractFields` 的取料与取值**(首个捕获组,无组取全匹配)。 * 服务端仍是唯一裁判:这里只负责在打字时点灯,起单被退回的判词永远以服务端为准。 */ function preflight(rules: readonly ExtractRule[], mail: MailDraft): ReadonlyMap { const out = new Map(); for (const rule of rules) { const haystack = rule.source === 'subject' ? mail.subject : rule.source === 'body' ? mail.body : rule.source === 'from' ? mail.from : rule.source === 'to' ? mail.to : `${mail.subject}\n${mail.body}`; try { const match = new RegExp(rule.regex).exec(haystack); const captured = match?.[1] ?? match?.[0]; if (captured !== undefined) out.set(rule.key, captured); } catch { /* 配置里的正则写坏了:按未命中点灯,不让预检把整屏拖死 */ } } return out; } /** 信本身就带的字段 —— 装配时从载荷取,不走抽键,预检不必为它们报「无规则」 */ const MAIL_BUILTIN = new Set(['subject', 'body', 'from', 'quotedAt', 'occurredAt']); /** 调用接口声明的必填键(JSON-Schema 的 required) */ function requiredOf(pkg: ProcessPackage | undefined): readonly string[] { const input = pkg?.callInterface.input as { required?: string[] } | undefined; return input?.required ?? []; } /* ==================== 台面骨架 ==================== */ /** * 仪器顶条 —— 装配态与观测态共用同一根:左边一盏灯 + 状态词,右边是这一态的读数与动作。 * 两态换的是灯色与右侧内容,骨架不换 —— 起单前后你面对的是同一台机器。 */ function Strip({ lamp, breathe, word, detail, right, }: { lamp: 'idle' | 'live' | 'wait' | 'done' | 'bad'; breathe: boolean; word: string; detail?: ReactNode; right?: ReactNode; }): ReactNode { return (
{word} {detail !== undefined && {detail}} {right !== undefined && {right}}
); } /* ==================== 装配态 ==================== */ 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 mailChannels = (chanQ.data?.channels ?? []).filter((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 channel = mailChannels.find((ch) => ch.address === mail.to) ?? mailChannels[0]; /* 预检:通道正则 × 正打的字,实时点灯 */ const caught = useMemo( () => preflight(channel?.extract ?? [], mail), [channel, mail], ); const required = requiredOf(pkg); const ruleKeys = new Set((channel?.extract ?? []).map((r) => r.key)); /** 必填但既不是信自带、又没有任何抽键规则 —— 这键只能靠受理补全单找人收 */ const unruled = required.filter((k) => !MAIL_BUILTIN.has(k) && !ruleKeys.has(k)); const mustCatch = required.filter((k) => ruleKeys.has(k)); // 宽进:预检不再决定上不上膛 —— 抽不全照样起,缺的进活动后补全单收齐 const keysReady = mustCatch.every((k) => caught.has(k)) && unruled.length === 0; const fireable = pkg !== undefined && channel !== undefined && mail.from.trim() !== '' && mail.subject.trim() !== ''; const armed = fireable; const run = useMutation({ mutationFn: (input: { packageId: string; mail: MailDraft; assignments: Record }) => call(testRunRoute, { body: input }), onSuccess: (r) => { setWorkflowId(r.workflowId); setError(undefined); void queryClient.invalidateQueries({ queryKey: ['gov', 'testbed', 'current'] }); }, // 后端的判词照抄:「抽不出客户码」「无生效版」都是要人改配置的话,不该被我改写 onError: (e: unknown) => setError((e as { message?: string }).message ?? '起单失败'), }); const fire = (): void => { if (pkg === undefined || channel === undefined) return; run.mutate({ packageId: pkg.id, mail: { ...mail, to: channel.address }, 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; const ai = staff.find((s) => s.name === 'AI'); return (
caught.has(k)).length}/${mustCatch.length} · 指派 ${pinned}/${nodes.length}`} /> {livePkgs.length === 0 ? (
台面上还没有流

试跑要有一个生效版才起得来 —— 去流程包激活一版,回来它就出现在这里。

) : (
{/* ── 左:被测流 + 抽键预检读数屏 ── */} {/* ── 右:① 一封信 ② 派给谁 + 起单条 ── */}
01 输入 · 一封客户来信
02 节点指派 {ai !== undefined && ( )}
{/* * 链轨:节点按编排序挂在一条竖轨上,右端是「插座」—— * 空着即照旧轮值(有员工组按组轮,没有按全员最闲者先),插了人那一格点琥珀 * (琥珀在这套语言里恒表「人」)。指派只在那人接得了这个节点时生效,接不了退回轮值。 */}
{nodes.map((n) => { const pin = (assignments[n.id] ?? '') !== ''; return (
{n.id} {n.label} {n.kind === 'approval' ? '审批' : '活动'}
); })}
{/* 起单条:仪器的前面板边缘 —— 全屏唯一的一枚动作钮,灯全亮才上膛 */}
{error !== undefined ? ( {error} ) : keysReady ? ( '键全抽得出 —— 起单会先销毁上一单,任务真实落到被指派人的工作台' ) : ( `有键抽不出(${mustCatch.filter((k) => !caught.has(k)).concat(unruled).join('、')})—— 照样能起,受理人会收到补全单` )}
)}
); } /* ==================== ① 一封信 ==================== */ function LetterForm({ draft, onChange, channels, channel, }: { draft: MailDraft; onChange: (next: MailDraft) => void; channels: readonly Channel[]; channel: Channel | undefined; }): ReactNode { const set = (k: K, v: MailDraft[K]): void => onChange({ ...draft, [k]: v }); return (