diff --git a/src/Jiaowu.Api/Controllers/ExperimentsController.cs b/src/Jiaowu.Api/Controllers/ExperimentsController.cs index 6656bfe..35a3c57 100644 --- a/src/Jiaowu.Api/Controllers/ExperimentsController.cs +++ b/src/Jiaowu.Api/Controllers/ExperimentsController.cs @@ -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 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(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 UpdateProject( @@ -478,6 +551,101 @@ public sealed class ExperimentsController( return Created(string.Empty, new { session.Id }); } + [HttpPost("sessions/batch")] + [Authorize(Roles = Managers)] + public Task CreateSessions( + ExperimentSessionBatchRequest request, + CancellationToken cancellationToken) + { + if (request.Items.Count == 0) + return Task.FromResult( + ValidationProblem("请至少添加一条实验排课。")); + if (request.Items.Count > 100) + return Task.FromResult( + 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( + ValidationProblem("同一批次中每个实验项目只能安排一个场次。")); + + return db.ExecuteInRetriableTransactionAsync( + 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 CancelSession( @@ -1029,6 +1197,28 @@ public sealed record ExperimentProjectRequest( DateOnly StartDate, DateOnly EndDate); +public sealed record ExperimentProjectBatchRequest( + [Required] IReadOnlyList 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 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, diff --git a/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs b/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs index ae68cdb..bce7aea 100644 --- a/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs @@ -12,6 +12,112 @@ namespace Jiaowu.Api.Tests; public sealed class ExperimentsControllerTests { + [Fact] + public async Task BatchProjects_CreateSameDefinitionForSameCourseTasks() + { + await using var fixture = await ExperimentFixture.CreateAsync(); + var controller = fixture.Controller(fixture.ManagerScope); + + var result = await controller.CreateProjects( + fixture.BatchProjectRequest( + ExperimentArrangementMode.Centralized), + CancellationToken.None); + + Assert.IsType(result); + var projects = await fixture.Db.ExperimentProjects + .OrderBy(x => x.TeachingTaskId) + .ToListAsync(); + Assert.Equal(2, projects.Count); + Assert.Equal(2, projects.Select(x => x.TeachingTaskId).Distinct().Count()); + Assert.All(projects, project => + { + Assert.Equal("LAB-BATCH", project.Code); + Assert.Equal("公共实验任务", project.Name); + Assert.Equal(ExperimentProjectStatus.Draft, project.Status); + }); + } + + [Fact] + public async Task BatchSessions_RollsBackAllRowsWhenOneConflicts() + { + await using var fixture = await ExperimentFixture.CreateAsync(); + var controller = fixture.Controller(fixture.ManagerScope); + await controller.CreateProjects( + fixture.BatchProjectRequest( + ExperimentArrangementMode.SelfScheduled), + CancellationToken.None); + var projectIds = await fixture.Db.ExperimentProjects + .OrderBy(x => x.TeachingTaskId) + .Select(x => x.Id) + .ToListAsync(); + + var result = await controller.CreateSessions( + new ExperimentSessionBatchRequest( + [ + new ExperimentSessionBatchItem( + projectIds[0], + fixture.Classroom.Id, + fixture.Term.StartDate, + 1, + 2, + 20, + null), + new ExperimentSessionBatchItem( + projectIds[1], + fixture.Classroom.Id, + fixture.Term.StartDate, + 1, + 2, + 20, + null) + ]), + CancellationToken.None); + + Assert.IsType(result); + fixture.Db.ChangeTracker.Clear(); + Assert.Empty(await fixture.Db.ExperimentSessions.ToListAsync()); + } + + [Fact] + public async Task BatchSessions_CreatesRowsAtomicallyWhenAllAreValid() + { + await using var fixture = await ExperimentFixture.CreateAsync(); + var controller = fixture.Controller(fixture.ManagerScope); + await controller.CreateProjects( + fixture.BatchProjectRequest( + ExperimentArrangementMode.SelfScheduled), + CancellationToken.None); + var projectIds = await fixture.Db.ExperimentProjects + .OrderBy(x => x.TeachingTaskId) + .Select(x => x.Id) + .ToListAsync(); + + var result = await controller.CreateSessions( + new ExperimentSessionBatchRequest( + [ + new ExperimentSessionBatchItem( + projectIds[0], + fixture.Classroom.Id, + fixture.Term.StartDate, + 1, + 2, + 20, + null), + new ExperimentSessionBatchItem( + projectIds[1], + fixture.SecondClassroom.Id, + fixture.Term.StartDate, + 1, + 2, + 20, + null) + ]), + CancellationToken.None); + + Assert.IsType(result); + Assert.Equal(2, await fixture.Db.ExperimentSessions.CountAsync()); + } + [Fact] public async Task CentralizedProject_PublishesAndUsesTeachingTaskRoster() { @@ -421,6 +527,15 @@ public sealed class ExperimentsControllerTests } ] }; + var secondTask = new TeachingTask + { + TaskNumber = "2099-1-CSLAB-02", + Name = "系统实验教学班 02", + AcademicTermId = term.Id, + CourseId = course.Id, + Capacity = 40, + Status = TeachingTaskStatus.Published + }; db.AddRange( manager, studentUser, @@ -435,7 +550,8 @@ public sealed class ExperimentsControllerTests teacher, term, course, - task); + task, + secondTask); for (var period = 1; period <= 12; period++) { db.ScheduleTimeSlots.Add(new ScheduleTimeSlot @@ -486,6 +602,21 @@ public sealed class ExperimentsControllerTests Term.StartDate, Term.StartDate.AddDays(14)); + public ExperimentProjectBatchRequest BatchProjectRequest( + ExperimentArrangementMode mode) => + new( + Db.TeachingTasks + .OrderBy(x => x.TaskNumber) + .Select(x => x.Id) + .ToList(), + "LAB-BATCH", + "公共实验任务", + mode, + "多个教学任务共用的实验内容。", + "携带校园卡。", + Term.StartDate, + Term.StartDate.AddDays(14)); + public ExperimentSessionRequest SessionRequest( int startPeriod, int periodCount, diff --git a/web/src/views/ExperimentsView.vue b/web/src/views/ExperimentsView.vue index 62a15e9..68a20eb 100644 --- a/web/src/views/ExperimentsView.vue +++ b/web/src/views/ExperimentsView.vue @@ -27,7 +27,7 @@ const statusFilter = ref('') const projectDialog = ref(false) const editingProjectId = ref('') const projectForm = reactive({ - teachingTaskId: '', + teachingTaskIds: [] as string[], code: '', name: '', arrangementMode: 'Centralized', @@ -47,13 +47,20 @@ const sessionForm = reactive({ notes: '', }) +const batchSessionDialog = ref(false) +const batchSessionProjectIds = ref([]) +const batchSessionRows = ref([]) + const participantsDialog = ref(false) const participantSession = ref(null) const participants = ref([]) const participantsLoading = ref(false) const selectedTask = computed(() => - options.tasks.find((task) => task.id === projectForm.teachingTaskId), + options.tasks.find((task) => task.id === projectForm.teachingTaskIds[0]), +) +const batchProjectOptions = computed(() => + projects.value.filter((project) => project.status !== 'Closed'), ) const activePeriods = computed(() => options.periods.filter((period) => @@ -82,7 +89,7 @@ const statusLabels: Record = { function resetProjectForm() { editingProjectId.value = '' Object.assign(projectForm, { - teachingTaskId: '', + teachingTaskIds: [], code: '', name: '', arrangementMode: 'Centralized', @@ -106,7 +113,7 @@ function openCreateProject() { function openEditProject(project: any) { editingProjectId.value = project.id Object.assign(projectForm, { - teachingTaskId: project.teachingTaskId, + teachingTaskIds: [project.teachingTaskId], code: project.code, name: project.name, arrangementMode: project.arrangementMode, @@ -118,13 +125,13 @@ function openEditProject(project: any) { } async function saveProject() { - if (!projectForm.teachingTaskId || !projectForm.code.trim() + if (!projectForm.teachingTaskIds.length || !projectForm.code.trim() || !projectForm.name.trim() || projectForm.dates.length !== 2) { ElMessage.warning('请填写教学任务、项目编码、名称和开放日期') return } const payload = { - teachingTaskId: projectForm.teachingTaskId, + teachingTaskId: projectForm.teachingTaskIds[0], code: projectForm.code, name: projectForm.name, arrangementMode: projectForm.arrangementMode, @@ -138,8 +145,12 @@ async function saveProject() { await http.put(`/experiments/${editingProjectId.value}`, payload) ElMessage.success('实验项目已更新') } else { - await http.post('/experiments', payload) - ElMessage.success('实验项目已创建') + await http.post('/experiments/batch', { + ...payload, + teachingTaskId: undefined, + teachingTaskIds: projectForm.teachingTaskIds, + }) + ElMessage.success(`已为 ${projectForm.teachingTaskIds.length} 个教学任务创建实验项目`) } projectDialog.value = false await load() @@ -148,6 +159,78 @@ async function saveProject() { } } +function openBatchSession() { + batchSessionProjectIds.value = [] + batchSessionRows.value = [] + batchSessionDialog.value = true +} + +function syncBatchSessionRows() { + const existing = new Map(batchSessionRows.value.map((row) => [row.projectId, row])) + batchSessionRows.value = batchSessionProjectIds.value.map((projectId) => { + const current = existing.get(projectId) + if (current) return current + const project = projects.value.find((item) => item.id === projectId) + const firstPeriod = options.periods.find((item) => + item.academicTermId === project?.academicTermId, + ) + return { + projectId, + classroomId: '', + sessionDate: project?.startDate ?? '', + startPeriod: firstPeriod?.periodNumber, + periodCount: 2, + capacity: 30, + notes: '', + } + }) +} + +function applyFirstBatchTime() { + const first = batchSessionRows.value[0] + if (!first) return + batchSessionRows.value.slice(1).forEach((row) => { + row.sessionDate = first.sessionDate + row.startPeriod = first.startPeriod + row.periodCount = first.periodCount + }) + ElMessage.success('已套用首行的日期和节次,请分别选择不冲突的实验室') +} + +function batchProject(projectId: string) { + return projects.value.find((project) => project.id === projectId) +} + +function periodsForProject(projectId: string) { + const project = batchProject(projectId) + return options.periods.filter((period) => + period.academicTermId === project?.academicTermId, + ) +} + +async function saveBatchSessions() { + if (!batchSessionRows.value.length) { + ElMessage.warning('请至少选择一个实验项目') + return + } + if (batchSessionRows.value.some((row) => + !row.classroomId || !row.sessionDate || !row.startPeriod || !row.periodCount, + )) { + ElMessage.warning('请完整填写每个项目的日期、节次、实验室和容量') + return + } + try { + await http.post('/experiments/sessions/batch', { + items: batchSessionRows.value, + }) + batchSessionDialog.value = false + ElMessage.success(`已批量安排 ${batchSessionRows.value.length} 个实验场次`) + await load() + } catch (error) { + ElMessage.error(apiErrorMessage(error)) + } +} + function openSession(project: any) { selectedProject.value = project const firstPeriod = options.periods.find((item) => @@ -382,8 +465,11 @@ onMounted(async () => {

把实验项目分成两条运行轨道:集中排入固定课次,或开放场次供学生自主预约。

+ + 批量排课 + - 新建实验项目 + 批量设置实验任务 刷新
@@ -627,19 +713,22 @@ onMounted(async () => {
PROJECT规定实验项目
- + { :key="task.id" :label="`${task.courseCode} · ${task.courseName} · ${task.taskNumber}`" :value="task.id" + :disabled="!!selectedTask + && (task.academicTermId !== selectedTask.academicTermId + || task.courseId !== selectedTask.courseId)" /> + + 已选 {{ projectForm.teachingTaskIds.length }} 个;实验编码、名称、内容和开放日期将一次应用到这些教学任务。 +
@@ -708,7 +803,13 @@ onMounted(async () => { @@ -778,6 +879,86 @@ onMounted(async () => { + + + + + + + + +
+ 共 {{ batchSessionRows.length }} 条排课 + 套用首行日期与节次 +
+
+
+
+ {{ index + 1 }} +
+ {{ batchProject(row.projectId)?.name }} + {{ batchProject(row.projectId)?.taskNumber }} · {{ batchProject(row.projectId)?.classNames.join('、') || '选课学生' }} +
+
+ + + + + + + + + +
+
+ +
+ +
+ { .student-booking-summary span { display: grid; gap: 2px; color: #365f5b; font-size: 12px; } .student-booking-summary b { font-size: 10px; text-transform: uppercase; letter-spacing: .06em; } .experiment-form { display: grid; gap: 13px; } +.form-help { display: block; margin-top: 7px; color: var(--muted); line-height: 1.5; } .form-section { padding: 14px 15px 1px; border: 1px solid var(--line); background: #fbfcfd; } .form-section > header { display: flex; align-items: baseline; gap: 9px; margin-bottom: 13px; } .form-section > header span { color: var(--lab-teal); font: 700 10px/1 Consolas, monospace; letter-spacing: .08em; } @@ -920,6 +1102,25 @@ onMounted(async () => { .mode-choice :deep(.el-radio-button__inner) { display: grid; gap: 5px; width: 100%; padding: 13px; } .mode-choice b { font-size: 13px; } .mode-choice small { font-size: 10px; font-weight: 400; } +.batch-session-form { display: grid; gap: 12px; margin-top: 16px; } +.batch-session-tools { display: flex; align-items: center; justify-content: space-between; color: var(--muted); font-size: 12px; } +.batch-session-table { display: grid; gap: 8px; overflow-x: auto; padding-bottom: 4px; } +.batch-session-row { + display: grid; + grid-template-columns: minmax(230px, 1.4fr) 150px 120px 110px minmax(230px, 1.3fr) 110px; + gap: 8px; + align-items: center; + min-width: 1000px; + padding: 10px; + border: 1px solid #dce5e9; + background: #f9fbfc; +} +.batch-project-cell { display: flex; align-items: center; gap: 10px; min-width: 0; } +.batch-project-cell > span { display: grid; width: 25px; height: 25px; place-items: center; border-radius: 50%; background: #e5f0f5; color: var(--lab-blue); font: 700 11px/1 Consolas, monospace; } +.batch-project-cell > div { display: grid; gap: 3px; min-width: 0; } +.batch-project-cell b, .batch-project-cell small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.batch-project-cell b { color: var(--lab-ink); font-size: 12px; } +.batch-project-cell small { color: var(--muted); font-size: 10px; } @media (prefers-reduced-motion: reduce) { .session-ticket { transition: none; } } @@ -936,5 +1137,6 @@ onMounted(async () => { .ticket-action { grid-column: 1 / -1; justify-content: flex-start; padding: 0 10px 10px; } .project-actions, .student-booking-summary { padding-inline: 14px; } .mode-choice { grid-template-columns: 1fr; } + .intro-actions { flex-wrap: wrap; justify-content: flex-end; } }