SpringBoot+Vue学生成绩管理系统实战指南
2026/9/15 12:28:33 网站建设 项目流程

简介:本资源是一套完整的基于SpringBoot与Vue.js的学生成绩管理系统开发实践资料,面向Java Web初学者、全栈入门开发者及高校课程设计学生,解决教育信息化场景下成绩管理功能模块的前后端分离开发与落地问题。压缩包共7个文件,含4份核心设计文档(可行性、需求、概要、详细设计)、1个SQL数据库脚本、1个MP4功能演示视频(涵盖系统运行、数据库导入与关键操作),以及1个源码ZIP包,整体大小567.14MB,结构清晰、开箱即用。已有233人学习下载,资料覆盖从环境搭建、API设计、Vue组件开发到部署测试的全流程,配套视频直观展示运行效果,设计文档体系完整,便于理解系统架构逻辑与工程规范,是掌握SpringBoot后端开发与Vue前端集成的典型教学级项目范例。

1. 为什么一个“学生成绩管理系统”值得用 SpringBoot + Vue 重做一遍?

不是所有毕设项目都经得起生产环境推敲,但学生成绩管理是少有的、能同时覆盖权限控制、数据聚合、多角色协同、实时校验与导出报表等典型业务场景的练手系统。很多高校教务系统仍停留在 JSP+Servlet 时代,界面僵硬、响应迟滞、权限粒度粗、Excel 导入导出易出错——而用 SpringBoot 做后端,Vue 做前端,恰恰能把这些痛点逐个击穿:SpringBoot 的自动装配和 Starter 机制让 REST 接口开发效率提升 3 倍以上;Vue 的响应式 + 组件化让成绩录入页、班级对比图、学生成长曲线等动态视图可复用、可测试、可热更新。它不只是一套毕业设计模板,更是前后端分离架构下,从数据库建模、API 设计、JWT 鉴权、文件上传校验到前端路由守卫、表单联动、ECharts 图表渲染的完整闭环训练场。适合刚完成 Java Web 课程的学生快速上手真实工程流,也适合已有经验的开发者验证自己对 Spring Security 权限模型、Vue Router 元信息守卫、Axios 请求拦截器等关键能力的掌握深度。

2. 后端选型与核心模块实现:用 SpringBoot 搭建高内聚、低耦合的成绩服务

2.1 为什么选 SpringBoot 而非传统 SpringMVC?关键在三个“开箱即用”

传统 SpringMVC 项目需手动配置 DispatcherServlet、ViewResolver、DataSource、TransactionManager 等十余个 Bean,而 SpringBoot 通过spring-boot-starter-webspring-boot-starter-data-jpaspring-boot-starter-security三个 Starter,将这些配置压缩为application.yml中几行声明。例如:

# application.yml spring: datasource: url: jdbc:mysql://localhost:3306/school_db?useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: update show-sql: true properties: hibernate: format_sql: true

提示:ddl-auto: update仅用于开发阶段;上线必须改为validatenone,配合 Flyway 进行版本化数据库迁移,避免误删生产表。

这种约定优于配置的设计,让开发者聚焦在实体建模与业务逻辑上。比如成绩实体Score必须关联学生、课程、教师三张主表,且需支持按学期、班级、课程维度聚合统计——这就决定了 JPA 关系映射不能简单用@ManyToOne堆砌,而要引入中间实体Enrollment(选课记录)来承载成绩值、录入时间、审核状态等业务字段:

@Entity @Table(name = "enrollment") public class Enrollment { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "student_id") private Student student; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "course_id") private Course course; @Column(name = "score_value", nullable = false, columnDefinition = "DECIMAL(5,2)") private BigDecimal scoreValue; @Column(name = "semester_code", length = 10, nullable = false) private String semesterCode; // 如 "2024-1" @Column(name = "status", length = 10, nullable = false, columnDefinition = "ENUM('DRAFT','SUBMITTED','APPROVED','REJECTED')") private String status; // 支持成绩录入、提交、审核、驳回四态流转 }
2.1.1 成绩录入接口的幂等性与并发控制

成绩录入不是简单 INSERT,而是“先查再判再存”的复合操作。若多个教师同时为同一学生同一课程录入成绩,必须防止覆盖。常见做法是加数据库唯一约束(UNIQUE(student_id, course_id, semester_code)),但更健壮的方式是在 Service 层加乐观锁:

@Transactional public void submitScore(Long enrollmentId, BigDecimal score) { Enrollment enrollment = enrollmentRepository.findById(enrollmentId) .orElseThrow(() -> new RuntimeException("选课记录不存在")); // 检查是否已提交 if ("SUBMITTED".equals(enrollment.getStatus()) || "APPROVED".equals(enrollment.getStatus())) { throw new IllegalStateException("成绩已提交或已审核,不可重复提交"); } // 更新并检查 version 字段 int updated = enrollmentRepository.updateScoreAndVersion( score, enrollment.getVersion() + 1, enrollmentId, enrollment.getVersion() ); if (updated == 0) { throw new OptimisticLockException("成绩已被他人修改,请刷新后重试"); } }

对应 SQL 使用WHERE version = ?实现原子更新,避免 ABA 问题。这是 SpringBoot 项目中比@Version注解更可控的写法,尤其当需要自定义更新逻辑时。

2.2 权限模型落地:RBAC + 数据级过滤,不止于菜单可见

学生成绩系统涉及三类角色:管理员(全权限)、教师(仅能录本班成绩)、学生(仅能查本人成绩)。Spring Security 的@PreAuthorize只能控制接口访问,但无法阻止教师查询其他班级学生成绩。必须结合数据级过滤(Data Level Security):

@Component public class ScorePermissionFilter { @PersistenceContext private EntityManager entityManager; public <T> List<T> filterByRole(List<T> entities, String role, Long userId) { if ("STUDENT".equals(role)) { // 学生只能看到自己的成绩 return entities.stream() .filter(entity -> entity instanceof Enrollment) .map(e -> (Enrollment) e) .filter(e -> e.getStudent().getId().equals(userId)) .map(e -> (T) e) .collect(Collectors.toList()); } else if ("TEACHER".equals(role)) { // 教师只能看到所授课程的成绩 String sql = "SELECT e.* FROM enrollment e " + "JOIN course c ON e.course_id = c.id " + "WHERE c.teacher_id = :teacherId"; Query query = entityManager.createNativeQuery(sql, Enrollment.class); query.setParameter("teacherId", userId); return query.getResultList(); } return entities; // 管理员返回全部 } }

注意:此过滤器需在 Controller 返回前注入调用,而非依赖全局拦截器——因为不同接口返回实体类型不同(如/api/scores/summary返回ScoreSummaryDTO,而非Enrollment),必须按实际返回类型定制过滤逻辑。

2.3 文件导入导出:用 Apache POI 处理 Excel,避开内存溢出陷阱

成绩批量导入常因 Excel 行数超万导致 OOM。SpringBoot 默认 Servlet 容器(Tomcat)堆内存有限,必须改用SXSSFWorkbook(流式写入)替代XSSFWorkbook

@PostMapping("/import") public ResponseEntity<String> importScores(@RequestParam MultipartFile file) { try (InputStream is = file.getInputStream(); Workbook workbook = new SXSSFWorkbook(100)) { // 每 100 行刷入磁盘 Sheet sheet = workbook.getSheetAt(0); List<Enrollment> enrollments = new ArrayList<>(); for (Row row : sheet) { if (row.getRowNum() == 0) continue; // 跳过表头 Enrollment e = new Enrollment(); e.setStudentId(getLongCellValue(row.getCell(0))); e.setCourseId(getLongCellValue(row.getCell(1))); e.setScoreValue(new BigDecimal(row.getCell(2).getNumericCellValue())); e.setSemesterCode(row.getCell(3).getStringCellValue()); e.setStatus("DRAFT"); enrollments.add(e); } enrollmentRepository.saveAll(enrollments); return ResponseEntity.ok("导入成功:" + enrollments.size() + " 条记录"); } catch (Exception e) { return ResponseEntity.badRequest().body("导入失败:" + e.getMessage()); } }

导出同理,用SXSSFWorkbook生成大文件时,务必设置setCompressTempFiles(true)并指定临时目录,避免/tmp空间不足:

// application.yml 中配置 spring: servlet: context-path: /api web: resources: static-locations: classpath:/static/,file:/opt/app/static/

3. 前端工程搭建与关键交互实现:Vue 3 Composition API 驱动成绩可视化

3.1 Vue 3 + Vite 初始化:避开 Vue CLI 的历史包袱

Vue CLI 已停止维护,Vite 是当前标准。创建项目命令必须带--template vue显式指定模板,否则默认生成 React 项目:

npm create vite@latest student-score-vue -- --template vue cd student-score-vue npm install npm install axios element-plus @element-plus/icons-vue echarts npm run dev

提示:@element-plus/icons-vue是 Element Plus 2.3+ 版本必需的图标库,漏装会导致<el-icon>标签不渲染。安装后需在main.js中全局注册:

import * as ElementPlusIcons from '@element-plus/icons-vue' const app = createApp(App) for (const [key, component] of Object.entries(ElementPlusIcons)) { app.component(key, component) }

3.2 成绩录入页:用v-model+watch实现课程-班级-学生三级联动

成绩录入页需先选课程,再根据课程加载授课班级,再根据班级加载学生列表。若用v-model直接绑定select,会因异步请求未返回导致options为空。正确做法是用watch监听课程 ID 变化,触发班级查询:

<template> <el-form :model="form" label-width="120px"> <el-form-item label="课程"> <el-select v-model="form.courseId" @change="loadClasses"> <el-option v-for="c in courses" :key="c.id" :label="c.name" :value="c.id" /> </el-select> </el-form-item> <el-form-item label="班级"> <el-select v-model="form.classId" @change="loadStudents"> <el-option v-for="cl in classes" :key="cl.id" :label="cl.name" :value="cl.id" /> </el-select> </el-form-item> <el-form-item label="学生"> <el-select v-model="form.studentId"> <el-option v-for="s in students" :key="s.id" :label="s.name + '(' + s.studentNo + ')'" :value="s.id" /> </el-select> </el-form-item> </el-form> </template> <script setup> import { ref, watch } from 'vue' import { getClassesByCourse, getStudentsByClass } from '@/api/class' const form = ref({ courseId: null, classId: null, studentId: null }) const courses = ref([]) const classes = ref([]) const students = ref([]) // 监听课程变更,加载班级 watch(() => form.value.courseId, async (newVal) => { if (newVal) { classes.value = await getClassesByCourse(newVal) form.value.classId = null // 重置班级 students.value = [] // 清空学生 } }) // 监听班级变更,加载学生 watch(() => form.value.classId, async (newVal) => { if (newVal) { students.value = await getStudentsByClass(newVal) } }) </script>
3.2.1 表单校验:用rules+trigger实现失焦即时反馈

成绩录入要求分数在 0–100 之间,且小数位不超过 2 位。Element Plus 的el-form支持正则校验,但需注意trigger: 'blur'才能在失焦时触发:

const rules = { scoreValue: [ { required: true, message: '请输入成绩', trigger: 'blur' }, { pattern: /^([0-9]|[1-9][0-9]|100)(\.\d{1,2})?$/, message: '成绩应为0-100之间的数字,最多两位小数', trigger: 'blur' } ] }

3.3 成绩分析页:用 ECharts 渲染班级平均分趋势图

学生最关心“我在班级排第几”,教师需要“哪门课挂科率高”。ECharts 的line图最适合展示学期维度的趋势。关键点在于数据格式必须符合 ECharts 要求:xAxis.data是学期数组,series.data是对应平均分数组:

<template> <div ref="chartRef" style="width: 100%; height: 400px;"></div> </template> <script setup> import { onMounted, ref, watch } from 'vue' import * as echarts from 'echarts' const chartRef = ref(null) let chart = null const props = defineProps({ semesterData: { // 由父组件传入,格式:[{semester: "2023-2", avgScore: 78.5}, ...] type: Array, default: () => [] } }) onMounted(() => { initChart() }) watch(() => props.semesterData, () => { if (chart && props.semesterData.length > 0) { const semesters = props.semesterData.map(d => d.semester) const scores = props.semesterData.map(d => d.avgScore) chart.setOption({ xAxis: { data: semesters }, series: [{ data: scores }] }) } }) function initChart() { if (chartRef.value) { chart = echarts.init(chartRef.value) chart.setOption({ tooltip: { trigger: 'axis' }, xAxis: { type: 'category', data: [] }, yAxis: { type: 'value' }, series: [{ type: 'line', data: [] }], grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true } }) } } </script>

注意:watch必须监听props.semesterData,而非直接在onMounted中初始化图表数据——因为父组件可能异步获取数据,props初始为空,需等待数据到达后再setOption

4. 前后端联调与部署:Nginx 反向代理解决跨域,打包路径精准控制

4.1 开发期跨域:用 Vite 的proxy配置替代 CORS 注解

SpringBoot 后端开启@CrossOrigin仅适用于简单 GET 请求,POST/PUT 带 JSON Body 时仍会触发预检请求(OPTIONS),且生产环境不应依赖注解开放跨域。Vite 的vite.config.js应配置代理,让前端请求/api/**自动转发到http://localhost:8080

// vite.config.js export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } })

这样前端axios.get('/api/scores')实际请求的是http://localhost:3000/api/scoreshttp://localhost:8080/scores,完全规避浏览器跨域限制。

4.2 生产打包:Vue 的base和 SpringBoot 的static路径必须严格匹配

Vue 打包后生成dist/目录,其中index.html引用的 JS/CSS 路径默认为/assets/xxx.js。若直接将dist放入 SpringBoot 的src/main/resources/static,则访问http://localhost:8080/时,浏览器会请求http://localhost:8080/assets/xxx.js—— 但 SpringBoot 的静态资源根路径是/,所以必须确保index.html中的资源路径正确:

// vite.config.js export default defineConfig({ base: './', // 关键!设为相对路径,使 assets 路径变为 ./assets/xxx.js build: { outDir: 'dist', assetsDir: 'assets' } })

然后将整个dist目录内容(含index.htmlassets/文件夹)复制到 SpringBoot 的src/main/resources/static/下。此时启动 SpringBoot,访问http://localhost:8080即可加载 Vue 页面,所有请求经由@Controller处理,无需额外配置 WebMvcConfigurer。

4.3 Nginx 部署:用location规则区分静态资源与 API 转发

生产环境通常用 Nginx 托管前端,同时反向代理后端 API。配置必须明确划分路径:

server { listen 80; server_name score.example.com; # 前端静态资源(Vue 打包后的 dist) location / { root /opt/app/student-score-vue/dist; try_files $uri $uri/ /index.html; } # 后端 API 接口,转发到 SpringBoot location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 静态文件(如上传的 Excel 模板) location /uploads/ { alias /opt/app/uploads/; } }

提示:try_files $uri $uri/ /index.html;是 Vue Routerhistory模式的必需配置,否则刷新页面会返回 404。

5. 实战排错:五个高频问题与精准定位方法

5.1 Vue 打包后布局异常:检查public/index.html<base>标签

Vue Router 使用history模式时,若vite.config.jsbase设置为/admin/,则index.html会自动生成<base href="/admin/">。但若该标签被手动删除或路径错误,所有 CSS/JS 路径将 404,导致白屏或样式错乱。定位方法:打开浏览器开发者工具 → Network 标签页 → 刷新页面 → 查看assets/开头的请求是否返回 404。若返回 404,检查index.html<base>是否存在且值与vite.config.js一致。

5.2 SpringBoot 启动报错 “Failed to configure a DataSource”:确认application.yml位置与内容

此错误表明 SpringBoot 未找到数据库配置。常见原因有三:

  1. application.yml不在src/main/resources/目录下;
  2. 文件名误写为application.yaml(YAML 规范允许,但 SpringBoot 默认只识别.yml);
  3. spring.datasource.url中的数据库名拼写错误,或 MySQL 服务未启动。
    验证方法:在application.yml中添加logging.level.org.springframework.boot.autoconfigure.jdbc: DEBUG,启动时观察日志是否打印HikariPool-1 - Starting...

5.3 成绩导入 Excel 时中文乱码:强制指定WorkbookFactory编码

Apache POI 读取.xls文件时默认用Cp1252编码,导致中文列名解析为??。必须显式指定WorkbookFactory的编码参数:

// 替换原代码中的 WorkbookFactory.create(is) Workbook workbook = WorkbookFactory.create(is, "UTF-8"); // 显式传入编码

5.4 Element Plus 组件样式不生效:确认unplugin-vue-components插件配置

若使用按需引入,必须在vite.config.js中配置插件,并启用dirs指向组件目录:

import Components from 'unplugin-vue-components/vite' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' export default defineConfig({ plugins: [ Components({ resolvers: [ElementPlusResolver()], dirs: ['src/components'] // 必须包含此行,否则自定义组件不被扫描 }) ] })

5.5 Axios 请求 401 未跳转登录页:检查response.interceptors的错误处理逻辑

Vue 中常在main.js全局配置 Axios 拦截器,但若response.interceptor中未正确处理 401 状态码,用户 Token 过期后仍停留在原页面。正确写法:

axios.interceptors.response.use( response => response, error => { if (error.response?.status === 401) { localStorage.removeItem('token') router.push('/login') // 跳转登录页 } return Promise.reject(error) } )

注意:error.response可能为undefined(网络错误),必须加可选链?.防止报错。

6. 性能优化与安全加固:从慢查询到敏感信息防护

6.1 成绩查询慢?给enrollment表加复合索引

当按semester_code+course_id查询某学期某课程所有成绩时,若无索引,MySQL 将全表扫描。执行以下 SQL 创建覆盖索引:

ALTER TABLE enrollment ADD INDEX idx_semester_course (semester_code, course_id, score_value, status);

该索引覆盖了 WHERE 条件(semester_code,course_id)和 SELECT 字段(score_value,status),避免回表查询,查询速度可提升 10 倍以上。

6.2 防止 SpringBoot Actuator 泄露敏感信息:关闭非必要端点

SpringBoot Actuator 默认暴露/actuator/health,/actuator/env等端点,其中/env可能泄露数据库密码。必须在application.yml中显式禁用:

management: endpoints: web: exposure: include: health,info,metrics # exclude: "*" # 禁用全部,只保留必要项 endpoint: health: show-details: when_authorized

同时,在SecurityConfig中限制/actuator/**访问权限:

.authorizeHttpRequests(authz -> authz .requestMatchers("/actuator/**").hasRole("ADMIN") .anyRequest().authenticated() )

6.3 Vue 生产环境禁用 Vue Devtools:构建时自动剥离

开发时 Vue Devtools 方便调试,但生产环境必须禁用以减小包体积并防止恶意用户窥探组件状态。Vite 默认在mode: 'production'时自动移除,但需确认vite.config.js中未强制开启:

// vite.config.js export default defineConfig({ build: { rollupOptions: { plugins: [ // 确保没有手动添加 vueDevtools 插件 ] } } })

构建后检查dist/assets/index-xxx.js中是否还存在__VUE_DEVTOOLS_GLOBAL_HOOK__字符串,存在则说明未剥离成功。

6.4 成绩导出 Excel 文件名含中文?用encodeURIComponent编码响应头

浏览器对Content-Disposition中的中文文件名支持不一。Chrome 支持filename*=UTF-8''xxx.xlsx,但 IE 仅认filename=xxx.xlsx。最兼容方案是 URL 编码:

@GetMapping("/export") public void exportScores(HttpServletResponse response) throws IOException { String filename = "成绩导出_" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + ".xlsx"; response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment; filename=" + URLEncoder.encode(filename, StandardCharsets.UTF_8)); // 写入 Excel 流... }

此写法在 Chrome、Firefox、Edge、Safari 中均能正确显示中文文件名,且不依赖filename*语法。

提示:URLEncoder.encode()会将空格转为+,但 Excel 文件名中不应含空格,故直接使用即可。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询