流程图绘制在项目中实际是一个复杂的应用,但是因为有很多算法都是和项目中业务相关不一定符合其他小伙伴的实际应用情况并且项目存在保密机制不方便全部分享出来,所以本文章仅抽取最基础的部分简单介绍流程图创建、节点链接、节点删除、菜单操作、重新渲染、本地存储的交互。仅供有类似需求的小伙伴参考,效果图如下:

在这里插入图片描述
具体实现的业务效果有如下几点

  1. 节点拖拽功能,节点任意链接;
  2. 默认节点锚点(入点、出点)动态设置;
  3. 节点、边线菜单操作,快捷键操作;
  4. 节点状态切换,画布一键清除;
  5. 根据业务场景加入动态参数配置;
  6. 节点事件监听根据业务需求动态添加逻辑;
  7. 流程图数据再渲染;
  8. 不同模式切换以及支持的操作;

一、安装相关依赖组件

  1. 流程图绘制是基于antv/g6的基础 :
    安装命令:npm install @antv/g6 -S
    官方文档:https://www.yuque.com/antv/g6/api-g6
  2. 拖拽功能使用的是jquery里的拖拽插件:
    安装命令:npm install jquery -S
    npm install jquery-ui -S

二、思路梳理组件拆分

在这里插入图片描述
从上面简单的思路图可以看出,新增、编辑、展示、提交数据这种都属于业务部分,流程图这块属于他们的公用部分,所以把流程图这块单独拆分为一个公用组件,并支持不同业务的能力。

  1. 绘制流程图公用组件;
  2. 新增/编辑可以拆分为同一个业务组件;
  3. 详情展示一个业务组件;

三、流程图公用组件部分

  1. 理解基本概念:画布、图形、 节点(形状、文字)、锚点、边线具体理解可以参考G6官方文档;
  2. 流程图公用组件这部分,是基于antv/g6的绘制能力结合vuex状态管理的存储的一个综合运用;
    a. 因业务独特的交互需求,运用了G6的自定义能力;
    b. 自定义节点,包括节点形状、文本、锚点设计、状态切换样式等设置;
    c. 自定义边线,包括边线形状、箭头、动画效果等设置;
    d. 自定义右键菜单事件,菜单动态位置、展示等设置;
    e. 自定义添加边线的事件;
    f. 自定义键盘快捷键事件;
    g. 自定义节点交互事件;
    h. 自定义边线交互事件;
    i. 自定义节点、边线交互状态样式切换事件;
(1) AppFlows.vue公用组件代码如下:
<template>
	<div class="sys-flows" ref="sysflows" :id="graphContainer">
		<!-- 节点右键菜单 -->
		<ul class="sys-flows-contentMenu" :style="contentMenuStyle">
			<!-- <li><i class="el-icon-document-copy"></i> 编辑</li> -->
			<li @click="graph_Remove"><i class="el-icon-delete-solid"></i> 删除</li>
		</ul>
	</div>
</template>
<script>
/**
 * @description 全局公用流程图组件
 * @author      sunsy
 */
import G6 from '@antv/g6';
import customNode from '@/util/G6/customNode.js';
import customEdge from '@/util/G6/customEdge.js';
import addMenu from '@/util/G6/behavor_addMenu.js';
import addEdge from '@/util/G6/behavor_addEdge.js';
import keyboard from '@/util/G6/behavor_keyboard.js';
import hoverNode from '@/util/G6/behavor_hoverNode.js';
import hoverEdge from '@/util/G6/behavor_hoverEdge.js';
import selectNode from '@/util/G6/behavor_selectNode.js';
export default {
  name:'AppFlows',
  props:{
        // 需要渲染的画布的数据
	    initData: {
		   type: Object,
		   default: function() {
			    return {
				   nodes: [],
				   edges: [],
			    }
		   }
        },
        // 需要画布支持的模式(上面有提到业务场景中有新增、编辑、渲染)几种场景下需要支持的操作不同,用模式来区分
        initMode: {
            type: String,
            default: function() {
                return 'default';
            }
        }
    },
	data(){
		return {
			graph: null,
			graphData: this.initData,
            graphContainer: 'dragger-target',
            contentMenuStyle: '',
		}
    },
    computed: {
        // 监听计算业务中右键菜单的变化
        contentMenuShow(){
            return this.$store.state.graphs.contentMenuShow;
        },
        // 监听计算业务中当前画布操作的事件
        events(){
            return this.$store.state.graphs.events;
        }
    },
    watch: {
        // 监听右键菜单样式来控制位置变化
        contentMenuShow(nval,oval) {
            this.contentMenuStyle = this.$store.state.graphs.contentMenuStyle;
        },
        // 监听画布操作事件变化后回执到业务回调事件具体业务具体处理
        events(nval,oval) {
            if(nval!==oval) {
                this.$emit('getEvents',nval);
            }
        },
        // 监听渲染数据变化重新渲染画布
        initData(nval,oval) {
            if(nval!==oval) {
                this.graphData = nval;
                this.graph_Read();
            }
        },
        //监听多标签打开路由变化,更新store的缓存的当前实例
        $route(){
            let graphCache     = this.$store.state.graphs.graphCache;
            let graphCacheKeys = Object.keys(graphCache);
            let graphCacheKey  = this.$route.path;
            if(graphCacheKeys.includes(graphCacheKey)) {
                this.$store.dispatch('graphs/updateGraph');
            }
        },
    },
	created() {
        this.init();
	},
	mounted(){
        this.$nextTick(() => {
            this.graph_Init();
        });
	},
	methods:{
		init() {
			//注册自定义节点
			customNode.init();
			//注册自定义边线
			customEdge.init(); 
			//注册自定义行为到G6
            const behavors = {
                'hover-node': hoverNode,
                'add-edge': addEdge,
                'select-node': selectNode,
                'hover-edge': hoverEdge,
                'keyboard':keyboard,
                'add-menu':addMenu
            };
            for (let key in behavors) {
                G6.registerBehavior(key, behavors[key])
			}
		},
		//初始化G6
		graph_Init() {
            const _this  = this;
			const width  = this.$refs.sysflows.clientWidth;
            const height = this.$refs.sysflows.clientHeight;
            const modes  = this.graph_Mode();
			const graph  = new G6.Graph({
                container: _this.graphContainer,
                width: width,
                height: height,
				fitView: false,  //是否开启画布自适应。开启后图自动适配画布大小
				minZoom: 0.5,
				maxZoom: 2,
                // plugins: [grid],
                // groupByTypes: true,
                modes: modes,
                defaultNode:  {
                    shape: 'customNode',
                    size: [ 120, 50 ],
                },
                defaultEdge: {
                    shape: 'customEdge',
                    style: {
                        radius: 20,
                        offset: 45,
                        endArrow: true,
                        lineWidth: 1,
                        stroke: '#1A9CFF'
                    }
                },
                edgeStateStyles: {
                    selected: {
                        stroke: '#1A9CFF'
                    }
                }
            });
            _this.$store.dispatch('graphs/init',graph);
            _this.graph = _this.$store.state.graphs.graph;
            _this.graph_Read();
		},
		//渲染G6画布
		graph_Read() {
			const _this     = this;
			const graphData = this.graphData;
            if(graphData.nodes && graphData.nodes.length>0) {
                _this.graph.read(graphData);
                _this.$store.dispatch('graphs/refresh');
			}
        },
        //删除节点
        graph_Remove() {
            this.$store.dispatch('graphs/remove');
            this.$store.dispatch('graphs/cotentmenu',{ show:false, style: ''});
        },
        //读取G6需要的功能
        graph_Mode() {
            let modes;
            let mode   = this.initMode;
            if(mode =='editor') {
                //开启编辑模式的时候开放编辑相关能力
                modes = { 
                    default: [
                        'drag-canvas', 
                        'zoom-canvas', 
                        'drag-node',
                        'select-node',
                        'keyboard',
                        'hover-node',
                        'hover-edge',
                        'add-menu',
                    ],
                    addEdge: ['add-edge'],
                }
            } else {
                //默认为显示模式
                modes = { 
                    default: [
                        'drag-canvas', 
                        'zoom-canvas', 
                        'select-node',
                        'hover-node',
                        'hover-edge'
                    ],
                }
            }
            return modes;
        }
	}
}
</script>
<style lang="scss" scoped>
.sys-flows{
    position: relative;
    width: 100%;
    height: 100%;
    background-color: rgb(242, 242, 242);
    background-size:10px 10px;
    background-image:linear-gradient(transparent 9px,#dedede 9px,#dedede 10px),linear-gradient(90deg,transparent 9px,#dedede 9px,#dedede 10px);
    cursor: default;

    &-contentMenu{
        position: absolute;
        width:80px;
        height:auto;
        z-index:2; 
        // left: 10px;
        // top: 10px;
        display: none; 
        cursor: pointer; 
        background:#fff;
        color: #333;
        border-radius:3px; 
        box-shadow: 0px 2px 8px rgba(0, 0, 0,0.15);
        overflow: hidden;
        li{
            text-align: center;
            line-height: 30px;
            &:hover{
                background: #E9F9FE;
            }
        }
    }
    .g6-tooltip {
        border: 1px solid #e2e2e2;
        border-radius: 4px;
        font-size: 12px;
        color: #545454;
        background-color: rgba(255, 255, 255, 0.9);
        padding: 10px 8px;
        box-shadow: rgb(174, 174, 174) 0px 0px 10px;
    }
}
</style>

公用组件中使用了G6自定义能力,自定义注册行为有多个js文件,以下我会选几个重要行为展示,不都全部展示出来,有需要的下载示例代码自己查看即可。

(2)customNode.js自定义节点代码如下:
/**
 * @description 全局公用流程图组件-自定义节点
 * @author      sunsy
 */
import G6 from '@antv/g6';
import store from '@/store';
export default {
    init() { 
        //********************************注册自定义节点**********************************
        const customNode = {
            draw(cfg, group) {
                console.log(G6)
                let  size = cfg.size;
                if(!size){
                    size = [120 , 50];
                }
                const shapeId = 'rect' + G6.Util.uniqueId();
                const width  = parseInt(size[0]);
                const height = parseInt(size[1]);
                const offsetX  = width / 2;
                const offsetY  = height / 2;
                const fillColor  = '#E8F8FE';
                const stokeColor = '#ACDAF8';
                // 创建节点
                const rect = group.addShape('rect', {
                    attrs: {
                        id: shapeId,
                        x: -offsetX,
                        y: -offsetY,
                        width: width,
                        height: height,
                        radius: 5,
                        stroke: stokeColor,
                        fill: fillColor,
                        lineWidth: 1,
                    },
                });
                // 父节点中创建文本形状
                if (cfg.label) {
                    let text = cfg.label;
                    if(text.length >8) {
                        text = text.substr(0,8) + '\n' + text.substr(8);
                    }
                    group.addShape('text', {
                        attrs: {
                            text: text,
                            x: 0,
                            y: 0,
                            fill: '#6A6C6D',
                            fontSize: 14,
                            textAlign: 'center',
                            textBaseline: 'middle',
                            fontWeight: 'bold',
                            parent: shapeId,
                        }
                    });
                }
                // 判断Graph实例处于编辑模式绘制锚点
                const graph   = store.state.graphs.graph;
                const modes   = graph._cfg.modes;
                if(modes.addEdge) {
                    const linkPoints = [[-offsetX,0], [offsetX,0], [0,-offsetY], [0,offsetY]];
                    for (let i = 0; i < linkPoints.length; i++) {
                        let inId  = 'circle-in-' + G6.Util.uniqueId();
                        let outId = 'circle-out-' + G6.Util.uniqueId();
                        group.addShape('circle', {
                            attrs: {
                                id: outId,
                                parent: inId,
                                x: linkPoints[i][0],
                                y: linkPoints[i][1],
                                r: 10,
                                fill: '#1A9CFF',
                                opacity: 0,
                                isOutPointOut: cfg.isOutPoint,
                                isInPointOut: cfg.isInPoint,
                            },
                        });
                        group.addShape('circle', {
                            attrs: {
                                id: inId,
                                x: linkPoints[i][0],
                                y: linkPoints[i][1],
                                r: 4,
                                fill: '#fff',
                                stroke: '#1A9CFF',
                                opacity: 0,
                                isOutPoint: cfg.isOutPoint,
                                isInPoint: cfg.isInPoint,
                            },
                        });
                    }
                }
                return rect;
            },
            // 切换节点状态样式
            setState(name, value, item) {
                const group = item.getContainer();
                const shape = group.get("children")[0]; 
                const children = group.findAll(g => {
                    return g._attrs.parent === shape._attrs.id
                });
                const circles = group.findAll(circle => {
                    return circle._attrs.isInPoint || circle._attrs.isOutPoint;
                });
                const defaultStyles = () => {
                    shape.attr({'fill':'#E8F8FE', 'stroke':'#ACDAF8'});
                    circles.forEach(circle => {
                        circle.attr('opacity', 0)
                    });
                };
                const selectedStyles = () => {
                    shape.attr({'fill':'#94D6FC', 'stroke':'#1A9CFF','cursor':'move'});
                    children.forEach(child => {
                        child.attr('cursor', 'move');
                    });
                    circles.forEach(circle => {
                        circle.attr('opacity', 1)
                    });
                };
                const successStyles = () => {
                    shape.attr({'fill':'#90B44B', 'stroke':'#ACDAF8'});
                } 
                const warningStyle = () => {
                    shape.attr({'fill':'#FAD689', 'stroke':'#D19826'});
                }
                switch (name) {
                    case "success":
                        if(value) {
                            successStyles();
                        } else {
                            defaultStyles();
                        }
                        break;
                    case "warning":
                        if(value) {
                            warningStyle();
                        } else {
                            defaultStyles();
                        }
                        break;
                    case "hover":
                    case "selected":
                        if (value) {
                            selectedStyles()
                        } else {
                            defaultStyles()
                        }
                        break;
                }
            },
        };
        // 注册到g6
        G6.registerNode('customNode', customNode);
    }
};

自定义节点文件里,可以根据ui图和业务交互自行定义需要的形状,不同交互状态样式,文字溢出等设置。

(3)customEdge.js 自定义边线代码如下:
/**
 * @description 全局公用流程图组件-自定义边线
 * @author      sunsy
 */
import G6 from '@antv/g6';
export default {
    init() {
        const dashArray = [
            [0, 1],
            [0, 2],
            [1, 2],
            [0, 1, 1, 2],
            [0, 2, 1, 2],
            [1, 2, 1, 2],
            [2, 2, 1, 2],
            [3, 2, 1, 2],
            [4, 2, 1, 2]
        ];
      
        const lineDash = [4,2,1,2];
        const interval = 9;
        //***************************************注册自定义边***************************************
        const customEdge = {
            draw(cfg, group) {
                let sourceNode, targetNode, start, end;
                if (typeof (cfg.source) === 'string') {
                    cfg.source = cfg.sourceNode
                }
                if(!cfg.start){
                    cfg.start={
                        x: 0,
                        y: 17
                    }
                }
                if(!cfg.end){
                    cfg.end={
                        x: 0,
                        y: -17
                    }
                }
                if (!cfg.source.x) {
                    sourceNode = cfg.source.getModel()
                    start = { 
                        x: sourceNode.x + cfg.start.x, 
                        y: sourceNode.y + cfg.start.y 
                    };
                } else {
                    start = cfg.source
                }
                if (typeof (cfg.target) === 'string') {
                    cfg.target = cfg.targetNode;
                }
                if (!cfg.target.x) {
                    targetNode = cfg.target.getModel();
                    end = { 
                        x: targetNode.x + cfg.end.x, 
                        y: targetNode.y +  cfg.end.y 
                    };
                } else {
                    end = cfg.target
                }
                let path = [];
                let hgap = Math.abs(end.x - start.x);
                if (end.x > start.x) {
                    path = [
                        ['M', start.x, start.y],
                        [
                           'C',
                            start.x,
                            start.y + hgap / (hgap / 50),
                            end.x,
                            end.y - hgap / (hgap / 50),
                            end.x,
                            end.y - 4],
                        [
                            'L',
                            end.x,
                            end.y
                        ]
                    ];
                } else {
                    path = [
                        ['M', start.x, start.y],
                        [
                            'C',
                            start.x,
                            start.y + hgap / (hgap / 50),
                            end.x,
                            end.y - hgap / (hgap / 50),
                            end.x,
                            end.y - 4
                        ],
                        [
                            'L',
                            end.x,
                            end.y
                        ]
                    ];
                }
                let lineWidth = 1;
                let MIN_ARROW_SIZE = 3;
                lineWidth = lineWidth > MIN_ARROW_SIZE ? lineWidth : MIN_ARROW_SIZE;
                const width = lineWidth * 10 / 3;
                const halfHeight = lineWidth * 4 / 3;
                const radius = lineWidth * 4;
                const endArrowPath = [
                    ['M', -width, halfHeight],
                    ['L', 0, 0],
                    ['L', -width, -halfHeight],
                    ['A', radius, radius, 0, 0, 1, -width, halfHeight],
                    ['Z']
                ];
                const keyShape = group.addShape('path', {
                    attrs: {
                        id: 'edge' + G6.Util.uniqueId(),
                        path: path,
                        stroke: '#b8c3ce',
                        //边的点击宽度(值越大越容易点击线)
                        lineAppendWidth: 20,
                        //结束箭头路径
                        endArrow: {
                            path: endArrowPath,
                            d: 1
                        }
                    }
                });
                return keyShape
            },
            afterDraw(cfg, group) {
                if (cfg.source.getModel().isOutPoint && cfg.target.getModel().isInPoint) {
                    //添加虚线轨迹动画
                    const shape = group.get('children')[0];
                    const length = shape.getTotalLength(); // G 增加了 totalLength 的接口
                    let totalArray = [];
                    for (var i = 0; i < length; i += interval) {
                        totalArray = totalArray.concat(lineDash);
                    }
                    let index = 0;
                    shape.animate({
                        onFrame() {
                            const cfg = {
                                lineDash: dashArray[index].concat(totalArray)
                            };
                            index = (index + 1) % interval;
                            return cfg;
                        },
                        repeat: true
                    }, 3000);
                }
            },
            setState(name, value, item) {
                const group = item.getContainer();
                const shape = group.get("children")[0];
                const selectStyles = () => {
                    shape.attr("stroke", "#6ab7ff");
                };
                const unSelectStyles = () => {
                    shape.attr("stroke", "#b8c3ce");
                };
                switch (name) {
                    case "selected":
                    case "hover":
                        if (value) {
                            selectStyles();
                        } else {
                            unSelectStyles(); 
                        }
                        break;
                }
            }
        };
        G6.registerEdge('customEdge', customEdge);

        //************************************注册自定义边线虚线***************************************
        const linkEdge = {
            draw(cfg, group) {
                let sourceNode, targetNode, start, end;
                if (!cfg.source.x) {
                    sourceNode = cfg.source.getModel()
                    start = { 
                        x: sourceNode.x + cfg.start.x, 
                        y: sourceNode.y + cfg.start.y 
                    };
                } else {
                    start = cfg.source
                }
                if (!cfg.target.x) {
                    targetNode = cfg.target.getModel()
                    end = { 
                        x: targetNode.x + cfg.end.x, 
                        y: targetNode.y + cfg.end.y 
                    };
                } else {
                    end = cfg.target;
                }

                let path = [];
                path = [
                    ['M', start.x, start.y],
                    ['L', end.x, end.y]
                ];
                const keyShape = group.addShape('path', {
                    attrs: {
                        id: 'edge' + G6.Util.uniqueId(),
                        path: path,
                        stroke: '#1890FF',
                        strokeOpacity: 0.9,
                        lineDash: [5, 5]
                    }
                });
                return keyShape
            },
        };
        G6.registerEdge('link-edge', linkEdge);
    }
};

自定义边线文件中,可以设置边线的头和尾形状、边线的形状、动画效果等。

(4)behavor_addEdge.js自定义节点、边线绘制事件代码如下:
/**
 * @description 全局公用流程图组件-注册画线行为
 * @author      sunsy
 */
import G6 from '@antv/g6';
import store from '@/store';
let startPoint = null;
let startItem  = null;
let endPoint   = {};
let activeItem = null;
let curInPoint = null;
export default {
    getEvents() {
        return {
            'mousemove': 'onMousemove',
            'mouseup': 'onMouseup',
            'node:mouseover': 'onMouseover',
            'node:mouseleave': 'onMouseleave'
        };
    },
    onMouseup(e) {
        // 判断当前对象是节点
        const item = e.item
        if (item && item.getType() === 'node') {
            const group = item.getContainer()
            // 判断当前对象是否是入点,是则循环节点中子节点中存在入点外圈元素并设置为目标锚点
            if (e.target._attrs.isInPoint) {
                const children = group._cfg.children;
                children.map(child => {
                    if (child._attrs.isInPointOut && child._attrs.parent === e.target._attrs.id) {
                        activeItem = child;
                    }
                });
                curInPoint = e.target;
            // 判断当前对象是否有出点,是则循环节点中子节点中存在出点外圈元素并设置为目标锚点
            } else if (e.target._attrs.isInPointOut) {
                activeItem = e.target;
                const children = group._cfg.children;
                children.map(child => {
                    if (child._attrs.isInPoint && child._attrs.id === e.target._attrs.parent) {
                        curInPoint = child
                    }
                });
            }
            // 判断如果存在目标锚点,创建一条两个节点间的边线基本数据
            if (activeItem) {
                const endX = parseInt(curInPoint._attrs.x);
                const endY = parseInt(curInPoint._attrs.y);
                endPoint = { x: endX, y: endY };
                if (this.edge) {
                    this.graph.removeItem(this.edge);
                    const model = {
                        id: 'edge-' + G6.Util.uniqueId(),
                        source: startItem,
                        sourceId: startItem._cfg.id,
                        target: item,
                        targetId: item._cfg.id,
                        start: startPoint,
                        end: endPoint,
                        shape: 'customEdge',
                        type: 'edge'
                    }
                    store.dispatch('graphs/add',{type:model.type, model:model});
                }
            } else {
                // 删除未找到目标节点的边线 
                if (this.edge) {
                    this.graph.removeItem(this.edge);
                }
            }
        } else {
            if (this.edge) {
                this.graph.removeItem(this.edge);
            }  
        }
        // 上面执行完成重新切换画布中所有节点的锚点展示状态样式
        this.graph.find("node", node => {
            const group = node.get('group');
            const children = group._cfg.children;
            children.map(child => {
                if (child._attrs.isInPointOut) {
                    child.attr('opacity', 0);
                }
                if (child._attrs.isInPoint) {
                    child.attr('opacity', 0);
                }
                if (child._attrs.isOutPoint) {
                    child.attr({'opacity': 0, 'fill': '#fff'});
                }
            })
        })
        // 切换开始节点的状态样式
        if (startItem) {
            this.graph.setItemState(startItem, 'hover', false);
        }
               
        this.graph.paint();
        startPoint = null;
        startItem = null;
        endPoint = {};
        activeItem = null;
        curInPoint = null;
        this.graph.setMode('default');
    },
    // 鼠标移动过程中,不断计算和切换边线开始和结束点的坐标位置并修改边线存储数据
    onMousemove(e) {
        const item = e.item;
        if (!startPoint) {
            this.graph.find("node", node => {
                const group = node.get('group');
                const children = group._cfg.children;
                children.map(child => {
                    if (child._attrs.isInPointOut) {
                        child.attr('opacity', 0.3);
                    }
                    if (child._attrs.isInPoint) {
                        child.attr('opacity', 1);
                    }
                })
            });
            const startX = parseInt(e.target._attrs.x);
            const startY = parseInt(e.target._attrs.y);
            startPoint = { x: startX, y: startY };
            startItem = item
            this.edge = this.graph.addItem('edge', {
                source: item,
                target: item,
                start: startPoint,
                end: startPoint,
                shape: 'link-edge'
            });
        } else {
            const point = { x: e.x, y: e.y };
            if (this.edge) {
                // 增加边的过程中,移动时边跟着移动
                this.graph.updateItem(this.edge, {
                    //  start: startPoint,
                    target: point
                });
            }
        }
    },
    // 鼠标经过锚点设置锚点的高亮效果
    onMouseover(e) {
        const item = e.item;
        if (item && item.getType() === 'node') {
            if (e.target._attrs.isInPointOut && !this.hasTran) {
                this.hasTran = true;
                //添加translate平移和scale缩放效果
                e.target.transform([
                    ['t', 0, 3],
                    ['s', 1.2, 1.2],
                ]);
            }
            this.graph.paint();
        }
    },
    // 鼠标离开锚点重置锚点的高亮效果
    onMouseleave() {
        this.graph.find("node", node => {
            const group = node.get('group');
            const children = group._cfg.children;
            children.map(child => {
                if (child._attrs.isInPointOut) {
                    child.resetMatrix();
                }
            })
        })
        this.hasTran = false;
        this.graph.paint();
    }
};

自定义行为中监听鼠标行为,动态切换锚点交互、边线和节点的样式、锚点样式等业务逻辑。
其他的例如右键菜单、节点选中、节点点击、键盘事件监听等行为就不详细说明和代码展示,具体代码都会在下载包里大家可以自己研究参考。

(5)利用Vuex管理流程图数据、状态、事件等行为

提示:流程图最重要G6实例、(节点、边线、状态)数据、行为存储部分主要靠vuex实现。

  1. 因为我的项目是多标签打开并可以保持的项目框架,所以需要考虑多个G6实例同时存在的情况,切换标签页面不刷新的情况又能保持渲染对应的g6实例,所以需要考虑多G6实例对象存储;
  2. 因此Vuex定义的状态树中:graphCache、graphCacheKey、graphCacheName是比较特殊的变量;
  3. 以上三个特殊变量主要是做多实例存储、切换实例使用,其他剩余变量都是针对单个g6实例数据存储的,graphCache存储的其实是排除以上三个变量数据的缓存集合。
  4. 当切换标签页面的时候,实际是根据状态树中的graphCacheKey,从graphCache集合中拿到对应的实例然后在分别赋值给当前实例的相关状态;

store/modules/graphs/index.js 代码如下

import router from '@/router'
const state = {
    //Graph实例对象缓存列表
    graphCache: {},
    //当前访问的Graph实例对象键名
    graphCacheKey: null,
    //添加到Graph实例对象列表需要排除的属性
    graphCacheName: ['graphCache', 'graphCacheKey','graphCacheName','events'],
    //Graph实例对象
    graph: {},
    //Graph实例数据
    data:{
        nodes: [],
        edges: [],
    },
    //Graph实例当前事件
    events: {},
    //Graph实例当前选中对象
    selectedItem: null,
    //Graph实例当前选中节点
    selectedNode: null,
    //Graph实例当前选中边线
    selectedEdge: null,
    //Graph实例右键菜单显示/隐藏状态
    contentMenuShow: false,
    //Graph实例右键样式
    contentMenuStyle: '',
}

const mutations = {
    setGraphCacheKey:  (state, data) => {
        state.graphCacheKey = data;
    },
    setGraphCache: (state, data) => {
        state.graphCache[data.key] = data.val;
    },
    setUpdateGraph:  (state) => {
        const cur      = state.graphCacheKey;
        const curGraph = state.graphCache[cur];
        for(let key in state) {
            if(!state.graphCacheName.includes(key)) {
               state[key] = curGraph[key];
            }
        }
    },
    setGraph: (state, data) => {
        state.graph = data;
    },
    setData: (state, data) => {
       state.data = {
           nodes: data.nodes,
           edges: data.edges
       };
    },
    setSelectedItem: (state, data) =>{
        if(data.select) {
            let model = data.target.getModel();
            let type  = data.target.getType();
            if(type === 'node') {
                state.selectedNode = model;
            } else if(type === 'edge') {
                state.selectedEdge = model;
            }
            state.selectedItem = data.target;
        } else {
            state.selectedItem = null;
            state.selectedNode = null;
            state.selectedEdge = null;
        }
    },
    setCotentMenu: (state, data) =>{
        state.contentMenuShow = data.show;
        state.contentMenuStyle = data.style;
    },
    setEvents: (state, data) =>{
        state.events  = data;
    }
}

const getters = {
    //读取节点/边线
    getById:  (state)=> (data)=>{
        const node = state.graph.findById(data.id);
        return node;
    },
}

const actions = {
    //设置当前激活的graph实例,便于存储多graph实例
    setGraphCache: ({ commit, state }) =>  {
        const route  = router.currentRoute;
        const path   = route.path;
        commit('setGraphCacheKey',path); 
    },
    //更新Graph实例列表缓存
    updateGraphCache: ({ commit, state }) =>  {
        let key = state.graphCacheKey;
        let obj = {}; 
        for(let key in state) {
            if(!state.graphCacheName.includes(key)) {
               obj[key] = state[key];
            }
        }
        commit('setGraphCache',{key: key ,val: obj });
    },
    //读取缓存更新graph实例
    updateGraph : ({ commit, dispatch }) =>  {
        dispatch('setGraphCache').then(()=>{
            commit('setUpdateGraph');
        });
    },
    //存储初始化graph实例
    init: ({ commit, dispatch }, data) =>  {
        commit('setGraph', data);
        dispatch('setGraphCache').then(()=>{
            dispatch('updateGraphCache');
        });
    },
    //新增节点/边线
    add: ({ dispatch, state }, data) =>  {
        let type  = data.type;
        let model = data.model;
        if(type === 'edge') {
            //重复边线处理/有向无环处理
            let edges = state.data.edges;
            let ishas = edges.some(item=> {
                return (item.source == model.sourceId  && item.target == model.targetId) || (item.source == model.targetId && item.target == model.sourceId) || (model.sourceId == model.targetId);
            });
            if(ishas) {
                return;
            }
        }
        state.graph.add(type, model);
        dispatch('refresh').then(()=>{
            dispatch('selectedstate',{select: true, target: model});
        });
    },
    //修改节点/边线
    update: ({ dispatch, state }, data) =>  {
        const node = state.graph.findById(data.id);
        state.graph.update(node, data);
        dispatch('refresh');
    },
    //删除节点/边线
    remove: ({ dispatch, state }, data) =>  {
        let selected = state.selectedItem;
        if(!data && !selected) {
            return false;
        }
        if(!data) {
            data     = selected.getModel();
        }
        const node = state.graph.findById(data.id)
        state.graph.remove(node);
        dispatch('refresh');
    },
    //更新graph数据集合
    refresh: ({ commit, dispatch, state }, data) =>  {
        if(!data) {
            let data =  state.graph.save();
            commit('setData', data);
        } else {
            commit('setData', data);
        }
        dispatch('updateGraphCache');
    },
    //修改选中节点/边线
    selectedstate: ({ state, dispatch }, data) =>  {
        let graph = state.graph;
        let node  = graph.findById(data.target.id);
        // graph.trigger('node:click');
        if(node._cfg.type === 'node') {
            const selected = graph.findAllByState('node', 'selected');
            selected.forEach(node => {
                if (node !== item) {
                    graph.setItemState(node, 'selected', false);
                }
            });
            graph.setItemState(node, 'selected', data.select);
            let item  = { select: data.select, target: node };
            dispatch('selected',item);
        }
    },
    //更新选中节点/边线存储数据
    selected: ({ commit, dispatch }, data) =>  {
        commit('setSelectedItem', data);
        dispatch('updateGraphCache');
    },
    //更新graph显示/隐藏右键菜单
    cotentmenu:  ({ commit, dispatch }, data) =>  {
        commit('setCotentMenu', data)
        dispatch('updateGraphCache');
    },
    //更新graph实例事件,便于公用组件监听回执,不同页面回调执行自己的业务逻辑
    events: ({ commit, dispatch }, data) =>  {
        commit('setEvents', data);
        dispatch('updateGraphCache');
    },
    //清除graph实例数据并刷新
    clear: ({ commit, dispatch, state }, data) =>  {
        commit('setData', data);
        commit('setEvents', {});
        commit('setSelectedItem', { target: null, select: false});
        state.graph.clear();
        state.graph.refresh();
        dispatch('updateGraphCache');
    }
}

export default {
    namespaced: true,
    state,
    mutations,
    actions,
    getters,
}

以上部分基本是公用流程图组件的相关部分了,剩下的就是根据具体业务场景的使用。

四、新增/编辑模型业务(拖拽+流程图应用)

算子数据准备
  1. 因业务需求我们的算子都是读取后端已经设置好的基本算子数据,不允许动态生成算子,参数配置和交互这一块也是相当负责和不同,为了方便理解Demo展示里我做成了简单参数配置交互形式。具体数据格式参考下图:
    在这里插入图片描述

  2. 当然固定算子这部分业务逻辑换成动态生成也很方便,只要稍微调整下前面这块的逻辑即可,便可以生成简单动态流程图绘制工具,这块留着大家自行发挥吧,不做演示。

  3. 根据业务需求自定义的节点中我设置了四个固定的锚点,会根据以上初始化节点数据中isInPointisOutPoint 来动态变化节点是否开启入点和出点。

  4. 具体的切换场景如下几种详细演示和说明:
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

算子和节点的关系理解

因业务不同可能大家不理解本业务中的算子和节点的关系,其实大家可以理解业务中的算子就是预置好的节点基本信息,设置好节点的名称、出点、入点等。

新增和编辑业务结合为一个组件
<template>
  <div class="sys-page">
      <div class="models-flows">
        <!-- 左侧算子区域 -->
        <div class="page-panel page-panel-primary models-flows-left">
            <div class="panel-head"><h5>模型算子</h5></div>
            <div class="panel-body">
                <ul ref="dragger-source" id="draggerTarget">
                    <li v-for="(item,index) in butnList" :key="index" class="dragger-button" :data-id="item.label">
                        {{item.name}}
                    </li>
                </ul>
            </div>
        </div>
        <!-- 中间绘制区域 -->
        <div class="page-panel page-panel-primary models-flows-middle">      
            <el-row :gutter="10" class="panel-head">
                <el-col :lg="12"><h5>新增模型</h5></el-col>
                <el-col :lg="12" class="textR">
                    <el-button type="primary" size="mini" @click="resetModelFlow"><i class="el-icon-delete"></i> 重置画板</el-button>
                    <el-button type="success" size="mini" @click="saveModelFlow" :disabled="graphData.nodes.length==0 ? true: false"><i class="el-icon-document-checked"></i> 保存为模型</el-button>
                </el-col>
            </el-row>
            <div class="panel-body">
                <AppFlows initMode="editor" :initData="detailInfo" @getEvents="getGraphEvents"></AppFlows>
            </div>
        </div>
        <!-- 右侧模型区域 -->
        <div class="page-panel page-panel-primary models-flows-right">
            <el-row :gutter="10" class="panel-head">
                <el-col :lg="12"><h5>参数配置</h5></el-col>
                <el-col :lg="12" class="textR">
                    <el-button type="primary" size="mini" @click="setCurrentNode"><i class="el-icon-document-checked"></i> 保存</el-button>
                </el-col>
            </el-row>
            <div class="panel-body">
                <el-form ref="form" :model="modelInfo" size="small" label-width="100px">
                    <el-form-item label="名称:" prop="name">
                        {{modelInfo.name}}
                    </el-form-item>
                    <el-form-item label="开始日期:" prop="startDate">
                        <el-date-picker placeholder="选择日期" v-model="modelInfo.startDate" value-format="yyyy-MM-dd" style="width:100%"></el-date-picker>
                    </el-form-item>
                    <el-form-item label="结束日期:" prop="endDate">
                        <el-date-picker placeholder="选择日期" v-model="modelInfo.endDate" value-format="yyyy-MM-dd" style="width:100%"></el-date-picker>
                    </el-form-item>
                    <el-form-item label="备注:" prop="desc">
                        <el-input type="textarea" v-model="modelInfo.desc" placeholder="输入备注"></el-input>
                    </el-form-item>
                </el-form>
            </div>
        </div>
    </div>
  </div>
</template>
<script>
/**
 5. @description 模型新增/编辑
 6. @author      sunsy
 */
import AppFlows from '../appflows'
import $ from 'jquery';
import 'jquery-ui/ui/widgets/draggable';
import 'jquery-ui/ui/widgets/droppable';
import { mapState,mapGetters,mapActions } from 'vuex'
export default {
    components:{
        AppFlows,
    },
    data() {
        return {
            //用户信息
            userInfo: JSON.parse(localStorage.getItem('userInfo')),
            //拖拽目标容器
            draggerTarget: 'draggerTarget',
            //选中节点对象
            modelInfo: {},
            //编辑模型详情对象
            detailInfo: {}
        };
    },
    computed: {
        ...mapState({
            // 分析任务列表
            butnList: state=> state.basic.taskType,
            // 绘制数据
            graphData: state=> state.graphs.data,
            // 选中节点
            selectedNode: state=> state.graphs.selectedNode,
        }),
    },
    created() {
        this.getDetail();
    },
    mounted() {
        this.initLeftFunc();
    },
    methods: {
        // 读取模型详情
        getDetail() {
            //读取接口数据赋值初始化画布数据
            this.detailInfo = {
               nodes: [],
               edges: []
            }
        },
        // 初始化左侧按钮
        initLeftFunc(){
            const _this = this;
            $( ".dragger-button").draggable({
                appendTo: '#' + _this.draggerTarget,
                cursor: 'move',
                helper: 'clone',
                opacity: 0.35,
                revert: 'invaild',
                start:function(ev,ui){},
                drag: function(ev, ui){},
                stop: function(ev){
                    if(ev.toElement.nodeName === 'CANVAS') {
                        let id       = ev.target.dataset.id;
                        let butnInfo = _this.$store.getters['basic/getTaskType'](id);
                        const nodeId = _this.graphData.nodes.length;
                        const node = {
                            id: id + '-' + nodeId,
                            label: butnInfo.name, 
                            x: ev.offsetX,
                            y: ev.offsetY,
                            type: butnInfo.label,
                            isInPoint: butnInfo.isInPoint,
                            isOutPoint: butnInfo.isOutPoint,
                        }
                        _this.$store.dispatch('graphs/add',{type:'node',model:node}).then(()=>{                            
                            _this.modelInfo  = {
                               name: butnInfo.name,
                               desc: null,
                               startDate: null,
                               endDate: null
                            }
                        });
                    }
                },
            });
        },
        //获取画布当前事件
        getGraphEvents(data) {
            if(data.type ==='node:click') {
                if(this.selectedNode.params) {
                    this.modelInfo = this.selectedNode.params;
                } else {
                    this.modelInfo  = {
                        name: this.selectedNode.label,
                        desc: null,
                        startDate: null,
                        endDate: null
                    }
                }
            }
        },
        //修改节点参数
        setCurrentNode(){
            let nodeList = this.graphData.nodes;
            for(let i = 0; i< nodeList.length; i++) {
                if(nodeList[i].id == this.selectedNode.id) {
                    nodeList[i].params = this.modelInfo;
                    this.$store.dispatch('graphs/update',nodeList[i]);
                    this.$message({ 
                        message: "参数保存成功!", 
                        type: "success" 
                    });
                    break;
                }
            }
        },
        //保存为模型弹窗
        saveModelFlow(){
            let nodesList = this.graphData.nodes;
            let edgesList = this.graphData.edges;
            for(let key in nodesList) {
                if(!nodesList[key].params) {
                    this.$store.dispatch('graphs/selectedstate',{ select: true,  target: nodesList[key]});
                    this.$message({ 
                        message: "请补全选中的节点参数并保存!", 
                        type: "warning" 
                    });
                    break;
                }
            }
            // if(edgesList.length ==0) {
            //     this.$message({ 
            //         message: "请确保节点之间只有一个逻辑关系的连接线!", 
            //         type: "warning" 
            //     });
            // }
            //其他业务处理然后数据保存到服务器           
        },
        //重置画板
        resetModelFlow() {
            const data = {
                nodes:[],
                edges:[],
            };
            this.$store.dispatch('graphs/clear', data);
            this.modelInfo  = {};
            this.detailInfo = {};
        },
    },
};
</script>
<style lang="scss" scoped>
.models-flows{
    display: flex;
    justify-content: space-between;
    height: calc(100vh - 150px);
    margin-top: 20px;
    overflow: hidden;
    &-left{
        width: 13%;
        ul{
            padding:20px;
            li{
                padding:10px;
                text-align: center; 
                list-style: none; 
                background: #3BC0B3;
                color: #fff;
                border:solid 1px #3BC0B3;
                border-radius:5px;
                margin-top: 15px;cursor: pointer;
                &:hover,&:nth-child(n+6){
                    border-color: #3BC0B3;
                    color: #fff;
                    background-color: #26766E;
                }
            }
        }
    }
    &-middle{
        width: 69.7%;
        .panel-body{
            padding: 0;
            height: 100%;
        }
    }
    &-right{
        width: 17%;
        position: relative;
        .panel-body{
            height: 92%;
            overflow-y: auto;
            color: #fff;
            /deep/ .el-form-item__label {
                color: #fff;
            }
        }
    }
}
</style>
  1. 新增根据拖入到画布的算子的基本信息和位置,画布中创建对应的节点数据;
  2. 编辑的时候读取画布需要的数据格式,然后根据公用组件的intData参数传入到公用组件中渲染;
  3. 编辑状态其实就是多出了数据初始化部分,其余部分和新增状态操作基本一致。

五、读取详情渲染流程图

  1. 详情状态一般只是做展示简单的交互即可,不像编辑和新增状态需要那么多交互,所以通过参数控制定义一个简单模式即可。initMode参数可以不传,因为公用组件里默认为展示简单交互模式。
  2. 如需展示详细参数,利用getEvents回执事件,监听单击、双击等事件处理即可。
  3. 详情组件完整排版和应用不作介绍比较简单,文章内容有点长了。大家理解即可,流程图应用部分示例代码:
  <AppFlows :initData="detailInfo" @getEvents="getGraphEvents"></AppFlows>

六、Demo代码下载

在这里插入图片描述
终于写完了,这个坑填的有点久抱歉,最近事情比较多一直都是陆陆续续的,而且项目比较久了具体业务相当复杂,为了单独摘出可演示Demo又自己回顾了一次以前的自己写代码逻辑。

不得不吐槽当时我做这个功能的时候简直要秃顶爆肝,项目组单独给我预留了调研和开发的时间,当时的g6官方文档不是现在的版本,不得不吐槽当时的官方文档简直没法看,我自认为理解能力是比较强的人,但看他们的文档简直痛苦到极点。
在这里插入图片描述

不过现在官方改版后的版本发现好太多了,不管怎么样还是要感谢有了g6的基础和他们咨询群小伙伴的答疑才能根据自己的业务需求实现自己复杂的业务功能。
在这里插入图片描述
完整demo下载地址:https://download.csdn.net/download/sunshouyan/12820045

希望本文章对有需求的有参考价值,具体演示代码稍后会上传资源库,大家可以根据需求下载参考。

七、懒人先立flag挖坑占位…

在这里插入图片描述
下一篇文章更新利用node/express模块搭建前端数据库,模拟后端创建业务接口。

Logo

前往低代码交流专区

更多推荐