From 710946e729c1e813b1ed05789fff22695f891bd9 Mon Sep 17 00:00:00 2001 From: 18628386721 <18628386721@163.com> Date: Tue, 28 Jul 2026 14:35:54 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B5=81=E7=A8=8B=E5=8C=85=E5=8F=B0=E9=9D=A2?= =?UTF-8?q?=E9=87=8D=E5=81=9A:=E7=89=88=E6=9C=AC=E6=94=B6=E8=BF=9B?= =?UTF-8?q?=E5=8C=85=E5=86=85=E9=83=A8,=E5=90=AF=E5=81=9C=E6=98=AF?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=B6=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按你定的六条做: ## ① 台面三页签:总览 · 流程包 · 触发器 「调起规则」改名触发器(键不动,深链不断)。 ## ② 版本收在流程内部,不平铺台面 货架一流程一卡:名字 + 标识 + 「生效 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) Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m --- .../api/src/modules/process/contract.d.ts | 7 + .../apps/api/src/modules/process/port.d.ts | 2 + .../contracts/src/api/governance.d.ts | 212 ++++ apps/api/src/app/routes/governance-ops.ts | 18 + apps/api/src/app/wiring/engines.ts | 6 +- apps/api/src/modules/process/CONTRACT.md | 8 +- .../process/__tests__/start-guard.test.ts | 1 + apps/api/src/modules/process/contract.ts | 7 + .../process/internal/adapter/process-store.ts | 16 + .../internal/usecase/process-service.ts | 48 +- apps/api/src/modules/process/port.ts | 2 + apps/web/src/app/styles/console.css | 18 + .../features/gov-process/internal/panel.tsx | 1103 ++++++++++------- apps/web/src/shared/gov/gov-route.ts | 4 +- db/migrations/0013_one_active_version.sql | 13 + db/migrations/meta/_journal.json | 7 + packages/contracts/src/api/governance.ts | 14 + 17 files changed, 1019 insertions(+), 467 deletions(-) create mode 100644 db/migrations/0013_one_active_version.sql diff --git a/api-report/apps/api/src/modules/process/contract.d.ts b/api-report/apps/api/src/modules/process/contract.d.ts index 2d45208..a2d2163 100644 --- a/api-report/apps/api/src/modules/process/contract.d.ts +++ b/api-report/apps/api/src/modules/process/contract.d.ts @@ -30,10 +30,17 @@ export type ProcessService = { /** * 保存 —— **严格检查 + 即刻生效**,不闭合抛 `NotClosedError`。 * 恒产生新版本号(服务端算,不采信入参的 version);返回实际落的那一版。 + * 落下来的新版**即为生效版**:同流的旧生效版随之停用(一条流恰一个生效版本)。 */ savePackage(pkg: ProcessPackage): Promise; listPackages(): Promise; getPackage(id: string, version?: number): Promise; + /** + * 版本启停 —— 运行时开关,**不改定义一个字**。 + * 激活某版:同流其余版本一并停用(恰一个生效);停用生效版:整条流下线,新起单被拒。 + * 在途工作流按启动时钉住的版本跑完,启停对它们零影响。 + */ + setPackageState(id: string, version: number, state: 'active' | 'retired'): Promise; /** 画图即校验 —— 与前端画布同源;只算不存 */ validate(pkg: ProcessPackage): Promise; start(input: StartWorkflowInput): Promise; diff --git a/api-report/apps/api/src/modules/process/port.d.ts b/api-report/apps/api/src/modules/process/port.d.ts index 201d28a..5d78218 100644 --- a/api-report/apps/api/src/modules/process/port.d.ts +++ b/api-report/apps/api/src/modules/process/port.d.ts @@ -37,6 +37,8 @@ export type WorkflowRecord = { }; export type ProcessStore = { savePackage(pkg: ProcessPackage): Promise; + /** 只改状态,不动定义 —— state 在列上与 definition 里各存一份,两处要一起改 */ + setPackageState(id: string, version: number, state: 'active' | 'retired'): Promise; getPackage(id: string, version?: number): Promise; listPackages(): Promise; insertWorkflow(record: WorkflowRecord): Promise; diff --git a/api-report/packages/contracts/src/api/governance.d.ts b/api-report/packages/contracts/src/api/governance.d.ts index c7fab63..97d6563 100644 --- a/api-report/packages/contracts/src/api/governance.d.ts +++ b/api-report/packages/contracts/src/api/governance.d.ts @@ -1065,6 +1065,218 @@ export declare const saveProcessRoute: { }, 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; + name: z.ZodString; + version: z.ZodNumber; + state: z.ZodEnum<{ + active: "active"; + retired: "retired"; + }>; + callInterface: z.ZodObject<{ + input: z.ZodRecord; + output: z.ZodRecord; + }, z.core.$strip>; + nodes: z.ZodArray; + id: z.core.$ZodBranded; + label: z.ZodString; + sub: z.ZodOptional; + layout: z.ZodOptional; + y: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"end">; + output: z.ZodOptional>; + id: z.core.$ZodBranded; + label: z.ZodString; + sub: z.ZodOptional; + layout: z.ZodOptional; + y: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"activity">; + activityId: z.core.$ZodBranded; + in: z.ZodOptional>; + out: z.ZodOptional>; + activityVersion: z.ZodOptional; + owner: z.ZodObject<{ + position: z.ZodString; + strategy: z.ZodDefault>; + staffId: z.ZodOptional>; + }, z.core.$strip>; + failure: z.ZodDefault; + }, 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.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"tolerate">; + }, z.core.$strip>], "kind">>; + message: z.ZodOptional; + id: z.core.$ZodBranded; + label: z.ZodString; + sub: z.ZodOptional; + layout: z.ZodOptional; + y: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"approval">; + approver: z.ZodObject<{ + position: z.ZodString; + strategy: z.ZodDefault>; + staffId: z.ZodOptional>; + }, z.core.$strip>; + reject: z.ZodDiscriminatedUnion<[z.ZodObject<{ + kind: z.ZodLiteral<"reopen">; + nodeId: z.core.$ZodBranded; + mapping: z.ZodDefault; + 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.core.$strip>>>; + }, z.core.$strip>>; + message: z.ZodOptional; + id: z.core.$ZodBranded; + label: z.ZodString; + sub: z.ZodOptional; + layout: z.ZodOptional; + y: z.ZodOptional; + }, 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; + id: z.core.$ZodBranded; + label: z.ZodString; + sub: z.ZodOptional; + layout: z.ZodOptional; + y: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>], "kind">>; + edges: z.ZodArray; + to: z.core.$ZodBranded; + condition: z.ZodOptional; + mapping: z.ZodDefault; + 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; + tone: z.ZodOptional>; + reopen: z.ZodDefault; + }, z.core.$strip>>; + variables: z.ZodDefault; + }, z.core.$strip>>>; + description: z.ZodOptional; + diagram: z.ZodOptional>; + }, z.core.$strip>>; + }, z.core.$strip>; +}; /** 画图即校验 —— 与前端画布同源;保存前随手看还差几条,不改任何状态 */ export declare const validateProcessRoute: { readonly method: "POST"; diff --git a/apps/api/src/app/routes/governance-ops.ts b/apps/api/src/app/routes/governance-ops.ts index 95eb286..31f786d 100644 --- a/apps/api/src/app/routes/governance-ops.ts +++ b/apps/api/src/app/routes/governance-ops.ts @@ -25,6 +25,7 @@ import { listTriggersRoute, registerToolRoute, saveProcessRoute, + setProcessStateRoute, reassignTaskRoute, resumeTaskRoute, staffIdSchema, @@ -290,6 +291,23 @@ export function registerGovernanceOpsRoutes(app: Hono, 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) => { const denied = await governProcess(c.get('account')); diff --git a/apps/api/src/app/wiring/engines.ts b/apps/api/src/app/wiring/engines.ts index de6bb3e..ccac828 100644 --- a/apps/api/src/app/wiring/engines.ts +++ b/apps/api/src/app/wiring/engines.ts @@ -747,11 +747,9 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { process: { start: async (input) => process.start(input), requiredInputs: async (packageId) => { - // 与起单取版同口径:读**最新一版**的调用接口(没有草稿了,存进来的都生效) + // 与起单取版同口径:读**生效版本**的调用接口(一条流恰一个生效版本) const all = await process.listPackages(); - const pkg = all - .filter((x) => x.id === packageId && x.state !== 'retired') - .sort((a, b) => b.version - a.version)[0]; + const pkg = all.find((x) => x.id === packageId && x.state === 'active'); const input = pkg?.callInterface.input as { required?: string[] } | undefined; return input?.required ?? []; }, diff --git a/apps/api/src/modules/process/CONTRACT.md b/apps/api/src/modules/process/CONTRACT.md index 13f8878..017c1a2 100644 --- a/apps/api/src/modules/process/CONTRACT.md +++ b/apps/api/src/modules/process/CONTRACT.md @@ -11,7 +11,7 @@ - 错误:`NotClosedError` `PositionVacantError` - 端口类型:`ProcessStore` `ActivityDirectory` `OrgDirectory` `TaskGateway` `ApprovalGateway` `Sfeel` `FlowEventPublisher` `ReceiptNotifier` - - 用例工厂:`createProcessService(deps): ProcessService` + - 用例工厂:`createProcessService(deps): ProcessService`(含 `setPackageState` 版本启停) - 纯函数:`checkClosure` `closed` `resolveOwner` `aggregateWorkflowState` (`resolveOwner` 的 `bound` 入参 = 已校验的工作组绑定,ADR-011) `isDigested` `nextInstanceNumber` `selectEdges` `assemble` `joinReady` @@ -28,8 +28,10 @@ - **不闭合不得保存**:未完成边、必填未映射、类型冲突、孤立节点、异或出边缺条件、 汇合同名字段来源不明、容忍分支被下游必填依赖 —— 任一即 `closed = false`,`savePackage` 抛 `NotClosedError` - **严格检查 + 保存即生效,没有发布机制**(产品决策 2026-07-28): - 没有草稿态、没有发布门。存进来的每一版都能起单;生效与否由**运行时**决定 —— - 新工作流起单取最新版,在途的按启动时快照的版本号读定义、跑完旧编排。 + 没有草稿态、没有发布门。保存 = 存为新版 + 即刻生效。 + - **一条流恰一个生效版本**(产品决策 2026-07-28):`setPackageState` 激活即独占 + (同流其余版本一并停用);停用生效版 = 整条流下线,新起单被拒。 + 起单取**生效版**;在途工作流按启动时钉住的版本号读定义 —— 启停对它们零影响。 - **保存恒新增版本号**(服务端算,不采信入参):工作流按 (包id, 版本) 读定义, 就地覆盖会改掉在途任务正在读的编排。store 层因此**只插不改**,撞键宁可报错也不静默覆盖。 - **求值语义前后端同源**:画布的即时校验与后端裁决共用 `checkClosure` diff --git a/apps/api/src/modules/process/__tests__/start-guard.test.ts b/apps/api/src/modules/process/__tests__/start-guard.test.ts index 1016f41..11c2be6 100644 --- a/apps/api/src/modules/process/__tests__/start-guard.test.ts +++ b/apps/api/src/modules/process/__tests__/start-guard.test.ts @@ -45,6 +45,7 @@ function harness(validationErrors: readonly string[]): Harness { const deps: ProcessServiceDeps = { store: { savePackage: async () => undefined, + setPackageState: async () => undefined, getPackage: async () => PKG, listPackages: async () => [PKG], insertWorkflow: async (record) => { diff --git a/apps/api/src/modules/process/contract.ts b/apps/api/src/modules/process/contract.ts index 9091f5c..b86ed89 100644 --- a/apps/api/src/modules/process/contract.ts +++ b/apps/api/src/modules/process/contract.ts @@ -55,10 +55,17 @@ export type ProcessService = { /** * 保存 —— **严格检查 + 即刻生效**,不闭合抛 `NotClosedError`。 * 恒产生新版本号(服务端算,不采信入参的 version);返回实际落的那一版。 + * 落下来的新版**即为生效版**:同流的旧生效版随之停用(一条流恰一个生效版本)。 */ savePackage(pkg: ProcessPackage): Promise; listPackages(): Promise; getPackage(id: string, version?: number): Promise; + /** + * 版本启停 —— 运行时开关,**不改定义一个字**。 + * 激活某版:同流其余版本一并停用(恰一个生效);停用生效版:整条流下线,新起单被拒。 + * 在途工作流按启动时钉住的版本跑完,启停对它们零影响。 + */ + setPackageState(id: string, version: number, state: 'active' | 'retired'): Promise; /** 画图即校验 —— 与前端画布同源;只算不存 */ validate(pkg: ProcessPackage): Promise; diff --git a/apps/api/src/modules/process/internal/adapter/process-store.ts b/apps/api/src/modules/process/internal/adapter/process-store.ts index 778f0ed..8eef1d7 100644 --- a/apps/api/src/modules/process/internal/adapter/process-store.ts +++ b/apps/api/src/modules/process/internal/adapter/process-store.ts @@ -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 }); }, + 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), state }; + await db + .update(processPackageTable) + .set({ state, definition }) + .where(and(eq(processPackageTable.id, id), eq(processPackageTable.version, version))); + }, + async getPackage(id, version) { const conditions = [eq(processPackageTable.id, id)]; if (version !== undefined) conditions.push(eq(processPackageTable.version, version)); diff --git a/apps/api/src/modules/process/internal/usecase/process-service.ts b/apps/api/src/modules/process/internal/usecase/process-service.ts index eef5c0b..0bbc8d6 100644 --- a/apps/api/src/modules/process/internal/usecase/process-service.ts +++ b/apps/api/src/modules/process/internal/usecase/process-service.ts @@ -47,20 +47,17 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService { } /** - * 起单取版:未指定版本时取**最新的一版**。 + * 起单取版:取这条流的**生效版本** —— 一条流恰一个(或零个)生效版本。 * - * 从前这里取「最新已发布」,因为草稿是编排中的半成品、运行时不该看见它。 - * 现在没有草稿了 —— 「严格检查 + 保存生效」,不闭合根本存不进来, - * 所以库里的每一版都是能起单的。生效就是这个意思:**新任务起单时取到最新版**, - * 在途的按启动时快照的版本号读定义、跑完旧编排。 - * `retired` 的版本拒起单(留给「这一版不再用了」)。 + * 「生效」是运行时开关,不是内容属性:保存新版即生效(旧生效版随之停用), + * 也可以手动把生效切回旧版本、或全部停用让整条流下线。 + * 在途工作流按启动时钉住的版本号读定义,启停对它们零影响 —— 这正是 + * 「切换版本不影响旧实例」的机制保证:实例上钉的是版本号,不是「当前生效」指针。 */ - async function latest(id: string): Promise { + async function activeVersion(id: string): Promise { const all = await deps.store.listPackages(); - const hit = all - .filter((pkg) => pkg.id === id && pkg.state !== 'retired') - .sort((a, b) => b.version - a.version)[0]; - if (hit === undefined) throw new Error(`流程包无可用版本,不得起单:${id}`); + const hit = all.find((pkg) => pkg.id === id && pkg.state === 'active'); + if (hit === undefined) throw new Error(`流程包无生效版本,不得起单:${id} —— 去流程包里激活一个版本`); return hit; } @@ -295,9 +292,33 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService { if (!report.closed) throw new NotClosedError(report); const saved: ProcessPackage = { ...pkg, version: await nextVersion(pkg.id), state: 'active' }; 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; }, + 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() { return deps.store.listPackages(); }, @@ -320,9 +341,10 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService { async start(input: StartWorkflowInput) { const pkg = input.packageVersion === undefined - ? await latest(input.packageId) + ? await activeVersion(input.packageId) : 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}`); // 先校验后建单:不合调用接口契约的输入**一条工作流都不留**。 // (校验若放在建单之后,失败就会留下一条永远推不动的在途单,污染在途口径。) diff --git a/apps/api/src/modules/process/port.ts b/apps/api/src/modules/process/port.ts index 3a622a6..e829f87 100644 --- a/apps/api/src/modules/process/port.ts +++ b/apps/api/src/modules/process/port.ts @@ -32,6 +32,8 @@ export type WorkflowRecord = { export type ProcessStore = { savePackage(pkg: ProcessPackage): Promise; + /** 只改状态,不动定义 —— state 在列上与 definition 里各存一份,两处要一起改 */ + setPackageState(id: string, version: number, state: 'active' | 'retired'): Promise; getPackage(id: string, version?: number): Promise; listPackages(): Promise; diff --git a/apps/web/src/app/styles/console.css b/apps/web/src/app/styles/console.css index 32a4dc6..727cd83 100644 --- a/apps/web/src/app/styles/console.css +++ b/apps/web/src/app/styles/console.css @@ -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 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-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)); } diff --git a/apps/web/src/features/gov-process/internal/panel.tsx b/apps/web/src/features/gov-process/internal/panel.tsx index b31cb13..e0f7c99 100644 --- a/apps/web/src/features/gov-process/internal/panel.tsx +++ b/apps/web/src/features/gov-process/internal/panel.tsx @@ -1,21 +1,25 @@ /** - * feature `gov-process` —— 工作流管理(设计态):图编排 + 活动契约定义。 + * feature `gov-process` —— 流程包(设计态):**一条流一个包,版本收在包内部**。 * - * 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 的 - * `WorkflowsHome` / `PkgPage` / `EdgesPage` 平移:DOM 结构、类名(`g-*`)与文案原样保留, - * mock 常量换成服务端投影(README 平移规则 1/2/4)。 + * 台面三页签:总览 · 流程包 · 触发器。 + * 货架一流程一卡(不按版本平铺);进详情后左 1/3 是版本列表、右 2/3 是流程图, + * 图下是节点属性,再往下才是声明式的契约细节(调用接口 · 边映射 · 触发器)。 * - * 编排即定契约:连线即映射、画图即校验、**边不闭合不得保存**; - * 图画完可导出活动开发的依赖文件 —— 开发者对着声明在系统外开发。 + * 版本是**运行时开关**,不是发布流程: + * · 保存 = 严格检查 + 存为新版 + 即刻生效(不闭合存不进来) + * · 一条流恰一个生效版本;可把生效切回旧版,也可全部停用让整条流下线 + * · 在途工作流按启动时钉住的版本跑完 —— 启停对它们零影响 */ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useMemo, useRef, useState, useEffect } from 'react'; import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; 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 { Ico } from '@web-shared/Ico.tsx'; +import type { AskSpec } from '@web-shared/gov/primitives.tsx'; import { Acts, + AskBand, Card, Chip, Entry, @@ -28,10 +32,10 @@ import { PageHead, Prop, QueryGate, + Rows, Sec, Seg, St, - Todo, } 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'; @@ -56,97 +60,40 @@ function edgeCheckOf(pkg: ProcessPackage, report: ClosureReport | undefined) { return { closed: total - badEdges.size, total, issue }; } -/* ---------------- ③ 工作流管理 ---------------- */ - -export function WorkflowsHome({ tab, nav }: HomeProps) { - // 调起规则 = 触发器的**定义面**:谁调起这条流,与流程包一处维护(启停在运营中心) - if (tab === 'rules') return ; - return ; +/** 一条流的版本按新→旧;生效版恰一个(或零个 —— 整条流下线) */ +function flowsOf(packages: readonly ProcessPackage[]) { + const byId = new Map(); + 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') }; + }); } -function PackagesHome({ tab, nav }: HomeProps) { +/* ---------------- 台面:总览 · 流程包 · 触发器 ---------------- */ + +export function WorkflowsHome({ tab, nav }: HomeProps) { + // 触发器的**定义面**:谁调起这条流,与流程包一处维护(启停在运营中心) + if (tab === 'rules') return ; + if (tab === 'pkgs') return ; + return ; +} + +function OverviewHome() { const q = useGovPackages(); const packages = q.data?.packages ?? []; const reports = useGovPkgReports(packages); - const reportOf = (p: ProcessPackage) => reports.data?.get(`${p.id}@${p.version}`); - const counts = (p: ProcessPackage) => ({ - activity: p.nodes.filter((n) => n.kind === 'activity').length, - approval: p.nodes.filter((n) => n.kind === 'approval').length, - gateway: p.nodes.filter((n) => n.kind === 'gateway').length, - }); - - if (tab === 'pkgs') { - return ( - -
- {/* - * 没有「已发布 / 草稿」了 —— 严格检查 + 保存即生效。 - * 真正的区别是「哪一版会被新起单取到」:同一条流里**版本号最大的那一版生效中**, - * 更早的版本是历史(在途任务还照它跑完)。 - */} - {packages.map((p) => { - const latestOfFlow = Math.max(...packages.filter((x) => x.id === p.id).map((x) => x.version)); - const live = p.version === latestOfFlow && p.state !== 'retired'; - const ec = edgeCheckOf(p, reportOf(p)); - const okEdge = ec.closed === ec.total; - const c = counts(p); - return ( - - ); - })} -
-
- ); - } - - const flows = new Set(packages.map((p) => p.id)).size; + const flows = flowsOf(packages); const totalEdges = packages.reduce((a, p) => a + p.edges.length, 0); - const closedEdges = packages.reduce((a, p) => a + edgeCheckOf(p, reportOf(p)).closed, 0); + const closedEdges = packages.reduce( + (a, p) => a + edgeCheckOf(p, reports.data?.get(`${p.id}@${p.version}`)).closed, + 0, + ); return (
- - + +
- 严格检查 + 保存即生效:不闭合的编排存不进来;存下来即刻对**新起的**工作流生效,在途的按启动时版本跑完。保存恒新增版本,旧版本原样留着。 + + 保存即生效:严格检查过了才存得进来,存下来即刻对新起的工作流生效; + 在途的按启动时版本跑完。要回退,去流程包里把生效切回旧版本 —— 一条流一次只有一个生效版本。 +
); } -/* ---------------- 流程包详情:编排工作区 + 活动契约定义 ---------------- */ +/* ---------------- 货架:一条流一张卡 + 新建流程 ---------------- */ + +/** + * 新建的流程 = 一张**最小可生效**的图:开始 → 结束。 + * 空图也要过闭合校验(不闭合存不进来),这张最小图天然闭合 —— 建出来就能起单 + * (起了直接走完,没业务意义,但机制是通的);进详情画上活动再存,即成下一版。 + */ +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(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 ( + + {creating ? ( + +
+ + setNfName(e.target.value)} /> + + + setNfId(e.target.value.trim())} + /> + + {nfId !== '' && !idOk && 标识不合规:小写字母开头,只许小写、数字、连字符,2–41 位。} + {idTaken && 这个标识已有流程在用 —— 换一个,或直接进那条流改。} + {nfError !== undefined && {nfError}} + setCreating(false) }, + { + label: create.isPending ? '建流程中…' : '建流程并进入', + ico: 'plus', + primary: true, + disabled: create.isPending || !idOk || idTaken || nfName.trim() === '', + onClick: () => create.mutate(), + }, + ]} + /> +
+
+ ) : ( + setCreating(true) }]} /> + )} + +
+ {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 ( + + ); + })} +
+
+ ); +} + +/* ---------------- 流程详情:版本列表 + 画布 + 节点属性 + 契约细节 ---------------- */ 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 acts = useGovActivities(); 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(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 ( - 流程包不存在。 + 流程不存在。 ); } - const siblings = (q.data?.packages ?? []).filter((x) => x.id === pkg.id); - const latestVersion = Math.max(...siblings.map((x) => x.version)); return ( - ({ id: p.id, name: p.name, abbr: p.code }))} - nextVersion={latestVersion + 1} - /* 生效中 = 这条流版本号最大的那一版:只有它能改(改则存成下一版);历史版本只读 */ - live={pkg.version === latestVersion && pkg.state !== 'retired'} + nextVersion={Math.max(...versions.map((x) => x.version)) + 1} + onPick={(v) => setPinned(v)} + onSaved={() => setPinned(null)} /> ); } @@ -203,23 +311,30 @@ type ActivityRow = { contract: GovActivities['activities'][number]['contract']; }; -function Orchestrator({ +function FlowLab({ pkg, + versions, + activeVersion, nav, activities, positions, nextVersion, - live, + onPick, + onSaved, }: { pkg: ProcessPackage; + versions: readonly ProcessPackage[]; + activeVersion: number | undefined; nav: Nav; activities: readonly ActivityRow[]; positions: SpPosition[]; nextVersion: number; - live: boolean; + 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]); @@ -228,6 +343,7 @@ function Orchestrator({ const [sel, setSel] = useState(null); const [dirty, setDirty] = useState(false); const [report, setReport] = useState(undefined); + const [ask, setAsk] = useState<{ spec: AskSpec; danger?: boolean } | undefined>(undefined); const canvasRef = useRef(null); const nodes = graph?.nodes ?? []; @@ -301,10 +417,64 @@ function Orchestrator({ setDirty(false); void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] }); 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: ( + + 激活即独占:此后新起的工作流用 v{v.version} + {activeVersion === undefined ? '' : `,v${activeVersion} 随之停用`}。 在途的按各自启动时的版本跑完,不受影响。 + + ), + 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: ( + + 停用后这条流没有生效版本:新起单会被拒,触发器命中了也起不了。 + 在途工作流照旧跑完,不受影响。 + + ), + impact: { + who: '整条流对新起单下线', + undo: '随时激活任一版本即恢复', + when: '确认即下线', + }, + okLabel: '停用,整条流下线', + onOk: () => setState.mutate({ version: v.version, state: 'retired' }), + }, + }); /** * 导出依赖文件 —— 编排定契约:各活动的接口声明、责任岗位、输入/输出声明与上游装配, @@ -347,65 +517,37 @@ function Orchestrator({ 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 boundTriggers = (triggersQ.data?.triggers ?? []).filter((t) => t.processPackageId === pkg.id); + const callInput = pkg.callInterface.input as { required?: string[]; properties?: Record }; return ( <> - {live ? ( + {activeVersion !== undefined ? ( - 生效中 + 生效 v{activeVersion} ) : ( - - 历史版本 + + 已下线 —— 没有生效版本 )} - + + {versions.length} 个版本 + + 边 {ec.closed}/{ec.total} - - 活动 {counts.activity} - - - 审批 {counts.approval} - - - 网关 {counts.gateway} - } acts={[ - /* - * 历史版本只读 —— 改图要回到生效中的那一版(在那儿保存会存成下一版)。 - * 从前这里是「已发布不可回改 → 起草新版本 / 去草稿编辑」两条路; - * 没有草稿之后只剩一条:生效版就地改,保存即新版。 - */ - ...(live - ? [] - : [ - { - label: `去 v${nextVersion - 1} 改(生效中)`, - ico: 'square-pen', - primary: true, - onClick: () => nav.push({ k: 'pkg', id: `${pkg.id}@${nextVersion - 1}` }), - }, - ]), { label: '导出依赖文件', ico: 'upload', @@ -425,6 +567,8 @@ function Orchestrator({ ]} /> + {ask !== undefined && setAsk(undefined)} />} + {activeReport !== undefined && !activeReport.closed && ( 未闭合: @@ -433,42 +577,89 @@ function Orchestrator({ )} {report !== undefined && report.closed && 闭合校验通过。} - {/* 编排工作区:左 2/3 画布(整图显示,不裁切)+ 右 1/3 属性卡(就地填写,不下钻) */} + {/* 编排工作区:左 1/3 版本列表 + 右 2/3 画布 —— 版本收在流程内部,不平铺台面 */} - {/* - 历史版本只读,但「去哪儿改」不该让人自己找 —— 就地把路指出来。 - 从前这里是「克隆成草稿再编排」;没有草稿之后,生效版就地改、保存即存成下一版。 - */} - {readOnly && ( -
- nav.push({ k: 'pkg', id: `${pkg.id}@${nextVersion - 1}` }), - }, - ]} - /> -
- )} -
+
+ +
n.kind === 'activity').map((n) => n.id)} positions={positions} @@ -497,263 +688,265 @@ function Orchestrator({
+
-