Files
JX-BPOS/apps/web/src/features/gov-process/internal/panel.tsx
T
ChaiHaoandClaude Opus 5 710946e729 流程包台面重做:版本收进包内部,启停是运行时开关
按你定的六条做:

## ① 台面三页签:总览 · 流程包 · 触发器
「调起规则」改名触发器(键不动,深链不断)。

## ② 版本收在流程内部,不平铺台面
货架一流程一卡:名字 + 标识 + 「生效 vN / 已下线」+ 版本数。
从前一版本一张卡,三个版本占三格 —— 版本是流程的内部事,不该摊在台面上。

## ③ 详情页布局照你说的排
左 1/3 版本列表(恰一个生效,点谁看谁的图),右 2/3 流程图;
图下是节点属性(点画布或点节点条,就地填);再往下才是声明式契约细节
(边映射校验 · 调用接口 · 触发器)。属性区从右侧窄栏挪到图下整行。

## ④ 新建流程
「新建流程」→ 填名称与标识 → 建出一张「开始 → 结束」的最小图,v1 即刻生效,直接进详情。
最小图天然过闭合校验 —— 不闭合存不进来这条闸对新建同样成立。
标识收紧为小写字母开头、只许小写/数字/连字符(它进 URL 和触发器规则)。

## ⑤ 历史包袱文案清掉
「工作流不设负责人,责任只落在节点上:此处不声明岗位,起单时就解析不出任务负责人」
→「这个节点还没指定岗位 —— 不指定,起单时就不知道任务该派给谁」。
同类:「未声明 · 发布前须补齐」→「未指定 —— 保存前选一个岗位」;
「发布前须先安装并过跨包契约相容核对」→「先去活动库安装;保存时会核对契约相容」;
边映射页的「边不闭合不得发布」→「不得保存」。

## ⑥ 版本可激活/停用,一次只能激活一个,切换不动在途
运行时模型从「最新版生效」改为**显式生效指针**:
  · `setPackageState` 激活即独占(同流其余版本一并停用)
  · 停用生效版 = 整条流下线,新起单被拒(报「无生效版本 —— 去流程包里激活一个」)
  · 起单取生效版;在途工作流按启动时钉住的版本号读定义,启停对它们零影响
  · 保存新版仍即刻生效(旧生效版自动停用)—— 回退就是把生效切回旧版,不用改图
store 的 setPackageState 把列上与 definition(jsonb)里的两份 state 一起改(教训同 0012);
迁移 0013 把存量多版同 active 归一为「每条流只留最高版生效」。

实测:激活 v1 → v3 自动停用,新起单钉 v1;停用 v1(零生效)→ 起单 500 且明说原因;
在途 v1 工作流照跑;恢复激活 v3。UI:切版看图、只读提示、启停确认卡、新建一条龙全通。

typecheck ✓ lint ✓ depcruise 376 模块 ✓ 498 测试 ✓

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
2026-07-28 14:35:54 +08:00

1118 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* feature `gov-process` —— 流程包(设计态):**一条流一个包,版本收在包内部**。
*
* 台面三页签:总览 · 流程包 · 触发器。
* 货架一流程一卡(不按版本平铺);进详情后左 1/3 是版本列表、右 2/3 是流程图,
* 图下是节点属性,再往下才是声明式的契约细节(调用接口 · 边映射 · 触发器)。
*
* 版本是**运行时开关**,不是发布流程:
* · 保存 = 严格检查 + 存为新版 + 即刻生效(不闭合存不进来)
* · 一条流恰一个生效版本;可把生效切回旧版,也可全部停用让整条流下线
* · 在途工作流按启动时钉住的版本跑完 —— 启停对它们零影响
*/
import { useMemo, 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, TRIGGER_KIND_LABEL } 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,
Entry,
Field,
Kpi,
ListEdit,
NList,
NRow,
Note,
PageHead,
Prop,
QueryGate,
Rows,
Sec,
Seg,
St,
} from '@web-shared/gov/primitives.tsx';
import type { Nav } from '@web-shared/gov/gov-route.ts';
import { useGovActivities, useGovPackages, useGovPkgReports, useGovStaff, useGovTriggers } 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 { 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') };
});
}
/* ---------------- 台面:总览 · 流程包 · 触发器 ---------------- */
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({
mutationFn: () =>
call<{ pkg: ProcessPackage; report: ClosureReport }>(saveProcessRoute, {
body: { pkg: minimalFlow(nfId, nfName.trim()) },
}),
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,
gateway: shown.nodes.filter((n) => n.kind === 'gateway').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">
网关 {c.gateway}
</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 [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}
onPick={(v) => setPinned(v)}
onSaved={() => setPinned(null)}
/>
);
}
/** 本页要用的活动字段面 —— 与 `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,
onPick,
onSaved,
}: {
pkg: ProcessPackage;
versions: readonly ProcessPackage[];
activeVersion: number | undefined;
nav: Nav;
activities: readonly ActivityRow[];
positions: SpPosition[];
nextVersion: number;
onPick: (version: number) => void;
onSaved: () => void;
}) {
const queryClient = useQueryClient();
/** 只有**生效版**可编辑(改则存成下一版);其余版本只读 —— 在途任务还按它们跑 */
const live = pkg.version === activeVersion;
const readOnly = !live;
const paradigms = useMemo(() => new Map(activities.map((a) => [a.activityId, a.paradigm])), [activities]);
/** 画布工作副本:属性卡与手绘编辑都改它;保存时合并回流程包 */
const [graph, setGraph] = useState<FlowGraph | null>(() => packageToGraph(pkg, [], paradigms));
const [sel, setSel] = useState<string | 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) =>
(semanticNode?.kind === 'activity' && a.activityId === semanticNode.activityId) || a.name === node.label,
);
// 每次进页问一次闭合 —— 画图即校验(与后端裁决同源);落笔保存后用返回的报告
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 okEdge = activeReport?.closed === true;
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.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';
// 画布上的选中态: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 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);
void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] });
void queryClient.invalidateQueries({ queryKey: ['gov', 'process-reports'] });
// 保存即成新生效版 —— 解钉,画布跟去新版本
onSaved();
},
});
/** 版本启停:运行时开关,不改定义 —— 在途按启动版本跑完,零影响 */
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 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);
};
const triggersQ = useGovTriggers();
const boundTriggers = (triggersQ.data?.triggers ?? []).filter((t) => t.processPackageId === pkg.id);
const callInput = pkg.callInterface.input as { required?: string[]; properties?: Record<string, { type?: string }> };
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)} />}
{activeReport !== undefined && !activeReport.closed && (
<Note tone="warn">
<b>未闭合:</b>
{activeReport.violations.map((v) => v.message).join(';')}
</Note>
)}
{report !== undefined && report.closed && <Note tone="ok">闭合校验通过。</Note>}
{/* 编排工作区:左 1/3 版本列表 + 右 2/3 画布 —— 版本收在流程内部,不平铺台面 */}
<Sec
ico="route"
title="编排工作区"
extra={
readOnly
? `看的是 v${pkg.version}(只读)—— 要改图,回生效版去改`
: `画的是生效版 v${pkg.version} —— 保存即存成 v${nextVersion} 并生效`
}
>
<div className="g-flowlab">
<aside className="g-vers">
<div className="g-side-h">
<Ico name="history" s={13} />
版本
<span className="x">恰一个生效 · 切换不动在途</span>
</div>
{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 ? ' t-live' : '')}
role="button"
tabIndex={0}
onClick={() => onPick(v.version)}
onKeyDown={(e) => {
if (e.key === 'Enter') onPick(v.version);
}}
>
<b>v{v.version}</b>
{isActive ? (
<St tone="ok" ico="circle-check">
生效中
</St>
) : (
<St tone="mute" ico="pause">
停用
</St>
)}
<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"
onClick={(e) => {
e.stopPropagation();
askActivate(v);
}}
>
激活
</button>
)}
</span>
</div>
);
})}
<Note>
保存恒存成新版本并即刻生效;旧版本原样留着,在途工作流按启动时的版本跑完。
把生效切回旧版即是回退 —— 不用改图。
</Note>
</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={positions}
canEdit={!readOnly}
onNodeClick={(nid) => setSel(nid)}
onEditNodeText={async (nid, p2) => {
setGraph((g) =>
g === null
? g
: {
...g,
nodes: g.nodes.map((n) =>
n.id === nid
? { ...n, label: p2.label, ...(p2.owner === undefined ? {} : { owner: p2.owner }) }
: n,
),
},
);
setDirty(true);
}}
onSaveFlowGraph={async (next, idMap) => {
setGraph(next);
await save.mutateAsync({ graph: next, idMap });
}}
/>
</div>
</div>
</div>
</div>
{/* 图下:节点属性 —— 点画布或点节点条,属性就地填 */}
<div className="g-side g-wide">
<div className="g-side-h">
<Ico name="sliders-horizontal" s={13} />
节点属性
<span className="x">{readOnly ? `v${pkg.version} 只读` : dirty ? '有改动待保存' : '点节点就地填写'}</span>
</div>
<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)}
>
<b className="g-mono">{n.id}</b>
{n.label}
</button>
))}
</div>
{node === null ? (
<Note>点画布上的节点(或上面的节点条)选一个 —— 属性就在下面填,网关判据与卡点去向同样在此改。</Note>
) : (
<>
<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 ?? 'flow'}
options={[
{ k: 'flow', label: '标准 · flow' },
{ k: 'case', label: '案例 · loop' },
{ k: 'approval', label: '审批点' },
]}
onPick={(k) =>
patch((n) => ({ ...n, flavor: k === 'flow' ? undefined : (k as 'case' | 'approval') }))
}
/>
</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' ? '审批岗位' : '责任岗位'}
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 === '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="回到节点">
<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="活动住库,编排只引用">
{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>
<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>
<Acts
align="end"
items={[
{
label: dirty ? `保存并生效(存为 v${nextVersion})` : '编排无改动',
ico: 'send',
primary: dirty,
disabled: !dirty || readOnly,
ask: dirty
? {
title: `保存并生效:${pkg.name} → v${nextVersion}`,
note: okEdge ? (
<Note tone="ok">
边映射全闭合、必填齐、类型无冲突 —— 存下去即刻对新起的工作流生效。
</Note>
) : (
<Note tone="warn">
<b>不闭合存不进来:</b>
{activeReport?.violations.map((v) => v.message).join(';') ?? '闭合校验未跑完'}
</Note>
),
impact: {
who: okEdge
? `存为 v${nextVersion} 并成为唯一生效版;在途的按启动时版本跑完`
: '被拒存 —— 先补齐边映射',
undo: `v${pkg.version} 原样留着,可把生效切回去`,
when: okEdge ? '确认即生效' : '存不进去',
},
okLabel: okEdge ? '确认保存并生效' : '我知道了',
onOk: () => {
if (graph !== null) save.mutate({ graph, idMap: {} });
},
}
: undefined,
},
]}
/>
</>
)}
</div>
</Sec>
{/* 声明式契约细节:调用接口 · 边映射 · 触发器 —— 图之下,按查看频率排 */}
<Sec ico="file-text" title="契约细节" extra="声明式 —— 图定结构,这里定字段与调起">
<div className="g-entries">
<Entry
ico="git-merge"
tone={ec.closed === ec.total ? 'ok' : 'danger'}
title="边映射校验"
desc="上游输出口 → 下游契约字段 · 类型与必填,逐边核对"
meta={
<Chip tone={ec.closed === ec.total ? 'ok' : 'danger'}>
{ec.closed}/{ec.total}
</Chip>
}
onClick={() => nav.push({ k: 'edges', id: `${pkg.id}@${pkg.version}` })}
/>
</div>
<Card ico="plug" title="调用接口" extra="起单入参 —— 触发器与登记表单都按它装配">
{Object.keys(callInput.properties ?? {}).length === 0 ? (
<Note>这条流还没声明入参 —— 谁调起它都不用带数据;要带,去画布点「开始」节点声明。</Note>
) : (
<Rows
items={Object.entries(callInput.properties ?? {}).map(([k, v]) => ({
k,
v: `${v.type ?? 'any'}${(callInput.required ?? []).includes(k) ? ' · 必填' : ''}`,
mono: true,
}))}
/>
)}
</Card>
<Card ico="zap" title="调起它的触发器" extra="平级、不入包版本 —— 增删启停不改本流任何版本">
{boundTriggers.length > 0 ? (
<NList>
{boundTriggers.map((t) => (
<NRow
key={t.id}
tone="warn"
ico="zap"
title={t.name}
sub={
t.spec.kind === 'event'
? t.spec.rule
: t.spec.kind === 'registration'
? TRIGGER_KIND_LABEL.registration
: TRIGGER_KIND_LABEL.periodic
}
cols={[{ k: '状态', v: t.enabled ? '已启用' : '已停用' }]}
right={
<button
className="g-btn"
onClick={(e) => {
e.stopPropagation();
nav.jump('workflows', 'rules');
}}
>
改规则
</button>
}
onClick={() => nav.jump('workflows', 'rules', { k: 'trigger', id: t.id })}
/>
))}
</NList>
) : (
<Note>
还没有触发器指向这条流 —— 它定义得再完整,也没人调得起来。去<b>触发器</b>页签配一条
(周期 / 事件 / 登记三选一)。
</Note>
)}
</Card>
</Sec>
</>
);
}
/* ---- 边映射校验(整页) ---- */
export function EdgesPage({ id }: { id: string }) {
const [pid, versionText] = id.split('@');
const q = useGovPackages();
const pkg = (q.data?.packages ?? []).find((p) => p.id === pid && String(p.version) === versionText);
const reports = useGovPkgReports(pkg === undefined ? [] : [pkg]);
if (pkg === undefined) {
return (
<QueryGate query={q}>
<Note>流程包不存在。</Note>
</QueryGate>
);
}
const report = reports.data?.get(`${pkg.id}@${pkg.version}`);
const ec = edgeCheckOf(pkg, report);
const okEdge = ec.closed === ec.total;
const labelOf = (nodeId: string) => pkg.nodes.find((n) => n.id === nodeId)?.label ?? nodeId;
const sourceText = (m: ProcessPackage['edges'][number]['mapping'][number]) => {
const s = m.source;
switch (s.from) {
case 'initiation':
return `起单.${s.path}`;
case 'output-port':
return `${s.port}.${s.path}`;
case 'workflow-var':
return `流程变量.${s.name}`;
case 'constant':
return `常量 ${JSON.stringify(s.value)}`;
default:
return '表达式';
}
};
return (
<>
<PageHead
ico="git-merge"
tone={okEdge ? 'ok' : 'danger'}
kicker={`${pkg.name} v${pkg.version} · 边映射`}
title="边映射校验"
chips={
<St tone={okEdge ? 'ok' : 'danger'} ico={okEdge ? 'circle-check' : 'alert-circle'}>
{ec.closed} / {ec.total} 已闭合
</St>
}
desc="连线即映射:每条顺序边都要把上游输出口接到下游契约字段上,必填齐、类型无冲突才算闭合。边不闭合不得保存。"
/>
{!okEdge && report !== undefined && <Note tone="warn">{report.violations.map((v) => v.message).join(';')}</Note>}
<Card ico="git-merge" title="逐边核对" extra="上游输出口 → 下游契约字段">
<table className="g-matrix">
<thead>
<tr>
<th>边</th>
<th>映射</th>
<th>条数</th>
<th>状态</th>
</tr>
</thead>
<tbody>
{pkg.edges.map((e) => {
const bad = (report?.violations ?? []).filter((v) => v.edgeId === e.id);
return (
<tr key={e.id}>
<td className="mono">
{labelOf(e.from)} → {labelOf(e.to)}
{e.label === undefined ? '' : `(${e.label})`}
</td>
<td>
{e.mapping.length === 0
? '—'
: e.mapping.map((m) => `${sourceText(m)} → data.${m.target}`).join('; ')}
{bad.length > 0 && (
<>
<br />
<span className="g-mono">{bad.map((v) => v.message).join(';')}</span>
</>
)}
</td>
<td className="mono">{e.mapping.length}</td>
<td>
{bad.length === 0 ? (
<St tone="ok" ico="circle-check">
已闭合
</St>
) : (
<St tone="danger" ico="alert-circle">
未完成
</St>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</Card>
</>
);
}