Archived
版本号自己定:点编辑,整列版本进编辑态,取消/保存收尾
用户的目的是**自己改版本号**,不是让系统替他递增;顺带把上一轮那些
指示性交互全撤了 —— 这软件给专业业务设计人员用,不需要「点我改」的提示条。
界面:
· 顶栏 编辑 → 取消 / 保存(与流程图卡「编辑 → 保存」同一套手势);
· 行内两个字段:号(自己编)与名(各版不同),行尾 删除;
· 改动攒在草稿里,按保存一趟落库;取消原样退出;
· **删掉**:改名指示条、「点我改」虚下划线、`删掉 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
+15
-2
@@ -56,8 +56,15 @@ export type ProcessService = {
|
|||||||
instancesOf(workflowId: string): Promise<NodeInstance[]>;
|
instancesOf(workflowId: string): Promise<NodeInstance[]>;
|
||||||
/** 删一版:生效版与在途钉版都不许删(见 `ActiveVersionError` / `PinnedVersionError`) */
|
/** 删一版:生效版与在途钉版都不许删(见 `ActiveVersionError` / `PinnedVersionError`) */
|
||||||
deleteVersion(id: string, version: number): Promise<ProcessPackage[]>;
|
deleteVersion(id: string, version: number): Promise<ProcessPackage[]>;
|
||||||
/** 改名:名字是这条流的身份,一次改及于所有版本 */
|
/**
|
||||||
rename(id: string, name: string): Promise<ProcessPackage[]>;
|
* 改版本记录:**号与名**,只动这一版 —— 号自己定。
|
||||||
|
* 名不设闸(标签不参与流转);号设两道:不许撞同流已有号(`VersionTakenError`)、
|
||||||
|
* 在途钉版不许改号(`PinnedVersionError`);已完结的单跟着改指,留痕不失真。
|
||||||
|
*/
|
||||||
|
editVersion(id: string, version: number, patch: {
|
||||||
|
version?: number;
|
||||||
|
name?: string;
|
||||||
|
}): Promise<ProcessPackage[]>;
|
||||||
nodeOwners(packageId: string): Promise<Record<string, string>>;
|
nodeOwners(packageId: string): Promise<Record<string, string>>;
|
||||||
setNodeOwner(packageId: string, nodeId: string, staffId: string | undefined): Promise<void>;
|
setNodeOwner(packageId: string, nodeId: string, staffId: string | undefined): Promise<void>;
|
||||||
/** 任务终态到达:完成即继续流转;非完成即走声明的失败走向 */
|
/** 任务终态到达:完成即继续流转;非完成即走声明的失败走向 */
|
||||||
@@ -101,6 +108,12 @@ export declare class PinnedVersionError extends Error {
|
|||||||
readonly inFlight: number;
|
readonly inFlight: number;
|
||||||
constructor(packageId: string, version: number, 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 {
|
export declare class PositionVacantError extends Error {
|
||||||
readonly position: string;
|
readonly position: string;
|
||||||
constructor(position: string);
|
constructor(position: string);
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
* module: process —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
* 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 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 { FlowRuntime, ActivityDirectory, ApprovalGateway, Clock, FlowEventPublisher, IdGenerator, OrgDirectory, ProcessStore, ReceiptNotifier, Sfeel, TaskGateway, } from './port.ts';
|
||||||
export type { ProcessServiceDeps } from './internal/usecase/process-service.ts';
|
export type { ProcessServiceDeps } from './internal/usecase/process-service.ts';
|
||||||
export { createProcessService } from './internal/usecase/process-service.ts';
|
export { createProcessService } from './internal/usecase/process-service.ts';
|
||||||
|
|||||||
+8
-2
@@ -68,8 +68,14 @@ export type ProcessStore = {
|
|||||||
/** 只改状态,不动定义 —— state 在列上与 definition 里各存一份,两处要一起改 */
|
/** 只改状态,不动定义 —— state 在列上与 definition 里各存一份,两处要一起改 */
|
||||||
setPackageState(id: string, version: number, state: 'active' | 'retired'): Promise<void>;
|
setPackageState(id: string, version: number, state: 'active' | 'retired'): Promise<void>;
|
||||||
deletePackage(id: string, version: number): Promise<void>;
|
deletePackage(id: string, version: number): Promise<void>;
|
||||||
/** 改名及于这条流的所有版本 —— 名字是流的身份,不是版本的属性 */
|
/** 只改一版的名 —— 名字是版本自己的属性(列上与 definition 里各一份,两处一起改) */
|
||||||
renameFlow(id: string, name: string): Promise<void>;
|
renameVersion(id: string, version: number, name: string): Promise<void>;
|
||||||
|
/**
|
||||||
|
* 改版本号 —— 号在三处:包的列上、definition 里、**跑过的单的 `packageVersion` 上**。
|
||||||
|
* 三处一起改:号只是这一版的名牌,搬号连引用一起搬,谁也不会指空或指错。
|
||||||
|
* 撞号与在途钉版由用例层先挡。
|
||||||
|
*/
|
||||||
|
renumberVersion(id: string, from: number, to: number): 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>;
|
||||||
|
|||||||
+11
-5
@@ -1053,17 +1053,23 @@ export declare const deleteProcessVersionRoute: {
|
|||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* 改名 —— 名字是**这条流的身份**,不是某一版的属性:一次改及于它的所有版本。
|
* 改版本记录 —— **版本号自己定**(2026-07-29 用户定)、名字随之:改哪版只动哪版。
|
||||||
* (版本列表上只显示 v1/v2…,若只改一版的名,列表上什么也看不见,人会以为没生效。)
|
*
|
||||||
|
* 号从前只能由系统递增。设计人员要能自己编号(1 / 2 / 10 都行),所以放开;
|
||||||
|
* 两条约束是数据本身要求的:号不许撞同流已有版本;**在途钉版不许改号**
|
||||||
|
* (那些单正按这个号找定义)。已完结的单跟着改指 —— 号只是这一版的名牌,
|
||||||
|
* 搬号连引用一起搬,留痕仍指同一份定义。
|
||||||
*/
|
*/
|
||||||
export declare const renameProcessRoute: {
|
export declare const editProcessVersionRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/gov/process/:id/rename";
|
readonly path: "/api/gov/process/:id/:version/edit";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
|
version: z.ZodString;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
readonly body: z.ZodObject<{
|
readonly body: z.ZodObject<{
|
||||||
name: z.ZodString;
|
version: z.ZodOptional<z.ZodNumber>;
|
||||||
|
name: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
packages: z.ZodArray<z.ZodObject<{
|
packages: z.ZodArray<z.ZodObject<{
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import {
|
|||||||
staffIdSchema,
|
staffIdSchema,
|
||||||
taskIdSchema,
|
taskIdSchema,
|
||||||
deleteProcessVersionRoute,
|
deleteProcessVersionRoute,
|
||||||
renameProcessRoute,
|
editProcessVersionRoute,
|
||||||
setNodeOwnerRoute,
|
setNodeOwnerRoute,
|
||||||
setTriggerConfirmRoute,
|
setTriggerConfirmRoute,
|
||||||
listNodeOwnersRoute,
|
listNodeOwnersRoute,
|
||||||
@@ -49,7 +49,13 @@ import { extractFields } from '@modules/channel';
|
|||||||
import { assembleInput, scopeOf } from '@modules/trigger';
|
import { assembleInput, scopeOf } from '@modules/trigger';
|
||||||
import { InstallRejectedError } from '@modules/activity-registry';
|
import { InstallRejectedError } from '@modules/activity-registry';
|
||||||
import type { InstallVerdict } from '@modules/activity-registry';
|
import type { InstallVerdict } from '@modules/activity-registry';
|
||||||
import { ActiveVersionError, NotClosedError, PinnedVersionError, StaleVersionError } from '@modules/process';
|
import {
|
||||||
|
ActiveVersionError,
|
||||||
|
NotClosedError,
|
||||||
|
PinnedVersionError,
|
||||||
|
StaleVersionError,
|
||||||
|
VersionTakenError,
|
||||||
|
} from '@modules/process';
|
||||||
import type { Container } from '../container.ts';
|
import type { Container } from '../container.ts';
|
||||||
import type { AppEnv } from '../http-env.ts';
|
import type { AppEnv } from '../http-env.ts';
|
||||||
|
|
||||||
@@ -505,18 +511,31 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 改名:名字是这条流的身份 —— 一次改及于所有版本;在途单不受影响(名字不参与流转) */
|
/** 改版本记录:号自己定、名随之 —— 撞号与「跑过单改号」409,别的版一字不动 */
|
||||||
app.post(renameProcessRoute.path, async (c) => {
|
app.post(editProcessVersionRoute.path, async (c) => {
|
||||||
const denied = await governProcess(c.get('account'));
|
const denied = await governProcess(c.get('account'));
|
||||||
if (denied) return c.json(denied, denyStatus(denied));
|
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) {
|
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);
|
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 {
|
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) {
|
} 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) };
|
const body: ApiError = { code: 'not-found', message: error instanceof Error ? error.message : String(error) };
|
||||||
return c.json(body, 404);
|
return c.json(body, 404);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# module: process
|
# module: process
|
||||||
|
|
||||||
职责: 流程层:流程包定义与版本(建/克隆/启停/**删/改名**)、**闭合校验**(边只表达顺序与条件 · 画图不检查 · 不闭合不得激活)、
|
职责: 流程层:流程包定义与版本(建/克隆/启停/**删/改号改名**)、**闭合校验**(边只表达顺序与条件 · 画图不检查 · 不闭合不得激活)、
|
||||||
负责人四策略的确定性解析、工作流状态的聚合投影、失败走向的消化判定、节点实例重开
|
负责人四策略的确定性解析、工作流状态的聚合投影、失败走向的消化判定、节点实例重开
|
||||||
不负责: 活动的执行(见 task 与范式引擎)、触发器(与流程包**平级**,见 trigger)、
|
不负责: 活动的执行(见 task 与范式引擎)、触发器(与流程包**平级**,见 trigger)、
|
||||||
画布的绘制(见 apps/web 的 gov-process)
|
画布的绘制(见 apps/web 的 gov-process)
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
公开出口: index.ts
|
公开出口: index.ts
|
||||||
- 类型:`ProcessService` `StartWorkflowInput` `WorkflowRecord` `ClosureReport` `ClosureViolation`
|
- 类型:`ProcessService` `StartWorkflowInput` `WorkflowRecord` `ClosureReport` `ClosureViolation`
|
||||||
`ActivityContractFace` `Candidate` `Resolution` `NodeInstance` `WorkflowState` `Dispatch`
|
`ActivityContractFace` `Candidate` `Resolution` `NodeInstance` `WorkflowState` `Dispatch`
|
||||||
- 错误:`NotClosedError` `PinnedVersionError` `PositionVacantError`
|
- 错误:`NotClosedError` `PinnedVersionError` `PositionVacantError` `ActiveVersionError` `VersionTakenError`
|
||||||
- 端口类型:`ProcessStore` `ActivityDirectory` `OrgDirectory` `TaskGateway` `ApprovalGateway`
|
- 端口类型:`ProcessStore` `ActivityDirectory` `OrgDirectory` `TaskGateway` `ApprovalGateway`
|
||||||
`Sfeel` `FlowEventPublisher` `ReceiptNotifier`
|
`Sfeel` `FlowEventPublisher` `ReceiptNotifier`
|
||||||
- 用例工厂:`createProcessService(deps): ProcessService`(含 `setPackageState` 版本启停)
|
- 用例工厂:`createProcessService(deps): ProcessService`(含 `setPackageState` 版本启停)
|
||||||
@@ -29,8 +29,15 @@
|
|||||||
类型对不对全归活动自己。激活闸只剩两面:**结构闭合**(dangling-node / multi-default-edge)
|
类型对不对全归活动自己。激活闸只剩两面:**结构闭合**(dangling-node / multi-default-edge)
|
||||||
与**活动装载**(unknown-activity);字段类的三条检查(边未映射 / 必填未映射 / 类型相抵)全退役。
|
与**活动装载**(unknown-activity);字段类的三条检查(边未映射 / 必填未映射 / 类型相抵)全退役。
|
||||||
- **版本可删,两道闸**:生效版不可删(先停用,`ActiveVersionError`)、在途钉版不可删
|
- **版本可删,两道闸**:生效版不可删(先停用,`ActiveVersionError`)、在途钉版不可删
|
||||||
(那些单要按它跑完,`PinnedVersionError`)。**改名及于整条流的所有版本** ——
|
(那些单要按它跑完,`PinnedVersionError`)。
|
||||||
名字是流的身份,不是某一版的属性。
|
- **版本号与名归设计人员自己定**(2026-07-29 用户定):
|
||||||
|
· **号自己编**,不再只能系统递增 —— 两道闸:不许撞同流已有号(`VersionTakenError`)、
|
||||||
|
在途钉版不许改号(`PinnedVersionError`,那些单正按这个号找定义)。
|
||||||
|
号在三处(包的列、definition、跑过的单的 `packageVersion`)**一起改** ——
|
||||||
|
号只是这一版的名牌,搬号连引用一起搬,已完结的单仍指同一份定义。
|
||||||
|
· **名只动这一版**(推翻前一日的「及于整条流」)—— 各版本正是靠名字互相区分
|
||||||
|
(「v3 手工议价」/「v4 含寻源」),全同名等于没名字;流的身份是 `id`,
|
||||||
|
货架上以生效版的名字示人。名不设闸:标签不参与流转,在途钉版也能改。
|
||||||
- **数据按字段名流动**(2026-07-30 对齐 Mastra,替代「连线即映射」):边不带映射,
|
- **数据按字段名流动**(2026-07-30 对齐 Mastra,替代「连线即映射」):边不带映射,
|
||||||
只表达顺序与条件(条件读上游输出字段)。节点入参 = 铺平作用域
|
只表达顺序与条件(条件读上游输出字段)。节点入参 = 铺平作用域
|
||||||
(起单入参 ← 沿途各输出口字段 ← 刚发射的载荷,后者赢)按下游契约取;
|
(起单入参 ← 沿途各输出口字段 ← 刚发射的载荷,后者赢)按下游契约取;
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ function harness(seed: ProcessPackage[], pinned = 0) {
|
|||||||
updateInstance: async () => undefined,
|
updateInstance: async () => undefined,
|
||||||
findInstanceByTask: async () => undefined,
|
findInstanceByTask: async () => undefined,
|
||||||
deletePackage: async () => undefined,
|
deletePackage: async () => undefined,
|
||||||
renameFlow: async () => undefined,
|
renameVersion: async () => undefined,
|
||||||
|
renumberVersion: async () => undefined,
|
||||||
nodeOwners: async () => ({}),
|
nodeOwners: async () => ({}),
|
||||||
setNodeOwner: async () => undefined,
|
setNodeOwner: async () => undefined,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ function harness(validationErrors: readonly string[]): Harness {
|
|||||||
updateInstance: async () => undefined,
|
updateInstance: async () => undefined,
|
||||||
findInstanceByTask: async () => undefined,
|
findInstanceByTask: async () => undefined,
|
||||||
deletePackage: async () => undefined,
|
deletePackage: async () => undefined,
|
||||||
renameFlow: async () => undefined,
|
renameVersion: async () => undefined,
|
||||||
|
renumberVersion: async () => undefined,
|
||||||
nodeOwners: async () => ({}),
|
nodeOwners: async () => ({}),
|
||||||
setNodeOwner: async () => undefined,
|
setNodeOwner: async () => undefined,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* 版本的删与改名(2026-07-30 用户要的两件)—— 守两道闸、一条口径。
|
* 版本记录的删与改 —— 号与名归设计人员自己定,三道闸守住数据说得通。
|
||||||
*
|
*
|
||||||
* · **生效版不可删**:先停用。挡的是「手滑把正在起单的定义删了」;
|
* · **生效版不可删**:先停用。挡的是「手滑把正在起单的定义删了」;
|
||||||
* · **在途钉版不可删**:那些工作流按启动时钉住的这一版跑完,定义没了就再也推不动
|
* · **在途钉版不可删**:那些工作流按启动时钉住的这一版跑完,定义没了就再也推不动
|
||||||
* —— 与「在途钉版不可改」同一条理由、同一个错(PinnedVersionError);
|
* —— 与「在途钉版不可改」同一条理由、同一个错(PinnedVersionError);
|
||||||
* · **改名及于整条流的所有版本**:名字是流的身份,不是某一版的属性。
|
* · **号自己定**(2026-07-29 用户定):不再只能系统递增。两条约束是数据本身要的 ——
|
||||||
* (版本列表上只显示 v1/v2…,若只改一版的名,列表上什么也看不见,人会以为没生效。)
|
* 号不许撞同流已有版本(VersionTakenError);**在途钉版不许改号**
|
||||||
|
* (PinnedVersionError:那些单正按这个号找定义)。已完结的单由 store 跟着改指,
|
||||||
|
* 号只是这一版的名牌,搬号连引用一起搬,留痕仍指同一份定义。
|
||||||
|
* · **名只动这一版**、不设闸:各版本正是靠名字互相区分,标签不参与流转。
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { ProcessPackage } from '@contracts';
|
import type { ProcessPackage } from '@contracts';
|
||||||
import { processPackageSchema } 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 { createProcessService } from '../internal/usecase/process-service.ts';
|
||||||
import type { ProcessServiceDeps } 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);
|
const i = live.findIndex((p) => p.id === id && p.version === version);
|
||||||
if (i >= 0) live.splice(i, 1);
|
if (i >= 0) live.splice(i, 1);
|
||||||
},
|
},
|
||||||
renameFlow: async (id: string, name: string) => {
|
renameVersion: async (id: string, version: number, name: string) => {
|
||||||
for (let i = 0; i < live.length; i += 1) {
|
const i = live.findIndex((p) => p.id === id && p.version === version);
|
||||||
if (live[i]!.id === id) live[i] = { ...live[i]!, name };
|
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) =>
|
getPackage: async (id: string, version?: number) =>
|
||||||
live.find((p) => p.id === id && (version === undefined || p.version === version)),
|
live.find((p) => p.id === id && (version === undefined || p.version === version)),
|
||||||
@@ -104,14 +110,35 @@ describe('版本的删与改名', () => {
|
|||||||
expect(h.rows).toHaveLength(2);
|
expect(h.rows).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('改名及于所有版本 —— 名字是这条流的身份', async () => {
|
it('改名只动这一版 —— 各版本正是靠名字互相区分', async () => {
|
||||||
const h = harness([pkg(2, 'active'), pkg(1, 'retired')]);
|
const h = harness([pkg(2, 'active'), pkg(1, 'retired')]);
|
||||||
const after = await h.service.rename('quote', '统一报价(新)');
|
const after = await h.service.editVersion('quote', 1, { name: '统一报价 · 手工议价' });
|
||||||
expect(after.map((p) => p.name)).toEqual(['统一报价(新)', '统一报价(新)']);
|
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')]);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -81,8 +81,12 @@ export type ProcessService = {
|
|||||||
|
|
||||||
/** 删一版:生效版与在途钉版都不许删(见 `ActiveVersionError` / `PinnedVersionError`) */
|
/** 删一版:生效版与在途钉版都不许删(见 `ActiveVersionError` / `PinnedVersionError`) */
|
||||||
deleteVersion(id: string, version: number): Promise<ProcessPackage[]>;
|
deleteVersion(id: string, version: number): Promise<ProcessPackage[]>;
|
||||||
/** 改名:名字是这条流的身份,一次改及于所有版本 */
|
/**
|
||||||
rename(id: string, name: string): Promise<ProcessPackage[]>;
|
* 改版本记录:**号与名**,只动这一版 —— 号自己定。
|
||||||
|
* 名不设闸(标签不参与流转);号设两道:不许撞同流已有号(`VersionTakenError`)、
|
||||||
|
* 在途钉版不许改号(`PinnedVersionError`);已完结的单跟着改指,留痕不失真。
|
||||||
|
*/
|
||||||
|
editVersion(id: string, version: number, patch: { version?: number; name?: string }): Promise<ProcessPackage[]>;
|
||||||
|
|
||||||
/* ── 节点负责人(运营配置):显式指派 > 这里 > 轮值 ── */
|
/* ── 节点负责人(运营配置):显式指派 > 这里 > 轮值 ── */
|
||||||
nodeOwners(packageId: string): Promise<Record<string, string>>;
|
nodeOwners(packageId: string): Promise<Record<string, string>>;
|
||||||
@@ -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 {
|
export class PositionVacantError extends Error {
|
||||||
constructor(readonly position: string) {
|
constructor(readonly position: string) {
|
||||||
super(`岗位空缺,无法解析负责人:${position} —— 按节点失败走向处置,或转治理台待指派`);
|
super(`岗位空缺,无法解析负责人:${position} —— 按节点失败走向处置,或转治理台待指派`);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export type {
|
|||||||
WorkflowRecord,
|
WorkflowRecord,
|
||||||
WorkflowState,
|
WorkflowState,
|
||||||
} from './contract.ts';
|
} from './contract.ts';
|
||||||
export { ActiveVersionError, CallInterfaceViolationError, NotClosedError,
|
export { ActiveVersionError, CallInterfaceViolationError, NotClosedError, VersionTakenError,
|
||||||
PinnedVersionError, PositionVacantError, StaleVersionError } from './contract.ts';
|
PinnedVersionError, PositionVacantError, StaleVersionError } from './contract.ts';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -64,16 +64,38 @@ export function createProcessStore(db: Db): ProcessStore {
|
|||||||
.where(and(eq(processPackageTable.id, id), eq(processPackageTable.version, version)));
|
.where(and(eq(processPackageTable.id, id), eq(processPackageTable.version, version)));
|
||||||
},
|
},
|
||||||
|
|
||||||
async renameFlow(id, name) {
|
async renameVersion(id, version, name) {
|
||||||
// 名字在列上与 definition(jsonb)里各有一份 —— 与 setPackageState 同一口径,两处一起改
|
// 名字在列上与 definition(jsonb)里各有一份 —— 与 setPackageState 同一口径,两处一起改
|
||||||
const rows = await db.select().from(processPackageTable).where(eq(processPackageTable.id, id));
|
const rows = await db
|
||||||
for (const row of rows) {
|
.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<string, unknown>), name };
|
const definition = { ...(row.definition as Record<string, unknown>), name };
|
||||||
await db
|
await db
|
||||||
.update(processPackageTable)
|
.update(processPackageTable)
|
||||||
.set({ name, definition })
|
.set({ name, definition })
|
||||||
.where(and(eq(processPackageTable.id, id), eq(processPackageTable.version, row.version)));
|
.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<string, unknown>), 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) {
|
async getPackage(id, version) {
|
||||||
|
|||||||
@@ -5,7 +5,13 @@
|
|||||||
*/
|
*/
|
||||||
import type { ProcessPackage } from '@contracts';
|
import type { ProcessPackage } from '@contracts';
|
||||||
import type { ProcessService, StartWorkflowInput } from '../../contract.ts';
|
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 {
|
import type {
|
||||||
ActivityDirectory,
|
ActivityDirectory,
|
||||||
ApprovalGateway,
|
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);
|
return after.filter((p) => p.id === id).sort((a, b) => b.version - a.version);
|
||||||
},
|
},
|
||||||
|
|
||||||
async rename(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();
|
const all = await deps.store.listPackages();
|
||||||
if (!all.some((p) => p.id === id)) throw new Error(`流程包不存在:${id}`);
|
if (all.some((p) => p.id === id && p.version === to)) throw new VersionTakenError(id, to);
|
||||||
// 名字是这条流的身份 —— 一次改及于所有版本(在途单不受影响:名字不参与流转)
|
/*
|
||||||
await deps.store.renameFlow(id, name);
|
* 在途钉版不许改号:那些单正按这个号找定义,搬号等于在它们脚下抽地板。
|
||||||
|
* 已完结的单不拦 —— 它们的 `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();
|
const after = await deps.store.listPackages();
|
||||||
return after.filter((p) => p.id === id).sort((a, b) => b.version - a.version);
|
return after.filter((p) => p.id === id).sort((a, b) => b.version - a.version);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -77,8 +77,14 @@ export type ProcessStore = {
|
|||||||
/** 只改状态,不动定义 —— state 在列上与 definition 里各存一份,两处要一起改 */
|
/** 只改状态,不动定义 —— state 在列上与 definition 里各存一份,两处要一起改 */
|
||||||
setPackageState(id: string, version: number, state: 'active' | 'retired'): Promise<void>;
|
setPackageState(id: string, version: number, state: 'active' | 'retired'): Promise<void>;
|
||||||
deletePackage(id: string, version: number): Promise<void>;
|
deletePackage(id: string, version: number): Promise<void>;
|
||||||
/** 改名及于这条流的所有版本 —— 名字是流的身份,不是版本的属性 */
|
/** 只改一版的名 —— 名字是版本自己的属性(列上与 definition 里各一份,两处一起改) */
|
||||||
renameFlow(id: string, name: string): Promise<void>;
|
renameVersion(id: string, version: number, name: string): Promise<void>;
|
||||||
|
/**
|
||||||
|
* 改版本号 —— 号在三处:包的列上、definition 里、**跑过的单的 `packageVersion` 上**。
|
||||||
|
* 三处一起改:号只是这一版的名牌,搬号连引用一起搬,谁也不会指空或指错。
|
||||||
|
* 撞号与在途钉版由用例层先挡。
|
||||||
|
*/
|
||||||
|
renumberVersion(id: string, from: number, to: number): Promise<void>;
|
||||||
getPackage(id: string, version?: number): Promise<ProcessPackage | undefined>;
|
getPackage(id: string, version?: number): Promise<ProcessPackage | undefined>;
|
||||||
listPackages(): Promise<ProcessPackage[]>;
|
listPackages(): Promise<ProcessPackage[]>;
|
||||||
|
|
||||||
|
|||||||
@@ -553,7 +553,10 @@
|
|||||||
/* 三卡同厚:头行内容一律 26 高(= 一枚方钮),没按钮的头也不塌 —— 三条底线同一水平 */
|
/* 三卡同厚:头行内容一律 26 高(= 一枚方钮),没按钮的头也不塌 —— 三条底线同一水平 */
|
||||||
.g-verslim .g-vercard-h { height: auto; min-height: 26px; padding: 14px 16px 11px; gap: 4px; }
|
.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-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 .g-ver-act { height: 22px; padding: 0 8px; font-size: 10.5px; }
|
||||||
.g-verrow:hover { border-color: #aeb9c6; }
|
.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 { border-left-color: var(--sh-brass); background: var(--sh-brass-wash); }
|
||||||
.g-verrow.live.on { border-color: #cbb98d; }
|
.g-verrow.live.on { border-color: #cbb98d; }
|
||||||
.g-verrow:focus-visible { outline: 2px solid var(--brand); outline-offset: 1px; }
|
.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 { font-size: 9px; letter-spacing: .12em; color: var(--sh-muted); }
|
||||||
.g-verrow .st.live { color: var(--sh-brass); font-weight: 700; }
|
.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 { 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 { 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:hover { border-color: #aeb9c6; }
|
||||||
.g-ver.on { background: #f2f5f8; border-color: #9fb0c0; }
|
.g-ver.on { background: #f2f5f8; border-color: #9fb0c0; }
|
||||||
|
|||||||
@@ -15,12 +15,13 @@
|
|||||||
* · 在途工作流按启动时钉住的版本跑完 —— 启停对它们零影响
|
* · 在途工作流按启动时钉住的版本跑完 —— 启停对它们零影响
|
||||||
*/
|
*/
|
||||||
import { useRef, useState, useEffect } from 'react';
|
import { useRef, useState, useEffect } from 'react';
|
||||||
|
import type { KeyboardEvent } 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 {
|
import {
|
||||||
deleteProcessVersionRoute,
|
deleteProcessVersionRoute,
|
||||||
processPackageSchema,
|
processPackageSchema,
|
||||||
renameProcessRoute,
|
editProcessVersionRoute,
|
||||||
saveProcessRoute,
|
saveProcessRoute,
|
||||||
setProcessStateRoute,
|
setProcessStateRoute,
|
||||||
validateProcessRoute,
|
validateProcessRoute,
|
||||||
@@ -359,13 +360,14 @@ function FlowLab({
|
|||||||
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 [ask, setAsk] = useState<{ spec: AskSpec; danger?: boolean } | undefined>(undefined);
|
||||||
/** 改名就地开一个输入框(名字改的是整条流,不是某一版) */
|
|
||||||
const [renaming, setRenaming] = useState(false);
|
|
||||||
/**
|
/**
|
||||||
* **版本记录**的编辑态(2026-07-30):开着才出改名条、行尾钮换成删除 ——
|
* **版本记录**的编辑态:开着,每行的号与名就是输入框、行尾钮换成删除;
|
||||||
|
* 顶栏两枚钮 取消 / 保存 —— 改动攒在草稿里,按保存才落库。
|
||||||
* 它只管版本记录这一件事,与画布的手绘编辑(那是流程图卡自己的)互不相干。
|
* 它只管版本记录这一件事,与画布的手绘编辑(那是流程图卡自己的)互不相干。
|
||||||
*/
|
*/
|
||||||
const [editingVersions, setEditingVersions] = useState(false);
|
const [editingVersions, setEditingVersions] = useState(false);
|
||||||
|
/** 版本记录草稿:原版本号 → 改后的号与名 */
|
||||||
|
const [edits, setEdits] = useState<Record<number, { version: string; name: string }>>({});
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const nodes = graph?.nodes ?? [];
|
const nodes = graph?.nodes ?? [];
|
||||||
@@ -463,15 +465,32 @@ function FlowLab({
|
|||||||
onError: (e: unknown) => setSaveErr((e as { message?: string }).message ?? '删不掉'),
|
onError: (e: unknown) => setSaveErr((e as { message?: string }).message ?? '删不掉'),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 改名:名字是这条流的身份 —— 一次改及于所有版本 */
|
/**
|
||||||
const rename = useMutation({
|
* 存版本记录:逐行把改过的号与名送上去 —— 撞号、跑过单改号,服务端 409,判词照抄。
|
||||||
mutationFn: (name: string) =>
|
* 一行不通,前面已落的不回滚:版本记录各自独立,没有「半条」可言。
|
||||||
call<{ packages: ProcessPackage[] }>(renameProcessRoute, { params: { id: pkg.id }, body: { name } }),
|
*/
|
||||||
onSuccess: () => {
|
const saveVersions = useMutation({
|
||||||
void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] });
|
mutationFn: async (drafts: readonly { from: number; version: number; name: string }[]) => {
|
||||||
setRenaming(false);
|
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,76 +692,123 @@ function FlowLab({
|
|||||||
<aside className="g-vercard g-verslim">
|
<aside className="g-vercard g-verslim">
|
||||||
<div className="g-vercard-h">
|
<div className="g-vercard-h">
|
||||||
版本
|
版本
|
||||||
<button className="g-ver-act" onClick={askClone} disabled={cloneVersion.isPending} title="以当前版为底克隆一版">
|
{/*
|
||||||
<Ico name="plus" s={11} /> 新建
|
* 编辑态两枚钮:取消 / 保存。改动攒在草稿里 —— 号与名一趟改完再落库,
|
||||||
</button>
|
* 与画布的「编辑 → 保存」同一套手势。
|
||||||
{/* 这枚「编辑」只作用于**版本记录**:改名与删除 —— 画布的编辑在流程图卡上 */}
|
*/}
|
||||||
|
{editingVersions ? (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
className={'g-ver-act' + (editingVersions ? ' t-go' : '')}
|
className="g-ver-act"
|
||||||
title={editingVersions ? '完成:行尾钮变回启停' : '编辑版本记录:改名 · 删除'}
|
disabled={saveVersions.isPending}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingVersions(!editingVersions);
|
setEditingVersions(false);
|
||||||
setRenaming(false);
|
setEdits({});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ico name={editingVersions ? 'check' : 'pen-line'} s={11} /> {editingVersions ? '完成' : '编辑'}
|
取消
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<button
|
||||||
{editingVersions && (
|
className="g-ver-act"
|
||||||
<div className="g-veredit">
|
disabled={saveVersions.isPending}
|
||||||
{renaming ? (
|
onClick={() => {
|
||||||
<div className="g-verrename">
|
const drafts = versions.flatMap((v) => {
|
||||||
<input
|
const d = edits[v.version];
|
||||||
className="g-input"
|
if (d === undefined) return [];
|
||||||
autoFocus
|
const num = Number(d.version);
|
||||||
defaultValue={pkg.name}
|
const name = d.name.trim();
|
||||||
placeholder="流程名"
|
if (!Number.isInteger(num) || num < 1 || name === '') return [];
|
||||||
onKeyDown={(e) => {
|
if (num === v.version && name === v.name) return [];
|
||||||
if (e.key === 'Enter') rename.mutate((e.target as HTMLInputElement).value.trim());
|
return [{ from: v.version, version: num, name }];
|
||||||
if (e.key === 'Escape') setRenaming(false);
|
});
|
||||||
|
if (drafts.length === 0) {
|
||||||
|
setEditingVersions(false);
|
||||||
|
setEdits({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveVersions.mutate(drafts);
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
<span className="t">回车改名 · Esc 取消 —— 名字是这条流的身份,各版本同名</span>
|
<Ico name="send" s={11} /> {saveVersions.isPending ? '保存中…' : '保存'}
|
||||||
</div>
|
</button>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button className="g-ver-act" onClick={() => setRenaming(true)}>
|
<button className="g-ver-act" onClick={askClone} disabled={cloneVersion.isPending}>
|
||||||
改名
|
<Ico name="plus" s={11} /> 新建
|
||||||
|
</button>
|
||||||
|
{/* 这枚「编辑」只作用于**版本记录**:号、名、删 —— 画布的编辑在流程图卡上 */}
|
||||||
|
<button
|
||||||
|
className="g-ver-act"
|
||||||
|
onClick={() => {
|
||||||
|
setEdits(Object.fromEntries(versions.map((v) => [v.version, { version: String(v.version), name: v.name }])));
|
||||||
|
setEditingVersions(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ico name="pen-line" s={11} /> 编辑
|
||||||
</button>
|
</button>
|
||||||
<span className="t">行尾可删版本 —— 生效版先停用</span>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<div className="g-vercard-list">
|
<div className="g-vercard-list">
|
||||||
{versions.map((v) => {
|
{versions.map((v) => {
|
||||||
const isActive = v.version === activeVersion;
|
const isActive = v.version === activeVersion;
|
||||||
const isShown = v.version === pkg.version;
|
const isShown = v.version === pkg.version;
|
||||||
return (
|
const draft = edits[v.version];
|
||||||
/* 行本身可点(切到这一版看);行尾一枚小钮 —— 平时是启停,编辑态是删除 */
|
/*
|
||||||
<div
|
* 编辑态的行不再是「可点切版」的按钮 —— 它是一排字段:号、名、删。
|
||||||
key={v.version}
|
* 展示态才是按钮(点行切到这一版看)。
|
||||||
className={'g-verrow' + (isShown ? ' on' : '') + (isActive ? ' live' : '')}
|
*/
|
||||||
role="button"
|
const pickable = editingVersions
|
||||||
tabIndex={0}
|
? {}
|
||||||
onClick={() => onPick(v.version)}
|
: {
|
||||||
onKeyDown={(e) => {
|
role: 'button',
|
||||||
|
tabIndex: 0,
|
||||||
|
onClick: () => onPick(v.version),
|
||||||
|
onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onPick(v.version);
|
onPick(v.version);
|
||||||
}
|
}
|
||||||
}}
|
},
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={v.version}
|
||||||
|
className={'g-verrow' + (isShown ? ' on' : '') + (isActive ? ' live' : '') + (editingVersions ? ' ed' : '')}
|
||||||
|
{...pickable}
|
||||||
>
|
>
|
||||||
|
<div className="ln">
|
||||||
|
{/* 号与名都归设计人员自己定:号自己编,名各版不同 —— 版本靠这两样互相区分 */}
|
||||||
|
{editingVersions && draft !== undefined ? (
|
||||||
|
<>
|
||||||
|
<b>v</b>
|
||||||
|
<input
|
||||||
|
className="g-input vnum"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={draft.version}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEdits({ ...edits, [v.version]: { ...draft, version: e.target.value.replace(/\D/g, '') } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="g-input nm"
|
||||||
|
maxLength={40}
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(e) => setEdits({ ...edits, [v.version]: { ...draft, name: e.target.value } })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<b>v{v.version}</b>
|
<b>v{v.version}</b>
|
||||||
<span className={'st' + (isActive ? ' live' : '')}>{isActive ? '生效' : '停用'}</span>
|
<span className="nm">{v.name}</span>
|
||||||
<span className="cnt">
|
</>
|
||||||
{v.nodes.length} 节点 · {v.edges.length} 边
|
)}
|
||||||
</span>
|
|
||||||
{editingVersions ? (
|
{editingVersions ? (
|
||||||
<button
|
<button
|
||||||
className="g-ver-act t-bad"
|
className="g-ver-act t-bad"
|
||||||
disabled={isActive || dropVersion.isPending}
|
disabled={isActive || dropVersion.isPending}
|
||||||
title={isActive ? '生效版删不掉 —— 先停用' : `删掉 v${v.version}`}
|
title={isActive ? '生效版先停用' : ''}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
askDrop(v);
|
askDrop(v);
|
||||||
@@ -765,7 +831,7 @@ function FlowLab({
|
|||||||
className="g-ver-act t-go"
|
className="g-ver-act t-go"
|
||||||
/* 不闭合点不了:结构闭合 + 活动装载两面清零才亮 */
|
/* 不闭合点不了:结构闭合 + 活动装载两面清零才亮 */
|
||||||
disabled={closedOf(v.version) !== true}
|
disabled={closedOf(v.version) !== true}
|
||||||
title={closedOf(v.version) === true ? `激活 v${v.version}` : '还不闭合 —— 属性面会列出差哪几条'}
|
title={closedOf(v.version) === true ? '' : '还不闭合'}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
askActivate(v);
|
askActivate(v);
|
||||||
@@ -775,6 +841,14 @@ function FlowLab({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* 第二行只放事实:状态 · 点边计数 */}
|
||||||
|
<div className="ln2">
|
||||||
|
<span className={'st' + (isActive ? ' live' : '')}>{isActive ? '生效' : '停用'}</span>
|
||||||
|
<span className="cnt">
|
||||||
|
{v.nodes.length} 节点 · {v.edges.length} 边
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -63,7 +63,7 @@
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **蓝图** | 全局 —— 定义完了没有 | 四库因果链 + 待补清单(见上) |
|
| **蓝图** | 全局 —— 定义完了没有 | 四库因果链 + 待补清单(见上) |
|
||||||
| **触发器** | 谁调起流程 | 事件 / 登记 / 周期三类调起规则的定义;定完就地投一封信演练,看哪条规则接住(归属解析 → 三级消费的真链路)。**启停开关在运营中心** |
|
| **触发器** | 谁调起流程 | 事件 / 登记 / 周期三类调起规则的定义;定完就地投一封信演练,看哪条规则接住(归属解析 → 三级消费的真链路)。**启停开关在运营中心** |
|
||||||
| **流程包** | 战略 —— 企业以哪些流转运转 | 编排工作区**三栏**:左窄轨版本(激活/停用 · 改名 · 删除)、中流程图、右节点属性。**边只表达顺序与条件**(对齐 Mastra),数据按字段名流动;**字段只声明,不做任何要求** —— 激活闸只剩结构闭合与活动装载两面,字段够不够是活动自己的事 |
|
| **流程包** | 战略 —— 企业以哪些流转运转 | 编排工作区**三栏**:左窄轨版本(激活/停用 · **号与名自己定** · 删除;点「编辑」整列进编辑态,取消/保存收尾)、中流程图、右节点属性。**边只表达顺序与条件**(对齐 Mastra),数据按字段名流动;**字段只声明,不做任何要求** —— 激活闸只剩结构闭合与活动装载两面,字段够不够是活动自己的事 |
|
||||||
| **活动库** | 方法 —— 每件事怎么干 | 货架、契约面、范式、资产版本谱系、安装两审 |
|
| **活动库** | 方法 —— 每件事怎么干 | 货架、契约面、范式、资产版本谱系、安装两审 |
|
||||||
| **工具箱** | 能力 —— 系统能对世界做什么 | 功能 / LLM / 人类三类工具目录、白名单授权、幂等口径、引用反查。**一律主显示中文名**,逻辑名退居副标 |
|
| **工具箱** | 能力 —— 系统能对世界做什么 | 功能 / LLM / 人类三类工具目录、白名单授权、幂等口径、引用反查。**一律主显示中文名**,逻辑名退居副标 |
|
||||||
| **数据域** | 对象 —— 企业运营什么 | 域与对象 schema、字段、放行规则、被谁引用。**行数据不在这里** —— 那是运营的事 |
|
| **数据域** | 对象 —— 企业运营什么 | 域与对象 schema、字段、放行规则、被谁引用。**行数据不在这里** —— 那是运营的事 |
|
||||||
|
|||||||
@@ -239,14 +239,21 @@ export const deleteProcessVersionRoute = {
|
|||||||
} as const satisfies RouteContract;
|
} as const satisfies RouteContract;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 改名 —— 名字是**这条流的身份**,不是某一版的属性:一次改及于它的所有版本。
|
* 改版本记录 —— **版本号自己定**(2026-07-29 用户定)、名字随之:改哪版只动哪版。
|
||||||
* (版本列表上只显示 v1/v2…,若只改一版的名,列表上什么也看不见,人会以为没生效。)
|
*
|
||||||
|
* 号从前只能由系统递增。设计人员要能自己编号(1 / 2 / 10 都行),所以放开;
|
||||||
|
* 两条约束是数据本身要求的:号不许撞同流已有版本;**在途钉版不许改号**
|
||||||
|
* (那些单正按这个号找定义)。已完结的单跟着改指 —— 号只是这一版的名牌,
|
||||||
|
* 搬号连引用一起搬,留痕仍指同一份定义。
|
||||||
*/
|
*/
|
||||||
export const renameProcessRoute = {
|
export const editProcessVersionRoute = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/api/gov/process/:id/rename',
|
path: '/api/gov/process/:id/:version/edit',
|
||||||
params: z.object({ id: z.string() }),
|
params: z.object({ id: z.string(), version: z.string() }),
|
||||||
body: z.object({ name: z.string().min(1).max(40) }),
|
body: z.object({
|
||||||
|
version: z.number().int().min(1).max(9999).optional(),
|
||||||
|
name: z.string().min(1).max(40).optional(),
|
||||||
|
}),
|
||||||
output: z.object({ packages: z.array(processPackageSchema) }),
|
output: z.object({ packages: z.array(processPackageSchema) }),
|
||||||
} as const satisfies RouteContract;
|
} as const satisfies RouteContract;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user