forked from ChaiHao/BPOS-MVP
测试台:抽键预检屏与收件箱格一并撤,台面改两栏对开
**抽键预检读数屏删了**。宽进之后它已经不是闸,只是一屏会亮会灭的字 —— 抽得出抽不出,进了活动由受理补全单说了算,不必在门口先演一遍。 镜像服务端正则的那份前端实现(`preflight`)随之退场:一套规则两处实现, 本就是迟早对不上的账。 **收件箱那一格删了**。投给哪个信箱是通道配置的事,不是造这封信的人该操心的 —— 契约里 `mail.to` 放宽为可空,空地址由服务端取在用的邮件通道(与线上真来一封信同一条路)。 信头从三行收成两行:发件人 / 主题 → 正文 → 附件签条。 **台面重排**:从「左读数屏 + 右两段」改成 **两栏对开** —— 左造信(01)、右派人(02), 起单条横跨两栏吸在底边,像仪器前面板压在桌沿上。被测流不再占一整栏:它是一次性的 决定,改成顶部一行卡片,且只在多于一条生效流时才出(没得选的选择器是纯占位)。 实测(丁子乔账号):不写收件箱照样起单成功,起后「停止仿真」连根销毁, 任务 0 / 工作流 0,试跑数据未留。五闸绿。 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
-1
@@ -2317,7 +2317,7 @@ export declare const testRunRoute: {
|
||||
packageId: z.ZodString;
|
||||
mail: z.ZodObject<{
|
||||
from: z.ZodString;
|
||||
to: z.ZodString;
|
||||
to: z.ZodDefault<z.ZodString>;
|
||||
subject: z.ZodString;
|
||||
body: z.ZodString;
|
||||
attachments: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
|
||||
@@ -120,8 +120,10 @@ async function assembleFromMail(
|
||||
mail: { from: string; to: string; subject: string; body: string; attachments: readonly { name: string }[] },
|
||||
): Promise<Record<string, unknown>> {
|
||||
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();
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
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<string, string> }) =>
|
||||
mutationFn: (input: { packageId: string; mail: MailDraft & { to: string }; assignments: Record<string, string> }) =>
|
||||
call<RunOutput>(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}`}
|
||||
/>
|
||||
|
||||
<QueryGate query={pkgQ}>
|
||||
@@ -238,15 +186,13 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="g-rig">
|
||||
{/* ── 左:被测流 + 抽键预检读数屏 ── */}
|
||||
<aside className="g-rig-l">
|
||||
<div className="g-rig-h">
|
||||
<Ico name="workflow" s={11} />
|
||||
被测流
|
||||
<span className="x">{livePkgs.length} 个生效</span>
|
||||
</div>
|
||||
<div className="tb-pks">
|
||||
<>
|
||||
{/*
|
||||
* 被测流不再占一整栏:它是**一次选定**的东西,选完就该让位给真正要动手的两件事。
|
||||
* 只有一条生效流时连选择器都不出 —— 没得选的选择器是纯占位。
|
||||
*/}
|
||||
{livePkgs.length > 1 && (
|
||||
<div className="tb-pkrow">
|
||||
{livePkgs.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
@@ -260,61 +206,13 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
|
||||
<span className="id">
|
||||
{p.id}@v{p.version}
|
||||
</span>
|
||||
<span className="cnt">
|
||||
<i>节点 {p.nodes.length}</i>
|
||||
<i>边 {p.edges.length}</i>
|
||||
<i>必填 {requiredOf(p).length}</i>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="g-rig-h">
|
||||
<Ico name="scan" s={11} />
|
||||
抽键预检
|
||||
<span className="x">{channel === undefined ? '无邮件通道' : channel.name}</span>
|
||||
</div>
|
||||
{/*
|
||||
* 读数屏:通道的每条抽键正则一行 —— 键名 / 抽到的值 / 一盏灯。
|
||||
* 实时跑在正打的字上;必填的键标出来,没灯的必填就是投出去会被退回的那单。
|
||||
*/}
|
||||
<div className="tb-well">
|
||||
{channel === undefined ? (
|
||||
<p className="tb-well-none">没有可用的邮件通道 —— 去系统区配一个,信才有地方投。</p>
|
||||
) : (
|
||||
<>
|
||||
{channel.extract.map((r) => {
|
||||
const hit = caught.get(r.key);
|
||||
const must = required.includes(r.key);
|
||||
return (
|
||||
<div key={r.key} className={'tb-well-r' + (hit === undefined ? '' : ' hit')}>
|
||||
<i />
|
||||
<span className="k">
|
||||
{r.key}
|
||||
{must && <em>必填</em>}
|
||||
</span>
|
||||
<span className="v">{hit ?? '—'}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{unruled.map((k) => (
|
||||
<div key={k} className="tb-well-r dead">
|
||||
<i />
|
||||
<span className="k">
|
||||
{k}
|
||||
<em>必填</em>
|
||||
</span>
|
||||
<span className="v">无规则 · 靠补全</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="tb-well-t">与服务端同一套正则。灯灭不拦起单 —— 抽不出的键由受理补全单找人收齐。</p>
|
||||
</aside>
|
||||
|
||||
{/* ── 右:① 一封信 ② 派给谁 + 起单条 ── */}
|
||||
<section className="g-rig-r">
|
||||
)}
|
||||
<div className="g-rig">
|
||||
{/* ── 左:造一封信 ── */}
|
||||
<section className="g-rig-l">
|
||||
<div className="g-rig-h">
|
||||
<span className="n">01</span>
|
||||
输入 · 一封客户来信
|
||||
@@ -325,8 +223,11 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<LetterForm draft={mail} onChange={setMail} channels={mailChannels} channel={channel} />
|
||||
<LetterForm draft={mail} onChange={setMail} />
|
||||
</section>
|
||||
|
||||
{/* ── 右:派给谁 ── */}
|
||||
<section className="g-rig-r">
|
||||
<div className="g-rig-h">
|
||||
<span className="n">02</span>
|
||||
节点指派
|
||||
@@ -380,24 +281,24 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 起单条:仪器的前面板边缘 —— 全屏唯一的一枚动作钮,灯全亮才上膛 */}
|
||||
<div className="tb-fire">
|
||||
<span className="say">
|
||||
{error !== undefined ? (
|
||||
<b className="bad">{error}</b>
|
||||
) : keysReady ? (
|
||||
'键全抽得出 —— 起单会先销毁上一单,任务真实落到被指派人的工作台'
|
||||
) : (
|
||||
`有键抽不出(${mustCatch.filter((k) => !caught.has(k)).concat(unruled).join('、')})—— 照样能起,受理人会收到补全单`
|
||||
)}
|
||||
</span>
|
||||
<button className={'tb-go' + (armed ? ' armed' : '')} disabled={!fireable || run.isPending} onClick={fire}>
|
||||
<Ico name="play" s={13} />
|
||||
{run.isPending ? '起单中…' : '起单试跑'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 起单条:横跨两栏坐在台面底边 —— 仪器的前面板边缘,全屏唯一一枚动作钮 */}
|
||||
<div className="tb-fire">
|
||||
<span className="say">
|
||||
{error !== undefined ? (
|
||||
<b className="bad">{error}</b>
|
||||
) : (
|
||||
'起单会先销毁上一单;任务真实落到被指派人的工作台,抽不出的键由受理补全单收齐'
|
||||
)}
|
||||
</span>
|
||||
<button className={'tb-go' + (fireable ? ' armed' : '')} disabled={!fireable || run.isPending} onClick={fire}>
|
||||
<Ico name="play" s={13} />
|
||||
{run.isPending ? '起单中…' : '起单试跑'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</QueryGate>
|
||||
</div>
|
||||
@@ -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 extends keyof MailDraft>(k: K, v: MailDraft[K]): void => onChange({ ...draft, [k]: v });
|
||||
return (
|
||||
/*
|
||||
* 一张**纸信**压在暗台面上 —— 整台仪器里唯一的浅色面。
|
||||
* 「机器说 mono、信是外界」推到视觉结论:外界伸进仪器的那件东西,就长得像张纸。
|
||||
* 解剖照邮件写:信头三行(发件人 / 收件箱 / 主题)→ 正文 → 附件签条。
|
||||
* 收件箱是**选**的不是打的:投给哪个信箱决定用哪套抽键规则,地址错一位,
|
||||
* 左边预检整屏熄灯还不知道为什么。
|
||||
* 解剖照邮件写:信头两行(发件人 / 主题)→ 正文 → 附件签条。
|
||||
* 收件箱不在纸上:投给哪个信箱是通道配置的事,造信的人不该为它分神。
|
||||
*/
|
||||
<div className="tb-paper">
|
||||
<div className="tb-pp-h">
|
||||
@@ -432,16 +328,6 @@ function LetterForm({
|
||||
<em>发件人</em>
|
||||
<input className="tb-pp-i" value={draft.from} onChange={(e) => set('from', e.target.value)} />
|
||||
</label>
|
||||
<label className="tb-pp-r">
|
||||
<em>收件箱</em>
|
||||
<select className="tb-pp-i sel" value={channel?.address ?? ''} onChange={(e) => set('to', e.target.value)}>
|
||||
{channels.map((ch) => (
|
||||
<option key={ch.id} value={ch.address}>
|
||||
{ch.address} —— {ch.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="tb-pp-r">
|
||||
<em>主题</em>
|
||||
<input className="tb-pp-i strong" value={draft.subject} onChange={(e) => set('subject', e.target.value)} />
|
||||
|
||||
@@ -615,7 +615,11 @@ export const testRunRoute = {
|
||||
*/
|
||||
mail: z.object({
|
||||
from: z.string().min(1),
|
||||
to: z.string().min(1),
|
||||
/**
|
||||
* 收件箱**不由界面写**(2026-07-29):投给哪个信箱是通道配置的事,不是造信的人该操心的。
|
||||
* 留空 = 服务端取在用的邮件通道(与线上真来一封信同一条路)。
|
||||
*/
|
||||
to: z.string().default(''),
|
||||
subject: z.string(),
|
||||
body: z.string(),
|
||||
attachments: z
|
||||
|
||||
Reference in New Issue
Block a user