Java+Vue课后服务智能选课排班与家校协同平台实战

Java+Vue课后服务智能选课排班与家校协同平台实战 简介这份资源面向具备一定Java与Vue基础的研发人员、软件工程专业师生及教育信息化开发者聚焦中小学课后服务中流程不规范、资源错配、选课不公、家校沟通低效等痛点给出基于Spring Boot与Vue前后端分离架构的全栈项目实例。内容覆盖多角色权限与未成年人信息保护、事务与锁机制下的选课容量一致性、候补转正队列、教师负荷与场地匹配评分、时间冲突检测、半自动智能排班、消息触达与阅读率统计以及数据库建模、API接口规范与部署方案形成可落地、可维护的工程案例。资源包共1个docx文档压缩包约171KB以图文文档形式串联需求分析、系统设计、代码示例与前端交互说明便于按模块查阅。目前已有205人学习适合作为课程设计、毕业设计或教育信息化项目立项的参考帮助读者从数据库实体关系到核心业务接口逐步实践掌握事务控制、并发处理与算法设计的落地思路。1. 课后服务排课这件事难的不是写代码而是约束太多很多学校的课后服务排课第一年靠 Excel第二年在微信群里接龙第三年就顶不住了。一个年级 300 个学生、20 门课程、每门课限 30 人、每周两次、要避开主科老师的教研时间、还要兼顾场地不撞车——这些条件叠加起来手工排一次要三天改一个学生就要重来。基于 Java Vue 的中小学课后服务智能选课排班与家校协同平台要解决的就是这件事把选课、分班、排课、通知、请假、评价串成一条线让教务从表格管理员变成规则维护者。技术选型上Java 侧负责业务约束与排课算法Vue 侧负责把选课结果和课表可视化成家长看得懂的样子。适合读者正在做教育信息化项目的后端与前端工程师、需要把课程设计和真实业务对齐的学生开发者。下面从数据模型讲到排课算法再讲到前端交互和踩坑。2. Java 后端选课与排班的数据建模和约束落地排班系统的稳定性八成取决于表结构设计是否留了余地。随口就建一张course表、一张student_course表做到第二步同一位老师在两个班同一时段就崩了。常见做法是把课程和课程开设的这个具体班次拆开再单独抽出时间段这个维度。2.1 五张核心表的职责划分我一般会保留这样五个核心实体其余都是它们的组合表名作用关键字段student学生档案含年级、班级id, name, grade_id, class_idcourse课程定义不含时间地点id, name, capacity, teacher_id, grade_scopecourse_slot课程开设的具体班次id, course_id, term_id, weekday, period, room_idenrollment选课报名记录id, student_id, slot_id, status, created_atslot_conflict冲突检测结果缓存id, slot_a, slot_b, conflict_type拆成coursecourse_slot的好处是同一门篮球可以开三个班次分别放在周二、周四、周五容量各自独立而课程本身的学分、教师、适用年级只维护一份。enrollment里的status至少要有PENDING、CONFIRMED、DROPPED、WAITLIST四种抽签和补录都靠它。slot_conflict是排课时的中间产物把哪些班次撞了算好落库前端展示冲突矩阵时直接查不用每次重算。2.2 用 JPA 定义实体与唯一约束下面这段是course_slot和enrollment的实体定义重点看约束注解它决定了数据库层面能不能拦住脏数据Entity Table( name course_slot, uniqueConstraints UniqueConstraint( name uk_room_weekday_period, columnNames {room_id, weekday, period, term_id} ) ) public class CourseSlot { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; ManyToOne(fetch FetchType.LAZY) JoinColumn(name course_id, nullable false) private Course course; Column(nullable false) private Integer weekday; // 1周一 ... 7周日 Column(nullable false) private Integer period; // 第几节取值受 period_dict 控制 Column(name room_id, nullable false) private Long roomId; Column(name term_id, nullable false) private Long termId; } Entity Table( name enrollment, uniqueConstraints UniqueConstraint( name uk_student_slot, columnNames {student_id, slot_id} ) ) public class Enrollment { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name student_id, nullable false) private Long studentId; Column(name slot_id, nullable false) private Long slotId; Enumerated(EnumType.STRING) Column(nullable false, length 16) private Status status Status.PENDING; public enum Status { PENDING, CONFIRMED, WAITLIST, DROPPED } }逻辑说明course_slot上的联合唯一约束保证同一学期、同一教室、同一个星期几的同一节次只能有一条记录把场地冲突挡在数据库层比在业务代码里到处 if 判断可靠得多。enrollment上的uk_student_slot防止同一学生对同一班次重复报名——这是接龙改成系统后最常见的一类脏数据来源。参数说明weekday用 1~7 而不是枚举字符串是为了排序和位图运算方便period不写死 1~8而是留整数位因为不同学校的作息节次不同。status用EnumType.STRING而不是 ORDINAL避免以后插一个枚举值导致历史数据错位。提示外键约束在一个学校几千名学生、几万条选课记录的量级下完全撑得住不要因为怕慢就全省掉冲突数据清理成本远高于加索引。2.3 容量控制不要用 count 判断用带条件的更新选课高并发发生在开放选课的头几分钟。用先 count 再 insert的写法两个请求同时读到 29会双双插入变成 31 人。常见可靠做法是库存扣减思想把已用名额放到course_slot上直接做原子更新UPDATE course_slot SET enrolled_count enrolled_count 1 WHERE id #{slotId} AND enrolled_count capacity; -- 再根据影响行数决定后续动作 SELECT enrolled_count, capacity FROM course_slot WHERE id #{slotId};逻辑说明第一条 SQL 把有没有余量和占用名额合成一个原子操作enrolled_count capacity写在 WHERE 里MySQL 的 InnoDB 会加行锁返回影响行数为 1 才代表抢到名额为 0 就直接返回已满是否加入候补。参数说明capacity不要和enrolled_count放两张表否则无法在一条语句里做条件比较。如果还要考虑候补自动转正可以在DROPPED时反向enrolled_count - 1再查enrollment里最早的WAITLIST记录补位。3. 排课算法把约束写成可校验的评分函数排课在很多项目里被写成一个巨大的 if 网络最后没人敢改。我的经验是把它拆成两件事一是把硬约束写成独立的合法性校验方法二是把软约束写成分数用局部搜索去优化。3.1 硬约束与软约束分别是什么硬约束是违反了就不能用的规则典型有四条同一教师同一时段只能有一个班次、同一教室同一时段只能有一个班次、同一学生同一时段只能选一门课、班次人数不能超过容量。这四条必须在生成候选解的时候就用校验器过滤掉。软约束是违反了不好但可以接受的规则例如尽量不让同一学生连上两节、尽量让同一门课分散在不同星期的不同节次、尽量把低年级学生安排在离教室楼近的场地。软约束用加权分数表达权重由教务在后台配。约束类型举例处理方式硬约束教师时段冲突候选解生成时直接过滤硬约束教室时段冲突数据库唯一约束 过滤软约束学生连堂减分权重可调软约束课程时间分布减分权重可调3.2 用回溯 评分做排班的最小实现完整的智能排课可以上遗传算法或约束求解器但在一个学期几百个班次的规模下带评分的回溯搜索足够而且结果可解释。下面是一个精简版本public class Scheduler { private final ListCourseSlot slots; private final ConflictChecker checker; private int bestScore Integer.MIN_VALUE; private ListAssignment bestSolution; // 对每个待排班次尝试可用的(星期,节次,教室)组合 public void solve(int index, ListAssignment current, int score) { if (index slots.size()) { if (score bestScore) { bestScore score; bestSolution new ArrayList(current); } return; } CourseSlot slot slots.get(index); for (TimeRoom tr : availableTimeRooms(slot)) { if (checker.hasHardConflict(slot, tr, current)) { continue; // 硬约束冲突剪枝 } current.add(new Assignment(slot, tr)); // 软约束打分连堂 -5同周分布 2时段偏好 3 solve(index 1, current, score softScore(slot, tr, current)); current.remove(current.size() - 1); // 回溯 } } }逻辑说明hasHardConflict检查教师、教室、学生三条维度上是否已经被占用占用即剪枝不再往下走。softScore每接受一个安排就把软约束的加减分累加最终保留分数最高的完整方案。回溯保证了在可接受的时间内遍历到合法解空间的大部分。参数说明slots的顺序很关键。实践中的做法是按约束最紧的班次先排——比如只有一位老师能教的课、只有一间场地能容纳的课排在列表前面剪枝效率显著高于按 ID 排序。软约束的权重建议做可视化配置页让教务自己拖而不是写死在代码里。3.3 冲突查询接口与结果返回排完课要能回答家长最关心的问题我孩子下周到底去哪上课。所以需要一个按学生维度返回课表的接口GetMapping(/api/student/{id}/schedule) public ResultListScheduleItem schedule( PathVariable Long id, RequestParam Long termId) { // 只取 CONFIRMED 状态候补和已退选不展示 ListEnrollment list enrollmentRepo .findByStudentIdAndStatusAndSlotTermId(id, Status.CONFIRMED, termId); return Result.ok(list.stream().map(ScheduleItem::from).toList()); }逻辑说明接口只返回CONFIRMED的记录候补中的课不落到常规课表避免家长误解。返回结构里带上weekday、period、roomName、teacherName前端可以直接渲染成周视图。参数说明termId必传而不是取当前学期是因为期末前后两个学期的数据会并存由前端显式选择。查询上加(student_id, status, term_id)的联合索引学生课表打开速度基本在几十毫秒。4. Vue 前端课表可视化与家校协同的交互细节后端把数据组织好了前端的核心任务是把周课表和选课入口做得家长和学生都能一眼看懂同时把谁改了课、谁请了假这类协同动作留下痕迹。这一层出了问题前面算法再聪明也没用。4.1 周课表组件的网格布局思路课表本质是一个 7 列 × N 行的二维网格星期做列、节次做行。用 CSS Grid 而不是表格是因为跨节次的课程一次两节用grid-row: span 2表达更自然template div classschedule-grid div classhead v-ford in weekdays :keyd.value{{ d.label }}/div div v-foritem in placed :keyitem.id classcell :style{ gridColumn: item.weekday, gridRow: ${item.period} / span ${item.span} } clickopenDetail(item) span classcourse{{ item.courseName }}/span span classroom{{ item.roomName }}/span /div /div /template script setup import { computed } from vue const props defineProps({ items: Array }) // 把同一天连续两节的同一课程合并为一个 span2 的块 const placed computed(() { const sorted [...props.items].sort( (a, b) a.weekday - b.weekday || a.period - b.period ) return sorted.map((it, i) { const next sorted[i 1] const continuous next next.weekday it.weekday next.period it.period 1 next.courseId it.courseId return { ...it, span: continuous ? 2 : 1 } }).filter((it, i, arr) { const prev arr[i - 1] return !(prev prev.weekday it.weekday prev.period 1 it.period prev.courseId it.courseId) }) }) /script逻辑说明placed计算属性先按星期和节次排序再把连续两节的同课程标记为span: 2然后用 filter 把被合并的第二节剔除避免同一节课渲染两次。gridColumn直接用星期数字定位gridRow用period / span N控制跨行。参数说明weekday从 1 开始与网格第几列对齐第 1 列留给节次标签所以实际渲染时列号可加 1。span只处理两节的情况三节及以上按需扩展但中小学课后服务一次超过两节的情况极少。注意打包后课表布局错位是 Vue 项目高频问题多与 Grid 的隐式行列有关。给容器显式写死grid-template-columns: 60px repeat(7, 1fr)不要依赖自动推断。4.2 选课页面的名额实时反馈选课页要让家长在点击的瞬间知道还剩几个名额、点下去会不会变成候补。前端不要自己维护计数而是以后端返回的enrolledCount和capacity为准async function enroll(slotId) { try { const res await api.post(/api/enroll, { slotId }) if (res.data.code WAITLIST) { ElMessage.warning(名额已满已加入候补队列) } else { ElMessage.success(选课成功可在课表中查看) } await refreshSlots() // 重新拉取名额避免本地计数漂移 } catch (e) { ElMessage.error(e.response?.data?.message || 选课失败请重试) } }逻辑说明前端不做乐观更新成功后统一refreshSlots重拉列表宁可多一次请求也不让家长看到的数字和实际不一致。候补状态由后端返回的code区分前端只负责提示。参数说明refreshSlots建议带节流避免家长连点造成请求风暴。真正的高并发防护在后端前端这层是体验优化。4.3 家校协同通知已读、请假与调课留痕协同部分最容易被做成一堆单向通知家长不点就不知道看没看。让每条通知带readBy列表请假和调课走审批流-- 通知已读记录readBy 不要用逗号分隔存大字段 CREATE TABLE notice_read ( id BIGINT PRIMARY KEY AUTO_INCREMENT, notice_id BIGINT NOT NULL, user_id BIGINT NOT NULL, read_at DATETIME NOT NULL, UNIQUE KEY uk_notice_user (notice_id, user_id) ); -- 请假申请携带审批状态 CREATE TABLE leave_request ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, slot_id BIGINT NOT NULL, reason VARCHAR(255), status VARCHAR(16) DEFAULT SUBMITTED, approver_id BIGINT, approved_at DATETIME );逻辑说明已读单独建表而不是在主表里存字符串好处是能精确查哪位家长没读过也让一键催读这类功能有据可依。请假表带status从SUBMITTED到APPROVED或REJECTED教师的审批动作就是一次状态更新。参数说明reason长度控制在 255 以内避免大字段拖慢列表查询。approver_id可空因为提交瞬间还没有审批人。5. 上线前必须过的验证与几个能省下大量返工的技巧功能能跑通和能被教务放心用中间隔着一轮专门去找它错的验证。这一步做扎实后面的返工量能砍掉一大半。5.1 用冲突检测脚本验证排课结果排完课不要靠人眼盯写个脚本把结果重新扫一遍看有没有违反硬约束public void verifySchedule(Long termId) { ListCourseSlot all slotRepo.findByTermId(termId); MapString, CourseSlot teacherMap new HashMap(); MapString, CourseSlot roomMap new HashMap(); for (CourseSlot s : all) { String tKey s.getTeacherId() - s.getWeekday() - s.getPeriod(); String rKey s.getRoomId() - s.getWeekday() - s.getPeriod(); if (teacherMap.putIfAbsent(tKey, s) ! null) { throw new IllegalStateException(教师冲突: tKey); } if (roomMap.putIfAbsent(rKey, s) ! null) { throw new IllegalStateException(教室冲突: rKey); } } }逻辑说明把所有已排班次按教师-时段和教室-时段两个维度塞进 MapputIfAbsent返回非空说明键已存在即冲突。这个方法可以挂成接口或单元测试每次排完课跑一次。参数说明termId维度不能省跨学期同一教师同一时段重复是正常的。5.2 学生维度冲突用位图查更快学生冲突比教师冲突量级大得多逐个比较是 O(n²)。把一个学生选的所有班次按时段映射成位图用 AND 判断有没有重叠几千人的数据也能秒级查出// weekday 1-7, period 1-8共 56 位 long bitmapOf(ListCourseSlot slots) { long mask 0L; for (CourseSlot s : slots) { int bit (s.getWeekday() - 1) * 8 (s.getPeriod() - 1); long flag 1L bit; if ((mask flag) ! 0) { return -1L; // 该生内部已有冲突 } mask | flag; } return mask; }逻辑说明56 位正好塞进一个 long两个学生的位图做 AND 为 0 表示无重叠冲突。返回 -1 表示单个学生自己的选课就撞了要在选课阶段拦住。参数说明位宽按星期数 × 每天节次数计算如果学校一天有 10 节就把 8 改成 10总位数仍然小于 64不用换BigInteger。5.3 三个容易踩的坑第一个坑是候补转正没有触发时机。很多项目只在用户手动退选时补位结果候补永远等不到。做法是在退选事务里同步查最早候补并更新状态或者用定时任务每几分钟扫一次。第二个坑是学期切换没做数据隔离。第二年开学把新的班次塞进同一批表上学期课表就乱套。所有查询强制带term_id列表默认只显示当前学期。第三个坑是选课时间窗口没控住。开放时间应在后端校验而不是前端隐藏按钮否则改个请求时间就能绕过。用term表存select_start和select_end进入选课接口先比对服务器时间。5.4 一键导出课表给家长的小技巧家长最想要的是能把课表存到手机里。做一个后端导出接口按学生生成 ics 日历文件比图片更实用也能自动同步到手机日历GetMapping(/api/student/{id}/schedule.ics) public ResponseEntitybyte[] exportIcs(PathVariable Long id, RequestParam Long termId) { ListScheduleItem items scheduleService.list(id, termId); StringBuilder sb new StringBuilder(); sb.append(BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//afterschool//CN\r\n); for (ScheduleItem it : items) { sb.append(BEGIN:VEVENT\r\n) .append(SUMMARY:).append(it.courseName()).append(\r\n) .append(LOCATION:).append(it.roomName()).append(\r\n) .append(DTSTART:).append(it.startTime()).append(\r\n) .append(DTEND:).append(it.endTime()).append(\r\n) .append(END:VEVENT\r\n); } sb.append(END:VCALENDAR\r\n); return ResponseEntity.ok() .header(Content-Type, text/calendar;charsetUTF-8) .body(sb.toString().getBytes(StandardCharsets.UTF_8)); }逻辑说明按 iCalendar 规范拼出 VCALENDAR每个班次一个 VEVENT时间用本地时间格式即可。家长点链接就能导入系统日历比截图清晰临时调课时也能重新导出覆盖。参数说明换行必须用\r\n否则部分手机日历解析失败。DTSTART需要是yyyyMMddTHHmmss形式时区可在DTSTART前加TZID或统一按本地时间处理视部署环境而定。导出接口同样要校验学生与当前登录用户的关系防止越权拿到别人课表。本文还有配套的精品资源点击获取