前阵子一个学弟找我帮忙收拾一套课表管理系统,他的需求说得很直白:老师要求后端必须用SpringBoot,前端用Vue,数据库用MySQL,还要能按周次切换课表、按班级/教师/教室三种维度查询。我看了一眼他下载的所谓“完整源码”,数据库脚本少了两张表,前端页面路由一堆报错,跑起来连登录都给404。最后没办法,我带他把这套系统从头理了一遍,重新整理成一套干净的前后端分离架构。今天我就把这套基于SpringBoot+Vue3+MyBatis+MySQL的课表管理系统完整实现思路拆给大家,从数据库设计到后端接口,再到Vue3课表组件渲染和实际部署,都挑重点说。正在做课设、毕设,或者想练手前后端分离项目的同学,可以直接照着改,少走几个月的弯路。
1. 项目概述与整体设计思路
1.1 课表管理系统的真实需求是什么
很多同学一看到“课表管理系统”这六个字,就以为只是把每周课程显示在表格里。真正动手做之后才发现,课表系统的核心不是“显示”,而是“排课约束”。西安工商学院这样的场景下,实际使用角色有三种:管理员负责维护基础数据和排课,教师查看自己的授课安排,学生查看班级课表。
基础需求可以拆成四块:
- 基础信息管理:班级、教师、学生、课程、教室的新增、修改、删除、分页查询。
- 排课管理:管理员选择班级、课程、教师、教室、起止周次、星期几、第几节,生成一条排课记录。
- 课表查询:学生按班级查课表,教师按教师ID查课表,管理员可以按教室查课表,所有查询都支持按周次切换(比如只看第8周)。
- 登录认证:不同角色登录后看到不同的功能菜单和数据范围,权限设计不复杂但必须有。
容易被忽略的非功能需求也很关键:排课不能冲突,意思是同一时间、同一教室不能同时排两门课;同一个班级同一时间也不能排两门课;同一个教师同一时间也不能跨教室上课。这些约束光靠前端校验不够,数据库和后端接口都必须兜底。后面我会重点讲这块。
1.2 为什么选择前后端分离 + SpringBoot + Vue3 + MyBatis
先说结论:这套组合不是最新潮的,但绝对是课程设计和中小型管理系统里最稳的。前后端分离的好处是后端只提供JSON接口,前端独立开发、独立部署,本地开发时用Vite代理转发请求,生产环境用Nginx托管静态页面,职责非常清晰。
后端用SpringBoot,理由很简单:内嵌Tomcat、自动配置、依赖管理省心。不需要像传统SSH项目那样写一堆web.xml和Spring配置文件,一个Application类直接启动。版本选择上,我建议如果本地JDK是8,就用SpringBoot 2.7.18;如果是JDK17或更高,可以用SpringBoot 3.2.x。网上很多人说“SpringBoot版本太高报错”,多半是JDK没匹配上。
MyBatis在这个项目里很合适,因为课表查询往往要带很多动态条件——按班级查、按教师查、按教室查、按周过滤,直接用JDBC拼接SQL很容易出错,MyBatis的动态SQL<if>和<where>可以把这些条件组合写得很干净。再加上数据库字段习惯用下划线命名,MyBatis开启驼峰映射后,查出来的结果能直接对应实体类。
前端选Vue3,主要看重组合式API和响应式数据。课表本质是一个二维矩阵,Vue3的ref和reactive在处理动态渲染表格、点击单元格弹窗这类交互时非常顺手。组件库用Element Plus,表格、表单、弹窗、消息提示都现成,做后台管理系统效率极高。
1.3 项目目录结构与模块划分
后端项目名我这里叫timetable-backend,标准的Maven结构:
timetable-backend ├── src/main/java/com/example/timetable │ ├── controller // 接口层:AuthController, CourseController, ScheduleController... │ ├── service // 业务逻辑:ScheduleService, UserService... │ ├── mapper // MyBatis接口:ScheduleMapper, CourseMapper... │ ├── entity // 数据库实体 │ ├── dto // 请求/响应对象 │ ├── common // 统一返回结果、全局异常处理、JWT工具等 │ └── config // 拦截器、跨域配置 └── src/main/resources ├── mapper // MyBatis XML文件 └── application.yml前端项目叫timetable-frontend,Vite默认结构的基础上加了:
timetable-frontend/src ├── api // axios 请求封装 ├── assets // 静态资源 ├── components // 课表组件、课程弹窗等 ├── router // 路由配置 ├── store // Pinia 状态管理 ├── views // 登录页、课表页、管理页 ├── utils // 工具函数 └── App.vue模块划分清晰的好处是,后面你想加“调课申请”“教室空闲查询”这类扩展点,不需要动太多老代码。
2. 数据库设计:课表系统的地基
2.1 课表数据建模的关键难点
课表系统最核心的难点,是把“课程安排”这个多维信息在关系型数据库里表达清楚。一条排课记录至少涉及班级、课程、教师、教室、星期几、节次、起止周、单双周这8个维度。很多毕设源码喜欢把所有字段堆到一张course表里,这样写起来最省事,但后果是课程基础信息和具体排课混在一起,想查“第8周3-4节机械楼101有没有课”要写一堆模糊条件,性能差而且极容易排重。
我推荐的做法是拆两层:课程基础表和排课安排表。课程表只存课程名称、学分、总学时这类不变信息;排课表存“哪个班级、哪门课、哪个老师、哪个教室、什么时间上”,一条记录就是一次排课。这样做的一个直观好处是,一门课可以拆成多条排课记录,比如周一1-2节一次、周三3-4节一次,学分和总学时依然记在课程表里,数据结构非常干净。
2.2 核心表结构设计
下面是精简版的表清单,实际做课设可以再加字段。
| 表名 | 用途 | 关键字段 |
|---|---|---|
| tb_user | 登录账号 | user_id, username, password, role, real_name |
| tb_class | 班级 | class_id, class_name, grade |
| tb_student | 学生 | student_id, user_id, class_id, name |
| tb_teacher | 教师 | teacher_id, user_id, name, title |
| tb_course | 课程 | course_id, course_name, credit, total_hours, teacher_id |
| tb_classroom | 教室 | classroom_id, room_name, capacity, building |
| tb_schedule | 排课记录 | schedule_id, class_id, course_id, teacher_id, classroom_id, week_start, week_end, week_type, day_of_week, section_start, section_end |
排课表字段的命名直接对应前端课表组件的数据结构,这点很关键。week_start和week_end表示这门课从第几周上到第几周,比如第1周第16周;week_type用1表示全周,2表示单周,3表示双周,避免单双周交替的课排不出来;day_of_week存1到7,section_start和section_end表示第几节到第几节,比如“3-4节”。
建表SQL里值得注意的地方是字符集和排序规则,我统一用utf8mb4和utf8mb4_general_ci,千万别用latin1,否则中文姓名和课程名插入时会直接乱码。MySQL 5.7和8.0都支持这个字符集。
2.3 索引设计与排课防冲突
索引不要贪多,根据查询场景来。课表查询的WHERE条件通常是class_id + day_of_week + section_start + week_start,这个组合最需要索引;教师查课表走teacher_id,教室查询走classroom_id。我的建议是至少给tb_schedule加三个单列索引:idx_class_id、idx_teacher_id、idx_classroom_id,如果数据量不大(几千条),这三个索引足够用。
排课防冲突是重点。最稳妥的兜底办法是在tb_schedule上建一个联合唯一索引,但问题是“同一教室同一时间”和“同一班级同一时间”是两组不同维度,无法用一个唯一索引同时覆盖。实际操作中我是这样处理的:数据库给tb_schedule加主键自增,不做复杂唯一约束,而是在Service层提供排课校验方法,判断新增的排课记录是否和已有记录“时间重叠”。比如同样教室、同样星期、同样节次范围,如果两条记录在周次上存在交集,且单双周规则也重叠,就拒绝保存。这种判断逻辑用Java写很直白,就是在插入前先SELECT一次。并发量很低的教务系统场景,这个方案完全够用。
3. 后端核心实现:SpringBoot + MyBatis
3.1 项目初始化和关键依赖
项目用Spring Initializr生成就好,不要自己手写pom。不同版本的依赖名称有区别,用SpringBoot 2.7.x时,MyBatis Starter依赖是mybatis-spring-boot-starter,如果是SpringBoot 3.x,建议用mybatis-spring-boot-starter的3.x版本。MySQL驱动在SpringBoot 2.7里用mysql-connector-java,在SpringBoot 3里改成了com.mysql:mysql-connector-j,这个细节常年坑人。
另外建议加上lombok,实体类不用写一堆getter/setter,代码清爽很多。spring-boot-starter-validation做参数校验也建议带上。
application.yml里重点配置几块:
spring: datasource: url: jdbc:mysql://localhost:3306/timetable?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.timetable.entity configuration: map-underscore-to-camel-case: trueserverTimezone=Asia/Shanghai这个参数必填,很多本地能跑、部署到服务器后时间差8小时的问题,就是这里漏了。map-underscore-to-camel-case开启后,course_name这种字段能自动映射到courseName,省掉一大堆resultMap。
3.2 登录认证与权限拦截
课表系统不推荐引入SpringSecurity全家桶,太重了。我用的方案是JWT + 拦截器。登录接口接收用户名密码,校验通过后生成一个JWT字符串返回前端,前端存在localStorage里,每次请求在Authorization头带上。后端写一个拦截器,放行/api/auth/login,其他/api/**请求都校验Token,解析失败直接返回401。
Token里我放了userId、role和realName三个信息。这样后端接口里需要用到当前用户时,直接从Token里取,不用每次查数据库。拦截器里校验角色的地方要注意:比如管理员才能调用排课保存接口,学生只能查自己的课表。这个角色判断用HandlerInterceptor的preHandle写就行,代码量不大,但能挡住90%的越权调用。
密码存储不要用明文。我建议用BCryptPasswordEncoder,Spring Security框架里抽出来的这个工具类可以单独用,不依赖整套Security。前端传来明文密码,后端用BCrypt加密后存库。注意:如果是从网上下的源码,经常看到密码直接明文存库,必须改掉。
3.3 课表查询接口:MyBatis动态SQL
课表系统的灵魂接口是getTimetable,它同时支持三种查询条件:按班级查、按教师查、按教室查,并且支持按周数过滤。接口参数我用一个DTO接收:
public class TimetableQueryDTO { private Integer classId; private Integer teacherId; private Integer classroomId; private Integer week; // 要查询的周数,如8 }Controller层先做参数校验,至少传入一个查询维度,然后调用Service查询。Mapper接口方法定义如下:
List<ScheduleVO> selectTimetable(@Param("query") TimetableQueryDTO query);MyBatis XML里的核心是动态SQL,多表关联加条件拼装:
<select id="selectTimetable" resultType="com.example.timetable.dto.ScheduleVO"> SELECT s.schedule_id, s.class_id, cl.class_name, s.course_id, c.course_name, s.teacher_id, t.name AS teacher_name, s.classroom_id, cr.room_name, s.week_start, s.week_end, s.week_type, s.day_of_week, s.section_start, s.section_end FROM tb_schedule s LEFT JOIN tb_class cl ON s.class_id = cl.class_id LEFT JOIN tb_course c ON s.course_id = c.course_id LEFT JOIN tb_teacher t ON s.teacher_id = t.teacher_id LEFT JOIN tb_classroom cr ON s.classroom_id = cr.classroom_id <where> <if test="query.classId != null"> AND s.class_id = #{query.classId} </if> <if test="query.teacherId != null"> AND s.teacher_id = #{query.teacherId} </if> <if test="query.classroomId != null"> AND s.classroom_id = #{query.classroomId} </if> </where> ORDER BY s.day_of_week, s.section_start </select>注意周数过滤不在SQL里做,而是查出来后在内存中过滤。为什么这么做?因为一条排课记录的week_start和week_end是一个区间,可能跨好几周,加上单双周规则,直接写SQL判断稍不留神就把边界情况漏了。数据量小、性能影响几乎可忽略,内存过滤逻辑更直观:
// 判断某条排课记录在指定周是否上课 public boolean isScheduleInWeek(ScheduleVO schedule, int week) { if (week < schedule.getWeekStart() || week > schedule.getWeekEnd()) { return false; } if (schedule.getWeekType() == 1) { return true; } // 单周:week 为奇数;双周:week 为偶数 if (schedule.getWeekType() == 2) { return week % 2 == 1; } if (schedule.getWeekType() == 3) { return week % 2 == 0; } return false; }这样一周没课的格子就不会显示任何课程,前端渲染也就不会出现条数错位的问题。
3.4 排课保存:事务和冲突校验
管理员提交排课时,前端传过来的是一个排课对象,包含班级、课程、教师、教室、周次范围、单双周、星期、节次。Service层要做三件事:
- 参数合法性检查:节次是否1到12之间,星期是否1到7,周次是否结束大于开始。
- 冲突检查:查库判断这个班级、这个教室、这个教师,在相同星期和节次范围内是否已有排课。
- 插入记录:通过后用事务包裹,保证冲突检查和插入是原子操作。
冲突检查方法我单独抽出来,调用Mapper的一个查询方法,查询参数包括班级ID、教室ID、教师ID、星期、节次、周次范围。全部分别查一次,只要任何一个结果集非空就拒绝。有人会问为什么不写一个超级SQL一次查清,实际上分开查代码更易读,课设场景下性能也不是瓶颈。事务只需要在Service方法上加@Transactional,插入失败自动回滚,不会留下半条脏数据。
4. Vue3 前端实现:把课表画出来
4.1 Vite + Vue3 工程搭建
前端工程用Vite创建最省事:
npm create vite@latest timetable-frontend -- --template vue cd timetable-frontend npm install npm install vue-router@4 pinia element-plus axiosVue3项目建议一定要用<script setup>语法,课表页面的逻辑都写在setup函数式代码块里,比Options API的data/methods分散写法清晰很多。Element Plus按需引入还是全量引入?课表管理系统这类后台项目,我建议全量引入,省心。开发模式下无所谓,生产构建时会自动tree-shake一部分,但核心组件一般都会用到。
前端路由我分了三个页面:登录页/login,课表主页/timetable,管理后台/admin。管理后台里面用子路由区分班级管理、课程管理、排课管理。
4.2 课表组件渲染方案
课表渲染是整个前端的重头戏。我的思路是先把后端返回的排课记录做一次扁平化处理,转成一个以“星期几-节次”为key的Map结构。比如一条周一3-4节的记录,如果本周有效,就存到map['3-1']、map['4-1']两个key下面(key是节次值和星期值的组合),每个key对应一个数组。这个转换在组件里用computed操作,不污染原始数据。
模板部分我用Element Plus的el-table来展示。因为课表是一个行列固定、内容可变的二维矩阵,用el-table比手写<table>方便得多。行是节次,列是星期一到星期日。某一行的某个单元格,就显示这个节次对应的全部课程块,每个课程块用el-tag加不同颜色区分课程名称。为了让视觉更接近真实课表,我给课程块加了背景色和鼠标悬停效果,点击后弹窗显示课程详情。
核心渲染代码思路类似这样:
<el-table :data="sections" border> <el-table-column label="节次" width="80"> <template #default="{ row }">{{ row.label }}</template> </el-table-column> <el-table-column v-for="day in 7" :key="day" :label="'周' + weekNames[day]"> <template #default="{ row }"> <div v-for="item in getItems(day, row.value)" :key="item.scheduleId" @click="showCourseDetail(item)" > {{ item.courseName }}<br> <small>{{ item.teacherName }}</small><br> <small>{{ item.roomName }}</small> </div> </template> </el-table-column> </el-table>getItems(day, section)返回的是前面那个Map里对应的数组。节次常量我定义在一个独立的utils/constants.js里,包括每节课的时间段说明,比如上午1-2节是8:00-9:40。
4.3 Axios封装与跨域处理
前端必须统一封装axios请求,不要在每个组件里乱写fetch。我在src/api/request.js里创建axios实例,设置baseURL为/api,请求拦截器里把JWT Token塞进请求头,响应拦截器统一处理401跳登录、500弹错误提示。
开发环境会遇到最经典的跨域问题。Vite通过代理解决,配置vite.config.js:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }这样前端开发服务器在5173端口,请求/api/schedule/list时自动转发到8080后端,浏览器不会出现跨域报错。后端的跨域配置也保留一份,主要给非浏览器环境或者部署后特殊场景兜底使用。实际部署时,生产环境的请求路径由Nginx转发,也不存在跨域问题。
4.4 课程详情弹窗与周次切换
课表页顶部放一个周次选择器,默认显示当前周。切换周次时重新调用查询接口,拿到数据后重新生成课表Map,表格区域自动响应刷新。这一步对初学者来说容易踩坑的是:直接把后端返回的所有排课记录都渲染出来,没有先做“本周是否生效”的过滤。我在组件里加了currentWeek的ref,所有数据转换都基于它,切周时数据同步更新。
点击课程块弹窗,我用Element Plus的el-dialog,显示课程名、教师、教室、上课时间、起止周、单双周信息。同时弹窗里提供“这周有课”的标识,方便学生查看。管理员在弹窗里还可以看到编辑和删除按钮,这个按钮根据用户角色动态渲染,思路也很简单:role === 'admin'才显示。
5. 打包部署:从本地到服务器
5.1 后端打包运行
后端打包命令是老生常谈:
mvn clean package -DskipTests java -jar target/timetable-backend-0.0.1-SNAPSHOT.jar这里有一个细节:打包前一定要检查application.yml里的数据库地址、账号密码是不是生产环境的,很多系统本地能跑,部署到服务器突然启动失败,九成是数据库连不上。如果用的是云服务器,还要注意安全组和防火墙是否放行8080端口。
MySQL服务器端如果部署在单独的机器上,记得给应用账号授权远程访问,不然从Java应用连接时会被拒绝。还有MySQL 8.0默认认证插件是caching_sha2_password,老版本MySQL驱动连不上,报错关键字是Public Key Retrieval is not allowed,解决办法是在JDBC URL上加allowPublicKeyRetrieval=true,或者把驱动版本升到8.0.33以上。
5.2 前端构建部署到Nginx
前端构建:
npm run build生成dist目录后,把里面的文件上传到服务器的Nginx静态目录。Nginx配置里最关键的是一段try_files,否则切换路由后刷新页面会404:
server { listen 80; server_name your-server-ip; root /usr/share/nginx/html/timetable; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; } }try_files $uri $uri/ /index.html这行是Vue Router使用history模式时的标准配置,刷新/admin页面时Nginx不会去找真实的admin.html,而是把请求回退到index.html,交给前端路由处理。如果不加,刷新就404,这是Vue3项目部署最常见的毛病。
5.3 另一种做法:把前端打进SpringBoot
有同学不想单独部署Nginx,问能不能把前端dist目录直接放进SpringBoot的src/main/resources/static目录下。理论上可以,但有一个大坑:Vue Router如果用history模式,后端必须处理未知路径的转发。简单处理方式是前端使用哈希路由createWebHashHistory(),地址栏里会带#,虽然不够美观,但省事。如果你坚持用history模式,需要在SpringBoot里配置一个转发规则,把非/api开头的所有路径转发到index.html,操作成本不大,但对新手来说很容易漏配。
我个人的建议是:如果服务器资源充足,还是用Nginx部署前端,SpringBoot只做API服务。前后端分离项目就该这么玩,后续扩展微信小程序或者App端时,后端接口可以直接复用,不用再动前端生产包。
6. 常见问题与避坑实录
6.1 前端请求后端总是404或跨域报错
这大概是我被问过最多的问题。404要先分清是前端路由的404还是接口404。如果浏览器Network里能看到请求已经发到后端,状态码是404,那就是后端没有这个接口路径,检查@RequestMapping的类路径和方法路径是不是多了一个/。如果是前端请求根本没发出去,控制台报跨域错误,那先看Vite代理配置是否生效,确认路径前缀是不是/api。我在排查时习惯先直接浏览器访问http://localhost:8080/api/schedule/list,如果这个地址能返回JSON,问题就出在前端代理或配置上。
6.2 MyBatis查询结果某些字段为null
最常见的原因是数据库列名和下划线转驼峰没生效。检查两项:map-underscore-to-camel-case是否设为true,以及实体类字段名是否真的和驼峰对应。比如数据库字段teacher_id,实体类属性名一定要是teacherId,漏掉一个字母就会导致结果里有null。还有一种情况是使用LEFT JOIN时,被关联表的字段正好是数据库保留字,比如order、desc,查询时直接报语法错误,处理办法是给字段加反引号,或者干脆改字段名。
6.3 排课保存后查不到,怀疑缓存作怪
MyBatis的一级缓存是SqlSession级别的,默认开启;二级缓存默认关闭。如果在同一个SqlSession里先查再插再查,会命中缓存导致查不到刚插入的数据。但SpringBoot中每次Mapper操作都从连接池获取新连接,实际上很难触发一级缓存问题。我在这个项目里故意关闭了二级缓存,毕竟排课数据更新频繁,用本地缓存容易引发“课表改了前端还是旧数据”的幻觉。如果你确实要用缓存,记得@CacheNamespace配置好,并保证每次排课变更后执行flushCache。
6.4 时间字段和星期几显示错乱
这个问题几乎都是时区导致的。MySQL连接URL里的serverTimezone和系统时区不一致,或者服务器的系统时区是UTC,就会看到时间差8小时。课表系统里虽然主要存星期几和节次,但如果你加了创建时间字段,插入后查询会发现时间差,这时统一把serverTimezone设为Asia/Shanghai,别偷懒。还有通过day_of_week存数字,前端映射时注意JS里getDay()返回的是0到6,0代表星期天,这个映射关系写错,课表会整体错位一格。
6.5 打包部署后页面白屏
页面白屏先看浏览器控制台,如果报错是Failed to fetch dynamically imported module,说明前端资源的路径不对。Vite默认构建出来的资源路径是绝对路径/assets/...,如果你的前端部署在子路径下,比如/timetable/,资源就加载不到。解决办法是在vite.config.js里设置base: '/timetable/',然后重新构建。还有可能是服务器静态目录权限不对,Nginx的user配置没有读文件权限,404或者403都会导致白屏。
6.6 MySQL安装和字符集问题
很多同学在Windows上安装MySQL后,启动时总报Can't connect to MySQL server,先检查服务是否启动,再检查3306端口是否被占用。课表系统对MySQL版本不挑,5.7或8.0都行,但5.7的默认字符集可能是latin1,建库时一定要显式指定:
CREATE DATABASE timetable DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;如果表已经建好了,再补一道转换语句:
ALTER TABLE tb_course CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;不然插入中文课程名后查出来是一堆问号,十有八九是字符集问题。
7. 写在最后的经验与建议
课表管理系统这套源码实现到能跑通前后端,其实只完成了60%,剩下的40%在数据初始化和边界场景。我陪学弟调完这个项目后最大的感触是,很多同学一上来就急着写代码,结果排课数据不知道怎么造,单双周的课也没设计好,演示的时候老师随便输一个班级号,页面一片空白,体验很差。建议你们拿到任何课表系统源码后,第一件事不是跑起来,而是先把数据库脚本看明白,自己往里造两周的完整课表数据,包括单周课、双周课、跨周课、同教室不同时间段课,全部造全了再启动项目,这样演示时才有底气。
如果想让这个项目在答辩时更出彩,可以往后端加一个“班级课表导出Excel”功能,前端一个按钮,后端用EasyExcel把当前周课表导出来,这属于典型的加分项。另外还可以把排课冲突的校验结果用可视化的形式反馈给管理员,比如前端页面上直接标红冲突格子,这个改动不复杂,但很能体现你对业务的理解深度。
我实际开发中还踩过一个很有意思的坑:前端课表组件一次性渲染整个学期的课表数据,结果有四十多周,每个格子都要过滤判断,页面明显卡顿。后来改成只渲染当前周,切周时重新组装数据,流畅很多。这种性能和体验上的细节,往往比接口写得多更能让老师眼前一亮。课表系统没有很复杂的高级算法,它的价值在于把多维度数据、权限和交互细节处理得足够稳,你可以在此基础上放心加功能,也可以拿它作为前后端分离项目练手的起点。