Archived
用户定的逻辑:「调用活动,输入数据可以不定或者为空;进入活动后受理归一,
信息不全派补全单。」把收敛点从门口挪进活动 —— 三道门口闸全撤:
· 触发装配:缺必填**不再入死信**(AssemblyResult 的 missing 改 partial,带上已装配的);
装到多少带多少照样起单。死信只留给真错误(勾了确认没指定人这类配置错误)。
· process.start:撤掉调用接口缺项闸 —— 从前缺一个字段整条流都起不来,
现实是客户来信本来就常缺客户码,拦在门外等于要求外界替系统把信息备齐。
· task.start(替代旧 AM 12.1「缺项不得启动」):缺必填的任务落成「等人」,
缺的字段做成**补全卡**(elicitation,标签取 schema 的 title)发到负责人的
任务对话流,并武装 human 监听(IntakeDesk 端口;不占活动的人卡预算)。
答复只收契约声明过的键,合并进 data;齐了或答「没法填」才转 running 开跑;
答一半就就缺的部分再派一张卡。等人期间谱一步不走、不扣步数。
· 测试台预检改成**信息**不是闸:灯灭不拦起单,文案直说
「抽不出的键由受理补全单找人收齐」。
5 条受理归一单测 + 全链实测:投一封只写「想问下工控板价格」的信(零键)→
流照起 → A1 落负责人工位、等人、补全卡在对话流 → 答卡(customerCode/rfqRef)→
任务醒、合并、谱跑 3 步到验收。修一处接线错误:监听 deadline 要 ISO-8601
时长(P2D)不是时刻,传错整个补全监听静默没装上。
trigger/task/process 三份 CONTRACT.md 与产品设计.md 同步(宽进纪律成文)。
五闸绿;契约快照随本提交入库。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
667 lines
27 KiB
TypeScript
667 lines
27 KiB
TypeScript
/**
|
|
* 仿真试跑台 —— 浅色桌面上的**一台仪器**。
|
|
*
|
|
* 这里检测的是**后端的活动与流程**,不是这一屏自己:起单走的是流程包的调用接口
|
|
* (`POST /api/gov/testbed/run`),与触发器起单同一条路;起出来的是**真任务**,
|
|
* 照旧落到被指派人的工作台上。测试台不代答 —— 它只管两件事:
|
|
* ① 把流跑起来(装配态) ② 看穿它怎么跑的(观测态)
|
|
*
|
|
* 视觉语言分两个世界:**机器说 mono**(读数、灯、编号、TRACE),
|
|
* **那封信说无衬线** —— 它是外界唯一伸进这台仪器的东西。
|
|
* 戏眼是左栏的「抽键预检」读数屏:通道的正则**实时**跑在正打的字上
|
|
* (与服务端同一套规则,契约原文回传,不另编一套)。
|
|
* 预检是**信息**,不是闸(宽进,2026-07-30):抽不全照样起 —— 缺的键进活动后
|
|
* 由受理归一派补全单收齐;灯只告诉你「哪些会当场带上、哪些要有人补」。
|
|
*/
|
|
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 { 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 { 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';
|
|
|
|
/* ==================== 启动输入:一封信 ==================== */
|
|
|
|
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();
|
|
const chanQ = useGovChannels();
|
|
|
|
/** 只列有生效版的:同一个包可能存着多版,能起单的只有 `active` 那一版 */
|
|
const livePkgs = useMemo<ProcessPackage[]>(
|
|
() => (pkgQ.data?.packages ?? []).filter((p) => p.state === 'active'),
|
|
[pkgQ.data],
|
|
);
|
|
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 mailChannels = (chanQ.data?.channels ?? []).filter((ch) => ch.kind === 'mail' && ch.enabled);
|
|
|
|
const [pickedId, setPicked] = useState('');
|
|
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);
|
|
/*
|
|
* **一次只跑一条**:刷新页面也认得回来 —— 不然仿真在后台跑着,界面上却像没起过,
|
|
* 人会再起一单(而上一单的任务还在别人工作台上挂着)。
|
|
*/
|
|
const current = useQuery({
|
|
queryKey: ['gov', 'testbed', 'current'],
|
|
queryFn: () => call<{ workflowId?: string; subject?: string }>(testCurrentRoute),
|
|
});
|
|
const running = workflowId ?? current.data?.workflowId;
|
|
|
|
/** 停止仿真 = **销毁**:工作流、活动任务、工单、实例、trace 一并拔掉 */
|
|
const stop = useMutation({
|
|
mutationFn: (id: string) => call<{ destroyed: boolean; tasks: number }>(testStopRoute, { body: { workflowId: id } }),
|
|
onSuccess: () => {
|
|
setWorkflowId(undefined);
|
|
run.reset();
|
|
void queryClient.invalidateQueries({ queryKey: ['gov', 'testbed'] });
|
|
void queryClient.invalidateQueries({ queryKey: ['desk'] });
|
|
},
|
|
});
|
|
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 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 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> }) =>
|
|
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 || channel === undefined) return;
|
|
run.mutate({
|
|
packageId: pkg.id,
|
|
mail: { ...mail, to: channel.address },
|
|
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)} />
|
|
);
|
|
}
|
|
|
|
const pinned = Object.values(assignments).filter((v) => v !== '').length;
|
|
const ai = staff.find((s) => s.name === 'AI');
|
|
|
|
return (
|
|
<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="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>
|
|
</div>
|
|
<div className="tb-pks">
|
|
{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>
|
|
<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">
|
|
<button className="tb-ghost" onClick={() => setMail(SAMPLE_MAIL)}>
|
|
<Ico name="rotate-ccw" s={10} />
|
|
重置样例
|
|
</button>
|
|
</span>
|
|
</div>
|
|
<LetterForm draft={mail} onChange={setMail} channels={mailChannels} channel={channel} />
|
|
|
|
<div className="g-rig-h">
|
|
<span className="n">02</span>
|
|
节点指派
|
|
<span className="x">
|
|
{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="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>
|
|
|
|
{/* 起单条:仪器的前面板边缘 —— 全屏唯一的一枚动作钮,灯全亮才上膛 */}
|
|
<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>
|
|
)}
|
|
</QueryGate>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ==================== ① 一封信 ==================== */
|
|
|
|
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 (
|
|
<div className="tb-ml">
|
|
<label className="tb-ml-r">
|
|
<em>发件人</em>
|
|
<input className="tb-ml-i" value={draft.from} onChange={(e) => set('from', e.target.value)} />
|
|
</label>
|
|
<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="tb-ml-r">
|
|
<em>主题</em>
|
|
<input className="tb-ml-i" value={draft.subject} onChange={(e) => set('subject', e.target.value)} />
|
|
</label>
|
|
<label className="tb-ml-r tall">
|
|
<em>正文</em>
|
|
<textarea className="tb-ml-i" rows={4} value={draft.body} onChange={(e) => set('body', e.target.value)} />
|
|
</label>
|
|
<div className="tb-ml-r tall">
|
|
<em>附件</em>
|
|
<div className="tb-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>
|
|
);
|
|
}
|
|
|
|
/* ==================== 观测态 ==================== */
|
|
|
|
const LAMP_OF: Readonly<Record<string, 'live' | 'wait' | 'done' | 'bad'>> = {
|
|
running: 'live',
|
|
waiting: 'wait',
|
|
completed: 'done',
|
|
abnormal: 'bad',
|
|
};
|
|
|
|
function Observe({
|
|
id,
|
|
staffName,
|
|
nav,
|
|
onBack,
|
|
stopping,
|
|
}: {
|
|
id: string;
|
|
staffName: (sid: string) => string;
|
|
nav: Nav;
|
|
onBack: () => void;
|
|
stopping: boolean;
|
|
}): ReactNode {
|
|
const wf = useRunWorkflow(id);
|
|
const state = wf.data?.workflow.state;
|
|
// 到终态就停轮询:已经不动的东西没必要每两秒问一遍
|
|
const live = isLive(state);
|
|
const trQ = useRunTrace(id, live);
|
|
|
|
const spans = trQ.data?.spans ?? [];
|
|
const w = wf.data;
|
|
const nodeLabel = (nid: string): string => w?.pkg.nodes.find((n) => n.id === nid)?.label ?? nid;
|
|
/** 节点实例出参里没有受理人,受理人只在 span 的 attributes 上 —— 从 TRACE 反查 */
|
|
const assigneeOfNode = (nid: string): string | undefined => {
|
|
const s = spans.find((x) => x.kind === 'node' && x.nodeId === nid);
|
|
const a = s?.attributes?.['assigneeId'];
|
|
return typeof a === 'string' ? staffName(a) : undefined;
|
|
};
|
|
|
|
const started = w === undefined ? undefined : new Date(w.workflow.startedAt);
|
|
|
|
return (
|
|
<div className="g-tb">
|
|
<Strip
|
|
lamp={state === undefined ? 'live' : (LAMP_OF[state] ?? 'live')}
|
|
breathe={live}
|
|
word={state === undefined ? '取数中' : (WORKFLOW_STATE_LABEL[state] ?? state)}
|
|
detail={
|
|
<>
|
|
{w?.workflow.subject ?? id.slice(0, 8)}
|
|
{live && <i className="auto"> · 自动刷新 2s</i>}
|
|
</>
|
|
}
|
|
right={
|
|
<button className="tb-stop" disabled={stopping} onClick={onBack}>
|
|
<Ico name="square" s={11} />
|
|
{stopping ? '销毁中…' : '停止仿真'}
|
|
</button>
|
|
}
|
|
/>
|
|
|
|
<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>
|
|
<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>
|
|
|
|
<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>
|
|
{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>
|
|
);
|
|
}
|