From f631b5b8bb8243a08b09cda16a7cc613fba1fa0e Mon Sep 17 00:00:00 2001 From: 18628386721 <18628386721@163.com> Date: Wed, 29 Jul 2026 22:15:19 +0800 Subject: [PATCH] =?UTF-8?q?=E7=89=88=E6=9C=AC=E5=8F=B7=E8=87=AA=E5=B7=B1?= =?UTF-8?q?=E5=AE=9A:=E7=82=B9=E7=BC=96=E8=BE=91,=E6=95=B4=E5=88=97?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E8=BF=9B=E7=BC=96=E8=BE=91=E6=80=81,?= =?UTF-8?q?=E5=8F=96=E6=B6=88/=E4=BF=9D=E5=AD=98=E6=94=B6=E5=B0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户的目的是**自己改版本号**,不是让系统替他递增;顺带把上一轮那些 指示性交互全撤了 —— 这软件给专业业务设计人员用,不需要「点我改」的提示条。 界面: · 顶栏 编辑 → 取消 / 保存(与流程图卡「编辑 → 保存」同一套手势); · 行内两个字段:号(自己编)与名(各版不同),行尾 删除; · 改动攒在草稿里,按保存一趟落库;取消原样退出; · **删掉**:改名指示条、「点我改」虚下划线、`删掉 v4`/`激活 v4` 这类复述钮名的浮字。 只留下禁用态那一句(生效版先停用 / 还不闭合),那不是指示,是钮为什么点不动。 后端 —— 号从「系统递增」放开为「设计人员自己编」,两道闸是数据本身要求的: · 撞号拒(VersionTakenError,409); · 在途钉版拒改号(那些单正按这个号找定义,搬号等于抽它们脚下的地板); · 已完结的单**跟着改指**:号在三处(包的列、definition、workflow.packageVersion) 一起改 —— 号只是这一版的名牌,搬号连引用一起搬,留痕仍指同一份定义。 先前写成「跑过单就不许改号」,实测等于任何试跑过的包永远编不了号,是过闸。 · 名不设闸:标签不参与流转,在途钉版也能改;改名只动这一版(推翻前一日的 「及于整条流」)—— 各版本正是靠名字互相区分,全同名等于没名字。 路由 POST /api/gov/process/:id/:version/edit 取代 …/:id/rename(单一消费者是本仓前端); CONTRACT.md 与产品设计.md 同步,api-report 快照随本次提交。 实测(丁子乔账号,测后已 reset-runs 清运行数据):v4→v2→v4 往返,列表、画布图签、 库里三处同步;在途单存在时改号如实报 409 并原样退回。五闸绿。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m --- .../api/src/modules/process/contract.d.ts | 17 +- .../apps/api/src/modules/process/index.d.ts | 2 +- .../apps/api/src/modules/process/port.d.ts | 10 +- .../contracts/src/api/governance.d.ts | 16 +- apps/api/src/app/routes/governance-ops.ts | 33 +- apps/api/src/modules/process/CONTRACT.md | 15 +- .../process/__tests__/save-package.test.ts | 3 +- .../process/__tests__/start-guard.test.ts | 3 +- .../__tests__/version-lifecycle.test.ts | 53 +++- apps/api/src/modules/process/contract.ts | 19 +- apps/api/src/modules/process/index.ts | 2 +- .../process/internal/adapter/process-store.ts | 40 ++- .../internal/usecase/process-service.ts | 35 ++- apps/api/src/modules/process/port.ts | 10 +- apps/web/src/app/styles/console.css | 23 +- .../features/gov-process/internal/panel.tsx | 282 +++++++++++------- docs/产品设计.md | 2 +- packages/contracts/src/api/governance.ts | 19 +- 18 files changed, 405 insertions(+), 179 deletions(-) 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 3d15ece..9bdbe72 100644 --- a/api-report/apps/api/src/modules/process/contract.d.ts +++ b/api-report/apps/api/src/modules/process/contract.d.ts @@ -56,8 +56,15 @@ export type ProcessService = { instancesOf(workflowId: string): Promise; /** 删一版:生效版与在途钉版都不许删(见 `ActiveVersionError` / `PinnedVersionError`) */ deleteVersion(id: string, version: number): Promise; - /** 改名:名字是这条流的身份,一次改及于所有版本 */ - rename(id: string, name: string): Promise; + /** + * 改版本记录:**号与名**,只动这一版 —— 号自己定。 + * 名不设闸(标签不参与流转);号设两道:不许撞同流已有号(`VersionTakenError`)、 + * 在途钉版不许改号(`PinnedVersionError`);已完结的单跟着改指,留痕不失真。 + */ + editVersion(id: string, version: number, patch: { + version?: number; + name?: string; + }): Promise; nodeOwners(packageId: string): Promise>; setNodeOwner(packageId: string, nodeId: string, staffId: string | undefined): Promise; /** 任务终态到达:完成即继续流转;非完成即走声明的失败走向 */ @@ -101,6 +108,12 @@ export declare class PinnedVersionError extends Error { readonly inFlight: number; constructor(packageId: string, version: number, inFlight: number); } +/** 号被占:同一条流里版本号唯一 —— 换个号,或先把占号那版删了 */ +export declare class VersionTakenError extends Error { + readonly packageId: string; + readonly version: number; + constructor(packageId: string, version: number); +} export declare class PositionVacantError extends Error { readonly position: string; constructor(position: string); diff --git a/api-report/apps/api/src/modules/process/index.d.ts b/api-report/apps/api/src/modules/process/index.d.ts index cf270de..64c5ace 100644 --- a/api-report/apps/api/src/modules/process/index.d.ts +++ b/api-report/apps/api/src/modules/process/index.d.ts @@ -2,7 +2,7 @@ * module: process —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。 */ export type { ActivityContractFace, AssembleScope, Candidate, ClosureInput, ClosureReport, ClosureViolation, NodeInstance, OwnerSpec, ProcessPackage, ProcessService, ResolveContext, Resolution, StartWorkflowInput, WorkflowRecord, WorkflowState, } from './contract.ts'; -export { ActiveVersionError, CallInterfaceViolationError, NotClosedError, PinnedVersionError, PositionVacantError, StaleVersionError } from './contract.ts'; +export { ActiveVersionError, CallInterfaceViolationError, NotClosedError, VersionTakenError, PinnedVersionError, PositionVacantError, StaleVersionError } from './contract.ts'; export type { FlowRuntime, ActivityDirectory, ApprovalGateway, Clock, FlowEventPublisher, IdGenerator, OrgDirectory, ProcessStore, ReceiptNotifier, Sfeel, TaskGateway, } from './port.ts'; export type { ProcessServiceDeps } from './internal/usecase/process-service.ts'; export { createProcessService } from './internal/usecase/process-service.ts'; 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 2a90c42..f194c6b 100644 --- a/api-report/apps/api/src/modules/process/port.d.ts +++ b/api-report/apps/api/src/modules/process/port.d.ts @@ -68,8 +68,14 @@ export type ProcessStore = { /** 只改状态,不动定义 —— state 在列上与 definition 里各存一份,两处要一起改 */ setPackageState(id: string, version: number, state: 'active' | 'retired'): Promise; deletePackage(id: string, version: number): Promise; - /** 改名及于这条流的所有版本 —— 名字是流的身份,不是版本的属性 */ - renameFlow(id: string, name: string): Promise; + /** 只改一版的名 —— 名字是版本自己的属性(列上与 definition 里各一份,两处一起改) */ + renameVersion(id: string, version: number, name: string): Promise; + /** + * 改版本号 —— 号在三处:包的列上、definition 里、**跑过的单的 `packageVersion` 上**。 + * 三处一起改:号只是这一版的名牌,搬号连引用一起搬,谁也不会指空或指错。 + * 撞号与在途钉版由用例层先挡。 + */ + renumberVersion(id: string, from: number, to: number): 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 e8ce7b0..d7f4cd7 100644 --- a/api-report/packages/contracts/src/api/governance.d.ts +++ b/api-report/packages/contracts/src/api/governance.d.ts @@ -1053,17 +1053,23 @@ export declare const deleteProcessVersionRoute: { }, z.core.$strip>; }; /** - * 改名 —— 名字是**这条流的身份**,不是某一版的属性:一次改及于它的所有版本。 - * (版本列表上只显示 v1/v2…,若只改一版的名,列表上什么也看不见,人会以为没生效。) + * 改版本记录 —— **版本号自己定**(2026-07-29 用户定)、名字随之:改哪版只动哪版。 + * + * 号从前只能由系统递增。设计人员要能自己编号(1 / 2 / 10 都行),所以放开; + * 两条约束是数据本身要求的:号不许撞同流已有版本;**在途钉版不许改号** + * (那些单正按这个号找定义)。已完结的单跟着改指 —— 号只是这一版的名牌, + * 搬号连引用一起搬,留痕仍指同一份定义。 */ -export declare const renameProcessRoute: { +export declare const editProcessVersionRoute: { readonly method: "POST"; - readonly path: "/api/gov/process/:id/rename"; + readonly path: "/api/gov/process/:id/:version/edit"; readonly params: z.ZodObject<{ id: z.ZodString; + version: z.ZodString; }, z.core.$strip>; readonly body: z.ZodObject<{ - name: z.ZodString; + version: z.ZodOptional; + name: z.ZodOptional; }, z.core.$strip>; readonly output: z.ZodObject<{ packages: z.ZodArray, container: Contai } }); - /** 改名:名字是这条流的身份 —— 一次改及于所有版本;在途单不受影响(名字不参与流转) */ - app.post(renameProcessRoute.path, async (c) => { + /** 改版本记录:号自己定、名随之 —— 撞号与「跑过单改号」409,别的版一字不动 */ + app.post(editProcessVersionRoute.path, async (c) => { const denied = await governProcess(c.get('account')); if (denied) return c.json(denied, denyStatus(denied)); - const parsed = renameProcessRoute.body.safeParse(await c.req.json()); + const parsed = editProcessVersionRoute.body.safeParse(await c.req.json()); if (!parsed.success) { - const body: ApiError = { code: 'validation-failed', message: '流程名不合协议(1–40 字)' }; + const body: ApiError = { code: 'validation-failed', message: '版本号要 1–9999 的整数,名字 1–40 字' }; return c.json(body, 400); } + const version = Number(c.req.param('version')); + if (!Number.isInteger(version)) { + const body: ApiError = { code: 'validation-failed', message: '版本号要是整数' }; + return c.json(body, 400); + } + const patch = { + ...(parsed.data.version === undefined ? {} : { version: parsed.data.version }), + ...(parsed.data.name === undefined ? {} : { name: parsed.data.name.trim() }), + }; try { - return c.json({ packages: await container.process.rename(c.req.param('id'), parsed.data.name.trim()) }); + return c.json({ packages: await container.process.editVersion(c.req.param('id'), version, patch) }); } catch (error) { + if (error instanceof VersionTakenError || error instanceof PinnedVersionError) { + const body: ApiError = { code: 'conflict', message: error.message }; + return c.json(body, 409); + } const body: ApiError = { code: 'not-found', message: error instanceof Error ? error.message : String(error) }; return c.json(body, 404); } diff --git a/apps/api/src/modules/process/CONTRACT.md b/apps/api/src/modules/process/CONTRACT.md index c975dd2..c2e3412 100644 --- a/apps/api/src/modules/process/CONTRACT.md +++ b/apps/api/src/modules/process/CONTRACT.md @@ -1,6 +1,6 @@ # module: process -职责: 流程层:流程包定义与版本(建/克隆/启停/**删/改名**)、**闭合校验**(边只表达顺序与条件 · 画图不检查 · 不闭合不得激活)、 +职责: 流程层:流程包定义与版本(建/克隆/启停/**删/改号改名**)、**闭合校验**(边只表达顺序与条件 · 画图不检查 · 不闭合不得激活)、 负责人四策略的确定性解析、工作流状态的聚合投影、失败走向的消化判定、节点实例重开 不负责: 活动的执行(见 task 与范式引擎)、触发器(与流程包**平级**,见 trigger)、 画布的绘制(见 apps/web 的 gov-process) @@ -8,7 +8,7 @@ 公开出口: index.ts - 类型:`ProcessService` `StartWorkflowInput` `WorkflowRecord` `ClosureReport` `ClosureViolation` `ActivityContractFace` `Candidate` `Resolution` `NodeInstance` `WorkflowState` `Dispatch` - - 错误:`NotClosedError` `PinnedVersionError` `PositionVacantError` + - 错误:`NotClosedError` `PinnedVersionError` `PositionVacantError` `ActiveVersionError` `VersionTakenError` - 端口类型:`ProcessStore` `ActivityDirectory` `OrgDirectory` `TaskGateway` `ApprovalGateway` `Sfeel` `FlowEventPublisher` `ReceiptNotifier` - 用例工厂:`createProcessService(deps): ProcessService`(含 `setPackageState` 版本启停) @@ -29,8 +29,15 @@ 类型对不对全归活动自己。激活闸只剩两面:**结构闭合**(dangling-node / multi-default-edge) 与**活动装载**(unknown-activity);字段类的三条检查(边未映射 / 必填未映射 / 类型相抵)全退役。 - **版本可删,两道闸**:生效版不可删(先停用,`ActiveVersionError`)、在途钉版不可删 - (那些单要按它跑完,`PinnedVersionError`)。**改名及于整条流的所有版本** —— - 名字是流的身份,不是某一版的属性。 + (那些单要按它跑完,`PinnedVersionError`)。 + - **版本号与名归设计人员自己定**(2026-07-29 用户定): + · **号自己编**,不再只能系统递增 —— 两道闸:不许撞同流已有号(`VersionTakenError`)、 + 在途钉版不许改号(`PinnedVersionError`,那些单正按这个号找定义)。 + 号在三处(包的列、definition、跑过的单的 `packageVersion`)**一起改** —— + 号只是这一版的名牌,搬号连引用一起搬,已完结的单仍指同一份定义。 + · **名只动这一版**(推翻前一日的「及于整条流」)—— 各版本正是靠名字互相区分 + (「v3 手工议价」/「v4 含寻源」),全同名等于没名字;流的身份是 `id`, + 货架上以生效版的名字示人。名不设闸:标签不参与流转,在途钉版也能改。 - **数据按字段名流动**(2026-07-30 对齐 Mastra,替代「连线即映射」):边不带映射, 只表达顺序与条件(条件读上游输出字段)。节点入参 = 铺平作用域 (起单入参 ← 沿途各输出口字段 ← 刚发射的载荷,后者赢)按下游契约取; diff --git a/apps/api/src/modules/process/__tests__/save-package.test.ts b/apps/api/src/modules/process/__tests__/save-package.test.ts index 95f57a9..049ecb9 100644 --- a/apps/api/src/modules/process/__tests__/save-package.test.ts +++ b/apps/api/src/modules/process/__tests__/save-package.test.ts @@ -59,7 +59,8 @@ function harness(seed: ProcessPackage[], pinned = 0) { updateInstance: async () => undefined, findInstanceByTask: async () => undefined, deletePackage: async () => undefined, - renameFlow: async () => undefined, + renameVersion: async () => undefined, + renumberVersion: async () => undefined, nodeOwners: async () => ({}), setNodeOwner: async () => undefined, }, 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 d5088e5..86c9d05 100644 --- a/apps/api/src/modules/process/__tests__/start-guard.test.ts +++ b/apps/api/src/modules/process/__tests__/start-guard.test.ts @@ -69,7 +69,8 @@ function harness(validationErrors: readonly string[]): Harness { updateInstance: async () => undefined, findInstanceByTask: async () => undefined, deletePackage: async () => undefined, - renameFlow: async () => undefined, + renameVersion: async () => undefined, + renumberVersion: async () => undefined, nodeOwners: async () => ({}), setNodeOwner: async () => undefined, }, diff --git a/apps/api/src/modules/process/__tests__/version-lifecycle.test.ts b/apps/api/src/modules/process/__tests__/version-lifecycle.test.ts index 44ea43c..841de91 100644 --- a/apps/api/src/modules/process/__tests__/version-lifecycle.test.ts +++ b/apps/api/src/modules/process/__tests__/version-lifecycle.test.ts @@ -1,16 +1,19 @@ /** - * 版本的删与改名(2026-07-30 用户要的两件)—— 守两道闸、一条口径。 + * 版本记录的删与改 —— 号与名归设计人员自己定,三道闸守住数据说得通。 * * · **生效版不可删**:先停用。挡的是「手滑把正在起单的定义删了」; * · **在途钉版不可删**:那些工作流按启动时钉住的这一版跑完,定义没了就再也推不动 * —— 与「在途钉版不可改」同一条理由、同一个错(PinnedVersionError); - * · **改名及于整条流的所有版本**:名字是流的身份,不是某一版的属性。 - * (版本列表上只显示 v1/v2…,若只改一版的名,列表上什么也看不见,人会以为没生效。) + * · **号自己定**(2026-07-29 用户定):不再只能系统递增。两条约束是数据本身要的 —— + * 号不许撞同流已有版本(VersionTakenError);**在途钉版不许改号** + * (PinnedVersionError:那些单正按这个号找定义)。已完结的单由 store 跟着改指, + * 号只是这一版的名牌,搬号连引用一起搬,留痕仍指同一份定义。 + * · **名只动这一版**、不设闸:各版本正是靠名字互相区分,标签不参与流转。 */ import { describe, expect, it } from 'vitest'; import type { ProcessPackage } from '@contracts'; import { processPackageSchema } from '@contracts'; -import { ActiveVersionError, PinnedVersionError } from '../contract.ts'; +import { ActiveVersionError, PinnedVersionError, StaleVersionError, VersionTakenError } from '../contract.ts'; import { createProcessService } from '../internal/usecase/process-service.ts'; import type { ProcessServiceDeps } from '../internal/usecase/process-service.ts'; @@ -46,10 +49,13 @@ function harness(rows: ProcessPackage[], pinned = 0): Harness { const i = live.findIndex((p) => p.id === id && p.version === version); if (i >= 0) live.splice(i, 1); }, - renameFlow: async (id: string, name: string) => { - for (let i = 0; i < live.length; i += 1) { - if (live[i]!.id === id) live[i] = { ...live[i]!, name }; - } + renameVersion: async (id: string, version: number, name: string) => { + const i = live.findIndex((p) => p.id === id && p.version === version); + if (i >= 0) live[i] = { ...live[i]!, name }; + }, + renumberVersion: async (id: string, from: number, to: number) => { + const i = live.findIndex((p) => p.id === id && p.version === from); + if (i >= 0) live[i] = { ...live[i]!, version: to }; }, getPackage: async (id: string, version?: number) => live.find((p) => p.id === id && (version === undefined || p.version === version)), @@ -104,14 +110,35 @@ describe('版本的删与改名', () => { expect(h.rows).toHaveLength(2); }); - it('改名及于所有版本 —— 名字是这条流的身份', async () => { + it('改名只动这一版 —— 各版本正是靠名字互相区分', async () => { const h = harness([pkg(2, 'active'), pkg(1, 'retired')]); - const after = await h.service.rename('quote', '统一报价(新)'); - expect(after.map((p) => p.name)).toEqual(['统一报价(新)', '统一报价(新)']); + const after = await h.service.editVersion('quote', 1, { name: '统一报价 · 手工议价' }); + expect(after.map((p) => `v${p.version} ${p.name}`)).toEqual(['v2 统一报价', 'v1 统一报价 · 手工议价']); }); - it('改不存在的流:如实报,不静默建一条', async () => { + it('版本号自己定 —— 号与名一趟改完,别的版不动', async () => { + const h = harness([pkg(2, 'active'), pkg(1, 'retired')]); + const after = await h.service.editVersion('quote', 1, { version: 10, name: '统一报价 · 手工议价' }); + expect(after.map((p) => `v${p.version} ${p.name}`)).toEqual(['v10 统一报价 · 手工议价', 'v2 统一报价']); + }); + + it('号不许撞同流已有版本', async () => { + const h = harness([pkg(2, 'active'), pkg(1, 'retired')]); + await expect(h.service.editVersion('quote', 1, { version: 2 })).rejects.toBeInstanceOf(VersionTakenError); + expect(h.rows.map((p) => p.version), '一行都没动').toEqual([2, 1]); + }); + + it('在途钉版不许改号 —— 那些单正按这个号找定义', async () => { + const h = harness([pkg(2, 'active'), pkg(1, 'retired')], 3); + await expect(h.service.editVersion('quote', 1, { version: 7 })).rejects.toBeInstanceOf(PinnedVersionError); + // 但**名**照改不误:标签不参与流转,不受这道闸管 + const after = await h.service.editVersion('quote', 1, { name: '旧版' }); + expect(after.find((p) => p.version === 1)?.name).toBe('旧版'); + }); + + it('改不存在的版本:如实报,不静默建一条', async () => { const h = harness([pkg(1, 'retired')]); - await expect(h.service.rename('nope', 'X')).rejects.toThrow(/不存在/); + await expect(h.service.editVersion('quote', 9, { name: 'X' })).rejects.toBeInstanceOf(StaleVersionError); + await expect(h.service.editVersion('nope', 1, { name: 'X' })).rejects.toBeInstanceOf(StaleVersionError); }); }); diff --git a/apps/api/src/modules/process/contract.ts b/apps/api/src/modules/process/contract.ts index 0151cd6..784f49f 100644 --- a/apps/api/src/modules/process/contract.ts +++ b/apps/api/src/modules/process/contract.ts @@ -81,8 +81,12 @@ export type ProcessService = { /** 删一版:生效版与在途钉版都不许删(见 `ActiveVersionError` / `PinnedVersionError`) */ deleteVersion(id: string, version: number): Promise; - /** 改名:名字是这条流的身份,一次改及于所有版本 */ - rename(id: string, name: string): Promise; + /** + * 改版本记录:**号与名**,只动这一版 —— 号自己定。 + * 名不设闸(标签不参与流转);号设两道:不许撞同流已有号(`VersionTakenError`)、 + * 在途钉版不许改号(`PinnedVersionError`);已完结的单跟着改指,留痕不失真。 + */ + editVersion(id: string, version: number, patch: { version?: number; name?: string }): Promise; /* ── 节点负责人(运营配置):显式指派 > 这里 > 轮值 ── */ nodeOwners(packageId: string): Promise>; @@ -163,6 +167,17 @@ export class PinnedVersionError extends Error { } } +/** 号被占:同一条流里版本号唯一 —— 换个号,或先把占号那版删了 */ +export class VersionTakenError extends Error { + constructor( + readonly packageId: string, + readonly version: number, + ) { + super(`v${version} 已被同流的另一版占着 —— 换个号`); + this.name = 'VersionTakenError'; + } +} + export class PositionVacantError extends Error { constructor(readonly position: string) { super(`岗位空缺,无法解析负责人:${position} —— 按节点失败走向处置,或转治理台待指派`); diff --git a/apps/api/src/modules/process/index.ts b/apps/api/src/modules/process/index.ts index c829c79..8864f1a 100644 --- a/apps/api/src/modules/process/index.ts +++ b/apps/api/src/modules/process/index.ts @@ -18,7 +18,7 @@ export type { WorkflowRecord, WorkflowState, } from './contract.ts'; -export { ActiveVersionError, CallInterfaceViolationError, NotClosedError, +export { ActiveVersionError, CallInterfaceViolationError, NotClosedError, VersionTakenError, PinnedVersionError, PositionVacantError, StaleVersionError } from './contract.ts'; export type { 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 ac921ac..a1bc10b 100644 --- a/apps/api/src/modules/process/internal/adapter/process-store.ts +++ b/apps/api/src/modules/process/internal/adapter/process-store.ts @@ -64,16 +64,38 @@ export function createProcessStore(db: Db): ProcessStore { .where(and(eq(processPackageTable.id, id), eq(processPackageTable.version, version))); }, - async renameFlow(id, name) { + async renameVersion(id, version, name) { // 名字在列上与 definition(jsonb)里各有一份 —— 与 setPackageState 同一口径,两处一起改 - const rows = await db.select().from(processPackageTable).where(eq(processPackageTable.id, id)); - for (const row of rows) { - const definition = { ...(row.definition as Record), name }; - await db - .update(processPackageTable) - .set({ name, definition }) - .where(and(eq(processPackageTable.id, id), eq(processPackageTable.version, row.version))); - } + const rows = await db + .select() + .from(processPackageTable) + .where(and(eq(processPackageTable.id, id), eq(processPackageTable.version, version))); + const row = rows[0]; + if (row === undefined) return; // 用例层已挡「版本不存在」,这里不静默造行 + const definition = { ...(row.definition as Record), name }; + await db + .update(processPackageTable) + .set({ name, definition }) + .where(and(eq(processPackageTable.id, id), eq(processPackageTable.version, version))); + }, + + async renumberVersion(id, from, to) { + const rows = await db + .select() + .from(processPackageTable) + .where(and(eq(processPackageTable.id, id), eq(processPackageTable.version, from))); + const row = rows[0]; + if (row === undefined) return; // 用例层已挡「版本不存在」 + const definition = { ...(row.definition as Record), version: to }; + await db + .update(processPackageTable) + .set({ version: to, definition }) + .where(and(eq(processPackageTable.id, id), eq(processPackageTable.version, from))); + // 跑过的单跟着改指 —— 同一份定义,只是名牌换了号;不搬引用才会让留痕指错 + await db + .update(workflowTable) + .set({ packageVersion: to }) + .where(and(eq(workflowTable.packageId, id), eq(workflowTable.packageVersion, from))); }, async getPackage(id, 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 914b7c2..072693c 100644 --- a/apps/api/src/modules/process/internal/usecase/process-service.ts +++ b/apps/api/src/modules/process/internal/usecase/process-service.ts @@ -5,7 +5,13 @@ */ import type { ProcessPackage } from '@contracts'; import type { ProcessService, StartWorkflowInput } from '../../contract.ts'; -import { ActiveVersionError, NotClosedError, PinnedVersionError, StaleVersionError } from '../../contract.ts'; +import { + ActiveVersionError, + NotClosedError, + PinnedVersionError, + StaleVersionError, + VersionTakenError, +} from '../../contract.ts'; import type { ActivityDirectory, ApprovalGateway, @@ -171,11 +177,28 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService { return after.filter((p) => p.id === id).sort((a, b) => b.version - a.version); }, - async rename(id, name) { - const all = await deps.store.listPackages(); - if (!all.some((p) => p.id === id)) throw new Error(`流程包不存在:${id}`); - // 名字是这条流的身份 —— 一次改及于所有版本(在途单不受影响:名字不参与流转) - await deps.store.renameFlow(id, name); + async editVersion(id, version, patch) { + const one = await deps.store.getPackage(id, version); + if (one === undefined) throw new StaleVersionError(id, version); + const to = patch.version; + if (to !== undefined && to !== version) { + const all = await deps.store.listPackages(); + if (all.some((p) => p.id === id && p.version === to)) throw new VersionTakenError(id, to); + /* + * 在途钉版不许改号:那些单正按这个号找定义,搬号等于在它们脚下抽地板。 + * 已完结的单不拦 —— 它们的 `packageVersion` 由 store 跟着一起改指,留痕仍指同一份定义。 + */ + const pinned = await deps.store.countPinnedInFlight(id, version); + if (pinned > 0) throw new PinnedVersionError(id, version, pinned); + await deps.store.renumberVersion(id, version, to); + } + /* + * 名不设闸:**在途钉版也允许改** —— 名字是标签不参与流转, + * 那几道闸守的是能跑的东西(定义与号),与名字无关。 + */ + if (patch.name !== undefined && patch.name !== one.name) { + await deps.store.renameVersion(id, to ?? version, patch.name); + } const after = await deps.store.listPackages(); return after.filter((p) => p.id === id).sort((a, b) => b.version - a.version); }, diff --git a/apps/api/src/modules/process/port.ts b/apps/api/src/modules/process/port.ts index 13d9ce4..b685c41 100644 --- a/apps/api/src/modules/process/port.ts +++ b/apps/api/src/modules/process/port.ts @@ -77,8 +77,14 @@ export type ProcessStore = { /** 只改状态,不动定义 —— state 在列上与 definition 里各存一份,两处要一起改 */ setPackageState(id: string, version: number, state: 'active' | 'retired'): Promise; deletePackage(id: string, version: number): Promise; - /** 改名及于这条流的所有版本 —— 名字是流的身份,不是版本的属性 */ - renameFlow(id: string, name: string): Promise; + /** 只改一版的名 —— 名字是版本自己的属性(列上与 definition 里各一份,两处一起改) */ + renameVersion(id: string, version: number, name: string): Promise; + /** + * 改版本号 —— 号在三处:包的列上、definition 里、**跑过的单的 `packageVersion` 上**。 + * 三处一起改:号只是这一版的名牌,搬号连引用一起搬,谁也不会指空或指错。 + * 撞号与在途钉版由用例层先挡。 + */ + renumberVersion(id: string, from: number, to: number): 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 debecc7..324301d 100644 --- a/apps/web/src/app/styles/console.css +++ b/apps/web/src/app/styles/console.css @@ -553,7 +553,10 @@ /* 三卡同厚:头行内容一律 26 高(= 一枚方钮),没按钮的头也不塌 —— 三条底线同一水平 */ .g-verslim .g-vercard-h { height: auto; min-height: 26px; padding: 14px 16px 11px; gap: 4px; } .g-verslim .g-vercard-list { padding: 12px; gap: 6px; } -.g-verrow { display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 8px 7px 11px; border: 1px solid var(--sh-line); border-left: 3px solid var(--sh-steel); background: #fff; font-family: var(--sh-mono); font-size: 10px; color: #3a4452; text-align: left; cursor: pointer; transition: border-color var(--t-fast); } +/* 版本行两行:上行 版本号 · 名字 · 动作;下行只放事实(状态 · 点边计数) */ +.g-verrow { display: flex; flex-direction: column; gap: 4px; width: 100%; padding: 7px 8px 8px 11px; border: 1px solid var(--sh-line); border-left: 3px solid var(--sh-steel); background: #fff; font-family: var(--sh-mono); font-size: 10px; color: #3a4452; text-align: left; cursor: pointer; transition: border-color var(--t-fast); } +.g-verrow .ln { display: flex; align-items: center; gap: 7px; } +.g-verrow .ln2 { display: flex; align-items: center; gap: 8px; padding-left: 1px; } /* 行尾小钮:启停(平时)/ 删除(编辑态)—— 比动作条矮一号,行不发胖 */ .g-verrow .g-ver-act { height: 22px; padding: 0 8px; font-size: 10.5px; } .g-verrow:hover { border-color: #aeb9c6; } @@ -561,20 +564,16 @@ .g-verrow.live { border-left-color: var(--sh-brass); background: var(--sh-brass-wash); } .g-verrow.live.on { border-color: #cbb98d; } .g-verrow:focus-visible { outline: 2px solid var(--brand); outline-offset: 1px; } -.g-verrow > b { font-size: 13px; font-weight: 700; color: var(--sh-ink); } +.g-verrow b { font-size: 13px; font-weight: 700; color: var(--sh-ink); } +/* 版本名:占满余下、一行截断 —— 界面字体,不用图签的等宽 */ +.g-verrow .nm { flex: 1; min-width: 0; font: 500 11.5px/1.3 var(--ui-sans); color: var(--sh-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* 编辑态:号与名就是两个输入框(号自己编),光标默认,不再是可点切版的行 */ +.g-verrow.ed { cursor: default; } +.g-verrow .g-input.vnum { flex: none; width: 42px; height: 22px; padding: 0 4px; font-family: var(--sh-mono); font-size: 11.5px; text-align: center; } +.g-verrow .g-input.nm { flex: 1; min-width: 0; height: 22px; padding: 0 6px; font-size: 11.5px; } .g-verrow .st { font-size: 9px; letter-spacing: .12em; color: var(--sh-muted); } .g-verrow .st.live { color: var(--sh-brass); font-weight: 700; } .g-verrow .cnt { margin-left: auto; font-size: 9.5px; color: var(--sh-muted); white-space: nowrap; } -.g-verrow .cnt + .g-ver-act { margin-left: 8px; } -/* 版本记录编辑态:一条浅带 —— 改名钮与「行尾可删」的交代住这儿 */ -.g-veredit { flex: none; display: flex; align-items: center; gap: 8px; padding: 9px 16px; border-bottom: 1px solid var(--sh-line); background: #f9fafc; } -.g-veredit .t { font-family: var(--sh-mono); font-size: 9px; line-height: 1.6; color: var(--sh-muted); } -.g-veredit .g-verrename { flex: 1; padding: 0; border: 0; background: transparent; } - -/* 改名条:编辑态里点「改名」就地展开 */ -.g-verrename { flex: none; display: flex; flex-direction: column; gap: 5px; padding: 9px 14px; border-bottom: 1px solid var(--sh-line); background: #f9fafc; } -.g-verrename .g-input { height: 26px; font-size: 12px; } -.g-verrename .t { font-family: var(--sh-mono); font-size: 8.5px; line-height: 1.7; color: var(--sh-muted); } .g-ver { display: flex; align-items: center; gap: 9px; padding: 9px 11px; border: 1px solid var(--sh-line); border-left: 3px solid var(--sh-steel); background: #fff; cursor: pointer; font-family: var(--sh-mono); font-size: 11px; color: #3a4452; } .g-ver:hover { border-color: #aeb9c6; } .g-ver.on { background: #f2f5f8; border-color: #9fb0c0; } diff --git a/apps/web/src/features/gov-process/internal/panel.tsx b/apps/web/src/features/gov-process/internal/panel.tsx index d803635..f3aeefc 100644 --- a/apps/web/src/features/gov-process/internal/panel.tsx +++ b/apps/web/src/features/gov-process/internal/panel.tsx @@ -15,12 +15,13 @@ * · 在途工作流按启动时钉住的版本跑完 —— 启停对它们零影响 */ 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 { deleteProcessVersionRoute, processPackageSchema, - renameProcessRoute, + editProcessVersionRoute, saveProcessRoute, setProcessStateRoute, validateProcessRoute, @@ -359,13 +360,14 @@ function FlowLab({ const [dirty, setDirty] = useState(false); const [report, setReport] = useState(undefined); const [ask, setAsk] = useState<{ spec: AskSpec; danger?: boolean } | undefined>(undefined); - /** 改名就地开一个输入框(名字改的是整条流,不是某一版) */ - const [renaming, setRenaming] = useState(false); /** - * **版本记录**的编辑态(2026-07-30):开着才出改名条、行尾钮换成删除 —— + * **版本记录**的编辑态:开着,每行的号与名就是输入框、行尾钮换成删除; + * 顶栏两枚钮 取消 / 保存 —— 改动攒在草稿里,按保存才落库。 * 它只管版本记录这一件事,与画布的手绘编辑(那是流程图卡自己的)互不相干。 */ const [editingVersions, setEditingVersions] = useState(false); + /** 版本记录草稿:原版本号 → 改后的号与名 */ + const [edits, setEdits] = useState>({}); const canvasRef = useRef(null); const nodes = graph?.nodes ?? []; @@ -463,15 +465,32 @@ function FlowLab({ onError: (e: unknown) => setSaveErr((e as { message?: string }).message ?? '删不掉'), }); - /** 改名:名字是这条流的身份 —— 一次改及于所有版本 */ - const rename = useMutation({ - mutationFn: (name: string) => - call<{ packages: ProcessPackage[] }>(renameProcessRoute, { params: { id: pkg.id }, body: { name } }), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] }); - setRenaming(false); + /** + * 存版本记录:逐行把改过的号与名送上去 —— 撞号、跑过单改号,服务端 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; }, - onError: (e: unknown) => setSaveErr((e as { message?: string }).message ?? '改名失败'), + 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 ?? '版本记录没存上'), }); /** @@ -673,107 +692,162 @@ function FlowLab({