自选实验项目教师代选

This commit is contained in:
biss committed 2026-08-16 17:19:06 +08:00
1 parent 1c488d70e5
commit 68be9dfc3d
3 files changed
+224

No files matched your search

@@ -1208,6 +1208,106 @@ public sealed class ExperimentsController(
.ToListAsync(cancellationToken));
}
[HttpGet("sessions/{id:guid}/eligible-students")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> 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<ActionResult> CreateProxyBooking(
Guid id,
ProxyExperimentBookingRequest request,
CancellationToken cancellationToken)
{
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
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<ActionResult> Book(
@@ -1604,6 +1704,24 @@ public sealed class ExperimentsController(
taskIds.Contains(x.TeachingTaskId));
}
private Task<ExperimentSession?> 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<Guid>? InstructorTeacherIds = null);
public sealed record ProxyExperimentBookingRequest(Guid StudentId);
public sealed record ExperimentSessionBatchRequest(
[Required] IReadOnlyList<ExperimentSessionBatchItem> Items);
@@ -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<NoContentResult>(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()
{
+79
View File
@@ -78,6 +78,11 @@ const participantsDialog = ref(false)
const participantSession = ref<any>(null)
const participants = ref<any[]>([])
const participantsLoading = ref(false)
const proxyBookingDialog = ref(false)
const proxyBookingStudents = ref<any[]>([])
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 () => {
</el-table-column>
</el-table>
<el-empty v-if="!participantsLoading && !participants.length" description="当前没有学生名单" />
<template #footer>
<el-button
v-if="participantSession?.project?.arrangementMode === 'SelfScheduled'"
type="primary"
@click="openProxyBooking"
>教师代选</el-button>
<el-button @click="participantsDialog = false">关闭</el-button>
</template>
</el-dialog>
<el-dialog v-model="proxyBookingDialog" title="教师代选实验场次" width="620px">
<el-alert
title="代选不受学生自主预约时间窗口限制,但仍校验教学任务名单、场次容量和学生课表/其他实验冲突。"
type="info"
:closable="false"
show-icon
/>
<div class="proxy-booking-search">
<el-input v-model="proxyBookingKeyword" clearable placeholder="输入学号、姓名或行政班" @keyup.enter="loadProxyBookingStudents" />
<el-button @click="loadProxyBookingStudents">查询</el-button>
</div>
<el-table v-loading="proxyBookingLoading" :data="proxyBookingStudents" max-height="400" @current-change="(row) => proxyBookingStudentId = row?.studentId ?? ''">
<el-table-column width="54">
<template #default="{ row }"><el-radio v-model="proxyBookingStudentId" :value="row.studentId" /></template>
</el-table-column>
<el-table-column prop="studentNumber" label="学号" min-width="130" />
<el-table-column prop="name" label="姓名" min-width="100" />
<el-table-column prop="className" label="行政班" min-width="180" />
</el-table>
<el-empty v-if="!proxyBookingLoading && !proxyBookingStudents.length" description="没有可代选的学生" />
<template #footer>
<el-button @click="proxyBookingDialog = false">取消</el-button>
<el-button type="primary" :disabled="!proxyBookingStudentId" @click="saveProxyBooking">确认代选</el-button>
</template>
</el-dialog>
</div>
</template>