This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-MVP/apps/web/src/features/gov-process/internal/panel.tsx
T
ChaiHaoandClaude Opus 5 1fd215cf40 范式退役:活动只剩一种、只跑 Loop;审批活动可管;活动库重做
用户定的两件,一并落地。

**一、活动不再分 flow 和 loop —— 彻底退役 flow**
  · `flow-engine` 模块整个删除(runner 379 行 + step-sequence 184 行 + 测试);
  · `paradigm` 从清单身份、`activity_package`/`task` 两张表、任务卡与活动库一并退场;
    `businessModel` 不再是判别联合 —— 只有案例模型(CMMN + 说明书 + DMN)一形;
  · task 的引擎分派从「按 paradigm 取两个之一」收成**唯一的引擎**;
  · `activities/` 四个 flow 活动(A1 受理 / A4 定价 / A6 议价 / A7 归档)各写了一份
    案例模型:阶段照原来的步分,触达世界的项一律 manualActivationRule,
    能算的交 DMN(A1 的三分类 P-03、A4 的 P-01/P-02)不交 Agent;
  · 六个占位槽位的骨架从「机器步落台账 + 一张验收单」改成案例骨架,包版本 0.8.0 → 0.9.0。

**代价写在明处,不假装没有**:`turn-runner` 每一拍都要 `agent.decide` ——
环境里没有模型 key,案例活动一步也走不动。三套端到端(统一报价 / 新建流程包 / 测试台
Trace)整套跳过,文件头写明恢复条件:①有可用模型 ②走法改写成按 Enabled 集推进。
**不许把断言改松让它变绿** —— 绿得不诚实比红着更坏。debt.md 已记。

**二、审批活动可管**(新能力)
  · `sys.approval_template`:审批点**可复用的那一半** —— 审批人看哪几项、按什么名字看、
    怎么催、说什么话。谁来审不在里面(与活动点同口径,运营时轮值);驳回回哪个节点
    也不在里面(那是每张图自己的事,留在节点上);
  · 三条路由 + 引用反查:还被审批点引着就不许删(409),删了那些节点会**静默**退回旧行为;
  · 运行时 `basisOf`:引了模板就按模板筛与改名,没引即旧行为(载荷全摊开)。
    模板改了只影响新发出的审批单 —— 在途单的依据在发出那一刻已定形。

**三、活动库重做**:一区两格 —— 「活动(装进来的)」与「审批活动(配出来的)」,
都用流程卡那套两段式卡片(头 + 四格数据板),幽灵卡当入口(装入活动 / 新建审批活动);
「标准/案例」那一维随范式一起撤,业务分类成了唯一的一维,以行内小签呈现。

迁移 0020:两张表 drop paradigm、建 approval_template。已在开发库跑过并重播种。
实测(丁子乔账号):建「定价审批」模板 → 流程包 A5 属性里选得到 → 引用反查计数正确。
四闸绿(api:check 随本提交)。

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

1228 lines
53 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` —— 流程包(设计态):**一条流一个包,版本收在包内部**。
*
* 一区一内容:三枚计数摆在货架顶端,底下就是包货架(触发器已独立成区)。
* 货架一流程一卡(不按版本平铺);进详情是**三栏**:左窄轨=版本(选中那版才出动作:
* 激活/停用 · 改名 · 删除)、中=流程图、右=节点属性 —— **平铺一列字段,不分类**:
* 节点只决定 ID/名称/说明 · 类型 · 链接哪个活动 · 出边条件;数据契约归活动自己,
* 这里不写声明,要看去活动库看(点图选节点,内容长了卡内滚,不拉长图)。
* 「激活判据」不再单占一卡 —— 没选节点时,属性栏就地列出还差哪几条,点一条定位过去。
*
* 版本是**运行时开关**,不是发布流程:
* · 编辑即其身:保存**写回当前版本**(号不变);新版本只出自「新建」
* · 画图不检查(未生效版);写回生效版须闭合;在途钉版不可变(409)
* · 一条流恰一个生效版本;可把生效切回旧版,也可全部停用让整条流下线
* · 在途工作流按启动时钉住的版本跑完 —— 启停对它们零影响
*/
import { useRef, useState, useEffect } from 'react';
import type { KeyboardEvent } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import type { ClosureReport, ProcessPackage, ProcessNode } from '@contracts';
import {
deleteProcessFlowRoute,
deleteProcessVersionRoute,
processPackageSchema,
editProcessVersionRoute,
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,
Note,
PageHead,
QueryGate,
Sec,
St,
} from '@web-shared/gov/primitives.tsx';
import type { Nav } from '@web-shared/gov/gov-route.ts';
import { useGovActivities, useGovApprovalTemplates, useGovPackages, useGovPkgReports } 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';
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({ nav }: HomeProps) {
return (
<>
<OverviewHome />
<FlowShelf nav={nav} />
</>
);
}
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="不闭合不得激活"
/>
</div>
</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: 'E1', 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);
/** 卡上的 ⋯ 菜单:开在哪张卡上;`drop` = 那张卡的菜单已翻到删除确认 */
const [menu, setMenu] = useState<{ id: string; drop: boolean } | null>(null);
/** 就地改名:改的是卡面显示的**那一版**(名字是版本的属性) */
const [renaming, setRenaming] = useState<string | null>(null);
const [cardErr, setCardErr] = 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 ?? '建流程失败'),
});
/** 卡上改名 —— 落在卡面那一版上(整条流的名字就是它生效版的名字) */
const rename = useMutation({
mutationFn: (input: { id: string; version: number; name: string }) =>
call<{ packages: ProcessPackage[] }>(editProcessVersionRoute, {
params: { id: input.id, version: String(input.version) },
body: { name: input.name },
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] });
setRenaming(null);
},
onError: (e: unknown) => setCardErr((e as { message?: string }).message ?? '改名失败'),
});
/** 删整条流 —— 在途单钉着、还有调起规则指着,服务端都会挡回来(409),判词照抄 */
const dropFlow = useMutation({
mutationFn: (id: string) =>
call<{ packages: ProcessPackage[] }>(deleteProcessFlowRoute, { params: { id } }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] });
setMenu(null);
},
onError: (e: unknown) => {
setCardErr((e as { message?: string }).message ?? '删不掉');
setMenu(null);
},
});
return (
<QueryGate query={q}>
{creating && (
<Card ico="workflow" title="新建流程" extra="建出来就是 v1 并即刻生效 —— 一张「开始 → 结束」的最小图,进详情再画">
<div className="g-newflow">
<Field k="流程名称" hint="人读的名字,如「统一报价」">
<input className="g-input" autoFocus 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>
)}
{cardErr !== undefined && <Note tone="warn">{cardErr}</Note>}
<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,
};
const open = menu?.id === f.id;
return (
/*
* 卡是个 div,整面盖一枚透明的「进这条流」按钮 —— 卡里还要住 ⋯ 菜单
* 与改名输入框,按钮套按钮是非法 DOM。字面部分不吃点击,直接穿到底下那枚。
*/
<div key={f.id} className={'g-fcard' + (f.active === undefined ? ' off' : '')}>
<button className="hit" aria-label={`打开 ${shown.name}`} onClick={() => nav.push({ k: 'pkg', id: f.id })} />
<div className="hd">
<span className="ico">
<Ico name="workflow" s={15} />
</span>
<div className="ttl">
{renaming === f.id ? (
<input
className="g-input"
autoFocus
maxLength={40}
defaultValue={shown.name}
onClick={(e) => e.stopPropagation()}
onBlur={() => setRenaming(null)}
onKeyDown={(e) => {
e.stopPropagation();
const val = (e.target as HTMLInputElement).value.trim();
if (e.key === 'Enter' && val !== '') rename.mutate({ id: f.id, version: shown.version, name: val });
if (e.key === 'Escape') setRenaming(null);
}}
/>
) : (
<b>{shown.name}</b>
)}
<em>{f.id}</em>
</div>
<span className={'stx' + (f.active === undefined ? '' : ' on')}>
{f.active === undefined ? '已下线' : `生效 v${f.active.version}`}
</span>
<button
className={'mbtn' + (open ? ' on' : '')}
aria-label="更多"
onClick={(e) => {
e.stopPropagation();
setCardErr(undefined);
setMenu(open ? null : { id: f.id, drop: false });
}}
>
<Ico name="more-horizontal" s={14} />
</button>
</div>
{/* 数据板:四格等宽、竖线分隔 —— 一行说完这条流有多大、闭合没有 */}
<div className="plate">
<span>
<b>{c.activity}</b>
<i>活动</i>
</span>
<span>
<b>{c.approval}</b>
<i>审批</i>
</span>
<span className={ec.closed === ec.total ? '' : 'bad'}>
<b>
{ec.closed}/{ec.total}
</b>
<i>边闭合</i>
</span>
<span>
<b>{f.versions.length}</b>
<i>版本</i>
</span>
</div>
{open && (
<div className="menu">
{menu.drop ? (
<>
<span className="q">删掉整条流?{f.versions.length} 个版本一起没</span>
<button onClick={() => setMenu({ id: f.id, drop: false })}>算了</button>
<button className="bad" disabled={dropFlow.isPending} onClick={() => dropFlow.mutate(f.id)}>
删除
</button>
</>
) : (
<>
<button
onClick={() => {
setRenaming(f.id);
setMenu(null);
}}
>
<Ico name="pen-line" s={12} /> 编辑
</button>
<button className="bad" onClick={() => setMenu({ id: f.id, drop: true })}>
<Ico name="trash-2" s={12} /> 删除
</button>
</>
)}
</div>
)}
</div>
);
})}
{/* 幽灵卡:新建就住货架里 —— 与真卡同一尺寸,一眼看出「这儿还能再添一条」 */}
<button className="g-fcard ghost" onClick={() => setCreating(true)}>
<Ico name="plus" s={18} />
新建流程
</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 approvalTpl = useGovApprovalTemplates();
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 ?? []}
approvalTemplates={approvalTpl.data?.templates ?? []}
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;
packageVersions: ReadonlyArray<{ version: string }>;
assetVersions: ReadonlyArray<{ version: number; effective: boolean }>;
contract: GovActivities['activities'][number]['contract'];
};
function FlowLab({
pkg,
versions,
activeVersion,
nav,
activities,
approvalTemplates,
nextVersion,
closedOf,
onPick,
onSaved,
}: {
pkg: ProcessPackage;
versions: readonly ProcessPackage[];
activeVersion: number | undefined;
nav: Nav;
activities: readonly ActivityRow[];
/** 审批模板一览 —— 审批点属性里选一张 */
approvalTemplates: readonly { id: string; name: string }[];
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);
const [dirty, setDirty] = useState(false);
const [report, setReport] = useState<ClosureReport | undefined>(undefined);
const [ask, setAsk] = useState<{ spec: AskSpec; danger?: boolean } | undefined>(undefined);
/**
* **版本记录**的编辑态:开着,每行的号与名就是输入框、行尾钮换成删除;
* 顶栏两枚钮 取消 / 保存 —— 改动攒在草稿里,按保存才落库。
* 它只管版本记录这一件事,与画布的手绘编辑(那是流程图卡自己的)互不相干。
*/
const [editingVersions, setEditingVersions] = useState(false);
/** 版本记录草稿:原版本号 → 改后的号与名 */
const [edits, setEdits] = useState<Record<number, { version: string; name: string }>>({});
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);
};
/*
* 出边**归发起它的节点**(Mastra:`.branch([[条件, 下一步], …])` 挂在上一步上)——
* 边不再是可编辑对象,分支条件在源节点的属性卡里填,所以这里按 `from` 取它的出边。
*/
const outEdges = (graph?.edges ?? [])
.map((edge, index) => ({ edge, index }))
.filter(({ edge }) => sel !== null && edge.from === sel);
const patchEdge = (index: number, f: (e: FlowEdge) => FlowEdge) => {
setGraph((g) => (g === null ? g : { ...g, edges: g.edges.map((e, i) => (i === index ? f(e) : e)) }));
setDirty(true);
};
// 画布上的选中态: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);
},
});
/** 删一版:生效版与在途钉版都会被服务端挡回来(409),判词照抄 */
const dropVersion = useMutation({
mutationFn: (version: number) =>
call<{ packages: ProcessPackage[] }>(deleteProcessVersionRoute, {
params: { id: pkg.id, version: String(version) },
}),
onSuccess: (out) => {
void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] });
setAsk(undefined);
// 删掉的正是在看的这一版 → 落到还剩的最新一版;一版不剩就回货架
const rest = out.packages;
if (rest.length === 0) nav.jump('workflows', 'pkgs');
else if (rest[0] !== undefined) onSaved(rest[0].version);
},
onError: (e: unknown) => setSaveErr((e as { message?: string }).message ?? '删不掉'),
});
/**
* 存版本记录:逐行把改过的号与名送上去 —— 撞号、跑过单改号,服务端 409,判词照抄。
* 一行不通,前面已落的不回滚:版本记录各自独立,没有「半条」可言。
*/
const saveVersions = useMutation({
mutationFn: async (drafts: readonly { from: number; version: number; name: string }[]) => {
let packages: ProcessPackage[] = [];
for (const d of drafts) {
const out = await call<{ packages: ProcessPackage[] }>(editProcessVersionRoute, {
params: { id: pkg.id, version: String(d.from) },
body: { version: d.version, name: d.name },
});
packages = out.packages;
}
return packages;
},
onSuccess: (_packages, drafts) => {
void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] });
void queryClient.invalidateQueries({ queryKey: ['gov', 'process-reports'] });
setEditingVersions(false);
setEdits({});
// 正看的这一版换了号 → 跟着落到新号上,别停在一个已经不存在的号
const moved = drafts.find((d) => d.from === pkg.version);
if (moved !== undefined && moved.version !== pkg.version) onSaved(moved.version);
},
onError: (e: unknown) => setSaveErr((e as { message?: string }).message ?? '版本记录没存上'),
});
/**
* 新建版本 = **以当前看的版本为底克隆一版**(服务端定版号,**不生效**)。
* 与「激活」分工明确:激活只是把生效指针切过去,不产生新版本;
* 新建版本才长一版 —— 把旧定义复活成最新版、或在现状上另起一版接着画,都走这儿。
*/
const cloneVersion = useMutation({
mutationFn: () => call<{ pkg: ProcessPackage; report: ClosureReport }>(saveProcessRoute, { body: { pkg, asNew: true } }),
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{pkg.version}」再来新建。
</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 askDrop = (v: ProcessPackage) =>
setAsk({
danger: true,
spec: {
title: `删除 v${v.version}:${pkg.name}`,
note: (
<Note tone="warn">
版本删了**不可撤销**。生效版与**在途钉版**删不掉 —— 后者那些单要按这一版跑完,
定义没了就再也推不动。
</Note>
),
impact: { who: '这一版的定义不再存在', undo: '不可撤销', when: '确认即删' },
okLabel: `删掉 v${v.version}`,
onOk: () => dropVersion.mutate(v.version),
},
});
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,
// 契约不带负责人岗位 —— 分派归运营,依赖文件只描述干什么
/*
* 活动库里那份**真契约**:必填入参、字段、输出口、声明的工具/模型/域对象。
* 开发者照着它写实现;只给注记的话,依赖文件说不清这个活动到底要什么。
*/
contract: actContract.get(n.activityId) ?? null,
/*
* 数据按字段名流动(对齐 Mastra):依赖文件不再列边映射 ——
* 上游从哪来,看 contract.input 的字段名对上游 output 的字段名。
*/
upstream: pkg.edges.filter((e) => e.to === n.id).map((e) => e.from),
})),
approvals: pkg.nodes
.filter((n) => n.kind === 'approval')
// 审批人运营时分配,依赖文件只描述编排语义(驳回走向)
.map((n) => ({ nodeId: n.id, label: n.label, 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">
{/*
* 左栏:版本。**动作统一住顶部**(作用于正看的这一版),行里只放事实
* (v几 · 状态 · 点边计数)—— 完整显示,不在行内再展开一排小钮。
*/}
<aside className="g-vercard g-verslim">
<div className="g-vercard-h">
版本
{/*
* 编辑态两枚钮:取消 / 保存。改动攒在草稿里 —— 号与名一趟改完再落库,
* 与画布的「编辑 → 保存」同一套手势。
*/}
{editingVersions ? (
<>
<button
className="g-ver-act"
disabled={saveVersions.isPending}
onClick={() => {
setEditingVersions(false);
setEdits({});
}}
>
取消
</button>
<button
className="g-ver-act"
disabled={saveVersions.isPending}
onClick={() => {
const drafts = versions.flatMap((v) => {
const d = edits[v.version];
if (d === undefined) return [];
const num = Number(d.version);
const name = d.name.trim();
if (!Number.isInteger(num) || num < 1 || name === '') return [];
if (num === v.version && name === v.name) return [];
return [{ from: v.version, version: num, name }];
});
if (drafts.length === 0) {
setEditingVersions(false);
setEdits({});
return;
}
saveVersions.mutate(drafts);
}}
>
<Ico name="send" s={11} /> {saveVersions.isPending ? '保存中…' : '保存'}
</button>
</>
) : (
<>
<button className="g-ver-act" onClick={askClone} disabled={cloneVersion.isPending}>
<Ico name="plus" s={11} /> 新建
</button>
{/* 这枚「编辑」只作用于**版本记录**:号、名、删 —— 画布的编辑在流程图卡上 */}
<button
className="g-ver-act"
onClick={() => {
setEdits(Object.fromEntries(versions.map((v) => [v.version, { version: String(v.version), name: v.name }])));
setEditingVersions(true);
}}
>
<Ico name="pen-line" s={11} /> 编辑
</button>
</>
)}
</div>
<div className="g-vercard-list">
{versions.map((v) => {
const isActive = v.version === activeVersion;
const isShown = v.version === pkg.version;
const draft = edits[v.version];
/*
* 编辑态的行不再是「可点切版」的按钮 —— 它是一排字段:号、名、删。
* 展示态才是按钮(点行切到这一版看)。
*/
const pickable = editingVersions
? {}
: {
role: 'button',
tabIndex: 0,
onClick: () => onPick(v.version),
onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onPick(v.version);
}
},
};
return (
<div
key={v.version}
className={'g-verrow' + (isShown ? ' on' : '') + (isActive ? ' live' : '') + (editingVersions ? ' ed' : '')}
{...pickable}
>
<div className="ln">
{/* 号与名都归设计人员自己定:号自己编,名各版不同 —— 版本靠这两样互相区分 */}
{editingVersions && draft !== undefined ? (
<>
<b>v</b>
<input
className="g-input vnum"
inputMode="numeric"
value={draft.version}
onChange={(e) =>
setEdits({ ...edits, [v.version]: { ...draft, version: e.target.value.replace(/\D/g, '') } })
}
/>
<input
className="g-input nm"
maxLength={40}
value={draft.name}
onChange={(e) => setEdits({ ...edits, [v.version]: { ...draft, name: e.target.value } })}
/>
</>
) : (
<>
<b>v{v.version}</b>
<span className="nm">{v.name}</span>
</>
)}
{editingVersions ? (
<button
className="g-ver-act t-bad"
disabled={isActive || dropVersion.isPending}
title={isActive ? '生效版先停用' : ''}
onClick={(e) => {
e.stopPropagation();
askDrop(v);
}}
>
删除
</button>
) : 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 ? '' : '还不闭合'}
onClick={(e) => {
e.stopPropagation();
askActivate(v);
}}
>
激活
</button>
)}
</div>
{/* 第二行只放事实:状态 · 点边计数 */}
<div className="ln2">
<span className={'st' + (isActive ? ' live' : '')}>{isActive ? '生效' : '停用'}</span>
<span className="cnt">
{v.nodes.length} 节点 · {v.edges.length} 边
</span>
</div>
</div>
);
})}
</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)}
/* 边不是可编辑对象 —— 点一条线,选中**发起它的那个节点**(分支归它管) */
onEdgeClick={(edge) => setSel(edge.from)}
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>
{/*
* 右栏:节点属性。外面这层包壳是**为了不撑高整行** —— 卡绝对定位撑满行高,
* 行高由中栏的图定;属性长了在卡内滚,不把图拉长(用户要的「卡片高度跟图一致」)。
*/}
<div className="g-nodewrap">
<div className="g-vercard g-nodecard">
<div className="g-vercard-h">
节点 · 属性
{dirty && <span className="n dirty">有改动待保存</span>}
{/* 与「新建 / 编辑」同款素钮 —— 可不可存由禁用态说,不靠换色抢戏 */}
<button
className="g-ver-act"
disabled={!dirty || save.isPending}
onClick={() => {
if (graph !== null) save.mutate({ graph, idMap: {} });
}}
>
<Ico name="send" s={11} /> {save.isPending ? '保存中…' : `保存 v${pkg.version}`}
</button>
</div>
<div className="g-nodecard-b">
{saveErr !== undefined && (
<Note tone="warn">
<b>保存失败:</b>
{saveErr}
</Note>
)}
{/* key 随选中对象换 —— 换一件看,明细区滚回顶,不带上一件的滚动位 */}
<div className="g-propsheet" key={node !== null ? `n:${node.id}` : 'blank'}>
{node === null ? (
/*
* 没选节点时,这块地方替原来的「激活判据」看板:还差哪几条就列在这儿,点一条定位过去。
* 激活闸只剩两面 —— **结构闭合**与**活动装载**;字段一概不查(只声明,不要求)。
*/
<div className="g-sheetempty">
{(activeReport?.violations.length ?? 0) === 0 ? (
<>
<Ico name="target" s={20} />
<b>在图上点一个节点</b>
<p>
节点填名称、链接实现、往哪走(出边条件)。边不是可编辑对象 —— 它只表达顺序;
点线会选中发起它的那个节点。负责人运营时分配,不在这儿。
</p>
{activeReport !== undefined && (
<span className="g-okstamp">
<Ico name="circle-check" s={13} />
结构闭合 · 活动装载齐备 —— v{pkg.version} 可运行
</span>
)}
</>
) : (
<div className="g-lack">
<div className="g-lack-h">
<Ico name="triangle-alert" s={13} />
还差 {activeReport!.violations.length} 条才能激活
</div>
{activeReport!.violations.map((v, i) => {
const nid = v.nodeId ?? pkg.edges.find((e) => e.id === v.edgeId)?.to;
return (
<button
key={`${v.kind}-${i}`}
className="g-lack-r"
disabled={nid === undefined}
onClick={() => {
if (nid !== undefined) setSel(String(nid));
}}
>
{nid !== undefined && <b>{String(nid)}</b>}
<span>{v.message}</span>
</button>
);
})}
</div>
)}
</div>
) : (
/*
* 节点属性**平铺**(2026-07-30 用户定「不用分类了」):不再分「身份/类型/装载/出边」
* 几张小卡 —— 节点本来只决定这么几件,分类反而把四五个字段摊成半屏卡片。
* 也不再摆数据契约声明:数据契约**归活动自己**,要看去活动库看(下面那枚钮直达)。
*/
<div className="g-nodeform">
{/*
* 你点的就是它:选中的节点以**画布同款皮肤**摆在卡顶(活动蓝 / 审批琥珀,
* 左上角标同画布)—— 属性改的是哪一件,一眼对上,不用回图上找。
*/}
<div className={'g-nchip' + (node.flavor === 'approval' ? ' apr' : '')}>
<i>{node.id}</i>
<b>{node.label || '(未命名)'}</b>
<em>{node.flavor === 'approval' ? '审批点' : '活动点'}</em>
</div>
{/* 图签行:字段名住左格(mono 竖签),值住右格 —— 与图框的图签同一形制 */}
<div className="g-tbrow">
<em>ID</em>
<span className="v mono">{node.id}</span>
</div>
<div className="g-tbrow">
<em>名称</em>
<input
className="g-tbin"
value={node.label}
placeholder="节点名称"
onChange={(e) => patch((n) => ({ ...n, label: e.target.value }))}
/>
</div>
{node.kind === 'activity' && (
<div className="g-tbrow">
<em>类型</em>
{/* 切换器长成它代表的东西:两枚迷你节点皮肤(画布蓝 / 琥珀) */}
<div className="g-ntype">
<button
className={'act' + (node.flavor !== 'approval' ? ' on' : '')}
onClick={() => patch((n) => ({ ...n, flavor: undefined }))}
>
活动点
</button>
<button
className={'apr' + (node.flavor === 'approval' ? ' on' : '')}
onClick={() => patch((n) => ({ ...n, flavor: 'approval' as const }))}
>
审批点
</button>
</div>
</div>
)}
{node.kind === 'activity' && node.flavor === 'approval' && (
<div className="g-tbrow">
{/*
* 审批模板 = 从活动库链接一张**审批活动** —— 审批人看哪几项、怎么催、说什么。
* 不选也能跑(旧行为:审批卡把载荷全摊开);谁来审仍是运营时按组轮值。
*/}
<em>审批模板 · 链接自活动库</em>
<span className="v col">
<select
className="g-tbsel"
value={node.templateId ?? ''}
onChange={(e) =>
patch((n) => ({ ...n, templateId: e.target.value === '' ? undefined : e.target.value }))
}
>
<option value="">不引模板 —— 审批卡把载荷全摊开</option>
{approvalTemplates.map((t) => (
<option key={t.id} value={t.id}>
{t.name} · {t.id}
</option>
))}
</select>
<button className="g-tblink" onClick={() => nav.jump('library', 'shelf')}>
去活动库配审批活动 <Ico name="arrow-right" s={11} />
</button>
</span>
</div>
)}
{node.kind === 'activity' && node.flavor !== 'approval' && (
<div className="g-tbrow">
{/* 实现 = **从活动库链接一个活动**:节点不含实现,链接才算装载(激活判据之二) */}
<em>实现 · 链接自活动库</em>
<span className="v col">
<select
className="g-tbsel"
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>
{refAct !== undefined ? (
/* 数据契约归活动自己 —— 要看它吃什么吐什么,去活动库看 */
<button
className="g-tblink"
onClick={() => nav.jump('library', 'shelf', { k: 'act', id: refAct.activityId })}
>
去活动库看契约 <Ico name="arrow-right" s={11} />
</button>
) : (
<small className="warn">图可以照画照存,激活前链上即可</small>
)}
</span>
</div>
)}
{outEdges.length > 0 && (
<>
{/*
* 出边 = 路由小表:**分支归发起它的这个节点管**(Mastra 的
* `.branch([[条件, 下一步], …])` 挂在上一步上)。三条重复的长提示收成一句;
* 条件留空即默认兜底(placeholder 说),边本身只是画布上的一条线。
*/}
<div className="g-tbband">
出边 · {outEdges.length === 1 ? '直连' : '命中即走'}
<span className="x">
{node.kind === 'activity' && node.flavor === 'approval'
? '条件读审批裁决 verdict'
: '条件读本节点的输出字段'}
</span>
</div>
{outEdges.map(({ edge, index }) => {
const target = nodes.find((n) => n.id === edge.to);
return (
<div key={edge.code ?? index} className="g-tbedge">
<span className="rt">
<b>{edge.code ?? `边${index + 1}`}</b>
<Ico name="arrow-right" s={11} />
<span className="to">{edge.to}</span>
{target !== undefined && target.label !== '' && <span className="nm">{target.label}</span>}
</span>
<input
className="g-tbin mono"
value={edge.condition ?? ''}
placeholder={
outEdges.length === 1
? '无条件直连'
: node.kind === 'activity' && node.flavor === 'approval'
? 'verdict = "pass" · 留空 = 默认兜底'
: 'needAlternative = true · 留空 = 默认兜底'
}
onChange={(e) => patchEdge(index, (x) => ({ ...x, condition: e.target.value }))}
/>
</div>
);
})}
</>
)}
</div>
)}
</div>
</div>
</div>
</div>
</div>
</Sec>
</>
);
}