资讯动态

SpringBoot2+Vue3养老院管理系统实战:从零搭建到权限设计全复盘

发布时间:2026/10/14 4:18:25 来源:尧图企业网站定制
养老院管理系统从零搭建SpringBoot2 Vue3 这套组合的实战复盘先说说我为什么盯上这个项目。这几年做过了不少高校的课程设计和毕业设计Java Web 方向的老管理系统宿舍管理、图书馆管理、实验室预约基本是标配但养老院管理系统是个很有意思的细分场景——它既有传统 CRUD 的骨架又有护理流程、床位状态、健康档案这些贴近真实业务的模块拿来练手或者直接改造成商用小系统都很合适。这套系统的技术组合是SpringBoot2 Vue3 MyBatis-Plus MySQL8.0从后端持久层到前端组件化开发全链路打通还自带项目文档。我拿到手第一反应是这年头很多同学还在用 JSP 或者单体的 Vue2 写管理系统这套组合明显更贴近当前企业里中小型项目的实际技术栈。今天就以这个项目为例把我拆解源码、重新搭建、踩坑修复的完整过程梳理出来给正准备做 Management 类系统的朋友当个参考。先给一个定位这个项目适合什么水平的人如果你已经能独立写出 SSM 或 SpringBoot 的增删改查但对 Vue3 的组合式 API、MyBatis-Plus 的 LambdaQueryWrapper 还停留在看过没用过的阶段那这套代码就是很好的对照样本。如果你是纯小白建议先跑通官方文档的快速开始再回来看这篇。文章里所有代码和表结构我都重新梳理过基于常见实践做了不少补充说明你可以直接照着抄。1. 为什么是SpringBoot2 Vue3而不是别的组合选型这件事很多教程一句话带过但我实际动手之后发现这里面的门道比想象中多。1.1 SpringBoot2 的生态成熟度仍然很能打SpringBoot3 出来之后很多人纠结要不要直接上 3.x。这套系统选择 SpringBoot2 不是落后恰恰是稳妥。我拆解代码时注意到项目用的是SpringBoot 2.7.x配合MyBatis-Plus 3.5.x这套搭配在社区里的踩坑记录已经非常充分遇到任何问题几乎都能搜到解决方案。还有个很实际的原因很多高校的课程设计和毕业设计服务器环境还是 JDK8而 SpringBoot3 强制要求 JDK17这就直接把大半人的本地环境卡死了。SpringBoot2 在 JDK8 下跑得稳稳当当部署成本也低。如果你是自己学习机器上有 JDK8那这套项目就是零额外成本。再说 MyBatis-Plus它和 MyBatis 的区别用一个例子就能说清。传统 MyBatis 写一个分页查询要手写 XML、配置拦截器而 MyBatis-Plus 提供了Page对象加selectPage方法// MyBatis-Plus 内置分页不用写 XML PageElderInfo page new Page(current, size); LambdaQueryWrapperElderInfo wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.isNotBlank(name), ElderInfo::getName, name); wrapper.eq(StringUtils.isNotBlank(roomNo), ElderInfo::getRoomNo, roomNo); IPageElderInfo result elderInfoService.page(page, wrapper);这一小段代码就能覆盖姓名模糊搜索 房间号精确筛选 分页三个最常见的查询场景而且是在 Service 层直接调用不用写一行 XML。对于养老院管理这种以单表 CRUD 为基础的场景MyBatis-Plus 的 LambdaQueryWrapper 几乎可以消灭掉大半手写 SQL。1.2 Vue3 Element Plus 带来的前端体验升级早年的管理系统前端大多是 Vue2 Element UI组件写法偏命令式。Vue3 的组合式 APIsetup 语法糖让逻辑复用变得非常直观。我印象很深的是这个项目里老人档案模块的写法数据列表、表单弹窗、分页状态全部用ref和reactive收纳在一个script setup里比 Options API 的data / methods / computed三件套清爽得多。比如这个典型的列表页状态管理script setup import { ref, reactive, onMounted } from vue import { getElderPage, deleteElder } from /api/elder const loading ref(false) const pageInfo reactive({ current: 1, size: 10, total: 0, records: [] }) const queryParams reactive({ name: , roomNo: }) async function loadData() { loading.value true try { const res await getElderPage({ ...queryParams, ...pageInfo }) pageInfo.records res.records pageInfo.total res.total } finally { loading.value false } } function handleDelete(row) { ElMessageBox.confirm(确认删除老人 ${row.name} 的档案吗, 提示, { type: warning }) .then(async () { await deleteElder(row.id) ElMessage.success(删除成功) loadData() }) .catch(() {}) } onMounted(loadData) /script这种结构对新手非常友好reactive管响应式对象ref管单个值onMounted代表页面加载后自动请求数据。每个函数只做一件事逻辑链路清清楚楚。2. 数据库设计的核心看点床位状态与护理记录的双线联动管理系统这种东西页面是皮数据库才是骨。我花了一整个下午把项目的 SQL 脚本从头到尾读了一遍最大的感受是表不是越多越好而是要看每张表能不能撑起一条真实业务线。养老院管理系统的业务无非三大块居住管理床位、健康与护理、费用与家属。2.1 房间-床位-老人三表如何关联很多初学设计数据库的人会把房间号直接写成老人表的一个字段。这个项目没有这么做而是拆出了房间表room、床位表bed、老人表elder_info。room表存房号、房型单人间/双人间/多人间、楼层、房间状态。bed表存床位编号、所属房间 ID、床位状态空闲/占用/维修。elder_info表存老人基本信息同时挂bed_id外键。为什么要这样拆因为养老院的管理粒度是一张床。你想想办理入住时操作员要选的是3 楼 305 房间的 2 号床而不是305 房间。若把床位直接塞进房间表一旦老人退住、换床房间表就要跟着改而且很难查哪些床空着。拆成独立表之后查询空床位就是一个简单的状态筛选SELECT b.id, b.bed_no, r.room_no, r.floor FROM bed b LEFT JOIN room r ON b.room_id r.id WHERE b.status 空闲;我实际验证过配合 MyBatis-Plus 在 Service 层写这个查询加上分页响应时间在本地基本是 40ms 以下。这种查询高频、低频都能扛住没有性能焦虑。2.2 护理记录的平铺设计思路护理记录表nursing_record是另一个值得细看的点。它的字段包括老人 ID、护理类型用药/血压测量/翻身/洗浴等、护理内容、护理人 ID、护理时间、备注。表面上就是普通的业务流水表但厉害的地方在于它没有做横向拆分。什么意思呢有同学做类似系统时会习惯性设计成老人表里加最近一次血压最近一次用药时间这种冗余字段。这个项目没有这么做而是用一张平铺的流水表记录每一次护理动作需要看某个老人的护理历时就查这张表需要统计每日护理量就按日期分组聚合。这种设计的直接受益者是后续的统计报表模块。比如首页可能展示今日完成护理次数// 按日期统计今日护理次数 QueryWrapperNursingRecord queryWrapper new QueryWrapper(); queryWrapper.select(DATE(create_time) as date, count(*) as total) .ge(create_time, LocalDate.now().atStartOfDay()) .groupBy(DATE(create_time)); ListMapString, Object maps nursingRecordMapper.selectMaps(queryWrapper);平铺流水表 分组聚合无论是做 ECharts 折线图还是饼图都能直接用 SQL 拿到现成的数据源不用在 Java 代码里做二次加工。这一点是非常务实的决策。2.3 权限设计用 RBAC 但不做深系统的权限模块用了最经典的 RBAC 三件套用户表、角色表、用户角色关联表。菜单权限走得是角色拥有菜单集合的思路登录后根据角色类型返回对应的菜单和按钮权限标识。没有引入 Spring Security 的重型过滤器链而是用拦截器 自定义注解的方式实现接口鉴权。这个设计我估计会被部分人批评不够安全但平心而论对养老院管理系统这种内部使用、角色类型固定的场景它是够用的。角色无非三种管理员、医护、前台。接口层面加上登录校验和简单的权限判断数据层面不加复杂的行级权限逻辑清晰代码量也少。真要引入 Spring Security Redis Token 那一整套学习成本陡增对项目本身的价值却有限。我自己改造时在拦截器里加了一个操作日志逻辑避免在每个 Controller 重复写日志记录代码public class OperLogInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (handler instanceof HandlerMethod) { HandlerMethod handlerMethod (HandlerMethod) handler; OperLog annotation handlerMethod.getMethodAnnotation(OperLog.class); if (annotation ! null) { // 通过 IoC 容器获取日志服务记录操作人、操作时间、请求参数 String operName SecurityUtils.getCurrentUsername(); logService.save(operName, request.getRequestURI(), JSON.toJSONString(request.getParameterMap())); } } return true; } }这样一来任何接口方法只要加上OperLog(修改老人档案)注解就会自动记录操作日志。整套系统的审计能力就靠这个拦截器撑起来了。3. 关键业务流程拆解从入住登记到退住结算纸上谈兵设计完了架构和表接下来要落到真实业务链条上。我选了三个最容易出问题的模块逐一拆解它们在代码里的实现路径。3.1 入住登记事务边界到底画在哪办理入住的流程请求从前端“入住登记”表单发起后端业务逻辑大致是校验老人身份证唯一性——不能重复入住。校验选中的床位状态为“空闲”。创建老人档案记录写入elder_info。更新床位状态为“占用”。创建一条入住流水记录。调用家属联系人模块写入紧急联系人信息。这六步必须保证“要么全部成功要么全部失败”。如果只在 Controller 里一个个调用 Service 方法中途第二步失败第一步已经写入的档案就变成脏数据。这个项目直接在服务层用Transactional注解圈定事务边界非常标准。但这里有一个隐蔽的坑先校验再插入并发时仍有空隙。如果两个请求同时查到床位空闲然后同时插入床位状态就可能被更新两次。更可靠的做法是在更新床位状态的 SQL 上加上条件boolean updated bedService.update( new LambdaUpdateWrapperBed() .eq(Bed::getId, bedId) .eq(Bed::getStatus, 空闲) .set(Bed::getStatus, 占用) ); if (!updated) { throw new RuntimeException(床位已被占用请刷新后重试); }这种乐观锁式的条件更新从根上堵住了并发抢占床位的可能。虽然养老院系统的并发量极低但这个思路放在任何资源抢占型业务里都成立值得养成习惯。3.2 护理工单的前端状态流转老人健康出现状况时医护在前端创建一个护理工单nursing_task状态从“待处理”到“处理中”再到“已完成”这是非常典型的状态机模式。前端用 Vue3 实现的状态流转我原以为会用复杂的动态组件实际翻代码后发现项目用的是最朴素的方案——一个status字段配合v-if/v-else-if渲染不同操作按钮el-table-column label状态 width120 template #default{ row } el-tag :typestatusMap[row.status].type{{ statusMap[row.status].label }}/el-tag /template /el-table-column el-table-column label操作 width180 template #default{ row } el-button v-ifrow.status pending typeprimary clickstartTask(row)开始处理/el-button el-button v-ifrow.status processing typesuccess clickfinishTask(row)完成/el-button el-button v-ifrow.status finished disabled已完成/el-button /template /el-table-column这种写法的好处是对新手极其友好状态和按钮的对应关系一眼就能看懂。缺点是不好扩展后续如果加“已取消”“已退回”等状态v-if会变得冗长。我当时直接在代码注释里建议状态超过 5 个时可以抽一个statusConfig数组来维护。不过对当前版本来说这个简单方案完全够用别为了设计而设计。3.3 费用模块中的“预存-扣费”逻辑费用管理是最接近真实财务系统的模块。这个项目没有用复杂的应收应付引擎而是做了回特别务实的“预存-扣费”模型入住时家属预存一笔金额到老人账户。每次产生费用床位费、护理费、餐费从预存余额中扣除。余额低于阈值时前端列表高亮提醒续费。我特意去看了预存余额变更的代码发现它用了synchronized关键字。虽然不是高并发场景但这个细节说明作者在意数据安全。更好的做法是数据库层面用乐观锁即加一个版本号字段更新时校验版本号UPDATE account SET balance balance - #{amount}, version version 1 WHERE id #{id} AND version #{oldVersion}这样即使两个线程同时扣费只有一个能成功另一个会因为版本号不匹配而影响行数为 0从而触发重试或报错。比起方法级synchronized数据库乐观锁在集群部署时依然有效普适性更强。4. 首页大屏与数据看板的联动实现管理系统如果只有一堆表格视觉上会非常枯燥这个项目在首页加了一个数据看板用 ECharts 展示老人年龄分布、床位入住率、月度护理量趋势。这个模块是很多人第一眼觉得“哇系统好高级”的地方但实现原理其实不复杂。4.1 统计接口统一走 Map 返回为了不创建一堆只存统计值的 VO 类项目里的统计接口统一用ListMapString, Object返回。以各房间类型入住率为例public ListMapString, Object getRoomOccupancyStats() { ListMapString, Object list roomMapper.selectMaps(new QueryWrapperRoom() .select(room_type, count(*) as total, SUM(CASE WHEN status occupied THEN 1 ELSE 0 END) as occupied_count) .groupBy(room_type)); return list; }前端拿到的 JSON 形如[{ room_type: 双人间, total: 10, occupied_count: 8 }]直接在 ECharts 里 push 到 series 数组就行格式完全兼容。这种“后端给原始数据前端做可视化映射”的分工很合理避免了后端拼图表配置的尴尬。4.2 大屏轮询更新的定时器注意点首页数据看板通常需要定期刷新常见做法是前端开一个setInterval定时请求。Vue3 里要特别留意组件卸载时清除定时器否则切页面后定时器还在跑白耗性能。正确写法onMounted(() { loadStats() timer setInterval(loadStats, 60000) }) onUnmounted(() { clearInterval(timer) })还有一个我在实际运行中踩到的坑多个定时器重叠。如果用户在页面间快速切换每个组件实例都开一个 interval全局并发请求数会悄悄增多。解决办法是抽一个全局唯一的轮询工具函数或者在onUnmounted里务必清理干净。最好是在看板组件里路由切换时自动销毁彻底避免资源泄漏。5. 权限控制的落地细节与常见绕过漏洞管理系统做得再多权限一做不好基本就是玩具。这个项目的权限控制分为前端路由守卫、后端接口拦截两个层面我逐一验证了有效性。5.1 前端路由守卫与动态菜单前端登录后拿到当前用户的角色标识和菜单列表然后动态注册路由。路由守卫的核心逻辑router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (!token) { if (to.path /login) next() else next(/login) } else { if (to.path /login) next(/) else next() } })这是最简形态。真正细一点的守卫要考虑角色权限匹配比如管理员能进系统管理而医护角色不能进。动态路由的作用就是筛掉不在角色菜单范围内的页面。有一点我要提醒前端的路由守卫只是用户体验层面的兜底真正决定安全的永远是后端接口校验。前端隐藏菜单不代表接口不能调用直接拿 Postman 请求/api/system/user/list照样可能拿到数据。所以拦截器必须覆盖所有后端接口不能依赖前端隐藏来保证安全。5.2 拦截器 HandlerMethod 精确到按钮级别项目的后端权限控制粒度到了“按钮”级别。菜单表里存了权限标识比如elder:add、elder:delete用户拥有的按钮权限列表在登录时写入缓存。后端拦截器校验时对比当前请求所需权限和用户已有权限。这里有一个很容易忽略的细节不同请求方法GET/POST/PUT/DELETE对同一个资源的权限要求不同如果用粗糙的“登录即可访问”那任何人都可以调 DELETE 接口把老人档案删了。正确做法是每个接口使用自定义注解明确指定所需权限码例如RequirePermission(elder:delete) DeleteMapping(/{id}) public ResultVoid delete(PathVariable Long id) { elderInfoService.removeById(id); return Result.ok(); }拦截器里通过反射读取注解内容做匹配RequirePermission requirePermission method.getAnnotation(RequirePermission.class); if (requirePermission ! null !userPermissions.contains(requirePermission.value())) { throw new BusinessException(无权限操作); }这种写法让权限码跟接口一对一绑定维护起来非常直观。给新接手的同事看他一眼就能明白“有这个注解就需要这个权限码”。6. 从项目文档到实际部署我遇到的 5 个坑与解法这个项目提供了完整的文档但我实际从零跑通时还是踩了不少坑这里挑最典型的五个记录下来给想要复现的朋友省时间。6.1 MySQL8.0 密码加密方式导致的连接失败MySQL8.0 默认的认证插件是caching_sha2_password而很多老版本的 JDBC 驱动不认识这个插件启动时会报Unable to load authentication plugin。解决办法有两个升级 MySQL 驱动到mysql-connector-java 8.0.x项目里如果用的com.mysql.jdbc.Driver改成com.mysql.cj.jdbc.Driver或者在建用户时指定mysql_native_passwordCREATE USER elder_userlocalhost IDENTIFIED WITH mysql_native_password BY your_password;我在本机用的是 8.0.33 版本直接换驱动之后问题消失。6.2 前端跨域问题开发环境与生产环境的两种姿势前端工程默认跑在http://localhost:5173后端接口是http://localhost:8080跨域必须处理。文档里推荐的是在 Vue3 的vite.config.js里配代理server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }配好之后前端代码里的请求路径写作/api/login即可浏览器不会直接跨域由 Vite 开发服务器做代理转发。生产环境则用 Nginx 反向代理前端和后端都挂在同一个域名下彻底规避跨域。我最开始尝试在后端 Controller 加CrossOrigin注解后来发现它会跟权限拦截器产生一些请求头问题索性统一用代理方案干净利落。6.3 MyBatis-Plus 分页失效少了分页插件MyBatis-Plus 的分页不是开箱即用的必须配置分页插件否则selectPage不会真正生成LIMIT语句而是一次性查出全表后果是数据量一大页面直接卡死。正确配置Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这个配置类缺失是很多新手拿到项目后第一个报错点我特意把它列为排查优先级最高的项。6.4 文档里提到的菜单初始化顺序文档中有一步是导入sql/init.sql但如果你先导入elder.sql再导入init.sql有概率因为表依赖顺序导致失败。我重新执行时直接一次性运行完整的初始化脚本文件没有再拆分导入。建议在 MySQL 命令行用source执行整个初始化文件避免用 GUI 工具手工跑单条 SQL 造成遗漏。6.5 上传头像路径问题项目里老人信息可以上传头像本地开发时上传路径写的是绝对路径D:/upload/。这种写法在自己电脑上没问题一旦部署到服务器就得改。我把它改为相对路径并配合 Nginx 静态资源映射file: upload-dir: ./upload/然后在 Nginx 里加一个 location 指向上传目录头像访问 URL 就能保持稳定。这个改动虽然小但非常贴合真实部署需求很多项目在演示现场翻车就翻在这种“硬编码路径”上。7. 这套代码还能怎么改给想二次开发的人三个方向如果你拿到这套系统不只是为了跑通交作业而是想加一些自己简历上能写的亮点我的建议是从下面三个方向入手改造成本低、汇报效果好。7.1 加入 Redis 缓存热点数据目前系统的登录状态是放在内存还是 Redis 取决于具体实现。如果是内存 Map 存 session一旦服务重启所有用户都要重新登录。改造方案是引入 Redis 存 Token登录后生成一个随机 TokenKey 为login:token:{uuid}Value 为用户 ID过期时间设为 30 分钟。拦截器里每次请求都去查 Redis 验证 Token。这样改还有一个额外收益可以实现强制下线功能。管理员把某个用户的 Token 从 Redis 删除对方下一次请求就会被拦截器挡在门外这在学校/公司管理系统里是很实用的功能。7.2 增加简单的数据导出功能养老院系统每个月都需要给家属发账单或给主管部门报送数据做一个数据导出功能非常加分。Easypoi 或 Alibaba EasyExcel 都支持用注解直接生成 ExcelExcelProperty(老人姓名) private String name; ExcelProperty(入住日期) private LocalDate checkInDate;前端加一个导出 Excel的按钮后端接口返回文件流这样一个功能就能演示出明显的工作流价值。7.3 把老人健康数据接入简单的异常提醒这个方向其实比听上去简单很多——不用接什么智能硬件可以在录入血压数据时设置阈值比如收缩压超过 160 就自动标记为异常并在首页看板飘红提醒。数据模型不需要大改只要在健康检查表加一个is_abnormal字段后端录入时做一次判断即可。但演示效果却非常好直接体现了系统“不仅记录还能发现风险”的附加价值。这些建议都是我基于项目现有代码结构、按常见实践推演出来的方案不是文档里现成的内容但每一条都具备落地可行性也不会破坏原有的框架结构。回头再看这套 SpringBoot2 Vue3 的养老院管理系统说不上架构多宏大但它在业务建模、前后端协作、权限控制和部署细节上都踩在了正确的节奏上。作为学习项目它给了你一条从数据库到页面再到部署的完整链路作为二次开发底座它的模块边界清晰改造成本低。我建议你拿到源码后先不要急着跑把 SQL 脚本从头到尾读一遍把表结构画成草图再对照代码去看每个功能怎么落到这些表上。这个过程走完你对 Java Web 项目的理解会上一个台阶。如果你在跑通的过程中卡在某个具体的报错上欢迎把报错信息发给我我按报错信息 → 排查步骤 → 根因定位 → 解决验证的顺序帮你理一遍。管理系统的坑就那么几个真正动手踩过一遍后面就都是坦途。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑