十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

个人健康管理系统开发实战:SpringBoot+VUE从建表到联调

个人健康管理系统开发实战:SpringBoot+VUE从建表到联调 简介基于SpringBootVue的个人健康管理系统毕业设计项目面向计算机、通信、人工智能等专业的学生及开发者适合用作课程设计、课程大作业或毕业设计参考。系统围绕健康档案、数据记录与统计分析等典型场景实现后端Java业务逻辑与前端Vue页面交互的完整闭环代码结构分层清晰便于理解全栈开发流程。压缩包共2000个文件以Java源码、Vue组件、JS/CSS静态资源及XML/YML配置文件为主同时包含说明文档与辅助脚本整体大小约32MB目录组织完整可快速定位前端页面、后端接口和配置项。这套代码为作者个人毕设成果答辩评审平均分达95分所有功能均通过测试可正常运行目前已有369人学习或下载。对于希望掌握SpringBootVue整合开发、学习健康管理系统业务设计的读者该项目从环境搭建到功能落地均有较高参考价值也便于在此基础上二次修改与功能扩展。1. 个人健康管理系统为什么 SpringBoot VUE 能速通一个完整毕设做一套个人健康管理系统很多人第一反应是“不就是体重、步数、血压的增删改查”。可真把 Java SpringBoot 后端和 VUE 前端合在一起时你会发现需要回答的是另一类问题每天一条健康记录应该用什么结构落库趋势图的数据是后端聚合还是前端计算重新登录之后用户数据怎么隔离这套系统的价值不在“会写 CRUD”而在把用户、指标、时间三条线串起来。下面按我做这类需求时最快落地的路径来讲先定表再写接口最后让页面动起来。2. 建表先于编码个人健康管理系统的数据模型与 health_record 设计在写任何业务接口之前我一般先把“健康”翻译成表结构。个人健康管理系统的数据主体有两类用户基础信息以及每个用户每天产生的健康指标。第一类落在sys_user表第二类落在以health_record为核心的记录表。字段、类型和索引在这一步定下来后面所有代码都是围绕这套结构写。2.1 功能边界健康管理系统只需要三条业务线不要一上来就做睡眠分期、饮食摄入、心率变异性那是把个人健康管理做成了医疗系统。毕设或内部工具做到三件事就足够完整用户认证与资料维护注册、登录、修改身高体重和每日目标步数健康记录录入支持手动填写体重、血压、心率、睡眠时长、步数等指标趋势查看按日期范围展示某一指标的变化曲线并给出最大、最小、平均等基础统计。选 SpringBoot VUE 做这三条线原因是前后端可以并行开发后端只暴露接口前端先用 Mock 数据画页面联调阶段再替换成真实接口。这也是当前企业管理系统最常见的协作方式“前后端分离”在这里对应的不是技术炫技而是工程分工。对个人开发者来说后端接口和前端页面分开跑排错时能快速定位问题是出在数据格式还是页面渲染。2.2 一张宽表还是一条记录一个指标健康记录表有两种常见设计。第一种是“一条记录一个指标”每行包含metric_type和metric_value指标可以无限扩展但要查某一天的全部指标就得聚合多行做趋势图还得对数据做透视接口写起来绕。第二种是“一天一行”宽表体重、血压、步数、睡眠时长各占一列插入和查询最直觉和页面表单的对应关系也最简单。个人健康管理系统的规模用第二种更合适。一个人一天最多产生一条记录数据量小指标字段是确定的不会每天新增一个类型趋势查询只需要按日期排序。牺牲的扩展性可以通过“预留字段”和后续加表来弥补但对一个以展示为主的系统来说宽表带来的开发效率提升非常明显。2.3 建表 SQL 与字段参数说明sys_user表保存账号和默认身体参数health_record表保存每日数据。建表语句如下CREATE TABLE sys_user ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(32) NOT NULL UNIQUE, password_hash VARCHAR(60) NOT NULL, nickname VARCHAR(32) DEFAULT NULL, gender TINYINT DEFAULT 0 COMMENT 0未知 1男 2女, height_cm DECIMAL(5,1) DEFAULT NULL, target_steps INT DEFAULT 8000, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE health_record ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id BIGINT UNSIGNED NOT NULL, record_date DATE NOT NULL, height_cm DECIMAL(5,1) DEFAULT NULL, weight_kg DECIMAL(5,2) DEFAULT NULL, systolic_pressure INT DEFAULT NULL, diastolic_pressure INT DEFAULT NULL, heart_rate_bpm INT DEFAULT NULL, steps INT DEFAULT NULL, sleep_hours DECIMAL(3,1) DEFAULT NULL, note VARCHAR(255) DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user_date (user_id, record_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;围绕这张表有几点值得说明。UNIQUE KEY uk_user_date在数据库层面拦住了“同一用户同一天多条记录”接口里先查后插也只是二次保护。password_hash用 VARCHAR(60) 是因为 BCrypt 生成的哈希长度固定为 60不需要给 255当然如果你后续要换加密算法再调长度也不迟。record_date用 DATE 而不是 DATETIME让“一天一行”的约束直接从字段类型上成立避免 Java 端还要截断时间。字段说明汇总如下字段类型说明idBIGINT UNSIGNED主键自增user_idBIGINT UNSIGNED所属用户关联 sys_user.idrecord_dateDATE记录日期不允许为空height_cmDECIMAL(5,1)身高单位 cm体重变化时用于计算 BMIweight_kgDECIMAL(5,2)体重单位 kgsystolic_pressureINT收缩压单位 mmHgdiastolic_pressureINT舒张压单位 mmHgheart_rate_bpmINT静息心率次/分钟stepsINT当日步数sleep_hoursDECIMAL(3,1)睡眠时长单位小时noteVARCHAR(255)备注可记录身体状态按“用户 日期范围”查询是这张表最频繁的操作SQL 形态是WHERE user_id ? AND record_date BETWEEN ? AND ?联合唯一键(user_id, record_date)恰好覆盖这个访问模式不需要再为 create_time 建额外索引。有些方案会在建表时顺手给所有字段加索引对个人系统来说只会浪费磁盘和写入时间。这里也提一下“springboot mybatis 当表不存在自动建表”这个常见做法。开发期可以用 MyBatis 或 Hibernate 的自动建表能力但别让ddl-auto: update长期生效。因为实体字段一旦改错数据库表结构会被静默变更线上数据可能直接丢字段。成熟一点的项目会把 SQL 交给 Flyway 管表结构变更走版本化脚本。3. SpringBoot 后端健康记录写入与趋势查询接口实现表结构定好后后端工作就是围绕sys_user和health_record提供接口。下面按 SpringBoot 3.x 的常用写法给依赖、配置、写入接口和趋势查询接口关键在版本差异和日期字段处理。3.1 依赖与版本SpringBoot 3.x 下 MyBatis-Plus 别选错 starter用 Spring Initializr 创建工程时选 Java 17 和 SpringBoot 3.x 稳定版。如果你照着 SpringBoot 2.7 的教程把mybatis-plus-boot-starter直接加进 3.x 工程启动时会见到Failed to introspect Class一类错误根因是包名从javax迁移到了jakartaORM 框架也必须切换。正确依赖是dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version${mybatis-plus.version}/version /dependency${mybatis-plus.version}需要在你创建工程时改成当前可用的稳定版本可以直接去 Maven 仓库搜 groupId 加 artifactId把最新版本号填进去不要沿用网上教程里 2.x 的旧坐标。除了它还需要spring-boot-starter-web、spring-boot-starter-validation、mysql-connector-j和 Lombok。认证部分可以加 jjwt但健康数据接口本身不依赖认证框架下面先专注数据接口。提示如果启动时出现ClassNotFoundException: javax.servlet.*说明框架包还是旧的 SpringBoot 2 命名空间优先检查 starter 坐标是否带spring-boot3标识。3.2 SpringBoot 配置日期格式、时区与跨域健康记录按自然日处理前后端日期字符串统一为yyyy-MM-dd时区必须锁定GMT8。application.yml里这样配server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/health_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: ${DB_PASSWORD:root} jackson: date-format: yyyy-MM-dd time-zone: GMT8 mybatis-plus: configuration: map-underscore-to-camel-case: truemap-underscore-to-camel-case让record_date自动映射到recordDate字段少写一堆TableField。数据库连接的serverTimezoneAsia/Shanghai和 Jackson 的time-zone: GMT8必须同时存在否则后端读出LocalDate可能比前端早 8 小时。数据库密码用${DB_PASSWORD:root}占位开发环境默认 root部署时通过环境变量覆盖。跨域集中处理比在每个 Controller 加CrossOrigin更干净。我一般在配置类里注册Configuration public class WebConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(http://localhost:5173) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*); } }这里allowedOrigins写的是 Vite 开发服务器默认地址。前端如果换端口联调时会先看到跨域报错优先检查这里而不是浏览器插件。3.3 写入接口同一用户同一天只保留一条记录Controller 层收 DTOuserId从登录上下文拿不从请求体拿避免水平越权。新增和更新合并成一个接口由 Service 层根据日期判断PostMapping(/api/health/record) public ResultLong addRecord(Valid RequestBody HealthRecordDTO dto) { Long userId SecurityUtils.getCurrentUserId(); return Result.success(healthRecordService.addOrUpdateRecord(userId, dto)); }Service 实现public Long addOrUpdateRecord(Long userId, HealthRecordDTO dto) { if (dto.getRecordDate().isAfter(LocalDate.now())) { throw new BusinessException(不能记录未来日期的健康数据); } HealthRecord record healthRecordMapper.selectOne( new LambdaQueryWrapperHealthRecord() .eq(HealthRecord::getUserId, userId) .eq(HealthRecord::getRecordDate, dto.getRecordDate()) ); if (record null) { record new HealthRecord(); record.setUserId(userId); record.setRecordDate(dto.getRecordDate()); } BeanUtils.copyProperties(dto, record, id, userId, recordDate); if (record.getId() null) { healthRecordMapper.insert(record); } else { healthRecordMapper.updateById(record); } return record.getId(); }这段代码有三个关键点先查后插保证一天一条isAfter(LocalDate.now())挡住未来日期BeanUtils.copyProperties排除id、userId、recordDate前端传这三个字段也改不掉归属和时间。更新时保持recordDate不变新增时使用 DTO 里的日期语义清楚。ResultT是一个简单的通用返回体包含code、message、data三个字段即可接口无论成功失败都返回统一结构。3.4 趋势查询指标类型映射到列前端画趋势图时传一个metricType后端把指标类型映射到宽表对应列。查询核心是“按日期排序取一段范围”然后逐条转换成{date, value}结构public ListTrendPointVO getTrend(Long userId, Integer metricType, LocalDate startDate, LocalDate endDate) { ListHealthRecord records healthRecordMapper.selectList( new LambdaQueryWrapperHealthRecord() .eq(HealthRecord::getUserId, userId) .between(HealthRecord::getRecordDate, startDate, endDate) .orderByAsc(HealthRecord::getRecordDate) ); return records.stream().map(r - { BigDecimal value switch (metricType) { case 1 - r.getWeightKg(); case 2 - BigDecimal.valueOf(r.getSystolicPressure()); case 3 - BigDecimal.valueOf(r.getDiastolicPressure()); case 4 - BigDecimal.valueOf(r.getHeartRateBpm()); case 5 - BigDecimal.valueOf(r.getSteps()); case 6 - r.getSleepHours(); default - throw new BusinessException(不支持的指标类型); }; return new TrendPointVO(r.getRecordDate(), value); }).toList(); }这里的switch是 Java 17 的表达式写法比一长串if else更容易维护。需要特别注意的是血压在宽表里是两列趋势图如果展示“血压”通常要两条线前后端约定好把收缩压和舒张压拆成两个metricType返回前端才能分别渲染。指标类型约定如下metricType含义value 来源1体重 kgweightKg2收缩压 mmHgsystolicPressure3舒张压 mmHgdiastolicPressure4心率 bpmheartRateBpm5步数 步steps6睡眠时长 小时sleepHours这套接口设计避开了一个常见坑不要在 Controller 里把整张HealthRecord实体直接返回前端那样会把userId、createTime等字段一起暴露后续改动字段时前端也可能被破坏。4. VUE 前端路由、Axios 与 ECharts 健康图表联动VUE 这一层负责两件事组织页面路由以及把后端数据画成图。这章用 Vite 初始化项目配好路由参数和请求实例然后写一个体重趋势组件。页面样式保持朴素但数据流是完整的。4.1 VUE 安装与环境配置用 Vite 初始化并装好 axios、echarts常见做法是用 Vite 而不是 Vue CLI 创建项目命令如下npm create vitelatest health-web -- --template vue cd health-web npm install npm install axios echarts命令会进入交互式提示选择 Vue 3 和 JavaScript 即可。Vite 创建的工程默认走组合式 API比选项式更适合管理接口请求和图表实例。组件库可以选 Ant Design Vue 或 Element Plus但个人健康管理系统的主要页面就两三个先不引组件库页面跑通后再加也不迟。这里也提醒一句npm install遇到 peer 依赖冲突时先看是 node 版本还是包版本问题不要一上来就--force那样容易装出运行时报错的环境。4.2 Vue 路由参数详情页怎么拿到要编辑的日期页面结构通常是登录页、主页面和记录编辑页路由对应关系如下页面路由用途登录页/login用户登录仪表盘/dashboard健康概览与趋势图记录编辑/record/:date编辑某一天的记录编辑页需要知道“编辑哪一天的数据”我把日期放进路径参数import { createRouter, createWebHistory } from vue-router import Dashboard from ../views/Dashboard.vue import RecordEdit from ../views/RecordEdit.vue const router createRouter({ history: createWebHistory(), routes: [ { path: /, redirect: /dashboard }, { path: /dashboard, name: dashboard, component: Dashboard }, { path: /record/:date, name: record-edit, component: RecordEdit } ] }) export default router在RecordEdit.vue里通过useRoute读取import { useRoute } from vue-router const route useRoute() const recordDate route.params.date/record/2025-06-01这种路径式参数比?date2025-06-01更适合“某一天的数据”这类资源URL 本身就能表达层级。如果要传多个条件比如日期加指标类型再考虑 query 参数。路由参数到手之后编辑页加载时用这个日期去请求后端对应记录保存时也把这个日期传回去。4.3 Axios 统一实例token 附带和错误处理收敛不要在页面里直接散落axios.get我在src/api/http.js里封一个实例import axios from axios const http axios.create({ baseURL: http://localhost:8080/api, timeout: 10000 }) http.interceptors.request.use(config { const token localStorage.getItem(health_token) if (token) { config.headers.Authorization Bearer ${token} } return config }) http.interceptors.response.use( response response.data, error { if (error.response?.status 401) { localStorage.removeItem(health_token) window.location.href /login } return Promise.reject(error) } ) export default httpbaseURL写成绝对地址联调时最简单。如果后端接口分布在多台机器再用环境变量区分。请求拦截器统一附带 token响应拦截器统一解包外层结构并处理 401。这样页面里只需要写const { data } await http.get(/health/trend, { params: {...} })拿到的data就是后端 Result 里的业务数据。注意 token 的键名和 localStorage 的读取位置要前后端约定一致最常遇到的坑是前端存了token请求头却叫Authorization导致后端过滤器拿不到身份信息。4.4 ECharts 折线图组合式 API 里数据流怎么组织以体重趋势为例template放一个绑定 ref 的 divscript setup里请求接口后初始化图表script setup import { ref, onMounted, watch } from vue import * as echarts from echarts import http from ../api/http const chartRef ref(null) const metricType ref(1) const unitMap { 1: kg, 2: mmHg } let chart null async function loadTrend() { const { data } await http.get(/health/trend, { params: { metricType: metricType.value, startDate: 2025-01-01, endDate: 2025-06-30 } }) if (!chart) { chart echarts.init(chartRef.value) } chart.setOption({ xAxis: { type: category, data: data.map(p p.date) }, yAxis: { type: value, name: unitMap[metricType.value] || }, series: [{ type: line, data: data.map(p p.value), smooth: true, areaStyle: { opacity: 0.1 } }], tooltip: { trigger: axis } }) } onMounted(loadTrend) watch(metricType, loadTrend) /script template div select v-modelmetricType option :value1体重/option option :value2收缩压/option /select div refchartRef styleheight: 360px/div /div /templateecharts.init必须等 div 渲染完成所以放在onMounted里。接口字段是date和value和script setup里 map 的字段一一对应。如果换成睡眠时长只需把metricType改成 6并把 y 轴 name 改成 “小时”。每次切换指标时watch会重新请求图表自动刷新。这段代码把“数据状态”和“DOM 操作”拆开了这就是组合式 API 相对选项式最直观的差别逻辑只存在于script setup不再散落在data、methods和computed之间。这里也提一个调试技巧图表空白时先在loadTrend里console.log(data)确认后端字段名再决定改前端 map 还是改后端 VO。多数情况不是 ECharts 配置错了而是接口数据结构和页面预期不一致。5. 前后端联调的四类报错与「每日提醒」小功能落地联调阶段的高频问题集中在四类直接对应处理方式。现象根因处理方式请求能发出去浏览器报 CORS后端跨域来源与前端地址不一致统一allowedOrigins上线换域名后端收到的日期变成时间戳LocalDate 被 Jackson 默认序列化yml 配date-format、字段加JsonFormat图表 Y 轴全为 0 或空白后端字段是weightKg前端取了value先 console.log 接口返回再改映射刷新后登录状态丢失token 只存在内存变量写入 localStorageaxios 拦截器统一读取5.1 联调检查表先看状态码401 是认证问题403 是权限问题404 优先查路由和RequestMapping前缀500 在后端日志找异常栈。再看日期接口入参用yyyy-MM-dd数据库连接和 Jackson 时区都设为GMT8。最后看字段后端实体、DTO、VO 三层字段名是否一致前端 map 时是否拼错了大小写。这套检查表能定位大部分联调问题剩下的往往集中在请求参数格式上比如前端传了空字符串而后端校验NotNull这时把请求体完整打出来比对即可。5.2 给系统加一个“步数提醒”定时任务一个常见又加分的扩展是每日提醒每天上午 9 点检查昨天步数是否达到目标没达到就生成提醒。SpringBoot 实现成本很低先在启动类加EnableScheduling然后写一个定时任务Component RequiredArgsConstructor public class HealthRemindTask { private final HealthRecordMapper healthRecordMapper; private final RemindService remindService; Scheduled(cron 0 0 9 * * ?) public void remindYesterdaySteps() { LocalDate yesterday LocalDate.now().minusDays(1); ListHealthRecord records healthRecordMapper.selectList( new LambdaQueryWrapperHealthRecord() .eq(HealthRecord::getRecordDate, yesterday) .lt(HealthRecord::getSteps, 8000) ); records.forEach(r - remindService.send(r.getUserId(), 昨日步数未达标)); } }cron 表达式0 0 9 * * ?表示每天 9 点触发秒、分、时分别固定为 0。查询条件是“昨天”和“步数小于 8000”目标步数也可以从sys_user.target_steps读出来再逐人比较这里为了演示先写固定值。最容易被忽略的是Scheduled默认单线程执行如果以后加了邮件、短信等耗时通知要对定时任务配置线程池否则任务之间会互相阻塞。本文还有配套的精品资源点击获取
返回列表