先来看看实现效果:
【组件库】使用 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;
- }
- }
复制代码 四、运行项目
完成以上步骤后,运行项目:
打开浏览器访问 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代码
[code]<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(' |