☰
智能教室管理系统开发实战:SpringBoot+Vue前后端分离与部署
2026/10/8 4:40:16 网站建设 项目流程

简介:一份基于SpringBoot与Vue前后端分离架构的智能教室管理系统项目源码,面向需要完成毕业设计或课程设计的学生,提供可直接运行、便于二次开发的全栈Web方案,可覆盖教室信息管理、预约审批、用户权限控制等典型业务场景。压缩包共978个文件,包体约108MB,主要包含Java后端源码与编译字节码、Vue前端页面及组件、JS脚本、XML/VM模板、YML与Properties配置、SQL数据库初始化脚本,以及BAT/SH构建清理与启动脚本等,类型齐全,目录结构清晰。目前已有724人学习/下载,是同类毕设项目中被广泛参考的方案之一。解压后借助打包脚本即可快速搭建运行环境,通过演示视频与说明文档辅助梳理系统流程;对于需要以SpringBoot+Vue整合案例完成答辩演示、理解权限模型或改造为后台管理系统的读者,复用价值较高。

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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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 Result<T> { private Integer code; private String msg; private T data; public static <T> Result<T> success(T data) { Result<T> r = new Result<>(); r.setCode(200); r.setMsg("操作成功"); r.setData(data); return r; } public static <T> Result<T> error(Integer code, String msg) { Result<T> r = new Result<>(); r.setCode(code); r.setMsg(msg); return r; } }

约定:code=200 是正常,401 是未登录或 token 过期,403 是权限不足,500 是服务端内部错误,参数校验失败用 400。前端 axios 拦截器统一判断 code,200 之外全部弹错误提示。这套约定做起来成本低,但能保证前后端联调时不再为“返回格式不统一”扯皮。

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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> </parent> <dependencies> <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.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency> </dependencies>

注意,版本我强烈不建议直接上 SpringBoot 3.x。3.x 把 javax 包换成了 jakarta,MyBatis-Plus 的启动器也要换成mybatis-plus-spring-boot3-starter,很多老代码直接从网上抄来根本跑不起来。用 2.7.18 是一套成熟到不能再熟的组合,教程多、报错好查。

application.yml 里几个关键配置也要提前讲清楚:

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/smart_class?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/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,查询自动带deleted=0条件,这也是建表时没有在每张表都写 deleted 字段的原因,它由框架统一处理。log-impl打开后控制台会打印每条 SQL,联调排错非常有用,正式部署再关掉。

3.2 JWT 登录认证:登录接口、Token 生成、拦截器放行与校验

登录认证这块,毕业设计没必要上 Spring Security 全家桶,那一套配置复杂度会在答辩前把你熬疯。用 JWT + 拦截器就够了,代码量小,逻辑也容易讲清楚。先写 JwtUtil 负责生成和解析 token:

@Component 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 放进 subject,role 放进 claim,拦截器从请求头拿 token 解析出身份。expire 单位是秒,7200 就是 2 小时。登录接口长这样:

@RestController @RequestMapping("/api/auth") public class AuthController { @Autowired private UserService userService; @Autowired private JwtUtil jwtUtil; @PostMapping("/login") public Result<Map<String, 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()); Map<String, 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;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"token无效或已过期\"}"); } }

拦截器解析成功后,把 userId 和 role 放进 request attribute,Controller 里直接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 Result<List<Device>> list(@RequestParam(required = false) Long classroomId) { LambdaQueryWrapper<Device> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(classroomId != null, Device::getClassroomId, classroomId); return Result.success(deviceService.list(wrapper)); } @PostMapping("/{id}/control") public Result<Device> 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 vue@latest 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/stats:

<template> <div class="dashboard-card"> <div ref="chartRef" class="chart-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 代理,就不存在跨域问题,因为浏览器访问的是同源地址 5173,Vite 在后端转发。生产环境部署后跨域配置才真正关键,两个都要配好,不要只在一边解决。

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: GMT+8

同时在数据源 URL 里保留serverTimezone=Asia/Shanghai。两个配置一起上,前端不会再收到数组结构,也不会出现差 8 小时的诡异问题。如果还有个别字段不听话,就在实体字段上补@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8"),但不要整个项目到处都加,先查全局配置是不是漏了。

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 清空后刷新页面跳转登录页。这三个动作分别对应前端权限、后端逻辑和登录态管理,正好覆盖了这套系统的大部分考点。做完这套项目后我最大的教训是,不要把后端权限校验当成前端不做路由守卫的理由,双层校验才是生产项目该有的样子;演示时故意“翻车”一次再圆回来,往往比自己讲十分钟更能说明问题。希望这些经验和坑能帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询