From 4446cfbd304fa35c3618fd0175ad987e64206e63 Mon Sep 17 00:00:00 2001 From: biss Date: Fri, 21 Aug 2026 17:51:33 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E9=AA=8C=E6=88=90=E7=BB=A9=E5=AF=BC?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/ExperimentGradesController.cs | 89 +++++++++++++++++++ .../ExperimentGradesControllerTests.cs | 86 ++++++++++++++++++ web/src/views/ExperimentGradesView.vue | 19 ++++ 3 files changed, 194 insertions(+) diff --git a/src/Jiaowu.Api/Controllers/ExperimentGradesController.cs b/src/Jiaowu.Api/Controllers/ExperimentGradesController.cs index 0510b80..c8bc5a2 100644 --- a/src/Jiaowu.Api/Controllers/ExperimentGradesController.cs +++ b/src/Jiaowu.Api/Controllers/ExperimentGradesController.cs @@ -190,6 +190,84 @@ public sealed class ExperimentGradesController( }); } + [HttpGet("teaching-tasks/{teachingTaskId:guid}/export.xlsx")] + [Authorize(Roles = Managers)] + public async Task ExportTeachingTaskScores( + Guid teachingTaskId, + CancellationToken cancellationToken) + { + var sheets = await ScopedSheets() + .Where(sheet => + sheet.ExperimentProject!.TeachingTaskId == teachingTaskId) + .Include(sheet => sheet.Records) + .ThenInclude(record => record.Student) + .ThenInclude(student => student!.AdministrativeClass) + .Include(sheet => sheet.ExperimentProject) + .ThenInclude(project => project!.TeachingTask) + .ThenInclude(task => task!.AcademicTerm) + .Include(sheet => sheet.ExperimentProject) + .ThenInclude(project => project!.TeachingTask) + .ThenInclude(task => task!.Course) + .ThenInclude(course => course!.College) + .AsSplitQuery() + .AsNoTracking() + .OrderBy(sheet => sheet.ExperimentProject!.Code) + .ToListAsync(cancellationToken); + if (sheets.Count == 0) + return NotFound(); + + var task = sheets[0].ExperimentProject!.TeachingTask!; + var course = task.Course!; + var students = sheets + .SelectMany(sheet => sheet.Records) + .Select(record => record.Student!) + .GroupBy(student => student.Id) + .Select(group => group.First()) + .OrderBy(student => student.StudentNumber) + .ToList(); + var aggregate = await ExperimentGradeAggregationService.CalculateAsync( + db, + teachingTaskId, + students.Select(student => student.Id).ToArray(), + cancellationToken); + var headers = new List + { + "学期", "开课学院", "课程代码", "课程名称", "教学班", + "学号", "姓名", "行政班" + }; + headers.AddRange(sheets.Select(sheet => + $"{sheet.ExperimentProject!.Code}·{sheet.ExperimentProject.Name}" + + $"({SheetStatusLabel(sheet.Status)})")); + headers.Add("实验合成总分(仅已发布项目)"); + + var rows = students.Select(student => + { + var values = new List + { + task.AcademicTerm!.Name, + course.College!.Name, + course.Code, + course.Name, + task.TaskNumber, + student.StudentNumber, + student.Name, + student.AdministrativeClass!.Name + }; + values.AddRange(sheets.Select(sheet => (object?)sheet.Records + .FirstOrDefault(record => record.StudentId == student.Id) + ?.TotalScore)); + values.Add(aggregate.Scores.GetValueOrDefault(student.Id)); + return (IReadOnlyList)values; + }).ToList(); + var bytes = ExcelWorkbookHelper.Create("课程实验成绩", headers, rows); + return File(bytes, ExcelWorkbookHelper.ContentType, + DownloadFileName.Export( + "课程实验成绩", + ".xlsx", + course.Code, + task.TaskNumber)); + } + [HttpGet("mine")] [Authorize(Roles = SystemRoles.Student)] public async Task GetMine(CancellationToken cancellationToken) @@ -1300,6 +1378,17 @@ public sealed class ExperimentGradesController( _ => "待登记" }; + private static string SheetStatusLabel(ExperimentGradeSheetStatus status) => + status switch + { + ExperimentGradeSheetStatus.Draft => "录入中", + ExperimentGradeSheetStatus.Submitted => "待审核", + ExperimentGradeSheetStatus.Approved => "审核通过", + ExperimentGradeSheetStatus.Published => "已发布", + ExperimentGradeSheetStatus.Returned => "已退回", + _ => status.ToString() + }; + private static bool TryParseParticipationStatus( ExcelRow row, out ExperimentParticipationStatus status, diff --git a/tests/Jiaowu.Api.Tests/ExperimentGradesControllerTests.cs b/tests/Jiaowu.Api.Tests/ExperimentGradesControllerTests.cs index bf696f9..4766721 100644 --- a/tests/Jiaowu.Api.Tests/ExperimentGradesControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/ExperimentGradesControllerTests.cs @@ -14,6 +14,92 @@ namespace Jiaowu.Api.Tests; public sealed class ExperimentGradesControllerTests { + [Fact] + public async Task ExportTeachingTaskScores_IncludesEveryProjectAndPublishedTotal() + { + await using var fixture = await ExperimentGradeFixture.CreateAsync(); + var firstProject = new ExperimentProject + { + TeachingTaskId = fixture.Task.Id, + Code = "LAB-EXPORT-1", + Name = "实验一", + ArrangementMode = ExperimentArrangementMode.Centralized, + StartDate = fixture.Term.StartDate, + EndDate = fixture.Term.EndDate, + Status = ExperimentProjectStatus.Published, + PublishedAt = DateTime.UtcNow + }; + var secondProject = new ExperimentProject + { + TeachingTaskId = fixture.Task.Id, + Code = "LAB-EXPORT-2", + Name = "实验二", + ArrangementMode = ExperimentArrangementMode.Centralized, + StartDate = fixture.Term.StartDate, + EndDate = fixture.Term.EndDate, + Status = ExperimentProjectStatus.Published, + PublishedAt = DateTime.UtcNow + }; + fixture.Db.ExperimentProjects.AddRange(firstProject, secondProject); + await fixture.Db.SaveChangesAsync(); + fixture.Db.ExperimentGradeSheets.AddRange( + new ExperimentGradeSheet + { + ExperimentProjectId = firstProject.Id, + ContributionWeight = 1, + PassScore = 60, + Status = ExperimentGradeSheetStatus.Published, + PublishedAt = DateTime.UtcNow, + Records = + [ + new ExperimentGradeRecord + { + StudentId = fixture.Student.Id, + ParticipationStatus = ExperimentParticipationStatus.Completed, + TotalScore = 80, + IsPassed = true + } + ] + }, + new ExperimentGradeSheet + { + ExperimentProjectId = secondProject.Id, + ContributionWeight = 3, + PassScore = 60, + Status = ExperimentGradeSheetStatus.Published, + PublishedAt = DateTime.UtcNow, + Records = + [ + new ExperimentGradeRecord + { + StudentId = fixture.Student.Id, + ParticipationStatus = ExperimentParticipationStatus.Completed, + TotalScore = 90, + IsPassed = true + } + ] + }); + await fixture.Db.SaveChangesAsync(); + await ExperimentGradeAggregationService.RefreshTeachingTaskAsync( + fixture.Db, + fixture.Task.Id, + CancellationToken.None); + fixture.Db.ChangeTracker.Clear(); + + var file = Assert.IsType(await fixture + .ExperimentGrades(fixture.AdminScope) + .ExportTeachingTaskScores(fixture.Task.Id, CancellationToken.None)); + await using var stream = new MemoryStream(file.FileContents); + using var workbook = new XLWorkbook(stream); + var worksheet = workbook.Worksheet("课程实验成绩"); + Assert.Equal("LAB-EXPORT-1·实验一(已发布)", worksheet.Cell(1, 9).GetString()); + Assert.Equal("LAB-EXPORT-2·实验二(已发布)", worksheet.Cell(1, 10).GetString()); + Assert.Equal("实验合成总分(仅已发布项目)", worksheet.Cell(1, 11).GetString()); + Assert.Equal("80", worksheet.Cell(2, 9).GetFormattedString()); + Assert.Equal("90", worksheet.Cell(2, 10).GetFormattedString()); + Assert.Equal("87.5", worksheet.Cell(2, 11).GetFormattedString()); + } + [Fact] public async Task UpdateSettings_ReplacesItemsAndRecreatesScores() { diff --git a/web/src/views/ExperimentGradesView.vue b/web/src/views/ExperimentGradesView.vue index 2b10c5e..4cd0a7a 100644 --- a/web/src/views/ExperimentGradesView.vue +++ b/web/src/views/ExperimentGradesView.vue @@ -376,6 +376,18 @@ function downloadTemplate() { ) } +async function exportCourseScores(course: any) { + try { + await downloadApiFile( + `/experiment-grades/teaching-tasks/${course.teachingTaskId}/export.xlsx`, + `${course.courseCode}-${course.taskNumber}-课程实验成绩.xlsx`, + ) + ElMessage.success('课程实验成绩已导出') + } catch (error) { + ElMessage.error(apiErrorMessage(error)) + } +} + function chooseImportFile() { importFileInput.value?.click() } @@ -650,6 +662,13 @@ onMounted(async () => { {{ course.projectCount }}个实验项目 {{ course.sheetCount }} 份成绩单 · {{ course.scoredCount }}/{{ course.studentCount }} 已计分 尚未建立成绩单 + 导出实验成绩