diff --git a/src/Jiaowu.Api/Controllers/ExperimentGradesController.cs b/src/Jiaowu.Api/Controllers/ExperimentGradesController.cs index f00cee5..e9f5379 100644 --- a/src/Jiaowu.Api/Controllers/ExperimentGradesController.cs +++ b/src/Jiaowu.Api/Controllers/ExperimentGradesController.cs @@ -1,8 +1,10 @@ using System.ComponentModel.DataAnnotations; using System.Data; +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 Jiaowu.Api.Infrastructure.Teaching; @@ -574,6 +576,166 @@ public sealed class ExperimentGradesController( return NoContent(); } + [HttpGet("sheets/{id:guid}/template")] + [Authorize(Roles = Managers)] + public async Task DownloadTemplate( + Guid id, + CancellationToken cancellationToken) + { + var sheet = await LoadEditableSheetAsync(id, cancellationToken); + if (sheet is null) return NotFound(); + if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid(); + + var headers = ExperimentImportHeaders(sheet.Items); + var rows = sheet.Records + .OrderBy(record => record.Student!.StudentNumber) + .Select(record => + { + var values = new List + { + record.Student!.StudentNumber, + record.Student.Name, + record.Student.AdministrativeClass!.Name, + ParticipationLabel(record.ParticipationStatus) + }; + foreach (var item in sheet.Items.OrderBy(item => item.SortOrder)) + values.Add(record.ItemScores.FirstOrDefault(score => + score.ExperimentGradeItemId == item.Id)?.Score); + values.Add(record.SafetyViolation); + values.Add(record.AttemptNumber); + values.Add(null); + values.Add(record.TeacherComment); + return (IReadOnlyList)values; + }) + .ToList(); + var itemCount = sheet.Items.Count; + var totalColumn = 7 + itemCount; + var scoreColumns = Enumerable.Range(5, itemCount).Append(totalColumn); + var instructions = new List + { + "请勿修改第一行列名;学号、姓名、班级列请勿修改,用于匹配本实验成绩单的学生。", + "参与状态填写:待登记、已完成、缺席、请假、补做 或 免做。", + "评分项填写 0—100 的数值,留空表示暂未录入。", + "安全违规填写 是 或 否;实验次数填写 1—20。", + "实验总评(自动计算)仅供 Excel 预览;上传时系统会按评分项、参与状态和安全违规重新计算。", + $"本实验共 {itemCount} 个评分项:{string.Join("、", sheet.Items.OrderBy(item => item.SortOrder).Select(item => item.Name))}。" + }; + var bytes = ExcelWorkbookHelper.Create( + "实验成绩导入", headers, rows, instructions, + (worksheet, rowNumber) => + { + var itemReferences = Enumerable.Range(5, itemCount) + .Select(column => $"{ColumnLetter(column)}{rowNumber}") + .ToArray(); + var weightedExpression = string.Join("+", sheet.Items + .OrderBy(item => item.SortOrder) + .Select((item, index) => + $"{ColumnLetter(index + 5)}{rowNumber}*{item.Weight.ToString(CultureInfo.InvariantCulture)}/100")); + var statusReference = $"D{rowNumber}"; + var safetyReference = $"{ColumnLetter(5 + itemCount)}{rowNumber}"; + worksheet.Cell(rowNumber, totalColumn).FormulaA1 = + $"=IF(OR({statusReference}=\"缺席\",{safetyReference}=\"是\"),0,IF(OR({statusReference}=\"待登记\",{statusReference}=\"请假\",{statusReference}=\"免做\"),\"\",IF(COUNT({string.Join(",", itemReferences)})={itemCount},ROUND({weightedExpression},1),\"\")))"; + var totalCell = worksheet.Cell(rowNumber, totalColumn); + totalCell.Style.NumberFormat.Format = "0.0"; + totalCell.Style.Font.Bold = true; + totalCell.Style.Fill.BackgroundColor = ClosedXML.Excel.XLColor.FromHtml("#E8F1FB"); + foreach (var column in scoreColumns) + { + var format = worksheet.Range(rowNumber, column, rowNumber, column) + .AddConditionalFormat().WhenLessThan(60); + format.Fill.BackgroundColor = ClosedXML.Excel.XLColor.FromHtml("#FDECEC"); + format.Font.FontColor = ClosedXML.Excel.XLColor.FromHtml("#B42318"); + } + }); + return File(bytes, ExcelWorkbookHelper.ContentType, + $"实验成绩导入模板-{sheet.ExperimentProject!.Code}.xlsx"); + } + + [HttpPost("sheets/{id:guid}/import")] + [Authorize(Roles = Managers)] + [RequestSizeLimit(10 * 1024 * 1024)] + public async Task ImportRecords( + Guid id, + IFormFile file, + CancellationToken cancellationToken) + { + var sheet = await LoadEditableSheetAsync(id, cancellationToken); + if (sheet is null) return NotFound(); + if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid(); + if (sheet.Status is not (ExperimentGradeSheetStatus.Draft or ExperimentGradeSheetStatus.Returned)) + return ConflictProblem("只有录入中或已退回实验成绩单可以导入成绩。"); + + var headers = ExperimentImportHeaders(sheet.Items); + IReadOnlyList rows; + try + { + rows = await ExcelWorkbookHelper.ReadAsync(file, + headers.Where(header => header != "实验总评(自动计算)").ToArray(), + cancellationToken); + } + catch (InvalidDataException exception) + { + return ValidationProblem(exception.Message); + } + if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的实验成绩数据。"); + + var records = sheet.Records.ToDictionary(record => record.Student!.StudentNumber, + StringComparer.OrdinalIgnoreCase); + var items = sheet.Items.OrderBy(item => item.SortOrder).ToList(); + var errors = new List(); + var updated = 0; + foreach (var row in rows) + { + var studentNumber = row["学号"]; + if (string.IsNullOrWhiteSpace(studentNumber)) + { + errors.Add($"第 {row.RowNumber} 行:学号不能为空。"); + continue; + } + if (!records.TryGetValue(studentNumber, out var record)) + { + errors.Add($"第 {row.RowNumber} 行:学号“{studentNumber}”不在本实验成绩单中。"); + continue; + } + if (!TryParseParticipationStatus(row, out var participationStatus, out var participationError)) + { + errors.Add($"第 {row.RowNumber} 行:{participationError}"); + continue; + } + if (!TryParseYesNo(row["安全违规"], out var safetyViolation)) + { + errors.Add($"第 {row.RowNumber} 行:“安全违规”请填写是或否。"); + continue; + } + if (!int.TryParse(row["实验次数"], out var attemptNumber) || attemptNumber is < 1 or > 20) + { + errors.Add($"第 {row.RowNumber} 行:“实验次数”请填写 1—20 的整数。"); + continue; + } + var scores = new List(); + foreach (var item in items) + { + var score = ParseOptionalDecimal(row, item.Name, 0, 100, errors); + if (errors.Count > 0 && errors[^1].Contains($"第 {row.RowNumber} 行")) break; + scores.Add(score); + } + if (scores.Count != items.Count) continue; + + record.ParticipationStatus = participationStatus; + record.SafetyViolation = safetyViolation; + record.AttemptNumber = attemptNumber; + record.TeacherComment = Normalize(row["教师评语"]); + var scoreMap = record.ItemScores.ToDictionary(score => score.ExperimentGradeItemId); + for (var index = 0; index < items.Count; index++) + scoreMap[items[index].Id].Score = scores[index]; + Recalculate(sheet, record); + updated++; + } + if (errors.Count > 0) return ImportValidationProblem(errors); + await db.SaveChangesAsync(cancellationToken); + return Ok(new { Updated = updated, Total = rows.Count }); + } + [HttpPost("sheets/{id:guid}/sync-participants")] [Authorize(Roles = Managers)] public async Task SyncParticipants( @@ -834,6 +996,22 @@ public sealed class ExperimentGradesController( .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); } + private async Task LoadEditableSheetAsync( + Guid id, + CancellationToken cancellationToken) => + await ScopedSheets() + .Include(x => x.Items) + .Include(x => x.Records) + .ThenInclude(x => x.ItemScores) + .Include(x => x.Records) + .ThenInclude(x => x.Student) + .ThenInclude(x => x!.AdministrativeClass) + .Include(x => x.ExperimentProject) + .ThenInclude(x => x!.TeachingTask) + .ThenInclude(x => x!.Teachers) + .ThenInclude(x => x.Teacher) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + private async Task> LoadParticipantsAsync( ExperimentProject project, CancellationToken cancellationToken) @@ -974,6 +1152,93 @@ public sealed class ExperimentGradesController( private static bool ValidScore(decimal? score) => !score.HasValue || score.Value is >= 0 and <= 100; + private static List ExperimentImportHeaders( + IEnumerable items) + { + var headers = new List { "学号", "姓名", "班级", "参与状态" }; + headers.AddRange(items.OrderBy(item => item.SortOrder).Select(item => item.Name)); + headers.AddRange(["安全违规", "实验次数", "实验总评(自动计算)", "教师评语"]); + return headers; + } + + private static string ParticipationLabel( + ExperimentParticipationStatus status) => status switch + { + ExperimentParticipationStatus.Pending => "待登记", + ExperimentParticipationStatus.Completed => "已完成", + ExperimentParticipationStatus.Absent => "缺席", + ExperimentParticipationStatus.Excused => "请假", + ExperimentParticipationStatus.Makeup => "补做", + ExperimentParticipationStatus.Exempt => "免做", + _ => "待登记" + }; + + private static bool TryParseParticipationStatus( + ExcelRow row, + out ExperimentParticipationStatus status, + out string error) + { + error = string.Empty; + switch (row["参与状态"]) + { + case "待登记": status = ExperimentParticipationStatus.Pending; return true; + case "已完成": status = ExperimentParticipationStatus.Completed; return true; + case "缺席": status = ExperimentParticipationStatus.Absent; return true; + case "请假": status = ExperimentParticipationStatus.Excused; return true; + case "补做": status = ExperimentParticipationStatus.Makeup; return true; + case "免做": status = ExperimentParticipationStatus.Exempt; return true; + default: + status = default; + error = "“参与状态”请填写待登记、已完成、缺席、请假、补做或免做。"; + return false; + } + } + + private static bool TryParseYesNo(string value, out bool result) + { + if (value == "是") { result = true; return true; } + if (value == "否") { result = false; return true; } + result = false; + return false; + } + + 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 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 string ColumnLetter(int column) + { + var result = string.Empty; + while (column > 0) + { + column--; + result = (char)('A' + column % 26) + result; + column /= 26; + } + return result; + } + private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); diff --git a/tests/Jiaowu.Api.Tests/ExperimentGradesControllerTests.cs b/tests/Jiaowu.Api.Tests/ExperimentGradesControllerTests.cs index b4c9894..f979d43 100644 --- a/tests/Jiaowu.Api.Tests/ExperimentGradesControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/ExperimentGradesControllerTests.cs @@ -4,6 +4,8 @@ using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Grades; using Jiaowu.Api.Infrastructure.Persistence; +using ClosedXML.Excel; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; @@ -72,6 +74,56 @@ public sealed class ExperimentGradesControllerTests CancellationToken.None)); } + [Fact] + public async Task Teacher_CanDownloadAndImportExperimentGradeTemplate() + { + await using var fixture = await ExperimentGradeFixture.CreateAsync(); + var project = new ExperimentProject + { + TeachingTaskId = fixture.Task.Id, + Code = "LAB-EXCEL", + Name = "Excel 实验成绩", + ArrangementMode = ExperimentArrangementMode.Centralized, + StartDate = fixture.Term.StartDate, + EndDate = fixture.Term.EndDate, + Status = ExperimentProjectStatus.Published, + PublishedAt = DateTime.UtcNow + }; + fixture.Db.ExperimentProjects.Add(project); + await fixture.Db.SaveChangesAsync(); + + var admin = fixture.ExperimentGrades(fixture.AdminScope); + await admin.CreateSheet(new ExperimentGradeSheetRequest( + project.Id, 1, 60, + [new ExperimentGradeItemRequest("操作", ExperimentGradeItemKind.Operation, 100)]), + CancellationToken.None); + var sheet = await fixture.Db.ExperimentGradeSheets.SingleAsync(); + fixture.Db.ChangeTracker.Clear(); + + var teacher = fixture.ExperimentGrades(fixture.TeacherScope); + var template = Assert.IsType(await teacher.DownloadTemplate( + sheet.Id, CancellationToken.None)); + Assert.Equal("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + template.ContentType); + await using var templateStream = new MemoryStream(template.FileContents); + await using var stream = new MemoryStream(); + using (var workbook = new XLWorkbook(templateStream)) + { + var worksheet = workbook.Worksheet("实验成绩导入"); + worksheet.Cell("D2").Value = "已完成"; + worksheet.Cell("E2").Value = 85; + workbook.SaveAs(stream); + } + stream.Position = 0; + var file = new FormFile(stream, 0, stream.Length, "file", "实验成绩导入模板.xlsx"); + Assert.IsType(await teacher.ImportRecords( + sheet.Id, file, CancellationToken.None)); + fixture.Db.ChangeTracker.Clear(); + Assert.Equal(85m, await fixture.Db.ExperimentGradeRecords + .Select(record => record.TotalScore) + .SingleAsync()); + } + [Fact] public async Task ManagementList_IsPagedFilteredAndCollegeScoped() { diff --git a/web/src/views/ExperimentGradesView.vue b/web/src/views/ExperimentGradesView.vue index 3cf8b41..7b50193 100644 --- a/web/src/views/ExperimentGradesView.vue +++ b/web/src/views/ExperimentGradesView.vue @@ -2,14 +2,17 @@ import { computed, onMounted, reactive, ref } from 'vue' import { Check, + Download, EditPen, Plus, Promotion, Refresh, Setting, + Upload, } from '@element-plus/icons-vue' import { ElMessage, ElMessageBox } from 'element-plus' import http, { apiErrorMessage } from '../api/http' +import { downloadApiFile, importExcel } from '../api/excel' import { useAuthStore } from '../stores/auth' import { academicTermLabel, @@ -44,6 +47,7 @@ const recordKeyword = ref('') const schemeDialog = ref(false) const editingScheme = ref(false) const schemeProject = ref(null) +const importFileInput = ref() const schemeForm = reactive({ contributionWeight: 1, @@ -290,6 +294,38 @@ async function saveRecords() { } } +function downloadTemplate() { + if (!detail.value) return + downloadApiFile( + `/experiment-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 || !detail.value) return + saving.value = true + try { + const result = await importExcel( + `/experiment-grades/sheets/${detail.value.id}/import`, + file, + ) + ElMessage.success(`导入完成:已更新 ${result.data.updated} 条实验成绩记录`) + await loadDetail() + } catch (error) { + ElMessage.error(apiErrorMessage(error)) + } finally { + input.value = '' + saving.value = false + } +} + async function syncParticipants() { if (!detail.value) return try { @@ -777,12 +813,16 @@ onMounted(async () => { @size-change="recordPage = 1; loadDetail()" /> + +