feat: 流程编辑器 bpmn-js 画布模式 + 引擎多出口/退回/移交/或签前端接线
build / build (push) Successful in 4m6s

- def-editor 新增画布模式(默认):bpmn-js 内存画布(精简 palette/汉化/撤销重做/缩放/出口命名/导出 BPMN),
  toBpmn/fromBpmn 双向序列化,contentJson 仍为持久格式(+layout 坐标);卡片模式保留回退
- 新增 utils/flowLayout.ts 分层布局(设计器与查看器共用)
- TaskHandle:多出口节点出口选择下拉;退回/移交按钮与弹窗;轨迹显示已退回/已移交及所选出口
- FlowDiagram:优先使用保存坐标;边画出口名标签;办件状态着色/当前节点高亮
- api/flow:approve 带 edgeName,新增 returnBack/transfer
This commit is contained in:
chenxing
2026-09-16 22:22:26 +08:00
parent 842a4cfb14
commit e52c4324f6
11 changed files with 1177 additions and 80 deletions
+170 -5
View File
@@ -31,6 +31,7 @@ import NodeList from "./components/NodeList.vue";
import ApprovalDrawer from "./components/ApprovalDrawer.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();
@@ -53,6 +54,64 @@ const depts = reactive<DeptOption[]>([]);
/** userId → 姓名快照(mapLookupUser 检索补全,失败降级 #id) */
const userNames = reactive<Record<number, string>>({});
// ==================== 画布模式(默认):bpmn-js 建模任意 DAG ====================
/** 编辑形态:canvas=bpmn-js 画布(支持多出口/跨段连线),cards=卡片流(保留回退) */
const editMode = ref<"canvas" | "cards">("canvas");
const bpmnWrap = ref<InstanceType<typeof BpmnCanvas> | null>(null);
/** 画布数据基准(载入时的 contentJson,供配置按 id 继承) */
const canvasBase = ref<any>(null);
/** 画布上审批节点的配置快照(element id → ApprovalNode 配置),点卡片写入,保存时并入 nodes */
const canvasConfigs = reactive<Record<string, any>>({});
/** 原始 contentJson 快照(画布脏检查基准) */
const sourceJson = ref("");
function onCanvasNode(_el: any): void {
// selection.changed 自画布 emit:选中审批节点 → 打开配置抽屉
if (!_el || _el.type !== "bpmn:UserTask") return;
const id = String(_el.businessObject.id || _el.id);
const baseNode = (canvasBase.value?.nodes || []).find((n: any) => n.id === id) || {};
if (!canvasConfigs[id]) {
canvasConfigs[id] = {
uid: id,
kind: "approval",
name: _el.businessObject.name || baseNode.name || "审批",
assignee: baseNode.assignee || null,
countersign: baseNode.countersign || "ALL",
deadlineHours: baseNode.deadlineHours || null,
};
}
editingUid.value = id;
nodeDrawer.value = true;
}
function canvasRemoveNode(uid: string): void {
bpmnWrap.value?.removeNode(uid);
delete canvasConfigs[uid];
}
/** 画布模式序列化:画布 → contentJson(叠加 code/name/settings 与审批配置) */
function serializeCanvas(): string {
const canvasJson = bpmnWrap.value?.serialize(
canvasBase.value,
model.code.trim(),
model.name.trim()
);
if (!canvasJson) return "";
// 配置并入:canvasConfigs 以 element id 为键覆盖审批节点业务属性
canvasJson.nodes.forEach((n: any) => {
const cfg = canvasConfigs[String(n.id)];
if (cfg && n.type === "APPROVAL") {
n.name = cfg.name || n.name;
if (cfg.assignee) n.assignee = cfg.assignee;
if (cfg.countersign && cfg.countersign !== "ALL") n.countersign = cfg.countersign;
if (cfg.deadlineHours) n.deadlineHours = cfg.deadlineHours;
}
});
canvasJson.settings = { rejectPolicy: "END_REJECTED" };
void model.settings; // 设置抽屉暂绑定卡模型(画布模式直接采用卡模型设置)
return JSON.stringify(canvasJson);
}
const issues = computed<Issue[]>(() => validateModel(model, depts));
const defJson = computed(() => toContentJson(model));
@@ -67,6 +126,9 @@ const jsonDrawer = ref(false);
const editingUid = ref("");
const editingNode = computed<ApprovalNode | null>(() => {
if (editMode.value === "canvas") {
return canvasConfigs[editingUid.value] || null;
}
const item = editingUid.value ? findItem(model, editingUid.value) : null;
return item && item.kind === "approval" ? item : null;
});
@@ -137,10 +199,20 @@ async function load(): Promise<void> {
sourceVersion.value = d?.version ?? 0;
model.code = d?.code || "";
model.name = d?.name || "";
sourceJson.value = d?.contentJson || "{}";
try {
canvasBase.value = fromContentJson(JSON.parse(d?.contentJson || "{}")) as any;
} catch {
// 解析失败仍保留原始 JSON 供画布直接读取(fromContentJson 卡片树可能不支持)
canvasBase.value = JSON.parse(d?.contentJson || "{}");
}
try {
applyParsed(fromContentJson(JSON.parse(d?.contentJson || "{}")));
} catch (e: any) {
Message.error(`既有定义无法还原为卡片视图:${e?.message || e}。请改用源码模式编辑。`);
// 画布模式可编辑管线不支持的结构(多出口等),不再强制卡片树还原
if (editMode.value === "cards") {
Message.error(`既有定义无法还原为卡片视图:${e?.message || e}。请切换画布模式编辑。`);
}
}
await resolveUserNames();
savedSnapshot.value = currentSnapshot.value;
@@ -171,6 +243,10 @@ function openNode(uid: string): void {
}
function removeNode(uid: string): void {
if (editMode.value === "canvas") {
canvasRemoveNode(uid);
return;
}
const siblings = containerOfItem(uid);
if (siblings && siblings.length <= 1) {
Message.warning("每个流程/分支至少保留一个节点");
@@ -236,10 +312,25 @@ async function onSave(): Promise<void> {
return;
}
const errors = countErrors(issues.value);
if (errors.length) {
if (errors.length && editMode.value === "cards") {
Message.error(`校验未通过:${errors[0].text}`);
return;
}
let content: string;
if (editMode.value === "canvas") {
content = serializeCanvas();
const problems = checkCanvas(content);
if (problems.length) {
Message.error(problems[0]);
return;
}
if (!content || content === "{}") {
Message.warning("画布为空,无法保存");
return;
}
} else {
content = JSON.stringify(toContentJson(model));
}
saving.value = true;
try {
// code/name 同时出现在 body 与 contentJson 且取值同源,规避 contentJson 静默覆盖行字段的既有坑
@@ -247,7 +338,7 @@ async function onSave(): Promise<void> {
id: editId.value || undefined,
code: model.code.trim(),
name: model.name.trim(),
contentJson: JSON.stringify(toContentJson(model)),
contentJson: content,
baseGuid: baseGuid.value || undefined,
}).send();
savedSnapshot.value = currentSnapshot.value;
@@ -258,6 +349,72 @@ 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) + "」缺少审批人配置");
}
});
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;
const href = router.resolve({ path: "/diagram", query: { defId: String(editId.value), mode: "auto" } }).href;
@@ -332,6 +489,10 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
</label>
</div>
<div class="hd-right">
<RadioGroup v-model="editMode" size="small" type="button">
<Radio label="canvas">画布</Radio>
<Radio label="cards">卡片</Radio>
</RadioGroup>
<Button @click="settingsDrawer = true">流程设置</Button>
<Button @click="jsonDrawer = true">源码</Button>
<Button :disabled="!editId" @click="onOpenDiagram">图示预览</Button>
@@ -339,7 +500,11 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
</div>
</header>
<main v-if="!loading" class="ed-canvas">
<!-- 画布模式:bpmn-js 建模(多出口/并联/退回环路均可画) -->
<BpmnCanvas v-if="!loading && editMode === 'canvas'" ref="bpmnWrap"
:source="sourceJson" @node-config="onCanvasNode" />
<main v-if="!loading && editMode === 'cards'" class="ed-canvas">
<div class="ed-hint">点击卡片配置节点 · 悬停虚线处 + 插入节点(审批 / 并行分支)</div>
<div class="ed-flow">
<div class="capsule start">发起开始</div>
@@ -347,7 +512,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
<div class="capsule end">流程结束</div>
</div>
</main>
<main v-else class="ed-canvas loading">
<main v-else-if="loading" class="ed-canvas loading">
<Spin size="large" fix />
</main>