项目背景
基于Vue3+ElementPlus+Typescript,需要开发一个具备拖拽、编辑等功能的流程设计器,调研了很多框架如gojs、vue-flow、antv/x6,最终选择antv/x6,因为它生态成熟、性能与可扩展性兼备,并能深度匹配 Vue3+TypeScript 技术栈与企业级定制需求。
界面展示
功能介绍
支持【撤销、恢复、编辑、删除、分组、取消分组、保存、导出图片】等常用功能,可扩展【导出JSON】等功能。支持Ctrl+C / Ctrl+V /Ctrl+Z以及画布缩放、画布拖动等功能。
开始
查阅Antv/X6的官方文档,刚好有一个流程图的Demo示例:x6.antv.antgroup.com/examples/sh… ,研究示例代码,准备开干。
顶部工具栏
HTML代码:
<div class="flex-between tool-bar">
<el-space spacer="|" :size="16">
<el-button-group>
<el-button link title="撤销(Ctrl+Z)" @click="undo"><icon-return></icon-return></el-button>
<el-button link title="恢复(Ctrl+Y)" @click="redo"><icon-go-on></icon-go-on></el-button>
</el-button-group>
<el-button-group>
<el-button link title="编辑" @click="editCell"><icon-edit></icon-edit></el-button>
<el-button link title="删除" @click="deleteSelected"><icon-delete></icon-delete></el-button>
</el-button-group>
<el-button-group>
<el-button link title="框选分组" @click="createGroup"><icon-group></icon-group></el-button>
<el-button link title="取消分组" @click="unGroup"><icon-ungroup></icon-ungroup></el-button>
</el-button-group>
<el-button-group>
<el-button link title="保存" @click="saveData"><icon-save-one></icon-save-one></el-button>
<el-button link title="导出图片" @click="exportPNG"><icon-down-picture></icon-down-picture></el-button>
</el-button-group>
</el-space>
<p style="color: var(--el-color-warning)">
<icon-help></icon-help> 按住【Ctrl+鼠标左键】可平移画布,按住【Ctrl+鼠标滚轮】可缩放画布
</p>
</div>
调用方法:
const undo = () => graph.undo();
const redo = () => graph.redo();
const editCell = () => {
const cells = graph.getSelectedCells();
if (cells.length !== 1) return ElMessage.warning("请选择一个元素");
const cell = cells[0];
if (cell.isNode() || cell.isEdge()) {
currentCell.value = cell as Node | Edge;
if (cell.isNode()) {
editLabel.value = currentCell.value.attr("label/text") || "";
edgeLabel.value = "";
} else if (cell.isEdge()) {
edgeLabel.value = (currentCell.value as any).getLabels()[0]?.attrs?.text?.text || "";
editLabel.value = "";
}
editVisible.value = true;
}
};
const deleteSelected = () => {
const cells = graph.getSelectedCells();
if (cells.length) graph.removeCells(cells);
else ElMessage.warning("请选择要删除的元素");
};
const createGroup = () => {
const cells = graph.getSelectedCells();
if (cells.length < 2) {
ElMessage.warning("至少选择2个元素");
return;
}
const bbox = graph.getCellsBBox(cells);
const group = graph.addNode({
x: bbox.x - 10,
y: bbox.y - 15,
width: bbox.width + 20,
height: bbox.height + 40,
attrs: {
body: { fill: "rgb(244, 244, 245)", stroke: "#E8EEF2", rx: 4 },
label: { text: "", fontSize: 14 }
},
zIndex: -1
});
group.setData({ isGroup: true });
cells.forEach(cell => {
if (cell.isNode()) group.addChild(cell);
});
ElMessage.success("分组成功");
};
const unGroup = () => {
const cells = graph.getSelectedCells();
if (cells.length !== 1) {
ElMessage.warning("请选择一个分组容器");
return;
}
const groupNode = cells[0] as Node;
if (!groupNode.getData()?.isGroup) {
ElMessage.warning("请选择分组");
return;
}
const children = graph.getNodes().filter(node => node.getParent() === groupNode);
children.forEach(child => child.setParent(null));
graph.removeCell(groupNode);
ElMessage.success("取消分组成功");
};
const saveData = () => {
localStorage.setItem("topology", JSON.stringify(graph.toJSON()));
ElMessage.success("保存成功");
// TODO:真实业务需配合后台接口进行数据存储
};
const exportPNG = () => {
graph.exportPNG("流程图", {
backgroundColor: "#fff",
padding: 40,
quality: 1,
preserveDimensions: true,
serializeImages: true
});
ElMessage.success("导出成功");
};
左侧模板
// 初始化模板容器
const stencil = new Stencil({
title: "流程图",
target: graph,
stencilGraphWidth: 200,
stencilGraphHeight: 440,
stencilGraphOptions: { panning: false },
collapsable: true,
layoutOptions: { columns: 2, columnWidth: 100, rowHeight: 96 }
});
document.getElementById("stencil")?.appendChild(stencil.container as HTMLElement);
// 创建模板自定义模板
// 定义模板连线端口
const ports = {
groups: {
top: {
position: "top",
attrs: {
circle: { r: 4, magnet: true, stroke: "#5F95FF", strokeWidth: 1, fill: "#fff", style: { visibility: "hidden" } }
}
},
right: {
position: "right",
attrs: {
circle: { r: 4, magnet: true, stroke: "#5F95FF", strokeWidth: 1, fill: "#fff", style: { visibility: "hidden" } }
}
},
bottom: {
position: "bottom",
attrs: {
circle: { r: 4, magnet: true, stroke: "#5F95FF", strokeWidth: 1, fill: "#fff", style: { visibility: "hidden" } }
}
},
left: {
position: "left",
attrs: {
circle: { r: 4, magnet: true, stroke: "#5F95FF", strokeWidth: 1, fill: "#fff", style: { visibility: "hidden" } }
}
}
},
items: [{ group: "top" }, { group: "right" }, { group: "bottom" }, { group: "left" }]
};
// 注册节点属性信息
Graph.registerNode(
"custom-image",
{
inherit: "circle",
width: 66,
height: 66,
markup: [{ tagName: "circle", selector: "body" }, { tagName: "image" }, { tagName: "text", selector: "label" }],
attrs: {
body: { stroke: "#fff", fill: "rgb(235, 243, 254)" },
image: { width: 42, height: 42, refX: 11, refY: 11 },
label: { refY: 78, textAnchor: "middle", textVerticalAnchor: "middle", fontSize: 14, fill: "#303133" }
},
ports: { ...ports }
},
true
);
// 定义节点名称及图片
const imageShapes = [
{ label: "单位", image: unitImg },
{ label: "交换机", image: exchangeImg },
{ label: "文件服务器", image: fileServerImg },
{ label: "防火墙", image: firewallImg },
{ label: "服务器", image: groupImg },
{ label: "计算机", image: pcImg },
{ label: "路由器", image: routerImg },
{ label: "互联网资产", image: softWareImg }
];
// 创建节点
const imageNodes = imageShapes.map(item =>
graph.createNode({
shape: "custom-image",
label: item.label,
attrs: {
label: { text: item.label, fontSize: 14 },
image: { "xlink:href": item.image }
}
})
);
// 将节点添加到模板容器中(stencil)
stencil.load(imageNodes);
绑定事件
// 快捷键事件绑定
graph.bindKey(["ctrl+c", "meta+c"], () => graph.copy(graph.getSelectedCells()));
graph.bindKey(["ctrl+v", "meta+v"], () => graph.paste());
graph.bindKey(["ctrl+z", "meta+z"], () => graph.undo());
graph.bindKey(["ctrl+y", "meta+y"], () => graph.redo());
graph.bindKey(["backspace", "delete"], deleteSelected);
// 鼠标移入显示连接端口
const showPorts = (ports: NodeListOf<SVGElement>, show: boolean) => {
for (let i = 0, len = ports.length; i < len; i += 1) {
ports[i].style.visibility = show ? "visible" : "hidden";
}
};
graph.on("node:mouseenter", () => {
const container = document.getElementById("graph-container") as HTMLElement;
const ports = container.querySelectorAll(".x6-port-body") as NodeListOf<SVGElement>;
showPorts(ports, true);
});
graph.on("node:mouseleave", () => {
const container = document.getElementById("graph-container") as HTMLElement;
const ports = container.querySelectorAll(".x6-port-body") as NodeListOf<SVGElement>;
showPorts(ports, false);
});
结尾
关键代码已贴完,仅供参考。不得不感叹Antv/X6真强大!!!用不复杂的代码逻辑就实现了最基本的编辑器功能,如果有时间有精力可以继续完善逻辑。