支持为同一学期、同一课程的多个教学任务批量创建相同实验项目。
新增批量排课表,可为多个实验项目分别设置日期、节次、实验室和容量。 批量排课采用原子事务:任一项目发生教室、教师、班级或课表冲突,整批不写入。 已发布项目批量新增场次时,继续按原逻辑通知学生。 后端继续执行管理范围、教学任务状态、开放日期和容量校验。 页面在 390px 手机宽度下无横向溢出。
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user