排班系统这个项目,我前后搭过两套。第一套还是用Freemarker写页面、后端一把梭的传统单体,第二套才完全切到SpringBoot+Vue3+MyBatis前后端分离。说实话,医护人员排班这块业务,比普通的企业排班复杂得多——有夜班轮转、有职称约束、有工时统计,还有换班审批这些细碎流程。用这套技术栈重新做一遍之后,最大的感受就是:系统的边界清晰了,排班规则的扩展性也出来了。
这篇就把整条链路拆开讲。从为什么选SpringBoot+Vue3+MyBatis+MySQL这套组合,到数据库怎么设计、自动排班算法怎么落地、Vue3前端日历组件怎么和接口对接,最后是实际开发中踩过的坑和排查思路。适合正在做毕设、接外包,或者想在企业内部快速搭建排班系统的人参考。项目源码是前后端分离结构,后端负责排班引擎和业务接口,前端负责交互展示,数据库用MySQL存人员、科室、排班记录这些核心数据。
1. 项目整体设计与思路拆解
1.1 排班系统到底在解决什么问题
医护排班和普通公司排班的区别很大。公司排班最多是“谁在哪个时间段值班”,但医护场景里,排班要同时满足几个硬性条件:每个科室每天每个班次必须有对应职称的人值守,比如夜班至少要有一个主治以上;同一个护士不能连续两晚夜班之后又立刻排白班;每个月总工时不能超过规定上限;还要考虑年假、调休、产假这些请假类型对班次的影响。
最早用手工Excel表排班的时候,护士长每周五下午都要花三四个小时,拿着一张纸质表对着人员名单来回画。人少还好,科室超过15个人,排列组合一下子就复杂了,经常出现“排完班发现某天夜班没人”或者“某个人被排了连续三个夜班”的情况。所以做这个系统的核心目标很明确:把排班规则固化到代码里,用自动排班算法生成初稿,再让护士长在系统里做微调,最后发布排班表。
1.2 系统的角色划分与功能边界
系统的角色我分成了四类:系统管理员、科室护士长、医生护士、科室主任。不同角色看到的功能入口差异很大,这也是前后端分离模式真正发挥价值的地方——前端按角色动态渲染菜单,后端按角色做接口权限控制。
- 系统管理员:维护科室、职称、账号、系统参数。
- 护士长:排班规则配置、自动排班、手工调班、发布确认。
- 医生护士:查看个人班表、提交换班申请、发起请假申请。
- 科室主任:查看全科排班总览、统计工时、审批特殊请假。
这个权限模型决定了接口的设计方式。后端用SpringBoot拦截器加用户角色判断,前端路由守卫控制页面访问。实际项目中我建议用Sa-Token或者Spring Security做权限,但如果你只是想快速跑通,用拦截器加一个@RequireRole自定义注解就够了,逻辑清晰还不用引入太重的依赖。
1.3 为什么是SpringBoot+Vue3+MyBatis而不是其他组合
选这套技术栈的原因其实很务实。SpringBoot是目前Java后端里开发效率最高的框架之一,内置Tomcat、自动配置、starter机制,少写大量配置代码,尤其是版本用的是2.7.x,稳定而且资料多。Vue3配合Vite的开发体验比Vue2+Webpack强很多,热更新速度快,Composition API写复杂交互组件的时候,代码组织更清晰。MyBatis则是为了SQL可控——排班查询往往涉及多表关联、动态条件、复杂统计,MyBatis的XML里写SQL,比JPA自动生成的SQL更直观、更好调优。
MySQL作为存储层是最稳妥的选择。排班数据量虽然不大,但是查询模式多样,MySQL的索引机制完全能覆盖,而且运维成本低,不管你是部署在云服务器还是本地,都不用操太多心。整体组合是“中间业务用Java扛、前端交互用Vue拖、SQL自己掌控”,这套组合对于中小型业务系统来说,性价比确实很高。
2. 数据库设计:排班系统的核心模型
2.1 从业务需求推导数据表结构
数据库设计是排班系统里最需要花时间琢磨的环节。我第一版设计的时候偷懒,只建了一张schedule表,把排班信息全塞进去,结果后面加规则、加班次统计的时候,表结构改得痛不欲生。第二版老老实实把表拆分成了这样:
- user表:用户基础信息,包括姓名、工号、职称、所属科室、角色。
- department表:科室信息。
- shift表:班次字典表,定义白班、小夜、大夜、休息、节假日班等。
- schedule_rule表:排班规则配置表,每个科室可以配置周期、每班人数、约束条件。
- schedule_record表:排班结果表,记录某人在某天属于哪个班次。
- shift_change表:换班申请表。
- leave_request表:请假申请表。
以schedule_record表为例,核心字段是:id、user_id、department_id、work_date、shift_id、create_time。唯一索引建在(user_id, work_date)上,防止同一天对同一个人排两次班。这是排班系统的底线约束,必须在数据库层面锁死,不能只靠前端校验。
CREATE TABLE schedule_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT '排班人员ID', department_id BIGINT NOT NULL COMMENT '科室ID', work_date DATE NOT NULL COMMENT '排班日期', shift_id BIGINT NOT NULL COMMENT '班次ID', status TINYINT DEFAULT 0 COMMENT '0草稿 1已发布', create_by VARCHAR(50), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_date (user_id, work_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;2.2 班次字典与规则配置表的设计思路
班次字典表最好不要写死。不同医院的班次叫法不一样,有的医院有“白班、小夜、大夜、休息”,有的还有“责任班、辅助班、预约班、门诊班”。如果你把班次写死在枚举里,每次调整都要改代码重新部署。所以我把班次做成字典表,由管理员在系统里维护。
schedule_rule表是排班系统里最灵活的配置点。它设计成规则模板的结构,每个科室一行规则,记录了该科室的排班周期、周期内各天需要的班次及人数。举个例子,某科室规则是这样的:周期为7天,周一到周五每天需要2个白班、1个小夜、1个大夜,周末需要1个白班、1个小夜、1个大夜。这个规则在表里用JSON字段或者子表方式存储都可以。我建议用规则明细子表,因为后续做算法读取的时候,SQL查询比解析JSON更直接。
CREATE TABLE schedule_rule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, department_id BIGINT NOT NULL, rule_type VARCHAR(20) DEFAULT 'weekly' COMMENT '周排班/月排班', cycle_days INT DEFAULT 7 COMMENT '排班周期天数', effective_date DATE COMMENT '生效日期', status TINYINT DEFAULT 1, remark VARCHAR(255) );2.3 索引设计与事务边界要注意的点
排班系统查询最多就是“按科室+日期范围查排班表”和“按人员查当月班表”。这两个查询要单独建索引。(department_id, work_date)和(user_id, work_date)是两个最核心的联合索引。实际开发中我发现,如果某个科室排班记录超过10万条之后,不加索引的慢查询会直接拖垮接口,尤其是日历组件一次要拉整月的排班数据。
事务边界主要在换班审批和请假流程。换班涉及两条schedule_record记录的修改,必须放在同一个事务里,避免出现一个人被换到两个班次的脏数据。排班自动生成的时候,建议事务粒度控制在“每次只生成一个科室的一轮班表”,如果整个科室月初批量生成50个人的整月班表,一个事务锁太多记录,并发操作时会出问题。
3. 自动排班算法:从理论到Java实现
3.1 规则建模:把护士长的经验翻译成代码
排班算法是整个系统的灵魂。要实现自动排班,第一步就是把你脑子里的排班规则翻译成计算机能理解的形式。这里我用的是约束满足的思路,把排班需求拆成硬约束和软约束。
硬约束是必须满足的,违反就不生成:
- 每天每个班次的在岗人数必须达标。
- 同一个人同一天只能排一个班次。
- 一个人连续排班天数不能超过6天。
- 大夜班之后必须安排至少48小时休息。
软约束是尽量满足的,用于优化排班质量:
- 同一个人尽量不连续上两个大夜班。
- 每个护士一个月内的夜班次数尽量均等。
- 周末和法定节假日的班次尽量轮换分配。
这种建模方式在代码里不需要复杂的人工智能算法,用轮转加上回溯就够了。先把人员列表按工号排序,维护一个“上次夜班日期”数组,遍历每一天的时候,优先选出满足硬约束的人,再在候选集合里按照“夜班距离天数最长优先”的规则选人,这样就能自动实现夜班次数均等的效果。
3.2 Java实现:基于轮转的排班引擎
实际写排班引擎的时候,我采用的是“按天逐日生成+周轮转偏移”的方式。核心代码如下:
public List<ScheduleRecord> generate(List<User> users, Department dept, List<Shift> shifts, LocalDate startDate, int days) { List<ScheduleRecord> result = new ArrayList<>(); Map<Long, LocalDate> lastNightMap = new HashMap<>(); for (int i = 0; i < days; i++) { LocalDate date = startDate.plusDays(i); Map<String, Integer> demand = getDemand(dept.getId(), date); for (Shift shift : shifts) { int needCount = demand.getOrDefault(shift.getCode(), 0); if (needCount == 0) continue; List<User> candidates = users.stream() .filter(u -> !u.isLeave(date)) .filter(u -> !isOnDuty(u, date, result)) .filter(u -> !violatesNightRest(u, date, lastNightMap, shift)) .collect(Collectors.toList()); candidates.sort((a, b) -> compareByLastNight(b, a, lastNightMap)); List<User> selected = candidates.subList(0, Math.min(needCount, candidates.size())); for (User user : selected) { ScheduleRecord record = new ScheduleRecord(); record.setUserId(user.getId()); record.setWorkDate(date); record.setShiftId(shift.getId()); record.setDeptId(dept.getId()); result.add(record); if (shift.isNight()) lastNightMap.put(user.getId(), date); } } } return result; }这段代码的核心逻辑就是三层过滤加一层排序。先过滤掉请假的人、当天已经排了班的人、夜班后休息不够的人,然后对剩余候选人按“离上次夜班间隔时间最长优先”排序,选前N个。这样既满足硬约束,又不至于出现某个人被连续安排多个夜班的情况。
3.3 冲突检测与手工调整实现
自动排班只能生成初稿,实际场景中护士长几乎总要手工微调。所以系统必须提供一个“冲突检测”功能,在护士长调整完班次后,马上提示哪些调整违反了硬约束。
我在后端实现了一个validate接口,接收调整后的批量排班数据,逐条检查硬约束,返回冲突列表。前端收到冲突列表后,直接在日历上把冲突格子标红,鼠标悬停还能看到具体冲突原因。这个交互在Vue3里实现很顺手,用computed属性监听排班数据变化,动态计算每个格子的样式类就行。
冲突检测器本质上是把排班引擎里的约束逻辑抽出来,形成一个独立的校验类。这里要特别注意:手工调整后的校验和自动生成时的约束判断必须复用同一套代码,不要写两遍。不然会出现“自动排班能通过,手工调完却校验不过”或者反过来的一致性问题。
4. 后端核心实现:SpringBoot接口与MyBatis落地
4.1 项目分层与核心接口设计
后端项目我用的是经典四层结构:Controller、Service、Mapper、Entity。Controller只做参数接收和结果封装,不写任何业务逻辑。Service层放排班算法、审批流这些业务逻辑。Mapper层只负责和数据库交互。
接口设计遵循REST风格,核心接口有以下几个:
| 接口 | 方法 | 说明 |
|---|---|---|
| /api/department/{id}/schedule?start=&end= | GET | 查询某科室的排班表 |
| /api/schedule/generate | POST | 触发自动排班 |
| /api/schedule/adjust | PUT | 手工调整班次(带校验) |
| /api/schedule/publish | PUT | 发布排班表 |
| /api/shift-change/apply | POST | 提交换班申请 |
| /api/shift-change/approve | PUT | 审核换班申请 |
| /api/stats/workload?userId=&month= | GET | 工时统计 |
统一返回体我用的是Result对象,包含code、message、data三个字段。这样做的好处是前端axios拦截器可以统一处理错误码,比如code为401时自动跳转登录页,code为500时弹出错误提示。
4.2 MyBatis动态SQL实战:条件查询与批量插入
排班查询接口是最典型的“多条件动态查询”,参数可能有科室、日期范围、班次类型、人员姓名、角色。用MyBatis动态SQL处理这种场景非常合适。
<select id="selectScheduleRecords" resultType="com.example.entity.ScheduleRecord"> SELECT sr.*, u.name AS userName, u.rank AS userRank, s.name AS shiftName, s.start_time AS shiftStartTime FROM schedule_record sr LEFT JOIN user u ON sr.user_id = u.id LEFT JOIN shift s ON sr.shift_id = s.id <where> <if test="departmentId != null"> AND sr.department_id = #{departmentId} </if> <if test="userId != null"> AND sr.user_id = #{userId} </if> <if test="startDate != null"> AND sr.work_date >= #{startDate} </if> <if test="endDate != null"> AND sr.work_date <= #{endDate} </if> <if test="shiftId != null"> AND sr.shift_id = #{shiftId} </if> </where> ORDER BY sr.work_date, sr.shift_id </select>批量插入排班记录的时候,MyBatis的batch要小心一点。我建议用executeBatch的方式而不是foreach标签一条条insert,因为后者拼接出来的SQL特别长,MySQL默认的max_allowed_packet可能不够用。实际代码层面,在SpringBoot里注入SqlSessionTemplate,强制走batch executor:
@Autowired private SqlSessionTemplate sqlSessionTemplate; public void batchInsert(List<ScheduleRecord> records) { SqlSession session = sqlSessionTemplate.getSqlSessionFactory() .openSession(ExecutorType.BATCH, false); try { ScheduleRecordMapper mapper = session.getMapper(ScheduleRecordMapper.class); for (ScheduleRecord record : records) { mapper.insert(record); } session.commit(); } finally { session.close(); } }4.3 权限拦截与日志记录的实现细节
权限拦截我用的是SpringBoot的HandlerInterceptor。定义一个注解@RequireRole,标注在Controller方法上,拦截器里从请求头中取出token,解析用户信息,判断角色是否有权限访问对应接口。
日志方面,我用的AOP切面统一记录操作日志,切点定义在Controller层,记录请求参数、返回结果、耗时。特别是排班发布和换班审批这两个敏感操作,一定要记录操作人和操作时间。不然出了事没法追溯。
一个容易忽略的细节是:排班数据的查询接口要支持“发布前草稿”和“发布后正式”数据的分开查询。草稿状态的数据只对护士长和管理员可见,普通护士看不到;发布后所有人都能看到。这个状态区分在查询SQL里要加条件判断,不要把所有排班数据一股脑返回给前端,让前端自己过滤。
5. 前端Vue3实现:排班日历与交互细节
5.1 Vite初始化与项目结构组织
前端我用Vite初始化Vue3项目,相比vue-cli,Vite的依赖预构建和热更新速度快很多。项目结构上,views按页面划分,components放可复用的排班日历、人员选择器等组件,stores放Pinia状态管理,api目录集中管理所有后端接口请求。
npm create vite@latest nurse-schedule-fe -- --template vue cd nurse-schedule-fe npm install npm install pinia vue-router axios element-plusElement Plus是这套系统的UI基石。排班页面里用到的日历组件、下拉选择器、弹窗、表单,Element Plus都有现成的,稍微改改样式就能用。日历这一块我用的是FullCalendar的Vue3适配,因为Element Plus本身没有完整的日历年视图组件,而排班表的周视图、月视图切换,FullCalendar自带这个能力。
5.2 排班日历组件:数据绑定与视图渲染
排班日历组件的核心是把后端返回的排班记录数组映射成日历上的单元格。我设计的数据结构是Map,键是日期字符串,值是该日期所有排班条目列表。
const scheduleMap = computed(() => { const map = new Map(); props.scheduleList.forEach(item => { const key = item.workDate; if (!map.has(key)) map.set(key, []); map.get(key).push(item); }); return map; });模板里渲染单个格子的时候,把当前格子的日期作为key去取map中的值,遍历显示班次名称和人员姓名。每个班次用不同颜色的标签区分:白班绿色、小夜橙色、大夜深蓝色、休息灰色。颜色的配置放在班次字典数据里,由后端返回,这样护士长在前端调整班次颜色就不需要改代码。
排班调整操作在日历上的交互是:点击某个格子,弹出一个对话框,显示当前已排班人员和可以替换的人员列表;替换时前端先把候选人的日期占用情况查出来,如果目标人在当天已经有班,就提示冲突。这个“先查再用”的前置校验能减少很多无效请求。
5.3 前后端联调的接口设计与状态管理
联调的时候最容易出问题的是日期格式。后端返回的LocalDate序列化之后默认是"2024-12-01"这种字符串,前端组件接收没问题。但如果前端传日期参数给后端,最好统一用YYYY-MM-DD格式,不要用时间戳。我在axios封装里做了请求拦截器,如果params里有Date类型,就自动格式化成字符串,避免格式不一致。
Pinia在这里主要管理两类状态:当前登录用户信息、当前选中的科室和日期范围。登录信息在用户刷新页面后需要从本地存储恢复,通过一个initialize方法在App.vue的onMounted里调用。科室和日期范围则是一进入排班页就更新,后续组件共享这些响应式状态。
前端还有一个很重要的细节:发布排班表时,要先拉取最新的排班数据,对比前端本地数据是否有改动。如果护士长打开页面后,另一个人已经发布了新班表,这时候再发布就会覆盖掉别人的操作。我在发布接口里加了一个version字段做乐观锁,前端把最初拉取数据时返回的version回传,后端对比version不一致就拒绝发布,提示“页面数据已过期,请刷新后重试”。
6. 常见问题排查与避坑实录
6.1 SpringBoot与MyBatis的版本兼容坑
SpringBoot版本高的时候,MyBatis的starter也要跟着升级。我见过最多的问题是:SpringBoot 3.x要求JDK 17以上,如果本地装的还是JDK 8,启动就会直接报错。所以如果你用的是JDK 8环境,SpringBoot尽量选2.7.x,MyBatis Spring Boot Starter选2.x版本。SpringBoot 3.x + MyBatis starter 3.x虽然也能跑,但是需要额外引入mybatis-spring的配置调整,新手容易卡住。
另一个高频问题是MyBatis接口和XML映射文件绑定不上。报错信息一般是Invalid bound statement (not found)。排查思路固定:检查Mapper接口和XML文件在同一个包路径下,检查application.yml里mybatis.mapper-locations配置的是不是classpath:mapper/*.xml,检查target/classes目录里有没有编译进去XML文件。最后一条很关键,IDEA默认不会把src/main/java下的XML文件拷贝到target目录,如果你把XML放到了java包里,必须加一个build配置:
<build> <resources> <resource> <directory>src/main/java</directory> <includes> <include>**/*.xml</include> </includes> </resource> </resources> </build>6.2 数据库连接池与SQL性能问题
开发环境经常遇到数据库连接不够用的情况。SpringBoot默认的HikariCP连接池,默认最大连接数是10,如果排班页面同时打开多个日历视图,每个视图发多个请求,连接池很容易被打满。出现这个问题的典型报错是Connection is not available, request timed out。解决办法是把最大连接数调到50左右,注意设max-lifetime,防止数据库侧回收了连接但连接池侧还不知道。
SQL性能上,最容易踩的坑是排班总览页面的N+1查询。一次拉取整个科室一个月的数据,如果先查排班记录,再逐条查用户名、班次名,性能会很差。正确做法是用一条JOIN SQL把排班记录、用户姓名、班次信息一次性查出来。用上面MyBatis里那套LEFT JOIN方案就能解决。
6.3 Vue3中容易踩的坑:响应式丢失与组件刷新
Vue3里一个常见的坑是用reactive包裹数组,然后直接用下标赋值,结果界面不刷新。这是因为Vue3的reactive对数组下标的更新是支持响应式的,但如果你用解构的方式把数组元素赋值给了普通变量,再修改这个普通变量的属性,响应式就断了。排班调整逻辑里,我遇到过修改某个scheduleItem对象的shiftId,页面不更新的情况,原因就是对象是从Map中取出来的,修改的只是局部引用。
解决办法是修改后把整个Map重新赋值,或者用ref包一层,通过.value方式整体替换。我在排班日历组件里,所有修改排班数据的操作,最后都会调一个refresh方法,重新从后端拉数据,保证视图一致性。这种做法虽然多一次请求,但是避免了响应式复杂调试,对排班这种低频操作来说体验更好。
6.4 排班系统的并发问题:重复排班和发布覆盖
并发问题其实在演示环境很少出现,生产环境一上就暴露。我遇到最典型的现象是:两个护士长同时打开排班页面,A排了周一的班,B排了周二的班,结果B先保存,A后保存,B的排班被覆盖了。这个问题就是前面提到的失发布,用version乐观锁解决。到数据库层,再补充一个唯一索引(user_id, work_date),即使代码里有漏洞,数据库这层兜底也不会出现同一个人同一天被排两个班的情况。
排班系统的数据量虽然不大,但并发问题仍然值得注意,因为你面对的不是“大量用户”,而是“少量用户做高频操作”。两个护士长可能同时在调整班表,操作节奏又完全一致,所以乐观锁这里是必须实现的,不能怕麻烦而省略掉。
7. 从源码到部署:环境搭建与运行配置
7.1 本地环境准备:JDK、Node、MySQL版本选择
想把这个项目跑起来,本地环境需要JDK 8或11(对应SpringBoot 2.7.x)、Node 16.20+(对应Vite 4)、MySQL 5.7或8.0。MySQL 8.0需要注意时区问题,连接串里必须加serverTimezone=Asia/Shanghai,否则会报Server returns invalid timezone错误。
前端启动前要改api配置文件里的后端地址,默认是http://localhost:8080/api。如果后端接口前缀不同,调整axios的baseURL就好。我用.env.development文件管理环境变量,开发环境和生产环境分开配置。
# .env.development VITE_API_BASE_URL=http://localhost:8080/api后端启动前要确保数据库库表已经建好,我提供了建表SQL脚本,直接用Navicat或者命令行执行都行。注意字符集要指定utf8mb4,不然存不了emoji或者特殊符号。数据库连接信息在application.yml里配置,默认账号密码是root/root,如果不一致要改掉。
7.2 生产部署:前后端分离项目的打包方式
生产部署其实不算复杂,但有几个细节要处理好。前端用npm run build生成dist目录,里面是纯静态文件,用Nginx托管。后端用Maven打包成jar包,java -jar命令启动。
Nginx配置的关键是反向代理后端接口。前端静态资源直接访问Nginx端口,而/api路径要代理到后端服务:
server { listen 80; server_name yourdomain.com; root /usr/share/nginx/html/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }最后一行try_files配置特别重要,Vue3路由用history模式时,刷新非首页路径会出现404,这行配置的作用是把所有前端路由都回退到index.html,由前端路由接管。如果你用的是hash模式就不会有这个问题,但history模式更干净,所以我推荐保留这行配置。
7.3 排班系统后续扩展方向
这个项目的架构留了不少扩展空间。比如自动排班算法目前是轮转加约束,后续可以接入更智能的优化算法,像遗传算法或者模拟退火,用来寻找更均衡的排班方案。再比如数据报表可以做深,从单纯的工时统计扩展到人员负荷分析、夜班频次预警,这些都是护士长非常关心的点。
我自己的体会是:排班系统真正难的不是技术,而是把业务规则完整梳理清楚。技术栈只是一层壳,核心是对排班业务的理解程度。你如果只是想要一个能跑起来的Demo,那按照这套架构搭起来很快;但如果你想真正在科室里用起来,一定要花时间把护士长的排班思路沟通透,把所有规则落到代码里,然后再开始写逻辑。这个顺序千万不能反。
最后再分享一个踩过的坑:开发排班条目的拖拽调整功能时,我一直纠结要不要做拖拽,后来发现护士长的操作习惯是点选加确认,而不是拖拽。做完第一版拖拽功能后,在演示时护士长普遍反馈不习惯,最后还是改回了点选模式。所以做这类行业系统,先搞清楚目标用户的真实使用习惯,比追求交互炫酷重要得多。