X6官方示例「数据加工DAG图」转为Vue版

组件:

基于vue@3.5.18开发,封装成workflow-view组件

使用@antv/x6-vue-shape插件,将vue组件作为节点渲染

横向布局转为纵向布局(主要是连节点位置调整)

使用@antv/x6-plugin-dnd插件,实现拖拽插入节点,移除了下拉选择添加下个节点

调整节点状态样式,节点布局调整,

v-tooltip指令

点击节点中'配置'图标触发自定义事件node:config事件,用于节点属性配置

组件属性:interacting:是否可交互;initData:场景初始数据

workflow-view/index.vue

vue 复制代码
<template>
  <div ref="container" style="width: 100%; height: 100%; overflow: hidden" />
</template>
<script lang="ts" setup>
import { ref, useTemplateRef, onMounted, watchEffect, defineExpose, nextTick } from 'vue'
import { Graph, Node, Path, Edge, Platform, StringExt } from '@antv/x6'
import { Selection } from '@antv/x6-plugin-selection'
import { register } from '@antv/x6-vue-shape'
import DataProcessingDagNode from './components/DataProcessingDagNode.vue'
import { Dnd } from '@antv/x6-plugin-dnd'

let graph: Graph
let dnd: any

const { interacting = true, initData } = defineProps<{
  interacting?: boolean
  initData?: Record<string, any>[]
}>()
const emits = defineEmits(['node:config'])
const container = useTemplateRef('container')

watchEffect(
  async () => {
    if (initData.length) {
      await nextTick()
      setTimeout(() => {
        renderScene(initData)
      }, 200)
    }
  },
  { flush: 'post' },
)

register({
  shape: 'data-processing-dag-node',
  width: 180,
  height: 48,
  component: DataProcessingDagNode,
  // port默认不可见
  ports: {
    groups: {
      in: {
        position: {
          name: 'top',
          args: {
            // dx: -14,
          },
        },
        attrs: {
          circle: {
            r: 4,
            magnet: true,
            stroke: 'transparent',
            strokeWidth: 1,
            fill: 'transparent',
          },
        },
      },

      out: {
        position: {
          name: 'bottom',
          args: {
            // dx: -14,
          },
        },

        attrs: {
          circle: {
            r: 4,
            magnet: true,
            stroke: 'transparent',
            strokeWidth: 1,
            fill: 'transparent',
          },
        },
      },
    },
  },
})

// 注册连接器
Graph.registerConnector(
  'curveConnector',
  (s, e) => {
    const offset = 4
    const deltaY = Math.abs(e.y - s.y)
    const control = Math.floor((deltaY / 3) * 2)

    const v1 = { x: s.x, y: s.y + offset + control }
    const v2 = { x: e.x, y: e.y - offset - control }

    return Path.normalize(
      `M ${s.x} ${s.y}
       L ${s.x} ${s.y + offset}
       C ${v1.x} ${v1.y} ${v2.x} ${v2.y} ${e.x} ${e.y - offset}
       L ${e.x} ${e.y}
      `,
    )
  },
  true,
)

Edge.config({
  markup: [
    {
      tagName: 'path',
      selector: 'wrap',
      attrs: {
        fill: 'none',
        cursor: 'pointer',
        stroke: 'transparent',
        strokeLinecap: 'round',
      },
    },
    {
      tagName: 'path',
      selector: 'line',
      attrs: {
        fill: 'none',
        pointerEvents: 'none',
      },
    },
  ],
  connector: { name: 'curveConnector' },
  attrs: {
    wrap: {
      connection: true,
      strokeWidth: 10,
      strokeLinejoin: 'round',
    },
    line: {
      connection: true,
      stroke: '#A2B1C3',
      strokeWidth: 1,
      targetMarker: {
        name: 'classic',
        size: 6,
      },
    },
  },
})

Graph.registerEdge('data-processing-curve', Edge, true)

onMounted(() => {
  init()
})

function init() {
  graph = new Graph({
    container: container.value!,
    interacting: interacting, // 禁止元素拖拽
    autoResize: true,
    panning: {
      enabled: true,
      eventTypes: ['leftMouseDown', 'mouseWheel'],
    },
    mousewheel: {
      enabled: true,
      modifiers: 'ctrl',
      factor: 1.1,
      maxScale: 1.5,
      minScale: 0.5,
    },
    highlighting: {
      magnetAdsorbed: {
        name: 'stroke',
        args: {
          attrs: {
            fill: '#fff',
            stroke: '#31d0c6',
            strokeWidth: 4,
          },
        },
      },
    },
    connecting: {
      snap: true,
      allowBlank: false,
      allowLoop: false,
      highlight: true,
      sourceAnchor: {
        name: 'top',
        args: {
          dy: Platform.IS_SAFARI ? 4 : 8,
        },
      },
      targetAnchor: {
        name: 'bottom',
        args: {
          dy: Platform.IS_SAFARI ? 4 : -8,
        },
      },
      createEdge() {
        return graph.createEdge({
          shape: 'data-processing-curve',
          // attrs: {
          //   line: {
          //     strokeDasharray: '5 5',
          //   },
          // },
          zIndex: -1,
        })
      },
      // 连接桩校验
      validateConnection({ sourceMagnet, targetMagnet }) {
        // 只能从输出链接桩创建连接
        if (!sourceMagnet || sourceMagnet.getAttribute('port-group') === 'in') {
          return false
        }
        // 只能连接到输入链接桩
        if (!targetMagnet || targetMagnet.getAttribute('port-group') !== 'in') {
          return false
        }
        return true
      },
    },
  })
  if (interacting) {
    dnd = new Dnd({
      target: graph,
    })
    graph.use(
      new Selection({
        multiple: true,
        rubberEdge: true,
        rubberNode: true,
        modifiers: 'shift',
        rubberband: true,
      }),
    )
    graph.on('node:mouseenter', ({ node }) => {
      node.addTools({
        name: 'button-remove',
        args: {
          x: '100%',
          y: 0,
          offset: { x: -10, y: 10 },
        },
      })
    })
    graph.on('node:mouseleave', ({ node }) => {
      node.removeTools()
    })
    graph.on('edge:mouseenter', ({ cell }) => {
      cell.addTools([
        { name: 'vertices' },
        {
          name: 'button-remove',
          args: { distance: '50%' },
        },
      ])
    })
    // 节点单击事件
    graph.on('node:config', async (val) => {
      emits('node:config', val)
      // RightPanelRef.value.show(val)
    })

    graph.on('edge:mouseleave', ({ cell }) => {
      if (cell.hasTool('button-remove')) {
        cell.removeTool('button-remove')
      }
    })
  }
}

function renderScene(json) {
  graph?.fromJSON(json)
  // 居中显示
  graph.zoomToFit({
    maxScale: 1,
    padding: {
      left: 10,
      right: 10,
      top: 30,
      bottom: 30,
    },
  })
}

function startDrag(e: any, item) {
  if (!interacting) return

  // 该 node 为拖拽的节点,默认也是放置到画布上的节点,可以自定义任何属性
  const id = Date.now() + Math.random().toString(36).slice(-5)
  const node = graph.createNode({
    // custom_id: id, // 没用
    shape: 'data-processing-dag-node',
    ports: getPortsByType(item.componentType, id),
    data: {
      componentDefId: item.componentDefId,
      name: item.name,
      nodeName: item.name,
      componentType: item.componentType,
    },
  })
  dnd?.start(node, e)
}
enum NodeType {
  RECEIVE = 'RECEIVE', // 数据输入
  PROCESS = 'PROCESS', // 数据过滤
  // JOIN = 'JOIN', // 数据连接
  // UNION = 'UNION', // 数据合并
  // AGG = 'AGG', // 数据聚合
  SEND = 'SEND', // 数据输出
}
function getPortsByType(type: NodeType, nodeId: string) {
  let ports = []
  switch (type) {
    case NodeType.RECEIVE:
      ports = [
        {
          id: `${nodeId}-out`,
          group: 'out',
        },
      ]
      break
    case NodeType.SEND:
      ports = [
        {
          id: `${nodeId}-in`,
          group: 'in',
        },
      ]
      break
    default:
      ports = [
        {
          id: `${nodeId}-in`,
          group: 'in',
        },
        {
          id: `${nodeId}-out`,
          group: 'out',
        },
      ]
      break
  }
  return ports
}

function getData() {
  return graph.toJSON().cells
}
// '清除'按钮
function clear() {
  graph.fromJSON([])
  graph.zoomTo(1)
}

// 显示节点状态
function showNodeStatus(
  edgeStatusList: {
    id: string
    status: 'success' | 'error'
    statusMsg?: string
  }[],
) {
  nodeStatusList.forEach((item) => {
    const { id, status, statusMsg } = item
    const node = graph.getCellById(id)
    const data = node.getData()
    node.setData({
      ...data,
      status,
      statusMsg,
    })
  })
}

// 开启边的运行动画
function excuteAnimate() {
  graph.getEdges().forEach((edge) => {
    edge.attr({
      line: {
        stroke: '#3471F9',
      },
    })
    edge.attr('line/strokeDasharray', 5)
    edge.attr('line/style/animation', 'running-line 30s infinite linear')
  })
}

// 关闭边的动画
function stopAnimate(
  edgeStatusList: {
    id: string
    status: 'success' | 'error'
    statusMsg?: string
  }[],
) {
  graph.getEdges().forEach((edge) => {
    edge.attr('line/strokeDasharray', 0)
    edge.attr('line/style/animation', '')
  })
  edgeStatusList.forEach((item) => {
    const { id, status } = item
    const edge = graph.getCellById(id)
    if (status === 'success') {
      edge.attr('line/stroke', '#52c41a')
    }
    if (status === 'error') {
      edge.attr('line/stroke', '#ff4d4f')
    }
  })
}

defineExpose({
  renderScene,
  showNodeStatus,
  excuteAnimate,
  stopAnimate,
  startDrag,
  getData,
  clear,
})
</script>

workflow-view/components/DataProcessingDagNode.vue

vue 复制代码
<template>
  <div style="width: 100%; height: 100%">
    <div class="data-processing-dag-node">
      <div
        class="main-area"
        :class="{
          success: nodeData?.status === CellStatus.SUCCESS,
          error: nodeData?.status === CellStatus.ERROR,
        }"
        @mouseenter="onMainMouseEnter"
      >
        <div class="flex-vcenter" style="gap: 6px">
          <!-- 节点类型icon -->
          <i
            class="node-logo"
            :style="{ backgroundImage: `url(${NODE_TYPE_LOGO?.[nodeData?.componentType] as any})` }"
          />
          <div class="flex-1 text-ellipsis" v-tooltip:top="nodeData?.nodeName">
            {{ nodeData?.nodeName }}
          </div>
          <!-- 字体图标自行替换 -->
          <i
            class="more-action iconfont icon-peizhi pointer"
            v-tooltip="'配置'"
            @click="handleEdit()"
          />
        </div>
      </div>
      <!-- 添加下游节点 -->
      <!-- <div v-if="nodeData?.componentType !== NodeType.SEND">
        <el-dropdown trigger="click" popper-class="processing-node-menu" class="plus-dag">
          <i class="plus-action" :class="{ 'plus-action-selected': plusActionSelected }" />
          <template #dropdown>
            <el-dropdown-menu>
              <el-dropdown-item v-for="item in PROCESSING_TYPE_LIST" class="each-sub-menu">
                <a @click="clickPlusDragMenu(item.type)">
                  <i
                    class="node-mini-logo"
                    :style="{ backgroundImage: `url(${NODE_TYPE_LOGO[item.type]})` }"
                  />
                  <span>{{ item.name }}</span>
                </a>
              </el-dropdown-item>
            </el-dropdown-menu>
          </template>
        </el-dropdown>
      </div> -->
    </div>
  </div>
</template>
<script lang="ts" setup>
import { defineComponent, ref, shallowRef, onMounted, inject, defineExpose } from 'vue'
import { Graph, Node, Path, Edge, Platform, StringExt } from '@antv/x6'
import { ElDropdown, ElDropdownMenu, ElDropdownItem } from 'element-plus'
import { Tooltip } from '@/directives/tooltip.ts'
  
const vTooltip = Tooltip // 指令
enum NodeType {
  RECEIVE = 'RECEIVE', // 数据输入
  PROCESS = 'PROCESS', // 数据过滤
  // JOIN = 'JOIN', // 数据连接
  // UNION = 'UNION', // 数据合并
  // AGG = 'AGG', // 数据聚合
  SEND = 'SEND', // 数据输出
}
// 元素校验状态
enum CellStatus {
  DEFAULT = 'default',
  SUCCESS = 'success',
  ERROR = 'error',
}

// 节点位置信息
interface Position {
  x: number
  y: number
}

// 加工类型列表
const PROCESSING_TYPE_LIST: { type: NodeType; name: string } = [
  {
    type: 'PROCESS',
    name: '数据筛选',
  },
  // {
  //   type: 'JOIN',
  //   name: '数据连接',
  // },
  // {
  //   type: 'UNION',
  //   name: '数据合并',
  // },
  // {
  //   type: 'AGG',
  //   name: '数据聚合',
  // },

  // {
  //   type: 'SEND',
  //   name: '数据输出',
  // },
]

// 不同节点类型的icon
const NODE_TYPE_LOGO: Record<string, string> = {
  RECEIVE:
    'https://mdn.alipayobjects.com/huamei_f4t1bn/afts/img/A*RXnuTpQ22xkAAAAAAAAAAAAADtOHAQ/original', // 数据输入
  PROCESS:
    'https://mdn.alipayobjects.com/huamei_f4t1bn/afts/img/A*ZJ6qToit8P4AAAAAAAAAAAAADtOHAQ/original', // 数据筛选
  JOIN: 'https://mdn.alipayobjects.com/huamei_f4t1bn/afts/img/A*EHqyQoDeBvIAAAAAAAAAAAAADtOHAQ/original', // 数据连接
  UNION:
    'https://mdn.alipayobjects.com/huamei_f4t1bn/afts/img/A*k4eyRaXv8gsAAAAAAAAAAAAADtOHAQ/original', // 数据合并
  AGG: 'https://mdn.alipayobjects.com/huamei_f4t1bn/afts/img/A*TKG8R6nfYiAAAAAAAAAAAAAADtOHAQ/original', // 数据聚合
  SEND: 'https://mdn.alipayobjects.com/huamei_f4t1bn/afts/img/A*zUgORbGg1HIAAAAAAAAAAAAADtOHAQ/original', // 数据输出
}

const plusActionSelected = ref(false)
const node = shallowRef<Node>({})
const nodeData = shallowRef<Record<string, any>>({})
const getNode: any = inject('getNode')

node.value = getNode() as Node
nodeData.value = node.value?.getData() as Record<string, any>

onMounted(() => {
  node.value.on('change:data', ({ current }) => {
    nodeData.value = current
  })
})

function createDownstream(type: NodeType) {
  const { graph } = node.value?.model || {}
  if (graph) {
    // 获取下游节点的初始位置信息
    const position = getDownstreamNodePosition(node.value, graph)
    // 创建下游节点
    const newNode: any = createNode(type, graph, position)
    const source: string = node.value.id
    const target: string = newNode.id
    // 创建该节点出发到下游节点的边
    createEdge(source, target, graph)
  }
}
function createNode(type: NodeType, graph: Graph, position?: Position) {
  if (!graph) {
    return {}
  }
  let newNode = {}
  const sameTypeNodes = graph.getNodes().filter((item) => item.getData()?.type === type)
  const typeName = PROCESSING_TYPE_LIST?.find((item) => item.type === type)?.name
  const id = StringExt.uuid()
  const node = {
    id,
    shape: 'data-processing-dag-node',
    x: position?.x,
    y: position?.y,
    ports: getPortsByType(type, id),
    data: {
      name: `${typeName}_${sameTypeNodes.length + 1}`,
      type,
    },
  }
  newNode = graph.addNode(node)
  return newNode
}
function createEdge(source: string, target: string, graph: Graph) {
  const edge = {
    id: StringExt.uuid(),
    shape: 'data-processing-curve',
    source: {
      cell: source,
      port: `${source}-out`,
    },
    target: {
      cell: target,
      port: `${target}-in`,
    },
    zIndex: -1,
    data: {
      source,
      target,
    },
  }
  if (graph) {
    graph.addEdge(edge)
  }
}
// 根据节点的类型获取ports
function getPortsByType(type: NodeType, nodeId: string) {
  let ports = []
  switch (type) {
    case NodeType.RECEIVE:
      ports = [
        {
          id: `${nodeId}-out`,
          group: 'out',
        },
      ]
      break
    case NodeType.SEND:
      ports = [
        {
          id: `${nodeId}-in`,
          group: 'in',
        },
      ]
      break
    default:
      ports = [
        {
          id: `${nodeId}-in`,
          group: 'in',
        },
        {
          id: `${nodeId}-out`,
          group: 'out',
        },
      ]
      break
  }
  return ports
}
function getDownstreamNodePosition(node: Node, graph: Graph, dx = 220, dy = 100) {
  // 找出画布中以该起始节点为起点的相关边的终点id集合
  const downstreamNodeIdList: string[] = []
  graph.getEdges().forEach((edge: any) => {
    const originEdge = edge.toJSON()?.data || {}
    if (originEdge.source === node.id) {
      downstreamNodeIdList.push(originEdge.target)
    }
  })
  // 获取起点的位置信息
  const position = node.getPosition()
  let minX = Infinity
  let maxY = -Infinity
  graph.getNodes().forEach((graphNode) => {
    if (downstreamNodeIdList.indexOf(graphNode.id) > -1) {
      const nodePosition = graphNode.getPosition()
      // 找到所有节点中最左侧的节点的x坐标
      if (nodePosition.x < minX) {
        minX = nodePosition.x
      }
      // 找到所有节点中最x下方的节点的y坐标
      if (nodePosition.y > maxY) {
        maxY = nodePosition.y
      }
    }
  })

  return {
    x: minX !== Infinity ? minX + dx : position.x,
    y: maxY !== -Infinity ? maxY : position.y + dy,
  }
}
function clickPlusDragMenu(type: NodeType) {
  createDownstream(type)
  plusActionSelected.value = false
}
function onPlusDropdownOpenChange(value: boolean) {
  plusActionSelected.value = value
}
function onMainMouseEnter() {
  const ports = node.value?.getPorts() || []
  ports.forEach((port) => {
    node.value.setPortProp(port.id, 'attrs/circle', {
      fill: '#fff',
      stroke: '#85A5FF',
    })
  })
}
function onMainMouseLeave() {
  // 获取该节点下的所有连接桩
  const ports = node.value?.getPorts() || []
  ports.forEach((port) => {
    node.value.setPortProp(port.id, 'attrs/circle', {
      fill: 'transparent',
      stroke: 'transparent',
    })
  })
}
function handleEdit() {
  const { graph } = node.value?.model || {}
  if (graph) {
    graph.emit('node:config', { data: { ...nodeData.value }, node: node.value })
  }
}
defineExpose({ getPortsByType })
</script>
<style lang="scss" scoped>
.flex-vcenter {
  display: flex;
  align-items: center;
}
.flex-1 {
  flex: 1;
}
.text-ellipsis {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.data-processing-dag-node {
  display: flex;
  flex-direction: row;
  align-items: center;
}

.main-area {
  padding: 12px;
  width: 180px;
  height: 48px;
  color: rgba(0, 0, 0, 65%);
  font-size: 12px;
  font-family: PingFangSC;
  line-height: 24px;
  background-color: #fff;
  box-shadow:
    0 -1px 4px 0 rgba(209, 209, 209, 50%),
    1px 1px 4px 0 rgba(217, 217, 217, 50%);
  border-radius: 2px;
  border: 1px solid transparent;
  border-radius: 4px;
  overflow: hidden;
}
.main-area:hover {
  border: 1px solid rgba(0, 0, 0, 10%);

  box-shadow:
    0 -2px 4px 0 rgba(209, 209, 209, 50%),
    2px 2px 4px 0 rgba(217, 217, 217, 50%);
}

.node-logo {
  display: inline-block;
  width: 24px;
  height: 24px;
  background-repeat: no-repeat;
  background-position: center;
  background-size: 100%;
}

.node-name {
  overflow: hidden;
  display: inline-block;
  width: 95px;
  margin-left: 6px;
  color: rgba(0, 0, 0, 65%);
  font-size: 12px;
  font-family: PingFangSC;
  white-space: nowrap;
  text-overflow: ellipsis;
  vertical-align: top;
}

.status-action {
  display: flex;
  flex-direction: row;
  align-items: center;
}

.status-icon {
  display: inline-block;
  width: 24px;
  height: 24px;
}

.status-icon-error {
  background: url('@/assets/icon-error.png') no-repeat center center / 100% 100%;
}

.status-icon-success {
  background: url('@/assets/icon-success.png') no-repeat center center / 100% 100%;
}

.more-action-container {
  width: 15px;
  height: 15px;
  text-align: center;
  cursor: pointer;
}

.more-action {
  visibility: hidden;
  pointer-events: none;
}

.x6-node-selected .more-action {
  visibility: visible;
  pointer-events: all;
}

.plus-dag {
  visibility: hidden;
  position: absolute;
  bottom: -6px;
  left: calc(50% - 8px);
}

.plus-action {
  position: absolute;
  width: 16px;
  height: 16px;
  background: url('https://mdn.alipayobjects.com/huamei_f4t1bn/afts/img/A*ScX2R4ODfokAAAAAAAAAAAAADtOHAQ/original')
    no-repeat center center / 100% 100%;
  cursor: pointer;
}
.plus-action:hover {
  background-image: url('https://mdn.alipayobjects.com/huamei_f4t1bn/afts/img/A*tRaoS5XhsuQAAAAAAAAAAAAADtOHAQ/original');
}

.plus-action:active,
.plus-action-selected {
  background-image: url('https://mdn.alipayobjects.com/huamei_f4t1bn/afts/img/A*k9cnSaSmlw4AAAAAAAAAAAAADtOHAQ/original');
}

.x6-node-selected .main-area {
  border-color: #3471f9;
}

.x6-node-selected .plus-dag {
  visibility: visible;
}

.processing-node-menu {
  padding: 2px 0;
  width: 105px;
  background-color: #fff;
  box-shadow:
    0 9px 28px 8px rgba(0, 0, 0, 5%),
    0 6px 16px 0 rgba(0, 0, 0, 8%),
    0 3px 6px -4px rgba(0, 0, 0, 12%);
  border-radius: 2px;
}
.processing-node-menu ul {
  margin: 0;
  padding: 0;
}
.processing-node-menu li {
  list-style: none;
}

.each-sub-menu {
  padding: 6px 12px;
  width: 100%;
}

.each-sub-menu:hover {
  background-color: rgba(0, 0, 0, 4%);
}

.each-sub-menu a {
  display: inline-block;
  width: 100%;
  height: 16px;
  font-family: PingFangSC;
  font-weight: 400;
  font-size: 12px;
  color: rgba(0, 0, 0, 65%);
}

.each-sub-menu span {
  margin-left: 8px;
  vertical-align: top;
}

.each-disabled-sub-menu a {
  cursor: not-allowed;
  color: rgba(0, 0, 0, 35%);
}

.node-mini-logo {
  display: inline-block;
  width: 16px;
  height: 16px;
  background-repeat: no-repeat;
  background-position: center;
  background-size: 100%;
  vertical-align: top;
}

@keyframes running-line {
  to {
    stroke-dashoffset: -1000;
  }
}

.main-area.success {
  border-color: #52c41a;
  border-left-width: 4px;
}
.main-area.error {
  border-color: #ff4d4f;
  border-left-width: 4px;
}
</style>

@/directives/tooltip.ts

typescript 复制代码
import type { App } from 'vue'
import { ElTooltip } from 'element-plus'
import { useDirectiveComponent } from '@/hooks/useDirectiveComponent'

export const Tooltip = useDirectiveComponent(ElTooltip, (el: any, binding: Record<string, any>) => {
  return {
    content: typeof binding.value === 'boolean' ? undefined : binding.value,
    placement: binding.arg,
    enterable: false,
    hideAfter: 0,
    'virtual-triggering': true,
    'virtual-ref': el,
  }
})

export default (app: App) => {
  app.directive('tooltip', Tooltip)
}

@/hooks/useDirectiveComponent

typescript 复制代码
import { h, mergeProps, render, resolveComponent } from 'vue'

export function useDirectiveComponent(component: any, props: any) {
  const concreteComponent = typeof component === 'string' ? resolveComponent(component) : component
  const hook = mountComponent(concreteComponent, props)
  return {
    mounted: hook,
    updated: hook,
    unmounted(el: any) {
      render(null, el)
    },
  }
}

function mountComponent(component: any, props: any) {
  return function (el: any, binding: any) {
    const _props = typeof props === 'function' ? props(el, binding) : props
    const text = binding.value?.text ?? binding.value ?? _props?.text
    const value = binding.value && typeof binding.value === 'object' ? binding.value : {}

    const children = () => text ?? el.textContent

    const node = h(component, mergeProps(_props, value), children)

    render(node, el)
  }
}

用法:

vue 复制代码
<template>
  <div style="display: flex">
      <!-- 拖拽列表 -->
      <div style="width: 200px">
          <div
            v-for="item in componentList"
            class="node pointer text-ellipsis"
            @mousedown="($event) => workflowViewRef.startDrag($event, item)"
          >
            {{ item.name }}
          </div>
      </div>
      <div style="height: 300px; flex:1; overflow:hidden">
        <workflowView
          ref="workflowViewRef"
          :initData="data"
          @node:config="handleNodeConfig"
        />
      </div>
  </div>
  
</template>
<script lang="ts" setup>
import { ref, useTemplateRef, onMounted, nextTick, defineExpose } from 'vue'

const workflowViewRef = useTemplateRef('workflowViewRef')
// 拖拽节点列表
const componentList= Ref([
  {
    "componentDefId": "1964962263787741185",
    "name": "接收组件",
    "componentType": "RECEIVE",
  },
  {
    "componentDefId": "1964962263787741186",
    "name": "过程组件",
    "componentType": "PROCESS",
  },
  {
    "componentDefId": "1964962263787741187",
    "name": "发送组件",
    "componentType": "SEND",
  },
])
// 演示数据
const data = ref([
  {
    "shape": "data-processing-curve",
    "connector": {
      "name": "curveConnector"
    },
    "id": "434e0975-a09a-4b51-b265-03e1fc594ec4",
    "zIndex": -1,
    "source": {
      "cell": "6c827f2f-7861-4cd3-9621-c97b1f41d4e0",
      "port": "1757412449213blq1o-out"
    },
    "target": {
      "cell": "ec51f09f-a546-4112-a463-c3ce6e123273",
      "port": "1757412016182wgqto-in"
    },
    "tools": {
      "items": [
        {
          "name": "vertices"
        },
        {
          "name": "vertices"
        },
        {
          "name": "vertices"
        }
      ],
      "name": null
    }
  },
  {
    "shape": "data-processing-curve",
    "connector": {
      "name": "curveConnector"
    },
    "id": "70c881ea-3165-40e2-a940-5c90d6e9e260",
    "zIndex": -1,
    "source": {
      "cell": "ec51f09f-a546-4112-a463-c3ce6e123273",
      "port": "1757412016182wgqto-out"
    },
    "target": {
      "cell": "7fe9aec4-999a-4dfb-a99d-b209df2e2a58",
      "port": "1757412446647i7yfn-in"
    },
    "tools": {
      "items": [
        {
          "name": "vertices"
        },
        {
          "name": "vertices"
        },
        {
          "name": "vertices"
        },
        {
          "name": "vertices"
        },
        {
          "name": "vertices"
        },
        {
          "name": "vertices"
        },
        {
          "name": "vertices"
        }
      ],
      "name": null
    }
  },
  {
    "position": {
      "x": 380,
      "y": 170
    },
    "size": {
      "width": 180,
      "height": 48
    },
    "view": "vue-shape-view",
    "shape": "data-processing-dag-node",
    "ports": {
      "groups": {
        "in": {
          "position": {
            "name": "top",
            "args": {}
          },
          "attrs": {
            "circle": {
              "r": 4,
              "magnet": true,
              "stroke": "transparent",
              "strokeWidth": 1,
              "fill": "transparent"
            }
          }
        },
        "out": {
          "position": {
            "name": "bottom",
            "args": {}
          },
          "attrs": {
            "circle": {
              "r": 4,
              "magnet": true,
              "stroke": "transparent",
              "strokeWidth": 1,
              "fill": "transparent"
            }
          }
        }
      },
      "items": [
        {
          "id": "1757412016182wgqto-in",
          "group": "in",
          "attrs": {
            "circle": {
              "fill": "#fff",
              "stroke": "#85A5FF"
            }
          }
        },
        {
          "id": "1757412016182wgqto-out",
          "group": "out",
          "attrs": {
            "circle": {
              "fill": "#fff",
              "stroke": "#85A5FF"
            }
          }
        }
      ]
    },
    "id": "ec51f09f-a546-4112-a463-c3ce6e123273",
    "data": {
      "componentDefId": "1964957852445286401",
      "name": "mapping-test",
      "nodeName": "mapping-test",
      "componentType": "PROCESS"
    },
    "zIndex": 1
  },
  {
    "position": {
      "x": 380,
      "y": 270
    },
    "size": {
      "width": 180,
      "height": 48
    },
    "view": "vue-shape-view",
    "shape": "data-processing-dag-node",
    "ports": {
      "groups": {
        "in": {
          "position": {
            "name": "top",
            "args": {}
          },
          "attrs": {
            "circle": {
              "r": 4,
              "magnet": true,
              "stroke": "transparent",
              "strokeWidth": 1,
              "fill": "transparent"
            }
          }
        },
        "out": {
          "position": {
            "name": "bottom",
            "args": {}
          },
          "attrs": {
            "circle": {
              "r": 4,
              "magnet": true,
              "stroke": "transparent",
              "strokeWidth": 1,
              "fill": "transparent"
            }
          }
        }
      },
      "items": [
        {
          "id": "1757412446647i7yfn-in",
          "group": "in",
          "attrs": {
            "circle": {
              "fill": "#fff",
              "stroke": "#85A5FF"
            }
          }
        }
      ]
    },
    "id": "7fe9aec4-999a-4dfb-a99d-b209df2e2a58",
    "data": {
      "componentDefId": "1964853143181004801",
      "name": "SFTP发送",
      "nodeName": "SFTP发送",
      "componentType": "SEND"
    },
    "zIndex": 2
  },
  {
    "position": {
      "x": 380,
      "y": 70
    },
    "size": {
      "width": 180,
      "height": 48
    },
    "view": "vue-shape-view",
    "shape": "data-processing-dag-node",
    "ports": {
      "groups": {
        "in": {
          "position": {
            "name": "top",
            "args": {}
          },
          "attrs": {
            "circle": {
              "r": 4,
              "magnet": true,
              "stroke": "transparent",
              "strokeWidth": 1,
              "fill": "transparent"
            }
          }
        },
        "out": {
          "position": {
            "name": "bottom",
            "args": {}
          },
          "attrs": {
            "circle": {
              "r": 4,
              "magnet": true,
              "stroke": "transparent",
              "strokeWidth": 1,
              "fill": "transparent"
            }
          }
        }
      },
      "items": [
        {
          "id": "1757412449213blq1o-out",
          "group": "out",
          "attrs": {
            "circle": {
              "fill": "#fff",
              "stroke": "#85A5FF"
            }
          }
        }
      ]
    },
    "id": "6c827f2f-7861-4cd3-9621-c97b1f41d4e0",
    "data": {
      "componentDefId": "1964852291938619394",
      "name": "SFTP接收",
      "nodeName": "SFTP接收",
      "componentType": "RECEIVE"
    },
    "zIndex": 3
  }
])
function handleNodeConfig(data) {
  console.log(data)
}
</script>
<style lang="scss" scoped>
  .node {
    padding: 10px;
    border: 1px solid #ccc;
    border-radius: 4px;
    margin-bottom: 10px;
    cursor: move;
  }
</style>
相关推荐
南雨北斗4 小时前
vue3 attribute绑定
前端
一枚前端小能手4 小时前
🚀 主线程卡死用户要骂娘?Web Worker让你的应用丝滑如德芙
前端·javascript
小桥风满袖4 小时前
极简三分钟ES6 - Promise
前端·javascript
breeze_whisper4 小时前
当前端收到一个比梦想还大的数字:BigInt处理指南
前端·面试
小喷友4 小时前
阶段四:实战(项目开发能力)
前端·rust
小高0074 小时前
性能优化零成本:只加3行代码,FCP从1.8s砍到1.2s
前端·javascript·面试
用户66982061129824 小时前
vue3 hooks、utils、data这几个文件夹分别是放什么的?
javascript·vue.js
子兮曰4 小时前
🌏浏览器硬件API大全:30个颠覆性技术让你重新认识Web开发
前端·javascript·浏览器
即兴小索奇4 小时前
Google AI Mode 颠覆传统搜索方式,它是有很大可能的
前端·后端·架构