每年毕业季后台都会收到类似的私信:"学长,SpringBoot+Vue的毕设题,到底怎么做才不像应付差事?"今天借"社区老人健康信息管理系统"这个题目,把从选题拆解、数据库设计、前后端编码到最后的部署联调完整捋一遍。这个题不只是个查档案的CRUD,它涉及多角色权限、健康数据录入、异常预警、统计报表、家属互通,真正做出来是一个能落地的小型业务系统。适合用来做毕业设计、课程设计,也能给想入门前后端分离开发的朋友当练手项目。
先说结论:SpringBoot+Vue+MySQL这套组合做老人健康管理,属于既稳又能出彩的选项。后端业务边界清晰,前端交互和可视化表现力强,数据库层面又能承载业务关系。只要把关键设计想清楚,代码量其实不大,但能讲的东西非常多——这正是答辩时最需要的。
1. 这个毕设题到底在做什么
1.1 选题背景与价值判断
社区老人健康管理系统,本质上是把线下的纸质健康档案和服务流程搬到线上。老人基本信息、血压血糖心率体温、体检记录、用药计划、家属联系方式,这些数据散落在纸面上很难查,更别说做长期趋势分析了。系统要解决的问题就是统一建档、实时记录、异常提醒、数据可视化。
选这个题有天然优势:需求明确、场景贴近生活、功能边界清晰,评委一听就知道这个系统是干什么的,不需要费劲解释。同时它又有足够的复杂度。多角色(管理员、社区专员、家属)的权限区分,健康记录的批量录入,预警规则的设定,趋势图的展示,这些模块每一个都有深入挖掘的空间,工作量能控制在一个学期内,也能适当往上加码。
1.2 三个用户角色与核心功能闭环
我在梳理需求的时候,没有上来就列功能清单,而是先想清楚谁会使用这个系统、他们分别关心什么。
管理员和社区专员是核心使用方,负责老人建档、健康记录录入、体检信息维护、用药计划和社区公告管理。家属是重要的外延用户,登录后可以查看自家老人的健康数据、接收异常预警消息。老人的直接登录使用场景相对较轻,通常由代录或设备自动采集,所以老人端可以做得轻量,甚至只作为档案对象存在。
这里有一个设计判断:要不要做"老人本人登录"?我最后做的是老人表独立于用户表,老人本人可以绑定一个登录账号,但在第一版里只提供查询和报告下载功能。原因很简单——老人端的高频操作是数据录入,让老人自己敲键盘录血压不太现实,通常是设备测量后自动上报或者社区专员帮忙录入,所以他的操作面板复杂度不需要和家属端一样。
核心功能闭环是这样的:社区专员为老人建档,然后按天录入健康数据(血压、血糖、心率、体温等),系统根据预设阈值自动标记异常项并生成预警消息,推送给绑定的家属账号。家属通过趋势图查看一段时间的数据变化,还可以下载体检报告PDF。管理员则负责账号分配、数据统计和社区公告维护。这个闭环覆盖了一个健康数据从产生、采集、分析到反馈的完整链路,不管是写论文还是做答辩,都能讲出一套逻辑来。
2. 技术选型:SpringBoot+Vue+MySQL的组合逻辑
2.1 后端:SpringBoot接得住业务的稳定性
经常有同学问:用SSH(SpringMVC+Hibernate)行不行?用传统的SSM行不行?技术上都可以,但现在做新项目,SpringBoot的收益明显更高。
SpringBoot最核心的价值是自动配置和起步依赖。传统SSM项目里,配置文件要写数据源、事务管理器、MyBatis映射、SpringMVC视图解析器,任何一个环节配错,启动直接报错。SpringBoot通过starter机制把常用的依赖组合好,约定大于配置,一个application.yml就能把数据源、端口、日志全部搞定。这对毕设这种开发周期短、团队可能只有自己一个人的项目来说,等于省掉了大量和环境搏斗的时间,把精力留给业务逻辑。
另外一个实际优势是第三方生态。做权限有Spring Security或者Shiro,做文档有Knife4j,做数据库操作有MyBatis-Plus。这些都是社区沉淀多年的解决方案,遇到问题一搜就有答案,不至于卡在某个jar包折腾两周。
2.2 前端:Vue的渐进式开发体验
Vue在这套系统里的角色是构建单页应用(SPA),核心价值在于组件化和响应式数据绑定。
组件化就很好理解——老人列表是一个组件,健康表单是另一个组件,趋势图是一个图表组件。每个组件只管自己的部分,代码结构清晰,到时候答辩讲起来也方便:先把页面拆成组件树,然后逐个说明数据如何流转。响应式数据绑定则是Vue的灵魂,数据变化自动更新DOM,不需要手动操作页面元素,开发效率比直接操作DOM高很多。
Vue3相比Vue2,组合式API(Composition API)让逻辑复用更加方便。比如"获取老人列表",以前可能需要mixins,现在一个useLoadOldPerson()函数就能搞定,然后把加载状态、列表数据、异常信息都封装在里面。配合Vite构建工具,启动和热更新都很快,开发体验比老一代构建工具顺手得多。
2.3 数据库:MySQL的成熟与稳妥
MySQL是这个组合里的老三样,但它依然是这种中小型业务系统最稳妥的选择。顾虑完全不必有:多表关联查询、事务、索引、排序这些功能MySQL都支持得很好。
还有一个很现实的理由:MySQL的安装教程、配置教程、面试题在网上浩如烟海,出现问题基本都能找到解决方案。比如MySQL 5.7和8.0的驱动差异、SSL连接错误、时区问题,这些典型坑都有现成的排查经验。用MySQL还能方便你在毕业论文里写"数据库设计"这一章——ER图,关系模式,范式分析,这些内容都是MySQL系的常规素材。
2.4 三者联动:一次完整请求的生命周期
为了把技术栈串起来,我通常会画这样一条线(当然,这是用文字描述,不是流程图工具):
- 用户在Vue页面上点击"查询老人列表"
- Vue组件调用封装好的Axios请求,发送HTTP请求到SpringBoot的Controller
- Controller接收参数,调用Service层处理业务逻辑
- Service层调用Mapper层(MyBatis-Plus)操作MySQL数据库
- 数据返回经过统一Result封装,前端解析后通过响应式数据渲染到页面
这一步最好在答辩前对着代码逐行讲一遍,能讲清楚"从页面到数据库再回到页面"的同学,技术分数一般都不会低。另外有一个容易忽视的点:跨域问题。开发时Vue在3000端口,SpringBoot在8080端口,浏览器默认禁止跨端口访问,需要在前端配置代理(vue.config.js里的devServer.proxy)或者后端开启CORS。我推荐开发阶段用代理,生产环境用Nginx转发,不要把CORS当成常规方案——纯后端放开跨域虽然开发方便,但生产环境会有安全风险。
3. 数据库设计:每一张表都要有存在理由
3.1 核心表结构设计
数据库设计是整个系统真正的地基,表设计不合理,后面所有代码都是空中楼阁。我习惯先设计表再写代码,避免边写代码边改表的痛苦。这个系统的核心表一共有6张:系统用户表、老人基本信息表、健康记录表、体检记录表、用药计划表、公告表。
系统用户表:
CREATE TABLE `sys_user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL, `password` varchar(100) NOT NULL, `real_name` varchar(50) DEFAULT NULL, `phone` varchar(20) DEFAULT NULL, `role` tinyint NOT NULL DEFAULT 2 COMMENT '1管理员 2社区专员 3家属', `status` tinyint NOT NULL DEFAULT 1 COMMENT '1启用 0禁用', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表';密码字段不存明文,至少要用MD5加盐或者BCrypt处理一次,这是论文里能写的安全点。角色字段我用数字表示,权限控制时直接判断数字大小比字符串比较更可靠。
老人基本信息表:
CREATE TABLE `old_person` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL, `gender` tinyint NOT NULL, `birth_date` date DEFAULT NULL, `id_card` varchar(18) DEFAULT NULL, `phone` varchar(20) DEFAULT NULL, `address` varchar(200) DEFAULT NULL, `emergency_contact` varchar(50) DEFAULT NULL, `emergency_phone` varchar(20) DEFAULT NULL, `chronic_disease` varchar(200) DEFAULT NULL COMMENT '慢性病史', `medication_status` varchar(200) DEFAULT NULL COMMENT '长期服药情况', `avatar` varchar(200) DEFAULT NULL COMMENT '头像路径', `status` tinyint DEFAULT 1, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='老人基本信息表';设计时要注意一个原则:老人表不存血压、血糖这种动态数据,只存相对固定的个人信息。曾经有同学把"最近一次血压"直接放进老人表,结果每次更新都要UPDATE一条记录,历史数据完全丢失,这样没办法画趋势图,也很容易丢失数据。动态数据应该单独建表,用外键关联老人ID。
健康记录表是这个系统的核心表,它的设计有讲究:
CREATE TABLE `health_record` ( `id` bigint NOT NULL AUTO_INCREMENT, `old_person_id` bigint NOT NULL, `type` tinyint NOT NULL COMMENT '1血压 2血糖 3心率 4体温', `value` decimal(8,2) NOT NULL, `unit` varchar(20) DEFAULT NULL, `record_time` datetime NOT NULL, `remark` varchar(255) DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_old_person_time` (`old_person_id`, `record_time`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='健康记录表';这里我采用的是"通用型"设计——用type字段区分记录类型,value存数值。这么做的好处是以后加一个新的健康指标(比如血氧饱和度)不需要改表结构,加一个枚举值就行。缺点是每个指标的单位和正常范围维护起来要靠另一张配置表或者代码里定义。还有一种"字段化"设计,建表时就把收缩压、舒张压、血糖、心率各做成一个字段。两种设计我都用过,通用型适合逐步扩展,字段型适合指标确定不变化的场景。这个毕设里指标会不断增加,所以我选了通用型。
注意还有一个细节:健康记录表一定要建(old_person_id, record_time)的联合索引。如果没有这个索引,按老人加时间范围查询数据时,MySQL会直接全表扫描,数据量上到几万条之后响应时间会明显变慢。答辩时如果你说出"我建了联合索引来优化慢查询",技术功底马上就能体现出来。
体检记录表用来存周期性的体检报告信息:
CREATE TABLE `health_check` ( `id` bigint NOT NULL AUTO_INCREMENT, `old_person_id` bigint NOT NULL, `check_date` date NOT NULL, `check_org` varchar(100) DEFAULT NULL COMMENT '体检机构', `summary` varchar(500) DEFAULT NULL COMMENT '体检结论', `report_file` varchar(200) DEFAULT NULL COMMENT '报告文件路径', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_old_person` (`old_person_id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='体检记录表';report_file字段保存的是上传文件的路径,文件本身放在服务器本地目录或者对象存储里。这里有个避坑项:不要把文件转成Base64字符串直接存数据库,非常占用空间,性能也差,应该只在数据库存路径。
3.2 表间关系与事务意识
六张表之间的关系并不复杂:老人表是核心,健康记录、体检记录、用药计划都关联到老人表;家属通过用户表关联到老人表,需要一张家属与老人的关联表。
CREATE TABLE `family_bind` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL COMMENT '家属用户ID', `old_person_id` bigint NOT NULL, `relation` varchar(20) DEFAULT NULL COMMENT '父子/母子等', `bind_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_old` (`user_id`, `old_person_id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='家属绑定表';一个家属可以绑定多位老人,一位老人也可以有多个家属,这个多对多关系需要通过关联表来实现。在新增老人之后立即给家属发送绑定邀请时,就需要用事务来保证:先插入old_person,再插入family_bind,两个操作要么同时成功,要么同时失败。SpringBoot中使用@Transactional注解就可以解决。
3.3 数据库版本与连接配置
MySQL这边有两个容易踩的坑,提前说清楚。
MySQL 8.0开始,JDBC驱动类名变成了com.mysql.cj.jdbc.Driver,如果你还在用5.x时代的老配置会直接报错。另一个是连接串需要加上时区和SSL配置,否则可能出现时区错误、SSL连接错误:
spring: datasource: url: jdbc:mysql://localhost:3306/health?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8&allowPublicKeyRetrieval=true username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.DriverallowPublicKeyRetrieval=true这个参数是MySQL 8.0在本地连接时经常需要的,不加可能报"Public Key Retrieval is not allowed"。这里建议把数据库字符集统一成utf8mb4,不要用utf8——utf8mb4才是完整的UTF-8编码,能正常存储emoji、生僻字等四字节字符。老人名字里如果带了很生僻的字,用utf8可能直接报"Data truncation"错误,到时候排查起来非常痛苦。
4. 后端核心实现:SpringBoot里的业务落地点
4.1 项目初始化与基础配置
创建SpringBoot项目,我推荐直接在IDEA里用Spring Initializr,选择Java 8或者Java 11都可以,SpringBoot版本建议2.7.x稳定版本即可。很多同学喜欢一上来就选最新的3.x版本,结果发现一些老教程里的写法都变了,那些教程大多基于SpringBoot 2.x。安全稳定的选择是2.7.x版本,它的社区资料最丰富,遇到问题解决起来也最快。
pom.xml里需要引入的核心依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>2.0.25</version> </dependency>MyBatis-Plus这个选型值得解释一下。它省去了写XML、写BaseMapper接口的工作量,单表CRUD直接继承内置接口就能做。对于这个系统的绝大部分操作——按ID查老人、分页查询健康记录、按条件筛选——都能直接用,代码量能省30%以上。
4.2 统一返回与JWT认证
前后端分离的项目,接口返回格式必须统一。我习惯的返回结构是:
{ "code": 200, "message": "操作成功", "data": { ... } }为了达到这个效果,写一个统一的Result类:
@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.code = 200; result.message = "操作成功"; result.data = data; return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.code = 500; result.message = message; return result; } }统一返回的好处是前端只需要做一个拦截器处理三个状态——成功、业务失败、未登录,不需要每个接口单独写解析逻辑。
登录认证我强烈推荐JWT方案,原因只有一个:它是无状态认证,后端不用保存Session,对前后端分离和多端部署很友好。核心逻辑是用户登录成功后,服务端签发一个token返回给前端,前端每次请求都把这token放在请求头里,后端拦截器校验token合法性。
JWT工具类:
@Component public class JwtUtil { private static final String SECRET = "health-community-system-secret"; public String createToken(Long userId, Integer role) { return Jwts.builder() .claim("userId", userId) .claim("role", role) .setExpiration(new Date(System.currentTimeMillis() + 24 * 3600 * 1000)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token) .getBody(); } }拦截器:
@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行OPTIONS预检请求 if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token == null || token.isEmpty()) { throw new BusinessException("未登录"); } // 校验token,解析失败则抛异常 Claims claims = jwtUtil.parseToken(token); request.setAttribute("userId", claims.get("userId")); request.setAttribute("role", claims.get("role")); return true; } }在WebMvcConfig注册时,要把登录接口和静态资源放行:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/auth/login", "/api/auth/register"); } }我踩过一个坑:前端请求会先发一个OPTIONS预检请求,如果不放行这个请求,拦截器会直接拦下来导致跨域失败,一定要记得把OPTIONS方法放行掉。
4.3 老人档案CRUD与文件上传
老人档案模块就是典型的单表CRUD,用MyBatis-Plus写起来非常简单。
@Service public class OldPersonServiceImpl extends ServiceImpl<OldPersonMapper, OldPerson> implements OldPersonService { @Override public Page<OldPerson> pageOldPerson(int page, int size, String keyword) { LambdaQueryWrapper<OldPerson> wrapper = new LambdaQueryWrapper<>(); wrapper.and(StringUtils.isNotBlank(keyword), w -> w.like(OldPerson::getName, keyword) .or().like(OldPerson::getPhone, keyword) ); return this.page(new Page<>(page, size), wrapper); } }这里有个细节值得讲:用LambdaQueryWrapper而不是普通的QueryWrapper,最大好处是类型安全,实体字段改名后编译期就能发现错误,而不是运行期才爆出SQL异常。
文件上传(比如老人头像、体检报告)用的是SpringBoot的MultipartFile:
@PostMapping("/api/upload") public Result<String> upload(@RequestParam("file") MultipartFile file) { // 生成唯一文件名,防止重名覆盖 String originalFilename = file.getOriginalFilename(); String ext = originalFilename != null ? originalFilename.substring(originalFilename.lastIndexOf(".")) : ""; String fileName = UUID.randomUUID() + ext; // 按照日期分目录存储,避免一个目录文件过多 String datePath = new SimpleDateFormat("yyyyMMdd").format(new Date()); String realPath = "D:/upload/" + datePath + "/"; File dir = new File(realPath); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(realPath + fileName)); return Result.success("/upload/" + datePath + "/" + fileName); }注意文件路径不要直接存在根目录,建议用年月日建子目录。体检报告如果是PDF,前端可以用pdf.js或者浏览器内置的<iframe src>来预览;图片则可以直接用<img>标签展示。之前有同学问"Vue的image能显示PDF吗",严格说image标签不能直接显示PDF,要么用embed、iframe,要么用PDF.js渲染成canvas再显示。我在这个项目里体检报告用的是iframe展示,简单够用。
4.4 健康记录上报与异常预警
健康记录上报是核心接口。社区专员每次测完血压、血糖后,把数据提交到后端:
@PostMapping("/api/health-record") @Transactional(rollbackFor = Exception.class) public Result<?> addHealthRecord(@RequestBody HealthRecord record) { // 1. 保存健康记录 healthRecordService.save(record); // 2. 查询阈值配置(血压高于140/90属于异常) Threshold threshold = thresholdService.getThresholdByType(record.getType()); // 3. 如果异常,生成预警消息通知家属 if (threshold != null && isAbnormal(record, threshold)) { List<FamilyBind> binds = familyBindService.getByOldPersonId(record.getOldPersonId()); for (FamilyBind bind : binds) { warningService.createWarning( record.getOldPersonId(), bind.getUserId(), "健康异常提醒:" + getTypeName(record.getType()) + "数值为 " + record.getValue() ); } } return Result.success(null); }这段代码有几个要点。@Transactional保证记录保存和预警生成的原子性——如果预警消息生成失败,健康记录也要回滚,不会出现"数据存了但家属没收到提醒"的脏状态。异常判断我用的是阈值配置而不是硬编码,这样调整阈值不用改代码,是很小但很实用的设计。
定时任务可以做用药提醒。健康管理系统里老人的长期用药很重要,可以做一个定时任务每天定时查用药计划表,给相关人员发送提醒:
@Component public class MedicationTask { @Scheduled(cron = "0 30 8 * * ?") public void sendMedicationReminder() { // 查询当天需要用药的老人及其家属绑定 // 发送短信或站内消息提醒 } }在启动类加上@EnableScheduling开启定时即可。注意定时任务里的逻辑最好是幂等的——重复执行不会产生重复数据。最简单做法是每次执行前查一下今天的提醒是否已经发过,发过就跳过。
5. 前端核心实现:Vue3里的界面与交互
5.1 项目初始化与路由配置
前端我建议直接使用Vue3 + Vite + Element Plus的组合。Vite的启动速度比vue-cli快很多,Element Plus的组件库能直接满足后台管理的多数场景,表格、表单、弹窗、树形控件都有现成的。
用Vite创建项目:
npm create vite@latest health-web -- --template vue cd health-web npm install npm install element-plus axios vue-router pinia echarts路由配置使用vue-router 4,这里要注意初始路由和登录页的处理:
import { createRouter, createWebHistory } from 'vue-router' const routes = [ { path: '/login', component: Login, meta: { public: true } }, { path: '/', component: HomeLayout, children: [ { path: '', redirect: '/dashboard' }, { path: 'dashboard', component: Dashboard }, { path: 'old-person', component: OldPersonList }, { path: 'health-record', component: HealthRecord }, { path: 'statistics', component: Statistics } ] } ] const router = createRouter({ history: createWebHistory(), routes }) // 路由守卫:没有token就跳登录 router.beforeEach((to) => { const token = localStorage.getItem('token') if (!to.meta.public && !token) { return '/login' } return true })路由守卫是前端权限的第一道门。它的作用是未登录用户不允许访问后台页面,但真正的数据权限还是靠后端接口拦截器控制。前端守卫负责"体验",后端拦截器负责"安全",这个边界要分清楚。
Vue3里还想提一个动态路由的概念。简单说就是根据用户角色动态添加路由表,管理员能访问用户管理,家属用户不能。这个系统的第一版可以用固定的路由表加按钮级权限控制实现,例如v-if="role===1"控制某个菜单显示,效果一样,复杂度低很多。如果有余力再上动态路由不迟。
5.2 Axios封装与开发代理
Axios封装是我在所有Vue项目里必做的事,不封装直接用的后果是每个页面都要重复写loading、错误提示、token加头,代码能重复到让人崩溃。我习惯把axios实例单独放在src/utils/request.js:
import axios from 'axios' import { ElMessage } from 'element-plus' import router from '../router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) // 请求拦截器:自动携带token request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = token } return config }) // 响应拦截器:统一处理错误 request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } else { ElMessage.error('请求失败,请稍后重试') } return Promise.reject(error) } ) export default request开发时的跨域通过vue.config.js代理解决:
// vite.config.js export default defineConfig({ server: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })这样前端的/api/old-person请求会被代理到后端的http://localhost:8080/api/old-person,浏览器端不会产生跨域错误。生产环境就把前端打包成静态文件丢给Nginx,再配置一个location /api反向代理到后端服务,思路上是一样的。
5.3 健康趋势图与数据可视化
这个系统最有展示效果的页面就是统计报表。我用ECharts画血压趋势折线图,家属端也能看到老人的健康数据变化。这是整个系统里"体验感"最强的部分,答辩时一定要演示。
先封装一个图表组件:
<template> <div ref="chartRef" :style="{ height: height + 'px' }"></div> </template> <script setup> import * as echarts from 'echarts' import { ref, onMounted, onBeforeUnmount } from 'vue' const props = defineProps({ height: { type: Number, default: 300 } }) const chartRef = ref(null) let chartInstance = null onMounted(() => { chartInstance = echarts.init(chartRef.value) }) onBeforeUnmount(() => { chartInstance && chartInstance.dispose() }) function setOption(option) { chartInstance && chartInstance.setOption(option) } defineExpose({ setOption }) </script>在统计页面调用接口获取近30天的血压数据,转换成ECharts需要的格式:
const trendData = async (oldPersonId) => { const res = await request.get(`/health-record/trend`, { params: { oldPersonId, days: 30, type: 1 } }) chartRef.value.setOption({ xAxis: { type: 'category', data: res.data.dates }, yAxis: { type: 'value', name: 'mmHg' }, series: [{ name: '收缩压', type: 'line', data: res.data.highValues, smooth: true, areaStyle: {} }, { name: '舒张压', type: 'line', data: res.data.lowValues, smooth: true, areaStyle: {} }] }) }图表加载有个常见问题:有时候明明数据接口正常,图却显示不出来。十有八九是echarts容器初始化时宽度或高度为0。解决办法是在容器渲染完成后再init,如果容器一开始是display:none或者v-if控制了显隐,就需要在显隐切换后重新init。我在项目里用nextTick处理:
const showPanel = ref(false) watch(showPanel, (val) => { if (val) { nextTick(() => { chartInstance = echarts.init(chartRef.value) }) } })5.4 页面组件与权限细节
老人列表页用Element Plus的Table和Pagination组合:
<el-table :data="list" v-loading="loading" stripe> <el-table-column type="index" label="序号" width="60" /> <el-table-column prop="name" label="姓名" /> <el-table-column prop="gender" label="性别" :formatter="genderFormatter" /> <el-table-column prop="phone" label="联系电话" /> <el-table-column prop="chronicDisease" label="慢性病史" show-overflow-tooltip /> <el-table-column label="操作" width="240"> <template #default="{ row }"> <el-button type="primary" link @click="goDetail(row)">详情</el-button> <el-button type="warning" link @click="goEdit(row)">编辑</el-button> <el-button type="danger" link @click="handleDelete(row)">删除</el-button> </template> </el-table-column> </el-table> <el-pagination v-model:current-page="query.page" :page-size="query.size" :total="total" @change="loadData" />权限控制这里我遇到过一个教学相长的细节:前端不能只靠隐藏按钮来实现权限,一定要在后端接口再校验一次。比如家属角色调用"删除老人"接口,后端必须判断当前用户角色不是管理员就拒绝。我一般用一个自定义注解在Controller上标注需要的角色,比如@RequireRole(role = 1),再用AOP或者拦截器统一校验,这样每个接口只需要加一行注解,权限逻辑不会散落到处都是。
6. 前后端联调与打包部署
6.1 本地联调流程
开发阶段前后端是分开跑的。前端npm run dev跑在3000端口,后端SpringBoot跑在8080端口。联调的第一步检查后端的接口文档——我一般用Knife4j,它是Swagger的增强版,页面清爽,不用登录就能看接口。前端同学对着接口文档调接口,比自己猜路径效率高十倍。
联调时我的工作是先按接口文档把Postman的接口测试跑一遍,确认后端都通了再让前端对接,否则定位问题会分不清是前端的问题还是后端的问题。前后端联调遇到接口返回格式与约定不一致时,尽量改后端,因为后端是统一返回,改一处全改动。
6.2 后端打包与前端部署
后端打包:
mvn clean package -DskipTests打包成jar包后直接用命令启动:
java -jar health-server.jar --spring.profiles.active=prod我习惯在application-prod.yml里配置生产环境的数据源和端口,开发和生产配置通过spring.profiles.active切换,这样不会出现本地开发配置误连生产库的问题。
前端打包:
npm run build打包产物在dist目录。有两种部署方式:
第一种是前后端完全分离部署,dist文件放到Nginx的html目录,Nginx配置代理转发API请求到后端jar包:
server { listen 80; server_name yourdomain.com; location / { root /usr/share/nginx/html; index index.html; 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; } }第二种是把前端dist目录复制到SpringBoot项目的src/main/resources/static目录下,然后重新打包成一个jar。这种方式适合演示场景,不需要额外装Nginx。但有个明显的坑:Vue Router如果用了history模式,刷新页面会404,因为SpringBoot不认识前端的路由路径。解决方法是加一个控制器把所有非API路径转发到index.html:
@Controller public class SpaForwardController { @RequestMapping(value = "/{path:[^\\.]*}", produces = "text/html") public String forward() { return "forward:/index.html"; } }或者把Vue Router的history模式改为hash模式(路径上有#号),刷新不会出问题。两种方案,我推荐开发期用hash模式方便,答辩演示用history模式+Nginx更专业。
6.3 环境差异坑
部署到Linux服务器上,很多同学会遇到MySQL连接不上或者启动失败的问题。第一排查防火墙有没有放行3306和8080端口;第二检查MySQL的bind-address配置,默认只监听127.0.0.1,外部机器连不上需要改成0.0.0.0;第三注意MySQL 8.0的加密规则,如果客户端连的时候报"Authentication plugin 'caching_sha2_password' cannot be loaded",说明驱动版本太旧,用最新版MySQL Connector/J即可。
7. 常见问题排查速查表
做了这么多前后端分离项目,把最容易踩的坑整理成一张排查表,遇到问题对号入座就行。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| SpringBoot启动报数据源错误 | MySQL版本和驱动不匹配 | MySQL 8.0用com.mysql.cj.jdbc.Driver,5.x用com.mysql.jdbc.Driver |
| 连接报SSL错误 | 连接串没加useSSL=false | 加?useSSL=false&serverTimezone=Asia/Shanghai |
| 前端请求接口返回401 | token过期或没带token | 检查请求拦截器是否设置Authorization头,检查token过期时间 |
| 前端请求接口跨域报错 | 3000端口请求8080端口 | 用Vite代理或后端允许CORS,生产环境用Nginx转发 |
| 数据库存中文变问号 | 数据库或表字符集不是utf8mb4 | 统一改成utf8mb4,连接串加characterEncoding=utf8 |
| Vue页面刷新后404 | history模式路由刷新时无法匹配 | Nginx配置try_files,或改成hash模式 |
| 日期显示NaN或时间差8小时 | 前后端时区不一致 | 后端统一用serverTimezone=Asia/Shanghai,前端格式化YYYY-MM-DD |
| 删除有绑定的老人报外键错误 | 关联表有数据引用 | 设计时不要物理外键删除保护,要么软删除,要么级联删除 |
| Maven依赖下载慢 | 镜像源问题 | 配置阿里云Maven镜像仓库 |
| 打包后静态资源404 | 前后端合并部署的Spa问题 | 增加forward控制器或者用Nginx部署 |
其中有两个值得单独展开:
数据库时间字段和前端显示的8小时偏差问题。后端MySQL的datetime是时区无关的,如果JDBC连接串没配置时区,默认取系统时区,而前端浏览器用的又是本地时区,两边对不上就会差8小时。统一在连接串和Jackson配置里都用
Asia/Shanghai,问题迎刃而解。删除老人提示外键约束失败。如果表之间用了数据库物理外键,删除主表数据时关联表有记录就会报错。我的实践是数据库层不用物理外键,只在代码层维护关系逻辑。这样删除、分页查询的性能更好,也不会被外键约束卡住。需要在业务层自己保证数据一致性,配合事务处理。
8. 写在最后:从项目到能力沉淀
做这个毕设,我最大的体会是"系统设计永远比写代码重要"。把需求理清楚、把数据库表设计对、把权限边界划分好,剩下的代码就是体力活。反过来,如果一上来就埋头写代码,写着写着发现表结构缺字段、角色权限混乱、接口返回格式五花八门,返工成本远远超出预期。
对这个项目,我个人推荐一条扩展路径:接入物联网设备。老人佩戴的手环定时上报心率、步数到系统,后端提供一个接收设备数据的HTTP接口,前端展示实时数据。这个扩展方案技术上不复杂,但会让毕设的档次明显上升——从"信息管理系统"变成"智慧健康监测平台",论文的落脚点也更有时代感。
还有一条更省事的扩展是消息通知渠道。站内消息第一版已经有,后续可以接入短信或者微信公众号模板消息。这样老人血压异常时可以自动发短信给家属,而不是等家属自己登录系统看。这个功能在答辩时讲出来,评委会觉得你考虑到了真实业务场景。
最后分享一个小事:我记得项目联调阶段,有次家属端图表怎么都不显示,排查了半天发现是老年人生日字段传成了字符串,导致前端的年龄计算函数返回了NaN,进而图表x轴数据全是空值。从那以后我所有接口返回的日期字段都统一格式化成yyyy-MM-dd,不在前端做二次解析。这类小坑非常不显眼,但每一个都能让人折腾一下午。
这个系统做完,你收获的不只是一份毕业设计,而是一条完整的前后端分离开发流程——从需求分析、数据库建模、接口设计、联调到部署,每一步你都亲手走过。面对"说说这个系统的架构"、"为什么选这个技术栈"这类问题,你心里是有底气的,因为每一步都是你自己选择和验证过的。