三面拆分⑬:流程包纯编排化 —— 编排与传递契约归包,实现方式归库,激活看双判据

模型(用户定):流程包只决定活动编排 + 活动传递的契约,不决定活动的实现方式;
节点只分活动点与审批点;图只定名称与顺序,属性在卡上填,链接好实现即可运行;
激活 = ①Mastra 式图编排闭合完善 + ②活动装载,两面判据。

schema(库经外科手术清空,无历史包袱,网关彻底出局):
- contracts:删 gateway 节点变体 / GATEWAY_KINDS / GATEWAY_KIND_LABEL /
  'gateway-deprecated' 违规项 —— 节点四种:start/end(结构件)+ activity/approval
- routing:joinReady 整个删除(多入边即异或汇合,没有「等回齐」;AND 待并行组);
  selectEdges 精简;closure 删 checkGateway;process-service 删汇合等待与网关直传,
  节点一律只收装配后载荷(verdict/outcome 判据由系统注入,不走映射)

编排面(画布 = 名称 + 顺序,其余在属性卡):
- 元素盘五件:活动点(单一,不再分标准/案例 —— 那是实现方式)/审批点/起止/卡点
- 属性卡:节点类型二分(活动点/审批点);新增「链接活动实现」选择器(装载面,
  FlowNode.act 经 merge 成为语义 activityId);范式/版本降为只读装载信息
- 边属性可填:条件(S-FEEL)+ 映射(上游输出口/起单载荷/常量三种来源;
  workflow-var/expression 归系统隐性,不进设计者面)—— FlowEdge.condition/mapping
  工作副本,pkg-to-graph 回填、graph-to-pkg 合并,清空条件即变默认兜底边
- 异常看板 → 激活双判据看板:图编排(结构+属性+契约)与活动装载(unknown-activity)
  分面亮灯,两面清零激活才亮
- pkg-to-graph 去 paradigms 绿化与网关分支;图例/文案全线「活动点/审批点」

测试:closure 27(joinReady 两组 describe 随机制摘除)、advance-order 按真图形状重写
(去 as never 捏造的网关节点)、graph-to-pkg 9(新增 act 合并 + 边契约合并/清空用例)、
seed 8、端到端八例真库全绿;五闸退出码验过(api:check 提交后转绿)。

库:purge 后重种,唯一 unified-quote@v1 生效(10 节点 14 边,双面判据齐)。
债:M5-5 清账(兼容路径连 schema 一起摘);M5-4(并行组)仍挂。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-28 18:29:32 +08:00
co-authored by Claude Fable 5
parent 6854524784
commit 281f4ef084
22 changed files with 458 additions and 634 deletions
@@ -10,7 +10,7 @@
* · 一条流恰一个生效版本;可把生效切回旧版,也可全部停用让整条流下线
* · 在途工作流按启动时钉住的版本跑完 —— 启停对它们零影响
*/
import { useMemo, useRef, useState, useEffect } from 'react';
import { useRef, useState, useEffect } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import type { ClosureReport, ProcessPackage, ProcessNode } from '@contracts';
import { processPackageSchema, saveProcessRoute, setProcessStateRoute, validateProcessRoute } from '@contracts';
@@ -39,7 +39,7 @@ import type { GovActivities } from '@web-shared/gov/gov-api.ts';
import { FlowFigureBlock } from '@web-shared/gov/flow/FlowFigure.tsx';
import { packageToGraph } from '@web-shared/gov/flow/pkg-to-graph.ts';
import { mergeGraphIntoPackage } from '@web-shared/gov/flow/graph-to-pkg.ts';
import type { FlowGraph, FlowNode } from '@web-shared/gov/flow/flow-layout.ts';
import type { FlowEdge, FlowGraph, FlowNode } from '@web-shared/gov/flow/flow-layout.ts';
import type { SpPosition } from '@web-shared/gov/flow/support.tsx';
import { TriggerRulesHome } from '@features/gov-trigger';
@@ -66,6 +66,151 @@ function flowsOf(packages: readonly ProcessPackage[]) {
});
}
/* ── 边属性:传递契约(条件 + 映射)──────────────────────────────────── */
/** 卡点 id → 去向节点 id(边属性按语义对端认亲:A5→c_reject 即 A5→A4) */
function calloutToOf(graph: FlowGraph | null): Map<string, string> | undefined {
if (graph === null) return undefined;
return new Map(
graph.nodes.filter((n) => n.kind === 'callout' && n.to !== undefined).map((n) => [n.id, n.to!]),
);
}
/** 映射行的工作形状 —— 语义边 mapping 的元素;设计者只见三种来源,其余是系统隐性上下文 */
type MapRow = { target: string; source: Record<string, unknown> & { from: string } };
const SOURCE_KINDS = [
{ k: 'output-port', label: '上游输出口' },
{ k: 'initiation', label: '起单载荷' },
{ k: 'constant', label: '常量' },
] as const;
function EdgeProps({
edge,
toSem,
onPatch,
}: {
edge: FlowEdge;
toSem: string;
onPatch: (f: (e: FlowEdge) => FlowEdge) => void;
}) {
const rows = (edge.mapping ?? []) as MapRow[];
const setRows = (next: MapRow[]) => onPatch((e) => ({ ...e, mapping: next }));
const patchRow = (i: number, f: (r: MapRow) => MapRow) => setRows(rows.map((r, j) => (j === i ? f(r) : r)));
return (
<div className="g-props">
<Prop ico="git-branch" title={`条件 · ${edge.from} → ${toSem}`} extra="多出边异或:命中即走;留空 = 默认兜底">
<Field k="S-FEEL 条件" hint="如 needSourcing = true;可用 and / or 连接。留空即无条件(默认边,至多一条)">
<input
className="g-input mono"
value={edge.condition ?? ''}
placeholder="留空 = 默认兜底"
onChange={(e) => onPatch((x) => ({ ...x, condition: e.target.value }))}
/>
</Field>
<Field k="边标签" hint="画在线旁的注记,辅助阅读,不参与判断">
<input
className="g-input"
value={edge.label ?? ''}
placeholder="如:需要选型"
onChange={(e) =>
onPatch((x) => {
const { label: _drop, ...rest } = x;
return e.target.value === '' ? rest : { ...rest, label: e.target.value };
})
}
/>
</Field>
</Prop>
<Prop ico="git-merge" title="映射 · 载荷装配" extra="下游字段 ← 来源;必填映射齐了这条边才闭合">
{rows.map((r, i) => {
const known = SOURCE_KINDS.some((k) => k.k === r.source.from);
return (
<div className="g-maprow" key={i}>
<input
className="g-input mono"
value={r.target}
placeholder="下游字段"
onChange={(e) => patchRow(i, (x) => ({ ...x, target: e.target.value }))}
/>
<span className="g-maparr">←</span>
{known ? (
<select
className="g-select"
value={r.source.from}
onChange={(e) =>
patchRow(i, (x) => ({
...x,
source:
e.target.value === 'output-port'
? { from: 'output-port', port: '', path: '' }
: e.target.value === 'initiation'
? { from: 'initiation', path: '' }
: { from: 'constant', value: '' },
}))
}
>
{SOURCE_KINDS.map((k) => (
<option key={k.k} value={k.k}>
{k.label}
</option>
))}
</select>
) : (
// workflow-var / expression 等是系统侧来源 —— 保留展示,不给编辑入口
<Chip tone="mute">{String(r.source.from)}(系统)</Chip>
)}
{r.source.from === 'output-port' && (
<>
<input
className="g-input mono"
value={String(r.source['port'] ?? '')}
placeholder="口名"
onChange={(e) => patchRow(i, (x) => ({ ...x, source: { ...x.source, port: e.target.value } }))}
/>
<input
className="g-input mono"
value={String(r.source['path'] ?? '')}
placeholder="字段路径"
onChange={(e) => patchRow(i, (x) => ({ ...x, source: { ...x.source, path: e.target.value } }))}
/>
</>
)}
{r.source.from === 'initiation' && (
<input
className="g-input mono"
value={String(r.source['path'] ?? '')}
placeholder="起单字段路径"
onChange={(e) => patchRow(i, (x) => ({ ...x, source: { ...x.source, path: e.target.value } }))}
/>
)}
{r.source.from === 'constant' && (
<input
className="g-input mono"
value={String(r.source['value'] ?? '')}
placeholder="常量值"
onChange={(e) => patchRow(i, (x) => ({ ...x, source: { ...x.source, value: e.target.value } }))}
/>
)}
<button className="g-btn" onClick={() => setRows(rows.filter((_, j) => j !== i))}>
<Ico name="x" s={12} />
</button>
</div>
);
})}
<Acts
items={[
{
label: '加一条映射',
ico: 'plus',
onClick: () => setRows([...rows, { target: '', source: { from: 'output-port', port: '', path: '' } }]),
},
]}
/>
</Prop>
</div>
);
}
/* ---------------- 台面:总览 · 流程包 · 触发器 ---------------- */
export function WorkflowsHome({ tab, nav }: HomeProps) {
@@ -218,7 +363,6 @@ function FlowShelf({ nav }: { nav: Nav }) {
const c = {
activity: shown.nodes.filter((n) => n.kind === 'activity').length,
approval: shown.nodes.filter((n) => n.kind === 'approval').length,
gateway: shown.nodes.filter((n) => n.kind === 'gateway').length,
};
return (
<button
@@ -252,7 +396,7 @@ function FlowShelf({ nav }: { nav: Nav }) {
审批 {c.approval}
</Chip>
<Chip tone="mute" ico="git-branch">
网关 {c.gateway}
边 {shown.edges.length}
</Chip>
</span>
<span className="g-tile-f">
@@ -351,11 +495,11 @@ function FlowLab({
* 任何版本都可编辑:保存恒存为**未生效**的新版本,谁也伤不着 ——
* 在旧版上改就是「以旧版为底另起一版」。生效切换只在激活那一下。
*/
const paradigms = useMemo(() => new Map(activities.map((a) => [a.activityId, a.paradigm])), [activities]);
/** 画布工作副本:属性卡与手绘编辑都改它;保存时合并回流程包 */
const [graph, setGraph] = useState<FlowGraph | null>(() => packageToGraph(pkg, [], paradigms));
const [graph, setGraph] = useState<FlowGraph | null>(() => packageToGraph(pkg, []));
const [sel, setSel] = useState<string | null>(null);
/** 边选中态:graph.edges 的下标(与节点选中互斥 —— 属性卡一次编辑一件事) */
const [selEdge, setSelEdge] = useState<number | null>(null);
const [dirty, setDirty] = useState(false);
const [report, setReport] = useState<ClosureReport | undefined>(undefined);
const [ask, setAsk] = useState<{ spec: AskSpec; danger?: boolean } | undefined>(undefined);
@@ -369,7 +513,8 @@ function FlowLab({
? undefined
: activities.find(
(a) =>
(semanticNode?.kind === 'activity' && a.activityId === semanticNode.activityId) || a.name === node.label,
(node.act !== undefined && a.activityId === node.act) ||
(node.act === undefined && semanticNode?.kind === 'activity' && a.activityId === semanticNode.activityId),
);
// 每次进页问一次闭合 —— 画图即校验(与后端裁决同源);落笔保存后用返回的报告
@@ -390,28 +535,14 @@ function FlowLab({
n.kind === 'activity'
? n.flavor === 'approval'
? '审批点'
: n.flavor === 'case'
? '案例活动 · loop'
: '标准活动 · flow'
: n.kind === 'gateway'
? n.mode === 'and'
? '并行网关'
: '判断网关'
: n.kind === 'callout'
? '卡点'
: n.kind === 'start'
? '调用接口'
: '显式终点';
const kindTone = (n: FlowNode) =>
n.kind === 'activity'
? n.flavor === 'approval'
? 'warn'
: n.flavor === 'case'
? 'collab'
: 'auto'
: '活动点'
: n.kind === 'callout'
? 'danger'
: 'mute';
? '卡点'
: n.kind === 'start'
? '调用接口'
: '显式终点';
const kindTone = (n: FlowNode) =>
n.kind === 'activity' ? (n.flavor === 'approval' ? 'warn' : 'auto') : n.kind === 'callout' ? 'danger' : 'mute';
// 画布上的选中态:diagram-js 给每个形状挂了 data-element-id,外挂一个类,不改画布件
useEffect(() => {
@@ -694,16 +825,16 @@ function FlowLab({
<FlowFigureBlock
key={`${pkg.id}@${pkg.version}`}
title={`${pkg.name} v${pkg.version}`}
oneLiner={`蓝 = 标准活动 · 绿 = 案例活动 · 琥珀 = 审批点 · 红 = 卡点`}
oneLiner={`蓝 = 活动点 · 琥珀 = 审批点 · 红 = 卡点`}
graph={graph}
drilledIds={nodes.filter((n) => n.kind === 'activity').map((n) => n.id)}
/*
* 画布只画**编排逻辑**:节点与网关的名字、连线;边上的字是辅助阅读的注记。
* 画布只画**编排逻辑**:节点的名字、连线的顺序;边上的字是辅助阅读的注记。
* 图上真正生效可编辑的只有**名字** —— 不传 positions,双击就不出岗位一栏。
* 岗位、判据、映射这些闭环要素在**节点属性**里决定;分配到人是运营的事,这里不碰。
*/
canEdit
onNodeClick={(nid) => setSel(nid)}
onNodeClick={(nid) => { setSel(nid); setSelEdge(null); }}
onEditNodeText={async (nid, p2) => {
setGraph((g) =>
g === null
@@ -727,39 +858,59 @@ function FlowLab({
* 清零了激活按钮才亮 —— 完整性闸在激活,不在画图。
*/}
<aside className="g-vercard g-exboard">
<div className="g-vercard-h">异常 · 看板</div>
<div className="g-vercard-h">激活 · 双判据</div>
<div className="g-vercard-list">
{activeReport === undefined ? (
<div className="g-exboard-empty">校验中…</div>
) : activeReport.violations.length === 0 ? (
<div className="g-exboard-empty">
没有异常 —— v{pkg.version} 的编排闭合了。
{pkg.version === activeVersion ? '这一版正在生效。' : '版本行的「激活」已亮,点它即可上线。'}
</div>
) : (
activeReport.violations.map((v, i) => {
const nid = v.nodeId ?? pkg.edges.find((e) => e.id === v.edgeId)?.to;
(() => {
/* 能不能激活看两面:①图编排闭合完善 ②活动装载。unknown-activity 归装载,其余归编排。 */
const loading = activeReport.violations.filter((v) => v.kind === 'unknown-activity');
const drawing = activeReport.violations.filter((v) => v.kind !== 'unknown-activity');
const row = (v: (typeof activeReport.violations)[number], i: number) => {
const nid = v.nodeId ?? pkg.edges.find((e) => e.id === v.edgeId)?.to;
return (
<div
key={`${v.kind}-${i}`}
className="g-ver g-ex"
role="button"
tabIndex={0}
onClick={() => {
if (nid !== undefined) { setSel(String(nid)); setSelEdge(null); }
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && nid !== undefined) { setSel(String(nid)); setSelEdge(null); }
}}
>
{nid !== undefined && <b>{String(nid)}</b>}
<span className="msg">{v.message}</span>
</div>
);
};
return (
<div
key={`${v.kind}-${i}`}
className="g-ver g-ex"
role="button"
tabIndex={0}
onClick={() => {
if (nid !== undefined) setSel(String(nid));
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && nid !== undefined) setSel(String(nid));
}}
>
{nid !== undefined && <b>{String(nid)}</b>}
<span className="msg">{v.message}</span>
</div>
<>
<div className={'g-exfacet' + (drawing.length === 0 ? ' ok' : '')}>
<Ico name={drawing.length === 0 ? 'circle-check' : 'triangle-alert'} s={12} />
图编排 {drawing.length === 0 ? '闭合' : `${drawing.length} 项待补`}
</div>
{drawing.map(row)}
<div className={'g-exfacet' + (loading.length === 0 ? ' ok' : '')}>
<Ico name={loading.length === 0 ? 'circle-check' : 'triangle-alert'} s={12} />
活动装载 {loading.length === 0 ? '齐' : `${loading.length} 个节点未链接实现`}
</div>
{loading.map(row)}
{activeReport.violations.length === 0 && (
<div className="g-exboard-empty">
两面都齐 —— v{pkg.version} 可运行。
{pkg.version === activeVersion ? '这一版正在生效。' : '版本行的「激活」已亮,点它即可上线。'}
</div>
)}
</>
);
})
})()
)}
</div>
<div className="g-vercard-f">点一条异常,定位到它的节点属性;清零后才可激活。</div>
<div className="g-vercard-f">点一条定位到属性去补;两面清零,激活才亮。</div>
</aside>
{/* 右下:节点属性(图纸卡,与流程图同宽)—— 闭环(岗位/判据/映射)在这里决定 */}
@@ -791,16 +942,47 @@ function FlowLab({
<button
key={n.id}
className={`g-pick t-${kindTone(n)}` + (sel === n.id ? ' on' : '')}
onClick={() => setSel(sel === n.id ? null : n.id)}
onClick={() => { setSel(sel === n.id ? null : n.id); setSelEdge(null); }}
>
<b className="g-mono">{n.id}</b>
{n.label}
</button>
))}
</div>
{node === null ? (
<Note>点画布上的节点(或上面的节点条)选一个 —— 属性就在下面填,网关判据与卡点去向同样在此改。</Note>
) : (
{/* 边拣选条:边属性 = 传递契约(条件 + 映射)—— 图只定顺序,契约在这儿填 */}
<div className="g-picker">
{(graph?.edges ?? []).map((e, i) => {
const toSem = calloutToOf(graph)?.get(e.to) ?? e.to;
const brief = e.condition !== undefined && e.condition !== '' ? ` [${e.condition}]` : '';
return (
<button
key={`${e.from}→${e.to}-${i}`}
className={'g-pick t-mute' + (selEdge === i ? ' on' : '')}
onClick={() => { setSelEdge(selEdge === i ? null : i); setSel(null); }}
>
<b className="g-mono">{e.from}→{toSem}</b>
{(e.label ?? '') + brief}
</button>
);
})}
</div>
{node === null && selEdge === null ? (
<Note>
点画布或上面的拣选条选一件 —— 节点填岗位与链接实现,边填传递契约(条件 + 映射)。
审批裁决(verdict)与放行选口(outcome)由系统注入判据,不用你声明。
</Note>
) : node === null && selEdge !== null && graph !== null ? (
<EdgeProps
edge={graph.edges[selEdge]!}
toSem={calloutToOf(graph)?.get(graph.edges[selEdge]!.to) ?? graph.edges[selEdge]!.to}
onPatch={(f) => {
setGraph((g) =>
g === null ? g : { ...g, edges: g.edges.map((e, i) => (i === selEdge ? f(e) : e)) },
);
setDirty(true);
}}
/>
) : node === null ? null : (
<>
<div className="g-props">
<Prop ico="tag" title="身份与命名" extra={kindLabel(node)}>
@@ -841,34 +1023,21 @@ function FlowLab({
}
>
{node.kind === 'activity' && (
<Field k="活动类型" hint="决定它在运行时长成什么">
<Field k="节点类型" hint="节点只分两种 —— 实现方式是活动库的事,这里不选">
<Seg
value={node.flavor ?? 'flow'}
value={node.flavor === 'approval' ? 'approval' : 'act'}
options={[
{ k: 'flow', label: '标准 · flow' },
{ k: 'case', label: '案例 · loop' },
{ k: 'act', label: '活动点' },
{ k: 'approval', label: '审批点' },
]}
onPick={(k) =>
patch((n) => ({ ...n, flavor: k === 'flow' ? undefined : (k as 'case' | 'approval') }))
patch((n) => ({ ...n, flavor: k === 'approval' ? ('approval' as const) : undefined }))
}
/>
</Field>
)}
{node.kind === 'gateway' && (
<Field k="网关模式" hint="× 判断分流 / + 并行同走">
<Seg
value={node.mode ?? 'x'}
options={[
{ k: 'x', label: '判断 ×' },
{ k: 'and', label: '并行 +' },
]}
onPick={(k) => patch((n) => ({ ...n, mode: k as 'x' | 'and' }))}
/>
</Field>
)}
<Field
k={node.kind === 'gateway' ? '判定岗位' : node.flavor === 'approval' ? '审批岗位' : '责任岗位'}
k={node.flavor === 'approval' ? '审批岗位' : '责任岗位'}
hint={node.flavor === 'approval' ? '审批单派给它' : '任务的负责人由它解析而来'}
>
<select
@@ -919,19 +1088,6 @@ function FlowLab({
/>
</Prop>
)}
{node.kind === 'gateway' && (
<Prop ico="shield-check" title="判据" extra="条件用 S-FEEL 子集,静态可检">
<textarea
className="g-ta"
rows={3}
value={node.basis ?? ''}
placeholder="如:台账 90 天内有成交锚点则直接定价,否则先寻源"
onChange={(e) =>
patch((n) => ({ ...n, basis: e.target.value === '' ? undefined : e.target.value }))
}
/>
</Prop>
)}
{node.kind === 'callout' && (
<Prop ico="rotate-ccw" title="去向" extra="失败走向 / 驳回去向">
<Field k="回到节点">
@@ -955,15 +1111,29 @@ function FlowLab({
</Prop>
)}
{node.kind === 'activity' && node.flavor !== 'approval' && (
<Prop ico="boxes" title="引用的活动" extra="活动住库,编排只引用">
<Prop ico="boxes" title="链接活动实现" extra="装载面:编排不含实现,链接决定可运行">
<Field k="实现" hint="活动住库;链好了这节点才算装载(激活判据之二)">
<select
className="g-select"
value={refAct?.activityId ?? ''}
onChange={(e) =>
patch((n) => ({ ...n, act: e.target.value === '' ? undefined : e.target.value }))
}
>
<option value="">未链接 —— 激活会被「活动装载」拦下</option>
{activities.map((a) => (
<option key={a.activityId} value={a.activityId}>
{a.name} · {a.activityId}
</option>
))}
</select>
</Field>
{refAct !== undefined ? (
<>
{/* 实现方式(范式/版本)是装载信息,只读展示 —— 编排面不决定它 */}
<div className="g-prop-ref">
<Chip
tone={refAct.paradigm === 'loop' ? 'collab' : 'auto'}
ico={refAct.paradigm === 'loop' ? 'repeat' : 'git-commit'}
>
{refAct.paradigm === 'loop' ? '案例活动 loop' : '标准活动 flow'}
<Chip tone={refAct.paradigm === 'loop' ? 'collab' : 'auto'} ico="git-commit">
{refAct.paradigm === 'loop' ? '实现:案例 loop' : '实现:标准 flow'}
</Chip>
<Chip tone="mute">
v{refAct.packageVersions[0]?.version ?? '—'} × a
@@ -979,7 +1149,7 @@ function FlowLab({
</button>
</>
) : (
<Note tone="warn">还没对上活动库里已安装的活动 —— 先去活动库安装;保存时会核对契约相容。</Note>
<Note tone="warn">还没链接活动库里已安装的实现 —— 图可以照画照存,激活前链上即可。</Note>
)}
</Prop>
)}