diff --git a/api-report/packages/contracts/src/api/governance.d.ts b/api-report/packages/contracts/src/api/governance.d.ts index 063e22b..8f8fdf2 100644 --- a/api-report/packages/contracts/src/api/governance.d.ts +++ b/api-report/packages/contracts/src/api/governance.d.ts @@ -2317,7 +2317,7 @@ export declare const testRunRoute: { packageId: z.ZodString; mail: z.ZodObject<{ from: z.ZodString; - to: z.ZodString; + to: z.ZodDefault; subject: z.ZodString; body: z.ZodString; attachments: z.ZodDefault> { const channels = await container.channel.list(); + // 收件箱可以不写:界面已不出这一格 —— 空地址就取在用的邮件通道 const channel = - channels.find((ch) => ch.address === mail.to) ?? channels.find((ch) => ch.kind === 'mail' && ch.enabled); + (mail.to === '' ? undefined : channels.find((ch) => ch.address === mail.to)) ?? + channels.find((ch) => ch.kind === 'mail' && ch.enabled); if (channel === undefined) throw new Error(`没有可用的邮件通道 —— 收件地址 ${mail.to} 对不上任何通道`); const triggers = await container.trigger.list(); diff --git a/apps/web/src/app/styles/console.css b/apps/web/src/app/styles/console.css index 86bc693..71b5945 100644 --- a/apps/web/src/app/styles/console.css +++ b/apps/web/src/app/styles/console.css @@ -827,9 +827,11 @@ /* ── 装配台:**拆盒**(2026-07-30 三改)—— 台面上只有三件「实物」:纸信、黑读数屏、 起单钮;其余全是台面排版。两栏之间一道竖细线,不再是两只描边盒子摞题头栏。 */ -.g-rig { display: grid; grid-template-columns: minmax(232px, 280px) minmax(0, 1fr); gap: 0; align-items: stretch; } -.g-rig-l { display: flex; flex-direction: column; min-width: 0; padding-right: 16px; border-right: 1px solid #16233c; } -.g-rig-r { display: flex; flex-direction: column; min-width: 0; padding-left: 18px; } +/* 台面两栏对开:左造信、右派人;起单条横跨两栏坐底(抽键读数屏 2026-07-29 撤) */ +.g-rig { display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(330px, .95fr); gap: 0; align-items: start; } +.g-rig-l { display: flex; flex-direction: column; min-width: 0; padding: 0 18px 12px 0; border-right: 1px solid #16233c; } +.g-rig-r { display: flex; flex-direction: column; min-width: 0; padding: 0 0 12px 18px; } +.g-rig > .tb-fire { grid-column: 1 / -1; } /* 节题:不再是填色横栏 —— 一行疏排大写 + 底缘细线,是**标题**不是盒盖 */ .g-rig-h { display: flex; align-items: center; gap: 8px; padding: 12px 0 8px; border-bottom: 1px solid #1d2c48; font-family: var(--sh-mono); font-size: 9.5px; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; color: var(--sh-steel); } @@ -845,6 +847,9 @@ /* 左栏:一包一行 —— 选中只亮一道磷光左棱 + 提亮文字;不整块染色,色彩留给读数屏 */ .tb-pks { display: flex; flex-direction: column; padding: 4px 0 2px; } +/* 被测流选择器:横排一行,选完就让位 —— 它是一次性的决定,不值一整栏 */ +.tb-pkrow { display: flex; flex-wrap: wrap; gap: 8px; padding: 12px 0 14px; border-bottom: 1px solid #1d2c48; } +.tb-pkrow .g-pk { flex: 0 1 auto; min-width: 168px; padding: 8px 12px; border: 1px solid var(--sh-line); border-radius: 8px; } .g-pk { display: flex; flex-direction: column; gap: 3px; padding: 9px 10px 9px 12px; border: 0; border-left: 2px solid transparent; background: transparent; text-align: left; font: inherit; color: var(--sh-steel); cursor: pointer; transition: color var(--t-fast), border-color var(--t-fast), background var(--t-fast); } .g-pk:hover { color: var(--sh-ink); background: rgba(255, 255, 255, .025); } .g-pk.on { border-left-color: var(--sh-live); color: var(--sh-ink); background: rgba(255, 255, 255, .03); } @@ -855,20 +860,7 @@ .g-pk .cnt i { font-style: normal; } /* ── 抽键预检读数屏(戏眼):左栏唯一的「实物」—— 黑色凹屏,一键一灯,值走磷光 ── */ -.tb-well { margin: 12px 0 8px; padding: 5px 0; background: var(--sh-well); border: 1px solid #1a2946; border-radius: 9px; box-shadow: inset 0 2px 10px rgba(0, 0, 0, .5); } -.tb-well-r { display: grid; grid-template-columns: 16px minmax(0, .9fr) minmax(0, 1.1fr); align-items: center; gap: 7px; padding: 8px 12px 8px 10px; font-family: var(--sh-mono); } -.tb-well-r + .tb-well-r { border-top: 1px dashed #14213a; } -.tb-well-r > i { width: 7px; height: 7px; border-radius: 50%; border: 1.5px solid var(--sh-muted); background: transparent; justify-self: center; transition: background var(--t-fast), border-color var(--t-fast), box-shadow var(--t-fast); } -.tb-well-r.hit > i { border-color: var(--sh-live); background: var(--sh-live); box-shadow: 0 0 7px var(--sh-live); } -.tb-well-r.dead > i { border-color: var(--sh-alarm); } -.tb-well-r .k { display: flex; align-items: center; gap: 6px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; color: var(--sh-steel); } /* 必填不描框:一枚小字缀在键名后就够 —— 屏上的字越少越像屏 */ -.tb-well-r .k em { flex: none; font: 600 8.5px/1 var(--sh-mono); font-style: normal; color: var(--sh-muted); } -.tb-well-r .v { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; font-size: 12px; font-weight: 700; color: var(--sh-muted); } -.tb-well-r.hit .v { color: var(--sh-live); text-shadow: 0 0 8px rgba(69, 220, 178, .35); } -.tb-well-r.dead .v { color: var(--sh-alarm); font-weight: 600; } -.tb-well-none { margin: 0; padding: 10px 12px; font-size: 11px; line-height: 1.8; color: var(--sh-muted); } -.tb-well-t { margin: 0; padding: 6px 2px 0; font-size: 10px; line-height: 1.7; color: var(--sh-muted); } /* ── ① 一封信 = 一张**纸**压在暗台面上:整台仪器里唯一的浅色面 ── 「机器说 mono、信是外界」推到视觉结论 —— 外界伸进仪器的那件东西,长得像张纸。 @@ -922,7 +914,8 @@ .tb-socket option { background: var(--sh-panel); color: var(--sh-ink); } /* ── 起单条:仪器的前面板边缘 —— 全屏唯一的动作钮,灯全亮才上膛 ── */ -.tb-fire { position: sticky; bottom: -1px; z-index: 3; display: flex; align-items: center; gap: 14px; margin-top: auto; padding: 12px 0; border-top: 1px solid #1d2c48; background: var(--sh-bed); } +/* 起单条:横跨两栏、吸在台面底边 —— 像仪器前面板压在桌沿上,内容从它底下过 */ +.tb-fire { position: sticky; bottom: -1px; z-index: 3; display: flex; align-items: center; gap: 14px; margin-top: 6px; padding: 12px 14px; border: 1px solid #1d2c48; border-bottom: 0; border-radius: 10px 10px 0 0; background: var(--sh-bed); box-shadow: 0 -10px 24px -12px rgba(0, 0, 0, .75); } .tb-fire .say { min-width: 0; font-size: 11px; line-height: 1.6; color: var(--sh-muted); } .tb-fire .say .bad { font-weight: 600; color: var(--sh-alarm); } .tb-go { flex: none; margin-left: auto; display: inline-flex; align-items: center; gap: 8px; height: 36px; padding: 0 22px; border: 1px solid var(--sh-lineb); border-radius: 8px; background: var(--sh-panel); color: var(--sh-steel); font: 700 12.5px/1 var(--sh-mono); letter-spacing: .12em; cursor: pointer; transition: color var(--t-fast), border-color var(--t-fast), background var(--t-fast), box-shadow var(--t-fast); } diff --git a/apps/web/src/features/gov-testbed/internal/simulate.tsx b/apps/web/src/features/gov-testbed/internal/simulate.tsx index 67f9e90..9c8c855 100644 --- a/apps/web/src/features/gov-testbed/internal/simulate.tsx +++ b/apps/web/src/features/gov-testbed/internal/simulate.tsx @@ -8,15 +8,16 @@ * * 视觉语言分两个世界:**机器说 mono**(读数、灯、编号、TRACE), * **那封信说无衬线** —— 它是外界唯一伸进这台仪器的东西。 - * 戏眼是左栏的「抽键预检」读数屏:通道的正则**实时**跑在正打的字上 - * (与服务端同一套规则,契约原文回传,不另编一套)。 - * 预检是**信息**,不是闸(宽进,2026-07-30):抽不全照样起 —— 缺的键进活动后 - * 由受理归一派补全单收齐;灯只告诉你「哪些会当场带上、哪些要有人补」。 + * + * 「抽键预检」读数屏 2026-07-29 撤了(用户定):宽进之后它已经不是闸,只是一屏 + * 会亮会灭的字 —— 抽得出抽不出,进了活动由受理补全单说了算,不必在门口先演一遍。 + * 收件箱那一格同时撤掉:投给哪个信箱是通道配置的事,不是造这封信的人该操心的。 + * 台面随之从「左读数屏 + 右两段」改成**两栏对开**:左边造信,右边派人。 */ 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 type { 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'; @@ -27,8 +28,6 @@ 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'; @@ -37,7 +36,6 @@ const isAssignable = (n: ProcessNode): boolean => n.kind === 'activity' || n.kin type MailDraft = { from: string; - to: string; subject: string; body: string; attachments: ReadonlyArray<{ name: string; contentType: string; size: number }>; @@ -47,53 +45,15 @@ 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 ?? []; -} - /* ==================== 台面骨架 ==================== */ /** @@ -169,25 +129,12 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode { // 没选就落到第一个:常见情形只有一个生效包、一个邮件通道,不该逼人先各点一下 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 channel = mailChannels[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 }) => + mutationFn: (input: { packageId: string; mail: MailDraft & { to: string }; assignments: Record }) => call(testRunRoute, { body: input }), onSuccess: (r) => { setWorkflowId(r.workflowId); @@ -199,10 +146,11 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode { }); const fire = (): void => { - if (pkg === undefined || channel === undefined) return; + if (pkg === undefined) return; run.mutate({ packageId: pkg.id, - mail: { ...mail, to: channel.address }, + // 收件箱交给服务端认在用通道 —— 这一屏不写地址(与线上真来一封信同一条路) + mail: { ...mail, to: '' as string }, assignments: Object.fromEntries(Object.entries(assignments).filter(([, v]) => v !== '')), }); }; @@ -223,7 +171,7 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode { breathe={false} word="装配中" detail={pkg === undefined ? '没有可起单的流程包' : `${pkg.name} v${pkg.version} · 单例仿真`} - right={`抽键 ${mustCatch.filter((k) => caught.has(k)).length}/${mustCatch.length} · 指派 ${pinned}/${nodes.length}`} + right={`指派 ${pinned}/${nodes.length}`} /> @@ -238,15 +186,13 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode { ) : ( -
- {/* ── 左:被测流 + 抽键预检读数屏 ── */} - - - {/* ── 右:① 一封信 ② 派给谁 + 起单条 ── */} -
+ )} +
+ {/* ── 左:造一封信 ── */} +
01 输入 · 一封客户来信 @@ -325,8 +223,11 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
- + +
+ {/* ── 右:派给谁 ── */} +
02 节点指派 @@ -380,24 +281,24 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode { })}
- {/* 起单条:仪器的前面板边缘 —— 全屏唯一的一枚动作钮,灯全亮才上膛 */} -
- - {error !== undefined ? ( - {error} - ) : keysReady ? ( - '键全抽得出 —— 起单会先销毁上一单,任务真实落到被指派人的工作台' - ) : ( - `有键抽不出(${mustCatch.filter((k) => !caught.has(k)).concat(unruled).join('、')})—— 照样能起,受理人会收到补全单` - )} - - -
-
+ + {/* 起单条:横跨两栏坐在台面底边 —— 仪器的前面板边缘,全屏唯一一枚动作钮 */} +
+ + {error !== undefined ? ( + {error} + ) : ( + '起单会先销毁上一单;任务真实落到被指派人的工作台,抽不出的键由受理补全单收齐' + )} + + +
+
+ )}
@@ -409,22 +310,17 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode { 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 ( /* * 一张**纸信**压在暗台面上 —— 整台仪器里唯一的浅色面。 * 「机器说 mono、信是外界」推到视觉结论:外界伸进仪器的那件东西,就长得像张纸。 - * 解剖照邮件写:信头三行(发件人 / 收件箱 / 主题)→ 正文 → 附件签条。 - * 收件箱是**选**的不是打的:投给哪个信箱决定用哪套抽键规则,地址错一位, - * 左边预检整屏熄灯还不知道为什么。 + * 解剖照邮件写:信头两行(发件人 / 主题)→ 正文 → 附件签条。 + * 收件箱不在纸上:投给哪个信箱是通道配置的事,造信的人不该为它分神。 */
@@ -432,16 +328,6 @@ function LetterForm({ 发件人 set('from', e.target.value)} /> -