Spring Boot+Vue实验室管理系统:预约审批闭环与二次开发 简介面向计算机相关专业毕业设计、课程设计与Java项目实战的实验室管理系统全套资料围绕实验室设备、实验室申请、消耗品与新闻资讯等业务提供可直接运行和答辩展示的完整项目方案。压缩包约80.62MB文件总数及类型明细上游暂未提供据说明包内包含项目源码、数据库脚本、开发说明文档、论文与代码注释等。源码采用SpringBoot后端与MySQL数据库开发工具为IntelliJ IDEA功能覆盖登录注册、设备管理、实验室管理、新闻资讯管理、消耗品管理及论坛信息管理并区分普通用户的申请、报备、领取权限与管理员的后台管理权限。已有512人学习适合需要参考权限设计、业务模块拆分、数据库表结构与论文写作框架的读者也便于课程设计、期末大作业和项目练习时对照复现与二次修改。1. 一份能直接跑起来的实验室管理系统四件套凑齐省了多少返工接手一个实验室管理系统最耗时间的从来不是写业务代码而是把环境、数据库、文档和一堆对不上的字段凑成一套能自洽的东西。这份资源把源码、论文、数据库脚本、操作文档放在一起对应的是高校实验室场地预约、设备借还、耗材领用、审批与统计这一整条业务线后端 Spring Boot MyBatis-Plus MySQL前端 Vue 2 Element UI 的前后端分离结构登录态用 JWT 加拦截器自己管不引 Spring Security。它适合两类人一类是几周内要交出一份完整毕业设计的同学直接拿骨架换业务模块另一类是想找个业务闭环完整的项目练手因为预约—审批—归还这条链路上的状态流转和冲突校验比单纯增删改查有意思得多。下面按能跑、能读、能改的顺序拆。2. 技术选型与项目骨架Spring Boot Vue 前后端分离分了几层选型思路很务实毕业设计场景下评委看的是业务闭环是否完整、数据库设计是否合理、代码分层是否清楚而不是你有没有上微服务。所以整套代码走单体架构后端一个 jar前端一个静态包中间只隔一层 JSON 接口。这种结构的好处是联调成本低——前端npm run dev起在 8080 之外的端口通过 devServer 代理打到后端本地开发完全不碰 Nginx部署时前端打包丢进 Nginx 的html目录后端java -jar挂在 8081反向代理一层就完事。版本上别贪新。Spring Boot 2.7 配 JDK 8 是这套代码最稳的组合MyBatis-Plus 3.4.x 的 API 与 3.5 有差异直接抄网上 3.5 的写法会出现selectPage找不到方法这类玄学报错。前端 Vue 2.6 Element UI 2.15不要试图升 Vue 3el-table的插槽写法和$refs调用方式全变了改造成本远超收益。层次技术版本职责后端框架Spring Boot2.7.x容器、自动装配、REST 接口持久层MyBatis-Plus3.4.3单表 CRUD、分页、逻辑删除数据库MySQL8.0业务数据存储认证JJWT0.9.1签发与校验 token前端框架Vue2.6视图与组件UI 库Element UI2.15表格、表单、弹窗请求库Axios0.27统一拦截、错误提示2.1 后端分层与依赖清单后端目录按controller / service / mapper / entity / dto / vo / common / config切这套切法是无数毕设项目磨出来的entity与数据库表一一对应只放字段dto接前端入参带校验注解vo是出参可以把关联表的名字拼进去避免前端拿着 id 二次请求。三者分开写看起来啰嗦但等到要改字段名的时候你会庆幸没把entity直接当接口出入参。lab-server ├── src/main/java/com/lab │ ├── LabApplication.java # 启动类MapperScan 在这里 │ ├── common │ │ ├── Result.java # 统一返回体 {code,msg,data} │ │ ├── BizException.java # 业务异常全局处理器接住 │ │ └── GlobalExceptionHandler.java │ ├── config │ │ ├── WebConfig.java # 跨域 拦截器注册 │ │ └── MybatisPlusConfig.java # 分页插件 │ ├── interceptor/JwtInterceptor.java │ ├── controller / service / mapper / entity / dto / vo │ └── ... └── src/main/resources ├── application.yml ├── mapper/*.xml # 复杂 SQL 写在 XML 里 └── ...依赖只留真正用得到的多一个 starter 就多一份启动失败的可能dependencies !-- Web 层REST 接口与内嵌 Tomcat -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- 持久层单表 CRUD 基本不用手写 SQL -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.4.3/version /dependency !-- JWT登录态放 token服务端不维护 session -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency !-- LombokData 少写几百行 getter/setter -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependenciesjjwt的三个坐标是jjwtjjwt-apijjwt-impl那一套的话版本要严格对齐0.9.1 是老的单坐标写法配 JDK 8 没问题一旦你把项目升到 JDK 11 又混进新版本SignatureAlgorithm.HS256会直接编译不过。配置文件里两个地方必须改数据源和 MyBatis 的驼峰映射。后者不配start_time查出来是startTime还是null全靠运气。server: port: 8081 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver # 三个参数缺一不可中文不乱码、时区不错位、MySQL8 允许公钥检索 url: jdbc:mysql://localhost:3306/lab_manager?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 你的密码 mybatis-plus: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true # 下划线转驼峰漏配就是一堆 null global-config: db-config: logic-delete-field: deleted # 全局逻辑删除字段 logic-delete-value: 1 logic-not-delete-value: 02.2 前端目录与路由权限前端是典型的vue-cli脚手架产物api目录按业务模块拆文件views与菜单一一对应utils/request.js是所有请求的唯一出口。这套结构的好处是改接口地址只动一处加 token 只动一处。lab-web ├── public ├── src │ ├── api # 每个业务一个 js导出函数 │ ├── assets │ ├── components # 通用组件如分页封装、上传 │ ├── layout # 侧边栏 顶栏 内容区 │ ├── router # index.js 静态路由 动态菜单 │ ├── store # Vuex存用户信息和角色 │ ├── utils/request.js │ └── views │ ├── login │ ├── lab # 实验室管理 │ ├── device # 设备管理 │ ├── consumable # 耗材管理 │ ├── reservation # 预约与审批 │ └── system # 用户、角色、日志 └── vue.config.js请求拦截器负责两件事塞 token、统一处理返回码。这里有个容易翻车的点后端返回的是{code:200,msg:,data:{}}这种包装体如果拦截器直接把res返回出去每个页面都得写res.data.data写着写着自己都晕。// src/utils/request.js import axios from axios import { Message } from element-ui import router from /router const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) // 后端拦截器按 Bearer 前缀截取前后必须一致 if (token) config.headers[Authorization] Bearer token return config }) service.interceptors.response.use(res { const { code, data, msg } res.data if (code 200) return data // 页面拿到的直接是业务数据 if (code 401) { // token 过期回登录页 localStorage.clear() router.replace(/login) return Promise.reject(msg) } Message.error(msg || 请求失败) return Promise.reject(msg) }, err { Message.error(err.message || 网络异常) return Promise.reject(err) }) export default service路由守卫管的是能不能进菜单接口管的是看不看得见。两者都得做只做前端隐藏菜单等于没做权限——改个地址栏照样能进。// src/router/index.js 片段 router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.path /login) return next() if (!token) return next(/login) // 路由 meta 里声明 roles进页面前比对本地缓存的角色码 const roles JSON.parse(localStorage.getItem(roles) || []) if (to.meta.roles !to.meta.roles.some(r roles.includes(r))) { return next(/403) } next() })2.3 数据库表设计从实验室到预约单的 12 张核心表数据库是这份资源里最值得先看的部分论文里的系统设计章节基本就是围着它展开。整套库 12 张表按职责分三组权限组、资源组、业务组。表名分组作用关键字段sys_user权限用户学生/教师/管理员username、password、role_id、deptsys_role权限角色定义role_code、role_namesys_menu权限菜单与按钮权限parent_id、path、permssys_role_menu权限角色-菜单关联role_id、menu_idsys_notice权限站内通知receiver_id、content、is_readsys_log权限操作日志user_id、module、ip、create_timelab_info资源实验室lab_name、capacity、location、manager_id、statuslab_device资源设备device_no、lab_id、status、pricelab_consumable资源耗材name、stock、unit、warn_stockdevice_borrow业务设备借还单device_id、user_id、borrow_time、return_time、statusconsumable_record业务耗材领用流水consumable_id、num、user_idlab_reservation业务实验室预约单lab_id、start_time、end_time、status、approver_id预约单是全系统的主线建表时把状态机字段和审批字段一次性设计到位后面加功能不用改表CREATE TABLE lab_reservation ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, lab_id BIGINT NOT NULL COMMENT 实验室ID, user_id BIGINT NOT NULL COMMENT 申请人ID, start_time DATETIME NOT NULL COMMENT 开始时间, end_time DATETIME NOT NULL COMMENT 结束时间, purpose VARCHAR(255) DEFAULT NULL COMMENT 使用用途, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待审批 1已通过 2已驳回 3使用中 4已完成 5已取消, approver_id BIGINT DEFAULT NULL COMMENT 审批人ID, approve_time DATETIME DEFAULT NULL COMMENT 审批时间, approve_remark VARCHAR(255) DEFAULT NULL COMMENT 审批意见, deleted TINYINT NOT NULL DEFAULT 0 COMMENT 逻辑删除, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_lab_time (lab_id, start_time, end_time), KEY idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT实验室预约单;idx_lab_time这个联合索引是为冲突查询量身定的。冲突 SQL 的过滤条件是lab_id ? AND start_time ? AND end_time ?有了它MySQL 会先在lab_id上定位一个小范围再在范围里做时间比对而不是全表扫。等数据量涨到几万条有没有这个索引是 20ms 和 800ms 的差别。3. 核心链路一次实验室预约从提交到审批通过经历了什么这条链路是整个系统的价值所在也是论文里最出彩的关键技术与实现章节。它串起了参数校验、状态机、并发冲突、异步通知四件事写清楚一段抵得上十段增删改查。3.1 预约单的状态机与流转边界状态机不能只写在文档里得落到代码的常量类和更新条件上。六个状态之间允许的迁移是有限的0 待审批只能到1 已通过或2 已驳回1 已通过可以到3 使用中、4 已完成或5 已取消提前取消2/4/5是终态不允许再动。把状态定义成枚举比散落各处的魔法数字强太多public enum ReservationStatus { PENDING(0, 待审批), APPROVED(1, 已通过), REJECTED(2, 已驳回), IN_USE(3, 使用中), FINISHED(4, 已完成), CANCELED(5, 已取消); private final Integer code; private final String desc; ReservationStatus(Integer code, String desc) { this.code code; this.desc desc; } public Integer getCode() { return code; } public String getDesc() { return desc; } // 状态机核心判断从当前状态能否迁移到目标状态 public static boolean canTransfer(Integer from, Integer to) { switch (from) { case 0: return to 1 || to 2; case 1: return to 3 || to 4 || to 5; case 3: return to 4; default: return false; } } }canTransfer这个方法在审批、取消、开始使用、完成四个接口里都会被调用一次。看着是重复劳动但它把状态不能乱跳这条规则收敛到一个地方以后加状态只改这一处。3.2 时间冲突检测一条 SQL 顶掉一整个循环新人最容易写出的冲突检测是把当天所有预约查出来for 循环逐条比对小时。这种写法在实验室数量少、预约量小的时候能跑一旦同一天有几十条记录性能就很勉强而且代码里嵌套的if自己都不想看第二遍。时间区间重叠有数学判定两个区间 A、B 重叠当且仅当A.start B.end AND A.end B.start。注意是严格小于和严格大于这样首尾相接比如 10:00-12:00 和 12:00-14:00不会被误判成冲突——这个边界条件答辩时很爱问。!-- mapper/ReservationMapper.xml -- select idcountConflict resultTypeint SELECT COUNT(1) FROM lab_reservation WHERE lab_id #{labId} AND deleted 0 !-- 只有占用场地的状态才参与冲突判断驳回和取消的放行 -- AND status IN (0, 1, 3) AND start_time lt; #{endTime} AND end_time gt; #{startTime} /selectXML 里和必须转义成lt;gt;否则 MyBatis 解析 mapper 文件时直接抛SAXParseException报错信息指向的行号还可能和真实位置差几行找起来很折磨。服务层把前置校验、冲突校验、落库三步串起来Override Transactional(rollbackFor Exception.class) public Result submit(ReservationDTO dto, Long userId) { // 1. 基础校验时间非空、开始早于结束、不能预约过去的时间 if (dto.getStartTime() null || dto.getEndTime() null || !dto.getStartTime().before(dto.getEndTime()) || dto.getStartTime().before(new Date())) { return Result.fail(预约时间不合法); } // 2. 实验室必须存在且处于开放状态status1 Lab lab labMapper.selectById(dto.getLabId()); if (lab null || lab.getStatus() ! 1) { return Result.fail(实验室不存在或已停用); } // 3. 冲突校验返回的是冲突条数 int conflict reservationMapper.countConflict(dto.getLabId(), dto.getStartTime(), dto.getEndTime()); if (conflict 0) { return Result.fail(该时间段已被占用请换个时间); } // 4. 落库状态置待审批审批人默认取实验室负责人 Reservation entity new Reservation(); BeanUtils.copyProperties(dto, entity); entity.setUserId(userId); entity.setStatus(ReservationStatus.PENDING.getCode()); entity.setApproverId(lab.getManagerId()); entity.setCreateTime(new Date()); this.save(entity); return Result.ok(entity.getId()); }参数说明几处细节dto里只有labId / startTime / endTime / purpose四个字段userId从 token 里解出来绝不能由前端传——这是最经典的越权漏洞approverId不写死成管理员而是跟着实验室负责人走这样以后加多个实验室不用改代码。3.3 审批接口与并发下的重复提交审批接口的坑在于两个人同时点通过。如果先select查状态再update两个请求可能都查到status0都判定可以审批结果同一张单被处理两遍还产生两条通知。正确做法是把状态判断塞进UPDATE的WHERE里用影响行数当判据Override Transactional(rollbackFor Exception.class) public Result approve(Long id, Integer pass, String remark, Long approverId) { Reservation r this.getById(id); if (r null) return Result.fail(预约单不存在); // 先做一次状态机校验给用户友好提示 if (!ReservationStatus.canTransfer(r.getStatus(), pass 1 ? 1 : 2)) { return Result.fail(当前状态不允许审批); } LambdaUpdateWrapperReservation wrapper new LambdaUpdateWrapper(); wrapper.eq(Reservation::getId, id) .eq(Reservation::getStatus, ReservationStatus.PENDING.getCode()) // 乐观条件 .set(Reservation::getStatus, pass 1 ? ReservationStatus.APPROVED.getCode() : ReservationStatus.REJECTED.getCode()) .set(Reservation::getApproverId, approverId) .set(Reservation::getApproveTime, new Date()) .set(Reservation::getApproveRemark, remark); boolean updated this.update(wrapper); if (!updated) { return Result.fail(单据已被处理请刷新后重试); } // 通知先落库前端轮询或下次登录时能看见 noticeService.push(r.getUserId(), 预约审批结果, pass 1 ? 您的预约已通过 : 您的预约被驳回 remark); return Result.ok(); }这里没引分布式锁也没用Version字段靠一条带条件的UPDATE就解决了。单机项目里这是性价比最高的写法UPDATE的WHERE命中行数为 0 就说明状态已经变了直接返回失败让用户刷新。noticeService.push里落的是sys_notice表没做成 WebSocket。毕设场景下没必要为了实时引入长连接前端顶栏每 30 秒拉一次未读数实现成本低、演示效果也在。3.4 设备借还与耗材扣减库存不能靠先查后改耗材领用是另一个高频出错点。先select stock判断够不够再update stock stock - n两个线程同时进来就会超领。标准解法是让数据库来做判断!-- 库存够才减不够直接影响 0 行 -- update iddeduct UPDATE lab_consumable SET stock stock - #{num}, update_time NOW() WHERE id #{id} AND stock #{num} AND deleted 0 /updateOverride Transactional(rollbackFor Exception.class) public void consume(Long consumableId, Integer num, Long userId) { if (num null || num 0) throw new BizException(领用数量必须大于 0); // 影响行数为 0说明库存不足或记录不存在两种情况都该回滚 int rows consumableMapper.deduct(consumableId, num); if (rows 0) { throw new BizException(库存不足请核对领用数量); } // 流水表记录谁在什么时候领了多少用于统计和追溯 consumableMapper.insertRecord(consumableId, num, userId, new Date()); // 低于预警线时给管理员推一条通知 Consumable c consumableMapper.selectById(consumableId); if (c.getStock() c.getWarnStock()) { noticeService.pushToAdmin(耗材库存预警, c.getName() 剩余 c.getStock() c.getUnit()); } }Transactional(rollbackFor Exception.class)里的rollbackFor别省。Spring 默认只对RuntimeException回滚你抛个受检异常事务照样提交扣了库存没记流水账就对不上了。设备借还同理device_borrow插入记录的同时把lab_device.status从 0 改成 1两个操作必须在同一个事务里。4. 把项目跑起来从装 JDK 到前后端联调的完整步骤拿到一份源码跑不起来的概率远高于跑得起来。这套项目的启动顺序是有讲究的先库、再后端、最后前端中间任何一步的顺序颠倒都会得到一堆看不懂的报错。4.1 环境与版本对齐组件版本要求检查命令常见问题JDK81.8.0_2xxjava -version装成 17 后 Lombok 编译报错Maven3.6mvn -v镜像没配依赖下载卡住MySQL8.0mysql --version5.7 缺utf8mb4_0900_ai_ci排序规则Node.js14 或 16node -v上 18 后node-sass装不上npm6.x/8.xnpm -v与 Node 版本不匹配报ERESOLVENode 这块是重灾区。Vue 2 生态里node-sass依赖编译好的二进制包Node 18 配node-sass4基本装不动。要么把 Node 降到 16要么把node-sass换成纯 JS 实现的sassDart Sass后者只改package.json一行加vue.config.js里的sass配置是更省事的路子。Maven 建议在settings.xml里配好国内镜像~/.m2/repository目录空了会重新拉几百兆依赖网速不好的时候能等到怀疑人生。4.2 建库、导脚本、改配置数据库脚本一般放在sql/目录按编号排序。执行顺序不能乱schema建表在前data插初始数据在后有些项目还有menu.sql单独管菜单数据。# 1. 建库字符集必须是 utf8mb4utf8 存不了 emoji 和部分生僻字 mysql -uroot -p -e CREATE DATABASE IF NOT EXISTS lab_manager DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci; # 2. 按序号导入注意 是 shell 的重定向不是 SQL 语法 mysql -uroot -p lab_manager sql/01_schema.sql mysql -uroot -p lab_manager sql/02_data.sql mysql -uroot -p lab_manager sql/03_menu.sql # 3. 验证表数量和初始账号是否进来了 mysql -uroot -p lab_manager -e SHOW TABLES; SELECT username, role_id FROM sys_user;导入完先别急着启动去application.yml把username和password改成你本机的。初始数据里的账号密码通常是加密后的字符串MD5 或 BCrypt登录用的是明文密码具体在操作文档的初始账号一节别自己瞎猜。4.3 后端启动与接口自测cd lab-server # 第一次建议先跳过测试打包有些项目自带测试用例连的是本地库 mvn clean package -DskipTests # 前台启动方便看日志加 --spring.profiles.activedev 可切配置 java -jar target/lab-server-1.0.0.jar # 或者直接在 IDEA 里跑 LabApplication打断点更方便看到Started LabApplication in 6.8 seconds且没有异常栈后端就算起来了。紧接着做一次最小验证——不带 token 请求登录接口# 密码用初始数据里的明文返回体里应该带 token curl -X POST http://localhost:8081/api/login \ -H Content-Type: application/json \ -d {username:admin,password:123456}返回{code:200,data:{token:eyJ...}}说明拦截器白名单配对了。如果这里就返回 401八成是/login没加进拦截器的放行列表去看WebConfig里的excludePathPatterns。4.4 前端启动与跨域处理cd lab-web # 依赖装完再动代码装依赖时别同时改 package.json npm install # 启动开发服务器默认 8080 或 8081 端口 npm run dev前后端端口不同必然跨域。开发环境走 devServer 代理最干净不用后端开 CORS// vue.config.js module.exports { devServer: { port: 9528, proxy: { /api: { target: http://localhost:8081, // 后端地址 changeOrigin: true, // 改了 origin某些鉴权逻辑依赖它 pathRewrite: { ^/api: /api } // 后端接口本身带 /api 前缀就不重写 } } } }pathRewrite这里最容易搞混request.js里baseURL写的是/api后端RequestMapping(/api/reservation)也带/api那就保持原样如果后端接口是/reservation就得写成^/api: 。判断方法很简单浏览器 F12 看请求的完整 URL再和后端RequestMapping拼一下对不上就是这里的问题。5. 常见问题排查启动失败、时间错位、403 这些坑逐个拆跑这套代码的过程中遇到的问题九成集中在四类环境版本、数据库配置、时间与字符集、权限拦截。下面五条是我自己踩过、也帮别人定位过的按现象—原因—解决记下来遇到了直接对号入座。5.1 启动与环境类现象mvn clean package报Fatal error compiling: java.lang.ExceptionInInitializerError: com.sun.tools.javac.code.TypeTag或者 Lombok 相关的一堆cannot find symbol。原因JDK 版本和 Lombok 版本不匹配。项目锁的是 JDK 8 Lombok 1.18.12你本机是 JDK 17Lombok 在编译期反射拿不到对应的TypeTag枚举值直接崩在注解处理器里。解决把 IDEA 的Project Structure → SDK和Maven → Runner → JRE都切到 1.8如果确实要用高版本 JDK把 Lombok 升到 1.18.24 以上同时确认 Spring Boot 版本能撑住2.7 勉强可以2.3 不行。现象Port 8081 was already in use但任务管理器里找不到占用进程。原因上一次启动的 Java 进程没退干净尤其是 IDEA 里点了停止但主线程卡在某个非守护线程上。解决netstat -ano | findstr :8081找 PIDtaskkill /F /PID xxx干掉。Linux 下lsof -i:8081加kill -9。别改端口绕过去改端口只能骗自己。5.2 数据库与数据类现象启动时报Access denied for user rootlocalhost (using password: YES)密码明明是对的。原因application.yml里配的密码和你以为的不是同一个。多环境配置下application-dev.yml会覆盖主配置而你可能只改了主配置。解决java -jar启动时加--spring.profiles.activedev指定配置文件或者直接在启动日志里搜HikariPool那几行它会打印实际用的 jdbc url 和用户名。密码里有、#这类字符的别忘了 URL 编码要写成%40。现象列表页查出来的中文全是???但数据库里用命令行查是正常的。原因JDBC URL 少了characterEncodingutf8或者数据库本身建库时用了latin1。前者是连接层丢字符后者是存储层丢字符。解决先SHOW CREATE DATABASE lab_manager;看字符集不是utf8mb4就ALTER DATABASE改掉再重建表URL 里把useUnicodetruecharacterEncodingutf8补齐。现象预约时间和实际差 8 小时前端选 10:00数据库里存的是 02:00。原因serverTimezone没配或者配成 UTCMySQL 驱动把LocalDateTime按 UTC 解析了。解决URL 加serverTimezoneAsia/Shanghai。如果还是不对在application.yml里补spring.jackson.time-zone: GMT8和date-format: yyyy-MM-dd HH:mm:ss前者管序列化后者管反序列化两个都得配。5.3 前端与权限类现象登录成功了跳转到首页但所有接口都返回 401刷新一下又好了。原因登录接口返回 token 后代码里localStorage.setItem写在router.push之后路由跳转触发的首个请求还没拿到 token。解决把存 token 和存角色信息的代码提到跳转之前或者干脆在login.vue里await完写入再this.$router.push(/)。现象本地开发一切正常打包丢到 Nginx 后刷新页面 404只有首页能打开。原因vue-router用了mode: history去掉#之后刷新时浏览器直接向服务器请求/reservation/listNginx 找不到这个物理路径就 404 了。解决Nginx 加try_files $uri $uri/ /index.html;把所有前端路由的请求兜回index.html让前端路由自己处理。location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; # history 模式必须加 } location /api/ { proxy_pass http://127.0.0.1:8081/api/; }现象审批通过后另一台电脑上的用户还能对同一张单点通过没报错。原因前端列表数据没刷新页面上还是旧状态后端如果用的是先查后改的写法两个请求都能过。解决后端按 3.3 的写法改成条件更新前端在审批成功后调一次列表刷新别只this.dialogVisible false就完事。6. 二次开发在这套骨架上加一个「实验室安全检查」模块这是最值得动手的改造方向因为完整项目的骨架你已经摸熟了加一个模块能同时练到建表、写接口、配权限、加菜单四件事答辩时也容易讲出我做了什么。改造顺序建议从下往上先建两张表检查计划、检查记录再写entity/mapper/service/controller四层最后配菜单和前端页面。检查记录表关联实验室和检查人字段带检查项 JSON 和结论等级CREATE TABLE lab_inspection ( id BIGINT NOT NULL AUTO_INCREMENT, lab_id BIGINT NOT NULL COMMENT 被检查实验室, checker_id BIGINT NOT NULL COMMENT 检查人, check_time DATETIME NOT NULL, items JSON DEFAULT NULL COMMENT 检查项与结果如 [{name:消防器材,pass:true}], level TINYINT NOT NULL DEFAULT 0 COMMENT 0合格 1需整改 2严重, remark VARCHAR(500) DEFAULT NULL, deleted TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_lab_time (lab_id, check_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT实验室安全检查记录;items用 JSON 字段而不是再拆一张明细表是因为检查项本身是配置驱动的项数会变、顺序会变拆表反而带来大量关联查询。MySQL 8 对 JSON 的支持够用取出来反序列化成ListInspectionItem就行。要按某项不合格做统计的话MySQL 8 的JSON_EXTRACT加虚拟列也能兜住。前端照着views/lab复制一份改名字最快改api/lab.js里的接口地址、改表格列、改表单字段。菜单权限记得在sys_menu里插一条perms填inspection:list再给对应角色在sys_role_menu里授权否则页面能打开但接口报 403会卡很久。验证方法我一般走三步先用 Postman 直接打接口绕过前端确认后端逻辑再用不同角色登录确认学生看不到审批按钮最后造两条时间重叠的检查记录看统计图表会不会重复计数。第三步最容易发现问题——很多人写统计只顾着COUNT(*)忘了加deleted 0删掉的数据还占着数。如果是要写进论文代码和文档得对上数据库设计章节放那 12 张表加新表的结构图系统实现章节抓 3.2 的冲突检测和 3.3 的乐观更新这两段讲一个体现算法思维一个体现并发意识比罗列 CRUD 有说服力得多。别把selectById这种也贴进去占篇幅评委一眼就能看出哪些是真讲过。还有个提醒改动之前先git init提一次初始版本。我第一次改这类项目时直接在原始包上动刀改崩了想回退发现连个对照组都没有最后只能重新解压。从那以后我每次拿到一份陌生源码第一件事都是先提交一个init提交改到能跑起来再提一次baseline后面每加一个模块提一次。有这个后悔药在改坏了也不慌。希望帮到你。本文还有配套的精品资源点击获取