ARTICLE DETAIL

资讯详情

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

SpringBoot+Vue+MyBatisPlus学生成绩管理系统开发实战

SpringBoot+Vue+MyBatisPlus学生成绩管理系统开发实战 简介这份PDF是一套面向Java全栈开发者的前后端分离实战教程出自“虾米大王教你学编程”系列以学生成绩管理系统为完整案例重点讲解Spring Boot、Vue.js与MyBatisPlus三种技术的协同应用。内容从数据库设计入手细致展示了am_user、am_student、am_score三张表的结构用户表存储管理员和教师账号学生表记录学号、姓名、入学日期成绩表保存考试名称与语数英成绩并通过学生外键关联同时每张表都设置了软删除字段后端部分演示了基于Spring Boot的工程搭建、依赖配置以及MyBatisPlus的Mapper接口与实体类操作前端则使用Vue组件化开发结合axios完成登录、学生列表、成绩录入等页面与后端接口的交互。资源包共1个PDF文件大小2.8MB适合正在入门前后端分离架构、希望快速掌握项目整合流程的开发者学习使用。目前已有448人学习通过这份资料可以逐步复现学生成绩管理系统的完整雏形理解从建表、配置到前后端联调的全过程为实际项目开发提供可参考的代码思路与排错要点。1. 学生成绩管理系统这个前后端分离实战项目拿来练手到底值不值「springbootvuemybatisplus前后端分离项目实战学生成绩管理系统」这个标题几乎每年都会出现在课设、毕设和入门资料包里。它之所以能反复被拿出来讲是因为它恰好覆盖了一套实用系统的最小闭环登录鉴权、角色权限、学生与课程维护、成绩录入查询、条件分页列表以及前后端各自独立的开发与部署流程。对第一次接触前后端分离的人来说这套项目的信息量刚刚好——后端不会复杂到劝退前端也够你理解路由和状态管理对需要交付一个可运行演示系统的人来说它又是一个拿得出手的成品方向。这篇笔记就把我按这个技术栈从零搭成绩管理系统的完整路径拆给你看。2. 选型与建模SpringBoot、Vue、MyBatisPlus 三件套为什么是黄金组合学生成绩管理系统的功能边界其实很清晰管理员管理学生和课程教师录入和修改成绩学生按学号查询自己的成绩。这个场景决定了它不太需要复杂的微服务拆分、消息队列或者分布式缓存反而最看重「一个人能在短时间内把前后端都跑通」。SpringBoot 负责把后端接口以极简方式暴露出来Vue 负责把页面和交互组织起来MyBatisPlus 负责把数据访问层从繁琐的 XML 里解放出来。三者叠加正好覆盖了一条请求从前端按钮到数据库记录再返回页面的完整链路。2.1 三个框架的分工边界谁管接口、谁管页面、谁管数据访问SpringBoot 在这一套里的角色是「接口提供方」。学生成绩管理系统里的登录校验、学生增删改查、成绩分页查询本质都是一组 REST 接口SpringBoot 用 Controller 接收请求、用 Service 组织业务逻辑再通过统一的 Result 结构返回 JSON。它对新手友好的地方在于不用自己管理 Tomcat不用手写大量配置类一个SpringBootApplication就能把 Web 容器、依赖注入和自动配置一起带起来。常见做法是直接创建一个 Spring Initializr 工程依赖选 Web、MySQL、Validation 这三个就够了。Vue 的角色是「页面与交互」。成绩管理页面需要表格展示、弹窗编辑、分页跳转、搜索条件联动这些在 Vue 里用数据驱动视图的方式写起来最顺手。你只需要维护一个data()里的查询对象和列表数组页面会自动跟着状态变。相比 jQuery 时代反复操作 DOMVue 把「数据到页面」这件事变成了声明式描述这对成绩列表这种高频交互页面尤其友好。MyBatisPlus 的角色是「数据访问加速器」。它建立在 MyBatis 之上保留了 SQL 的灵活度又帮你把单表 CRUD 和分页这种重复劳动省掉。像学生表、课程表这类简单的增删改查你可以完全不用写 SQL直接继承BaseMapperT就有现成的selectById、insert、delete方法。成绩表需要三表关联查询时再单独写一条注解 SQL 或者 XML 也不迟。三者的边界一句话概括SpringBoot 管「收到请求后干什么」Vue 管「用户看到什么」MyBatisPlus 管「数据怎么存取」。2.2 数据模型学生、课程、成绩三张表怎么设计最省事学生成绩管理系统的核心数据模型就是三张表学生表、课程表、成绩表。学生表记录学号、姓名、性别、班级课程表记录课程名称和学分成绩表则把学生和课程关联起来再带上考试名称和分数。最需要想清楚的是成绩表一次期中考试会有多个学生、多门课程成绩表必须以「学生 课程 考试名称」为唯一维度否则同一学生同一门课考两次时数据就会互相覆盖。CREATE TABLE student ( id BIGINT AUTO_INCREMENT PRIMARY KEY, student_no VARCHAR(20) NOT NULL UNIQUE COMMENT 学号, name VARCHAR(50) NOT NULL COMMENT 姓名, gender TINYINT DEFAULT 1 COMMENT 1男 2女, clazz VARCHAR(100) COMMENT 班级, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); CREATE TABLE course ( id BIGINT AUTO_INCREMENT PRIMARY KEY, course_name VARCHAR(100) NOT NULL COMMENT 课程名称, credit DECIMAL(3,1) DEFAULT 0 COMMENT 学分, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE score ( id BIGINT AUTO_INCREMENT PRIMARY KEY, student_id BIGINT NOT NULL COMMENT 学生ID, course_id BIGINT NOT NULL COMMENT 课程ID, exam_name VARCHAR(50) COMMENT 考试名称如期中/期末, score DECIMAL(5,2) COMMENT 成绩, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_stu_course_exam (student_id, course_id, exam_name), KEY idx_student (student_id), KEY idx_course (course_id) );三张表的主键都用 BIGINT 自增避免用学号这类业务字段当主键。学号加上 UNIQUE 约束保证同一个学生在系统里只有一个档案成绩表加组合唯一键uk_stu_course_exam从数据库层面杜绝重复录分。create_time和update_time用数据库默认值生成Java 实体里只要标记TableField(fill FieldFill.INSERT)或者干脆只读不写就能省掉大量手动 set 时间的代码。我一般还会给score字段用 DECIMAL(5,2)兼容百分制和少数学校采取的 150 分制。2.3 项目结构前后端分离的标准目录照着搭就行前后端分离项目首先要习惯两套工程并存。我一般会在磁盘上建一个student-score-system总目录下面分backend和frontend两个子目录后端是 Maven 工程前端是 Vue 工程。后端目录严格按 SpringBoot 标准结构分层controller、service、mapper、entity、common其中common放统一返回结果、异常处理、JWT 工具和配置类。前端则按首页、成绩管理、学生管理、课程管理划分视图公共部分抽到api和components。student-score-system/ ├── backend/ │ ├── src/main/java/com/example/score/ │ │ ├── controller/ │ │ ├── service/ │ │ ├── mapper/ │ │ ├── entity/ │ │ └── common/ │ └── src/main/resources/ │ ├── application.yml │ └── mapper/ └── frontend/ ├── src/ │ ├── api/ │ ├── router/ │ ├── views/ │ ├── layout/ │ └── utils/ └── package.json这个目录结构的关键在于「分层清晰」四个字。学生成绩管理系统虽然业务不复杂但如果你把 SQL 写在 Controller 里、把请求逻辑全塞进一个巨型组件里后面每加一个功能都要在大文件里翻半天。我见过很多课设项目的翻车现场不是功能做不出来而是代码堆在几个文件里改一个需求要牵动三处最后连原作者都懒得维护。分层不是形式主义它是给三个月后的自己留的后悔药——项目验收前一周你一定会庆幸当时把 Mapper 和 Controller 拆开了。3. 后端落地用 MyBatisPlus 把成绩分页查询和条件统计做出来后端部分我会直接围绕「成绩分页查询」这个核心接口来落地因为它是这个系统里最典型的一个功能需要多表关联、需要条件过滤、需要分页又需要把结果以 VO 形式返回给前端。这也是 MyBatisPlus 最容易翻车的地方——很多人以为继承了BaseMapper就万事大吉结果一写多表关联和分页就懵。按下面这个链路走你至少能避开八成常见的坑。3.1 依赖与配置pom 和 application.yml 里最关键的几项我用 Spring Boot 2.7.x 作为默认起点兼容性最稳妥网上搜资料也不容易踩到 Spring Boot 3 里javax变jakarta的坑。MyBatisPlus 引入mybatis-plus-boot-starter版本用 3.5.x 即可它会自动把 MyBatis 的核心依赖带进来不需要再单独引入 MyBatis。MySQL 驱动在 Boot 2.7 下用mysql-connector-java注意不要和 Boot 3 的mysql-connector-j混淆。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependencyapplication.yml里有三个配置项决定了这个项目能不能顺利跑起来数据源地址、Jackson 时间格式、MyBatisPlus 的 SQL 日志。数据源地址里必须带serverTimezoneAsia/Shanghai否则 MySQL 8 的时区校验会报错Jackson 的时间格式如果不配前端会收到形如2025-01-01T12:00:00的字符串SQL 日志打开后你能在控制台看到 MyBatisPlus 实际拼接的 SQL排查分页问题尤其有用。server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/student_score?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: autoid-type: auto表示主键用数据库自增这样插入学生或课程记录时不用手动 set ID。SQL 日志在开发期开着没问题上线前记得关掉否则控制台会刷屏生产环境也容易把敏感数据打到日志文件里。3.2 分页查询从 Controller 到 Mapper 的完整链路分页查询最容易踩的坑是漏配分页插件。MyBatisPlus 的分页不是框架默认开启的你必须把PaginationInnerInterceptor注册到MybatisPlusInterceptor里否则Page对象虽然能查到结果但 total 永远为 0记录也只有第一页。这个配置类放在common包下整个项目只需要配一次。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); // 单页最大条数默认不限制生产环境可以设置护栏比如 500 // pagination.setMaxLimit(500L); interceptor.addInnerInterceptor(pagination); return interceptor; } }这里多说一句关于「单页 500 条限制」的说法。MyBatisPlus 本身默认不限制单页条数网上流传的 500 条限制通常是有人改了setMaxLimit(500L)或者把前端页码传成了 0。如果你遇到「单页超过 500 条就报错或截断」先检查两处分页插件是否被设置了 maxLimit以及前端有没有把 pageSize 拼错。分页参数在 MyBatisPlus 里叫current和size很多人误以为可以传pageNum和pageSize直接生效其实要自己映射。接下来写成绩分页查询接口。成绩列表需要展示学号、姓名、课程名、考试名称、分数这是一次典型的三表左连接。我习惯在 Mapper 层用一个自定义方法完成关联查询返回一个ScoreVO而不是直接把三个实体都塞给前端。Mapper public interface ScoreMapper extends BaseMapperScore { Select(script SELECT s.id, st.student_no AS studentNo, st.name AS studentName, c.course_name AS courseName, s.exam_name AS examName, s.score FROM score s LEFT JOIN student st ON s.student_id st.id LEFT JOIN course c ON s.course_id c.id where if teststudentName ! null and studentName ! \\ AND st.name LIKE CONCAT(%, #{studentName}, %) /if if testcourseName ! null and courseName ! \\ AND c.course_name LIKE CONCAT(%, #{courseName}, %) /if /where ORDER BY s.create_time DESC /script) IPageScoreVO selectScorePage(PageScoreVO page, Param(studentName) String studentName, Param(courseName) String courseName); }这段注解 SQL 的关键有两个第一PageScoreVO必须放在方法第一个参数位置MyBatisPlus 的分页插件就是靠识别这个参数类型来自动拼接LIMIT的第二动态条件用script和if包裹时字符串里的双引号要用\转义否则启动阶段解析 XML 就会报错。CONCAT(%, #{studentName}, %)是 MyBatis 里做模糊查询的标准写法不要直接写%#{studentName}%那样#{}不会生效查出来永远是空。Controller 层只需要把前端的查询参数接收进来组装Page对象调用selectScorePage再包一层统一返回结构。这里前端传的pageNum和pageSize是常规命名而后端Page构造器的第一个参数是 current第二个才是 size。RestController RequestMapping(/api/score) public class ScoreController { Autowired private ScoreMapper scoreMapper; GetMapping(/page) public ResultIPageScoreVO page(RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize, RequestParam(required false) String studentName, RequestParam(required false) String courseName) { PageScoreVO page new Page(pageNum, pageSize); IPageScoreVO result scoreMapper.selectScorePage(page, studentName, courseName); return Result.ok(result); } }Result.ok是我的统一返回结构内部包含 code、msg、data 三个字段。这样做的好处是前端 axios 拦截器只需要判断一次 code就能处理所有接口的异常而不是每个接口单独写判断逻辑。IPage返回的数据里records是当前页记录total是总条数current是当前页码size是每页条数这四个字段前端都要用到。3.3 多条件拼接用 LambdaQueryWrapper 代替一串 if分页查询写完后你很快会发现一个痛点学生管理、课程管理里的条件查询也长得差不多总不能每写一个查询都复制一段script。MyBatisPlus 的LambdaQueryWrapper就是为这种场景准备的。它用 Lambda 表达式直接引用实体字段编译期就能发现字段名写错的问题不用等到运行时才报错。Service public class ScoreServiceImpl implements ScoreService { Autowired private ScoreMapper scoreMapper; Override public IPageScoreVO pageScore(int pageNum, int pageSize, ScoreQuery query) { PageScore page new Page(pageNum, pageSize); LambdaQueryWrapperScore wrapper Wrappers.lambdaQuery(); wrapper.eq(Objects.nonNull(query.getStudentId()), Score::getStudentId, query.getStudentId()); wrapper.between(Objects.nonNull(query.getMinScore()), Score::getScore, query.getMinScore(), query.getMaxScore()); wrapper.orderByDesc(Score::getCreateTime); IPageScore result scoreMapper.selectPage(page, wrapper); return result.convert(score - { ScoreVO vo new ScoreVO(); vo.setScoreId(score.getId()); vo.setScore(score.getScore()); vo.setExamName(score.getExamName()); return vo; }); } }LambdaQueryWrapper 的核心用法是wrapper.eq(condition, 字段, 值)第一个参数为 true 时才拼接这个条件。Objects.nonNull返回值正好可以作为判断条件这样就把「有没有传参」和「拼不拼 SQL」绑定在一起代码里不会出现一大串 if。between适合处理分数区间比如最低分 60、最高分 100MyBatisPlus 会自动生成score BETWEEN 60 AND 100。convert方法可以把查询结果从实体批量转成 VO省掉手写 for 循环转换的样板代码。这里要留意一个边界LambdaQueryWrapper只适合单表查询。成绩表本身只有 student_id、course_id、score 这些字段你要展示学生姓名、课程名就必须 join 别的表这时候回到 3.2 的Select注解 SQL 更合适。实战里两者经常混用——分页列表用自定义 SQL单表的增删改和简单查询用 Wrapper没有哪个方案是万能的。4. 前端落地Vue 路由跳转、axios 封装与成绩页面的数据流后端接口就绪后前端要解决三件事页面怎么跳转、请求怎么发、表格数据怎么跟分页组件联动。学生成绩管理系统的前端页面不算多登录页、首页、成绩管理页、学生管理页但如果路由和请求封装这层地基不打牢页面一多就会乱。我按「路由 → 请求层 → 页面组件」的顺序带你把这套数据流搭起来。4.1 路由设计登录页、主页与学生成绩页的跳转关系Vue 项目创建后第一步是装路由。如果你用的是 Vue CLI 创建的项目环境配置里最常出问题的就是 Node 版本和依赖源。我一般建议先把 npm 源切到国内镜像再安装依赖否则npm install卡在 node-sass 上大半天是常有的事。装完后在src/router/index.js里配置路由表成绩管理页要作为主页的子路由挂载这样左侧菜单和顶部栏能共用一套布局。const routes [ { path: /login, component: () import(/views/Login.vue), meta: { title: 登录 } }, { path: /, component: () import(/layout/Index.vue), redirect: /score, children: [ { path: score, component: () import(/views/Score.vue), meta: { title: 成绩管理 } }, { path: student, component: () import(/views/Student.vue), meta: { title: 学生管理 } } ] } ]路由配置里最需要注意的是子路由的path不要加斜杠。写path: score它会自动拼接成/score如果写成/score它会被当成绝对路径导致和父级布局脱离。component: () import()这种写法是路由懒加载只有访问到该路由时才加载对应组件的 JS首屏加载速度会明显快于一次性引入所有页面。meta.title用来配合路由守卫设置浏览器标签页标题。路由守卫一般都会用来做登录校验。学生成绩管理系统虽然简单但成绩数据不该未登录就能访问所以在router.beforeEach里检查 localStorage 里有没有 token没有就跳转登录页。这里要注意登录页本身要放行否则会陷入「未登录跳登录页登录页又被守卫拦截」的死循环。4.2 axios 封装把 token 和统一状态码处理收敛到一个文件里请求层是前后端分离项目里最容易偷懒也最容易出事的地方。如果每个页面直接axios.get一旦后端把状态码从 200 改成 201或者 token 过期需要统一跳登录你就得满项目找请求代码。我一般会在src/utils/request.js里创建一个 axios 实例把 baseURL、超时时间、请求拦截器、响应拦截器都收敛到这一个文件。import axios from axios import { Message } from element-ui import router from /router const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) service.interceptors.response.use( response { const res response.data if (res.code ! 200) { Message.error(res.msg || 请求失败) if (res.code 401) { router.push(/login) localStorage.removeItem(token) } return Promise.reject(new Error(res.msg || 请求失败)) } return res }, error { Message.error(error.message || 网络异常) return Promise.reject(error) } ) export default servicebaseURL: /api这个写法特意用了相对路径而不是写死http://localhost:8080。这样前后端联调时用代理转发打包后放进 SpringBoot 静态目录里也完全不用改地址一套代码适配两种部署方式。请求拦截器在每次请求发出前从 localStorage 取 token 放进请求头后端 JWT 过滤器就能通过Authorization头完成身份识别。响应拦截器只认res.code 200后端的业务错误码和 HTTP 错误码在这里被统一翻译成前端提示消息。对于 401 的处理要格外小心不能弹个错误提示就完事必须localStorage.removeItem(token)再跳登录页否则用户点回上一页还是会带着过期 token 继续请求。响应拦截器里return res而不是return res.data也有讲究业务组件里就能统一用res.data.records取数据不会出现有的地方多包一层有的地方少包一层的情况。4.3 成绩列表页表格、分页和搜索条件怎么串起来成绩列表页是整个前端最核心的组件。它要同时处理搜索表单、表格数据、分页组件三块状态的联动。我习惯用一个query对象统一管理页码、每页条数和搜索条件当用户点搜索时从第 1 页重新加载点分页时带着当前搜索条件请求对应页码。这样搜索和分页不会互相覆盖参数。template div classscore-page el-form :inlinetrue el-form-item label学生姓名 el-input v-modelquery.studentName placeholder输入姓名 clearable / /el-form-item el-form-item label课程名称 el-input v-modelquery.courseName placeholder输入课程 clearable / /el-form-item el-form-item el-button typeprimary clickhandleSearch查询/el-button /el-form-item /el-form el-table :datalist v-loadingloading border el-table-column propstudentNo label学号 width120 / el-table-column propstudentName label姓名 width120 / el-table-column propcourseName label课程 / el-table-column propexamName label考试 width100 / el-table-column propscore label成绩 width100 / /el-table el-pagination :current-pagequery.pageNum :page-sizequery.pageSize :totaltotal layouttotal, prev, pager, next current-changehandlePageChange / /div /templateexport default { data() { return { query: { pageNum: 1, pageSize: 10, studentName: , courseName: }, list: [], total: 0, loading: false } }, created() { this.loadData(1) }, methods: { async loadData(pageNum) { this.query.pageNum pageNum this.loading true try { const res await this.$http.get(/score/page, { params: this.query }) this.list res.data.records this.total Number(res.data.total) } finally { this.loading false } }, handleSearch() { this.loadData(1) }, handlePageChange(page) { this.loadData(page) } } }模板里v-model双向绑定查询条件clickhandleSearch触发查询时从第 1 页开始加载这样能避免搜索结果为空时还停留在深页码的尴尬。el-pagination的current-change事件只在页码变化时触发初始渲染不会触发正好避免和created里的首次加载重复请求。total用Number()包一层是因为后端返回的 total 可能是字符串分页组件对数字类型的处理更稳定。loading在finally里复位无论请求成功还是失败都不会让表格卡在加载态。这一段数据流串起来后整个页面就有了一条清晰的链路用户在搜索框输入条件 → 点击查询 →query对象带着条件请求后端 → 后端分页返回records和total→ 表格渲染当前页数据 → 分页组件根据 total 计算总页数。后续要加「按分数区间筛选」或「导出当前页 Excel」只需要在query里增加字段、在表格列上加按钮不需要动数据流的主干。5. 联调避坑分页失效、跨域拦截、路由刷新 404 的排查记录前后端分离项目的开发过程里单独跑后端、单独跑前端都很顺利一联调就四处冒烟。这一章是我做这类系统时真实踩过的五个坑按「现象 → 原因 → 解决」拆开写每一条都花了不止一个下午才定位。它们不是玄学全部是配置或约定层面的问题。5.1 分页 total 一直是 0第一页数据却不全现象接口返回的 records 只有当前页数据但 total 固定为 0前端分页组件显示不出总页数数据量超过 pageSize 时第二页点不动。原因MyBatisPlus 的分页插件没注册。很多人继承了BaseMapper.selectPage后以为能直接分页但 MyBatisPlus 的分页功能依赖PaginationInnerInterceptor没有它时selectPage会退化成普通查询total 自然拿不到。另一类原因是Page对象没放在方法参数第一位插件识别不到分页参数。解决按 3.2 的MybatisPlusConfig配好拦截器PageScoreVO page固定放在 Mapper 方法的第一个参数。如果是自定义 SQL还要检查IPage返回类型和Page泛型一致不一致时 MyBatis 可能无法识别返回结果。排查时优先看控制台 SQL 日志。正常分页查询会打印出LIMIT 0,10和一个SELECT COUNT(*)语句如果没有这两个片段说明插件没生效。这个坑最常见于 Spring Boot 3 MyBatisPlus 新版本组合网上教程互相抄配置类往往被漏在某个没有扫描到的包里。5.2 前端报 CORS error后端却说接口没问题现象前端页面请求http://localhost:8080/api/score/page浏览器控制台报「Access-Control-Allow-Origin」错误接口在 Postman 里却完全正常。原因前后端分离开发时前端跑在 5173 或 8081 端口后端跑在 8080 端口浏览器跨域策略默认拦截了不同端口之间的请求。Postman 没有同源策略限制所以测不出这个问题。这是一个典型的联调期翻车点也是新手最容易被整懵的错误。解决后端起一个配置类放行跨域或者在 SpringBoot 里写一个WebMvcConfigurer。注意allowCredentials(true)时allowedOriginPatterns不能写成*要写具体的前端地址模式否则浏览器会拒绝携带凭证的请求。Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }如果你的后端加了登录拦截器或过滤器还要确保预检请求OPTIONS被放行否则 CORS 配置写对了也会被拦截器挡在门外。另外前端 axios 的baseURL如果写成了绝对地址http://localhost:8080开发期还得在 Vue 的vue.config.js里配 devServer 代理更省事的做法是直接用 4.2 里的相对路径/api搭配代理解决。5.3 时间字段变成一串英文或数组前端表格显示不对现象后端返回的createTime字段在 JSON 里显示成2025-01-01T12:00:00或者一串带时区的英文前端表格直接展示这串字符看起来既不符合国内习惯也不利于阅读。原因Jackson 默认对LocalDateTime的序列化格式依赖 ISO 标准而不是yyyy-MM-dd HH:mm:ss。加上 JVM 默认时区和 MySQL 连接时区不一致同一时刻在两个时区下显示会有偏差。解决在application.yml里同时配置 Jackson 的日期格式和时区这也是 3.1 里那两项配置的用途。如果某个字段有特殊格式要求比如成绩录入时间要精确到秒也可以在实体字段上加JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8)。配置全局格式之后普通字段就不用每个都加了。5.4 路由刷新 404路由跳转却正常现象在成绩管理页按 F5 刷新页面直接白屏或出现 404但从登录页正常点击进入成绩管理页又完全正常。原因前端路由用了 HTML5 的 history 模式URL 里没有#刷新时浏览器会向服务器请求/score这个真实路径。后端没有这个静态资源自然返回 404。hash 模式不会出这个问题因为#后面的内容不会被发往服务器。解决开发期可以用 devServer 的 historyApiFallback 解决生产环境按部署方式处理。如果是独立用 Nginx 部署配置try_files $uri $uri/ /index.html把所有路径请求都引回前端入口如果是打包进 SpringBoot后端加一个转发规则把非/api开头的路径转发到index.html。注意接口路径要避开这个转发规则否则/api/score/page也会被转发到前端页面。5.5 Vue 打包放进 SpringBoot 的两种部署形态现象后端接口全部调通后想让项目变成一个独立的可执行 Jar但前端静态资源不知道怎么合并进来。常见的疑问是前端要不要改接口地址打包后的文件放哪个目录解决先在前端项目根目录执行npm run build产物默认在dist目录。第一种形态是直接把dist里的index.html和静态资源复制到后端src/main/resources/static下重新打包 SpringBoot访问localhost:8080就是前端页面。第二种形态是前端保持独立部署后端只提供接口服务静态资源交给 Nginx。我推荐先做第一种课设毕设演示只需要一个 Jar 包就能到处跑不用在对方机器上再装 Node。两种形态下前端 axios 的baseURL: /api都不用改第一种形态里请求会打到同一个 SpringBoot 服务的/api路径第二种形态里 Nginx 只需要配置一个/api的代理转发到后端端口。唯一的坑是后端如果设置了 context-path前端 baseURL 也要同步改否则请求路径对不上。6. 项目跑通之后坚持「日志先行」联调效率会明显不一样系统联调通过后我建议你养成一个习惯给所有后端接口加上统一的访问日志。很多时候前端说「接口没返回」后端说「我这边没收到请求」最后发现是代理配置问题浪费半小时在来回确认上。而日志先行这个习惯能让你一眼看出请求到底有没有到达后端到了哪一层、参数是什么、耗时多久、在哪一步抛的异常。我用 AOP 切面实现接口日志统一拦截所有 Controller打印方法名、入参、出参和耗时。这个做法比在每个接口里手写log.info省事得多新增接口也不需要额外加日志代码。Aspect Component Slf4j public class ApiLogAspect { Around(within(org.springframework.web.bind.annotation.RestController)) public Object logAround(ProceedingJoinPoint pjp) throws Throwable { long start System.currentTimeMillis(); String method pjp.getSignature().toShortString(); Object result; try { result pjp.proceed(); log.info({} 入参{} 出参{} 耗时{}ms, method, Arrays.toString(pjp.getArgs()), result, System.currentTimeMillis() - start); return result; } catch (Throwable e) { log.error({} 入参{} 异常{}, method, Arrays.toString(pjp.getArgs()), e.getMessage(), e); throw e; } finally { if (System.currentTimeMillis() - start 1000) { log.warn({} 响应超过 1 秒检查是否出现慢 SQL 或循环调用, method); } } } }这段代码的逻辑不复杂Around(within(RestController))表示匹配所有标注了RestController的类请求进来先记录开始时间方法正常返回时打出入参、出参和耗时抛异常时打出完整堆栈如果响应超过 1 秒额外标记一条 warn方便回头定位 SQL 慢查询或者接口里误用 Thread.sleep 的问题。入参直接打pjp.getArgs()是最朴素的做法敏感字段记得自己在 Service 层脱敏或者用LogIgnore注解配合自定义处理。我自己的血泪经验是凡是「跑不通」的问题先去看日志顺序。前端说报错了先看后端有没有收到请求后端日志有入参没出参说明卡在 Service 层入参出参都打出来了前端还报错再怀疑响应格式和跨域。这套排查顺序能帮你砍掉至少一半的无意义联调。日志先行不是给接口增加负担它是在给未来的自己留一条线索。希望帮到你。本文还有配套的精品资源点击获取
返回列表