ARTICLE DETAIL

资讯详情

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

基于SpringBoot+Vue3的健美操评分系统设计与实战

基于SpringBoot+Vue3的健美操评分系统设计与实战 做毕设的时候很多人第一反应就是图书管理系统学生信息管理系统一搜一大把答辩撞车率极高。健美操评分系统这个名字听起来小众但恰恰是这种自带明确业务规则的项目反而更容易在答辩时讲出亮点。这套基于 SpringBoot Vue 3 MySQL 的健美操评分系统管理平台覆盖了赛事管理、评委打分、实时成绩展示、排名统计这些核心场景技术栈主流、前后端分离、业务逻辑清晰非常适合拿来做毕业设计、课程设计或者作为 Java 和前端入门学习的完整参考项目。这篇文章我会把整个项目从需求拆解、数据库设计、后端接口实现、前端页面开发到联调部署讲一遍重点是把我实际开发中踩过的坑和为什么这样做的逻辑讲透。不管你是准备照着做一遍还是想从里面抽几个模块学习都应该能有所收获。1. 项目概述与核心业务梳理1.1 健美操评分场景到底在解决什么问题健美操比赛和普通的考试评分完全是两回事。考试是学生交卷老师改分一个人改一份卷子就完事健美操比赛是多个评委同时给一支队伍打分而且分数维度不止一个。以常见的竞技健美操评分规则为例评委需要分别给出艺术分、完成分、难度分最后还要通过一套规则去掉极端值、计算最终得分。这里面有几件麻烦事多评委并行打分一场比赛少则五六个评委多则十几个手工收集评分表效率极低。多个评分维度每个维度的权重、计算方式不一样纯手工算容易出错。计分规则繁琐需要去掉最高分和最低分再取平均还要处理同分排名等边界情况。成绩实时性要求高比赛现场通常有大屏实时显示各队得分纸质流程基本做不到。数据追溯困难比赛结束后如果有队伍对成绩提出异议得能查到是哪些评委、哪些维度出了问题。以前很多比赛是用 Excel 收集评分表再人工算分我见过某个基层赛事因为算分出错排名公布半小时后又改回来的尴尬场面。这套系统的核心价值就是把评分-计算-排名-公示全流程线上化每一笔评分都有记录每一项成绩都能追溯来源。对毕设而言这种有真实场景、有明确痛点的项目天然就比千篇一律的管理系统更适合展开讲。1.2 为什么是 SpringBoot Vue 这个组合老实说做这类项目你可以有不止一种选择但我最终选了 SpringBoot Vue MySQL理由是实际考量过的SpringBoot 生态成熟、资料量巨大遇到任何报错基本都能搜到解决方案。它的自动配置让项目起步极快不用像 SSM 那样写一大堆 XML 配置。这对课设和毕设的时间安排非常重要。Vue 响应式和组件化评委打分是高频率交互场景Vue 的双向绑定让分数录入、即时校验写起来非常顺手。组件化开发也能把队列表格、打分表单、排名面板拆开维护。MySQL 免费且通用学校机房、个人电脑都能装网上教程一抓一把部署成本几乎为零。前后端分离是当前主流开发模式用这套组合做完你既练了后端接口设计也练了前端联调答辩时能聊的东西面儿很宽。有人会问为什么不用 JSP Servlet我的看法是JSP 方案技术太旧现在企业里基本见不到了做毕设对就业的参考价值有限。还有人问为什么不用 SpringCloud 微服务这属于典型的过度设计一个评分系统单体架构完全够用微服务的分布式事务、服务注册发现这些问题在答辩时反而容易把自己绕进去。1.3 系统角色与核心业务流程这个系统我设计了三类角色正好对应比赛现场的三类人管理员负责创建赛事、配置组别、录入参赛队伍、创建评委账号、查看所有评分记录和最终排名。评委登录后选择赛事和组别给参赛队伍打分可查看自己已提交的评分。观众/大屏不需要登录通过公开接口查看实时成绩和排名用于现场大屏展示。核心业务流程大概是管理员创建赛事并设置组别 → 录入参赛队伍并分配到对应组别 → 创建评委账号并关联到赛事 → 比赛开始后评委逐队打分 → 系统实时计算成绩并推送大屏 → 比赛结束后台生成最终排名和报表。这套流程从赛前准备到赛中评分再到赛后统计全覆盖每一环都能对应到具体的表结构和接口做需求分析的时候非常好讲。2. 数据库建模与评分规则设计2.1 核心表结构设计表设计是这个系统的灵魂我前后改了三版才定下来。核心表一共五张用户表sys_user字段类型说明idbigint主键usernamevarchar(50)登录账号passwordvarchar(100)BCrypt加密后的密码rolevarchar(20)ADMIN / JUDGE / VIEWERreal_namevarchar(50)真实姓名这里有两个细节值得注意。第一密码必须加密存储我用的是 BCrypt不能明文存库这是很多课设项目答辩时被老师批评的重灾区。第二评委和赛事之间的关系我放在独立的赛事评委关系表里而不是在用户表加一个 competition_id 字段因为同一个评委可能参与多场赛事。赛事表competition字段包括 id、赛事名称、举办地点、开始日期、结束日期、状态0未开始/1进行中/2已结束。状态字段很重要它决定了评委能不能打分、大屏能不能看到成绩前端也能根据状态切换页面展示。组别表category健美操比赛通常是分组进行的比如小学组、初中组、高中组、大学组、社会组不同组别评分规则可能不同。组别表挂在赛事表下面字段有 id、赛事id、组别名称、排序号。为什么要独立建表而不是直接在赛事表里存一个组别名字段因为一个赛事有多个组别如果把组别存到赛事表里改起来会非常痛苦。参赛队伍表team字段包括 id、赛事id、组别id、队伍名称、教练姓名、联系电话、出场顺序。出场顺序单独建一个 sort_order 字段方便比赛前手动调整出场顺序不用改队伍基本信息。评分表score这是整个系统最关键的表字段类型说明idbigint主键competition_idbigint赛事idcategory_idbigint组别idteam_idbigint队伍idjudge_idbigint评委idart_scoredecimal(4,1)艺术分execution_scoredecimal(4,1)完成分difficulty_scoredecimal(4,1)难度分total_scoredecimal(4,1)三个维度总分create_timedatetime提交时间update_timedatetime修改时间这里我特别想强调一个设计思路每个评委的一次打分在 score 表里存一条独立记录而不是只存最终平均分。为什么因为去掉最高分最低分的规则要求系统保存所有评委的原始评分最终成绩需要实时计算。而且赛事结束后如果有队伍对成绩有异议管理员能清楚看到这个队去掉的是哪两个评委的分数。这个追溯能力恰恰是 Excel 手工操作最容易出问题的地方。2.2 计分规则的核心算法健美操评分系统里最核心的算法是去掉最高分和最低分取平均这是国际体操和健美操赛事的通用做法目的是避免个别评委的极端打分影响整体结果。假设一场比赛有 5 个评委每个评委打出一个总分计算最终得分的逻辑是收集所有评委的总分。按从小到大排序。去掉一个最高分、一个最低分。对剩下的分数求平均保留两位小数。对应的 Java 代码如下public BigDecimal calculateFinalScore(ListBigDecimal scores) { if (scores null || scores.size() 3) { throw new IllegalArgumentException(有效成绩不足无法计算最终得分); } ListBigDecimal sorted scores.stream() .sorted() .collect(Collectors.toList()); // 去掉一个最低分和一个最高分 sorted.remove(0); sorted.remove(sorted.size() - 1); // 剩余分数求平均保留两位小数 return sorted.stream() .reduce(BigDecimal.ZERO, BigDecimal::add) .divide(BigDecimal.valueOf(sorted.size()), 2, RoundingMode.HALF_UP); }这个代码里有一个所有初学者都必须记住的坑一定要用 BigDecimal不能用 double 或 float。因为浮点数在计算机里是用二进制表示的0.1 0.2 的结果不是 0.3而是 0.30000000000000004。在比赛成绩这种精确到小数点后两位的评分场景里用 double 做累加和除法最后很可能出现 9.990000000000002 这样的情况。真要在答辩现场被老师指出这个问题那基本就是从优秀滑到合格的关键失误。另外在三个维度分别打分时我采用了各维度独立去掉最高最低再平均最后汇总的方式比先加总分再去掉最高最低更符合实际赛制的评分习惯。具体用哪种规则可以在管理员后台做成可配置项这样也让系统显得更完整。2.3 同分排名怎么处理还有一个容易被忽略的点同分排名。两个队伍最终得分完全相同怎么办我的实现是设计了一个排名字段 rank按以下规则计算先按最终得分降序排列。若最终得分相同按完成分execution_score降序排列。若完成分仍相同按队伍出场顺序升序排列。这个规则在管理后台的赛事配置里可以调整比如改成按队伍编号排序或并列排名。为什么把规则做成可配置因为现实中不同赛事的仲裁规则不一样写死的话遇到特殊情况就得改代码。这个设计细节在答辩时讲到我对不同赛事规则的兼容性做了考虑会非常加分。3. 后端核心模块实现3.1 项目结构与目录规划后端我用的是 Maven 标准结构单模块就够用了。包结构如下src/main/java/com/example/aerobics ├── controller // REST接口层只做参数接收和结果封装 ├── service // 业务逻辑层加分、算分、排名的核心逻辑在这里 ├── mapper // MyBatis-Plus的Mapper接口 ├── entity // 数据库实体类 ├── dto // 前端传来的参数对象 ├── vo // 返回给前端的视图对象 ├── config // 配置类CORS、MyBatis-Plus分页、WebSocket等 ├── interceptor // JWT登录拦截器 ├── exception // 全局异常处理 └── common // 统一返回结果 Result、常量定义等分层的好处不用多说但我建议你在做的时候想一个问题如果让你新增一个导出成绩 PDF的功能你要动哪些地方的代码答案应该是controller 加一个接口方法service 加一个方法前端加一个下载按钮。如果这个改动需要你翻遍所有层才能定位说明分层有问题。统一返回结果我用了一个泛型类ResultT包含 code、message、data 三个字段。所有接口都返回这个结构前端拦截器就能统一处理错误码比如 401 跳登录、500 弹提示框不用每个接口单独处理。3.2 JWT 认证与权限控制用户登录后后端签发一个 JWT前端后续请求都在请求头里带上这个 token后端通过拦截器校验。为什么不用传统的 Session核心原因是前后端分离之后前端和后端可能部署在不同的端口甚至不同的服务器上Session 的跨域和共享问题处理起来很麻烦。JWT 是无状态的后端不用保存登录状态扩展性也好。JWT 的配置我建议这样jwt: secret: your-256-bit-random-secret-key expiration: 7200000 # 2小时单位毫秒密钥长度至少要 256 位而且不能直接写在代码里写死123456应该放到配置文件里正式环境用环境变量注入。过期时间我设置的是 2 小时因为比赛期间评委可能长时间停留在打分页面时间太短会频繁被踢下线但也不能太长否则安全性没有保障。拦截器的实现逻辑public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口和公开查询接口 if (request.getRequestURI().contains(/api/auth/login)) { return true; } String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); } // 校验token解析失败则返回401 try { Claims claims JwtUtil.parseToken(token); request.setAttribute(userId, claims.get(userId)); request.setAttribute(role, claims.get(role)); return true; } catch (Exception e) { response.setStatus(401); return false; } } }这里要注意拦截器要排除登录接口、大屏公开查询接口、以及日常开发要用的接口文档路径。否则你在测试登录接口时会一直被 401 拦截排查半天才发现是拦截器把自己的登录接口也拦了这个坑我踩过。权限控制我用的是最朴素的方式在拦截器里解析出角色接口层通过自定义注解或者手动判断当前用户角色来决定是否放行。管理员接口打一个 RequireAdmin 注解评委接口必须校验当前登录用户确实关联到该赛事否则返回 403。这个业务级权限校验比单纯角色判断更难写但也更能体现思考深度。3.3 打分接口与成绩计算实现打分是整个系统使用频率最高的操作接口设计要简单直接PostMapping(/api/score/submit) public Result? submitScore(RequestBody ScoreSubmitDTO dto) { // dto 包含 competitionId、categoryId、teamId、judgeId、 // artScore、executionScore、difficultyScore、totalScore return scoreService.submitScore(dto); }打分接口的核心逻辑有几步校验当前赛事状态是否为进行中不是则拒绝打分。校验该评委是否被分配到这个赛事。校验分数范围0-10 分合法分数到 0.1 精度。判断该评委是否已给该队伍打过分数若已打过则走更新逻辑。保存或更新评分记录。有一个容易踩坑的细节评委提交后发现打错了怎么办我采用的方案是可修改但有痕迹。评委在比赛结束前可以修改自己的打分score 表里 update_time 会更新同时我在审计日志表里记录修改前后的分数。比赛结束后赛事状态变为已结束打分接口直接拒绝提交。这样既保证了灵活性也为成绩争议提供了追溯依据。3.4 成绩发布与实时推送比赛现场的成绩展示要求实时性。最传统的方案是前端轮询每 3 秒请求一次成绩接口。这个方案实现简单毕设完全够用。但如果你想让系统更有含金量建议引入 WebSocket 或者 SSE后端在成绩变化时主动推送新数据到前端大屏。我当时用的是 SpringBoot 自带的 WebSocket逻辑不复杂评委提交分数后后端计算该队伍的最新有效评分和排名然后通过 WebSocket 推送到 /topic/score 频道大屏页面订阅这个频道收到消息后自动刷新。相比轮询这个方案实时性更高、对服务器压力更小而且在答辩现场演示评委提交后大屏秒级更新的效果非常直观。4. 前端页面开发与交互4.1 前端项目结构与路由设计前端我用的是 Vue 3 Vite Element Plus。如果你用 Vue 2那就配 Element UI两者选一个就行不要混用。项目结构如下src/ ├── api/ // axios请求封装 │ ├── request.js // axios实例拦截器统一处理token和错误 │ ├── auth.js // 登录相关接口 │ └── score.js // 打分、成绩、排名接口 ├── router/ // 路由配置 ├── store/ // Pinia状态管理 ├── views/ │ ├── login/ // 登录页 │ ├── admin/ // 管理员赛事管理、组别管理、队伍管理、评委管理 │ ├── judge/ // 评委赛事选择、打分页 │ ├── scoreboard/ // 大屏实时成绩展示 │ └── report/ // 报表ECharts统计图表 └── components/ // 公共组件赛事选择器、队伍表格、分数输入组件等路由设计的核心是权限。不同角色登录后看到的菜单和页面应该不一样我用的是动态路由方案用户登录后根据角色从后端拉取可访问路由列表通过router.addRoute动态注册。如果没有做动态路由至少也要在路由守卫里做静态的角色判断router.beforeEach((to, from, next) { const token localStorage.getItem(token) const role localStorage.getItem(role) if (to.path /login) { next() } else if (!token) { next(/login) } else if (to.meta.role to.meta.role.indexOf(role) -1) { next(/403) } else { next() } })4.2 核心页面拆解打分页、成绩大屏打分页是这个系统交互要求最高的页面。评委的操作场景是比赛现场很吵、节奏很快评委要快速找到当前出场的队伍快速输入分数并提交。所以打分页的设计有几个原则队伍信息要醒目当前打分队伍的名称、组别、出场顺序要放在页面最显眼的位置。输入框要大分数输入用大号数字输入框支持键盘快速操作。提交防误触提交按钮要做二次确认弹窗防止点错。分数输入组件我用的是 Element Plus 的el-input-number设置:min0 :max10 :step0.1确保评委不会输入超出范围的分数。同时在前端做一次校验三个维度分数都合法才能提交避免无效请求打到后端。成绩大屏页是展示系统逼格的核心页面。我用深色背景、大号字体显示队伍排名和得分顶部轮播显示各组别当前前三名。技术上如果用了 WebSocket大屏就是实时刷新的如果用轮询建议用setInterval每 3 秒拉一次成绩。轮询要注意页面销毁时清除定时器否则切走后定时器还在后台跑白白消耗资源。4.3 Axios 封装与接口联调前端所有请求都走一个 axios 实例统一配置 baseURL 和拦截器。开发环境我用 Vite 的代理解决跨域问题// vite.config.js export default { server: { proxy: { /api: { target: http://localhost:8080, // 后端地址 changeOrigin: true } } } }这个配置的意思是前端请求/api/xxx时Vite 开发服务器会把这个请求转发到http://localhost:8080/api/xxx。这样前端代码里不需要写完整的后端地址也避免了浏览器直接跨域请求后端的问题。请求拦截器统一加 tokenhttp.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config })响应拦截器统一处理业务码和 HTTP 错误码比如 401 清空本地 token 并跳转登录页500 弹出错误提示。这样做的好处是所有页面的错误处理逻辑统一不用在每处调接口的地方重复写 try/catch。5. 环境搭建与部署要点5.1 开发环境准备做这个项目之前先把环境准备好版本问题可以少一半JDKSpringBoot 2.7.x 用 JDK 8 或 JDK 11SpringBoot 3.x 需要 JDK 17。做毕设建议用 SpringBoot 2.7.x JDK 8网上资料最多遇到报错最容易搜到答案。SpringBoot 3.x 把 javax 包名换成了 jakarta很多老代码直接复制过来会报编译错误新手容易被折腾崩溃。Maven3.6 以上即可。MySQL5.7 或 8.0 都可以。8.0 的驱动类名是com.mysql.cj.jdbc.Driver连接 URL 要加时区参数比如jdbc:mysql://localhost:3306/aerobics?serverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingutf8。不加时区参数会报The server time zone value й׼ʱ is unrecognized这类错误看着像乱码实际是时区问题。Node.js16 以上npm 或 pnpm 都行。前端依赖安装慢的话可以配置 npmmirror 镜像源十分钟能省。5.2 项目快速跑起来的完整步骤新建数据库比如aerobics执行项目里的sql/init.sql创建表结构并插入管理员账号和测试数据。打开后端项目修改application.yml里的数据库地址、账号、密码改成自己的。在 IDEA 里运行AerobicsApplication.java的主方法或者在项目根目录执行mvn spring-boot:run看到 Tomcat started 说明后端启动成功默认端口 8080。打开前端项目执行npm install安装依赖然后npm run dev启动开发服务器默认端口 5173Vite。浏览器访问http://localhost:5173用管理员账号登录比如 admin / 123456创建一场赛事、录入队伍再创建评委账号切到评委账号打分大屏页查看实时成绩。整个流程走通后你基本就把这个系统的全貌掌握了。5.3 初始化数据怎么做比较省事每次手动往数据库里插测试数据很烦我建议用 SpringBoot 的CommandLineRunner或者直接在init.sql里写死初始数据。比如给管理员账号、三个测试评委账号、两支测试队伍这样项目启动后就能直接登录体验不用手动敲 SQL。但要注意初始化数据的逻辑要写清楚哪些是演示数据正式部署上线时要删掉或改为不自动插入否则真实赛事的数据和演示数据混在一起后期清理很麻烦。6. 常见问题与排查经验6.1 前后端联调阶段的高频问题我把自己在开发过程中真实遇到过的、以及帮学弟学妹排查过的问题整理成了表格对着找基本能对症下药现象可能原因解决办法前端请求一直 404Vite 代理没配或后端接口路径和前端不一致检查 vite.config.js 的 proxy 和接口路径登录接口 500MySQL 连接失败、驱动类名错误、时区配置缺失检查 application.yml 的 URL、驱动、serverTimezone返回中文乱码数据库连接没加 characterEncodingURL 加useUnicodetruecharacterEncodingutf8请求能通但返回 401token 过期、拦截器没放行登录接口检查 JWT 过期时间和拦截器排除路径前端页面跨域报错没用 Vite proxy直接请求了后端地址用代理或后端配 CorsFilter改了后端代码前端无变化前端代理缓存了旧配置重启 Vite dev server 或强制刷新启动端口被占用8080 被其他进程占用改 server.port或杀掉占用进程npm install 慢到怀疑人生默认源在国外配置 npmmirror 镜像6.2 评委并发打分时最容易忽略的问题比赛场景里多个评委是同时在打分这就带来了并发问题。比如两个评委同时给同一支队伍提交分数如果后端不做控制可能出现数据覆盖。我的方案是submitScore 方法加事务并且同一评委对同一队伍的打分执行 upsert存在即更新不存在即插入。数据库层面给(team_id, judge_id)加了唯一索引从根上避免重复插入。还有一个小坑成绩计算需要读取所有评委的评分如果在一个评委提交分数的同时另一个评委也在提交计算时可能读到不完整的数据。所以最终成绩的计算我放在了查询时实时计算而不是在每次打分后写入一个固定的最终成绩字段。这样虽然多了一点计算开销但避免了并发更新的数据一致性问题。对毕设规模的数据量来说这点性能损耗完全不是问题。6.3 大屏数据刷新的性能问题如果用的是轮询方案要注意一个细节成绩大屏页面可能有多个组件同时拉数据比如排名组件、得分组件、队伍列表组件如果每个组件各自 setInterval 拉接口请求数量会翻倍。我的做法是父组件统一拉取数据通过 props 分发给子组件或者用 Pinia 存一份全局成绩状态这样大屏刷新只需一个定时器。另外轮询间隔不建议小于 2 秒太频繁会给数据库带来不必要的压力。如果用的是 WebSocket 推送则不存在这个问题这是推荐做 WebSocket 的另一个理由。7. 个人实操心得与扩展方向做完这个项目我最大的体会是一个评分系统的难点从不在于登录和增删改查而在于业务规则的严谨性和数据的一致性。去掉最高最低分的算法写起来只有十几行但为了想清楚同分怎么办打错了怎么改并发提交怎么防覆盖这几个问题我翻了不少赛事规则文档也改了好几版设计。对准备拿这个项目做毕设或课设的同学有几点建议数据库表设计一定要花足够时间想清楚。表设计错了后面所有代码都是推翻重来。我第一版把评委和赛事的关系放在用户表里后来发现一个评委可以参加多场比赛只能改表结构连带改了一堆代码。答辩时不要只讲我用了 SpringBoot 和 Vue要讲清楚我的系统解决了什么真实问题、评分规则是怎么设计的、并发和数据一致性是怎么处理的。这些才是体现专业度的地方。项目的扩展方向很多我列几个你可以继续做的点接入 MinIO 做比赛视频上传和回放给争议判罚提供视频证据用 WebSocket 替代轮询做实时推送增加 Excel 导出功能比赛结束一键生成成绩单用 Docker 打包部署到服务器让评委通过浏览器远程打分。如果只是学习用我建议你也不要只照着抄代码而是自己从头把建表 SQL 写一遍把打分接口自己实现一遍遇到不会的再看参考代码。这个项目的技术栈和学习路径都很常规照着敲一遍能巩固 SpringBoot 的接口开发、MyBatis-Plus 的数据库操作、Vue 的组件通信、axios 的请求封装这些核心技能比只看不做要扎实得多。把它彻底吃透后面再接触更复杂的项目很多思路都是通用的。
返回列表