课表管理系统这个题目,在CSDN和GitHub上搜一下,出来的结果没有一千也有八百。但真正能跑通、能部署、代码不烂到让人想删库的,其实不多。尤其是“前后端分离”这四个字,很多所谓开源项目只是把Vue当静态页面扔进去,压根没做真正的接口交互和权限控制。前段时间我刚好帮一个学生把这类项目从头到尾捋了一遍,从数据库建模到Nginx部署,踩了一路的坑,今天把它完整写出来。这篇文章适合正在做课设、毕设,或者刚接触SpringBoot+Vue想找个完整实战练手的人。我会覆盖技术选型逻辑、数据建模、核心接口实现、Vue周视图渲染思路,以及一套可以直接照抄的部署流程。
1. 为什么课表管理系统遍地都是,但好用的没几个
1.1 前后端分离到底解决了什么
先聊一个容易被忽略的问题:为什么要做前后端分离。很多课设项目还在用Thymeleaf模板套Bootstrap,改了前端样式还得重启服务,连个实时刷新都做不到。前后端分离之后,前端工程和后端工程彻底解耦,Vue开发时有热更新,后端只需要提供一套纯JSON格式的REST接口。两者并行开发,谁都不卡谁。
具体到课表系统这个场景,分离架构带来的好处非常明显。课表类的操作交互很重:切换周次、按教室筛选、拖拽调课、课程详情弹窗,这些如果放在服务端渲染里,每一次点击都要走一遍完整的HTTP请求和HTML拼接,体验很差。拆成Vue前端之后,课表切换只是本地组件的状态变化,只有增删改才会真正请求后端接口。
1.2 为什么不选SSM单体,也不选微服务
很多新手喜欢问:为什么不用更“高级”的SpringCloud?答案很简单,杀鸡不用牛刀。课表系统本质上就是一个单域应用,规模撑死百八十张表,微服务化只会带来分布式事务、服务注册发现、配置中心这些与业务毫无关系的复杂度。SSM是传统单体,能用但开发效率低,光写那些XML映射文件就要花不少时间。
SpringBoot的出现把SSM时代最痛苦的配置环节砍掉了大半,自动配置机制让你只需要关注业务代码。MyBatis依然保留SQL的灵活性,复杂查询可以直接写SQL控制,不像JPA那样在奇怪的地方“自动”生成有问题的查询。MySQL是成本最低、资料最多的关系型数据库,学生电脑上装个5.7或者8.0都行。这套组合是JavaWeb实战项目里最经典、最稳妥的选择,没有之一。
提示:如果你看到有人说SpringBoot+MyBatis是“过时组合”,不用理。技术选型看团队和场景,课表系统这个量级,这套组合是性价比最高的。
1.3 同类项目源码的常见水坑
网上的课表管理系统源码我下载过好几个,普遍存在三个问题。第一是数据库脚本缺失或者字段对不上,代码里查的是course_name,脚本里建的是courseName,跑起来必报错;第二是没有任何鉴权逻辑,直接裸奔,这在前后端分离项目里属于硬伤;第三是所谓“课表”只是一个静态表格,完全没有排课冲突检测和课次时间管理。本文的项目实现里,这三个坑都做了针对性处理。尤其是鉴权部分,我会采用JWT方案,逻辑清晰且面试时也方便讲。
2. 数据模型是课表系统的灵魂:时间、教室、教师、课次怎么建模
2.1 核心需求梳理
任何系统的代码设计都从需求出发。一个可用的课表管理系统,至少要覆盖三类用户视角。管理员负责维护基础数据,包括教师、学生、课程、教室和课表安排;教师登录后能查看自己的授课安排,按周切换浏览;学生登录后能看到本班级或者本专业的全部课程安排。
这个需求决定了系统要能表达:某个教师在某周的某一天第几节课,在哪个教室给哪个班级上哪门课。听起来很简单,但这实际上是一个五维关联问题——时间、地点、教师、课程、班级,任何一个维度冲突,排课都必须拒绝。
2.2 各表的字段设计与关联关系
数据库物理模型我拆成六张核心表和两张辅助表。
用户表(sys_user)存的是登录账号和密码,用BCrypt加密存储,密码不能明文。表里放一个role字段区分管理员、教师、学生三种身份。教师表(teacher)和学生表(student)通过user_id关联到用户表,这样可以保证一个账号对应一个人。课程表(course)记录课程名称、课程编号和学分。
最关键的是课表信息表(schedule)。这张表我设计的字段是term、week_start、week_end、day_of_week、start_section、end_section,分别代表学期、起始周、结束周、星期几、开始节次、结束节次。这样做的好处是支持按周查询,比如第7周星期一第1-2节有课,第9周以后没课,这种间隔性排课用起始周和结束周就能表达。
教室表(classroom)要带capacity字段,排课时要校验班级人数是否超过教室容量。班级表(classes)承载学生分组,一个班级关联多个学生,一个班级在同一个时间点只能有一门课。教师和教室同理,三个唯一性约束要同时成立,才能保证课表不冲突。
2.3 排课冲突检测的表级设计
冲突检测是课表系统的核心难点。如果把冲突检测全部写进业务代码,每种组合都写一遍会非常臃肿。我的设计是把约束推进数据库层,利用唯一联合索引拦截冲突,再在Service层做二次校验,输出友好的错误提示。
我建了一个名为uk_schedule_time的唯一索引,组合字段是term + week_start + day_of_week + start_section + end_section + teacher_id。教师在同一时间只能有一个安排,这是硬约束。同样,教室维度建uk_classroom_time,班级维度建uk_classes_time。三个唯一索引一建,理论上就不会出现同一教师同一节课同时出现在两个教室的情况。
但数据库唯一索引只能防住“完全重复”,对于时间交叉重叠的情况,比如同一教师第1-2节和第2-3节分别在两个教室上课,唯一索引是拦不住的。这一部分的校验我放在Service层,查询出该教师、该周、该星期几已经存在的所有课次,做区间重叠判断。具体代码见下一章节。
3. 从Controller到Mapper:SpringBoot后端核心接口与排课冲突检测
3.1 REST接口的整体设计
后端采用经典的三层架构,Controller负责参数接收和结果封装,Service负责业务逻辑,Mapper负责数据库操作。接口设计遵循RESTful风格,路径全部用名词复数形式,HTTP方法表示操作类型。
核心接口有八个,我列出来:
GET /api/schedule?week=7:按周查询课表,返回当前登录用户视角的课程列表POST /api/schedule:新增排课记录,管理员权限PUT /api/schedule/{id}:修改排课记录,管理员权限DELETE /api/schedule/{id}:删除排课记录,管理员权限GET /api/course:课程列表GET /api/teacher:教师列表,下拉选择框用POST /api/auth/login:登录,返回JWT令牌GET /api/user/info:获取当前登录用户信息
返回结果统一封装为Result<T>对象,包含code、message和data三个字段。前端axios统一拦截器里判断code,不等于200时直接弹出错误消息。这个封装格式虽然简单,但能保证前端处理逻辑统一,不散落各处的res.data.data.data。
3.2 时间区间重叠判断的实现
排课冲突检测逻辑我写了两个方法。第一个方法查数据库,把同一教师、同一星期、同一节次区间内的已有课次全部查出来。第二个方法做区间重叠判断。
重叠判断的语义是:新排的课次[newStart, newEnd],如果和已存在的[existStart, existEnd]存在任意交叉,就是冲突。核心代码如下:
public boolean isTimeOverlap(ScheduleVO newSchedule, ScheduleVO existSchedule) { int newStart = newSchedule.getStartSection(); int newEnd = newSchedule.getEndSection(); int existStart = existSchedule.getStartSection(); int existEnd = existSchedule.getEndSection(); return newStart <= existEnd && existStart <= newEnd; }这个方法虽然只有一行判断,但语义是需要认真想的。newStart <= existEnd && existStart <= newEnd覆盖了四种情况:完全不重叠、完全包含、部分重叠、首尾相接。首尾相接——比如第1-2节和第2节恰好衔接——在现实中其实是允许的直线时间段,但如果在同一个表里按“节次区间”语义理解,第1-2节包含了第二节,第2-3节也包含了第二节,学生就会出现在两个教室。所以这里的区间判断采用闭区间,newStart <= existEnd && existStart <= newEnd能正确拦截这种边界情况,宁可严格一点也不要漏排。
实际调用时,Service层会把该教师该天该周的所有记录查出来循环判断。学生和教室的维度也调用类似的方法,只是查询条件换成班级ID和教室ID。这里要说一个细节:判断条件里必须过滤掉当前记录本身。如果是编辑接口,要把!= scheduleId拼进查询条件,否则改个课程名都会自冲突。
3.3 MyBatis映射与动态SQL拼接
MyBatis的XML映射是实践中最容易出问题的地方。课表查询需要根据用户角色、周次、学期动态拼接SQL,我用<where>标签搭配<if>判断处理。
来看一个实际的Mapper示例:
<select id="selectScheduleWithDetail" resultMap="ScheduleDetailMap"> SELECT s.id, s.term, s.week_start, s.week_end, s.day_of_week, s.start_section, s.end_section, c.id AS course_id, c.course_name, t.id AS teacher_id, t.teacher_name, r.id AS classroom_id, r.classroom_name, cl.id AS classes_id, cl.classes_name FROM schedule s LEFT JOIN course c ON s.course_id = c.id LEFT JOIN teacher t ON s.teacher_id = t.id LEFT JOIN classroom r ON s.classroom_id = r.id LEFT JOIN classes cl ON s.classes_id = cl.id <where> <if test="term != null and term != ''"> AND s.term = #{term} </if> <if test="week != null"> AND #{week} BETWEEN s.week_start AND s.week_end </if> <if test="teacherId != null"> AND s.teacher_id = #{teacherId} </if> <if test="classesId != null"> AND s.classes_id = #{classesId} </if> </where> ORDER BY s.day_of_week, s.start_section </select>这个SQL设计用了四条LEFT JOIN,一开始我写的是INNER JOIN,后来发现课表里允许存在部分字段为空的情况——比如自习课没有教师,换了一版才稳定。这里给初学者一个建议:什么时候用LEFT JOIN要看业务允不允许主表记录存在孤立的关联。
BETWEEN #{week} AND ...这种写法,是需要MyBatis做参数绑定,无法预编译优化,但课表系统数据量撑死几万条,性能完全不是问题,代码可读性优先。用week_start和week_end两个字段加BETWEEN判断,要考虑一个重要问题:如果一门课从第1周到第8周上课,查第9周的数据时,这条记录会被过滤掉。这是正确行为。
3.4 JWT鉴权与拦截器配置
前后端分离项目没有Session帮我们管理登录态,JWT是最主流的方案。用户登录成功后,后端生成一个Token,包含用户ID和角色信息,签名用HMAC-SHA256,有效期我设置为24小时。前端每次请求在axios请求拦截器里给Authorization头加上Bearer Token。
后端的拦截器继承HandlerInterceptorAdapter,在preHandle里校验Token有效性,把当前用户ID塞进ThreadLocal。放行路径设置为/api/auth/login和静态资源路径,其余所有/api/**都要校验。这样有个好处:前端可以根据接口返回的401状态码统一做跳转登录页的操作,不用每个页面自己判断。
JWT里放的信息不宜过多,只放userId和role就够了。任何和用户相关的详细信息都通过登录态再去数据库查,不塞进Token里。Token体积和续签问题,课表系统不需要考虑。
4. Vue前端把课表“画”成周视图:组件拆分与前后端联调细节
4.1 Vue项目的初始化和请求封装
前端我用的是Vue3 + Element Plus + Axios + Vite的组合。这里说一下为什么选Vite而不用Vue CLI:Vite开发服务器启动速度快、热更新响应快,对课表这样需要频繁改样式调布局的项目,开发体验明显更好。
创建项目的命令很简单:
npm create vite@latest timetable-web -- --template vue cd timetable-web npm install npm install axios element-plus请求封装方面,我在src/utils/request.js里创建一个axios实例,设置基础URL为/api,然后写两个拦截器。请求拦截器从localStorage里取Token拼到Header上,响应拦截器统一处理错误码。这里有个容易踩的坑:开发环境里前端地址是http://localhost:5173,后端是http://localhost:8080,跨域问题必须处理。我在Vite的vite.config.js里配置了代理服务器:
server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }这样请求时前端写/api/schedule,Vite开发服务器会代理到后端的http://localhost:8080/api/schedule。跨域问题在开发阶段就解决了,生产环境用Nginx同一个域部署,天然不存在跨域。
注意:不要在后端代码里写
@CrossOrigin("*")然后放开所有跨域请求,这样等于给攻击者开了门。开发阶段用Vite代理,生产阶段用Nginx反向代理,两种方式都不需要在后端做跨域配置。
4.2 周视图表格的渲染逻辑
课表前端的核心是那个7列6行的表格。7列对应周一到周日,6行对应一天的节次段。我先定义一个纯前端的时间常量:
const SECTIONS = [ { index: 1, label: '第1-2节', start: '08:00', end: '09:40' }, { index: 2, label: '第3-4节', start: '10:00', end: '11:40' }, { index: 3, label: '第5-6节', start: '14:00', end: '15:40' }, { index: 4, label: '第7-8节', start: '16:00', end: '17:40' }, { index: 5, label: '第9-10节', start: '18:30', end: '20:10' } ]表头是星期,行是节次段,单元格里放课程卡片。后端传回的每个schedule对象含dayOfWeek和startSection/endSection字段,前端要做一次映射:判断startSection等于1,endSection等于2,就放进第1行;如果课程跨两个节次段,比如第1-4节,就需要合并单元格。Element Plus的表格组件做单元格合并很麻烦,我用的是最直接的方式:用CSS Grid画表格,动态计算每个课程卡片的网格位置。
CSS Grid的实现思路是:容器是7列5行的网格,课程卡片用grid-column和grid-row指定跨度和位置。这样代码逻辑很清晰,课程的开始节次映射为行号,持续节次映射为行跨度,只需要几行计算:
const rowStart = item.startSection const rowSpan = item.endSection - item.startSection + 1 const styleObj = { gridRow: `${rowStart} / ${rowStart + rowSpan}`, gridColumn: `${item.dayOfWeek + 1} / ${item.dayOfWeek + 2}` }这种渲染方式比表格合并单元格灵活太多,而且遇到课程冲突重叠显示时,CSS Grid可以自动让卡片第二个覆盖第一个,视觉上能直接看出来。
4.3 前端与后端联调的五个高频问题
联调是最花时间的阶段,我把遇到的高频问题列一下,提前给你们排雷。
第一个是时间字段格式不统一。后端返回week_start是整数,但有些同学会把它序列化成了字符串,前端做BETWEEN判断全错。我统一要求后端在VO类里用Integer接收,保证JSON返回"weekStart":1而不是"weekStart":"1"。
第二个是跨域配置问题。不代理的话浏览器控制台报CORS error,解决方式上文已经写了,直接用Vite代理。
第三个是JWT过期导致的所有接口全部401。前端需要响应拦截器统一处理,而不是每个页面单独弹报错。我写了一个简易处理:遇到401就清空LocalStorage,然后跳转到登录页。
第四个是Element Plus的日期选择器默认语言是英文。需要在main.js里引入zhCn语言包配置,不配置的话周一显示为Mon,和中文课表风格不符。
第五个是删除二次确认。Element Plus的ElMessageBox.confirm弹窗确认后,要记得在finally里关闭Loading状态,否则操作失败时按钮一直转圈,用户会以为卡死了。
5. 完整部署教程:从本地编译到服务器上线,附实测踩坑记录
5.1 部署架构和版本选择
部署方案选型时我权衡过两个方向。一个方向是单独搞一台云服务器,装JDK、MySQL、Nginx,后端打成Jar包跑,前端打包成静态文件用Nginx托管。另一个方向是用Docker Compose把MySQL、后端、Nginx全部容器化。前者适合新手理解和排查问题,后者适合有一台服务器后追求一劳永逸的自动化。
最终我推荐你自己动手做一次传统部署。原因很简单:Docker部署隐藏了太多细节,一旦容器起不来,你连日志在哪里都不知道。先把传统部署流程跑通,再去容器化,是更扎实的学习路径。生产环境分为两种:只用一台2核4G的云服务器,带宽按量计费;或者本地Windows/Linux机器加内网穿透。我实测2核4G跑这个项目,空闲内存还剩不少,完全够用。
版本需要注意:JDK必须用8或11,别用17以上。SpringBoot 2.7.x最高支持JDK 8和11,JDK 17用不了老版本SpringBoot的某些字节码处理。我一开始图新装了JDK 21,SpringBoot项目启动直接报错UnsupportedClassVersionError,折腾了半小时换回JDK 11。MySQL用5.7或者8.0都行,生产环境我选的是MySQL 8.0。在云服务器上我直接使用系统包管理器安装的默认版本:
sudo apt update sudo apt install openjdk-11-jdk mysql-server nginx -y java -version5.2 后端打包与启动配置
后端打包之前要做一个关键操作:把application.yml里的数据库连接地址从localhost改成服务器的内网IP或域名,账号密码改成生产环境的专用账号,别用root。注意,生产环境MySQL账号不要用root,权限范围控制到指定数据库即可。
修改完成后执行:
mvn clean package -DskipTests打包好的Jar文件在target/目录下。我将它放到服务器的/opt/timetable/目录,则启动命令如下:
nohup java -jar timetable-server-1.0.0.jar --spring.profiles.active=prod > timetable.log 2>&1 &日志输出到timetable.log,排查问题时用tail -f timetable.log实时看。启动完验证一下:curl http://localhost:8080/api/auth/login,应该能看到JSON响应。
关于配置文件,我强烈建议做成多环境。application-dev.yml走本地数据库,application-prod.yml走服务器数据库。配置文件差异大,漏改一处环境就废了。数据库表结构不用手动建,我把init.sql上传到服务器后执行:
mysql -u timetabledb -p -h 127.0.0.1 timetabledb < init.sql注意,执行前先CREATE DATABASE,别把建表语句跑到系统库里。这里我吃过亏——表都建好了,结果发现建在sys库下,白忙一场。
5.3 前端打包与Nginx配置
前端打包前要检查request.js里的基础URL。我统一用/api相对路径发请求,这样前端打包后不管部署在哪个域名下,Nginx代理都能正确转发。别用绝对路径http://localhost:8080写死在代码里,否则部署到服务器之后所有请求都会打到本地。
执行打包:
npm run build打包产物在dist/目录。把dist上传到服务器的/var/www/timetable目录,然后配置Nginx。下面是我验证过可用的配置:
server { listen 80; server_name your-domain-or-ip; root /var/www/timetable; 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; } }有几个关于Nginx配置的坑。
try_files $uri $uri/ /index.html是单页应用的关键配置。Vue打包后只有一个index.html入口,如果不配置这个,用户刷新页面访问/schedule时Nginx会返回404,因为服务器文件系统里没有schedule这个目录。加上这一行后,所有路由都回退到index.html,由Vue Router接管页面跳转。
proxy_pass http://127.0.0.1:8080;的末尾不要加斜杠。加了斜杠http://127.0.0.1:8080/,代理到后端时/api前缀会被删除,而后端控制器注解是@RequestMapping("/api/schedule"),实际请求会404。这是我踩过最隐蔽的坑,配置的时候一定注意。
配置完成后重载Nginx:
sudo nginx -t sudo systemctl reload nginx5.4 部署后的常见故障排查清单
部署完成不等于万事大吉,我把实际出过的问题和对应的排查思路整理成了一张表:
| 故障现象 | 可能原因 | 排查命令/检查点 |
|---|---|---|
| 页面白屏或404 | Nginxtry_files未配置或路径错误 | 检查Nginx配置和dist目录权限 |
| 接口401循环 | JWT密钥不一致或前端Token存储问题 | 检查后端application.yml的密钥配置,清除浏览器LocalStorage |
| 数据库连接超时 | MySQL地址写成了localhost或权限受限 | telnet 127.0.0.1 3306,用MySQL客户端连接测试 |
| 静态资源加载失败 | dist文件权限不足 | chmod -R 755 /var/www/timetable |
| 刷新页面404 | try_files没配置 | 确认Nginx的location /块包含try_files... |
排查问题是有方法论可循的。我自己的排查顺序永远是从简单到复杂:先看浏览器F12控制台有没有请求报错,确定是前端问题还是后端问题;再curl后端接口确认服务是否正常;最后看Nginx的error.log和SpringBoot的timetable.log,两个日志下来基本就能定位到具体模块。
有一个经验必须提到:前端控制台显示的接口报错,很多时候问题出在Nginx层,不在后端。比如404,要么是Nginx的proxy_pass写错,要么是后端根本没有这个路由。别一上来就查SpringBoot的代码。
6. 部署之外:把项目从“能跑”变成“能答辩”
项目能跑起来只是及格线,要把课表系统做出亮点,还有一些值得扩展的功能模块。
一个建议是增加课表导入导出。用EasyExcel封装一个导入接口,支持管理员下载模板,批量导入课程安排,这样老师不用在前端一条一条录课,体验提升一个档次。导出功能也一样,按当前筛选条件生成Excel文件,前端下载。
另一个建议是周次自动计算。系统可以根据当前日期自动计算出当前是教学周第几周,用户打开页面默认显示当前周,不用手动切换。这个逻辑很考验基础:需要一个起始日期表或者配置文件,然后计算日期间隔,对日期API的熟悉度有要求,适合作为面试加分项。
我实际开发时还做了一个小功能:同一课程在不同周次的不同教室。比如一门课程单周的实验课在实验楼A栋,双周的在实验楼B栋,这就用到了week_type字段标记单双周。这个功能在排课系统里属于进阶需求,放到答辩环节讲很容易引导评委往你的思路上问。
不要贪大求全,核心是把自己实现的部分讲清楚。面试官问你排课冲突怎么解决的,你直接说数据库唯一索引+Service层区间重叠判断,然后给出代码,这个回答已经超过大多数候选人了。
我最后再分享一个实在的建议:如果你把项目部署到了云服务器,给IP加一个HTTPS。申请证书并不难,用Let's Encrypt的Certbot一条命令就能搞定。有了HTTPS,部署成果可以直接发朋友圈和简历链接,视觉可信度高不少。不用HTTPS的话,现代浏览器在某些功能上会拦截,比如课表的语音播报、摄像头扫码这类Web API就直接不工作。
从数据库设计到前端渲染再到Nginx上线,这条链路走通一遍,你对前后端分离项目的理解会比看十篇教程都深。剩下的,就交给你在自己机器上动手试了。