forked from ChaiHao/BPOS-MVP
画图不检查,激活才查完整 —— 保存 bug 的根治 + 异常看板
你存不了图的根因:保存跑闭合校验,而画图必然经过不完整的中间态
(节点没接完边、活动没安装)—— 画到一半就 412 卡死。实测同一份图:
改前 412 拒存;改后存为未生效的新版,报告随行。
## 模型改成三段
画图 = 只创建节点和边,图上真正生效可编辑的只有**名字**(双击不再出岗位一栏;
边上的字是辅助阅读的注记;岗位/判据/映射这些**闭环要素在节点属性里决定**;
分配到人是运营的事,设计侧不碰)
保存 = 不检查、不生效:恒存为新版本号,谁也伤不着 —— 因此不设确认卡,直接存;
任何版本都可编辑(在旧版上改 = 以旧版为底另起一版)
激活 = 完整性闸:不闭合 412 拦下并点名缺什么;过了即独占生效
## 异常看板(左,与版本卡同列同风)
画图不拦,看板替你盯着:逐条列这一版缺什么,**点一条定位到那个节点的属性**;
零异常时给空占位说「可以激活」。版本行的「激活」按钮按闭合灰显 ——
不闭合点不了,亮了才能上线。实测:v4 两条异常全列出、激活灰;闭合的 v2/v1 亮。
## 顺手修一个画布盲区
packageToGraph 以手绘存证为呈现真相,**语义里有、存证里没有的节点画布看不见**:
异常看板指着一个图上不存在的节点,点了也定位不到(实测:API 直存的包新增节点后
picker 里根本没有它)。补画:缺席节点按 60px 一档排在图纸下缘 —— 位置丑没关系,
看得见才能修。实测点异常 → 选中 A8 → 五张属性小卡弹出。
## 布局照你定的
row2 = 异常看板(1/3,与版本卡对齐)+ 节点属性卡(2/3,与流程图同宽);
属性卡内部小卡**等高 250px**,输入/输出超高的内容自己滚(实测五张全 250)。
种子改为保存后显式激活;NotClosedError 文案「不得保存」→「不得激活」。
typecheck ✓ lint ✓ depcruise ✓ 498 测试 ✓
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
+5
-6
@@ -28,17 +28,16 @@ export type StartWorkflowInput = {
|
|||||||
};
|
};
|
||||||
export type ProcessService = {
|
export type ProcessService = {
|
||||||
/**
|
/**
|
||||||
* 保存 —— **严格检查 + 即刻生效**,不闭合抛 `NotClosedError`。
|
* 保存 —— **不检查、不生效**:恒存为新版本号(服务端算,不采信入参的 version),
|
||||||
* 恒产生新版本号(服务端算,不采信入参的 version);返回实际落的那一版。
|
* 状态为未生效。画图的中间态天然不完整,保存不设闸;完整性闸在 `setPackageState` 激活时。
|
||||||
* 落下来的新版**即为生效版**:同流的旧生效版随之停用(一条流恰一个生效版本)。
|
|
||||||
*/
|
*/
|
||||||
savePackage(pkg: ProcessPackage): Promise<ProcessPackage>;
|
savePackage(pkg: ProcessPackage): Promise<ProcessPackage>;
|
||||||
listPackages(): Promise<ProcessPackage[]>;
|
listPackages(): Promise<ProcessPackage[]>;
|
||||||
getPackage(id: string, version?: number): Promise<ProcessPackage | undefined>;
|
getPackage(id: string, version?: number): Promise<ProcessPackage | undefined>;
|
||||||
/**
|
/**
|
||||||
* 版本启停 —— 运行时开关,**不改定义一个字**。
|
* 版本启停 —— 运行时开关,**不改定义一个字**。
|
||||||
* 激活某版:同流其余版本一并停用(恰一个生效);停用生效版:整条流下线,新起单被拒。
|
* 激活先过闭合校验(不闭合抛 `NotClosedError`),过了即独占:同流其余版本一并停用;
|
||||||
* 在途工作流按启动时钉住的版本跑完,启停对它们零影响。
|
* 停用生效版:整条流下线,新起单被拒。在途按启动时钉住的版本跑完,启停零影响。
|
||||||
*/
|
*/
|
||||||
setPackageState(id: string, version: number, state: 'active' | 'retired'): Promise<ProcessPackage[]>;
|
setPackageState(id: string, version: number, state: 'active' | 'retired'): Promise<ProcessPackage[]>;
|
||||||
/** 画图即校验 —— 与前端画布同源;只算不存 */
|
/** 画图即校验 —— 与前端画布同源;只算不存 */
|
||||||
@@ -64,7 +63,7 @@ export declare class CallInterfaceViolationError extends Error {
|
|||||||
readonly errors: readonly string[];
|
readonly errors: readonly string[];
|
||||||
constructor(packageId: string, errors: readonly string[]);
|
constructor(packageId: string, errors: readonly string[]);
|
||||||
}
|
}
|
||||||
/** 不闭合不得**保存** —— 没有发布这道门,闸就落在保存上 */
|
/** 不闭合不得**激活** —— 画图与保存不拦(中间态天然不完整),上线这一步拦 */
|
||||||
export declare class NotClosedError extends Error {
|
export declare class NotClosedError extends Error {
|
||||||
readonly report: ClosureReport;
|
readonly report: ClosureReport;
|
||||||
constructor(report: ClosureReport);
|
constructor(report: ClosureReport);
|
||||||
|
|||||||
@@ -276,19 +276,11 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
return c.json(body, 400);
|
return c.json(body, 400);
|
||||||
}
|
}
|
||||||
/*
|
/*
|
||||||
* **严格检查 + 保存即生效**:不闭合拒存(412 带报告),存下来的就能起单。
|
* 保存**不检查、不生效**:画图的中间态天然不完整,存下来是未生效的新版本。
|
||||||
* 版本号由用例层算(恒新增),这里不采信入参的 version —— 它只说明「改的是哪一版」。
|
* 返回连同闭合报告 —— 异常看板据它列清单;完整性闸在激活路由。
|
||||||
*/
|
*/
|
||||||
try {
|
const pkg = await container.process.savePackage(parsed.data.pkg);
|
||||||
const pkg = await container.process.savePackage(parsed.data.pkg);
|
return c.json({ pkg, report: await container.process.validate(pkg) });
|
||||||
return c.json({ pkg, report: await container.process.validate(pkg) });
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof NotClosedError) {
|
|
||||||
const body: ApiError = { code: 'precondition-failed', message: error.message, detail: error.report };
|
|
||||||
return c.json(body, 412);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 版本启停:激活即独占,停用即下线 —— 在途按启动版本跑完,零影响 */
|
/** 版本启停:激活即独占,停用即下线 —— 在途按启动版本跑完,零影响 */
|
||||||
@@ -300,12 +292,21 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
const body: ApiError = { code: 'validation-failed', message: '启停状态不合协议' };
|
const body: ApiError = { code: 'validation-failed', message: '启停状态不合协议' };
|
||||||
return c.json(body, 400);
|
return c.json(body, 400);
|
||||||
}
|
}
|
||||||
const packages = await container.process.setPackageState(
|
try {
|
||||||
c.req.param('id'),
|
const packages = await container.process.setPackageState(
|
||||||
Number(c.req.param('version')),
|
c.req.param('id'),
|
||||||
parsed.data.state,
|
Number(c.req.param('version')),
|
||||||
);
|
parsed.data.state,
|
||||||
return c.json({ packages });
|
);
|
||||||
|
return c.json({ packages });
|
||||||
|
} catch (error) {
|
||||||
|
// 不闭合不得激活 —— 412 带整份报告,前端异常看板直接用
|
||||||
|
if (error instanceof NotClosedError) {
|
||||||
|
const body: ApiError = { code: 'precondition-failed', message: error.message, detail: error.report };
|
||||||
|
return c.json(body, 412);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 画图即校验 —— 与前端画布同源 */
|
/** 画图即校验 —— 与前端画布同源 */
|
||||||
|
|||||||
@@ -455,10 +455,12 @@ export async function seedMvpFlow(container: Container): Promise<void> {
|
|||||||
if (current !== undefined && stripped(current) === stripped(MVP_PACKAGE)) {
|
if (current !== undefined && stripped(current) === stripped(MVP_PACKAGE)) {
|
||||||
log(`④ 流程包「统一报价」v${current.version} 已在库,内容未变 —— 不存新版`);
|
log(`④ 流程包「统一报价」v${current.version} 已在库,内容未变 —— 不存新版`);
|
||||||
} else {
|
} else {
|
||||||
|
// 保存不生效 —— 激活才是上线闸(不闭合在这一步被拦,种子包若不闭合要吵出来)
|
||||||
const saved = await container.process.savePackage(MVP_PACKAGE);
|
const saved = await container.process.savePackage(MVP_PACKAGE);
|
||||||
|
await container.process.setPackageState(saved.id, saved.version, 'active');
|
||||||
log(
|
log(
|
||||||
`④ 流程包「统一报价」v${saved.version} 已保存生效 —— ${saved.nodes.length} 节点,` +
|
`④ 流程包「统一报价」v${saved.version} 已保存并激活 —— ${saved.nodes.length} 节点,` +
|
||||||
`闭合校验通过(不闭合会拒存)${current === undefined ? '' : `;旧版 v${current.version} 原样留着,在途任务照它跑完`}`,
|
`闭合校验过(不闭合不得激活)${current === undefined ? '' : `;旧版 v${current.version} 原样留着,在途任务照它跑完`}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ const pkg = (over: Partial<ProcessPackage> = {}): ProcessPackage =>
|
|||||||
const contracts = new Map([['a4', face()]]);
|
const contracts = new Map([['a4', face()]]);
|
||||||
const positions = new Set(['BD', 'PM', 'FAE', 'PRM', 'OP']);
|
const positions = new Set(['BD', 'PM', 'FAE', 'PRM', 'OP']);
|
||||||
|
|
||||||
describe('checkClosure —— 不闭合不得保存', () => {
|
describe('checkClosure —— 不闭合不得激活', () => {
|
||||||
it('必填全映射、岗位存在即可保存', () => {
|
it('必填全映射、岗位存在即可激活', () => {
|
||||||
const report = checkClosure({ pkg: pkg(), contracts, positions });
|
const report = checkClosure({ pkg: pkg(), contracts, positions });
|
||||||
expect(report.violations).toEqual([]);
|
expect(report.violations).toEqual([]);
|
||||||
expect(report.closed).toBe(true);
|
expect(report.closed).toBe(true);
|
||||||
|
|||||||
@@ -53,17 +53,16 @@ export type StartWorkflowInput = {
|
|||||||
|
|
||||||
export type ProcessService = {
|
export type ProcessService = {
|
||||||
/**
|
/**
|
||||||
* 保存 —— **严格检查 + 即刻生效**,不闭合抛 `NotClosedError`。
|
* 保存 —— **不检查、不生效**:恒存为新版本号(服务端算,不采信入参的 version),
|
||||||
* 恒产生新版本号(服务端算,不采信入参的 version);返回实际落的那一版。
|
* 状态为未生效。画图的中间态天然不完整,保存不设闸;完整性闸在 `setPackageState` 激活时。
|
||||||
* 落下来的新版**即为生效版**:同流的旧生效版随之停用(一条流恰一个生效版本)。
|
|
||||||
*/
|
*/
|
||||||
savePackage(pkg: ProcessPackage): Promise<ProcessPackage>;
|
savePackage(pkg: ProcessPackage): Promise<ProcessPackage>;
|
||||||
listPackages(): Promise<ProcessPackage[]>;
|
listPackages(): Promise<ProcessPackage[]>;
|
||||||
getPackage(id: string, version?: number): Promise<ProcessPackage | undefined>;
|
getPackage(id: string, version?: number): Promise<ProcessPackage | undefined>;
|
||||||
/**
|
/**
|
||||||
* 版本启停 —— 运行时开关,**不改定义一个字**。
|
* 版本启停 —— 运行时开关,**不改定义一个字**。
|
||||||
* 激活某版:同流其余版本一并停用(恰一个生效);停用生效版:整条流下线,新起单被拒。
|
* 激活先过闭合校验(不闭合抛 `NotClosedError`),过了即独占:同流其余版本一并停用;
|
||||||
* 在途工作流按启动时钉住的版本跑完,启停对它们零影响。
|
* 停用生效版:整条流下线,新起单被拒。在途按启动时钉住的版本跑完,启停零影响。
|
||||||
*/
|
*/
|
||||||
setPackageState(id: string, version: number, state: 'active' | 'retired'): Promise<ProcessPackage[]>;
|
setPackageState(id: string, version: number, state: 'active' | 'retired'): Promise<ProcessPackage[]>;
|
||||||
/** 画图即校验 —— 与前端画布同源;只算不存 */
|
/** 画图即校验 —— 与前端画布同源;只算不存 */
|
||||||
@@ -110,10 +109,10 @@ export class CallInterfaceViolationError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 不闭合不得**保存** —— 没有发布这道门,闸就落在保存上 */
|
/** 不闭合不得**激活** —— 画图与保存不拦(中间态天然不完整),上线这一步拦 */
|
||||||
export class NotClosedError extends Error {
|
export class NotClosedError extends Error {
|
||||||
constructor(readonly report: ClosureReport) {
|
constructor(readonly report: ClosureReport) {
|
||||||
super(`流程包不闭合,不得保存:${report.violations.map((v) => v.message).join('; ')}`);
|
super(`流程包不闭合,不得激活:${report.violations.map((v) => v.message).join('; ')}`);
|
||||||
this.name = 'NotClosedError';
|
this.name = 'NotClosedError';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -287,18 +287,14 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
|
|||||||
* 而「旧任务走完旧版本」正是要防这个。恒新增之后,旧版本是不可变的历史,
|
* 而「旧任务走完旧版本」正是要防这个。恒新增之后,旧版本是不可变的历史,
|
||||||
* 在途任务读得到的永远是它启动那天的那一份。
|
* 在途任务读得到的永远是它启动那天的那一份。
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 保存**不检查**:画图必然经过不完整的中间态(节点没接完边、活动还没安装),
|
||||||
|
* 拿闭合校验拦保存,画到一半就存不了(实测 412 卡死编辑)。
|
||||||
|
* 存下来的是**未生效**的新版本 —— 完整性闸在激活那一步。
|
||||||
|
*/
|
||||||
async savePackage(pkg) {
|
async savePackage(pkg) {
|
||||||
const report = await this.validate(pkg);
|
const saved: ProcessPackage = { ...pkg, version: await nextVersion(pkg.id), state: 'retired' };
|
||||||
if (!report.closed) throw new NotClosedError(report);
|
|
||||||
const saved: ProcessPackage = { ...pkg, version: await nextVersion(pkg.id), state: 'active' };
|
|
||||||
await deps.store.savePackage(saved);
|
await deps.store.savePackage(saved);
|
||||||
// 新版即生效 → 旧生效版随之停用:一条流恰一个生效版本(要回退就去版本列表切)
|
|
||||||
const siblings = await deps.store.listPackages();
|
|
||||||
for (const sib of siblings) {
|
|
||||||
if (sib.id === saved.id && sib.version !== saved.version && sib.state === 'active') {
|
|
||||||
await deps.store.setPackageState(sib.id, sib.version, 'retired');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return saved;
|
return saved;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -307,6 +303,9 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
|
|||||||
const target = all.find((p) => p.id === id && p.version === version);
|
const target = all.find((p) => p.id === id && p.version === version);
|
||||||
if (target === undefined) throw new Error(`流程包版本不存在:${id}@${version}`);
|
if (target === undefined) throw new Error(`流程包版本不存在:${id}@${version}`);
|
||||||
if (state === 'active') {
|
if (state === 'active') {
|
||||||
|
// **激活才查完整性**:不闭合不得激活 —— 画图不拦,上线拦
|
||||||
|
const report = await this.validate(target);
|
||||||
|
if (!report.closed) throw new NotClosedError(report);
|
||||||
// 激活即独占:同流其余生效版一并停用 —— 「一次只能激活一个版本」
|
// 激活即独占:同流其余生效版一并停用 —— 「一次只能激活一个版本」
|
||||||
for (const sib of all) {
|
for (const sib of all) {
|
||||||
if (sib.id === id && sib.version !== version && sib.state === 'active') {
|
if (sib.id === id && sib.version !== version && sib.state === 'active') {
|
||||||
|
|||||||
@@ -557,6 +557,15 @@
|
|||||||
.g-ver-act { border: 1px solid var(--cline); background: #fff; border-radius: 8px; padding: 3px 9px; font: inherit; font-size: 11px; font-weight: 600; color: var(--ink-2); cursor: pointer; }
|
.g-ver-act { border: 1px solid var(--cline); background: #fff; border-radius: 8px; padding: 3px 9px; font: inherit; font-size: 11px; font-weight: 600; color: var(--ink-2); cursor: pointer; }
|
||||||
.g-ver-act:hover { border-color: #c9d6e6; }
|
.g-ver-act:hover { border-color: #c9d6e6; }
|
||||||
.g-ver-act.t-go { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 35%, var(--cline)); }
|
.g-ver-act.t-go { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 35%, var(--cline)); }
|
||||||
/* 属性区整行铺开:g-side 的窄栏约束(单列 grid、sticky)在这里都不要 */
|
/* 异常看板:与版本卡同风(图纸式),行是红左线的方框;点一条定位到节点属性 */
|
||||||
.g-wide { position: static; margin-top: 12px; }
|
.g-exboard .g-vercard-h { height: auto; padding: 13px 18px 10px; }
|
||||||
.g-wide .g-props { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
|
.g-ex { cursor: pointer; border-left-color: #b0413e; }
|
||||||
|
.g-ex > b { font-size: 11px; }
|
||||||
|
.g-ex .msg { flex: 1; min-width: 0; font-size: 10.5px; line-height: 1.55; letter-spacing: 0; text-transform: none; color: #3a4452; }
|
||||||
|
.g-exboard-empty { padding: 16px 18px; font-family: var(--sh-mono); font-size: 10px; line-height: 1.9; color: var(--sh-muted); }
|
||||||
|
|
||||||
|
/* 节点属性卡:与流程图同宽(格栅右列);内部小卡等高,超高的内容自己滚 */
|
||||||
|
.g-nodecard { min-height: 0; margin-top: 2px; }
|
||||||
|
.g-nodecard .g-props { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); align-items: stretch; }
|
||||||
|
.g-nodecard .g-prop { height: 250px; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.g-nodecard .g-prop-b { flex: 1; min-height: 0; overflow-y: auto; }
|
||||||
|
|||||||
@@ -273,7 +273,9 @@ export function PkgPage({ id, nav }: { id: string; nav: Nav }) {
|
|||||||
const versions = (q.data?.packages ?? [])
|
const versions = (q.data?.packages ?? [])
|
||||||
.filter((p) => p.id === flowId)
|
.filter((p) => p.id === flowId)
|
||||||
.sort((a, b) => b.version - a.version);
|
.sort((a, b) => b.version - a.version);
|
||||||
/* 选中哪一版看图:默认跟着生效版走;手动点过就钉住(保存成新版后解钉,跟回新生效版) */
|
/* 全部版本的闭合报告 —— 版本行的「激活」按钮据它灰显(不闭合点不了) */
|
||||||
|
const reports = useGovPkgReports(versions);
|
||||||
|
/* 选中哪一版看图:默认跟着生效版走;手动点过就钉住;保存后钉到**刚存的新版**(它未生效) */
|
||||||
const [pinned, setPinned] = useState<number | null>(null);
|
const [pinned, setPinned] = useState<number | null>(null);
|
||||||
const active = versions.find((v) => v.state === 'active');
|
const active = versions.find((v) => v.state === 'active');
|
||||||
const shown = (pinned === null ? undefined : versions.find((v) => v.version === pinned)) ?? active ?? versions[0];
|
const shown = (pinned === null ? undefined : versions.find((v) => v.version === pinned)) ?? active ?? versions[0];
|
||||||
@@ -295,8 +297,9 @@ export function PkgPage({ id, nav }: { id: string; nav: Nav }) {
|
|||||||
activities={acts.data?.activities ?? []}
|
activities={acts.data?.activities ?? []}
|
||||||
positions={(staffQ.data?.positions ?? []).map((p) => ({ id: p.id, name: p.name, abbr: p.code }))}
|
positions={(staffQ.data?.positions ?? []).map((p) => ({ id: p.id, name: p.name, abbr: p.code }))}
|
||||||
nextVersion={Math.max(...versions.map((x) => x.version)) + 1}
|
nextVersion={Math.max(...versions.map((x) => x.version)) + 1}
|
||||||
|
closedOf={(v) => reports.data?.get(`${flowId}@${v}`)?.closed}
|
||||||
onPick={(v) => setPinned(v)}
|
onPick={(v) => setPinned(v)}
|
||||||
onSaved={() => setPinned(null)}
|
onSaved={(v) => setPinned(v)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -319,6 +322,7 @@ function FlowLab({
|
|||||||
activities,
|
activities,
|
||||||
positions,
|
positions,
|
||||||
nextVersion,
|
nextVersion,
|
||||||
|
closedOf,
|
||||||
onPick,
|
onPick,
|
||||||
onSaved,
|
onSaved,
|
||||||
}: {
|
}: {
|
||||||
@@ -329,13 +333,16 @@ function FlowLab({
|
|||||||
activities: readonly ActivityRow[];
|
activities: readonly ActivityRow[];
|
||||||
positions: SpPosition[];
|
positions: SpPosition[];
|
||||||
nextVersion: number;
|
nextVersion: number;
|
||||||
|
/** 该版本闭合与否(undefined = 报告还没回来)—— 激活按钮据此灰显 */
|
||||||
|
closedOf: (version: number) => boolean | undefined;
|
||||||
onPick: (version: number) => void;
|
onPick: (version: number) => void;
|
||||||
onSaved: () => void;
|
onSaved: (version: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
/** 只有**生效版**可编辑(改则存成下一版);其余版本只读 —— 在途任务还按它们跑 */
|
/*
|
||||||
const live = pkg.version === activeVersion;
|
* 任何版本都可编辑:保存恒存为**未生效**的新版本,谁也伤不着 ——
|
||||||
const readOnly = !live;
|
* 在旧版上改就是「以旧版为底另起一版」。生效切换只在激活那一下。
|
||||||
|
*/
|
||||||
const paradigms = useMemo(() => new Map(activities.map((a) => [a.activityId, a.paradigm])), [activities]);
|
const paradigms = useMemo(() => new Map(activities.map((a) => [a.activityId, a.paradigm])), [activities]);
|
||||||
|
|
||||||
/** 画布工作副本:属性卡与手绘编辑都改它;保存时合并回流程包 */
|
/** 画布工作副本:属性卡与手绘编辑都改它;保存时合并回流程包 */
|
||||||
@@ -364,7 +371,6 @@ function FlowLab({
|
|||||||
});
|
});
|
||||||
const activeReport = report ?? liveReport.data;
|
const activeReport = report ?? liveReport.data;
|
||||||
const ec = edgeCheckOf(pkg, activeReport);
|
const ec = edgeCheckOf(pkg, activeReport);
|
||||||
const okEdge = activeReport?.closed === true;
|
|
||||||
|
|
||||||
const patch = (f: (n: FlowNode) => FlowNode) => {
|
const patch = (f: (n: FlowNode) => FlowNode) => {
|
||||||
if (sel === null) return;
|
if (sel === null) return;
|
||||||
@@ -407,6 +413,7 @@ function FlowLab({
|
|||||||
if (sel !== null) root.querySelector(`[data-element-id="${sel}"]`)?.classList.add('flow-node-sel');
|
if (sel !== null) root.querySelector(`[data-element-id="${sel}"]`)?.classList.add('flow-node-sel');
|
||||||
}, [sel, graph]);
|
}, [sel, graph]);
|
||||||
|
|
||||||
|
const [saveErr, setSaveErr] = useState<string | undefined>(undefined);
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: (input: { graph: FlowGraph; idMap: Record<string, string> }) =>
|
mutationFn: (input: { graph: FlowGraph; idMap: Record<string, string> }) =>
|
||||||
call<{ pkg: ProcessPackage; report: ClosureReport }>(saveProcessRoute, {
|
call<{ pkg: ProcessPackage; report: ClosureReport }>(saveProcessRoute, {
|
||||||
@@ -415,11 +422,13 @@ function FlowLab({
|
|||||||
onSuccess: (out) => {
|
onSuccess: (out) => {
|
||||||
setReport(out.report);
|
setReport(out.report);
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
|
setSaveErr(undefined);
|
||||||
void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] });
|
void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ['gov', 'process-reports'] });
|
void queryClient.invalidateQueries({ queryKey: ['gov', 'process-reports'] });
|
||||||
// 保存即成新生效版 —— 解钉,画布跟去新版本
|
// 存下来的是未生效的新版 —— 钉过去接着画;异常看板会列它还缺什么
|
||||||
onSaved();
|
onSaved(out.pkg.version);
|
||||||
},
|
},
|
||||||
|
onError: (e: unknown) => setSaveErr((e as { message?: string }).message ?? '保存失败'),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 版本启停:运行时开关,不改定义 —— 在途按启动版本跑完,零影响 */
|
/** 版本启停:运行时开关,不改定义 —— 在途按启动版本跑完,零影响 */
|
||||||
@@ -447,7 +456,7 @@ function FlowLab({
|
|||||||
void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] });
|
void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ['gov', 'process-reports'] });
|
void queryClient.invalidateQueries({ queryKey: ['gov', 'process-reports'] });
|
||||||
setAsk(undefined);
|
setAsk(undefined);
|
||||||
onSaved();
|
onSaved(out.pkg.version);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -455,24 +464,23 @@ function FlowLab({
|
|||||||
setAsk({
|
setAsk({
|
||||||
spec: {
|
spec: {
|
||||||
title: `新建版本:以 v${pkg.version} 为底 → v${nextVersion}`,
|
title: `新建版本:以 v${pkg.version} 为底 → v${nextVersion}`,
|
||||||
note:
|
note: dirty ? (
|
||||||
dirty && !readOnly ? (
|
<Note tone="warn">
|
||||||
<Note tone="warn">
|
画布上有<b>未保存的改动</b>,新建版本不会带上它们(克隆的是已存的 v{pkg.version})。
|
||||||
画布上有<b>未保存的改动</b>,新建版本不会带上它们(克隆的是已存的 v{pkg.version})。
|
想连改动一起走,直接点「保存为 v{nextVersion}」。
|
||||||
想连改动一起走,用下面的「保存并生效」。
|
</Note>
|
||||||
</Note>
|
) : (
|
||||||
) : (
|
<Note>
|
||||||
<Note>
|
把 v{pkg.version} 的定义原样复制成 v{nextVersion}。<b>不生效</b> ——
|
||||||
把 v{pkg.version} 的定义原样复制成 v{nextVersion} 并即刻生效
|
画完、异常清零后再激活。
|
||||||
{readOnly ? ' —— 这就是「把旧版本的画法复活成最新版」的路' : ',再在新版上接着画'}。
|
</Note>
|
||||||
</Note>
|
),
|
||||||
),
|
|
||||||
impact: {
|
impact: {
|
||||||
who: `此后新起的工作流走 v${nextVersion}`,
|
who: '只多一版,不动任何在跑的东西',
|
||||||
undo: '版本并存,可把生效切回任一版',
|
undo: '版本并存,不要就停着别激活',
|
||||||
when: '确认即建并生效',
|
when: '确认即建',
|
||||||
},
|
},
|
||||||
okLabel: `建 v${nextVersion} 并生效`,
|
okLabel: `建 v${nextVersion}`,
|
||||||
onOk: () => cloneVersion.mutate(),
|
onOk: () => cloneVersion.mutate(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -611,23 +619,11 @@ function FlowLab({
|
|||||||
|
|
||||||
{ask !== undefined && <AskBand spec={ask.spec} danger={ask.danger === true} onCancel={() => setAsk(undefined)} />}
|
{ask !== undefined && <AskBand spec={ask.spec} danger={ask.danger === true} onCancel={() => setAsk(undefined)} />}
|
||||||
|
|
||||||
{activeReport !== undefined && !activeReport.closed && (
|
|
||||||
<Note tone="warn">
|
|
||||||
<b>未闭合:</b>
|
|
||||||
{activeReport.violations.map((v) => v.message).join(';')}
|
|
||||||
</Note>
|
|
||||||
)}
|
|
||||||
{report !== undefined && report.closed && <Note tone="ok">闭合校验通过。</Note>}
|
|
||||||
|
|
||||||
{/* 编排工作区:左 1/3 版本列表 + 右 2/3 画布 —— 版本收在流程内部,不平铺台面 */}
|
{/* 编排工作区:左 1/3 版本列表 + 右 2/3 画布 —— 版本收在流程内部,不平铺台面 */}
|
||||||
<Sec
|
<Sec
|
||||||
ico="route"
|
ico="route"
|
||||||
title="编排工作区"
|
title="编排工作区"
|
||||||
extra={
|
extra={`画的是 v${pkg.version} —— 保存存为 v${nextVersion}(不生效);画图不检查,激活才查完整`}
|
||||||
readOnly
|
|
||||||
? `看的是 v${pkg.version}(只读)—— 要改图,回生效版去改`
|
|
||||||
: `画的是生效版 v${pkg.version} —— 保存即存成 v${nextVersion} 并生效`
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className="g-flowlab">
|
<div className="g-flowlab">
|
||||||
<aside className="g-vercard">
|
<aside className="g-vercard">
|
||||||
@@ -672,6 +668,9 @@ function FlowLab({
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
className="g-ver-act t-go"
|
className="g-ver-act t-go"
|
||||||
|
/* 不闭合点不了:异常清零才亮 —— 完整性闸在激活,不在画图 */
|
||||||
|
disabled={closedOf(v.version) !== true}
|
||||||
|
title={closedOf(v.version) === true ? `激活 v${v.version}` : '有异常,先看异常看板'}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
askActivate(v);
|
askActivate(v);
|
||||||
@@ -697,21 +696,18 @@ function FlowLab({
|
|||||||
oneLiner={`蓝 = 标准活动 · 绿 = 案例活动 · 琥珀 = 审批点 · 菱形 = 网关 · 红 = 卡点`}
|
oneLiner={`蓝 = 标准活动 · 绿 = 案例活动 · 琥珀 = 审批点 · 菱形 = 网关 · 红 = 卡点`}
|
||||||
graph={graph}
|
graph={graph}
|
||||||
drilledIds={nodes.filter((n) => n.kind === 'activity').map((n) => n.id)}
|
drilledIds={nodes.filter((n) => n.kind === 'activity').map((n) => n.id)}
|
||||||
positions={positions}
|
/*
|
||||||
canEdit={!readOnly}
|
* 画布只画**编排逻辑**:节点与网关的名字、连线;边上的字是辅助阅读的注记。
|
||||||
|
* 图上真正生效可编辑的只有**名字** —— 不传 positions,双击就不出岗位一栏。
|
||||||
|
* 岗位、判据、映射这些闭环要素在**节点属性**里决定;分配到人是运营的事,这里不碰。
|
||||||
|
*/
|
||||||
|
canEdit
|
||||||
onNodeClick={(nid) => setSel(nid)}
|
onNodeClick={(nid) => setSel(nid)}
|
||||||
onEditNodeText={async (nid, p2) => {
|
onEditNodeText={async (nid, p2) => {
|
||||||
setGraph((g) =>
|
setGraph((g) =>
|
||||||
g === null
|
g === null
|
||||||
? g
|
? g
|
||||||
: {
|
: { ...g, nodes: g.nodes.map((n) => (n.id === nid ? { ...n, label: p2.label } : n)) },
|
||||||
...g,
|
|
||||||
nodes: g.nodes.map((n) =>
|
|
||||||
n.id === nid
|
|
||||||
? { ...n, label: p2.label, ...(p2.owner === undefined ? {} : { owner: p2.owner }) }
|
|
||||||
: n,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
setDirty(true);
|
setDirty(true);
|
||||||
}}
|
}}
|
||||||
@@ -723,14 +719,54 @@ function FlowLab({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 图下:节点属性卡 —— 点画布或点节点条,属性就地填 */}
|
{/*
|
||||||
<div className="g-wide">
|
* 左下:异常看板 —— 与版本卡同列同风(图纸式)。
|
||||||
|
* 画图不检查,这里**替**你盯着:哪个节点缺什么,点一条定位到属性去补;
|
||||||
|
* 清零了激活按钮才亮 —— 完整性闸在激活,不在画图。
|
||||||
|
*/}
|
||||||
|
<aside className="g-vercard g-exboard">
|
||||||
|
<div className="g-vercard-h">异常 · 看板</div>
|
||||||
|
<div className="g-vercard-list">
|
||||||
|
{activeReport === undefined ? (
|
||||||
|
<div className="g-exboard-empty">校验中…</div>
|
||||||
|
) : activeReport.violations.length === 0 ? (
|
||||||
|
<div className="g-exboard-empty">
|
||||||
|
没有异常 —— v{pkg.version} 的编排闭合了。
|
||||||
|
{pkg.version === activeVersion ? '这一版正在生效。' : '版本行的「激活」已亮,点它即可上线。'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
activeReport.violations.map((v, i) => {
|
||||||
|
const nid = v.nodeId ?? pkg.edges.find((e) => e.id === v.edgeId)?.to;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${v.kind}-${i}`}
|
||||||
|
className="g-ver g-ex"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => {
|
||||||
|
if (nid !== undefined) setSel(String(nid));
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && nid !== undefined) setSel(String(nid));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{nid !== undefined && <b>{String(nid)}</b>}
|
||||||
|
<span className="msg">{v.message}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="g-vercard-f">点一条异常,定位到它的节点属性;清零后才可激活。</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 右下:节点属性卡 —— 与流程图同宽;闭环(岗位/判据/映射)在这里决定 */}
|
||||||
|
<div className="g-nodecard">
|
||||||
<Card
|
<Card
|
||||||
ico="sliders-horizontal"
|
ico="sliders-horizontal"
|
||||||
title="节点属性"
|
title="节点属性"
|
||||||
extra={readOnly ? `v${pkg.version} 只读` : dirty ? '有改动待保存' : '点节点就地填写'}
|
extra={dirty ? '有改动待保存' : '点节点就地填写 —— 闭环(岗位/判据/映射)在这里决定'}
|
||||||
>
|
>
|
||||||
<div className="g-picker">
|
<div className="g-picker">
|
||||||
{nodes
|
{nodes
|
||||||
@@ -936,42 +972,27 @@ function FlowLab({
|
|||||||
align="end"
|
align="end"
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
label: dirty ? `保存并生效(存为 v${nextVersion})` : '编排无改动',
|
label: save.isPending ? '保存中…' : dirty ? `保存为 v${nextVersion}` : '编排无改动',
|
||||||
ico: 'send',
|
ico: 'send',
|
||||||
primary: dirty,
|
primary: dirty,
|
||||||
disabled: !dirty || readOnly,
|
disabled: !dirty || save.isPending,
|
||||||
ask: dirty
|
// 保存不设闸也不设确认:存下来的是未生效的新版,谁也伤不着;异常看板会说还缺什么
|
||||||
? {
|
onClick: () => {
|
||||||
title: `保存并生效:${pkg.name} → v${nextVersion}`,
|
if (graph !== null) save.mutate({ graph, idMap: {} });
|
||||||
note: okEdge ? (
|
},
|
||||||
<Note tone="ok">
|
|
||||||
边映射全闭合、必填齐、类型无冲突 —— 存下去即刻对新起的工作流生效。
|
|
||||||
</Note>
|
|
||||||
) : (
|
|
||||||
<Note tone="warn">
|
|
||||||
<b>不闭合存不进来:</b>
|
|
||||||
{activeReport?.violations.map((v) => v.message).join(';') ?? '闭合校验未跑完'}
|
|
||||||
</Note>
|
|
||||||
),
|
|
||||||
impact: {
|
|
||||||
who: okEdge
|
|
||||||
? `存为 v${nextVersion} 并成为唯一生效版;在途的按启动时版本跑完`
|
|
||||||
: '被拒存 —— 先补齐边映射',
|
|
||||||
undo: `v${pkg.version} 原样留着,可把生效切回去`,
|
|
||||||
when: okEdge ? '确认即生效' : '存不进去',
|
|
||||||
},
|
|
||||||
okLabel: okEdge ? '确认保存并生效' : '我知道了',
|
|
||||||
onOk: () => {
|
|
||||||
if (graph !== null) save.mutate({ graph, idMap: {} });
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
{saveErr !== undefined && (
|
||||||
|
<Note tone="warn">
|
||||||
|
<b>保存失败:</b>
|
||||||
|
{saveErr}
|
||||||
|
</Note>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Sec>
|
</Sec>
|
||||||
|
|
||||||
|
|||||||
@@ -34,13 +34,32 @@ export function packageToGraph(
|
|||||||
*/
|
*/
|
||||||
const stored = readStoredDiagram(pkg.diagram);
|
const stored = readStoredDiagram(pkg.diagram);
|
||||||
if (stored !== undefined) {
|
if (stored !== undefined) {
|
||||||
return {
|
/*
|
||||||
...stored,
|
* **语义里有、存证里没有的节点要补画出来** —— 不补,画布就看不见它:
|
||||||
nodes: stored.nodes.map((n) => {
|
* 异常看板指着一个图上不存在的节点,点了也定位不到(实测:API 直存的包
|
||||||
|
* 语义层加了新节点、diagram 还是旧的,picker 里根本没有那个节点)。
|
||||||
|
* 编辑器保存时两层同步,不会走到这;这是给「语义先行」的数据兜底。
|
||||||
|
* 补的节点没有几何,按 60px 一档排在图纸下缘 —— 位置丑没关系,看得见才能修。
|
||||||
|
*/
|
||||||
|
const drawn = new Set(stored.nodes.map((n) => n.id));
|
||||||
|
const missing = pkg.nodes.filter((n) => !drawn.has(n.id));
|
||||||
|
const bottom = stored.nodes.reduce((max, n) => Math.max(max, (n.y ?? 0)), 0) + 90;
|
||||||
|
const extras: FlowNode[] = missing.map((n, i) => ({
|
||||||
|
id: n.id,
|
||||||
|
kind: n.kind === 'gateway' ? ('gateway' as const) : n.kind === 'start' ? ('start' as const) : n.kind === 'end' ? ('end' as const) : ('activity' as const),
|
||||||
|
label: n.label,
|
||||||
|
...(n.kind === 'gateway' ? { mode: n.gateway === 'parallel' ? ('and' as const) : ('x' as const) } : {}),
|
||||||
|
...(n.kind === 'approval' ? { flavor: 'approval' as const, owner: n.approver.position } : {}),
|
||||||
|
...(n.kind === 'activity' ? { code: n.id, owner: n.owner.position } : {}),
|
||||||
|
x: 30 + (i % 6) * 160,
|
||||||
|
y: bottom + Math.floor(i / 6) * 60,
|
||||||
|
}));
|
||||||
|
const withState = (list: readonly FlowNode[]) =>
|
||||||
|
list.map((n) => {
|
||||||
const state = stateOf(markOf.get(n.id));
|
const state = stateOf(markOf.get(n.id));
|
||||||
return state === undefined ? n : { ...n, note: state };
|
return state === undefined ? n : { ...n, note: state };
|
||||||
}),
|
});
|
||||||
};
|
return { ...stored, nodes: [...withState(stored.nodes), ...withState(extras)] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodes: FlowNode[] = pkg.nodes.map((n) => {
|
const nodes: FlowNode[] = pkg.nodes.map((n) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user