简介:这份资源是面向计算机专业应届生的Java毕业设计完整项目包,主题为基于SpringBoot与Vue的大型商场应急预案管理系统,适合需要高分毕设、课程设计或期末作业参考的本科与专科学生。项目已通过导师指导与答辩评审,获得97分成绩,并在Windows 10/11环境下严格调试,下载后可直接运行,部署教程齐全。压缩包共359个文件,约19.98MB,其中92个Java文件承载后端业务逻辑,30个Vue文件构建前端页面,另有SQL数据库脚本、XML与YML配置、PNG与JPG界面截图、MP4演示视频及BAT启动脚本等,覆盖源码、数据库、使用文档与演示视频四类内容。目前已有95人学习关注。读者可从中获得完整的应急预案管理赛题实现方案、前后端分离的工程目录结构、可复用的权限与数据管理模块,以及配套的部署说明与操作录屏,便于快速理解项目架构并完成二次开发或答辩准备。
1. 商场应急预案管理系统到底解决什么问题:从一次消防演练的混乱说起
做过商场运营的人都知道,一次消防演练最怕的不是警报响,而是响完之后没人知道该干什么。楼层经理在群里刷屏问“谁去疏散东区”,保安队长打电话找中控室确认集合点,物业主管翻着三年前的 Word 文档找应急小组名单——这套流程跑下来,演练结束时间比预案里写的多了二十分钟。基于 Springboot+Vue 的大型商场应急预案管理系统,要解决的就是这个信息不对称问题:把预案文本、组织架构、物资清单、演练记录、事件上报全部结构化,让每个角色登录后只看到自己该做的事。
这个选题在计算机毕业设计里属于典型的“管理信息系统”方向,技术栈是 Springboot 做后端接口、Vue 做前后端分离的前台、MySQL 存业务数据。它适合两类人:一是正在找毕设题目的计算机专业学生,需要一套能跑通、能写论文、能演示的系统;二是刚接触 Springboot+Vue 全栈开发、想拿一个完整项目练手的初级开发者。核心难点不在算法,而在业务建模是否合理、权限是否分得清、前后端联调是否顺畅。下面按“先跑起来、再改明白、最后避坑”的顺序拆开讲。
2. 技术选型与系统骨架:为什么用 Springboot+Vue 而不是 JSP 或纯前端
2.1 后端选 Springboot 的三个现实理由
毕业设计最怕的是“环境配三天,代码写两行”。Springboot 的核心价值在于起步依赖和自动配置:引入spring-boot-starter-web就自带 Tomcat 和 Jackson,不用再手动配web.xml和DispatcherServlet。对于应急预案管理系统这种以 CRUD 为主、附带少量定时任务(比如预案到期提醒)的项目,Springboot 的轻量特性刚好匹配。
另一个理由是生态成熟。MyBatis-Plus 能省掉大量单表 SQL,Spring Security 或 Sa-Token 能快速搭起角色权限,Redis 可选做登录态缓存。这些组件在毕设场景下都有大量可参考的配置模板,遇到问题搜索成本低。相比之下,JSP 方案前后端耦合严重,页面里嵌 Java 代码,调试时改一个字段要重启整个应用,效率太低。
第三个理由是部署简单。Springboot 打成可执行 jar 后,java -jar一条命令就能跑,配合 Vue 打包后的静态资源直接放进static目录,整个系统只有一个进程。对于答辩演示来说,这种“单 jar 启动”的方式最不容易翻车。
2.2 前端选 Vue 而不是 Thymeleaf 的考量
Thymeleaf 是服务端渲染,页面跳转由后端控制,适合内容型网站。但应急预案管理系统有大量表格、弹窗、树形组织架构、动态表单,这些交互用 Vue 的组件化开发效率高得多。Vue 的响应式数据绑定让“新增预案后列表自动刷新”这类需求变得自然,不需要手动操作 DOM。
具体到版本选择,Vue 2 + Element UI 是毕设里最稳的组合,资料多、坑少。Vue 3 + Element Plus 更现代,但部分教程和插件还在迁移中。如果学校没有强制要求 Vue 3,建议先用 Vue 2 把功能跑通,后期再考虑升级。路由用vue-router,状态管理用vuex(Vue 2)或pinia(Vue 3),HTTP 请求统一封装axios并加拦截器处理 token 和错误提示。
2.3 数据库表设计的核心五张表
应急预案管理系统的业务实体不算多,但关系要理清。以下是最小可用表结构,字段类型以 MySQL 8.0 为准:
| 表名 | 作用 | 关键字段 |
|---|---|---|
sys_user | 用户与角色 | id, username, password, real_name, role_id, phone |
sys_role | 角色定义 | id, role_name, role_code, remark |
emergency_plan | 预案主表 | id, plan_name, plan_type, level, content, create_time, status |
emergency_team | 应急小组 | id, team_name, leader_id, member_ids, duty, plan_id |
drill_record | 演练记录 | id, plan_id, drill_time, location, participants, result, report |
emergency_plan.content用TEXT存富文本或 Markdown,plan_type区分消防、治安、设备故障等类型,level用枚举值表示一至三级响应。emergency_team.member_ids存逗号分隔的用户 ID,虽然不符合范式,但在毕设规模下查询方便,避免多表关联拖慢开发速度。
2.4 前后端分离的目录结构与跨域配置
后端推荐用 Maven 多模块或单模块分包。单模块结构更简单:
src/main/java/com/example/emergency/ ├── config/ # 跨域、拦截器、Swagger 配置 ├── controller/ # 接口层 ├── service/ # 业务逻辑 ├── mapper/ # MyBatis-Plus Mapper ├── entity/ # 数据库实体 ├── dto/ # 请求/响应对象 └── EmergencyApplication.java前端 Vue 项目用vue-cli或vite创建后,在vue.config.js里配代理解决开发环境跨域:
// vue.config.js module.exports = { devServer: { port: 8081, proxy: { '/api': { target: 'http://localhost:8080', // 后端地址 changeOrigin: true, pathRewrite: { '^/api': '' } // 去掉前缀 } } } }这段配置的含义是:前端开发服务器跑在 8081,所有以/api开头的请求转发到 8080 的后端,并去掉/api前缀。changeOrigin: true让后端收到的 Host 头是目标地址,避免某些安全校验拦截。生产环境打包后,把dist目录内容复制到 Springboot 的src/main/resources/static下,就不需要代理了。
提示:如果后端用了 Spring Security,记得在配置里放行
/api/auth/login和静态资源路径,否则前端页面加载时会被 401 拦住。
3. 从零跑通最小闭环:登录、预案列表、新增预案
3.1 后端登录接口与 JWT 发放
登录是第一个要跑通的接口。用 JWT 做无状态认证,避免 Session 在前后端分离下的跨域问题。引入jjwt依赖后,核心代码如下:
// AuthController.java @PostMapping("/login") public Result login(@RequestBody LoginDTO dto) { // 1. 查用户 User user = userService.getByUsername(dto.getUsername()); if (user == null || !passwordEncoder.matches(dto.getPassword(), user.getPassword())) { return Result.fail("用户名或密码错误"); } // 2. 生成 token,有效期 2 小时 String token = JwtUtil.createToken(user.getId(), user.getRoleCode()); // 3. 返回 token 和用户基本信息 Map<String, Object> data = new HashMap<>(); data.put("token", token); data.put("username", user.getUsername()); data.put("role", user.getRoleCode()); return Result.success(data); }JwtUtil.createToken里把用户 ID 和角色编码放进 payload,签名密钥写在application.yml里,不要硬编码在 Java 文件中。passwordEncoder用 BCrypt,注册时加密存储,登录时用matches比对。返回的Result是统一响应体,包含code、msg、data三个字段,前端拦截器根据code判断是否跳转登录页。
3.2 前端登录页与 token 存储
Vue 登录页用 Element UI 的el-form加校验规则,提交时调用封装好的request.js:
// request.js import axios from 'axios' import { Message } from 'element-ui' import router from '@/router' const service = axios.create({ baseURL: '/api', timeout: 10000 }) // 请求拦截器:带上 token service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) config.headers['Authorization'] = 'Bearer ' + token return config }) // 响应拦截器:统一处理错误 service.interceptors.response.use( res => { if (res.data.code === 401) { localStorage.removeItem('token') router.push('/login') Message.error('登录已过期,请重新登录') } return res.data }, err => { Message.error(err.message || '网络异常') return Promise.reject(err) } ) export default servicelocalStorage存 token 虽然不如 HttpOnly Cookie 安全,但在毕设场景下足够用,且实现简单。请求拦截器自动加Authorization头,响应拦截器统一处理 401 跳转。注意baseURL设为/api,与前面vue.config.js的代理前缀对应。
3.3 预案列表分页查询与条件筛选
预案列表是使用频率最高的页面。后端用 MyBatis-Plus 的分页插件:
// EmergencyPlanController.java @GetMapping("/plan/page") public Result pagePlans(PlanQuery query) { Page<EmergencyPlan> page = new Page<>(query.getPageNum(), query.getPageSize()); LambdaQueryWrapper<EmergencyPlan> wrapper = new LambdaQueryWrapper<>(); // 按名称模糊查 if (StringUtils.hasText(query.getPlanName())) { wrapper.like(EmergencyPlan::getPlanName, query.getPlanName()); } // 按类型精确查 if (StringUtils.hasText(query.getPlanType())) { wrapper.eq(EmergencyPlan::getPlanType, query.getPlanType()); } // 按状态筛选 if (query.getStatus() != null) { wrapper.eq(EmergencyPlan::getStatus, query.getStatus()); } wrapper.orderByDesc(EmergencyPlan::getCreateTime); return Result.success(planService.page(page, wrapper)); }PlanQuery是自定义查询对象,包含pageNum、pageSize、planName、planType、status五个字段。LambdaQueryWrapper的好处是字段名用方法引用,编译期就能发现拼写错误。分页插件需要在配置类里注册MybatisPlusInterceptor并添加PaginationInnerInterceptor,否则page方法不会真正分页。
前端表格用el-table加el-pagination,筛选条件放在表格上方的el-form里,点击查询时重置pageNum为 1 再请求。删除操作加el-popconfirm二次确认,避免误触。
3.4 新增预案表单与富文本内容存储
新增预案的难点在content字段。如果只是纯文本,el-input的type="textarea"就够。但应急预案通常需要加粗、列表、表格,建议用富文本编辑器。毕设里推荐wangEditor或quill,体积小、文档全。
<template> <el-form :model="form" :rules="rules" ref="planForm" label-width="100px"> <el-form-item label="预案名称" prop="planName"> <el-input v-model="form.planName" maxlength="50" show-word-limit /> </el-form-item> <el-form-item label="预案类型" prop="planType"> <el-select v-model="form.planType" placeholder="请选择"> <el-option label="消防" value="fire" /> <el-option label="治安" value="security" /> <el-option label="设备故障" value="equipment" /> </el-select> </el-form-item> <el-form-item label="响应级别" prop="level"> <el-radio-group v-model="form.level"> <el-radio :label="1">一级</el-radio> <el-radio :label="2">二级</el-radio> <el-radio :label="3">三级</el-radio> </el-radio-group> </el-form-item> <el-form-item label="预案内容" prop="content"> <div ref="editor" style="border: 1px solid #dcdfe6;"></div> </el-form-item> <el-form-item> <el-button type="primary" @click="submit">保存</el-button> </el-form-item> </el-form> </template>编辑器初始化在mounted里,提交时用editor.txt.html()取 HTML 内容赋给form.content。后端content字段用TEXT类型,注意 MySQL 的max_allowed_packet默认 4MB,一般够用。如果内容里包含图片,建议图片上传到本地磁盘或对象存储,数据库只存 URL,避免单表体积膨胀。
注意:富文本内容在前端展示时要用
v-html,但必须做 XSS 过滤,否则存在脚本注入风险。毕设里可以用xss库在保存前清洗一遍。
4. 权限、角色与应急小组的联动设计
4.1 三种角色的权限边界
商场应急预案管理系统至少需要三种角色:管理员、应急小组负责人、普通员工。管理员拥有全部菜单和按钮权限;负责人能查看和编辑自己负责的预案、发起演练记录;普通员工只能查看预案和提交事件上报。权限控制分两层:前端路由守卫控制菜单可见性,后端接口注解控制数据访问。
前端路由守卫在router/index.js里配置:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') const role = localStorage.getItem('role') if (to.path === '/login') return next() if (!token) return next('/login') // 检查路由 meta 里的角色要求 if (to.meta.roles && !to.meta.roles.includes(role)) { Message.warning('无权访问该页面') return next(false) } next() })每个路由的meta里写roles: ['admin', 'leader'],普通员工访问时被拦截。后端用自定义注解@RequireRole加拦截器实现,或者直接用 Spring Security 的@PreAuthorize。
4.2 应急小组与预案的关联查询
应急小组表emergency_team通过plan_id关联预案。一个预案可以有多个小组,一个小组也可以服务多个预案,但毕设里为了简化,先做一对多:一个预案下挂多个小组。查询预案详情时,需要把小组列表一起返回:
// EmergencyPlanServiceImpl.java public PlanDetailVO getPlanDetail(Long planId) { EmergencyPlan plan = this.getById(planId); if (plan == null) throw new BusinessException("预案不存在"); // 查关联小组 List<EmergencyTeam> teams = teamMapper.selectList( new LambdaQueryWrapper<EmergencyTeam>() .eq(EmergencyTeam::getPlanId, planId) ); // 查每个小组的负责人姓名 for (EmergencyTeam team : teams) { User leader = userMapper.selectById(team.getLeaderId()); team.setLeaderName(leader != null ? leader.getRealName() : "未指定"); } PlanDetailVO vo = new PlanDetailVO(); BeanUtils.copyProperties(plan, vo); vo.setTeams(teams); return vo; }PlanDetailVO继承预案实体并追加teams字段。BeanUtils.copyProperties做浅拷贝,注意字段名要一致。小组负责人姓名通过userMapper单独查,避免在循环里查数据库导致 N+1 问题——如果小组数量多,应该用selectBatchIds批量查再在内存里匹配。
4.3 演练记录的状态流转
演练记录有“计划中、进行中、已完成”三种状态。状态流转由负责人手动触发,后端做合法性校验:
// DrillRecordController.java @PutMapping("/drill/status") public Result updateStatus(@RequestBody StatusDTO dto) { DrillRecord record = drillService.getById(dto.getId()); if (record == null) return Result.fail("记录不存在"); // 只允许 计划中->进行中->已完成 单向流转 if (record.getStatus() == 0 && dto.getStatus() != 1) { return Result.fail("计划中只能转为进行中"); } if (record.getStatus() == 1 && dto.getStatus() != 2) { return Result.fail("进行中只能转为已完成"); } if (record.getStatus() == 2) { return Result.fail("已完成的记录不可修改"); } record.setStatus(dto.getStatus()); drillService.updateById(record); return Result.success(); }状态值用 0、1、2 表示,前端用el-tag显示不同颜色。这种硬编码的状态机在毕设里够用,但如果状态多、流转复杂,建议用枚举加状态模式,避免if-else堆成山。
4.4 事件上报的实时提醒思路
普通员工提交事件上报后,负责人应该能及时看到。毕设里不必上 WebSocket,用轮询也能实现:前端每 60 秒请求一次未读事件数量,有新增就在导航栏显示红点。后端提供一个/event/unread/count接口,按当前用户角色返回未读条数。
如果想让系统看起来更有技术含量,可以引入 Springboot 的@Scheduled定时任务,每 5 分钟扫描一次超时未处理的紧急事件,给负责人发站内信。站内信表sys_message存receiver_id、content、is_read、create_time,前端在消息中心展示。这个功能代码量不大,但答辩时是一个加分项。
5. 避坑与排查:从环境配置到前后端联调的五个血泪教训
5.1 现象:前端请求一直 404,浏览器控制台报Cannot POST /api/login
原因通常是vue.config.js的代理没生效,或者后端接口路径没加/api前缀。检查三点:devServer.proxy的target是否指向后端真实端口;pathRewrite是否把/api去掉了;后端@RequestMapping是否与前端请求路径匹配。如果后端用了server.servlet.context-path,代理的target要带上这个前缀。
解决:在浏览器 Network 面板看请求的完整 URL,与后端启动日志里的映射路径逐段比对。最稳妥的方式是后端不设context-path,前端代理只做转发不做重写,保持路径一致。
5.2 现象:登录成功但刷新页面后 token 丢失,又跳回登录页
原因是 token 存在localStorage里,但路由守卫在页面刷新时读取时机不对,或者localStorage的 key 拼写不一致。另一个可能是响应拦截器里判断 401 的逻辑过于激进,把正常请求也当成过期处理。
解决:在router.beforeEach里先读 token 再判断,确保localStorage.getItem('token')的 key 与登录时setItem的 key 完全一致。可以在main.js里把 token 读取封装成一个函数,全局统一调用,避免多处拼写错误。
5.3 现象:MyBatis-Plus 分页查询返回全部数据,total为 0
这是分页插件没注册的典型表现。MyBatis-Plus 从 3.4 版本开始,分页功能需要手动配置拦截器,不再自动生效。
解决:在配置类里加:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }DbType.MYSQL指定数据库类型,分页 SQL 会自动适配。如果用了多数据源,需要给每个数据源单独配。
5.4 现象:Vue 打包后放到 Springboot 的 static 目录,刷新页面报 404
前端路由用history模式时,直接访问/plan/list这样的路径,Springboot 找不到对应的静态资源,返回 404。hash模式不会有这个问题,但 URL 里带#不好看。
解决:在后端加一个配置,把所有非 API 请求转发到index.html:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController("/{path:[^\\.]*}") .setViewName("forward:/index.html"); } }这段代码把所有不含点号的路径转发到index.html,由 Vue 路由接管。注意不要拦截/api/**,否则接口请求也会被转发。
5.5 现象:富文本内容保存后,列表页显示一堆 HTML 标签
原因是列表页用{{ plan.content }}直接插值,Vue 默认转义 HTML。如果改成v-html,又可能引入 XSS 风险。
解决:列表页只显示摘要,用plan.content.replace(/<[^>]+>/g, '').substring(0, 50)去掉标签后截取前 50 字。详情页再用v-html渲染完整内容,并在后端保存前用Jsoup.clean(content, Whitelist.basic())清洗。这样既保证列表整洁,又降低安全风险。
6. 让系统更像“高分项目”的两个进阶技巧
6.1 用 ECharts 做应急资源分布看板
答辩时一个直观的图表比十页 CRUD 页面更有说服力。在首页加一个看板,用 ECharts 展示各类型预案数量、演练完成率、应急小组人数分布。后端提供一个/dashboard/stats接口,返回聚合数据:
@GetMapping("/stats") public Result stats() { Map<String, Object> data = new HashMap<>(); // 各类型预案数量 List<Map<String, Object>> planTypeCount = planMapper.selectMaps( new QueryWrapper<EmergencyPlan>() .select("plan_type, count(*) as cnt") .groupBy("plan_type") ); data.put("planTypeCount", planTypeCount); // 演练完成率 long total = drillMapper.selectCount(null); long finished = drillMapper.selectCount( new LambdaQueryWrapper<DrillRecord>().eq(DrillRecord::getStatus, 2) ); data.put("finishRate", total == 0 ? 0 : (double) finished / total * 100); return Result.success(data); }前端用echarts.init绑定 DOM,setOption传入配置。饼图展示类型分布,仪表盘展示完成率。注意在组件销毁时调用chart.dispose(),避免内存泄漏。
6.2 用 EasyExcel 做预案批量导入导出
商场预案往往有几十份历史文档,逐个录入不现实。用阿里 EasyExcel 做批量导入,模板列固定为“预案名称、类型、级别、内容”。导入时逐行校验,类型和级别用枚举匹配,不合法就记录行号返回给前端。
@PostMapping("/plan/import") public Result importPlans(@RequestParam("file") MultipartFile file) { try { List<PlanImportDTO> list = EasyExcel.read(file.getInputStream()) .head(PlanImportDTO.class) .sheet() .doReadSync(); List<String> errors = new ArrayList<>(); for (int i = 0; i < list.size(); i++) { PlanImportDTO dto = list.get(i); if (!Arrays.asList("fire", "security", "equipment").contains(dto.getPlanType())) { errors.add("第" + (i + 2) + "行:类型不合法"); continue; } // 转成实体保存 EmergencyPlan plan = new EmergencyPlan(); BeanUtils.copyProperties(dto, plan); plan.setCreateTime(new Date()); planMapper.insert(plan); } return errors.isEmpty() ? Result.success("导入成功") : Result.fail(String.join(";", errors)); } catch (IOException e) { return Result.fail("文件读取失败"); } }PlanImportDTO的字段用@ExcelProperty("预案名称")注解与表头对应。doReadSync同步读取适合小文件,大文件要用监听器模式。导出用EasyExcel.write(response.getOutputStream(), PlanExportDTO.class).sheet("预案列表").doWrite(list),设置响应头Content-Type为application/vnd.ms-excel。
这两个功能加上去,系统就从“能跑”变成“有亮点”。我自己的习惯是:先把核心 CRUD 跑通,再挑两个不复杂但视觉效果好的功能做深,答辩时主动演示这两个点,比泛泛介绍整个系统更抓人。希望帮到你。
本文还有配套的精品资源,点击获取