antv x6实现封装拖拽流程图配置(适用于工单流程、审批流程应用场景)

一、实现效果:

左侧节点拖拽到右侧画布,可进行节点连接,同时点击节点弹出弹框填写表单,表单校验失败,节点为红色;表单校验成功,节点为绿色。

插件: 【antv x6】https://x6.antv.antgroup.com/
技术栈: vue3 + ts

二、具体实现:

1、安装antv x6以及相关插件

1.1 安装antv x6

bash 复制代码
# npm
$ npm install @antv/x6 --save

# yarn
$ yarn add @antv/x6 --save

1.2 安装其他依赖插件(见官网文档:插件描述

npm方式
bash 复制代码
npm install @antv/x6-plugin-stencil @antv/x6-plugin-transform @antv/x6-plugin-selection @antv/x6-plugin-snapline @antv/x6-plugin-keyboard @antv/x6-plugin-clipboard @antv/x6-plugin-history --save
yarn方式
bash 复制代码
yarn add @antv/x6-plugin-stencil @antv/x6-plugin-transform @antv/x6-plugin-selection @antv/x6-plugin-snapline @antv/x6-plugin-keyboard @antv/x6-plugin-clipboard @antv/x6-plugin-history --save

我这里只安装了我用到的

2、封装自定义vue节点组件

由于antv x6已有的基础图形形状呢,不能满足我的需求,所以采用【自定义vue节点组件 】的方式,自定义节点样式。

详见官方文档:自定义vue节点配置

components/graph文件夹下新建process-node.vue,书写自定义节点代码

typescript 复制代码
<template>
  <div :class="['node', nodeStatus]">
    <img :src="image.logo" alt="logo" />
    <span class="label">{{ nodeLabel }}</span>
    <span class="status">
      <img v-if="nodeStatus === 'success'" :src="image.success" alt="success" />
      <img v-if="nodeStatus === 'failed'" :src="image.failed" alt="failed" />
      <img v-if="nodeStatus === 'running'" :src="image.running" alt="running" />
    </span>
  </div>
</template>

<script setup lang="ts">
import { computed, inject, onMounted, onUnmounted, ref, watch } from 'vue'

const props = defineProps({
  node: {
    type: Object,
  }
})
const { node } = props
const nodeData = ref({})
const nodeLabel = ref('')
const nodeStatus = ref('default')

const updateData = () => {
  const data = node.getData()
  nodeData.value = data || {}
  nodeLabel.value = data?.label || ''
  nodeStatus.value = data?.status || 'default'
}

onMounted(() => {
  updateData()
  node.on('change:data', updateData)
})

onUnmounted(() => {
  node.off('change:data', updateData)
})
const image = {
  logo: 'https://gw.alipayobjects.com/mdn/rms_43231b/afts/img/A*evDjT5vjkX0AAAAAAAAAAAAAARQnAQ',
  success:
    'https://gw.alipayobjects.com/mdn/rms_43231b/afts/img/A*6l60T6h8TTQAAAAAAAAAAAAAARQnAQ',
  failed:
    'https://gw.alipayobjects.com/mdn/rms_43231b/afts/img/A*SEISQ6My-HoAAAAAAAAAAAAAARQnAQ',
  running:
    'https://gw.alipayobjects.com/mdn/rms_43231b/afts/img/A*t8fURKfgSOgAAAAAAAAAAAAAARQnAQ',
}
 
</script>

<style lang="scss">
.node {
  display: flex;
  align-items: center;
  width: 100%;
  height: 100%;
  background-color: #fff;
  border: 1px solid #c2c8d5;
  border-left: 4px solid #5F95FF;
  border-radius: 4px;
  box-shadow: 0 2px 5px 1px rgba(0, 0, 0, 0.06);
}
.node img {
  width: 20px;
  height: 20px;
  flex-shrink: 0;
  margin-left: 8px;
}
.node .label {
  display: inline-block;
  flex-shrink: 0;
  width: 104px;
  margin-left: 8px;
  color: #666;
  font-size: 12px;
}
.node .status {
  flex-shrink: 0;
}
.node.success {
  border-left: 4px solid #52c41a;
}
.node.failed {
  border-left: 4px solid #ff4d4f;
}
.node.running .status img {
  animation: spin 1s linear infinite;
}
.x6-node-selected .node {
  border-color: #1890ff;
  border-radius: 2px;
  box-shadow: 0 0 0 4px #d4e8fe;
}
.x6-node-selected .node.success {
  border-color: #52c41a;
  border-radius: 2px;
  box-shadow: 0 0 0 4px #ccecc0;
}
.x6-node-selected .node.failed {
  border-color: #ff4d4f;
  border-radius: 2px;
  box-shadow: 0 0 0 4px #fedcdc;
}
.x6-edge:hover path:nth-child(2){
  stroke: #1890ff;
  stroke-width: 1px;
}

.x6-edge-selected path:nth-child(2){
  stroke: #1890ff;
  stroke-width: 1.5px !important;
}

@keyframes running-line {
  to {
    stroke-dashoffset: -1000;
  }
}
@keyframes spin {
  from {
      transform: rotate(0deg);
  }
  to {
      transform: rotate(360deg);
  }
}
</style>

3、使用x6插件,绘制流程图

安装好第一步所有antv插件之后,就可进行正常引入和配置了

components/graph文件夹下新建process-flow.vue,进行流程图、节点、连线的配置,最终绘制流程图

2.1 dom部分:

html 复制代码
<template>
<div class="process-flow">
    <div class="top-btn">
        <el-button type="primary" @click="sumbit">保存</el-button>
        <el-button type="primary" @click="showProperty">回显</el-button>
    </div>
    <div id="container">
        <!-- 节点库 -->
        <div id="stencil"></div>
        <!-- 画布 -->
        <div id="graph-container"></div>
     </div>
     <!--  自定义节点需配置 -->
     <TeleportContainer />
     <!-- 弹窗 -->
     <el-dialog title="节点属性" v-model="formVisible" width="408px" :draggable="true">
     <!-- 表单组件 -->
            <TableEdit
                :key="selectedNodeId"
                :form-data="rowData" 
                :initData="rowData" 
                :options="options" 
                :update="updateFormData"
                :close-dialog="closeDialog"
                :is-form-show="true"
                >
            </TableEdit>
     </el-dialog>
</div>
</template>

2.2 方法实现部分:

typescript 复制代码
<script setup lang="ts">
import { Graph, Shape } from '@antv/x6'
import { Stencil } from '@antv/x6-plugin-stencil/lib' // 如果你加上/lib会报错,那你就去掉试试
import { Transform } from '@antv/x6-plugin-transform/lib'
import { Selection } from '@antv/x6-plugin-selection/lib'
import { Snapline } from '@antv/x6-plugin-snapline/lib'
import { Keyboard } from '@antv/x6-plugin-keyboard/lib'
import { Clipboard } from '@antv/x6-plugin-clipboard/lib'
import { History } from '@antv/x6-plugin-history/lib'
import { register, getTeleport } from '@antv/x6-vue-shape'
import ProcessNode from './process-node.vue'
import { computed, nextTick, onMounted, provide, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'

// 存储各节点的表单数据
const nodeFormData = ref({})
// 当前选中节点的ID
const selectedNodeId = ref(null)
const rowData = ref({})
const formVisible = ref(false)


// 配置自定义节点数据
const nodeData = [{
  shape: 'custom-vue-node',
  label: 'Vue节点',
  data: {
    label: 'Vue节点1',
    type: 1
  }
},{
  shape: 'custom-vue-node',
  label: 'Vue节点',
  data: {
    label: 'Vue节点2',
    type: 1
  }
},{
  shape: 'custom-vue-node',
  label: 'Vue节点',
  data: {
    label: 'Vue节点3',
    type: 1
  }
},{
  shape: 'custom-vue-node',
  label: 'Vue节点',
  data: {
    label: 'Vue节点4',
    type: 1
  }
}]

// 节点表单配置  --- start
const tableEdit = ref()
// 配置各种表单字段
let options1 = {
    labelWidth: '150px',
    span: 24,
    list: [
        { type: 'number', label: '姓名', prop: 'priority', placeholder: '不设置', min: 1,required: true },
        { type: 'number', label: '职位', prop: 'threshold_max_power', placeholder: '不限制', clearable: true, min: 1},
    ]
}
let options2 = {
    labelWidth: '150px',
    span: 24,
    list: [
         { type: 'number', label: '部门', prop: 'threshold_total_power', placeholder: '不限制', clearable: true, min: 1},
    ]
}
// 定义节点类型与options的映射关系
const nodeTypeToOptionsMap = {
  1: options1,
  2: options2,
  3: options1,
};

// 获取选中节点的类型
const getSelectedNodeType = (nodeId?) => {
  const NodeId = nodeId || selectedNodeId.value
  if (!NodeId) return null;
  const node = graph.getCellById(NodeId);
  return node?.data?.type;
};
// 根据点击的节点,确定展示不同表单
let options = computed(() => {
  const nodeType = getSelectedNodeType();
  return nodeTypeToOptionsMap[nodeType];
})
// 关闭弹窗
const closeDialog = () => {
    formVisible.value = false
}
// 表单更新
const updateFormData = (formData) => {
    nodeFormData.value[selectedNodeId.value] = formData
    // 判断状态
    checkNodeStatus()

    setTimeout(() => {
        closeDialog()
    }, 100)
}
// 【检查节点状态】根据表单校验,展示不同状态
const checkNodeStatus = () => {
    let flag = false
    const nodes = graph.getNodes();
    nodes.forEach(node => {
         const data = node.getData()
        // 检查nodeFormData.value是否有这个node.id的key
        if (nodeFormData.value?.hasOwnProperty(node.id)) {
            // 如果存在,标绿
            node.attr('body/stroke', 'green');
            node.setData({
                ...data,
                status: 'success',
            })
            console.log('成功', node)
            flag = true
        } else {
            // 如果不存在,标红
            node.attr('body/stroke', 'red');
            node.setData({
                ...data,
                status: 'failed',
            })
            console.log('失败', node)
            flag = false
        }
    })
    return flag

}
// 节点表单配置  --- end


// 【保存流程图】可将节点json数据提交到后端
const sumbit = async () => {  
    if(!checkNodeStatus()){
        ElMessage.error('请完善节点信息')
        return
    }

    const res = graph.toJSON() // 获取节点json数据,可用于回显数据
}

// 链接桩配置
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',
        },
    ],
  }
// 注册vue自定义形状节点
register({
    shape: 'custom-vue-node',
    width: 180,
    height: 36,
    component: ProcessNode,
    ports: ports,
})
const TeleportContainer = getTeleport()
provide('notifyParent', (node) => {
  return node.getData()
})
let graph = null
// 初始化画布
const initGraph = () => {
  graph = new Graph({
    container: document.getElementById('graph-container')!,
    grid: true,
    mousewheel: {
        enabled: true,
        zoomAtMousePosition: true,
        modifiers: 'ctrl',
        minScale: 0.5,
        maxScale: 3,
    },
    connecting: {
        router: 'manhattan',
        connector: {
            name: 'rounded',
            args: {
                radius: 8,
            },
        },
        snap: { radius: 20 },
        allowBlank: false,
        allowLoop: false,
        allowMulti: false,
        highlight: true,
        connectionPoint: 'anchor',
        anchor: 'center',
        createEdge() { // 连接线配置
            return new Shape.Edge({
                attrs: {
                    line: {
                        stroke: '#1890ff',
                        strokeDasharray: 5,
                        targetMarker: 'classic',
                        style: {
                            animation: 'ant-line 30s infinite linear',
                        },
                    },
                },
                zIndex: 0,
            })
        },
        validateConnection({ targetMagnet }) {
            return !!targetMagnet
        }, 
        validateEdge({ edge }) {
            const { source, target } = edge
            return true
        }
    },
    highlighting: {
        magnetAdsorbed: {
        name: 'stroke',
        args: {
            attrs: {
            fill: '#5F95FF',
            stroke: '#5F95FF',
            },
        },
        },
    },
  })
}

// 配置插件
const configurePlugins = () => {
  graph
    .use(
        new Transform({
        resizing: true,
        rotating: true,
        }),
    )
    .use(
        new Selection({
        rubberband: true,
        showNodeSelectionBox: true,
        }),
    )
    .use(new Snapline())
    .use(new Keyboard())
    .use(new Clipboard())
    .use(new History())
}

// 初始化Stencil
const initStencil = () => {
  const stencil = new Stencil({
    title: '流程配置',
    target: graph,  
    stencilGraphWidth: 250,
    stencilGraphHeight: 180,
    collapsable: true,
    groups: [
        {
        title: '事件流程',
        name: 'group1',
        },
    ],
    layoutOptions: {
        columns: 1,
        columnWidth: 175,
        rowHeight: 55,
    },
  })
  document.getElementById('stencil')!.appendChild(stencil.container)
  return stencil
}

// 绑定快捷键
const bindKeys = () => {
  graph.bindKey(['meta+c', 'ctrl+c'], () => {
    const cells = graph.getSelectedCells()
    if (cells.length) {
        graph.copy(cells)
    }
    return false
  })
  graph.bindKey(['meta+x', 'ctrl+x'], () => {
    const cells = graph.getSelectedCells()
    if (cells.length) {
        graph.cut(cells)
    }
    return false
  })
  graph.bindKey(['meta+v', 'ctrl+v'], () => {
    if (!graph.isClipboardEmpty()) {
        const cells = graph.paste({ offset: 32 })
        graph.cleanSelection()
        graph.select(cells)
    }
    return false
  })

  // undo redo
  graph.bindKey(['meta+z', 'ctrl+z'], () => {
    if (graph.canUndo()) {
        graph.undo()
    }
    return false
  })
  graph.bindKey(['meta+shift+z', 'ctrl+shift+z'], () => {
    if (graph.canRedo()) {
        graph.redo()
    }
    return false
  })

  // select all
  graph.bindKey(['meta+a', 'ctrl+a'], () => {
    const nodes = graph.getNodes()
    if (nodes) {
        graph.select(nodes)
    }
  })

  // delete
  graph.bindKey('backspace', () => {
    const cells = graph.getSelectedCells()
    if (cells.length) {
        graph.removeCells(cells)
    }
  })

  // zoom
  graph.bindKey(['ctrl+1', 'meta+1'], () => {
    const zoom = graph.zoom()
    if (zoom < 1.5) {
        graph.zoom(0.1)
    }
  })
  graph.bindKey(['ctrl+2', 'meta+2'], () => {
    const zoom = graph.zoom()
    if (zoom > 0.5) {
        graph.zoom(-0.1)
    }
  })
}

// 控制连接桩显示/隐藏
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'
  }
}

// 绑定事件----start
const bindEvents = () => {
  graph.on('node:mouseenter', ({ node }) => {
    const container = document.getElementById('graph-container')!
    const ports = container.querySelectorAll(
        '.x6-port-body',
    ) as NodeListOf<SVGElement>
    showPorts(ports, true)
    // 鼠标 Hover 节点时添加删除按钮
    node.addTools({
        name: "button-remove",
        args: {
        x: -10,
        y: -10,
        offset: { x: 10, y: 10 },
        markup: [
            {
            tagName: 'circle',
            selector: 'button',
            attrs: {
                r: 8,
                stroke: '#F25C54',
                strokeWidth: 1,
                fill: 'rgba(214, 40, 40, 0.25)',
                cursor: 'pointer',
            },
            },
            {
            tagName: 'text',
            textContent: '✕',
            selector: 'icon',
            attrs: {
                fill: '#F25C54',
                fontSize: 7,
                textAnchor: 'middle',
                pointerEvents: 'none',
                y: '0.3em',
            },
            },
        ],
        },
    });
  })
  graph.on('node:mouseleave', ({ node }) => {
    const container = document.getElementById('graph-container')!
    const ports = container.querySelectorAll(
        '.x6-port-body',
    ) as NodeListOf<SVGElement>
    showPorts(ports, false)
    // 鼠标移开节点时删除删除按钮
    node.removeTools();
  })

  graph.on("node:removed", ({ node, options }) => {
    if (!options.ui) {
      return;
    }
  });
  
  // 线删除操作
  graph.on("edge:mouseenter", ({ edge }) => {
    // 鼠标 Hover 边时添加删除按钮
    edge.addTools([
      "target-arrowhead",
      {
        name: "button-remove",
        args: {
          distance: -30,
        },
      },
    ]);
  });
  
  graph.on("edge:removed", ({ edge, options }) => {
    if (!options.ui) {
      return;
    }
    const cellId = edge.getTargetCellId()
    const target = graph.getCellById(cellId)
    if (target) {
      const id = target.ports.items[0].id
      target && target.setPortProp(id, 'connected', false)
    }else{
      target && target.setPortProp(cellId+'_in', 'connected', false)
    }
  });
  
  graph.on("edge:mouseleave", ({ edge }) => {
    // 鼠标移开边时删除删除按钮
    edge.removeTools();
  });
  
  graph.on('node:change:data', ({ node }) => {
    // 修改节点数据的实时响应事件
  })
   // 节点单击事件
  graph.on('node:click', ({ e, x, y, node, view }) => {
    formVisible.value = true
    
    // 设置新选中的节点
    selectedNodeId.value = node.id
    
    // 加载该节点之前保存的表单数据(如果有的话)
    if (nodeFormData.value[node.id]) {
        rowData.value = nodeFormData.value[node.id]
    } else {
        // 如果没有保存的数据,则清空表单
        rowData.value = {}
    }
  })
}
// 绑定事件-----end

// 在onMounted中调用这些函数
onMounted(() => {
  initGraph()
  configurePlugins()
  const stencil = initStencil()
  bindKeys()
  bindEvents()
  stencil.load(nodeData, 'group1')
})
</script>

2.3 css部分:

连接线动画,需要单独写个全局的

css 复制代码
<style>
@keyframes ant-line {
    to {
        stroke-dashoffset: -1000
    }
  }
</style>

style部分

css 复制代码
<style scoped lang="scss">
.top-btn {
    text-align: right;
}
  #container {
      width: 100%;
      height: 100vh;
      display: flex;
      border: 1px solid #dfe3e8;
    }
    #stencil {
      min-width: 200px;
      height: 100%;
      position: relative;
      border-right: 1px solid #dfe3e8;
    }
    #graph-container {
      width: calc(100% - 200px)!important;
      height: 100%!important;
    }
    #property {
        min-width: 350px;
        height: 100%;
        border-left: 1px solid #dfe3e8;
        .pro-title {
            font-size: 16px;
            font-weight: 500;
            padding: 12px 20px;
            border-bottom: 1px solid #dfe3e8;
            margin-bottom: 24px;
        }

    }
    .x6-widget-stencil  {
      background-color: #fff;
    }
    .x6-widget-stencil-title {
      background-color: #fff;
    }
    .x6-widget-stencil-group-title {
      background-color: #fff !important;
    }
    .x6-widget-transform {
      margin: -1px 0 0 -1px;
      padding: 0px;
      border: 1px solid #239edd;
    }
    .x6-widget-transform > div {
      border: 1px solid #239edd;
    }
    .x6-widget-transform > div:hover {
      background-color: #3dafe4;
    }
    .x6-widget-transform-active-handle {
      background-color: #3dafe4;
    }
    .x6-widget-transform-resize {
      border-radius: 0;
    }
    .x6-widget-selection-inner {
      border: 1px solid #239edd;
    }
    .x6-widget-selection-box {
      opacity: 0;
    }
</style>

4、表单组件 封装(拓展)

相信你也看到我实现【点击节点展示不同表单】,其实是用了一个组件TableEdit,也是为了简化代码,如果你不需要 ,可忽略

原理其实就是不同类型的节点,对应的是不同的表单配置项options

实现代码如下:

typescript 复制代码
<template>
	<el-form ref="formRef" :model="form" :rules="rules" :label-width="options?.labelWidth" v-show="isFormShow">
		<el-row v-for="item in options?.list">
			<el-col :span="i.span ? i.span : options.span / item.list.length" v-for="i in item.list">
				<el-divider v-if="i.type === 'divider'" content-position="left"> {{ i.placeholder }}</el-divider>
				<el-form-item v-else :label="i.label" :prop="i.prop" :rules="addRule(i)"
					:label-width="i.labelWidth ? i.labelWidth : options.labelWidth">
					<!-- 文本框、数字框、下拉框、日期框、开关、上传、按钮、滑块 -->
					<el-input v-if="i.type === 'input'" v-model.trim="form[i.prop]" :disabled="i.disabled || item.disabled"
						:placeholder="i.placeholder" :type="i.inputType" :clearable="i.clearable"
						@change="onchange($event, i.onChange)"></el-input>
					<el-input-number v-else-if="i.type === 'number'" v-model.trim="form[i.prop]" :placeholder="i.placeholder"
						:disabled="i.disabled || item.disabled" controls-position="right" :clearable="i.clearable" step-strictly
						@change="onchange($event, i.onChange)" :min="i.min" :max="i.max" :step="i.step"></el-input-number>
					<el-select v-else-if="i.type === 'select'" v-model="form[i.prop]" :clearable="i.clearable"
						:disabled="i.disabled || item.disabled" :multiple="i.selectMultiple"
						:placeholder="i.placeholder" @change="onchange($event, i.onChange)">
						<el-option v-for="opt in i.opts" :label="opt.label" :value="opt.value"
							:disabled="opt.disabled"></el-option>
					</el-select>
					<el-cascader v-else-if="i.type === 'cascader'" expand-trigger="hover" :options="i.opts"
						:disabled="i.disabled || item.disabled" v-model="form[i.prop]"></el-cascader>
					<el-date-picker v-else-if="i.type === 'date'" type="date" v-model="form[i.prop]"
						:disabled="i.disabled || item.disabled" :value-format="i.format"></el-date-picker>
					<el-time-picker v-else-if="i.type === 'time'" v-model="form[i.prop]" value-format="HH:mm"
						format="HH:mm" @change="onchange($event, i.onChange)" :disabled="i.disabled || item.disabled"
						:editable="false" :clearable="false" :default-value="new Date(new Date().setMinutes(0))"></el-time-picker>
					<div v-else-if="i.type === 'timerange'" class='el-time-picker'>
						<el-time-picker  is-range v-model="form[i.prop]" :clearable="i.clearable"
						:editable=false :disabled="i.disabled || item.disabled" range-separator="到" :value-format="i.valueFormat"
						:start-placeholder="i.placeholder" :end-placeholder="i.placeholder" @change="onchange($event, i.onChange)"></el-time-picker>
					</div>
					<div v-else-if="i.type === 'slider'" class="sliderBox">
						<el-slider v-model="form[i.prop]" range show-stops :max="24" :format-tooltip="formatText" :marks="marks"/>
					</div>
					<el-switch v-else-if="i.type === 'switch'" v-model="form[i.prop]"
						:disabled="i.disabled || item.disabled" :active-value="i.activeValue"
						:inactive-value="i.inactiveValue" :active-text="i.activeText"
						:inactive-text="i.inactiveText" @change="onchange($event, i.onChange)"></el-switch>
					<el-upload v-else-if="i.type === 'upload'" action="#" list-type="picture-card" class="uploader"
						:disabled="i.disabled || item.disabled" :file-list="initUploadFileList(i.prop)[i.prop + 'upload']"
						:http-request="i.onUpload" :on-preview="handlePictureCardPreview"
						:on-success="handleUploadSuccess" :on-remove="handleUploadRemove" :limit="i.fileLimit"
						:on-exceed="handleUploadExceed" :before-upload="i.beforeUpload">
						<el-icon>
							<Plus />
						</el-icon>
					</el-upload>
					<el-button v-else-if="i.type === 'button'" v-text="i.activeText" @click="i.click()"
						:disabled="i.disabled || item.disabled"></el-button>
					<slot :name="i.prop" v-else></slot>
					<text v-else>{{ i.placeholder }}</text>
				</el-form-item>
			</el-col>
		</el-row>
		<el-form-item label-width="0">
			<div class="footer" v-if="isFooterShow">
				<el-button plain @click="cancel">取消</el-button>
				<el-button class="submit-btn" @click="saveEdit(formRef)">确 认</el-button>
			</div>
		</el-form-item>
	</el-form>
	<el-dialog v-model="dialogVisible">
		<img w-full :src="dialogImageUrl" alt="预览图片" />
	</el-dialog>
</template>

<script lang="ts" setup>
import { FormInstance, FormRules, UploadProps, ElMessage } from 'element-plus';
import { PropType, ref, watch } from 'vue';

const { options, formData, initData, edit, update, closeDialog } = defineProps({
	options: {
		type: Object,
		required: true
	},
	formData: {
		type: Object,
		required: true
	},
	initData: {
		type: Object,
		required: false
	},
	edit: {
		type: Boolean,
		required: false
	},
	update: {
		type: Function,
		required: true
	}, 
	closeDialog: {
		type: Function,
		required: true
	},
	// 表单是否显示
	isFormShow: {
		type: Boolean,
		required: false
	},
	// 表单是否显示底部按钮
	isFooterShow: {
		type: Boolean,
		required: false,
		default: true
	}
});

console.log('props===', formData, initData)

const form = ref({ ...(edit ? formData : initData) });
const marks = {
	0: '00:00',
	12: '12:00',
	24: '24:00'
}
const formatText = (value: number) => {
	return `${value}:00`;
}
const rules: FormRules = options?.list?.map(item => {
	if (item.required) {
		return { [item.prop]: [{ required: true, message: `${item.label}不能为空`, trigger: ['blur', 'change'] }] };
	}
	return {};
}).reduce((acc, cur) => ({ ...acc, ...cur }), {});

for (var i = 0; i < options?.list?.length; i++) {
	if (options?.list[i]?.list == null) {
		options.list[i].list = []
		options.list[i].list.push(options?.list[i])
	}
}

const formRef = ref<FormInstance>();
const saveEdit = (formEl: FormInstance | undefined) => {
	if (!formEl) return;
	formEl.validate(valid => {
		if (!valid) return;
		update(form.value);
	});
};

const cancel = () => {
	closeDialog()
}
const onchange = (val: string, onChange?: (val: string, form?) => void) => {
	if (onChange != null) {
		onChange(val, form)
	}
}

const addRule = (item) => {
	if (item.required) {
		return { required: true, message: `${item.label}不能为空`, trigger: 'blur' };
	}
	if (item.rules) {
		return {
			...item.rules,
			validator: (rule, value, callback) => {
				item.rules[0].validator.call(this, rule, value, callback, form.value);
			}
		};
    }
	return null;
}

const dialogImageUrl = ref('')
const dialogVisible = ref(false)

const initUploadFileList = (prop: string) => {
	if (!form.value[prop + "upload"] && form.value[prop]) {
		form.value[prop + "upload"] = form.value[prop].map((value) => { return { name: prop, url: value, status: 'ready' } })
	}
	return form.value
}

const handleUploadRemove: UploadProps['onRemove'] = (uploadFile, uploadFiles) => {
	form.value[uploadFile.name] = uploadFiles.map(value => value.url)
}

const handleUploadSuccess: UploadProps['onSuccess'] = (data, uploadFile, uploadFiles) => {
	if (data) {
		uploadFile.name = data.prop
		uploadFile.url = data.imgUrl
		uploadFile.status = 'ready'
		form.value[data.prop] = uploadFiles.map(value => value.url)
	}
}

const handlePictureCardPreview: UploadProps['onPreview'] = (uploadFile) => {
	dialogImageUrl.value = uploadFile.url!
	dialogVisible.value = true
}

const handleUploadExceed: UploadProps['onExceed'] = () => {
	ElMessage.warning("最多上传4张图片");
}
// 暴露几个字段
defineExpose({
  formRef,
  form,
  saveEdit,
});
</script>

<style scoped>
.sliderBox {
	width: 90%;
	margin-bottom: 18px;
}
.uploader /deep/ .el-upload-list__item {
	transition: none !important;
}

.el-form-item__content {
	max-width: 70%;
}

.el-select {
	--el-select-width: 80%;
}

.el-input {
	--el-input-width: 80%;
}

.el-textarea {
	width: 80%;
	resize: none;
}

.el-input-number {
	width: 80%;
}

.el-cascader {
	width: 80%;
}

.el-time-picker {
	width: 80%;
}
.submit-btn {
	color: #FFF;
	background: var(--main-color);
}
.footer {
	width: 100%;
	margin:0 32px;
	display: flex;
	align-items: center;
	justify-content: flex-end;
}
</style>

三、插件其他常用功能补充:

1、自定义基础图形

某些情况下,如果你需要使用基础图形,而不是像我上面一样自定义vue节点,那么可直接注册相关基础图形

实现方法如下,直接注册即可

typescript 复制代码
// 初始化基础图形
const initShapes = () => {
  Graph.registerNode(
    'custom-rect',
    {
        inherit: 'rect',
        width: 66,
        height: 36,
        attrs: {
        body: {
            strokeWidth: 1,
            stroke: '#5F95FF',
            fill: '#EFF4FF',
        },
        text: {
            fontSize: 12,
            fill: '#262626',
        },
        },
        ports: { ...ports }, // port链接桩配置可如上
    },
    true,
  )

  Graph.registerNode(
    'custom-polygon',
    {
        inherit: 'polygon',
        width: 66,
        height: 36,
        attrs: {
        body: {
            strokeWidth: 1,
            stroke: '#5F95FF',
            fill: '#EFF4FF',
        },
        text: {
            fontSize: 12,
            fill: '#262626',
        },
        },
        ports: {
        ...ports,
        items: [
            {
            group: 'top',
            },
            {
            group: 'bottom',
            },
        ],
        },
    },
    true,
  )

  Graph.registerNode(
    'custom-circle',
    {
        inherit: 'circle',
        width: 45,
        height: 45,
        attrs: {
        body: {
            strokeWidth: 1,
            stroke: '#5F95FF',
            fill: '#EFF4FF',
        },
        text: {
            fontSize: 12,
            fill: '#262626',
        },
        },
        ports: { ...ports },
    },
    true,
  )

  Graph.registerNode(
    'custom-image',
    {
        inherit: 'rect',
        width: 52,
        height: 52,
        markup: [
        {
            tagName: 'rect',
            selector: 'body',
        },
        {
            tagName: 'image',
        },
        {
            tagName: 'text',
            selector: 'label',
        },
        ],
        attrs: {
        body: {
            stroke: '#5F95FF',
            fill: '#5F95FF',
        },
        image: {
            width: 26,
            height: 26,
            refX: 13,
            refY: 16,
        },
        label: {
            refX: 3,
            refY: 2,
            textAnchor: 'left',
            textVerticalAnchor: 'top',
            fontSize: 12,
            fill: '#fff',
        },
        },
        ports: { ...ports },
    },
    true,
  )
}

2、流程图的数据保存和回显

如官网的这个例子,绘制的流程图:

2.1 保存

如果整个流程图绘制完成后,保存的方式就是:

typescript 复制代码
let jsondata = graph.toJSON()

这样得到的一个大json数据,可以console.log出来,如下:

typescript 复制代码
let jsondata =  [
    {
        "shape": "edge",
        "id": "d941e09e-e938-4041-9861-1d081bfd01d1",
        "zIndex": 0,
        "source": {
            "cell": "b39149ab-070e-4dfc-8d50-305f16950ffc",
            "port": "35e782cd-cf54-4000-911e-1e9fa3209bce"
        },
        "target": {
            "cell": "b7af14b3-e20a-4421-966c-4f370636193c",
            "port": "61603aea-93ed-4af5-8535-fc6ef1c27ba6"
        }
    },
    {
        "shape": "edge",
        "id": "151a7294-5129-45fd-8797-cde24c539c66",
        "zIndex": 0,
        "source": {
            "cell": "b7af14b3-e20a-4421-966c-4f370636193c",
            "port": "d3e6af3d-a4c7-4637-a732-fbf208bea189"
        },
        "target": {
            "cell": "c02638ad-28e3-44d8-acb2-132291921774",
            "port": "7210f722-bb69-449f-9c31-769f3b612ec8"
        }
    },
    {
        "label": "开始",
        "x": 560,
        "y": 160,
        "ports": [
            {
                "group": "top",
                "id": "d2a67c86-01df-4825-a753-c2bb50ad904c"
            },
            {
                "group": "right",
                "id": "77a8625e-157d-440d-b9c7-ad6ac7124de3"
            },
            {
                "group": "bottom",
                "id": "35e782cd-cf54-4000-911e-1e9fa3209bce"
            },
            {
                "group": "left",
                "id": "26ff3e2f-5fa4-4b7a-acaa-d4477a9116b8"
            }
        ],
        "data": {
            "isStart": true
        },
        "shape": "custom-rect",
        "id": "b39149ab-070e-4dfc-8d50-305f16950ffc",
        "zIndex": 1
    },
    {
        "label": "过程",
        "x": 560,
        "y": 244,
        "ports": [
            {
                "group": "top",
                "id": "61603aea-93ed-4af5-8535-fc6ef1c27ba6",
                "connected": false
            },
            {
                "group": "right",
                "id": "73f3699c-e013-49a3-8118-d32f7b6c1094"
            },
            {
                "group": "bottom",
                "id": "d3e6af3d-a4c7-4637-a732-fbf208bea189"
            },
            {
                "group": "left",
                "id": "1b7bf6b8-d93b-4c29-9001-de1ae1b043b7"
            }
        ],
        "shape": "custom-rect",
        "id": "b7af14b3-e20a-4421-966c-4f370636193c",
        "zIndex": 2
    },
    {
        "label": "连接",
        "x": 570.5,
        "y": 343,
        "ports": [
            {
                "group": "top",
                "id": "7210f722-bb69-449f-9c31-769f3b612ec8"
            },
            {
                "group": "right",
                "id": "7c2f5921-a4ac-400d-8260-f4a75e8dd9e4"
            },
            {
                "group": "bottom",
                "id": "1bbebb2b-8d52-49ac-b0fe-b6da02d82295"
            },
            {
                "group": "left",
                "id": "3884d732-c382-4db5-a340-04435decf83f"
            }
        ],
        "shape": "custom-circle",
        "id": "c02638ad-28e3-44d8-acb2-132291921774",
        "zIndex": 3
    }
]

数据处理: 如果后端能同意这样存储到后端那当然可以啦,如果不行,那就需要处理一下,比如:

typescript 复制代码
// 【保存】数据处理
const saveData = (jsondata) =>{
    return jsondata.map(item =>{
        if(item.shape === 'edge'){
            return item
        }
        return {
            label: item.attrs?.text?.text,
            x: item.position?.x,
            y: item.position?.y,
            ports: item.ports?.items,
            data: item.data, // 该节点的自定义数据
            shape: item.shape,
            id: item.id,
            zIndex: item.zIndex,
        }
    })
}

当然,你可以按照你的实际情况,按照跟后端约定好的数据格式处理,之后进行存储

2.2 回显

存储到后端之后,我们在编辑或者查看场景下,还是需要回显一下流程图的,官网的图形回显渲染api是:

typescript 复制代码
graph.fromJSON(jsondata)

1、【直接回显】

如果你是在保存的时候,保存的graph.toJSON()api保存的原json数据,那么直接调用graph.fromJSON就可以了
2、【处理后回显】

如果你保存时进行了【数据处理】,那么,就需要把处理的json数据,还原成graph.toJSON()得到的这种数据格式之后,再调用graph.fromJSON回显数据

end~

希望记录的问题能帮助到你!

相关推荐
崔庆才丨静觅4 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60615 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了5 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅5 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅6 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅6 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment6 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅6 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊6 小时前
jwt介绍
前端
爱敲代码的小鱼6 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax