1.写在前面
一个完整的流程表单审批(起表单-->各环节审批-->回退-->重新审批-->完成),前端由Vue2+js+Element UI升级为Vue3+ts+Element Plus,后端流程框架使用Flowable,项目参考了ruoyi-vue-pro(https://gitee.com/zhijiantianya/ruoyi-vue-pro)项目。
2.视频演示
3.表单
3.1表单的设计
依据业务需求,完全自定义表单,可以依据流程节点设置表单中每个属性的读写,实现原理是读取审批节点的编码,依据编码控制每个属性的读写。
TypeScript
<el-tree-select
:disabled="!required"
v-model="form.deptCode"
:data="deptList"
:props="defaultPropsForData"
check-strictly
node-key="id"
placeholder="请选择集团成员单位"
/>
/**
* 根据不同参数控制表单属性的可读可写
* @param pattern
*/
const changeRequiredByPattern = (pattern: string) => {
if (pattern === 'create') {
//说明增加
required.value = true
} else if (pattern === 'update' || pattern === 'starter') {
//修改
required.value = true
getBid()
} else {
//只读
required.value = false
getBid()
}
}
3.2.表单与流程关联
通过设计流程时定义的流程编码,在创建表单的后端服务调用中,实现表单与流程的绑定。
java
/**
* 投标对应的流程定义 KEY
*/
public static final String PROCESS_KEY = "bidApproval";
//发起BPM流程
Map<String, Object> processInstanceVariables = new HashMap<>();
processInstanceVariables.put("deptId", bidDO.getDeptId());
processInstanceVariables.put("bidMoney", bidDO.getBidMoney());
String processInstanceId = processInstanceApi.createProcessInstance(getLoginUserId(),
new BpmProcessInstanceCreateReqDTO().setProcessDefinitionKey(PROCESS_KEY)
.setVariables(processInstanceVariables).setBusinessKey(bidDO.getProjectName()));
3.3.表单与前端流程实例的关联
在通过待办打开需要审批的表单时,不同的流程实例如何对应不同的表单,并在页面展示具体的表单数据了?答案是通过定义流程时填写的表单组件名称,利用Vue的<component>元组件来实现。
html
<component
ref="formDetailRef"
v-if="processInstance.id !== undefined"
:is="processInstance.processDefinition.formComponentName"
:processInstanceId="processInstance.id"
:pattern="runningTasks.length > 0 ? runningTasks[0].definitionKey : 'readOnly'"
@success="getDetail"
/>
3.4.表单的保存
表单的保存分为提交时的保存与不提交的保存。不提交的保存用于修改数据但流程不需要提交到下一节点审批的情况,方便保存数据进入待办里面进行后续的修改。提交的时,流程会自动调用保存接口,先进行业务数据的保存,然后再进行流程的提交。
TypeScript
<el-button color="#626aef" @click="handleSave">
<Icon icon="ep:coin" />
保存
</el-button>
<el-button type="success" @click="handleApproval(item)">
<Icon icon="ep:select" />
提交
</el-button>
/** 处理保存表单的操作 只更新表单数据 不提交流程任务 */
const formDetailRef = ref()
const handleSave = () => {
formDetailRef.value.submitForm()
}
/** 处理审批通过的操作 */
const approvalRef = ref()
const handleApproval = async (item) => {
approvalRef.value.open(item)
}
java
@Override
@Transactional(rollbackFor = Exception.class)
public Long updateBid(BidUpdateReqVO updateReqVO) {
BidDO bidDO = BidConvert.INSTANCE.convert3(updateReqVO);
//checkDeptIsMateTendererDept(deptRespDTO, tendererDeptRespDTO);
bidDO.setDeptId(findDeptByCode(updateReqVO.getDeptCode()).getId());
bidDO.setTendererId(findDeptByName(updateReqVO.getTendererName()).getId());
fileApi.deleteFile(bidMapper.selectById(bidDO.getId()).getFiles(), bidDO.getFiles());
bidMapper.updateById(bidDO);
return bidDO.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void approveProcessTask(BidUpdateReqVO bidUpdateReqVO, BpmProcessTaskApprovalDTO bpmProcessTaskApprovalDTO) {
//需要根据流程中不同的节点 更改对应的表单信息 比如 需要在流程最后一个节点点击提交时 更改流程状态为完成
BidDO bidDO = BidConvert.INSTANCE.convert3(bidUpdateReqVO);
bidDO.setDeptId(findDeptByCode(bidUpdateReqVO.getDeptCode()).getId());
bidDO.setTendererId(findDeptByName(bidUpdateReqVO.getTendererName()).getId());
updateFlowInfoByProcessInstanceState(bidDO);
fileApi.deleteFile(bidMapper.selectById(bidDO.getId()).getFiles(), bidDO.getFiles());
bidMapper.updateById(bidDO);
bpmProcessTaskApi.approvalTask(bpmProcessTaskApprovalDTO);
}
4.流程的审批
流程的审批按照设计流程的审批节点依次进行流转,不支持夸环节提交,支持流程的自由回退。提交当前审批任务时,进行下一节点的人员选择,在流程设计时,每个节点审批的人员的选择逻辑已经确定,也支持自由选择组织中的所有人员。
4.1.流程审批人员的设置
流程审批节点的人员设置主要思路为给定一个角色,让审批人员提交任务时,从角色中选择一个人员,这样可以缩小选择的范围。如果表单有对应的部门属性,可以设置审批人员是某个角色中且部门与表单部门属性相同的人员。
java
// 选择角色中的人员
private Set<Long> calculateTaskCandidateUsersByRole(BpmTaskAssignRuleDO rule) {
return permissionApi.getUserRoleIdListByRoleIds(rule.getOptions());
}
//依据角色选择表单部门中的所属人员
private Set<Long> calculateTaskCandidateUsersByRolePerson(Map<String, Object> variables, BpmTaskAssignRuleDO rule) {
Long deptId = (Long) variables.get("deptId");
//获取流程实例变量的部门
//从角色中获取属于该部门的人员
Set<Long> userIdsByRoleId = permissionApi.getUserRoleIdListByRoleIds(rule.getOptions());
Set<Long> userIdsByDeptId = userApi.getUsersByDeptId(deptId);
return new HashSet<>(CollUtil.intersection(userIdsByDeptId, userIdsByRoleId));
}
4.2.流程的流转
流程的流转其实没有什么好说的,就是按照流程设计的审批节点依次往下走,遇到网关时,根据前期设计好的条件读取对应的属性跳转到不同审批支线。本例中,会根据投标金额是否大于500万做判断,走不同的分支,而500万的属性,在创建流程时就已经传入了流程实例的变量中。
流程的回退,流程的回退依据流程的节点图,不管流程流转了多少圈,回退只允许回退当前审批节点的前面节点。
java
public Set<BpmDoneUserTaskNodeRespVO> getDoneUserTaskNodes(String taskId) {
Set<BpmDoneUserTaskNodeRespVO> resultList = new HashSet<>();
//获取流程实例id
Task task = getTask(taskId);
// 校验流程实例存在
ProcessInstance instance = processInstanceService.getProcessInstance(task.getProcessInstanceId());
//获取历史任务实例 条件为 流程实例 未完成 按照任务开始时间降序排列
List<HistoricTaskInstance> historicTaskInstances = historyService.createHistoricTaskInstanceQuery()
.processInstanceId(instance.getProcessInstanceId())
.finished()
.orderByHistoricTaskInstanceEndTime()
.desc()
.list();
//需要做一个筛选,只能选择当前任务节点之前的节点进行回退
// 1. 获取流程模型实例 BpmnModel
BpmnModel bpmnModel = bpmProcessDefinitionService.getBpmnModel(task.getProcessDefinitionId());
// 2. 通过任务节点id,来获取当前节点信息
FlowElement flowElement = bpmnModel.getFlowElement(task.getTaskDefinitionKey());
// 3.获取下一个节点(或者多个节点的)信息,需要去重,因为并行的节点之前的节点会找多遍
Set<FlowElement> flowElements = new HashSet<>();
// 4.获取流程实例的变量
Map<String, Object> variables = taskService.getVariables(taskId);
getBeforeNodes(flowElement, flowElements, variables);
if (flowElements.isEmpty()){
//说明处于第一个节点,此时不能回退
throw exception(TASK_ROLLBACK_FORBIDDEN);
}
//找交集
for(FlowElement f: flowElements) {
HistoricTaskInstance h = historicTaskInstances.stream()
.filter( hi -> hi.getTaskDefinitionKey().equals(f.getId()))
.findFirst().orElse(null);
if (h == null){
//没有获取到最新的的节点审批信息,是不正常的情况
throw exception(TASK_ROLLBACK_APPROVED_INFO_NULL);
}
//获取 审批人员的编号
Long assignee = Long.valueOf(h.getAssignee());
//获取人员信息
UserRespDTO userRespDTO = adminUserApi.getUser(assignee);
//获取部门信息
DeptRespDTO deptRespDTO = deptApi.getDept(userRespDTO.getDeptId());
resultList.add(BpmTaskConvert.INSTANCE.convertBpmDoneUserTaskNodeRespVO(h, userRespDTO, deptRespDTO));
}
return resultList;
}
5.流程审批详情
流程审批详情包括审批记录与流程图的展示。
5.1.审批记录详情
审批记录根据审批的先后顺序展示数据,状态根据提交回退的不同使用不同颜色的标签显示。
html
<el-table v-loading="loading" :data="tasks" border>
<el-table-column align="center" prop="name" label="审批环节" width="200" />
<el-table-column align="center" prop="assigneeUser.nickname" label="审批人" width="180" />
<el-table-column
label="任务开始时间"
align="center"
prop="createTime"
width="180"
:formatter="dateFormatter"
/>
<el-table-column
label="任务结束时间"
align="center"
prop="endTime"
width="180"
:formatter="dateFormatter"
/>
<el-table-column label="任务耗时" align="center" prop="durationInMillis" width="150">
<template #default="scope">
<span>{{ formatPast2(scope.row.durationInMillis) }}</span>
</template>
</el-table-column>
<el-table-column label="审批结果" align="center" prop="result" width="150">
<template #default="scope">
<el-tag :type="getTimelineItemType(scope.row)"
>{{ getDictLabel(DICT_TYPE.BPM_PROCESS_INSTANCE_RESULT, scope.row.result) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="审批意见" align="center" prop="reason" width="250" />
</el-table>
java
public List<BpmTaskRespVO> getTaskListByProcessInstanceId(String processInstanceId) {
// 获得任务列表
List<HistoricTaskInstance> tasks = historyService.createHistoricTaskInstanceQuery()
.processInstanceId(processInstanceId)
.orderByHistoricTaskInstanceStartTime().desc() // 创建时间倒序
.list();
if (CollUtil.isEmpty(tasks)) {
return Collections.emptyList();
}
// 获得 TaskExtDO Map
List<BpmTaskExtDO> bpmTaskExtDOs = taskExtMapper.selectListByTaskIds(convertSet(tasks, HistoricTaskInstance::getId));
Map<String, BpmTaskExtDO> bpmTaskExtDOMap = convertMap(bpmTaskExtDOs, BpmTaskExtDO::getTaskId);
// 获得 ProcessInstance Map
HistoricProcessInstance processInstance = processInstanceService.getHistoricProcessInstance(processInstanceId);
// 获得 User Map
Set<Long> userIds = convertSet(tasks, task -> NumberUtils.parseLong(task.getAssignee()));
userIds.add(NumberUtils.parseLong(processInstance.getStartUserId()));
Map<Long, UserRespDTO> userMap = adminUserApi.getUserMap(userIds);
// 获得 Dept Map
Map<Long, DeptRespDTO> deptMap = deptApi.getDeptMap(convertSet(userMap.values(), UserRespDTO::getDeptId));
// 拼接数据
return BpmTaskConvert.INSTANCE.convertList3(tasks, bpmTaskExtDOMap, processInstance, userMap, deptMap);
}
5.2.流程图显示
流程图显示比较复杂,可以查看对应的代码,主要就是使用了bpmn-js库,根据后端的数据,进行不同的展示,核心代码个人理解是这一块。
TypeScript
const highlightDiagram = async () => {
const activityList = activityLists.value
if (activityList.length === 0) {
return
}
// 参考自 https://gitee.com/tony2y/RuoYi-flowable/blob/master/ruoyi-ui/src/components/Process/index.vue#L222 实现
// 再次基础上,增加不同审批结果的颜色等等
let canvas = bpmnModeler.get('canvas')
let todoActivity: any = activityList.find((m: any) => !m.endTime) // 找到待办的任务
let endActivity: any = activityList[activityList.length - 1] // 获得最后一个任务
// debugger
bpmnModeler.getDefinitions().rootElements[0].flowElements?.forEach((n: any) => {
let activity: any = activityList.find((m: any) => m.key === n.id) // 找到对应的活动
if (!activity) {
return
}
if (n.$type === 'bpmn:UserTask') {
// 用户任务
// 处理用户任务的高亮
const task: any = taskList.value.find((m: any) => m.id === activity.taskId) // 找到活动对应的 taskId
if (!task) {
return
}
// 高亮任务
canvas.addMarker(n.id, getResultCss(task.result))
// 如果非通过,就不走后面的线条了
if (task.result !== 2) {
return
}
// 处理 outgoing 出线
const outgoing = getActivityOutgoing(activity)
outgoing?.forEach((nn: any) => {
// debugger
let targetActivity: any = activityList.find((m: any) => m.key === nn.targetRef.id)
// 如果目标活动存在,则根据该活动是否结束,进行【bpmn:SequenceFlow】连线的高亮设置
if (targetActivity) {
canvas.addMarker(nn.id, targetActivity.endTime ? 'highlight' : 'highlight-todo')
} else if (nn.targetRef.$type === 'bpmn:ExclusiveGateway') {
// TODO 芋艿:这个流程,暂时没走到过
canvas.addMarker(nn.id, activity.endTime ? 'highlight' : 'highlight-todo')
canvas.addMarker(nn.targetRef.id, activity.endTime ? 'highlight' : 'highlight-todo')
} else if (nn.targetRef.$type === 'bpmn:EndEvent') {
// TODO 芋艿:这个流程,暂时没走到过
if (!todoActivity && endActivity.key === n.id) {
canvas.addMarker(nn.id, 'highlight')
canvas.addMarker(nn.targetRef.id, 'highlight')
}
if (!activity.endTime) {
canvas.addMarker(nn.id, 'highlight-todo')
canvas.addMarker(nn.targetRef.id, 'highlight-todo')
}
}
})
} else if (n.$type === 'bpmn:ExclusiveGateway') {
// 排它网关
// 设置【bpmn:ExclusiveGateway】排它网关的高亮
canvas.addMarker(n.id, getActivityHighlightCss(activity))
// 查找需要高亮的连线
let matchNN: any = undefined
let matchActivity: any = undefined
const outgoing = getActivityOutgoing(activity)
outgoing.forEach((nn: any) => {
let targetActivity = activityList.find((m: any) => m.key === nn.id)
if (!targetActivity) {
return
}
// 特殊判断 endEvent 类型的原因,ExclusiveGateway 可能后续连有 2 个路径:
// 1. 一个是 UserTask => EndEvent
// 2. 一个是 EndEvent
// 在选择路径 1 时,其实 EndEvent 可能也存在,导致 1 和 2 都高亮,显然是不正确的。
// 所以,在 matchActivity 为 EndEvent 时,需要进行覆盖~~
if (!matchActivity || matchActivity.type === 'endEvent') {
matchNN = nn
matchActivity = targetActivity
}
})
if (matchNN && matchActivity) {
canvas.addMarker(matchNN.id, getActivityHighlightCss(matchActivity))
}
} else if (n.$type === 'bpmn:ParallelGateway') {
// 并行网关
// 设置【bpmn:ParallelGateway】并行网关的高亮
canvas.addMarker(n.id, getActivityHighlightCss(activity))
const outgoing = getActivityOutgoing(activity)
outgoing.forEach((nn: any) => {
// 获得连线是否有指向目标。如果有,则进行高亮
const targetActivity = activityList.find((m: any) => m.key === nn.targetRef.id)
if (targetActivity) {
canvas.addMarker(nn.id, getActivityHighlightCss(targetActivity)) // 高亮【bpmn:SequenceFlow】连线
// 高亮【...】目标。其中 ... 可以是 bpm:UserTask、也可以是其它的。当然,如果是 bpm:UserTask 的话,其实不做高亮也没问题,因为上面有逻辑做了这块。
canvas.addMarker(nn.targetRef.id, getActivityHighlightCss(targetActivity))
}
})
} else if (n.$type === 'bpmn:StartEvent') {
// 开始节点 流程只要发起 开始节点就是完成状态
let targetActivity = activityList.find((m) => m.key === n.id)
if (targetActivity) {
canvas.addMarker(n.id, 'highlight') // 高亮【bpmn:StartEvent】开始节点(自己)
}
// 开始节点
const outgoing = getActivityOutgoing(activity)
outgoing.forEach((nn) => {
// outgoing 例如说【bpmn:SequenceFlow】连线
// 获得连线是否有指向目标。如果有,则进行高亮
let targetActivity = activityList.find((m: any) => m.key === nn.targetRef.id)
if (targetActivity) {
canvas.addMarker(nn.id, 'highlight') // 高亮【bpmn:SequenceFlow】连线
canvas.addMarker(n.id, 'highlight') // 高亮【bpmn:StartEvent】开始节点(自己)
}
})
} else if (n.$type === 'bpmn:EndEvent') {
// 结束节点
if (!processInstance.value || processInstance.value.result === 1) {
return
}
canvas.addMarker(n.id, getResultCss(processInstance.value.result))
} else if (n.$type === 'bpmn:ServiceTask') {
//服务任务
if (activity.startTime > 0 && activity.endTime === 0) {
//进入执行,标识进行色
canvas.addMarker(n.id, getResultCss(1))
}
if (activity.endTime > 0) {
// 执行完成,节点标识完成色, 所有outgoing标识完成色。
canvas.addMarker(n.id, getResultCss(2))
const outgoing = getActivityOutgoing(activity)
outgoing?.forEach((out) => {
canvas.addMarker(out.id, getResultCss(2))
})
}
}
})
}
6.写在最后
本文简单的介绍了一个OA办公系统表单审批的全过程,行文比较粗糙,代码只展示了很少的一部分,如果有兴趣一起研究讨论的,欢迎留言批评指教。