1. 从一张“静止”的流程图说起:为什么高亮显示是刚需
做过审批流、工单流或者任何带流程引擎的项目,大概率都遇到过这样一个场景:业务方跑过来问,“这个单子现在卡在谁那里了?前面谁审过?为什么退回来了?”如果你只能打开后台数据库,对着ACT_HI_ACTINST、ACT_RU_EXECUTION这几张表把数据捞出来,再一条条比对节点ID,那基本等于把天聊死了。业务方要的不是数据表,是一张能一眼看懂的图——走过的路亮起来,当前站的地方标红,连线的箭头顺着方向流动。
这就是“activiti流程图的显示 高亮连线和已执行节点及当前节点”这个需求最朴素的出发点。它要解决的核心问题,是把流程引擎里冷冰冰的运行时数据,翻译成前端画布上带颜色的视觉语言。具体来说,包含三个层次:已执行节点(历史走过的路径)、当前节点(此刻停留的位置)、高亮连线(节点之间的流转关系)。三者叠加,才能构成一张有“生命感”的流程图。
这个功能适合谁?如果你正在用Activiti 5/6/7做工作流开发,前端用的是BPMN.js或者自研的SVG渲染,并且已经能把流程图静态展示出来,那这篇内容就是为你准备的。如果你连静态图都还没跑通,建议先把bpmn-js的Viewer模式跑起来,再回来看高亮逻辑。因为高亮本质上是在静态图的基础上做“二次着色”,静态渲染是地基。
我见过不少团队在这一步栽跟头,不是技术多难,而是没想清楚数据从哪来、颜色怎么定、边界情况怎么处理。有人直接拿ACT_HI_ACTINST全表扫,结果并行网关的分支全被点亮,业务方一看就懵了;有人只标当前节点,历史路径一片灰,用户根本不知道从哪来的。所以这篇文章不打算只给你一段代码,而是把整个链路拆开:数据怎么查、模型怎么映射、前端怎么画、坑怎么避。
2. 数据源头:从Activiti的几张核心表里捞出“该亮”的节点
2.1 历史表与运行时表的分工
Activiti的持久化层把“正在跑”和“跑完了”的数据分得很清楚。要拿到完整的执行轨迹,必须同时查两类表:
- 运行时表(RU):
ACT_RU_EXECUTION、ACT_RU_TASK、ACT_RU_ACTINST。这些表里只有当前活跃的数据,流程结束后记录会被删除(除非配置了历史级别为full且不清理)。 - 历史表(HI):
ACT_HI_PROCINST、ACT_HI_ACTINST、ACT_HI_TASKINST。流程实例结束后,运行时数据清空,但历史表会保留完整轨迹。
关键点在于:当前节点既在运行时表里,也在历史表里。ACT_HI_ACTINST中END_TIME_为null的那条记录,就是当前停留的节点。而已经执行完的节点,END_TIME_有值。所以最稳妥的查询策略是:以ACT_HI_ACTINST为主表,按PROC_INST_ID_过滤,按START_TIME_排序,一次性拿到所有活动实例记录。
SELECT ACT_ID_, ACT_NAME_, ACT_TYPE_, START_TIME_, END_TIME_, EXECUTION_ID_ FROM ACT_HI_ACTINST WHERE PROC_INST_ID_ = #{processInstanceId} ORDER BY START_TIME_ ASC, END_TIME_ ASC;这条SQL返回的每一行,对应流程图中一个“活动实例”。注意是“实例”不是“定义”,同一个用户任务节点如果被驳回过两次,这里会有两条记录。这正是高亮需要的信息——它能告诉你这个节点被走了几遍。
2.2 并行网关带来的“假高亮”问题
上面那条SQL在串行流程里没问题,但一遇到并行网关就出事了。假设流程在并行网关后分出三条分支,其中两条已经走完,一条还在跑。ACT_HI_ACTINST里会有三条分支的记录,两条END_TIME_有值,一条为null。如果你简单地把“有END_TIME_的节点”全部标绿,那两条已完成分支确实该绿,但问题是:并行网关本身这个节点,它的END_TIME_什么时候才有值?
答案是:所有分支都汇聚到汇聚网关之后,并行网关的END_TIME_才会被写入。在那之前,并行网关的END_TIME_是null。如果你按“END_TIME_为null就是当前节点”来判断,并行网关会被误标成当前节点,而实际上它早就执行完了,只是引擎在等分支汇聚。
我踩过这个坑,当时业务方看到并行网关被标红,以为流程卡在网关上,排查了半天才发现是判断逻辑太粗糙。正确的做法是结合ACT_TYPE_字段做区分:parallelGateway类型的节点,如果它的所有后继分支都已经有记录,那它就算已完成;如果还有分支没记录,它算“已通过但未汇聚”。更简单的方案是直接看ACT_HI_ACTINST里该网关的END_TIME_,如果为null但存在后继节点的记录,说明网关本身已经触发过了,只是汇聚条件未满足。
2.3 驳回与回退场景下的节点去重
另一个高频坑是驳回。用户任务A被驳回后重新提交,再走到用户任务B,ACT_HI_ACTINST里会有两条A的记录。如果你直接按ACT_ID_去重,只保留一条,那“A被走了两次”这个信息就丢了。但前端画布上同一个节点只能有一种颜色状态,怎么办?
我的处理策略是分两级:节点级别去重,但保留“执行次数”作为附加信息。前端高亮时,同一个ACT_ID_只画一次,但如果执行次数大于1,可以在节点旁边加一个小角标或者用更深的颜色表示“多次经过”。这样既不会让画布出现重叠的色块,又能把驳回信息透传给用户。具体实现时,后端返回的数据结构可以设计成:
{ "activityId": "userTaskA", "activityName": "部门审批", "activityType": "userTask", "executed": true, "current": false, "executionCount": 2, "firstStartTime": "2024-01-01 10:00:00", "lastEndTime": "2024-01-02 15:30:00" }前端拿到这个结构,executed=true就标绿,current=true就标红,executionCount>1就加角标。逻辑清晰,扩展性也好。
3. 模型映射:把BPMN XML里的节点ID和数据库记录对上号
3.1 BPMN XML是唯一的“地图”
Activiti部署流程时,会把BPMN 2.0的XML存到ACT_RE_PROCDEF表的RESOURCE_NAME_字段对应的资源文件里,或者直接存在ACT_GE_BYTEARRAY中。前端要画图,第一步就是拿到这份XML。拿到之后,用bpmn-js的Viewer或Modeler导入,画布上每个图形元素都会有一个id属性,这个id就是BPMN XML里定义的节点ID,也是Activiti数据库里ACT_ID_字段的值。
所以映射关系非常直接:数据库查出来的ACT_ID_,直接对应画布上id相同的元素。不需要额外的映射表,也不需要按名称匹配。我见过有人用节点名称去匹配,结果遇到两个同名节点就歇菜了。记住,ID是唯一标识,名称只是给人看的。
3.2 获取元素句柄的正确姿势
在bpmn-js中,拿到画布元素句柄的标准做法是通过elementRegistry:
const elementRegistry = viewer.get('elementRegistry'); const element = elementRegistry.get(activityId); if (element) { // 这个element就是画布上的图形对象 // 可以对它进行着色、加标记等操作 }注意elementRegistry.get()返回的是图形对象,不是DOM节点。要对它着色,需要用modeling或canvas模块的API,或者直接操作它对应的SVG元素。bpmn-js内部用SVG渲染,每个图形元素外面包了一层g标签,里面是rect、path等基础图形。直接改fill属性是最粗暴但有效的办法,不过更推荐用canvas.addMarker()方法,它会给元素加上一个CSS类,你通过CSS控制颜色,这样样式和逻辑分离,后期换主题也方便。
3.3 连线的高亮:别只盯着节点
节点高亮相对直观,连线高亮才是真正体现“流程走向”的地方。BPMN里的连线叫SequenceFlow,它的ID同样存在BPMN XML里。但Activiti的ACT_HI_ACTINST表里不直接记录连线信息,它只记录节点活动。那怎么知道哪条连线被走了?
答案是:通过节点的执行顺序推断连线。如果节点A执行完了,节点B开始执行了,那A到B之间的那条SequenceFlow就是被走过的。具体实现时,把ACT_HI_ACTINST按START_TIME_排序,相邻两条记录之间如果存在一条从前者ACT_ID_指向后者ACT_ID_的连线,那这条连线就高亮。
但这里有个陷阱:并行网关和排他网关的连线推断逻辑不同。排他网关只会走一条分支,相邻记录之间的连线是唯一的;并行网关会同时走多条分支,相邻记录可能不是直接相连的。所以更稳妥的做法是:先构建一张“节点到连线”的邻接表,然后根据执行记录的顺序,逐对判断是否存在可达的连线。如果直接相邻没有连线,就找它们之间的最短路径上的所有连线,全部高亮。
// 伪代码:根据执行顺序推断高亮连线 function findHighlightedFlows(executedNodes, allFlows) { const highlighted = new Set(); for (let i = 0; i < executedNodes.length - 1; i++) { const from = executedNodes[i].activityId; const to = executedNodes[i + 1].activityId; // 在allFlows里找from->to的连线 const directFlow = allFlows.find(f => f.source === from && f.target === to); if (directFlow) { highlighted.add(directFlow.id); } else { // 找最短路径上的所有连线 const path = findShortestPath(from, to, allFlows); path.forEach(flowId => highlighted.add(flowId)); } } return highlighted; }这段逻辑不复杂,但边界情况多。比如驳回场景下,执行顺序是A->B->A->C,那A到B、B到A、A到C三条连线都要高亮,而且B到A那条是“反向”连线,在BPMN里可能是一条独立的SequenceFlow,也可能和A到B是同一条(如果BPMN定义的是双向连线)。实际项目中,驳回通常走的是独立的连线,所以按ID匹配没问题。
4. 前端着色实战:从SVG属性到CSS类的完整操作
4.1 三种着色方案的取舍
给BPMN图形着色,我试过至少三种方案,各有优劣:
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 直接改SVG属性 | 找到元素的rect/path,改fill和stroke | 简单直接,立即生效 | 样式硬编码,换主题要改代码,容易覆盖原始样式 |
| 添加CSS类 | 用canvas.addMarker()加类名,CSS控制颜色 | 样式分离,可维护性好 | 需要提前写好CSS,类名冲突要处理 |
| 覆盖层绘制 | 在画布上方叠一层SVG,只画高亮部分 | 不影响原图,可做动画 | 坐标计算复杂,缩放时容易错位 |
我最终选的是方案二,也就是addMarker加CSS类。原因很简单:BPMN.js本身就用类名来控制选中、悬停等状态,顺着它的机制走最省事。而且CSS类可以配合过渡动画,高亮时有个渐变效果,体验更好。
4.2 节点着色的具体代码
假设后端返回的高亮数据里,每个节点有executed和current两个布尔字段。前端处理逻辑如下:
const canvas = viewer.get('canvas'); const elementRegistry = viewer.get('elementRegistry'); highlightData.forEach(item => { const element = elementRegistry.get(item.activityId); if (!element) return; if (item.current) { canvas.addMarker(item.activityId, 'highlight-current'); } else if (item.executed) { canvas.addMarker(item.activityId, 'highlight-executed'); } if (item.executionCount > 1) { canvas.addMarker(item.activityId, 'highlight-multiple'); } });对应的CSS:
.highlight-executed .djs-visual > :nth-child(1) { fill: #e8f5e9 !important; stroke: #4caf50 !important; stroke-width: 2px !important; } .highlight-current .djs-visual > :nth-child(1) { fill: #ffebee !important; stroke: #f44336 !important; stroke-width: 3px !important; } .highlight-multiple .djs-visual > :nth-child(1) { stroke-dasharray: 4, 2; }注意.djs-visual是BPMN.js给每个图形元素加的类,它下面的第一个子元素通常是主图形(矩形、圆形等)。用:nth-child(1)是为了避免影响到标签文字。!important在这里是必要的,因为BPMN.js内联了一些样式,不加!important可能被覆盖。
4.3 连线高亮的特殊处理
连线的高亮和节点不太一样。连线的SVG元素是path,它的stroke控制颜色,marker-end控制箭头。如果只改stroke,箭头颜色不会变,看起来会很割裂。所以连线高亮要同时处理stroke和marker-end的fill。
.highlight-flow .djs-visual > path { stroke: #2196f3 !important; stroke-width: 2px !important; marker-end: url(#highlight-arrow) !important; }这里#highlight-arrow需要你在SVG的defs里预先定义一个蓝色的箭头标记。BPMN.js默认的箭头是黑色的,你可以在初始化Viewer后,往它的SVG根节点里注入自定义的marker定义。这个操作稍微有点绕,但做一次就够了。
另一个细节是:连线的标签。有些连线带条件表达式,显示为文字标签。高亮连线时,标签文字的颜色最好也跟着变,否则蓝线配黑字很别扭。标签的类名通常是.djs-label,可以一并处理。
4.4 当前节点的“呼吸”效果
当前节点如果只是静态红色,在复杂流程里可能不够醒目。我通常会加一个CSS动画,让当前节点的边框有轻微的呼吸效果:
@keyframes pulse-border { 0% { stroke-width: 2px; stroke-opacity: 1; } 50% { stroke-width: 4px; stroke-opacity: 0.6; } 100% { stroke-width: 2px; stroke-opacity: 1; } } .highlight-current .djs-visual > :nth-child(1) { animation: pulse-border 1.5s ease-in-out infinite; }这个效果在演示时特别抓眼球,业务方一眼就能看到“现在在这里”。但注意不要滥用,如果当前节点有多个(比如并行分支同时活跃),全部呼吸会显得很乱。这时候可以只让第一个活跃节点呼吸,其他的用静态红色。
5. 那些文档里不会写的坑:从坐标错位到性能瓶颈
5.1 缩放和拖拽后的坐标错位
如果你的流程图支持缩放和拖拽(bpmn-js的Modeler默认支持),高亮层如果用的是绝对定位的覆盖层,缩放后必然错位。我早期用覆盖层方案时,每次缩放都要重新计算所有高亮元素的坐标,代码复杂且容易出bug。后来改用addMarker方案,因为类名是加在图形元素本身的,缩放拖拽时图形元素自己会变换,类名跟着走,完全不用额外处理。这也是我推荐addMarker的重要原因之一。
5.2 大流程图的渲染性能
当一个流程有上百个节点时,逐个addMarker会有明显的卡顿。我实测过,200个节点左右的流程图,循环调用addMarker大约需要300-500毫秒,在低端机上更慢。优化思路有两个:一是批量操作,把需要高亮的元素先收集起来,用canvas.addMarker的批量版本(如果有)或者先隐藏画布再统一加类;二是懒加载,只高亮视口内的元素,滚动时再动态加载。不过大多数业务流程图不会超过50个节点,所以这个优化不是必须的,知道有这回事就行。
5.3 历史数据缺失时的降级方案
有些流程实例的历史级别配置的是none或activity,ACT_HI_ACTINST里可能没有详细记录。这时候高亮就无从谈起了。我的降级方案是:如果查不到历史活动记录,就只高亮当前节点(从ACT_RU_EXECUTION和ACT_RU_TASK里查),已执行节点全部不标。同时给前端一个提示:“历史轨迹未开启,仅显示当前节点”。这样至少不会让用户看到一张全灰的图,以为系统坏了。
5.4 多实例节点的处理
多实例节点(比如会签)在ACT_HI_ACTINST里会有多条记录,每条对应一个实例。高亮时,如果简单按ACT_ID_去重,会丢失“会签了几个人”的信息。我的做法是:节点本身标绿,但在节点旁边显示一个“3/5”的角标,表示5个实例中已完成3个。这个角标的数据来源就是ACT_HI_ACTINST里该ACT_ID_的记录数和已完成数。实现上可以用bpmn-js的overlays模块,在节点上方叠加一个文本标签。
const overlays = viewer.get('overlays'); overlays.add(activityId, { position: { top: -10, right: -10 }, html: '<span class="multi-instance-badge">3/5</span>' });overlays是bpmn-js提供的叠加层机制,专门用来在图形上加额外信息,比直接操作SVG干净得多。
6. 一套可复用的后端接口设计
6.1 接口返回结构
把前面所有逻辑串起来,后端只需要提供一个接口:传入processInstanceId,返回高亮所需的所有数据。结构如下:
{ "processInstanceId": "proc-123", "processDefinitionId": "leave:1:456", "activities": [ { "activityId": "startEvent1", "activityName": "开始", "activityType": "startEvent", "executed": true, "current": false, "executionCount": 1 }, { "activityId": "userTask1", "activityName": "部门审批", "activityType": "userTask", "executed": true, "current": true, "executionCount": 2 } ], "flows": [ { "flowId": "flow1", "source": "startEvent1", "target": "userTask1", "highlighted": true } ] }activities数组直接对应画布节点,flows数组对应连线。前端拿到后,遍历两个数组分别加类名即可。这个结构的好处是:后端把“哪些该亮”算好了,前端只负责“怎么亮”,职责清晰,前端不需要理解Activiti的表结构。
6.2 缓存策略
流程实例的历史数据在流程结束后基本不变,所以可以缓存。我的做法是:流程未结束时,缓存时间设短一点(比如30秒),因为状态可能变化;流程结束后,缓存时间设长(比如1小时),因为不会再变了。缓存的key用processInstanceId,value就是上面的JSON。这样频繁刷新页面时不会反复查库。
6.3 权限校验
别忘了权限。不是所有人都能看所有流程实例的轨迹。接口里必须校验当前用户是否有权限查看该流程实例。通常的做法是:查ACT_HI_PROCINST的START_USER_ID_和ACT_HI_IDENTITYLINK里的参与人,判断当前用户是否在参与人列表里。如果不在,返回403。这个校验放在接口层,不要放到前端,前端校验只是防君子不防小人。
7. 实测效果与几个值得注意的细节
整套方案在几个内部项目里跑下来,稳定性没问题。一个50节点左右的审批流,从请求接口到画布高亮完成,耗时在200毫秒以内,用户基本无感。业务方的反馈也很直接:“终于不用问开发了,自己看图就明白。”
有几个细节值得再强调一下。第一,颜色不要超过三种。已执行用绿色,当前用红色,未执行保持默认灰色,足够了。有人喜欢用蓝色表示已执行,黄色表示当前,结果和BPMN本身的配色冲突,看起来像打翻了调色盘。第二,当前节点的红色要足够醒目,但不要用纯红#ff0000,太刺眼,用#f44336这种带一点灰度的红,长时间看眼睛不累。第三,连线的箭头一定要跟着变色,否则蓝线黑箭头,视觉上会断裂。
还有一个容易被忽略的点:流程结束后的展示。流程结束后,没有“当前节点”了,所有节点都是已执行。这时候如果还按“当前节点标红”的逻辑走,会找不到红色节点,画布全绿。这其实是对的,但最好在画布上方加一行文字:“流程已结束”,给用户一个明确的信号。这个文字可以用overlays加在画布左上角,或者直接用HTML放在画布容器外面。
最后说一个我最近才想明白的事:高亮显示的本质不是“画图”,而是“翻译”。把引擎的状态翻译成视觉语言,翻译得越准确、越直观,用户的理解成本就越低。所以不要只盯着代码,多站在业务方的角度想:他们看到这张图,第一眼想知道什么?把那个信息用最醒目的方式呈现出来,其他的都是辅助。这个思路想通了,具体用addMarker还是覆盖层,用绿色还是蓝色,都是细枝末节了。