天天看点

activiti6.0通过bpmn.js展示高亮流程图(前端绘制流程图)

为了让大家看到最后的效果是否是自己想要的,所以我们先来看最后的效果图:

activiti6.0通过bpmn.js展示高亮流程图(前端绘制流程图)

1.背景

我们都知道activiti在5.22的时候就有了流程图跟踪组件

Diagram Viewer

,如图(图片来源《Activiti实战》):

activiti6.0通过bpmn.js展示高亮流程图(前端绘制流程图)

但是,到6.0就找不到该组件了,可能因为6.0的api改动很大,有些类和包完全舍弃了,该组件就不适用了。所以在6.0的时候,我们要想展示高亮流程图,往往都是通过后端来生成图片,返回给前端展示。这种方式也是我们项目之前所用的,生成图片代码繁琐,在生产环境还有字体问题,我们就不多介绍。

无意中了解到

bpmn.js

可以设计和展示流程图(本篇只讲解展示流程图),所以就去搜索这方面的资料。找到了这篇博客:

bpmn整合流程图高亮显示流程进度图

在他的基础上,进行了改造和优化,就有了开头的效果图。

2.前端依赖

"bpmn-js": "^8.7.1",
"bpmn-js-properties-panel": "^0.44.0",
"bpmn-js-token-simulation": "^0.21.1",
"camunda-bpmn-moddle": "^5.1.2",
"xml-js": "^1.6.11"
           

引入这些依赖就可以了。然后就是页面,基本上与 bpmn整合流程图高亮显示流程进度图 一样,但是加入了自己的逻辑,修复了一些显示问题:

<template>
  <div>
  <div class="bpmn-viewer-container">
    <div
      style="width:100%;height:20px;position: absolute; left: 20px; top: 10px; color: #000000D9;font-size: 16px;font-weight: 500">
      {{title}}
    </div>
    <div style="position: absolute; left: 10px; top: 40px;z-index: 999">
      <el-button-group key="scale-control">
        <el-tooltip effect="light" content="缩小视图">
          <el-button :size="headerButtonSize" :disabled="defaultZoom < 0.2" icon="el-icon-zoom-out"
                     @click="processZoomOut()"/>
        </el-tooltip>
        <el-button :size="headerButtonSize">{{ Math.floor(this.defaultZoom * 10 * 10) + '%' }}</el-button>
        <el-tooltip effect="light" content="放大视图">
          <el-button :size="headerButtonSize" :disabled="defaultZoom > 4" icon="el-icon-zoom-in"
                     @click="processZoomIn()"/>
        </el-tooltip>
        <el-tooltip effect="light" content="重置视图并居中">
          <el-button :size="headerButtonSize" icon="el-icon-c-scale-to-original" @click="processReZoom()"/>
        </el-tooltip>
      </el-button-group>
    </div>

    <div id="bpmnCanvas" style="width:100%;height:500px;margin: 0 auto;"></div>
    <div v-for="item in detailInfo" :key="item.activityId" style="width: 90%;margin: 0 auto;border-bottom: 1px dashed #333;">
      <el-row>
        <el-col :span="12">
          <p>节点名称:{{item.activityName}}</p>
          <p>审批人:{{item.assignee}}</p>
          <p>审批状态:{{item.approvalStatus}}</p>
        </el-col>
        <el-col :span="12">
          <p>审批结果:{{item.result}}</p>
          <p>审批意见:{{item.comment}}</p>
          <p>审批时间:{{item.endTime}}</p>
        </el-col>
      </el-row>
    </div>
  </div>
  </div>
</template>

<script>
  import BpmnViewer from 'bpmn-js'
  import MoveCanvasModule from 'diagram-js/lib/navigation/movecanvas'
  import { checkSpeed, getOneActivity } from '@/api/approval/build'
  let bpmnViewer = null
  export default {
    props: {
      headerButtonSize: {
        type: String,
        default: 'small',
        validator: value => ['default', 'medium', 'small', 'mini'].indexOf(value) !== -1
      },
      reviewObj: {
        type: Object
      }
    },
    name: 'reviewRuningFlow',
    components: {
    },
    data() {
      return {
        detailInfo: [],
        executedLightNode: [],
        highlightLine: [],
        activeLightNode:[],
        defaultZoom: 1,
        nodeDetail: {},
        scale: 1,
        title: '流程预览',
        showViewDialog: false,
        instanceId: undefined
      }
    },
    mounted() {
      // this.initPage()
    },
    methods: {
      initPage(instanceId, procDefId) {
        bpmnViewer && bpmnViewer.destroy()
        bpmnViewer = new BpmnViewer({
          container: '#bpmnCanvas',
          width: '100%',
          additionalModules: [
            MoveCanvasModule // 移动整个画布
          ]
        })
        this.instanceId = instanceId
        let len = document.getElementsByTagName('svg').length
        document.getElementsByTagName('svg')[len-2].setAttribute('display', 'none')
        if(instanceId || procDefId) {
          checkSpeed({'instanceId':instanceId, 'procDefId':procDefId}).then(res => {
          if (res.code === 200) {
            this.title = res.data.modelName
            this.highlightLine = res.data.highlightedFlowIds
            this.executedLightNode = res.data.executedActivityIds
            this.activeLightNode = res.data.activeActivityIds
            if (bpmnViewer) {
              this.importXml(res.data.modelXml)
            }
          } else {
            this.$message({
              message: res.data.msg,
              type: 'error'
            })
          }

        })}
        //以下注释代码是只展示流程图不需要高亮展示
        /*if(bpmnViewer){
            this.importXml(this.reviewObj.modelXml);
        } else {
            console.error('bpmnViewer is null or undefined!');
        }*/
      },

      getHtmlAttr(source, element, attr) {
        let result = []
        let reg = '<' + element + '[^<>]*?\\s' + attr + '=[\'"]?(.*?)[\'"]?\\s.*?>'
        let matched = source.match(new RegExp(reg, 'gi'))

        matched && matched.forEach(item => {
          item && result.push(item)
        })
        return result
      },

      importXml(modelXml) {
        // 处理排他网关, 注:流程图预览时,排他网关需要在对应的<bpmndi:BPMNShape>节点上添加属性isMarkerVisible="true"
        let gatewayIds = this.getHtmlAttr(modelXml, 'exclusiveGateway', 'id')
        let modelXmlTemp = modelXml
        if (gatewayIds && gatewayIds.length > 0) {
          gatewayIds.forEach(item => {
            const result = new RegExp('id="(.+?)"').exec(item)
            if (result && result[1]) {
              modelXmlTemp = modelXmlTemp.replace('bpmnElement="' + result[1] + '"', 'bpmnElement="' + result[1] + '" isMarkerVisible="true"')
            }
          })
        }
        bpmnViewer.importXML(modelXmlTemp, (err) => {
          if (err) {
            console.error(err, 1111)
          } else {
            this.importXmlSuccess()
          }
        })
      },

      importXmlSuccess() {
        // 使流程图自适应屏幕
        let canvas = bpmnViewer.get('canvas')
        canvas.zoom('fit-viewport', 'auto')
        //设置高亮线和高亮节点,需要配合style中的css样式一起使用,否则没有颜色
        this.setViewerStyle(canvas)
        //给任务节点加聚焦和离焦事件
        this.bindEvents()
      },

      setViewerStyle(canvas) {
        //已完成节点高亮
        let executedLightNode = this.executedLightNode
        if (executedLightNode && executedLightNode.length > 0) {
          executedLightNode.forEach(item => {
            canvas.addMarker(item, 'highlight-executed')
          })
        }

        //顺序线高亮
        let highlightLines = this.highlightLine
        if (highlightLines && highlightLines.length > 0) {
          highlightLines.forEach(item => {
            canvas.addMarker(item, 'highlight-line')
          })
        }

        //正在执行节点高亮
        let activeLightNode = this.activeLightNode
        if (activeLightNode && activeLightNode.length > 0) {
          activeLightNode.forEach((item,index) => {
            canvas.addMarker(item, 'highlight')
          })
          document.querySelectorAll('.highlight').forEach((item,index)=>{
            item.querySelector('.djs-visual rect').setAttribute('stroke-dasharray', '4,4')
          })
        }
      },

      // 以下代码为:为节点注册鼠标悬浮事件
      bindEvents() {
        let eventBus = bpmnViewer.get('eventBus')
        eventBus.on('element.hover', (e) => {
          if (e.element.type === 'bpmn:UserTask') {
            if (this.nodeDetail[e.element.id]) {
              this.detailInfo = this.nodeDetail[e.element.id]
            } else {
              getOneActivity({
                instanceId: this.instanceId,
                activityId: e.element.id
              }).then(res => {
                this.nodeDetail[e.element.id] = res.data;
                this.detailInfo = res.data
              })
            }
          }
        })
        eventBus.on('element.out', (e) => {
           if (e.element.type === 'bpmn:UserTask') {
            this.detailInfo = []
          }
        })
      },

      //悬浮框设置
      /*genNodeDetailBox(e, overlays) {
        let tempDiv = document.createElement('div')
        //this.detailInfo = detail;
        let popoverEl = document.querySelector('.flowMsgPopover')
        //let popoverEl = this.$refs.flowMsgPopover;
        console.log(this.detailInfo)
        tempDiv.innerHTML = popoverEl.innerHTML
        tempDiv.className = 'tipBox'
        tempDiv.style.width = '260px'
        tempDiv.style.background = 'rgba(255, 255, 255)'
        overlays.add(e.element.id, {
          position: { top: e.element.height, left: 0 },
          html: tempDiv
        })
      },*/

      processZoomIn(zoomStep = 0.1) {
        let newZoom = Math.floor(this.defaultZoom * 100 + zoomStep * 100) / 100
        if (newZoom > 4) {
          throw new Error('[Process Designer Warn ]: The zoom ratio cannot be greater than 4')
        }
        this.defaultZoom = newZoom
        bpmnViewer.get('canvas').zoom(this.defaultZoom)
      },
      processZoomOut(zoomStep = 0.1) {
        let newZoom = Math.floor(this.defaultZoom * 100 - zoomStep * 100) / 100
        if (newZoom < 0.2) {
          throw new Error('[Process Designer Warn ]: The zoom ratio cannot be less than 0.2')
        }
        this.defaultZoom = newZoom
        bpmnViewer.get('canvas').zoom(this.defaultZoom)
      },
      processReZoom() {
        this.defaultZoom = 1
        bpmnViewer.get('canvas').zoom('fit-viewport', 'auto')
      }
    }
  }
</script>

<style lang="scss">
  @import '../../../../node_modules/bpmn-js/dist/assets/diagram-js.css';
  @import '../../../../node_modules/bpmn-js/dist/assets/bpmn-font/css/bpmn.css';
  @import '../../../../node_modules/bpmn-js/dist/assets/bpmn-font/css/bpmn-codes.css';
  @import '../../../../node_modules/bpmn-js/dist/assets/bpmn-font/css/bpmn-embedded.css';
  @import '../../../../node_modules/bpmn-js-properties-panel/dist/assets/bpmn-js-properties-panel.css';

  /*.bjs-powered-by {
      display: none;
  }*/
  .flowMsgPopover {
    display: none;
  }

  .highlight:not(.djs-connection) .djs-visual > :nth-child(1) {
    fill: rgb(251, 233, 209) !important; /* color elements as green */
  }

  .highlight g.djs-visual > :nth-child(1) {
    stroke: rgb(214, 126, 125) !important;
  }

  .highlight-executed g.djs-visual > :nth-child(1) {
    stroke: rgb(0, 190, 0, 1) !important;
    fill: rgb(180, 241, 180) !important;
  }

  .highlight-line g.djs-visual > :nth-child(1) {
    stroke: rgb(0, 190, 0) !important;
  }

  @-webkit-keyframes dynamicNode {
    to {
      stroke-dashoffset: 100%;
    }
  }

  .highlight {
    .djs-visual {
      -webkit-animation: dynamicNode 18S linear infinite;
      -webkit-animation-fill-mode: forwards;
    }
  }

  .tipBox {
    width: 300px;
    background: #fff;
    border-radius: 4px;
    border: 1px solid #ebeef5;
    padding: 12px;
    /*.ant-popover-arrow{
        display: none;
    }*/
    p {
      line-height: 28px;
      margin: 0;
      padding: 0;
    }
  }

</style>

           

这样前端的工作就完成了。

3.后端返回数据

我们从图片可以看出,前端渲染图片需要4种数据:

  • 已执行的节点
  • 正在执行的节点
  • 已执行的线
  • 原始流程文件(xml)

基本上我们原先后端的代码也能用,我们就不用通过代码去画图了,只需要将查找出的4种数据返回给前端。这部分的代码就不贴了,大家东拼西凑都能找到,我这边用的也上一个负责工作流的伙伴东拼西凑留下来的。

4.更新后端返回数据代码

返回实体:

@Data
public class ProcessHighlightEntity {

    /**
     * 当前正执行节点id
     */
    private Set<String> activeActivityIds;

    /**
     * 已执行节点id
     */
    private Set<String> executedActivityIds;

    /**
     * 高亮线id(流程已走过的线)
     */
    private Set<String> highlightedFlowIds;

    /**
     * 流程xml文件 字符串
     */
    private String modelXml;

    /**
     * 流程名称
     */
    private String modelName;
}
           

获取流程图高亮所需数据:

public ProcessHighlightEntity getActivitiProcessHighlight(String instanceId, String procDefId) {
        ProcessDefinition processDefinition = getProcessDefinition(procDefId, instanceId);

        procDefId = processDefinition.getId();
        BpmnModel bpmnModel = getBpmnModel(procDefId);

        List<HistoricActivityInstance> histActInstances = historyService.createHistoricActivityInstanceQuery()
                .processInstanceId(instanceId).orderByHistoricActivityInstanceId().asc().list();

        ProcessHighlightEntity highlightEntity = getHighLightedData(bpmnModel.getMainProcess(), histActInstances);
        highlightEntity.setModelName(processDefinition.getName());
		// Map缓存,提高获取流程文件速度
        if (ActivitiConstants.BPMN_XML_MAP.containsKey(procDefId)) {
            highlightEntity.setModelXml(ActivitiConstants.BPMN_XML_MAP.get(procDefId));
        } else {
            InputStream bpmnStream = repositoryService.getResourceAsStream(processDefinition.getDeploymentId(), processDefinition.getResourceName());
            try (Reader reader = new InputStreamReader(bpmnStream, StandardCharsets.UTF_8)) {
                String xmlString = IoUtil.read(reader);
                highlightEntity.setModelXml(xmlString);
                ActivitiConstants.BPMN_XML_MAP.put(procDefId, xmlString);
            } catch (IOException e) {
                log.error("[获取流程数据] 失败,{}", e.getMessage());
                throw new CustomException("获取流程数据失败,请稍后重试");
            }
        }

        return highlightEntity;
}
           

获取流程定义数据:

public ProcessDefinition getProcessDefinition(String procDefId, String instanceId) {
        if (StrUtil.isBlank(procDefId)) {
            if (StrUtil.isBlank(instanceId)) {
                throw new CustomException("流程实例id,流程定义id 两者不能都为空");
            }
            ProcessInstance processInstance = runtimeService.createProcessInstanceQuery()
                    .processInstanceId(instanceId)
                    .singleResult();
            if (processInstance == null) {
                HistoricProcessInstance histProcInst = historyService.createHistoricProcessInstanceQuery()
                        .processInstanceId(instanceId)
                        .singleResult();
                if (histProcInst == null) {
                    throw new CustomException("查询失败,请检查传入的 instanceId 是否正确");
                }
                procDefId = histProcInst.getProcessDefinitionId();
            } else {
                procDefId = processInstance.getProcessDefinitionId();
            }
        }

        try {
            return repositoryService.getProcessDefinition(procDefId);
        } catch (ActivitiObjectNotFoundException e) {
            throw new CustomException("该流程属于之前流程,已删除");
        }
}
           

获取Bpmn模型数据:

public BpmnModel getBpmnModel(String procDefId) {
        try {
            return repositoryService.getBpmnModel(procDefId);
        } catch (ActivitiObjectNotFoundException e) {
            throw new CustomException("流程定义数据不存在");
        }
}
           

获取需要高亮的流程数据:

private ProcessHighlightEntity getHighLightedData(Process process,
                                                      List<HistoricActivityInstance> historicActInstances) {
        ProcessHighlightEntity entity = new ProcessHighlightEntity();
        // 已执行的节点id
        Set<String> executedActivityIds = new HashSet<>();
        // 正在执行的节点id
        Set<String> activeActivityIds = new HashSet<>();
        // 高亮流程已发生流转的线id集合
        Set<String> highLightedFlowIds = new HashSet<>();
        // 全部活动节点
        List<FlowNode> historicActivityNodes = new ArrayList<>();
        // 已完成的历史活动节点
        List<HistoricActivityInstance> finishedActivityInstances = new ArrayList<>();

        for (HistoricActivityInstance historicActivityInstance : historicActInstances) {
            FlowNode flowNode = (FlowNode) process.getFlowElement(historicActivityInstance.getActivityId(), true);
            historicActivityNodes.add(flowNode);
            if (historicActivityInstance.getEndTime() != null) {
                finishedActivityInstances.add(historicActivityInstance);
                executedActivityIds.add(historicActivityInstance.getActivityId());
            } else {
                activeActivityIds.add(historicActivityInstance.getActivityId());
            }
        }

        FlowNode currentFlowNode = null;
        FlowNode targetFlowNode = null;
        // 遍历已完成的活动实例,从每个实例的outgoingFlows中找到已执行的
        for (HistoricActivityInstance currentActivityInstance : finishedActivityInstances) {
            // 获得当前活动对应的节点信息及outgoingFlows信息
            currentFlowNode = (FlowNode) process.getFlowElement(currentActivityInstance.getActivityId(), true);
            List<SequenceFlow> sequenceFlows = currentFlowNode.getOutgoingFlows();

            /**
             * 遍历outgoingFlows并找到已已流转的 满足如下条件认为已已流转:
             * 1.当前节点是并行网关或兼容网关,则通过outgoingFlows能够在历史活动中找到的全部节点均为已流转
             * 2.当前节点是以上两种类型之外的,通过outgoingFlows查找到的时间最早的流转节点视为有效流转
             */
            if ("parallelGateway".equals(currentActivityInstance.getActivityType()) || "inclusiveGateway".equals(currentActivityInstance.getActivityType())) {
                // 遍历历史活动节点,找到匹配流程目标节点的
                for (SequenceFlow sequenceFlow : sequenceFlows) {
                    targetFlowNode = (FlowNode) process.getFlowElement(sequenceFlow.getTargetRef(), true);
                    if (historicActivityNodes.contains(targetFlowNode)) {
                        highLightedFlowIds.add(sequenceFlow.getId());
                    }
                }
            } else {
                List<Map<String, Object>> tempMapList = new ArrayList<>();
                for (SequenceFlow sequenceFlow : sequenceFlows) {
                    for (HistoricActivityInstance historicActivityInstance : historicActInstances) {
                        if (historicActivityInstance.getActivityId().equals(sequenceFlow.getTargetRef())) {
                            Map<String, Object> map = new HashMap<>();
                            map.put("highLightedFlowId", sequenceFlow.getId());
                            map.put("highLightedFlowStartTime", historicActivityInstance.getStartTime().getTime());
                            tempMapList.add(map);
                        }
                    }
                }

                if (!CollectionUtils.isEmpty(tempMapList)) {
                    // 遍历匹配的集合,取得开始时间最早的一个
                    long earliestStamp = 0L;
                    String highLightedFlowId = null;
                    for (Map<String, Object> map : tempMapList) {
                        long highLightedFlowStartTime = Long.parseLong(map.get("highLightedFlowStartTime").toString());
                        if (earliestStamp == 0 || earliestStamp >= highLightedFlowStartTime) {
                            highLightedFlowId = map.get("highLightedFlowId").toString();
                            earliestStamp = highLightedFlowStartTime;
                        }
                    }
                    highLightedFlowIds.add(highLightedFlowId);
                }
            }
        }

        entity.setActiveActivityIds(activeActivityIds);
        entity.setExecutedActivityIds(executedActivityIds);
        entity.setHighlightedFlowIds(highLightedFlowIds);

        return entity;
}