实验成绩 Excel 流程
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user