画图不检查,激活才查完整 —— 保存 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:
2026-07-28 15:52:45 +08:00
co-authored by Claude Opus 5
parent 2a89e9feda
commit ba5f65e685
9 changed files with 183 additions and 134 deletions
@@ -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<number | null>(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<string | undefined>(undefined);
const save = useMutation({
mutationFn: (input: { graph: FlowGraph; idMap: Record<string, string> }) =>
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 ? (
<Note tone="warn">
画布上有<b>未保存的改动</b>,新建版本不会带上它们(克隆的是已存的 v{pkg.version})。
想连改动一起走,用下面的「保存并生效」。
</Note>
) : (
<Note>
把 v{pkg.version} 的定义原样复制成 v{nextVersion} 并即刻生效
{readOnly ? ' —— 这就是「把旧版本的画法复活成最新版」的路' : ',再在新版上接着画'}。
</Note>
),
note: dirty ? (
<Note tone="warn">
画布上有<b>未保存的改动</b>,新建版本不会带上它们(克隆的是已存的 v{pkg.version})。
想连改动一起走,直接点「保存为 v{nextVersion}」。
</Note>
) : (
<Note>
把 v{pkg.version} 的定义原样复制成 v{nextVersion}。<b>不生效</b> ——
画完、异常清零后再激活。
</Note>
),
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 && <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 画布 —— 版本收在流程内部,不平铺台面 */}
<Sec
ico="route"
title="编排工作区"
extra={
readOnly
? `看的是 v${pkg.version}(只读)—— 要改图,回生效版去改`
: `画的是生效版 v${pkg.version} —— 保存即存成 v${nextVersion} 并生效`
}
extra={`画的是 v${pkg.version} —— 保存存为 v${nextVersion}(不生效);画图不检查,激活才查完整`}
>
<div className="g-flowlab">
<aside className="g-vercard">
@@ -672,6 +668,9 @@ function FlowLab({
) : (
<button
className="g-ver-act t-go"
/* 不闭合点不了:异常清零才亮 —— 完整性闸在激活,不在画图 */
disabled={closedOf(v.version) !== true}
title={closedOf(v.version) === true ? `激活 v${v.version}` : '有异常,先看异常看板'}
onClick={(e) => {
e.stopPropagation();
askActivate(v);
@@ -697,21 +696,18 @@ function FlowLab({
oneLiner={`蓝 = 标准活动 · 绿 = 案例活动 · 琥珀 = 审批点 · 菱形 = 网关 · 红 = 卡点`}
graph={graph}
drilledIds={nodes.filter((n) => n.kind === 'activity').map((n) => n.id)}
positions={positions}
canEdit={!readOnly}
/*
* 画布只画**编排逻辑**:节点与网关的名字、连线;边上的字是辅助阅读的注记。
* 图上真正生效可编辑的只有**名字** —— 不传 positions,双击就不出岗位一栏。
* 岗位、判据、映射这些闭环要素在**节点属性**里决定;分配到人是运营的事,这里不碰。
*/
canEdit
onNodeClick={(nid) => setSel(nid)}
onEditNodeText={async (nid, p2) => {
setGraph((g) =>
g === null
? g
: {
...g,
nodes: g.nodes.map((n) =>
n.id === nid
? { ...n, label: p2.label, ...(p2.owner === undefined ? {} : { owner: p2.owner }) }
: n,
),
},
: { ...g, nodes: g.nodes.map((n) => (n.id === nid ? { ...n, label: p2.label } : n)) },
);
setDirty(true);
}}
@@ -723,14 +719,54 @@ function FlowLab({
</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
ico="sliders-horizontal"
title="节点属性"
extra={readOnly ? `v${pkg.version} 只读` : dirty ? '有改动待保存' : '点节点就地填写'}
extra={dirty ? '有改动待保存' : '点节点就地填写 —— 闭环(岗位/判据/映射)在这里决定'}
>
<div className="g-picker">
{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 ? (
<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,
disabled: !dirty || save.isPending,
// 保存不设闸也不设确认:存下来的是未生效的新版,谁也伤不着;异常看板会说还缺什么
onClick: () => {
if (graph !== null) save.mutate({ graph, idMap: {} });
},
},
]}
/>
{saveErr !== undefined && (
<Note tone="warn">
<b>保存失败:</b>
{saveErr}
</Note>
)}
</>
)}
</Card>
</div>
</div>
</Sec>