diff --git a/src/Jiaowu.Api/Controllers/GradesController.cs b/src/Jiaowu.Api/Controllers/GradesController.cs index b13c046..d2b6ee6 100644 --- a/src/Jiaowu.Api/Controllers/GradesController.cs +++ b/src/Jiaowu.Api/Controllers/GradesController.cs @@ -1,7 +1,9 @@ using System.ComponentModel.DataAnnotations; +using System.Globalization; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.Excel; using Jiaowu.Api.Infrastructure.Grades; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; @@ -531,6 +533,196 @@ public sealed class GradesController( return NoContent(); } + [HttpGet("sheets/{id:guid}/template")] + [Authorize(Roles = SheetUsers)] + public async Task 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 { "学号", "姓名", "班级", "平时成绩" }; + headers.AddRange(itemNames); + headers.AddRange(["期末成绩", "考试状态", "备注"]); + + var rows = sheet.Records.Select(record => + { + var values = new List + { + 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)values; + }).ToList(); + + var instructions = new List + { + "请勿修改第一行列名;学号、姓名、班级列请勿修改,用于匹配学生。", + "成绩列填写 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 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 { "学号", "姓名", "班级", "平时成绩" }; + headers.AddRange(itemNames); + headers.AddRange(["期末成绩", "考试状态", "备注"]); + + IReadOnlyList 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(); + 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")] [Authorize(Roles = SystemRoles.Student)] public async Task GetStudentTranscript( @@ -670,6 +862,38 @@ public sealed class GradesController( Status = StatusCodes.Status409Conflict }); + private ActionResult ImportValidationProblem(IReadOnlyList 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 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) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } diff --git a/web/src/views/GradesView.vue b/web/src/views/GradesView.vue index 14ef4f7..225f954 100644 --- a/web/src/views/GradesView.vue +++ b/web/src/views/GradesView.vue @@ -4,12 +4,15 @@ import { Check, Delete, DocumentChecked, + Download, EditPen, Plus, Promotion, Refresh, + Upload, } from '@element-plus/icons-vue' import http, { apiErrorMessage } from '../api/http' +import { downloadApiFile, importExcel } from '../api/excel' import { useAuthStore } from '../stores/auth' const auth = useAuthStore() @@ -230,6 +233,37 @@ async function submitSheet() { } } +const importFileInput = ref() + +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() { try { await http.post(`/grades/sheets/${detail.value.id}/approve`) @@ -531,6 +565,8 @@ onMounted(async () => { + +