227 lines
7.8 KiB
Vue
227 lines
7.8 KiB
Vue
<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>
|