三面拆分⑫:设计台九区一级目录 —— 一区一内容,测试台收成单例仿真

设计中心从「蓝图 + 四库」摊成九区,顺序即设计的次序:
蓝图 → 触发器 → 流程包 → 活动库 → 工具箱 → 数据域 → 知识库 → 大模型 → 测试台。
触发器与大模型从「并进流程包 / 塞在系统区」里拿出来独立成区 —— 前者与流程包平级
(不入包结构、不随包版本),后者是设计者天天要碰的东西;知识库先立个诚实的空占位。

一区一内容,于是区内页签栏整条删掉(壳层在只剩一格时收起):
  · 流程包:总览的三枚计数并进货架顶端
  · 测试台:入站邮件演练移到触发器区就地验(它验的正是那一区定的规则)
  · 大模型:从系统区拆出,系统区只剩通道实例

测试台一次只跑一条流。起单前先销毁上一单,刷新页面能认回在跑的那一单,
「停止仿真」把工作流连同它派出的任务、工单、实例、留痕一并拔掉 ——
不留半单挂在别人工作台上。实测:起单 → 1 单落到 AI 工作台 → 停止 → 库里零残留。

功能工具补中文名(migration 0018,读回退到逻辑名),治理台一律主显示中文名。
顺带清掉几处说教式文案(「这些是工具,不是模型 —— 模型是它们跑在上面的发动机…」那类)。

五闸:typecheck / lint / depcruise / test 绿;api:check 快照随本提交入库。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-29 04:35:36 +08:00
co-authored by Claude Opus 5
parent ffb71def4b
commit 8cb00d1ff6
28 changed files with 367 additions and 135 deletions
@@ -1,328 +0,0 @@
/**
* 联调测试台 · 入站邮件演练 —— 从**外界**推一封信进来,看谁接。
*
* 造一封入站邮件(可带附件)投进通道,走的是**与真通道同一条**入站归一链路
* (`POST /api/ingest/channel/:id/mail` → 归属解析 → 统一事件流 → 三级消费)。
* 邮件通道是外界,模拟通道是允许的(README:mock 只许出现在外界);
* 系统内部机制这里一行都不 mock —— 命中判定、起单、消息兜底全是真的。
*
* 这一屏要回答的是三个问题,所以结果就摆这三样:
* · 归属判到谁了(哪一级、认领岗位)
* · 谁接了(任务监听 / 触发器,各命中几个)
* · 没人接的话落哪儿(消息兜底)
*/
import { useMemo, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import type { IngestMailOutput, InboundMail } from '@contracts';
import { ingestMailRoute } from '@contracts';
import { call } from '@web-shared/typed-fetch.ts';
import { useGovChannels, useGovStaff } from '@web-shared/gov/gov-api.ts';
import { Acts, Card, Field, Note, PageHead, Rows, Sec, St } from '@web-shared/gov/primitives.tsx';
import type { Nav } from '@web-shared/gov/gov-route.ts';
/*
* 出参形状**直接用契约类型**,不手搓。
* 我手搓过一次(`level` / `kind` / `position` / `rationale`),四个字段名全是编的 ——
* 页面上就摆出两个永远显示「—」的格子。归属解析真实是按 `outcome` 判别的联合:
* owned 给 `rule` + `taskId`,unowned 给 `reason` + 可选的 `fallbackPosition`。
*/
type IngestResult = IngestMailOutput;
const RULE_LABEL: Readonly<Record<string, string>> = {
thread: '①线程精确 —— 回信落在原线程上',
'listener-key': '②关联键命中在途任务的监听',
'address-window': '③地址 + 时间窗对上了',
};
const REASON_LABEL: Readonly<Record<string, string>> = {
'no-match': '三级都没对上',
ambiguous: '对上多条,有歧义 —— 宁交人不错投',
'extract-failed': '归属键抽不出来',
};
type Attach = { name: string; contentType: string; size: number; ref: string };
/**
* 样例邮件 —— 一键就能起单的那一封。
*
* 三件事都得对,少一件就不起单(这也正是这一屏要让人看见的):
* ① 投给「公司询价邮箱」这个通道(触发器规则写死了 `channelId = "ch-rfq-mail"`)
* ② 主题含「询价」或 RFQ(触发器规则的第二半)
* ③ 正文或主题里有客户码 `C\d{3}` 与单号 `RFQ-[A-Z0-9]+` —— 通道的归属键提取规则按正则抽,
* 抽不出来就没有关联键,起单装配拿不到 customerCode / rfqRef
*/
const SAMPLE = {
from: 'buyer@sz-precision.com',
to: 'rfq@we.com',
subject: '【询价】RFQ-2607-501 精密车削件 6 项',
body: [
'你好,',
'',
'我方客户码 C014,本次询价单号 RFQ-2607-501,附件是 BOM 清单(6 项)。',
'月需约 2000 套,请两周内回价。',
'',
'深圳精密件采购部',
].join('\n'),
};
const SAMPLE_ATTACH: Attach = {
name: 'BOM-RFQ-2607-501.xlsx',
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
size: 18_432,
ref: 'sim://attach/BOM-RFQ-2607-501.xlsx',
};
export function InboundDrill({ nav }: { nav: Nav }) {
const channels = useGovChannels();
const rows = channels.data?.channels ?? [];
// 岗位在出参里是 id;摆一串 UUID 等于没说 —— 换成人读的名字
const staffQ = useGovStaff();
const positionName = (id: string) =>
(staffQ.data?.positions ?? []).find((p) => p.id === id)?.name ?? id;
const [channelId, setChannelId] = useState('');
const [from, setFrom] = useState(SAMPLE.from);
const [to, setTo] = useState(SAMPLE.to);
const [subject, setSubject] = useState(SAMPLE.subject);
const [body, setBody] = useState(SAMPLE.body);
const [attachments, setAttachments] = useState<readonly Attach[]>([SAMPLE_ATTACH]);
const [result, setResult] = useState<IngestResult | undefined>(undefined);
const [error, setError] = useState<string | undefined>(undefined);
// 没选通道时默认落到第一个 —— 让「一键投递」真的一键
const effectiveChannel = channelId !== '' ? channelId : (rows[0]?.id ?? '');
const channel = rows.find((c) => c.id === effectiveChannel);
/**
* 起单前置自检 —— 把「为什么没起单」提前说出来,而不是让人投完了猜。
* 判据与通道/触发器的真实配置同源:通道 id、主题关键词、两个归属键正则。
*/
const preflight = useMemo(() => {
const text = `${subject}\n${body}`;
return [
{ ok: effectiveChannel === 'ch-rfq-mail', k: '投给公司询价邮箱', v: 'ch-rfq-mail' },
{ ok: /询价|RFQ/.test(subject), k: '主题含「询价」或 RFQ', v: '触发器规则' },
{ ok: /\bC\d{3}\b/.test(text), k: '含客户码', v: 'C000 形' },
{ ok: /RFQ-[A-Z0-9]+/.test(text), k: '含询价单号', v: 'RFQ-XXXX 形' },
];
}, [effectiveChannel, subject, body]);
const allGreen = preflight.every((p) => p.ok);
const send = useMutation({
mutationFn: (mail: InboundMail) =>
call<IngestResult>(ingestMailRoute, { params: { channelId: effectiveChannel as never }, body: mail }),
onSuccess: (r) => {
setResult(r);
setError(undefined);
},
onError: (e: unknown) => {
setResult(undefined);
setError((e as { message?: string }).message ?? '投递失败');
},
});
const fire = () => {
// messageId 每次不同 —— 同一封信重投在归属解析里会被当同一线程,那是另一回事
const stamp = Date.now();
send.mutate({
messageId: `sim-${stamp}`,
from,
to: [to],
cc: [],
subject,
body,
receivedAt: new Date().toISOString(),
attachments: [...attachments],
} as InboundMail);
};
return (
<>
<PageHead
ico="flask-conical"
tone="brand"
kicker="联调测试台 · 事件触发"
title="入站邮件演练"
desc="试跑测的是「调用接口起单」那条路,这一屏测的是另一条:外界来了封信,触发器接不接、起不起单。"
chips={
<>
<St tone={allGreen ? 'ok' : 'warn'} ico={allGreen ? 'circle-check' : 'alert-circle'}>
{allGreen ? '起单条件齐' : `差 ${preflight.filter((p) => !p.ok).length} 条`}
</St>
<St tone="mute" ico="paperclip">
附件 {attachments.length}
</St>
</>
}
acts={[
{
label: send.isPending ? '投递中…' : '投递这封信',
ico: 'send',
primary: true,
disabled: send.isPending || effectiveChannel === '',
onClick: fire,
},
]}
/>
<Note>
走的是与真通道同一条入站链路:通道归一 → 归属解析 → 统一事件流 → 三级消费(任务监听 /
触发器 / 消息兜底)。邮件通道是外界,模拟通道是允许的;命中判定与起单都是真的。
</Note>
<Sec ico="mail" title="造一封信" extra="预填的是一键能起单的样例 —— 改坏了下面的自检会告诉你差哪条">
<Card ico="inbox" title="信头">
{/* `.g-card` 自己不带内距 —— 卡里放自带框的块(Rows / Note)刚好贴边,
放裸表单件就得垫一层卡体;console.css 早备好了 `.g-card-b` */}
<div className="g-card-b">
<Field k="投给哪个通道" hint="触发器的规则里写死了通道 id,投错通道不会命中">
<select className="g-select" value={effectiveChannel} onChange={(e) => setChannelId(e.target.value)}>
{rows.map((c) => (
<option key={c.id} value={c.id}>
{c.name} · {c.address}
</option>
))}
</select>
</Field>
<Field k="发件人">
<input className="g-input" value={from} onChange={(e) => setFrom(e.target.value)} />
</Field>
<Field k="收件地址" {...(channel === undefined ? {} : { hint: `通道地址:${channel.address}` })}>
<input className="g-input" value={to} onChange={(e) => setTo(e.target.value)} />
</Field>
<Field k="主题">
<input className="g-input" value={subject} onChange={(e) => setSubject(e.target.value)} />
</Field>
</div>
</Card>
<Card ico="file-text" title="正文">
<div className="g-card-b">
<textarea className="g-ta" rows={7} value={body} onChange={(e) => setBody(e.target.value)} />
</div>
</Card>
<Card
ico="paperclip"
title="附件"
extra={
<Acts
align="end"
items={[
{
label: '加一个附件',
ico: 'plus',
onClick: () =>
setAttachments((a) => [
...a,
{
name: `附件-${a.length + 1}.pdf`,
contentType: 'application/pdf',
size: 1024,
ref: `sim://attach/${a.length + 1}`,
},
]),
},
]}
/>
}
>
{attachments.length === 0 ? (
<Note>没有附件 —— 这封信也照样能起单(触发器判的是通道与主题,不判附件)。</Note>
) : (
<Rows
items={attachments.map((a) => ({
k: a.name,
v: `${a.contentType.split('.').pop() ?? a.contentType} · ${a.size} B · ${a.ref}`,
mono: true,
}))}
/>
)}
<Note>
附件目前只走<b>登记</b>:名字、类型、大小与存证指针进事件信封,内容不解析 ——
解析 Excel / PDF 是活动的事,活动还没实现。
</Note>
</Card>
<Card ico="list-checks" title="起单前置自检" extra="投之前就知道会不会命中,不用投完了猜">
<Rows
items={preflight.map((p) => ({
k: p.k,
v: (
<St tone={p.ok ? 'ok' : 'warn'} ico={p.ok ? 'circle-check' : 'alert-circle'}>
{p.ok ? '齐' : `缺:${p.v}`}
</St>
),
}))}
/>
</Card>
</Sec>
{error !== undefined && (
<Note tone="warn">
<b>投递失败:</b>
{error}
</Note>
)}
{result !== undefined && (
<Sec ico="git-merge" title="这封信的下场" extra="归属判到谁 · 谁接了 · 没人接落哪儿">
<Card ico="fingerprint" title="归属解析" extra="匹配次序确定、逐级短路">
<Rows
items={[
{ k: '事件 id', v: result.eventId, mono: true },
...(result.resolution.outcome === 'owned'
? [
{ k: '判定', v: '有主' },
{ k: '命中哪一级', v: RULE_LABEL[result.resolution.rule] ?? result.resolution.rule },
{ k: '归到任务', v: result.resolution.taskId, mono: true },
]
: [
{ k: '判定', v: '无主' },
{ k: '为什么', v: REASON_LABEL[result.resolution.reason] ?? result.resolution.reason },
...(result.resolution.fallbackPosition === undefined
? []
: [{ k: '默认认领岗位', v: positionName(result.resolution.fallbackPosition) }]),
]),
]}
/>
{result.resolution.outcome === 'unowned' && (
<Note>
无主不是错:新来的询价本来就没有在途任务可归。<b>无主入站不写台账</b>,只入事件流,
由触发器决定要不要起单 —— 下面那格就是它的裁决。
</Note>
)}
</Card>
<Card ico="split" title="三级消费">
<Rows
items={[
{ k: '命中任务监听', v: String(result.listenersHit) },
{ k: '命中触发器', v: String(result.triggersHit) },
{ k: '落消息兜底', v: result.fellBackToMessage ? '是' : '否' },
]}
/>
{result.triggersHit > 0 ? (
<Note tone="ok">
触发器命中 {result.triggersHit} 个 —— 已按调起规则起单。去运营中心 · 任务看在途,
或去工作台看落到谁工位上。
</Note>
) : result.listenersHit > 0 ? (
<Note tone="ok">这封信被在途任务的监听接走了 —— 那一单的完成条件因此满足。</Note>
) : (
<Note tone="warn">
没有任何监听或触发器接它,已落消息兜底 —— 认领岗位的人会在「消息」里看到它。
想起单就回上面看自检差哪条。
</Note>
)}
</Card>
<Acts
items={[
{ label: '去运营中心看在途', ico: 'activity', onClick: () => nav.jump('tasks', 'list') },
{ label: '再投一封', ico: 'rotate-ccw', primary: true, onClick: fire },
]}
/>
</Sec>
)}
</>
);
}
@@ -2,49 +2,17 @@
* 联调测试台 —— 把定义**试着跑一遍**。
*
* 测试台是**后端检测活动与流程**的地方,不是代人干活的地方:在这儿装配并起单,
* 真实的任务照旧落到被指派人的工作台上。所以它只管两件事:
* **把流跑起来** + **看穿它怎么跑的**。
* 真实的任务照旧落到被指派人的工作台上。
*
* 一条流有两种起法,对应两个模式 —— 两条路都得能试:
* · 仿真试跑:直接调流程包的**调用接口**起单(触发器起单走的也是这个口),带节点指派;
* · 入站邮件演练:从**外界**推一封信进来,看归属解析怎么判、哪个触发器接、有没有起单。
* **一次只跑一条**:测试台上的工作流生命周期完全跟着仿真状态 —— 起一单就是它,
* 停止仿真,这条流连同它派出去的活动任务一起销毁,不留半单在别人工作台上挂着。
*
* 模式不做成区页签(区页签由壳层的 `TABS` 定,测试台只占一格),做成本屏的一个控件 ——
* 它选的是「用哪种刺激」,不是「看哪个维度」。
* (外界来信那条路在**触发器**区就地演练 —— 它验的是规则,归那边。)
*/
import { useState } from 'react';
import type { ReactNode } from 'react';
import { Ico } from '@web-shared/Ico.tsx';
import type { Nav } from '@web-shared/gov/gov-route.ts';
import { InboundDrill } from './inbound.tsx';
import { SimulateRun } from './simulate.tsx';
type Mode = 'run' | 'mail';
const MODES: ReadonlyArray<{ k: Mode; label: string; ico: string }> = [
{ k: 'run', label: '仿真试跑', ico: 'flask-conical' },
{ k: 'mail', label: '入站邮件演练', ico: 'mail' },
];
export function TestbedHome({ nav }: { tab: string; nav: Nav }): ReactNode {
const [mode, setMode] = useState<Mode>('run');
return (
<>
<div className="g-tbmode">
<em>试法</em>
{MODES.map((m) => (
<button
key={m.k}
className={'g-tbmode-b' + (mode === m.k ? ' on' : '')}
aria-pressed={mode === m.k}
onClick={() => setMode(m.k)}
>
<Ico name={m.ico} s={12} />
{m.label}
</button>
))}
</div>
{mode === 'run' ? <SimulateRun nav={nav} /> : <InboundDrill nav={nav} />}
</>
);
return <SimulateRun nav={nav} />;
}
@@ -8,9 +8,9 @@
*/
import { useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { ProcessNode, ProcessPackage } from '@contracts';
import { TERMINAL_STATE_LABEL, WORKFLOW_STATE_LABEL, testRunRoute } 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';
@@ -76,7 +76,28 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
const [subject, setSubject] = useState('测试台试跑');
const [values, setValues] = useState<CallValues>({});
const [assignments, setAssignments] = useState<Readonly<Record<string, string>>>({});
const queryClient = useQueryClient();
const [workflowId, setWorkflowId] = useState<string | undefined>(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<string | undefined>(undefined);
// 没选就落到第一个有生效版的包 —— 常见情形只有一个包,不该逼人先点一下
@@ -107,16 +128,14 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
});
};
if (workflowId !== undefined) {
if (running !== undefined) {
return (
<Observe
id={workflowId}
id={running}
staffName={staffName}
nav={nav}
onBack={() => {
setWorkflowId(undefined);
run.reset();
}}
stopping={stop.isPending}
onBack={() => stop.mutate(running)}
/>
);
}
@@ -312,11 +331,13 @@ function Observe({
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;
@@ -368,7 +389,7 @@ function Observe({
}
acts={[
{ label: '刷新', ico: 'refresh-cw', onClick: refresh },
{ label: '再装一单', ico: 'rotate-ccw', primary: true, onClick: onBack },
{ label: stopping ? '销毁中…' : '停止仿真', ico: 'square', primary: true, onClick: onBack },
]}
/>