diff --git a/api-report/apps/api/src/modules/process/contract.d.ts b/api-report/apps/api/src/modules/process/contract.d.ts index a2d2163..30f9c83 100644 --- a/api-report/apps/api/src/modules/process/contract.d.ts +++ b/api-report/apps/api/src/modules/process/contract.d.ts @@ -28,17 +28,16 @@ export type StartWorkflowInput = { }; export type ProcessService = { /** - * 保存 —— **严格检查 + 即刻生效**,不闭合抛 `NotClosedError`。 - * 恒产生新版本号(服务端算,不采信入参的 version);返回实际落的那一版。 - * 落下来的新版**即为生效版**:同流的旧生效版随之停用(一条流恰一个生效版本)。 + * 保存 —— **不检查、不生效**:恒存为新版本号(服务端算,不采信入参的 version), + * 状态为未生效。画图的中间态天然不完整,保存不设闸;完整性闸在 `setPackageState` 激活时。 */ savePackage(pkg: ProcessPackage): Promise; listPackages(): Promise; getPackage(id: string, version?: number): Promise; /** * 版本启停 —— 运行时开关,**不改定义一个字**。 - * 激活某版:同流其余版本一并停用(恰一个生效);停用生效版:整条流下线,新起单被拒。 - * 在途工作流按启动时钉住的版本跑完,启停对它们零影响。 + * 激活先过闭合校验(不闭合抛 `NotClosedError`),过了即独占:同流其余版本一并停用; + * 停用生效版:整条流下线,新起单被拒。在途按启动时钉住的版本跑完,启停零影响。 */ setPackageState(id: string, version: number, state: 'active' | 'retired'): Promise; /** 画图即校验 —— 与前端画布同源;只算不存 */ @@ -64,7 +63,7 @@ export declare class CallInterfaceViolationError extends Error { readonly errors: readonly string[]; constructor(packageId: string, errors: readonly string[]); } -/** 不闭合不得**保存** —— 没有发布这道门,闸就落在保存上 */ +/** 不闭合不得**激活** —— 画图与保存不拦(中间态天然不完整),上线这一步拦 */ export declare class NotClosedError extends Error { readonly report: ClosureReport; constructor(report: ClosureReport); diff --git a/apps/api/src/app/routes/governance-ops.ts b/apps/api/src/app/routes/governance-ops.ts index 31f786d..16da5aa 100644 --- a/apps/api/src/app/routes/governance-ops.ts +++ b/apps/api/src/app/routes/governance-ops.ts @@ -276,19 +276,11 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai return c.json(body, 400); } /* - * **严格检查 + 保存即生效**:不闭合拒存(412 带报告),存下来的就能起单。 - * 版本号由用例层算(恒新增),这里不采信入参的 version —— 它只说明「改的是哪一版」。 + * 保存**不检查、不生效**:画图的中间态天然不完整,存下来是未生效的新版本。 + * 返回连同闭合报告 —— 异常看板据它列清单;完整性闸在激活路由。 */ - try { - const pkg = await container.process.savePackage(parsed.data.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; - } + const pkg = await container.process.savePackage(parsed.data.pkg); + return c.json({ pkg, report: await container.process.validate(pkg) }); }); /** 版本启停:激活即独占,停用即下线 —— 在途按启动版本跑完,零影响 */ @@ -300,12 +292,21 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai const body: ApiError = { code: 'validation-failed', message: '启停状态不合协议' }; return c.json(body, 400); } - const packages = await container.process.setPackageState( - c.req.param('id'), - Number(c.req.param('version')), - parsed.data.state, - ); - return c.json({ packages }); + try { + const packages = await container.process.setPackageState( + c.req.param('id'), + Number(c.req.param('version')), + parsed.data.state, + ); + 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; + } }); /** 画图即校验 —— 与前端画布同源 */ diff --git a/apps/api/src/app/seed-mvp-flow.ts b/apps/api/src/app/seed-mvp-flow.ts index d9182c3..8b8121f 100644 --- a/apps/api/src/app/seed-mvp-flow.ts +++ b/apps/api/src/app/seed-mvp-flow.ts @@ -455,10 +455,12 @@ export async function seedMvpFlow(container: Container): Promise { if (current !== undefined && stripped(current) === stripped(MVP_PACKAGE)) { log(`④ 流程包「统一报价」v${current.version} 已在库,内容未变 —— 不存新版`); } else { + // 保存不生效 —— 激活才是上线闸(不闭合在这一步被拦,种子包若不闭合要吵出来) const saved = await container.process.savePackage(MVP_PACKAGE); + await container.process.setPackageState(saved.id, saved.version, 'active'); log( - `④ 流程包「统一报价」v${saved.version} 已保存生效 —— ${saved.nodes.length} 节点,` + - `闭合校验通过(不闭合会拒存)${current === undefined ? '' : `;旧版 v${current.version} 原样留着,在途任务照它跑完`}`, + `④ 流程包「统一报价」v${saved.version} 已保存并激活 —— ${saved.nodes.length} 节点,` + + `闭合校验过(不闭合不得激活)${current === undefined ? '' : `;旧版 v${current.version} 原样留着,在途任务照它跑完`}`, ); } diff --git a/apps/api/src/modules/process/__tests__/closure.test.ts b/apps/api/src/modules/process/__tests__/closure.test.ts index 36fe285..f4caf92 100644 --- a/apps/api/src/modules/process/__tests__/closure.test.ts +++ b/apps/api/src/modules/process/__tests__/closure.test.ts @@ -61,8 +61,8 @@ const pkg = (over: Partial = {}): ProcessPackage => const contracts = new Map([['a4', face()]]); const positions = new Set(['BD', 'PM', 'FAE', 'PRM', 'OP']); -describe('checkClosure —— 不闭合不得保存', () => { - it('必填全映射、岗位存在即可保存', () => { +describe('checkClosure —— 不闭合不得激活', () => { + it('必填全映射、岗位存在即可激活', () => { const report = checkClosure({ pkg: pkg(), contracts, positions }); expect(report.violations).toEqual([]); expect(report.closed).toBe(true); diff --git a/apps/api/src/modules/process/contract.ts b/apps/api/src/modules/process/contract.ts index b86ed89..882756b 100644 --- a/apps/api/src/modules/process/contract.ts +++ b/apps/api/src/modules/process/contract.ts @@ -53,17 +53,16 @@ export type StartWorkflowInput = { export type ProcessService = { /** - * 保存 —— **严格检查 + 即刻生效**,不闭合抛 `NotClosedError`。 - * 恒产生新版本号(服务端算,不采信入参的 version);返回实际落的那一版。 - * 落下来的新版**即为生效版**:同流的旧生效版随之停用(一条流恰一个生效版本)。 + * 保存 —— **不检查、不生效**:恒存为新版本号(服务端算,不采信入参的 version), + * 状态为未生效。画图的中间态天然不完整,保存不设闸;完整性闸在 `setPackageState` 激活时。 */ savePackage(pkg: ProcessPackage): Promise; listPackages(): Promise; getPackage(id: string, version?: number): Promise; /** * 版本启停 —— 运行时开关,**不改定义一个字**。 - * 激活某版:同流其余版本一并停用(恰一个生效);停用生效版:整条流下线,新起单被拒。 - * 在途工作流按启动时钉住的版本跑完,启停对它们零影响。 + * 激活先过闭合校验(不闭合抛 `NotClosedError`),过了即独占:同流其余版本一并停用; + * 停用生效版:整条流下线,新起单被拒。在途按启动时钉住的版本跑完,启停零影响。 */ setPackageState(id: string, version: number, state: 'active' | 'retired'): Promise; /** 画图即校验 —— 与前端画布同源;只算不存 */ @@ -110,10 +109,10 @@ export class CallInterfaceViolationError extends Error { } } -/** 不闭合不得**保存** —— 没有发布这道门,闸就落在保存上 */ +/** 不闭合不得**激活** —— 画图与保存不拦(中间态天然不完整),上线这一步拦 */ export class NotClosedError extends Error { constructor(readonly report: ClosureReport) { - super(`流程包不闭合,不得保存:${report.violations.map((v) => v.message).join('; ')}`); + super(`流程包不闭合,不得激活:${report.violations.map((v) => v.message).join('; ')}`); this.name = 'NotClosedError'; } } diff --git a/apps/api/src/modules/process/internal/usecase/process-service.ts b/apps/api/src/modules/process/internal/usecase/process-service.ts index 0bbc8d6..9551083 100644 --- a/apps/api/src/modules/process/internal/usecase/process-service.ts +++ b/apps/api/src/modules/process/internal/usecase/process-service.ts @@ -287,18 +287,14 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService { * 而「旧任务走完旧版本」正是要防这个。恒新增之后,旧版本是不可变的历史, * 在途任务读得到的永远是它启动那天的那一份。 */ + /** + * 保存**不检查**:画图必然经过不完整的中间态(节点没接完边、活动还没安装), + * 拿闭合校验拦保存,画到一半就存不了(实测 412 卡死编辑)。 + * 存下来的是**未生效**的新版本 —— 完整性闸在激活那一步。 + */ async savePackage(pkg) { - const report = await this.validate(pkg); - if (!report.closed) throw new NotClosedError(report); - const saved: ProcessPackage = { ...pkg, version: await nextVersion(pkg.id), state: 'active' }; + const saved: ProcessPackage = { ...pkg, version: await nextVersion(pkg.id), state: 'retired' }; 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; }, @@ -307,6 +303,9 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService { const target = all.find((p) => p.id === id && p.version === version); if (target === undefined) throw new Error(`流程包版本不存在:${id}@${version}`); if (state === 'active') { + // **激活才查完整性**:不闭合不得激活 —— 画图不拦,上线拦 + const report = await this.validate(target); + if (!report.closed) throw new NotClosedError(report); // 激活即独占:同流其余生效版一并停用 —— 「一次只能激活一个版本」 for (const sib of all) { if (sib.id === id && sib.version !== version && sib.state === 'active') { diff --git a/apps/web/src/app/styles/console.css b/apps/web/src/app/styles/console.css index 3a31b3f..ee15611 100644 --- a/apps/web/src/app/styles/console.css +++ b/apps/web/src/app/styles/console.css @@ -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:hover { border-color: #c9d6e6; } .g-ver-act.t-go { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 35%, var(--cline)); } -/* 属性区整行铺开:g-side 的窄栏约束(单列 grid、sticky)在这里都不要 */ -.g-wide { position: static; margin-top: 12px; } -.g-wide .g-props { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); } +/* 异常看板:与版本卡同风(图纸式),行是红左线的方框;点一条定位到节点属性 */ +.g-exboard .g-vercard-h { height: auto; padding: 13px 18px 10px; } +.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; } diff --git a/apps/web/src/features/gov-process/internal/panel.tsx b/apps/web/src/features/gov-process/internal/panel.tsx index b31c613..e988da3 100644 --- a/apps/web/src/features/gov-process/internal/panel.tsx +++ b/apps/web/src/features/gov-process/internal/panel.tsx @@ -273,7 +273,9 @@ export function PkgPage({ id, nav }: { id: string; nav: Nav }) { const versions = (q.data?.packages ?? []) .filter((p) => p.id === flowId) .sort((a, b) => b.version - a.version); - /* 选中哪一版看图:默认跟着生效版走;手动点过就钉住(保存成新版后解钉,跟回新生效版) */ + /* 全部版本的闭合报告 —— 版本行的「激活」按钮据它灰显(不闭合点不了) */ + const reports = useGovPkgReports(versions); + /* 选中哪一版看图:默认跟着生效版走;手动点过就钉住;保存后钉到**刚存的新版**(它未生效) */ const [pinned, setPinned] = useState(null); const active = versions.find((v) => v.state === 'active'); const shown = (pinned === null ? undefined : versions.find((v) => v.version === pinned)) ?? active ?? versions[0]; @@ -295,8 +297,9 @@ export function PkgPage({ id, nav }: { id: string; nav: Nav }) { activities={acts.data?.activities ?? []} positions={(staffQ.data?.positions ?? []).map((p) => ({ id: p.id, name: p.name, abbr: p.code }))} nextVersion={Math.max(...versions.map((x) => x.version)) + 1} + closedOf={(v) => reports.data?.get(`${flowId}@${v}`)?.closed} onPick={(v) => setPinned(v)} - onSaved={() => setPinned(null)} + onSaved={(v) => setPinned(v)} /> ); } @@ -319,6 +322,7 @@ function FlowLab({ activities, positions, nextVersion, + closedOf, onPick, onSaved, }: { @@ -329,13 +333,16 @@ function FlowLab({ activities: readonly ActivityRow[]; positions: SpPosition[]; nextVersion: number; + /** 该版本闭合与否(undefined = 报告还没回来)—— 激活按钮据此灰显 */ + closedOf: (version: number) => boolean | undefined; onPick: (version: number) => void; - onSaved: () => void; + onSaved: (version: number) => void; }) { const queryClient = useQueryClient(); - /** 只有**生效版**可编辑(改则存成下一版);其余版本只读 —— 在途任务还按它们跑 */ - const live = pkg.version === activeVersion; - const readOnly = !live; + /* + * 任何版本都可编辑:保存恒存为**未生效**的新版本,谁也伤不着 —— + * 在旧版上改就是「以旧版为底另起一版」。生效切换只在激活那一下。 + */ const paradigms = useMemo(() => new Map(activities.map((a) => [a.activityId, a.paradigm])), [activities]); /** 画布工作副本:属性卡与手绘编辑都改它;保存时合并回流程包 */ @@ -364,7 +371,6 @@ function FlowLab({ }); const activeReport = report ?? liveReport.data; const ec = edgeCheckOf(pkg, activeReport); - const okEdge = activeReport?.closed === true; const patch = (f: (n: FlowNode) => FlowNode) => { if (sel === null) return; @@ -407,6 +413,7 @@ function FlowLab({ if (sel !== null) root.querySelector(`[data-element-id="${sel}"]`)?.classList.add('flow-node-sel'); }, [sel, graph]); + const [saveErr, setSaveErr] = useState(undefined); const save = useMutation({ mutationFn: (input: { graph: FlowGraph; idMap: Record }) => call<{ pkg: ProcessPackage; report: ClosureReport }>(saveProcessRoute, { @@ -415,11 +422,13 @@ function FlowLab({ onSuccess: (out) => { setReport(out.report); setDirty(false); + setSaveErr(undefined); void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] }); 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-reports'] }); setAsk(undefined); - onSaved(); + onSaved(out.pkg.version); }, }); @@ -455,24 +464,23 @@ function FlowLab({ setAsk({ spec: { title: `新建版本:以 v${pkg.version} 为底 → v${nextVersion}`, - note: - dirty && !readOnly ? ( - - 画布上有未保存的改动,新建版本不会带上它们(克隆的是已存的 v{pkg.version})。 - 想连改动一起走,用下面的「保存并生效」。 - - ) : ( - - 把 v{pkg.version} 的定义原样复制成 v{nextVersion} 并即刻生效 - {readOnly ? ' —— 这就是「把旧版本的画法复活成最新版」的路' : ',再在新版上接着画'}。 - - ), + note: dirty ? ( + + 画布上有未保存的改动,新建版本不会带上它们(克隆的是已存的 v{pkg.version})。 + 想连改动一起走,直接点「保存为 v{nextVersion}」。 + + ) : ( + + 把 v{pkg.version} 的定义原样复制成 v{nextVersion}。不生效 —— + 画完、异常清零后再激活。 + + ), impact: { - who: `此后新起的工作流走 v${nextVersion}`, - undo: '版本并存,可把生效切回任一版', - when: '确认即建并生效', + who: '只多一版,不动任何在跑的东西', + undo: '版本并存,不要就停着别激活', + when: '确认即建', }, - okLabel: `建 v${nextVersion} 并生效`, + okLabel: `建 v${nextVersion}`, onOk: () => cloneVersion.mutate(), }, }); @@ -611,23 +619,11 @@ function FlowLab({ {ask !== undefined && setAsk(undefined)} />} - {activeReport !== undefined && !activeReport.closed && ( - - 未闭合: - {activeReport.violations.map((v) => v.message).join(';')} - - )} - {report !== undefined && report.closed && 闭合校验通过。} - {/* 编排工作区:左 1/3 版本列表 + 右 2/3 画布 —— 版本收在流程内部,不平铺台面 */}
- - {/* 图下:节点属性卡 —— 点画布或点节点条,属性就地填 */} -
+ {/* + * 左下:异常看板 —— 与版本卡同列同风(图纸式)。 + * 画图不检查,这里**替**你盯着:哪个节点缺什么,点一条定位到属性去补; + * 清零了激活按钮才亮 —— 完整性闸在激活,不在画图。 + */} + + + {/* 右下:节点属性卡 —— 与流程图同宽;闭环(岗位/判据/映射)在这里决定 */} +
{nodes @@ -936,42 +972,27 @@ function FlowLab({ align="end" items={[ { - label: dirty ? `保存并生效(存为 v${nextVersion})` : '编排无改动', + label: save.isPending ? '保存中…' : dirty ? `保存为 v${nextVersion}` : '编排无改动', ico: 'send', primary: dirty, - disabled: !dirty || readOnly, - ask: dirty - ? { - title: `保存并生效:${pkg.name} → v${nextVersion}`, - note: okEdge ? ( - - 边映射全闭合、必填齐、类型无冲突 —— 存下去即刻对新起的工作流生效。 - - ) : ( - - 不闭合存不进来: - {activeReport?.violations.map((v) => v.message).join(';') ?? '闭合校验未跑完'} - - ), - impact: { - who: okEdge - ? `存为 v${nextVersion} 并成为唯一生效版;在途的按启动时版本跑完` - : '被拒存 —— 先补齐边映射', - undo: `v${pkg.version} 原样留着,可把生效切回去`, - when: okEdge ? '确认即生效' : '存不进去', - }, - okLabel: okEdge ? '确认保存并生效' : '我知道了', - onOk: () => { - if (graph !== null) save.mutate({ graph, idMap: {} }); - }, - } - : undefined, + disabled: !dirty || save.isPending, + // 保存不设闸也不设确认:存下来的是未生效的新版,谁也伤不着;异常看板会说还缺什么 + onClick: () => { + if (graph !== null) save.mutate({ graph, idMap: {} }); + }, }, ]} /> + {saveErr !== undefined && ( + + 保存失败: + {saveErr} + + )} )} +
diff --git a/apps/web/src/shared/gov/flow/pkg-to-graph.ts b/apps/web/src/shared/gov/flow/pkg-to-graph.ts index 434d599..b399ec3 100644 --- a/apps/web/src/shared/gov/flow/pkg-to-graph.ts +++ b/apps/web/src/shared/gov/flow/pkg-to-graph.ts @@ -34,13 +34,32 @@ export function packageToGraph( */ const stored = readStoredDiagram(pkg.diagram); 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)); return state === undefined ? n : { ...n, note: state }; - }), - }; + }); + return { ...stored, nodes: [...withState(stored.nodes), ...withState(extras)] }; } const nodes: FlowNode[] = pkg.nodes.map((n) => {