简介:基于Java+SpringBoot+Vue+MySQL的养老院管理系统完整源码包,主要面向计算机相关专业学生的毕业设计、课程设计与期末大作业,解决养老院日常运营中的数字化管理问题,系统功能完善、操作便捷。系统覆盖老人信息管理、员工管理、财务统计、日常活动安排、健康监测等核心模块,前后端分离架构,界面简洁、操作直观,具备较高的实际应用价值和良好的可扩展性。资源压缩包包含591个文件,主要有123个Java后端源码、91个Vue前端页面、SQL数据库脚本、部署运行脚本以及配置和设计文档,包体大小约为20.4MB,部署环境为JDK、Maven、MySQL5.7以上,搭配IDEA与Navicat可快速运行,整体结构清晰便于学习。目前已有49人学习下载。项目经严格调试确保可运行,目录清晰、代码完整,适合学习SpringBoot与Vue整合开发,也可在此基础上根据养老院具体需求进行功能扩展。
1. 这个毕设项目能拿高分,靠的不是堆功能而是把权限和业务流程讲透
接手这类「养老院管理系统」毕设项目的同学,大多会陷入同一个误区:先把老人管理、床位管理、缴费管理这些 CRUD 页面全做出来,觉得页面多就等于功能全。实际上评阅老师打开系统第一个看的是登录和权限,第二个看的是核心业务闭环是否跑得通——养老院的核心不是「登记老人信息」,而是「护理任务怎么派发、执行、留痕、统计」。基于 java + Spring Boot + Vue + MySQL 这套技术栈做养老院管理系统,正确做法是先建一套能自圆其说的角色权限模型(管理员、护理员、家属),再把「入住登记 → 护理计划 → 执行打卡 → 异常上报」这条链走通,最后才补缴费和统计报表。本文从前端 Vue 到后端 Spring Boot 再到 MySQL 表结构,把一条能演示、能答辩、能改造成自己项目的高分路径完整落地。
2. 养老院业务建模与 MySQL 表设计:先把三张核心表画出闭环
2.1 养老院管理系统的角色与权限边界
养老院管理系统和普通管理系统最大的区别在于它的使用者横跨三个完全不同的角色:管理员关注全局(床位利用率、收费情况、护工人数配比),护理员关注每日任务(今天要给哪些老人翻身、喂药、测血压),家属只关心自己老人的动态(近一周的照护记录、健康指标)。这决定了权限模型必须做到「数据级隔离」——家属登录后不应该能看到其他老人的信息,甚至不应该看到床位列表。
我的做法是引入三张基础表:sys_user(用户表)、sys_role(角色表)、sys_user_role(用户角色关联表),不做菜单按钮级的那种复杂 RBAC 细粒度权限,因为毕设答辩时你很难在三分钟内把「用户-角色-菜单-按钮」四层关系讲清楚。用用户 + 角色 + 数据权限字段(比如elder_id)反而更实用。后端接口通过拦截器校验角色,再通过 SQL 拼接WHERE elder_id = ?做行级隔离。
2.2 三张核心业务表:elder_info、care_plan、care_record
业务表不需要设计太多,八到十张足够拿高分,但三张表必须精心设计:elder_info(老人档案)、care_plan(护理计划)、care_record(护理执行记录)。这三张表构成了养老院系统的业务闭环:老人入住后,护理员或管理员为其制定护理计划,护理员每日按计划执行并打卡,打卡记录沉淀为数据,最终呈现在统计报表和家属查看的页面里。
CREATE TABLE `elder_info` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '老人ID', `name` varchar(32) NOT NULL COMMENT '姓名', `gender` tinyint(1) DEFAULT '1' COMMENT '性别 1男 2女', `age` int(11) DEFAULT NULL COMMENT '年龄', `id_card` varchar(18) DEFAULT NULL COMMENT '身份证号', `room_no` varchar(16) DEFAULT NULL COMMENT '房间号', `bed_no` varchar(16) DEFAULT NULL COMMENT '床位号', `health_level` varchar(8) DEFAULT 'A' COMMENT '护理等级 A/B/C', `emergency_contact` varchar(32) DEFAULT NULL COMMENT '紧急联系人', `emergency_phone` varchar(20) DEFAULT NULL COMMENT '紧急联系电话', `status` tinyint(1) DEFAULT '1' COMMENT '状态 1在院 0退住', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1001 DEFAULT CHARSET=utf8mb4 COMMENT='老人信息表'; CREATE TABLE `care_plan` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `elder_id` bigint(20) NOT NULL COMMENT '老人ID', `plan_name` varchar(64) NOT NULL COMMENT '计划名称', `care_type` varchar(16) NOT NULL COMMENT '护理类型:血压/翻身/喂药/清洁', `frequency` varchar(32) DEFAULT NULL COMMENT '频率:每天3次/每2小时', `start_date` date DEFAULT NULL, `end_date` date DEFAULT NULL, `status` tinyint(1) DEFAULT '1' COMMENT '1启用 0停用', `create_by` bigint(20) DEFAULT NULL COMMENT '创建人', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_elder` (`elder_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='护理计划表'; CREATE TABLE `care_record` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `plan_id` bigint(20) NOT NULL COMMENT '护理计划ID', `elder_id` bigint(20) NOT NULL COMMENT '老人ID', `nurse_id` bigint(20) NOT NULL COMMENT '执行护理员ID', `care_type` varchar(16) NOT NULL COMMENT '护理类型', `result` varchar(255) DEFAULT NULL COMMENT '执行结果/备注', `record_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '执行时间', PRIMARY KEY (`id`), KEY `idx_elder_time` (`elder_id`, `record_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='护理执行记录表';逻辑说明:care_record不直接关联elder_info,而是通过care_plan间接关联,同时在表中冗余一个elder_id字段。这是刻意设计的——查询「某位老人的所有护理记录」时,直接走idx_elder_time索引,避免每次都要 JOIN 到care_plan表才能拿到老人 ID。代价是插入记录时要冗余写入elder_id,但查询性能收益远大于存储成本。health_level字段用 A/B/C 表示护理等级,A 级代表完全不能自理,护理计划频率更高,这个字段在统计页会用于计算护理工作量权重。
2.3 数据库初始化脚本的写法:演示数据比表结构更重要
表结构设计好之后,评分差距往往体现在初始化脚本上。常见做法是准备两个文件:schema.sql建表、data.sql灌数据。data.sql里要预置三种角色账号(管理员 admin、护理员 nurse01、家属家属账号),以及 20 条左右的老人档案数据、每个老人对应的护理计划和最近 7 天的护理记录。答辩时演示「展示最近一周护理趋势图」,如果没有这 7 天的历史数据,前端图表就是空的,再好的 ECharts 配置也没用。
注意data.sql里插入中文数据时,MySQL 连接串必须显式指定characterEncoding=utf8并在建库时设置DEFAULT CHARSET=utf8mb4,否则 Windows 环境下很容易出现乱码。另外一个实用技巧:在application.yml里配置spring.sql.init.mode=always并配合spring.sql.init.continue-on-error=true,这样重新启动项目时会自动执行建表和灌数据脚本,省去手动source的步骤。
3. Spring Boot 后端实现:从登录鉴权到护理打卡的完整链路
3.1 用 JWT + 拦截器实现登录状态管理,不引入 Spring Security
很多毕设项目会引入 Spring Security + JWT 的组合,但 Spring Security 的过滤器链配置对于第一次做项目的同学来说学习曲线太陡,答辩时也容易被追问「过滤器执行顺序」这类细节。我更推荐的做法是:用 JWT 做无状态登录,自己写一个HandlerInterceptor做统一拦截。核心依赖只需要jjwt一个库,代码量控制在 100 行以内,逻辑一目了然。
// JwtUtil.java @Component public class JwtUtil { // 注意:正式项目密钥要放到配置文件中,这里仅为演示 private static final String SECRET = "nursing-home-secret-key"; private static final long EXPIRE_TIME = 24 * 60 * 60 * 1000L; // 24小时 public String createToken(Long userId, String username, String role) { return Jwts.builder() .setSubject(username) .claim("userId", userId) .claim("role", role) .setExpiration(new Date(System.currentTimeMillis() + EXPIRE_TIME)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public Claims parseToken(String token) { return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody(); } }参数说明:claim("role", role)把角色放进 token 里,后续接口判断权限时不需要查数据库;EXPIRE_TIME设置为 24 小时,足以覆盖一次完整的毕设演示周期。HS256 是对称签名算法,密钥在前后端分离架构下保存在后端即可。需要补充的是,jjwt库版本 0.9.x 与 0.11.x 的 API 差异很大,后者要求使用Keys.hmacShaKeyFor()传入字节数组密钥,如果 maven 拉的是新版,上面这段signWith(SignatureAlgorithm.HS256, SECRET)会编译报错。建议锁定<jjwt.version>0.9.1</jjwt.version>,或用新版写法适配。
3.2 通过拦截器实现角色权限校验,每个接口自己做判断还是统一拦截
登录校验和权限校验交给拦截器做,业务接口里就能省掉「从 session 里取用户、判断是否登录」的重复代码。
// AuthInterceptor.java @Component public class AuthInterceptor implements HandlerInterceptor { @Autowired private JwtUtil jwtUtil; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { response.setStatus(401); return false; } try { Claims claims = jwtUtil.parseToken(token.substring(7)); request.setAttribute("userId", claims.get("userId")); request.setAttribute("role", claims.get("role")); return true; } catch (Exception e) { response.setStatus(401); return false; } } }要点说明:request.setAttribute把解析后的用户信息放进请求上下文,Controller 里通过(Long) request.getAttribute("userId")直接取,不需要再写解析 token 的工具方法。OPTIONS请求放行是前后端分离项目最容易忽略的点——Vue 开发服务器向 Spring Boot 发请求时有跨域预检,预检请求不会携带自定义 Header,如果拦截器不放行预检,前端控制台会不断报跨域错误。
角色级权限校验我用@RequireRole自定义注解 + 在拦截器里读取 HandlerMethod 的注解做判断。代码逻辑:如果方法上有 @RequireRole("nurse"),就用 request.getAttribute("role") 比对,不一致就返回 403。这样新增一个接口时只需要在方法上加注解,权限规则集中管理。
3.3 护理打卡接口:一个典型的事务与状态流转场景
@RestController @RequestMapping("/api/care") public class CareRecordController { @Autowired private CareRecordService careRecordService; @PostMapping("/record") public Result<?> record(@RequestBody CareRecordDTO dto, HttpServletRequest request) { Long nurseId = (Long) request.getAttribute("userId"); careRecordService.executeCare(dto, nurseId); return Result.success("护理记录提交成功"); } }Service 层核心逻辑里,我先校验护士权限以及dto.getPlanId()对应的护理计划是否处于启用状态,再插入一条care_record数据,同时更新care_plan表里对应记录的最近执行时间。
// CareRecordServiceImpl.java @Transactional(rollbackFor = Exception.class) public void executeCare(CareRecordDTO dto, Long nurseId) { CarePlan plan = carePlanMapper.selectById(dto.getPlanId()); if (plan == null || plan.getStatus() != 1) { throw new BusinessException("护理计划不存在或已停用"); } CareRecord record = new CareRecord(); record.setPlanId(dto.getPlanId()); record.setElderId(plan.getElderId()); record.setNurseId(nurseId); record.setCareType(dto.getCareType()); record.setResult(dto.getResult()); careRecordMapper.insert(record); // 这里做计划进度更新 carePlanMapper.updateLastExecTime(dto.getPlanId(), new Date()); }参数说明:@Transactional(rollbackFor = Exception.class)指定了任何异常都回滚,包括自定义的BusinessException。这里没有用默认的RuntimeException回滚策略,目的是让业务异常(比如计划已停用)也触发事务回滚,避免出现「提示失败但数据已写入」的脏数据。此外,CareRecordDTO包含planId、careType、result三个字段,其中careType是从前端下拉菜单传来的,但真正落库时以plan表里的类型为准——防止前端传错类型捣乱。
3.4 Spring Boot 版本相关的三个常见坑
毕设项目最常见的运行环境差异集中在 Spring Boot 2.7.x 与 3.x 之间。如果你的 pom 里用的是 3.x,javax.servlet全部要换成jakarta.servlet,springdoc替代springfox做接口文档。还有一个高频问题:Spring Boot 3.x 中WebMvcConfigurer的方法签名不变但包名变了,拦截器注册代码里的import org.springframework.web.servlet.config.annotation.InterceptorRegistry是不受影响的,受影响的是HandlerInterceptor的javax.servlet.http.HttpServletRequest引用。推荐直接用 2.7.18 版本,资料最多且网上搜到的配置代码基本都能直接运行。
另一个影响较大的坑是 MySQL 驱动坐标:8.x 版本的驱动类名是com.mysql.cj.jdbc.Driver,但 5.x 是com.mysql.jdbc.Driver。如果 pom 里引了mysql-connector-java8.0.x,配置文件写错驱动类名,启动时直接报ClassNotFoundException。我的习惯是直接指定runtimeOnly 'com.mysql:mysql-connector-j:8.0.33'(新版坐标,groupId 不变,artifactId 变了)。
4. Vue 前端实现:路由守卫、动态菜单与 axios 拦截器
4.1 前端工程结构与 Vue 环境搭建要点
前端部分采用 Vue 2 + Element UI 还是 Vue 3 + Element Plus,取决于你本地 Node 版本。Vue 3 要求 Node.js 16+,但很多教学机上的 Node 还是 14.x,这会直接导致npm install阶段报ERESOLVE错误。如果遇到这种情况,一种可复现的做法是降低依赖版本而不是升级 Node——在package.json中将element-plus锁到2.2.x版本,同时用npm install --legacy-peer-deps绕开依赖冲突检查。另一个更省事的选择是 Vue 2.7 + Element UI 2.15.x,对 Node 版本要求宽得多,几乎不需要额外配置就能跑起来。
项目目录组织方面,毕设级别的前端不建议搞太复杂的 monorepo 结构,src/api放所有接口调用、src/router放路由配置、src/store放 Vuex(或 Pinia)状态、src/views按业务模块分目录即可。下面重点说三个必写文件:axios封装、路由守卫、权限指令。
4.2 用 axios 拦截器统一处理 token 注入与 401 跳转
// src/utils/request.js import axios from 'axios' 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) { // 与后端拦截器约定的格式:Authorization: Bearer <token> config.headers['Authorization'] = `Bearer ${token}` } return config }, error => { return Promise.reject(error) }) // 响应拦截器:401 跳登录页 service.interceptors.response.use(response => { // 后端统一返回 { code, message, data } 结构 const res = response.data if (res.code !== 200) { // Message.error(res.message) // Element UI 的全局提示 return Promise.reject(new Error(res.message)) } return res.data }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(error) }) export default service参数说明:baseURL: '/api'配合 Vue 开发服务器的proxy配置,把请求转发到后端 8080 端口。如果直接写http://localhost:8080,会产生跨域问题,虽然后端可以配置CorsFilter解决,但本地开发用 proxy 更干净——发出去的请求是相对路径,打包部署到生产环境时不需要改代码。响应拦截器里将res.data解包返回,业务代码中const res = await api.getElderList()拿到的就是data部分。
4.3 路由守卫控制访问权限,菜单按角色动态渲染
// src/router/index.js const routes = [ { path: '/login', component: () => import('@/views/Login.vue') }, { path: '/', component: Layout, redirect: '/dashboard', meta: { roles: ['admin', 'nurse', 'family'] }, children: [ { path: 'dashboard', name: 'Dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: '数据看板', icon: 'Odometer' } }, { path: 'elder', name: 'ElderList', component: () => import('@/views/elder/ElderList.vue'), meta: { title: '老人档案', roles: ['admin', 'nurse'] } }, { path: 'family', name: 'FamilyView', component: () => import('@/views/family/FamilyView.vue'), meta: { title: '家属查看', roles: ['family'] } } ] } ]路由守卫的核心逻辑是:读取本地存储中的用户角色,与路由meta.roles比对,没有权限就重定向到 404 或首页,避免用户手动输入 URL 越过菜单访问接口页面。这只是前端的 UI 层限制,真正的数据安全靠的是第二章设计的后端行级权限——前端隐藏菜单只是不让用户看到入口,防不住有人通过控制台直接调用 API,后端必须再拦一道。
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (!token && to.path !== '/login') { next('/login') return } if (to.meta.roles) { const role = localStorage.getItem('role') if (!to.meta.roles.includes(role)) { next('/403') return } } next() })菜单动态渲染的做法是用 Vuex 存储menus数组,登录后根据角色过滤routes中的children,遍历生成菜单项。这里的核心是「根据角色过滤」逻辑,写页面时不要为了省事把菜单写死,否则答辩时老师让切换角色登录,前端菜单不变,就会被认为是写死的假权限。
4.4 Vue 页面组件与后端的对接写法
以「护理记录提交页面」为例,展示页面的基础结构:表单、提交按钮、调用后端接口。这里有一个在答辩演示中很加分的细节:提交成功后不跳转页面,而是用 Element UI 的$message.success提示,并且把列表刷新和表单重置放在同一个 Promise 链里执行。
<template> <div class="care-record-page"> <el-form :model="form" ref="formRef" label-width="100px"> <el-form-item label="护理计划" prop="planId"> <el-select v-model="form.planId" placeholder="请选择护理计划"> <el-option v-for="plan in planList" :key="plan.id" :label="plan.planName" :value="plan.id" /> </el-select> </el-form-item> <el-form-item label="护理类型" prop="careType"> <el-input v-model="form.careType" placeholder="如:测血压" /> </el-form-item> <el-form-item label="执行结果" prop="result"> <el-input type="textarea" v-model="form.result" placeholder="填写检测结果或备注" /> </el-form-item> <el-form-item> <el-button type="primary" @click="submitForm">提交记录</el-button> </el-form-item> </el-form> </div> </template> <script> import { getPlanList, submitCareRecord } from '@/api/care' export default { data() { return { form: { planId: '', careType: '', result: '' }, planList: [] } }, created() { this.loadPlans() }, methods: { async loadPlans() { const res = await getPlanList() this.planList = res }, async submitForm() { await submitCareRecord(this.form) this.$message.success('护理记录已提交') this.form = { planId: '', careType: '', result: '' } } } } </script>附带参数与代码逻辑的展开说明:这里调用的是第二章里设计的care/record接口,接口本身会校验userId和角色。前端表单的作用仅仅是收集数据并展示结果,页面本身不包含任何权限校验逻辑——权限校验应当在路由守卫与后端接口处完成,不要在页面里用if (role === 'admin')这种代码控制可见性,因为这种控制方式在v-if被绕过时(比如直接改内存中的 role 值),会导致无权限用户接触数据。
5. 答辩与运行验证:一份 60 秒快速演示的 MySQL 数据回放脚本
毕设项目和工业项目的区别在于:演示效果直接影响评分。我建议准备一个专门用于答辩的 MySQL 数据生成脚本,把平时积累的散数据替换成一套完整的演示数据。核心技巧是构造「过去 7 天」的护理记录,让前端 ECharts 趋势图有数据可画。
-- demo_data.sql -- 生成最近7天每个老人每天3条护理记录 SET @start_date = DATE_SUB(CURDATE(), INTERVAL 7 DAY); INSERT INTO care_record (plan_id, elder_id, nurse_id, care_type, result, record_time) SELECT cp.id, cp.elder_id, e.nurse_id, cp.care_type, CASE cp.care_type WHEN '测血压' THEN CONCAT(120 + FLOOR(RAND() * 20), '/', 80 + FLOOR(RAND() * 10), ' mmHg') WHEN '测血糖' THEN CONCAT(5.0 + RAND() * 2, ' mmol/L') ELSE '正常' END AS result, -- 生成从7天前到现在的随机时间 DATE_ADD(@start_date, INTERVAL FLOOR(RAND() * 7 * 24 * 60) MINUTE) FROM care_plan cp JOIN elder_info e ON cp.elder_id = e.id WHERE cp.status = 1;这段脚本用RAND()生成随机时间戳和随机指标值,保证图表数据有波动——如果所有数据点斜率完全一致,一眼就能看出是伪造的。使用前先执行一次TRUNCATE care_record,清空手点产生的零散数据。执行后,在系统首页的「近7日护理趋势」图表上就能看到三条线(血压、血糖、翻身)的自然起伏。
答辩时容易问到的追问,整理成一张速查表:
| 追问方向 | 建议应答 | 说出这句话对应的代码证据 |
|---|---|---|
| 为什么用 JWT 不用 Session | 无状态、横向扩展友好,适合前后端分离 | JwtUtil.createToken不含任何 Session 存储 |
| 多角色权限怎么控制的 | 登录发放角色 claim,后端拦截器校验注解 | AuthInterceptor中读取@RequireRole |
| 若缓存被删除如何保证一致性 | 数据一致性靠事务保证,缓存只是加速 | executeCare上的@Transactional注解 |
| 养老院的护理单一与普通 CRUD 有何不同 | 护理计划与执行记录构成业务闭环,涉及状态机 | care_plan.status与care_record的联动更新 |
| 若老人退住如何处理历史记录 | 不做物理删除,仅更新状态字段与恢复默认密码 | elder_info.status字段与sys_user的因果逻辑 |
这份脚本和速查表,是用 30 分钟换答辩现场 10 分钟的从容。演示前两周运行时若有时间拿几个账户分别跑一遍「登录 → 各模块转一圈 → 提交一条护理记录」,手机录屏回放三层页面的逻辑,比背代码更记得住。最后,系统里留一个「重置密码」的按钮,万一现场演示时录入的测试账号密码被记错,也不需要关服务器改库。
本文还有配套的精品资源,点击获取