From 68be9dfc3d6435fe519a4dead89e98c3edd70e81 Mon Sep 17 00:00:00 2001 From: biss Date: Sun, 16 Aug 2026 17:19:06 +0800 Subject: [PATCH] =?UTF-8?q?=E8=87=AA=E9=80=89=E5=AE=9E=E9=AA=8C=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E6=95=99=E5=B8=88=E4=BB=A3=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/ExperimentsController.cs | 120 ++++++++++++++++++ .../ExperimentsControllerTests.cs | 25 ++++ web/src/views/ExperimentsView.vue | 79 ++++++++++++ 3 files changed, 224 insertions(+) diff --git a/src/Jiaowu.Api/Controllers/ExperimentsController.cs b/src/Jiaowu.Api/Controllers/ExperimentsController.cs index 8fc4525..3bbc26d 100644 --- a/src/Jiaowu.Api/Controllers/ExperimentsController.cs +++ b/src/Jiaowu.Api/Controllers/ExperimentsController.cs @@ -1208,6 +1208,106 @@ public sealed class ExperimentsController( .ToListAsync(cancellationToken)); } + [HttpGet("sessions/{id:guid}/eligible-students")] + [Authorize(Roles = Managers)] + public async Task GetProxyBookingStudents( + Guid id, + string? keyword, + CancellationToken cancellationToken) + { + var session = await LoadManagedSessionAsync(id, cancellationToken); + if (session is null) return NotFound(); + if (!CanManageSession(session)) return Forbid(); + if (session.ExperimentProject!.ArrangementMode != ExperimentArrangementMode.SelfScheduled) + return ConflictProblem("集中安排实验无需代选场次。"); + + var bookedStudentIds = await db.ExperimentBookings.AsNoTracking() + .Where(x => x.ExperimentProjectId == session.ExperimentProjectId && + x.Status == ExperimentBookingStatus.Booked) + .Select(x => x.StudentId) + .ToListAsync(cancellationToken); + var normalizedKeyword = string.IsNullOrWhiteSpace(keyword) ? null : keyword.Trim(); + var roster = await TeachingTaskRosterQuery.LoadForTasksAsync( + db, [session.ExperimentProject.TeachingTaskId], cancellationToken); + return Ok(roster + .Where(x => !bookedStudentIds.Contains(x.StudentId)) + .Where(x => normalizedKeyword is null || + x.StudentNumber.Contains(normalizedKeyword) || + x.Name.Contains(normalizedKeyword) || + x.ClassName.Contains(normalizedKeyword)) + .OrderBy(x => x.StudentNumber) + .Take(100) + .Select(x => new { x.StudentId, x.StudentNumber, x.Name, x.ClassName })); + } + + [HttpPost("sessions/{id:guid}/proxy-bookings")] + [Authorize(Roles = Managers)] + public async Task CreateProxyBooking( + Guid id, + ProxyExperimentBookingRequest request, + CancellationToken cancellationToken) + { + return await db.ExecuteInRetriableTransactionAsync( + async transaction => + { + db.ChangeTracker.Clear(); + var session = await LoadManagedSessionAsync(id, cancellationToken); + if (session is null) return NotFound(); + if (!CanManageSession(session)) return Forbid(); + if (session.Status != ExperimentSessionStatus.Scheduled || + session.ExperimentProject!.Status != ExperimentProjectStatus.Published || + session.ExperimentProject.ArrangementMode != ExperimentArrangementMode.SelfScheduled) + return ConflictProblem("只能为已发布的自行安排实验场次代选学生。"); + + var student = await db.Students.FirstOrDefaultAsync(x => + x.Id == request.StudentId && x.Status == StudentStatus.Active, + cancellationToken); + if (student is null) return ValidationProblem("学生不存在或不在学。"); + if (!await TeachingTaskRosterQuery.TaskIdsForStudent(db, student.Id) + .ContainsAsync(session.ExperimentProject.TeachingTaskId, cancellationToken)) + return ValidationProblem("该学生不在本实验项目的教学任务名单内。"); + + var existing = await db.ExperimentBookings.FirstOrDefaultAsync(x => + x.ExperimentProjectId == session.ExperimentProjectId && x.StudentId == student.Id, + cancellationToken); + if (existing?.Status == ExperimentBookingStatus.Booked) + return existing.ExperimentSessionId == session.Id + ? NoContent() + : ConflictProblem("该学生已预约本实验项目的其他场次。"); + + var scheduleProblem = await StudentConflictAsync(student.Id, session, cancellationToken); + if (scheduleProblem is not null) return ConflictProblem(scheduleProblem); + var reservedCount = await db.ExperimentBookings.CountAsync(x => + x.ExperimentSessionId == session.Id && x.Status == ExperimentBookingStatus.Booked, + cancellationToken); + if (reservedCount >= session.Capacity) + return ConflictProblem("该实验场次名额已满,请选择其他时间。"); + + if (existing is null) + { + db.ExperimentBookings.Add(new ExperimentBooking + { + ExperimentProjectId = session.ExperimentProjectId, + ExperimentSessionId = session.Id, + StudentId = student.Id + }); + } + else + { + existing.ExperimentSessionId = session.Id; + existing.Status = ExperimentBookingStatus.Booked; + existing.BookedAt = DateTime.UtcNow; + existing.CancelledAt = null; + } + session.ReservedCount = reservedCount + 1; + await db.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return NoContent(); + }, + cancellationToken, + IsolationLevel.Serializable); + } + [HttpPost("sessions/{id:guid}/book")] [Authorize(Roles = SystemRoles.Student)] public async Task Book( @@ -1604,6 +1704,24 @@ public sealed class ExperimentsController( taskIds.Contains(x.TeachingTaskId)); } + private Task LoadManagedSessionAsync( + Guid id, + CancellationToken cancellationToken) => + db.ExperimentSessions + .Include(x => x.ExperimentProject) + .ThenInclude(x => x!.TeachingTask) + .ThenInclude(x => x!.AcademicTerm) + .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) + .Include(x => x.Instructors) + .ThenInclude(x => x.Teacher) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + private bool CanManageSession(ExperimentSession session) { var scope = currentUserDataScope.Current; @@ -1847,6 +1965,8 @@ public sealed record ExperimentSessionRequest( [MaxLength(500)] string? Notes, [Required, MinLength(1)] IReadOnlyList? InstructorTeacherIds = null); +public sealed record ProxyExperimentBookingRequest(Guid StudentId); + public sealed record ExperimentSessionBatchRequest( [Required] IReadOnlyList Items); diff --git a/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs b/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs index 96b60b9..087a485 100644 --- a/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs @@ -402,6 +402,31 @@ public sealed class ExperimentsControllerTests sessions[1].Id))!.ReservedCount); } + [Fact] + public async Task ManagerCanProxyBookAStudentAfterTheSelectionWindowHasClosed() + { + await using var fixture = await ExperimentFixture.CreateAsync(); + var manager = fixture.Controller(fixture.ManagerScope); + await manager.CreateProject( + fixture.ProjectRequest(ExperimentArrangementMode.SelfScheduled), + CancellationToken.None); + var project = await fixture.Db.ExperimentProjects.SingleAsync(); + await manager.CreateSession( + project.Id, fixture.SessionRequest(3, 2), CancellationToken.None); + await manager.PublishProject(project.Id, CancellationToken.None); + project.SelectionStartsAt = DateTime.UtcNow.AddDays(-2); + project.SelectionEndsAt = DateTime.UtcNow.AddDays(-1); + await fixture.Db.SaveChangesAsync(); + var session = await fixture.Db.ExperimentSessions.SingleAsync(); + var studentId = await fixture.Db.Students.Select(x => x.Id).SingleAsync(); + + Assert.IsType(await manager.CreateProxyBooking( + session.Id, new ProxyExperimentBookingRequest(studentId), CancellationToken.None)); + Assert.Equal(1, await fixture.Db.ExperimentBookings.CountAsync(x => + x.ExperimentSessionId == session.Id && x.StudentId == studentId && + x.Status == ExperimentBookingStatus.Booked)); + } + [Fact] public async Task StudentBooking_RejectsPublishedTimetableConflict() { diff --git a/web/src/views/ExperimentsView.vue b/web/src/views/ExperimentsView.vue index f17830e..741be4f 100644 --- a/web/src/views/ExperimentsView.vue +++ b/web/src/views/ExperimentsView.vue @@ -78,6 +78,11 @@ const participantsDialog = ref(false) const participantSession = ref(null) const participants = ref([]) const participantsLoading = ref(false) +const proxyBookingDialog = ref(false) +const proxyBookingStudents = ref([]) +const proxyBookingLoading = ref(false) +const proxyBookingKeyword = ref('') +const proxyBookingStudentId = ref('') const selectedTask = computed(() => options.tasks.find((task) => task.id === projectForm.teachingTaskIds[0]), @@ -613,6 +618,46 @@ async function showParticipants(project: any, session: any) { } } +async function loadProxyBookingStudents() { + if (!participantSession.value) return + proxyBookingLoading.value = true + try { + proxyBookingStudents.value = (await http.get( + `/experiments/sessions/${participantSession.value.id}/eligible-students`, + { params: { keyword: proxyBookingKeyword.value || undefined } }, + )).data + } catch (error) { + proxyBookingStudents.value = [] + ElMessage.error(apiErrorMessage(error)) + } finally { + proxyBookingLoading.value = false + } +} + +async function openProxyBooking() { + proxyBookingStudentId.value = '' + proxyBookingKeyword.value = '' + proxyBookingDialog.value = true + await loadProxyBookingStudents() +} + +async function saveProxyBooking() { + if (!participantSession.value || !proxyBookingStudentId.value) { + ElMessage.warning('请选择需要代选场次的学生') + return + } + try { + await http.post(`/experiments/sessions/${participantSession.value.id}/proxy-bookings`, { + studentId: proxyBookingStudentId.value, + }) + ElMessage.success('已为该学生代选实验场次') + proxyBookingDialog.value = false + await showParticipants(participantSession.value.project, participantSession.value) + } catch (error) { + ElMessage.error(apiErrorMessage(error)) + } +} + async function book(project: any, session: any) { try { await ElMessageBox.confirm( @@ -1452,6 +1497,40 @@ onMounted(async () => { + + + + + + + + + + + + + + + +