refactor(flow-editor)!: 删除画布模式,卡片树成为唯一编辑形态

This commit is contained in:
chenxing
2026-10-03 14:13:37 +08:00
parent 656c0d1bf5
commit ffa196954e
+16 -417
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
// 流程编辑器(方案 §3):钉钉式卡片流——纵向节点卡片 + 节点间"+"插入 + 点卡片右侧抽屉配置,免连线。
// 流程编辑器(飞书流派,2026-10-03 画布退役):卡片树是唯一编辑形态与数据源;
// 多出口=审批节点出口按钮+泳道(OutletLanes),结构域封死 → 出图确定性布局。
//
// 入口三态(query):无参=新建;?id={defId}=编辑草稿;?id={defId}&baseGuid={flowGuid}=升版本(载入该版本内容为底稿)。
// 保存只产草稿,走既有 POST /api/flow/admin/def/save;发布仍留在流程定义列表页(后端 DefValidator 终审)。
@@ -39,7 +40,6 @@ import NotifyDrawer from "./components/NotifyDrawer.vue";
import ConditionDrawer from "./components/ConditionDrawer.vue";
import SettingsDrawer from "./components/SettingsDrawer.vue";
import JsonDrawer from "./components/JsonDrawer.vue";
import BpmnCanvas from "./components/BpmnCanvas.vue";
const route = useRoute();
const router = useRouter();
@@ -64,174 +64,9 @@ const userNames = reactive<Record<number, string>>({});
/** 条件分支的判断服务注册(flw_router;拉取失败为空数组,校验降级提示) */
const routers = reactive<RouterOption[]>([]);
// ==================== 双模式统一(2026-09-17):卡片树 = 唯一数据源,画布 = 其可视化投影 ====================
/**
* 旧实现画布/卡片各持一份基准(sourceJson vs model),切换后互相读不到对方改动,"切回去数据变初始化"。
* 现统一为:EditorModel 永远持有业务配置(审批人/会签/时限)与卡片结构;
* - 进画布:canvasSource = toContentJson(model),节点 id 直接用卡片 uid(convert.ts),天然一一对应;
* - 出画布(切卡片/保存/源码应用后):serializeUnified 以 model 为 base 让 bpmn 画布结构性改动
* (新增节点/改连线/布局)叠加其上,再经 fromContentJson 反解回卡片树;卡片树不支持的结构(多出口
* DAG 等)拒绝切卡片,留在画布编辑。
*/
/** 编辑形态:canvas=bpmn-js 画布(支持多出口/跨段连线),cards=卡片流 */
const editMode = ref<"canvas" | "cards">("cards");
/** 画布侧数据是否可反解为卡片树(载入时探测,决定卡片单选是否可用) */
const cardsBroken = ref(false);
const bpmnWrap = ref<InstanceType<typeof BpmnCanvas> | null>(null);
/** 画布数据源:载入用原始 contentJson;由卡片切回画布时用卡片树投影覆盖 */
const canvasSource = ref("");
/** 载入时的原始 contentJson(画布上"卡片树不存在"的新画节点配置兜底) */
const canvasBase = ref<any>(null);
/** 画布上新画节点的配置快照(element id → ApprovalNode 配置),保存时并入 nodes */
const canvasConfigs = reactive<Record<string, any>>({});
/** 原始 contentJson 快照(画布裁剪兜底与 JsonDrawer 应用基准) */
const sourceJson = ref("");
function onCanvasNode(_el: any): void {
// selection.changed 自画布 emit:选中审批/抄送/条件分支节点 → 打开对应配置抽屉
const isApproval = _el && _el.type === "bpmn:UserTask";
const isNotify = _el && _el.type === "bpmn:ServiceTask";
const isCondition = _el && _el.type === "bpmn:ExclusiveGateway";
if (!isApproval && !isNotify && !isCondition) return;
const id = String(_el.businessObject.id || _el.id);
// 条件分支的画布元素 id 是 JSON id(uid+"c" 后缀),卡片树按 uid 查(uid 由本编辑器生成,不带 c 尾)
const lookupId = isCondition && id.endsWith("c") ? id.slice(0, -1) : id;
const item = findItem(model, lookupId);
if (item && (item.kind === "approval" || item.kind === "notify" || item.kind === "condition")) {
// 卡片树已有节点:抽屉直接编辑卡片本体(id=uid 天然对应)
openNode(item.uid);
return;
}
// 画布上新画的节点(卡片树里没有):配置进 canvasConfigs,保存时并入
const kind = isNotify ? "notify" : isCondition ? "condition" : "approval";
const baseNode = (canvasBase.value?.nodes || []).find((n: any) => n.id === id) || {};
if (!canvasConfigs[id]) {
canvasConfigs[id] =
kind === "notify"
? {
uid: id,
kind: "notify",
name: _el.businessObject.name || baseNode.name || "抄送",
receivers: baseNode.receivers || { type: "USERS", userIds: [] },
}
: kind === "condition"
? {
uid: id,
kind: "condition",
name: _el.businessObject.name || baseNode.name || "条件判断",
routerKey: baseNode.routerKey || "",
outlets: [],
branches: [],
}
: {
uid: id,
kind: "approval",
name: _el.businessObject.name || baseNode.name || "审批",
assignee: baseNode.assignee || null,
countersign: baseNode.countersign || "ANY",
deadlineHours: baseNode.deadlineHours || null,
};
}
editingUid.value = id;
if (canvasConfigs[id].kind === "notify") {
notifyDrawer.value = true;
} else if (canvasConfigs[id].kind === "condition") {
conditionDrawer.value = true;
} else {
nodeDrawer.value = true;
}
}
function canvasRemoveNode(uid: string): void {
bpmnWrap.value?.removeNode(uid);
delete canvasConfigs[uid];
}
/** 画布序列化 → contentJson 字符串:结构性内容(节点/连线/布局)出自画布,
* 业务配置(审批人/会签/时限/抄送对象)以卡片树为 base 按 id 继承,settings(含催办)随 base 带出 */
function serializeUnified(): string {
const canvasJson = snapshotCanvasObject();
// canvasConfigs(画布新画节点的抽屉配置)按 id 并入
(canvasJson.nodes || []).forEach((n: any) => {
const cfg = canvasConfigs[String(n.id)];
if (!cfg) return;
if (cfg.kind === "notify" && n.type === "NOTIFY") {
n.name = cfg.name || n.name;
if (cfg.receivers) n.receivers = cfg.receivers;
} else if (cfg.kind === "approval" && n.type === "APPROVAL") {
n.name = cfg.name || n.name;
if (cfg.assignee) n.assignee = cfg.assignee;
if (cfg.countersign) n.countersign = cfg.countersign;
if (cfg.deadlineHours) n.deadlineHours = cfg.deadlineHours;
} else if (cfg.kind === "condition" && n.type === "CONDITION") {
n.name = cfg.name || n.name;
if (cfg.routerKey) n.routerKey = cfg.routerKey;
}
});
return JSON.stringify(canvasJson);
}
const issues = computed<Issue[]>(() => validateModel(model, depts, routers));
const defJson = computed(() => toContentJson(model));
/** 序列化 base:原始 contentJson 为底(保住卡片树不支持结构/新画节点的既有业务配置),
* code/name/settings 与卡片树审批/抄送节点配置(审批人/会签/时限/抄送对象)以卡片树为准覆盖 */
function buildSerializeBase(): any {
const modelDef = toContentJson(model);
const base = JSON.parse(JSON.stringify(canvasBase.value || {}));
base.code = model.code.trim();
base.name = model.name.trim();
base.settings = JSON.parse(JSON.stringify(modelDef.settings));
const byId: Record<string, any> = {};
(base.nodes || []).forEach((n: any) => {
byId[String(n.id)] = n;
});
// 全层级遍历(含并行分支内节点):只覆盖 id 能对上的,画布独有节点不受影响
walkItems(model.list, (item) => {
if (item.kind === "approval") {
const merged: any = { ...(byId[item.uid] || {}) };
merged.id = item.uid;
merged.type = "APPROVAL";
merged.name = item.name || "审批";
if (item.assignee) merged.assignee = JSON.parse(JSON.stringify(item.assignee));
if (item.countersign === "ANY") merged.countersign = "ANY";
else delete merged.countersign; // 后端缺省即会签,不回写
if (item.deadlineHours) merged.deadlineHours = item.deadlineHours;
byId[item.uid] = merged;
} else if (item.kind === "notify") {
const merged: any = { ...(byId[item.uid] || {}) };
merged.id = item.uid;
merged.type = "NOTIFY";
merged.name = item.name || "抄送";
if (item.receivers) merged.receivers = JSON.parse(JSON.stringify(item.receivers));
byId[item.uid] = merged;
} else if (item.kind === "condition") {
// 条件分支的 JSON id 带 c 后缀(convert.ts 生成约定),routerKey/名称随卡片树覆盖
const jsonId = `${item.uid}c`;
const merged: any = { ...(byId[jsonId] || {}) };
merged.id = jsonId;
merged.type = "CONDITION";
merged.name = item.name || "条件判断";
merged.routerKey = item.routerKey || "";
byId[jsonId] = merged;
}
});
base.nodes = Object.values(byId);
return base;
}
/** 画布未挂载(载入中/刚切换未挂好)时,快照退回卡片树投影 */
function snapshotCanvasObject(): any {
if (!bpmnWrap.value) return JSON.parse(JSON.stringify(defJson.value));
const canvasJson = bpmnWrap.value?.serialize(
buildSerializeBase(),
model.code.trim(),
model.name.trim()
);
if (!canvasJson) throw new Error("画布不可用");
return canvasJson;
}
/** 归一化(去 layout 坐标、节点按 id 排序):卡片树投影与画布序列化表示不同但内容等价,脏比较必须同表示 */
function canonicalJson(content: any): string {
const copy: any = JSON.parse(JSON.stringify(content));
@@ -250,13 +85,9 @@ function canonicalJson(content: any): string {
return JSON.stringify(copy);
}
/** 脏检查基准(载入/保存后刷新);随编辑形态取同一内容的对应投影,切换模式后不因表示差异误报 */
/** 脏检查基准(载入/保存后刷新) */
const savedSnapshot = ref("");
const currentSnapshot = computed(() =>
editMode.value === "cards"
? canonicalJson(JSON.parse(JSON.stringify(defJson.value)))
: canonicalJson(snapshotCanvasObject())
);
const currentSnapshot = computed(() => canonicalJson(JSON.parse(JSON.stringify(defJson.value))));
const isDirty = computed(() => !loading.value && currentSnapshot.value !== savedSnapshot.value);
const nodeDrawer = ref(false);
@@ -267,34 +98,16 @@ const jsonDrawer = ref(false);
const editingUid = ref("");
const editingNode = computed<ApprovalNode | null>(() => {
if (editMode.value === "canvas") {
const item = editingUid.value ? findItem(model, editingUid.value) : null;
if (item && item.kind === "approval") return item;
const cfg = canvasConfigs[editingUid.value];
return cfg && cfg.kind === "approval" ? cfg : null;
}
const item = editingUid.value ? findItem(model, editingUid.value) : null;
return item && item.kind === "approval" ? item : null;
});
const editingNotify = computed<NotifyNode | null>(() => {
if (editMode.value === "canvas") {
const item = editingUid.value ? findItem(model, editingUid.value) : null;
if (item && item.kind === "notify") return item;
const cfg = canvasConfigs[editingUid.value];
return cfg && cfg.kind === "notify" ? cfg : null;
}
const item = editingUid.value ? findItem(model, editingUid.value) : null;
return item && item.kind === "notify" ? item : null;
});
const editingCondition = computed<ConditionNode | null>(() => {
if (editMode.value === "canvas") {
const item = editingUid.value ? findItem(model, editingUid.value) : null;
if (item && item.kind === "condition") return item;
const cfg = canvasConfigs[editingUid.value];
return cfg && cfg.kind === "condition" ? cfg : null;
}
const item = editingUid.value ? findItem(model, editingUid.value) : null;
return item && item.kind === "condition" ? item : null;
});
@@ -367,8 +180,7 @@ async function load(): Promise<void> {
if (queryId && !queryGuid) editId.value = queryId;
if (!queryId) {
// 新建:画布源取卡片树投影(默认模板的审批节点 uid 与卡片一一对应),不用通用骨架
canvasSource.value = JSON.stringify(defJson.value);
// 新建:卡片树默认模板起步
savedSnapshot.value = currentSnapshot.value;
return;
}
@@ -378,18 +190,17 @@ async function load(): Promise<void> {
sourceVersion.value = d?.version ?? 0;
model.code = d?.code || "";
model.name = d?.name || "";
sourceJson.value = d?.contentJson || "{}";
canvasBase.value = safeJson(sourceJson.value);
canvasSource.value = sourceJson.value;
let parsedOk = true;
try {
applyParsed(fromContentJson(JSON.parse(d?.contentJson || "{}")));
} catch (e: any) {
// 卡片树反解失败(多出口等结构):留在画布模式,卡片单选禁用
parsedOk = false;
void e;
Modal.error({
title: "该定义无法用卡片编辑器打开",
content: `内容包含卡片视图不支持的结构(${e?.message || e})。此类结构多为历史画布草稿,请从流程定义列表处理。`,
onOk: () => router.replace("/admin/defs"),
});
loading.value = false;
return;
}
cardsBroken.value = !parsedOk;
await resolveUserNames();
savedSnapshot.value = currentSnapshot.value;
} finally {
@@ -397,66 +208,6 @@ async function load(): Promise<void> {
}
}
function safeJson(s: string): any {
try {
return JSON.parse(s || "{}");
} catch {
return {};
}
}
// ==================== 模式切换(双向互通) ====================
/** 卡片 → 画布:画布源换成卡片树投影(画布重挂载即拿到卡片最新结构/配置) */
function toCanvas(): void {
canvasSource.value = JSON.stringify(defJson.value);
editMode.value = "canvas";
bumpSnapshot();
}
/** 画布 → 卡片:画布结构经 fromContentJson 反解回卡片树;不支持的结构拒绝切换 */
function toCards(): void {
let content = "";
try {
content = serializeUnified();
} catch (e: any) {
Message.error(`画布数据读取失败:${e?.message || e}`);
return;
}
if (!content) {
Message.warning("画布为空,无法切换到卡片视图");
return;
}
try {
applyParsed(fromContentJson(JSON.parse(content)));
} catch (e: any) {
Message.error(`画布含卡片视图不支持的结构(${e?.message || e}),请继续用画布编辑或先简化结构`);
return;
}
Object.keys(canvasConfigs).forEach((k) => delete canvasConfigs[k]);
editMode.value = "cards";
bumpSnapshot();
}
/** 切换后按"切换前是否已脏"重置脏基准:干净切换 representations 等价,直接对齐;脏切换保留脏态 */
function bumpSnapshot(): void {
savedSnapshot.value = currentSnapshot.value;
}
function onModeSwitch(next: string | number | boolean): void {
if (loading.value) return;
if (next === editMode.value) return;
if (next === "cards" && cardsBroken.value) {
Message.warning("该定义包含卡片视图不支持的结构(如多出口/退回环路),请用画布编辑");
return;
}
if (next === "canvas") {
toCanvas();
} else {
toCards();
}
}
// ==================== 编辑操作(画布子树经 ctx 共享) ====================
/** 定位某节点所在的兄弟列表(删除前的"至少保留一个"判断用) */
@@ -486,12 +237,6 @@ function openNode(uid: string): void {
}
function removeNode(uid: string): void {
if (editMode.value === "canvas") {
// 卡片树是唯一数据源:卡片已有节点从两侧一起删;画布新画节点只删画布+配置快照
if (findItem(model, uid)) removeItem(model, uid);
canvasRemoveNode(uid);
return;
}
const siblings = containerOfItem(uid);
if (siblings && siblings.length <= 1) {
Message.warning("每个流程/分支至少保留一个节点");
@@ -590,30 +335,9 @@ function onUrgeSave(urge: UrgeSetting): void {
model.settings.urge = urge;
}
/** 抽屉关闭(确定/删除)后,画布上的节点名回写(配置改的是卡片树本体) */
watch([nodeDrawer, notifyDrawer, conditionDrawer], ([aOpen, nOpen, cOpen], [aWas, nWas, cWas]) => {
const closed = (open: boolean, was: boolean) => !open && was;
if (!closed(aOpen, aWas) && !closed(nOpen, nWas) && !closed(cOpen, cWas)) return;
if (editMode.value !== "canvas" || !bpmnWrap.value) return;
const item = findItem(model, editingUid.value);
if (item && item.kind === "approval") {
bpmnWrap.value.renameNode(item.uid, item.name);
}
if (item && item.kind === "notify") {
bpmnWrap.value.renameNode(item.uid, item.name);
}
if (item && item.kind === "condition") {
bpmnWrap.value.renameNode(`${item.uid}c`, item.name);
}
});
/** 源码抽屉应用:写回卡片树;画布模式下画布源同步换投影,避免看到旧结构 */
/** 源码抽屉应用:写回卡片树(应用源码后 isDirty 自然为 true,预期行为) */
function onJsonApplied(p: ParsedDef): void {
applyParsed(p);
if (editMode.value === "canvas") {
canvasSource.value = JSON.stringify(defJson.value);
bumpSnapshot();
}
}
async function onSave(): Promise<void> {
@@ -626,36 +350,11 @@ async function onSave(): Promise<void> {
return;
}
const errors = countErrors(issues.value);
if (errors.length && editMode.value === "cards") {
if (errors.length) {
Message.error(`校验未通过:${errors[0].text}`);
return;
}
let content: string;
if (editMode.value === "canvas") {
try {
content = serializeUnified();
} catch (e: any) {
Message.error(`画布数据读取失败:${e?.message || e}`);
return;
}
const problems = checkCanvas(content);
if (problems.length) {
Message.error(problems[0]);
return;
}
if (!content || content === "{}") {
Message.warning("画布为空,无法保存");
return;
}
// 保存内容同步回卡片树(画布=数据源投影,保存后两侧一致,脏基准重算不误报)
try {
applyParsed(fromContentJson(JSON.parse(content)));
} catch {
// 理论不可达(checkCanvas 通过即可反解);失败不阻断保存
}
} else {
content = JSON.stringify(toContentJson(model));
}
const content = JSON.stringify(toContentJson(model));
saving.value = true;
try {
// code/name 同时出现在 body 与 contentJson 且取值同源,规避 contentJson 静默覆盖行字段的既有坑
@@ -674,98 +373,6 @@ async function onSave(): Promise<void> {
}
}
/** 画布产物轻校验(保存前拦截;结构完整校验仍以发布期 DefValidator 为准) */
function checkCanvas(content: string): string[] {
const problems: string[] = [];
let def: any;
try {
def = JSON.parse(content);
} catch {
return ["画布数据序列化失败,请重试"];
}
const nodes = def.nodes || [];
const edges = def.edges || [];
const nodeIds = new Set(nodes.map((n: any) => n.id));
if (nodes.filter((n: any) => n.type === "START").length !== 1) {
problems.push("画布必须有且仅有 1 个开始节点");
}
if (nodes.filter((n: any) => n.type === "END").length !== 1) {
problems.push("画布必须有且仅有 1 个结束节点");
}
nodes.forEach((n: any) => {
if (n.type === "APPROVAL") {
if (!n.name) problems.push("审批节点未命名");
if (!n.assignee) problems.push("审批节点「" + (n.name || n.id) + "」缺少审批人配置");
}
if (n.type === "NOTIFY") {
if (!n.name) problems.push("抄送节点未命名");
if (!n.receivers) problems.push("抄送节点「" + (n.name || n.id) + "」缺少抄送对象配置");
else if (n.receivers.type === "USERS" && !(n.receivers.userIds || []).length) {
problems.push("抄送节点「" + (n.name || n.id) + "」未选择用户");
}
}
if (n.type === "CONDITION") {
// 条件分支(镜像后端 DefValidator 的 CONDITION 口径):routerKey 必填 + 出口命名唯一 + 出边 ≥2
if (!n.routerKey) problems.push("条件分支「" + (n.name || n.id) + "」未配置判断服务(routerKey)");
const outs = edges.filter((e: any) => e.from === n.id);
if (outs.length < 2) {
problems.push("条件分支「" + (n.name || n.id) + "」至少 2 条出边(当前 " + outs.length + ")");
} else {
const names = new Set<string>();
outs.forEach((e: any) => {
const label = e.name?.trim() || "";
if (!label) {
problems.push(`条件分支「${n.name || n.id}」每个出口必须命名(出口名即判断依据)`);
} else if (names.has(label)) {
problems.push(`条件分支「${n.name || n.id}」出口名重复:「${label}」`);
} else {
names.add(label);
}
});
}
}
});
edges.forEach((e: any) => {
if (!nodeIds.has(e.to) || !nodeIds.has(e.from)) {
problems.push("存在指向缺失节点的连线,请放置后重画");
}
});
// 多出口命名(镜像后端 DefValidator.validateOutlets 口径)
const outNames: Record<string, Set<string>> = {};
edges.forEach((e: any) => {
(outNames[e.from] = outNames[e.from] || new Set()).add(e.name || "");
});
nodes.forEach((n: any) => {
const outs = edges.filter((e: any) => e.from === n.id);
if (n.type === "APPROVAL" && outs.length > 1) {
outs.forEach((e: any) => {
if (!e.name?.trim()) problems.push(`多出口节点「${n.name || n.id}」每个出口必须命名(按钮文案)`);
});
outNames[n.id]?.forEach((x) => {
// 重复名展示一次即可
});
}
});
// 可达性(simple BFS from start,无孤儿)
const start = def.start;
if (start) {
const seen = new Set([start]);
const queue = [start];
while (queue.length) {
const cur = queue.shift()!;
edges.forEach((e: any) => {
if (e.from === cur && nodeIds.has(e.to) && !seen.has(e.to)) {
seen.add(e.to);
queue.push(e.to);
}
});
}
nodes.forEach((n: any) => {
if (!seen.has(n.id)) problems.push(`从开始不可达的节点:${n.name || n.id}`);
});
}
return [...new Set(problems)];
}
function onOpenDiagram(): void {
if (!editId.value) return;
@@ -841,10 +448,6 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
</label>
</div>
<div class="hd-right">
<RadioGroup :model-value="editMode" size="small" type="button" @on-change="onModeSwitch">
<Radio label="cards">卡片(推荐)</Radio>
<Radio label="canvas" :disabled="cardsBroken">画布(高级)</Radio>
</RadioGroup>
<Button @click="settingsDrawer = true">流程设置</Button>
<Button @click="jsonDrawer = true">源码</Button>
<Button :disabled="!editId" @click="onOpenDiagram">图示预览</Button>
@@ -852,11 +455,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
</div>
</header>
<!-- 画布模式:bpmn-js 建模(多出口/并联/退回环路均可画) -->
<BpmnCanvas v-if="!loading && editMode === 'canvas'" ref="bpmnWrap"
:source="sourceJson" @node-config="onCanvasNode" />
<main v-if="!loading && editMode === 'cards'" class="ed-canvas">
<main v-if="!loading" class="ed-canvas">
<div class="ed-hint">点击卡片配置节点 · 悬停虚线处 + 插入节点(审批 / 抄送 / 并行分支)</div>
<div class="ed-flow">
<div class="capsule start">发起开始</div>