Archived
测试台改仪表台:启动输入换成一封信;卡片化触发器与大模型;清掉 ⓘ 说教条
**测试台**
· 启动输入不再是「调用参数」表 —— 这条流真实的起法就是客户来信,
界面上就该是一封信(发件人 / 收件人 / 主题 / 正文 / BOM 附件)。
服务端拿**真通道的抽键正则** + **真触发器的装配映射**推出调用入参:
测试台自己不解析邮件,否则试通的是测试台而不是这条流。
抽不出必填就如实报「哪个字段没命中、通道的正则要什么」,不静默起空单。
· 一屏两大块:左选流,右两节 —— 01 输入配置、02 节点指派。
· 起单钮只剩一枚(原来页头一枚、RUN 卡里一枚)。
· 改暗色仪表台:调色板集中在 .g-tb/.g-rig 的一组变量上,
TRACE 那张树 + 甘特的结构一行没动,只是纸变成了面板。
**触发器 / 大模型** 列表改卡片:一条规则、一个实例都要一眼看全
(何时起 · 起哪条流 · 开没开 / 谁家的 · 什么型号 · 密钥绑没绑)。
触发器卡是 div 不是 button —— 卡里还坐着「改规则」那枚真按钮。
**同类卡等高**:底栏钉在卡底、说明恒占两行、卡头一行截断。
实测同一货架内 13 张工具卡、6 张活动卡、3 张模型卡各自完全等高。
**删掉 19 条 ⓘ 说教横幅**(「只列有生效版的包…」那一类)。
留下的是三种:确认带里的影响说明(点了动作才出现,是决策依据)、
warn/ok 的真反馈、以及「X 不存在」这类空态。
删掉入站邮件演练整屏 —— 它验的那两步已经并进测试台的起单路径了。
新增一条接缝测试:通道抽出来的键叫 keys.customerCode,触发器映射也得照这名字取;
哪头改了名字单还是能起,只是入参悄悄变空、流走到 G1 才莫名其妙地停。
四闸绿;契约快照随本提交入库。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -1,319 +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, Band, Card, Field, Note, 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 (
|
||||
<>
|
||||
<Band ico="flask-conical" extra="定完就地验:规则写下来了,来一封真信试试对不对">
|
||||
投一封信试试
|
||||
</Band>
|
||||
|
||||
<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>
|
||||
<Acts
|
||||
align="end"
|
||||
items={[
|
||||
{
|
||||
label: send.isPending ? '投递中…' : allGreen ? '投递这封信' : `还差 ${preflight.filter((p) => !p.ok).length} 条,仍要投`,
|
||||
ico: 'send',
|
||||
primary: true,
|
||||
disabled: send.isPending || effectiveChannel === '',
|
||||
onClick: fire,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -39,8 +39,8 @@ import {
|
||||
Seg,
|
||||
St,
|
||||
} from '@web-shared/gov/primitives.tsx';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||
import { InboundDrill } from './inbound.tsx';
|
||||
import { useGovObjects, useGovPackages, useGovTriggers } from '@web-shared/gov/gov-api.ts';
|
||||
import { domainShort } from '@web-shared/gov/domains.ts';
|
||||
|
||||
@@ -88,14 +88,10 @@ export function TriggerRulesHome({ nav }: { nav: Nav }) {
|
||||
return (
|
||||
<QueryGate query={q}>
|
||||
<>
|
||||
<Note>
|
||||
调起规则决定<b>什么时候起一单</b>:周期 / 事件 / 登记三类。触发器与流程包平级——不入包结构、
|
||||
不随包版本,改规则不动任何版本。<b>启停不在这里</b>,那是运营中心的事。
|
||||
</Note>
|
||||
{triggers.length === 0 ? (
|
||||
<Clear>还没有调起规则 —— 配一条,这条流就能被自动或人工调起来。</Clear>
|
||||
) : (
|
||||
<NList>
|
||||
<div className="g-shelf">
|
||||
{triggers.map((t) =>
|
||||
editing === t.id ? (
|
||||
<TriggerForm
|
||||
@@ -105,37 +101,57 @@ export function TriggerRulesHome({ nav }: { nav: Nav }) {
|
||||
onCancel={() => setEditing(undefined)}
|
||||
/>
|
||||
) : (
|
||||
<NRow
|
||||
/*
|
||||
* 卡而不是行:一条规则要一眼看全「何时起 · 起哪条流 · 开没开」,
|
||||
* 行只放得下前两样。卡是 div 不是 button —— 卡里还坐着「改规则」那枚真按钮。
|
||||
*/
|
||||
<div
|
||||
key={t.id}
|
||||
tone={t.enabled ? 'brand' : 'mute'}
|
||||
ico="zap"
|
||||
title={t.name}
|
||||
sub={ruleText(t)}
|
||||
cols={[
|
||||
{ k: '类别', v: <Chip tone="brand">{TRIGGER_KIND_LABEL[triggerKindOf(t.spec)]}</Chip> },
|
||||
{ k: '调起', v: t.processPackageId },
|
||||
{ k: '装配', v: `${t.mapping.length} 条` },
|
||||
]}
|
||||
right={
|
||||
className={'g-tile t-' + (t.enabled ? 'brand' : 'mute')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => nav.push({ k: 'trigger', id: t.id })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
nav.push({ k: 'trigger', id: t.id });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="g-tile-h">
|
||||
<span className="g-tile-i">
|
||||
<Ico name="zap" s={16} />
|
||||
</span>
|
||||
<span className="g-tile-t">
|
||||
<b>{t.name}</b>
|
||||
<em>{t.processPackageId}</em>
|
||||
</span>
|
||||
<Chip tone="brand">{TRIGGER_KIND_LABEL[triggerKindOf(t.spec)]}</Chip>
|
||||
</span>
|
||||
<span className="g-tile-d">{ruleText(t)}</span>
|
||||
<span className="g-tile-m">
|
||||
<Chip ico="arrow-left-right">装配 {t.mapping.length} 条</Chip>
|
||||
</span>
|
||||
<span className="g-tile-f">
|
||||
<St tone={t.enabled ? 'ok' : 'mute'} ico={t.enabled ? 'circle-check' : 'power'}>
|
||||
{t.enabled ? '起单中' : '已停用'}
|
||||
</St>
|
||||
<button
|
||||
className="g-btn"
|
||||
className="g-btn r"
|
||||
onClick={(e) => {
|
||||
// 行本身也可点(进详情)—— 按钮要拦住冒泡,否则点「改规则」会跳走
|
||||
// 卡本身也可点(进详情)—— 按钮要拦住冒泡,否则点「改规则」会跳走
|
||||
e.stopPropagation();
|
||||
setEditing(t.id);
|
||||
}}
|
||||
>
|
||||
改规则
|
||||
</button>
|
||||
}
|
||||
onClick={() => nav.push({ k: 'trigger', id: t.id })}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</NList>
|
||||
</div>
|
||||
)}
|
||||
{/* 定完就地验:来一封真信,看这些规则里哪条接住 */}
|
||||
<InboundDrill nav={nav} />
|
||||
</>
|
||||
</QueryGate>
|
||||
);
|
||||
@@ -240,9 +256,6 @@ function TriggerForm({
|
||||
<Field k="类别" hint="周期 / 事件 / 登记三类">
|
||||
<Seg value={draft.spec.kind} options={SPEC_KINDS.map((s) => ({ k: s.k, label: s.label }))} onPick={setSpecKind} />
|
||||
</Field>
|
||||
{draft.spec.kind === 'registration' && (
|
||||
<Note>登记型无参:人在工作台按调用接口的 schema 填表提交,即起一单。</Note>
|
||||
)}
|
||||
{draft.spec.kind === 'event' && (
|
||||
<>
|
||||
<Field k="事件类型">
|
||||
@@ -422,7 +435,6 @@ export function TriggerOpsTab({ nav }: { nav: Nav }) {
|
||||
))}
|
||||
</NList>
|
||||
)}
|
||||
<Note>逐条启停在触发器详情页做 —— 开关是治理动作,要走影响确认带,不做列表上的顺手一点。</Note>
|
||||
{toggle.isError && <Note tone="warn">切换失败,请重试。</Note>}
|
||||
</>
|
||||
</QueryGate>
|
||||
|
||||
Reference in New Issue
Block a user