功能概述
本文接上文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
}
效果展示:
代码解析:
- 文件验证:检查文件类型和大小,确保符合要求
- Excel解析:使用XLSX库读取Excel文件内容
- 自动识别考试:从Excel中提取考试名称并自动匹配系统内的考试
- 异常处理:捕获解析过程中可能出现的错误
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
}
}
流程说明:
- 参数校验:确保文件、考试和班级信息已填写
- 用户确认:弹出确认对话框,防止误操作
- 构建表单数据:将文件和相关参数封装为FormData
- 调用API:发送上传请求
- 处理结果:成功则刷新页面,失败则提示错误信息
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;
}
解析流程:
- 读取Excel文件:使用Apache POI库解析Excel
- 解析标题行:确定各数据列的索引位置
- 数据验证:检查必要列是否存在
- 获取映射关系:建立科目名称到ID、学生姓名到ID的映射
- 遍历数据行:逐行解析成绩数据
- 计算总分:累加每个学生的各科成绩
- 构建成绩对象:创建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>
实现原理总结
- 前端上传:用户选择Excel文件,前端进行基本验证
- 文件解析:前端初步解析文件内容,自动识别考试信息
- 数据提交:将文件和相关参数封装为FormData提交到后端
- 后端处理:接收文件流,使用POI库解析Excel内容
- 数据转换:将Excel中的学生姓名、科目名称转换为对应的数据库ID
- 数据存储:先删除原有成绩,再批量插入新成绩
- 结果反馈:向前端返回操作结果
技术亮点
- 自动识别机制:系统能够自动从Excel中识别考试信息,减少用户操作
- 数据验证:多层数据验证确保数据的完整性和准确性
- 批量处理:使用批量插入提高数据库操作性能
- 异常处理:完善的异常处理机制,提供友好的错误提示
- 用户体验:提供进度提示和结果反馈,增强用户体验
本文详细解析了成绩录入功能的实现原理和代码细节,希望能帮助读者理解整个流程,并在实际开发中借鉴相关技术方案。