likes
comments
collection
share

【VUE3 + AntV-X6】ANTV X6 流程图编辑器应用(一)

作者站长头像
站长
· 阅读数 15

唠唠叨叨

        唉!最近感觉非常忙,要学的东西太多,要实现的方法变幻莫测,层出不穷,越学越觉得自己不会的实在太多。
	项目本来中标了,后来又不知道发生了什么,临到签合同又黄了,整的我们开发的瑟瑟发抖,产品整个大改,恐怕要……
	只能说还是抓紧时间好好学习,知识和技术是永远不会背叛自己的。

事发背景

	俺滴组长(后端大佬)要我配合开发数据平台的任务流全局调度系统,想用流程图实现作业运行配置。
	于是我在各种流程图的前端框架中选了ANTV X6来引入实现。官方文档天天在看,可这知识他就是不入脑子呀TvT。
	终于有一天腌肉某人一拍脑门醍醐灌顶实现了功能,遂记录实现过程及困难解决方法。

【VUE3 + AntV-X6】ANTV X6 流程图编辑器应用(一) 在官方文档中给出了场景案例,恰好就有我所需要的流程图: 【VUE3 + AntV-X6】ANTV X6 流程图编辑器应用(一)

实现效果

经过一系列拼拼凑凑,修修改改,还有样式设计和调整,调度作业画布页面如下图所示: 【VUE3 + AntV-X6】ANTV X6 流程图编辑器应用(一)

实现思路

  1. 首先我们要了解系统的业务功能需求,以及业务流程的线路是怎样的; 如图是窗口的拆解,其中①作业树使用的是element UI的el-tree组件,是用于储存画布内容和画布回显更新的,②是选中节点树中的某项流程作业进行对应的任务操作,③和④才是实际应用AntV X6的画布插件实现的可拖拽、增、删、改、配置的流程图编辑器。 【VUE3 + AntV-X6】ANTV X6 流程图编辑器应用(一) 通过vue的父子组件我们创建一个index.vue的父组件,然后将这四个部分拆分为三个子组件的.vue文件,分别为DataTree.vue、DataButton.vue、FiliationGraph.vue(包含③、④两部分)今天记录的是FiliationGraph.vue内节点配置画布的实现。
  2. 安装AntV X6 通过 npm 或 yarn 命令安装 X6。
# npm
$ npm install @antv/x6 --save

# yarn
$ yarn add @antv/x6
  1. 初始化画布,在html中建立节点库和画布div容器:
<-- 节点库 -->
<div class="app-stencil" ref="stencilContainer"></div>
<-- 画布 -->
<div class="app-content" id="flowContainer" ref="container"></div>
// 引入
import { Graph, Shape, Addon, FunctionExt } from "@antv/x6";

const { Stencil } = Addon;  //节点库
const {graph} = Graph;  //画布

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,  //行高
  },
});
proxy.$refs.stencilContainer.appendChild(stencil.container) //注册到div中
const graph = new Graph({ // 新建画布
  container: document.getElementById('flowContainer'),
  width: 800,
  height: 600,
  background: {
    color: "#F2F7FA",
  },
});
  1. 初始化节点、边: 初始化节点、边数据:
const nodeData = {
 // 节点
  nodes: [
  ],
  // 边
  edges: [
  ],
};

准备节点样式内容数据,其中节点的左上角图标image是自定义的svg文件,根据产品ui或自己设计来使用哟。 【VUE3 + AntV-X6】ANTV X6 流程图编辑器应用(一)

const imageShapes = [
 {
    body: {
      fill: "rgba(102, 153, 204, 0.05)",
      stroke: "rgb(102, 153, 204)",
    },
    label: {
      text: state.collectLabel,
      fill: 'rgb(102, 153, 204)',
    },
    image: require('/src/assets/Scheduler/DataCollect.svg'),
  },
  {
    body: {
      fill: "rgba(185, 147, 214, 0.05)",
      stroke: "rgb(185, 147, 214)",
    },
    label: {
      text: state.flinkLabel,
      fill: 'rgb(185, 147, 214)',
      },
    image: require('/src/assets/Scheduler/DataFlink.svg'),
  },
  {
    body: {
      fill: "rgba(154, 184, 122, 0.05)",
      stroke: "rgb(154, 184, 122)",
    },
    label: {
      text: state.controlLabel,
      fill: 'rgb(154, 184, 122)',
      },
    image: require('/src/assets/Scheduler/DataQc.svg'),
  },
  {
    body: {
      fill: "rgba(247, 178, 103, 0.05)",
      stroke: "rgb(247, 178, 103)",
    },
    label: {
      text: state.monitorLabel,
      fill: 'rgb(247, 178, 103)',
      },
    image: require('/src/assets/Scheduler/DataWatch.svg'),
  },
  {
    body: {
      fill: "rgba(219, 127, 142, 0.05)",
      stroke: "rgb(219, 127, 142)",
    },
    label: {
      text: state.standerLabel,
      fill: 'rgb(219, 127, 142)',
      },
    image: require('/src/assets/Scheduler/DataStandred.svg'),
  },
]
  1. 左侧模型栏节点样式: 初始化链接桩
const ports = {
 groups: {
   in: {
     position: 'top',
     attrs: {
       circle: {
         r: 4,
         magnet: true,
         stroke: '#108ee9',
         strokeWidth: 2,
         fill: '#fff',
         style: {
           visibility: "hidden",
         },
       }
     }
   },
   out: {
     position: 'bottom',
     attrs: {
       circle: {
         r: 4,
         magnet: true,
         stroke: '#31d0c6',
         strokeWidth: 2,
         fill: '#fff',
         style: {
           visibility: "hidden",
         },
       }
     }
   }
 },
 items: [
   {
     id: state.currentCode+ '_in',
     group: 'in',
   },
   {
     id: state.currentCode + '_out',
     group: 'out',
   },
 ],
}

根据样式数据,设计画布左侧节点样式

Graph.registerNode( //注册节点
  'custom-node',
  {
    inherit: 'rect', //基础图形
    width: 140,
    height: 76,
    attrs: {  //自定义样式
      body: {  //节点主体
        strokeWidth: 1,
        rx: 30,
        ry: 30,
      },
      image: {  //图片
        width: 20,
        height: 20,
        x: 12,
        y: 3,
      },
      text: {  //主题文本
        refX: 40,
        refY: 15,
        fontSize: 15,
        'text-anchor': 'start',
      },
      label: {  //标签名
        text: 'Please nominate this node',
        id: 0,  //自定义传给接口的数据
        data: {},  //这里是我加入的标签内自定义储存的数据内容
        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 imageNodes = imageShapes.map((item) => //呈现画布节点数据的样式
  graph.createNode({
    shape: 'custom-node',
    attrs: {
      image: {
        'xlink:href': item.image,
      },
      body: item.body,
      text: item.label,
    },
  }),
)
stencil.load( //载入左侧节点模型库
  imageNodes,
  "processLibrary"
);

6.定义边的样式(节点连线)

Graph.registerConnector(
  'algo-edge',
  (source, target) => {
    const offset = 4
    const control = 80
    const v1 = { x: source.x, y: source.y + offset + control }
    const v2 = { x: target.x, y: target.y - offset - control }

    return `M ${source.x} ${source.y}
            L ${source.x} ${source.y + offset}
            C ${v1.x} ${v1.y} ${v2.x} ${v2.y} ${target.x} ${target.y - offset}
            L ${target.x} ${target.y}`
  },
  true,
)

以及可以在初始化画布graph = new graph({})方法中,定义连线规则

// 连线规则
connecting: {
  snap: true,  // 当 snap 设置为 true 时连线的过程中距离节点或者连接桩 50px 时会触发自动吸附
  allowBlank: false,  // 是否允许连接到画布空白位置的点,默认为 true
  allowLoop: false, // 是否允许创建循环连线,即边的起始节点和终止节点为同一节点,默认为 true
  allowMulti: false, // 当设置为 false 时,在起始和终止节点之间只允许创建一条边
  highlight: true,  // 拖动边时,是否高亮显示所有可用的连接桩或节点,默认值为 false。
  sourceAnchor: {  // 当连接到节点时,通过 sourceAnchor 来指定源节点的锚点。
    name: 'bottom',
    args: {
      dx: 0,
    },
  },
  targetAnchor: {  // 当连接到节点时,通过 targetAnchor 来指定目标节点的锚点。
    name: 'top',
    args: {
      dx: 0,
    },
  },
  connectionPoint: 'anchor',  // 指定连接点,默认值为 boundary。
  connector: 'algo-edge',  // 连接器将起点、路由返回的点、终点加工为 元素的 d 属性,决定了边渲染到画布后的样式,默认值为 normal。
  createEdge() {
    return graph.createEdge({
      attrs: {
        line: {
          strokeDasharray: '5 5',
          stroke: '#808080',
          strokeWidth: 1,
          targetMarker: {
            name: 'block',
            args: {
              size: '6',
            },
          },
        },
      },
    })
  },
  validateMagnet({ magnet }) {
    return magnet.getAttribute('port-group') !== 'in'
  },
  validateConnection({
    sourceView,
    targetView,
    sourceMagnet,
    targetMagnet
  }) {
    if (sourceView === targetView) {
      return false;
    }
    if (!sourceMagnet) {
      return false;
    }
    // 只能连接到输入链接桩
    if (
      !targetMagnet ||
      targetMagnet.getAttribute("port-group") !== "in"
    ) {
      return false;
    }
    return true;
  },
  // 当停止拖动边的时候根据 validateEdge 返回值来判断边是否生效,如果返回 false, 该边会被清除。
  validateEdge({ edge }) {
    const { source, target } = edge
    return true
  }
},

7.方法们

graph.toJSON()  //  数据导出
graph.fromJSON()  //  数据渲染
graph.isPannable()  //  画布是否可以平移
graph.enablePanning()  //  启用画布平移
graph.centerContent();  //  中心对称

8.绑定事件

graph.on('node:added', ({ node, cell }) => {}) // 节点移入画布事件
graph.on("cell:dblclick", ({ node, cell }) => {});  //  节点双击事件
graph.on("node:mouseenter", ({ node }) => {  //  节点删除操作
  // 鼠标 Hover 节点时添加删除按钮
  node.addTools({
    name: "button-remove",
    args: {
      x: 0,
      y: 0,
      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:removed", ({ node, options }) => {  //  删除节点事件
  if (!options.ui) {
    return;
  }
});
graph.on("node:mouseleave", ({ node }) => {
  // 鼠标移开节点时删除删除按钮
  node.removeTools();
});
// 线删除操作
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 }) => {  //  修改节点数据的实时响应事件
  node.data = eachNodeData
})
graph.on("node:contextmenu", ({ cell, view }) => {  //  节点文本
  const oldText = cell.attr("text/textWrap/text");
  const elem = view.container.querySelector(".x6-edit-text");
  if (elem == null) {
    return;
  }
  cell.attr("text/style/display", "none");
  if (elem) {
    elem.style.display = "";
    elem.contentEditable = "true";
    elem.innerText = oldText;
    elem.focus();
  }
  const onBlur = () => {
    cell.attr("text/textWrap/text", elem.innerText);
    cell.attr("text/style/display", "");
    elem.style.display = "none";
    elem.contentEditable = "false";
  };
  elem.addEventListener("blur", () => {
    onBlur();
    elem.removeEventListener("blur", onBlur);
  });
});
const container = document.getElementById("flowContainer");
graph.on("node:mouseenter",FunctionExt.debounce(() => {  //  节点链接桩显隐
    const ports = container.querySelectorAll(".x6-port-body");
    showPorts(ports, true);
  }),
  500
);
graph.on("node:mouseleave", () => {  //  节点链接桩显隐
  const ports = container.querySelectorAll(".x6-port-body");
  showPorts(ports, false);
});
graph.bindKey("backspace", () => {  //  点按空格后跳出带有节点的画布区域
  const cells = graph.getSelectedCells();
  if (cells.length) {
    graph.removeCells(cells);
  }
});

其他有趣的用法

//设置指定路径上的属性值cell.attr('text/text', value)  其中label/text为自定义标题
cell.attr('label/text', state.flinkLabel)
cell.attr('label/data', j)

9.根据接口数据回显画布流程图

//就是通过这个方法来实现的
graph.fromJSON(nodeData)  //  数据渲染,括号内填入回显的数组数据变量
//而nodeData在前面2.提出的数据准备中初始化定义了,我们在接口返回的数据里填充数据和样式就搞定回显了

总结

在vue项目中,使用AntV X6实现节点和画布、连线的配置,非常灵活,一旦会用后有相当多的方法可以使用,自由度和定制度高。还有许多用法可以实现,如点击执行指令显示流程图的动画运行流: 【VUE3 + AntV-X6】ANTV X6 流程图编辑器应用(一) 还有自定义的布局等 【VUE3 + AntV-X6】ANTV X6 流程图编辑器应用(一) 剩下的其他拓展方法等我下次整理了再发,今天这篇太长了,肝不动了TvT。

参考资料

  1. ANTV-X6 流程图
  2. ANTV-X6 快速上手