☰
Spring Boot + Vue社区养老服务平台前后端分离实战解析
2026/11/3 17:14:42 网站建设 项目流程

简介:一套面向毕业设计、期末大作业的社区养老服务平台完整源码案例,基于Spring Boot与Vue.js前后端分离架构,覆盖用户管理、养老服务预约、支付等典型业务模块,适合具备Java与前端基础的学习者进行项目实战或二次开发。压缩包共380个文件,约19MB,以java、vue为核心代码,含90个Java类、36个Vue组件、161个svg图标,另有sql数据库脚本、yml配置、bat启动脚本及说明文档,目录组织完整,可直接导入IDE运行调试。已有252人学习下载。资源附带一键安装/运行脚本,并包含API接口、数据库模型、测试代码与部署配置,可帮助使用者快速理解Spring Boot业务层开发、Vue组件化交互以及前后端联调流程,是完成课程设计或毕设答辩的实用参考。

1. 社区养老服务平台为什么选 Spring Boot + Vue:这个源码案例解决的痛点

社区养老卡人的往往不是 AI 和智能硬件,而是最基础的运营问题:几十个老人的档案、家属电话、上门服务工单、血压血糖随访记录,散落在 Excel 和聊天记录里,谁在负责哪个老人、服务做完没做完、结果怎么样,根本串不成一条线。Spring Boot + Vue 社区养老服务平台,就是这种场景下最常见的一套前后端分离源码案例设计:后端 Spring Boot 负责建表、写接口、管权限,前端 Vue 负责做出社区工作人员和护工真正点得动的操作界面。选 Spring Boot,是因为中小型管理系统用它最顺手,内嵌 Tomcat、starter 依赖、自动装配齐全,写接口基本是 Controller 到 Service 到 Mapper 一条直线;选 Vue,则是因为表格、表单、弹窗、图表这类高频交互用组件化方案维护最省事。适合两类人:做 Java Web 课设或毕设的学生,以及想用低成本系统替代 Excel 的社区养老机构信息化负责人。

它的技术深度不算高,没有微服务,也没有分布式中间件,但把 Spring Boot + Vue 前后端分离开发的主流程完整走了一遍:数据库建表、接口分层、Vue 路由、部署验证。对想入行 Java 后端的人来说,是最好的练习样本;对想快速搭内部管理系统的团队来说,把业务字段换一换,就是一个能跑的框架。下面按“先读表、再写接口、再接页面、最后避坑”的顺序讲透。

2. 拆解 Spring Boot + Vue 前后端分离工程:先读数据表,再定接口

拿到任何同类型项目,我第一件事不是跑起来,而是看两样东西:SQL 脚本和工程目录。因为这类前后端分离项目技术栈高度雷同,真正决定业务能不能落地的,是数据表能不能把“老人、护工、工单、健康记录”的闭环存清楚。表结构读懂了,后端 Service 里的业务逻辑、前端 Vue 页面里绑定的字段,全都能对上。

后端工程通常按分包治理:common放统一返回和异常处理,config放配置类,controller接收请求,service写业务,mapper操作数据库,entity对应表,dto/vo负责接口出入参。前端工程则常见src/api、src/views、src/router、src/utils、src/components,views按页面放,components放可复用弹窗和图表。这种分法不是强迫症,而是给后续接手的人划了一条清晰的路:要加功能,知道去哪个包里找代码,而不是全文搜索碰运气。

2.1 工程分层:Controller-Service-Mapper 与 Vue 组件怎么对应

前后端分离项目初学者最容易懵的是“后端接口返回什么,前端怎么拿”。链路其实很固定:Vue 页面里触发事件 → axios 发 HTTP 请求 → Spring Boot Controller 接收 → Service 处理业务 → Mapper 执行 SQL → 数据一层层返回 → Vue 把数据绑定到表格或表单。所有页面都是这套请求-响应模型的变体,区别只在于请求方式和参数个数。

把这个链路串起来的第一步,是封装一个全局可用的 axios 实例。几乎所有页面都会用到它,放在src/utils/request.js:

import axios from 'axios' const service = axios.create({ baseURL: process.env.VUE_APP_BASE_URL || '/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( response => { const res = response.data if (res.code !== 200) { alert(res.msg || '接口返回错误') return Promise.reject(new Error(res.msg)) } return res.data }, error => { alert('网络异常,请检查后端服务是否启动') return Promise.reject(error) } ) export default service

逻辑说明:axios 实例统一配置后,页面里就不用反复写axios.create和 token 处理。请求拦截器从 localStorage 取登录后存的 token,加到 Authorization 头;响应拦截器把后端的{ code, msg, data }结构拆开,业务代码里只拿data。后端接口的返回结构统一是这套,前后端协作最省事。

参数说明:timeout设 10 秒,导出大报表或批量生成体检报告时可能不够,需要单独对那个请求改超时。baseURL在开发环境通常走/api代理,生产环境由 nginx 转发;如果后端要求 token 不带 Bearer 前缀,把拦截器里的拼接方式改一下即可,具体看合作方的过滤器怎么解析。

2.2 核心数据表设计:老人档案、护工、服务工单、健康记录怎么建

社区养老平台和普通 CRUD 系统的最大区别,是好几张表都围绕“老人”这个主实体展开。最常见的设计是六张核心表:老人档案表、护工表、服务项目表、服务工单表、健康记录表、系统用户表。下面摘一段代表性建表脚本:

CREATE TABLE elder ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', name VARCHAR(50) NOT NULL COMMENT '老人姓名', gender TINYINT DEFAULT 1 COMMENT '性别 1男 2女', age INT COMMENT '年龄', phone VARCHAR(20) COMMENT '老人联系电话', id_card VARCHAR(18) COMMENT '身份证号', address VARCHAR(200) COMMENT '居住地址', family_name VARCHAR(50) COMMENT '家属联系人', family_phone VARCHAR(20) COMMENT '家属电话', health_status VARCHAR(255) COMMENT '基础病情况', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='老人档案表'; CREATE TABLE service_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, elder_id BIGINT NOT NULL COMMENT '老人ID', caregiver_id BIGINT COMMENT '护工ID', service_item_id BIGINT COMMENT '服务项目ID', status TINYINT DEFAULT 0 COMMENT '状态 0待派单 1已派单 2服务中 3已完成 4已取消', appoint_time DATETIME COMMENT '预约时间', finish_time DATETIME COMMENT '完成时间', remark VARCHAR(500) COMMENT '备注', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务工单表'; CREATE TABLE health_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, elder_id BIGINT NOT NULL COMMENT '老人ID', type VARCHAR(20) COMMENT '指标类型: blood_pressure / heart_rate / blood_sugar', value VARCHAR(50) COMMENT '测量值,如 120/80', record_time DATETIME COMMENT '测量时间', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_elder_time (elder_id, record_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康测量记录表';

逻辑说明:elder表把基础联系人和健康状况放一起,方便列表页直接展示,不用每次去关联家属表。service_order是所有业务流转的核心,状态字段从 0 到 4 决定了前端按钮的显示和后端接口的校验逻辑:0 才能派单,1 才能转成服务中或完成。health_record用 type 加 value 的宽表设计,避免给血压、心率、血糖各建一张表,测量值统一按字符串存,省去单位换算的麻烦。

参数说明:status字段是这类系统的重中之重,前后端必须共用同一份枚举,否则会出现“前端传 2,后端以为是已完成”的错位。health_record必须建(elder_id, record_time)联合索引,折线图和列表页都按这个维度查询,数据量过五万之后会明显拖慢。老人手机号用 varchar 而不是 bigint,因为固话格式或国际电话会让数值类型直接溢出报错。

2.3 springboot 配置与请求链路:token 在前端和后端之间怎么串

大多数 springboot 项目的配置骨架是相通的,核心是数据源和相关组件的开关。一份典型的application.yml长这样:

server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/community_elder?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai username: root password: root jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto

逻辑说明:连接串固定带上characterEncoding=utf8mb4,否则前端传进来的 Emoji 字符会报Incorrect string value,这是很常见的隐性问题。serverTimezone=Asia/Shanghai避免 JDBC 驱动读时间比本地差 8 小时。jackson 的 date-format 决定接口返回 JSON 的时间格式,前端拿到后直接显示,不用重复格式化。

参数说明:mybatis-plus.configuration.log-impl设为StdOutImpl会在控制台逐条打印 SQL,接手别人代码时这个配置特别救命,能一眼看出查询条件和参数是否对得上。不想看 SQL 就改成Slf4jImpl。最后的id-type: auto决定实体主键用数据库自增,和@TableId(type = IdType.AUTO)配合。

登录链路的走向是:前端把用户名密码发给/auth/login,后端校验通过后用 JWT 生成一串 token 返回;前端把 token 放进 localStorage,后续每个请求都带在 Authorization 头里;后端用一个 Spring 拦截器统一解析 token,并把当前用户 id 和角色放进 request attribute,Controller 直接取用。下一章就把这套轮子一个类一个类写出来。

3. 后端 Spring Boot 接口实现:老人档案、工单流转与登录鉴权

3.1 用 MyBatis-Plus 搭分页查询:实体类映射和分页插件

MyBatis-Plus 在中小型管理系统里几乎成了标配,原因是它把单表增删改查的样板代码吃掉了:实体类继承BaseMapper之后,selectById、insert、deleteById都能直接用,真正要手写 SQL 的只剩多表关联和复杂统计。第一步是引入 starter:

<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency>

实体类这样写:

@Data @TableName("elder") public class Elder { @TableId(type = IdType.AUTO) private Long id; private String name; private Integer gender; private Integer age; private String phone; private String idCard; private String address; private String familyName; private String familyPhone; private String healthStatus; private LocalDateTime createTime; }

逻辑说明:@TableName把实体映射到 elder 表,驼峰字段自动转下划线对应表字段,所以familyName对应family_name,createTime 对应create_time。@TableId(type = IdType.AUTO)声明主键自增。这段代码没有任何 SQL 却完成了完整映射,背后是 MyBatis-Plus 的默认驼峰转换。

分页接口的 Controller 写法:

@RestController @RequestMapping("/api/elder") public class ElderController { @Autowired private ElderService elderService; @GetMapping("/page") public Result<PageResult<Elder>> page(int page, int size, String keyword) { LambdaQueryWrapper<Elder> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(keyword), Elder::getName, keyword); wrapper.orderByDesc(Elder::getCreateTime); Page<Elder> p = new Page<>(page, size); Page<Elder> result = elderService.page(p, wrapper); return Result.ok(PageResult.of(result.getRecords(), result.getTotal())); } }

逻辑说明:分页入口固定传page和size,keyword是老人姓名模糊搜索。LambdaQueryWrapper用方法引用来指定字段,比字符串"name"安全,字段改名时编译期就会暴露。最后按创建时间倒序,让新录的老人出现在最前面。elderService.page是 MyBatis-Plus 自带方法,但必须配合分页插件,否则背后只是内存截断。

分页插件配置不能少:

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }

逻辑说明:PaginationInnerInterceptor会在执行 SQL 前自动拼接LIMIT,并另外执行一条 count 查询拿到 total。如果不注册这个 Bean,page方法返回的 total 恒为 0,数据还会全量查出后在内存里“假分页”,这是新手的第一个翻车点。

参数说明:DbType.MYSQL要写对。切 PostgreSQL 或达梦数据库时,这个枚举不同,SQL 拼接方言也会不同,直接把 MySQL 配置搬过去会报语法错误。

3.2 老人档案模块:统一返回结果、参数校验和 Service 层落库

为了让所有接口返回格式一致,案例项目里一定有个统一的Result类:

@Data public class Result<T> { private Integer code; private String msg; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.setCode(200); r.setMsg("success"); r.setData(data); return r; } public static <T> Result<T> error(String msg) { Result<T> r = new Result<>(); r.setCode(500); r.setMsg(msg); return r; } }

逻辑说明:ok与error两个静态方法,让 Controller 里不用手动构造返回对象。前端响应拦截器恰好认这个结构,code 为 200 就解出 data,否则弹 msg。这个类的设计很朴素,但保证了全项目五十多个接口的返回风格一致。

老人的新增逻辑放在 Service 层,Controller 保持薄:

@Service public class ElderServiceImpl extends ServiceImpl<ElderMapper, Elder> implements ElderService { @Override public Result<?> addElder(Elder elder) { if (!StringUtils.hasText(elder.getName()) || elder.getAge() == null) { return Result.error("老人姓名和年龄不能为空"); } if (elder.getPhone() != null && !elder.getPhone().matches("^1[3-9]\\d{9}$")) { return Result.error("老人手机号格式不正确"); } this.save(elder); return Result.ok(elder.getId()); } }

逻辑说明:ServiceImpl和BaseMapper一样自带常用方法,this.save就是单条插入。校验写在 Service 而不是 Controller,是因为新增和编辑两个接口都要用同一份校验逻辑,写在 Controller 里会复制粘贴两遍。手机号校验用正则做基础格式判断,数据库加上唯一索引兜底重复录入。

参数说明:正则^1[3-9]\\d{9}$只适合中国大陆手机号。如果平台要录座机和紧急联系人,正则要放宽,否则填个021-88888888直接被拒,页面体验很差。

3.3 服务工单的状态流转:派单、完成、事务回滚

工单流程是这类系统的业务主链,常见状态是“待派单 → 已派单 → 服务中 → 已完成 / 已取消”。后端接口往往会约束状态只能按顺序跳,不能随便传。派单和完成的接口如下:

@RestController @RequestMapping("/api/order") public class ServiceOrderController { @Autowired private IServiceOrderService orderService; @PostMapping("/dispatch") public Result<?> dispatch(@RequestBody DispatchRequest req) { return orderService.dispatch(req.getOrderId(), req.getCaregiverId()); } @PostMapping("/complete") public Result<?> complete(@RequestBody CompleteRequest req) { return orderService.complete(req.getOrderId(), req.getResultDesc()); } }

Service 实现状态流转:

@Override @Transactional(rollbackFor = Exception.class) public Result<?> dispatch(Long orderId, Long caregiverId) { ServiceOrder order = this.getById(orderId); if (order == null) { return Result.error("工单不存在"); } if (order.getStatus() != 0) { return Result.error("只有待派单状态才能派单"); } order.setStatus(1); order.setCaregiverId(caregiverId); this.updateById(order); return Result.ok(); } @Override @Transactional(rollbackFor = Exception.class) public Result<?> complete(Long orderId, String resultDesc) { ServiceOrder order = this.getById(orderId); if (order == null) { return Result.error("工单不存在"); } if (order.getStatus() != 1 && order.getStatus() != 2) { return Result.error("当前状态不允许完成"); } order.setStatus(3); order.setFinishTime(LocalDateTime.now()); order.setRemark(resultDesc); this.updateById(order); return Result.ok(); }

逻辑说明:这两个方法的核心不是 set 字段,而是“先查原状态,再判断能否迁移”。dispatch里 status 必须是 0 才能派给护工;complete里已派单和服务中都可以直接完成,这样上门服务如果没来得及在系统里点“开始”,也不会卡死。所有状态校验都发生在后端,前端按钮只是视觉入口,不能信任前端传回来的状态值。

参数说明:@Transactional(rollbackFor = Exception.class)必须指定 rollbackFor,默认只回滚 RuntimeException,业务异常不会触发回滚。工单流转里如果同时要更新护工的当月工作量统计,第二步失败时前面工单状态的修改会一起回滚,状态才不会卡在半路。

实际案例里还会在dispatch之后给护工发送一条通知记录,这里通过消息表异步处理即可,不展开细节。只要记住:凡是跨两张表的写操作,都放进事务方法里。

3.4 登录鉴权:JWT 工具类、拦截器与角色区分

JWT 在案例项目里是最省事的登录方案,不用改数据库表,服务端不存 session。工具类用 jjwt 生成和解析:

@Component public class JwtUtils { @Value("${jwt.secret}") private String secret; @Value("${jwt.expire}") private Long expire; public String generateToken(Long userId, String role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("role", role) .setExpiration(new Date(System.currentTimeMillis() + expire)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); } }

拦截器统一校验:

public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { response.setStatus(401); return false; } try { Claims claims = jwtUtils.parseToken(token.substring(7)); request.setAttribute("userId", claims.getSubject()); request.setAttribute("role", claims.get("role")); return true; } catch (Exception e) { response.setStatus(401); return false; } } }

再把这个拦截器注册到 WebMvc 配置里,指定拦截路径:

@Configuration public class WebConfig implements WebMvcConfigurer { @Autowired private AuthInterceptor authInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/auth/login"); } }

逻辑说明:解析 token 成功后,把 userId 和 role 放进 request attribute,Controller 通过(Long) request.getAttribute("userId")就能拿到当前操作人。角色区分来自 JWT 里的 claim:管理员和护工登录时生成不同 role,在需要限制管理员权限的接口里再判断一次。

参数说明:jwt.secret和jwt.expire放到 application.yml。secret 不能太短,HS256 要求至少 256 位密钥,否则启动直接抛异常。expire 用毫秒,常见设置是7200000,也就是两小时。管理员操作频繁可放宽到半天;护工端长期在线建议用七分钟过期加自动续期,但这套简单拦截器不涉及,案例源码里通常直接调长 expire。

如果对整个登录鉴权不满意,后续可以升级成 Spring Security 加 Sa-Token,但先跑通这套拦截器,你就能理解为什么前端每个请求头都要带 token,以及 401 出现时先自查哪个环节。

4. Vue 前端页面实现:路由、表格、表单和健康看板的完整写法

4.1 用 Element Plus 搭后台布局:菜单、侧边栏和内容区

Vue 前端的环境配置一般是这个顺序:装 Node.js,用 npm 或 yarn 安装依赖,跑 Vue CLI 或 Vite 创建项目。案例源码大多直接带一个package.json,项目下来后第一句就是:

npm install npm install element-plus @element-plus/icons-vue axios vue-router@4 echarts

装完依赖,后台管理布局通常由一个 Layout 组件统一撑起来:左边菜单,右边内容区,顶部放标题和用户信息。核心代码是:

<template> <el-container class="layout"> <el-aside width="220px"> <el-menu :default-active="$route.path" router> <el-menu-item index="/dashboard">数据看板</el-menu-item> <el-menu-item index="/elder/list">老人档案</el-menu-item> <el-menu-item index="/order/list">工单管理</el-menu-item> <el-menu-item index="/health/list">健康记录</el-menu-item> </el-menu> </el-aside> <el-container> <el-header>社区养老服务平台</el-header> <el-main> <router-view /> </el-main> </el-container> </el-container> </template>

逻辑说明:el-menu开启router属性后,点击菜单项会把 index 当作路由路径跳转。<router-view />是页面挂载点,路由匹配到哪个组件就渲染到这里。菜单的 index 必须和路由表中定义的一致,否则点了菜单,内容区一片空白。$route.path 作为高亮判断,刷新后菜单仍停留在当前项。

参数说明:Element Plus 的 el-container 和 el-aside 组合天然适配后台布局。菜单项少时 width 用 220px,菜单文字长就放宽到 260px。动态权限菜单的场景下,这里的 el-menu-item 会换成 v-for 渲染路由数组,再配合路由守卫控制是否显示。

4.2 vue-router 路由与登录守卫:页面跳转、路由参数和刷新处理

路由表把页面和路径串起来,代码集中在src/router/index.js:

import { createRouter, createWebHistory } from 'vue-router' import Layout from '@/layout/index.vue' const routes = [ { path: '/login', component: () => import('@/views/Login.vue') }, { path: '/', component: Layout, redirect: '/dashboard', children: [ { path: 'dashboard', component: () => import('@/views/Dashboard.vue') }, { path: 'elder/list', component: () => import('@/views/elder/ElderList.vue') }, { path: 'order/list', component: () => import('@/views/order/OrderList.vue') }, { path: 'health/list', component: () => import('@/views/health/HealthList.vue') } ] } ] const router = createRouter({ history: createWebHistory(), routes }) router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path !== '/login' && !token) { next('/login') } else { next() } }) export default router

逻辑说明:children里的 path 不带前缀斜杠,是相对 Layout 下的相对路径,最终访问 URL 是/elder/list。登录页独立成一条路由,不套 Layout,这样未登录用户访问后台页面时会被守卫拦到/login。路由懒加载用() => import(...),首屏只加载必要组件,避免打开页面白屏等待。beforeEach做登录守卫,刷新页面后 localStorage 里的 token 还在,就不会被踢回登录页。

参数说明:createWebHistory()对应 HTML5 history 模式,URL 好看,但部署到 nginx 必须配try_files回退,否则刷新 404。路由地址带查询参数时,可以这样跳转:router.push({ path: '/elder/detail', query: { id: row.id } }),详情页里用route.query.id接收。这是最常见的 vue 路由参数传递方式。

4.3 老人档案表格页:axios 请求、搜索、分页联动

列表页是所有后台管理系统使用频率最高的页面,值得写细。老人档案页由搜索框、表格、操作列、分页器四块组成:

<template> <div class="elder-list"> <el-input v-model="query.keyword" placeholder="姓名搜索" clearable style="width: 200px" /> <el-button type="primary" @click="loadData">搜索</el-button> <el-table :data="list" border stripe> <el-table-column prop="name" label="姓名" /> <el-table-column prop="age" label="年龄" /> <el-table-column prop="phone" label="联系电话" /> <el-table-column prop="familyName" label="家属" /> <el-table-column prop="createTime" label="录入时间" /> <el-table-column label="操作"> <template #default="{ row }"> <el-button link type="primary" @click="openDetail(row)">详情</el-button> <el-button link type="danger" @click="deleteElder(row.id)">删除</el-button> </template> </el-table-column> </el-table> <el-pagination v-model:current-page="query.page" v-model:page-size="query.size" :total="total" layout="total, prev, pager, next" @current-change="loadData" @size-change="loadData" /> </div> </template>
import { ref, reactive, onMounted } from 'vue' import request from '@/utils/request' export default { setup() { const list = ref([]) const total = ref(0) const query = reactive({ page: 1, size: 10, keyword: '' }) async function loadData() { const data = await request.get('/elder/page', { params: query }) list.value = data.records total.value = data.total } async function deleteElder(id) { await request.delete(`/elder/${id}`) loadData() } function openDetail(row) { router.push({ path: '/elder/detail', query: { id: row.id } }) } onMounted(loadData) return { list, total, query, loadData, deleteElder, openDetail } } }

逻辑说明:query是响应式对象,搜索按钮和分页跳转都通过它驱动loadData,每次请求携带当前页数和条数。删除完成后重新拉列表,保证界面和数据同步。表格列字段名为 prop,必须与后端返回的 JSON 字段完全一致,createTime后端已经格式化好,前端直接展示。

参数说明:分页组件在 Element Plus 里大小变化的回调是@size-change,和 Vue 2 的 Element UI 一样;如果你手里的源码是 Element UI,模板写法从#default="{ row }"变成slot-scope="{ row }",这是两代框架最明显的分水岭。删除操作生产环境一定要包一层el-popconfirm二次确认,误点删除对养老档案来说是事故。

4.4 ECharts 健康看板:接后端趋势数据,画血压双折线图

平台里最能体现“数据可视化”价值的是健康看板。老人近期血压、血糖、心率变化用折线图展示,护工能一眼看出异常波动。组件如下:

<template> <div ref="chartRef" style="width: 100%; height: 400px"></div> </template> <script setup> import * as echarts from 'echarts' import { onMounted, onBeforeUnmount, ref } from 'vue' import request from '@/utils/request' const chartRef = ref(null) let chart onMounted(async () => { chart = echarts.init(chartRef.value) const data = await request.get('/health/trend', { params: { elderId: 1, type: 'blood_pressure', days: 30 } }) chart.setOption({ xAxis: { type: 'category', data: data.dates }, yAxis: { type: 'value', name: 'mmHg' }, series: [ { name: '收缩压', type: 'line', data: data.systolic }, { name: '舒张压', type: 'line', data: data.diastolic } ], tooltip: { trigger: 'axis' } }) }) onBeforeUnmount(() => { chart.dispose() }) </script>

逻辑说明:后端返回三组平行数组dates、systolic、diastolic,前端直接塞进 ECharts 的 xAxis 和 series。血压天生是收缩压和舒张压两条线,用两个 series 才能看到整体走势。onBeforeUnmount里chart.dispose()是必须的,否则页面来回切换后内存持续上涨,用不了多久浏览器就卡。

参数说明:days参数由前端传,常见选项是 7、30、90。后端按天聚合时,一天多次测量要约定取值策略:取最后一次最符合“当前状态”直觉,取平均值更平滑。如果前后端没对齐,前端看到折线突变会以为是 bug,其实是聚合口径不一致。

5. 部署与排查:跨域、分页、日期时区等 5 个必踩的坑

5.1 跨域请求被浏览器拦掉:前端能开,接口全红

现象:前端npm run serve正常打开,点击登录后浏览器 console 报 CORS error,后端控制台看不到任何请求日志。

原因:开发环境前端跑在localhost:8080,后端跑在8081,浏览器同源策略把请求拦住了。后端没配 CORS,或者配置了addAllowedOrigin("*")和setAllowCredentials(true)同时出现,Spring Boot 某些版本会直接抛异常。

解决:后端加一个全局 CORS 配置类:

@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedMethod("*"); config.addAllowedHeader("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }

排查方法:如果前端用了代理,浏览器里看到的请求地址是同源的,CORS 问题会消失;如果生产环境用 nginx 反代,后端也感知不到跨域。优先用代理而不是在后端放开 CORS,风险和配置量都更小。addAllowedOriginPattern("*")才能和allowCredentials共存,这是不少开源项目都踩过的坑。

5.2 MyBatis-Plus 分页插件不生效:total 始终为 0

现象:分页接口能返回数据,但 total 恒等于 0,且前端翻页时数据量不变,SQL 日志里没有 LIMIT。

原因:MybatisPlusInterceptor没有注册到 Spring 容器,或者注册了但没加PaginationInnerInterceptor。再隐蔽一点的原因:引入的 mybatis-plus 版本和 jsqlparser 版本不匹配。

解决:确认MybatisPlusConfig被扫描到,分页插件按第 3.1 节配置。如果用了 3.4.0 以上版本,需要显式引入 jsqlparser,否则启动都不报错,但分页就是诡异地不生效:

<dependency> <groupId>com.github.jsqlparser</groupId> <artifactId>jsqlparser</artifactId> <version>4.6</version> </dependency>

排查方法:看控制台 SQL 日志末尾有没有LIMIT ?,有就说明分页生效;没有就先检查配置类,再看 pom 依赖。这个问题的迷惑性在于系统不报错,只有 total 和翻页行为对不上,属于典型的黑匣子型 bug。

5.3 时间显示少了 8 小时:健康记录变成了凌晨

现象:数据库里record_time是 2024-05-20 14:30:00,页面表格却显示 2024-05-20 06:30:00。

原因:MySQL 连接串没带serverTimezone=Asia/Shanghai,或者 spring.jackson.time-zone 没设置。数据库存的是本地时间,JDBC 驱动读出来时按下 UTC 处理了,JSON 序列化又转了一次,两个环节叠加导致时区偏移。

解决:按第 2.3 节的配置,连接串加serverTimezone=Asia/Shanghai,jackson 的 time-zone 设为GMT+8。MySQL 服务端启动参数也建议指定default-time-zone='+08:00',让数据库层、连接层、应用层三个时区口径一致。

注意:这是配置问题,不是数据问题,不要去手工改数据。表里已经存错的数据,改配置后再次查询会拿到正确结果;手工UPDATE只会让数据更乱。

5.4 上传老人照片报 500:文件大小受限

现象:上传 2MB 的老人照片,Spring Boot 报MultipartException: FileSizeLimitExceededException,或者浏览器直接 413。

原因:Spring Boot 默认单文件最大 1MB,请求体最大 10MB;如果前面还有 nginx,默认client_max_body_size也是 1MB。两个限制叠加,谁先到谁拦截。

解决:先在 application.yml 调大:

spring: servlet: multipart: max-file-size: 20MB max-request-size: 50MB

再改 nginx 配置:

client_max_body_size 20m;

排查方法:如果 Spring 控制台有异常,就是 Spring 限制;如果浏览器直报 413 且后端没日志,是 nginx 先拦截了。改完 nginx 记得nginx -t && nginx -s reload,reload 不会中断在线服务,restart 会。

5.5 前端刷新 404:history 路由在 nginx 下的回退配置

现象:开发环境一切正常,部署到服务器后能打开首页,点击菜单跳转也正常,但按 F5 刷新直接 404。

原因:vue-router 的createWebHistory是基于 History API 的路径,路由是前端虚拟路径,nginx 目录下没有对应的物理文件。刷新时 nginx 按/elder/list找静态文件找不到,就返回 404。这是前后端分离部署最常见的失常现场。

解决:nginx 配置try_files把不存在的路径回退到 index.html,再由前端路由接管:

server { listen 80; server_name your.domain.com; root /opt/community-elder/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }

逻辑说明:location /的try_files兜底到index.html,前端拿到页面后按自己的路由表渲染,刷新不再 404。/api/前缀单独代理到后端 8080,后端接口不存在跨域问题,前端 axios 的 baseURL 也直接用相对路径/api。

参数说明:root路径指向npm run build生成的 dist 目录,注意不要配置alias和root混用,否则静态资源路径层层嵌套后 404。proxy_set_header Host $host让后端能看到原始域名,部分后端做重定向时依赖它。

6. 拿到案例源码后的验证方法与交付习惯

6.1 按业务闭环跑通:登录、档案、工单、健康数据

我拿到这类源码,不会先把页面翻一遍,而是按业务闭环走三条链路:登录进系统 → 新增老人档案 → 给老人派一张服务工单 → 模拟护工完成工单 → 录入一条健康记录 → 到看板确认图表更新。这条链路全部走通,说明核心代码是完整的,再动 UI 和业务名。

验证前先准备好环境:本地装好 MySQL 8、JDK 8 或 11、Node.js 16 以上。数据库用压缩包里的 sql 脚本初始化,后端启动命令:

mvn clean package -DskipTests java -jar target/community-elder-0.0.1-SNAPSHOT.jar

前端启动:

npm install npm run serve

启动后我会先看后端控制台有没有打印 SQL,再打开前端登录。如果前后端配置对接不上,优先检查数据库账号密码、端口号、前端代理路径三件事,这三样出错占了启动失败的大头。

6.2 保留接口回归集与可回退基线

有一个习惯对我帮助很大:跑通第一遍时,用 Postman 或 Apifox 把所有接口按模块存成一个 collection,把每个接口的请求参数和返回值样例保存下来。案例源码常常没有完整接口文档,自己留存一份接口清单,后面改字段时拿它做回归,比肉眼点页面高效得多。

另一个习惯是动手前先备份原始压缩包并标记 baseline。改坏了有后悔药吃,是我踩过多次之后养成的。真正要交付给社区使用时,不要把 MySQL 和 Node 环境交给现场:常见做法是把前端 build 后的 dist 目录交给 nginx 托管,后端 jar 单独跑,/api反代过去;如果只有一个端口,就把 dist 放进 Spring Boot 的 static 目录,打成一个 jar 单机部署。

最值得做的验证其实是反向验证:把 token 故意改错,看是否被拦截;把工单状态乱传,看后端是否拒绝;把分页 size 调成 10000,看接口是否变慢。失败方式符合预期,这门系统才算真正测过。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询