实验成绩 Excel 流程

This commit is contained in:
2026-08-10 16:52:03 +08:00 Unverified
parent 720ff6ef1d
commit 031459ac80
3 changed files with 357 additions and 0 deletions
@@ -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<IActionResult> 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<object?>
{
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<object?>)values;
})
.ToList();
var itemCount = sheet.Items.Count;
var totalColumn = 7 + itemCount;
var scoreColumns = Enumerable.Range(5, itemCount).Append(totalColumn);
var instructions = new List<string>
{
"请勿修改第一行列名;学号、姓名、班级列请勿修改,用于匹配本实验成绩单的学生。",
"参与状态填写:待登记、已完成、缺席、请假、补做 或 免做。",
"评分项填写 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<ActionResult> 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<ExcelRow> 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<string>();
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<decimal?>();
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<ActionResult> SyncParticipants(
@@ -834,6 +996,22 @@ public sealed class ExperimentGradesController(
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
}
private async Task<ExperimentGradeSheet?> 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<List<ExperimentParticipantSeed>> 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<string> ExperimentImportHeaders(
IEnumerable<ExperimentGradeItem> items)
{
var headers = new List<string> { "学号", "姓名", "班级", "参与状态" };
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<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 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 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();