三面拆分⑰:编辑即其身 —— 保存写回本版,新版本只出自「新建」

用户问:为什么编辑一个版本、保存就自动多一个副本?那不是我要的。
旧机制「保存恒新增版本号」改为:

- savePackage:pkg.version 已存在 → **就地写回该版**(号不变、状态不变);
  `asNew: true`(新建按钮)或新流程首版才铸新版本号(服务端算,不生效)
- 底线三道,一道不少:
  ① **在途钉版不可变** —— 该版有未完结工作流(按 (包,版本) 钉定义)→ 409,
    一字不落库;「就地覆盖会改掉在途正在读的编排」由这道闸守,不再靠只插不改一刀切
  ② **生效版必须始终可运行** —— 写回生效版跑闭合闸,不闭合 412 带整份报告;
    未生效版画图不检查、随手存
  ③ **陈旧编辑不复活** —— 流程存在而版本号已被清理 → 409(实测:停在已删旧版上的
    标签页发来保存,把老内容静默复活成了新版本;StaleVersionError 拦死)
- store:新增 updatePackageDefinition(只许命中现存版本)+ countPinnedInFlight
- 前端:保存按钮「保存 v{n}」;新建走 asNew;文案全线「新建才长版本」
- 测试:save-package.test 六例(写回/钉版/热改闭合/asNew/防复活/首版);
  过程中揪出双 API 进程并存(旧进程霸端口跑旧代码)——已重启归一

实测:改名保存 → 版本列表仍唯一 v4 生效中,名字已写回;八例端到端全绿;
五闸退出码全过(api:check 提交后转绿)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-28 20:11:41 +08:00
co-authored by Claude Fable 5
parent 2246b26438
commit a10a7b2855
16 changed files with 312 additions and 36 deletions
+19 -6
View File
@@ -36,7 +36,7 @@ import {
} from '@contracts';
import { InstallRejectedError } from '@modules/activity-registry';
import type { InstallVerdict } from '@modules/activity-registry';
import { NotClosedError } from '@modules/process';
import { NotClosedError, PinnedVersionError, StaleVersionError } from '@modules/process';
import type { Container } from '../container.ts';
import type { AppEnv } from '../http-env.ts';
@@ -265,7 +265,7 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
/**
* 保存编排 —— 保存 ≠ 发布:草稿可反复存;**已发布版本不可回改**。
* 想改已发布的,另起新版本(恒新增、版本并存,在途按启动时版本跑完)。
* 编辑即其身:保存写回当前版本;在途钉版不可变,新版本只出自显式新建。
*/
app.put(saveProcessRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
@@ -276,11 +276,24 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
return c.json(body, 400);
}
/*
* 保存**不检查、不生效**:画图的中间态天然不完整,存下来是未生效的新版本。
* 返回连同闭合报告 —— 异常看板据它列清单;完整性闸在激活路由。
* 编辑即其身:已存在的版本就地写回(号不变);asNew / 新流程首版才长新版本。
* 画图不检查(未生效版随手存);返回连同闭合报告 —— 异常看板据它列清单。
* 两道闸:在途钉版 409;写回生效版不闭合 412(生效的必须始终可运行)。
*/
const pkg = await container.process.savePackage(parsed.data.pkg);
return c.json({ pkg, report: await container.process.validate(pkg) });
try {
const pkg = await container.process.savePackage(parsed.data.pkg, { asNew: parsed.data.asNew === true });
return c.json({ pkg, report: await container.process.validate(pkg) });
} catch (error) {
if (error instanceof PinnedVersionError || error instanceof StaleVersionError) {
const body: ApiError = { code: 'conflict', message: error.message };
return c.json(body, 409);
}
if (error instanceof NotClosedError) {
const body: ApiError = { code: 'precondition-failed', message: error.message, detail: error.report };
return c.json(body, 412);
}
throw error;
}
});
/** 版本启停:激活即独占,停用即下线 —— 在途按启动版本跑完,零影响 */
+1 -1
View File
@@ -374,7 +374,7 @@ export async function seedMvpFlow(container: Container): Promise<void> {
/*
* 保存即生效,且**内容没变就不再存新版**。
*
* 保存恒新增版本(这是「旧任务走完旧版本」的前提),所以种子不能无条件存 ——
* 内容变更走新版本(在途钉旧版跑完),所以种子不能无条件存 ——
* 那样每跑一次播种就多一个版本,库里堆一串一模一样的定义。
* 比对的是语义与呈现,不含 version/state(它们由保存本身决定)。
*/
+13 -6
View File
@@ -8,7 +8,7 @@
公开出口: index.ts
- 类型:`ProcessService` `StartWorkflowInput` `WorkflowRecord` `ClosureReport` `ClosureViolation`
`ActivityContractFace` `Candidate` `Resolution` `NodeInstance` `WorkflowState` `Dispatch`
- 错误:`NotClosedError` `PositionVacantError`
- 错误:`NotClosedError` `PinnedVersionError` `PositionVacantError`
- 端口类型:`ProcessStore` `ActivityDirectory` `OrgDirectory` `TaskGateway` `ApprovalGateway`
`Sfeel` `FlowEventPublisher` `ReceiptNotifier`
- 用例工厂:`createProcessService(deps): ProcessService`(含 `setPackageState` 版本启停)
@@ -25,9 +25,15 @@
被依赖: trigger, app(治理台 · 工作流管理)
不变量(OS 03 / 3.2):
- **画图不检查,激活才查完整**(产品决策 2026-07-28):`savePackage` 不跑闭合校验,
随手画的半成品也存得进来(存出来即 `retired`,不生效);`setPackageState` 激活时
跑 `checkClosure`,不闭合抛 `NotClosedError`(HTTP 412 带整份报告)。
- **编辑即其身**(产品决策 2026-07-28 第四轮):`savePackage` 对已存在的版本
**就地写回**(号不变、状态不变);新版本只出自显式新建(`asNew: true`)或
新流程首版(插入,`retired` 不生效)。两道底线:
① **在途钉版不可变** —— 该版有未完结工作流(按 (包, 版本) 钉定义)即抛
`PinnedVersionError`(HTTP 409),一字不落库;改动请新建一版。
② **生效版必须始终可运行** —— 写回生效版跑 `checkClosure`,不闭合抛
`NotClosedError`(HTTP 412 带整份报告);未生效版画图不检查、随手存。
- **激活才查完整**:`setPackageState` 激活时跑 `checkClosure`,
不闭合抛 `NotClosedError`(HTTP 412 带整份报告)。
违规项:未完成边、必填未映射、类型冲突、孤立节点、多默认出边、
活动未装载、审批岗位不存在、容忍分支被下游必填依赖 —— 任一即 `closed = false`,激活被拒。
(活动点不核岗位 —— 契约不带负责人,`missing-owner-position` 只出自审批点。)
@@ -53,8 +59,9 @@
- **一条流恰一个生效版本**(产品决策 2026-07-28):`setPackageState` 激活即独占
(同流其余版本一并停用);停用生效版 = 整条流下线,新起单被拒。
起单取**生效版**;在途工作流按启动时钉住的版本号读定义 —— 启停对它们零影响。
- **保存恒新增版本号**(服务端算,不采信入参):工作流按 (包id, 版本) 读定义,
就地覆盖会改掉在途任务正在读的编排。store 层因此**只插不改**,撞键宁可报错也不静默覆盖。
- **版本号服务端算**(新建时,不采信入参):工作流按 (包id, 版本) 读定义;
store 的写回(`updatePackageDefinition`)只许命中已存在的版本,撞不存在报错;
「就地覆盖会改掉在途正在读的编排」由在途钉版闸守着,不再靠只插不改一刀切。
- **求值语义前后端同源**:画布的即时校验与后端裁决共用 `checkClosure`
- **负责人解析确定性**:轮值并列时按 staffId 稳定排序;同输入必同结果,结果与依据留痕
- **工作流状态是聚合投影**:流程层不另设状态机;计量对象是**节点实例**,
@@ -0,0 +1,139 @@
/**
* 保存 —— **编辑即其身**(产品决策 2026-07-28 第四轮):
* 写回当前版本号不变;新版本只出自显式新建(asNew)或新流程首版。
* 两道底线:在途钉版不可变;生效版写回必须闭合(生效的必须始终可运行)。
*/
import { describe, expect, it } from 'vitest';
import { processPackageSchema } from '@contracts';
import type { ProcessPackage } from '@contracts';
import { NotClosedError, PinnedVersionError, StaleVersionError } from '../contract.ts';
import { createProcessService } from '../internal/usecase/process-service.ts';
import type { ProcessServiceDeps } from '../internal/usecase/process-service.ts';
const basePkg = (version: number, state: 'active' | 'retired'): ProcessPackage =>
processPackageSchema.parse({
id: 'p1',
name: '包',
version,
state,
callInterface: { input: { type: 'object' }, output: { type: 'object' } },
nodes: [
{ id: 'S', kind: 'start', label: '起' },
{ id: 'E', kind: 'end', label: '终' },
],
edges: [{ id: 'E1', from: 'S', to: 'E', mapping: [], reopen: false }],
variables: [],
});
function harness(seed: ProcessPackage[], pinned = 0) {
const shelf = [...seed];
const updated: Array<{ id: string; version: number }> = [];
const inserted: ProcessPackage[] = [];
const deps: ProcessServiceDeps = {
store: {
savePackage: async (pkg) => {
inserted.push(pkg);
shelf.push(pkg);
},
updatePackageDefinition: async (id, version, pkg) => {
const at = shelf.findIndex((p) => p.id === id && p.version === version);
if (at < 0) throw new Error('版本不存在');
shelf[at] = pkg;
updated.push({ id, version });
},
countPinnedInFlight: async () => pinned,
setPackageState: async () => undefined,
getPackage: async (id, version) =>
version === undefined
? [...shelf].sort((a, b) => b.version - a.version).find((p) => p.id === id)
: shelf.find((p) => p.id === id && p.version === version),
listPackages: async () => shelf,
insertWorkflow: async () => undefined,
getWorkflow: async () => undefined,
listWorkflows: async () => [],
updateWorkflow: async () => {
throw new Error('测试:不该走到 updateWorkflow');
},
listInstances: async () => [],
insertInstance: async () => undefined,
updateInstance: async () => undefined,
findInstanceByTask: async () => undefined,
},
activities: { faces: async () => new Map() },
org: {
positions: async () => new Set<string>(['PM']),
candidates: async () => [],
workGroupBinding: async () => undefined,
isActive: async () => true,
},
tasks: { start: async () => ({ id: 't' }) },
approvals: { raise: async () => ({ id: 'a' }) },
sfeel: { condition: () => true, expression: () => undefined },
schemas: { validate: () => ({ ok: true, errors: [] }) },
events: { publish: async () => undefined },
receipts: { notify: async () => undefined },
clock: { now: () => new Date('2026-07-28T00:00:00.000Z') },
ids: { next: () => 'x' },
};
return { service: createProcessService(deps), shelf, updated, inserted };
}
describe('savePackage —— 编辑即其身', () => {
it('已存在的版本就地写回:号不变、状态不变、不长新版', async () => {
const h = harness([basePkg(4, 'retired')]);
const edited = { ...basePkg(4, 'retired'), name: '改名了' };
const saved = await h.service.savePackage(edited);
expect(saved.version).toBe(4);
expect(saved.state).toBe('retired');
expect(h.updated).toEqual([{ id: 'p1', version: 4 }]);
expect(h.inserted).toHaveLength(0);
expect(h.shelf).toHaveLength(1);
});
it('在途钉版不可变 —— 有未完结工作流即拒,一字不落库', async () => {
const h = harness([basePkg(4, 'retired')], 2);
await expect(h.service.savePackage(basePkg(4, 'retired'))).rejects.toBeInstanceOf(PinnedVersionError);
expect(h.updated).toHaveLength(0);
expect(h.inserted).toHaveLength(0);
});
it('写回生效版须闭合 —— 生效的必须始终可运行', async () => {
const h = harness([basePkg(4, 'active')]);
// 把唯一一条边砍掉:起点无出边、终点无入边 —— 不闭合
const broken = { ...basePkg(4, 'active'), edges: [] };
await expect(h.service.savePackage(broken)).rejects.toBeInstanceOf(NotClosedError);
expect(h.updated).toHaveLength(0);
// 闭合的改动照常写回,生效状态保持
const ok = { ...basePkg(4, 'active'), name: '热改' };
const saved = await h.service.savePackage(ok);
expect(saved.state).toBe('active');
expect(h.updated).toEqual([{ id: 'p1', version: 4 }]);
});
it('asNew 显式新建:铸下一个版本号,不生效 —— 新版本只出自这条路', async () => {
const h = harness([basePkg(4, 'active')]);
const saved = await h.service.savePackage(basePkg(4, 'active'), { asNew: true });
expect(saved.version).toBe(5);
expect(saved.state).toBe('retired');
expect(h.inserted).toHaveLength(1);
expect(h.updated).toHaveLength(0);
});
it('陈旧编辑拒之门外:流程存在而版本号已不存在 —— 不静默复活旧内容', async () => {
const h = harness([basePkg(4, 'active')]);
// 停在已清理旧版(v1)上的标签页发来保存
await expect(h.service.savePackage(basePkg(1, 'retired'))).rejects.toBeInstanceOf(StaleVersionError);
expect(h.inserted).toHaveLength(0);
expect(h.updated).toHaveLength(0);
});
it('新流程首版:版本不存在即插入 v1,不生效', async () => {
const h = harness([]);
const saved = await h.service.savePackage(basePkg(1, 'active'));
expect(saved.version).toBe(1);
expect(saved.state).toBe('retired');
expect(h.inserted).toHaveLength(1);
});
});
@@ -45,6 +45,8 @@ function harness(validationErrors: readonly string[]): Harness {
const deps: ProcessServiceDeps = {
store: {
savePackage: async () => undefined,
updatePackageDefinition: async () => undefined,
countPinnedInFlight: async () => 0,
setPackageState: async () => undefined,
getPackage: async () => PKG,
listPackages: async () => [PKG],
+28 -4
View File
@@ -53,10 +53,11 @@ export type StartWorkflowInput = {
export type ProcessService = {
/**
* 保存 —— **不检查、不生效**:恒存为新版本号(服务端算,不采信入参的 version),
* 状态为未生效。画图的中间态天然不完整,保存不设闸;完整性闸在 `setPackageState` 激活时。
* 保存 —— **编辑即其身**:pkg.version 已存在则就地写回该版(号不变)。
* 两道闸:在途钉版抛 PinnedVersionError;写回生效版须闭合,否则 NotClosedError。
* `asNew` 或版本不存在(新流程首版)才产生新版本号(服务端算,不生效)。
*/
savePackage(pkg: ProcessPackage): Promise<ProcessPackage>;
savePackage(pkg: ProcessPackage, opts?: { asNew?: boolean }): Promise<ProcessPackage>;
listPackages(): Promise<ProcessPackage[]>;
getPackage(id: string, version?: number): Promise<ProcessPackage | undefined>;
/**
@@ -109,7 +110,7 @@ export class CallInterfaceViolationError extends Error {
}
}
/** 不闭合不得**激活** —— 画图与保存不拦(中间态天然不完整),上线这一步拦 */
/** 不闭合不得**激活/热改生效版** —— 画图与写回未生效版不拦(中间态天然不完整) */
export class NotClosedError extends Error {
constructor(readonly report: ClosureReport) {
super(`流程包不闭合,不得激活:${report.violations.map((v) => v.message).join('; ')}`);
@@ -117,6 +118,29 @@ export class NotClosedError extends Error {
}
}
/** 陈旧编辑:流程存在但该版本号已不存在(被清理)—— 不静默复活旧内容,刷新后再改 */
export class StaleVersionError extends Error {
constructor(
readonly packageId: string,
readonly version: number,
) {
super(`流程包版本不存在:${packageId}@v${version} —— 编辑器里的可能是已清理的旧版,刷新后在现存版本上改`);
this.name = 'StaleVersionError';
}
}
/** 在途钉版:该版本有未完结的工作流按 (包, 版本) 钉着定义 —— 定义不可变,改动请新建一版 */
export class PinnedVersionError extends Error {
constructor(
readonly packageId: string,
readonly version: number,
readonly inFlight: number,
) {
super(`流程包 ${packageId}@v${version} 被 ${inFlight} 条在途工作流钉住 —— 定义不可变,新建一版再改`);
this.name = 'PinnedVersionError';
}
}
export class PositionVacantError extends Error {
constructor(readonly position: string) {
super(`岗位空缺,无法解析负责人:${position} —— 按节点失败走向处置,或转治理台待指派`);
+2 -1
View File
@@ -18,7 +18,8 @@ export type {
WorkflowRecord,
WorkflowState,
} from './contract.ts';
export { CallInterfaceViolationError, NotClosedError, PositionVacantError } from './contract.ts';
export { CallInterfaceViolationError, NotClosedError,
PinnedVersionError, PositionVacantError, StaleVersionError } from './contract.ts';
export type {
ActivityDirectory,
@@ -1,7 +1,7 @@
/**
* process 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。
*/
import { and, asc, desc, eq } from 'drizzle-orm';
import { and, asc, desc, eq, isNull } from 'drizzle-orm';
import type { ProcessPackage, TerminalState, WorkflowState } from '@contracts';
import { processPackageSchema } from '@contracts';
import type { Db } from '@shared/db.ts';
@@ -74,6 +74,29 @@ export function createProcessStore(db: Db): ProcessStore {
return rows.map((r) => processPackageSchema.parse(r.definition));
},
async updatePackageDefinition(id, version, pkg) {
const hit = await db
.update(processPackageTable)
.set({ definition: pkg })
.where(and(eq(processPackageTable.id, id), eq(processPackageTable.version, version)))
.returning({ version: processPackageTable.version });
if (hit.length === 0) throw new Error(`就地写回失败:流程包版本不存在 ${id}@${version}`);
},
async countPinnedInFlight(id, version) {
const rows = await db
.select({ id: workflowTable.id })
.from(workflowTable)
.where(
and(
eq(workflowTable.packageId, id),
eq(workflowTable.packageVersion, version),
isNull(workflowTable.finishedAt),
),
);
return rows.length;
},
async insertWorkflow(record) {
await db.insert(workflowTable).values({
id: record.id,
@@ -5,7 +5,7 @@
*/
import type { ProcessNode, ProcessPackage, StaffId, TerminalState, WorkflowState } from '@contracts';
import type { ProcessService, StartWorkflowInput } from '../../contract.ts';
import { CallInterfaceViolationError, NotClosedError, PositionVacantError } from '../../contract.ts';
import { CallInterfaceViolationError, NotClosedError, PinnedVersionError, PositionVacantError, StaleVersionError } from '../../contract.ts';
import type {
ActivityDirectory,
ApprovalGateway,
@@ -61,7 +61,7 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
return hit;
}
/** 下一个版本号 —— 保存恒新增,不覆盖任何在途任务正在读的定义 */
/** 下一个版本号 —— 只在显式新建 / 新流程首版时用(编辑即其身,写回不长号) */
async function nextVersion(id: string): Promise<number> {
const all = await deps.store.listPackages();
return all.filter((pkg) => pkg.id === id).reduce((max, pkg) => Math.max(max, pkg.version), 0) + 1;
@@ -298,9 +298,40 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
* 拿闭合校验拦保存,画到一半就存不了(实测 412 卡死编辑)。
* 存下来的是**未生效**的新版本 —— 完整性闸在激活那一步。
*/
async savePackage(pkg) {
const saved: ProcessPackage = { ...pkg, version: await nextVersion(pkg.id), state: 'retired' };
await deps.store.savePackage(saved);
async savePackage(pkg, opts) {
const existing = opts?.asNew === true ? undefined : await deps.store.getPackage(pkg.id, pkg.version);
if (existing === undefined) {
/*
* 流程已存在而这个版本号查无此版 = 陈旧编辑器(实测:停在已清理旧版上的
* 标签页发来保存,把老内容静默复活成了新版本)—— 拒掉,让人刷新后再改。
* 只有整条流都不存在(新流程首版)或显式 asNew 才走插入。
*/
if (opts?.asNew !== true) {
const newest = await deps.store.getPackage(pkg.id);
if (newest !== undefined) throw new StaleVersionError(pkg.id, pkg.version);
}
// 显式新建 / 新流程首版:插入下一个版本号,不生效 —— 新版本只出自这条路
const saved: ProcessPackage = { ...pkg, version: await nextVersion(pkg.id), state: 'retired' };
await deps.store.savePackage(saved);
return saved;
}
/*
* **编辑即其身**(产品决策 2026-07-28 第四轮):保存写回当前版本,号不变,
* 不再每存一次长一版。两道底线:
* ① 在途钉版不可变 —— 工作流按 (包, 版本) 钉定义,有未完结的就不许改字
* ② 生效版必须始终可运行 —— 写回生效版要过闭合闸(未生效版画图不检查)
*/
const pinned = await deps.store.countPinnedInFlight(pkg.id, pkg.version);
if (pinned > 0) throw new PinnedVersionError(pkg.id, pkg.version, pinned);
const saved: ProcessPackage = { ...pkg, version: existing.version, state: existing.state };
if (existing.state === 'active') {
const report = await this.validate(saved);
if (!report.closed) throw new NotClosedError(report);
}
await deps.store.updatePackageDefinition(pkg.id, pkg.version, saved);
return saved;
},
+4
View File
@@ -32,6 +32,10 @@ export type WorkflowRecord = {
export type ProcessStore = {
savePackage(pkg: ProcessPackage): Promise<void>;
/** 就地写回一版的定义(编辑即其身)—— 撞不存在的版本报错,不静默插入 */
updatePackageDefinition(id: string, version: number, pkg: ProcessPackage): Promise<void>;
/** 钉在该版上的未完结工作流数 —— 在途钉版不可变的判据 */
countPinnedInFlight(id: string, version: number): Promise<number>;
/** 只改状态,不动定义 —— state 在列上与 definition 里各存一份,两处要一起改 */
setPackageState(id: string, version: number, state: 'active' | 'retired'): Promise<void>;
getPackage(id: string, version?: number): Promise<ProcessPackage | undefined>;
@@ -6,7 +6,8 @@
* 图下是节点属性,再往下才是声明式的契约细节(调用接口 · 边映射 · 触发器)。
*
* 版本是**运行时开关**,不是发布流程:
* · 画图不检查:保存 = 存为新版,**不生效**;激活才跑闭合校验(异常清零方可)
* · 编辑即其身:保存**写回当前版本**(号不变);新版本只出自「新建」
* · 画图不检查(未生效版);写回生效版须闭合;在途钉版不可变(409)
* · 一条流恰一个生效版本;可把生效切回旧版,也可全部停用让整条流下线
* · 在途工作流按启动时钉住的版本跑完 —— 启停对它们零影响
*/
@@ -218,7 +219,7 @@ function OverviewHome() {
<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="history" label="版本" value={packages.length} sub="新建才长版本 · 在途按启动版本跑完" />
<Kpi
ico="git-merge"
label="边闭合率"
@@ -539,7 +540,7 @@ function FlowLab({
setSaveErr(undefined);
void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] });
void queryClient.invalidateQueries({ queryKey: ['gov', 'process-reports'] });
// 存下来的是未生效的新版 —— 钉过去接着画;异常看板会列它还缺什么
// 编辑即其身:写回的还是这一版,号不变;异常看板会列它还缺什么
onSaved(out.pkg.version);
},
onError: (e: unknown) => setSaveErr((e as { message?: string }).message ?? '保存失败'),
@@ -564,7 +565,7 @@ function FlowLab({
* 新建版本才长一版 —— 把旧定义复活成最新版、或在现状上另起一版接着画,都走这儿。
*/
const cloneVersion = useMutation({
mutationFn: () => call<{ pkg: ProcessPackage; report: ClosureReport }>(saveProcessRoute, { body: { pkg } }),
mutationFn: () => call<{ pkg: ProcessPackage; report: ClosureReport }>(saveProcessRoute, { body: { pkg, asNew: true } }),
onSuccess: (out) => {
setReport(out.report);
void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] });
@@ -581,7 +582,7 @@ function FlowLab({
note: dirty ? (
<Note tone="warn">
画布上有<b>未保存的改动</b>,新建版本不会带上它们(克隆的是已存的 v{pkg.version})。
想连改动一起走,直接点「保存为 v{nextVersion}」。
想连改动一起走,先点「保存 v{pkg.version}」再来新建。
</Note>
) : (
<Note>
@@ -906,7 +907,7 @@ function FlowLab({
if (graph !== null) save.mutate({ graph, idMap: {} });
}}
>
<Ico name="send" s={11} /> {save.isPending ? '保存中…' : `保存为 v${nextVersion}`}
<Ico name="send" s={11} /> {save.isPending ? '保存中…' : `保存 v${pkg.version}`}
</button>
</div>
<div className="g-nodecard-b">