基于SpringBoot+Vue3的训练信息管理系统开发实战

发布时间:2026/9/9 17:39:05
基于SpringBoot+Vue3的训练信息管理系统开发实战 半年前被朋友拉进一个球队管理群说是让我顺手帮忙搞个小程序记录训练考勤。结果真坐到电脑前才发现教练的需求远不止打卡训练计划要提前下发每个球员的体能数据要按月追踪请假审批得有流程连训练分组都想在系统里一键排好。群里的Excel表格已经改了十几个版本光是本周出勤率就对不上三次账。折腾了一个多月我用SpringBootVue3MyBatisMySQL给这支球队搭了一套前后端分离的训练信息管理系统从数据库表设计到页面联调再到打包部署全程走了一遍。这篇文章就是把整个搭建过程踩过的坑、做过的取舍、以及最终能稳定跑起来的方案完整记录下来给同样打算做这类信息管理系统的同学一个可复现的参考。1. 训练管理的真实痛点决定了这套系统的边界先说清楚一个事实这类XX信息管理系统最容易翻车的地方不是代码写不出来而是功能范围没定清楚。教练随口一句最好什么都能管你要真照做光需求文档就能写一百页。所以我动手前第一件事是把球队训练管理这个场景拆成真正有频率、有痛点的几件事。1.1 纸质记录与群接龙带不动的训练流程大部分业余或半职业球队的训练管理现状基本是教练在群里发训练通知球员用接龙回复收到到了球场由队长拿本子划勾体能测试数据记录在Excel里发到群里之后就被聊天记录淹没月度训练总结靠教练凭记忆写。这套流程在十个人的小球队勉强能转人员一过二十就必然出问题——考勤有人漏报训练计划有人没看到体能数据前后对不上。系统要解决的就是这三件事的信息化计划有发布渠道、考勤有统一记录、数据有固定沉淀。1.2 功能模块拆解三类角色六个核心模块我把最终确认的功能收敛成三类角色、六个模块。管理员负责系统配置和球员账号管理教练负责训练计划和评估球员只能查看自己的训练记录和考勤。六个模块分别是球员管理、训练计划、考勤打卡、请假审批、体能评估、数据统计。模块核心操作数据量级重要程度球员管理新增、编辑、分组、启用禁用几十到几百基础训练计划发布、编辑、归档、状态流转每周2-4条高频考勤打卡签到、补签、统计每次训练一条最高频请假审批提交、审批、状态低频流程性体能评估录入、趋势对比每月一次数据沉淀数据统计出勤率、训练完成率聚合展示决策辅助这个边界定完之后后面的表结构和接口设计就清晰了。不要一上来就想着搞消息推送、视频分析、AI排兵先把信息的录入、查询、统计闭环跑通系统才有实际使用价值。1.3 技术选型为什么没上微服务也没用JSP技术选型被问得最多我直接说结论。后端用SpringBoot而非传统SSH或JSP核心原因只有一个前后端分离之后后端只需要安心输出JSON接口SpringBoot的自动配置和嵌入式Tomcat让本地开发、测试、部署都省心。版本上我选了Spring Boot 2.7.x没有直接用3.x因为当时团队JDK环境还在83.x强制要求JDK17升级成本不划算。前端用Vue3而不是Vue2是因为项目是全新启动没必要抱着旧生态不放。配合Vite构建开发时的热更新速度比Webpack快得多。UI组件库选的Element Plus表单、表格、弹窗这些管理系统的高频组件都有现成封装。持久层选MyBatis而不是JPA或MyBatis-Plus是刻意做的减法。这个系统的SQL大多是明确的增删改查和少量统计查询MyBatis的XML映射能让SQL完全可控出现慢查询时可以直接把SQL拿出来EXPLAIN不需要理解框架自动生成的SQL。团队里如果有新手MyBatis的上手成本也比JPA低。数据库则是MySQL 8.0考虑到训练数据量级很小单库单表完全够用没必要引入分库分表。2. 数据库设计把训练数据拆成一张张有用的表很多人在这种小系统里容易犯一个毛病表设计得特别节省一张表恨不得装下所有字段。结果统计出勤率的时候要在一堆字段里LIKE匹配索引没法用数据一多就卡。我的原则是宁可多拆几张表也别让一张表承担过多职责。2.1 六张核心表与字段设计训练信息管理系统的核心表我最终定为六张每张表的职责单一字段命名统一采用小写加下划线和Java实体类的驼峰命名通过MyBatis的map-underscore-to-camel-case配置自动映射。第一张是用户表字段包括id、username、passwordBCrypt加密存储、real_name、roleADMIN/COACH/PLAYER、phone、status、create_time。这里特别注意密码一定不能明文存数据库泄露是小概率事件但一旦发生明文密码就是安全事故。第二张是球员表冗余了user_id做关联同时放了team_group分组字段、position场上位置、number球衣号、height、weight、join_date。位置和球衣号在训练统计里经常作为筛选条件单独成字段比塞在备注里好用得多。第三张是训练计划表字段有title、content、train_date、start_time、end_time、location、coach_id、status。status用整数表示状态0为草稿、1为已发布、2为已结束、3为已取消前端用tag标签展示不同颜色。第四张是考勤表核心字段是plan_id、player_id、statusstatus取值为PRESENT出勤/ABSENT缺勤/LEAVE请假。每条考勤记录都对应一条训练计划和一名球员这就是典型的关联表。第五张是请假审批表字段有plan_id、player_id、reason、start_time、end_time实际改成针对某次训练的请假简化流程、approve_status、approve_remark、approve_time。第六张是体能评估表字段有player_id、assess_date、assess_type如折返跑、俯卧撑、体脂率等、value、unit、evaluator_id。体能数据是典型的一人多次结构按球员和日期建立联合索引之后折线图的查询就是一条简单的索引扫描。2.2 关联关系与索引设计六张表的关系可以通过一对多和多对一概括用户对球员是一对一训练计划对考勤是一对多球员对考勤是一对多训练计划对请假也是一对多。所有外键在数据库层面我没建物理外键约束只保留逻辑关联理由很实在——物理外键在删除数据时会带来一堆顺序问题而这种规模的系统靠应用层保证数据一致性就足够了。索引设计上考勤表建了(plan_id, player_id)联合唯一索引保证同一个球员对同一次训练不会重复打卡这个唯一索引在数据库层面兜底了接口层的重复提交问题。请假表建了(plan_id, player_id)联合索引。体能评估表建了(player_id, assess_date)联合索引。训练计划表建了(train_date)普通索引因为列表页大概率按时间倒序查。2.3 初始化数据与脚本管理数据库脚本我放在项目的db目录下命名为01_schema.sql、02_init_data.sql用版本号管理每次改动增加新的脚本而不是修改旧的。这个习惯看起来简单但在多人协作或者回滚场景下非常救命。初始化数据里最重要的是一个默认管理员账号密码在脚本里用占位符首次启动后强制修改。另外我还预置了三四条训练计划和对应的考勤数据方便前端开发时不用每次手动造数据。如果团队有条件接一个Flyway或者Liquibase做自动迁移会更好但单机部署的小项目规范的SQL脚本加上执行文档已经够用。CREATE TABLE training_plan ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, title VARCHAR(128) NOT NULL COMMENT 训练标题, content TEXT COMMENT 训练内容详情, train_date DATE NOT NULL COMMENT 训练日期, start_time TIME COMMENT 开始时间, end_time TIME COMMENT 结束时间, location VARCHAR(255) COMMENT 训练地点, coach_id BIGINT COMMENT 教练用户ID, status TINYINT DEFAULT 1 COMMENT 状态0草稿 1已发布 2已结束 3已取消, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_train_date (train_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT训练计划表;3. 后端落地SpringBoot MyBatis 的关键节点后端工程结构直接决定后续维护体验。我见过不少项目Controller里塞业务逻辑Service层形同虚设结果接口一多就乱成一锅粥。这套系统虽然不大我还是严格遵守了controller-service-mapper三层结构。3.1 工程结构与统一返回设计工程按照业务模块分包而不是按技术类型分包。也就是说player包下面放PlayerController、PlayerService、PlayerMapper、Player实体而不是把所有Controller放一个包、所有Mapper放另一个包。按业务分包的好处是改一个功能时所有相关文件都在同一个目录上下文里IDEA里切文件非常顺手。统一返回结构是后端接口设计必须一开始就定好的规矩。我定义了一个Result类包含code、message、data三个字段code为0表示成功非0为业务错误码。所有接口返回Result前端拦截器根据code判断逻辑。这样做的好处是错误处理逻辑在前端只需要写一次不用每个接口单独判断。Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(0); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(Integer code, String message) { ResultT result new Result(); result.setCode(code); result.setMessage(message); return result; } }3.2 MyBatis的两种映射写法怎么选MyBatis的SQL写在注解里还是XML里我一直坚持一个标准单表简单CRUD用注解涉及多表关联或动态条件查询用XML。考勤打卡、请假提交这类单表操作用Insert、Update注解几行搞定不需要额外文件。而训练计划的分页条件查询要拼接训练日期范围、状态、标题模糊搜索这种动态SQL放XML里配合 标签和 标签可读性和维护性都远胜字符串拼接。这里必须强调一个MyBatis动态SQL的细节 标签的test条件里判断字符串和数字要用不同的写法。字符串判空用testtitle ! null and title ! 数字判断直接用teststatus ! null。我见过有人把状态字段用字符串判空方式写结果传入数字0时被当成空值列表页永远查不到草稿状态的计划排查了半天才发现是这个问题。分页这块我用的PageHelper一个PageHelper.startPage加上PageInfo封装就能搞定。需要注意PageHelper只对紧跟其后的第一条SQL生效如果startPage和Mapper调用之间插了其他数据库操作分页会失效或者跑到错误的SQL上。3.3 登录鉴权JWT配合拦截器够用且简单这种前后端分离系统的登录态最主流的方案就是JWT。用户登录成功后后端生成一个包含用户ID、用户名、角色、过期时间的token返回给前端前端存储到localStorage后续请求在Authorization头带上。后端用一个拦截器统一解析校验token不需要在Redis里维护session这在单机部署的小系统里既省资源又简单可靠。拦截器里我做了三件事解析token、取出用户信息放入ThreadLocal、校验角色权限。角色校验用的是自定义注解RequireRole标注在Controller方法上拦截器根据注解里声明的角色和当前用户角色做比对。比方说发布训练计划接口标注RequireRole({COACH,ADMIN})球员角色访问就会返回无权限。这套方案比Spring Security轻量太多又不失灵活性。Component public class JwtInterceptor 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 )) { throw new BusinessException(401, 未登录); } // 解析并校验token失败会抛出异常由全局异常处理器兜底 LoginUser user JwtUtil.parseToken(token.substring(7)); UserContext.set(user); return true; } }4. Vue3 前端从创建项目到页面组件的完整链路前端部分我默认读者有基本的Vue基础所以重点讲工程搭建、请求封装和几个核心页面的实现思路而不是从template语法讲起。创建Vue3项目我用的命令很固定npm create vitelatest frontend -- --template vue选择Vue官方推荐的Vite作为构建工具。4.1 创建Vue3工程后的目录规划与依赖创建完工程后先把目录按views、components、router、store、api、utils规划好。views放页面级组件components放通用组件api目录按业务模块放接口请求文件utils放request封装和工具函数。这种目录结构对中小型管理系统来说足够清晰不需要引入复杂的企业级分层。依赖方面除了vue-router和pinia我装了element-plus、axios、dayjs这三个关键库。element-plus按需引入用unplugin-auto-import和unplugin-vue-components两个插件避免全量打包导致体积膨胀。dayjs用来处理日期格式化Element Plus的日期组件默认返回Date对象转成后端需要的yyyy-MM-dd格式用dayjs一行搞定。4.2 axios请求封装统一处理token和错误码axios封装是我每次都要强调的部分。不只是简单地设置baseURL还要做三件事请求拦截器统一加token、响应拦截器统一处理业务错误码、401时跳转登录页。// utils/request.js import axios from axios import { ElMessage } from element-plus import router from /router const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response { const res response.data if (res.code ! 0) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res.data }, error { if (error.response?.status 401) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(error.response?.data?.message || 网络异常) return Promise.reject(error) } )开发环境的跨域问题我在vite.config.js里配置了proxy代理。前端请求写/api开头的相对路径代理把请求转发到后端8080端口。生产环境则通过Nginx反向代理这个后面部署章节会细说。4.3 核心页面实现思路考勤打卡页面考勤打卡是使用频率最高的页面设计得好不好直接影响球员的使用意愿。我的方案是进入考勤页先选择训练计划页面展示该计划下所有球员的名单卡片每个卡片上有出勤请假缺勤三个按钮教练点击即完成打卡实时更新统计数字。这个页面有两个实现要点。一是数据加载时一次性把该计划的考勤记录查出来用player_id作为key组装成一个对象前端根据对象判断每个球员当前状态而不是每点击一个按钮就发一次请求。二是状态变更使用乐观更新先更新前端UI再发异步请求请求失败时回滚并提示。这样在网络稍慢的情况下教练连点也不会觉得卡顿。请假审批页面对应的是请假表的流程处理。球员提交请假后请假记录状态为PENDING教练的审批列表里可以看到待办点击审批通过或驳回后状态更新。前端用el-tabs把待审批已审批分成两个页签状态清晰且实现简单。5. 联调阶段的三个经典坑每一个都浪费了我一下午前后端联调是这套系统开发过程中最折磨人的阶段踩过的坑很多但归结起来有三个最具代表性分别是跨域、字段映射和时间格式。这三个问题几乎每个前后端分离项目都会遇到值得单独拎出来复盘。5.1 跨域问题本地联调时前端页面一片空白第一次启动前端工程访问页面时所有接口请求都是红色报错控制台提示CORS policy。原因很清楚前端跑在5173端口后端跑在8080端口浏览器的同源策略拦截了跨域请求。我最终的解决方案是双管齐下开发环境用Vite的proxy代理解决生产环境用Nginx反向代理解决后端不开启CORS跨域配置。这里有个容易被忽略的细节Vite配置proxy之后前端代码里的请求URL一定不要写完整的http地址必须写/api这类相对路径代理才会生效。我看到有人前端写死了http://localhost:8080配了代理也白搭。后端全局异常处理是我在这个阶段补上的。联调时接口报错如果后端返回的是默认的Whitelabel错误页前端拿到的不是JSON响应的data里全是HTML根本无法解析。我加了RestControllerAdvice全局异常处理器把所有异常统一转换为Result格式前端才能拿到统一的错误信息。5.2 驼峰与下划线MyBatis字段映射的经典错位这个坑属于不报错但数据不对的类型排查起来比报错更折磨。场景是查询球员列表时realName字段一直返回null但数据库里明明有值。原因就是Java实体类用驼峰命名realName数据库字段是real_nameMyBatis默认不会自动映射这两个名字。解决的配置在application.yml里一行搞定mybatis: configuration: map-underscore-to-camel-case: true这个配置开启之后MyBatis会自动把数据库的下划线字段映射为Java实体的驼峰属性。但是有个前提实体类字段必须严格遵循驼峰命名规范如果数据库字段是real_name实体类写的realname配置开了也映射不上。另外如果用了Results注解自定义映射显式指定的优先级高于全局配置别被两者的叠加效果绕晕。5.3 日期时间格式JSON序列化前后不一致体能评估表里有个assess_date字段前端传入的是2024-06-15字符串后端接收后存进数据库正常。但查询时返回给前端的却变成了2024-06-15T00:00:00.00008:00这种带时区的格式前端表格里显示一长串极其难看。这个问题的根源是Jackson默认的日期序列化格式不是我们习惯的yyyy-MM-dd。解决方式是在application.yml里统一配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8配置完之后LocalDateTime类型字段返回给前端就是2024-06-15 14:30:00这种格式。但要注意如果实体类用了JsonFormat注解个别覆盖注解的优先级高于全局配置。前端的dayjs格式化也别忘了统一日期显示这块前后端各管一半很容易出现两端格式不一致的观感问题。6. 部署与运行环境让系统真正跑起来的最后一步开发完成后部署上线又是一轮新的挑战。尤其是MySQL安装配置、Java环境变量、SpringBoot打包这几个点每个环节都有大量新手踩坑记录。我把完整的部署链路走一遍把关键步骤和注意事项写清楚。6.1 MySQL 8.0安装与账户配置MySQL 8.0和5.7的安装配置差异不小。8.0默认字符集是utf8mb4对中文支持更好但初次安装后有两个地方必须处理。一是root账户默认的认证插件是caching_sha2_password一些旧版本的数据库连接工具和驱动不支持容易报连接错误。虽然新版本的MySQL Connector/J 8.0驱动已经兼容但如果Java项目用的还是5.x依赖连接数据库时就会报错。所以项目里我用的依赖版本是mysql-connector-java 8.0.x对应Spring Boot 2.7.x的版本兼容性没问题。二是创建一个专用的业务账户而不是所有服务都用root连接。我习惯建一个train_user账户只授权train_db数据库的所有权限最小权限原则能避免很多安全问题。账户创建和数据库初始化脚本我在部署文档里写清楚服务器上执行顺序都定好了。CREATE DATABASE IF NOT EXISTS train_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; CREATE USER train_user% IDENTIFIED BY your_password; GRANT ALL PRIVILEGES ON train_db.* TO train_user%; FLUSH PRIVILEGES;6.2 JDK安装、环境变量与SpringBoot打包服务器上Java环境配置是另一个高频问题点。JDK下载后解压到指定目录然后配置JAVA_HOME和PATH环境变量。这里最容易犯的错误是只改了当前会话的环境变量ssh断开重连后又失效。正确做法是写到/etc/profile文件里用source /etc/profile刷新然后用java -version验证。另外8和17的JDK建议明确分开目录装因为不同SpringBoot版本对JDK版本要求不同混在一起容易踩版本坑。SpringBoot项目打包用mvn clean package即可最终生成一个可执行的jar包。但是有一个关键点是Spring Boot 2.7.x的maven打包插件必须要配置好mainClass否则生成的是普通jar包而不是可执行jar包启动时报no main manifest attribute错误。我在pom.xml里显式指定了插件配置。启动方式我用的是nohup加重定向日志nohup java -jar train-system-0.0.1-SNAPSHOT.jar --spring.profiles.activeprod train.log 21 生产环境配置文件application-prod.yml里单独配置数据库连接地址、密码等敏感信息和开发环境隔离。有人喜欢把生产配置写在启动命令的--参数里我不推荐参数一多就容易出错还是配置文件更清晰。6.3 Nginx部署前端与反向代理前端构建之前要改两个地方一是请求的baseURL配置为/api二是路由的history模式需要服务端配合。执行npm run build后dist目录就是待部署的静态文件。Nginx配置的核心是两部分静态文件服务和API反向代理。这个配置解决了两个问题前端通过80端口直接访问不需要再带端口号/api开头的请求反向代理到后端的8080端口同时解决了跨域问题。server { listen 80; server_name your_domain_or_ip; # 前端静态文件 root /opt/train-system/dist; index index.html; # 前端路由history模式回退 location / { try_files $uri $uri/ /index.html; } # 后端API反向代理 location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }最后说一下日志和备份。后端日志我分了info和error两个文件方便排查问题。MySQL数据库我写了个简单的定时任务脚本每天凌晨用mysqldump备份一次保留最近七天的备份文件。球队训练数据虽然量不大但考勤记录和体能数据是长期积累的资产丢一次数据就够让人崩溃的。这套系统从设计到上线最大的体会是信息管理系统的复杂度不在于技术栈多先进而在于是否把业务场景的每个细节理清楚。SpringBoot负责接口稳定输出MyBatis让SQL完全可控Vue3让交互层响应迅速MySQL稳扎稳打存好每一份数据前后端分离让团队协作更顺畅。如果后续有精力我打算在现有基础上增加训练数据的Excel导出、球员历史考勤趋势图以及训练效果的月度对比报表——这些功能的数据基础在这套表结构里都已经预留好了。

相关新闻