【组件库】使用Vue2+AntV X6+ElementUI 实现拖拽配置自定义vue节点
先来看看实现效果:
【组件库】使用 AntV X6 + ElementUI 实现拖拽配置自定义 Vue 节点
在现代前端开发中,流程图和可视化编辑器的需求日益增加。AntV X6 是一个强大的图形化框架,支持丰富的图形操作和自定义功能。结合 ElementUI,我们可以轻松实现一个基于 Vue 的拖拽配置流程图,支持自定义节点和动态字段编辑。
一、技术栈介绍
AntV X6:一个基于 HTML5 Canvas 的图形化框架,支持流程图、拓扑图等多种图形化场景。
- ElementUI:一个基于 Vue 的 UI 组件库,提供丰富的表单、表格和弹窗组件。
- Vue.js:一个渐进式 JavaScript 框架,用于构建用户界面。
二、项目需求
我们的目标是实现一个流程图编辑器,支持以下功能:
拖拽添加节点:用户可以通过拖拽的方式在画布上添加自定义节点。
节点配置:通过弹窗对话框配置节点的属性,包括实体名称和字段信息。
字段动态编辑:支持动态添加、删除字段,并提供字段类型选择。
数据导入导出:支持从 JSON 文件导入数据,以及将当前流程图导出为 JSON 文件。
三、实现步骤
1. 初始化项目
首先,确保你已经安装了 Vue CLI 和相关依赖。创建一个新的 Vue 项目,并安装以下依赖:
vue create x6-vue-flow
cd x6-vue-flow
npm install @antv/x6 @antv/x6-plugin-dnd @antv/x6-vue-shape element-ui
2. 创建主组件
在 src/components/FlowEditor.vue 中,实现流程图编辑器的主组件。以下是核心代码:
<template>
<div class="flow-container">
<!-- 导航栏 -->
<div class="flow-nav">
<div class="add-entity" @mousedown="startDrag">添加实体</div>
<el-button type="primary" plain size="medium">保存</el-button>
<el-button type="warning" plain size="medium">清空</el-button>
<el-button type="success" plain size="medium">导出</el-button>
<el-upload accept=".json" :on-progress="handleOnProgress" action="" :show-file-list="false">
<el-button plain size="primary">导入</el-button>
</el-upload>
</div>
<!-- 画布 -->
<div class="flow-content">
<div id="container"></div>
</div>
<!-- 实体编辑对话框 -->
<el-dialog title="新增实体" :visible.sync="dialogVisible" width="45%">
<el-form ref="form" :model="formData" label-width="130px" :rules="rules" size="small">
<el-form-item prop="entity_name_CN" label="逻辑实体中文名">
<el-input v-model="formData.entity_name_CN" clearable></el-input>
</el-form-item>
<el-form-item prop="entity_name_EN" label="逻辑实体英文名">
<el-input v-model="formData.entity_name_EN" clearable></el-input>
</el-form-item>
<div class="field-container">
<div class="field-container__title">
<div class="primary-title">字段信息</div>
<el-button type="primary" size="small" icon="el-icon-plus" @click="addField">新增一行</el-button>
</div>
<el-table :data="formData.formField" height="250">
<el-table-column type="index" width="50" label="序号" align="center"></el-table-column>
<el-table-column prop="cname" label="字段中文名" width="150" align="center">
<template v-slot:default="{ $index }">
<el-input size="small" v-model="formData.formField[$index].cname"></el-input>
</template>
</el-table-column>
<el-table-column prop="ename" label="字段英文名" width="200" align="center">
<template v-slot:default="{ $index }">
<el-input size="small" v-model="formData.formField[$index].ename"></el-input>
</template>
</el-table-column>
<el-table-column prop="efType" label="字段类型" align="center">
<template v-slot:default="{ $index }">
<el-select v-model="formData.formField[$index].efType" size="small">
<el-option v-for="item in options" :key="item" :label="item" :value="item"></el-option>
</el-select>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" align="center" width="80">
<template v-slot:default="{ $index }">
<el-button type="danger" size="small" @click="handleFieldDelete($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-form>
<span slot="footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="onSubmit(formData)">确定</el-button>
</span>
</el-dialog>
</div>
</template>
<script>
import { Graph, Shape } from '@antv/x6';
import { Dnd } from '@antv/x6-plugin-dnd';
import { register } from '@antv/x6-vue-shape';
import CellNode from './CellNode.vue';
export default {
name: 'FlowEditor',
data() {
return {
graph: null,
dialogVisible: false,
formData: {
entity_name_CN: '',
entity_name_EN: '',
formField: [{ cname: '', ename: '', efType: '' }],
},
options: ['STRING', 'NUMBER', 'BOOLEAN', 'DATE', 'EMAIL'],
};
},
mounted() {
this.$nextTick(() => {
this.initGraph();
});
},
methods: {
initGraph() {
const container = document.getElementById('container');
const config = {
container,
width: '100%',
height: '100%',
autoResize: true,
panning: true,
mousewheel: true,
};
this.graph = new Graph(config);
const dnd = new Dnd({
target: this.graph,
validateNode: (node) => {
this.currentDragNode = node;
this.dialogVisible = true;
return false;
},
});
this.dnd = dnd;
},
startDrag(e) {
const node = this.graph.createNode({
shape: 'CellNode',
});
this.dnd.start(node, e);
},
onSubmit(data) {
this.dialogVisible = false;
const node = this.graph.addNode(this.currentDragNode);
node.setData(data);
},
addField() {
this.formData.formField.push({ cname: '', ename: '', efType: '' });
},
handleFieldDelete(index) {
this.formData.formField.splice(index, 1);
},
},
};
</script>
3. 创建自定义 Vue 节点
在 src/components/CellNode.vue 中,实现自定义的 Vue 节点组件。以下是代码:
<template>
<div style="background-color: #ffffff;">
<el-table :data="formData.formField" style="width: 100%" height="250">
<el-table-column label="资源名称" :formatter="() => formData.entity_name_CN"></el-table-column>
<el-table-column prop="cname" label="中文名"></el-table-column>
<el-table-column prop="ename" label="英文名"></el-table-column>
<el-table-column prop="efType" label="类型"></el-table-column>
</el-table>
</div>
</template>
<script>
export default {
name: 'CellNode',
inject: ['getNode'],
data() {
return {
node: null,
formData: {
entity_name_CN: '',
entity_name_EN: '',
formField: [{ cname: '', ename: '', efType: '' }],
},
};
},
mounted() {
const node = this.getNode();
this.node = node;
node.on('change:data', (data) => {
if (data.cell && data.cell.data) {
this.formData = node.getData();
}
});
},
};
## 4. 注册自定义节点 在主组件中,使用 @antv/x6-vue-shape 注册自定义的 Vue 节点:
import { register } from '@antv/x6-vue-shape';
import CellNode from './CellNode.vue';
register({
shape: 'CellNode',
width: 300,
height: 250,
component: CellNode,
});
- 样式优化
在 src/styles/index.scss 中,添加全局样式:
.flow-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
.flow-nav {
display: flex;
align-items: center;
margin-bottom: 5px;
.add-entity {
border-radius: 6px;
background: rgba(64, 158, 255, 0.7);
border-color: #409eff;
border-style: dashed;
text-align: center;
color: #fff;
font-size: 14px;
padding: 5px;
box-sizing: content-box;
width: 100px;
cursor: pointer;
}
}
.flow-content {
flex: 1;
}
}
四、运行项目
完成以上步骤后,运行项目:
npm run serve
打开浏览器访问 http://localhost:8080,你将看到一个支持拖拽添加节点、动态字段编辑的流程图编辑器。
五、总结
通过 AntV X6 和 ElementUI 的结合,我们实现了一个功能丰富的流程图编辑器。AntV X6 提供了强大的图形化能力,ElementUI 提供了丰富的 UI 组件,两者结合可以快速搭建出高效的可视化工具。
完整代码
Graph.vue
代码
// Graph.vue
<template>
<div class="flow-container">
<!-- 导航栏 -->
<div class="flow-nav">
<div class="add-entity" @mousedown="startDrag">添加实体</div>
<el-button type="primary" style="margin-left: 10px" plain size="medium">保存</el-button>
<el-button type="warning" style="margin-left: 10px" plain size="medium">清空</el-button>
<el-button type="success" style="margin-left: 10px" plain size="medium">导出</el-button>
<el-upload accept=".JSON" :on-progress="handleOnProgress" action="" :show-file-list="false">
<el-button plain size="primary" style="margin-left: 10px">导入</el-button>
</el-upload>
</div>
<!-- 画布 -->
<div class="flow-content" >
<div id="container"></div>
</div>
<!-- 实体编辑对话框 -->
<el-dialog class="dialog-box" title="新增实体" :visible.sync="dialogVisible" width="45%" :before-close="handleClose" append-to-body="false">
<div class="content-wrapper">
<div class="primary-title">基本信息</div>
<el-form
ref="form"
:model="formData"
label-width="130px"
:rules="rules"
size="small"
>
<el-form-item prop="entity_name_CN" label="逻辑实体中文名">
<el-input v-model="formData.entity_name_CN" clearable></el-input>
</el-form-item>
<el-form-item prop="entity_name_EN" label="逻辑实体英文名">
<el-input v-model="formData.entity_name_EN" clearable></el-input>
</el-form-item>
<div class="field-container">
<div class="field-container__title">
<div class="primary-title">字段信息</div>
<div class="field-operation">
<el-button
type="primary"
size="small"
icon="el-icon-plus"
@click="addField"
>新增一行</el-button
>
</div>
</div>
<el-table :data="formData.formField" label-position="center" height="250">
<el-table-column type="index" width="50" label="序号" align="center">
</el-table-column>
<el-table-column
property="cname"
label="字段中文名"
width="150"
align="center"
>
<template v-slot:default="{ $index }" >
<el-input size="small" v-model="formData.formField[$index].cname"></el-input>
</template>
</el-table-column>
<el-table-column
property="ename"
label="字段英文名"
width="200"
align="center"
>
<template v-slot:default="{ $index }" >
<el-input size="small" v-model="formData.formField[$index].ename"></el-input>
</template>
</el-table-column>
<el-table-column
property="efType"
label="字段类型"
align="center"
>
<template v-slot:default="{ $index }">
<el-select
v-model="formData.formField[$index].efType"
default-first-option
placeholder="请选择字段类型"
clearable
size="small"
>
<el-option
v-for="item in options"
:key="item"
:label="item"
:value="item"
>
</el-option>
</el-select>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" align="center" width="80">
<template v-slot:default="{ $index }">
<el-button
type="danger"
size="small"
@click="handleFieldDelete($index)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
</div>
</el-form>
</div>
<span slot="footer" class="dialog-footer">
<el-button @click="dialogVisible = false">取 消</el-button>
<el-button type="primary" @click="onSubmit(formData)">确 定</el-button>
</span>
</el-dialog>
</div>
</template>
<script>
// import index from '../../index.js'
import { Graph, Shape } from '@antv/x6'
import { Dnd } from '@antv/x6-plugin-dnd'
import { register } from '@antv/x6-vue-shape'
import CellNode from './components/cellNode.vue'
// 画布配置
const config = {
container: null,
width: '100%',
height: '100%',
autoResize: true,
// 拖拽画布
panning: true,
mousewheel: true,
connecting: {
snap: true, // 是否开启连线自动吸附
highlight: true, // 是否高亮显示连线
router: 'manhattan',
connectionPoint: 'anchor',
anchor: 'center',
allowBlank: false,
// 不允许创建循环连线,即边的起始节点和终止节点为同一节点
allowEdge: false,
// 不允许起点终点相同
allowLoop: false,
// 是否允许边连接到非节点上
allowNode: false,
// 起始和终止节点的相同连接桩之间只允许创建一条边
allowMulti: 'witPort'
},
background: {
color: '#F2F7FA'
},
grid: {
visible: true,
type: 'doubleMesh',
args: [
{
color: '#eee', // 主网格线颜色
thickness: 1 // 主网格线宽度
},
{
color: '#ddd', // 次网格线颜色
thickness: 1, // 次网格线宽度
factor: 4 // 主次网格线间隔
}
]
}
}
// 连接桩配置
const ports = {
groups: {
// 对话框需要的一些外部数据
right: {
position: 'right',
attrs: {
circle: {
r: 5,
magnet: true,
stroke: '#5F95FF',
strokeWidth: 1,
fill: '#fff'
}
}
},
left: {
position: 'left',
attrs: {
circle: {
r: 5,
magnet: true,
stroke: '#5F95FF',
strokeWidth: 1,
fill: '#fff'
}
}
}
}
}
// 注册HTML节点
Shape.HTML.register({
shape: 'custom-html',
width: 160,
height: 80,
effect: ['data'],
html (formData) {
const data = formData
const div = document.createElement('div')
div.className = 'custom-html'
const span1 = document.createElement('span')
const span2 = document.createElement('span')
const span3 = document.createElement('span')
span1.innerText = '1111'
span2.innerText = '2222'
span3.innerText = '3333'
div.appendChild(span1)
div.appendChild(span2)
div.appendChild(span3)
return div
}
})
// 注册Vue节点
register({
shape: 'CellNode',
width: 300,
height: 250,
component: CellNode,
ports: {
...ports,
items: [{ group: 'left' }, { group: 'right' }]
}
})
// 注册边
Graph.registerEdge(
'dag-edge',
{
inherit: 'edge',
attrs: {
line: {
stroke: '#C2C8D5',
strokeWidth: 1,
targetMarker: null
}
}
},
true
)
export default {
name: 'vue-flow',
data () {
return {
graph: null,
currentEdge: null,
dnd: null,
dialogVisible: false,
currentDragNode: null,
// 对话框类型 编辑/新增
formData: {
entity_name_CN: '',
entity_name_EN: '',
// field_list: [],
formField: [{
cname: '',
ename: '',
efType: ''
}]
},
options: [
'STRING',
'NUMBER',
'BOOLEAN',
'DATE',
'EMAIL',
'URL',
'ARRAY',
'OBJECT',
'TEXTAREA',
'SELECT',
'RADIO',
'CHECKBOX',
'PASSWORD',
'FILE',
'IMAGE',
'RANGE',
'COLOR',
'TEL',
'SEARCH',
'DATETIME',
'DATETIME_LOCAL',
'MONTH',
'WEEK',
'TIME',
'HIDDEN'
],
rules: {
entity_name_CN: [
{ required: true, trigger: 'blur', message: '请输入逻辑实体中文名' }
],
entity_name_EN: [
{ required: true, trigger: 'blur', message: '请输入逻辑实体英文名' }
]
}
}
},
provide () {
return {
// 要用箭头函数保证this在其他组件获取正确
getGraph: () => {
return this.graph
}
}
},
mounted () {
this.$nextTick(() => {
this.initGraph()
})
},
methods: {
initGraph () {
// 容器dom
const container = document.getElementById('container')
config.container = container
// 实例化画布
this.graph = new Graph(config)
// 实例化拖拽节点
const dnd = new Dnd({
target: this.graph,
validateNode: (node) => {
this.currentDragNode = node
this.dialogVisible = true
return false
}
})
this.dnd = dnd
this.graph.centerContent() // 居中显示
},
startDrag (e) {
const node = this.graph.createNode({
shape: 'CellNode'
})
this.dnd.start(node, e)
},
handleClose (done) {
this.$confirm('确认关闭?')
.then(_ => {
done()
})
.catch(_ => {})
},
// 添加节点
addNode (node) {
return this.graph.addNode(node)
},
onSubmit (data) {
this.dialogVisible = false
if (this.currentDragNode) {
const node = this.addNode(this.currentDragNode)
const dataSource = {
...data
}
// TODO 有异步问题需要处理先写死200ms
setTimeout(() => {
node.setData(dataSource)
}, 200)
}
},
addField () {
this.formData.formField.push({
cname: '',
ename: '',
efType: ''
})
},
handleFieldDelete () {
this.formData.formField.splice(0, 1)
}
}
}
</script>
<style lang="scss">
$height: 40px;
.custom-html {
display: flex;
width: 100%;
height: 100%;
align-items: center;
background-color: #fff;
span {
display: inline-block;
height: $height;
line-height: $height;
border: 1px solid #0f7bcc;
text-align: center;
min-width: 0;
flex: 1;
}
}
.flow-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
.flow-nav {
display: flex;
align-items: center;
margin-bottom: 5px;
.add-entity {
border-radius: 6px;
background: rgba(64, 158, 255, 0.7);
background-clip: padding-box;
border-color: #409eff;
border-style: dashed;
text-align: center;
color: #fff;
font-size: 14px;
padding: 5px;
box-sizing: content-box;
width: 100px;
cursor: pointer;
}
}
.flow-content {
flex: 1;
}
.my-selecting {
border: 1px dashed #40ff7c;
background-color: #0f7bcc;
}
.x6-widget-selection-box {
border: 0px dashed rgba(0, 0, 0, 0);
box-shadow: 1px 1px 10px 0 rgba(0, 0, 0, 0.3);
border-radius: 6px;
// background-color: #0F7BCC;
// opacity: 0.1;
}
.x6-widget-selection-inner {
opacity: 0.1;
border: 5px solid #000000;
background-color: #0f7bcc;
}
}
.dialog-box{
// width: 600%;
// height: 800px;
.el-dialog__title{
color:#fff;
font-size: 18px;
}
.content-wrapper {
padding:15px;
.primary-title {
font-size: 18px;
font-weight: 700;
padding-bottom: 10px;
}
.field-container {
&__title {
display: flex;
justify-content: space-between;
}
}
}
}
</style>
cellNode.vue
代码
<template>
<div style="background-color: #ffffff;">
<el-table
:data="formData.formField"
style="width: 100%"
height="250"
>
<el-table-column :label="`资源名称:${formData.entity_name_CN}`">
<el-table-column
prop="cname"
label="中文名"
>
</el-table-column>
<el-table-column
prop="ename"
label="英文名"
>
</el-table-column>
<el-table-column
prop="efType"
label="类型"
>
</el-table-column>
</el-table-column>
</el-table>
</div>
</template>
<script>
export default {
name: 'CellNode',
inject: ['getNode'],
data () {
return {
// 当前实体节点
node: null,
formData: {
entity_name_CN: '',
entity_name_EN: '',
formField: [
{
cname: '',
ename: '',
efType: ''
}
]
}
}
},
mounted () {
// 获取node节点
const node = this.getNode()
this.node = node
// 节点data改变监听
node.on('change:data', (data) => {
if (data.cell && data.cell.data) {
this.formData = node.getData()
console.log('🚀 ~ :', this.formData)
}
})
}
}
</script>