简介:这是一份基于JAVA+SpringBoot+Vue+MySQL的实验室管理系统毕业设计项目,适合计算机专业学生用于毕业设计、课程设计或期末大作业。系统采用前后端分离架构,后端由Java和SpringBoot实现,前端使用Vue框架,数据库选用MySQL 8.0,配合Maven部署与Navicat数据库工具,覆盖实验安排、设备管理、预约管理、实验报告提交等核心功能;配套毕业论文文档,为理解设计思路与实现细节提供支撑。压缩包内共1816个文件,体积62.89MB,包含Java源码、Vue组件、JavaScript脚本、HTML页面、CSS样式、数据库SQL脚本、论文文档及项目配置,开发与运行所需材料齐全。项目经严格调试,下载后可直接运行,代码结构清晰,便于二次开发。目前已有130人浏览学习,内容完整、模块划分明确,适合系统掌握前后端分离开发模式,也可作为高分毕业设计案例参考,整体实用价值较高。
1. 拆一套能直接跑的实验室管理系统:SpringBoot 接口与 Vue 页面是怎么咬合的
拿到这份压缩包时,文件列表里最吸引我的不是那篇高分毕业设计论文,而是三个.vue.bak备份文件和两个.bat启动脚本。这说明作者在交付前经历过真实调试:侧边栏从静态改到动态、面包屑组件被反复覆盖、最后用批处理把 Maven 安装和 SpringBoot 启动固化成了两步。整套系统基于 SpringBoot 提供 REST 接口,Vue 负责页面渲染,MySQL 8.0 存业务数据,覆盖实验安排、设备管理、预约、实验报告提交这些实验室场景。对准备毕业答辩的人来说,这是一套业务逻辑完整、下载后能直接跑的案例;对想补前后端分离知识的工程师来说,它的价值在于把设备、预约、报告这三类核心数据的流转完整串了起来。我先把后端表结构和接口拆开看,再讲 Vue 路由与 axios 封装,最后落到数据库导入和启动脚本上。
2. SpringBoot 后端:实验室业务的表结构、Maven 依赖与预约分页接口
2.1 设备、预约、报告拆表:为什么冗余一个设备名
实验室管理系统的业务边界很清晰:管理员维护设备目录,学生预约某台设备做实验,实验完成后提交实验报告。最忌讳把所有业务字段塞进一张大表,一旦预约状态变化或者报告需要扩展字段,整张表都要跟着改迁移脚本。站在 SpringBoot 项目的角度看,我更倾向于把设备、预约、报告拆成三张独立表,再用索引和冗余字段来控制查询成本。
下面是我从项目思路里总结出的核心表结构,按 MySQL 8.0 语法精简过:
CREATE TABLE lab_device ( id BIGINT PRIMARY KEY AUTO_INCREMENT, device_code VARCHAR(32) NOT NULL COMMENT '设备编号', device_name VARCHAR(64) NOT NULL COMMENT '设备名称', location VARCHAR(32) COMMENT '存放位置', status TINYINT DEFAULT 1 COMMENT '1可用 0停用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_device_code (device_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='实验室设备表'; CREATE TABLE lab_reservation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, device_id BIGINT NOT NULL, user_id BIGINT NOT NULL, start_time DATETIME NOT NULL COMMENT '预约开始时间', end_time DATETIME NOT NULL COMMENT '预约结束时间', device_name VARCHAR(64) NOT NULL COMMENT '设备名称快照', status TINYINT DEFAULT 0 COMMENT '0待审核 1通过 2驳回', remark VARCHAR(255), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_device_time (device_id, start_time, end_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约表'; CREATE TABLE lab_report ( id BIGINT PRIMARY KEY AUTO_INCREMENT, reservation_id BIGINT NOT NULL, user_id BIGINT NOT NULL, report_content LONGTEXT COMMENT '实验报告正文', file_url VARCHAR(255) COMMENT '附件路径', submit_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='实验报告表';拆表之后,预约列表页不需要每次都 JOIN 设备表拿设备名,因为lab_reservation里已经冗余了一份device_name。设备名称变更时,可以由后台服务在保存预约时同步刷新快照,毕业设计阶段不需要做复杂的同步任务。设备编号device_code保持唯一,是为了防止管理员重复录入型号,如果做成普通字段,后续统计设备使用率时会把两台同名设备混在一起。
2.2 Maven 依赖:为什么锁 SpringBoot 2.7 而不是 3.x
这套系统的后端由 Maven 构建,我翻源码时先看pom.xml。真正值得关注的不是依赖有多全,而是 SpringBoot 版本的选择。现在很多新教程直接给 SpringBoot 3.x,但它默认要求 JDK 17,并且把javax.*包整体换成了jakarta.*,对于这份毕业设计来说,生态兼容性最好的还是 2.7.x。
| 依赖 | 版本建议 | 作用 |
|---|---|---|
| spring-boot-starter-parent | 2.7.18 | 统一管理整个依赖版本,避免 jar 冲突 |
| mybatis-plus-boot-starter | 3.5.3.2 | 提供分页插件、自动填充、条件构造器 |
| mysql-connector-j | 8.0.33 | MySQL 8.0 JDBC 驱动,运行时使用 |
| lombok | 1.18.30 | 编译期生成 getter/setter,减少样板代码 |
pom.xml 里的关键配置如下:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.2</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>这里有两个细节。第一,mysql-connector-j的 scope 写成runtime,运行期才需要驱动,打包时不会把多余的依赖塞进 fat jar。第二,JDBC 驱动类要写com.mysql.cj.jdbc.Driver,这是 MySQL 8.x 的命名,沿用 5.x 的com.mysql.jdbc.Driver虽然不报编译错,但启动日志里会出现弃用提示。如果你恰好遇到 springboot版本太高 的问题,可以先查本机 JDK 版本,2.7.x 在 JDK 8 和 JDK 11 下表现最稳定,IDEA 里创建 SpringBoot 项目超时也多半和 Maven 镜像没配好有关。
2.3 预约分页接口:参数默认值、分页插件与时间段校验
预约列表是这个系统最常被访问的接口之一,尤其在实验课前后的高峰期,学生会反复刷新页面看设备空闲情况。我看到源码里用的是 MyBatis-Plus 分页插件,而不是手写LIMIT,这样可以少写很多方言判断。先看 Controller 的入口:
@RestController @RequestMapping("/api/reservation") public class ReservationController { @Autowired private ReservationService reservationService; @GetMapping("/page") public Result<IPage<Reservation>> page( @RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize, @RequestParam(required = false) Long deviceId, @RequestParam(required = false) String startTime) { return Result.ok(reservationService.queryPage(pageNum, pageSize, deviceId, startTime)); } }pageNum和pageSize都给了默认值,前端 el-pagination 切页时会传这两个参数;deviceId和startTime是可选的过滤条件,前端下拉框选中设备后接口会自动带上。Service 层可以这样写:
@Override public IPage<Reservation> queryPage(Integer pageNum, Integer pageSize, Long deviceId, String startTime) { LambdaQueryWrapper<Reservation> wrapper = Wrappers.lambdaQuery(); wrapper.eq(deviceId != null, Reservation::getDeviceId, deviceId) .ge(StringUtils.hasText(startTime), Reservation::getStartTime, startTime) .orderByDesc(Reservation::getCreateTime); return reservationMapper.selectPage(new Page<>(pageNum, pageSize), wrapper); }这里使用LambdaQueryWrapper而不是字符串列名,好处是编译期就能发现字段拼写错误。分页插件必须在配置类里注册才会生效:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }如果不注册插件,selectPage返回的分页对象里total永远是 0。startTime参数建议在 Controller 层用@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")直接转成LocalDateTime,而不是在 Service 里手动LocalDateTime.parse,这样参数异常时 Spring 会返回 400,比业务代码抛异常更清晰。
2.4 时间段冲突校验与事务边界
前端可以在 Form 表单里限制结束时间大于开始时间,但后端必须再做一次校验,因为绕过前端直接调接口太容易了。我一般会在 Service 层写一个方法判断时间段是否重叠:
private boolean hasConflict(Long deviceId, LocalDateTime start, LocalDateTime end) { LambdaQueryWrapper<Reservation> wrapper = Wrappers.lambdaQuery(); wrapper.eq(Reservation::getDeviceId, deviceId) .ne(Reservation::getStatus, 2) .lt(Reservation::getStartTime, end) .gt(Reservation::getEndTime, start); return reservationMapper.selectCount(wrapper) > 0; }条件里<和>的方向容易写反。以新增预约“9:00-10:00”为例,已有的预约是“9:30-10:30”,那么新预约的 startTime(9:00) 小于已有 endTime(10:30),新预约的 endTime(10:00) 大于已有 startTime(9:30),两个条件同时满足才算冲突。这个判断在事务里执行时,要注意REPEATABLE READ隔离级别下并发插入可能互相不可见,所以最终兜底还是要靠索引或唯一约束,这个我放到第 5 章展开。
3. Vue 前端:从 .vue.bak 备份文件到路由懒加载与 axios 拦截
3.1 三个 .vue.bak 暴露的改造痕迹
解压后看到IndexAsideStatic.vue.bak、BreadCrumbs.vue.bak、IndexHeader.vue.bak时,我立刻想到了开发后期最常见的场景:代码没做 Git 版本管理,改坏一个组件就复制一份备份。这三个文件分别对应侧边栏、面包屑、顶栏,是 vue-element-admin 这类后台模板的核心布局组件。.bak的存在说明作者在“静态菜单”和“动态菜单”之间反复横跳过。
静态侧边栏的经典写法是这样:
<el-menu :default-active="$route.path" router> <el-menu-item index="/dashboard">首页</el-menu-item> <el-menu-item index="/device">设备管理</el-menu-item> <el-menu-item index="/reservation">预约管理</el-menu-item> </el-menu>静态菜单看着简单,问题是菜单树和后端权限不联动。比如管理员能看“实验报告审核”,普通学生只能看“我的报告”,每次改角色都要改前端代码。更常见的做法是后端返回菜单数组,前端用v-for递归渲染,这也是为什么最终保留的IndexAsideStatic.vue.bak只是备份,线上用的其实是动态侧边栏。另一个容易被忽略的点是,.bak文件放在src/views下会被 webpack 当成模块解析,npm run build时可能出现奇怪的编译错误,所以交付项目里这类文件应该删掉。
3.2 路由懒加载:别把首屏变成一个大 bundle
Vue 单页应用最大的坑是打包后的 app.js 越来越大。这套系统页面数量不多,但如果不做路由懒加载,首屏加载时间会明显变长。路由文件里合理的写法:
import Vue from 'vue' import Router from 'vue-router' Vue.use(Router) const routes = [ { path: '/', redirect: '/dashboard' }, { path: '/device', component: () => import('@/views/device/index.vue'), meta: { title: '设备管理' } }, { path: '/reservation/detail/:id', component: () => import('@/views/reservation/detail.vue'), meta: { title: '预约详情' } } ] export default new Router({ mode: 'hash', routes })() => import('@/views/device/index.vue')会把设备页单独打成 chunk,用户访问/device时才加载对应 JS。/reservation/detail/:id是动态路由,详情页用this.$route.params.id拿到预约 ID,再调后端详情接口;如果改成像/reservation/detail?id=18这样的 query 参数,取值就变成this.$route.query.id。新增一个全局前置守卫,可以统一处理登录态:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('lab_token') if (to.path !== '/login' && !token) { next('/login') } else { next() } })hash 模式和 history 模式在这类管理系统里差别很大,整理成一张表:
| 路由模式 | URL 效果 | 刷新页面 | 部署要求 |
|---|---|---|---|
| hash | /#/device | 正常 | 任意静态服务器 |
| history | /device | 404 | Nginx 配 try_files |
我一般建议实验室管理系统用 hash 模式,因为部署在校园网内时不一定有 Nginx,任何静态文件服务器都能跑。如果坚持 history 模式,后端要加转发规则,Nginx 里把非接口路径转发到index.html,否则刷新就白屏。
3.3 axios 封装:业务错误码与 HTTP 状态码要分开判断
前端所有接口请求都集中在src/utils/request.js,这是我从这套源码里看到的最关键封装。拦截器负责三件事:注入 token、统一处理业务错误、遇到 401 跳登录页。正确实现如下:
import axios from 'axios' import { Message } from 'element-ui' import router from '@/router' const service = axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 15000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('lab_token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { Message.error(res.msg || '请求失败') return Promise.reject(new Error(res.msg)) } return res.data }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('lab_token') router.push('/login') } return Promise.reject(error) } )这里有一个隐蔽的坑:很多示例代码在业务错误分支里也写router.push('/login'),这会导致预约冲突时页面被强行踢回登录页。正确的做法是分清楚两层:第一层是 HTTP 状态码,401 才跳登录;第二层是后端返回的code,业务失败时只需要弹错误消息,不要动路由。
跨域问题在前后端联调时一定会遇到。最常见的方式是在后端加 CORS 配置:
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }addAllowedOriginPattern("*")可以从任意域名发起请求,仅限开发环境;正式部署时最好把域名写死,否则任何网站都能调用你的接口。很多源码接口能跑通,但换到线上就各种拦截,问题往往在这一行。
4. 数据库导入与一键启动:Navicat 连 MySQL 8.0,2-run.bat 背后的 Maven 流程
4.1 两个脚本把部署压缩成两步
压缩包里的1-install.bat和2-run.bat是“下载即用”的关键。第一个脚本负责构建,第二个脚本负责启动,逻辑上有点像 CI 里的 build 阶段和 run 阶段。按照文件列表还原的脚本逻辑大概是这样:
@echo off rem 1-install.bat 构建后端 jar 包 cd /d %~dp0 call mvn clean package -DskipTests if errorlevel 1 ( echo Maven 构建失败,请检查 JDK 和 Maven 环境变量 pause exit /b 1 ) echo 构建成功,jar 包已生成到 target 目录 pause @echo off rem 2-run.bat 启动 SpringBoot 服务 cd /d %~dp0 java -jar target/lab-management-0.0.1-SNAPSHOT.jar --server.port=8080 pausecall mvn clean package -DskipTests里的-DskipTests很重要。毕业设计项目通常没有完整的单元测试,但 Maven 默认会执行test阶段,如果测试类里有失败的断言,构建就会中断。跳过测试能减少大量时间,但要记住这只是交付演示用法,正式项目里测试不能跳。
2-run.bat启动后,控制台会输出 SpringBoot 的 banner 和日志,这其实是排查问题的第一现场。如果看到APPLICATION FAILED TO START,优先去看Description字段,例如端口占用、数据源连不上,都会有明确提示。窗口不要关,关了就是直接杀掉整个进程。
4.2 Navicat 导入、JDBC 连接串与前端 publicPath
数据库脚本通常是.sql文件,用 Navicat 导入最稳妥。先把 MySQL 8.0 服务跑起来,然后在 Navicat 里新建连接,主机 127.0.0.1,端口 3306,用户名 root,填上安装时设置的密码。连接成功后右键“运行 SQL 文件”,选择项目里的lab_management.sql,执行完刷新就能看到表。
比导入更关键的是 SpringBoot 的数据源配置。application.yml里至少要保证这三项是对的:
spring: datasource: url: jdbc:mysql://127.0.0.1:3306/lab_management?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.DriverserverTimezone=Asia/Shanghai是必须的,不写会报The server time zone value 'Öйú±ê׼ʱ¼ä',这是 MySQL 8.0 时区设置为 CST 导致的乱码提示。characterEncoding=utf8mb4用于支持特殊字符,比如实验报告里有人粘贴数学符号或 emoji,默认 utf8 会报Incorrect string value。allowPublicKeyRetrieval=true解决的是 MySQL 8.0 默认 caching_sha2_password 插件报的Public Key Retrieval is not allowed错误,Navicat 里通常不遇到,但 JDBC 访问一定会遇到。
如果导入后提示某张表不存在,多半是脚本开头没有USE lab_management;。Navicat 运行整个 SQL 文件时,如果缺这一句,表会被建到默认库mysql下,SpringBoot 连接lab_management自然找不到表。前端部分不要直接双击index.html,因为 vue-cli 默认把静态资源路径写成/,要通过npm run dev启动开发服务器,或者npm run build后用 Nginx 托管 dist 目录。如果看到白屏,F12 里全是Failed to load resource,先看dist/index.html里的资源路径是不是/js/...,是的话把vue.config.js里的publicPath改成'./'。
4.3 启动失败快速索引
| 现象 | 优先排查 | 处理方式 |
|---|---|---|
| 端口被占用 | 控制台提示 Port 8080 was already in use | 改--server.port=8081或杀进程 |
| Unknown database | application.yml库名不对 | 核对 Navicat 里的库名和配置一致 |
| Public Key Retrieval is not allowed | JDBC 连接串缺参数 | 追加allowPublicKeyRetrieval=true |
| Maven 下载超时 | 中央仓库网络慢 | settings.xml 配置阿里云镜像 |
Maven 依赖下载超时在大学机房太常见了。在conf/settings.xml的<mirrors>节点下加阿里云镜像,十分钟的下载可能压缩到一分钟。前端如果还要npm install,同样建议把 registry 切换到淘宝源,这样整套项目才能真正做到“下载即用”。
5. 把这份毕业设计改得更抗打:预约冲突检测与 MyBatis-Plus 自动填充
5.1 数据库唯一索引兜底并发冲突
答辩时最容易被追问的问题通常是:“两个学生同时预约一台设备,你的系统怎么保证不冲突?”只靠前端按钮置灰是不够的,因为并发请求在网络层就是同时到达。最常见的后端方案是 Service 层先 count 再 insert,但并发下会有时间窗口。更抗打的是让数据库兜底,针对预约表做约束。
如果要用唯一索引,需要谨慎,因为(device_id, start_time, end_time)会让边界相邻的预约也变得严格冲突。更适合这套系统的是在插入时捕获DuplicateKeyException,同时返回友好提示:
try { reservationMapper.insert(reservation); return Result.ok("预约成功"); } catch (DuplicateKeyException e) { return Result.fail("该时段设备已被预约,请换一个时间段"); }这种做法的价值在于把“业务判断”和“数据落地”做了解耦。哪怕是未来接微服务,或者有定时任务批量插入,数据库的唯一约束始终是底层防线。
5.2 MyBatis-Plus 自动填充创建时间和更新时间
这套系统的每张表都有create_time,如果每个 Service 都手动setCreateTime,代码重复不说,还容易漏。MyBatis-Plus 提供了自动填充机制,只需要一个处理器类:
@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } }实体字段上加注解:
@TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime;这里有一个容易忽略的细节:insert 时也要给updateTime赋值,否则新记录创建后updateTime是 null,列表排序时会出问题。字段名要和实体类属性名一致,MyBatis-Plus 默认开启驼峰映射,所以create_time可以自动对应到createTime。
5.3 用 IDEA HTTP Client 做最小回归验证
我不太喜欢为了验证一个接口就打开 Postman,IDEA 自带的 HTTP Client 更快,而且文件可以提交到 Git 里当接口文档用。验证这套系统最快的方式:
GET http://127.0.0.1:8080/api/reservation/page?pageNum=1&pageSize=5 Authorization: Bearer eyJhbGciOiJIUzI1NiJ9... ### POST http://127.0.0.1:8080/api/reservation Content-Type: application/json { "deviceId": 1, "userId": 2, "startTime": "2025-06-01 09:00:00", "endTime": "2025-06-01 10:00:00" }第一次提交成功后,原样再跑一次,第二次应该返回业务错误码。如果两次都返回成功,说明冲突校验没生效,回查 Service 层是否真的查了时间段重叠。把.http文件命名为smoke-test.http放进项目根目录,答辩前点一下运行按钮,就能验证这套系统从数据库约束到后端接口再到前端提示的完整链路。
本文还有配套的精品资源,点击获取