给若依加审批流,不用 Flowable
英文标题:给若依加审批流,不用 Flowable
若依官方免费版没有审批流。本文用 jeeflow 工作流引擎补上:后端一个依赖一个薄壳,前端一个宿主组件九个薄页,一条菜单 SQL 收口 RBAC,半天跑通带工作台、发起、待办、审批、流程设计器的完整流程中心,附真机实录与踩坑。
25.6 KB
37.3 KB
27.3 KB
25.3 KB
24.7 KB
24.8 KB
34.8 KB
29.6 KB
32.1 KB
25.6 KB
23.5 KB
6.7 KB
中文
引言
若依是国内最流行的快速开发框架之一,GitHub 镜像 4 万多 star,几乎每个 Java 后台项目都从它起步。但它有个大家都知道的"缺口":官方免费版不内置审批流 。
用若依的人,迟早要碰审批——请假、报销、合同、采购。怎么补?
这篇文章给一个不绕弯子的答案:别上 Flowable,用 jeeflow。后端一个依赖一个薄壳,前端一个宿主组件九个薄页,配一条菜单 SQL,半天跑通一个带工作台、发起、待办、审批、流程设计器的完整流程中心。
我把整个过程真机走了一遍(若依 3.9.2 master 分支,Spring Boot 4.1.0 + JDK 17),造了一批发起、会签、网关分流、退回重提、拒绝、抄送的真实演示数据,下面全是实录截图。
一、先看清现状:若依到底有没有审批流
先把话说清楚,免得被标题党带节奏:
若依官方是活跃的。 master 分支最近还在升级 Spring Boot 4.1.0、poi 5.5.1(2026-08 的提交),项目本身没死。
但"活跃"和"有审批流"是两回事。若依官方只有三条主线:master(Boot 4)、springboot3、springboot2,没有工作流/审批流模块,也没有相关分支 。它的定位是"用户管理、部门岗位、菜单权限、字典参数、代码生成"这一套基础脚手架,审批从来不在范围内。
所以给若依加审批流,实际上只有三条路:
| 路线 | 做法 | 代价 |
|---|---|---|
| ① 找付费/二开版 | 用 RuoYi-Vue-Plus、某些商业增强版里的工作流 | 绑定特定衍生版,升级跟着它走 |
| ② 自集成 Flowable | 引 Flowable 引擎,自己写业务适配 | 学习曲线陡、BPMN 模型重、和若依权限体系要硬接 |
| ③ 轻量引擎薄集成 | 用一个薄 的工作流引擎,只写映射层 | 引擎得够轻、契约得够清 |
本文走第三条。选 jeeflow 的理由就一句话:它是为"快速开发框架集成"而生的薄引擎,门面契约、权限码、选人字典这些和后台框架的对接点都做好了,你只写"若依侧"那一薄层。
后面会看到,这一薄层有多薄。
二、为什么不用 Flowable
不踩一捧一,但对比是客观的。Flowable 是成熟的 BPMN 引擎,能力强,但它的"重量"和若依这种"薄脚手架"场景是错配的:
- 模型重 。BPMN XML + 图形建模器,一个请假流程要维护一整套模型文件。jeeflow 的流程定义就是一段 JSON(本文第四节有完整示例),设计器也是 JSON 驱动。
- 学习曲线 。Flowable 的 Process / Task / Execution / 监听器 / Delegate 那一套,上手要花时间。jeeflow 对外只有一个门面,45 个 action,POST 一下就完事。
- 权限对接 。Flowable 自带一套 identity 用户体系,跟若依的
sys_user/sys_role/sys_menu是两套账本,得写桥。jeeflow 把"用户是谁""有没有权限""按什么角色找人"全做成 SPI 钩子,你填若依的 Service 就行(第三节)。 - 前端 。Flowable 官方前端要自己拼。jeeflow 有个 npm 组件包
@mldong/jeeflow-ui,工作台/发起/待办/已办/我发起的/抄送/流程定义/流程设计/委托九个页面开箱即用,而且每个页面都能单独挂进宿主的菜单体系 (第五节),不是塞给你一个iframe或者一套独立布局。
如果你的项目要上复杂 BPMN(会签矩阵、动态路由、跨系统编排),Flowable 依然是对的。但"若依加个请假/报销审批"这种 80% 的场景,jeeflow 的薄集成更省。
三、后端:一个依赖 + 一个薄壳
3.1 加依赖
若依 master 是 Spring Boot 4,对应 jeeflow 的 spring-boot4 starter:
<dependency>
<groupId>com.mldong.jeeflow</groupId>
<artifactId>jeeflow-spring-boot4-starter</artifactId>
<version>1.8.27</version>
</dependency>
这一个依赖进去,starter 自动装配好引擎核心:JeeflowEngine、JDBC 仓储、雪花 ID 生成、Jackson(Long→String,前端不丢精度)、事务模板、SpEL 表达式求值器、默认用户 Provider。全部分 @ConditionalOnMissingBean,你想覆盖哪个就自己 @Bean 哪个。
注意:不用覆盖 ID 生成器 。若依没引 MyBatis-Plus,没有"雪花 vs 自增主键"的对齐问题,starter 内置雪花直接能用。这是"薄"的体现——少写一个 Bean。
3.2 只补两个 Bean
starter 不自动装配的就两样:设计/历史/委托三表的扩展仓储,和统一门面 JeeflowFacade。一个配置类搞定:
@Configuration
public class WfJeeflowConfig {
@Bean
@ConditionalOnMissingBean
public IProcessExtRepository jeeflowExtRepository(DataSource dataSource) {
return new JdbcProcessExtRepository(dataSource);
}
@Bean
@ConditionalOnMissingBean
public JeeflowFacade jeeflowFacade(JeeflowEngine engine,
IProcessRepository repository,
IProcessExtRepository extRepository,
ObjectProvider<IUserSearchProvider> userSearchProvider) {
JeeflowFacade facade = new JeeflowFacade(engine, repository, extRepository);
userSearchProvider.ifAvailable(facade::setUserSearchProvider);
return facade;
}
}
3.3 一个 Controller 吃下所有请求
jeeflow 的门面是"一个入口,45 个 action"——POST /wf/{action},body 是参数 map,返回 {code: 0, msg, data}。所以若依侧只需要一个 Controller :
@RestController
@RequestMapping("/wf")
public class WfFlowController {
@Autowired
private JeeflowFacade jeeflowFacade;
@PostMapping("/**")
public Map<String, Object> flow(HttpServletRequest request,
@RequestBody(required = false) Map<String, Object> body) {
String uri = request.getRequestURI();
int idx = uri.indexOf("/wf/");
String action = uri.substring(idx + "/wf/".length());
checkPermission(action);
if (body == null) body = new LinkedHashMap<>();
body.put("operator", SecurityUtils.getUserId().toString());
return jeeflowFacade.flow(action, body);
}
private void checkPermission(String action) {
if (SecurityUtils.isAdmin()) return;
IActionPermissionProvider provider = ServiceContext.find(IActionPermissionProvider.class);
String[] codes = provider == null ? null : provider.permissionCodes(action);
if (codes != null && codes.length > 0) {
for (String code : codes) {
if (SecurityUtils.hasPermi(code)) return;
}
throw new ServiceException("无权限执行操作:" + action, 403);
}
}
}
三件事,全是若依现成的能力:
- 权限 :引擎默认 Provider 把每个 action 映射成
wf:{action}权限码(如processTask/todoList → wf:processTask:todoList),SecurityUtils.hasPermi走若依的 RBAC(sys_menu按钮)。 - 操作人 :
SecurityUtils.getUserId()从若依登录态(Spring Security 的LoginUser)取,塞进args.operator——门面靠它判断"我的待办/我发起的"。 - 透传 :返回的是 jeeflow 门面原生结构
code: 0,不包若依的 AjaxResult(code: 200)。这是有意的——前端jeeflow-ui只认门面契约,包一层反而要拆。
3.4 三个 Provider:把若依的用户体系喂给引擎
引擎通过 SPI 问宿主三个问题:用户是谁、怎么搜人、按部门/角色怎么找人。各写一个 Provider 接若依 Service:
RyUserProvider(IUserProvider):selectUserById取nickName当姓名、取部门名。RyUserSearchProvider(IUserSearchProvider):候选人搜索,按昵称/账号模糊匹配。RyOrgUserProvider(IOrgUserProvider):findDeptLeaders(部门负责人)、findByRole(按角色找成员)——审批流里"派给部门主管""派给某角色"就靠它。
这三个是"若依侧"唯一要写的业务代码。没有之一。
3.5 种子流程:让系统开箱就有个流程
若依首次启动,我加了个 ApplicationRunner 幂等地上架一个"请假申请"流程(走的是和设计器完全相同的 processDesign/save → deploy 链路,不是直接插表):
为什么走门面而不是直接 insert?因为 listByType(发起页的数据源)读的是 design 表 + 历史表 ,不是 define 表。直接插 define 表,发起页看不到。走 save→deploy 才能保证和人工在界面上设计、发布走的是同一条路。
开箱只有请假一条还不够演示。我又写了三个流程定义(报销/采购/加班,JSON 见第四节),用同一条 save→deploy 链路上架——脚本调门面和在"流程设计"页面上点发布,后端是同一个动作 。最终发起页有四张流程卡片:
四、四个流程定义:从直线到网关和会签
流程定义就是 JSON。四个流程由简到繁,正好把 jeeflow 的核心概念全覆盖:直线审批、决策网关、并行会签、字段权限、抄送 。
4.1 leave:最简直线审批(全文)
{
"name": "leave",
"displayName": "请假申请",
"type": "approval",
"enableFieldPerm": true,
"__schema__": {
"columns": [
{ "fieldName": "reason", "remark": "请假事由", "component": "Textarea", "ext": { "required": 1, "placeholder": "请说明请假原因" } },
{ "fieldName": "days", "remark": "请假天数", "component": "Number", "ext": { "required": 1 } },
{ "fieldName": "leaveType", "remark": "请假类型", "component": "Select",
"ext": { "required": 1, "options": [
{ "label": "年假", "value": "annual" },
{ "label": "事假", "value": "personal" },
{ "label": "病假", "value": "sick" } ] } },
{ "fieldName": "startDate", "remark": "开始日期", "component": "Date" },
{ "fieldName": "endDate", "remark": "结束日期", "component": "Date" }
]
},
"nodes": [
{ "id": "start", "type": "snaker:start", "text": { "value": "开始" } },
{ "id": "apply", "type": "snaker:task",
"properties": { "form": "leave-form", "assignee": "applicant" },
"text": { "value": "填写申请" } },
{ "id": "approve", "type": "snaker:task",
"properties": { "form": "leave-form", "assignee": "1",
"field": { "PERMISSION_f_reason": 1, "PERMISSION_f_days": 1, "PERMISSION_f_leaveType": 1 } },
"text": { "value": "上级审批" } },
{ "id": "end", "type": "snaker:end", "text": { "value": "结束" } }
],
"edges": [
{ "sourceNodeId": "start", "targetNodeId": "apply" },
{ "sourceNodeId": "apply", "targetNodeId": "approve" },
{ "sourceNodeId": "approve", "targetNodeId": "end" }
]
}
几个值得注意的点:
__schema__.columns就是表单,前端零代码渲染(第五节)。不用为"请假"写一个 Vue 表单组件。assignee: "applicant"= 发起人本人填;assignee: "1"= 字面 userId(这里指 admin)。还能写deptLeader、roleCode这种,由RyOrgUserProvider解析。field里的PERMISSION_f_xxx: 1= 该字段在审批节点只读(1=只读 / 2=可编辑 / 3=隐藏)。审批人能看到"请假事由"但改不了。
4.2 purchase:金额网关分流
采购申请,预算 5000 是分水岭:以下部门审批,以上总经理审批。核心就是一个决策节点 + 两条带表达式的边 :
{ "id": "decision1", "type": "snaker:decision",
"properties": { "expr": "#f_amount > 5000" },
"text": { "value": "金额>5000?" } }
两条出边的表达式:
{ "sourceNodeId": "decision1", "targetNodeId": "deptApprove",
"properties": { "expr": "#f_amount <= 5000" }, "text": { "value": "≤5000" } },
{ "sourceNodeId": "decision1", "targetNodeId": "gmApprove",
"properties": { "expr": "#f_amount > 5000" }, "text": { "value": ">5000" } }
这里有个真机踩出来的坑,值得单独说 :表达式里引用表单字段,必须写成
#f_amount——井号引用 +f_前缀,两个都不能少。
f_前缀:jeeflow-ui 发起时给所有表单字段统一加f_前缀提交(f_amount、f_reason),这是引擎和前端约定好的业务参数命名空间,避免和引擎内置参数撞名。#引用:jeeflow 默认的 SpEL 求值器按#key从流程变量里取值。
我第一次写成了裸的
amount <= 5000,发起 86000 的采购单,引擎直接报"decision节点无法确定下一步执行路线"——两条边都不命中。对着求值器源码才看清替换规则。写文章之前替你们踩了。
4.3 baoxiao:并行会签
报销申请,经理和财务并行会签 ,两人全部同意才过:
{ "id": "countersign", "type": "snaker:task",
"properties": {
"form": "baoxiao-form",
"assignee": "1,2",
"performType": "1",
"countersignType": "PARALLEL",
"field": { "PERMISSION_f_reason": 1, "PERMISSION_f_amount": 1,
"PERMISSION_f_invoiceCount": 1, "PERMISSION_f_expenseDate": 1 } },
"text": { "value": "经理财务会签" } }
三个属性缺一不可:assignee: "1,2" 逗号分隔多个审批人;performType: "1" 表示会签(每人一票);countersignType: "PARALLEL" 表示并行(任务同时产生)。改成 SEQUENTIAL 就是串行会签——按顺序逐个审。
4.4 overtime:最简审批 + 抄送
加班申请走"发起 → 上级审批"直线,抄送不占节点——发起时带ccActors 参数就行,抄送对象会在发起瞬间收到一条抄送记录(我的抄送页可见)。
四个流程,定义文件加起来不到 400 行 JSON,没有一个 Java 类、没有一个 Vue 组件 。
五、前端:一个宿主组件 + 九个薄页
若依前端是 RuoYi-Vue3(Vite + Vue3 + Element Plus + Pinia)。jeeflow-ui 的九个页面(工作台/发起/待办/已办/我发起的/抄送/流程定义/流程设计/委托)不是塞给你一坨独立布局,而是九个可以独立挂载的页面组件 ——正确姿势是把它们挂进若依自己的菜单体系,让"工作流"成为若依左侧菜单里的一级目录,跟"系统管理"平起平坐。
前端要写的东西分三块:main.js 两行、一个宿主组件、九个 8 行的薄页 。
5.1 main.js:引一次样式
import '@mldong/jeeflow-ui/dist/style.css'
就一行。但这一行有个故事——见第八节,这行在 1.0.0 时代写了会报错,因为包漏导出了这个文件,1.0.1 才修。
5.2 wf-host.vue:全集成唯一值得细看的文件
九个页面共用一个宿主组件,职责就四件事:注入若依的身份与接口、桥接权限、映射页间跳转 。
<template>
<JeeflowUiProvider :config="jfConfig">
<component :is="page" @goto="onGoto" />
</JeeflowUiProvider>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { JeeflowUiProvider } from '@mldong/jeeflow-ui'
import request from '@/utils/request'
import { getToken } from '@/utils/auth'
import useUserStore from '@/store/modules/user'
defineProps({
page: { type: [Object, Function], required: true },
})
const router = useRouter()
const userStore = useUserStore()
// 通配权限匹配(对齐若依后端 PatternMatchUtils.simpleMatch)
function globMatch(pattern, str) {
if (!pattern || !str) return false
if (pattern === '*:*:*') return true
if (pattern === str) return true
const re = '^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$'
return new RegExp(re).test(str)
}
// hasPermission(codes[]):任一命中即放行
function hasPermission(codes) {
const list = Array.isArray(codes) ? codes : [codes]
if (!list.length) return false
const mine = userStore.permissions || []
return list.some(code => mine.some(perm => globMatch(perm, code)))
}
// 宿主 adapter:把若依 REST 喂给流程中心(选人/字典/上传)
const adapters = {
getDict: async (code) => {
const res = await request.get(`/system/dict/data/type/${code}`)
return (res.data || []).map(d => ({ value: d.dictValue, label: d.dictLabel }))
},
upload: async (file) => {
const fd = new FormData()
fd.append('file', file)
const res = await request.post('/common/upload', fd,
{ headers: { 'Content-Type': 'multipart/form-data' } })
return res.url || res.fileName
},
listUsers: async (keyword) => {
const res = await request.get('/system/user/list',
{ params: { pageNum: 1, pageSize: 50, userName: keyword || undefined } })
return (res.rows || []).map(u => ({
userId: String(u.userId), realName: u.nickName, deptName: u.dept?.deptName || ''
}))
},
}
const jfConfig = {
baseUrl: import.meta.env.VITE_APP_BASE_API, // 经 vite proxy → :8080
getToken: () => getToken(), // 若依 JWT(js-cookie Admin-Token)
getOperator: () => (userStore.id != null ? String(userStore.id) : ''),
hasPermission,
adapters,
}
// 组件内页间跳转(key 由组件 emit)→ 若依路由
const routeMap = {
workbench: '/workflow/workbench',
apply: '/workflow/apply',
todo: '/workflow/todo',
done: '/workflow/done',
mine: '/workflow/mine',
cc: '/workflow/cc',
define: '/workflow/define',
design: '/workflow/design',
surrogate: '/workflow/surrogate',
}
function onGoto(key) {
if (routeMap[key]) router.push(routeMap[key])
}
</script>
JeeflowUiProvider 是个依赖注入壳 ——它不绑死任何宿主 REST,token、当前用户、权限判断、选人/字典/上传全是你从外面塞进去的函数。你塞若依的,它就打若依的接口;塞别的系统的,它就打别的。
三个细节:
- token :若依用
js-cookie存Admin-Token,JWT 格式Bearer <token>,和 jeeflow-ui 要的一致,直接getToken()透传。 - 权限 :若依前端
plugins/auth.js是精确匹配 ,没有通配;而菜单上我们把引擎细粒度码收成了通配按钮(wf:processDesign:*,第六节)。所以宿主里做了个globMatch桥接,让wf:processDesign:*能匹配wf:processDesign:listByType。 - 跳转 :九个页面之间会互相跳(比如工作台点"待办数"跳待办页),jeeflow-ui 只 emit 一个
goto事件带页面 key,落地的路由由宿主决定——这就是它能"寄生"在任何宿主菜单体系里的关键。
5.3 九个薄页:每个 8 行
每个页面就是"宿主 + 对应的 Page 组件",以"我的待办"为例:
<template>
<JfHost :page="JfTodoPage" />
</template>
<script setup>
import JfHost from '../wf-host.vue'
import { JfTodoPage } from '@mldong/jeeflow-ui'
</script>
其余八个(workbench/apply/done/mine/cc/define/design/surrogate)一模一样,只是 import 的 Page 组件不同。九个文件合计 70 行出头。
路由都不用手写 ——若依的动态路由会读 sys_menu 里 component 字段,指向 views/workflow/todo/index 就自动加载。所以挂菜单就是第六节那条 SQL 的事。
为什么前端这么少?因为表单也不归前端管——发起表单、审批回显全部由流程 JSON 的 __schema__ 驱动渲染,业务侧零表单代码 。
六、RBAC:1 个目录 + 9 个页面 + 5 个通配按钮
菜单结构完全按若依的习惯来:一级目录"工作流",下面九个菜单项各挂一个页面,权限码用引擎的细粒度码(页面级入口),再配 5 个 F 型通配按钮 把一族 action 的权限收口:
delete from sys_role_menu where menu_id between 2000 and 2099;
delete from sys_menu where menu_id between 2000 and 2099;
insert into sys_menu values('2000', '工作流', 0, 5, 'workflow', null, '', '', 1, 0, 'M', '0', '0', '', 'tree', 'admin', sysdate(), '', null, 'jeeflow 工作流');
insert into sys_menu values('2001', '工作台', 2000, 1, 'workbench', 'workflow/workbench/index', '', '', 1, 0, 'C', '0', '0', 'wf:workbench', 'dashboard', 'admin', sysdate(), '', null, 'jeeflow 工作台');
insert into sys_menu values('2002', '发起申请', 2000, 2, 'apply', 'workflow/apply/index', '', '', 1, 0, 'C', '0', '0', 'wf:processDesign:listByType', 'form', 'admin', sysdate(), '', null, 'jeeflow 发起申请');
insert into sys_menu values('2003', '我的待办', 2000, 3, 'todo', 'workflow/todo/index', '', '', 1, 0, 'C', '0', '0', 'wf:processTask:todoList', 'checkbox', 'admin', sysdate(), '', null, 'jeeflow 我的待办');
insert into sys_menu values('2004', '我的已办', 2000, 4, 'done', 'workflow/done/index', '', '', 1, 0, 'C', '0', '0', 'wf:processTask:doneList', 'time', 'admin', sysdate(), '', null, 'jeeflow 我的已办');
insert into sys_menu values('2005', '我发起的', 2000, 5, 'mine', 'workflow/mine/index', '', '', 1, 0, 'C', '0', '0', 'wf:processInstance:page', 'list', 'admin', sysdate(), '', null, 'jeeflow 我发起的');
insert into sys_menu values('2006', '我的抄送', 2000, 6, 'cc', 'workflow/cc/index', '', '', 1, 0, 'C', '0', '0', 'wf:processInstance:ccList', 'message', 'admin', sysdate(), '', null, 'jeeflow 我的抄送');
insert into sys_menu values('2007', '流程定义', 2000, 7, 'define', 'workflow/define/index', '', '', 1, 0, 'C', '0', '0', 'wf:processDefine:page', 'documentation', 'admin', sysdate(), '', null, 'jeeflow 流程定义');
insert into sys_menu values('2008', '流程设计', 2000, 8, 'design', 'workflow/design/index', '', '', 1, 0, 'C', '0', '0', 'wf:processDesign:page', 'build', 'admin', sysdate(), '', null, 'jeeflow 流程设计');
insert into sys_menu values('2009', '我的委托', 2000, 9, 'surrogate', 'workflow/surrogate/index', '', '', 1, 0, 'C', '0', '0', 'wf:processSurrogate:page', 'peoples', 'admin', sysdate(), '', null, 'jeeflow 我的委托');
insert into sys_menu values('2020', '流程发起', 2000, 20, '', '', '', '', 1, 0, 'F', '0', '0', 'wf:processDesign:*', '#', 'admin', sysdate(), '', null, '发起/设计/上架/发布');
insert into sys_menu values('2021', '任务办理', 2000, 21, '', '', '', '', 1, 0, 'F', '0', '0', 'wf:processTask:*', '#', 'admin', sysdate(), '', null, '待办/已办/执行/转办');
insert into sys_menu values('2022', '实例查看', 2000, 22, '', '', '', '', 1, 0, 'F', '0', '0', 'wf:processInstance:*', '#', 'admin', sysdate(), '', null, '实例分页/详情/抄送');
insert into sys_menu values('2023', '定义管理', 2000, 23, '', '', '', '', 1, 0, 'F', '0', '0', 'wf:processDefine:*', '#', 'admin', sysdate(), '', null, '定义增删改/版本');
insert into sys_menu values('2024', '流程委托', 2000, 24, '', '', '', '', 1, 0, 'F', '0', '0', 'wf:processSurrogate:*','#', 'admin', sysdate(), '', null, '委托增删改查');
insert into sys_role_menu values ('2','2000'),('2','2001'),('2','2002'),('2','2003'),
('2','2004'),('2','2005'),('2','2006'),('2','2007'),('2','2008'),('2','2009'),
('2','2020'),('2','2021'),('2','2022'),('2','2023'),('2','2024');
能这么收,靠的是若依 SecurityUtils.hasPermi 底层用的 PatternMatchUtils.simpleMatch——天然支持* 通配。引擎给 wf:processTask:todoList,按钮配 wf:processTask:*,一命中就放行。45 个 action 一个按钮都没漏。
真机验证了三种情况:
| 场景 | 账号 | 结果 |
|---|---|---|
| 正向:全流程 | admin(超级管理员) | 发起→待办→审批→记录 全通 |
| 正向:普通角色 | ry(common 角色) | 发起成功,走 admin 审批 |
| 负向:无权限 | noperm(无角色用户) | listByType / startAndExecute 返回 403,approvalRecord(免权限)放行 |
负向那条尤其关键——它证明了权限不是"登录就能全干",而是真落在若依 RBAC 上。
七、跑通实录
环境:若依 3.9.2(master,Boot 4.1.0 / JDK 17),MySQL 8(jeeflow 引擎 8 张 wf_* 表),Redis。
7.1 造一批真数据
空系统的截图是没有说服力的。我参考 jeeflow 商业演示站的数据语义,用脚本走真实 API(登录→发起→审批,一步没有跳过)造了一批演示数据——4 个流程、8 条实例 ,把审批流最常见的几种生命周期全覆盖:
| # | 流程 | 剧本 | 终态 |
|---|---|---|---|
| S1 | 请假 | ry 发起 → admin 同意 | ✅ 走完 |
| S2 | 请假 | admin 自己发起自己审 | 🔵 在途 |
| S3 | 报销 | ry 发起 → 经理+财务并行会签,两人全同意 | ✅ 走完 |
| S4 | 请假 | ry 发起 → admin 退回发起人 → ry 改理由重新提交 → admin 同意 | ✅ 走完 |
| S5 | 采购 | admin 发 4200 元(小额)→ 走部门审批 | 🔵 在途(ry 待办) |
| S6 | 采购 | ry 发 86000 元(大额)→ 走总经理审批 | 🔵 在途(admin 待办) |
| S7 | 加班 | ry 发起(抄送 admin )→ admin 拒绝 | ❌ 被拒 |
| S8 | 加班 | ry 发起 → admin 同意 | ✅ 走完 |
(S4 那条是故意演的:退回重提是审批系统里最考验数据完整性的场景,审批记录的时间线必须把"退回→重提→同意"全程记下来,见 7.3。)
7.2 接口层(curl):一条链走通
curl -s -X POST http://localhost:8080/login -H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123","code":"13","uuid":"<验证码uuid>"}'
curl -s -X POST http://localhost:8080/wf/processDefine/startAndExecute \
-H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \
-d '{"processDefineId":15328901147922432,"f_reason":"家里有事","f_days":2,"f_leaveType":"personal"}'
curl -s -X POST http://localhost:8080/wf/processTask/execute \
-H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \
-d '{"processTaskId":15328926470901760,"submitType":1,"tf_approvalComment":"同意,注意工作交接"}'
⚠️ 一个 Windows 上的坑:用
curl -d '{...含中文...}'直接传中文,MSYS 的命令行参数编码会把中文变?(库里落一堆问号)。改用--data-binary @file.json从文件读,就正常了。跟若依、跟 jeeflow 都没关系 ,是 MSYS 的事——但如果你在 Windows 上用 curl 测,会遇到,先排掉这个再怀疑框架。
7.3 界面层:一个完整的流程中心
登录进首页,左侧菜单多了一级目录"工作流",九个菜单项全部就位:
工作台 ——在办/办结/发起/抄送四个统计卡、近 7 日发起趋势、热点流程 Top5、流程状态分布。这不是占位图表,数据全部来自引擎真实统计接口:
发起申请 ——四张流程卡片,还在途的流程会标"N 条在办"徽标。点开"报销申请",__schema__ 表单自动渲染:事由、金额、发票张数、费用日期,必填校验齐全:
我的待办 ——admin 登录,待办里躺着"总经理审批"(S6 采购 86000 大额)和"上级审批"(S2 自发起的请假):
点"办理",抽屉上半屏是申请信息只读回显 ——采购内容"测试服务器 2 台"、预算 86000、供应商、期望日期,审批人看得到但改不了(字段权限 PERMISSION_f_xxx: 1 在起作用);下半屏是审批意见和动作:
动作不止"同意/拒绝"——六种审批动作开箱即用:同意、拒绝、退回上一步、退回发起人、跳转指定节点、转办:
审批记录 ——S4 那条"退回重提"的时间线,四步全程可溯:发起 → 上级审批(退回发起人,带退回意见)→ 发起人重新提交 → 上级审批(同意)。审批系统最怕"口说无凭",这条时间线就是凭据:
流程定义 ——四个流程、版本号、发布状态一目了然:
流程设计 ——每个流程的设计快照可进可视化设计器调整再发布(本文的流程 JSON 就是在这里管理的):
我发起的 ——ry 视角看自己发起的 5 条,在途/完成/拒绝状态分明:
我的抄送 ——S7 加班单发起时抄送了 admin,这里多了一条带"抄送"标记的记录:
从零到这一屏,后端一个依赖 + 一个薄壳 + 三个 Provider + 一个种子 Runner,前端一个宿主组件 + 九个 8 行薄页 + 一行样式引入 + 一条菜单 SQL。没有写过一个表单,没有写过一个列表页。
八、首次第三方集成,反哺了上游三个问题
说个背景:jeeflow 此前的所有集成(十几个框架)都是我自己维护的 mldong 系列,"自家的引擎配自家的框架",很多坑是暴露不出来的。这次若依集成是第一次以第三方视角 从零接一遍,一天之内挖出了三个问题——两个在 ui 组件包,当场修了发版;一个在引擎,记了 issue。
① ui 包漏导出样式文件。 @mldong/jeeflow-ui 的 dist/style.css 文件在包里,但 package.json 的 exports 字段没导出它——宿主 import '@mldong/jeeflow-ui/dist/style.css' 直接报 Missing specifier。Node 的 exports 语义下"包里有文件"和"允许被引用"是两回事。没有自己家的约定俗成兜底,第三方一接就现形。
② 办理抽屉画出重复的空表单。 审批人点"办理",抽屉下半屏本该只有审批意见框,却多画了一整份绑着空数据 的业务表单——因为任务表单组件找不到宿主注册的审批表单时,回落渲染了流程级 __schema__ 表单,而 __schema__ 早就该由上半屏"申请信息"按字段权限渲染了。自家集成里表单注册习惯一致,这问题从来没冒过头。
这两个都在 jeeflow-ui 源头修复,发了 1.0.1 (本文所有截图就是 1.0.1 的效果)。若依侧只需要 npm i @mldong/jeeflow-ui@^1.0.1。
③ 引擎的并行会签+退回组合缺陷。 并行会签节点上,一人执行"退回发起人"后,兄弟审批人的会签任务不自动作废 ——僵尸任务继续挂在别人待办里,还会卡死会签的"全员完成"判定,实例永远走不到结束。这个是引擎级问题,集成层修不了,已提 issue 记录在案,留待引擎侧修。演示数据里我把"退回重提"闭环放在单人节点上演(S4),绕开了这个组合——该有的场景一样不少。
这大概是第三方集成最大的价值:它是一台天然的模糊测试机 。所有"我们一直都这么用"的隐式约定,在第一个真正的外人面前全部现形。
九、这个集成,我删了
写到这里,集成是通的、验证是齐的。但得跟你说清楚定位,免得有人真拿它当产品用。
jeeflow 是一个"多语言联邦"的工作流引擎 ,我维护的下游有一长串:Java 的 boot2/3/4、FastAPI、NestJS、Laravel、Go 的 goframe/gin/hertz、Rust 的 salvo、C#、Python 的 Flask/Django……每个框架都有一套"基础 + 1 commit"的集成壳,全部进了我的验收矩阵,长期维护。
若依不在这个列表里。
上面这套若依集成,是我为了写这篇文章,在一个临时目录 里从零走一遍流程做的实践示例——它证明了"jeeflow 能接进任何主流框架,前后端各一两个文件"这件事本身。它不会 进我的维护矩阵,没有持续升级承诺,文章发完我会把临时环境清掉。
所以:
- 如果你用 jeeflow,去看我维护的 mldong 快速开发框架系列 (boot4 / FastAPI / goframe 等),那才是有长期保障的集成。
- 若依这套,当作"怎么给一个存量 Spring 项目接 jeeflow"的参考样本 ,代码逻辑是通的,照搬到你的若依项目能跑;但它不是成品,别直接当生产依赖。
一个第三方集成,最大的价值不是"我维护它",而是"它证明了接入成本有多低"。若依这套的价值就在最后——前后端加起来,真正要写的业务代码,就那么三个 Provider、一个宿主组件和九个薄页。
审批流这个东西,不该是若依用户"要不要上 Flowable"的难题。它应该是"加个依赖,配个菜单,半天跑通"的常规操作。
(全文完)
English / 原文
引言
若依是国内最流行的快速开发框架之一,GitHub 镜像 4 万多 star,几乎每个 Java 后台项目都从它起步。但它有个大家都知道的"缺口":官方免费版不内置审批流 。
用若依的人,迟早要碰审批——请假、报销、合同、采购。怎么补?
这篇文章给一个不绕弯子的答案:别上 Flowable,用 jeeflow。后端一个依赖一个薄壳,前端一个宿主组件九个薄页,配一条菜单 SQL,半天跑通一个带工作台、发起、待办、审批、流程设计器的完整流程中心。
我把整个过程真机走了一遍(若依 3.9.2 master 分支,Spring Boot 4.1.0 + JDK 17),造了一批发起、会签、网关分流、退回重提、拒绝、抄送的真实演示数据,下面全是实录截图。
一、先看清现状:若依到底有没有审批流
先把话说清楚,免得被标题党带节奏:
若依官方是活跃的。 master 分支最近还在升级 Spring Boot 4.1.0、poi 5.5.1(2026-08 的提交),项目本身没死。
但"活跃"和"有审批流"是两回事。若依官方只有三条主线:master(Boot 4)、springboot3、springboot2,没有工作流/审批流模块,也没有相关分支 。它的定位是"用户管理、部门岗位、菜单权限、字典参数、代码生成"这一套基础脚手架,审批从来不在范围内。
所以给若依加审批流,实际上只有三条路:
| 路线 | 做法 | 代价 |
|---|---|---|
| ① 找付费/二开版 | 用 RuoYi-Vue-Plus、某些商业增强版里的工作流 | 绑定特定衍生版,升级跟着它走 |
| ② 自集成 Flowable | 引 Flowable 引擎,自己写业务适配 | 学习曲线陡、BPMN 模型重、和若依权限体系要硬接 |
| ③ 轻量引擎薄集成 | 用一个薄 的工作流引擎,只写映射层 | 引擎得够轻、契约得够清 |
本文走第三条。选 jeeflow 的理由就一句话:它是为"快速开发框架集成"而生的薄引擎,门面契约、权限码、选人字典这些和后台框架的对接点都做好了,你只写"若依侧"那一薄层。
后面会看到,这一薄层有多薄。
二、为什么不用 Flowable
不踩一捧一,但对比是客观的。Flowable 是成熟的 BPMN 引擎,能力强,但它的"重量"和若依这种"薄脚手架"场景是错配的:
- 模型重 。BPMN XML + 图形建模器,一个请假流程要维护一整套模型文件。jeeflow 的流程定义就是一段 JSON(本文第四节有完整示例),设计器也是 JSON 驱动。
- 学习曲线 。Flowable 的 Process / Task / Execution / 监听器 / Delegate 那一套,上手要花时间。jeeflow 对外只有一个门面,45 个 action,POST 一下就完事。
- 权限对接 。Flowable 自带一套 identity 用户体系,跟若依的
sys_user/sys_role/sys_menu是两套账本,得写桥。jeeflow 把"用户是谁""有没有权限""按什么角色找人"全做成 SPI 钩子,你填若依的 Service 就行(第三节)。 - 前端 。Flowable 官方前端要自己拼。jeeflow 有个 npm 组件包
@mldong/jeeflow-ui,工作台/发起/待办/已办/我发起的/抄送/流程定义/流程设计/委托九个页面开箱即用,而且每个页面都能单独挂进宿主的菜单体系 (第五节),不是塞给你一个iframe或者一套独立布局。
如果你的项目要上复杂 BPMN(会签矩阵、动态路由、跨系统编排),Flowable 依然是对的。但"若依加个请假/报销审批"这种 80% 的场景,jeeflow 的薄集成更省。
三、后端:一个依赖 + 一个薄壳
3.1 加依赖
若依 master 是 Spring Boot 4,对应 jeeflow 的 spring-boot4 starter:
<dependency>
<groupId>com.mldong.jeeflow</groupId>
<artifactId>jeeflow-spring-boot4-starter</artifactId>
<version>1.8.27</version>
</dependency>
这一个依赖进去,starter 自动装配好引擎核心:JeeflowEngine、JDBC 仓储、雪花 ID 生成、Jackson(Long→String,前端不丢精度)、事务模板、SpEL 表达式求值器、默认用户 Provider。全部分 @ConditionalOnMissingBean,你想覆盖哪个就自己 @Bean 哪个。
注意:不用覆盖 ID 生成器 。若依没引 MyBatis-Plus,没有"雪花 vs 自增主键"的对齐问题,starter 内置雪花直接能用。这是"薄"的体现——少写一个 Bean。
3.2 只补两个 Bean
starter 不自动装配的就两样:设计/历史/委托三表的扩展仓储,和统一门面 JeeflowFacade。一个配置类搞定:
@Configuration
public class WfJeeflowConfig {
@Bean
@ConditionalOnMissingBean
public IProcessExtRepository jeeflowExtRepository(DataSource dataSource) {
return new JdbcProcessExtRepository(dataSource);
}
@Bean
@ConditionalOnMissingBean
public JeeflowFacade jeeflowFacade(JeeflowEngine engine,
IProcessRepository repository,
IProcessExtRepository extRepository,
ObjectProvider<IUserSearchProvider> userSearchProvider) {
JeeflowFacade facade = new JeeflowFacade(engine, repository, extRepository);
userSearchProvider.ifAvailable(facade::setUserSearchProvider);
return facade;
}
}
3.3 一个 Controller 吃下所有请求
jeeflow 的门面是"一个入口,45 个 action"——POST /wf/{action},body 是参数 map,返回 {code: 0, msg, data}。所以若依侧只需要一个 Controller :
@RestController
@RequestMapping("/wf")
public class WfFlowController {
@Autowired
private JeeflowFacade jeeflowFacade;
@PostMapping("/**")
public Map<String, Object> flow(HttpServletRequest request,
@RequestBody(required = false) Map<String, Object> body) {
String uri = request.getRequestURI();
int idx = uri.indexOf("/wf/");
String action = uri.substring(idx + "/wf/".length());
checkPermission(action);
if (body == null) body = new LinkedHashMap<>();
body.put("operator", SecurityUtils.getUserId().toString());
return jeeflowFacade.flow(action, body);
}
private void checkPermission(String action) {
if (SecurityUtils.isAdmin()) return;
IActionPermissionProvider provider = ServiceContext.find(IActionPermissionProvider.class);
String[] codes = provider == null ? null : provider.permissionCodes(action);
if (codes != null && codes.length > 0) {
for (String code : codes) {
if (SecurityUtils.hasPermi(code)) return;
}
throw new ServiceException("无权限执行操作:" + action, 403);
}
}
}
三件事,全是若依现成的能力:
- 权限 :引擎默认 Provider 把每个 action 映射成
wf:{action}权限码(如processTask/todoList → wf:processTask:todoList),SecurityUtils.hasPermi走若依的 RBAC(sys_menu按钮)。 - 操作人 :
SecurityUtils.getUserId()从若依登录态(Spring Security 的LoginUser)取,塞进args.operator——门面靠它判断"我的待办/我发起的"。 - 透传 :返回的是 jeeflow 门面原生结构
code: 0,不包若依的 AjaxResult(code: 200)。这是有意的——前端jeeflow-ui只认门面契约,包一层反而要拆。
3.4 三个 Provider:把若依的用户体系喂给引擎
引擎通过 SPI 问宿主三个问题:用户是谁、怎么搜人、按部门/角色怎么找人。各写一个 Provider 接若依 Service:
RyUserProvider(IUserProvider):selectUserById取nickName当姓名、取部门名。RyUserSearchProvider(IUserSearchProvider):候选人搜索,按昵称/账号模糊匹配。RyOrgUserProvider(IOrgUserProvider):findDeptLeaders(部门负责人)、findByRole(按角色找成员)——审批流里"派给部门主管""派给某角色"就靠它。
这三个是"若依侧"唯一要写的业务代码。没有之一。
3.5 种子流程:让系统开箱就有个流程
若依首次启动,我加了个 ApplicationRunner 幂等地上架一个"请假申请"流程(走的是和设计器完全相同的 processDesign/save → deploy 链路,不是直接插表):
为什么走门面而不是直接 insert?因为 listByType(发起页的数据源)读的是 design 表 + 历史表 ,不是 define 表。直接插 define 表,发起页看不到。走 save→deploy 才能保证和人工在界面上设计、发布走的是同一条路。
开箱只有请假一条还不够演示。我又写了三个流程定义(报销/采购/加班,JSON 见第四节),用同一条 save→deploy 链路上架——脚本调门面和在"流程设计"页面上点发布,后端是同一个动作 。最终发起页有四张流程卡片:
四、四个流程定义:从直线到网关和会签
流程定义就是 JSON。四个流程由简到繁,正好把 jeeflow 的核心概念全覆盖:直线审批、决策网关、并行会签、字段权限、抄送 。
4.1 leave:最简直线审批(全文)
{
"name": "leave",
"displayName": "请假申请",
"type": "approval",
"enableFieldPerm": true,
"__schema__": {
"columns": [
{ "fieldName": "reason", "remark": "请假事由", "component": "Textarea", "ext": { "required": 1, "placeholder": "请说明请假原因" } },
{ "fieldName": "days", "remark": "请假天数", "component": "Number", "ext": { "required": 1 } },
{ "fieldName": "leaveType", "remark": "请假类型", "component": "Select",
"ext": { "required": 1, "options": [
{ "label": "年假", "value": "annual" },
{ "label": "事假", "value": "personal" },
{ "label": "病假", "value": "sick" } ] } },
{ "fieldName": "startDate", "remark": "开始日期", "component": "Date" },
{ "fieldName": "endDate", "remark": "结束日期", "component": "Date" }
]
},
"nodes": [
{ "id": "start", "type": "snaker:start", "text": { "value": "开始" } },
{ "id": "apply", "type": "snaker:task",
"properties": { "form": "leave-form", "assignee": "applicant" },
"text": { "value": "填写申请" } },
{ "id": "approve", "type": "snaker:task",
"properties": { "form": "leave-form", "assignee": "1",
"field": { "PERMISSION_f_reason": 1, "PERMISSION_f_days": 1, "PERMISSION_f_leaveType": 1 } },
"text": { "value": "上级审批" } },
{ "id": "end", "type": "snaker:end", "text": { "value": "结束" } }
],
"edges": [
{ "sourceNodeId": "start", "targetNodeId": "apply" },
{ "sourceNodeId": "apply", "targetNodeId": "approve" },
{ "sourceNodeId": "approve", "targetNodeId": "end" }
]
}
几个值得注意的点:
__schema__.columns就是表单,前端零代码渲染(第五节)。不用为"请假"写一个 Vue 表单组件。assignee: "applicant"= 发起人本人填;assignee: "1"= 字面 userId(这里指 admin)。还能写deptLeader、roleCode这种,由RyOrgUserProvider解析。field里的PERMISSION_f_xxx: 1= 该字段在审批节点只读(1=只读 / 2=可编辑 / 3=隐藏)。审批人能看到"请假事由"但改不了。
4.2 purchase:金额网关分流
采购申请,预算 5000 是分水岭:以下部门审批,以上总经理审批。核心就是一个决策节点 + 两条带表达式的边 :
{ "id": "decision1", "type": "snaker:decision",
"properties": { "expr": "#f_amount > 5000" },
"text": { "value": "金额>5000?" } }
两条出边的表达式:
{ "sourceNodeId": "decision1", "targetNodeId": "deptApprove",
"properties": { "expr": "#f_amount <= 5000" }, "text": { "value": "≤5000" } },
{ "sourceNodeId": "decision1", "targetNodeId": "gmApprove",
"properties": { "expr": "#f_amount > 5000" }, "text": { "value": ">5000" } }
这里有个真机踩出来的坑,值得单独说 :表达式里引用表单字段,必须写成
#f_amount——井号引用 +f_前缀,两个都不能少。
f_前缀:jeeflow-ui 发起时给所有表单字段统一加f_前缀提交(f_amount、f_reason),这是引擎和前端约定好的业务参数命名空间,避免和引擎内置参数撞名。#引用:jeeflow 默认的 SpEL 求值器按#key从流程变量里取值。
我第一次写成了裸的
amount <= 5000,发起 86000 的采购单,引擎直接报"decision节点无法确定下一步执行路线"——两条边都不命中。对着求值器源码才看清替换规则。写文章之前替你们踩了。
4.3 baoxiao:并行会签
报销申请,经理和财务并行会签 ,两人全部同意才过:
{ "id": "countersign", "type": "snaker:task",
"properties": {
"form": "baoxiao-form",
"assignee": "1,2",
"performType": "1",
"countersignType": "PARALLEL",
"field": { "PERMISSION_f_reason": 1, "PERMISSION_f_amount": 1,
"PERMISSION_f_invoiceCount": 1, "PERMISSION_f_expenseDate": 1 } },
"text": { "value": "经理财务会签" } }
三个属性缺一不可:assignee: "1,2" 逗号分隔多个审批人;performType: "1" 表示会签(每人一票);countersignType: "PARALLEL" 表示并行(任务同时产生)。改成 SEQUENTIAL 就是串行会签——按顺序逐个审。
4.4 overtime:最简审批 + 抄送
加班申请走"发起 → 上级审批"直线,抄送不占节点——发起时带ccActors 参数就行,抄送对象会在发起瞬间收到一条抄送记录(我的抄送页可见)。
四个流程,定义文件加起来不到 400 行 JSON,没有一个 Java 类、没有一个 Vue 组件 。
五、前端:一个宿主组件 + 九个薄页
若依前端是 RuoYi-Vue3(Vite + Vue3 + Element Plus + Pinia)。jeeflow-ui 的九个页面(工作台/发起/待办/已办/我发起的/抄送/流程定义/流程设计/委托)不是塞给你一坨独立布局,而是九个可以独立挂载的页面组件 ——正确姿势是把它们挂进若依自己的菜单体系,让"工作流"成为若依左侧菜单里的一级目录,跟"系统管理"平起平坐。
前端要写的东西分三块:main.js 两行、一个宿主组件、九个 8 行的薄页 。
5.1 main.js:引一次样式
import '@mldong/jeeflow-ui/dist/style.css'
就一行。但这一行有个故事——见第八节,这行在 1.0.0 时代写了会报错,因为包漏导出了这个文件,1.0.1 才修。
5.2 wf-host.vue:全集成唯一值得细看的文件
九个页面共用一个宿主组件,职责就四件事:注入若依的身份与接口、桥接权限、映射页间跳转 。
<template>
<JeeflowUiProvider :config="jfConfig">
<component :is="page" @goto="onGoto" />
</JeeflowUiProvider>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { JeeflowUiProvider } from '@mldong/jeeflow-ui'
import request from '@/utils/request'
import { getToken } from '@/utils/auth'
import useUserStore from '@/store/modules/user'
defineProps({
page: { type: [Object, Function], required: true },
})
const router = useRouter()
const userStore = useUserStore()
// 通配权限匹配(对齐若依后端 PatternMatchUtils.simpleMatch)
function globMatch(pattern, str) {
if (!pattern || !str) return false
if (pattern === '*:*:*') return true
if (pattern === str) return true
const re = '^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$'
return new RegExp(re).test(str)
}
// hasPermission(codes[]):任一命中即放行
function hasPermission(codes) {
const list = Array.isArray(codes) ? codes : [codes]
if (!list.length) return false
const mine = userStore.permissions || []
return list.some(code => mine.some(perm => globMatch(perm, code)))
}
// 宿主 adapter:把若依 REST 喂给流程中心(选人/字典/上传)
const adapters = {
getDict: async (code) => {
const res = await request.get(`/system/dict/data/type/${code}`)
return (res.data || []).map(d => ({ value: d.dictValue, label: d.dictLabel }))
},
upload: async (file) => {
const fd = new FormData()
fd.append('file', file)
const res = await request.post('/common/upload', fd,
{ headers: { 'Content-Type': 'multipart/form-data' } })
return res.url || res.fileName
},
listUsers: async (keyword) => {
const res = await request.get('/system/user/list',
{ params: { pageNum: 1, pageSize: 50, userName: keyword || undefined } })
return (res.rows || []).map(u => ({
userId: String(u.userId), realName: u.nickName, deptName: u.dept?.deptName || ''
}))
},
}
const jfConfig = {
baseUrl: import.meta.env.VITE_APP_BASE_API, // 经 vite proxy → :8080
getToken: () => getToken(), // 若依 JWT(js-cookie Admin-Token)
getOperator: () => (userStore.id != null ? String(userStore.id) : ''),
hasPermission,
adapters,
}
// 组件内页间跳转(key 由组件 emit)→ 若依路由
const routeMap = {
workbench: '/workflow/workbench',
apply: '/workflow/apply',
todo: '/workflow/todo',
done: '/workflow/done',
mine: '/workflow/mine',
cc: '/workflow/cc',
define: '/workflow/define',
design: '/workflow/design',
surrogate: '/workflow/surrogate',
}
function onGoto(key) {
if (routeMap[key]) router.push(routeMap[key])
}
</script>
JeeflowUiProvider 是个依赖注入壳 ——它不绑死任何宿主 REST,token、当前用户、权限判断、选人/字典/上传全是你从外面塞进去的函数。你塞若依的,它就打若依的接口;塞别的系统的,它就打别的。
三个细节:
- token :若依用
js-cookie存Admin-Token,JWT 格式Bearer <token>,和 jeeflow-ui 要的一致,直接getToken()透传。 - 权限 :若依前端
plugins/auth.js是精确匹配 ,没有通配;而菜单上我们把引擎细粒度码收成了通配按钮(wf:processDesign:*,第六节)。所以宿主里做了个globMatch桥接,让wf:processDesign:*能匹配wf:processDesign:listByType。 - 跳转 :九个页面之间会互相跳(比如工作台点"待办数"跳待办页),jeeflow-ui 只 emit 一个
goto事件带页面 key,落地的路由由宿主决定——这就是它能"寄生"在任何宿主菜单体系里的关键。
5.3 九个薄页:每个 8 行
每个页面就是"宿主 + 对应的 Page 组件",以"我的待办"为例:
<template>
<JfHost :page="JfTodoPage" />
</template>
<script setup>
import JfHost from '../wf-host.vue'
import { JfTodoPage } from '@mldong/jeeflow-ui'
</script>
其余八个(workbench/apply/done/mine/cc/define/design/surrogate)一模一样,只是 import 的 Page 组件不同。九个文件合计 70 行出头。
路由都不用手写 ——若依的动态路由会读 sys_menu 里 component 字段,指向 views/workflow/todo/index 就自动加载。所以挂菜单就是第六节那条 SQL 的事。
为什么前端这么少?因为表单也不归前端管——发起表单、审批回显全部由流程 JSON 的 __schema__ 驱动渲染,业务侧零表单代码 。
六、RBAC:1 个目录 + 9 个页面 + 5 个通配按钮
菜单结构完全按若依的习惯来:一级目录"工作流",下面九个菜单项各挂一个页面,权限码用引擎的细粒度码(页面级入口),再配 5 个 F 型通配按钮 把一族 action 的权限收口:
delete from sys_role_menu where menu_id between 2000 and 2099;
delete from sys_menu where menu_id between 2000 and 2099;
insert into sys_menu values('2000', '工作流', 0, 5, 'workflow', null, '', '', 1, 0, 'M', '0', '0', '', 'tree', 'admin', sysdate(), '', null, 'jeeflow 工作流');
insert into sys_menu values('2001', '工作台', 2000, 1, 'workbench', 'workflow/workbench/index', '', '', 1, 0, 'C', '0', '0', 'wf:workbench', 'dashboard', 'admin', sysdate(), '', null, 'jeeflow 工作台');
insert into sys_menu values('2002', '发起申请', 2000, 2, 'apply', 'workflow/apply/index', '', '', 1, 0, 'C', '0', '0', 'wf:processDesign:listByType', 'form', 'admin', sysdate(), '', null, 'jeeflow 发起申请');
insert into sys_menu values('2003', '我的待办', 2000, 3, 'todo', 'workflow/todo/index', '', '', 1, 0, 'C', '0', '0', 'wf:processTask:todoList', 'checkbox', 'admin', sysdate(), '', null, 'jeeflow 我的待办');
insert into sys_menu values('2004', '我的已办', 2000, 4, 'done', 'workflow/done/index', '', '', 1, 0, 'C', '0', '0', 'wf:processTask:doneList', 'time', 'admin', sysdate(), '', null, 'jeeflow 我的已办');
insert into sys_menu values('2005', '我发起的', 2000, 5, 'mine', 'workflow/mine/index', '', '', 1, 0, 'C', '0', '0', 'wf:processInstance:page', 'list', 'admin', sysdate(), '', null, 'jeeflow 我发起的');
insert into sys_menu values('2006', '我的抄送', 2000, 6, 'cc', 'workflow/cc/index', '', '', 1, 0, 'C', '0', '0', 'wf:processInstance:ccList', 'message', 'admin', sysdate(), '', null, 'jeeflow 我的抄送');
insert into sys_menu values('2007', '流程定义', 2000, 7, 'define', 'workflow/define/index', '', '', 1, 0, 'C', '0', '0', 'wf:processDefine:page', 'documentation', 'admin', sysdate(), '', null, 'jeeflow 流程定义');
insert into sys_menu values('2008', '流程设计', 2000, 8, 'design', 'workflow/design/index', '', '', 1, 0, 'C', '0', '0', 'wf:processDesign:page', 'build', 'admin', sysdate(), '', null, 'jeeflow 流程设计');
insert into sys_menu values('2009', '我的委托', 2000, 9, 'surrogate', 'workflow/surrogate/index', '', '', 1, 0, 'C', '0', '0', 'wf:processSurrogate:page', 'peoples', 'admin', sysdate(), '', null, 'jeeflow 我的委托');
insert into sys_menu values('2020', '流程发起', 2000, 20, '', '', '', '', 1, 0, 'F', '0', '0', 'wf:processDesign:*', '#', 'admin', sysdate(), '', null, '发起/设计/上架/发布');
insert into sys_menu values('2021', '任务办理', 2000, 21, '', '', '', '', 1, 0, 'F', '0', '0', 'wf:processTask:*', '#', 'admin', sysdate(), '', null, '待办/已办/执行/转办');
insert into sys_menu values('2022', '实例查看', 2000, 22, '', '', '', '', 1, 0, 'F', '0', '0', 'wf:processInstance:*', '#', 'admin', sysdate(), '', null, '实例分页/详情/抄送');
insert into sys_menu values('2023', '定义管理', 2000, 23, '', '', '', '', 1, 0, 'F', '0', '0', 'wf:processDefine:*', '#', 'admin', sysdate(), '', null, '定义增删改/版本');
insert into sys_menu values('2024', '流程委托', 2000, 24, '', '', '', '', 1, 0, 'F', '0', '0', 'wf:processSurrogate:*','#', 'admin', sysdate(), '', null, '委托增删改查');
insert into sys_role_menu values ('2','2000'),('2','2001'),('2','2002'),('2','2003'),
('2','2004'),('2','2005'),('2','2006'),('2','2007'),('2','2008'),('2','2009'),
('2','2020'),('2','2021'),('2','2022'),('2','2023'),('2','2024');
能这么收,靠的是若依 SecurityUtils.hasPermi 底层用的 PatternMatchUtils.simpleMatch——天然支持* 通配。引擎给 wf:processTask:todoList,按钮配 wf:processTask:*,一命中就放行。45 个 action 一个按钮都没漏。
真机验证了三种情况:
| 场景 | 账号 | 结果 |
|---|---|---|
| 正向:全流程 | admin(超级管理员) | 发起→待办→审批→记录 全通 |
| 正向:普通角色 | ry(common 角色) | 发起成功,走 admin 审批 |
| 负向:无权限 | noperm(无角色用户) | listByType / startAndExecute 返回 403,approvalRecord(免权限)放行 |
负向那条尤其关键——它证明了权限不是"登录就能全干",而是真落在若依 RBAC 上。
七、跑通实录
环境:若依 3.9.2(master,Boot 4.1.0 / JDK 17),MySQL 8(jeeflow 引擎 8 张 wf_* 表),Redis。
7.1 造一批真数据
空系统的截图是没有说服力的。我参考 jeeflow 商业演示站的数据语义,用脚本走真实 API(登录→发起→审批,一步没有跳过)造了一批演示数据——4 个流程、8 条实例 ,把审批流最常见的几种生命周期全覆盖:
| # | 流程 | 剧本 | 终态 |
|---|---|---|---|
| S1 | 请假 | ry 发起 → admin 同意 | ✅ 走完 |
| S2 | 请假 | admin 自己发起自己审 | 🔵 在途 |
| S3 | 报销 | ry 发起 → 经理+财务并行会签,两人全同意 | ✅ 走完 |
| S4 | 请假 | ry 发起 → admin 退回发起人 → ry 改理由重新提交 → admin 同意 | ✅ 走完 |
| S5 | 采购 | admin 发 4200 元(小额)→ 走部门审批 | 🔵 在途(ry 待办) |
| S6 | 采购 | ry 发 86000 元(大额)→ 走总经理审批 | 🔵 在途(admin 待办) |
| S7 | 加班 | ry 发起(抄送 admin )→ admin 拒绝 | ❌ 被拒 |
| S8 | 加班 | ry 发起 → admin 同意 | ✅ 走完 |
(S4 那条是故意演的:退回重提是审批系统里最考验数据完整性的场景,审批记录的时间线必须把"退回→重提→同意"全程记下来,见 7.3。)
7.2 接口层(curl):一条链走通
curl -s -X POST http://localhost:8080/login -H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123","code":"13","uuid":"<验证码uuid>"}'
curl -s -X POST http://localhost:8080/wf/processDefine/startAndExecute \
-H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \
-d '{"processDefineId":15328901147922432,"f_reason":"家里有事","f_days":2,"f_leaveType":"personal"}'
curl -s -X POST http://localhost:8080/wf/processTask/execute \
-H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \
-d '{"processTaskId":15328926470901760,"submitType":1,"tf_approvalComment":"同意,注意工作交接"}'
⚠️ 一个 Windows 上的坑:用
curl -d '{...含中文...}'直接传中文,MSYS 的命令行参数编码会把中文变?(库里落一堆问号)。改用--data-binary @file.json从文件读,就正常了。跟若依、跟 jeeflow 都没关系 ,是 MSYS 的事——但如果你在 Windows 上用 curl 测,会遇到,先排掉这个再怀疑框架。
7.3 界面层:一个完整的流程中心
登录进首页,左侧菜单多了一级目录"工作流",九个菜单项全部就位:
工作台 ——在办/办结/发起/抄送四个统计卡、近 7 日发起趋势、热点流程 Top5、流程状态分布。这不是占位图表,数据全部来自引擎真实统计接口:
发起申请 ——四张流程卡片,还在途的流程会标"N 条在办"徽标。点开"报销申请",__schema__ 表单自动渲染:事由、金额、发票张数、费用日期,必填校验齐全:
我的待办 ——admin 登录,待办里躺着"总经理审批"(S6 采购 86000 大额)和"上级审批"(S2 自发起的请假):
点"办理",抽屉上半屏是申请信息只读回显 ——采购内容"测试服务器 2 台"、预算 86000、供应商、期望日期,审批人看得到但改不了(字段权限 PERMISSION_f_xxx: 1 在起作用);下半屏是审批意见和动作:
动作不止"同意/拒绝"——六种审批动作开箱即用:同意、拒绝、退回上一步、退回发起人、跳转指定节点、转办:
审批记录 ——S4 那条"退回重提"的时间线,四步全程可溯:发起 → 上级审批(退回发起人,带退回意见)→ 发起人重新提交 → 上级审批(同意)。审批系统最怕"口说无凭",这条时间线就是凭据:
流程定义 ——四个流程、版本号、发布状态一目了然:
流程设计 ——每个流程的设计快照可进可视化设计器调整再发布(本文的流程 JSON 就是在这里管理的):
我发起的 ——ry 视角看自己发起的 5 条,在途/完成/拒绝状态分明:
我的抄送 ——S7 加班单发起时抄送了 admin,这里多了一条带"抄送"标记的记录:
从零到这一屏,后端一个依赖 + 一个薄壳 + 三个 Provider + 一个种子 Runner,前端一个宿主组件 + 九个 8 行薄页 + 一行样式引入 + 一条菜单 SQL。没有写过一个表单,没有写过一个列表页。
八、首次第三方集成,反哺了上游三个问题
说个背景:jeeflow 此前的所有集成(十几个框架)都是我自己维护的 mldong 系列,"自家的引擎配自家的框架",很多坑是暴露不出来的。这次若依集成是第一次以第三方视角 从零接一遍,一天之内挖出了三个问题——两个在 ui 组件包,当场修了发版;一个在引擎,记了 issue。
① ui 包漏导出样式文件。 @mldong/jeeflow-ui 的 dist/style.css 文件在包里,但 package.json 的 exports 字段没导出它——宿主 import '@mldong/jeeflow-ui/dist/style.css' 直接报 Missing specifier。Node 的 exports 语义下"包里有文件"和"允许被引用"是两回事。没有自己家的约定俗成兜底,第三方一接就现形。
② 办理抽屉画出重复的空表单。 审批人点"办理",抽屉下半屏本该只有审批意见框,却多画了一整份绑着空数据 的业务表单——因为任务表单组件找不到宿主注册的审批表单时,回落渲染了流程级 __schema__ 表单,而 __schema__ 早就该由上半屏"申请信息"按字段权限渲染了。自家集成里表单注册习惯一致,这问题从来没冒过头。
这两个都在 jeeflow-ui 源头修复,发了 1.0.1 (本文所有截图就是 1.0.1 的效果)。若依侧只需要 npm i @mldong/jeeflow-ui@^1.0.1。
③ 引擎的并行会签+退回组合缺陷。 并行会签节点上,一人执行"退回发起人"后,兄弟审批人的会签任务不自动作废 ——僵尸任务继续挂在别人待办里,还会卡死会签的"全员完成"判定,实例永远走不到结束。这个是引擎级问题,集成层修不了,已提 issue 记录在案,留待引擎侧修。演示数据里我把"退回重提"闭环放在单人节点上演(S4),绕开了这个组合——该有的场景一样不少。
这大概是第三方集成最大的价值:它是一台天然的模糊测试机 。所有"我们一直都这么用"的隐式约定,在第一个真正的外人面前全部现形。
九、这个集成,我删了
写到这里,集成是通的、验证是齐的。但得跟你说清楚定位,免得有人真拿它当产品用。
jeeflow 是一个"多语言联邦"的工作流引擎 ,我维护的下游有一长串:Java 的 boot2/3/4、FastAPI、NestJS、Laravel、Go 的 goframe/gin/hertz、Rust 的 salvo、C#、Python 的 Flask/Django……每个框架都有一套"基础 + 1 commit"的集成壳,全部进了我的验收矩阵,长期维护。
若依不在这个列表里。
上面这套若依集成,是我为了写这篇文章,在一个临时目录 里从零走一遍流程做的实践示例——它证明了"jeeflow 能接进任何主流框架,前后端各一两个文件"这件事本身。它不会 进我的维护矩阵,没有持续升级承诺,文章发完我会把临时环境清掉。
所以:
- 如果你用 jeeflow,去看我维护的 mldong 快速开发框架系列 (boot4 / FastAPI / goframe 等),那才是有长期保障的集成。
- 若依这套,当作"怎么给一个存量 Spring 项目接 jeeflow"的参考样本 ,代码逻辑是通的,照搬到你的若依项目能跑;但它不是成品,别直接当生产依赖。
一个第三方集成,最大的价值不是"我维护它",而是"它证明了接入成本有多低"。若依这套的价值就在最后——前后端加起来,真正要写的业务代码,就那么三个 Provider、一个宿主组件和九个薄页。
审批流这个东西,不该是若依用户"要不要上 Flowable"的难题。它应该是"加个依赖,配个菜单,半天跑通"的常规操作。
(全文完)