☰
SpringBoot+Vue3课表管理系统:从数据库到部署的全栈实战
2026/10/4 2:18:52 网站建设 项目流程

1. 项目整体设计与技术选型思路

1.1 这个项目到底在解决什么问题

课表管理系统,在Java Web项目里几乎是绕不开的经典场景。无论你是做毕业设计、期末课程设计,还是刚学完SpringBoot和Vue3想找个完整项目练手,这个题目都会出现在你面前。

这套源码解决的痛点很明确:传统的课表排布手工用Excel管理,到了学期中调整课程、临时调课、多班级冲突检查时,极易出错。做成管理系统后,管理员维护基础数据,学生或教师按条件查询课表,课程冲突校验交给程序,一套流程下来效率提升非常明显。

适合谁来参考?如果你是刚学完SpringBoot基础、Vue3刚上手的初级开发者,这个项目可以作为第一个完整的全栈项目来研究;如果你正在准备毕业设计,这套代码可以直接作为骨架,在其上扩展权限模块、公告模块;如果你是培训机构的讲师,也可以拆解它作为教学案例。核心价值在于:一个典型的前后端分离管理系统,覆盖了从数据库设计、后端CRUD、接口开发到前端页面渲染的全链路。

1.2 技术栈为什么这么选

SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0,这套组合在目前确实是主流且稳妥的。

先说SpringBoot2。很多人纠结现在SpringBoot3都出了,为什么要用2。实际开发中,SpringBoot2.7.x仍然是存量项目最大的版本群体,教程多、踩坑记录全、各类中间件兼容性成熟。而SpringBoot3强制要求JDK17,如果你本地还是JDK8或JDK11,直接上3会有很多环境层面的磕绊。这个项目选2,是求稳,不是落后。

Vue3这边,目前已经成了绝对主流,组合式API、响应式代理、Fragment支持这些特性让开发体验比Vue2好太多。搭配Element Plus组件库,后台管理系统的表格、表单、弹窗、消息提示基本就是拖拽式开发。

MyBatis-Plus的价值,我用一句话总结:它把单表CRUD的代码量压缩了至少80%。内置的BaseMapper直接提供了插入、删除、修改、分页查询等常用方法,你不需要手写XML和SQL,只需在Service层做业务逻辑组装。对于课表这种以单表查询为主、复杂报表为辅的系统,选MyBatis-Plus非常合适。

MySQL8.0的话,值得说的是身份认证插件和时区设置。8.0默认用了caching_sha2_password插件,老版本驱动会连不上,这个问题不少人踩过,后面我会专门讲。

这套技术栈还有个隐藏优势:岗位需求量大。目前中小企业Java后端招聘里,SpringBoot+MyBatis-Plus几乎是标配,Vue3前端更是必备技能。做完这个项目,简历上的项目经验就有了一个能写清楚全流程的内容。

2. 数据库设计与核心表结构

2.1 基础表设计:课程、教师、班级、学期

数据库设计是这类管理系统最见功夫的地方,也直接决定了后期代码好不好写。课表管理系统我建议至少拆成这几张表:课程表(course)、教师表(teacher)、班级表(class_info)、学期表(semester)、教室表(classroom),以及关联用的排课表(course_schedule)。

每张表的字段设计都有讲究。课程表除了课程名称、学时、学分这些基础字段,最好加一个course_type字段区分必修选修——很多实际需求里,导出Excel报表时都会按这个维度统计。教师表不用搞太复杂,工号、姓名、职称、所属院系,最多再加一个联系电话。

班级表要注意的是,班级名称不能只用字符串存个“软件2101班”就完事,建议拆成grade(年级)和class_no(班号)两个字段,查询和排序都方便。教室表则要记录容量和是否多媒体教室,排课时用来校验教室冲突。

学期表容易被忽略,但它很重要。课表和学期是强关联的,每个学期的课程安排独立,查询时先选学期再加载课表。没有这张表,历史课表查询和学期切换功能就无从实现。

2.2 排课关系表与字段设计思路

排课表(course_schedule)是整个系统的核心,它的字段设计决定了系统能支持的排课复杂度。

我先列一下这个表的字段方案:

字段名类型设计说明
idbigint主键,雪花ID或自增ID均可
semester_idbigint关联学期表
course_idbigint关联课程表
teacher_idbigint关联教师表
class_idbigint关联班级表
classroom_idbigint关联教室表
weekdaytinyint星期几,1~7
start_sectiontinyint开始节次,如第1节
end_sectiontinyint结束节次,如第2节
week_starttinyint起始周
week_endtinyint结束周
week_typetinyint周类型,0表示每周,1表示单周,2表示双周

这里最核心的设计是把“周几”“第几节”“第几周到第几周”拆成了独立字段,而不是直接存一个“周一第1-2节全周”。

这么设计的原因很简单:排课冲突检测时,需要按weekday+start_section+end_section去查同一时间段的已有排课,独立字段可以用一条简单的SQL完成冲突判断。如果把这些信息拼成一个字符串字段,查询时就要做字符串解析,性能和逻辑复杂度都会翻倍。

week_type字段是容易被新手忽略的点。很多学校有单双周课程的概念,比如有些课只在单周上。如果不在表结构里预留这个字段,后面做排课逻辑时会非常痛苦,要改表结构不说,还会影响前端展示的逻辑。

2.3 索引与数据初始化注意事项

排课表的索引设计,我的建议是建一个组合索引:(semester_id, weekday, start_section, end_section)。这是课表查询和冲突检测使用频率最高的查询条件。

再配合(class_id, semester_id)做班级课表查询、(teacher_id, semester_id)做教师课表查询。这里需要注意,不要每个字段都单独建一个索引,MySQL8.0虽然支持索引合并,但从执行计划来看,组合索引的效率和空间占用都更优。

数据初始化方面,建议在SQL脚本里预置一份基础数据。比如一个学期、几个班级、几十门课、几个教师的示例数据,方便项目跑起来后立刻能看到效果,也方便演示。这个细节能让拿到源码的人直接进入业务逻辑,而不是先花时间造数据。不要只给空表结构,那样体验真的差很多。

3. 后端核心实现:SpringBoot2 + MyBatis-Plus

3.1 工程结构的关键约定

后端工程结构建议采用标准的Maven多模块或单模块分包,这套源码采用的是单模块分包,结构清晰:

src/main/java ├── controller ├── service ├── mapper ├── entity ├── config └── common

controller层只做参数接收和结果封装,直接调service接口;service层处理业务逻辑,比如排课冲突校验、课表查询组装;mapper层继承MyBatis-Plus的BaseMapper,基本不用写SQL。

pom.xml里的依赖需要特别注意,几个核心依赖版本要写对:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-generator</artifactId> <version>3.5.3.1</version> </dependency>

有一个我踩过的坑:MySQL驱动包的groupId在老版本里是mysql:mysql-connector-java,但8.0.31之后推荐用com.mysql:mysql-connector-j,两者坐标不一样。用SpringBoot2.7.x内置的依赖管理,可以直接省略version标签,让Boot统一管理可避免版本冲突。

3.2 MyBatis-Plus通用CRUD与分页配置

MyBatis-Plus让单表CRUD变得非常轻松。entity类加注解映射表名,mapper继承BaseMapper后,增删改查方法全部内置。

以课程实体为例,核心代码是这样的:

@Data @TableName("course") public class Course { @TableId(type = IdType.ASSIGN_ID) private Long id; private String courseName; private Integer credit; private Integer courseType; private Integer totalHours; }

@TableName指定表名,@TableId配置主键策略,IdType.ASSIGN_ID使用雪花算法生成分布式ID。在单体应用里也可以直接用IdType.AUTO依赖数据库自增,看项目规模和个人习惯。

Service层直接继承ServiceImpl也是MyBatis-Plus的经典玩法:

@Service public class CourseServiceImpl extends ServiceImpl<CourseMapper, Course> implements CourseService { }

这一行代码就拥有了最基本的CRUD能力。但有两点必须提醒:第一,业务逻辑复杂的方法不要偷懒直接在Controller里用Mapper,还是要放在Service中封装事务和校验;第二,多表关联查询MyBatis-Plus没法直接做到,需要手写SQL或用VO拼接,课表详情这种就要在Service层自行组装。

分页插件配置也要提前弄好,不然分页查询会不生效。MyBatis-Plus的分页需要显式注入分页插件,配置类如下:

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }

这个拦截器不配置的话,调用Page对象做分页查询时会查出全部数据,非常坑。很多新手项目跑起来数据量小看不出问题,数据一多就暴露了。

3.3 课表查询接口与业务逻辑细节

课表查询是系统的核心接口,它要返回的不是排课表原始记录,而是前端可直接渲染的周课表数据结构。

我的实现思路是:先根据semester_id和查询条件(班级或教师),查出该学期全部排课记录,然后在内存中组装成一个二维结构:int[7][12]这样的星期与节次矩阵,每个格子放课程信息对象。

这个方案的好处是,前端拿到的数据是固定结构的,渲染时不必再做二次数据转换。而且因为一个学期一个班级的课表最多几十条记录,全量查询到内存中组装完全不存在性能问题,没必要做复杂的SQL连表查询再分页。

Service层的核心代码逻辑可以这样写:

public ScheduleVO getScheduleByClazz(Long semesterId, Long classId) { List<CourseSchedule> scheduleList = scheduleMapper.selectList( new LambdaQueryWrapper<CourseSchedule>() .eq(CourseSchedule::getSemesterId, semesterId) .eq(CourseSchedule::getClassId, classId) ); ScheduleVO vo = new ScheduleVO(); // 初始化7行12列二维数组 for (int day = 1; day <= 7; day++) { for (int section = 1; section <= 12; section++) { // 根据weekday和节次匹配课程,填充课时信息 } } return vo; }

实际排课时还会遇到跨周课程的问题。比如某门课是第2周到第16周每周三第3-4节,而周数是单双周交替的。这里有三种处理方式:如果week_type为0,表示全周期固定;为1表示单周上;为2表示双周上。前端渲染时看到week_type字段,直接用简单判断决定是否显示这节课。

事务处理方面,排课接口必须加事务。一次排课操作可能同时插入多条排课记录,比如同一门课同时给两个班级上课,中间任何一条插入失败都要整体回滚。用@Transactional注解在Service方法上,然后让Controller只负责接收参数,这个习惯要养成。

4. 前端核心实现:Vue3 + Element Plus

4.1 Vue3工程搭建与组合式API的使用习惯

前端部分使用Vite作为构建工具,创建工程命令很简单:

npm create vite@latest course-frontend -- --template vue

选Vite而不是Webpack的原因很朴素——启动快,热更新快。Vite开发阶段按需编译,一个中大型管理系统的开发体验比Webpack好太多了。构建工具这块,2026年开发Vue3项目基本默认Vite,没有太多纠结的必要。

项目目录我习惯按下述方式组织:

src ├── api // 接口请求封装 ├── views // 页面组件 ├── components // 通用组件 ├── router // 路由配置 ├── store // 状态管理(Pinia) ├── utils // 工具函数 └── layout // 布局组件

Vue3的组合式API,这里有一个核心习惯建议:把课程管理、排课管理、课表查询这些业务拆成独立的useCourse、useSchedule等组合式函数。比如排课页面里,课程表单的可见性、课程列表数据、加载状态都可以放进一个setup函数里统一管理。

组合式API和选项式API的区别,这里用一句话说透:选项式API把同一份数据的代码拆散在data、methods、computed里,而组合式API允许你按业务维度聚合代码。实际维护时你会发现,排课相关的逻辑集中在一个函数块里,阅读和修改体验完全不同。

4.2 课表网格组件设计与渲染逻辑

课表展示是前端最核心的部分。我的做法是做一个ScheduleGrid组件,用CSS Grid布局生成7列(周一~周日)乘若干行(节次)的网格。

表格表头是星期一到星期日,左侧第一列是节次序号,中间的单元格在对应的星期和节次位置渲染课程卡片。课程卡片上显示课程名、教师、教室和起止周。

模板结构大概是这样的:

<div class="schedule-grid"> <div class="schedule-header" v-for="day in dayNames" :key="day">{{ day }}</div> <div class="schedule-section" v-for="section in maxSection" :key="section"> {{ section }} </div> <div v-for="cell in cells" :key="cell.id" class="schedule-cell" > <div v-if="cell.course" class="course-card"> <div>{{ cell.course.courseName }}</div> <div>{{ cell.course.teacherName }}</div> <div>{{ cell.course.classroomName }}</div> </div> </div> </div>

这里要特别说明课程卡片跨节次的处理。第1-2节连续两节课的情况,普通表格布局会出现两个格子各显示一次课程信息,非常丑。

解决方案有两种。第一种,把每个时间段的数据合并,用CSS Grid的网格区域实现单元格合并——代码复杂度较高。第二种更实用:每个格子只放一次课程信息,但课程卡片的背景色让视觉上看起来是连续的。如果追求真正的合并效果,建议计算每门课占据的网格行列,然后动态生成grid-row样式。

移动端适配问题上,课表系统是桌面优先的,手机浏览器展示时7列表格会非常拥挤。一个妥协方案是显示横向滚动条,虽然不是最优体验,但保证了数据可见性。真正要适配移动端的话,需要改成按天切换的视图布局,后端接口不用变,前端做两套渲染逻辑就行。

4.3 前后端联调与代理配置

前后端分离开发,最难的不是代码,而是联调。接口请求这块,我习惯在src/api目录下按模块建文件,比如schedule.js,统一封装axios实例:

import axios from 'axios' const request = axios.create({ baseURL: '/api', timeout: 10000 }) export function getScheduleByClazz(semesterId, classId) { return request.get('/schedule/clazz', { params: { semesterId, classId } }) }

开发阶段最关键的配置是Vite的代理,否则前端请求/api/schedule时会直接404。要在vite.config.js加上:

export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } })

这个配置的意思是,前端发的/api开头的请求会被转发到后端8080端口,同时把/api前缀去掉。不配置的话,每个接口都要写全路径,部署到线上还得额外配Nginx转发规则,非常麻烦。

还有个经常遇到的问题——跨域。后端如果开启了CORS配置,前端不配代理也能请求通。但我的建议是:开发阶段统一走Vite代理,后端不要开CORS,这样能避免很多奇奇怪怪的问题。线上部署时用Nginx做静态文件服务和反向代理,天然解决跨域问题。

5. 常见问题与排查技巧实录

5.1 MySQL8.0连接时区与驱动问题

MySQL8.0安装完成后,最容易出的问题就是应用启动时报错:The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized。

这是时区问题。MySQL8.0默认时区设置的比较怪,连接串需要在末尾加上时区参数。正确的JDBC连接串配置:

spring: datasource: url: jdbc:mysql://localhost:3306/course_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true

serverTimezone=Asia/Shanghai是必须加的。allowPublicKeyRetrieval=true也是8.0连接时常见的参数,不加的话某些版本驱动会提示requires allowPublicKeyRetrieval。另外useSSL=false建议保留,本地开发环境下SSL验证意义不大,还能避免证书相关报错。

另外说一个和驱动版本相关的问题。MySQL8.0.33之前的驱动可能不支持MySQL8.4甚至更高版本的认证协议。如果服务器用的是MySQL8.4,建议驱动版本放在8.0.33以上,同时确认数据库用户的认证方式。如果遇到Public Key Retrieval is not allowed,就是缺少allowPublicKeyRetrieval参数。

5.2 MyBatis-Plus的更新空值问题

这个坑几乎每个用MyBatis-Plus的人都会遇到。默认情况下,使用updateById方法时,实体中为null的字段不会更新到数据库。这在大部分场景下是合理的,但有时候需要主动把某个字段置空。

解决办法有两种。一种是在实体字段上加@TableField(updateStrategy = FieldStrategy.IGNORED),让这个字段更新时忽略空值检查。另一种是自己在Service层构造UpdateWrapper,用.set方法显式设置字段值:

LambdaUpdateWrapper<CourseSchedule> wrapper = new LambdaUpdateWrapper<>(); wrapper.eq(CourseSchedule::getId, scheduleId) .set(CourseSchedule::getClassroomId, null);

这个方法更灵活,不用动实体类配置,推荐使用。

另一个相关的问题是字段自动填充。创建时间、更新时间这类字段,如果表结构里有而实体类里没配置,插入数据时会报错或者插入脏数据。建议在实体类使用@TableField(fill = FieldFill.INSERT)配合MetaObjectHandler实现自动填充,这个配置一次到位,后续所有表都能复用。

5.3 前端渲染与接口调试的几个坑

课表页面渲染空白,优先检查接口数据结构。表格组件渲染依赖最终返回的二维数组,如果接口返回的字段名大小写不一致,前端拿不到数据也不会报错,只是页面上什么都没有。调试技巧是把接口返回的数据先打印在控制台,和后端约定的字段名对一遍。

Vue3中有一个很隐蔽但常见的坑:使用reactive定义数组并直接用索引赋值,界面可能不更新。比如:

const scheduleData = reactive([]) scheduleData[0] = someData // 界面不更新

这是因为Vue3的reactive对数组的索引访问和length修改做了额外处理,直接赋值索引理论上应该触发更新,但在某些情况下就是不行。稳妥方案是用ref定义数组,赋值时用.value整体替换,或者用splice方法。这也是我建议课表组件里的cells数组使用ref而非reactive的原因。

数据回显的问题也遇到过几次。比如编辑课程信息,弹窗打开后表单初始值要回显。如果用reactive定义整个表单对象,直接赋值某个字段不会触发校验器更新。正确做法是打开弹窗时重建表单对象,或者用Object.assign整体替换:

const editForm = ref({}) const openEditDialog = (row) => { editForm.value = { ...row } dialogVisible.value = true }

这种写法每次打开弹窗都是新对象引用,响应式系统能正常追踪变化。

5.4 排课冲突校验的实战心得

排课冲突校验是这个系统里业务逻辑最密集的地方。我的校验逻辑分三层。

第一层,时间段冲突。查询同一个教室、同一周几、相同节次范围内是否有排课。SQL的where条件类似:

WHERE semester_id = #{semesterId} AND classroom_id = #{classroomId} AND weekday = #{weekday} AND start_section < #{endSection} AND end_section > #{startSection}

这个区间重叠判断的SQL是个通用姿势,记住start_section < endSection AND end_section > startSection即可,任何时间冲突检测都能套用。

第二层,教师冲突。同一个教师不能在同一时间出现在两个教室,校验逻辑跟教室冲突类似,换一下teacher_id条件即可。

第三层,班级冲突。这个场景是同一门课给两个班同时上,教室里坐了两个班的学生——排课表示例里课时记录的class_id只有一个,所以需要额外约定。我的做法是在排课表里加入多个classId以逗号分隔的方式维护,或者拆一张排课与班级的关联子表。考虑到课程管理系统大多数场景是一对一,用逗号分隔是偷懒但实用的方案。

你要是想做得更完善,用关联子表是正解。排课主表存课程、老师、教室、时间段,关联表存排课ID和班级ID的映射关系。查询班级课表时,从关联表反向查出排课记录即可。代码多写一个Mapper,但数据的规范性和扩展性会好很多。

6. Docker部署MySQL8.0与环境搭建

6.1 本地开发环境怎么快速跑起来

如果本地没装MySQL8.0,最省事的方案是用Docker直接起一个实例。用Docker跑MySQL8.0的好处是环境隔离,不污染本机,而且振作起来快。

docker run -d \ --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=123456 \ -e MYSQL_DATABASE=course_system \ -v /opt/mysql-data:/var/lib/mysql \ mysql:8.0

这个命令参数逐一说一下:-p 3306:3306把容器的3306端口映射到本机;MYSQL_ROOT_PASSWORD设置root密码;MYSQL_DATABASE自动创建数据库;-v把数据持久化到宿主机目录,这样容器删了数据还在。推荐端口用3306还是自定义,如果本机有MySQL占用,建议改映射端口如13306:3306,避免冲突。

启动后验证容器状态:

docker ps | grep mysql8 docker exec -it mysql8 mysql -uroot -p

如果容器起不来,最可能的原因是端口被占用或卷目录权限问题。查看日志用docker logs mysql8,大概率能看到真正的报错信息。

6.2 项目部署的完整流程

项目开发完成后,部署是另一个话题。前后端分离项目的部署方案,推荐用Nginx同时托管前端静态文件和后端反向代理。

前端执行npm run build生成dist目录,后端执行mvn clean package生成jar包。然后简单配置Nginx:

server { listen 80; server_name your-domain.com; location / { root /var/www/course-frontend/dist; index index.html; try_files $uri $uri/ /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; } }

try_files配置是SPA路由的关键——因为Vue3路由用的history模式,直接刷新页面时Nginx要把请求回退到index.html,否则会出现404。这个部署细节非常值得注意,上当的人极多。

jar包的启动命令,记得加上JVM参数:

nohup java -jar -Xms512m -Xmx1024m -Dfile.encoding=UTF-8 course-system.jar > app.log 2>&1 &

不加-Dfile.encoding=UTF-8的话,Linux服务器上偶尔会出现中文乱码问题,因为系统的默认字符集会跟程序的编码处理冲突。

6.3 文档与二次开发建议

源码附带文档这块,我的习惯是至少包含三份:数据库初始化脚本、接口文档、部署说明。数据库脚本要保证在一键执行后能还原完整结构加基础数据,接口文档用Swagger生成的JSON格式或Markdown格式都可以,部署说明要把上面提到的时区、编码这些坑提前写清楚。

二次开发方向上,比较有代表性的扩展点有三个。第一,加上SpringSecurity或Sa-Token做登录鉴权和角色管理,把课表管理从完全公开变成有权限控制;第二,增加Excel导入导出功能,用EasyExcel实现课程批量导入和课表导出,实用性会明显增强;第三,加一个公告或通知模块,排课调整后能将变动推送给相关用户。

我个人在实际操作中的体会是,这类系统做完一遍,最大的收获并不是那些CRUD代码写得有多熟练,而是你对“先设计表、再设计接口、最后设计页面”这条主线的理解。很多新手拿到需求后第一件事就是打开IDE写代码,结果越写越乱。课表管理系统最大的价值就是让你在改动不大、技术栈齐全的范围内,体会一次完整的业务闭环梳理过程。

最后再分享一个小技巧。排课冲突校验这类业务逻辑,你可以在数据库表里先不加唯一约束,而是在Service层做校验后抛出异常。这样在业务提示上会友好得多。数据库约束是兜底的,但用户绝不应该在界面上看到一条原生SQL报错。这个原则在任何管理系统里都成立。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询