做课程设计选题目的时候,很多人看到“大学生竞赛管理系统”这类基于Spring Boot + Vue的管理系统项目,第一反应是“又一个CRUD增删改查页面流转”。这话对了一半。竞赛管理系统表面上是典型的后台管理业务,但真把它当普通CRUD来做,答辩时大概率会被问住——竞赛的业务流程、前后端分离下的权限控制、多角色状态流转、还有数据统计报表,这些才是项目真正的分水岭。这篇文章我把整个系统的完整搭建思路、核心模块设计、数据库结构、关键代码实现,以及开发过程中最容易踩的坑,从头到尾捋一遍,源码配套MySQL数据库和设计文档,拿去做课程设计、毕业设计,或者想系统学一遍Spring Boot + Vue全栈开发,都可以直接套用。
1. 竞赛管理系统到底在管什么业务
动手写代码之前,先得把这个系统的业务边界想清楚。很多同学一上来就建表、写接口,做到一半发现角色权限乱成一团,报名流程没闭环,评委打分的数据对不上,最后只能推倒重来。所以先用这一节把竞赛管理的业务模型讲明白,这也是你答辩时展示“需求分析能力”的核心素材。
1.1 五个核心角色和各自的工作台
大学生竞赛管理系统,最典型的业务背景是高校的创新创业学院或教务处,每年要组织几十项学科竞赛,包括“互联网+”大学生创新创业大赛、数学建模、电子设计大赛、各类程序设计竞赛等等。线下组织的方式非常痛苦:报名靠Excel表格回收,作品提交靠U盘拷贝或网盘收集,评委打分靠纸质评分表,最后的获奖统计和证书生成又是新一轮手工活。
这套系统要解决的就是把整个过程搬到线上。围绕这个目标,系统里一共有五类角色,每一类角色的功能权限完全不同:
- 系统管理员:负责系统基础配置,包括用户管理、学院管理、竞赛分类管理、发布竞赛公告、审核整个竞赛流程、最终发布获奖结果。
- 竞赛负责人(也可以叫竞赛管理员):通常由某个竞赛的具体组织老师担任,负责创建竞赛、设定报名时间、指派评委、审核学生报名资格、管理作品收集。
- 学生:浏览竞赛公告,查看自己符合条件参加的竞赛,组队或单人报名,提交作品材料,查看报名审核状态和最终获奖结果。
- 指导教师:确认自己指导的学生队伍,审核学生提交的参赛作品,给出指导建议。
- 评审专家(评委):在评审周期内查看分配给自己评审的作品,按照评分维度打分,提交评审意见。
这五个角色的工作流串起来,就是一条完整的竞赛业务链路:发布竞赛 → 学生报名 → 指导老师确认 → 竞赛负责人审核 → 作品提交 → 评委打分 → 成绩汇总 → 管理员发布结果。这也是系统设计时的“主线流程图”,所有数据库表和接口设计都是围绕这条链路展开的。
1.2 从报名到获奖的完整业务闭环
理清角色之后,再看业务闭环。我画过很多版业务时序,最终沉淀下来的是下面这个逻辑,也直接对应到系统的菜单设计和接口设计上:
第一步,管理员或竞赛负责人在后台创建一场竞赛,配置竞赛名称、分类(比如“学科竞赛类”“创新创业类”)、报名开始与截止时间、作品提交截止时间、评审开始时间、竞赛级别(校级/省级/国家级)、参赛形式(个人/团队)、报名人数上限等参数。竞赛发布后,前台首页和竞赛列表页同步显示,学生端可以看到“进行中”“未开始”“已结束”三种状态的竞赛。
第二步,学生在竞赛列表中选择一场开放报名的竞赛,点击报名。如果是团队赛,需要创建团队并邀请成员,或者由队长统一填报成员信息;如果是个人赛,直接提交报名申请。报名时需要填写的基本信息包括学号、姓名、学院、专业、联系方式,以及选填的指导教师。
第三步,报名提交后进入审核环节。这里的权限设计上有一个容易忽略的点:审核分两级。第一级是指导教师确认(可选环节,适用于有指导教师的竞赛),第二级是竞赛负责人审核。两级都通过后,报名状态变为“已通过”,学生端出现“提交作品”入口。
第四步,学生在作品提交截止日期前上传作品材料。系统需要支持多文件上传,建议的作品材料类型包括项目计划书、演示视频、源代码压缩包、图片附件等。这里对后端的要求是文件存储方案,开发阶段我建议用本地磁盘存储,文件路径存入数据库,生产环境再替换为云存储和CDN。
第五步,竞赛负责人为通过报名的作品分配评委,评审专家登录后只能看到分配给自己的作品列表,按维度逐项打分并填写评审意见。作品最终得分由所有评委打分按规定权重汇总得出。
第六步,管理员根据系统汇总的成绩排名,在后台确认获奖名单,设置一等奖、二等奖、三等奖和优秀奖,一键发布。前端“获奖公示”模块展示最终结果,学生可以查看自己的获奖状态和证书编号。
这个闭环设计意味着表单里没有一蹴而就的大表单,而是通过状态机驱动逐步推进。每个阶段的数据边界清晰,权限也清晰——学生只能改自己的报名信息和作品,评委只能打分不能看其他评委的分数,管理员全程可见但通常不改业务数据。整个设计逻辑在答辩时就是一条非常漂亮的业务故事线。
1.3 非功能性需求:为什么这个系统不能只做CRUD
如果只是增删改查,Spring Boot + Vue可以很快搭一个demo。但有一个实际问题决定了系统复杂度:并发报名。竞赛报名经常是“开赛即高峰”,比如校级竞赛中午12点开放报名,前十分钟可能涌入上千个请求。如果报名接口不做任何限制,库存式的“参赛名额”瞬间被抢光,而后台又没办法甄别学院限制和外校人员,就会出乱子。
所以系统里必须处理几个非功能性需求:
- 报名幂等性:同一个学生同一场竞赛只能有一次有效的报名记录,后端接口要做重复提交校验,不能只靠前端按钮禁用。
- 文件上传可靠性:作品文件可能达到几百MB(尤其是视频类作品),上传过程中必须考虑前端进度反馈、后端文件大小限制、断点续传不是必须但要有错误重试。
- 权限数据隔离:评审专家能否查看其他评委的评分?竞赛负责人能否越权删除学生作品?这些都要在接口层明确校验,而不能仅靠前端路由隐藏。
- 数据统计可视化:首页的仪表盘要展示竞赛总数、报名总人数、待审核数量、进行中竞赛数量等指标,用图表直观呈现。
这些需求合在一起,决定了技术选型不是随便挑个模板就行的。下面进入技术方案设计。
2. 技术选型和工程结构设计
技术选型这块,标题已经限定了Spring Boot + Vue,这是目前全栈开发最好入手、社区资料最全的组合。但具体到版本和配套组件,初次动手的人很容易在环境上卡住,所以先把选型和环境版本讲清楚。
2.1 后端:Spring Boot + MyBatis-Plus + JWT
后端框架用Spring Boot,这是当前Java后端开发事实上的标准选择。版本上有一个特别注意的地方,Spring Boot 3.x已经发布好几年了,但它要求JDK 17及以上,而很多高校的实验环境和教程还停留在JDK 8,加上答辩演示时的服务器环境未必装的是新版JDK,所以我这个项目用的是Spring Boot 2.7.x + JDK 8的组合,稳定、兼容性好、资料多,网上遇到问题随便一搜都有答案。如果你本机已经装了JDK 17,也可以直接升级到Spring Boot 3.x,但要注意javax命名空间改为jakarta、MyBatis-Plus要用3.5.3+版本适配,这些细节后面避坑章节会专门讲。
ORM框架选择MyBatis-Plus而不是原生MyBatis或Spring Data JPA,理由很实际:MyBatis-Plus提供BaseMapper,单表CRUD不用写XML,分页插件一行配置搞定,代码量至少省三分之一。而复杂的多表关联查询仍然可以手写SQL,保留了灵活性和可优化空间。对做课程设计和毕设来说,这个平衡点非常合适。
认证授权方案用JWT(JSON Web Token)。因为前后端分离架构下,后端接口是无状态的,不能用传统的Session。流程是:用户登录成功后,后端签发一个带用户ID和角色信息的Token返回给前端,前端请求时在Header里携带Authorization: Bearer token,后端通过拦截器解析Token,获取当前用户身份并做权限校验。
安全这块要做两层:第一层是Spring Security或拦截器实现的身份认证,第二层是基于角色和资源URL的接口权限控制。考虑到Spring Security对初学者来说配置门槛偏高,可以退一步只写一个JWT拦截器 + 自定义注解来实现,代码量不大,逻辑也直观,答辩时反而更容易讲清楚。
2.2 前端:Vue 3 + Vite + Element Plus + Pinia
前端框架用Vue 3,构建工具用Vite而不是Webpack,最大的区别在开发体验——Vite基于ES Module,冷启动速度几乎秒开,热更新也是毫秒级,而Webpack在大型项目里的编译等待时间非常折磨人。
UI组件库选择Element Plus,这是Vue 3生态里最成熟的桌面端组件库,表格、表单、弹窗、上传、分页、日期选择器开箱即用,页面做出来整齐划一,非常契合管理后台的视觉预期。
状态管理用Pinia,它是Vuex的官方替代品,API更简洁,去掉了mutations的概念,TypeScript支持也更好。在这个项目里,主要用它存储用户登录信息、Token、菜单权限和全局应用状态。
路由用Vue Router 4,配合动态路由注册实现菜单权限控制——不同角色登录后,前端根据后端返回的权限菜单动态生成路由,这样学生登录后压根不会看到管理后台的菜单项。虽然真正的安全校验在后端,但前端动态路由能极大改善体验,避免跳转后被拦截弹出一堆无权限提示。
HTTP请求库使用Axios,二次封装后统一处理BaseURL、Token注入、响应码拦截、错误提示和文件下载的Blob类型响应。
2.3 前后端工程目录怎么组织
项目采用前后端分离的两个独立工程,根目录结构如下:
competition-system/ ├── backend/ # Spring Boot 后端工程 │ ├── src/main/java/com/example/competition/ │ │ ├── common/ # 通用返回结果、异常处理、常量 │ │ ├── config/ # 跨域配置、MyBatis-Plus配置、拦截器注册 │ │ ├── controller/ # 控制器层 │ │ ├── entity/ # 数据库实体类 │ │ ├── mapper/ # MyBatis-Plus Mapper接口 │ │ ├── service/ # 业务逻辑层 │ │ └── utils/ # JWT工具、文件上传工具、日期工具 │ └── src/main/resources/ │ ├── mapper/ # MyBatis XML文件 │ └── application.yml # 项目配置 ├── frontend/ # Vue 3 前端工程 │ ├── src/ │ │ ├── api/ # 接口请求封装 │ │ ├── assets/ # 静态资源 │ │ ├── components/ # 公共组件 │ │ ├── router/ # 路由配置 │ │ ├── stores/ # Pinia状态管理 │ │ ├── views/ # 页面视图 │ │ ├── utils/ # 工具函数 │ │ └── App.vue │ └── vite.config.js # Vite配置,含代理 └── sql/ └── competition.sql # 数据库初始化脚本后端严格遵循Controller → Service → Mapper三层架构,Controller只负责参数接收和结果封装,业务逻辑下沉到Service层,数据访问收敛到Mapper层。这样的分层结构带来的直接好处是:每个类的职责单一,答辩时无论被问到哪一层都能快速定位;后续扩展功能时,比如在Service层加缓存、加消息通知,都不需要改Controller和Mapper。
3. 数据库设计:核心表和状态流转
数据库设计是整个系统里信息密度最高的部分,面试官和答辩老师问得最多的也是这里。我见过太多管理系统项目的数据库只有三四张表,角色全堆在user表里用字符串区分,比赛和报名混在一张表里“灵活处理”,这种设计根本经不起追问。下面给出这套系统的完整表设计和设计思路。
3.1 核心数据表一览
一共设计了9张核心表,覆盖业务主线。
| 表名 | 说明 | 关键字段 |
|---|---|---|
| sys_user | 用户表 | id, username, password, real_name, role, college, phone, student_no, avatar, status, create_time |
| competition | 竞赛表 | id, title, category, level, description, start_time, end_time, register_deadline, work_deadline, max_team_members, competition_type, status, organizer, create_by, create_time |
| registration | 报名表 | id, competition_id, student_id, team_name, teacher_id, status, remark, create_time |
| team_member | 团队成员表 | id, registration_id, member_name, student_no, college, is_captain |
| work | 作品表 | id, registration_id, work_name, work_type, description, file_url, submit_time, status |
| review | 评审表 | id, competition_id, work_id, reviewer_id, score, comment, review_time |
| announcement | 公告表 | id, title, content, type, publisher_id, publish_time, is_top |
| college | 学院表 | id, name, code |
| competition_category | 竞赛分类表 | id, name, description |
有几张表的关联关系需要重点说明。报名表registration是业务主线的核心枢纽,它外联竞赛表,内联学生用户,再通过team_member表扩展团队成员,通过work表挂接作品,通过review表关联评委打分。所有的主线业务查询,最后都能收敛到registration表上,这也是为什么它被设计成承载最大数据量的一张表。
3.2 状态字段的数据字典设计
系统里有几类核心状态,我专门设计成数据字典,而不是任由前端传任意字符串。这样数据库层面就能约束数据合法性,前端下拉选项与后端数据字典一致,展示层翻译也更加统一。
报名状态 registration.status 取值如下:
| 状态值 | 含义 | 说明 |
|---|---|---|
| 0 | 待审核 | 学生提交报名,等待指导教师/负责人审核 |
| 1 | 已通过 | 报名审核通过,允许提交作品 |
| 2 | 已驳回 | 报名被驳回,学生可修改后重新提交 |
| 3 | 已取消 | 学生主动取消报名 |
竞赛状态 competition.status 我建议不手工维护,而是用时间自动判定。原因很简单:手工改状态很容易忘记,比如比赛都截止了后台还显示“报名中”。用一个状态枚举接口,根据当前时间和报名截止时间动态返回“未开始/报名中/评审中/已结束”,前端轮询即可。这在答辩时是个加分项,说明你考虑了系统的实时性和自动化程度。
作品状态 work.status 同样用数据字典管理:0草稿、1已提交、2已退回、3已通过、4已评阅。作品一旦进入已评阅状态,学生不能再修改,保证评审数据的一致性。
3.3 初始化数据和演示账号
数据库脚本里需要预置一批基础数据,方便项目拉下来直接运行演示。基础数据分三类:
第一类是学院数据,把常见的大学学院预置进去:计算机学院、机械工程学院、电气与电子工程学院、经济管理学院、外国语学院、艺术设计学院、理学院、马克思主义学院。
第二类是管理员和演示账号。管理员默认账号admin、密码admin123,角色为管理员。再预置几个测试学生账号(student1/123456)、评委账号(judge1/123456)、指导教师账号(teacher1/123456)。注意密码字段存放的不是明文,而是BCrypt加密后的哈希值。这个细节在答辩时也值得拿出来讲——说明你有基本的安全意识,不是把密码裸奔存在数据库里。
第三类是预置的一场演示竞赛和几条报名数据。比如“2025年全国大学生数学建模竞赛校内选拔赛”,状态为报名中,允许3人组队,已有一条通过审核的报名记录和一条待审核记录,这样前端页面打开就能看到数据效果,不需要从头走一遍完整流程。
4. 后端核心功能实现
数据库设计好之后,后端开发就是围绕着业务链路逐个实现接口。这一节挑几个最有代表性、面试和答辩提问概率最高的点来展开:登录认证、报名接口、文件上传和评分汇总。
4.1 JWT登录认证的实现思路
登录接口的代码逻辑不复杂,但涉及安全相关的设计点值得认真对待。
@Service public class UserServiceImpl implements UserService { @Autowired private SysUserMapper userMapper; @Override public LoginResult login(String username, String password) { // 1. 校验用户名是否存在 SysUser user = userMapper.selectOne( new LambdaQueryWrapper<SysUser>() .eq(SysUser::getUsername, username)); if (user == null) { throw new BusinessException("用户名或密码错误"); } // 2. 校验密码是否匹配(BCrypt加密比对) if (!BCrypt.checkpw(password, user.getPassword())) { throw new BusinessException("用户名或密码错误"); } // 3. 校验账号状态 if (user.getStatus() != 1) { throw new BusinessException("账号已被禁用,请联系管理员"); } // 4. 生成JWT Token String token = JwtUtil.generateToken(user.getId(), user.getRole()); // 5. 返回用户信息和Token LoginResult result = new LoginResult(); result.setToken(token); result.setUserInfo(user); return result; } }JWT生成的细节在JwtUtil里。我用jjwt库,密钥配置在application.yml中,Token里封装用户ID和角色,有效期设置为24小时。注意一个细节:不要把密码等敏感信息放进Token,因为JWT的载荷部分只是Base64编码,任何人拿到Token都能解码看到内容,并不加密。
JWT拦截器的实现关键在于:拦截所有/api/**请求,白名单放行登录接口,其余请求解析Token,验证通过后把用户ID和角色放入ThreadLocal,供后续业务代码直接获取当前登录用户。
public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if ("OPTIONS".equals(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); try { Claims claims = JwtUtil.parseToken(token); // 将当前用户ID和角色存入ThreadLocal UserContext.set(claims); return true; } catch (Exception e) { // Token无效或过期 } } response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录或登录已过期\"}"); return false; } }权限校验我采取的是代码注解方式。在需要特定角色才能访问的Controller方法上标注@RequireRole("admin")注解,然后在拦截器中检测到注解后比对当前用户角色。这种方式比配置Spring Security的antMatchers更直观,也更容易在答辩时解释清楚。
4.2 报名接口:幂等校验和状态机约束
报名是整个系统中并发压力相对集中的接口,代码实现要考虑三个层面的校验:参数合法性、重复报名校验、竞赛当前状态校验。
@PostMapping("/register") public Result<?> register(@RequestBody RegisterRequest request) { // 1. 参数校验 if (request.getCompetitionId() == null) { return Result.error("竞赛ID不能为空"); } // 2. 获取当前登录学生 Long studentId = UserContext.getUserId(); // 3. 查询竞赛并校验当前状态 Competition competition = competitionMapper.selectById(request.getCompetitionId()); if (competition == null) { return Result.error("竞赛不存在"); } // 4. 校验报名时间是否在有效期内 LocalDateTime now = LocalDateTime.now(); if (now.isBefore(competition.getRegisterStartTime()) || now.isAfter(competition.getRegisterDeadline())) { return Result.error("当前不在报名时间范围内"); } // 5. 校验是否重复报名 Long count = registrationMapper.selectCount( new LambdaQueryWrapper<Registration>() .eq(Registration::getCompetitionId, request.getCompetitionId()) .eq(Registration::getStudentId, studentId) .in(Registration::getStatus, 0, 1)); // 待审核和已通过都算有效报名 if (count > 0) { return Result.error("您已报名该竞赛,请勿重复提交"); } // 6. 校验团队人数是否超过上限 if (request.getMembers() != null && request.getMembers().size() > competition.getMaxTeamMembers()) { return Result.error("团队人数超过限制"); } // 7. 保存报名信息 // 注意:这里要加 @Transactional,保证报名主记录和团队成员记录同时成功或失败 registrationService.saveRegistration(request, studentId); return Result.success("报名成功"); }这个接口的设计有两个关键点。第一,幂等校验用的in(status, 0, 1)意思是只要存在一条“待审核”或“已通过”的报名记录就拦截,可以防止学生重复提交、重复取消再提交导致的脏数据。第二,整个保存过程要加事务,因为报名主表、团队成员表、可能还要生成一条报名的状态变更日志,三个写操作必须原子完成,否则会出现团队信息缺失但报名成功的半截数据。
4.3 文件上传:本地存储方案和大小限制
作品文件上传是竞赛管理系统的刚需功能。开发阶段采用本地磁盘存储,实现简单,可演示性强。核心逻辑是:前端通过Element Plus的Upload组件选择文件,Axios以multipart/form-data格式POST到后端,后端接收MultipartFile,生成UUID文件名,按日期分目录存储,最后把可访问的URL存入数据库。
@PostMapping("/upload") public Result<String> upload(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { return Result.error("上传文件不能为空"); } // 1. 校验文件大小,默认限制100MB long maxSize = 100 * 1024 * 1024; if (file.getSize() > maxSize) { return Result.error("文件大小不能超过100MB"); } // 2. 生成存储目录:/upload/2025/05/ String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM")); String uploadPath = fileConfig.getUploadPath() + "/" + datePath; File dir = new File(uploadPath); if (!dir.exists()) { dir.mkdirs(); } // 3. 生成不重复的文件名 String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); String newFilename = UUID.randomUUID().toString().replace("-", "") + ext; // 4. 保存文件 file.transferTo(new File(dir, newFilename)); // 5. 返回可访问的文件URL String fileUrl = "/files/" + datePath + "/" + newFilename; return Result.success(fileUrl); }特别注意application.yml中的资源映射配置,否则前端访问/files/**会404:
spring: servlet: multipart: max-file-size: 100MB max-request-size: 200MB web: resources: static-locations: classpath:/static/,file:${file.upload-path}这里有个坑:file:前缀后面的${file.upload-path}要以/结尾,而且本地路径写法在Windows和Linux下还不一样。Windows下配置D:/competition/upload/,Linux下配置/root/competition/upload/。所以我把上传路径的设计统一要求为以正斜杠结尾,跨平台兼容。
4.4 评分汇总:多评委打分如何取权重
评审模块的最终输出是一份汇总成绩。在很多竞赛场景下,多个评委的打分权重可能不同——比如组委会指定某个专家是组长,他的打分权重系数是1.2,其他评委是1.0。所以我的评审表review里设计了一个weight字段,表示该评委对这场比赛的权重。
汇总得分的算法逻辑如下:
public BigDecimal calculateFinalScore(Long competitionId, Long workId) { // 查询该作品的所有评分记录 List<Review> reviews = reviewMapper.selectList( new LambdaQueryWrapper<Review>() .eq(Review::getCompetitionId, competitionId) .eq(Review::getWorkId, workId)); if (CollectionUtils.isEmpty(reviews)) { return null; } // 加权平均:sum(score * weight) / sum(weight) BigDecimal totalWeightedScore = BigDecimal.ZERO; BigDecimal totalWeight = BigDecimal.ZERO; for (Review review : reviews) { BigDecimal weight = review.getWeight() == null ? BigDecimal.ONE : review.getWeight(); totalWeightedScore = totalWeightedScore.add( review.getScore().multiply(weight)); totalWeight = totalWeight.add(weight); } return totalWeightedScore.divide(totalWeight, 2, RoundingMode.HALF_UP); }这里有个业务细节需要前端配合:评委提交评分后,只能看到自己的打分,看不到其他人的分数,更不能看到汇总后的排名。汇总结果只对管理员和竞赛负责人开放。这个数据权限边界需要在查询接口上做严格区分——评委查询自己评分的接口和负责人查询汇总得分的接口,是两个独立的接口,不能只靠前端隐藏按钮。
5. 前端Vue实现和关键交互
前端部分,核心页面包括登录页、系统布局框架、竞赛管理页、报名流程页、评审打分页、数据仪表盘。从代码量上看,前端的工作量可能比后端还大。这一节挑几个关键实现来讲。
5.1 Axios封装和请求拦截
前端所有接口请求统一走封装好的request模块,核心逻辑是请求拦截器注入Token,响应拦截器统一处理错误码和401未登录跳转。
// src/utils/request.js import axios from 'axios' import { ElMessage } from 'element-plus' import router from '../router' import { useUserStore } from '../stores/user' const request = axios.create({ baseURL: '/api', // 开发环境通过Vite代理转发到后端 timeout: 15000 }) // 请求拦截器:注入Token request.interceptors.request.use(config => { const userStore = useUserStore() if (userStore.token) { config.headers.Authorization = `Bearer ${userStore.token}` } return config }) // 响应拦截器:统一处理业务错误码 request.interceptors.response.use( response => { const res = response.data // 后端统一返回格式:{ code: 200, message: 'success', data: ... } if (res.code === 200) { return res.data } if (res.code === 401) { ElMessage.error('登录已过期,请重新登录') const userStore = useUserStore() userStore.logout() router.push('/login') return Promise.reject(new Error(res.message)) } ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) }, error => { ElMessage.error(error.message || '网络异常,请稍后重试') return Promise.reject(error) } ) export default request开发环境下前端访问/api怎么和后端接口对上?通过Vite的代理配置解决:
// vite.config.js export default defineConfig({ server: { host: '0.0.0.0', port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, // 可选:路径重写,如果后端接口没有 /api 前缀 // rewrite: path => path.replace(/^\/api/, '') } } } })这个代理配置带来的便利是前端代码里不用写完整的后端地址,统一用相对路径/api开头,后端换端口、换服务器都不影响前端代码。生产部署时再用Nginx把同一域名的/api反向代理到后端服务,前后端同源,彻底避开跨域问题。
5.2 路由守卫和动态菜单
路由守卫实现“未登录不允许进入系统”,这是每个后台必备的基础能力。
// src/router/index.js router.beforeEach((to, from, next) => { const userStore = useUserStore() // 白名单:登录页和404页不需要登录 const whiteList = ['/login', '/404'] if (userStore.token) { // 已登录,访问登录页则跳转到首页 if (to.path === '/login') { next({ path: '/' }) } else { next() } } else { if (whiteList.includes(to.path)) { next() } else { // 未登录,跳转到登录页并携带目标路由 next(`/login?redirect=${to.fullPath}`) } } })动态菜单的实现在这个项目里比较实用。后端在登录成功后返回当前用户的角色和权限菜单树,前端根据菜单树动态添加路由。具体做法是:路由表分为“基础路由”(登录页、首页、个人中心)和“动态路由”(所有业务页面),动态路由在用户登录后根据权限字段filter出来,用router.addRoute()动态注册后跳转。这样做的好处是学生登录后浏览器地址栏直接输入/admin/users,路由根本不存在,直接掉进404,菜单和路由的双重防线让权限体验更完整。
5.3 评委打分页的交互设计
评审打分页面是最能体现交互细节的功能页。页面的诉求是:评委在一个页面里高效完成对多份作品的评分,而不是每评一份作品都要来回跳转。
我实现为一个左右布局:左侧是作品列表(显示作品名称、编号、提交时间、是否已评分),点击切换右侧的作品详情和评分表单。右侧上方展示作品的基本信息和一个可预览的附件列表,下方是评分维度表。评分维度不是写死的,而是由竞赛负责人配置,比如“创新性(30分)”“完整性(30分)”“实用性(20分)”“现场表现(20分)”,评委在每个维度后面填分数,下面自动计算总分,最后填一个综合评审意见,点击提交。
这个页面的核心是已评价作品不能重复打分。每次切换作品时都要从后端拉取该作品的“我是否已评分”状态,已评分的作品表单置灰,只可查看不可修改。如果需要修改,要单独走“修改评分”的接口,重新提交后覆盖旧纪录,并且保留修改日志。这个设计在答辩中也是一个值得讲的点,说明你考虑到了评审过程的公平性和可追溯性。
5.4 首页数据仪表盘
仪表盘页面使用ECharts做数据可视化。需要展示的指标包括:
- 顶部统计卡片:竞赛总数、报名总人次、进行中竞赛、待审核报名。
- 竞赛分类分布:饼图,展示学科类、创新创业类、文体类等各分类的竞赛占比。
- 近半年竞赛趋势:折线图,展示每个月新增竞赛数量和报名人次。
- 学院参赛排行:横向柱状图,展示各学院参赛人数排名,给管理员做参赛动员的参考。
ECharts在Vue里的用法,我推荐用vue-echarts或者直接在页面里import * as echarts from 'echarts',初始化实例后在onMounted里请求接口数据,再setOption渲染图表。注意一个细节:图表容器在Tab切换时可能因为宽度计算为0而渲染异常,需要在切换后调用chart.resize()重新适配尺寸。这个坑我踩过,后来固定写法是容器高度写死,宽度用百分比,切换时强制nextTick后再初始化。
6. 从拉取代码到跑通的完整流程
很多同学拿到源码之后第一步就卡住了,不是代码有问题,而是环境或操作顺序不对。这一节按照我实际跑通的流程一步一步来,照着做基本不会出大问题。
6.1 初始化数据库
- 准备MySQL 5.7或8.0,创建数据库
competition_db,字符集选utf8mb4。在MySQL命令行或Navicat里执行:CREATE DATABASE competition_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; - 找到源码目录下
sql/competition.sql,导入到刚才创建的数据库。使用命令行导入:mysql -u root -p competition_db < competition.sql - 导入成功后检查表数量。正常情况下应该有9张业务表,另外MyBatis-Plus不需要额外的表。如果表数量不对,先检查是不是导入时选了其他数据库。
导入成功后,建议先跑一条验证SQL确认数据完整:
SELECT c.title, COUNT(r.id) AS registered_count FROM competition c LEFT JOIN registration r ON c.id = r.competition_id GROUP BY c.id;能看到每场竞赛的报名人数就说明关联关系正确。
6.2 启动后端服务
- 用IDEA打开
backend目录,等待Maven依赖下载完成。这一步耗时较长,建议配置阿里云Maven镜像加速。 - 修改
application.yml里的数据库连接配置,把用户名、密码改成你本机的MySQL账号。 - 再检查一下文件上传路径
file.upload-path,Windows下改成D:/competition/upload/,Linux下改成绝对路径。 - 运行启动类
CompetitionApplication,看到Started CompetitionApplication in XX seconds就说明启动成功。控制台会输出Tomcat启动端口号,默认8080。
如果启动失败,百分之八十是数据库连接问题,报错信息里会包含Access denied for user或Communications link failure,前者是账号密码错误,后者是数据库没起来或连接地址不对。
6.3 启动前端工程
- 确保本机安装Node.js 16.20+或18.x版本,不要用太新的Node 21+,部分依赖可能不兼容。
- 在终端进入
frontend目录,执行npm install安装依赖。国内网络环境下如果安装速度极慢或卡住,可以配置可用的registry源再重试。 - 安装完成后执行
npm run dev启动开发服务器,默认地址是http://localhost:5173。 - 浏览器访问前端地址,使用管理员账号admin/admin123登录。如果能看到首页仪表盘和管理菜单,说明前后端联调成功。
联调中常遇到的一个问题是前端登录时报跨域或404。跨域问题检查Vite代理配置是否生效;404检查后端是否正常运行、接口路径是否匹配。排查时打开浏览器开发者工具的Network面板,看请求实际发出了什么地址、返回了什么状态码,基本一两分钟内能定位问题。
7. 开发中高频踩坑点复盘
这些坑我基本都踩过,而且很多是同学们在群里反复问的问题。单独列一节,按影响程度排序。
7.1 Spring Boot版本与JDK版本不匹配
这是环境问题里翻车率最高的一个。很多教程现在推荐Spring Boot 3.x,但Spring Boot 3.0强制要求JDK 17以上。如果你的电脑装的是JDK 8,新建项目时盲目选3.x版本,启动会直接报错:
java.lang.UnsupportedClassVersionError: org/springframework/boot/... has been compiled by a more recent version of the Java Runtime (class file version 61.0)class file version 61.0对应JDK 17,说明当前JDK版本太低。解决方案就是:要么升级JDK到17+,要么把Spring Boot降到2.7.x。作为课程设计和毕业设计,个人建议用2.7.x + JDK 8,兼容性最稳,学校机房环境、答辩机器都能跑。如果你已经装了JDK 17也建议直接用2.7.x,不需要为了版本新而升到3.x,徒增适配成本。
7.2 MyBatis-Plus分页插件不生效
引入MyBatis-Plus后,很多人直接写Page<User> page = new Page<>(1, 10); userMapper.selectPage(page, null),结果返回的total是0,数据也没分页。原因是没有配置分页插件拦截器。需要先往MyBatis-Plus里面注册拦截器:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }不注册分页插件时,MyBatis-Plus不会执行LIMIT语句,查询出的结果自然是全量数据,分页对象拿到的total也不对。这个配置在项目里已经有,但如果你是从零搭建框架,很容易漏掉。
7.3 后端跨域配置和JWT拦截器冲突
前后端分离开发时,默认情况下浏览器会拦截跨域请求。后端需要配置CORS跨域过滤器,但有个容易踩的细节:如果你既配置了CORS,又配置了JWT拦截器,预检请求OPTIONS会在进入控制器前被拦截器拦截,导致浏览器收到401,前端报“CORS error”或“No 'Access-Control-Allow-Origin' header”。
解决办法是在JWT拦截器里明确放行OPTIONS请求:
if ("OPTIONS".equals(request.getMethod())) { return true; }这段代码我在前面拦截器实现里已经写了,但这是无数人踩过坑的地方,值得单独拎出来说明。
7.4 前端npm install卡住或依赖版本冲突
前端依赖安装慢或失败的问题,根源通常是网络环境或源地址问题。配置一个可用的registry源可以显著改善。如果遇到依赖版本冲突,优先检查Node版本是否过新,特别是Element Plus最新版在某些Node版本下的工具有兼容问题。另外一个常见情况是前一次npm install没执行完就中断了,留下一个损坏的node_modules目录。稳妥的清理流程是:
rm -rf node_modules package-lock.json npm cache clean --force npm install7.5 Vue打包部署后刷新页面404
开发环境一切正常,npm run build打包部署到Nginx后,访问首页没问题,但在某个路由下按F5刷新就404。原因很简单:前端路由用的是history模式,Nginx没有配置try_files规则,后端服务器不知道如何响应前端路由的URL。
解决方案是修改Nginx配置文件:
location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; }try_files的作用是:如果请求的文件不存在,就回退到index.html,由前端路由接管并渲染对应页面。这个配置可以说是所有history模式前端项目的标配。如果是hash模式则不需要这个配置,但URL会带一个#号不美观,还是建议用history模式并配好Nginx。
7.6 文件上传成功但前端拿不到预览地址
本地存储方案下,上传接口返回了文件路径/files/2025/05/xxx.jpg,但前端访问这个URL返回404。原因是没有配置静态资源映射。我之前在4.3小节已经给出了配置方法,核心是这两行:
spring: web: resources: static-locations: classpath:/static/,file:${file.upload-path}需要再次提醒file.upload-path必须以斜杠/结尾。如果配置正确但依然404,检查上传目录是否有读写权限,Linux下可以用:
chmod -R 755 /root/competition/upload/8. 部署上线和答辩演示准备
课程设计或毕业设计最终要面对演示和答辩,这个环节的准备程度直接决定成绩上限。技术做得再好,演示时页面打不开、操作卡顿、流程不连贯,都是硬伤。
8.1 Linux服务器部署步骤
如果想把系统部署到服务器上给评委在线演示,推荐用一台2核4G的云服务器,系统选Ubuntu 20.04或CentOS 7+。部署流程归纳为四步:
- 安装基础环境:JDK 8、Nginx、MySQL 8.0。先装MySQL并导入
competition.sql,再装JDK 8,最后装Nginx。 - 后端打包上传:在IDEA中用Maven的package生命周期打包,执行
mvn clean package -DskipTests,生成competition-0.0.1-SNAPSHOT.jar。上传到服务器后,用nohup java -jar competition-0.0.1-SNAPSHOT.jar > app.log 2>&1 &启动。查看日志用tail -f app.log。 - 前端打包上传:在frontend目录执行
npm run build,生成dist目录。把dist目录下的所有文件上传到Nginx的html目录。 - config里改数据库连接配置,然后重启后端服务。
- 配置Nginx反向代理,把
/api请求转发到后端8080端口。别忘了配好7.5小节的try_files。
8.2 答辩演示脚本的建议
答辩演示时,建议按下面这条逻辑顺序展示,边演示边讲,每步都要有清晰的业务意图:
- 用管理员登录,演示首页仪表盘,说明系统整体数据情况。
- 演示竞赛创建流程:填写一场新竞赛的基本信息,发布后到学生端能看到,说明前后端数据联动。
- 切换学生账号,演示报名流程:选择竞赛、填写团队信息、提交报名,再切回管理员账号审核通过。这一步是评委最容易理解业务闭环的环节。
- 切回评委账号,演示评审打分过程,再让管理员查看汇总成绩,说明加权平均算法。
- 最后可以补充演示文件上传、公告发布等细节功能。
演示过程中的几个加分细节:提前准备好演示数据(我建议在数据库中预置3-4场不同状态的竞赛、5-6条报名记录、若干条评分记录),避免现场报名后显示空白;提前测试一遍上传功能,确认存储目录有权限;如果现场网络不好,确保所有页面数据都在本地数据库,不需要外部网络请求。
8.3 文档和源码的整理规范
提交的文档建议包含这几部分:需求分析(角色和用例描述)、数据库设计(ER图和表结构说明)、系统设计(架构图、模块划分、接口设计)、系统实现(关键技术点说明)、测试结果(功能测试用例和结果截图)。
源码里要保持目录整洁,不要在根目录扔一堆乱七八糟的临时文件。代码注释不用太多,但核心业务逻辑(比如报名校验、评分权重计算)旁边一定要写清楚思路。数据库脚本要保证能一键初始化,不要依赖任何手动步骤。
9. 从课程设计到真实项目的进阶方向
如果你做完这个系统还有余力,有几个方向可以让项目简历上的含金量明显提升。
第一个方向是引入Redis缓存。报名高峰期,竞赛列表、公告列表这些热点数据完全可以缓存到Redis里,减少数据库压力。报名接口还可以用Redis实现分布式锁,避免并发超报。这个方向加进去,项目描述里就能写上“Redis缓存”“高并发下的数据一致性”这类关键词。
第二个方向是接入消息通知。当报名审核通过、作品被退回、竞赛即将开始时,用Spring Boot整合邮件或WebSocket推送通知用户。这会让系统显得更“智能”,也扩展了Spring Boot的使用场景。
第三个方向是文件存储的云化改造。把本地存储替换为云存储服务(OSS或COS),通过预签名URL实现文件直传,减轻应用服务器的带宽和磁盘压力。这个改造让自己的项目更接近企业级架构。
第四个方向是对作品查重。如果是论文类竞赛,可以集成一个简单的文本相似度比对算法或第三方查重接口,给竞赛组织者提供作品查重参考。这个功能在创新创业类竞赛中非常实用,也是答辩时容易让人眼前一亮的点。
这些方向并不要求在课程设计阶段全部实现,但如果你想在简历上写得更漂亮,把其中任何一两个落地上线,效果都会比单纯说“做过一个管理系统”好得多。
按我的实际经验体会,这类管理系统在课程设计和毕业设计里之所以常年不缺选题,正因为它处在“懂业务”和“懂技术”的交汇点。你花心思把竞赛的业务流程建模清楚,把各个角色的权限边界和数据关系设计清楚,再把Spring Boot + Vue这套全栈技能完整走一遍,收获的不仅是一个能过答辩的项目,更是一套从0到1独立交付完整系统的能力和底气。需要说明的是,第6节到第9节里的部署流程和进阶方案,是基于我在多个类似项目里的通用实践补充的,并非这套源码本身已有的功能,你可以把它当作扩展阅读来看待。最后再分享一个小建议:拿到源码后不要急着改功能,先完整跑通一遍,再用一个下午把自己当成用户把每个角色都玩一遍,你会发现系统哪里顺手哪里别扭,那些觉得别扭的地方,就是你的第一个优化点,也是答辩时最能展示独立思考的部分。