已按“实验成绩独立管理”完成修改。

每个实验项目独立建成绩单,支持评分项、参与状态、补做次数、安全违规、报告地址和教师评语。
流程为:录入 → 学院审核 → 教务发布;学生只能查看已发布成绩。
自主预约实验支持同步有效预约名单,已评分记录不会被误删。
课程成绩可将已发布实验成绩汇总为快照导入指定分项;导入后锁定,不能手工篡改。
实验项目成绩不完整时,对应课程分项保持空白,不按 0 分处理。
一级“实验管理”菜单下增加“项目与场次”“实验成绩”;学生端增加“实验安排”“实验成绩”。
已生成 MySQL 正式迁移及 SQLite 开发迁移,尚未操作生产数据库。
界面采用“实验项目 → 独立评分 → 审核发布”工作台设计。
This commit is contained in:
2026-07-28 22:21:18 +08:00 Unverified
parent 51c3b81e27
commit 8f56548b54
17 changed files with 9239 additions and 14 deletions
@@ -0,0 +1,945 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/experiment-grades")]
public sealed class ExperimentGradesController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string Managers =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin + "," +
SystemRoles.Teacher;
private const string Reviewers =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin;
private const string Publishers =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin;
[HttpGet("management")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetManagement(
Guid? academicTermId,
ExperimentGradeSheetStatus? status,
CancellationToken cancellationToken)
{
var source = ScopedProjects().AsNoTracking()
.Where(x =>
x.Status == ExperimentProjectStatus.Published ||
x.Status == ExperimentProjectStatus.Closed);
if (academicTermId.HasValue)
source = source.Where(x =>
x.TeachingTask!.AcademicTermId == academicTermId);
if (status.HasValue)
source = source.Where(x =>
x.GradeSheet != null &&
x.GradeSheet.Status == status.Value);
return Ok(await source
.OrderByDescending(x => x.TeachingTask!.AcademicTerm!.StartDate)
.ThenBy(x => x.TeachingTask!.TaskNumber)
.ThenBy(x => x.Code)
.Select(x => new
{
x.Id,
x.Code,
x.Name,
x.ArrangementMode,
ProjectStatus = x.Status,
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name,
AcademicTermId = x.TeachingTask.AcademicTermId,
TermName = x.TeachingTask.AcademicTerm!.Name,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
CollegeName = x.TeachingTask.Course.College!.Name,
TeacherNames = x.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
Sheet = x.GradeSheet == null
? null
: new
{
x.GradeSheet.Id,
x.GradeSheet.Status,
x.GradeSheet.ContributionWeight,
x.GradeSheet.PassScore,
ItemCount = x.GradeSheet.Items.Count,
StudentCount = x.GradeSheet.Records.Count,
CompletedCount = x.GradeSheet.Records.Count(record =>
record.ParticipationStatus !=
ExperimentParticipationStatus.Pending),
ScoredCount = x.GradeSheet.Records.Count(record =>
record.TotalScore != null),
PassedCount = x.GradeSheet.Records.Count(record =>
record.IsPassed == true),
x.GradeSheet.SubmittedAt,
x.GradeSheet.ReviewedAt,
x.GradeSheet.PublishedAt
}
})
.ToListAsync(cancellationToken));
}
[HttpGet("mine")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMine(CancellationToken cancellationToken)
{
var student = await CurrentStudentAsync(cancellationToken);
if (student is null)
return ConflictProblem("当前账号未关联有效学生档案。");
return Ok(await db.ExperimentGradeRecords.AsNoTracking()
.Where(x =>
x.StudentId == student.Id &&
x.ExperimentGradeSheet!.Status ==
ExperimentGradeSheetStatus.Published)
.OrderByDescending(x =>
x.ExperimentGradeSheet!.PublishedAt)
.ThenBy(x =>
x.ExperimentGradeSheet!.ExperimentProject!.Code)
.Select(x => new
{
x.Id,
x.ExperimentGradeSheetId,
ProjectId = x.ExperimentGradeSheet!.ExperimentProjectId,
ProjectCode =
x.ExperimentGradeSheet.ExperimentProject!.Code,
ProjectName =
x.ExperimentGradeSheet.ExperimentProject.Name,
x.ExperimentGradeSheet.ExperimentProject.ArrangementMode,
CourseCode = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask!.Course!.Code,
CourseName = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask.Course.Name,
TermName = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask.AcademicTerm!.Name,
x.ExperimentGradeSheet.PassScore,
x.ParticipationStatus,
x.TotalScore,
x.IsPassed,
x.SafetyViolation,
x.AttemptNumber,
x.SubmissionReference,
x.SubmittedAt,
x.IsLate,
x.TeacherComment,
Items = x.ItemScores
.OrderBy(score =>
score.ExperimentGradeItem!.SortOrder)
.Select(score => new
{
score.ExperimentGradeItemId,
score.ExperimentGradeItem!.Name,
score.ExperimentGradeItem.Kind,
score.ExperimentGradeItem.Weight,
score.Score,
score.Comment
}),
x.ExperimentGradeSheet.PublishedAt
})
.ToListAsync(cancellationToken));
}
[HttpPost("sheets")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CreateSheet(
ExperimentGradeSheetRequest request,
CancellationToken cancellationToken)
{
var items = BuildItems(request.Items);
if (!ExperimentGradeCalculator.AreWeightsValid(
request.ContributionWeight,
request.PassScore,
items))
return ValidationProblem(
"实验评分项至少设置一项、权重合计 100%,项目贡献权重应大于 0。");
var project = await ScopedProjects()
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x =>
x.Id == request.ExperimentProjectId,
cancellationToken);
if (project is null) return NotFound();
if (project.Status is not (
ExperimentProjectStatus.Published or
ExperimentProjectStatus.Closed))
return ConflictProblem("只有已发布或已关闭实验项目可以建立成绩单。");
if (!CanInitialize(project.TeachingTask!)) return Forbid();
if (await db.ExperimentGradeSheets.AnyAsync(x =>
x.ExperimentProjectId == project.Id,
cancellationToken))
return ConflictProblem("该实验项目已经建立成绩单。");
var participants = await LoadParticipantsAsync(
project,
cancellationToken);
if (participants.Count == 0)
return ConflictProblem("实验项目当前没有可评分学生。");
var sheet = new ExperimentGradeSheet
{
ExperimentProjectId = project.Id,
ContributionWeight = request.ContributionWeight,
PassScore = request.PassScore,
Items = items,
Records = participants.Select(participant =>
new ExperimentGradeRecord
{
StudentId = participant.StudentId,
ExperimentSessionId = participant.SessionId,
ItemScores = items.Select(item =>
new ExperimentGradeItemScore
{
ExperimentGradeItemId = item.Id
}).ToList()
}).ToList()
};
db.ExperimentGradeSheets.Add(sheet);
await db.SaveChangesAsync(cancellationToken);
return Created(string.Empty, new { sheet.Id });
}
[HttpGet("sheets/{id:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetSheet(
Guid id,
int recordPage = 1,
int recordPageSize = 50,
string? studentKeyword = null,
CancellationToken cancellationToken = default)
{
recordPage = Math.Max(1, recordPage);
recordPageSize = Math.Clamp(recordPageSize, 10, 100);
studentKeyword = string.IsNullOrWhiteSpace(studentKeyword)
? null
: studentKeyword.Trim();
var sheet = await ScopedSheets().AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Id,
x.ExperimentProjectId,
ProjectCode = x.ExperimentProject!.Code,
ProjectName = x.ExperimentProject.Name,
x.ExperimentProject.ArrangementMode,
x.ExperimentProject.TeachingTaskId,
x.ExperimentProject.TeachingTask!.TaskNumber,
TaskName = x.ExperimentProject.TeachingTask.Name,
AcademicTermId =
x.ExperimentProject.TeachingTask.AcademicTermId,
TermName = x.ExperimentProject.TeachingTask
.AcademicTerm!.Name,
CourseCollegeId = x.ExperimentProject.TeachingTask
.Course!.CollegeId,
CourseCollegeName = x.ExperimentProject.TeachingTask
.Course.College!.Name,
CourseCode =
x.ExperimentProject.TeachingTask.Course.Code,
CourseName =
x.ExperimentProject.TeachingTask.Course.Name,
TeacherNames = x.ExperimentProject.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
x.ContributionWeight,
x.PassScore,
Items = x.Items.OrderBy(item => item.SortOrder)
.Select(item => new
{
item.Id,
item.Name,
item.Kind,
item.Weight
}),
x.Status,
x.ReviewComment,
x.SubmittedAt,
x.ReviewedAt,
x.PublishedAt,
x.CreatedAt,
x.UpdatedAt
})
.FirstOrDefaultAsync(cancellationToken);
if (sheet is null) return NotFound();
var recordsSource = db.ExperimentGradeRecords.AsNoTracking()
.Where(x => x.ExperimentGradeSheetId == id);
if (studentKeyword is not null)
recordsSource = recordsSource.Where(x =>
x.Student!.StudentNumber.Contains(studentKeyword) ||
x.Student.Name.Contains(studentKeyword) ||
x.Student.AdministrativeClass!.Name.Contains(studentKeyword));
var recordTotal = await recordsSource.CountAsync(cancellationToken);
var records = await recordsSource
.OrderBy(x => x.Student!.StudentNumber)
.Skip((recordPage - 1) * recordPageSize)
.Take(recordPageSize)
.Select(x => new
{
x.Id,
x.StudentId,
x.Student!.StudentNumber,
x.Student.Name,
ClassName = x.Student.AdministrativeClass!.Name,
x.ExperimentSessionId,
Session = x.ExperimentSession == null
? null
: new
{
x.ExperimentSession.SessionDate,
x.ExperimentSession.StartPeriod,
x.ExperimentSession.PeriodCount,
ClassroomName =
x.ExperimentSession.Classroom!.Name
},
x.ParticipationStatus,
x.TotalScore,
x.IsPassed,
x.SafetyViolation,
x.AttemptNumber,
x.SubmissionReference,
x.SubmittedAt,
x.IsLate,
x.TeacherComment,
ItemScores = x.ItemScores
.OrderBy(score =>
score.ExperimentGradeItem!.SortOrder)
.Select(score => new
{
score.ExperimentGradeItemId,
score.ExperimentGradeItem!.Name,
score.Score,
score.Comment
}),
x.UpdatedAt
})
.ToListAsync(cancellationToken);
var task = await db.TeachingTasks.AsNoTracking()
.Include(x => x.Teachers)
.ThenInclude(x => x.Teacher)
.SingleAsync(
x => x.Id == sheet.TeachingTaskId,
cancellationToken);
var canReview = CanReviewCollege(sheet.CourseCollegeId) &&
sheet.Status ==
ExperimentGradeSheetStatus.Submitted;
return Ok(new
{
Sheet = new
{
sheet.Id,
sheet.ExperimentProjectId,
sheet.ProjectCode,
sheet.ProjectName,
sheet.ArrangementMode,
sheet.TeachingTaskId,
sheet.TaskNumber,
sheet.TaskName,
sheet.AcademicTermId,
sheet.TermName,
sheet.CourseCollegeName,
sheet.CourseCode,
sheet.CourseName,
sheet.TeacherNames,
sheet.ContributionWeight,
sheet.PassScore,
sheet.Items,
sheet.Status,
sheet.ReviewComment,
sheet.SubmittedAt,
sheet.ReviewedAt,
sheet.PublishedAt,
RecordTotal = recordTotal,
RecordPage = recordPage,
RecordPageSize = recordPageSize,
Records = records,
sheet.CreatedAt,
sheet.UpdatedAt
},
CanEdit = CanEdit(task) &&
sheet.Status is
ExperimentGradeSheetStatus.Draft or
ExperimentGradeSheetStatus.Returned,
CanReview = canReview,
CanPublish = IsPublisher() &&
sheet.Status == ExperimentGradeSheetStatus.Approved
});
}
[HttpPut("sheets/{id:guid}/settings")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> UpdateSettings(
Guid id,
ExperimentGradeSettingsRequest request,
CancellationToken cancellationToken)
{
var replacementItems = BuildItems(request.Items);
if (!ExperimentGradeCalculator.AreWeightsValid(
request.ContributionWeight,
request.PassScore,
replacementItems))
return ValidationProblem(
"实验评分项至少设置一项、权重合计 100%,项目贡献权重应大于 0。");
var sheet = await ScopedSheets()
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.Include(x => x.ExperimentProject)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == 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("当前状态不能修改实验评分方案。");
sheet.ContributionWeight = request.ContributionWeight;
sheet.PassScore = request.PassScore;
db.ExperimentGradeItems.RemoveRange(sheet.Items);
foreach (var record in sheet.Records)
{
record.ItemScores.Clear();
record.TotalScore = null;
record.IsPassed = null;
}
sheet.Items = replacementItems;
foreach (var record in sheet.Records)
foreach (var item in replacementItems)
record.ItemScores.Add(new ExperimentGradeItemScore
{
ExperimentGradeItemId = item.Id
});
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpPut("sheets/{id:guid}/records")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> UpdateRecords(
Guid id,
ExperimentGradeRecordsRequest request,
CancellationToken cancellationToken)
{
var sheet = await ScopedSheets()
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.Include(x => x.ExperimentProject)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == 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 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(x => x.Id).ToHashSet();
foreach (var item in request.Records)
{
if (item.AttemptNumber is < 1 or > 20)
return ValidationProblem("实验尝试次数应在 1—20 次之间。");
if (item.ItemScores.Select(x => x.ExperimentGradeItemId)
.Distinct().Count() != item.ItemScores.Count ||
item.ItemScores.Any(x =>
!itemIds.Contains(x.ExperimentGradeItemId) ||
!ValidScore(x.Score)))
return ValidationProblem("包含无效、重复或超出 0—100 分的评分项。");
var record = records[item.Id];
record.ParticipationStatus = item.ParticipationStatus;
record.SafetyViolation = item.SafetyViolation;
record.AttemptNumber = item.AttemptNumber;
record.SubmissionReference =
Normalize(item.SubmissionReference);
record.SubmittedAt = item.SubmittedAt;
record.IsLate = item.IsLate;
record.TeacherComment = Normalize(item.TeacherComment);
var scores = record.ItemScores.ToDictionary(
x => x.ExperimentGradeItemId);
foreach (var score in item.ItemScores)
{
var target = scores[score.ExperimentGradeItemId];
target.Score = score.Score;
target.Comment = Normalize(score.Comment);
}
Recalculate(sheet, record);
}
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpPost("sheets/{id:guid}/sync-participants")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> SyncParticipants(
Guid id,
CancellationToken cancellationToken)
{
var sheet = await ScopedSheets()
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.Include(x => x.ExperimentProject)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == 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 expected = await LoadParticipantsAsync(
sheet.ExperimentProject,
cancellationToken);
var expectedByStudent = expected.ToDictionary(x => x.StudentId);
var existingByStudent = sheet.Records.ToDictionary(x => x.StudentId);
var added = 0;
var removed = 0;
var retained = 0;
var recordsToRemove = new List<ExperimentGradeRecord>();
var recordsToAdd = new List<ExperimentGradeRecord>();
foreach (var participant in expected)
{
if (existingByStudent.TryGetValue(
participant.StudentId,
out var record))
{
if (record.ParticipationStatus ==
ExperimentParticipationStatus.Pending &&
record.ItemScores.All(x => !x.Score.HasValue))
record.ExperimentSessionId = participant.SessionId;
retained++;
continue;
}
var newRecord = new ExperimentGradeRecord
{
ExperimentGradeSheetId = sheet.Id,
StudentId = participant.StudentId,
ExperimentSessionId = participant.SessionId
};
db.ExperimentGradeRecords.Add(newRecord);
recordsToAdd.Add(newRecord);
added++;
}
foreach (var record in sheet.Records
.Where(x => !expectedByStudent.ContainsKey(x.StudentId))
.ToList())
{
if (record.ParticipationStatus !=
ExperimentParticipationStatus.Pending ||
record.ItemScores.Any(x => x.Score.HasValue))
{
retained++;
continue;
}
db.ExperimentGradeItemScores.RemoveRange(record.ItemScores);
recordsToRemove.Add(record);
removed++;
}
if (recordsToRemove.Count > 0 || recordsToAdd.Count > 0)
{
await db.SaveChangesAsync(cancellationToken);
foreach (var record in recordsToRemove)
sheet.Records.Remove(record);
db.ExperimentGradeRecords.RemoveRange(recordsToRemove);
}
foreach (var record in recordsToAdd)
foreach (var item in sheet.Items)
record.ItemScores.Add(new ExperimentGradeItemScore
{
ExperimentGradeItemId = item.Id
});
await db.SaveChangesAsync(cancellationToken);
return Ok(new
{
Added = added,
Removed = removed,
Retained = retained,
Total = await db.ExperimentGradeRecords.CountAsync(
x => x.ExperimentGradeSheetId == sheet.Id,
cancellationToken)
});
}
[HttpPost("sheets/{id:guid}/submit")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Submit(
Guid id,
CancellationToken cancellationToken)
{
var sheet = await LoadForWorkflowAsync(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("只有草稿或已退回实验成绩单可以提交。");
if (sheet.Records.Count == 0)
return ConflictProblem("实验成绩单没有学生记录。");
foreach (var record in sheet.Records) Recalculate(sheet, record);
if (sheet.Records.Any(x =>
x.ParticipationStatus ==
ExperimentParticipationStatus.Pending))
return ConflictProblem("仍有学生未登记实验参与状态。");
if (sheet.Records.Any(x =>
x.ParticipationStatus is
ExperimentParticipationStatus.Completed or
ExperimentParticipationStatus.Makeup &&
!x.TotalScore.HasValue))
return ConflictProblem("仍有已完成或补做学生缺少必填评分项。");
sheet.Status = ExperimentGradeSheetStatus.Submitted;
sheet.SubmittedAt = DateTime.UtcNow;
sheet.ReviewComment = null;
await db.SaveChangesAsync(cancellationToken);
await NotificationService.SendToRoleAsync(
db,
SystemRoles.CollegeAdmin,
"实验成绩待审核",
$"“{sheet.ExperimentProject!.Name}”实验成绩已提交,请及时审核。",
sheet.ExperimentProject.TeachingTask!.Course!.CollegeId,
"/experiment-grades",
cancellationToken,
NotificationCategory.Grade);
return NoContent();
}
[HttpPost("sheets/{id:guid}/approve")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> Approve(
Guid id,
CancellationToken cancellationToken)
{
var sheet = await LoadForWorkflowAsync(id, cancellationToken);
if (sheet is null) return NotFound();
var collegeId = sheet.ExperimentProject!.TeachingTask!.Course!.CollegeId;
if (!CanReviewCollege(collegeId)) return Forbid();
if (sheet.Status != ExperimentGradeSheetStatus.Submitted)
return ConflictProblem("只有待审核实验成绩单可以通过审核。");
sheet.Status = ExperimentGradeSheetStatus.Approved;
sheet.ReviewedAt = DateTime.UtcNow;
sheet.ReviewComment = null;
await db.SaveChangesAsync(cancellationToken);
await NotifyTeachersAsync(
sheet.ExperimentProject.TeachingTaskId,
"实验成绩审核通过",
$"“{sheet.ExperimentProject.Name}”实验成绩已通过学院审核。",
cancellationToken);
return NoContent();
}
[HttpPost("sheets/{id:guid}/return")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> Return(
Guid id,
ExperimentGradeReviewRequest request,
CancellationToken cancellationToken)
{
var sheet = await LoadForWorkflowAsync(id, cancellationToken);
if (sheet is null) return NotFound();
var collegeId = sheet.ExperimentProject!.TeachingTask!.Course!.CollegeId;
if (!CanReviewCollege(collegeId)) return Forbid();
if (sheet.Status != ExperimentGradeSheetStatus.Submitted)
return ConflictProblem("只有待审核实验成绩单可以退回。");
if (string.IsNullOrWhiteSpace(request.Comment))
return ValidationProblem("退回时必须填写修改意见。");
sheet.Status = ExperimentGradeSheetStatus.Returned;
sheet.ReviewedAt = DateTime.UtcNow;
sheet.ReviewComment = request.Comment.Trim();
await db.SaveChangesAsync(cancellationToken);
await NotifyTeachersAsync(
sheet.ExperimentProject.TeachingTaskId,
"实验成绩被退回",
$"“{sheet.ExperimentProject.Name}”实验成绩被退回:{sheet.ReviewComment}",
cancellationToken);
return NoContent();
}
[HttpPost("sheets/{id:guid}/publish")]
[Authorize(Roles = Publishers)]
public async Task<ActionResult> Publish(
Guid id,
CancellationToken cancellationToken)
{
var sheet = await LoadForWorkflowAsync(id, cancellationToken);
if (sheet is null) return NotFound();
if (!IsPublisher()) return Forbid();
if (sheet.Status != ExperimentGradeSheetStatus.Approved)
return ConflictProblem("只有审核通过的实验成绩单可以发布。");
sheet.Status = ExperimentGradeSheetStatus.Published;
sheet.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
var userIds = await db.ExperimentGradeRecords
.Where(x => x.ExperimentGradeSheetId == sheet.Id)
.Select(x => x.Student!.UserId)
.Where(x => x != null)
.Select(x => x!.Value)
.Distinct()
.ToListAsync(cancellationToken);
await NotificationService.SendToUserIdsAsync(
db,
userIds,
"实验成绩已发布",
$"“{sheet.ExperimentProject!.Name}”实验成绩已发布。",
"/experiment-grades",
cancellationToken,
NotificationCategory.Grade);
return NoContent();
}
private async Task<ExperimentGradeSheet?> LoadForWorkflowAsync(
Guid id,
CancellationToken cancellationToken)
{
if (!await ScopedSheets().AnyAsync(
x => x.Id == id,
cancellationToken))
return null;
return await db.ExperimentGradeSheets
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.Include(x => x.ExperimentProject)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Course)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
}
private async Task<List<ExperimentParticipantSeed>> LoadParticipantsAsync(
ExperimentProject project,
CancellationToken cancellationToken)
{
if (project.ArrangementMode ==
ExperimentArrangementMode.Centralized)
{
return await TeachingTaskRosterQuery
.ForTask(db, project.TeachingTaskId)
.AsNoTracking()
.OrderBy(x => x.StudentNumber)
.Select(x => new ExperimentParticipantSeed(x.Id, null))
.ToListAsync(cancellationToken);
}
return await db.ExperimentBookings.AsNoTracking()
.Where(x =>
x.ExperimentProjectId == project.Id &&
x.Status == ExperimentBookingStatus.Booked &&
x.Student!.Status == StudentStatus.Active)
.OrderBy(x => x.Student!.StudentNumber)
.Select(x => new ExperimentParticipantSeed(
x.StudentId,
x.ExperimentSessionId))
.ToListAsync(cancellationToken);
}
private static List<ExperimentGradeItem> BuildItems(
IReadOnlyCollection<ExperimentGradeItemRequest>? requests) =>
requests?.Select((item, index) => new ExperimentGradeItem
{
Name = item.Name.Trim(),
Kind = item.Kind,
Weight = item.Weight,
SortOrder = index
}).ToList() ?? [];
private static void Recalculate(
ExperimentGradeSheet sheet,
ExperimentGradeRecord record)
{
record.TotalScore = ExperimentGradeCalculator.CalculateTotal(
record,
sheet.Items.ToList());
record.IsPassed = record.TotalScore.HasValue
? record.TotalScore.Value >= sheet.PassScore &&
!record.SafetyViolation
: null;
}
private IQueryable<TeachingTask> AccessibleTeachingTasks()
{
var source = db.TeachingTasks.AsQueryable();
var scope = currentUserDataScope.Current;
if (scope.Scope == DataScope.All) return source;
if (scope.Scope == DataScope.College)
return source.Where(x =>
x.Course!.CollegeId == scope.RestrictedCollegeId);
if (scope.IsInRole(SystemRoles.Teacher))
return source.Where(x => x.Teachers.Any(item =>
item.Teacher!.UserId == scope.UserId));
return source.Where(_ => false);
}
private IQueryable<ExperimentProject> ScopedProjects()
{
var taskIds = AccessibleTeachingTasks().Select(x => x.Id);
return db.ExperimentProjects.Where(x =>
taskIds.Contains(x.TeachingTaskId));
}
private IQueryable<ExperimentGradeSheet> ScopedSheets()
{
var projectIds = ScopedProjects().Select(x => x.Id);
return db.ExperimentGradeSheets.Where(x =>
projectIds.Contains(x.ExperimentProjectId));
}
private bool CanInitialize(TeachingTask task) =>
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.CollegeAdmin) ||
IsAssignedTeacher(task);
private bool CanEdit(TeachingTask task) =>
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
IsAssignedTeacher(task);
private bool IsAssignedTeacher(TeachingTask task) =>
task.Teachers.Any(x =>
x.Teacher?.UserId == currentUserDataScope.Current.UserId);
private bool CanReviewCollege(Guid collegeId)
{
var scope = currentUserDataScope.Current;
if (!scope.IsInRole(SystemRoles.SuperAdmin) &&
!scope.IsInRole(SystemRoles.AcademicAdmin) &&
!scope.IsInRole(SystemRoles.CollegeAdmin))
return false;
return scope.Scope == DataScope.All ||
scope.RestrictedCollegeId == collegeId;
}
private bool IsPublisher() =>
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
private Task<Student?> CurrentStudentAsync(
CancellationToken cancellationToken) =>
db.Students.FirstOrDefaultAsync(x =>
x.UserId == currentUserDataScope.Current.UserId &&
x.Status == StudentStatus.Active,
cancellationToken);
private async Task NotifyTeachersAsync(
Guid teachingTaskId,
string title,
string content,
CancellationToken cancellationToken)
{
var userIds = await db.TeachingTaskTeachers
.Where(x => x.TeachingTaskId == teachingTaskId)
.Select(x => x.Teacher!.UserId)
.Where(x => x != null)
.Select(x => x!.Value)
.Distinct()
.ToListAsync(cancellationToken);
await NotificationService.SendToUserIdsAsync(
db,
userIds,
title,
content,
"/experiment-grades",
cancellationToken,
NotificationCategory.Grade);
}
private static bool ValidScore(decimal? score) =>
!score.HasValue || score.Value is >= 0 and <= 100;
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
Title = "无法完成实验成绩操作",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
private sealed record ExperimentParticipantSeed(
Guid StudentId,
Guid? SessionId);
}
public sealed record ExperimentGradeSheetRequest(
Guid ExperimentProjectId,
[Range(typeof(decimal), "0.1", "100")] decimal ContributionWeight,
[Range(typeof(decimal), "0", "100")] decimal PassScore,
IReadOnlyCollection<ExperimentGradeItemRequest> Items);
public sealed record ExperimentGradeSettingsRequest(
[Range(typeof(decimal), "0.1", "100")] decimal ContributionWeight,
[Range(typeof(decimal), "0", "100")] decimal PassScore,
IReadOnlyCollection<ExperimentGradeItemRequest> Items);
public sealed record ExperimentGradeItemRequest(
[Required, MaxLength(60)] string Name,
ExperimentGradeItemKind Kind,
[Range(typeof(decimal), "0.1", "100")] decimal Weight);
public sealed record ExperimentGradeRecordsRequest(
IReadOnlyCollection<ExperimentGradeRecordRequest> Records);
public sealed record ExperimentGradeRecordRequest(
Guid Id,
ExperimentParticipationStatus ParticipationStatus,
bool SafetyViolation,
[Range(1, 20)] int AttemptNumber,
[MaxLength(500)] string? SubmissionReference,
DateTime? SubmittedAt,
bool IsLate,
[MaxLength(500)] string? TeacherComment,
IReadOnlyCollection<ExperimentGradeItemScoreRequest> ItemScores);
public sealed record ExperimentGradeItemScoreRequest(
Guid ExperimentGradeItemId,
decimal? Score,
[MaxLength(300)] string? Comment);
public sealed record ExperimentGradeReviewRequest(
[MaxLength(500)] string? Comment);
+82 -5
View File
@@ -101,7 +101,9 @@ public sealed class GradesController(
{
item.Id,
item.Name,
item.Weight
item.Weight,
item.SourceType,
item.SourceSnapshotAt
}),
StudentCount = sheet.Records.Count,
CompletedCount = sheet.Records.Count(record =>
@@ -220,7 +222,9 @@ public sealed class GradesController(
{
item.Id,
item.Name,
item.Weight
item.Weight,
item.SourceType,
item.SourceSnapshotAt
}),
x.Status,
x.ReviewComment,
@@ -408,7 +412,7 @@ public sealed class GradesController(
request.Records.Any(x => !records.ContainsKey(x.Id)))
return ValidationProblem("包含无效或重复的成绩记录。");
var itemIds = sheet.Items.Select(item => item.Id).ToHashSet();
var itemsById = sheet.Items.ToDictionary(item => item.Id);
foreach (var item in request.Records)
{
if (!ValidScore(item.RegularScore) ||
@@ -428,8 +432,19 @@ public sealed class GradesController(
{
if (!ValidScore(scoreEntry.Score))
return ValidationProblem("分项成绩必须在 0—100 分之间。");
if (scoreMap.TryGetValue(scoreEntry.GradeItemId, out var existing))
existing.Score = scoreEntry.Score;
if (!itemsById.TryGetValue(
scoreEntry.GradeItemId,
out var gradeItem) ||
!scoreMap.TryGetValue(
scoreEntry.GradeItemId,
out var existing))
continue;
if (gradeItem.SourceType ==
GradeItemSourceType.ExperimentSummary &&
existing.Score != scoreEntry.Score)
return ConflictProblem(
$"“{gradeItem.Name}”来自实验成绩快照,不能手工修改;请重新导入实验成绩。");
existing.Score = scoreEntry.Score;
}
}
@@ -438,6 +453,66 @@ public sealed class GradesController(
return await SaveAsync(id, false, cancellationToken);
}
[HttpPost("sheets/{id:guid}/import-experiment-scores")]
[Authorize(Roles = SheetUsers)]
public async Task<ActionResult> ImportExperimentScores(
Guid id,
ImportExperimentScoresRequest request,
CancellationToken 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 targetItem = sheet.Items.FirstOrDefault(x =>
x.Id == request.GradeItemId);
if (targetItem is null)
return ValidationProblem("请选择当前成绩单中的有效分项。");
var aggregate = await ExperimentGradeAggregationService.CalculateAsync(
db,
sheet.TeachingTaskId,
sheet.Records.Select(x => x.StudentId).ToArray(),
cancellationToken);
if (aggregate.PublishedProjectCount == 0)
return ConflictProblem(
"该教学任务尚无已发布的实验项目成绩,无法导入。");
var imported = 0;
var missing = 0;
foreach (var record in sheet.Records)
{
var targetScore = record.ItemScores.First(x =>
x.GradeItemId == targetItem.Id);
targetScore.Score = aggregate.Scores[record.StudentId];
if (targetScore.Score.HasValue) imported++;
else missing++;
Recalculate(sheet, record);
}
targetItem.SourceType = GradeItemSourceType.ExperimentSummary;
targetItem.SourceSnapshotAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
return Ok(new
{
aggregate.PublishedProjectCount,
aggregate.Projects,
ImportedCount = imported,
MissingCount = missing,
targetItem.SourceSnapshotAt
});
}
[HttpPost("sheets/{id:guid}/submit")]
[Authorize(Roles = SheetUsers)]
public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken)
@@ -998,5 +1073,7 @@ public sealed record GradeItemScoreRequest(
Guid GradeItemId,
decimal? Score);
public sealed record ImportExperimentScoresRequest(Guid GradeItemId);
public sealed record GradeReviewRequest(
[MaxLength(500)] string? Comment);