Archived
测试台重制:浅色桌面上的一台仪器 —— 抽键预检读数屏是戏眼
产品:
· 抽键预检(新):通道的抽键正则**实时**跑在正打的字上 —— 键名/抽到的值/一盏灯,
该抽的必填全亮,起单钮才「上膛」发光。从前的头号困惑是投出去才知道
「抽不出客户码」,现在打字就知道。契约把 extract 规则原文回给治理台
(正则不是密钥),前端镜像服务端 extractFields 的取料与取值;服务端仍是唯一裁判。
· 收件箱改成通道下拉 —— 投给哪个信箱决定用哪套抽键规则,手打地址错一位,
预检整屏熄灯还不知道为什么。
· 「全派 AI」一键(测试一律用 AI 账号,把项目规矩做成一枚钮)+「全部轮值」清空。
· 双层页头砍成一层:区壳的英雄头留着,屏内改成一根「仪器顶条」——
一盏灯 + 状态词,装配态与观测态共用同一根骨架,起单前后面对的是同一台机器。
· 观测态删掉两枚手动「刷新」(2s 自动轮询早就在),唯一动作是珊瑚红的停止仿真。
视觉:
· 概念:页面是浅色桌面,测试台是桌上**一台有边有圆角的暗色仪器**——
不是把纸刷黑(上一版的问题:半迁移的暗色,TRACE 里十几处浅色时代的硬编码
在深底上要么刺眼要么隐形,如 k-node #161b22 深底深条完全不可见)。
· 三色律:磷光绿 = 机器活着(灯/命中/上膛);琥珀 = 人(指派/审批/等人/人卡);
珊瑚红 = 坏(异常/销毁)。其余全是冷灰蓝结构色,集中在一组变量上。
· 机器说 mono,那封信说无衬线 —— 它是外界唯一伸进仪器的东西。
· 指派轨:节点挂在一条竖轨上,活动是圆点、审批是琥珀菱形,插了人点亮琥珀。
· 呼吸灯尊重 prefers-reduced-motion;焦点环全部磷光可见。
实测走查:改坏主题 → 灯灭/计数 0/2/钮退膛/状态行说因;重置样例 → 全派 AI →
起单 → 观测态(呼吸灯+TRACE 示波记录+派工带)→ 停止 → 库里零残留。
四闸绿;契约快照随本提交入库(listChannels 增 extract,加法)。
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,36 +1,129 @@
|
||||
/**
|
||||
* 仿真试跑台 —— 装配一次试跑 → 起单 → 看穿全程。
|
||||
* 仿真试跑台 —— 浅色桌面上的**一台仪器**。
|
||||
*
|
||||
* 这里检测的是**后端的活动与流程**,不是这一屏自己:起单走的是流程包的调用接口
|
||||
* (`POST /api/gov/testbed/run`),与触发器起单同一条路;起出来的是**真任务**,
|
||||
* 照旧落到被指派人的工作台上。测试台不代答 —— 它只管两件事:
|
||||
* ① 把流跑起来(装配态) ② 看穿它怎么跑的(观测态)
|
||||
*
|
||||
* 视觉语言分两个世界:**机器说 mono**(读数、灯、编号、TRACE),
|
||||
* **那封信说无衬线** —— 它是外界唯一伸进这台仪器的东西。
|
||||
* 戏眼是左栏的「抽键预检」读数屏:通道的正则**实时**跑在正打的字上
|
||||
* (与服务端同一套规则,契约原文回传,不另编一套),
|
||||
* 该抽的键都点了灯,起单钮才上膛 —— 从前要投出去才知道「抽不出客户码」,现在打字就知道。
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { ProcessNode, ProcessPackage } from '@contracts';
|
||||
import type { GovChannelsOutput, 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';
|
||||
import { useGovChannels, useGovPackages, useGovStaff } from '@web-shared/gov/gov-api.ts';
|
||||
import { Note, PageHead, QueryGate, St } from '@web-shared/gov/primitives.tsx';
|
||||
import { QueryGate } from '@web-shared/gov/primitives.tsx';
|
||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||
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';
|
||||
|
||||
const NODE_KIND_LABEL: Readonly<Record<string, string>> = {
|
||||
activity: '活动',
|
||||
approval: '审批',
|
||||
start: '起点',
|
||||
end: '终点',
|
||||
/* ==================== 启动输入:一封信 ==================== */
|
||||
|
||||
type MailDraft = {
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
attachments: ReadonlyArray<{ name: string; contentType: string; size: number }>;
|
||||
};
|
||||
|
||||
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 ?? [];
|
||||
}
|
||||
|
||||
/* ==================== 台面骨架 ==================== */
|
||||
|
||||
/**
|
||||
* 仪器顶条 —— 装配态与观测态共用同一根:左边一盏灯 + 状态词,右边是这一态的读数与动作。
|
||||
* 两态换的是灯色与右侧内容,骨架不换 —— 起单前后你面对的是同一台机器。
|
||||
*/
|
||||
function Strip({
|
||||
lamp,
|
||||
breathe,
|
||||
word,
|
||||
detail,
|
||||
right,
|
||||
}: {
|
||||
lamp: 'idle' | 'live' | 'wait' | 'done' | 'bad';
|
||||
breathe: boolean;
|
||||
word: string;
|
||||
detail?: ReactNode;
|
||||
right?: ReactNode;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div className="tb-strip">
|
||||
<i className={`tb-lamp ${lamp}${breathe ? ' breathe' : ''}`} />
|
||||
<span className="word">{word}</span>
|
||||
{detail !== undefined && <span className="det">{detail}</span>}
|
||||
{right !== undefined && <span className="x">{right}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ==================== 装配态 ==================== */
|
||||
|
||||
export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
|
||||
const pkgQ = useGovPackages();
|
||||
const staffQ = useGovStaff();
|
||||
@@ -43,7 +136,7 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
|
||||
);
|
||||
const staff = useMemo(() => (staffQ.data?.staff ?? []).filter((s) => s.state === 'active'), [staffQ.data]);
|
||||
const staffName = (id: string): string => staff.find((s) => s.id === id)?.name ?? id;
|
||||
const inbox = (chanQ.data?.channels ?? []).find((ch) => ch.kind === 'mail' && ch.enabled);
|
||||
const mailChannels = (chanQ.data?.channels ?? []).filter((ch) => ch.kind === 'mail' && ch.enabled);
|
||||
|
||||
const [pickedId, setPicked] = useState('');
|
||||
const [mail, setMail] = useState<MailDraft>(SAMPLE_MAIL);
|
||||
@@ -72,27 +165,42 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
|
||||
});
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
// 没选就落到第一个有生效版的包 —— 常见情形只有一个包,不该逼人先点一下
|
||||
// 没选就落到第一个:常见情形只有一个生效包、一个邮件通道,不该逼人先各点一下
|
||||
const pkg = livePkgs.find((p) => p.id === pickedId) ?? livePkgs[0];
|
||||
const nodes = useMemo(() => (pkg?.nodes ?? []).filter(isAssignable), [pkg]);
|
||||
const to = mail.to === '' ? (inbox?.address ?? '') : mail.to;
|
||||
const ready = pkg !== undefined && mail.from.trim() !== '' && to !== '' && mail.subject.trim() !== '';
|
||||
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 armed =
|
||||
pkg !== undefined && channel !== undefined && unruled.length === 0 && mustCatch.every((k) => caught.has(k));
|
||||
const fireable = pkg !== undefined && channel !== undefined && mail.from.trim() !== '' && mail.subject.trim() !== '';
|
||||
|
||||
const run = useMutation({
|
||||
mutationFn: (input: RunInput) => call<RunOutput>(testRunRoute, { body: input }),
|
||||
mutationFn: (input: { packageId: string; mail: MailDraft; assignments: Record<string, string> }) =>
|
||||
call<RunOutput>(testRunRoute, { body: input }),
|
||||
onSuccess: (r) => {
|
||||
setWorkflowId(r.workflowId);
|
||||
setError(undefined);
|
||||
void queryClient.invalidateQueries({ queryKey: ['gov', 'testbed', 'current'] });
|
||||
},
|
||||
// 后端的判词照抄:「抽不出客户码」「无生效版」都是要人改配置的话,不该被我改写
|
||||
onError: (e: unknown) => setError((e as { message?: string }).message ?? '起单失败'),
|
||||
});
|
||||
|
||||
const fire = (): void => {
|
||||
if (pkg === undefined) return;
|
||||
if (pkg === undefined || channel === undefined) return;
|
||||
run.mutate({
|
||||
packageId: pkg.id,
|
||||
mail: { ...mail, to },
|
||||
mail: { ...mail, to: channel.address },
|
||||
assignments: Object.fromEntries(Object.entries(assignments).filter(([, v]) => v !== '')),
|
||||
});
|
||||
};
|
||||
@@ -104,57 +212,39 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
|
||||
}
|
||||
|
||||
const pinned = Object.values(assignments).filter((v) => v !== '').length;
|
||||
const ai = staff.find((s) => s.name === 'AI');
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
ico="flask-conical"
|
||||
tone="brand"
|
||||
kicker="联调测试台 · 装配态"
|
||||
title="仿真试跑"
|
||||
desc="这条流真实的起法就是一封客户来信 —— 写这封信、指派每个节点归谁,起单看穿全程。任务照旧落到被指派人的工作台。"
|
||||
chips={
|
||||
<>
|
||||
<St tone={pkg === undefined ? 'warn' : 'ok'} ico={pkg === undefined ? 'alert-circle' : 'circle-check'}>
|
||||
{pkg === undefined ? '没有可起单的流程包' : `${pkg.name} v${pkg.version}`}
|
||||
</St>
|
||||
<St tone="mute" ico="user-check">
|
||||
指派 {pinned}/{nodes.length}
|
||||
</St>
|
||||
</>
|
||||
}
|
||||
acts={[
|
||||
{
|
||||
label: run.isPending ? '起单中…' : '起单试跑',
|
||||
ico: 'play',
|
||||
primary: true,
|
||||
disabled: !ready || run.isPending,
|
||||
onClick: fire,
|
||||
},
|
||||
]}
|
||||
<div className="g-tb">
|
||||
<Strip
|
||||
lamp="idle"
|
||||
breathe={false}
|
||||
word="装配中"
|
||||
detail={pkg === undefined ? '没有可起单的流程包' : `${pkg.name} v${pkg.version} · 单例仿真`}
|
||||
right={`抽键 ${mustCatch.filter((k) => caught.has(k)).length}/${mustCatch.length} · 指派 ${pinned}/${nodes.length}`}
|
||||
/>
|
||||
|
||||
<QueryGate query={pkgQ}>
|
||||
{livePkgs.length === 0 ? (
|
||||
<div className="g-rig">
|
||||
<div className="g-tb-empty">
|
||||
<Ico name="scan" s={26} />
|
||||
<b>没有可起单的流程包</b>
|
||||
<p>
|
||||
试跑要有一个<b>生效版</b>才起得来。去「流程包」里保存一版 —— 存得进去的版本就是生效的。
|
||||
</p>
|
||||
</div>
|
||||
<div className="tb-slot">
|
||||
<Ico name="scan" s={26} />
|
||||
<b>台面上还没有流</b>
|
||||
<p>试跑要有一个生效版才起得来 —— 去流程包激活一版,回来它就出现在这里。</p>
|
||||
<button className="g-btn" onClick={() => nav.jump('workflows', 'pkgs')}>
|
||||
<Ico name="workflow" s={12} />
|
||||
去流程包
|
||||
</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>
|
||||
被测流
|
||||
<span className="x">{livePkgs.length} 个生效</span>
|
||||
</div>
|
||||
<div className="g-rig-pk">
|
||||
<div className="tb-pks">
|
||||
{livePkgs.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
@@ -171,127 +261,191 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
|
||||
<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-h">
|
||||
<span className="n">01</span>
|
||||
输入配置
|
||||
<span className="x">启动这条流的那封信</span>
|
||||
输入 · 一封客户来信
|
||||
<span className="x">
|
||||
<button className="tb-ghost" onClick={() => setMail(SAMPLE_MAIL)}>
|
||||
<Ico name="rotate-ccw" s={10} />
|
||||
重置样例
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<MailForm draft={{ ...mail, to }} onChange={setMail} inbox={inbox?.address} />
|
||||
<LetterForm draft={mail} onChange={setMail} channels={mailChannels} channel={channel} />
|
||||
|
||||
<div className="g-rig-h">
|
||||
<span className="n">02</span>
|
||||
节点指派
|
||||
<span className="x">
|
||||
{pinned}/{nodes.length} 已定 · 留空即照旧轮值
|
||||
{ai !== undefined && (
|
||||
<button
|
||||
className="tb-ghost"
|
||||
onClick={() => setAssignments(Object.fromEntries(nodes.map((n) => [n.id, ai.id])))}
|
||||
>
|
||||
<Ico name="bot" s={10} />
|
||||
全派 AI
|
||||
</button>
|
||||
)}
|
||||
<button className="tb-ghost" onClick={() => setAssignments({})}>
|
||||
<Ico name="users" s={10} />
|
||||
全部轮值
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className="g-asn">
|
||||
{nodes.map((n) => (
|
||||
<div key={n.id} className={'g-asn-r' + ((assignments[n.id] ?? '') === '' ? '' : ' pinned')}>
|
||||
<span className="nid">{n.id}</span>
|
||||
<span className="kd">{NODE_KIND_LABEL[n.kind] ?? n.kind}</span>
|
||||
<span className="lb">{n.label}</span>
|
||||
<select
|
||||
className="g-select"
|
||||
value={assignments[n.id] ?? ''}
|
||||
onChange={(e) => setAssignments((prev) => ({ ...prev, [n.id]: e.target.value }))}
|
||||
>
|
||||
<option value="">照旧轮值</option>
|
||||
{staff.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name} · {s.position.code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
{/*
|
||||
* 链轨:节点按编排序挂在一条竖轨上,右端是「插座」——
|
||||
* 空着即照旧轮值(有员工组按组轮,没有按全员最闲者先),插了人那一格点琥珀
|
||||
* (琥珀在这套语言里恒表「人」)。指派只在那人接得了这个节点时生效,接不了退回轮值。
|
||||
*/}
|
||||
<div className="tb-asn">
|
||||
{nodes.map((n) => {
|
||||
const pin = (assignments[n.id] ?? '') !== '';
|
||||
return (
|
||||
<div key={n.id} className={'tb-asn-r' + (pin ? ' pin' : '') + (n.kind === 'approval' ? ' apr' : '')}>
|
||||
<span className="rail">
|
||||
<i />
|
||||
</span>
|
||||
<span className="nid">{n.id}</span>
|
||||
<span className="lb">{n.label}</span>
|
||||
<span className="kd">{n.kind === 'approval' ? '审批' : '活动'}</span>
|
||||
<select
|
||||
className="tb-socket"
|
||||
aria-label={`${n.id} ${n.label} 派给谁`}
|
||||
value={assignments[n.id] ?? ''}
|
||||
onChange={(e) => setAssignments((prev) => ({ ...prev, [n.id]: e.target.value }))}
|
||||
>
|
||||
<option value="">照旧轮值</option>
|
||||
{staff.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name} · {s.position.code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error !== undefined && (
|
||||
<Note tone="warn">
|
||||
<b>没起成:</b>
|
||||
{error}
|
||||
</Note>
|
||||
)}
|
||||
{/* 起单条:仪器的前面板边缘 —— 全屏唯一的一枚动作钮,灯全亮才上膛 */}
|
||||
<div className="tb-fire">
|
||||
<span className="say">
|
||||
{error !== undefined ? (
|
||||
<b className="bad">{error}</b>
|
||||
) : armed ? (
|
||||
'装配齐了 —— 起单会先销毁上一单,任务真实落到被指派人的工作台'
|
||||
) : unruled.length > 0 ? (
|
||||
`必填的 ${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>
|
||||
)}
|
||||
</QueryGate>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ==================== 启动输入:一封信 ==================== */
|
||||
/* ==================== ① 一封信 ==================== */
|
||||
|
||||
type MailDraft = {
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
attachments: ReadonlyArray<{ name: string; contentType: string; size: number }>;
|
||||
};
|
||||
|
||||
type RunInput = { packageId: string; mail: MailDraft; assignments: Record<string, string> };
|
||||
|
||||
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 }],
|
||||
};
|
||||
|
||||
function MailForm({
|
||||
function LetterForm({
|
||||
draft,
|
||||
onChange,
|
||||
inbox,
|
||||
channels,
|
||||
channel,
|
||||
}: {
|
||||
draft: MailDraft;
|
||||
onChange: (next: MailDraft) => void;
|
||||
inbox: string | undefined;
|
||||
channels: readonly Channel[];
|
||||
channel: Channel | undefined;
|
||||
}): ReactNode {
|
||||
const set = <K extends keyof MailDraft>(k: K, v: MailDraft[K]): void => onChange({ ...draft, [k]: v });
|
||||
return (
|
||||
<div className="g-ml">
|
||||
<label className="g-ml-r">
|
||||
<div className="tb-ml">
|
||||
<label className="tb-ml-r">
|
||||
<em>发件人</em>
|
||||
<input className="g-ml-i" value={draft.from} onChange={(e) => set('from', e.target.value)} />
|
||||
<input className="tb-ml-i" value={draft.from} onChange={(e) => set('from', e.target.value)} />
|
||||
</label>
|
||||
<label className="g-ml-r">
|
||||
<em>收件人</em>
|
||||
<input
|
||||
className="g-ml-i"
|
||||
value={draft.to}
|
||||
placeholder={inbox ?? '公司询价邮箱'}
|
||||
onChange={(e) => set('to', e.target.value)}
|
||||
/>
|
||||
<span className="g-ml-t">投给哪个信箱决定用哪套抽键规则</span>
|
||||
<label className="tb-ml-r">
|
||||
<em>收件箱</em>
|
||||
{/* 投给哪个信箱决定用哪套抽键规则 —— 选,不手打:地址错一位,预检整屏熄灯还不知道为什么 */}
|
||||
<select className="tb-ml-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="g-ml-r">
|
||||
<label className="tb-ml-r">
|
||||
<em>主题</em>
|
||||
<input className="g-ml-i" value={draft.subject} onChange={(e) => set('subject', e.target.value)} />
|
||||
<span className="g-ml-t">客户码与询价单号从这里抽</span>
|
||||
<input className="tb-ml-i" value={draft.subject} onChange={(e) => set('subject', e.target.value)} />
|
||||
</label>
|
||||
<label className="g-ml-r tall">
|
||||
<label className="tb-ml-r tall">
|
||||
<em>正文</em>
|
||||
<textarea className="g-ml-i" rows={4} value={draft.body} onChange={(e) => set('body', e.target.value)} />
|
||||
<textarea className="tb-ml-i" rows={4} value={draft.body} onChange={(e) => set('body', e.target.value)} />
|
||||
</label>
|
||||
<div className="g-ml-r tall">
|
||||
<div className="tb-ml-r tall">
|
||||
<em>附件</em>
|
||||
<div className="g-ml-at">
|
||||
<div className="tb-ml-at">
|
||||
{draft.attachments.map((a, i) => (
|
||||
<span key={a.name} className="g-att">
|
||||
<Ico name="paperclip" s={11} />
|
||||
@@ -323,6 +477,15 @@ function MailForm({
|
||||
);
|
||||
}
|
||||
|
||||
/* ==================== 观测态 ==================== */
|
||||
|
||||
const LAMP_OF: Readonly<Record<string, 'live' | 'wait' | 'done' | 'bad'>> = {
|
||||
running: 'live',
|
||||
waiting: 'wait',
|
||||
completed: 'done',
|
||||
abnormal: 'bad',
|
||||
};
|
||||
|
||||
function Observe({
|
||||
id,
|
||||
staffName,
|
||||
@@ -353,174 +516,151 @@ function Observe({
|
||||
};
|
||||
|
||||
const started = w === undefined ? undefined : new Date(w.workflow.startedAt);
|
||||
const refresh = (): void => {
|
||||
void wf.refetch();
|
||||
void trQ.refetch();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
ico="activity"
|
||||
tone={state === 'abnormal' ? 'danger' : state === 'completed' ? 'ok' : 'brand'}
|
||||
kicker="联调测试台 · 观测态"
|
||||
title={w?.workflow.subject ?? '试跑中'}
|
||||
desc="流已经起了。下面这三样看穿它:全链 TRACE 看它怎么跑,节点实例看它跑到哪,派工带看它把活派给了谁。"
|
||||
chips={
|
||||
<div className="g-tb">
|
||||
<Strip
|
||||
lamp={state === undefined ? 'live' : (LAMP_OF[state] ?? 'live')}
|
||||
breathe={live}
|
||||
word={state === undefined ? '取数中' : (WORKFLOW_STATE_LABEL[state] ?? state)}
|
||||
detail={
|
||||
<>
|
||||
<St
|
||||
tone={state === 'abnormal' ? 'danger' : state === 'completed' ? 'ok' : 'brand'}
|
||||
ico={state === 'completed' ? 'circle-check' : state === 'abnormal' ? 'alert-triangle' : 'radio'}
|
||||
>
|
||||
{state === undefined ? '取数中' : (WORKFLOW_STATE_LABEL[state] ?? state)}
|
||||
</St>
|
||||
<St tone="mute" ico="hash">
|
||||
{id.slice(0, 8)}
|
||||
</St>
|
||||
{live && (
|
||||
<St tone="mute" ico="refresh-cw">
|
||||
每 2 秒自动刷新
|
||||
</St>
|
||||
)}
|
||||
{w?.workflow.subject ?? id.slice(0, 8)}
|
||||
{live && <i className="auto"> · 自动刷新 2s</i>}
|
||||
</>
|
||||
}
|
||||
acts={[
|
||||
{ label: '刷新', ico: 'refresh-cw', onClick: refresh },
|
||||
{ label: stopping ? '销毁中…' : '停止仿真', ico: 'square', primary: true, onClick: onBack },
|
||||
]}
|
||||
right={
|
||||
<button className="tb-stop" disabled={stopping} onClick={onBack}>
|
||||
<Ico name="square" s={11} />
|
||||
{stopping ? '销毁中…' : '停止仿真'}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="g-tb">
|
||||
<QueryGate query={wf}>
|
||||
{w !== undefined && (
|
||||
<>
|
||||
{/* 图签栏:工程图右下角那张表 —— 一条流的身份,一格一件事 */}
|
||||
<div className="g-sheet">
|
||||
<div className="g-sheet-h">
|
||||
<Ico name="file-text" s={12} />
|
||||
WF — 工作流
|
||||
<span className="x">{w.workflow.source.kind} · {w.workflow.source.ref}</span>
|
||||
<QueryGate query={wf}>
|
||||
{w !== undefined && (
|
||||
<>
|
||||
{/* 图签栏:工程图右下角那张表 —— 一条流的身份,一格一件事 */}
|
||||
<div className="g-sheet">
|
||||
<div className="g-sheet-h">
|
||||
<Ico name="file-text" s={12} />
|
||||
WF — 工作流
|
||||
<span className="x">
|
||||
{w.workflow.source.kind} · {w.workflow.source.ref}
|
||||
</span>
|
||||
</div>
|
||||
<div className="g-tblock">
|
||||
<div>
|
||||
<em>工作流号</em>
|
||||
<b>{id.slice(0, 8)}</b>
|
||||
</div>
|
||||
<div className="g-tblock">
|
||||
<div>
|
||||
<em>工作流号</em>
|
||||
<b>{id.slice(0, 8)}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>状态</em>
|
||||
<b className={state === 'running' || state === 'waiting' ? 'run' : 'brass'}>
|
||||
{WORKFLOW_STATE_LABEL[w.workflow.state] ?? w.workflow.state}
|
||||
</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>流程包</em>
|
||||
<b>{w.workflow.packageId}@v{w.workflow.packageVersion}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>起单时刻</em>
|
||||
<b>{started?.toLocaleTimeString('zh-CN', { hour12: false }) ?? '—'}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>起单人</em>
|
||||
<b>{w.workflow.source.by === undefined ? '—' : staffName(w.workflow.source.by)}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>节点 / 边</em>
|
||||
<b>
|
||||
{w.pkg.nodes.length} / {w.pkg.edges.length}
|
||||
</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>状态</em>
|
||||
<b className={state === 'running' || state === 'waiting' ? 'run' : 'brass'}>
|
||||
{WORKFLOW_STATE_LABEL[w.workflow.state] ?? w.workflow.state}
|
||||
</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>流程包</em>
|
||||
<b>
|
||||
{w.workflow.packageId}@v{w.workflow.packageVersion}
|
||||
</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>起单时刻</em>
|
||||
<b>{started?.toLocaleTimeString('zh-CN', { hour12: false }) ?? '—'}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>起单人</em>
|
||||
<b>{w.workflow.source.by === undefined ? '—' : staffName(w.workflow.source.by)}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>节点 / 边</em>
|
||||
<b>
|
||||
{w.pkg.nodes.length} / {w.pkg.edges.length}
|
||||
</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 派工带:测试台不代答,这句必须说满 */}
|
||||
<div className="g-tbdesk">
|
||||
<div className="g-tbdesk-h">
|
||||
<Ico name="briefcase" s={12} />
|
||||
DSK — 任务落到了谁的工作台
|
||||
<button className="g-btn xs" onClick={() => nav.jump('tasks', 'list')}>
|
||||
<Ico name="activity" s={11} />
|
||||
去运营中心 · 任务
|
||||
</button>
|
||||
</div>
|
||||
{w.tasks.length === 0 ? (
|
||||
<p>还没有任务落到任何工位 —— 流刚起,或者卡在审批点上(审批单不是任务,见下面的节点实例)。</p>
|
||||
) : (
|
||||
w.tasks.map((t) => (
|
||||
<div key={t.id} className="g-tbdesk-r">
|
||||
<span className="nid">{t.nodeId}</span>
|
||||
{t.title}
|
||||
<span>→</span>
|
||||
<b>{t.ownerName} 的工作台</b>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<p>
|
||||
测试台<b>不代答</b>:这些是真任务,要那个人在自己的工作台上答,这条流才会往下走。
|
||||
</p>
|
||||
{/* 派工带:测试台不代答,这句必须说满 */}
|
||||
<div className="g-tbdesk">
|
||||
<div className="g-tbdesk-h">
|
||||
<Ico name="briefcase" s={12} />
|
||||
DSK — 任务落到了谁的工作台
|
||||
<button className="g-btn xs" onClick={() => nav.jump('tasks', 'list')}>
|
||||
<Ico name="activity" s={11} />
|
||||
去运营中心 · 任务
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TraceSheet
|
||||
spans={spans}
|
||||
staffName={staffName}
|
||||
extra={
|
||||
<button className="g-btn xs" onClick={refresh} disabled={trQ.isFetching}>
|
||||
<Ico name="refresh-cw" s={11} />
|
||||
{trQ.isFetching ? '取数中' : '刷新'}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="g-sheet">
|
||||
<div className="g-sheet-h">
|
||||
<Ico name="list-checks" s={12} />
|
||||
NOD — 节点实例
|
||||
<span className="x">{w.instances.length} 个已进入</span>
|
||||
</div>
|
||||
{w.instances.length === 0 ? (
|
||||
<div className="g-tb-empty">
|
||||
<Ico name="scan" s={26} />
|
||||
<b>还没有节点进入</b>
|
||||
<p>起点已过,第一个节点正在派工。</p>
|
||||
{w.tasks.length === 0 ? (
|
||||
<p>还没有任务落到任何工位 —— 流刚起,或者卡在审批点上(审批单不是任务,见下面的节点实例)。</p>
|
||||
) : (
|
||||
w.tasks.map((t) => (
|
||||
<div key={t.id} className="g-tbdesk-r">
|
||||
<span className="nid">{t.nodeId}</span>
|
||||
{t.title}
|
||||
<span>→</span>
|
||||
<b>{t.ownerName} 的工作台</b>
|
||||
</div>
|
||||
) : (
|
||||
<div className="g-nins">
|
||||
<div className="g-nins-h">
|
||||
<span>节点</span>
|
||||
<span>名称</span>
|
||||
<span>状态</span>
|
||||
<span>终态</span>
|
||||
<span>受理人</span>
|
||||
</div>
|
||||
{w.instances.map((it) => {
|
||||
const bad = it.terminal !== undefined && it.terminal !== 'released';
|
||||
const cls = bad ? 'bad' : it.running ? 'run' : it.waiting ? 'wait' : it.terminal === undefined ? '' : 'done';
|
||||
const who = assigneeOfNode(it.nodeId);
|
||||
return (
|
||||
<div key={`${it.nodeId}#${it.instance}`} className="g-nins-r">
|
||||
<span className="nid">{it.nodeId}</span>
|
||||
<span className="lb">{nodeLabel(it.nodeId)}</span>
|
||||
<span className={`st ${cls}`}>
|
||||
<i />
|
||||
{bad ? '异常' : it.running ? '在跑' : it.waiting ? '等人' : it.terminal === undefined ? '已进入' : '已放行'}
|
||||
</span>
|
||||
<span className="lb">
|
||||
{it.terminal === undefined ? '—' : (TERMINAL_STATE_LABEL[it.terminal] ?? it.terminal)}
|
||||
</span>
|
||||
<span className="who">
|
||||
{who === undefined ? '—' : <b>{who}</b>}
|
||||
{it.instance > 1 && <span className="ins"> 第 {it.instance} 次</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
))
|
||||
)}
|
||||
<p>
|
||||
测试台<b>不代答</b>:这些是真任务,要那个人在自己的工作台上答,这条流才会往下走。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TraceSheet spans={spans} staffName={staffName} />
|
||||
|
||||
<div className="g-sheet">
|
||||
<div className="g-sheet-h">
|
||||
<Ico name="list-checks" s={12} />
|
||||
NOD — 节点实例
|
||||
<span className="x">{w.instances.length} 个已进入</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</QueryGate>
|
||||
</div>
|
||||
</>
|
||||
{w.instances.length === 0 ? (
|
||||
<div className="g-tb-empty">
|
||||
<Ico name="scan" s={26} />
|
||||
<b>还没有节点进入</b>
|
||||
<p>起点已过,第一个节点正在派工。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="g-nins">
|
||||
<div className="g-nins-h">
|
||||
<span>节点</span>
|
||||
<span>名称</span>
|
||||
<span>状态</span>
|
||||
<span>终态</span>
|
||||
<span>受理人</span>
|
||||
</div>
|
||||
{w.instances.map((it) => {
|
||||
const bad = it.terminal !== undefined && it.terminal !== 'released';
|
||||
const cls = bad ? 'bad' : it.running ? 'run' : it.waiting ? 'wait' : it.terminal === undefined ? '' : 'done';
|
||||
const who = assigneeOfNode(it.nodeId);
|
||||
return (
|
||||
<div key={`${it.nodeId}#${it.instance}`} className="g-nins-r">
|
||||
<span className="nid">{it.nodeId}</span>
|
||||
<span className="lb">{nodeLabel(it.nodeId)}</span>
|
||||
<span className={`st ${cls}`}>
|
||||
<i />
|
||||
{bad ? '异常' : it.running ? '在跑' : it.waiting ? '等人' : it.terminal === undefined ? '已进入' : '已放行'}
|
||||
</span>
|
||||
<span className="lb">
|
||||
{it.terminal === undefined ? '—' : (TERMINAL_STATE_LABEL[it.terminal] ?? it.terminal)}
|
||||
</span>
|
||||
<span className="who">
|
||||
{who === undefined ? '—' : <b>{who}</b>}
|
||||
{it.instance > 1 && <span className="ins"> 第 {it.instance} 次</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</QueryGate>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user