前言
在日常开发中我们经常遇到这样的需求:员工提交任务申请,需要经过一系列的审核流程,很多人都会想到工作流,但又感觉非常复杂。如果是按照金蝶、腾讯这些大厂的工作流产品,当然是非常复杂了,但我们可以简单的做,没必要搞那么复杂。
下面我分享一套基于thinkphp8+vue3开发的工作流,简单实现流程审核的功能
本教程的项目已经集成了权限系统,下载下来就可以用来做二次开发,
教程里面的代码我是按照java的开发规范来的,包括表的设计,可以直接从php翻译成java,因为是在分享教程,所以就使用php了
本教程着重讲实现思路及方法,会贴出核心代码
后端代码:https://gitee.com/extraordinary-x/workflow-php
前端代码:https://gitee.com/extraordinary-x/workflow-vue
sql文件在sql目录里面,后台的账号密码是admin/12345678,如果项目对你有用,点个赞!!!
下面是本项目的一些截图

数据表的设计
用户权限那一套表:用户表、角色表、菜单、用户-角色映射表、角色-菜单映射表,这些表就不详细说了,来来去去都是那一套,本项目已经实现了管理员的登录、权限的控制,大家可以直接拿去用,本教程不会讲解这一块内容。
下面重点讲解一下几张重要表及作用
工作流设计表
CREATE TABLE `next_workflow_design` (
`id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID',
`company_id` bigint(20) NULL DEFAULT NULL COMMENT '企业ID',
`title` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '流程名称',
`remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '备注',
`status` tinyint(4) NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
`node_content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '节点内容',
`line_content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '线条内容',
`version` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '版本',
`create_by` bigint(255) NULL DEFAULT NULL COMMENT '创建者',
`create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',
`update_by` bigint(20) NULL DEFAULT NULL COMMENT '更新者',
`update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间',
`delete_time` datetime(0) NULL DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '流程设计表' ROW_FORMAT = Dynamic;
设计的流程就保存在这个表里面,比如上面的招商流程,设计的工作流就保存在node_content和line_content这两个字段里面
流程绑定表
CREATE TABLE `next_workflow_bind` (
`id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID',
`company_id` bigint(20) NULL DEFAULT NULL COMMENT '企业ID',
`bind_code` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '模块编码',
`title` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '绑定的模块',
`workflow_id` bigint(20) NULL DEFAULT NULL COMMENT '绑定的流程ID',
`status` tinyint(4) NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
`create_by` bigint(255) NULL DEFAULT NULL COMMENT '创建者',
`create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',
`update_by` bigint(20) NULL DEFAULT NULL COMMENT '更新者',
`update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间',
`delete_time` datetime(0) NULL DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '流程绑定表' ROW_FORMAT = Dynamic;
这个表是存储业务模块需要绑定的工作流,比如上面的招商活动模块,当我们要新增一个活动需要审核时,就会走这里绑定的工作流。
这里有个"模块编码"字段,我们可以定义一个枚举,比如"activity_review"表示的是招商活动模块编码。假如你后面开发的合同模块也需要审核,你就可以定义一个模块编码然后绑定想要的工作流。
用户事务表
CREATE TABLE `next_sys_user_task` (
`id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID',
`company_id` bigint(20) NULL DEFAULT NULL COMMENT '企业ID',
`module_code` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '模块编码',
`workflow_node_id` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '当前任务所处的工作流节点ID',
`applicant_id` bigint(20) NULL DEFAULT NULL COMMENT '发起人ID',
`user_id` bigint(20) NULL DEFAULT NULL COMMENT '用户ID',
`type` tinyint(1) NULL DEFAULT NULL COMMENT '类型:1-待办, 2-发起, 3-已处理, 4-抄送',
`status` tinyint(1) NULL DEFAULT NULL COMMENT '状态:-1-拒绝,0-待处理,1-同意,2-已读(用于抄送)3-进行中(用于发起)',
`data_id` bigint(20) NULL DEFAULT NULL COMMENT '关联的数据ID',
`comment` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '处理意见',
`handle_time` datetime(0) NULL DEFAULT NULL COMMENT '处理时间',
`create_by` bigint(20) NULL DEFAULT NULL COMMENT '创建人',
`create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',
`update_by` bigint(20) NULL DEFAULT NULL COMMENT '更新人',
`update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间',
`delete_time` datetime(0) NULL DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_user_id`(`user_id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 21 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '用户事务表' ROW_FORMAT = Dynamic;
这个表就是存储用户发起及处理、已处理的事务,这里的字段设计思路会在后面讲解代码的时候讲到
招商活动表
CREATE TABLE `next_activity` (
`id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '活动ID',
`company_id` bigint(20) NULL DEFAULT NULL COMMENT '企业ID',
`title` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '活动名称',
`type` bigint(20) NULL DEFAULT NULL COMMENT '活动类型(渠道)',
`activity_start_time` date NULL DEFAULT NULL COMMENT '活动开始时间',
`activity_end_time` date NULL DEFAULT NULL COMMENT '活动结束时间',
`scheduled_amount_invested` decimal(10, 0) NULL DEFAULT NULL COMMENT '预计投入金额',
`actual_amount_invested` decimal(10, 0) NULL DEFAULT NULL COMMENT '实际投入金额',
`total_output_amount` decimal(10, 0) NULL DEFAULT NULL COMMENT '产出总金额',
`clue_count` bigint(20) NULL DEFAULT NULL COMMENT '预计产出线索数',
`actual_clue_count` bigint(20) NULL DEFAULT 0 COMMENT '实际产出线索数',
`dealer_count` bigint(20) NULL DEFAULT 0 COMMENT '签约加盟商数',
`activity_content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '活动内容',
`activity_summary` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '活动总结',
`director_id` bigint(20) NULL DEFAULT NULL COMMENT '负责人',
`director_name` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '负责人姓名',
`dept_id` bigint(20) NULL DEFAULT NULL COMMENT '负责人所属部门',
`status` int(11) NULL DEFAULT 1 COMMENT '状态:-1草稿,0-待审核,1-审核中,2-审核通过,3-审核不通过',
`create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',
`create_by` bigint(20) NULL DEFAULT NULL COMMENT '创建者',
`update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间',
`update_by` bigint(20) NULL DEFAULT NULL COMMENT '更新者',
`delete_time` datetime(0) NULL DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 21 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '招商活动表' ROW_FORMAT = Dynamic;
这种就是我们业务表了,里面的status字段是跟审核有关的。
后端接口的开发
后端是使用thinkphp8框架,然后我在这个框架上封装了一些基本的业务,这里就不展开讲,只讲重点代码
工作流接口
怎么保存一个工作流,正常的流程是这样的,首先创建工作流的名称及版本等信息、然后是保存设计流程、接下来是发布、最后是绑定某个模块。我们就按照这个思路来写接口,工作流相关的接口写在WorkflowDesign.php文件里面
新增一个工作流
public function save(){
$data = $this->request->post();
$this->validate($data, [
'title' => 'require',
], [
'title.require' => '流程名称不能为空!'
]);
// 如果数据表的字段status设置了默认值为0, 这行代码可以省略
$data['status'] = BaseStatusEnum::DISABLED;
// WorkflowDesignService::save里面逻辑只是一个简单新增操作
return success(WorkflowDesignService::save($data));
}
设计工作流的节点
新增工作流后会有这样的一个数据

点击【设计】按钮,进入设计流程界面,进行如下设计

我们可以看到有两个审核节点、一个抄送节点,当点击保存的时候,看看前端传过来的参数
{
"id": 5,
"title": "招商活动v1.0",
"remark": null,
"status": 0,
"version": "v1.0",
"companyId": 1,
"nodeContent": null,
"lineContent": null,
"createBy": 23,
"createTime": "2025-12-03 16:05:05",
"updateBy": null,
"updateTime": "2025-12-03 16:05:05",
"deleteTime": null,
"nodeList": [
{
"nodeId": "guw9zx9nlh",
"left": "266px",
"top": "316px",
"class": "workflow-right-clone",
"name": "提交活动申请",
"icon": "iconfont icon-step",
"id": "16",
"attr": "start",
"type": "node",
"label": ""
},
{
"nodeId": "8zehlwhjqkg",
"left": "463px",
"top": "316px",
"class": "workflow-right-clone",
"name": "大区总监审核",
"icon": "iconfont icon-gerenzhongxin",
"id": "32",
"attr": "user",
"type": "node",
"label": "",
"reviewerId": 4,
"reviewerName": "张小凡"
},
{
"nodeId": "09frju8bmpbj",
"left": "924px",
"top": "318px",
"class": "workflow-right-clone",
"name": "结 束",
"icon": "iconfont icon-radio-off-full",
"id": "100",
"attr": "end"
},
{
"nodeId": "q0z8umk4hm9",
"left": "689px",
"top": "319px",
"class": "workflow-right-clone",
"name": "财务审核",
"icon": "iconfont icon-gerenzhongxin",
"id": "32",
"attr": "user",
"type": "node",
"label": "",
"reviewerId": 6,
"reviewerName": "陈瑶"
},
{
"nodeId": "ll51n6i08lg",
"left": "692px",
"top": "191px",
"class": "workflow-right-clone",
"name": "抄送人",
"icon": "iconfont icon-shouye_dongtaihui",
"id": "33",
"attr": "cc",
"type": "node",
"label": "",
"reviewerId": 23,
"reviewerName": "赵宇轩"
}
],
"lineList": [
{
"sourceId": "guw9zx9nlh",
"targetId": "8zehlwhjqkg",
"label": ""
},
{
"sourceId": "8zehlwhjqkg",
"targetId": "q0z8umk4hm9",
"label": ""
},
{
"sourceId": "q0z8umk4hm9",
"targetId": "09frju8bmpbj",
"label": ""
},
{
"sourceId": "q0z8umk4hm9",
"targetId": "ll51n6i08lg",
"label": ""
}
]
}
这里比较重要的字段是nodeList和lineList,下面是保存的核心代码
public static function update($data){
$info = CommonWorkflowDesignService::info($data['id']);
if(!$info){ serviceException(); }
$data['nodeContent'] = empty($data['nodeList']) ? null : json_encode($data['nodeList']);
$data['lineContent'] = empty($data['lineList']) ? null : json_encode($data['lineList']);
$info->save($data);
}
到这里为止一个完整的工作流已经建立了,到目前为止,后端还没有遇到什么挑战,只是新增新增和更新数据而已。
发布流程接口
其实就是把数据状态由未发布改成发布状态,但这里有个问题,这里发布一个工作流,必须有个一个完整的设计流程,如果流程不完整,那么就提示发布失败。那怎么才算是一个完整的设计流程呢?具体规则,根据项目情况而定,但必须满足最基本的要求:
-
必须只包含一个开始节点
-
必须只包含一个结束节点
-
必须至少包含一个执行节点
-
每个节点必须有线段连接
下面是这个判断的核心代码
public static function validateWorkflow($id){
// 获取工作流数据
$workflow = self::info($id);
if (!$workflow) {
serviceException(ErrorEnum::WORKFLOW_INVALID_CODE, ErrorEnum::WORKFLOW_INVALID_DESC);
}
$nodeList = $workflow['nodeList'] ?? [];
$lineList = $workflow['lineList'] ?? [];
// 规则1: 检查节点数量
$startCount = 0; //开始节点数量
$userCount = 0; //执行节点数量
$endCount = 0; //结束节点数量
$nodeIds = [];
foreach ($nodeList as $node) {
$nodeIds[] = $node['nodeId'];
// 根据节点里面的attr来判断节点类型,节点类型有四种(开始、执行节点、抄送、结束)
switch ($node['attr']) {
case WorkflowNodeAttrEnum::START:
$startCount++;
break;
case WorkflowNodeAttrEnum::USER:
$userCount++;
// 规则2: 执行节点必须选择执行人
if (empty($node['reviewerId'])) {
serviceException(ErrorEnum::WORKFLOW_NODE_REVIEWER_REQUIRED_CODE, '执行节点[' . $node['name'] . ']' . ErrorEnum::WORKFLOW_NODE_REVIEWER_REQUIRED_DESC);
}
break;
case WorkflowNodeAttrEnum::END:
$endCount++;
break;
}
}
// 规则1: 验证节点数量
if ($startCount != 1) {
serviceException(ErrorEnum::WORKFLOW_MUST_HAVE_ONE_START_NODE_CODE, ErrorEnum::WORKFLOW_MUST_HAVE_ONE_START_NODE_DESC);
}
if ($userCount < 1) {
serviceException(ErrorEnum::WORKFLOW_MUST_HAVE_AT_LEAST_ONE_USER_NODE_CODE, ErrorEnum::WORKFLOW_MUST_HAVE_AT_LEAST_ONE_USER_NODE_DESC);
}
if ($endCount != 1) {
serviceException(ErrorEnum::WORKFLOW_MUST_HAVE_ONE_END_NODE_CODE, ErrorEnum::WORKFLOW_MUST_HAVE_ONE_END_NODE_DESC);
}
// 规则3: 验证节点连接
if (empty($lineList)) {
serviceException(ErrorEnum::WORKFLOW_NODE_MUST_HAVE_LINE_CONNECTION_CODE, ErrorEnum::WORKFLOW_NODE_MUST_HAVE_LINE_CONNECTION_DESC);
}
// 收集所有连接的节点ID
$connectedNodeIds = [];
foreach ($lineList as $line) {
$connectedNodeIds[] = $line['sourceId'];
$connectedNodeIds[] = $line['targetId'];
}
$connectedNodeIds = array_unique($connectedNodeIds);
// 检查是否所有节点都有连接
$unconnectedNodes = array_diff($nodeIds, $connectedNodeIds);
if (!empty($unconnectedNodes)) {
serviceException(ErrorEnum::WORKFLOW_EXIST_UNCONNECTED_NODE_CODE, ErrorEnum::WORKFLOW_EXIST_UNCONNECTED_NODE_DESC);
}
// 检查是否有孤立的节点组
$visitedNodes = [];
$queue = [];
// 从开始节点开始遍历
foreach ($nodeList as $node) {
if ($node['attr'] == WorkflowNodeAttrEnum::START) {
$queue[] = $node['nodeId'];
$visitedNodes[] = $node['nodeId'];
break;
}
}
while (!empty($queue)) {
$currentNodeId = array_shift($queue);
foreach ($lineList as $line) {
if ($line['sourceId'] == $currentNodeId && !in_array($line['targetId'], $visitedNodes)) {
$visitedNodes[] = $line['targetId'];
$queue[] = $line['targetId'];
}
}
}
// 检查是否所有节点都在同一个连通图中
$unreachableNodes = array_diff($nodeIds, $visitedNodes);
if (!empty($unreachableNodes)) {
serviceException(ErrorEnum::WORKFLOW_EXIST_ISOLATED_NODE_GROUP_CODE, ErrorEnum::WORKFLOW_EXIST_ISOLATED_NODE_GROUP_DESC);
}
return true;
}
上面这个验证是有点复杂的,换作以前自己敲,得敲半天,不过好在有AI,只要告诉AI验证规则,它就帮你实现这个代码了。
模块绑定工作流
我们先看看这个模块

这里需要注意的是这些模块是我们事先添加好的,因为一个项目中哪些模块需要审核,我们是知道的,并且这个模块的标识(编码)是固定的,因此这里就没有添加按钮让用户自行添加了,但每个模块需要使用哪个工作流,是可以让用户自行修改的

因为只是一个简单的修改,这里就不贴出代码。
活动申请接口
上面的工作流接口已经准备好了,下面实现的就是活动申请相关的接口了。
新增活动接口
招商活动其实是一个独立模块,一般都有增删改查这四个接口,如图

这里我们重点讲"新增"接口,假设需求是这样:因为活动涉及到一个举办金额,因此在添加活动的时候,发起一个申请,需要上级领导和财务进行审核。接下来我们看看代码是怎么实现的。
添加活动的接口写在控制器Activity.php
public function save(){
$data = $this->request->post();
$this->validate($data, [
'title' => 'require',
'type' => 'require',
'activityStartTime' => 'require',
'activityEndTime' => 'require',
'scheduledAmountInvested' => 'require',
'clueCount' => 'require',
'activityContent' => 'require',
'directorId' => 'require',
], [
'title.require' => '活动名称不能为空!',
'type.require' => '活动类型不能为空!',
'activityStartTime.require' => '活动开始时间不能为空!',
'activityEndTime.require' => '活动结束时间不能为空!',
'scheduledAmountInvested.require' => '预计投入金额不能为空!',
'clueCount.require' => '预计产出线索数不能为空!',
'activityContent.require' => '活动内容不能为空!',
'directorId.require' => '负责人不能为空!',
]);
return success(ActivityService::save($data));
}
控制器负责参数验证,,我们继续看看ActivityService.php里面的save()方法
public static function save($data){
Db::startTrans();
try {
// 添加一个活动
$nodeId = CommonActivityService::save($data);
// 发一个申请
SysUserTaskService::saveAll($nodeId, WorkflowBindCodeEnum::ACTIVITY_REVIEW, $data['directorId']);
Db::commit();
} catch (\Throwable $th) {
Db::rollback();
Log::error('新增招商活动失败,error:' . $th->getMessage());
serviceException();
}
}
毫无疑问,核心代码就是SysUserTaskService::saveAll(),我们进行看看这个方法
/**
* 新增所有待办事务
* @param string $dataId 数据ID
* @param string $workflowBindCode 工作流绑定编码
* @param string $applicantId 申请人ID
*/
public static function saveAll($dataId, $workflowBindCode, $applicantId) {
// 根据模块编码,找到需要执行的工作流
$workflow = WorkflowBindService::getWorkflow($workflowBindCode);
if($workflow) {
// 找到工作流的第一个节点和第二个节点,,【怎么找到这些节点,具体怎么实现,等下会讲到】
$firstNode = WorkflowDesignService::getFirstNodeId($workflow);
$nextNode = WorkflowDesignService::getNextNodeId($workflow, $firstNode['nodeId']);
Db::startTrans();
try {
// 创建一条发起人事项记录
self::save([
'applicantId' => $applicantId,
'userId' => $applicantId, // 记录申请人ID
'type' => UserTaskTypeEnum::INITIATE, // 类型为发起
'status' => UserTaskStatusEnum::PENDING,// 状态为待处理
'dataId' => $dataId, // 业务数据ID,这里就是活动数据ID
'moduleCode' => $workflowBindCode, // 模块编码,它和dataId决定唯一性,因为如果存在多个模块的话,如果ID是自增的话,业务数据dataId肯定会出现重复
'workflowNodeId' => $firstNode['nodeId'],
]);
// 给第一个审核人新增一条待办任务
self::save([
'applicantId' => $applicantId,
'userId' => $nextNode['reviewerId'], // 从节点中找到审核人ID并记录
'type' => UserTaskTypeEnum::APPROVAL, // 类型为待办
'status' => UserTaskStatusEnum::PENDING, // 状态为待处理
'dataId' => $dataId,
'moduleCode' => $workflowBindCode,
'workflowNodeId' => $nextNode['nodeId'],
]);
Db::commit();
} catch (\Throwable $th) {
Log::error('新增待办事务失败:'.$th->getMessage());
Db::rollback();
serviceException();
}
}
}
我这里的设计思路就是为每一个涉及到的用户都创建一条数据,根据类型type来区分。比如本项目中,假如用户A新建了一个互动,那A会在【我发起的】菜单中看到自己发起的任务,如图

审核人B会在【我的待办】中看到一条需要审核的记录,跟上面的图一样。
那这里可能有人会问,审核人B审核通过之后,那状态怎么更新,难道这两条数据的状态都更新成审核通过?很明显这是有问题的,这个问题下一节就会讲到。
本项目中每一个节点审核人只能选择一个,如果要选择多个,则需要改代码,大家可以自行扩展
现在问题回到怎么找到工作流的第一个节点和当前节点的下一个节点,甚至是最后一个节点?
查找第一个节点和最后一个节点,比较简单,只要判断attr就行了,例如下面是查找第一个节点(开始节点)的代码
/**
* 获取第一个节点
* @param array $workflow 工作流
*/
public static function getFirstNodeId($workflow){
$nodeList = $workflow['nodeList'] ?? [];
foreach($nodeList as $node){
if($node['attr'] == WorkflowNodeAttrEnum::START){
return $node;
}
}
return null;
}
如果要找当前节点下一个节点,那么需要接口线条字段了,我们都知道每个节点都会保存一个节点ID,而line里面会保存这条线的开始节点和结束节点,例如
"nodeList": [
{
"nodeId": "guw9zx9nlh",
"name": "提交活动申请",
……………………………………………………………………
},
{
"nodeId": "8zehlwhjqkg",
"name": "大区总监审核",
……………………………………………………………………
},
],
"lineList": [
{
"sourceId": "guw9zx9nlh", // 对应上面的”提交活动申请“的nodeId
"targetId": "8zehlwhjqkg", // 对应上面的”大区总监审核“的nodeId
"label": ""
}
]
那么通过上面关系我们就可以找当前节点的下一个节点,看代码
/**
* 获取下一个节点,不包含抄送节点
* @param array $workflow 工作流
* @param string $currentNodeId 当前节点ID
*/
public static function getNextNodeId($workflow, $currentNodeId){
$lineList = $workflow['lineList'] ?? [];
$nodeList = $workflow['nodeList'] ?? [];
foreach($lineList as $line){
if($line['sourceId'] == $currentNodeId){
// 判断一个节点是否是抄送节点
$nextNode = WorkflowDesignService::getNodeInfoByNodeId($nodeList, $line['targetId']);
if(isset($nextNode['attr']) && $nextNode['attr'] == WorkflowNodeAttrEnum::CC){
continue;
}
return $nextNode;
}
}
return null;
}
这段代码排查了抄送节点,我把它分开了,另外写一个一个获取抄送节点的函数"getNextNodeCC",其实这里可以不用再写多一个方法,不过后续可以优化。
节点审核接口
审核接口写在Activity.php这个文件里,其核心的方法是调用了ActivityService.php的review()方法,现在我们看看这个方法的代码
/**
* 审核
* @param array $data 审核数据
*/
public static function review($data){
$info = CommonActivityService::info($data['dataId']);
if(!$info){ serviceException(); }
// 审核,然后返回相应的状态
$status = SysUserTaskService::review($data);
if($status == UserTaskStatusEnum::APPROVED){
// 如果返回的状态是审核通过,那么就是通过
$info->status = ReviewEnum::APPROVED;
} elseif($status == UserTaskStatusEnum::DOING){
// 如果返回的状态是进行中,把么就是审核中
$info->status = ReviewEnum::IN_REVIEW;
} else {
// 如果返回的状态是审核不通过,那么就是不通过
$info->status = ReviewEnum::REJECTED;
}
$info->save();
}
这里我并没有把更新活动状态的逻辑在SysUserTaskService::review($data);这个方法里面实现,主要的目的是相把流程节点的审核和业务数据的更新分开,这样逻辑比较清晰,也比较容易扩展。
下面我们重点看看节点审核的方法,这是很重要的一个方法
public static function review($data) {
$data['comment'] = $data['comment'] ?? '';
// 通过模块编码获取工作流
$workflow = WorkflowBindService::getWorkflow($data['moduleCode']);
// 如果没有设置工作流,则直接返回已处理
if(!$workflow){ return UserTaskStatusEnum::APPROVED; }
$handleTime = date('Y-m-d H:i:s', time());
// 获取待办任务
$task = SysUserTaskService::info($data['userTaskId']);
if(!$task){ serviceException('待办事务不存在了,请查看是否被删除了'); }
if($data['status'] == UserTaskStatusEnum::APPROVED){
// 如果是审核通过、找下一个审核人(节点)
$nextNode = WorkflowDesignService::getNextNodeId($workflow, $task['workflowNodeId']);
// 下一个节点是否是结束节点,如果是结束节点,最终返回审核通过状态,如果不是结束节点,返回进行中的状态,也就是说还要继续审核
if($nextNode['attr'] == WorkflowNodeAttrEnum::END){
$status = UserTaskStatusEnum::APPROVED;
} else {
$status = UserTaskStatusEnum::DOING;
}
} else {
// 审核不公告
$status = UserTaskStatusEnum::REJECTED;
}
// 除了找审核人节点,这里还要找抄送节点
$nextNodeCC = WorkflowDesignService::getNextNodeCC($workflow, $task['workflowNodeId']);
try {
Db::startTrans();
// 更新发起的记录状态
SysUserTaskModel::baseQuery()
->where('type', UserTaskTypeEnum::INITIATE)
->where('data_id', $data['dataId'])
->where('module_code', $data['moduleCode'])
->update(['status' => $status,'handle_time'=>$handleTime]);
// 更新当前记录状态
SysUserTaskModel::baseQuery()
->where('id', $data['userTaskId'])
->update(['status' => $data['status'],'comment' => $data['comment'],'handle_time'=>$handleTime,'type'=>UserTaskTypeEnum::HANDLED]);
// 新增下一个审核人任务
if($status == UserTaskStatusEnum::DOING){
self::save([
'applicantId' => $task['applicantId'],
'userId' => $nextNode['reviewerId'],
'workflowNodeId' => $nextNode['nodeId'],
'type' => UserTaskTypeEnum::APPROVAL,
'status' => UserTaskStatusEnum::PENDING,
'dataId' => $data['dataId'],
'moduleCode' => $data['moduleCode'],
]);
}
if($nextNodeCC){
// 新增抄送任务
self::save([
'applicantId' => $task['applicantId'],
'userId' => $nextNodeCC['reviewerId'],
'workflowNodeId' => $nextNodeCC['nodeId'],
'type' => UserTaskTypeEnum::CC,
'status' => $data['status'],
'dataId' => $data['dataId'],
'moduleCode' => $data['moduleCode'],
]);
}
Db::commit();
} catch (\Throwable $th) {
Db::rollback();
Log::error('审核待办事务失败:'.$th->getMessage());
serviceException();
}
return $status;
}
下面我在总结一下这个方法的逻辑:
1、根据前端传过来的moduleCode,获取当前审核的工作流
2、如果当前节点【通过审核】,获取下一个节点,如果下一个节点是【结束节点】,则说明所有审核流程走完,需要把当前节点和发起节点的记录状态更新为【审核通过】。如果下一个节点是【执行节点】,那么就得为下一个节点的执行人创建一条待办任务,另外需要把当前节点设置为【审核通过】,而发起节点则需要设置为【进行中】,如果当前节点【审核不通过】,直接把当前节点和发起节点的记录状态更新为【审核不通过】,如果下一个节点存在【抄送节点】,直接创建一条抄送记录即可。
后台管理vue框架
一般情况下我们不会自己使用vue搭一个后台管理框架,都是使用现成,本项目也是使用了一套现成的使用vue3开发的框架" vue-next-admin "
gitee地址:https://gitee.com/lyt-top/vue-next-admin
这是演示地址: http://vuenextadmin.ccfast.cc/
为什么使用它?
1、使用了vue3
2、能快速跑起来
3、集成了工作流插件
前端页面,这里我们只讲工作流相关的模块,首先我们要准备两个模块,一个就是活动添加的模块,一个是工作流设计模块,这里我们把框架自带的位于【views/pages/workflow】目录下的工作流复制一份到views目录下
│ ├── activity/
│ │ ├── detail.vue
│ │ ├── dialog.vue
│ │ └── index.vue
│ └── workflow/
│ ├── component/
│ │ ├── contextmenu/
│ │ │ └── index.vue
│ │ ├── drawer/
│ │ │ ├── index.vue
│ │ │ ├── line.vue
│ │ │ └── node.vue
│ ├── tool/
│ │ └── help.vue
│ ├── js/
│ │ ├── config.ts
│ │ └── T5_mock.ts
│ ├── bind.vue
│ ├── design.vue
│ ├── dialog.vue
│ ├── dialog.bind.vue
│ └── index.vue
工作流页面讲解
前端页面我讲一下一些改动过的页面以及数据传输的流程,如果懂vue的,一看就懂了,也没什么技术含量,这里主要是帮助大家快速的了解实现本项目的功能,到底前端做了什么改动。
我们先将工作流【workflow】相关的页面,
1、index.vue 这个是列表页,其实这里就是查询我们添加的工作流数据,没有什么特别的逻辑,看代码就行,不详细讲了
2、dialog.vue这个添加页,也不讲
3、接下来重点就是design.vue这个页面了,这是设计工作流节点页面

这里面我们看看左侧节点的导航和画布中间的工作流,一开始你看到的肯定是一个空画布,但如果你设计了工作流,当打开这个界面就会看到如上图你之前设计好的工作流。
左侧导航-数据初始化
我们看到左侧节点的导航,原来它的数据是位于【js/mock.ts】,我把它修改成由后端接口获取了,并且修改了数据,去掉了一下字段
// 左侧导航-数据初始化
const initLeftNavList = async() => {
// 获取工作流的名称、版本、节点、线条等信息
await getWorkflowInfo();
// 这里就是获取节点
const res = await workflowApi.initNode();
state.leftNavList = res.data || [];
// 这里就是初始节点和线条了
state.jsplumbData = {
nodeList: state.workflowInfo.nodeList || [],
lineList: state.workflowInfo.lineList || [],
};
};
我们看看接口返回的数据
[
{
"title": "节点",
"isOpen": true,
"icon": "iconfont icon-shuju",
"id": "3",
"children": [
{
"icon": "iconfont icon-step",
"name": "开 始",
"id": "16",
"attr": "start"
},
{
"icon": "iconfont icon-icon-",
"name": "参与人",
"id": "31",
"attr": "user"
},
{
"icon": "iconfont icon-gerenzhongxin",
"name": "执行人",
"id": "32",
"attr": "user"
},
{
"icon": "iconfont icon-shouye_dongtaihui",
"name": "抄送人",
"id": "33",
"attr": "cc"
},
{
"icon": "iconfont icon-radio-off-full",
"name": "结 束",
"id": "100",
"attr": "end"
}
]
}
]
这里面比较重要的是我加了一个【attr】字段,来标记节点的类型
注意【icon】只能使用iconfont类型的字体
左侧导航-初始化拖动
我们新增了一个【attr】,因此必须调整一下initSortable方法
// 左侧导航-初始化拖动
const initSortable = () => {
leftNavRefs.value.forEach((v) => {
Sortable.create(v as HTMLDivElement, {
const { name, icon, id, attr } = evt.clone.dataset; // 这里加了attr
…………………………
onEnd: function (evt: any) {
………………………………………………
if (clientX < x || clientX > width + x || clientY < y || y > y + height) {
ElMessage.warning('请把节点拖入到画布中');
} else {
// 节点id(唯一)
const nodeId = Math.random().toString(36).substr(2, 12);
// 处理节点数据
const node = {
nodeId,
left: `${layerX - 40}px`,
top: `${layerY - 15}px`,
class: 'workflow-right-clone',
name,
icon,
id,
attr, // 这里加了attr
};
// 右侧视图内容数组
state.jsplumbData.nodeList.push(node);
………………………………………………………………
}
},
});
});
};
这里省略部分代码,这个方法里面只修改了这两处,这很重要。你可以不加它,看看会有什么问题,自己去验证一下。另外还行要在模板标签上新增这个属性
<!-- 左侧导航区 -->
<div class="workflow-content">
<div class="workflow-left">
<el-scrollbar>
<div
ref="leftNavRefs"
v-for="val in state.leftNavList"
:key="val.id"
:style="{ height: val.isOpen ? 'auto' : '50px', overflow: 'hidden' }"
class="workflow-left-id"
>
<div class="workflow-left-title">
<span>{{ val.title }}</span>
<!-- <SvgIcon :name="val.isOpen ? 'ele-ArrowDown' : 'ele-ArrowRight'" /> -->
</div>
<!-- 我们看到这一行代码 attr-->
<div class="workflow-left-item" v-for="(v, k) in val.children" :key="k" :data-name="v.name" :data-icon="v.icon" :data-id="v.id" :data-attr="v.attr">
<div class="workflow-left-item-icon">
<SvgIcon :name="v.icon" class="workflow-icon-drag" />
<div class="font10 pl5 name">{{ v.name }}</div>
</div>
</div>
</div>
</el-scrollbar>
</div>
如果你这里不加:data-attr="v.attr",下面是获取不到这个节点类型的值,这样后面我们就无法判断节点类型了
后面初始化画布中的节点、线条,右击节点这些方法都没有改动,例子都写的很明白,大家可以自行去阅读。
设置节点名称和添加执行人
我们找到组件【component/drawer/index.vue】里面包含了node和line两个组件,我们看看node.vue
<el-tab-pane label="节点编辑" name="1">
<el-scrollbar>
<el-form :model="state.node" :rules="state.nodeRules" ref="nodeFormRef" size="default" label-width="80px" class="pt15 pr15 pb15 pl15">
<el-form-item label="数据id" prop="id">
<el-input v-model="state.node.id" placeholder="请输入数据id" disabled></el-input>
</el-form-item>
<el-form-item label="节点id" prop="nodeId">
<el-input v-model="state.node.nodeId" placeholder="请输入节点id" disabled></el-input>
</el-form-item>
<el-form-item label="类型" prop="type">
<el-input v-model="state.node.type" placeholder="请输入类型" disabled></el-input>
</el-form-item>
<el-form-item label="left坐标" prop="left">
<el-input v-model="state.node.left" placeholder="请输入left坐标" disabled></el-input>
</el-form-item>
<el-form-item label="top坐标" prop="top">
<el-input v-model="state.node.top" placeholder="请输入top坐标" disabled></el-input>
</el-form-item>
<el-form-item label="icon图标" prop="icon">
<el-input v-model="state.node.icon" placeholder="请输入icon图标" disabled></el-input>
</el-form-item>
<el-form-item label="节点名称" prop="name">
<el-input v-model="state.node.name" placeholder="请输入名称" clearable></el-input>
</el-form-item>
<el-form-item label="审核人" prop="reviewer" v-if="state.node.attr === `user`">
<el-input v-model="state.node.reviewerName" placeholder="" disabled>
<template #append>
<el-button :icon="Search" @click="onOpenUserDialog" />
</template>
</el-input>
</el-form-item>
<el-form-item label="抄送人" prop="reviewer" v-if="state.node.attr === `cc`">
<el-input v-model="state.node.reviewerName" placeholder="" disabled>
<template #append>
<el-button :icon="Search" @click="onOpenUserDialog" />
</template>
</el-input>
</el-form-item>
<el-form-item>
<el-button class="mb15" @click="onNodeRefresh">
<SvgIcon name="ele-RefreshRight" />
重置
</el-button>
<el-button type="primary" class="mb15" @click="onNodeSubmit">
<SvgIcon name="ele-Check" />
保存
</el-button>
</el-form-item>
</el-form>
</el-scrollbar>
</el-tab-pane>
<UserDialog ref="userDialogRef" @getUser="getUserInfo" />
我只保留了这一块的内容,另外新增了审核人和抄送人两个选项,不过这两个选项的显隐是根据【attr】属性控制的,也就是前面为啥要传这个属性的原因,如果不传就不会显示了,另外这里调用了user组件,用于选择用户。
当你点击保存后会调用下面的一个方法
// 子组件调用, 获取用户信息
const getUserInfo = (row: any) => {
state.node.reviewerId = row.id;
state.node.reviewerName = row.realname;
}
// 节点编辑-保存
const onNodeSubmit = () => {
nodeFormRef.value.validate((valid: boolean) => {
if (valid) {
// 这里会把数据传个父组件,也就是index.vue
emit('submit', state.node);
emit('close');
} else {
return false;
}
});
};
这里会把数据传个父组件,也就是index.vue
<template>
<div>
<el-drawer :title="`${state.nodeData.type === 'line' ? '线' : '节点'}操作`" v-model="state.isOpen" size="320px">
<el-scrollbar>
<Lines v-if="state.nodeData.type === 'line'" @change="onLineChange" @close="close" ref="lineRef" />
<Nodes v-else @submit="onNodeSubmit" @close="close" ref="nodeRef" />
</el-scrollbar>
</el-drawer>
</div>
</template>
我们发现submit调用的是这个onNodeSubmit方法
const onNodeSubmit = (data: object) => {
emit('node', data);
};
这里继续向父组件传数据,也就是design.vue。【node.vue->index.vue->design.vue】
最后实际上调用了父组件的setNodeContent方法
// 设置节点内容
const setNodeContent = (obj: any) => {
const { nodeId, name, icon,reviewerId,reviewerName } = obj;
// 设置节点 name 与 icon
state.jsplumbData.nodeList.forEach((v) => {
if (v.nodeId === nodeId) {
v.name = name;
v.icon = icon;
v.reviewerId = reviewerId;
v.reviewerName = reviewerName;
}
});
// 重绘
nextTick(() => {
state.jsPlumb.setSuspendDrawing(false, true);
});
};
保存数据
继续看design.vue,里面引入了顶部工具栏组件,也就是顶部右上角那一块,有个【提交】按钮
<!-- 顶部工具栏 -->
<Tool @tool="onToolClick" :workflowInfo="state.workflowInfo" />
这里很明显,直接找onToolClick方法
// 顶部工具栏-当前项点击
const onToolClick = (fnName: String) => {
switch (fnName) {
case 'help':
onToolHelp();
break;
case 'download':
onToolDownload();
break;
case 'submit':
onToolSubmit();
break;
case 'copy':
onToolCopy();
break;
case 'del':
onToolDel();
break;
case 'fullscreen':
onToolFullscreen();
case 'return':
onToolReturnList();
break;
}
};
最后就是调用了onToolSubmit()方法
// 顶部工具栏-提交
const onToolSubmit = async() => {
state.workflowInfo.nodeList = state.jsplumbData.nodeList;
state.workflowInfo.lineList = state.jsplumbData.lineList;
// console.log(state.jsplumbData);
const res = await workflowApi.update({...state.workflowInfo});
if(res.code === 200){
ElMessage.success('数据提交成功');
}
};
到这里我们就可以完整的创建一个工作流了。


