Vue3+Antv/X6+ElementPlus+Typescript实现可拖拽可视化流程图

6 阅读3分钟

项目背景

基于Vue3+ElementPlus+Typescript,需要开发一个具备拖拽、编辑等功能的流程设计器,调研了很多框架如gojs、vue-flow、antv/x6,最终选择antv/x6,因为它生态成熟、性能与可扩展性兼备,并能深度匹配 Vue3+TypeScript 技术栈与企业级定制需求。

界面展示

image.png

功能介绍

支持【撤销、恢复、编辑、删除、分组、取消分组、保存、导出图片】等常用功能,可扩展【导出JSON】等功能。支持Ctrl+C / Ctrl+V /Ctrl+Z以及画布缩放、画布拖动等功能。

开始

查阅Antv/X6的官方文档,刚好有一个流程图的Demo示例:x6.antv.antgroup.com/examples/sh… ,研究示例代码,准备开干。

顶部工具栏

HTML代码:

<div class="flex-between tool-bar">
  <el-space spacer="|" :size="16">
    <el-button-group>
      <el-button link title="撤销(Ctrl+Z)" @click="undo"><icon-return></icon-return></el-button>
      <el-button link title="恢复(Ctrl+Y)" @click="redo"><icon-go-on></icon-go-on></el-button>
    </el-button-group>
    <el-button-group>
      <el-button link title="编辑" @click="editCell"><icon-edit></icon-edit></el-button>
      <el-button link title="删除" @click="deleteSelected"><icon-delete></icon-delete></el-button>
    </el-button-group>
    <el-button-group>
      <el-button link title="框选分组" @click="createGroup"><icon-group></icon-group></el-button>
      <el-button link title="取消分组" @click="unGroup"><icon-ungroup></icon-ungroup></el-button>
    </el-button-group>
    <el-button-group>
      <el-button link title="保存" @click="saveData"><icon-save-one></icon-save-one></el-button>
      <el-button link title="导出图片" @click="exportPNG"><icon-down-picture></icon-down-picture></el-button>
    </el-button-group>
  </el-space>
  <p style="color: var(--el-color-warning)">
    <icon-help></icon-help> 按住【Ctrl+鼠标左键】可平移画布,按住【Ctrl+鼠标滚轮】可缩放画布
  </p>
</div>

调用方法:

const undo = () => graph.undo();
const redo = () => graph.redo();

const editCell = () => {
  const cells = graph.getSelectedCells();
  if (cells.length !== 1) return ElMessage.warning("请选择一个元素");
  const cell = cells[0];
  if (cell.isNode() || cell.isEdge()) {
      currentCell.value = cell as Node | Edge;
      if (cell.isNode()) {
          editLabel.value = currentCell.value.attr("label/text") || "";
          edgeLabel.value = "";
      } else if (cell.isEdge()) {
          edgeLabel.value = (currentCell.value as any).getLabels()[0]?.attrs?.text?.text || "";
          editLabel.value = "";
      }
      editVisible.value = true;
    }
};

const deleteSelected = () => {
  const cells = graph.getSelectedCells();
  if (cells.length) graph.removeCells(cells);
  else ElMessage.warning("请选择要删除的元素");
};

const createGroup = () => {
  const cells = graph.getSelectedCells();
  if (cells.length < 2) {
    ElMessage.warning("至少选择2个元素");
    return;
  }
  const bbox = graph.getCellsBBox(cells);
  const group = graph.addNode({
    x: bbox.x - 10,
    y: bbox.y - 15,
    width: bbox.width + 20,
    height: bbox.height + 40,
    attrs: {
      body: { fill: "rgb(244, 244, 245)", stroke: "#E8EEF2", rx: 4 },
      label: { text: "", fontSize: 14 }
    },
    zIndex: -1
  });
  group.setData({ isGroup: true });
  cells.forEach(cell => {
    if (cell.isNode()) group.addChild(cell);
  });
  ElMessage.success("分组成功");
};

const unGroup = () => {
  const cells = graph.getSelectedCells();
  if (cells.length !== 1) {
    ElMessage.warning("请选择一个分组容器");
    return;
  }
  const groupNode = cells[0] as Node;
  if (!groupNode.getData()?.isGroup) {
    ElMessage.warning("请选择分组");
    return;
  }
  const children = graph.getNodes().filter(node => node.getParent() === groupNode);
  children.forEach(child => child.setParent(null));
  graph.removeCell(groupNode);
  ElMessage.success("取消分组成功");
};

const saveData = () => {
  localStorage.setItem("topology", JSON.stringify(graph.toJSON()));
  ElMessage.success("保存成功");
  // TODO:真实业务需配合后台接口进行数据存储
};


const exportPNG = () => {
  graph.exportPNG("流程图", {
    backgroundColor: "#fff",
    padding: 40,
    quality: 1,
    preserveDimensions: true,
    serializeImages: true
  });
  ElMessage.success("导出成功");
};

左侧模板

// 初始化模板容器

 const stencil = new Stencil({
    title: "流程图",
    target: graph,
    stencilGraphWidth: 200,
    stencilGraphHeight: 440,
    stencilGraphOptions: { panning: false },
    collapsable: true,
    layoutOptions: { columns: 2, columnWidth: 100, rowHeight: 96 }
  });
  
  document.getElementById("stencil")?.appendChild(stencil.container as HTMLElement);
  
 // 创建模板自定义模板
 // 定义模板连线端口
 
  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" }]
  };
// 注册节点属性信息
  Graph.registerNode(
    "custom-image",
    {
      inherit: "circle",
      width: 66,
      height: 66,
      markup: [{ tagName: "circle", selector: "body" }, { tagName: "image" }, { tagName: "text", selector: "label" }],
      attrs: {
        body: { stroke: "#fff", fill: "rgb(235, 243, 254)" },
        image: { width: 42, height: 42, refX: 11, refY: 11 },
        label: { refY: 78, textAnchor: "middle", textVerticalAnchor: "middle", fontSize: 14, fill: "#303133" }
      },
      ports: { ...ports }
    },
    true
  );
  
  // 定义节点名称及图片
  
   const imageShapes = [
    { label: "单位", image: unitImg },
    { label: "交换机", image: exchangeImg },
    { label: "文件服务器", image: fileServerImg },
    { label: "防火墙", image: firewallImg },
    { label: "服务器", image: groupImg },
    { label: "计算机", image: pcImg },
    { label: "路由器", image: routerImg },
    { label: "互联网资产", image: softWareImg }
  ];
  
// 创建节点

  const imageNodes = imageShapes.map(item =>
    graph.createNode({
      shape: "custom-image",
      label: item.label,
      attrs: {
        label: { text: item.label, fontSize: 14 },
        image: { "xlink:href": item.image }
      }
    })
  );
  
// 将节点添加到模板容器中(stencil)

stencil.load(imageNodes);

绑定事件

 // 快捷键事件绑定
 graph.bindKey(["ctrl+c", "meta+c"], () => graph.copy(graph.getSelectedCells()));
 graph.bindKey(["ctrl+v", "meta+v"], () => graph.paste());
 graph.bindKey(["ctrl+z", "meta+z"], () => graph.undo());
 graph.bindKey(["ctrl+y", "meta+y"], () => graph.redo());
 graph.bindKey(["backspace", "delete"], deleteSelected);
 
 // 鼠标移入显示连接端口
 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 = document.getElementById("graph-container") as HTMLElement;
    const ports = container.querySelectorAll(".x6-port-body") as NodeListOf<SVGElement>;
    showPorts(ports, true);
  });
  graph.on("node:mouseleave", () => {
    const container = document.getElementById("graph-container") as HTMLElement;
    const ports = container.querySelectorAll(".x6-port-body") as NodeListOf<SVGElement>;
    showPorts(ports, false);
  });
  

结尾

关键代码已贴完,仅供参考。不得不感叹Antv/X6真强大!!!用不复杂的代码逻辑就实现了最基本的编辑器功能,如果有时间有精力可以继续完善逻辑。