支持为同一学期、同一课程的多个教学任务批量创建相同实验项目。

新增批量排课表,可为多个实验项目分别设置日期、节次、实验室和容量。
批量排课采用原子事务:任一项目发生教室、教师、班级或课表冲突,整批不写入。
已发布项目批量新增场次时,继续按原逻辑通知学生。
后端继续执行管理范围、教学任务状态、开放日期和容量校验。
页面在 390px 手机宽度下无横向溢出。
This commit is contained in:
2026-08-01 16:08:03 +08:00 Unverified
parent 551f1143b7
commit 9d525826ce
3 changed files with 560 additions and 15 deletions
@@ -75,6 +75,7 @@ public sealed class ExperimentsController(
x.TaskNumber,
x.Name,
x.AcademicTermId,
x.CourseId,
TermName = x.AcademicTerm!.Name,
TermStartDate = x.AcademicTerm.StartDate,
TermEndDate = x.AcademicTerm.EndDate,
@@ -295,6 +296,78 @@ public sealed class ExperimentsController(
return Created(string.Empty, new { project.Id });
}
[HttpPost("batch")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CreateProjects(
ExperimentProjectBatchRequest request,
CancellationToken cancellationToken)
{
var taskIds = request.TeachingTaskIds
.Where(x => x != Guid.Empty)
.Distinct()
.ToList();
if (taskIds.Count == 0)
return ValidationProblem("请至少选择一个教学任务。");
if (taskIds.Count > 100)
return ValidationProblem("单次最多为 100 个教学任务创建实验项目。");
var tasks = await AccessibleTeachingTasks().AsNoTracking()
.Include(x => x.AcademicTerm)
.Where(x =>
taskIds.Contains(x.Id) &&
x.Status == TeachingTaskStatus.Published)
.OrderBy(x => x.TaskNumber)
.ToListAsync(cancellationToken);
if (tasks.Count != taskIds.Count)
return ValidationProblem("部分教学任务不存在、未发布或不在当前管理范围内。");
var first = tasks[0];
if (tasks.Any(x =>
x.AcademicTermId != first.AcademicTermId ||
x.CourseId != first.CourseId))
return ValidationProblem("批量设置仅支持同一学期、同一课程的教学任务。");
var code = request.Code.Trim();
var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking()
.Where(x =>
taskIds.Contains(x.TeachingTaskId) &&
x.Code == code)
.Select(x => x.TeachingTask!.TaskNumber)
.OrderBy(x => x)
.ToListAsync(cancellationToken);
if (conflictingTaskNumbers.Count > 0)
return ConflictProblem(
$"以下教学任务已存在实验项目编码 {code}{string.Join("", conflictingTaskNumbers)}。");
var projects = new List<ExperimentProject>(tasks.Count);
foreach (var task in tasks)
{
var item = request.ForTeachingTask(task.Id);
var problem = ValidateProjectRequest(item, task.AcademicTerm!);
if (problem is not null) return ValidationProblem(problem);
projects.Add(new ExperimentProject
{
TeachingTaskId = task.Id,
Code = code,
Name = request.Name.Trim(),
ArrangementMode = request.ArrangementMode,
Description = Normalize(request.Description),
Requirements = Normalize(request.Requirements),
StartDate = request.StartDate,
EndDate = request.EndDate
});
}
db.ExperimentProjects.AddRange(projects);
await db.SaveChangesAsync(cancellationToken);
return Created(string.Empty, new
{
Count = projects.Count,
ProjectIds = projects.Select(x => x.Id)
});
}
[HttpPut("{id:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> UpdateProject(
@@ -478,6 +551,101 @@ public sealed class ExperimentsController(
return Created(string.Empty, new { session.Id });
}
[HttpPost("sessions/batch")]
[Authorize(Roles = Managers)]
public Task<ActionResult> CreateSessions(
ExperimentSessionBatchRequest request,
CancellationToken cancellationToken)
{
if (request.Items.Count == 0)
return Task.FromResult<ActionResult>(
ValidationProblem("请至少添加一条实验排课。"));
if (request.Items.Count > 100)
return Task.FromResult<ActionResult>(
ValidationProblem("单次最多安排 100 条实验场次。"));
if (request.Items.Any(x => x.ProjectId == Guid.Empty) ||
request.Items.Select(x => x.ProjectId).Distinct().Count() !=
request.Items.Count)
return Task.FromResult<ActionResult>(
ValidationProblem("同一批次中每个实验项目只能安排一个场次。"));
return db.ExecuteInRetriableTransactionAsync<ActionResult>(
async transaction =>
{
var projectIds = request.Items.Select(x => x.ProjectId).ToList();
var projects = await ScopedProjects()
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.AcademicTerm)
.Where(x => projectIds.Contains(x.Id))
.ToDictionaryAsync(x => x.Id, cancellationToken);
if (projects.Count != projectIds.Count)
return ValidationProblem(
"部分实验项目不存在或不在当前管理范围内。");
var createdSessions = new List<(ExperimentProject Project, ExperimentSession Session)>(
request.Items.Count);
foreach (var item in request.Items)
{
var project = projects[item.ProjectId];
if (project.Status == ExperimentProjectStatus.Closed)
return ConflictProblem(
$"实验项目“{project.Name}”已关闭,不能再增加场次。");
var sessionRequest = item.ToSessionRequest();
var problem = await ValidateSessionAsync(
project,
sessionRequest,
cancellationToken);
if (problem is not null)
return ConflictProblem(
$"实验项目“{project.Name}”:{problem}");
var session = new ExperimentSession
{
ExperimentProjectId = project.Id,
ClassroomId = item.ClassroomId,
SessionDate = item.SessionDate,
StartPeriod = item.StartPeriod,
PeriodCount = item.PeriodCount,
Capacity = await ResolveSessionCapacityAsync(
project,
item.Capacity,
cancellationToken),
Notes = Normalize(item.Notes)
};
db.ExperimentSessions.Add(session);
await db.SaveChangesAsync(cancellationToken);
createdSessions.Add((project, session));
}
foreach (var (project, session) in createdSessions.Where(x =>
x.Project.Status == ExperimentProjectStatus.Published))
{
var userIds = await RosterUserIdsAsync(
project.TeachingTaskId,
cancellationToken);
if (userIds.Count == 0) continue;
await NotificationService.SendToUserIdsAsync(
db,
userIds,
"新增实验场次",
$"“{project.Name}”新增 {session.SessionDate:yyyy-MM-dd} 第 {session.StartPeriod}—{session.StartPeriod + session.PeriodCount - 1} 节场次,请查看实验安排。",
"/experiments",
cancellationToken,
NotificationCategory.Schedule);
}
await transaction.CommitAsync(cancellationToken);
return Created(string.Empty, new
{
Count = createdSessions.Count,
SessionIds = createdSessions.Select(x => x.Session.Id)
});
},
cancellationToken,
IsolationLevel.Serializable);
}
[HttpDelete("sessions/{id:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CancelSession(
@@ -1029,6 +1197,28 @@ public sealed record ExperimentProjectRequest(
DateOnly StartDate,
DateOnly EndDate);
public sealed record ExperimentProjectBatchRequest(
[Required] IReadOnlyList<Guid> TeachingTaskIds,
[Required, MaxLength(40)] string Code,
[Required, MaxLength(120)] string Name,
ExperimentArrangementMode ArrangementMode,
[MaxLength(1000)] string? Description,
[MaxLength(1000)] string? Requirements,
DateOnly StartDate,
DateOnly EndDate)
{
public ExperimentProjectRequest ForTeachingTask(Guid teachingTaskId) =>
new(
teachingTaskId,
Code,
Name,
ArrangementMode,
Description,
Requirements,
StartDate,
EndDate);
}
public sealed record ExperimentSessionRequest(
Guid ClassroomId,
DateOnly SessionDate,
@@ -1037,6 +1227,28 @@ public sealed record ExperimentSessionRequest(
[Range(1, 10000)] int Capacity,
[MaxLength(500)] string? Notes);
public sealed record ExperimentSessionBatchRequest(
[Required] IReadOnlyList<ExperimentSessionBatchItem> Items);
public sealed record ExperimentSessionBatchItem(
Guid ProjectId,
Guid ClassroomId,
DateOnly SessionDate,
[Range(1, 30)] int StartPeriod,
[Range(1, 30)] int PeriodCount,
[Range(1, 10000)] int Capacity,
[MaxLength(500)] string? Notes)
{
public ExperimentSessionRequest ToSessionRequest() =>
new(
ClassroomId,
SessionDate,
StartPeriod,
PeriodCount,
Capacity,
Notes);
}
public sealed record ExperimentPeriodOption(
Guid AcademicTermId,
int PeriodNumber,