
简介一份基于SpringBoot与Vue前后端分离架构的智能教室管理系统项目源码面向需要完成毕业设计或课程设计的学生提供可直接运行、便于二次开发的全栈Web方案可覆盖教室信息管理、预约审批、用户权限控制等典型业务场景。压缩包共978个文件包体约108MB主要包含Java后端源码与编译字节码、Vue前端页面及组件、JS脚本、XML/VM模板、YML与Properties配置、SQL数据库初始化脚本以及BAT/SH构建清理与启动脚本等类型齐全目录结构清晰。目前已有724人学习/下载是同类毕设项目中被广泛参考的方案之一。解压后借助打包脚本即可快速搭建运行环境通过演示视频与说明文档辅助梳理系统流程对于需要以SpringBootVue整合案例完成答辩演示、理解权限模型或改造为后台管理系统的读者复用价值较高。1. 智能教室管理系统为什么不选图书管理系统偏要前后端分离智能教室管理系统基于 SpringBoot Vue 前后端分离架构是毕业设计和课题作业里很常见的选题。比起图书管理系统、商城系统它多了一层“智能”的含义——教室状态可视化、课表联动、设备远程控制、学生签到闭环前端做管理页面和驾驶舱大屏后端出 RESTful 接口加定时任务正好把前后端分离的完整链路走通。答辩时能展开讲的点非常多权限怎么校验、事务怎么控制、跨域怎么配、路由守卫怎么做。这篇按真实落地顺序写——需求拆分、数据库设计、SpringBoot 后端、Vue 前端、联调排坑、打包部署每个环节给可直接抄的代码让手上有这份项目源代码的人能快速改成自己的作品。2. 先拆需求再建库智能教室的四条业务主线与 8 张表设计2.1 功能边界教室管理、排课、签到、设备控制这四条主线大多数“智能教室管理系统”的课题需求都逃不开四条主线教室档案、课表排定、学生签到、设备控制。先把边界划清楚后面写代码才不会反复返工。第一块是教室和课表。教室管理负责维护楼栋、楼层、座位容量、设备清单这些基础档案以及教室当前状态空闲、使用中、维修。课表排定则是把“课程—教师—教室—时间”四元组匹配起来只有把排课表和教室状态关联上系统才能知道某个教室在某个时间段是否空着。第二块是签到。最简单的版本是学生在页面上点签到按钮系统记录签到时间和迟到/旷课情况稍微加一点智能感就是给每节课生成签到码或者让教室大屏显示动态二维码学生扫码完成签到。毕业设计选第一种就够接口和表结构都一样。第三块是设备控制这是“智能”两个字的主要体现。设备表挂在教室下面包括投影仪、空调、灯光、门禁等管理员可以远程下发开启/关闭指令更完整一点的做法是加一个定时任务到了课表安排的时间就自动打开对应教室的设备下课再自动关闭。这块不需要真的对接硬件用 HTTP 接口模拟就行。第四块是用户和权限角色一般分 admin、teacher、student 三档不同角色登录进系统看到的菜单不同这也是答辩时能讲清楚的权限控制案例。四条主线放到一起并不复杂但每一条都能从“增删改查”扩展出一点业务逻辑这正是毕业设计需要的深度。2.2 数据库设计用户表、教室表、课表、设备表、签到表的字段与关联这几条主线落到数据库我一般控制在 8 张表左右不要为了“看起来完整”建一堆冗余表。下面是核心表的建表语句直接按这套字段结构来就行。用户表 sys_user 统一存管理员、教师、学生三类账号用 role 字段区分CREATE TABLE sys_user ( id bigint NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 登录账号, password varchar(100) NOT NULL COMMENT BCrypt加密后的密码, real_name varchar(50) DEFAULT NULL COMMENT 真实姓名, role varchar(20) NOT NULL COMMENT 角色: admin/teacher/student, phone varchar(20) DEFAULT NULL COMMENT 手机号, email varchar(100) DEFAULT NULL COMMENT 邮箱, status tinyint NOT NULL DEFAULT 1 COMMENT 1启用 0停用, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;这里有两个参数要注意password 字段长度保留 100是因为 BCrypt 加密串本身就 60 位太短会直接报错status 字段默认值写成 1前端查询时过滤掉停用账号比物理删除更安全也方便后面做账号恢复。教室表 classroom 挂楼栋、楼层和容量status 字段用来支撑教室状态看板CREATE TABLE classroom ( id bigint NOT NULL AUTO_INCREMENT, room_no varchar(50) NOT NULL COMMENT 教室编号如 A201, building varchar(50) DEFAULT NULL COMMENT 所属楼栋, floor int DEFAULT NULL COMMENT 楼层, capacity int DEFAULT NULL COMMENT 座位容量, status tinyint DEFAULT 0 COMMENT 0空闲 1使用中 2维修, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT教室表;教室表本身不复杂但它会被排课、设备、签到三张表同时引用所以字段不要随意改。设备表 device 挂到教室下面通过 classroom_id 关联running_status 表示当前开关状态和在线状态 status 是两回事电灯在线但关闭是正常状态CREATE TABLE device ( id bigint NOT NULL AUTO_INCREMENT, classroom_id bigint NOT NULL COMMENT 所属教室, device_name varchar(50) NOT NULL COMMENT 设备名称, device_type varchar(20) DEFAULT NULL COMMENT 类型: projector/ac/lamp/door, status tinyint DEFAULT 0 COMMENT 0离线 1在线, running_status tinyint DEFAULT 0 COMMENT 0关闭 1开启, remark varchar(255) DEFAULT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT设备表;排课表 course_schedule 是核心关联表把课程、教室、教师、时间四者串起来。节次用 start_section 和 end_section 表示并记录学期周范围CREATE TABLE course_schedule ( id bigint NOT NULL AUTO_INCREMENT, course_id bigint NOT NULL COMMENT 课程ID, classroom_id bigint NOT NULL COMMENT 教室ID, teacher_id bigint NOT NULL COMMENT 教师用户ID, weekday tinyint NOT NULL COMMENT 1-7 对应周一到周日, start_section tinyint NOT NULL COMMENT 开始节次, end_section tinyint NOT NULL COMMENT 结束节次, week_start int NOT NULL COMMENT 起始周, week_end int NOT NULL COMMENT 结束周, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT排课表;排课时判断教室是否可用就是查这张表同一教室、同一星期几、节次区间有重叠就拒绝插入。这个冲突查询是后端的一个关键逻辑下面后端章节会写到。签到表 checkin_record 记录学生和课次的关系一次签到一行CREATE TABLE checkin_record ( id bigint NOT NULL AUTO_INCREMENT, schedule_id bigint NOT NULL COMMENT 排课ID, student_id bigint NOT NULL COMMENT 学生用户ID, checkin_time datetime DEFAULT CURRENT_TIMESTAMP COMMENT 签到时间, checkin_type varchar(20) DEFAULT normal COMMENT normal正常/late迟到/leave缺勤, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT签到记录表;剩下三张表——course 课程表、operation_log 操作日志表、notice 设备告警表——字段都比较直白课程表记课程名、学分、任课教师日志表记用户操作行为答辩时能用来讲审计告警表记录设备异常。八张表的关系一句话总结教室一对多设备教室一对多排课排课一对多签到用户分别关联教师和学生的身份。设计阶段把字段注释写全后面写实体类和导出数据字典都省事。2.3 接口规范RESTful 风格的 URL 与统一返回格式后端接口是给 Vue 前端消费的约定要一致。核心接口列表如下URL 全部走 /api 前缀功能请求方式URL说明登录POST/api/auth/login返回 token当前用户信息GET/api/auth/info带 token 获取个人信息教室列表GET/api/classrooms支持按楼栋/状态筛选新增/编辑教室POST/PUT/api/classrooms分别走新增和保存删除教室DELETE/api/classrooms/{id}逻辑删除排课列表GET/api/schedules支持按教室/星期几筛选新增排课POST/api/schedules后端做冲突检测设备列表GET/api/devices支持按教室过滤设备控制POST/api/devices/{id}/control传 action: on/off签到POST/api/checkin学生签到签到统计GET/api/checkin/stats按课程/日期统计仪表盘统计GET/api/dashboard/stats大屏数据源统一返回格式是所有接口的约定前端 axios 只认这一个结构我一般用一个 Result 类统一包装Data public class ResultT { private Integer code; private String msg; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.setCode(200); r.setMsg(操作成功); r.setData(data); return r; } public static T ResultT error(Integer code, String msg) { ResultT r new Result(); r.setCode(code); r.setMsg(msg); return r; } }约定code200 是正常401 是未登录或 token 过期403 是权限不足500 是服务端内部错误参数校验失败用 400。前端 axios 拦截器统一判断 code200 之外全部弹错误提示。这套约定做起来成本低但能保证前后端联调时不再为“返回格式不统一”扯皮。3. SpringBoot 后端落地项目结构、JWT 认证、设备控制与定时任务3.1 SpringBoot 项目结构依赖怎么选、配置怎么设、目录怎么分后端技术选型我不想搞复杂尤其毕业设计场景能讲清楚、能落地跑通才是第一位。最终我一般用 SpringBoot MyBatis-Plus MySQL 这套组合。MyBatis-Plus 把单表 CRUD 封装好了写代码效率高答辩时还能讲它和 JPA 的区别、逻辑删除怎么实现知识密度足够。先看工程目录结构这个结构本身也是我想强调的 SpringBoot 项目结构标准姿势smart-classroom-server ├── pom.xml ├── src/main/java/com/smartclass │ ├── common │ │ ├── Result.java // 统一返回 │ │ └── GlobalExceptionHandler.java │ ├── config │ │ ├── WebConfig.java // 跨域 拦截器注册 │ │ └── MybatisPlusConfig.java │ ├── controller │ │ ├── AuthController.java │ │ ├── ClassroomController.java │ │ ├── ScheduleController.java │ │ ├── DeviceController.java │ │ └── CheckInController.java │ ├── entity // 对应数据库表实体 │ ├── mapper // MyBatis-Plus Mapper 接口 │ ├── service │ │ ├── ClassroomService.java │ │ ├── DeviceService.java │ │ └── ... │ ├── task │ │ └── DeviceScheduleTask.java │ └── util │ └── JwtUtil.java └── src/main/resources ├── application.yml └── mapper // 复杂 SQL 写 XML 的地方pom.xml 的依赖要按 SpringBoot 版本对应好这一点特别容易翻车。下面是能用的依赖清单parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId /dependency /dependencies注意版本我强烈不建议直接上 SpringBoot 3.x。3.x 把 javax 包换成了 jakartaMyBatis-Plus 的启动器也要换成mybatis-plus-spring-boot3-starter很多老代码直接从网上抄来根本跑不起来。用 2.7.18 是一套成熟到不能再熟的组合教程多、报错好查。application.yml 里几个关键配置也要提前讲清楚server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/smart_class?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl jwt: secret: smart-class-room-secret-key expire: 7200逻辑删除配置的意思是实体里有 deleted 字段时MyBatis-Plus 自动把 DELETE 操作转成 UPDATE查询自动带deleted0条件这也是建表时没有在每张表都写 deleted 字段的原因它由框架统一处理。log-impl打开后控制台会打印每条 SQL联调排错非常有用正式部署再关掉。3.2 JWT 登录认证登录接口、Token 生成、拦截器放行与校验登录认证这块毕业设计没必要上 Spring Security 全家桶那一套配置复杂度会在答辩前把你熬疯。用 JWT 拦截器就够了代码量小逻辑也容易讲清楚。先写 JwtUtil 负责生成和解析 tokenComponent public class JwtUtil { Value(${jwt.secret}) private String secret; Value(${jwt.expire}) private Long expire; public String generateToken(Long userId, String role) { Date now new Date(); Date expiry new Date(now.getTime() expire * 1000); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(role, role) .setIssuedAt(now) .setExpiration(expiry) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); } }生成 token 时把 userId 放进 subjectrole 放进 claim拦截器从请求头拿 token 解析出身份。expire 单位是秒7200 就是 2 小时。登录接口长这样RestController RequestMapping(/api/auth) public class AuthController { Autowired private UserService userService; Autowired private JwtUtil jwtUtil; PostMapping(/login) public ResultMapString, Object login(RequestBody LoginRequest req) { User user userService.findByUsername(req.getUsername()); if (user null || !BCrypt.checkpw(req.getPassword(), user.getPassword())) { return Result.error(401, 用户名或密码错误); } if (user.getStatus() 0) { return Result.error(403, 账号已停用); } String token jwtUtil.generateToken(user.getId(), user.getRole()); MapString, Object data new HashMap(); data.put(token, token); data.put(userInfo, user); return Result.success(data); } }密码用 BCrypt 哈希存库登录时用BCrypt.checkpw校验这一步能直接回答答辩里“密码为什么不能明文存”的问题。JWT 校验靠拦截器统一做不放在每个 Controller 里重复写。下面这段拦截器是这套系统的门禁Component public class JwtInterceptor implements HandlerInterceptor { Autowired private JwtUtil jwtUtil; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求否则 CORS 配置会被拦死 if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String token request.getHeader(token); if (token ! null) { try { Claims claims jwtUtil.parseToken(token); request.setAttribute(userId, Long.valueOf(claims.getSubject())); request.setAttribute(role, claims.get(role)); return true; } catch (Exception e) { writeUnauthorized(response); return false; } } writeUnauthorized(response); return false; } private void writeUnauthorized(HttpServletResponse response) throws IOException { response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\token无效或已过期\}); } }拦截器解析成功后把 userId 和 role 放进 request attributeController 里直接request.getAttribute(role)就能判断权限。WebConfig 里注册拦截器并配置跨域Configuration public class WebConfig implements WebMvcConfigurer { Autowired private JwtInterceptor jwtInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/auth/login); } Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }前端 Vite 开发服务器默认在 5173 端口后端在 8080跨域跑不掉。这里有个关键点allowedOrigins(*)和allowCredentials(true)同时用部分 Spring 版本会直接抛异常要用allowedOriginPatterns(*)替代这是血泪经验。3.3 设备控制接口与定时任务把“智能”二字真正落地设备控制接口是教室管理系统里最能体现“智能”的部分。它的核心逻辑很简单前端传动作后端更新设备状态并写日志RestController RequestMapping(/api/devices) public class DeviceController { Autowired private DeviceService deviceService; GetMapping public ResultListDevice list(RequestParam(required false) Long classroomId) { LambdaQueryWrapperDevice wrapper new LambdaQueryWrapper(); wrapper.eq(classroomId ! null, Device::getClassroomId, classroomId); return Result.success(deviceService.list(wrapper)); } PostMapping(/{id}/control) public ResultDevice control(PathVariable Long id, RequestBody ControlRequest req) { Device device deviceService.getById(id); if (device null) { return Result.error(404, 设备不存在); } Integer targetStatus on.equals(req.getAction()) ? 1 : 0; device.setRunningStatus(targetStatus); // 注意状态更新和日志写入要放在同一个事务方法里 deviceService.controlDeviceAndLog(device, admin, req.getAction()); return Result.success(device); } }“controlDeviceAndLog”是一个带Transactional的服务方法里面先updateById(device)再插入一条operation_log记录。这样两件事要么都成功要么都失败不会出现设备状态变了、日志没写的脏数据。定时任务则用来模拟“无人值守”的智能场景。比如早上 8 点自动打开当天第一节课教室的设备晚上 10 点全部关闭Component public class DeviceScheduleTask { Autowired private DeviceService deviceService; // cron 从左到右秒 分 时 日 月 周每天 8 点执行 Scheduled(cron 0 0 8 * * ?) public void openDevicesForDay() { deviceService.openDevicesBySchedule(); } // 每天 22 点把所有开启状态的设备关掉 Scheduled(cron 0 0 22 * * ?) public void closeAllDevices() { deviceService.closeAllRunningDevices(); } }启动类上不要忘了加EnableScheduling否则定时任务不会生效。cron 表达式里?和*的区别是初学者最容易问的*表示任意值?表示“不指定”在周这一位上通常用?避免和日冲突。除了定时任务Dashboard 大屏接口也建议在后端做聚合查询把教室总数、使用中、空闲、设备在线率一次返回前端拿一份数据就能画图。4. Vue 前端落地路由配置、axios 封装、教室大屏与课表页面4.1 Vue3 Vue Router路由表、懒加载与权限守卫前端我一般选择 Vue 3 Vite Element Plus Pinia 的组合。Vue 3 和 Vite 是目前的默认姿势Element Plus 组件库覆盖表格、表单、弹窗几乎所有的后台 CRUD 场景几分钟能搭出一套管理页面。项目初始化用官方脚手架就行npm create vuelatest cd smart-class-room-web npm install npm install axios element-plus pinia echarts npm run dev脚手架会问要不要装 vue-router 和 pinia选上。装完后在 vite.config.js 里把/api代理到后端开发时就不用折腾跨域// vite.config.js import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })路由表把页面分成登录页和主布局两块子页面全部懒加载import { createRouter, createWebHistory } from vue-router const routes [ { path: /login, component: () import(../views/LoginView.vue) }, { path: /, component: () import(../layouts/MainLayout.vue), redirect: /dashboard, children: [ { path: dashboard, name: Dashboard, component: () import(../views/dashboard/Index.vue), meta: { title: 数据大屏 } }, { path: classroom, name: Classroom, component: () import(../views/classroom/ClassroomList.vue), meta: { title: 教室管理, roles: [admin] } }, { path: schedule, name: Schedule, component: () import(../views/schedule/ScheduleList.vue), meta: { title: 排课管理, roles: [admin, teacher] } }, { path: device, name: Device, component: () import(../views/device/DeviceList.vue), meta: { title: 设备控制, roles: [admin] } }, { path: checkin, name: CheckIn, component: () import(../views/checkin/CheckInView.vue), meta: { title: 签到, roles: [teacher, student] } } ] } ] const router createRouter({ history: createWebHistory(), routes })路由守卫负责拦截未登录的访问和越权访问router.beforeEach((to, from, next) { const token localStorage.getItem(token) const role localStorage.getItem(role) if (to.path /login) { next() return } if (!token) { next(/login) return } if (to.meta.roles !to.meta.roles.includes(role)) { next(/dashboard) return } next() })这个守卫的逻辑很直白没有 token 一律去登录页有 token 但角色不匹配 meta.roles就踢回大屏。它配合后端拦截器形成双层权限校验答辩时这一段能讲出一套完整的权限体系。4.2 axios 请求封装token 注入、401 跳转、统一错误提示请求封装是前后端对接时的关键一环。所有接口统一走一个 axios 实例避免每个页面都重复写 token 塞头、错误处理的逻辑// src/utils/request.js import axios from axios import { ElMessage } from element-plus import router from /router const request axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器自动带上 token request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[token] token } return config }) // 响应拦截器统一处理业务码和 HTTP 状态 request.interceptors.response.use( response { const res response.data if (res.code 200) { return res.data } ElMessage.error(res.msg || 请求失败) return Promise.reject(new Error(res.msg)) }, error { if (error.response error.response.status 401) { localStorage.removeItem(token) localStorage.removeItem(role) if (router.currentRoute.value.path ! /login) { router.push(/login) } } ElMessage.error(error.response?.data?.msg || 网络异常请稍后重试) return Promise.reject(error) } )两个细节值得注意请求头字段名要和后端拦截器读的字段一致这里后端读request.getHeader(token)前端就塞到headers[token]不要自作主张改成Authorization然后两边对不上401 跳转前要先判断当前不在登录页否则登录页请求也报 401会死循环跳转。API 文件按模块拆分比如src/api/device.js里就两个方法import request from /utils/request export const getDeviceList (params) request.get(/devices, { params }) export const controlDevice (id, action) request.post(/devices/${id}/control, { action })这样每个页面的业务逻辑只关心“调哪个函数”不关心 HTTP 细节。4.3 教室大屏与课表页面ECharts 可视化加 Element Plus 组件教室大屏是全项目的门面答辩打开第一个页面就是它。用 ECharts 画一个教室状态饼图加上设备在线率数据来自/api/dashboard/statstemplate div classdashboard-card div refchartRef classchart-box/div /div /template script setup import { ref, onMounted, onBeforeUnmount } from vue import * as echarts from echarts import { getDashboardStats } from /api/dashboard const chartRef ref(null) let chartInstance null const loadData async () { const data await getDashboardStats() chartInstance echarts.init(chartRef.value) chartInstance.setOption({ tooltip: { trigger: item }, legend: { bottom: 0 }, series: [{ name: 教室状态, type: pie, radius: [40%, 70%], data: [ { value: data.roomUsing, name: 使用中 }, { value: data.roomFree, name: 空闲 } ] }] }) } onMounted(() { loadData() }) onBeforeUnmount(() { chartInstance chartInstance.dispose() }) /script注意getDashboardStats返回值已经是后端 Result.data 里的内容是因为 axios 响应拦截器做了return res.data前端拿到的直接是业务数据不要再多取一层。课表页面用 Element Plus 的 el-table 展示排课数据按星期几分组显示管理员新增排课时后端会返回冲突提示前端只需要把错误信息弹出来。设备控制页面则是一排设备卡片每个卡片一个开关按钮点击调用controlDevice(id, on)或controlDevice(id, off)拿到返回后的最新状态再更新按钮颜色。这三个页面做完加上登录页和用户管理页整套前端的骨架就立住了。5. 联调排查避坑跨域、时区、依赖版本与登录态的典型翻车现场5.1 跨域配置踩坑接口明明能通浏览器一直报 CORS现象前端 F12 看网络请求后端确实返回了 JSON但浏览器 console 报跨域拦截GET 请求能通POST 带 JSON body 的请求必挂。原因这是前置 OPTIONS 预检请求被拦住了。后端同事说“我跨域配置写了呀”但拦截器把 OPTIONS 也拦了或者 CORS 映射和拦截器顺序不对预检请求返回 401浏览器就直接判非法。关键是/api/**的拦截器先接管了请求OPTIONS 还没走到 CORS 处理器就被打回去了。解决JwtInterceptor 里对 OPTIONS 请求直接放行WebConfig 的跨域映射用allowedOriginPatterns(*)加allowCredentials(true)。注意allowedOrigins(*)和allowCredentials(true)同时用会抛 IllegalArgumentException这是 Spring 版本演进后的硬性限制不是玄学。注意开发环境如果用了 Vite 代理就不存在跨域问题因为浏览器访问的是同源地址 5173Vite 在后端转发。生产环境部署后跨域配置才真正关键两个都要配好不要只在一边解决。5.2 时间格式踩坑LocalDateTime 序列化后前端显示一长串数组现象后端实体用 LocalDateTime前端表格里显示的不是2025-06-08 13:00:00而是[2025, 6, 8, 5, 0, 0]这种数组结构换算后还少 8 小时。原因Jackson 对 LocalDateTime 的默认序列化结果是数组而且 JVM 默认时区如果不是 Asia/Shanghai或者 JDBC URL 里没带serverTimezone参数时间就会整个偏移。解决在 application.yml 里全局配置 Jackson 格式spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8同时在数据源 URL 里保留serverTimezoneAsia/Shanghai。两个配置一起上前端不会再收到数组结构也不会出现差 8 小时的诡异问题。如果还有个别字段不听话就在实体字段上补JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8)但不要整个项目到处都加先查全局配置是不是漏了。5.3 SpringBoot 版本太高MyBatis-Plus 启动直接报错现象网上抄了依赖SpringBoot 用了 3.2.x启动时抛ClassNotFoundException: javax.servlet.Filter或者 Mapper 的 Bean 注入不进去。原因SpringBoot 3.x 从 javax 包迁到了 jakarta 命名空间老版mybatis-plus-boot-starter内部的类引用路径不兼容。解决毕业设计老老实实退回 SpringBoot 2.7.18配mybatis-plus-boot-starter3.5.3.2这套组合经过无数项目验证。真想用 SpringBoot 3.x就要换mybatis-plus-spring-boot3-starter的独立 artifact并且实体类里涉及javax.persistence注解的地方全部换成jakarta.persistence。这个坑的特点是不看堆栈根本不知道是版本问题看着像代码问题实际是依赖问题。5.4 前端 npm install 后项目启动报错源码发给别人时最容易踩的坑现象从压缩包解压源码后直接跑npm run dev报缺模块或者npm install时出现 ERESOLVE 错误装不满依赖。原因压缩包里没有包含 node_modules这是“把 vue 项目源码发给别人”最常见的失误。另外 node 版本太新某些依赖的 peerDependencies 对不上npm 7 之后默认严格校验依赖树一冲突就罢工。解决拿到源码先删掉 node_modules重新执行npm install。如果报 ERESOLVE用npm install --legacy-peer-deps绕过冲突。项目里package-lock.json一定要和源码一起发出去它能锁定依赖的精确版本避免对方装了不同版本的小版本后行为不一致。5.5 登录态失效后页面不跳登录用户以为系统卡死了现象token 过期后用户还在页面上操作每次点击都弹“操作失败”但页面一直停在当前页不跳回登录页。原因axios 响应拦截器只处理了业务码错误没有针对 HTTP 401 做统一跳转导致 token 失效后前端没有任何反应。解决在响应拦截器的 error 分支里判断 HTTP 状态码 401 后做三件事清空 localStorage 里的 token 和 role、跳转/login、弹一条“登录已过期”的提示。跳转前先判断当前路由不是/login避免在登录页重复处理。这个配置写一次所有接口都能复用不用每个页面单独判断。6. 部署只跑一个进程Vue 打包进 SpringBoot、演示数据与答辩准备6.1 把 Vue 打包进 SpringBoot部署时只跑一个 jar开发时前后端各跑一个服务没问题但现场演示时笔记本上开两个终端总是有点尴尬。我一般这样处理前端跑npm run build生成 dist 静态文件再把 dist 里的文件复制到 SpringBoot 的src/main/resources/static。重新mvn clean package后后端 jar 里就自带前端页面部署只需一个进程。cd smart-class-room-web npm run build cp -r dist/* ../smart-classroom-server/src/main/resources/static/ cd ../smart-classroom-server mvn clean package -DskipTests java -jar target/smart-classroom-server-1.0.0.jar注意前端路由要改用createWebHashHistory()否则部署后浏览器刷新/classroom这种路径会直接 404。历史模式需要后端配 fallback 控制器或 nginx 做 try_files对毕业设计来说hash 模式省事且稳定URL 里多一个#完全不影响演示效果。6.2 演示数据与答辩验证点让关键场景可控准备几份固定的演示数据答辩时按剧本走不做现场输入。数据库里先造这些场景教室状态大屏上要有 2 间“使用中”、3 间“空闲”设备列表里有一台投影仪能实时切换开关排课列表里预置一条冲突数据以便现场展示冲突检测。这样演示时每个操作都有明确的反馈不会冷场。答辩前把三个验证点各走一遍学生账号访问管理员菜单前端路由守卫打回大屏管理员关掉某台设备后大屏数据变化token 清空后刷新页面跳转登录页。这三个动作分别对应前端权限、后端逻辑和登录态管理正好覆盖了这套系统的大部分考点。做完这套项目后我最大的教训是不要把后端权限校验当成前端不做路由守卫的理由双层校验才是生产项目该有的样子演示时故意“翻车”一次再圆回来往往比自己讲十分钟更能说明问题。希望这些经验和坑能帮到你。本文还有配套的精品资源点击获取