/** * 联调测试台 · 入站邮件 —— 把定义**试着跑一遍**。 * * 造一封入站邮件(可带附件)投进通道,走的是**与真通道同一条**入站归一链路 * (`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> = { thread: '①线程精确 —— 回信落在原线程上', 'listener-key': '②关联键命中在途任务的监听', 'address-window': '③地址 + 时间窗对上了', }; const REASON_LABEL: Readonly> = { '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([SAMPLE_ATTACH]); const [result, setResult] = useState(undefined); const [error, setError] = useState(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(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 ( <> {allGreen ? '起单条件齐' : `差 ${preflight.filter((p) => !p.ok).length} 条`} 附件 {attachments.length} } acts={[ { label: send.isPending ? '投递中…' : '投递这封信', ico: 'send', primary: true, disabled: send.isPending || effectiveChannel === '', onClick: fire, }, ]} /> 走的是与真通道同一条入站链路:通道归一 → 归属解析 → 统一事件流 → 三级消费(任务监听 / 触发器 / 消息兜底)。邮件通道是外界,模拟通道是允许的;命中判定与起单都是真的。 setFrom(e.target.value)} /> setTo(e.target.value)} /> setSubject(e.target.value)} />