SpringBoot项目Excel成绩录入功能详解:从文件上传到数据入库的全流程解析
功能概述
本文接上文Excel模板下载功能,详细讲解成绩录入的完整流程。教师下载模板并填写成绩后,可通过上传功能将Excel中的数据批量导入系统,实现高效的成绩管理。
前端实现解析
1. 文件上传组件
前端使用Element UI的上传组件实现文件选择功能:
<el-uploadclass="upload-demo"dragaction="#":auto-upload="false":on-change="handleFileChange":on-remove="handleFileRemove":file-list="fileList"accept=".xlsx,.xls"
><i class="el-icon-upload"></i><div class="el-upload__text">将Excel文件拖到此处,或<em>点击上传</em></div><div class="el-upload__tip" slot="tip">请上传xlsx/xls格式的Excel文件,且不超过10MB</div>
</el-upload>
组件参数说明:
drag
: 启用拖拽上传功能auto-upload="false"
: 禁止自动上传,需要手动触发on-change
: 文件选择变化时的回调函数accept
: 限制只能选择Excel文件
2. 文件处理逻辑
当用户选择文件后,触发handleFileChange
函数:
// 处理文件选择
async handleFileChange(file) {// 文件类型校验 - 只允许Excel格式const isExcel = file.name.endsWith('.xlsx') || file.name.endsWith('.xls')if (!isExcel) {this.$message.error('只能上传Excel文件!')return false}// 文件大小校验 - 不超过10MBconst isLt10M = file.size / 1024 / 1024 < 10if (!isLt10M) {this.$message.error('文件大小不能超过10MB!')return false}this.selectedFile = file// 解析Excel文件获取考试名称try {const workbook = await this.readExcelFile(file.raw)const firstSheetName = workbook.SheetNames[0]const worksheet = workbook.Sheets[firstSheetName]// 获取第一行标题行,找到考试名称列const range = XLSX.utils.decode_range(worksheet['!ref'])let examNameColumn = -1// 查找考试名称列for (let C = range.s.c; C <= range.e.c; C++) {const cellAddress = { c: C, r: 0 }const cellRef = XLSX.utils.encode_cell(cellAddress)if (worksheet[cellRef] && worksheet[cellRef].v === '考试名称') {examNameColumn = Cbreak}}// 如果找到了考试名称列,获取第一行数据中的考试名称if (examNameColumn >= 0) {// 取第二行(数据行的第一行)的考试名称const examNameCellAddress = { c: examNameColumn, r: 1 }const examNameCellRef = XLSX.utils.encode_cell(examNameCellAddress)if (worksheet[examNameCellRef]) {const templateExamName = worksheet[examNameCellRef].v// 查找对应的examIdconst matchingExam = this.homeData.exams.find(e => e.name === templateExamName)if (matchingExam) {this.selectedExamId = matchingExam.idthis.isExamDisabled = true // 禁用考试选择器this.$message.success(`已自动设置考试: ${templateExamName}`)} else {this.$message.warning(`模板中的考试名称 "${templateExamName}" 不存在,请手动选择考试`)}}}} catch (error) {console.error('解析Excel文件失败:', error)this.$message.error('解析Excel文件失败,请检查文件格式')}return true
}
效果展示:
代码解析:
- 文件验证:检查文件类型和大小,确保符合要求
- Excel解析:使用XLSX库读取Excel文件内容
- 自动识别考试:从Excel中提取考试名称并自动匹配系统内的考试
- 异常处理:捕获解析过程中可能出现的错误
3. Excel文件读取工具函数
// 读取Excel文件
readExcelFile(file) {return new Promise((resolve, reject) => {const reader = new FileReader()reader.onload = e => {try {const data = new Uint8Array(e.target.result)const workbook = XLSX.read(data, { type: 'array' })resolve(workbook)} catch (error) {reject(error)}}reader.onerror = rejectreader.readAsArrayBuffer(file)})
}
功能说明:使用FileReader API将文件读取为ArrayBuffer,然后通过XLSX库解析为workbook对象。
4. 提交成绩数据
用户点击提交按钮后,触发成绩上传流程:
<div slot="footer" class="dialog-footer"><el-button @click="scoreDialogVisible = false">取消</el-button><el-button type="primary" @click="handleSubmitScore" :loading="uploadLoading">{{ uploadLoading ? '上传中...' : '提交' }}</el-button>
</div>
对应的处理函数:
// 提交成绩
async handleSubmitScore() {if (!this.selectedFile) {this.$message.warning('请选择Excel文件')return}if (!this.selectedExamId) {this.$message.warning('请选择考试')return}if (!this.currentClassId) {this.$message.error('未选择班级')return}try {await this.$confirm(`确定要导入 ${this.selectedFile.name} 的成绩数据吗?`, '确认导入', {confirmButtonText: '确定',cancelButtonText: '取消',type: 'warning'})this.uploadLoading = true// 根据selectedExamId获取考试名称const exam = this.homeData.exams.find(e => e.id === this.selectedExamId)const formData = new FormData()formData.append('file', this.selectedFile.raw)formData.append('examName', exam ? exam.name : '')formData.append('examId', this.selectedExamId)formData.append('classId', this.currentClassId)formData.append('teacherId', this.teacherId)await uploadScores(formData)this.$message.success('成绩导入成功!')this.scoreDialogVisible = false// 完全刷新页面,而不仅仅是重新加载数据window.location.reload()} catch (error) {if (error !== 'cancel') {console.error('导入失败:', error)this.$message.error('成绩导入失败:' + (error.message || '请检查文件格式'))}} finally {this.uploadLoading = false}
}
流程说明:
- 参数校验:确保文件、考试和班级信息已填写
- 用户确认:弹出确认对话框,防止误操作
- 构建表单数据:将文件和相关参数封装为FormData
- 调用API:发送上传请求
- 处理结果:成功则刷新页面,失败则提示错误信息
5. API请求封装
// 上传成绩文件
export function uploadScores(data) {return request({url: '/teacher/score/upload',method: 'post',data: data,headers: {'Content-Type': 'multipart/form-data'}})
}
后端实现解析
1. Controller层 - 接收上传请求
package com.eduscore.controller;import com.eduscore.common.core.controller.BaseController;
import com.eduscore.common.core.domain.AjaxResult;
import com.eduscore.domain.Exam;
import com.eduscore.domain.Score;
import com.eduscore.domain.Student;
import com.eduscore.domain.Subject;
import com.eduscore.mapper.ExamMapper;
import com.eduscore.mapper.ScoreMapper;
import com.eduscore.mapper.StudentMapper;
import com.eduscore.mapper.SubjectMapper;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;import java.io.InputStream;
import java.util.*;
import java.util.stream.Collectors;@RestController
@RequestMapping("/teacher/score")
public class ScoreController extends BaseController {@Autowiredprivate ScoreMapper scoreMapper;@Autowiredprivate StudentMapper studentMapper;@Autowiredprivate SubjectMapper subjectMapper;@Autowiredprivate ExamMapper examMapper;/*** 上传成绩文件*/@PostMapping("/upload")public AjaxResult uploadScoreFile(@RequestParam("file") MultipartFile file,@RequestParam("examId") Integer examId,@RequestParam("classId") Integer classId,@RequestParam("teacherId") Integer teacherId) {try {// 验证文件if (file.isEmpty()) {return AjaxResult.error("上传文件不能为空");}// 获取文件类型String fileName = file.getOriginalFilename();if (fileName == null || (!fileName.endsWith(".xlsx") && !fileName.endsWith(".xls"))) {return AjaxResult.error("请上传Excel格式的文件");}// 解析Excel文件List<Score> scoreList = parseExcelFile(file.getInputStream(), examId, classId);// 批量插入成绩数据if (!scoreList.isEmpty()) {// 先删除该考试该班级的所有成绩记录scoreMapper.deleteScoresByExamAndClass(examId, classId);// 批量插入新成绩scoreMapper.batchInsertScores(scoreList);}return AjaxResult.success("成绩导入成功");} catch (Exception e) {e.printStackTrace();return AjaxResult.error("成绩导入失败:" + e.getMessage());}}// 其他方法将在下面详细解析...
}
代码解析:
@PostMapping("/upload")
: 定义POST请求接口@RequestParam
: 接收前端传递的参数- 文件验证:检查文件是否为空和格式是否正确
- 业务逻辑:解析Excel并处理成绩数据
2. Excel解析核心方法
/*** 解析Excel文件,提取成绩数据*/
private List<Score> parseExcelFile(InputStream inputStream, Integer examId, Integer classId) throws Exception {List<Score> scoreList = new ArrayList<>();try (Workbook workbook = new XSSFWorkbook(inputStream)) {Sheet sheet = workbook.getSheetAt(0);if (sheet == null) {throw new Exception("Excel文件中没有工作表");}// 获取第一行作为标题行Row titleRow = sheet.getRow(0);if (titleRow == null) {throw new Exception("Excel文件中没有标题行");}// 确定各列的索引Map<String, Integer> columnIndexMap = new HashMap<>();for (int i = 0; i < titleRow.getLastCellNum(); i++) {Cell cell = titleRow.getCell(i);if (cell != null) {String columnName = cell.getStringCellValue().trim();columnIndexMap.put(columnName, i);}}// 验证必要的列是否存在if (!columnIndexMap.containsKey("学生姓名") || !columnIndexMap.containsKey("班级")) {throw new Exception("Excel文件缺少必要的列:学生姓名、班级");}// 获取所有科目列表List<Subject> allSubjects = subjectMapper.selectSubjectList(new Subject());Map<String, Integer> subjectNameToIdMap = allSubjects.stream().collect(Collectors.toMap(Subject::getName, Subject::getId));// 获取该班级的所有学生List<Student> students = studentMapper.selectStudentsByClass(classId);Map<String, Integer> studentNameToIdMap = students.stream().collect(Collectors.toMap(Student::getName, Student::getId));// 解析数据行int rowNum = 0;// 记录每个学生的总分Map<Integer, Float> studentTotalScores = new HashMap<>();// 记录每个学生的科目成绩Map<Integer, Map<Integer, Float>> studentSubjectScores = new HashMap<>();for (Row row : sheet) {rowNum++;// 跳过标题行if (rowNum == 1) {continue;}try {// 获取学生姓名Cell studentNameCell = row.getCell(columnIndexMap.get("学生姓名"));if (studentNameCell == null) {continue;}String studentName = getStringCellValue(studentNameCell).trim();if (studentName.isEmpty()) {continue;}// 获取学生IDInteger studentId = studentNameToIdMap.get(studentName);if (studentId == null) {throw new Exception("未找到学生:" + studentName);}// 初始化学生的科目成绩记录studentSubjectScores.putIfAbsent(studentId, new HashMap<>());// 遍历所有可能的科目列for (Map.Entry<String, Integer> entry : columnIndexMap.entrySet()) {String columnName = entry.getKey();Integer columnIndex = entry.getValue();// 跳过非科目列if (columnName.equals("学生姓名") || columnName.equals("班级") || columnName.equals("考试名称")) {continue;}// 检查是否为科目列if (subjectNameToIdMap.containsKey(columnName)) {Integer subjectId = subjectNameToIdMap.get(columnName);Cell scoreCell = row.getCell(columnIndex);Float scoreValue = getFloatCellValue(scoreCell);if (scoreValue != null) {studentSubjectScores.get(studentId).put(subjectId, scoreValue);// 累加总分studentTotalScores.put(studentId, studentTotalScores.getOrDefault(studentId, 0f) + scoreValue);}}}} catch (Exception e) {throw new Exception("第" + rowNum + "行数据解析错误:" + e.getMessage());}}// 构建Score对象列表for (Map.Entry<Integer, Map<Integer, Float>> studentEntry : studentSubjectScores.entrySet()) {Integer studentId = studentEntry.getKey();Float totalScore = studentTotalScores.getOrDefault(studentId, 0f);for (Map.Entry<Integer, Float> subjectEntry : studentEntry.getValue().entrySet()) {Integer subjectId = subjectEntry.getKey();Float scoreValue = subjectEntry.getValue();Score score = new Score();score.setExamId(examId);score.setStudentId(studentId);score.setSubjectId(subjectId);score.setScore(scoreValue);score.setTotalScore(totalScore);scoreList.add(score);}}}return scoreList;
}
解析流程:
- 读取Excel文件:使用Apache POI库解析Excel
- 解析标题行:确定各数据列的索引位置
- 数据验证:检查必要列是否存在
- 获取映射关系:建立科目名称到ID、学生姓名到ID的映射
- 遍历数据行:逐行解析成绩数据
- 计算总分:累加每个学生的各科成绩
- 构建成绩对象:创建Score对象并添加到结果列表
3. 工具方法
/*** 获取单元格的字符串值*/
private String getStringCellValue(Cell cell) {if (cell == null) {return "";}switch (cell.getCellType()) {case STRING:return cell.getStringCellValue();case NUMERIC:// 如果是整数,格式化为整数字符串if (Math.floor(cell.getNumericCellValue()) == cell.getNumericCellValue()) {return String.valueOf((long) cell.getNumericCellValue());}return String.valueOf(cell.getNumericCellValue());case BOOLEAN:return String.valueOf(cell.getBooleanCellValue());default:return "";}
}/*** 获取单元格的浮点数值*/
private Float getFloatCellValue(Cell cell) {if (cell == null) {return null;}try {switch (cell.getCellType()) {case NUMERIC:return (float) cell.getNumericCellValue();case STRING:String value = cell.getStringCellValue().trim();if (!value.isEmpty()) {return Float.parseFloat(value);}return null;default:return null;}} catch (NumberFormatException e) {return null;}
}
功能说明:这两个工具方法负责处理Excel单元格数据转换,确保不同类型的数据都能正确转换为需要的格式。
数据库操作解析
1. 先删除后插入策略
// 先删除该考试该班级的所有成绩记录
scoreMapper.deleteScoresByExamAndClass(examId, classId);
// 批量插入新成绩
scoreMapper.batchInsertScores(scoreList);
设计思路:采用"先删除后插入"的策略,确保数据的唯一性和一致性。这种方式避免了复杂的数据更新逻辑,简化了实现过程。
2. MyBatis批量插入
通常在Mapper中使用批量插入来提高性能:
<insert id="batchInsertScores" parameterType="java.util.List">INSERT INTO score (exam_id, student_id, subject_id, score, total_score)VALUES<foreach collection="list" item="item" separator=",">(#{item.examId}, #{item.studentId}, #{item.subjectId}, #{item.score}, #{item.totalScore})</foreach>
</insert>
实现原理总结
- 前端上传:用户选择Excel文件,前端进行基本验证
- 文件解析:前端初步解析文件内容,自动识别考试信息
- 数据提交:将文件和相关参数封装为FormData提交到后端
- 后端处理:接收文件流,使用POI库解析Excel内容
- 数据转换:将Excel中的学生姓名、科目名称转换为对应的数据库ID
- 数据存储:先删除原有成绩,再批量插入新成绩
- 结果反馈:向前端返回操作结果
技术亮点
- 自动识别机制:系统能够自动从Excel中识别考试信息,减少用户操作
- 数据验证:多层数据验证确保数据的完整性和准确性
- 批量处理:使用批量插入提高数据库操作性能
- 异常处理:完善的异常处理机制,提供友好的错误提示
- 用户体验:提供进度提示和结果反馈,增强用户体验
本文详细解析了成绩录入功能的实现原理和代码细节,希望能帮助读者理解整个流程,并在实际开发中借鉴相关技术方案。