反转基因福娃 发表于 2025-1-22 04:58:54

【组件库】使用Vue2+AntV X6+ElementUI 实现拖拽配置自定义vue节点

    先来看看实现效果:
https://i-blog.csdnimg.cn/direct/c924c587668a40f7969ea5494bb567a6.png
    【组件库】使用 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" plainsize="medium">保存</el-button>
            <el-button type="warning" style="margin-left: 10px" plain size="medium">清空</el-button>
            <el-button type="success" style="margin-left: 10px" plainsize="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('
页: [1]
查看完整版本: 【组件库】使用Vue2+AntV X6+ElementUI 实现拖拽配置自定义vue节点