设计中心加联调测试台:造入站邮件(带附件)投通道,看真结果
你要的「模拟输入邮件+附件触发起单」。后端**本来就齐了** —— `ingestMailRoute` 契约里早有 attachments,出参也早就回归属解析与三级消费结果。 所以这是纯前端加一个区:设计中心新增 `testbed`,一个页签「入站邮件」。 走的是与真通道**同一条**入站链路(通道归一 → 归属解析 → 统一事件流 → 三级消费)。 邮件通道是外界,模拟通道是 README 明许的;系统内部机制这里一行都不 mock —— 命中判定、起单、消息兜底全是真的。 ## 这一屏回答三个问题 投出去之后就摆三样:归属判到谁(哪一级命中 / 无主的原因 / 默认认领岗位)、 谁接了(任务监听 vs 触发器各命中几个)、没人接落哪儿(消息兜底)。 还有一格**起单前置自检**:投之前就告诉你会不会命中,不用投完了猜。 判据与真实配置同源 —— 通道 id、主题关键词、两个归属键正则,四条逐条给绿灯或说差什么。 预填的样例是一键能起单的那一封;把它改坏,自检立刻指出差哪条。 附件如实说明「只走**登记**」:名字/类型/大小/存证指针进事件信封,内容不解析 —— 解析 Excel/PDF 是活动的事,活动还没实现。不含糊过去。 ## 顺手修一个会静默分裂数据的 bug 通道的归属键正则 `RFQ-[A-Z0-9]+` 不含连字符,`RFQ-2607-501` 只抽到 `RFQ-2607` 就停。 而这个键会成为「受理结论」那一行的**主键** —— 截断了,同一单的数据就静默分裂成两行, 网关读到哪一行全看运气。改成分段续接 `RFQ-[A-Z0-9]+(?:-[A-Z0-9]+)*`, 实测 `RFQ-2607-501` / `RFQ-ABC123` 都整个吃下。 ## 我自己踩的两个 ① 出参类型手搓,`level`/`kind`/`position`/`rationale` **四个字段名全是编的** —— 页面上就摆出两个永远显示「—」的格子。改为直接用契约类型:归属解析真实是按 `outcome` 判别的联合(owned 给 rule+taskId,unowned 给 reason+可选 fallbackPosition)。 为此给契约补了 `IngestMailOutput` 导出(加法)。 ② JSX 正文里写 markdown 的 `**` 会显示成字面星号,换 `<b>`。这坑本轮踩过第二次。 另补两个缺失图标(flask-conical / split),不补就是空 SVG。 ## 实测 界面上点「投递这封信」→ 归属判**无主 · 三级都没对上 · 默认认领岗位 运营** (对新询价完全正确:没有在途任务可归)→ 触发器命中 1 个 → 起单。 起单入参 `customerCode: C014` / `rfqRef: RFQ-2607-501` 都抽对了。 一个值得记的观察:事件起单**没有「来源人」**,所以不装载工作组,A1 的 BD 岗位解析到 真正的 BD(冯兵),任务落他工位而不是我的 —— 这是对的,不是 bug。 要在自己工位上看,用「人工登记起单」那条路(登记人即来源)。 typecheck ✓ lint ✓ depcruise 376 模块 ✓ 498 测试 ✓ Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -69,3 +69,4 @@ export declare const ingestMailRoute: {
|
|||||||
fellBackToMessage: z.ZodBoolean;
|
fellBackToMessage: z.ZodBoolean;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
};
|
};
|
||||||
|
export type IngestMailOutput = z.infer<typeof ingestMailRoute.output>;
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"messageId": "sim-1785218175996",
|
||||||
|
"from": "buyer@sz-precision.com",
|
||||||
|
"to": [
|
||||||
|
"rfq@we.com"
|
||||||
|
],
|
||||||
|
"cc": [],
|
||||||
|
"subject": "【询价】RFQ-2607-501 精密车削件 6 项",
|
||||||
|
"body": "你好,\n\n我方客户码 C014,本次询价单号 RFQ-2607-501,附件是 BOM 清单(6 项)。\n月需约 2000 套,请两周内回价。\n\n深圳精密件采购部",
|
||||||
|
"receivedAt": "2026-07-28T05:56:15.996Z",
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"name": "BOM-RFQ-2607-501.xlsx",
|
||||||
|
"contentType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
"size": 18432,
|
||||||
|
"ref": "sim://attach/BOM-RFQ-2607-501.xlsx"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"messageId": "sim-1785218283747",
|
||||||
|
"from": "buyer@sz-precision.com",
|
||||||
|
"to": [
|
||||||
|
"rfq@we.com"
|
||||||
|
],
|
||||||
|
"cc": [],
|
||||||
|
"subject": "【询价】RFQ-2607-501 精密车削件 6 项",
|
||||||
|
"body": "你好,\n\n我方客户码 C014,本次询价单号 RFQ-2607-501,附件是 BOM 清单(6 项)。\n月需约 2000 套,请两周内回价。\n\n深圳精密件采购部",
|
||||||
|
"receivedAt": "2026-07-28T05:58:03.747Z",
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"name": "BOM-RFQ-2607-501.xlsx",
|
||||||
|
"contentType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
"size": 18432,
|
||||||
|
"ref": "sim://attach/BOM-RFQ-2607-501.xlsx"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"messageId": "sim-verify-1",
|
||||||
|
"from": "buyer@sz-precision.com",
|
||||||
|
"to": [
|
||||||
|
"rfq@we.com"
|
||||||
|
],
|
||||||
|
"cc": [],
|
||||||
|
"subject": "【询价】RFQ-2607-501 精密车削件 6 项",
|
||||||
|
"body": "我方客户码 C014,本次询价单号 RFQ-2607-501,附件是 BOM 清单。",
|
||||||
|
"receivedAt": "2026-07-28T06:00:00.000Z",
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"name": "BOM.xlsx",
|
||||||
|
"contentType": "application/vnd.ms-excel",
|
||||||
|
"size": 18432,
|
||||||
|
"ref": "sim://a/1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -470,9 +470,16 @@ export async function seedMvpFlow(container: Container): Promise<void> {
|
|||||||
const { positions } = await container.org.listStaff();
|
const { positions } = await container.org.listStaff();
|
||||||
const op = positions.find((p) => p.code === 'OP');
|
const op = positions.find((p) => p.code === 'OP');
|
||||||
const pm = positions.find((p) => p.code === 'PM');
|
const pm = positions.find((p) => p.code === 'PM');
|
||||||
|
/*
|
||||||
|
* 归属键提取 —— 正则要能吃**完整**的单号。
|
||||||
|
*
|
||||||
|
* `RFQ-[A-Z0-9]+` 不含连字符:遇到 `RFQ-2607-501` 只抽到 `RFQ-2607` 就停(实测)。
|
||||||
|
* 而这个键会成为「受理结论」那一行的主键 —— 截断了,同一单的数据就静默分裂成两行,
|
||||||
|
* 网关读到哪一行全看运气。分段续接才能整个吃下:`RFQ-2607-501` / `RFQ-ABC123` 都对。
|
||||||
|
*/
|
||||||
const extract = [
|
const extract = [
|
||||||
{ key: 'customerCode', regex: '\\bC\\d{3}\\b', source: 'both' as const },
|
{ key: 'customerCode', regex: '\\bC\\d{3}\\b', source: 'both' as const },
|
||||||
{ key: 'rfqRef', regex: 'RFQ-[A-Z0-9]+', source: 'both' as const },
|
{ key: 'rfqRef', regex: 'RFQ-[A-Z0-9]+(?:-[A-Z0-9]+)*', source: 'both' as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
await container.channel.upsert({
|
await container.channel.upsert({
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { TriggerPage } from '@features/gov-trigger';
|
|||||||
import { ActPage, LibraryHome } from '@features/gov-activity';
|
import { ActPage, LibraryHome } from '@features/gov-activity';
|
||||||
import { ToolPage, ToolsHome } from '@features/gov-tool';
|
import { ToolPage, ToolsHome } from '@features/gov-tool';
|
||||||
import { DataHome, LedgersHome, ObjPage } from '@features/gov-data';
|
import { DataHome, LedgersHome, ObjPage } from '@features/gov-data';
|
||||||
|
import { TestbedHome } from '@features/gov-testbed';
|
||||||
import { CockpitHome, KnowledgeHome, MemPage } from '@features/gov-memory';
|
import { CockpitHome, KnowledgeHome, MemPage } from '@features/gov-memory';
|
||||||
import { ChanPage, LlmPage, SystemHome } from '@features/gov-system';
|
import { ChanPage, LlmPage, SystemHome } from '@features/gov-system';
|
||||||
|
|
||||||
@@ -280,6 +281,7 @@ function ZoneHome({ zone, tab, nav }: { zone: GovZone; tab: string; nav: Nav })
|
|||||||
library: LibraryHome,
|
library: LibraryHome,
|
||||||
tools: ToolsHome,
|
tools: ToolsHome,
|
||||||
data: DataHome,
|
data: DataHome,
|
||||||
|
testbed: TestbedHome,
|
||||||
// 运营中心
|
// 运营中心
|
||||||
cockpit: CockpitHome,
|
cockpit: CockpitHome,
|
||||||
tasks: TasksHome,
|
tasks: TasksHome,
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* feature `gov-testbed` 唯一出口(编码规范 §9.1)。
|
||||||
|
* 联调测试台:把定义试着跑一遍 —— 造入站邮件投通道,看归属解析与三级消费的真结果。
|
||||||
|
*/
|
||||||
|
export { TestbedHome } from './internal/panel.tsx';
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
/**
|
||||||
|
* 联调测试台 · 入站邮件 —— 把定义**试着跑一遍**。
|
||||||
|
*
|
||||||
|
* 造一封入站邮件(可带附件)投进通道,走的是**与真通道同一条**入站归一链路
|
||||||
|
* (`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 TestbedHome({ nav }: { tab: string; 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="入站邮件"
|
||||||
|
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="信头">
|
||||||
|
<Field k="投给哪个通道" hint="触发器的规则里写死了通道 id,投错通道不会命中">
|
||||||
|
<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 value={from} onChange={(e) => setFrom(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field k="收件地址" {...(channel === undefined ? {} : { hint: `通道地址:${channel.address}` })}>
|
||||||
|
<input value={to} onChange={(e) => setTo(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field k="主题">
|
||||||
|
<input value={subject} onChange={(e) => setSubject(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card ico="file-text" title="正文">
|
||||||
|
<textarea rows={7} value={body} onChange={(e) => setBody(e.target.value)} />
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ export const GOV_ZONES = [
|
|||||||
'library',
|
'library',
|
||||||
'tools',
|
'tools',
|
||||||
'data',
|
'data',
|
||||||
|
'testbed',
|
||||||
// 运营中心
|
// 运营中心
|
||||||
'cockpit',
|
'cockpit',
|
||||||
'tasks',
|
'tasks',
|
||||||
@@ -51,6 +52,7 @@ export const ZONES: ReadonlyArray<{ key: GovZone; label: string; icon: string }>
|
|||||||
{ key: 'library', label: '活动库', icon: 'boxes' },
|
{ key: 'library', label: '活动库', icon: 'boxes' },
|
||||||
{ key: 'tools', label: '工具箱', icon: 'wrench' },
|
{ key: 'tools', label: '工具箱', icon: 'wrench' },
|
||||||
{ key: 'data', label: '数据域', icon: 'database' },
|
{ key: 'data', label: '数据域', icon: 'database' },
|
||||||
|
{ key: 'testbed', label: '测试台', icon: 'flask-conical' },
|
||||||
{ key: 'cockpit', label: '驾驶舱', icon: 'gauge' },
|
{ key: 'cockpit', label: '驾驶舱', icon: 'gauge' },
|
||||||
{ key: 'tasks', label: '任务', icon: 'activity' },
|
{ key: 'tasks', label: '任务', icon: 'activity' },
|
||||||
{ key: 'people', label: '员工', icon: 'users' },
|
{ key: 'people', label: '员工', icon: 'users' },
|
||||||
@@ -121,6 +123,12 @@ export const ZM: Readonly<Record<GovZone, { title: string; desc: string; accent:
|
|||||||
accent: 'linear-gradient(140deg,#0e9fd8,#245bdb)',
|
accent: 'linear-gradient(140deg,#0e9fd8,#245bdb)',
|
||||||
desc: '对象——企业运营什么。域 × 对象的结构定义:声明物化(最大化合并、只增不破坏)与调用接入(事实源在外)。数据可以自生长,权力不可以自生长。行数据不在这里看——那在运营中心的台账。',
|
desc: '对象——企业运营什么。域 × 对象的结构定义:声明物化(最大化合并、只增不破坏)与调用接入(事实源在外)。数据可以自生长,权力不可以自生长。行数据不在这里看——那在运营中心的台账。',
|
||||||
},
|
},
|
||||||
|
testbed: {
|
||||||
|
title: '联调测试台',
|
||||||
|
icon: 'flask-conical',
|
||||||
|
accent: 'linear-gradient(140deg,#e8833a,#c25f1a)',
|
||||||
|
desc: '把定义**试着跑一遍**:造一封入站邮件(可带附件)投进通道,看归属解析怎么判、哪个触发器命中、有没有起单。邮件通道是外界,模拟通道是允许的;系统内部机制一律真跑 —— 这里投出去的信走的是与真通道同一条入站归一链路。',
|
||||||
|
},
|
||||||
system: {
|
system: {
|
||||||
title: '系统管理',
|
title: '系统管理',
|
||||||
icon: 'settings',
|
icon: 'settings',
|
||||||
@@ -136,6 +144,7 @@ export const ZM: Readonly<Record<GovZone, { title: string; desc: string; accent:
|
|||||||
*/
|
*/
|
||||||
export const TABS: Readonly<Record<GovZone, ReadonlyArray<{ key: string; label: string; ico: string }>>> = {
|
export const TABS: Readonly<Record<GovZone, ReadonlyArray<{ key: string; label: string; ico: string }>>> = {
|
||||||
blueprint: [{ key: 'map', label: '企业蓝图', ico: 'route' }],
|
blueprint: [{ key: 'map', label: '企业蓝图', ico: 'route' }],
|
||||||
|
testbed: [{ key: 'inbound', label: '入站邮件', ico: 'mail' }],
|
||||||
workflows: [
|
workflows: [
|
||||||
{ key: 'overview', label: '总览', ico: 'gauge' },
|
{ key: 'overview', label: '总览', ico: 'gauge' },
|
||||||
{ key: 'pkgs', label: '流程包', ico: 'boxes' },
|
{ key: 'pkgs', label: '流程包', ico: 'boxes' },
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const SURFACE_META: Readonly<Record<Surface, { path: string; brand: strin
|
|||||||
|
|
||||||
/** 区归属:设计五区 + 运营六区,恰好铺满 `GOV_ZONES`(由测试守住) */
|
/** 区归属:设计五区 + 运营六区,恰好铺满 `GOV_ZONES`(由测试守住) */
|
||||||
export const SURFACE_ZONES: Readonly<Record<Surface, readonly GovZone[]>> = {
|
export const SURFACE_ZONES: Readonly<Record<Surface, readonly GovZone[]>> = {
|
||||||
design: ['blueprint', 'workflows', 'library', 'tools', 'data'],
|
design: ['blueprint', 'workflows', 'library', 'tools', 'data', 'testbed'],
|
||||||
ops: ['cockpit', 'tasks', 'people', 'ledgers', 'knowledge', 'system'],
|
ops: ['cockpit', 'tasks', 'people', 'ledgers', 'knowledge', 'system'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -39,3 +39,5 @@ export const ingestMailRoute = {
|
|||||||
fellBackToMessage: z.boolean(),
|
fellBackToMessage: z.boolean(),
|
||||||
}),
|
}),
|
||||||
} as const satisfies RouteContract;
|
} as const satisfies RouteContract;
|
||||||
|
|
||||||
|
export type IngestMailOutput = z.infer<typeof ingestMailRoute.output>;
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ export function Ico({ name, s = 18 }: { name: string; s?: number }) {
|
|||||||
"shield-check": (<><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /><path d="m9 12 2 2 4-4" /></>),
|
"shield-check": (<><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /><path d="m9 12 2 2 4-4" /></>),
|
||||||
scan: (<><path d="M3 7V5a2 2 0 0 1 2-2h2" /><path d="M17 3h2a2 2 0 0 1 2 2v2" /><path d="M21 17v2a2 2 0 0 1-2 2h-2" /><path d="M7 21H5a2 2 0 0 1-2-2v-2" /><path d="M7 12h10" /></>),
|
scan: (<><path d="M3 7V5a2 2 0 0 1 2-2h2" /><path d="M17 3h2a2 2 0 0 1 2 2v2" /><path d="M21 17v2a2 2 0 0 1-2 2h-2" /><path d="M7 21H5a2 2 0 0 1-2-2v-2" /><path d="M7 12h10" /></>),
|
||||||
"file-check": (<><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="m9 15 2 2 4-4" /></>),
|
"file-check": (<><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="m9 15 2 2 4-4" /></>),
|
||||||
|
"flask-conical": (<><path d="M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2" /><path d="M6.453 15h11.094" /><path d="M8.5 2h7" /></>),
|
||||||
|
split: (<><path d="M16 3h5v5" /><path d="M8 3H3v5" /><path d="M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3" /><path d="m15 9 6-6" /></>),
|
||||||
gavel: (<><path d="m14.5 12.5-8 8a2.119 2.119 0 1 1-3-3l8-8" /><path d="m16 16 6-6" /><path d="m8 8 6-6" /><path d="m9 7 8 8" /><path d="m21 11-8-8" /></>),
|
gavel: (<><path d="m14.5 12.5-8 8a2.119 2.119 0 1 1-3-3l8-8" /><path d="m16 16 6-6" /><path d="m8 8 6-6" /><path d="m9 7 8 8" /><path d="m21 11-8-8" /></>),
|
||||||
"pencil-line": (<><path d="M13 21h8" /><path d="m15 5 4 4" /><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /></>),
|
"pencil-line": (<><path d="M13 21h8" /><path d="m15 5 4 4" /><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /></>),
|
||||||
"chart-column": (<><path d="M3 3v16a2 2 0 0 0 2 2h16" /><path d="M18 17V9" /><path d="M13 17V5" /><path d="M8 17v-3" /></>),
|
"chart-column": (<><path d="M3 3v16a2 2 0 0 0 2 2h16" /><path d="M18 17V9" /><path d="M13 17V5" /><path d="M8 17v-3" /></>),
|
||||||
|
|||||||
Reference in New Issue
Block a user