成绩册 Excel 导入/导出 — 已完成

  后端 (GradesController.cs)

  GET /api/grades/sheets/{id}/template — 下载成绩导入模板
  - 根据成绩单的动态分项(GradeItems)生成对应的 Excel 列:学号 | 姓名 | 班级 | 平时成绩 | [分项1] | [分项2] | ... | 期末成绩 | 考试状态 | 备注
  - 预填已有学生信息和已有成绩
  - 附带填写说明工作表

  POST /api/grades/sheets/{id}/import — 从 Excel 导入成绩
  - 按学号匹配学生,批量更新平时成绩、分项成绩、期末成绩、考试状态、备注
  - 支持留空(不覆盖已有成绩)、0-100 范围校验、考试状态校验(正常/缺考/缓考/免修)
  - 导入后自动重新计算总评成绩和绩点
  - 仅限 Draft/Returned 状态的成绩单,仅限有编辑权限的用户
  - 错误时整批回滚,最多显示 50 条错误详情

  前端 (GradesView.vue)

  在成绩登记册底部操作栏新增两个按钮(仅在 canEdit 时显示):
  - 下载模板 — 调用 downloadApiFile 下载当前成绩单的 Excel 模板
  - Excel导入 — 通过隐藏 file input 选择 .xlsx 文件,调用 importExcel 上传

  工作流程

  1. 教师打开成绩单 → 点击「下载模板」
  2. 在 Excel 中填写/修改学生成绩
  3. 点击「Excel导入」选择文件上传
  4. 系统自动校验、合并成绩、重新计算总评 → 提示更新条数
  5. 成绩表刷新显示最新数据

✻ Crunched for 5m 50s
This commit is contained in:
2026-07-25 17:57:56 +08:00 Unverified
parent 97691cb203
commit 15a34a306c
2 changed files with 262 additions and 0 deletions
@@ -1,7 +1,9 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Globalization;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Grades; using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -531,6 +533,196 @@ public sealed class GradesController(
return NoContent(); return NoContent();
} }
[HttpGet("sheets/{id:guid}/template")]
[Authorize(Roles = SheetUsers)]
public async Task<IActionResult> DownloadTemplate(
Guid id,
CancellationToken cancellationToken)
{
var sheet = await AccessibleSheets()
.Include(x => x.Items.OrderBy(i => i.SortOrder))
.Include(x => x.Records.OrderBy(r => r.Student!.StudentNumber))
.ThenInclude(x => x.Student)
.ThenInclude(x => x!.AdministrativeClass)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
var itemNames = sheet.Items.Select(i => i.Name).ToList();
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
headers.AddRange(itemNames);
headers.AddRange(["期末成绩", "考试状态", "备注"]);
var rows = sheet.Records.Select(record =>
{
var values = new List<object?>
{
record.Student!.StudentNumber,
record.Student.Name,
record.Student.AdministrativeClass!.Name,
record.RegularScore
};
foreach (var item in sheet.Items)
{
var score = record.ItemScores
.FirstOrDefault(s => s.GradeItemId == item.Id)?.Score;
values.Add(score);
}
values.Add(record.FinalScore);
values.Add(record.ExamStatus == GradeExamStatus.Normal ? "正常" :
record.ExamStatus == GradeExamStatus.Absent ? "缺考" :
record.ExamStatus == GradeExamStatus.Deferred ? "缓考" :
record.ExamStatus == GradeExamStatus.Exempt ? "免修" : "正常");
values.Add(record.Notes);
return (IReadOnlyList<object?>)values;
}).ToList();
var instructions = new List<string>
{
"请勿修改第一行列名;学号、姓名、班级列请勿修改,用于匹配学生。",
"成绩列填写 0—100 的数值,留空表示暂未录入。",
"考试状态填写:正常、缺考、缓考 或 免修,留空默认为正常。",
$"本成绩单共 {sheet.Items.Count} 个分项:{string.Join("", itemNames)}。",
"导入后会自动重新计算总评成绩和绩点。"
};
var bytes = ExcelWorkbookHelper.Create(
"成绩导入", headers, rows, instructions);
var taskName = sheet.TeachingTask!.Name;
return File(bytes, ExcelWorkbookHelper.ContentType,
$"成绩导入模板-{taskName}.xlsx");
}
[HttpPost("sheets/{id:guid}/import")]
[Authorize(Roles = SheetUsers)]
[RequestSizeLimit(10 * 1024 * 1024)]
public async Task<ActionResult> ImportGrades(
Guid id,
IFormFile file,
CancellationToken cancellationToken)
{
var sheet = await AccessibleSheets()
.Include(x => x.Items.OrderBy(i => i.SortOrder))
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.Include(x => x.Records)
.ThenInclude(x => x.Student)
.ThenInclude(x => x!.AdministrativeClass)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned))
return ConflictProblem("只有草稿或已退回成绩单可以导入成绩。");
var itemNames = sheet.Items.Select(i => i.Name).ToList();
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
headers.AddRange(itemNames);
headers.AddRange(["期末成绩", "考试状态", "备注"]);
IReadOnlyList<ExcelRow> rows;
try
{
rows = await ExcelWorkbookHelper.ReadAsync(
file, headers, cancellationToken);
}
catch (InvalidDataException exception)
{
return ValidationProblem(exception.Message);
}
if (rows.Count == 0)
return ValidationProblem("Excel 中没有可导入的成绩数据。");
var studentMap = sheet.Records.ToDictionary(
x => x.Student!.StudentNumber,
StringComparer.OrdinalIgnoreCase);
var itemMap = sheet.Items.ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase);
var errors = new List<string>();
var updated = 0;
foreach (var row in rows)
{
var studentNumber = row["学号"]?.Trim();
if (string.IsNullOrWhiteSpace(studentNumber))
{
errors.Add($"第 {row.RowNumber} 行:学号不能为空。");
continue;
}
if (!studentMap.TryGetValue(studentNumber, out var record))
{
errors.Add($"第 {row.RowNumber} 行:学号“{studentNumber}”不在本成绩单中。");
continue;
}
// Parse regular score
var regularScore = ParseOptionalDecimal(row, "平时成绩", 0, 100, errors);
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
// Parse final score
var finalScore = ParseOptionalDecimal(row, "期末成绩", 0, 100, errors);
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
// Parse item scores
var itemScores = new List<(Guid GradeItemId, decimal? Score)>();
foreach (var itemName in itemNames)
{
if (itemMap.TryGetValue(itemName, out var item))
{
var score = ParseOptionalDecimal(row, itemName, 0, 100, errors);
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString()))
break;
itemScores.Add((item.Id, score));
}
}
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
// Parse exam status
var examStatusText = row["考试状态"]?.Trim();
var examStatus = GradeExamStatus.Normal;
if (!string.IsNullOrWhiteSpace(examStatusText))
{
if (examStatusText == "缺考") examStatus = GradeExamStatus.Absent;
else if (examStatusText == "缓考") examStatus = GradeExamStatus.Deferred;
else if (examStatusText == "免修") examStatus = GradeExamStatus.Exempt;
else if (examStatusText != "正常")
{
errors.Add($"第 {row.RowNumber} 行:考试状态“{examStatusText}”无效,请填写正常、缺考、缓考或免修。");
continue;
}
}
// Apply
record.RegularScore = regularScore;
record.FinalScore = finalScore;
record.ExamStatus = examStatus;
record.Notes = Normalize(row["备注"]);
foreach (var (itemId, score) in itemScores)
{
var existingScore = record.ItemScores
.FirstOrDefault(s => s.GradeItemId == itemId);
if (existingScore != null)
existingScore.Score = score;
}
Recalculate(sheet, record);
updated++;
}
if (errors.Count > 0)
return ImportValidationProblem(errors);
await db.SaveChangesAsync(cancellationToken);
return Ok(new { updated, total = rows.Count });
}
[HttpGet("student/transcript")] [HttpGet("student/transcript")]
[Authorize(Roles = SystemRoles.Student)] [Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetStudentTranscript( public async Task<ActionResult> GetStudentTranscript(
@@ -670,6 +862,38 @@ public sealed class GradesController(
Status = StatusCodes.Status409Conflict Status = StatusCodes.Status409Conflict
}); });
private ActionResult ImportValidationProblem(IReadOnlyList<string> errors)
{
foreach (var error in errors.Take(50))
ModelState.AddModelError("file", error);
if (errors.Count > 50)
ModelState.AddModelError("file", $"另有 {errors.Count - 50} 条错误未显示。");
return ValidationProblem(ModelState);
}
private static decimal? ParseOptionalDecimal(
ExcelRow row,
string header,
decimal minimum,
decimal maximum,
List<string> errors)
{
var value = row[header];
if (string.IsNullOrWhiteSpace(value)) return null;
if (decimal.TryParse(
value,
NumberStyles.Number,
CultureInfo.InvariantCulture,
out var result) &&
result >= minimum &&
result <= maximum)
return result;
errors.Add(
$"第 {row.RowNumber} 行:" +
$"“{header}”请填写 {minimum:0}—{maximum:0} 的数值或留空。");
return null;
}
private static string? Normalize(string? value) => private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim(); string.IsNullOrWhiteSpace(value) ? null : value.Trim();
} }
+38
View File
@@ -4,12 +4,15 @@ import {
Check, Check,
Delete, Delete,
DocumentChecked, DocumentChecked,
Download,
EditPen, EditPen,
Plus, Plus,
Promotion, Promotion,
Refresh, Refresh,
Upload,
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile, importExcel } from '../api/excel'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
const auth = useAuthStore() const auth = useAuthStore()
@@ -230,6 +233,37 @@ async function submitSheet() {
} }
} }
const importFileInput = ref<HTMLInputElement>()
function downloadTemplate() {
downloadApiFile(
`/grades/sheets/${detail.value.id}/template`,
'成绩导入模板.xlsx',
)
}
function chooseImportFile() {
importFileInput.value?.click()
}
async function handleImport(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
try {
const result = await importExcel(
`/grades/sheets/${detail.value.id}/import`,
file,
)
ElMessage.success(`导入完成:已更新 ${result.data.updated} 条成绩记录`)
await selectTask(selectedTask.value)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
input.value = ''
}
}
async function approveSheet() { async function approveSheet() {
try { try {
await http.post(`/grades/sheets/${detail.value.id}/approve`) await http.post(`/grades/sheets/${detail.value.id}/approve`)
@@ -531,6 +565,8 @@ onMounted(async () => {
</el-table> </el-table>
</div> </div>
<input ref="importFileInput" type="file" accept=".xlsx" style="display:none" @change="handleImport" />
<footer class="grade-actions"> <footer class="grade-actions">
<div class="workflow-status"> <div class="workflow-status">
<span>{{ statusMeta[detail.status]?.icon }} {{ statusLabels[detail.status] }}</span> <span>{{ statusMeta[detail.status]?.icon }} {{ statusLabels[detail.status] }}</span>
@@ -540,6 +576,8 @@ onMounted(async () => {
</small> </small>
</div> </div>
<div class="grade-actions-buttons"> <div class="grade-actions-buttons">
<el-button v-if="detail.canEdit" :icon="Download" @click="downloadTemplate">下载模板</el-button>
<el-button v-if="detail.canEdit" :icon="Upload" @click="chooseImportFile">Excel导入</el-button>
<el-button v-if="detail.canEdit" :icon="EditPen" @click="saveRecords">保存成绩</el-button> <el-button v-if="detail.canEdit" :icon="EditPen" @click="saveRecords">保存成绩</el-button>
<el-button v-if="detail.canEdit" type="primary" :icon="Promotion" @click="submitSheet">提交审核</el-button> <el-button v-if="detail.canEdit" type="primary" :icon="Promotion" @click="submitSheet">提交审核</el-button>
<el-button v-if="detail.canReview" type="danger" plain @click="openReturn">退回修改</el-button> <el-button v-if="detail.canReview" type="danger" plain @click="openReturn">退回修改</el-button>