Bootstrap

Vue3 + antv/x6 实现流程图

新建流程图

在这里插入图片描述

// AddDag.vue
<template>
  <div class="content-main">
    <div class="tool-container">
      <div @click="undo" class="command" title="后退">
        <Icon icon="ant-design:undo-outlined" />
      </div>
      <div @click="redo" class="command" title="前进">
        <Icon icon="ant-design:redo-outlined" />
      </div>
      <el-divider direction="vertical" />
      <div @click="copy" class="command" title="复制">
        <Icon icon="ant-design:copy-filled" />
      </div>
      <div @click="paste" class="command" title="粘贴">
        <Icon icon="fa-solid:paste" />
      </div>
      <div @click="del" class="command" title="删除">
        <Icon icon="ant-design:delete-filled" />
      </div>
      <el-divider direction="vertical" />
      <div @click="save" class="command" title="保存">
        <Icon icon="ant-design:save-filled" />
      </div>
      <el-divider direction="vertical" />
      <div @click="exportPng" class="command" title="导出PNG">
        <Icon icon="ant-design:file-image-filled" />
      </div>
    </div>
    <div class="content-container" id="">
      <div class="content">
        <div class="stencil" ref="stencilContainer"></div>
        <div class="graph-content" id="graphContainer" ref="graphContainer"> </div>

        <div class="editor-sidebar">
          <div class="edit-panel">
            <el-card shadow="never">
              <template #header>
                <div class="card-header">
                  <span>{{ cellFrom.title }}</span>
                </div>
              </template>
              <el-form :model="nodeFrom" label-width="50px" v-if="nodeFrom.show">
                <el-form-item label="label">
                  <el-input v-model="nodeFrom.label" @blur="changeLabel" />
                </el-form-item>
                <el-form-item label="desc">
                  <el-input type="textarea" v-model="nodeFrom.desc" @blur="changeDesc" />
                </el-form-item>
              </el-form>
              <el-form :model="cellFrom" label-width="50px" v-if="cellFrom.show">
                <el-form-item label="label">
                  <el-input v-model="cellFrom.label" @blur="changeEdgeLabel" />
                </el-form-item>
                <!-- <el-form-item label="连线方式">
                    <el-select v-model="cellFrom.edgeType" class="m-2" placeholder="Select"  @change="changeEdgeType">
                      <el-option
                        v-for="item in EDGE_TYPE_LIST"
                        :key="item.type"
                        :label="item.name"
                        :value="item.type"
                      />
                    </el-select>
                  </el-form-item> -->
              </el-form>
            </el-card>
          </div>
          <div>
            <el-card shadow="never">
              <template #header>
                <div class="card-header">
                  <span>Minimap</span>
                </div>
              </template>
              <div class="minimap" ref="miniMapContainer"></div>
            </el-card>
          </div>
        </div>
      </div>
    </div>
    <div v-if="showMenu" class="node-menu" ref="nodeMenu">
      <div
        class="menu-item"
        v-for="(item, index) in PROCESSING_TYPE_LIST"
        :key="index"
        @click="addNodeTool(item)"
      >
        <el-image :src="item.image" style="width: 16px; height: 16px" fit="fill" />
        <span>{{ item.name }}</span>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { Graph, Path, Edge, StringExt, Node, Cell, Model, DataUri } from '@antv/x6'
import { Transform } from '@antv/x6-plugin-transform'
import { Selection } from '@antv/x6-plugin-selection'
import { Snapline } from '@antv/x6-plugin-snapline'
import { Keyboard } from '@antv/x6-plugin-keyboard'
import { Clipboard } from '@antv/x6-plugin-clipboard'
import { History } from '@antv/x6-plugin-history'
import { MiniMap } from '@antv/x6-plugin-minimap'
//import { Scroller } from '@antv/x6-plugin-scroller'
import { Stencil } from '@antv/x6-plugin-stencil'
import { Export } from '@antv/x6-plugin-export'
import { ref, onMounted, reactive, toRefs, nextTick, onUnmounted } from 'vue'
import '@/styles/animation.less'
import { ElMessage, ElCard, ElForm, ElFormItem, ElInput, ElImage, ElDivider } from 'element-plus'

const stencilContainer = ref()
const graphContainer = ref()
const miniMapContainer = ref()

let graph: any = null

const state = reactive({
  cellFrom: {
    title: 'Canvas',
    label: '',
    desc: '',
    show: false,
    id: '',
    edgeType: 'topBottom'
  },
  nodeFrom: {
    title: 'Canvas',
    label: '',
    desc: '',
    show: false,
    id: ''
  },
  showMenu: false,
  data: {
    nodes: [
      {
        id: 'ac51fb2f-2753-4852-8239-53672a29bb14',
        position: {
          x: -340,
          y: -160
        },
        data: {
          name: '诗名',
          type: 'OUTPUT',
          desc: '春望'
        }
      },
      {
        id: '81004c2f-0413-4cc6-8622-127004b3befa',
        position: {
          x: -340,
          y: -10
        },
        data: {
          name: '第一句',
          type: 'SYNC',
          desc: '国破山河在'
        }
      },
      {
        id: '7505da25-1308-4d7a-98fd-e6d5c917d35d',
        position: {
          x: -140,
          y: 180
        },
        data: {
          name: '结束',
          type: 'INPUT',
          desc: '城春草木胜'
        }
      }
    ],
    edges: [
      {
        id: '6eea5dc9-4e15-4e78-959f-ee13ec59d11c',
        shape: 'processing-curve',
        source: { cell: 'ac51fb2f-2753-4852-8239-53672a29bb14', port: '-out' },
        target: { cell: '81004c2f-0413-4cc6-8622-127004b3befa', port: '-in' },
        zIndex: -1,
        data: {
          source: 'ac51fb2f-2753-4852-8239-53672a29bb14',
          target: '81004c2f-0413-4cc6-8622-127004b3befa'
        }
      },
      {
        id: '8cbce713-54be-4c07-8efa-59c505f74ad7',
        labels: ['下半句'],
        shape: 'processing-curve',
        source: { cell: '81004c2f-0413-4cc6-8622-127004b3befa', port: '-out' },
        target: { cell: '7505da25-1308-4d7a-98fd-e6d5c917d35d', port: '-in' },
        data: {
          source: '81004c2f-0413-4cc6-8622-127004b3befa',
          target: '7505da25-1308-4d7a-98fd-e6d5c917d35d'
        }
      }
    ]
  },
  // 节点状态列表
  nodeStatusList: [
    {
      id: 'ac51fb2f-2753-4852-8239-53672a29bb14',
      status: 'success'
    },
    {
      id: '81004c2f-0413-4cc6-8622-127004b3befa',
      status: 'success'
    }
  ],

  // 边状态列表
  edgeStatusList: [
    {
      id: '6eea5dc9-4e15-4e78-959f-ee13ec59d11c',
      status: 'success'
    },
    {
      id: '8cbce713-54be-4c07-8efa-59c505f74ad7',
      status: 'executing'
    }
  ],
  // 加工类型列表
  PROCESSING_TYPE_LIST: [
    {
      type: 'SYNC',
      name: '数据同步',
      image: new URL('@/assets/imgs/persimmon.png', import.meta.url).href
    },
    {
      type: 'INPUT',
      name: '结束',
      image: new URL('@/assets/imgs/lime.png', import.meta.url).href
    }
  ],
  //边类型
  EDGE_TYPE_LIST: [
    {
      type: 'topBottom',
      name: '上下'
    },
    {
      type: 'leftRight',
      name: '左右'
    }
  ]
})

const { cellFrom, nodeFrom, showMenu, PROCESSING_TYPE_LIST } = toRefs(state)

let nodeMenu = ref()

// 节点类型
enum NodeType {
  INPUT = 'INPUT', // 数据输入
  FILTER = 'FILTER', // 数据过滤
  JOIN = 'JOIN', // 数据连接
  UNION = 'UNION', // 数据合并
  AGG = 'AGG', // 数据聚合
  OUTPUT = 'OUTPUT', // 数据输出
  SYNC = 'SYNC' //数据同步
}

// 元素校验状态
// enum CellStatus {
//   DEFAULT = 'default',
//   SUCCESS = 'success',
//   ERROR = 'error'
// }

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

function init() {
  graph = new Graph({
    container: graphContainer.value,
    grid: 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: 'bottom',
      //   args: {
      //     dx: 0,
      //   },
      // },
      // targetAnchor: {
      //   name: 'top',
      //   args: {
      //     dx: 0,
      //   },
      // },
      createEdge() {
        return graph.createEdge({
          shape: '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') === 'out') {
          return false
        }
        return true
      }
    }
  })
  graph.centerContent()

  // #region 使用插件
  graph
    .use(
      new Transform({
        resizing: true,
        rotating: true
      })
    )
    .use(
      new Selection({
        rubberband: true,
        showNodeSelectionBox: true
      })
    )
    .use(
      new MiniMap({
        container: miniMapContainer.value,
        width: 200,
        height: 260,
        padding: 10
      })
    )
    .use(new Snapline())
    .use(new Keyboard())
    .use(new Clipboard())
    .use(new History())
    .use(new Export())
  //.use(new Scroller({
  //  enabled: true,
  //  pageVisible: true,
  //  pageBreak: false,
  //  pannable: true,

  // }))
  // #endregion

  // #region 初始化图形
  const ports = {
    groups: {
      in: {
        position: 'top',
        attrs: {
          circle: {
            r: 4,
            magnet: true,
            stroke: '#5F95FF',
            strokeWidth: 1,
            fill: '#fff',
            style: {
              visibility: 'hidden'
            }
          }
        }
      },
      out: {
        position: 'bottom',
        attrs: {
          circle: {
            r: 4,
            magnet: true,
            stroke: '#31d0c6',
            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'
            }
          }
        }
      },
      right: {
        position: 'right',
        attrs: {
          circle: {
            r: 4,
            magnet: true,
            stroke: '#5F95FF',
            strokeWidth: 1,
            fill: '#fff',
            style: {
              visibility: 'hidden'
            }
          }
        }
      }
    }
    // items: [
    //   {
    //     id: state.currentCode + '-in',
    //     group: 'top',
    //   },
    //   {
    //     id: state.currentCode + '-out',
    //     group: 'out',
    //   }
    // ],
  }

  Graph.registerNode(
    'custom-node',
    {
      inherit: 'rect',
      width: 140,
      height: 76,
      attrs: {
        body: {
          strokeWidth: 1
        },
        image: {
          width: 16,
          height: 16,
          x: 12,
          y: 6
        },
        text: {
          refX: 40,
          refY: 15,
          fontSize: 15,
          'text-anchor': 'start'
        },
        label: {
          text: 'Please nominate this node',
          refX: 10,
          refY: 30,
          fontSize: 12,
          fill: 'rgba(0,0,0,0.6)',
          'text-anchor': 'start',
          textWrap: {
            width: -10, // 宽度减少 10px
            height: '70%', // 高度为参照元素高度的一半
            ellipsis: true, // 文本超出显示范围时,自动添加省略号
            breakWord: true // 是否截断单词
          }
        }
      },
      markup: [
        {
          tagName: 'rect',
          selector: 'body'
        },
        {
          tagName: 'image',
          selector: 'image'
        },
        {
          tagName: 'text',
          selector: 'text'
        },
        {
          tagName: 'text',
          selector: 'label'
        }
      ],
      data: {},
      relation: {},
      ports: { ...ports }
    },
    true
  )

  const stencil = new Stencil({
    //新建节点库
    title: '数据集成',
    target: graph,
    search: false, // 搜索
    collapsable: true,
    stencilGraphWidth: 300, //容器宽度
    stencilGraphHeight: 600, //容器长度
    groups: [
      //分组
      {
        name: 'processLibrary',
        title: 'dataSource'
      }
    ],
    layoutOptions: {
      dx: 30,
      dy: 20,
      columns: 1, //列数(行内节点数)
      columnWidth: 130, //列宽
      rowHeight: 100 //行高
    }
  })
  stencilContainer.value.appendChild(stencil.container)

  // 控制连接桩显示/隐藏
  // eslint-disable-next-line no-undef
  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'
    }
  }
  graph.on('node:mouseenter', () => {
    const container = graphContainer.value
    const ports = container.querySelectorAll('.x6-port-body')
    showPorts(ports, true)
  })
  graph.on('node:mouseleave', () => {
    const container = graphContainer.value
    const ports = container.querySelectorAll(
      '.x6-port-body'
      // eslint-disable-next-line no-undef
    ) as NodeListOf<SVGElement>
    showPorts(ports, false)
  })

  // #region 快捷键与事件
  graph.bindKey(['meta+c', 'ctrl+c'], () => {
    // const cells = graph.getSelectedCells()
    // if (cells.length) {
    //   graph.copy(cells)
    // }
    // return false
    copy()
  })
  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
    paste()
  })

  // undo redo
  graph.bindKey(['meta+z', 'ctrl+z'], () => {
    // if (graph.canUndo()) {
    //   graph.undo()
    // }
    // return false
    undo()
  })
  graph.bindKey(['meta+y', 'ctrl+y'], () => {
    // if (graph.canRedo()) {
    //   graph.redo()
    // }
    // return false
    redo()
  })
  // 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)
    // }
    del()
  })

  // 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)
    }
  })
  // 节点移入画布事件
  graph.on('node:added', ({ node }: any) => {
    // console.log(node,cell);
    addNodeInfo(node)
  })
  //  节点单击事件
  graph.on('node:click', ({ node }: any) => {
    //  console.log(node,cell)
    addNodeInfo(node)
  })

  //节点被选中时显示添加节点按钮
  graph.on('node:selected', (args: { cell: Cell; node: Node; options: Model.SetOptions }) => {
    if (NodeType.INPUT != args.node.data.type) {
      args.node.removeTools()
      args.node.addTools({
        name: 'button',
        args: {
          x: 0,
          y: 0,
          offset: { x: 160, y: 40 },
          markup: [
            //自定义的删除按钮样式
            {
              tagName: 'circle',
              selector: 'button',
              attrs: {
                r: 8,
                stroke: 'rgba(0,0,0,.25)',
                strokeWidth: 1,
                fill: 'rgba(255, 255, 255, 1)',
                cursor: 'pointer'
              }
            },
            {
              tagName: 'text',
              textContent: '+',
              selector: 'icon',
              attrs: {
                fill: 'rgba(0,0,0,.25)',
                fontSize: 15,
                textAnchor: 'middle',
                pointerEvents: 'none',
                y: '0.3em',
                stroke: 'rgba(0,0,0,.25)'
              }
            }
          ],
          onClick({ e, view }: any) {
            //      console.log(e,cell);
            showNodeTool(e, view)
          }
        }
      })
    }
    // code here
  })

  //节点被取消选中时触发。
  graph.on('node:unselected', (args: { cell: Cell; node: Node; options: Model.SetOptions }) => {
    args.node.removeTools()
  })

  // 添加边事件
  graph.on('edge:added', ({ edge }: any) => {
    // console.log(edge);
    addEdgeInfo(edge)
    edge.data = {
      source: edge.source.cell,
      target: edge.target.cell
    }
  })
  //  线单击事件
  graph.on('edge:click', ({ edge }: any) => {
    //  console.log(node,cell)
    addEdgeInfo(edge)
  })

  //边选中事件
  graph.on('edge:selected', (args: { cell: Cell; edge: Edge; options: Model.SetOptions }) => {
    args.edge.attr('line/strokeWidth', 3)
  })

  //边被取消选中时触发。
  graph.on('edge:unselected', (args: { cell: Cell; edge: Edge; options: Model.SetOptions }) => {
    args.edge.attr('line/strokeWidth', 1)
  })

  const nodeShapes = [
    {
      label: '开始',
      nodeType: 'OUTPUT' as NodeType
    },
    {
      label: '数据同步',
      nodeType: 'SYNC' as NodeType
    },
    {
      label: '结束',
      nodeType: 'INPUT' as NodeType
    }
  ]

  const nodes = nodeShapes.map((item) => {
    const id = StringExt.uuid()
    const node = {
      id: id,
      shape: 'custom-node',
      // label: item.label,
      ports: getPortsByType(item.nodeType, id),
      data: {
        name: `${item.label}`,
        type: item.nodeType
      },
      attrs: getNodeAttrs(item.nodeType)
    }
    const newNode = graph.addNode(node)
    return newNode
  })

  //#endregion
  stencil.load(nodes, 'processLibrary')
}

// 根据节点的类型获取ports
const getPortsByType = (type: NodeType, nodeId: string) => {
  let ports = [] as any
  switch (type) {
    case NodeType.INPUT:
      ports = [
        {
          id: `${nodeId}-in`,
          group: 'in'
        },
        {
          id: `${nodeId}-left`,
          group: 'left'
        },
        {
          id: `${nodeId}-right`,
          group: 'right'
        }
      ]
      break
    case NodeType.OUTPUT:
      ports = [
        {
          id: `${nodeId}-out`,
          group: 'out'
        },
        {
          id: `${nodeId}-left`,
          group: 'left'
        },
        {
          id: `${nodeId}-right`,
          group: 'right'
        }
      ]
      break
    default:
      ports = [
        {
          id: `${nodeId}-in`,
          group: 'in'
        },
        {
          id: `${nodeId}-out`,
          group: 'out'
        },
        {
          id: `${nodeId}-left`,
          group: 'left'
        },
        {
          id: `${nodeId}-right`,
          group: 'right'
        }
      ]
      break
  }
  return ports
}

// 注册连线 --上下
Graph.registerConnector(
  'curveConnectorTB',
  (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
)

// 注册连线--左右
Graph.registerConnector(
  'curveConnectorLR',
  (sourcePoint, targetPoint) => {
    const hgap = Math.abs(targetPoint.x - sourcePoint.x)
    const path = new Path()
    path.appendSegment(Path.createSegment('M', sourcePoint.x - 4, sourcePoint.y))
    path.appendSegment(Path.createSegment('L', sourcePoint.x + 12, sourcePoint.y))
    // 水平三阶贝塞尔曲线
    path.appendSegment(
      Path.createSegment(
        'C',
        sourcePoint.x < targetPoint.x ? sourcePoint.x + hgap / 2 : sourcePoint.x - hgap / 2,
        sourcePoint.y,
        sourcePoint.x < targetPoint.x ? targetPoint.x - hgap / 2 : targetPoint.x + hgap / 2,
        targetPoint.y,
        targetPoint.x - 6,
        targetPoint.y
      )
    )
    path.appendSegment(Path.createSegment('L', targetPoint.x + 2, targetPoint.y))

    return path.serialize()
  },
  true
)

Graph.registerEdge(
  'processing-curve',
  {
    inherit: 'edge',
    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: 'smooth' }, //curveConnectorTB
    attrs: {
      wrap: {
        connection: true,
        strokeWidth: 10,
        strokeLinejoin: 'round'
      },
      line: {
        connection: true,
        stroke: '#A2B1C3',
        strokeWidth: 1,
        targetMarker: {
          name: 'classic',
          size: 6
        }
      }
    }
  },
  true
)

// Graph.registerEdge(
//   'processing-curve-lr',
//   {
//   inherit: 'edge',
//   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: 'curveConnectorLR' },
//     attrs: {
//       wrap: {
//         connection: true,
//         strokeWidth: 10,
//         strokeLinejoin: 'round',
//       },
//       line: {
//         connection: true,
//         stroke: '#A2B1C3',
//         strokeWidth: 1,
//         targetMarker: {
//           name: 'classic',
//           size: 6,
//         },
//       },
//     },
// },
//   true,
// )

//保存
function save() {
  console.log('save')
  const graphData = graph.toJSON()
  console.log(graphData)
}

//撤销
function undo() {
  if (graph.canUndo()) {
    graph.undo()
  }
  return false
}
//取消撤销
function redo() {
  if (graph.canRedo()) {
    graph.redo()
  }
  return false
}
//复制
function copy() {
  const cells = graph.getSelectedCells()
  if (cells.length) {
    graph.copy(cells)
  }
  return false
}
//粘贴
function paste() {
  if (!graph.isClipboardEmpty()) {
    const cells = graph.paste({ offset: 32 })
    graph.cleanSelection()
    graph.select(cells)
  }
  return false
}
//删除
function del() {
  const cells = graph.getSelectedCells()
  if (cells.length) {
    graph.removeCells(cells)
  }
}

//导出PNG
function exportPng() {
  graph.toPNG(
    (dataUri: string) => {
      // 下载
      DataUri.downloadDataUri(dataUri, 'chart.png')
    },
    {
      padding: {
        top: 20,
        right: 20,
        bottom: 20,
        left: 20
      }
    }
  )
  //graph.exportPNG('a.png',{padding:'20px'});
}

function addNodeInfo(node: any) {
  state.nodeFrom.title = 'Node'
  state.nodeFrom.label = node.label
  state.nodeFrom.desc = node.attrs.label.text
  state.nodeFrom.show = true
  state.nodeFrom.id = node.id
  state.cellFrom.show = false
}

function addEdgeInfo(edge: any) {
  state.nodeFrom.show = false
  state.cellFrom.title = 'Edge'
  if (edge.labels[0]) {
    state.cellFrom.label = edge.labels[0].attrs.label.text
  } else {
    state.cellFrom.label = ''
  }
  state.cellFrom.edgeType = edge.data ? edge.data.edgeType : ''
  state.cellFrom.show = true
  state.cellFrom.id = edge.id
}
//修改文本
function changeLabel() {
  const nodes = graph.getNodes()
  nodes.forEach((node: any) => {
    if (state.nodeFrom.id == node.id) {
      node.label = state.nodeFrom.label
    }
  })
}

//修改描述
function changeDesc() {
  const nodes = graph.getNodes()
  nodes.forEach((node: any) => {
    if (state.nodeFrom.id == node.id) {
      node.attr('label/text', state.nodeFrom.desc)
    }
  })
}

//修改边文本
function changeEdgeLabel() {
  const edges = graph.getEdges()
  edges.forEach((edge: any) => {
    if (state.cellFrom.id == edge.id) {
      edge.setLabels(state.cellFrom.label)
      console.log(edge)
    }
  })
}

//修改边的类型
// function changeEdgeType() {
//   const edges = graph.getEdges()
//   edges.forEach((edge: any) => {
//     if (state.cellFrom.id == edge.id) {
//       //    console.log(state.cellFrom.edgeType);
//       if (state.cellFrom.edgeType == 'topBottom') {
//         edge.setConnector('curveConnectorTB')
//       } else {
//         edge.setConnector('curveConnectorLR')
//         //      console.log(edge);
//       }
//       edge.data.edgeType = state.cellFrom.edgeType
//     }
//   })
// }

const getNodeAttrs = (nodeType: string) => {
  let attr = {} as any
  switch (nodeType) {
    case NodeType.INPUT:
      attr = {
        image: {
          'xlink:href': new URL('@/assets/imgs/lime.png', import.meta.url).href
        },
        //左侧拖拽样式
        body: {
          fill: '#b9dec9',
          stroke: '#229453'
        },
        text: {
          text: '结束',
          fill: '#229453'
        }
      }
      break
    case NodeType.SYNC:
      attr = {
        image: {
          'xlink:href': new URL('@/assets/imgs/persimmon.png', import.meta.url).href
        },
        //左侧拖拽样式
        body: {
          fill: '#edc3ae',
          stroke: '#f9723d'
        },
        text: {
          text: '数据同步',
          fill: '#f9723d'
        }
      }
      break
    case NodeType.OUTPUT:
      attr = {
        image: {
          'xlink:href': new URL('@/assets/imgs/rice.png', import.meta.url).href
        },
        //左侧拖拽样式
        body: {
          fill: '#EFF4FF',
          stroke: '#5F95FF'
        },
        text: {
          text: '开始',
          fill: '#5F95FF'
        }
      }
      break
  }
  return attr
}

//加载初始节点
function getData() {
  let cells = [] as any
  const location = state.data
  location.nodes.map((node) => {
    let attr = getNodeAttrs(node.data.type)
    if (node.data.desc) {
      attr.label = { text: node.data.desc }
    }
    if (node.data.name) {
      let temp = attr.text
      if (temp) {
        temp.text = node.data.name
      }
    }
    cells.push(
      graph.addNode({
        id: node.id,
        x: node.position.x,
        y: node.position.y,
        shape: 'custom-node',
        attrs: attr,
        ports: getPortsByType(node.data.type as NodeType, node.id),
        data: node.data
      })
    )
  })
  location.edges.map((edge) => {
    cells.push(
      graph.addEdge({
        id: edge.id,
        source: edge.source,
        target: edge.target,
        zIndex: edge.zIndex,
        shape: 'processing-curve',
        //  connector: { name: 'curveConnector' },
        labels: edge.labels,
        attrs: { line: { strokeDasharray: '5 5' } },
        data: edge.data
      })
    )
  })
  graph.resetCells(cells)
}

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

// 显示边状态
const showEdgeStatus = () => {
  state.edgeStatusList.forEach((item) => {
    const edge = graph.getCellById(item.id)
    if (item.status == 'success') {
      edge.attr('line/strokeDasharray', 0)
      edge.attr('line/stroke', '#52c41a')
    } else if ('error' == item.status) {
      edge.attr('line/stroke', '#ff4d4f')
    } else if ('executing' == item.status) {
      excuteAnimate(edge)
    }
  })
}

// 显示添加按钮菜单
function showNodeTool(e: any, _view: any) {
  //  console.log(view);
  state.showMenu = true
  nextTick(() => {
    nodeMenu.value.style.top = e.offsetY + 60 + 'px'
    nodeMenu.value.style.left = e.offsetX + 210 + 'px'
  })
}

// 点击添加节点按钮
function addNodeTool(item: any) {
  //  console.log(item);
  createDownstream(item.type)
  state.showMenu = false
}

/**
 * 根据起点初始下游节点的位置信息
 * @param node 起始节点
 * @param graph
 * @returns
 */
const getDownstreamNodePosition = (node: Node, graph: Graph, dx = 250, dy = 100) => {
  // 找出画布中以该起始节点为起点的相关边的终点id集合
  const downstreamNodeIdList: string[] = []
  graph.getEdges().forEach((edge) => {
    const originEdge = edge.toJSON()?.data
    console.log(node)
    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 : position.x + dx,
    y: maxY !== -Infinity ? maxY + dy : position.y
  }
}

// 创建下游的节点和边
const createDownstream = (type: NodeType) => {
  //  console.log(graph.getSelectedCells());
  const cells = graph.getSelectedCells()
  if (cells.length == 1) {
    const node = cells[0]
    //console.log(node,"node");
    if (graph) {
      // 获取下游节点的初始位置信息
      const position = getDownstreamNodePosition(node, graph)
      // 创建下游节点
      const newNode = createNode(type, graph, position)
      const source = node.id
      const target = newNode.id
      // 创建该节点出发到下游节点的边
      createEdge(source, target, graph)
    }
  } else {
    ElMessage({
      message: '请选择一个节点',
      type: 'warning'
    })
  }
}

const createNode = (type: NodeType, graph: Graph, position?: Position): Node => {
  let newNode = {} as Node
  const typeName = state.PROCESSING_TYPE_LIST?.find((item) => item.type === type)?.name
  const id = StringExt.uuid()
  const node = {
    id,
    shape: 'custom-node',
    x: position?.x,
    y: position?.y,
    ports: getPortsByType(type, id),
    data: {
      name: `${typeName}`,
      type
    },
    attrs: getNodeAttrs(type)
  }
  newNode = graph.addNode(node)
  return newNode
}

const createEdge = (source: string, target: string, graph: Graph) => {
  const edge = {
    id: StringExt.uuid(),
    shape: 'processing-curve',
    source: {
      cell: source
      // port: `${source}-out`,
    },
    target: {
      cell: target
      //  port: `${target}-in`,
    },
    zIndex: -1,
    data: {
      source,
      target
    },
    attrs: { line: { strokeDasharray: '5 5' } }
  }
  // console.log(edge);
  if (graph) {
    graph.addEdge(edge)
  }
}

onMounted(() => {
  init()
  // graph.fromJSON(state.data);
  getData()
  showEdgeStatus()
})

onUnmounted(() => {
  graph.dispose()
})
</script>

<style lang="less" scoped>
.content-main {
  display: flex;
  width: 100%;
  flex-direction: column;
  height: calc(100vh - 85px - 40px);
  background-color: #ffffff;
  position: relative;

  .tool-container {
    padding: 8px;
    display: flex;
    align-items: center;
    color: rgba(0, 0, 0, 0.45);

    .command {
      display: inline-block;
      width: 27px;
      height: 27px;
      margin: 0 6px;
      padding-top: 6px;
      text-align: center;
      cursor: pointer;
    }
  }
}
.content-container {
  position: relative;
  width: 100%;
  height: 100%;
  .content {
    width: 100%;
    height: 100%;
    position: relative;

    min-width: 400px;
    min-height: 600px;
    display: flex;
    border: 1px solid #dfe3e8;
    flex-direction: row;
    //   flex-wrap: wrap;
    flex: 1 1;

    .stencil {
      width: 250px;
      height: 100%;
      border-right: 1px solid #dfe3e8;
      position: relative;

      :deep(.x6-widget-stencil) {
        background-color: #fff;
      }
      :deep(.x6-widget-stencil-title) {
        background-color: #fff;
      }
      :deep(.x6-widget-stencil-group-title) {
        background-color: #fff !important;
      }
    }
    .graph-content {
      width: calc(100% - 180px);
      height: 100%;
    }

    .editor-sidebar {
      display: flex;
      flex-direction: column;
      border-left: 1px solid #e6f7ff;
      background: #fafafa;
      z-index: 9;

      .el-card {
        border: none;
      }
      .edit-panel {
        flex: 1 1;
        background-color: #fff;
      }

      :deep(.x6-widget-minimap-viewport) {
        border: 1px solid #8f8f8f;
      }

      :deep(.x6-widget-minimap-viewport-zoom) {
        border: 1px solid #8f8f8f;
      }
    }
  }
}

:deep(.x6-widget-transform) {
  margin: -1px 0 0 -1px;
  padding: 0px;
  border: 1px solid #239edd;
}
:deep(.x6-widget-transform > div) {
  border: 1px solid #239edd;
}
:deep(.x6-widget-transform > div:hover) {
  background-color: #3dafe4;
}
:deep(.x6-widget-transform-active-handle) {
  background-color: #3dafe4;
}
:deep(.x6-widget-transform-resize) {
  border-radius: 0;
}
:deep(.x6-widget-selection-inner) {
  border: 1px solid #239edd;
}
:deep(.x6-widget-selection-box) {
  opacity: 0;
}

.topic-image {
  visibility: hidden;
  cursor: pointer;
}
.x6-node:hover .topic-image {
  visibility: visible;
}
.x6-node-selected rect {
  stroke-width: 2px;
}
.node-menu {
  position: absolute;
  box-shadow: var(--el-box-shadow-light);
  background: var(--el-bg-color-overlay);
  border: 1px solid var(--el-border-color-light);
  padding: 5px 0px;

  .menu-item {
    display: flex;
    align-items: center;
    white-space: nowrap;
    list-style: none;
    line-height: 22px;
    padding: 5px 16px;
    margin: 0;
    font-size: var(--el-font-size-base);
    color: var(--el-text-color-regular);
    cursor: pointer;
    outline: none;
    box-sizing: border-box;
  }

  .menu-item .el-image {
    margin-right: 5px;
  }

  .menu-item:hover {
    background-color: var(--el-color-primary-light-9);
    color: var(--el-color-primary);
  }
}
</style>


显示流程图

在这里插入图片描述

<template>
  <div class="content-main">
    <div class="content-container" id="">
      <div class="content">
        <div class="graph-content" id="graphContainer" ref="graphContainer"></div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { Graph, Path, Edge } from '@antv/x6'
import { ref, onMounted, reactive } from 'vue'
import '@/styles/animation.less'

const graphContainer = ref()

let graph: any = null

const state = reactive({
  data: {
    nodes: [
      {
        id: 'ac51fb2f-2753-4852-8239-53672a29bb14',
        x: -340,
        y: -160,
        ports: [
          {
            id: 'ac51fb2f-2753-4852-8239-53672a29bb14_out',
            group: 'out'
          }
        ],
        data: {
          name: '数据输入_1',
          type: 'OUTPUT',
          checkStatus: 'sucess'
        },
        attrs: {
          body: {
            fill: '#EFF4FF',
            stroke: '#5F95FF'
          },
          image: {
            'xlink:href': 'http://localhost:20002/src/assets/imgs/rice.png'
          },
          label: {
            text: '春望'
          },
          text: {
            fill: '#5F95FF',
            text: '开始'
          }
        }
      },
      {
        id: '81004c2f-0413-4cc6-8622-127004b3befa',
        x: -340,
        y: -10,
        ports: [
          {
            id: '81004c2f-0413-4cc6-8622-127004b3befa_in',
            group: 'in'
          },
          {
            id: '81004c2f-0413-4cc6-8622-127004b3befa_out',
            group: 'out'
          }
        ],
        data: {
          name: '数据输入_1',
          type: 'SYAN',
          checkStatus: 'sucess'
        },
        attrs: {
          body: {
            fill: '#edc3ae',
            stroke: '#f9723d'
          },
          image: {
            'xlink:href': 'http://localhost:20002/src/assets/imgs/persimmon.png'
          },
          label: {
            text: '国破山河在'
          },
          text: {
            fill: '#f9723d',
            text: '数据同步'
          }
        }
      },
      {
        id: '7505da25-1308-4d7a-98fd-e6d5c917d35d',
        x: -140,
        y: 180,
        ports: [
          {
            id: '7505da25-1308-4d7a-98fd-e6d5c917d35d_in',
            group: 'in'
          }
        ],
        data: {
          name: '数据输入_1',
          type: 'INPUT',
          checkStatus: 'sucess'
        },
        attrs: {
          body: {
            fill: '#b9dec9',
            stroke: '#229453'
          },
          image: {
            'xlink:href': 'http://localhost:20002/src/assets/imgs/lime.png'
          },
          label: {
            text: '城春草木胜'
          },
          text: {
            fill: '#229453',
            text: '结束'
          }
        }
      }
    ],
    edges: [
      {
        attrs: { line: { strokeDasharray: '5 5' } },
        connector: { name: 'curveConnector' },
        id: '6eea5dc9-4e15-4e78-959f-ee13ec59d11c',
        shape: 'data-processing-curve',
        source: { cell: 'ac51fb2f-2753-4852-8239-53672a29bb14', port: '_out' },
        target: { cell: '81004c2f-0413-4cc6-8622-127004b3befa', port: '_in' },
        zIndex: -1
      },
      {
        attrs: { line: { strokeDasharray: '5 5' } },
        connector: { name: 'curveConnector' },
        id: '8cbce713-54be-4c07-8efa-59c505f74ad7',
        labels: ['下半句'],
        shape: 'data-processing-curve',
        source: { cell: '81004c2f-0413-4cc6-8622-127004b3befa', port: '_out' },
        target: { cell: '7505da25-1308-4d7a-98fd-e6d5c917d35d', port: '_in' }
      }
    ]
  },
  // 节点状态列表
  nodeStatusList: [
    {
      id: 'ac51fb2f-2753-4852-8239-53672a29bb14',
      status: 'success'
    },
    {
      id: '81004c2f-0413-4cc6-8622-127004b3befa',
      status: 'success'
    }
  ],

  // 边状态列表
  edgeStatusList: [
    {
      id: '6eea5dc9-4e15-4e78-959f-ee13ec59d11c',
      status: 'success'
    },
    {
      id: '8cbce713-54be-4c07-8efa-59c505f74ad7',
      status: 'executing'
    }
  ]
})

// const { data } = toRefs(state)

// // 节点类型
// enum NodeType {
//   INPUT = 'INPUT', // 数据输入
//   FILTER = 'FILTER', // 数据过滤
//   JOIN = 'JOIN', // 数据连接
//   UNION = 'UNION', // 数据合并
//   AGG = 'AGG', // 数据聚合
//   OUTPUT = 'OUTPUT' // 数据输出
// }

function init() {
  graph = new Graph({
    container: graphContainer.value,
    interacting: function () {
      return { nodeMovable: false }
    },
    grid: true,
    panning: {
      enabled: false,
      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: 'bottom',
        args: {
          dx: 0
        }
      },
      targetAnchor: {
        name: 'top',
        args: {
          dx: 0
        }
      },
      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') === 'out') {
          return false
        }
        return true
      }
    }
  })
  graph.centerContent()

  // #region 初始化图形
  const ports = {
    groups: {
      in: {
        position: 'top',
        attrs: {
          circle: {
            r: 4,
            magnet: true,
            stroke: '#5F95FF',
            strokeWidth: 1,
            fill: '#fff',
            style: {
              visibility: 'hidden'
            }
          }
        }
      },
      out: {
        position: 'bottom',
        attrs: {
          circle: {
            r: 4,
            magnet: true,
            stroke: '#31d0c6',
            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'
            }
          }
        }
      },
      right: {
        position: 'right',
        attrs: {
          circle: {
            r: 4,
            magnet: true,
            stroke: '#5F95FF',
            strokeWidth: 1,
            fill: '#fff',
            style: {
              visibility: 'hidden'
            }
          }
        }
      }
    }
    // items: [
    //   {
    //     id: state.currentCode + '_in',
    //     group: 'top',
    //   },
    //   {
    //     id: state.currentCode + '_out',
    //     group: 'out',
    //   }
    // ],
  }

  Graph.registerNode(
    'custom-node',
    {
      inherit: 'rect',
      width: 140,
      height: 76,
      attrs: {
        body: {
          strokeWidth: 1
        },
        image: {
          width: 16,
          height: 16,
          x: 12,
          y: 6
        },
        text: {
          refX: 40,
          refY: 15,
          fontSize: 15,
          'text-anchor': 'start'
        },
        label: {
          text: 'Please nominate this node',
          refX: 10,
          refY: 30,
          fontSize: 12,
          fill: 'rgba(0,0,0,0.6)',
          'text-anchor': 'start',
          textWrap: {
            width: -10, // 宽度减少 10px
            height: '70%', // 高度为参照元素高度的一半
            ellipsis: true, // 文本超出显示范围时,自动添加省略号
            breakWord: true // 是否截断单词
          }
        }
      },
      markup: [
        {
          tagName: 'rect',
          selector: 'body'
        },
        {
          tagName: 'image',
          selector: 'image'
        },
        {
          tagName: 'text',
          selector: 'text'
        },
        {
          tagName: 'text',
          selector: 'label'
        }
      ],
      data: {},
      relation: {},
      ports: { ...ports }
    },
    true
  )

  // 注册连线
  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)

function getData() {
  let cells = [] as any
  const location = state.data
  location.nodes.map((node) => {
    cells.push(
      graph.addNode({
        id: node.id,
        x: node.x,
        y: node.y,
        shape: 'custom-node',
        attrs: node.attrs,
        ports: node.ports,
        data: node.data
      })
    )
  })
  location.edges.map((edge) => {
    cells.push(
      graph.addEdge({
        id: edge.id,
        source: edge.source,
        target: edge.target,
        zIndex: edge.zIndex,
        shape: 'data-processing-curve',
        connector: { name: 'curveConnector' },
        labels: edge.labels,
        attrs: edge.attrs
      })
    )
  })
  graph.resetCells(cells)
}

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

// 显示边状态
const showEdgeStatus = () => {
  state.edgeStatusList.forEach((item) => {
    const edge = graph.getCellById(item.id)
    if (item.status == 'success') {
      edge.attr('line/strokeDasharray', 0)
      edge.attr('line/stroke', '#52c41a')
    } else if ('error' == item.status) {
      edge.attr('line/stroke', '#ff4d4f')
    } else if ('executing' == item.status) {
      excuteAnimate(edge)
    }
  })
}

onMounted(() => {
  init()
  // graph.fromJSON(state.data);
  getData()
  showEdgeStatus()
})
</script>

<style lang="less" scoped>
.content-main {
  display: flex;
  width: 100%;
  flex-direction: column;
  height: calc(100vh - 85px - 40px);
  background-color: #ffffff;
  position: relative;
}
.content-container {
  position: relative;
  width: 100%;
  height: 100%;
  .content {
    width: 100%;
    height: 100%;
    position: relative;

    min-width: 400px;
    min-height: 600px;
    display: flex;
    border: 1px solid #dfe3e8;
    flex-direction: row;
    //   flex-wrap: wrap;
    flex: 1 1;

    .graph-content {
      width: calc(100%);
      height: 100%;
    }
  }
}

:deep(.x6-widget-transform) {
  margin: -1px 0 0 -1px;
  padding: 0px;
  border: 1px solid #239edd;
}
:deep(.x6-widget-transform > div) {
  border: 1px solid #239edd;
}
:deep(.x6-widget-transform > div:hover) {
  background-color: #3dafe4;
}
:deep(.x6-widget-transform-active-handle) {
  background-color: #3dafe4;
}
:deep(.x6-widget-transform-resize) {
  border-radius: 0;
}
:deep(.x6-widget-selection-inner) {
  border: 1px solid #239edd;
}
:deep(.x6-widget-selection-box) {
  opacity: 0;
}

.topic-image {
  visibility: hidden;
  cursor: pointer;
}
.x6-node:hover .topic-image {
  visibility: visible;
}
.x6-node-selected rect {
  stroke-width: 2px;
}
</style>

;