跳到主要内容
一天搭建请假审批系统:JeecgBoot 低代码实战手记 | 极客日志
Java java
一天搭建请假审批系统:JeecgBoot 低代码实战手记 通过一个完整的请假审批系统案例,记录了使用 JeecgBoot 低代码平台从建表、在线生成 CRUD、可视化配置审批流程到编写业务逻辑和统计报表的全过程。文章重点讨论了代码生成器的便利与局限、流程引擎的事务处理、缓存策略以及低代码模式下开发者角色的转变。
很多传统的后台管理系统,80% 的功能都在重复写条件查询、分页列表、表单提交这类代码。JeecgBoot 这类低代码平台,就是想把这部分工作自动化,让人把精力花在真正的业务逻辑上。
这篇文章记录我用 JeecgBoot 搭建一个请假审批系统的大致过程,重点聊聊实际开发中哪些地方用低代码工具省了事,哪些环节必须自己手写代码。
先搞清楚 JeecgBoot 在做什么
JeecgBoot 是前后端分离的 Java 平台,核心能力是通过在线表单设计器、代码生成器、流程设计器这些可视化工具,把增删改查和流程审批的开发效率拉上去。
在线表单设计器 用 JSON Schema 描述 UI,可以在界面上拖拽式配置字段和校验规则,设计器会自动渲染成 Vue 组件。比如请假类型字段的 JSON 配置长成这样:
{
"schemas" : [
{
"field" : "leaveType" ,
"label" : "请假类型" ,
"component" : "JRadioButton" ,
"componentProps" : {
"options" : [
{ "label" : "年假" , "value" : "1" } ,
{ "label" : "病假" , "value"
:
"2"
}
,
{
"label"
:
"事假"
,
"value"
:
"3"
}
,
{
"label"
:
"调休"
,
"value"
:
"4"
}
]
}
,
"rules"
:
[
{
"required"
:
true
,
"message"
:
"请选择请假类型"
}
]
}
,
{
"field"
:
"startTime"
,
"label"
:
"开始时间"
,
"component"
:
"JDatePicker"
,
"componentProps"
:
{
"showTime"
:
true
,
"format"
:
"YYYY-MM-DD HH:mm:ss"
}
,
"rules"
:
[
{
"required"
:
true
,
"message"
:
"请选择开始时间"
}
]
}
]
}
代码生成器 是'一天搭建'的底气来源。它读取数据库表结构,用 Velocity 模板生成 Controller、Service、Mapper、Vue 页面、SQL 脚本等全套文件。生成后你可以直接跑起来一个带分页、查询、表单校验的 CRUD 模块。不过,模板生成的代码比较死板,复杂业务逻辑还得自己写。
流程设计器 基于 Activiti,可以拖拽画 BPMN 流程图,把节点和动态表单、权限配置勾连起来。比如部门经理审批节点,最终会生成一段 XML 配置(下面会展示)。
从头搭建请假审批系统
数据库设计 请假业务需要几张表:请假主表、审批记录表、请假类型字典表。建表 SQL 直接贴在下面,比较简单:
CREATE TABLE `sys_leave` (
`id` varchar (32 ) NOT NULL COMMENT '主键 ID' ,
`user_id` varchar (32 ) NOT NULL COMMENT '申请人 ID' ,
`user_name` varchar (50 ) NOT NULL COMMENT '申请人姓名' ,
`dept_id` varchar (32 ) DEFAULT NULL COMMENT '部门 ID' ,
`dept_name` varchar (50 ) DEFAULT NULL COMMENT '部门名称' ,
`leave_type` varchar (2 ) NOT NULL COMMENT '请假类型 1 年假 2 病假 3 事假 4 调休' ,
`start_time` datetime NOT NULL COMMENT '开始时间' ,
`end_time` datetime NOT NULL COMMENT '结束时间' ,
`leave_days` decimal (10 ,1 ) NOT NULL COMMENT '请假天数' ,
`reason` varchar (500 ) NOT NULL COMMENT '请假事由' ,
`emergency_contact` varchar (50 ) DEFAULT NULL COMMENT '紧急联系人' ,
`emergency_phone` varchar (20 ) DEFAULT NULL COMMENT '紧急联系电话' ,
`attachment` varchar (500 ) DEFAULT NULL COMMENT '附件' ,
`status` varchar (2 ) NOT NULL DEFAULT '0' COMMENT '状态 0 草稿 1 审批中 2 已批准 3 已拒绝 4 已撤销' ,
`process_instance_id` varchar (64 ) DEFAULT NULL COMMENT '流程实例 ID' ,
`create_time` datetime DEFAULT NULL COMMENT '创建时间' ,
`update_time` datetime DEFAULT NULL COMMENT '更新时间' ,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_status` (`status`),
KEY `idx_create_time` (`create_time`)
) ENGINE= InnoDB DEFAULT CHARSET= utf8mb4 COMMENT= '请假申请表' ;
CREATE TABLE `sys_leave_approval` (
`id` varchar (32 ) NOT NULL COMMENT '主键 ID' ,
`leave_id` varchar (32 ) NOT NULL COMMENT '请假 ID' ,
`approval_user_id` varchar (32 ) NOT NULL COMMENT '审批人 ID' ,
`approval_user_name` varchar (50 ) NOT NULL COMMENT '审批人姓名' ,
`approval_result` varchar (2 ) NOT NULL COMMENT '审批结果 1 通过 2 拒绝' ,
`approval_comment` varchar (500 ) DEFAULT NULL COMMENT '审批意见' ,
`approval_time` datetime NOT NULL COMMENT '审批时间' ,
`approval_node` varchar (50 ) NOT NULL COMMENT '审批节点' ,
`create_time` datetime DEFAULT NULL COMMENT '创建时间' ,
PRIMARY KEY (`id`),
KEY `idx_leave_id` (`leave_id`),
KEY `idx_approval_user_id` (`approval_user_id`)
) ENGINE= InnoDB DEFAULT CHARSET= utf8mb4 COMMENT= '请假审批记录表' ;
CREATE TABLE `sys_leave_type` (
`id` varchar (32 ) NOT NULL COMMENT '主键 ID' ,
`type_code` varchar (20 ) NOT NULL COMMENT '类型编码' ,
`type_name` varchar (50 ) NOT NULL COMMENT '类型名称' ,
`max_days` int (11 ) DEFAULT NULL COMMENT '最大天数' ,
`need_approval` tinyint(1 ) DEFAULT '1' COMMENT '是否需要审批' ,
`description` varchar (200 ) DEFAULT NULL COMMENT '描述' ,
`sort_no` int (11 ) DEFAULT '0' COMMENT '排序号' ,
`status` varchar (2 ) DEFAULT '1' COMMENT '状态 1 启用 0 停用' ,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_type_code` (`type_code`)
) ENGINE= InnoDB DEFAULT CHARSET= utf8mb4 COMMENT= '请假类型表' ;
用在线开发生成 CRUD 骨架 建完表后,在 JeecgBoot 的'在线开发'模块直接导入 sys_leave 表。系统会解析字段类型,自动生成一份默认的页面配置。然后我调整每个字段的表单组件和校验规则:leave_type 用下拉框并关联字典,start_time 和 end_time 用时间选择器,reason 用 textarea 并限制 500 字符。
{
"field" : "leave_type" ,
"title" : "请假类型" ,
"component" : "JSearchSelect" ,
"options" : [
{ "text" : "年假" , "value" : "1" } ,
{ "text" : "病假" , "value" : "2" } ,
{ "text" : "事假" , "value" : "3" } ,
{ "text" : "调休" , "value" : "4" }
] ,
"dictCode" : "leave_type" ,
"validateRules" : [ { "required" : true } ] ,
"tableShow" : true ,
"queryShow" : true
}
点击'生成代码',下载压缩包,里面包含后端 Java 代码(实体、Mapper、Service、Controller)、前端 Vue 代码以及菜单权限脚本。解压后按说明放到项目目录,重启服务,一个带增删改查和查询功能的请假列表页面就出来了。
这一步生成的东西是基础骨架,够用但不够灵活。后面我会在生成的代码上补充业务逻辑。
设计请假审批流程
请假天数 ≤3 天:部门经理审批后结束
3~7 天:部门经理 → 人事审批后结束
>7 天:部门经理 → 人事 → 总经理
在流程设计器中拖拽开始、网关、用户任务、结束节点,连线并设置条件。条件表达式写在网关出线的 <conditionExpression> 里,类似 ${leave.leaveDays <= 3}。每个审批节点绑定一个表单,用于填写审批意见。
<userTask id ="deptManagerApprove" name ="部门经理审批" >
<extensionElements >
<activiti:formProperty id ="approvalResult" name ="审批结果" type ="enum" required ="true" >
<activiti:value id ="1" name ="通过" />
<activiti:value id ="2" name ="拒绝" />
</activiti:formProperty >
<activiti:formProperty id ="approvalComment" name ="审批意见" type ="string" />
<activiti:taskListener event ="create" class ="com.jeecg.listener.LeaveTaskListener" />
</extensionElements >
<documentation > 部门经理审批请假申请</documentation >
</userTask >
<sequenceFlow id ="flow2" sourceRef ="judgeDays" targetRef ="deptManagerOnly" >
<conditionExpression xsi:type ="tFormalExpression" > <![CDATA[${leave.leaveDays <= 3}]]></conditionExpression >
</sequenceFlow >
<sequenceFlow id ="flow3" sourceRef ="judgeDays" targetRef ="deptManagerFirst" >
<conditionExpression xsi:type ="tFormalExpression" > <![CDATA[${leave.leaveDays > 3 && leave.leaveDays <= 7}]]></conditionExpression >
</sequenceFlow >
编写关键业务逻辑 低代码能解决的是一般性结构,但请假天数自动计算、启动流程、审批结果处理这些必须自己写。我在 SysLeaveServiceImpl 里实现了保存请假时自动计算天数并启动流程:
@Service
public class SysLeaveServiceImpl extends ServiceImpl <SysLeaveMapper, SysLeave> implements ISysLeaveService {
@Override
@Transactional(rollbackFor = Exception.class)
public boolean saveLeave (SysLeave sysLeave) {
sysLeave.setLeaveDays(calculateLeaveDays(sysLeave.getStartTime(), sysLeave.getEndTime()));
if (sysLeave.getStatus() == null ) {
sysLeave.setStatus("0" );
}
LoginUser loginUser = (LoginUser) SecurityUtils.getSubject().getPrincipal();
sysLeave.setUserId(loginUser.getId());
sysLeave.setUserName(loginUser.getRealname());
boolean result = this .save(sysLeave);
if ("1" .equals(sysLeave.getStatus())) {
startLeaveProcess(sysLeave);
}
return result;
}
private BigDecimal calculateLeaveDays (Date startTime, Date endTime) {
long diff = endTime.getTime() - startTime.getTime();
double days = (double ) diff / (1000 * 60 * 60 * 24 );
if (days % 1 > 0 ) {
days = Math.ceil(days * 2 ) / 2 ;
}
return BigDecimal.valueOf(days);
}
private void startLeaveProcess (SysLeave leave) {
try {
ProcessDefinition processDefinition = repositoryService
.createProcessDefinitionQuery()
.processDefinitionKey("leave_approval_process" )
.latestVersion()
.singleResult();
Map<String, Object> variables = new HashMap <>();
variables.put("leave" , leave);
variables.put("applicant" , leave.getUserId());
variables.put("deptManager" , getDeptManager(leave.getDeptId()));
ProcessInstance processInstance = runtimeService.startProcessInstanceById(
processDefinition.getId(), leave.getId(), variables);
leave.setProcessInstanceId(processInstance.getId());
this .updateById(leave);
sendProcessStartNotification(leave);
} catch (Exception e) {
log.error("启动请假流程失败" , e);
throw new JeecgBootException ("启动审批流程失败" );
}
}
}
审批处理单独写了一个 LeaveApprovalService,主要做三件事:保存审批记录、调用 Activiti 完成任务、当流程实例结束(即 processInstance 查询为 null)时更新请假单最终状态。
@Component
public class LeaveApprovalService {
@Autowired
private RuntimeService runtimeService;
@Autowired
private TaskService taskService;
@Autowired
private ISysLeaveService leaveService;
@Autowired
private ISysLeaveApprovalService leaveApprovalService;
@Transactional(rollbackFor = Exception.class)
public void handleApprovalTask (String taskId, String approvalResult, String comment, String userId) {
Task task = taskService.createTaskQuery().taskId(taskId).singleResult();
if (task == null ) {
throw new JeecgBootException ("任务不存在或已完成" );
}
String leaveId = task.getBusinessKey();
SysLeave leave = leaveService.getById(leaveId);
SysLeaveApproval approval = new SysLeaveApproval ();
approval.setLeaveId(leaveId);
approval.setApprovalUserId(userId);
approval.setApprovalResult(approvalResult);
approval.setApprovalComment(comment);
approval.setApprovalNode(task.getTaskDefinitionKey());
approval.setApprovalTime(new Date ());
leaveApprovalService.save(approval);
Map<String, Object> variables = new HashMap <>();
variables.put("approvalResult" , approvalResult);
variables.put("approvalComment" , comment);
variables.put("approvalUser" , userId);
taskService.complete(taskId, variables);
ProcessInstance processInstance = runtimeService
.createProcessInstanceQuery()
.processInstanceId(task.getProcessInstanceId())
.singleResult();
if (processInstance == null ) {
String finalStatus = "1" .equals(approvalResult) ? "2" : "3" ;
leave.setStatus(finalStatus);
leaveService.updateById(leave);
sendFinalNotification(leave, finalStatus);
}
}
}
自定义代码的量并不大,但都是流程和规则的核心部分,这部分低代码工具是帮不上忙的。
加个统计报表 为了直观看到请假数据,我用报表设计器配了一个统计图表。数据源为已批准的请假记录,按月、请假类型汇总次数和天数:
SELECT DATE_FORMAT(start_time,'%Y-%m' ) as month , leave_type,
COUNT (* ) as apply_count, SUM (leave_days) as total_days, AVG (leave_days) as avg_days
FROM sys_leave
WHERE status= '2'
GROUP BY DATE_FORMAT(start_time,'%Y-%m' ), leave_type
ORDER BY month DESC
前端用 AntV/G2 画出柱状图和饼图,页面模板如下(只看结构,数据处理方法略):
<template>
<div>
<a-card :bordered="false">
<a-form layout="inline" @keyup.enter.native="searchQuery">
<a-row :gutter="24">
<a-col :md="6" :sm="8">
<a-form-item label="统计月份">
<a-month-picker v-model="queryParam.month" format="YYYY-MM" placeholder="请选择月份" />
</a-form-item>
</a-col>
<a-col :md="6" :sm="8">
<a-form-item label="部门">
<j-select-depart v-model="queryParam.departId" :multi="false" />
</a-form-item>
</a-col>
<a-col :md="6" :sm="8">
<span>
<a-button type="primary" @click="searchQuery">查询</a-button>
<a-button @click="searchReset">重置</a-button>
</span>
</a-col>
</a-row>
</a-form>
<a-row :gutter="24">
<a-col :span="12">
<a-card title="月度请假统计" size="small">
<div id="monthChart"></div>
</a-card>
</a-col>
<a-col :span="12">
<a-card title="请假类型分布" size="small">
<div id="typeChart"></div>
</a-card>
</a-col>
</a-row>
<a-table ref="table" size="middle" :columns="columns" :dataSource="dataSource" :pagination="ipagination" @change="handleTableChange" rowKey="id">
</a-table>
</a-card>
</div>
</template>
<script>
import { getLeaveStatistics } from '@/api/system/leave'
import { Chart } from '@antv/g2'
export default {
name: 'LeaveStatistics',
data() {
return {
queryParam: {},
ipagination: { current: 1, pageSize: 10, total: 0 },
columns: [
{ title: '月份', dataIndex: 'month' },
{ title: '请假类型', dataIndex: 'leaveType' },
{ title: '申请次数', dataIndex: 'applyCount' },
{ title: '总天数', dataIndex: 'totalDays' },
{ title: '平均天数', dataIndex: 'avgDays' }
],
dataSource: [],
monthChart: null,
typeChart: null
}
},
mounted() {
this.loadData()
this.initCharts()
},
methods: {
loadData() {
const params = { ...this.queryParam, pageNo: this.ipagination.current, pageSize: this.ipagination.pageSize }
getLeaveStatistics(params).then(res => {
if (res.success) {
this.dataSource = res.result.records || []
this.ipagination.total = res.result.total
this.updateCharts(res.result.records)
}
})
},
initCharts() {
this.monthChart = new Chart({ container: 'monthChart', autoFit: true, height: 300 })
this.typeChart = new Chart({ container: 'typeChart', autoFit: true, height: 300 })
},
updateCharts(data) {
const monthData = this.processMonthData(data)
this.monthChart.data(monthData)
this.monthChart.scale({ month: { alias: '月份' }, value: { alias: '请假天数' }, type: { alias: '请假类型' } })
this.monthChart.interval().position('month*value').color('type')
this.monthChart.render()
const typeData = this.processTypeData(data)
this.typeChart.data(typeData)
this.typeChart.coordinate('theta', { radius: 0.75 })
this.typeChart.interval().position('value').color('type')
.label('type', { content: (data) => `${data.type}: ${data.value}` })
this.typeChart.render()
},
processMonthData(data) { return [] },
processTypeData(data) { return [] }
}
}
</script>
我个人倾向把图表逻辑封装成独立组件,但为了演示快速集成,直接写在页面里也够用。
架构层面的几点认识 JeecgBoot 的前端依托 Ant Design Vue,封装了 JDate、JSelectUser、JEditor 等业务组件。它的表单渲染引擎通过解析 JSON Schema 动态渲染组件,降低了前端重复开发量。后端统一用 Result<T> 封装响应,并配全局异常处理,这部分设计规整,改起来不会散落各处。
代码生成器 生成的 SysLeaveMapper.xml 只有基础 SQL,复杂统计或者多表关联必须自己写,不能指望全自动。
流程引擎 集成 Activiti 后,事务边界要处理好,比如请假保存和流程启动必须在同一个事务里,否则数据不一致。
缓存 如果加上 Redis,更新请假状态后要同步清缓存,不然审批结果半天不显示。
CREATE INDEX idx_leave_user_status ON sys_leave(user_id, status, create_time);
CREATE INDEX idx_leave_dept_time ON sys_leave(dept_id, create_time);
缓存这块,我简单写了个 LeaveCacheService,用 Redis 存用户统计,失效时主动清除:
@Service
@Slf4j
public class LeaveCacheService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ISysLeaveService leaveService;
public UserLeaveStats getUserLeaveStats (String userId) {
String cacheKey = String.format("leave:stats:user:%s" , userId);
UserLeaveStats stats = (UserLeaveStats) redisTemplate.opsForValue().get(cacheKey);
if (stats != null ) return stats;
stats = leaveService.calculateUserLeaveStats(userId);
if (stats != null ) {
redisTemplate.opsForValue().set(cacheKey, stats, 1 , TimeUnit.HOURS);
}
return stats;
}
public void clearUserLeaveCache (String userId) {
String cacheKey = String.format("leave:stats:user:%s" , userId);
redisTemplate.delete(cacheKey);
Set<String> keys = redisTemplate.keys(String.format("leave:list:user:%s:*" , userId));
if (keys != null && !keys.isEmpty()) redisTemplate.delete(keys);
}
}
开发者角色在变 使用低代码平台之后,一个明显的变化是:以前需要半天写的列表页,现在几分钟就出来了。但核心业务规则、审批流转、性能优化这些依然要靠编码能力。所以开发者不再是流水线上的 CRUD 工人,而是更偏业务理解和架构设计。
现实中,JeecgBoot 这类工具最适合内部管理系统、审批流、报表分析这些领域。如果是高并发交易或者底层算法,它基本使不上劲。所以别被'低代码取代程序员'的说法带偏,该学的数据库优化、事务管理、分布式基础,一样都不能少。
总结一下这次实践:用 JeecgBoot 把请假系统的主体功能在一天内跑通是可行的,但前提是你得清楚哪些部分让平台做,哪些必须自己写。把劲使在刀刃上,而不是跟代码生成器较劲。
相关免费在线工具 Keycode 信息 查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
Escape 与 Native 编解码 JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
JavaScript / HTML 格式化 使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
JavaScript 压缩与混淆 Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online