模型(用户定):流程包只决定活动编排 + 活动传递的契约,不决定活动的实现方式; 节点只分活动点与审批点;图只定名称与顺序,属性在卡上填,链接好实现即可运行; 激活 = ①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
1167 lines
50 KiB
TypeScript
1167 lines
50 KiB
TypeScript
/**
|
||
* feature `gov-process` —— 流程包(设计态):**一条流一个包,版本收在包内部**。
|
||
*
|
||
* 台面三页签:总览 · 流程包 · 触发器。
|
||
* 货架一流程一卡(不按版本平铺);进详情后左 1/3 是版本列表、右 2/3 是流程图,
|
||
* 图下是节点属性,再往下才是声明式的契约细节(调用接口 · 边映射 · 触发器)。
|
||
*
|
||
* 版本是**运行时开关**,不是发布流程:
|
||
* · 画图不检查:保存 = 存为新版,**不生效**;激活才跑闭合校验(异常清零方可)
|
||
* · 一条流恰一个生效版本;可把生效切回旧版,也可全部停用让整条流下线
|
||
* · 在途工作流按启动时钉住的版本跑完 —— 启停对它们零影响
|
||
*/
|
||
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';
|
||
import { call } from '@web-shared/typed-fetch.ts';
|
||
import { Ico } from '@web-shared/Ico.tsx';
|
||
import type { AskSpec } from '@web-shared/gov/primitives.tsx';
|
||
import {
|
||
Acts,
|
||
AskBand,
|
||
Card,
|
||
Chip,
|
||
Field,
|
||
Kpi,
|
||
ListEdit,
|
||
Note,
|
||
PageHead,
|
||
Prop,
|
||
QueryGate,
|
||
Sec,
|
||
Seg,
|
||
St,
|
||
} from '@web-shared/gov/primitives.tsx';
|
||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||
import { useGovActivities, useGovPackages, useGovPkgReports, useGovStaff } from '@web-shared/gov/gov-api.ts';
|
||
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 { 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';
|
||
|
||
export type HomeProps = { tab: string; nav: Nav };
|
||
|
||
/* ── 报告:画图即校验,列表与详情同一来源 ─────────────────────────────── */
|
||
|
||
/** 边闭合数:边总数 − 有违规的边(同一条边多处违规只记一次) */
|
||
function edgeCheckOf(pkg: ProcessPackage, report: ClosureReport | undefined) {
|
||
const total = pkg.edges.length;
|
||
if (report === undefined) return { closed: total, total, issue: undefined };
|
||
const badEdges = new Set(report.violations.filter((v) => v.edgeId !== undefined).map((v) => v.edgeId));
|
||
const issue = report.violations[0]?.message;
|
||
return { closed: total - badEdges.size, total, issue };
|
||
}
|
||
|
||
/** 一条流的版本按新→旧;生效版恰一个(或零个 —— 整条流下线) */
|
||
function flowsOf(packages: readonly ProcessPackage[]) {
|
||
const byId = new Map<string, ProcessPackage[]>();
|
||
for (const p of packages) byId.set(p.id, [...(byId.get(p.id) ?? []), p]);
|
||
return [...byId.entries()].map(([id, versions]) => {
|
||
const sorted = [...versions].sort((a, b) => b.version - a.version);
|
||
return { id, versions: sorted, active: sorted.find((v) => v.state === 'active') };
|
||
});
|
||
}
|
||
|
||
/* ── 边属性:传递契约(条件 + 映射)──────────────────────────────────── */
|
||
|
||
/** 卡点 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) {
|
||
// 触发器的**定义面**:谁调起这条流,与流程包一处维护(启停在运营中心)
|
||
if (tab === 'rules') return <TriggerRulesHome nav={nav} />;
|
||
if (tab === 'pkgs') return <FlowShelf nav={nav} />;
|
||
return <OverviewHome />;
|
||
}
|
||
|
||
function OverviewHome() {
|
||
const q = useGovPackages();
|
||
const packages = q.data?.packages ?? [];
|
||
const reports = useGovPkgReports(packages);
|
||
const flows = flowsOf(packages);
|
||
const totalEdges = packages.reduce((a, p) => a + p.edges.length, 0);
|
||
const closedEdges = packages.reduce(
|
||
(a, p) => a + edgeCheckOf(p, reports.data?.get(`${p.id}@${p.version}`)).closed,
|
||
0,
|
||
);
|
||
return (
|
||
<QueryGate query={q}>
|
||
<div className="g-kpis">
|
||
<Kpi ico="workflow" tone="ok" label="流程" value={flows.length} sub="一条流恰一个生效版本" />
|
||
<Kpi ico="history" label="版本" value={packages.length} sub="保存恒新增 · 在途按启动版本跑完" />
|
||
<Kpi
|
||
ico="git-merge"
|
||
label="边闭合率"
|
||
value={totalEdges === 0 ? 100 : Math.round((closedEdges / totalEdges) * 100)}
|
||
unit="%"
|
||
sub="不闭合不得激活"
|
||
/>
|
||
{/* 网关已废除:分支 = 条件出边,不再是一种要素 */}
|
||
<Kpi ico="boxes" label="编排要素" value="四种" sub="活动 · 审批 · 触发器 · 流程包" />
|
||
</div>
|
||
<Note>
|
||
画图不检查,激活才查完整:随手画的都存得下(存出来不生效),异常清零后激活,
|
||
即刻对<b>新起的</b>工作流生效;在途的按启动时版本跑完。要回退,把生效切回旧版本 ——
|
||
一条流一次只有一个生效版本。
|
||
</Note>
|
||
</QueryGate>
|
||
);
|
||
}
|
||
|
||
/* ---------------- 货架:一条流一张卡 + 新建流程 ---------------- */
|
||
|
||
/**
|
||
* 新建的流程 = 一张**最小可生效**的图:开始 → 结束。
|
||
* 激活要过闭合校验,这张最小图天然闭合,建完即激活 —— 建出来就能起单
|
||
* (起了直接走完,没业务意义,但机制是通的);进详情画上活动再存,即成下一版。
|
||
*/
|
||
function minimalFlow(id: string, name: string): ProcessPackage {
|
||
return processPackageSchema.parse({
|
||
id,
|
||
name,
|
||
version: 1,
|
||
state: 'active',
|
||
callInterface: { input: { type: 'object' }, output: { type: 'object' } },
|
||
nodes: [
|
||
{ id: 'start', kind: 'start', label: '开始', layout: { row: 0, col: 0, x: 30, y: 70 } },
|
||
{ id: 'end', kind: 'end', label: '结束', layout: { row: 0, col: 1, x: 150, y: 70 } },
|
||
],
|
||
edges: [{ id: 'e-start-end', from: 'start', to: 'end', mapping: [], reopen: false }],
|
||
variables: [],
|
||
});
|
||
}
|
||
|
||
function FlowShelf({ nav }: { nav: Nav }) {
|
||
const q = useGovPackages();
|
||
const queryClient = useQueryClient();
|
||
const packages = q.data?.packages ?? [];
|
||
const reports = useGovPkgReports(packages);
|
||
const flows = flowsOf(packages);
|
||
|
||
const [creating, setCreating] = useState(false);
|
||
const [nfName, setNfName] = useState('');
|
||
const [nfId, setNfId] = useState('');
|
||
const [nfError, setNfError] = useState<string | undefined>(undefined);
|
||
// 标识进 URL 和触发器规则里引用,所以收紧:小写字母开头,只许小写/数字/连字符
|
||
const idOk = /^[a-z][a-z0-9-]{1,40}$/.test(nfId);
|
||
const idTaken = flows.some((f) => f.id === nfId);
|
||
|
||
const create = useMutation({
|
||
/*
|
||
* 保存不生效(存出来一律 retired)—— 新建要兑现「建出来就能起单」,
|
||
* 得跟一记激活。最小图天然闭合,激活闸必过;真有 412 就让它抛,onError 如实报。
|
||
*/
|
||
mutationFn: async () => {
|
||
const out = await call<{ pkg: ProcessPackage; report: ClosureReport }>(saveProcessRoute, {
|
||
body: { pkg: minimalFlow(nfId, nfName.trim()) },
|
||
});
|
||
await call<{ packages: ProcessPackage[] }>(setProcessStateRoute, {
|
||
params: { id: out.pkg.id, version: String(out.pkg.version) },
|
||
body: { state: 'active' },
|
||
});
|
||
return out;
|
||
},
|
||
onSuccess: (out) => {
|
||
void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] });
|
||
setCreating(false);
|
||
setNfName('');
|
||
setNfId('');
|
||
nav.push({ k: 'pkg', id: out.pkg.id });
|
||
},
|
||
onError: (e: unknown) => setNfError((e as { message?: string }).message ?? '建流程失败'),
|
||
});
|
||
|
||
return (
|
||
<QueryGate query={q}>
|
||
{creating ? (
|
||
<Card ico="workflow" title="新建流程" extra="建出来就是 v1 并即刻生效 —— 一张「开始 → 结束」的最小图,进详情再画">
|
||
<div className="g-newflow">
|
||
<Field k="流程名称" hint="人读的名字,如「统一报价」">
|
||
<input className="g-input" value={nfName} placeholder="如:统一报价" onChange={(e) => setNfName(e.target.value)} />
|
||
</Field>
|
||
<Field k="标识" hint="进 URL 与触发器规则里引用 —— 小写字母开头,只许小写 / 数字 / 连字符">
|
||
<input
|
||
className="g-input mono"
|
||
value={nfId}
|
||
placeholder="如:unified-quote"
|
||
onChange={(e) => setNfId(e.target.value.trim())}
|
||
/>
|
||
</Field>
|
||
{nfId !== '' && !idOk && <Note tone="warn">标识不合规:小写字母开头,只许小写、数字、连字符,2–41 位。</Note>}
|
||
{idTaken && <Note tone="warn">这个标识已有流程在用 —— 换一个,或直接进那条流改。</Note>}
|
||
{nfError !== undefined && <Note tone="warn">{nfError}</Note>}
|
||
<Acts
|
||
align="end"
|
||
items={[
|
||
{ label: '算了', ico: 'x', onClick: () => setCreating(false) },
|
||
{
|
||
label: create.isPending ? '建流程中…' : '建流程并进入',
|
||
ico: 'plus',
|
||
primary: true,
|
||
disabled: create.isPending || !idOk || idTaken || nfName.trim() === '',
|
||
onClick: () => create.mutate(),
|
||
},
|
||
]}
|
||
/>
|
||
</div>
|
||
</Card>
|
||
) : (
|
||
<Acts items={[{ label: '新建流程', ico: 'plus', primary: true, onClick: () => setCreating(true) }]} />
|
||
)}
|
||
|
||
<div className="g-shelf">
|
||
{flows.map((f) => {
|
||
/* 卡面统计取生效版;整条流下线时取最新版(看的还是定义,只是没在跑) */
|
||
const shown = f.active ?? f.versions[0]!;
|
||
const ec = edgeCheckOf(shown, reports.data?.get(`${shown.id}@${shown.version}`));
|
||
const c = {
|
||
activity: shown.nodes.filter((n) => n.kind === 'activity').length,
|
||
approval: shown.nodes.filter((n) => n.kind === 'approval').length,
|
||
};
|
||
return (
|
||
<button
|
||
key={f.id}
|
||
className={'g-tile t-' + (f.active !== undefined ? 'ok' : 'mute')}
|
||
onClick={() => nav.push({ k: 'pkg', id: f.id })}
|
||
>
|
||
<span className="g-tile-h">
|
||
<span className="g-tile-i">
|
||
<Ico name="workflow" s={16} />
|
||
</span>
|
||
<span className="g-tile-t">
|
||
<b>{shown.name}</b>
|
||
<em>{f.id}</em>
|
||
</span>
|
||
{f.active !== undefined ? (
|
||
<St tone="ok" ico="circle-check">
|
||
生效 v{f.active.version}
|
||
</St>
|
||
) : (
|
||
<St tone="warn" ico="pause">
|
||
已下线
|
||
</St>
|
||
)}
|
||
</span>
|
||
<span className="g-tile-m">
|
||
<Chip tone="auto" ico="git-commit">
|
||
活动 {c.activity}
|
||
</Chip>
|
||
<Chip tone="warn" ico="shield-check">
|
||
审批 {c.approval}
|
||
</Chip>
|
||
<Chip tone="mute" ico="git-branch">
|
||
边 {shown.edges.length}
|
||
</Chip>
|
||
</span>
|
||
<span className="g-tile-f">
|
||
<St tone={ec.closed === ec.total ? 'ok' : 'danger'} ico={ec.closed === ec.total ? 'circle-check' : 'alert-circle'}>
|
||
边 {ec.closed}/{ec.total}
|
||
</St>
|
||
<span className="r">{f.versions.length} 个版本</span>
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</QueryGate>
|
||
);
|
||
}
|
||
|
||
/* ---------------- 流程详情:版本列表 + 画布 + 节点属性 + 契约细节 ---------------- */
|
||
|
||
export function PkgPage({ id, nav }: { id: string; nav: Nav }) {
|
||
// 旧深链是 `id@version`;详情现在是**流程级**的,版本在页内切,所以只认 id 段
|
||
const flowId = id.split('@')[0] ?? id;
|
||
const q = useGovPackages();
|
||
const acts = useGovActivities();
|
||
const staffQ = useGovStaff();
|
||
|
||
const versions = (q.data?.packages ?? [])
|
||
.filter((p) => p.id === flowId)
|
||
.sort((a, b) => b.version - a.version);
|
||
/* 全部版本的闭合报告 —— 版本行的「激活」按钮据它灰显(不闭合点不了) */
|
||
const reports = useGovPkgReports(versions);
|
||
/* 选中哪一版看图:默认跟着生效版走;手动点过就钉住;保存后钉到**刚存的新版**(它未生效) */
|
||
const [pinned, setPinned] = useState<number | null>(null);
|
||
const active = versions.find((v) => v.state === 'active');
|
||
const shown = (pinned === null ? undefined : versions.find((v) => v.version === pinned)) ?? active ?? versions[0];
|
||
|
||
if (shown === undefined) {
|
||
return (
|
||
<QueryGate query={q}>
|
||
<Note>流程不存在。</Note>
|
||
</QueryGate>
|
||
);
|
||
}
|
||
return (
|
||
<FlowLab
|
||
key={`${shown.id}@${shown.version}`}
|
||
pkg={shown}
|
||
versions={versions}
|
||
activeVersion={active?.version}
|
||
nav={nav}
|
||
activities={acts.data?.activities ?? []}
|
||
positions={(staffQ.data?.positions ?? []).map((p) => ({ id: p.id, name: p.name, abbr: p.code }))}
|
||
nextVersion={Math.max(...versions.map((x) => x.version)) + 1}
|
||
closedOf={(v) => reports.data?.get(`${flowId}@${v}`)?.closed}
|
||
onPick={(v) => setPinned(v)}
|
||
onSaved={(v) => setPinned(v)}
|
||
/>
|
||
);
|
||
}
|
||
|
||
/** 本页要用的活动字段面 —— 与 `GovActivitiesOutput` 的一致子集(含契约面,导出依赖文件要它) */
|
||
type ActivityRow = {
|
||
activityId: string;
|
||
name: string;
|
||
paradigm: string;
|
||
packageVersions: ReadonlyArray<{ version: string }>;
|
||
assetVersions: ReadonlyArray<{ version: number; effective: boolean }>;
|
||
contract: GovActivities['activities'][number]['contract'];
|
||
};
|
||
|
||
function FlowLab({
|
||
pkg,
|
||
versions,
|
||
activeVersion,
|
||
nav,
|
||
activities,
|
||
positions,
|
||
nextVersion,
|
||
closedOf,
|
||
onPick,
|
||
onSaved,
|
||
}: {
|
||
pkg: ProcessPackage;
|
||
versions: readonly ProcessPackage[];
|
||
activeVersion: number | undefined;
|
||
nav: Nav;
|
||
activities: readonly ActivityRow[];
|
||
positions: SpPosition[];
|
||
nextVersion: number;
|
||
/** 该版本闭合与否(undefined = 报告还没回来)—— 激活按钮据此灰显 */
|
||
closedOf: (version: number) => boolean | undefined;
|
||
onPick: (version: number) => void;
|
||
onSaved: (version: number) => void;
|
||
}) {
|
||
const queryClient = useQueryClient();
|
||
/*
|
||
* 任何版本都可编辑:保存恒存为**未生效**的新版本,谁也伤不着 ——
|
||
* 在旧版上改就是「以旧版为底另起一版」。生效切换只在激活那一下。
|
||
*/
|
||
/** 画布工作副本:属性卡与手绘编辑都改它;保存时合并回流程包 */
|
||
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);
|
||
const canvasRef = useRef<HTMLDivElement>(null);
|
||
|
||
const nodes = graph?.nodes ?? [];
|
||
const node = nodes.find((n) => n.id === sel) ?? null;
|
||
const semanticNode: ProcessNode | undefined = pkg.nodes.find((n) => n.id === sel);
|
||
const refAct =
|
||
node === null
|
||
? undefined
|
||
: activities.find(
|
||
(a) =>
|
||
(node.act !== undefined && a.activityId === node.act) ||
|
||
(node.act === undefined && semanticNode?.kind === 'activity' && a.activityId === semanticNode.activityId),
|
||
);
|
||
|
||
// 每次进页问一次闭合 —— 画图即校验(与后端裁决同源);落笔保存后用返回的报告
|
||
const liveReport = useQuery({
|
||
queryKey: ['gov', 'process-report', pkg.id, pkg.version],
|
||
queryFn: () => call<ClosureReport>(validateProcessRoute, { body: { pkg } }),
|
||
});
|
||
const activeReport = report ?? liveReport.data;
|
||
const ec = edgeCheckOf(pkg, activeReport);
|
||
|
||
const patch = (f: (n: FlowNode) => FlowNode) => {
|
||
if (sel === null) return;
|
||
setGraph((g) => (g === null ? g : { ...g, nodes: g.nodes.map((n) => (n.id === sel ? f(n) : n)) }));
|
||
setDirty(true);
|
||
};
|
||
|
||
const kindLabel = (n: FlowNode) =>
|
||
n.kind === 'activity'
|
||
? n.flavor === 'approval'
|
||
? '审批点'
|
||
: '活动点'
|
||
: n.kind === 'callout'
|
||
? '卡点'
|
||
: 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(() => {
|
||
const root = canvasRef.current;
|
||
if (root === null) return;
|
||
root.querySelectorAll('.flow-node-sel').forEach((el) => el.classList.remove('flow-node-sel'));
|
||
if (sel !== null) root.querySelector(`[data-element-id="${sel}"]`)?.classList.add('flow-node-sel');
|
||
}, [sel, graph]);
|
||
|
||
const [saveErr, setSaveErr] = useState<string | undefined>(undefined);
|
||
const save = useMutation({
|
||
mutationFn: (input: { graph: FlowGraph; idMap: Record<string, string> }) =>
|
||
call<{ pkg: ProcessPackage; report: ClosureReport }>(saveProcessRoute, {
|
||
body: { pkg: mergeGraphIntoPackage(pkg, input.graph, input.idMap) },
|
||
}),
|
||
onSuccess: (out) => {
|
||
setReport(out.report);
|
||
setDirty(false);
|
||
setSaveErr(undefined);
|
||
void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] });
|
||
void queryClient.invalidateQueries({ queryKey: ['gov', 'process-reports'] });
|
||
// 存下来的是未生效的新版 —— 钉过去接着画;异常看板会列它还缺什么
|
||
onSaved(out.pkg.version);
|
||
},
|
||
onError: (e: unknown) => setSaveErr((e as { message?: string }).message ?? '保存失败'),
|
||
});
|
||
|
||
/** 版本启停:运行时开关,不改定义 —— 在途按启动版本跑完,零影响 */
|
||
const setState = useMutation({
|
||
mutationFn: (input: { version: number; state: 'active' | 'retired' }) =>
|
||
call<{ packages: ProcessPackage[] }>(setProcessStateRoute, {
|
||
params: { id: pkg.id, version: String(input.version) },
|
||
body: { state: input.state },
|
||
}),
|
||
onSuccess: () => {
|
||
void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] });
|
||
setAsk(undefined);
|
||
},
|
||
});
|
||
|
||
/**
|
||
* 新建版本 = **以当前看的版本为底克隆一版**(服务端定版号,**不生效**)。
|
||
* 与「激活」分工明确:激活只是把生效指针切过去,不产生新版本;
|
||
* 新建版本才长一版 —— 把旧定义复活成最新版、或在现状上另起一版接着画,都走这儿。
|
||
*/
|
||
const cloneVersion = useMutation({
|
||
mutationFn: () => call<{ pkg: ProcessPackage; report: ClosureReport }>(saveProcessRoute, { body: { pkg } }),
|
||
onSuccess: (out) => {
|
||
setReport(out.report);
|
||
void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] });
|
||
void queryClient.invalidateQueries({ queryKey: ['gov', 'process-reports'] });
|
||
setAsk(undefined);
|
||
onSaved(out.pkg.version);
|
||
},
|
||
});
|
||
|
||
const askClone = () =>
|
||
setAsk({
|
||
spec: {
|
||
title: `新建版本:以 v${pkg.version} 为底 → v${nextVersion}`,
|
||
note: dirty ? (
|
||
<Note tone="warn">
|
||
画布上有<b>未保存的改动</b>,新建版本不会带上它们(克隆的是已存的 v{pkg.version})。
|
||
想连改动一起走,直接点「保存为 v{nextVersion}」。
|
||
</Note>
|
||
) : (
|
||
<Note>
|
||
把 v{pkg.version} 的定义原样复制成 v{nextVersion}。<b>不生效</b> ——
|
||
画完、异常清零后再激活。
|
||
</Note>
|
||
),
|
||
impact: {
|
||
who: '只多一版,不动任何在跑的东西',
|
||
undo: '版本并存,不要就停着别激活',
|
||
when: '确认即建',
|
||
},
|
||
okLabel: `建 v${nextVersion}`,
|
||
onOk: () => cloneVersion.mutate(),
|
||
},
|
||
});
|
||
|
||
const askActivate = (v: ProcessPackage) =>
|
||
setAsk({
|
||
spec: {
|
||
title: `激活 v${v.version}:${pkg.name}`,
|
||
note: (
|
||
<Note>
|
||
激活即独占:此后<b>新起</b>的工作流用 v{v.version}
|
||
{activeVersion === undefined ? '' : `,v${activeVersion} 随之停用`}。 在途的按各自启动时的版本跑完,不受影响。
|
||
</Note>
|
||
),
|
||
impact: {
|
||
who: `新起单一律走 v${v.version}`,
|
||
undo: '可再切回任一版本',
|
||
when: '确认即切换',
|
||
},
|
||
okLabel: `激活 v${v.version}`,
|
||
onOk: () => setState.mutate({ version: v.version, state: 'active' }),
|
||
},
|
||
});
|
||
|
||
const askRetire = (v: ProcessPackage) =>
|
||
setAsk({
|
||
danger: true,
|
||
spec: {
|
||
title: `停用 v${v.version}:${pkg.name}`,
|
||
note: (
|
||
<Note tone="warn">
|
||
停用后这条流<b>没有生效版本</b>:新起单会被拒,触发器命中了也起不了。
|
||
在途工作流照旧跑完,不受影响。
|
||
</Note>
|
||
),
|
||
impact: {
|
||
who: '整条流对新起单下线',
|
||
undo: '随时激活任一版本即恢复',
|
||
when: '确认即下线',
|
||
},
|
||
okLabel: '停用,整条流下线',
|
||
onOk: () => setState.mutate({ version: v.version, state: 'retired' }),
|
||
},
|
||
});
|
||
|
||
/**
|
||
* 导出依赖文件 —— 编排定契约:各活动的接口声明、责任岗位、输入/输出声明与上游装配,
|
||
* 一并成一份 JSON。**依赖文件即包清单骨架**,开发者对着声明在系统外开发活动。
|
||
*/
|
||
const exportDeps = () => {
|
||
const actContract = new Map(activities.map((a) => [a.activityId, a.contract]));
|
||
const doc = {
|
||
package: { id: pkg.id, name: pkg.name, version: pkg.version },
|
||
callInterface: pkg.callInterface,
|
||
activities: pkg.nodes
|
||
.filter((n) => n.kind === 'activity')
|
||
.map((n) => ({
|
||
nodeId: n.id,
|
||
label: n.label,
|
||
activityId: n.activityId,
|
||
owner: n.owner,
|
||
// 编排上手写的注记(给人读)
|
||
declaredIn: n.in ?? [],
|
||
declaredOut: n.out ?? [],
|
||
/*
|
||
* 活动库里那份**真契约**:必填入参、字段、输出口、声明的工具/模型/域对象。
|
||
* 开发者照着它写实现;只给注记的话,依赖文件说不清这个活动到底要什么。
|
||
*/
|
||
contract: actContract.get(n.activityId) ?? null,
|
||
upstreamAssembly: pkg.edges
|
||
.filter((e) => e.to === n.id)
|
||
.map((e) => ({ from: e.from, mapping: e.mapping })),
|
||
})),
|
||
approvals: pkg.nodes
|
||
.filter((n) => n.kind === 'approval')
|
||
.map((n) => ({ nodeId: n.id, label: n.label, approver: n.approver, reject: n.reject })),
|
||
};
|
||
const blob = new Blob([JSON.stringify(doc, null, 2)], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `${pkg.id}-v${pkg.version}-依赖文件.json`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
|
||
return (
|
||
<>
|
||
<PageHead
|
||
ico="workflow"
|
||
tone={activeVersion !== undefined ? 'ok' : 'warn'}
|
||
kicker={pkg.id}
|
||
title={pkg.name}
|
||
chips={
|
||
<>
|
||
{activeVersion !== undefined ? (
|
||
<St tone="ok" ico="circle-check">
|
||
生效 v{activeVersion}
|
||
</St>
|
||
) : (
|
||
<St tone="warn" ico="pause">
|
||
已下线 —— 没有生效版本
|
||
</St>
|
||
)}
|
||
<Chip tone="mute" ico="history">
|
||
{versions.length} 个版本
|
||
</Chip>
|
||
<St tone={ec.closed === ec.total ? 'ok' : 'danger'} ico={ec.closed === ec.total ? 'circle-check' : 'alert-circle'}>
|
||
边 {ec.closed}/{ec.total}
|
||
</St>
|
||
</>
|
||
}
|
||
acts={[
|
||
{
|
||
label: '导出依赖文件',
|
||
ico: 'upload',
|
||
ask: {
|
||
title: '导出依赖文件',
|
||
note: (
|
||
<Note>
|
||
编排定契约:导出各活动的接口声明、责任岗位、输入输出声明与上游装配 ——
|
||
依赖文件即包清单骨架,开发者对着声明在系统外开发。
|
||
</Note>
|
||
),
|
||
impact: { who: '活动开发者', undo: '可重复导出', when: '确认后生成' },
|
||
okLabel: '确认导出',
|
||
onOk: exportDeps,
|
||
},
|
||
},
|
||
]}
|
||
/>
|
||
|
||
{ask !== undefined && <AskBand spec={ask.spec} danger={ask.danger === true} onCancel={() => setAsk(undefined)} />}
|
||
|
||
{/* 编排工作区:左 1/3 版本列表 + 右 2/3 画布 —— 版本收在流程内部,不平铺台面 */}
|
||
<Sec ico="route" title="编排工作区">
|
||
<div className="g-flowlab">
|
||
<aside className="g-vercard">
|
||
{/* 题头与右侧图题成对:「流程包 · 版本」对「流程图 · 顺序」;新建与编辑同款芯片 */}
|
||
<div className="g-vercard-h">
|
||
流程包 · 版本
|
||
<button className="g-ver-new" onClick={askClone} disabled={cloneVersion.isPending}>
|
||
<Ico name="plus" s={11} /> 新建
|
||
</button>
|
||
</div>
|
||
<div className="g-vercard-list">
|
||
{versions.map((v) => {
|
||
const isActive = v.version === activeVersion;
|
||
const isShown = v.version === pkg.version;
|
||
return (
|
||
<div
|
||
key={v.version}
|
||
className={'g-ver' + (isShown ? ' on' : '') + (isActive ? ' live' : '')}
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={() => onPick(v.version)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') onPick(v.version);
|
||
}}
|
||
>
|
||
<b>v{v.version}</b>
|
||
<span className={'st' + (isActive ? ' live' : '')}>{isActive ? '生效中' : '停用'}</span>
|
||
<span className="m">
|
||
<span className="cnt">
|
||
{v.nodes.length}节点·{v.edges.length}边
|
||
</span>
|
||
{isActive ? (
|
||
<button
|
||
className="g-ver-act"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
askRetire(v);
|
||
}}
|
||
>
|
||
停用
|
||
</button>
|
||
) : (
|
||
<button
|
||
className="g-ver-act t-go"
|
||
/* 不闭合点不了:异常清零才亮 —— 完整性闸在激活,不在画图 */
|
||
disabled={closedOf(v.version) !== true}
|
||
title={closedOf(v.version) === true ? `激活 v${v.version}` : '有异常,先看异常看板'}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
askActivate(v);
|
||
}}
|
||
>
|
||
激活
|
||
</button>
|
||
)}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="g-vercard-f">恰一个生效版本;在途工作流按启动时的版本跑完,启停与新建都不影响它们。</div>
|
||
</aside>
|
||
|
||
<div className="g-canvas" ref={canvasRef}>
|
||
<div className="process-desk">
|
||
<div className="process-doc">
|
||
<FlowFigureBlock
|
||
key={`${pkg.id}@${pkg.version}`}
|
||
title={`${pkg.name} v${pkg.version}`}
|
||
oneLiner={`蓝 = 活动点 · 琥珀 = 审批点 · 红 = 卡点`}
|
||
graph={graph}
|
||
drilledIds={nodes.filter((n) => n.kind === 'activity').map((n) => n.id)}
|
||
/*
|
||
* 画布只画**编排逻辑**:节点的名字、连线的顺序;边上的字是辅助阅读的注记。
|
||
* 图上真正生效可编辑的只有**名字** —— 不传 positions,双击就不出岗位一栏。
|
||
* 岗位、判据、映射这些闭环要素在**节点属性**里决定;分配到人是运营的事,这里不碰。
|
||
*/
|
||
canEdit
|
||
onNodeClick={(nid) => { setSel(nid); setSelEdge(null); }}
|
||
onEditNodeText={async (nid, p2) => {
|
||
setGraph((g) =>
|
||
g === null
|
||
? g
|
||
: { ...g, nodes: g.nodes.map((n) => (n.id === nid ? { ...n, label: p2.label } : n)) },
|
||
);
|
||
setDirty(true);
|
||
}}
|
||
onSaveFlowGraph={async (next, idMap) => {
|
||
setGraph(next);
|
||
await save.mutateAsync({ graph: next, idMap });
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/*
|
||
* 左下:异常看板 —— 与版本卡同列同风(图纸式)。
|
||
* 画图不检查,这里**替**你盯着:哪个节点缺什么,点一条定位到属性去补;
|
||
* 清零了激活按钮才亮 —— 完整性闸在激活,不在画图。
|
||
*/}
|
||
<aside className="g-vercard g-exboard">
|
||
<div className="g-vercard-h">激活 · 双判据</div>
|
||
<div className="g-vercard-list">
|
||
{activeReport === undefined ? (
|
||
<div className="g-exboard-empty">校验中…</div>
|
||
) : (
|
||
(() => {
|
||
/* 能不能激活看两面:①图编排闭合完善 ②活动装载。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 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>
|
||
</aside>
|
||
|
||
{/* 右下:节点属性(图纸卡,与流程图同宽)—— 闭环(岗位/判据/映射)在这里决定 */}
|
||
<div className="g-vercard g-nodecard">
|
||
<div className="g-vercard-h">
|
||
节点 · 属性
|
||
<span className="n">{dirty ? '有改动待保存' : '点节点就地填写'}</span>
|
||
<button
|
||
className="g-ver-new"
|
||
disabled={!dirty || save.isPending}
|
||
onClick={() => {
|
||
if (graph !== null) save.mutate({ graph, idMap: {} });
|
||
}}
|
||
>
|
||
<Ico name="send" s={11} /> {save.isPending ? '保存中…' : `保存为 v${nextVersion}`}
|
||
</button>
|
||
</div>
|
||
<div className="g-nodecard-b">
|
||
{saveErr !== undefined && (
|
||
<Note tone="warn">
|
||
<b>保存失败:</b>
|
||
{saveErr}
|
||
</Note>
|
||
)}
|
||
<div className="g-picker">
|
||
{nodes
|
||
.filter((n) => n.kind !== 'start' && n.kind !== 'end' && n.kind !== 'end-bad')
|
||
.map((n) => (
|
||
<button
|
||
key={n.id}
|
||
className={`g-pick t-${kindTone(n)}` + (sel === n.id ? ' on' : '')}
|
||
onClick={() => { setSel(sel === n.id ? null : n.id); setSelEdge(null); }}
|
||
>
|
||
<b className="g-mono">{n.id}</b>
|
||
{n.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{/* 边拣选条:边属性 = 传递契约(条件 + 映射)—— 图只定顺序,契约在这儿填 */}
|
||
<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)}>
|
||
<Field k="节点 ID" hint="编排内唯一,边映射按它寻址">
|
||
<input className="g-input mono" value={node.id} readOnly />
|
||
</Field>
|
||
<Field k="名称">
|
||
<input
|
||
className="g-input"
|
||
value={node.label}
|
||
placeholder="节点名称"
|
||
onChange={(e) => patch((n) => ({ ...n, label: e.target.value }))}
|
||
/>
|
||
</Field>
|
||
{node.kind === 'activity' && (
|
||
<Field k="说明" hint="画布上的第二行小字">
|
||
<input
|
||
className="g-input"
|
||
value={node.sub ?? ''}
|
||
placeholder="如:照谱执行 / 临场演绎 / 人裁节点"
|
||
onChange={(e) =>
|
||
patch((n) => ({ ...n, sub: e.target.value === '' ? undefined : e.target.value }))
|
||
}
|
||
/>
|
||
</Field>
|
||
)}
|
||
</Prop>
|
||
|
||
<Prop
|
||
ico="user-check"
|
||
title="类型与责任"
|
||
extra={
|
||
node.kind === 'activity'
|
||
? node.flavor === 'approval'
|
||
? '运行时产出审批单'
|
||
: '运行时产出任务'
|
||
: undefined
|
||
}
|
||
>
|
||
{node.kind === 'activity' && (
|
||
<Field k="节点类型" hint="节点只分两种 —— 实现方式是活动库的事,这里不选">
|
||
<Seg
|
||
value={node.flavor === 'approval' ? 'approval' : 'act'}
|
||
options={[
|
||
{ k: 'act', label: '活动点' },
|
||
{ k: 'approval', label: '审批点' },
|
||
]}
|
||
onPick={(k) =>
|
||
patch((n) => ({ ...n, flavor: k === 'approval' ? ('approval' as const) : undefined }))
|
||
}
|
||
/>
|
||
</Field>
|
||
)}
|
||
<Field
|
||
k={node.flavor === 'approval' ? '审批岗位' : '责任岗位'}
|
||
hint={node.flavor === 'approval' ? '审批单派给它' : '任务的负责人由它解析而来'}
|
||
>
|
||
<select
|
||
className="g-select"
|
||
value={node.owner ?? ''}
|
||
onChange={(e) =>
|
||
patch((n) => ({ ...n, owner: e.target.value === '' ? undefined : e.target.value }))
|
||
}
|
||
>
|
||
<option value="">未指定 —— 保存前选一个岗位</option>
|
||
{positions.map((q2) => (
|
||
<option key={q2.id} value={q2.abbr}>
|
||
{q2.abbr} · {q2.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</Field>
|
||
{node.kind === 'activity' && node.flavor === 'approval' && (
|
||
<Note>
|
||
<b>审批点是系统内置要素</b>,不是活动 —— 声明即用、零开发、不进活动库(一个流程包里需要开发的
|
||
只有活动)。功能属性四件:审批岗位(复用负责人四策略解析)· 输入装配映射 · 驳回走向
|
||
(声明式:回指定节点重开新实例 / 终止)· 催办阶梯。运行时它产出<b>审批单</b>而非任务。
|
||
</Note>
|
||
)}
|
||
{node.owner === undefined && (
|
||
<Note tone="warn">
|
||
这个节点还没指定岗位 —— 不指定,起单时就不知道
|
||
{node.flavor === 'approval' ? '审批单' : '任务'}该派给谁。
|
||
</Note>
|
||
)}
|
||
</Prop>
|
||
|
||
{node.kind === 'activity' && (
|
||
<Prop ico="git-merge" title="输入" extra="上游边映射的落点">
|
||
<ListEdit
|
||
items={node.in ?? []}
|
||
placeholder="如:客户邮件"
|
||
onChange={(v) => patch((n) => ({ ...n, in: v.length > 0 ? [...v] : undefined }))}
|
||
/>
|
||
</Prop>
|
||
)}
|
||
{node.kind === 'activity' && (
|
||
<Prop ico="upload" title="输出口" extra="下游边映射的源">
|
||
<ListEdit
|
||
items={node.out ?? []}
|
||
placeholder="如:受理记录"
|
||
onChange={(v) => patch((n) => ({ ...n, out: v.length > 0 ? [...v] : undefined }))}
|
||
/>
|
||
</Prop>
|
||
)}
|
||
{node.kind === 'callout' && (
|
||
<Prop ico="rotate-ccw" title="去向" extra="失败走向 / 驳回去向">
|
||
<Field k="回到节点">
|
||
<select
|
||
className="g-select"
|
||
value={node.to ?? ''}
|
||
onChange={(e) =>
|
||
patch((n) => ({ ...n, to: e.target.value === '' ? undefined : e.target.value }))
|
||
}
|
||
>
|
||
<option value="">未指定</option>
|
||
{nodes
|
||
.filter((n) => n.kind === 'activity')
|
||
.map((n) => (
|
||
<option key={n.id} value={n.id}>
|
||
{n.id} · {n.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</Field>
|
||
</Prop>
|
||
)}
|
||
{node.kind === 'activity' && node.flavor !== 'approval' && (
|
||
<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="git-commit">
|
||
{refAct.paradigm === 'loop' ? '实现:案例 loop' : '实现:标准 flow'}
|
||
</Chip>
|
||
<Chip tone="mute">
|
||
v{refAct.packageVersions[0]?.version ?? '—'} × a
|
||
{refAct.assetVersions.find((x) => x.effective)?.version ?? '—'}
|
||
</Chip>
|
||
</div>
|
||
<button
|
||
className="g-btn"
|
||
onClick={() => nav.jump('library', 'shelf', { k: 'act', id: refAct.activityId })}
|
||
>
|
||
<Ico name="arrow-right" s={13} />
|
||
去活动库看契约
|
||
</button>
|
||
</>
|
||
) : (
|
||
<Note tone="warn">还没链接活动库里已安装的实现 —— 图可以照画照存,激活前链上即可。</Note>
|
||
)}
|
||
</Prop>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Sec>
|
||
|
||
</>
|
||
);
|
||
}
|