SpringBoot项目Excel成绩录入功能详解:从文件上传到数据入库的全流程解析

功能概述

本文接上文Excel模板下载功能,详细讲解成绩录入的完整流程。教师下载模板并填写成绩后,可通过上传功能将Excel中的数据批量导入系统,实现高效的成绩管理。

前端实现解析

1. 文件上传组件

前端使用Element UI的上传组件实现文件选择功能:

html 复制代码
<el-upload
  class="upload-demo"
  drag
  action="#"
  :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函数:

javascript 复制代码
// 处理文件选择
async handleFileChange(file) {
  // 文件类型校验 - 只允许Excel格式
  const isExcel = file.name.endsWith('.xlsx') || file.name.endsWith('.xls')
  if (!isExcel) {
    this.$message.error('只能上传Excel文件!')
    return false
  }
  
  // 文件大小校验 - 不超过10MB
  const isLt10M = file.size / 1024 / 1024 < 10
  if (!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 = C
        break
      }
    }
    
    // 如果找到了考试名称列,获取第一行数据中的考试名称
    if (examNameColumn >= 0) {
      // 取第二行(数据行的第一行)的考试名称
      const examNameCellAddress = { c: examNameColumn, r: 1 }
      const examNameCellRef = XLSX.utils.encode_cell(examNameCellAddress)
      
      if (worksheet[examNameCellRef]) {
        const templateExamName = worksheet[examNameCellRef].v
        
        // 查找对应的examId
        const matchingExam = this.homeData.exams.find(e => e.name === templateExamName)
        if (matchingExam) {
          this.selectedExamId = matchingExam.id
          this.isExamDisabled = true // 禁用考试选择器
          this.$message.success(`已自动设置考试: ${templateExamName}`)
        } else {
          this.$message.warning(`模板中的考试名称 "${templateExamName}" 不存在,请手动选择考试`)
        }
      }
    }
  } catch (error) {
    console.error('解析Excel文件失败:', error)
    this.$message.error('解析Excel文件失败,请检查文件格式')
  }
  
  return true
}

效果展示:

代码解析​:

  1. 文件验证:检查文件类型和大小,确保符合要求
  2. Excel解析:使用XLSX库读取Excel文件内容
  3. 自动识别考试:从Excel中提取考试名称并自动匹配系统内的考试
  4. 异常处理:捕获解析过程中可能出现的错误

3. Excel文件读取工具函数

javascript 复制代码
// 读取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 = reject
    reader.readAsArrayBuffer(file)
  })
}

功能说明​:使用FileReader API将文件读取为ArrayBuffer,然后通过XLSX库解析为workbook对象。

4. 提交成绩数据

用户点击提交按钮后,触发成绩上传流程:

html 复制代码
<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>

对应的处理函数:

javascript 复制代码
// 提交成绩
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
  }
}

流程说明​:

  1. 参数校验:确保文件、考试和班级信息已填写
  2. 用户确认:弹出确认对话框,防止误操作
  3. 构建表单数据:将文件和相关参数封装为FormData
  4. 调用API:发送上传请求
  5. 处理结果:成功则刷新页面,失败则提示错误信息

5. API请求封装

javascript 复制代码
// 上传成绩文件
export function uploadScores(data) {
  return request({
    url: '/teacher/score/upload',
    method: 'post',
    data: data,
    headers: {
      'Content-Type': 'multipart/form-data'
    }
  })
}

后端实现解析

1. Controller层 - 接收上传请求

java 复制代码
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 {

    @Autowired
    private ScoreMapper scoreMapper;

    @Autowired
    private StudentMapper studentMapper;

    @Autowired
    private SubjectMapper subjectMapper;

    @Autowired
    private 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解析核心方法

java 复制代码
/​**​
 * 解析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;
                }

                // 获取学生ID
                Integer 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;
}

解析流程​:

  1. 读取Excel文件:使用Apache POI库解析Excel
  2. 解析标题行:确定各数据列的索引位置
  3. 数据验证:检查必要列是否存在
  4. 获取映射关系:建立科目名称到ID、学生姓名到ID的映射
  5. 遍历数据行:逐行解析成绩数据
  6. 计算总分:累加每个学生的各科成绩
  7. 构建成绩对象:创建Score对象并添加到结果列表

3. 工具方法

java 复制代码
/​**​
 * 获取单元格的字符串值
 */
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. 先删除后插入策略

java 复制代码
// 先删除该考试该班级的所有成绩记录
scoreMapper.deleteScoresByExamAndClass(examId, classId);
// 批量插入新成绩
scoreMapper.batchInsertScores(scoreList);

设计思路​:采用"先删除后插入"的策略,确保数据的唯一性和一致性。这种方式避免了复杂的数据更新逻辑,简化了实现过程。

2. MyBatis批量插入

通常在Mapper中使用批量插入来提高性能:

sql 复制代码
<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>

实现原理总结

  1. 前端上传:用户选择Excel文件,前端进行基本验证
  2. 文件解析:前端初步解析文件内容,自动识别考试信息
  3. 数据提交:将文件和相关参数封装为FormData提交到后端
  4. 后端处理:接收文件流,使用POI库解析Excel内容
  5. 数据转换:将Excel中的学生姓名、科目名称转换为对应的数据库ID
  6. 数据存储:先删除原有成绩,再批量插入新成绩
  7. 结果反馈:向前端返回操作结果

技术亮点

  1. 自动识别机制:系统能够自动从Excel中识别考试信息,减少用户操作
  2. 数据验证:多层数据验证确保数据的完整性和准确性
  3. 批量处理:使用批量插入提高数据库操作性能
  4. 异常处理:完善的异常处理机制,提供友好的错误提示
  5. 用户体验:提供进度提示和结果反馈,增强用户体验

本文详细解析了成绩录入功能的实现原理和代码细节,希望能帮助读者理解整个流程,并在实际开发中借鉴相关技术方案。

相关推荐
wshzrf2 小时前
【Java系列课程·Java学前须知】第3课 JDK,JVM,JRE的区别和优缺
java·开发语言·jvm
铅笔侠_小龙虾3 小时前
JVM 深入研究 -- 详解class 文件
java·开发语言·jvm
聪明的笨猪猪3 小时前
面试清单:JVM类加载与虚拟机执行核心问题
java·经验分享·笔记·面试
韶光流年都束之高阁3 小时前
Java中的TCP与UDP
java·tcp/ip·udp
练习时长一年3 小时前
ApplicationContext接口实现(二)
java·开发语言
皮皮林5514 小时前
SpringBoot 容器镜像更新只要200k,你敢信???
spring boot
狂团商城小师妹4 小时前
JAVA露营基地预约户外露营预约下单系统小程序
java·开发语言·微信小程序·小程序
2501_930707784 小时前
C#:将Excel转换为HTML时将图像嵌入HTML中
excel