成绩+点名

This commit is contained in:
2026-07-25 14:08:50 +08:00 Unverified
parent 7800951aee
commit 36049af692
16 changed files with 1809 additions and 98 deletions
+129 -35
View File
@@ -71,8 +71,13 @@ public sealed class GradesController(
sheet.Id,
sheet.Status,
sheet.RegularWeight,
sheet.MidtermWeight,
sheet.FinalWeight,
Items = sheet.Items.OrderBy(item => item.SortOrder).Select(item => new
{
item.Id,
item.Name,
item.Weight
}),
StudentCount = sheet.Records.Count,
CompletedCount = sheet.Records.Count(record =>
record.TotalScore != null ||
@@ -99,11 +104,20 @@ public sealed class GradesController(
GradeSheetRequest request,
CancellationToken cancellationToken)
{
var items = request.Items?
.Select((item, index) => new GradeItem
{
Name = item.Name.Trim(),
Weight = item.Weight,
SortOrder = index
})
.ToList() ?? [];
if (!GradeCalculator.AreWeightsValid(
request.RegularWeight,
request.MidtermWeight,
request.FinalWeight))
return ValidationProblem("平时、期中和期末成绩比例必须合计 100%。");
request.FinalWeight,
items))
return ValidationProblem("平时、期末与所有分项的比例必须合计 100%。");
var task = await AccessibleTasks()
.Include(x => x.Teachers)
@@ -131,11 +145,15 @@ public sealed class GradesController(
{
TeachingTaskId = task.Id,
RegularWeight = request.RegularWeight,
MidtermWeight = request.MidtermWeight,
FinalWeight = request.FinalWeight,
Items = items,
Records = studentIds.Select(studentId => new GradeRecord
{
StudentId = studentId
StudentId = studentId,
ItemScores = items.Select(item => new GradeItemScore
{
GradeItemId = item.Id
}).ToList()
}).ToList()
};
db.GradeSheets.Add(sheet);
@@ -166,8 +184,13 @@ public sealed class GradesController(
ClassNames = x.TeachingTask.Classes
.Select(item => item.AdministrativeClass!.Name),
x.RegularWeight,
x.MidtermWeight,
x.FinalWeight,
Items = x.Items.OrderBy(item => item.SortOrder).Select(item => new
{
item.Id,
item.Name,
item.Weight
}),
x.Status,
x.ReviewComment,
x.SubmittedAt,
@@ -183,8 +206,15 @@ public sealed class GradesController(
record.Student.Name,
ClassName = record.Student.AdministrativeClass!.Name,
record.RegularScore,
record.MidtermScore,
record.FinalScore,
ItemScores = record.ItemScores
.OrderBy(itemScore => itemScore.GradeItem!.SortOrder)
.Select(itemScore => new
{
itemScore.GradeItemId,
itemScore.GradeItem!.Name,
itemScore.Score
}),
record.TotalScore,
record.GradePoint,
record.ExamStatus,
@@ -218,20 +248,58 @@ public sealed class GradesController(
GradeWeightsRequest request,
CancellationToken cancellationToken)
{
var items = request.Items?
.Select((item, index) => new GradeItem
{
Name = item.Name.Trim(),
Weight = item.Weight,
SortOrder = index
})
.ToList() ?? [];
if (!GradeCalculator.AreWeightsValid(
request.RegularWeight,
request.MidtermWeight,
request.FinalWeight))
return ValidationProblem("平时、期中和期末成绩比例必须合计 100%。");
var sheet = await EditableSheetAsync(id, cancellationToken);
request.FinalWeight,
items))
return ValidationProblem("平时、期末与所有分项的比例必须合计 100%。");
var sheet = await AccessibleSheets()
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.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("当前状态不能修改成绩构成。");
sheet.RegularWeight = request.RegularWeight;
sheet.MidtermWeight = request.MidtermWeight;
sheet.FinalWeight = request.FinalWeight;
foreach (var record in sheet.Records) Recalculate(sheet, record);
db.GradeItems.RemoveRange(sheet.Items);
var itemIdsToRemove = sheet.Items.Select(item => item.Id).ToHashSet();
foreach (var record in sheet.Records)
{
var toRemove = record.ItemScores
.Where(itemScore => itemIdsToRemove.Contains(itemScore.GradeItemId))
.ToList();
foreach (var removed in toRemove)
record.ItemScores.Remove(removed);
}
sheet.Items = items;
foreach (var record in sheet.Records)
{
foreach (var item in items)
{
record.ItemScores.Add(new GradeItemScore { GradeItemId = item.Id });
}
Recalculate(sheet, record);
}
return await SaveAsync(id, false, cancellationToken);
}
@@ -242,28 +310,49 @@ public sealed class GradesController(
GradeRecordsRequest request,
CancellationToken cancellationToken)
{
var sheet = await EditableSheetAsync(id, cancellationToken);
var sheet = await AccessibleSheets()
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.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 records = sheet.Records.ToDictionary(x => x.Id);
if (request.Records.Select(x => x.Id).Distinct().Count() != request.Records.Count ||
request.Records.Any(x => !records.ContainsKey(x.Id)))
return ValidationProblem("包含无效或重复的成绩记录。");
var itemIds = sheet.Items.Select(item => item.Id).ToHashSet();
foreach (var item in request.Records)
{
if (!ValidScore(item.RegularScore) ||
!ValidScore(item.MidtermScore) ||
!ValidScore(item.FinalScore))
return ValidationProblem("成绩必须在 0—100 分之间。");
var record = records[item.Id];
record.RegularScore = item.RegularScore;
record.MidtermScore = item.MidtermScore;
record.FinalScore = item.FinalScore;
record.ExamStatus = item.ExamStatus;
record.Notes = Normalize(item.Notes);
if (item.ItemScores is not null)
{
var scoreMap = record.ItemScores.ToDictionary(s => s.GradeItemId);
foreach (var scoreEntry in item.ItemScores)
{
if (!ValidScore(scoreEntry.Score))
return ValidationProblem("分项成绩必须在 0—100 分之间。");
if (scoreMap.TryGetValue(scoreEntry.GradeItemId, out var existing))
existing.Score = scoreEntry.Score;
}
}
Recalculate(sheet, record);
}
return await SaveAsync(id, false, cancellationToken);
@@ -273,7 +362,14 @@ public sealed class GradesController(
[Authorize(Roles = SheetUsers)]
public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken)
{
var sheet = await EditableSheetAsync(id, cancellationToken);
var sheet = await AccessibleSheets()
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.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))
@@ -412,16 +508,6 @@ public sealed class GradesController(
db.GradeSheets.Where(x => AccessibleTasks().Any(task =>
task.Id == x.TeachingTaskId));
private async Task<GradeSheet?> EditableSheetAsync(
Guid id,
CancellationToken cancellationToken) =>
await AccessibleSheets()
.Include(x => x.Records)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
private bool CanInitialize(TeachingTask task) =>
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) ||
@@ -449,11 +535,11 @@ public sealed class GradesController(
{
record.TotalScore = GradeCalculator.CalculateTotal(
record.RegularScore,
record.MidtermScore,
record.FinalScore,
record.ItemScores.ToList(),
sheet.RegularWeight,
sheet.MidtermWeight,
sheet.FinalWeight,
sheet.Items.ToList(),
record.ExamStatus);
record.GradePoint = GradeCalculator.CalculateGradePoint(record.TotalScore);
}
@@ -492,13 +578,17 @@ public sealed class GradesController(
public sealed record GradeSheetRequest(
Guid TeachingTaskId,
[Range(typeof(decimal), "0", "100")] decimal RegularWeight,
[Range(typeof(decimal), "0", "100")] decimal MidtermWeight,
[Range(typeof(decimal), "0", "100")] decimal FinalWeight);
[Range(typeof(decimal), "0", "100")] decimal FinalWeight,
IReadOnlyCollection<GradeItemRequest>? Items);
public sealed record GradeItemRequest(
[MaxLength(60)] string Name,
[Range(typeof(decimal), "0", "100")] decimal Weight);
public sealed record GradeWeightsRequest(
[Range(typeof(decimal), "0", "100")] decimal RegularWeight,
[Range(typeof(decimal), "0", "100")] decimal MidtermWeight,
[Range(typeof(decimal), "0", "100")] decimal FinalWeight);
[Range(typeof(decimal), "0", "100")] decimal FinalWeight,
IReadOnlyCollection<GradeItemRequest>? Items);
public sealed record GradeRecordsRequest(
IReadOnlyCollection<GradeRecordRequest> Records);
@@ -506,10 +596,14 @@ public sealed record GradeRecordsRequest(
public sealed record GradeRecordRequest(
Guid Id,
decimal? RegularScore,
decimal? MidtermScore,
decimal? FinalScore,
IReadOnlyCollection<GradeItemScoreRequest>? ItemScores,
GradeExamStatus ExamStatus,
[MaxLength(300)] string? Notes);
public sealed record GradeItemScoreRequest(
Guid GradeItemId,
decimal? Score);
public sealed record GradeReviewRequest(
[MaxLength(500)] string? Comment);