Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/ExperimentGradesController.cs
T

1422 lines
59 KiB
C#

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.Files;
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 + "," +
SystemRoles.CollegeAdmin;
[HttpGet("management")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetManagement(
Guid? academicTermId,
ExperimentGradeSheetStatus? status,
Guid? collegeId = null,
string? keyword = null,
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default,
Guid? sheetId = null)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 10, 50);
keyword = Normalize(keyword);
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);
if (sheetId.HasValue)
source = source.Where(x =>
x.GradeSheet != null && x.GradeSheet.Id == sheetId.Value);
if (collegeId.HasValue)
source = source.Where(x =>
x.TeachingTask!.Course!.CollegeId == collegeId.Value);
if (keyword is not null)
source = source.Where(x =>
x.Code.Contains(keyword) ||
x.Name.Contains(keyword) ||
x.TeachingTask!.TaskNumber.Contains(keyword) ||
x.TeachingTask.Name.Contains(keyword) ||
x.TeachingTask.Course!.Code.Contains(keyword) ||
x.TeachingTask.Course.Name.Contains(keyword) ||
x.TeachingTask.Teachers.Any(item =>
item.Teacher!.TeacherNumber.Contains(keyword) ||
item.Teacher.Name.Contains(keyword)));
var total = await source.Select(x => x.TeachingTaskId)
.Distinct()
.CountAsync(cancellationToken);
var taskIds = await source
.GroupBy(x => new
{
x.TeachingTaskId,
StartDate = x.TeachingTask!.AcademicTerm!.StartDate,
x.TeachingTask.TaskNumber
})
.OrderByDescending(x => x.Key.StartDate)
.ThenBy(x => x.Key.TaskNumber)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => x.Key.TeachingTaskId)
.ToListAsync(cancellationToken);
var projects = await source
.Where(x => taskIds.Contains(x.TeachingTaskId))
.OrderBy(x => x.ScheduleWeek ?? int.MaxValue)
.ThenBy(x => x.ScheduleEntry == null ? int.MaxValue : x.ScheduleEntry.DayOfWeek)
.ThenBy(x => x.ScheduleEntry == null ? int.MaxValue : x.ScheduleEntry.StartPeriod)
.ThenBy(x => x.StartDate)
.ThenBy(x => x.Code)
.Select(x => new
{
x.Id,
x.Code,
x.Name,
x.ArrangementMode,
x.ScheduleWeek,
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),
ScheduleEntry = x.ScheduleEntry == null
? null
: new
{
x.ScheduleEntry.DayOfWeek,
x.ScheduleEntry.StartPeriod,
x.ScheduleEntry.PeriodCount
},
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);
var order = taskIds.Select((id, index) => new { id, index })
.ToDictionary(x => x.id, x => x.index);
var items = projects.GroupBy(x => x.TeachingTaskId)
.OrderBy(group => order[group.Key])
.Select(group =>
{
var first = group.First();
return new
{
TeachingTaskId = group.Key,
first.TaskNumber,
first.TaskName,
first.AcademicTermId,
first.TermName,
first.CourseCode,
first.CourseName,
first.CollegeName,
first.TeacherNames,
ProjectCount = group.Count(),
SheetCount = group.Count(project => project.Sheet != null),
ScoredCount = group.Sum(project => project.Sheet?.ScoredCount ?? 0),
StudentCount = group.Sum(project => project.Sheet?.StudentCount ?? 0),
Projects = group.ToList()
};
})
.ToList();
return Ok(new
{
Items = items,
Total = total,
Page = page,
PageSize = pageSize
});
}
[HttpGet("mine")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMine(CancellationToken cancellationToken)
{
var student = await CurrentStudentAsync(cancellationToken);
if (student is null)
return ConflictProblem("当前账号未关联有效学生档案。");
var results = 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,
TeachingTaskId = x.ExperimentGradeSheet.ExperimentProject
.TeachingTaskId,
AcademicTermId = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask!.AcademicTermId,
TaskNumber = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask.TaskNumber,
CourseCode = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask!.Course!.Code,
CourseName = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask.Course.Name,
TermName = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask.AcademicTerm!.Name,
x.ExperimentGradeSheet.ContributionWeight,
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);
var taskIds = results.Select(x => x.TeachingTaskId).Distinct().ToArray();
var aggregates = await db.ExperimentCourseGrades.AsNoTracking()
.Where(x => x.StudentId == student.Id)
.WhereIn(taskIds, x => x.TeachingTaskId)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
var courses = results
.GroupBy(x => x.TeachingTaskId)
.Select(group =>
{
var first = group.First();
aggregates.TryGetValue(group.Key, out var aggregate);
return new
{
TeachingTaskId = group.Key,
first.AcademicTermId,
first.TermName,
first.TaskNumber,
first.CourseCode,
first.CourseName,
ExperimentCourseScore = aggregate?.WeightedAverageScore,
PublishedProjectCount =
aggregate?.PublishedProjectCount ?? group.Count(),
TotalWeight = aggregate?.TotalWeight ??
group.Sum(x => x.ContributionWeight),
RefreshedAt = aggregate?.RefreshedAt,
Projects = group.OrderBy(x => x.ProjectCode).ToList()
};
})
.OrderByDescending(x => x.Projects.Max(project => project.PublishedAt))
.ThenBy(x => x.CourseCode)
.ToList();
return Ok(courses);
}
[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.ScheduleWeek,
ScheduleEntry = x.ExperimentProject.ScheduleEntry == null
? null
: new
{
x.ExperimentProject.ScheduleEntry.DayOfWeek,
x.ExperimentProject.ScheduleEntry.StartPeriod,
x.ExperimentProject.ScheduleEntry.PeriodCount
},
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);
var scope = currentUserDataScope.Current;
var isBoundInstructor = scope.Scope == DataScope.Self &&
scope.IsInRole(SystemRoles.Teacher) &&
sheet.ArrangementMode == ExperimentArrangementMode.SelfScheduled;
if (isBoundInstructor)
recordsSource = recordsSource.Where(x =>
x.ExperimentSession != null && x.ExperimentSession.Instructors.Any(
instructor => instructor.Teacher!.UserId == scope.UserId));
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) || isBoundInstructor) &&
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;
var existingItems = sheet.Items.ToList();
var existingItemScores = sheet.Records
.SelectMany(record => record.ItemScores)
.ToList();
// MySQL must see each replacement item before its score rows. Do not
// rely on a single mixed insert/delete batch to infer that order.
await using var transaction =
await db.Database.BeginTransactionAsync(cancellationToken);
db.ExperimentGradeItemScores.RemoveRange(existingItemScores);
db.ExperimentGradeItems.RemoveRange(existingItems);
foreach (var record in sheet.Records)
{
record.TotalScore = null;
record.IsPassed = null;
}
await db.SaveChangesAsync(cancellationToken);
foreach (var item in replacementItems)
item.ExperimentGradeSheetId = sheet.Id;
db.ExperimentGradeItems.AddRange(replacementItems);
await db.SaveChangesAsync(cancellationToken);
db.ExperimentGradeItemScores.AddRange(
sheet.Records.SelectMany(record => replacementItems.Select(item =>
new ExperimentGradeItemScore
{
ExperimentGradeRecordId = record.Id,
ExperimentGradeItemId = item.Id
})));
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(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.Records)
.ThenInclude(x => x.ExperimentSession)
.ThenInclude(x => x!.Instructors)
.ThenInclude(x => x.Teacher)
.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!) &&
!CanEditBoundSessionRecords(sheet, request.Records)) 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();
}
[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,
DownloadFileName.Template("实验成绩", ".xlsx", sheet.ExperimentProject!.Code));
}
[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(
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?sheetId={sheet.Id}",
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.ExecuteInRetriableTransactionAsync(async transaction =>
{
await db.SaveChangesAsync(cancellationToken);
await ExperimentGradeAggregationService.RefreshTeachingTaskAsync(
db,
sheet.ExperimentProject!.TeachingTaskId,
cancellationToken);
await transaction.CommitAsync(cancellationToken);
}, cancellationToken, IsolationLevel.Serializable);
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)
.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<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)
{
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)
{
if (!scope.CollegeId.HasValue) return source.Where(_ => false);
var collegeId = scope.CollegeId.Value;
return source.Where(x =>
x.Course!.CollegeId == collegeId ||
x.Teachers.Any(item => item.Teacher!.CollegeId == collegeId));
}
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 scope = currentUserDataScope.Current;
if (scope.Scope == DataScope.Self && scope.IsInRole(SystemRoles.Teacher))
return db.ExperimentProjects.Where(x =>
(x.ArrangementMode == ExperimentArrangementMode.Centralized &&
x.TeachingTask!.Teachers.Any(item =>
item.Teacher!.UserId == scope.UserId)) ||
(x.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
x.Sessions.Any(session => session.Instructors.Any(instructor =>
instructor.Teacher!.UserId == scope.UserId))));
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 CanEditBoundSessionRecords(
ExperimentGradeSheet sheet,
IReadOnlyCollection<ExperimentGradeRecordRequest> requestedRecords)
{
var scope = currentUserDataScope.Current;
if (sheet.ExperimentProject!.ArrangementMode != ExperimentArrangementMode.SelfScheduled ||
scope.Scope != DataScope.Self || !scope.IsInRole(SystemRoles.Teacher) ||
requestedRecords.Count == 0)
return false;
var requestedIds = requestedRecords.Select(x => x.Id).ToHashSet();
return requestedIds.All(recordId => sheet.Records.Any(record =>
record.Id == recordId &&
record.ExperimentSession?.Instructors.Any(instructor =>
instructor.Teacher?.UserId == scope.UserId) == true));
}
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) ||
currentUserDataScope.Current.IsInRole(SystemRoles.CollegeAdmin);
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 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();
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);