Files
flow-center-front/src/views/flow/admin/def-editor/components/BpmnCanvas.vue
T

227 lines
7.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
// bpmn-js 画布(方案 §C):拖拽建模器,仅暴露业务 5 类元素;contentJson 为唯一持久化格式,
// 本组件负责画布初始化、按名路由的出口命名、以及 fromBpmn 序列化。
// bpmn.io 水印为协议要求(左下角 "Powered by bpmn.io"),勿删。
import { onMounted, onUnmounted, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import { createDesigner } from "../bpmn/custom";
import { toBpmn, fromBpmn, CanvasJson } from "../bpmn/serialize";
const props = defineProps<{
/** contentJson 字符串;空 = 新建骨架 */
source: string;
}>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const emit = defineEmits<{ dirty: []; nodeConfig: [any, any]; edgeBusy: [] }>();
const wrap = ref<HTMLDivElement | null>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let modeler: any = null;
/** 选中元素(审批节点弹配置抽屉 / 连线弹命名) */
const selected = ref<any>(null);
onMounted(() => {
if (!wrap.value) return;
modeler = createDesigner(wrap.value);
// 选中级联:parent emit 让外层判断(APPROVAL → 打开配置抽屉;连线 → 预留命名按钮)
modeler.on("selection.changed", (_e: unknown) => {
const sel = modeler.get("selection").get();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
selected.value = sel.length === 1 ? sel[0] : null;
emit("nodeConfig", selected.value, null);
});
modeler.on("connectivity-rule-violation", (evt: any) => Message.warning(String(evt?.message || "连线规则不允许")));
init();
});
onUnmounted(() => modeler?.destroy());
async function init() {
try {
if (props.source && props.source.trim().startsWith("{")) {
const def = JSON.parse(props.source) as CanvasJson;
if (def.nodes?.length) {
// 已有定义:先导入最小骨架再程序化布点连线
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await modeler.importXML(minimalXml());
toBpmn(modeler, { ...def, code: def.code || "", name: def.name || "" });
return;
}
}
// 新建:画默认骨架 start → 审批 → end
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await modeler.importXML(minimalXml());
toBpmn(modeler, skeleton());
} catch (e: any) {
Message.error(`流程图载入失败:${e?.message || e}`);
}
}
/** 新建骨架(含默认审批节点,审批人可见在外层点开抽屉配置) */
function skeleton(): CanvasJson {
return {
code: "",
name: "",
start: "start",
nodes: [
{ id: "start", type: "START" },
{ id: "n1", type: "APPROVAL", name: "步骤 1" },
{ id: "end", type: "END" },
],
edges: [
{ from: "start", to: "n1" },
{ from: "n1", to: "end" },
],
settings: { rejectPolicy: "END_REJECTED" },
};
}
/** 最小合法 BPMN XML(空 process):程序化布点用 */
function minimalXml(): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
targetNamespace="http://bpmn.io/schema/bpmn">
<bpmn:process id="lc_process" isExecutable="true" />
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="lc_process" />
</bpmndi:BPMNDiagram>
</bpmn:definitions>`;
}
watch(
() => props.source,
() => {
if (modeler) void init();
}
);
// ==================== 工具栏 ====================
function zoom(delta: number) {
const canvas = modeler.get("canvas");
canvas.zoom(canvas.zoom() + delta);
}
function resetZoom() {
modeler.get("canvas").zoom("fit-viewport", "auto");
}
function undo() {
modeler.get("commandStack").undo();
}
function redo() {
modeler.get("commandStack").redo();
}
/** 选中连线命名(出口按钮文案):弹 Vue 端 prompt(用 window.prompt 保实现简单) */
function renameEdge() {
const sel = modeler.get("selection").get();
if (sel.length !== 1 || sel[0].type !== "bpmn:SequenceFlow") {
Message.warning("请先点击选中一条连线");
return;
}
const conn = sel[0];
const cur = conn.businessObject?.name || "";
const name = window.prompt("出口名称(即办理按钮文案;单出口可留空,多出口必须命名)", cur);
if (name === null) return;
modeler.get("modeling").updateProperties(conn, { name: name.trim() });
}
function exportBpmn() {
modeler.saveXML({ format: true }).then(({ xml }: { xml: string }) => {
const blob = new Blob([xml], { type: "application/xml" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "flow.bpmn";
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 3000);
Message.success("已导出 BPMN 文件(不参与保存,仅备档)");
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
defineExpose({
/** 序列化当前画布(外层合并 code/name/settings 与审批配置) */
serialize: (base: CanvasJson | null, code: string, name: string): CanvasJson => {
return fromBpmn(modeler, base || ({} as CanvasJson), code, name);
},
/** 按 contentJson 节点 id 删除元素(卡片抽屉删除回调) */
removeNode: (nodeId: string) => {
const el = modeler.get("elementRegistry").get(nodeId);
if (el) modeler.get("modeling").removeElements([el]);
},
/** 更新节点名(配置抽屉确定后回写画布) */
renameNode: (nodeId: string, name: string) => {
const el = modeler.get("elementRegistry").get(nodeId);
if (el) modeler.get("modeling").updateProperties(el, { name });
},
commandStack: () => modeler?.get("commandStack"),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
elementRegistry: () => (modeler ? modeler.get("elementRegistry") : null),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
markClean: () => modeler?.get("commandStack").clear(),
selected: () => modeler?.get("selection").get(),
});
</script>
<template>
<div class="bpmn-wrap">
<div class="bpmn-toolbar">
<Button size="small" @click="undo">撤销</Button>
<Button size="small" @click="redo">重做</Button>
<span class="sep" />
<Button size="small" @click="zoom(-0.1)">−</Button>
<Button size="small" @click="resetZoom">复位</Button>
<Button size="small" @click="zoom(0.1)">+</Button>
<span class="sep" />
<Button size="small" @click="renameEdge">出口命名</Button>
<Button size="small" @click="exportBpmn">导出 BPMN</Button>
<span class="tip">左侧工具栏拖入节点 · 选中审批节点配置 · 选中连线命名出口(多出口必填)</span>
</div>
<div ref="wrap" class="bpmn-container" />
</div>
</template>
<style scoped>
.bpmn-wrap {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: #fafafa;
}
.bpmn-toolbar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: #fff;
border-bottom: 1px solid #e8eaec;
z-index: 5;
}
.sep {
width: 1px;
height: 20px;
background: #e8eaec;
margin: 0 4px;
}
.tip {
color: #a5abb8;
font-size: 12px;
margin-left: auto;
}
.bpmn-container {
flex: 1;
min-height: 380px;
}
/* bpmn.io 水印按协议保留 */
.bpmn-container :deep(.bjs-powered-by) {
display: block;
}
</style>