流程包台面重做:版本收进包内部,启停是运行时开关

按你定的六条做:

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

## ② 版本收在流程内部,不平铺台面
货架一流程一卡:名字 + 标识 + 「生效 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
This commit is contained in:
2026-07-28 14:35:54 +08:00
co-authored by Claude Opus 5
parent 2b1d983678
commit 710946e729
17 changed files with 1019 additions and 467 deletions
+7
View File
@@ -30,10 +30,17 @@ export type ProcessService = {
/** /**
* 保存 —— **严格检查 + 即刻生效**,不闭合抛 `NotClosedError`。 * 保存 —— **严格检查 + 即刻生效**,不闭合抛 `NotClosedError`。
* 恒产生新版本号(服务端算,不采信入参的 version);返回实际落的那一版。 * 恒产生新版本号(服务端算,不采信入参的 version);返回实际落的那一版。
* 落下来的新版**即为生效版**:同流的旧生效版随之停用(一条流恰一个生效版本)。
*/ */
savePackage(pkg: ProcessPackage): Promise<ProcessPackage>; savePackage(pkg: ProcessPackage): Promise<ProcessPackage>;
listPackages(): Promise<ProcessPackage[]>; listPackages(): Promise<ProcessPackage[]>;
getPackage(id: string, version?: number): Promise<ProcessPackage | undefined>; getPackage(id: string, version?: number): Promise<ProcessPackage | undefined>;
/**
* 版本启停 —— 运行时开关,**不改定义一个字**。
* 激活某版:同流其余版本一并停用(恰一个生效);停用生效版:整条流下线,新起单被拒。
* 在途工作流按启动时钉住的版本跑完,启停对它们零影响。
*/
setPackageState(id: string, version: number, state: 'active' | 'retired'): Promise<ProcessPackage[]>;
/** 画图即校验 —— 与前端画布同源;只算不存 */ /** 画图即校验 —— 与前端画布同源;只算不存 */
validate(pkg: ProcessPackage): Promise<ClosureReport>; validate(pkg: ProcessPackage): Promise<ClosureReport>;
start(input: StartWorkflowInput): Promise<WorkflowRecord>; start(input: StartWorkflowInput): Promise<WorkflowRecord>;
+2
View File
@@ -37,6 +37,8 @@ export type WorkflowRecord = {
}; };
export type ProcessStore = { export type ProcessStore = {
savePackage(pkg: ProcessPackage): Promise<void>; savePackage(pkg: ProcessPackage): Promise<void>;
/** 只改状态,不动定义 —— state 在列上与 definition 里各存一份,两处要一起改 */
setPackageState(id: string, version: number, state: 'active' | 'retired'): Promise<void>;
getPackage(id: string, version?: number): Promise<ProcessPackage | undefined>; getPackage(id: string, version?: number): Promise<ProcessPackage | undefined>;
listPackages(): Promise<ProcessPackage[]>; listPackages(): Promise<ProcessPackage[]>;
insertWorkflow(record: WorkflowRecord): Promise<void>; insertWorkflow(record: WorkflowRecord): Promise<void>;
+212
View File
@@ -1065,6 +1065,218 @@ export declare const saveProcessRoute: {
}, z.core.$strip>; }, z.core.$strip>;
}, z.core.$strip>; }, z.core.$strip>;
}; };
/**
* 版本启停 —— 运行时开关,不改定义一个字。
* 激活某版:同流其余版本一并停用(一条流恰一个生效版本);停用生效版:整条流下线,新起单被拒。
* 在途工作流按启动时钉住的版本跑完,启停对它们零影响。
* 返回这条流启停后的全部版本(新→旧),界面直接以它刷新版本列表。
*/
export declare const setProcessStateRoute: {
readonly method: "POST";
readonly path: "/api/gov/process/:id/:version/state";
readonly params: z.ZodObject<{
id: z.ZodString;
version: z.ZodString;
}, z.core.$strip>;
readonly body: z.ZodObject<{
state: z.ZodEnum<{
active: "active";
retired: "retired";
}>;
}, z.core.$strip>;
readonly output: z.ZodObject<{
packages: z.ZodArray<z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "ProcessPackageId", "out">;
name: z.ZodString;
version: z.ZodNumber;
state: z.ZodEnum<{
active: "active";
retired: "retired";
}>;
callInterface: z.ZodObject<{
input: z.ZodRecord<z.ZodString, z.ZodUnknown>;
output: z.ZodRecord<z.ZodString, z.ZodUnknown>;
}, z.core.$strip>;
nodes: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"start">;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"end">;
output: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"activity">;
activityId: z.core.$ZodBranded<z.ZodString, "ActivityId", "out">;
in: z.ZodOptional<z.ZodArray<z.ZodString>>;
out: z.ZodOptional<z.ZodArray<z.ZodString>>;
activityVersion: z.ZodOptional<z.ZodString>;
owner: z.ZodObject<{
position: z.ZodString;
strategy: z.ZodDefault<z.ZodEnum<{
assigned: "assigned";
sole: "sole";
rotation: "rotation";
"follow-source": "follow-source";
}>>;
staffId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
}, z.core.$strip>;
failure: z.ZodDefault<z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"terminate">;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"reroute">;
edgeId: z.ZodString;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"escalate">;
approvalNodeId: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"tolerate">;
}, z.core.$strip>], "kind">>;
message: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"approval">;
approver: z.ZodObject<{
position: z.ZodString;
strategy: z.ZodDefault<z.ZodEnum<{
assigned: "assigned";
sole: "sole";
rotation: "rotation";
"follow-source": "follow-source";
}>>;
staffId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
}, z.core.$strip>;
reject: z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"reopen">;
nodeId: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
mapping: z.ZodDefault<z.ZodArray<z.ZodObject<{
target: z.ZodString;
source: z.ZodDiscriminatedUnion<[z.ZodObject<{
from: z.ZodLiteral<"output-port">;
port: z.ZodString;
path: z.ZodString;
}, z.core.$strip>, z.ZodObject<{
from: z.ZodLiteral<"workflow-var">;
name: z.ZodString;
}, z.core.$strip>, z.ZodObject<{
from: z.ZodLiteral<"initiation">;
path: z.ZodString;
}, z.core.$strip>, z.ZodObject<{
from: z.ZodLiteral<"constant">;
value: z.ZodUnknown;
}, z.core.$strip>, z.ZodObject<{
from: z.ZodLiteral<"expression">;
sfeel: z.ZodString;
}, z.core.$strip>], "from">;
}, z.core.$strip>>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"terminate">;
}, z.core.$strip>], "kind">;
nudge: z.ZodOptional<z.ZodObject<{
deadline: z.ZodString;
steps: z.ZodDefault<z.ZodArray<z.ZodObject<{
after: z.ZodString;
action: z.ZodEnum<{
nudge: "nudge";
escalate: "escalate";
}>;
}, z.core.$strip>>>;
}, z.core.$strip>>;
message: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"gateway">;
gateway: z.ZodEnum<{
parallel: "parallel";
exclusive: "exclusive";
}>;
mode: z.ZodEnum<{
join: "join";
fork: "fork";
}>;
basis: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>], "kind">>;
edges: z.ZodArray<z.ZodObject<{
id: z.ZodString;
from: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
to: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
condition: z.ZodOptional<z.ZodString>;
mapping: z.ZodDefault<z.ZodArray<z.ZodObject<{
target: z.ZodString;
source: z.ZodDiscriminatedUnion<[z.ZodObject<{
from: z.ZodLiteral<"output-port">;
port: z.ZodString;
path: z.ZodString;
}, z.core.$strip>, z.ZodObject<{
from: z.ZodLiteral<"workflow-var">;
name: z.ZodString;
}, z.core.$strip>, z.ZodObject<{
from: z.ZodLiteral<"initiation">;
path: z.ZodString;
}, z.core.$strip>, z.ZodObject<{
from: z.ZodLiteral<"constant">;
value: z.ZodUnknown;
}, z.core.$strip>, z.ZodObject<{
from: z.ZodLiteral<"expression">;
sfeel: z.ZodString;
}, z.core.$strip>], "from">;
}, z.core.$strip>>>;
label: z.ZodOptional<z.ZodString>;
tone: z.ZodOptional<z.ZodEnum<{
ok: "ok";
bad: "bad";
}>>;
reopen: z.ZodDefault<z.ZodBoolean>;
}, z.core.$strip>>;
variables: z.ZodDefault<z.ZodArray<z.ZodObject<{
name: z.ZodString;
schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
}, z.core.$strip>>>;
description: z.ZodOptional<z.ZodString>;
diagram: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
}, z.core.$strip>>;
}, z.core.$strip>;
};
/** 画图即校验 —— 与前端画布同源;保存前随手看还差几条,不改任何状态 */ /** 画图即校验 —— 与前端画布同源;保存前随手看还差几条,不改任何状态 */
export declare const validateProcessRoute: { export declare const validateProcessRoute: {
readonly method: "POST"; readonly method: "POST";
+18
View File
@@ -25,6 +25,7 @@ import {
listTriggersRoute, listTriggersRoute,
registerToolRoute, registerToolRoute,
saveProcessRoute, saveProcessRoute,
setProcessStateRoute,
reassignTaskRoute, reassignTaskRoute,
resumeTaskRoute, resumeTaskRoute,
staffIdSchema, staffIdSchema,
@@ -290,6 +291,23 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
} }
}); });
/** 版本启停:激活即独占,停用即下线 —— 在途按启动版本跑完,零影响 */
app.post(setProcessStateRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const parsed = setProcessStateRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '启停状态不合协议' };
return c.json(body, 400);
}
const packages = await container.process.setPackageState(
c.req.param('id'),
Number(c.req.param('version')),
parsed.data.state,
);
return c.json({ packages });
});
/** 画图即校验 —— 与前端画布同源 */ /** 画图即校验 —— 与前端画布同源 */
app.post(validateProcessRoute.path, async (c) => { app.post(validateProcessRoute.path, async (c) => {
const denied = await governProcess(c.get('account')); const denied = await governProcess(c.get('account'));
+2 -4
View File
@@ -747,11 +747,9 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
process: { process: {
start: async (input) => process.start(input), start: async (input) => process.start(input),
requiredInputs: async (packageId) => { requiredInputs: async (packageId) => {
// 与起单取版同口径:读**最新一版**的调用接口(没有草稿了,存进来的都生效) // 与起单取版同口径:读**生效版本**的调用接口(一条流恰一个生效版本)
const all = await process.listPackages(); const all = await process.listPackages();
const pkg = all const pkg = all.find((x) => x.id === packageId && x.state === 'active');
.filter((x) => x.id === packageId && x.state !== 'retired')
.sort((a, b) => b.version - a.version)[0];
const input = pkg?.callInterface.input as { required?: string[] } | undefined; const input = pkg?.callInterface.input as { required?: string[] } | undefined;
return input?.required ?? []; return input?.required ?? [];
}, },
+5 -3
View File
@@ -11,7 +11,7 @@
- 错误:`NotClosedError` `PositionVacantError` - 错误:`NotClosedError` `PositionVacantError`
- 端口类型:`ProcessStore` `ActivityDirectory` `OrgDirectory` `TaskGateway` `ApprovalGateway` - 端口类型:`ProcessStore` `ActivityDirectory` `OrgDirectory` `TaskGateway` `ApprovalGateway`
`Sfeel` `FlowEventPublisher` `ReceiptNotifier` `Sfeel` `FlowEventPublisher` `ReceiptNotifier`
- 用例工厂:`createProcessService(deps): ProcessService` - 用例工厂:`createProcessService(deps): ProcessService`(含 `setPackageState` 版本启停)
- 纯函数:`checkClosure` `closed` `resolveOwner` `aggregateWorkflowState` - 纯函数:`checkClosure` `closed` `resolveOwner` `aggregateWorkflowState`
(`resolveOwner` 的 `bound` 入参 = 已校验的工作组绑定,ADR-011) (`resolveOwner` 的 `bound` 入参 = 已校验的工作组绑定,ADR-011)
`isDigested` `nextInstanceNumber` `selectEdges` `assemble` `joinReady` `isDigested` `nextInstanceNumber` `selectEdges` `assemble` `joinReady`
@@ -28,8 +28,10 @@
- **不闭合不得保存**:未完成边、必填未映射、类型冲突、孤立节点、异或出边缺条件、 - **不闭合不得保存**:未完成边、必填未映射、类型冲突、孤立节点、异或出边缺条件、
汇合同名字段来源不明、容忍分支被下游必填依赖 —— 任一即 `closed = false`,`savePackage` 抛 `NotClosedError` 汇合同名字段来源不明、容忍分支被下游必填依赖 —— 任一即 `closed = false`,`savePackage` 抛 `NotClosedError`
- **严格检查 + 保存即生效,没有发布机制**(产品决策 2026-07-28): - **严格检查 + 保存即生效,没有发布机制**(产品决策 2026-07-28):
没有草稿态、没有发布门。存进来的每一版都能起单;生效与否由**运行时**决定 —— 没有草稿态、没有发布门。保存 = 存为新版 + 即刻生效。
新工作流起单取最新版,在途的按启动时快照的版本号读定义、跑完旧编排。 - **一条流恰一个生效版本**(产品决策 2026-07-28):`setPackageState` 激活即独占
(同流其余版本一并停用);停用生效版 = 整条流下线,新起单被拒。
起单取**生效版**;在途工作流按启动时钉住的版本号读定义 —— 启停对它们零影响。
- **保存恒新增版本号**(服务端算,不采信入参):工作流按 (包id, 版本) 读定义, - **保存恒新增版本号**(服务端算,不采信入参):工作流按 (包id, 版本) 读定义,
就地覆盖会改掉在途任务正在读的编排。store 层因此**只插不改**,撞键宁可报错也不静默覆盖。 就地覆盖会改掉在途任务正在读的编排。store 层因此**只插不改**,撞键宁可报错也不静默覆盖。
- **求值语义前后端同源**:画布的即时校验与后端裁决共用 `checkClosure` - **求值语义前后端同源**:画布的即时校验与后端裁决共用 `checkClosure`
@@ -45,6 +45,7 @@ function harness(validationErrors: readonly string[]): Harness {
const deps: ProcessServiceDeps = { const deps: ProcessServiceDeps = {
store: { store: {
savePackage: async () => undefined, savePackage: async () => undefined,
setPackageState: async () => undefined,
getPackage: async () => PKG, getPackage: async () => PKG,
listPackages: async () => [PKG], listPackages: async () => [PKG],
insertWorkflow: async (record) => { insertWorkflow: async (record) => {
+7
View File
@@ -55,10 +55,17 @@ export type ProcessService = {
/** /**
* 保存 —— **严格检查 + 即刻生效**,不闭合抛 `NotClosedError`。 * 保存 —— **严格检查 + 即刻生效**,不闭合抛 `NotClosedError`。
* 恒产生新版本号(服务端算,不采信入参的 version);返回实际落的那一版。 * 恒产生新版本号(服务端算,不采信入参的 version);返回实际落的那一版。
* 落下来的新版**即为生效版**:同流的旧生效版随之停用(一条流恰一个生效版本)。
*/ */
savePackage(pkg: ProcessPackage): Promise<ProcessPackage>; savePackage(pkg: ProcessPackage): Promise<ProcessPackage>;
listPackages(): Promise<ProcessPackage[]>; listPackages(): Promise<ProcessPackage[]>;
getPackage(id: string, version?: number): Promise<ProcessPackage | undefined>; getPackage(id: string, version?: number): Promise<ProcessPackage | undefined>;
/**
* 版本启停 —— 运行时开关,**不改定义一个字**。
* 激活某版:同流其余版本一并停用(恰一个生效);停用生效版:整条流下线,新起单被拒。
* 在途工作流按启动时钉住的版本跑完,启停对它们零影响。
*/
setPackageState(id: string, version: number, state: 'active' | 'retired'): Promise<ProcessPackage[]>;
/** 画图即校验 —— 与前端画布同源;只算不存 */ /** 画图即校验 —— 与前端画布同源;只算不存 */
validate(pkg: ProcessPackage): Promise<ClosureReport>; validate(pkg: ProcessPackage): Promise<ClosureReport>;
@@ -41,6 +41,22 @@ export function createProcessStore(db: Db): ProcessStore {
.values({ id: pkg.id, version: pkg.version, name: pkg.name, state: pkg.state, definition: pkg }); .values({ id: pkg.id, version: pkg.version, name: pkg.name, state: pkg.state, definition: pkg });
}, },
async setPackageState(id, version, state) {
const rows = await db
.select()
.from(processPackageTable)
.where(and(eq(processPackageTable.id, id), eq(processPackageTable.version, version)))
.limit(1);
const row = rows[0];
if (row === undefined) return;
// state 在列上与 definition(jsonb)里各有一份 —— 读包走 definition,列供查询;两处必须一起改
const definition = { ...(row.definition as Record<string, unknown>), state };
await db
.update(processPackageTable)
.set({ state, definition })
.where(and(eq(processPackageTable.id, id), eq(processPackageTable.version, version)));
},
async getPackage(id, version) { async getPackage(id, version) {
const conditions = [eq(processPackageTable.id, id)]; const conditions = [eq(processPackageTable.id, id)];
if (version !== undefined) conditions.push(eq(processPackageTable.version, version)); if (version !== undefined) conditions.push(eq(processPackageTable.version, version));
@@ -47,20 +47,17 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
} }
/** /**
* 起单取版:未指定版本时取**最新的一版**。 * 起单取版:取这条流的**生效版本** —— 一条流恰一个(或零个)生效版本。
* *
* 从前这里取「最新已发布」,因为草稿是编排中的半成品、运行时不该看见它。 * 「生效」是运行时开关,不是内容属性:保存新版即生效(旧生效版随之停用),
* 现在没有草稿了 —— 「严格检查 + 保存生效」,不闭合根本存不进来, * 也可以手动把生效切回旧版本、或全部停用让整条流下线。
* 所以库里的每一版都是能起单的。生效就是这个意思:**新任务起单时取到最新版**, * 在途工作流按启动时钉住的版本号读定义,启停对它们零影响 —— 这正是
* 在途的按启动时快照的版本号读定义、跑完旧编排。 * 「切换版本不影响旧实例」的机制保证:实例上钉的是版本号,不是「当前生效」指针。
* `retired` 的版本拒起单(留给「这一版不再用了」)。
*/ */
async function latest(id: string): Promise<ProcessPackage> { async function activeVersion(id: string): Promise<ProcessPackage> {
const all = await deps.store.listPackages(); const all = await deps.store.listPackages();
const hit = all const hit = all.find((pkg) => pkg.id === id && pkg.state === 'active');
.filter((pkg) => pkg.id === id && pkg.state !== 'retired') if (hit === undefined) throw new Error(`流程包无生效版本,不得起单:${id} —— 去流程包里激活一个版本`);
.sort((a, b) => b.version - a.version)[0];
if (hit === undefined) throw new Error(`流程包无可用版本,不得起单:${id}`);
return hit; return hit;
} }
@@ -295,9 +292,33 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
if (!report.closed) throw new NotClosedError(report); if (!report.closed) throw new NotClosedError(report);
const saved: ProcessPackage = { ...pkg, version: await nextVersion(pkg.id), state: 'active' }; const saved: ProcessPackage = { ...pkg, version: await nextVersion(pkg.id), state: 'active' };
await deps.store.savePackage(saved); await deps.store.savePackage(saved);
// 新版即生效 → 旧生效版随之停用:一条流恰一个生效版本(要回退就去版本列表切)
const siblings = await deps.store.listPackages();
for (const sib of siblings) {
if (sib.id === saved.id && sib.version !== saved.version && sib.state === 'active') {
await deps.store.setPackageState(sib.id, sib.version, 'retired');
}
}
return saved; return saved;
}, },
async setPackageState(id, version, state) {
const all = await deps.store.listPackages();
const target = all.find((p) => p.id === id && p.version === version);
if (target === undefined) throw new Error(`流程包版本不存在:${id}@${version}`);
if (state === 'active') {
// 激活即独占:同流其余生效版一并停用 —— 「一次只能激活一个版本」
for (const sib of all) {
if (sib.id === id && sib.version !== version && sib.state === 'active') {
await deps.store.setPackageState(id, sib.version, 'retired');
}
}
}
await deps.store.setPackageState(id, version, state);
const after = await deps.store.listPackages();
return after.filter((p) => p.id === id).sort((a, b) => b.version - a.version);
},
async listPackages() { async listPackages() {
return deps.store.listPackages(); return deps.store.listPackages();
}, },
@@ -320,9 +341,10 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
async start(input: StartWorkflowInput) { async start(input: StartWorkflowInput) {
const pkg = const pkg =
input.packageVersion === undefined input.packageVersion === undefined
? await latest(input.packageId) ? await activeVersion(input.packageId)
: await requirePackage(input.packageId, input.packageVersion); : await requirePackage(input.packageId, input.packageVersion);
if (pkg.state === 'retired') throw new Error(`该版本已停用,不得起单:${pkg.id}@${pkg.version}`); // 指名道姓要某一版也得是生效版 —— 「一次只能激活一个版本」对新起单是硬约束
if (pkg.state !== 'active') throw new Error(`该版本未生效,不得起单:${pkg.id}@${pkg.version}`);
// 先校验后建单:不合调用接口契约的输入**一条工作流都不留**。 // 先校验后建单:不合调用接口契约的输入**一条工作流都不留**。
// (校验若放在建单之后,失败就会留下一条永远推不动的在途单,污染在途口径。) // (校验若放在建单之后,失败就会留下一条永远推不动的在途单,污染在途口径。)
+2
View File
@@ -32,6 +32,8 @@ export type WorkflowRecord = {
export type ProcessStore = { export type ProcessStore = {
savePackage(pkg: ProcessPackage): Promise<void>; savePackage(pkg: ProcessPackage): Promise<void>;
/** 只改状态,不动定义 —— state 在列上与 definition 里各存一份,两处要一起改 */
setPackageState(id: string, version: number, state: 'active' | 'retired'): Promise<void>;
getPackage(id: string, version?: number): Promise<ProcessPackage | undefined>; getPackage(id: string, version?: number): Promise<ProcessPackage | undefined>;
listPackages(): Promise<ProcessPackage[]>; listPackages(): Promise<ProcessPackage[]>;
+18
View File
@@ -503,3 +503,21 @@
.g-mixbar-legend { display: flex; flex-wrap: wrap; gap: 3px 10px; font-size: 10px; color: var(--ink-3); } .g-mixbar-legend { display: flex; flex-wrap: wrap; gap: 3px 10px; font-size: 10px; color: var(--ink-3); }
.g-mixbar-legend b { font-family: var(--mono); font-weight: 700; color: var(--ink-2); } .g-mixbar-legend b { font-family: var(--mono); font-weight: 700; color: var(--ink-2); }
.g-mixbar-legend i { display: inline-block; width: 7px; height: 7px; border-radius: 2px; margin-right: 4px; } .g-mixbar-legend i { display: inline-block; width: 7px; height: 7px; border-radius: 2px; margin-right: 4px; }
/* ── 流程详情:左版本列 + 右画布(补充)──────────────────────────────
版本收在流程内部:左列是这条流的全部版本(恰一个生效),点谁看谁的图;
属性区从右侧窄栏挪到图下整行(g-wide),声明式契约细节再往下。 */
.g-flowlab { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(0, 2.2fr); gap: 12px; align-items: start; }
.g-vers { position: sticky; top: 2px; display: flex; flex-direction: column; gap: 8px; }
.g-ver { display: flex; align-items: center; gap: 8px; padding: 10px 12px; background: var(--card); border: 1px solid var(--cline); border-radius: 11px; cursor: pointer; font: inherit; text-align: left; transition: border-color var(--t-fast), box-shadow var(--t-fast); }
.g-ver:hover { border-color: #c9d6e6; }
.g-ver.on { border-color: var(--brand); box-shadow: 0 0 0 2px var(--brand-wash); }
.g-ver > b { font-family: var(--mono); font-size: 13px; color: var(--ink); }
.g-ver .m { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.g-ver .cnt { font-size: 10.5px; font-family: var(--mono); color: var(--ink-4); white-space: nowrap; }
.g-ver-act { border: 1px solid var(--cline); background: #fff; border-radius: 8px; padding: 3px 9px; font: inherit; font-size: 11px; font-weight: 600; color: var(--ink-2); cursor: pointer; }
.g-ver-act:hover { border-color: #c9d6e6; }
.g-ver-act.t-go { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 35%, var(--cline)); }
/* 属性区整行铺开:g-side 的窄栏约束(单列 grid、sticky)在这里都不要 */
.g-wide { position: static; margin-top: 12px; }
.g-wide .g-props { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
@@ -1,21 +1,25 @@
/** /**
* feature `gov-process` —— 工作流管理(设计态):图编排 + 活动契约定义。 * feature `gov-process` —— 流程包(设计态):**一条流一个包,版本收在包内部**。
* *
* 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 的 * 台面三页签:总览 · 流程包 · 触发器。
* `WorkflowsHome` / `PkgPage` / `EdgesPage` 平移:DOM 结构、类名(`g-*`)与文案原样保留, * 货架一流程一卡(不按版本平铺);进详情后左 1/3 是版本列表、右 2/3 是流程图,
* mock 常量换成服务端投影(README 平移规则 1/2/4)。 * 图下是节点属性,再往下才是声明式的契约细节(调用接口 · 边映射 · 触发器)。
* *
* 编排即定契约:连线即映射、画图即校验、**边不闭合不得保存**; * 版本是**运行时开关**,不是发布流程:
* 图画完可导出活动开发的依赖文件 —— 开发者对着声明在系统外开发。 * · 保存 = 严格检查 + 存为新版 + 即刻生效(不闭合存不进来)
* · 一条流恰一个生效版本;可把生效切回旧版,也可全部停用让整条流下线
* · 在途工作流按启动时钉住的版本跑完 —— 启停对它们零影响
*/ */
import { useEffect, useMemo, useRef, useState } from 'react'; import { useMemo, useRef, useState, useEffect } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import type { ClosureReport, ProcessPackage, ProcessNode } from '@contracts'; import type { ClosureReport, ProcessPackage, ProcessNode } from '@contracts';
import { saveProcessRoute, validateProcessRoute, TRIGGER_KIND_LABEL } from '@contracts'; import { processPackageSchema, saveProcessRoute, setProcessStateRoute, validateProcessRoute, TRIGGER_KIND_LABEL } from '@contracts';
import { call } from '@web-shared/typed-fetch.ts'; import { call } from '@web-shared/typed-fetch.ts';
import { Ico } from '@web-shared/Ico.tsx'; import { Ico } from '@web-shared/Ico.tsx';
import type { AskSpec } from '@web-shared/gov/primitives.tsx';
import { import {
Acts, Acts,
AskBand,
Card, Card,
Chip, Chip,
Entry, Entry,
@@ -28,10 +32,10 @@ import {
PageHead, PageHead,
Prop, Prop,
QueryGate, QueryGate,
Rows,
Sec, Sec,
Seg, Seg,
St, St,
Todo,
} from '@web-shared/gov/primitives.tsx'; } from '@web-shared/gov/primitives.tsx';
import type { Nav } from '@web-shared/gov/gov-route.ts'; import type { Nav } from '@web-shared/gov/gov-route.ts';
import { useGovActivities, useGovPackages, useGovPkgReports, useGovStaff, useGovTriggers } from '@web-shared/gov/gov-api.ts'; import { useGovActivities, useGovPackages, useGovPkgReports, useGovStaff, useGovTriggers } from '@web-shared/gov/gov-api.ts';
@@ -56,61 +60,179 @@ function edgeCheckOf(pkg: ProcessPackage, report: ClosureReport | undefined) {
return { closed: total - badEdges.size, total, issue }; return { closed: total - badEdges.size, total, issue };
} }
/* ---------------- ③ 工作流管理 ---------------- */ /** 一条流的版本按新→旧;生效版恰一个(或零个 —— 整条流下线) */
function flowsOf(packages: readonly ProcessPackage[]) {
export function WorkflowsHome({ tab, nav }: HomeProps) { const byId = new Map<string, ProcessPackage[]>();
// 调起规则 = 触发器的**定义面**:谁调起这条流,与流程包一处维护(启停在运营中心) for (const p of packages) byId.set(p.id, [...(byId.get(p.id) ?? []), p]);
if (tab === 'rules') return <TriggerRulesHome nav={nav} />; return [...byId.entries()].map(([id, versions]) => {
return <PackagesHome tab={tab} nav={nav} />; const sorted = [...versions].sort((a, b) => b.version - a.version);
return { id, versions: sorted, active: sorted.find((v) => v.state === 'active') };
});
} }
function PackagesHome({ tab, nav }: HomeProps) { /* ---------------- 台面:总览 · 流程包 · 触发器 ---------------- */
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 q = useGovPackages();
const packages = q.data?.packages ?? []; const packages = q.data?.packages ?? [];
const reports = useGovPkgReports(packages); const reports = useGovPkgReports(packages);
const reportOf = (p: ProcessPackage) => reports.data?.get(`${p.id}@${p.version}`); const flows = flowsOf(packages);
const counts = (p: ProcessPackage) => ({ const totalEdges = packages.reduce((a, p) => a + p.edges.length, 0);
activity: p.nodes.filter((n) => n.kind === 'activity').length, const closedEdges = packages.reduce(
approval: p.nodes.filter((n) => n.kind === 'approval').length, (a, p) => a + edgeCheckOf(p, reports.data?.get(`${p.id}@${p.version}`)).closed,
gateway: p.nodes.filter((n) => n.kind === 'gateway').length, 0,
}); );
if (tab === 'pkgs') {
return ( return (
<QueryGate query={q}> <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"> <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 = {
{packages.map((p) => { activity: shown.nodes.filter((n) => n.kind === 'activity').length,
const latestOfFlow = Math.max(...packages.filter((x) => x.id === p.id).map((x) => x.version)); approval: shown.nodes.filter((n) => n.kind === 'approval').length,
const live = p.version === latestOfFlow && p.state !== 'retired'; gateway: shown.nodes.filter((n) => n.kind === 'gateway').length,
const ec = edgeCheckOf(p, reportOf(p)); };
const okEdge = ec.closed === ec.total;
const c = counts(p);
return ( return (
<button <button
key={`${p.id}@${p.version}`} key={f.id}
className={'g-tile t-' + (live ? 'ok' : 'mute')} className={'g-tile t-' + (f.active !== undefined ? 'ok' : 'mute')}
onClick={() => nav.push({ k: 'pkg', id: `${p.id}@${p.version}` })} onClick={() => nav.push({ k: 'pkg', id: f.id })}
> >
<span className="g-tile-h"> <span className="g-tile-h">
<span className="g-tile-i"> <span className="g-tile-i">
<Ico name="workflow" s={16} /> <Ico name="workflow" s={16} />
</span> </span>
<span className="g-tile-t"> <span className="g-tile-t">
<b>{p.name}</b> <b>{shown.name}</b>
<em>v{p.version}</em> <em>{f.id}</em>
</span> </span>
{live ? ( {f.active !== undefined ? (
<St tone="ok" ico="circle-check"> <St tone="ok" ico="circle-check">
生效中 生效 v{f.active.version}
</St> </St>
) : ( ) : (
<St tone="mute" ico="history"> <St tone="warn" ico="pause">
历史版本 已下线
</St> </St>
)} )}
</span> </span>
@@ -126,10 +248,10 @@ function PackagesHome({ tab, nav }: HomeProps) {
</Chip> </Chip>
</span> </span>
<span className="g-tile-f"> <span className="g-tile-f">
<St tone={okEdge ? 'ok' : 'danger'} ico={okEdge ? 'circle-check' : 'alert-circle'}> <St tone={ec.closed === ec.total ? 'ok' : 'danger'} ico={ec.closed === ec.total ? 'circle-check' : 'alert-circle'}>
边 {ec.closed}/{ec.total} 边 {ec.closed}/{ec.total}
</St> </St>
<span className="r">{p.nodes.length} 节点</span> <span className="r">{f.versions.length} 个版本</span>
</span> </span>
</button> </button>
); );
@@ -137,58 +259,44 @@ function PackagesHome({ tab, nav }: HomeProps) {
</div> </div>
</QueryGate> </QueryGate>
); );
}
const flows = new Set(packages.map((p) => p.id)).size;
const totalEdges = packages.reduce((a, p) => a + p.edges.length, 0);
const closedEdges = packages.reduce((a, p) => a + edgeCheckOf(p, reportOf(p)).closed, 0);
return (
<QueryGate query={q}>
<div className="g-kpis">
<Kpi ico="workflow" tone="ok" label="流程" value={flows} 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>严格检查 + 保存即生效:不闭合的编排存不进来;存下来即刻对**新起的**工作流生效,在途的按启动时版本跑完。保存恒新增版本,旧版本原样留着。</Note>
</QueryGate>
);
} }
/* ---------------- 流程包详情:编排工作区 + 活动契约定义 ---------------- */ /* ---------------- 流程详情:版本列表 + 画布 + 节点属性 + 契约细节 ---------------- */
export function PkgPage({ id, nav }: { id: string; nav: Nav }) { export function PkgPage({ id, nav }: { id: string; nav: Nav }) {
const [pid, versionText] = id.split('@'); // 旧深链是 `id@version`;详情现在是**流程级**的,版本在页内切,所以只认 id 段
const flowId = id.split('@')[0] ?? id;
const q = useGovPackages(); const q = useGovPackages();
const acts = useGovActivities(); const acts = useGovActivities();
const staffQ = useGovStaff(); const staffQ = useGovStaff();
const pkg = (q.data?.packages ?? []).find((p) => p.id === pid && String(p.version) === versionText);
if (pkg === undefined) { 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 ( return (
<QueryGate query={q}> <QueryGate query={q}>
<Note>流程包不存在。</Note> <Note>流程不存在。</Note>
</QueryGate> </QueryGate>
); );
} }
const siblings = (q.data?.packages ?? []).filter((x) => x.id === pkg.id);
const latestVersion = Math.max(...siblings.map((x) => x.version));
return ( return (
<Orchestrator <FlowLab
key={`${pkg.id}@${pkg.version}`} key={`${shown.id}@${shown.version}`}
pkg={pkg} pkg={shown}
versions={versions}
activeVersion={active?.version}
nav={nav} nav={nav}
activities={acts.data?.activities ?? []} activities={acts.data?.activities ?? []}
positions={(staffQ.data?.positions ?? []).map((p) => ({ id: p.id, name: p.name, abbr: p.code }))} positions={(staffQ.data?.positions ?? []).map((p) => ({ id: p.id, name: p.name, abbr: p.code }))}
nextVersion={latestVersion + 1} nextVersion={Math.max(...versions.map((x) => x.version)) + 1}
/* 生效中 = 这条流版本号最大的那一版:只有它能改(改则存成下一版);历史版本只读 */ onPick={(v) => setPinned(v)}
live={pkg.version === latestVersion && pkg.state !== 'retired'} onSaved={() => setPinned(null)}
/> />
); );
} }
@@ -203,23 +311,30 @@ type ActivityRow = {
contract: GovActivities['activities'][number]['contract']; contract: GovActivities['activities'][number]['contract'];
}; };
function Orchestrator({ function FlowLab({
pkg, pkg,
versions,
activeVersion,
nav, nav,
activities, activities,
positions, positions,
nextVersion, nextVersion,
live, onPick,
onSaved,
}: { }: {
pkg: ProcessPackage; pkg: ProcessPackage;
versions: readonly ProcessPackage[];
activeVersion: number | undefined;
nav: Nav; nav: Nav;
activities: readonly ActivityRow[]; activities: readonly ActivityRow[];
positions: SpPosition[]; positions: SpPosition[];
nextVersion: number; nextVersion: number;
live: boolean; onPick: (version: number) => void;
onSaved: () => void;
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
/** 只读的是**历史版本**(不是「已发布」)—— 生效中的那一版可以改,改则存成下一版 */ /** 只有**生效版**可编辑(改则存成下一版);其余版本只读 —— 在途任务还按它们跑 */
const live = pkg.version === activeVersion;
const readOnly = !live; const readOnly = !live;
const paradigms = useMemo(() => new Map(activities.map((a) => [a.activityId, a.paradigm])), [activities]); const paradigms = useMemo(() => new Map(activities.map((a) => [a.activityId, a.paradigm])), [activities]);
@@ -228,6 +343,7 @@ function Orchestrator({
const [sel, setSel] = useState<string | null>(null); const [sel, setSel] = useState<string | null>(null);
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const [report, setReport] = useState<ClosureReport | undefined>(undefined); const [report, setReport] = useState<ClosureReport | undefined>(undefined);
const [ask, setAsk] = useState<{ spec: AskSpec; danger?: boolean } | undefined>(undefined);
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
const nodes = graph?.nodes ?? []; const nodes = graph?.nodes ?? [];
@@ -301,10 +417,64 @@ function Orchestrator({
setDirty(false); setDirty(false);
void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] }); void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] });
void queryClient.invalidateQueries({ queryKey: ['gov', 'process-reports'] }); void queryClient.invalidateQueries({ queryKey: ['gov', 'process-reports'] });
void queryClient.invalidateQueries({ queryKey: ['gov', 'process-report', pkg.id, pkg.version] }); // 保存即成新生效版 —— 解钉,画布跟去新版本
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' }),
},
});
/** /**
* 导出依赖文件 —— 编排定契约:各活动的接口声明、责任岗位、输入/输出声明与上游装配, * 导出依赖文件 —— 编排定契约:各活动的接口声明、责任岗位、输入/输出声明与上游装配,
@@ -347,65 +517,37 @@ function Orchestrator({
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
const counts = {
activity: pkg.nodes.filter((n) => n.kind === 'activity').length,
approval: pkg.nodes.filter((n) => n.kind === 'approval').length,
gateway: pkg.nodes.filter((n) => n.kind === 'gateway').length,
};
const triggersQ = useGovTriggers(); const triggersQ = useGovTriggers();
const boundTriggers = (triggersQ.data?.triggers ?? []).filter((t) => t.processPackageId === pkg.id); 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 ( return (
<> <>
<PageHead <PageHead
ico="workflow" ico="workflow"
tone={live ? 'ok' : 'mute'} tone={activeVersion !== undefined ? 'ok' : 'warn'}
kicker={`${pkg.id} · v${pkg.version}`} kicker={pkg.id}
title={`${pkg.name} v${pkg.version}`} title={pkg.name}
chips={ chips={
<> <>
{live ? ( {activeVersion !== undefined ? (
<St tone="ok" ico="circle-check"> <St tone="ok" ico="circle-check">
生效中 生效 v{activeVersion}
</St> </St>
) : ( ) : (
<St tone="mute" ico="history"> <St tone="warn" ico="pause">
历史版本 已下线 —— 没有生效版本
</St> </St>
)} )}
<St <Chip tone="mute" ico="history">
tone={ec.closed === ec.total ? 'ok' : 'danger'} {versions.length} 个版本
ico={ec.closed === ec.total ? 'circle-check' : 'alert-circle'} </Chip>
> <St tone={ec.closed === ec.total ? 'ok' : 'danger'} ico={ec.closed === ec.total ? 'circle-check' : 'alert-circle'}>
边 {ec.closed}/{ec.total} 边 {ec.closed}/{ec.total}
</St> </St>
<Chip tone="auto" ico="git-commit">
活动 {counts.activity}
</Chip>
<Chip tone="warn" ico="shield-check">
审批 {counts.approval}
</Chip>
<Chip tone="mute" ico="git-branch">
网关 {counts.gateway}
</Chip>
</> </>
} }
acts={[ acts={[
/*
* 历史版本只读 —— 改图要回到生效中的那一版(在那儿保存会存成下一版)。
* 从前这里是「已发布不可回改 → 起草新版本 / 去草稿编辑」两条路;
* 没有草稿之后只剩一条:生效版就地改,保存即新版。
*/
...(live
? []
: [
{
label: `去 v${nextVersion - 1} 改(生效中)`,
ico: 'square-pen',
primary: true,
onClick: () => nav.push({ k: 'pkg', id: `${pkg.id}@${nextVersion - 1}` }),
},
]),
{ {
label: '导出依赖文件', label: '导出依赖文件',
ico: 'upload', ico: 'upload',
@@ -425,6 +567,8 @@ function Orchestrator({
]} ]}
/> />
{ask !== undefined && <AskBand spec={ask.spec} danger={ask.danger === true} onCancel={() => setAsk(undefined)} />}
{activeReport !== undefined && !activeReport.closed && ( {activeReport !== undefined && !activeReport.closed && (
<Note tone="warn"> <Note tone="warn">
<b>未闭合:</b> <b>未闭合:</b>
@@ -433,42 +577,89 @@ function Orchestrator({
)} )}
{report !== undefined && report.closed && <Note tone="ok">闭合校验通过。</Note>} {report !== undefined && report.closed && <Note tone="ok">闭合校验通过。</Note>}
{/* 编排工作区:左 2/3 画布(整图显示,不裁切)+ 右 1/3 属性卡(就地填写,不下钻) */} {/* 编排工作区:左 1/3 版本列表 + 右 2/3 画布 —— 版本收在流程内部,不平铺台面 */}
<Sec <Sec
ico="route" ico="route"
title="编排工作区" title="编排工作区"
extra={readOnly ? '历史版本只读 —— 改图回到生效中那一版' : '点画布上的活动 → 右栏属性卡直接改;「编辑」进手绘模式;保存即生效'} 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} />
{readOnly && ( 版本
<div className="g-todo"> <span className="x">恰一个生效 · 切换不动在途</span>
<Todo
tone="warn"
ico="history"
title={`v${pkg.version} 是历史版本,画布只读`}
desc={`在途任务还按这一版跑完,所以它不可改。改图去 v${nextVersion - 1}(生效中那一版)——`
+ `在那儿保存会存成 v${nextVersion},本版与在途工作流一概不动。`}
acts={[
{
label: `去 v${nextVersion - 1} 改`,
ico: 'square-pen',
onClick: () => nav.push({ k: 'pkg', id: `${pkg.id}@${nextVersion - 1}` }),
},
]}
/>
</div> </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>
)} )}
<div className="g-orch"> <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="g-canvas" ref={canvasRef}>
<div className="process-desk"> <div className="process-desk">
<div className="process-doc"> <div className="process-doc">
<FlowFigureBlock <FlowFigureBlock
key={`${pkg.id}@${pkg.version}`} key={`${pkg.id}@${pkg.version}`}
title={pkg.name} title={`${pkg.name} v${pkg.version}`}
oneLiner={`v${pkg.version} · 蓝 = 标准活动 · 绿 = 案例活动 · 琥珀 = 审批点 · 菱形 = 网关 · 红 = 卡点`} oneLiner={`蓝 = 标准活动 · 绿 = 案例活动 · 琥珀 = 审批点 · 菱形 = 网关 · 红 = 卡点`}
graph={graph} graph={graph}
drilledIds={nodes.filter((n) => n.kind === 'activity').map((n) => n.id)} drilledIds={nodes.filter((n) => n.kind === 'activity').map((n) => n.id)}
positions={positions} positions={positions}
@@ -497,12 +688,14 @@ function Orchestrator({
</div> </div>
</div> </div>
</div> </div>
</div>
<aside className="g-side"> {/* 图下:节点属性 —— 点画布或点节点条,属性就地填 */}
<div className="g-side g-wide">
<div className="g-side-h"> <div className="g-side-h">
<Ico name="sliders-horizontal" s={13} /> <Ico name="sliders-horizontal" s={13} />
节点属性 节点属性
<span className="x">{dirty ? '有改动待保存' : '就地填写'}</span> <span className="x">{readOnly ? `v${pkg.version} 只读` : dirty ? '有改动待保存' : '点节点就地填写'}</span>
</div> </div>
<div className="g-picker"> <div className="g-picker">
{nodes {nodes
@@ -519,7 +712,7 @@ function Orchestrator({
))} ))}
</div> </div>
{node === null ? ( {node === null ? (
<Note>点画布上的活动(或上面的节点条)选一个 —— 属性就在这一栏填,网关判据与卡点去向同样在此改。</Note> <Note>点画布上的节点(或上面的节点条)选一个 —— 属性就在下面填,网关判据与卡点去向同样在此改。</Note>
) : ( ) : (
<> <>
<div className="g-props"> <div className="g-props">
@@ -598,7 +791,7 @@ function Orchestrator({
patch((n) => ({ ...n, owner: e.target.value === '' ? undefined : e.target.value })) patch((n) => ({ ...n, owner: e.target.value === '' ? undefined : e.target.value }))
} }
> >
<option value="">未声明 · 发布前须补齐</option> <option value="">未指定 —— 保存前选一个岗位</option>
{positions.map((q2) => ( {positions.map((q2) => (
<option key={q2.id} value={q2.abbr}> <option key={q2.id} value={q2.abbr}>
{q2.abbr} · {q2.name} {q2.abbr} · {q2.name}
@@ -615,8 +808,8 @@ function Orchestrator({
)} )}
{node.owner === undefined && ( {node.owner === undefined && (
<Note tone="warn"> <Note tone="warn">
工作流不设负责人,责任只落在节点上:此处不声明岗位,起单时就解析不出 这个节点还没指定岗位 —— 不指定,起单时就不知道
{node.flavor === 'approval' ? '审批人' : '任务负责人'}。 {node.flavor === 'approval' ? '审批单' : '任务'}该派给谁。
</Note> </Note>
)} )}
</Prop> </Prop>
@@ -699,7 +892,7 @@ function Orchestrator({
</button> </button>
</> </>
) : ( ) : (
<Note tone="warn">名称未匹配到已安装活动 —— 发布前须先安装并过跨包契约相容核对。</Note> <Note tone="warn">还没对上活动库里已安装的活动 —— 先去活动库安装;保存时会核对契约相容。</Note>
)} )}
</Prop> </Prop>
)} )}
@@ -727,10 +920,10 @@ function Orchestrator({
), ),
impact: { impact: {
who: okEdge who: okEdge
? `存为 v${nextVersion},此后**新起**的工作流用它;在途的按启动时版本跑完` ? `存为 v${nextVersion} 并成为唯一生效版;在途的按启动时版本跑完`
: '被拒存 —— 先补齐边映射', : '被拒存 —— 先补齐边映射',
undo: `v${pkg.version} 原样留着,可回到那一版再改`, undo: `v${pkg.version} 原样留着,可把生效切回去`,
when: okEdge ? '确认即生效,没有发布这一步' : '存不进去', when: okEdge ? '确认即生效' : '存不进去',
}, },
okLabel: okEdge ? '确认保存并生效' : '我知道了', okLabel: okEdge ? '确认保存并生效' : '我知道了',
onOk: () => { onOk: () => {
@@ -743,17 +936,17 @@ function Orchestrator({
/> />
</> </>
)} )}
</aside>
</div> </div>
</Sec> </Sec>
<Sec ico="git-merge" title="边映射校验" extra="逐边核对占一页"> {/* 声明式契约细节:调用接口 · 边映射 · 触发器 —— 图之下,按查看频率排 */}
<Sec ico="file-text" title="契约细节" extra="声明式 —— 图定结构,这里定字段与调起">
<div className="g-entries"> <div className="g-entries">
<Entry <Entry
ico="git-merge" ico="git-merge"
tone={ec.closed === ec.total ? 'ok' : 'danger'} tone={ec.closed === ec.total ? 'ok' : 'danger'}
title="边映射校验" title="边映射校验"
desc="上游输出口 → 下游契约字段 · 类型与必填" desc="上游输出口 → 下游契约字段 · 类型与必填,逐边核对"
meta={ meta={
<Chip tone={ec.closed === ec.total ? 'ok' : 'danger'}> <Chip tone={ec.closed === ec.total ? 'ok' : 'danger'}>
{ec.closed}/{ec.total} {ec.closed}/{ec.total}
@@ -762,9 +955,22 @@ function Orchestrator({
onClick={() => nav.push({ k: 'edges', id: `${pkg.id}@${pkg.version}` })} onClick={() => nav.push({ k: 'edges', id: `${pkg.id}@${pkg.version}` })}
/> />
</div> </div>
</Sec>
<Sec ico="zap" title="调起它的触发器" extra="平级、不入包版本 —— 增删启停不改本包任何版本"> <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 ? ( {boundTriggers.length > 0 ? (
<NList> <NList>
{boundTriggers.map((t) => ( {boundTriggers.map((t) => (
@@ -773,7 +979,13 @@ function Orchestrator({
tone="warn" tone="warn"
ico="zap" ico="zap"
title={t.name} title={t.name}
sub={t.spec.kind === 'event' ? t.spec.rule : t.spec.kind === 'registration' ? TRIGGER_KIND_LABEL.registration : TRIGGER_KIND_LABEL.periodic} 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 ? '已启用' : '已停用' }]} cols={[{ k: '状态', v: t.enabled ? '已启用' : '已停用' }]}
right={ right={
<button <button
@@ -792,10 +1004,11 @@ function Orchestrator({
</NList> </NList>
) : ( ) : (
<Note> <Note>
还没有触发器指向这条流 —— 它定义得再完整,也没人调得起来。去 还没有触发器指向这条流 —— 它定义得再完整,也没人调得起来。去<b>触发器</b>页签配一条
<b>调起规则</b>页签配一条(周期 / 事件 / 登记三选一)。 (周期 / 事件 / 登记三选一)。
</Note> </Note>
)} )}
</Card>
</Sec> </Sec>
</> </>
); );
@@ -848,7 +1061,7 @@ export function EdgesPage({ id }: { id: string }) {
{ec.closed} / {ec.total} 已闭合 {ec.closed} / {ec.total} 已闭合
</St> </St>
} }
desc="连线即映射:每条顺序边都要把上游输出口接到下游契约字段上,必填齐、类型无冲突才算闭合。边不闭合不得发布。" desc="连线即映射:每条顺序边都要把上游输出口接到下游契约字段上,必填齐、类型无冲突才算闭合。边不闭合不得保存。"
/> />
{!okEdge && report !== undefined && <Note tone="warn">{report.violations.map((v) => v.message).join(';')}</Note>} {!okEdge && report !== undefined && <Note tone="warn">{report.violations.map((v) => v.message).join(';')}</Note>}
<Card ico="git-merge" title="逐边核对" extra="上游输出口 → 下游契约字段"> <Card ico="git-merge" title="逐边核对" extra="上游输出口 → 下游契约字段">
+2 -2
View File
@@ -103,7 +103,7 @@ export const ZM: Readonly<Record<GovZone, { title: string; desc: string; accent:
title: '流程包管理', title: '流程包管理',
icon: 'workflow', icon: 'workflow',
accent: 'linear-gradient(140deg,#4954e6,#3a44c9)', accent: 'linear-gradient(140deg,#4954e6,#3a44c9)',
desc: '战略——企业以哪些流转运转。五要素编排(活动 · 审批点 · 网关 · 触发器 · 流程包)与版本:编排即定契约,连线即映射、画图即校验,边不闭合不得发布。调起规则(触发器的定义)也在本区:谁调起这条流,与流程包一处维护;启停在运营中心。', desc: '战略——企业以哪些流转运转。一条流一个包,版本收在包内部:保存即生效、恰一个生效版本、在途按启动版本跑完。编排即定契约:连线即映射、画图即校验,边不闭合存不进来。触发器(谁调起这条流)也在本区定义;启停在运营中心。',
}, },
library: { library: {
title: '活动库', title: '活动库',
@@ -148,7 +148,7 @@ export const TABS: Readonly<Record<GovZone, ReadonlyArray<{ key: string; label:
workflows: [ workflows: [
{ key: 'overview', label: '总览', ico: 'gauge' }, { key: 'overview', label: '总览', ico: 'gauge' },
{ key: 'pkgs', label: '流程包', ico: 'boxes' }, { key: 'pkgs', label: '流程包', ico: 'boxes' },
{ key: 'rules', label: '调起规则', ico: 'zap' }, { key: 'rules', label: '触发器', ico: 'zap' },
], ],
library: [{ key: 'shelf', label: '活动货架', ico: 'boxes' }], library: [{ key: 'shelf', label: '活动货架', ico: 'boxes' }],
tools: [{ key: 'catalog', label: '能力目录', ico: 'wrench' }], tools: [{ key: 'catalog', label: '能力目录', ico: 'wrench' }],
+13
View File
@@ -0,0 +1,13 @@
-- 一条流恰一个生效版本(产品决策 2026-07-28:版本可激活/停用,一次只能激活一个)
--
-- 此前「保存即生效」按最新版取,库里同一条流可能多版都是 active。
-- 归一:每条流只留版本号最大的那版 active,其余停用。
-- state 在列上与 definition(jsonb)里各存一份,两处一起改(教训同 0012)。
update sys.process_package p
set state = 'retired',
definition = jsonb_set(definition, '{state}', '"retired"')
where p.state = 'active'
and exists (
select 1 from sys.process_package q
where q.id = p.id and q.state = 'active' and q.version > p.version
);
+7
View File
@@ -92,6 +92,13 @@
"when": 1785130053168, "when": 1785130053168,
"tag": "0012_process_no_publish", "tag": "0012_process_no_publish",
"breakpoints": true "breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1785130113168,
"tag": "0013_one_active_version",
"breakpoints": true
} }
] ]
} }
+14
View File
@@ -207,6 +207,20 @@ export const saveProcessRoute = {
output: z.object({ pkg: processPackageSchema, report: closureReportSchema }), output: z.object({ pkg: processPackageSchema, report: closureReportSchema }),
} as const satisfies RouteContract; } as const satisfies RouteContract;
/**
* 版本启停 —— 运行时开关,不改定义一个字。
* 激活某版:同流其余版本一并停用(一条流恰一个生效版本);停用生效版:整条流下线,新起单被拒。
* 在途工作流按启动时钉住的版本跑完,启停对它们零影响。
* 返回这条流启停后的全部版本(新→旧),界面直接以它刷新版本列表。
*/
export const setProcessStateRoute = {
method: 'POST',
path: '/api/gov/process/:id/:version/state',
params: z.object({ id: z.string(), version: z.string() }),
body: z.object({ state: z.enum(['active', 'retired']) }),
output: z.object({ packages: z.array(processPackageSchema) }),
} as const satisfies RouteContract;
/** 画图即校验 —— 与前端画布同源;保存前随手看还差几条,不改任何状态 */ /** 画图即校验 —— 与前端画布同源;保存前随手看还差几条,不改任何状态 */
export const validateProcessRoute = { export const validateProcessRoute = {