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~

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

相关推荐
秋名山大前端3 小时前
Chrome GPU 加速优化配置(前端 3D 可视化 / 数字孪生专用)
前端·chrome·3d
luquinn3 小时前
实现统一门户登录跳转免登录
开发语言·前端·javascript
用户21411832636023 小时前
dify案例分享-5分钟搭建智能思维导图系统!Dify + MCP工具实战教程
前端
augenstern4163 小时前
HTML(面试)
前端
excel3 小时前
前端常见布局误区:1fr 为什么撑爆了我的容器?
前端
烛阴3 小时前
TypeScript 类型魔法:像遍历对象一样改造你的类型
前端·javascript·typescript
vayy3 小时前
uniapp中 ios端 scroll-view 组件内部子元素z-index失效问题
前端·ios·微信小程序·uni-app
专注API从业者4 小时前
基于 Node.js 的淘宝 API 接口开发:快速构建异步数据采集服务
大数据·前端·数据库·数据挖掘·node.js
前端无冕之王4 小时前
一份兼容多端的HTML邮件模板实践与详解
前端·css·数据库·html