Archived
测试台改仪表台:启动输入换成一封信;卡片化触发器与大模型;清掉 ⓘ 说教条
**测试台**
· 启动输入不再是「调用参数」表 —— 这条流真实的起法就是客户来信,
界面上就该是一封信(发件人 / 收件人 / 主题 / 正文 / BOM 附件)。
服务端拿**真通道的抽键正则** + **真触发器的装配映射**推出调用入参:
测试台自己不解析邮件,否则试通的是测试台而不是这条流。
抽不出必填就如实报「哪个字段没命中、通道的正则要什么」,不静默起空单。
· 一屏两大块:左选流,右两节 —— 01 输入配置、02 节点指派。
· 起单钮只剩一枚(原来页头一枚、RUN 卡里一枚)。
· 改暗色仪表台:调色板集中在 .g-tb/.g-rig 的一组变量上,
TRACE 那张树 + 甘特的结构一行没动,只是纸变成了面板。
**触发器 / 大模型** 列表改卡片:一条规则、一个实例都要一眼看全
(何时起 · 起哪条流 · 开没开 / 谁家的 · 什么型号 · 密钥绑没绑)。
触发器卡是 div 不是 button —— 卡里还坐着「改规则」那枚真按钮。
**同类卡等高**:底栏钉在卡底、说明恒占两行、卡头一行截断。
实测同一货架内 13 张工具卡、6 张活动卡、3 张模型卡各自完全等高。
**删掉 19 条 ⓘ 说教横幅**(「只列有生效版的包…」那一类)。
留下的是三种:确认带里的影响说明(点了动作才出现,是决策依据)、
warn/ok 的真反馈、以及「X 不存在」这类空态。
删掉入站邮件演练整屏 —— 它验的那两步已经并进测试台的起单路径了。
新增一条接缝测试:通道抽出来的键叫 keys.customerCode,触发器映射也得照这名字取;
哪头改了名字单还是能起,只是入参悄悄变空、流走到 G1 才莫名其妙地停。
四闸绿;契约快照随本提交入库。
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,183 +0,0 @@
|
||||
/**
|
||||
* 调用参数表单 —— 按流程包 `callInterface.input`(JSON Schema 2020-12 子集)现生成。
|
||||
*
|
||||
* 表单不认业务字段,只认声明:必填由 `required` 决定,控件由 `type` / `enum` 决定。
|
||||
* 前端这一层只做**填得顺手**,不做裁决 —— 合不合契约由后端 ajv 判,判词原样回显。
|
||||
* 少填一个必填就把按钮按灰,是为了不让人白跑一趟接口,不是替后端把关。
|
||||
*/
|
||||
import type { ReactNode } from 'react';
|
||||
import { Field } from '@web-shared/gov/primitives.tsx';
|
||||
|
||||
/** 控件五型:字符串 / 数值 / 真假 / 枚举 / 复合(对象与数组落 JSON 文本框) */
|
||||
export type CallFieldKind = 'text' | 'number' | 'bool' | 'enum' | 'json';
|
||||
|
||||
export type CallField = {
|
||||
readonly key: string;
|
||||
readonly required: boolean;
|
||||
readonly kind: CallFieldKind;
|
||||
readonly options: readonly string[];
|
||||
/** 声明里的人读名与说明(有就用,没有就退回字段名) */
|
||||
readonly title: string | undefined;
|
||||
readonly hint: string | undefined;
|
||||
};
|
||||
|
||||
export type CallValues = Readonly<Record<string, string>>;
|
||||
|
||||
function asRecord(v: unknown): Record<string, unknown> | undefined {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
|
||||
}
|
||||
|
||||
function asStrings(v: unknown): string[] {
|
||||
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
|
||||
}
|
||||
|
||||
function kindOf(prop: Record<string, unknown>): CallFieldKind {
|
||||
if (asStrings(prop['enum']).length > 0) return 'enum';
|
||||
const t = prop['type'];
|
||||
if (t === 'number' || t === 'integer') return 'number';
|
||||
if (t === 'boolean') return 'bool';
|
||||
if (t === 'object' || t === 'array') return 'json';
|
||||
return 'text';
|
||||
}
|
||||
|
||||
/**
|
||||
* 声明 → 字段清单。**必填排前面**:properties 的键序来自库里的 jsonb,
|
||||
* 是存储顺序不是语义顺序,照抄等于把「哪几个非填不可」藏进随机次序里。
|
||||
*/
|
||||
export function callFieldsOf(schema: Readonly<Record<string, unknown>> | undefined): CallField[] {
|
||||
const props = asRecord(schema?.['properties']);
|
||||
if (props === undefined) return [];
|
||||
const required = new Set(asStrings(schema?.['required']));
|
||||
const fields = Object.entries(props).map(([key, raw]) => {
|
||||
const prop = asRecord(raw) ?? {};
|
||||
const title = typeof prop['title'] === 'string' ? prop['title'] : undefined;
|
||||
const desc = typeof prop['description'] === 'string' ? prop['description'] : undefined;
|
||||
const fmt = typeof prop['format'] === 'string' ? prop['format'] : undefined;
|
||||
const t = typeof prop['type'] === 'string' ? prop['type'] : 'any';
|
||||
return {
|
||||
key,
|
||||
required: required.has(key),
|
||||
kind: kindOf(prop),
|
||||
options: asStrings(prop['enum']),
|
||||
title,
|
||||
hint: desc ?? (fmt === undefined ? t : `${t} · ${fmt}`),
|
||||
};
|
||||
});
|
||||
return fields.sort((a, b) =>
|
||||
a.required === b.required ? a.key.localeCompare(b.key) : a.required ? -1 : 1,
|
||||
);
|
||||
}
|
||||
|
||||
/** 起单载荷 —— 空的选填字段不发出去,免得把 `""` 当成「填过了」 */
|
||||
export function buildCallData(
|
||||
fields: readonly CallField[],
|
||||
values: CallValues,
|
||||
): { data: Record<string, unknown>; errors: string[] } {
|
||||
const data: Record<string, unknown> = {};
|
||||
const errors: string[] = [];
|
||||
for (const f of fields) {
|
||||
const raw = (values[f.key] ?? '').trim();
|
||||
if (raw === '') {
|
||||
if (f.required) errors.push(`${f.key} 必填`);
|
||||
continue;
|
||||
}
|
||||
if (f.kind === 'number') {
|
||||
const n = Number(raw);
|
||||
if (Number.isNaN(n)) errors.push(`${f.key} 要是数字`);
|
||||
else data[f.key] = n;
|
||||
continue;
|
||||
}
|
||||
if (f.kind === 'bool') {
|
||||
data[f.key] = raw === 'true';
|
||||
continue;
|
||||
}
|
||||
if (f.kind === 'json') {
|
||||
try {
|
||||
data[f.key] = JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
errors.push(`${f.key} 不是合法 JSON`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
data[f.key] = raw;
|
||||
}
|
||||
return { data, errors };
|
||||
}
|
||||
|
||||
/** 占位值:按类型各填一份能过契约的样子,让人一键就能跑通一趟再回头改 */
|
||||
export function placeholderValues(fields: readonly CallField[]): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const f of fields) {
|
||||
out[f.key] =
|
||||
f.kind === 'number'
|
||||
? '0'
|
||||
: f.kind === 'bool'
|
||||
? 'false'
|
||||
: f.kind === 'enum'
|
||||
? (f.options[0] ?? '')
|
||||
: f.kind === 'json'
|
||||
? '{}'
|
||||
: `${f.key}-占位`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一格一字段。必填不靠星号靠**颜色**:整条注记转黄铜(黄铜在这套图纸语言里恒表
|
||||
* 「生效 / 裁定 / 人」这类要紧事),扫一眼就知道哪几格非填不可。
|
||||
*/
|
||||
export function CallForm({
|
||||
fields,
|
||||
values,
|
||||
onChange,
|
||||
}: {
|
||||
fields: readonly CallField[];
|
||||
values: CallValues;
|
||||
onChange: (key: string, v: string) => void;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<>
|
||||
{fields.map((f) => {
|
||||
const v = values[f.key] ?? '';
|
||||
const hint = `${f.required ? '必填' : '选填'}${f.hint === undefined ? '' : ` · ${f.hint}`}`;
|
||||
return (
|
||||
<div key={f.key} className={'g-tb-f' + (f.required ? ' req' : '')}>
|
||||
<Field k={f.title ?? f.key} hint={hint}>
|
||||
{f.kind === 'enum' ? (
|
||||
<select className="g-select" value={v} onChange={(e) => onChange(f.key, e.target.value)}>
|
||||
<option value="">不填</option>
|
||||
{f.options.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : f.kind === 'bool' ? (
|
||||
<select className="g-select" value={v} onChange={(e) => onChange(f.key, e.target.value)}>
|
||||
<option value="">不填</option>
|
||||
<option value="true">是</option>
|
||||
<option value="false">否</option>
|
||||
</select>
|
||||
) : f.kind === 'json' ? (
|
||||
<textarea
|
||||
className="g-ta mono"
|
||||
rows={3}
|
||||
value={v}
|
||||
placeholder="{ }"
|
||||
onChange={(e) => onChange(f.key, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className={'g-input' + (f.kind === 'number' ? '' : ' mono')}
|
||||
type={f.kind === 'number' ? 'number' : 'text'}
|
||||
value={v}
|
||||
onChange={(e) => onChange(f.key, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -13,11 +13,9 @@ 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';
|
||||
import { useGovPackages, useGovStaff } from '@web-shared/gov/gov-api.ts';
|
||||
import { Acts, Field, Note, PageHead, QueryGate, St } from '@web-shared/gov/primitives.tsx';
|
||||
import { useGovChannels, useGovPackages, useGovStaff } from '@web-shared/gov/gov-api.ts';
|
||||
import { Note, PageHead, QueryGate, St } from '@web-shared/gov/primitives.tsx';
|
||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||
import { CallForm, buildCallData, callFieldsOf, placeholderValues } from './call-form.tsx';
|
||||
import type { CallValues } from './call-form.tsx';
|
||||
import { isLive, useRunTrace, useRunWorkflow } from './run-api.ts';
|
||||
import { TraceSheet } from './trace-sheet.tsx';
|
||||
|
||||
@@ -33,36 +31,10 @@ const NODE_KIND_LABEL: Readonly<Record<string, string>> = {
|
||||
end: '终点',
|
||||
};
|
||||
|
||||
function Sheet({
|
||||
code,
|
||||
title,
|
||||
ico,
|
||||
extra,
|
||||
tight,
|
||||
children,
|
||||
}: {
|
||||
code: string;
|
||||
title: string;
|
||||
ico: string;
|
||||
extra?: ReactNode;
|
||||
tight?: boolean;
|
||||
children: ReactNode;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div className="g-sheet">
|
||||
<div className="g-sheet-h">
|
||||
<Ico name={ico} s={12} />
|
||||
{code} — {title}
|
||||
{extra !== undefined && <span className="x">{extra}</span>}
|
||||
</div>
|
||||
<div className={'g-sheet-b' + (tight === true ? ' tight' : '')}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
|
||||
const pkgQ = useGovPackages();
|
||||
const staffQ = useGovStaff();
|
||||
const chanQ = useGovChannels();
|
||||
|
||||
/** 只列有生效版的:同一个包可能存着多版,能起单的只有 `active` 那一版 */
|
||||
const livePkgs = useMemo<ProcessPackage[]>(
|
||||
@@ -71,10 +43,10 @@ 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 [pickedId, setPicked] = useState('');
|
||||
const [subject, setSubject] = useState('测试台试跑');
|
||||
const [values, setValues] = useState<CallValues>({});
|
||||
const [mail, setMail] = useState<MailDraft>(SAMPLE_MAIL);
|
||||
const [assignments, setAssignments] = useState<Readonly<Record<string, string>>>({});
|
||||
const queryClient = useQueryClient();
|
||||
const [workflowId, setWorkflowId] = useState<string | undefined>(undefined);
|
||||
@@ -102,19 +74,17 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
|
||||
|
||||
// 没选就落到第一个有生效版的包 —— 常见情形只有一个包,不该逼人先点一下
|
||||
const pkg = livePkgs.find((p) => p.id === pickedId) ?? livePkgs[0];
|
||||
const fields = useMemo(() => callFieldsOf(pkg?.callInterface.input), [pkg]);
|
||||
const nodes = useMemo(() => (pkg?.nodes ?? []).filter(isAssignable), [pkg]);
|
||||
const built = useMemo(() => buildCallData(fields, values), [fields, values]);
|
||||
const ready = pkg !== undefined && subject.trim() !== '' && built.errors.length === 0;
|
||||
const to = mail.to === '' ? (inbox?.address ?? '') : mail.to;
|
||||
const ready = pkg !== undefined && mail.from.trim() !== '' && to !== '' && mail.subject.trim() !== '';
|
||||
|
||||
const run = useMutation({
|
||||
mutationFn: (body: { packageId: string; subject: string; data: Record<string, unknown>; assignments: Record<string, string> }) =>
|
||||
call<RunOutput>(testRunRoute, { body }),
|
||||
mutationFn: (input: RunInput) => call<RunOutput>(testRunRoute, { body: input }),
|
||||
onSuccess: (r) => {
|
||||
setWorkflowId(r.workflowId);
|
||||
setError(undefined);
|
||||
},
|
||||
// 后端的判词照抄:「输入不合调用接口契约」「流程包无生效版本」都是要人改配置的话,不该被我改写
|
||||
// 后端的判词照抄:「抽不出客户码」「无生效版」都是要人改配置的话,不该被我改写
|
||||
onError: (e: unknown) => setError((e as { message?: string }).message ?? '起单失败'),
|
||||
});
|
||||
|
||||
@@ -122,21 +92,14 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
|
||||
if (pkg === undefined) return;
|
||||
run.mutate({
|
||||
packageId: pkg.id,
|
||||
subject: subject.trim(),
|
||||
data: built.data,
|
||||
mail: { ...mail, to },
|
||||
assignments: Object.fromEntries(Object.entries(assignments).filter(([, v]) => v !== '')),
|
||||
});
|
||||
};
|
||||
|
||||
if (running !== undefined) {
|
||||
return (
|
||||
<Observe
|
||||
id={running}
|
||||
staffName={staffName}
|
||||
nav={nav}
|
||||
stopping={stop.isPending}
|
||||
onBack={() => stop.mutate(running)}
|
||||
/>
|
||||
<Observe id={running} staffName={staffName} nav={nav} stopping={stop.isPending} onBack={() => stop.mutate(running)} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -149,7 +112,7 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
|
||||
tone="brand"
|
||||
kicker="联调测试台 · 装配态"
|
||||
title="仿真试跑"
|
||||
desc="装一次调用、把流真起起来,然后看穿它怎么跑。任务照旧落到被指派人的工作台 —— 测试台不代答。"
|
||||
desc="这条流真实的起法就是一封客户来信 —— 写这封信、指派每个节点归谁,起单看穿全程。任务照旧落到被指派人的工作台。"
|
||||
chips={
|
||||
<>
|
||||
<St tone={pkg === undefined ? 'warn' : 'ok'} ico={pkg === undefined ? 'alert-circle' : 'circle-check'}>
|
||||
@@ -171,160 +134,194 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="g-tb">
|
||||
<QueryGate query={pkgQ}>
|
||||
{livePkgs.length === 0 ? (
|
||||
<div className="g-sheet">
|
||||
<div className="g-tb-empty">
|
||||
<Ico name="scan" s={26} />
|
||||
<b>没有可起单的流程包</b>
|
||||
<p>
|
||||
试跑要有一个<b>生效版</b>才起得来。去「流程包」里保存一版 —— 存得进去的版本就是生效的。
|
||||
</p>
|
||||
</div>
|
||||
<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="g-tb-rig">
|
||||
<Sheet code="PKG" title="流程包" ico="workflow" extra={`${livePkgs.length} 个生效`}>
|
||||
<div className="g-tbpkgs">
|
||||
{livePkgs.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
className={'g-tbpkg' + (p.id === pkg?.id ? ' on' : '')}
|
||||
onClick={() => {
|
||||
setPicked(p.id);
|
||||
setValues({});
|
||||
setAssignments({});
|
||||
}}
|
||||
>
|
||||
<b>{p.name}</b>
|
||||
<span className="id">
|
||||
{p.id}@v{p.version}
|
||||
</span>
|
||||
<span className="cnt">
|
||||
<span>节点 {p.nodes.length}</span>
|
||||
<span>边 {p.edges.length}</span>
|
||||
<span>入参 {callFieldsOf(p.callInterface.input).length}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Note>只列有生效版的包。在途的流按起单时那一版跑完,改包不影响它们。</Note>
|
||||
</Sheet>
|
||||
</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="g-rig-pk">
|
||||
{livePkgs.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
className={'g-pk' + (p.id === pkg?.id ? ' on' : '')}
|
||||
onClick={() => {
|
||||
setPicked(p.id);
|
||||
setAssignments({});
|
||||
}}
|
||||
>
|
||||
<b>{p.name}</b>
|
||||
<span className="id">
|
||||
{p.id}@v{p.version}
|
||||
</span>
|
||||
<span className="cnt">
|
||||
<i>节点 {p.nodes.length}</i>
|
||||
<i>边 {p.edges.length}</i>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<Sheet
|
||||
code="ARG"
|
||||
title="调用参数"
|
||||
ico="sliders-horizontal"
|
||||
extra={pkg === undefined ? '' : `按 ${pkg.id} 的调用接口`}
|
||||
>
|
||||
{fields.length === 0 ? (
|
||||
<Note>这个包的调用接口没声明入参 —— 直接起单即可。</Note>
|
||||
) : (
|
||||
<>
|
||||
<div className="g-tbargs">
|
||||
<CallForm
|
||||
fields={fields}
|
||||
values={values}
|
||||
onChange={(k, v) => setValues((prev) => ({ ...prev, [k]: v }))}
|
||||
/>
|
||||
</div>
|
||||
<Acts
|
||||
align="end"
|
||||
items={[
|
||||
{ label: '填占位值', ico: 'sparkles', onClick: () => setValues(placeholderValues(fields)) },
|
||||
{ label: '清空', ico: 'trash-2', onClick: () => setValues({}) },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
<section className="g-rig-r">
|
||||
<div className="g-rig-h">
|
||||
<span className="n">01</span>
|
||||
输入配置
|
||||
<span className="x">启动这条流的那封信</span>
|
||||
</div>
|
||||
<MailForm draft={{ ...mail, to }} onChange={setMail} inbox={inbox?.address} />
|
||||
|
||||
<div className="g-rig-h">
|
||||
<span className="n">02</span>
|
||||
节点指派
|
||||
<span className="x">
|
||||
{pinned}/{nodes.length} 已定 · 留空即照旧轮值
|
||||
</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>
|
||||
|
||||
<Sheet code="ASN" title="节点指派" ico="user-check" extra={`${nodes.length} 个可指派 · 已定 ${pinned}`}>
|
||||
{/* 分两栏排:一条流七八个节点,单栏会把这张卡拉得比整块装配单还长 */}
|
||||
<div className="g-asn-2">
|
||||
{[nodes.slice(0, Math.ceil(nodes.length / 2)), nodes.slice(Math.ceil(nodes.length / 2))]
|
||||
.filter((half) => half.length > 0)
|
||||
.map((half, i) => (
|
||||
<div key={i} className="g-asn">
|
||||
<div className="g-asn-h">
|
||||
<span>节点</span>
|
||||
<span>类型</span>
|
||||
<span>名称</span>
|
||||
<span>派给谁</span>
|
||||
</div>
|
||||
{half.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>
|
||||
))}
|
||||
</div>
|
||||
<Note>
|
||||
留空即<b>照旧轮值</b>(有员工组按组轮,没有按全员最闲者先)。指派只在那个人<b>接得了这个节点</b>
|
||||
时生效,接不了会退回轮值 —— 实际受理人以 TRACE 里那一行为准。
|
||||
{error !== undefined && (
|
||||
<Note tone="warn">
|
||||
<b>没起成:</b>
|
||||
{error}
|
||||
</Note>
|
||||
</Sheet>
|
||||
|
||||
<Sheet code="RUN" title="起单" ico="play" extra={ready ? '装配齐了' : '还差料'}>
|
||||
<div className="g-tbrun">
|
||||
<div className="g-tb-f">
|
||||
<Field k="试跑标题" hint="落在工作流上的题,观测态与运营中心都按它认这一单">
|
||||
<input className="g-input" value={subject} onChange={(e) => setSubject(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<Acts
|
||||
items={[
|
||||
{
|
||||
label: run.isPending ? '起单中…' : '起单试跑',
|
||||
ico: 'play',
|
||||
primary: true,
|
||||
disabled: !ready || run.isPending,
|
||||
onClick: fire,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{built.errors.length > 0 && (
|
||||
<Note tone="warn">
|
||||
<b>还差 {built.errors.length} 项才起得来:</b>
|
||||
{built.errors.join(' · ')}
|
||||
</Note>
|
||||
)}
|
||||
{error !== undefined && (
|
||||
<Note tone="warn">
|
||||
<b>没起成:</b>
|
||||
{error}
|
||||
</Note>
|
||||
)}
|
||||
</Sheet>
|
||||
</>
|
||||
)}
|
||||
</QueryGate>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</QueryGate>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 观测态 ─────────────────────────────────────────────────────────── */
|
||||
/* ==================== 启动输入:一封信 ==================== */
|
||||
|
||||
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({
|
||||
draft,
|
||||
onChange,
|
||||
inbox,
|
||||
}: {
|
||||
draft: MailDraft;
|
||||
onChange: (next: MailDraft) => void;
|
||||
inbox: string | 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">
|
||||
<em>发件人</em>
|
||||
<input className="g-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>
|
||||
<label className="g-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>
|
||||
</label>
|
||||
<label className="g-ml-r tall">
|
||||
<em>正文</em>
|
||||
<textarea className="g-ml-i" rows={4} value={draft.body} onChange={(e) => set('body', e.target.value)} />
|
||||
</label>
|
||||
<div className="g-ml-r tall">
|
||||
<em>附件</em>
|
||||
<div className="g-ml-at">
|
||||
{draft.attachments.map((a, i) => (
|
||||
<span key={a.name} className="g-att">
|
||||
<Ico name="paperclip" s={11} />
|
||||
{a.name}
|
||||
<i>{(a.size / 1024).toFixed(0)} KB</i>
|
||||
<button
|
||||
aria-label={`移除 ${a.name}`}
|
||||
onClick={() => set('attachments', draft.attachments.filter((_, j) => j !== i))}
|
||||
>
|
||||
<Ico name="x" s={10} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
className="g-att-add"
|
||||
onClick={() =>
|
||||
set('attachments', [
|
||||
...draft.attachments,
|
||||
{ name: `BOM-${draft.attachments.length + 1}.xlsx`, contentType: XLSX, size: 20_480 },
|
||||
])
|
||||
}
|
||||
>
|
||||
<Ico name="plus" s={11} />
|
||||
加一个 BOM
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Observe({
|
||||
id,
|
||||
|
||||
Reference in New Issue
Block a user