排课
Build and publish Jiaowu packages and container image / Test, package and publish (push) Successful in 32m28s
Build and publish Jiaowu packages and container image / Test, package and publish (push) Successful in 32m28s
This commit is contained in:
@@ -169,6 +169,95 @@ public sealed class ExamsController(
|
|||||||
return await SaveAsync(session.Id, true, cancellationToken);
|
return await SaveAsync(session.Id, true, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("plans/{planId:guid}/sessions/batch")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> CreateSessionsBatch(
|
||||||
|
Guid planId,
|
||||||
|
CreateExamSessionsBatchRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var plan = await db.ExamPlans.FindAsync([planId], cancellationToken);
|
||||||
|
if (plan is null) return NotFound();
|
||||||
|
if (plan.Status != ExamPlanStatus.Draft)
|
||||||
|
return ConflictProblem("已发布的考试计划不能调整场次。");
|
||||||
|
|
||||||
|
var taskIds = request.TeachingTaskIds.Distinct().ToArray();
|
||||||
|
if (taskIds.Length == 0)
|
||||||
|
return ValidationProblem("请至少选择一个教学班。");
|
||||||
|
if (taskIds.Length > 100)
|
||||||
|
return ValidationProblem("一次最多选择100个教学班。");
|
||||||
|
|
||||||
|
var timeResult = ResolveExamTime(plan.AcademicTermId,
|
||||||
|
request.ExamDate, request.StartPeriod, request.PeriodCount);
|
||||||
|
if (timeResult.Error is not null) return timeResult.Error;
|
||||||
|
var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt);
|
||||||
|
|
||||||
|
var validTaskIds = await db.TeachingTasks.AsNoTracking()
|
||||||
|
.Where(x => x.AcademicTermId == plan.AcademicTermId &&
|
||||||
|
x.Status == TeachingTaskStatus.Published)
|
||||||
|
.WhereIn(taskIds, x => x.Id)
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (validTaskIds.Count != taskIds.Length)
|
||||||
|
return ValidationProblem("所选教学班包含不存在、跨学期或未发布的教学班。");
|
||||||
|
|
||||||
|
var duplicateTaskIds = await db.ExamSessions.AsNoTracking()
|
||||||
|
.Where(x => x.ExamPlanId == planId)
|
||||||
|
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||||
|
.Select(x => x.TeachingTaskId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (duplicateTaskIds.Count > 0)
|
||||||
|
return ConflictProblem($"所选教学班中有 {duplicateTaskIds.Count} 个已在当前计划中安排考试。");
|
||||||
|
|
||||||
|
var enrollmentPairs = await db.CourseEnrollments.AsNoTracking()
|
||||||
|
.Where(x => x.Status == CourseEnrollmentStatus.Enrolled)
|
||||||
|
.WhereIn(taskIds, x => x.CourseSelectionOffering!.TeachingTaskId)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.StudentId,
|
||||||
|
x.CourseSelectionOffering!.TeachingTaskId
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var conflictedStudentCount = enrollmentPairs
|
||||||
|
.GroupBy(x => x.StudentId)
|
||||||
|
.Count(group => group.Select(x => x.TeachingTaskId).Distinct().Skip(1).Any());
|
||||||
|
if (conflictedStudentCount > 0)
|
||||||
|
return ConflictProblem(
|
||||||
|
$"所选教学班在同一时段安排会造成 {conflictedStudentCount} 名学生考试冲突,请分批选择不同时间。");
|
||||||
|
|
||||||
|
foreach (var taskId in taskIds)
|
||||||
|
{
|
||||||
|
var validation = await ValidateSessionAsync(plan, null,
|
||||||
|
taskId, null, null, startsAt, endsAt, cancellationToken);
|
||||||
|
if (validation is not null) return validation;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.ExamSessions.AddRange(taskIds.Select(taskId => new ExamSession
|
||||||
|
{
|
||||||
|
ExamPlanId = planId,
|
||||||
|
TeachingTaskId = taskId,
|
||||||
|
ExamDate = request.ExamDate,
|
||||||
|
StartPeriod = request.StartPeriod,
|
||||||
|
PeriodCount = request.PeriodCount,
|
||||||
|
StartsAt = startsAt,
|
||||||
|
EndsAt = endsAt,
|
||||||
|
RequiredBuildingId = request.RequiredBuildingId,
|
||||||
|
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
|
||||||
|
Notes = Normalize(request.Notes)
|
||||||
|
}));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
|
||||||
|
return Ok(new { createdCount = taskIds.Length });
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
return ConflictProblem("批量创建考试场次失败,关联数据可能已发生变化。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPut("plans/{planId:guid}/sessions/{id:guid}")]
|
[HttpPut("plans/{planId:guid}/sessions/{id:guid}")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> UpdateSession(
|
public async Task<ActionResult> UpdateSession(
|
||||||
@@ -237,11 +326,19 @@ public sealed class ExamsController(
|
|||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> AutoArrange(
|
public async Task<ActionResult> AutoArrange(
|
||||||
Guid planId,
|
Guid planId,
|
||||||
|
ExamAutoArrangeRequest? request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await examArrangementService.ArrangeAsync(planId, cancellationToken);
|
request ??= new ExamAutoArrangeRequest();
|
||||||
|
var result = await examArrangementService.ArrangeAsync(
|
||||||
|
planId,
|
||||||
|
request.SessionIds,
|
||||||
|
request.AssignClassrooms,
|
||||||
|
request.AssignInvigilators,
|
||||||
|
cancellationToken);
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
return ConflictProblem(result.Message);
|
return ConflictProblem(result.Message);
|
||||||
|
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
|
||||||
return Ok(new { message = result.Message });
|
return Ok(new { message = result.Message });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,20 +501,40 @@ public sealed class ExamsController(
|
|||||||
x.TeachingTask!.TaskNumber,
|
x.TeachingTask!.TaskNumber,
|
||||||
CourseName = x.TeachingTask.Course!.Name,
|
CourseName = x.TeachingTask.Course!.Name,
|
||||||
ClassroomName = x.Classroom != null ? x.Classroom.Name : "待分配",
|
ClassroomName = x.Classroom != null ? x.Classroom.Name : "待分配",
|
||||||
x.StartsAt,
|
x.StartsAt
|
||||||
Students = db.CourseEnrollments
|
|
||||||
.Where(e => e.Status == CourseEnrollmentStatus.Enrolled &&
|
|
||||||
e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId)
|
|
||||||
.OrderBy(e => e.Student!.StudentNumber)
|
|
||||||
.Select(e => new
|
|
||||||
{
|
|
||||||
e.StudentId,
|
|
||||||
e.Student!.StudentNumber,
|
|
||||||
e.Student.Name,
|
|
||||||
ClassName = e.Student.AdministrativeClass!.Name
|
|
||||||
}).ToList()
|
|
||||||
}).FirstOrDefaultAsync(cancellationToken);
|
}).FirstOrDefaultAsync(cancellationToken);
|
||||||
return session is null ? NotFound() : Ok(session);
|
if (session is null) return NotFound();
|
||||||
|
|
||||||
|
var students = await db.CourseEnrollments.AsNoTracking()
|
||||||
|
.Where(e => e.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
|
e.CourseSelectionOffering!.TeachingTaskId == session.TeachingTaskId)
|
||||||
|
.OrderBy(e => e.Student!.StudentNumber)
|
||||||
|
.Select(e => new
|
||||||
|
{
|
||||||
|
e.StudentId,
|
||||||
|
e.Student!.StudentNumber,
|
||||||
|
e.Student.Name,
|
||||||
|
ClassName = e.Student.AdministrativeClass!.Name
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
session.Id,
|
||||||
|
session.TeachingTaskId,
|
||||||
|
session.TaskNumber,
|
||||||
|
session.CourseName,
|
||||||
|
session.ClassroomName,
|
||||||
|
session.StartsAt,
|
||||||
|
Students = students.Select((student, index) => new
|
||||||
|
{
|
||||||
|
student.StudentId,
|
||||||
|
student.StudentNumber,
|
||||||
|
student.Name,
|
||||||
|
student.ClassName,
|
||||||
|
SeatNumber = (index + 1).ToString("D3")
|
||||||
|
})
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
@@ -434,7 +551,7 @@ public sealed class ExamsController(
|
|||||||
.Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken);
|
.Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken);
|
||||||
if (!studentId.HasValue)
|
if (!studentId.HasValue)
|
||||||
return ConflictProblem("当前账号未关联学生档案。");
|
return ConflictProblem("当前账号未关联学生档案。");
|
||||||
return Ok(await db.ExamSessions.AsNoTracking()
|
var schedule = await db.ExamSessions.AsNoTracking()
|
||||||
.Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published &&
|
.Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published &&
|
||||||
db.CourseEnrollments.Any(e => e.StudentId == studentId &&
|
db.CourseEnrollments.Any(e => e.StudentId == studentId &&
|
||||||
e.Status == CourseEnrollmentStatus.Enrolled &&
|
e.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
@@ -455,9 +572,52 @@ public sealed class ExamsController(
|
|||||||
ClassroomName = x.Classroom != null ? x.Classroom.Name : null,
|
ClassroomName = x.Classroom != null ? x.Classroom.Name : null,
|
||||||
BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null,
|
BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null,
|
||||||
InvigilatorNames = x.Invigilators.Select(i => i.Teacher!.Name),
|
InvigilatorNames = x.Invigilators.Select(i => i.Teacher!.Name),
|
||||||
|
x.TeachingTaskId,
|
||||||
IsExam = true
|
IsExam = true
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken);
|
||||||
|
if (schedule.Count == 0) return Ok(schedule);
|
||||||
|
|
||||||
|
var taskIds = schedule.Select(x => x.TeachingTaskId).Distinct().ToArray();
|
||||||
|
var seatRows = await db.CourseEnrollments.AsNoTracking()
|
||||||
|
.Where(x => x.Status == CourseEnrollmentStatus.Enrolled)
|
||||||
|
.WhereIn(taskIds, x => x.CourseSelectionOffering!.TeachingTaskId)
|
||||||
|
.OrderBy(x => x.Student!.StudentNumber)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.CourseSelectionOffering!.TeachingTaskId,
|
||||||
|
x.StudentId,
|
||||||
|
x.Student!.StudentNumber
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var seatNumbers = seatRows
|
||||||
|
.GroupBy(x => x.TeachingTaskId)
|
||||||
|
.SelectMany(group => group.Select((row, index) => new
|
||||||
|
{
|
||||||
|
row.TeachingTaskId,
|
||||||
|
row.StudentId,
|
||||||
|
SeatNumber = (index + 1).ToString("D3")
|
||||||
|
}))
|
||||||
|
.ToDictionary(x => (x.TeachingTaskId, x.StudentId), x => x.SeatNumber);
|
||||||
|
|
||||||
|
return Ok(schedule.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.PlanName,
|
||||||
|
x.ExamDate,
|
||||||
|
x.StartPeriod,
|
||||||
|
x.PeriodCount,
|
||||||
|
x.StartsAt,
|
||||||
|
x.EndsAt,
|
||||||
|
x.TaskNumber,
|
||||||
|
x.CourseCode,
|
||||||
|
x.CourseName,
|
||||||
|
x.ClassroomName,
|
||||||
|
x.BuildingName,
|
||||||
|
x.InvigilatorNames,
|
||||||
|
SeatNumber = seatNumbers.GetValueOrDefault((x.TeachingTaskId, studentId.Value)),
|
||||||
|
x.IsExam
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
if (scope.IsInRole(SystemRoles.Teacher))
|
if (scope.IsInRole(SystemRoles.Teacher))
|
||||||
{
|
{
|
||||||
@@ -650,3 +810,17 @@ public sealed record CreateExamSessionRequest(
|
|||||||
[Range(1, 10)] int RequiredInvigilatorCount,
|
[Range(1, 10)] int RequiredInvigilatorCount,
|
||||||
IReadOnlyCollection<Guid>? InvigilatorIds,
|
IReadOnlyCollection<Guid>? InvigilatorIds,
|
||||||
[MaxLength(500)] string? Notes);
|
[MaxLength(500)] string? Notes);
|
||||||
|
|
||||||
|
public sealed record CreateExamSessionsBatchRequest(
|
||||||
|
[Required] IReadOnlyCollection<Guid> TeachingTaskIds,
|
||||||
|
DateOnly ExamDate,
|
||||||
|
[Range(1, 30)] int StartPeriod,
|
||||||
|
[Range(1, 6)] int PeriodCount,
|
||||||
|
Guid? RequiredBuildingId,
|
||||||
|
[Range(1, 10)] int RequiredInvigilatorCount,
|
||||||
|
[MaxLength(500)] string? Notes);
|
||||||
|
|
||||||
|
public sealed record ExamAutoArrangeRequest(
|
||||||
|
IReadOnlyCollection<Guid>? SessionIds = null,
|
||||||
|
bool AssignClassrooms = true,
|
||||||
|
bool AssignInvigilators = true);
|
||||||
|
|||||||
@@ -224,6 +224,78 @@ public sealed class MakeupExamsController(
|
|||||||
return await SaveAsync(session.Id, true, cancellationToken);
|
return await SaveAsync(session.Id, true, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("plans/{planId:guid}/sessions/batch")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> CreateSessionsBatch(
|
||||||
|
Guid planId,
|
||||||
|
CreateMakeupExamSessionsBatchRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken);
|
||||||
|
if (plan is null) return NotFound();
|
||||||
|
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||||
|
return ConflictProblem("已发布的补考计划不能调整场次。");
|
||||||
|
|
||||||
|
var taskIds = request.TeachingTaskIds.Distinct().ToArray();
|
||||||
|
if (taskIds.Length == 0)
|
||||||
|
return ValidationProblem("请至少选择一个教学班。");
|
||||||
|
if (taskIds.Length > 100)
|
||||||
|
return ValidationProblem("一次最多选择100个教学班。");
|
||||||
|
|
||||||
|
var timeResult = ResolveExamTime(plan.AcademicTermId,
|
||||||
|
request.ExamDate, request.StartPeriod, request.PeriodCount);
|
||||||
|
if (timeResult.Error is not null) return timeResult.Error;
|
||||||
|
var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt);
|
||||||
|
|
||||||
|
var validTaskIds = await db.TeachingTasks.AsNoTracking()
|
||||||
|
.Where(x => x.AcademicTermId == plan.AcademicTermId &&
|
||||||
|
x.Status == TeachingTaskStatus.Published)
|
||||||
|
.WhereIn(taskIds, x => x.Id)
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (validTaskIds.Count != taskIds.Length)
|
||||||
|
return ValidationProblem("所选教学班包含不存在、跨学期或未发布的教学班。");
|
||||||
|
|
||||||
|
var duplicateTaskIds = await db.MakeupExamSessions.AsNoTracking()
|
||||||
|
.Where(x => x.MakeupExamPlanId == planId)
|
||||||
|
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||||
|
.Select(x => x.TeachingTaskId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (duplicateTaskIds.Count > 0)
|
||||||
|
return ConflictProblem($"所选教学班中有 {duplicateTaskIds.Count} 个已在当前补考计划中安排。");
|
||||||
|
|
||||||
|
foreach (var taskId in taskIds)
|
||||||
|
{
|
||||||
|
var validation = await ValidateSessionAsync(plan, null,
|
||||||
|
taskId, null, null, startsAt, endsAt, cancellationToken);
|
||||||
|
if (validation is not null) return validation;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.MakeupExamSessions.AddRange(taskIds.Select(taskId => new MakeupExamSession
|
||||||
|
{
|
||||||
|
MakeupExamPlanId = planId,
|
||||||
|
TeachingTaskId = taskId,
|
||||||
|
ExamDate = request.ExamDate,
|
||||||
|
StartPeriod = request.StartPeriod,
|
||||||
|
PeriodCount = request.PeriodCount,
|
||||||
|
StartsAt = startsAt,
|
||||||
|
EndsAt = endsAt,
|
||||||
|
RequiredBuildingId = request.RequiredBuildingId,
|
||||||
|
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
|
||||||
|
Notes = Normalize(request.Notes)
|
||||||
|
}));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return Ok(new { createdCount = taskIds.Length });
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
return ConflictProblem("批量创建补考场次失败,关联数据可能已发生变化。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPut("plans/{planId:guid}/sessions/{id:guid}")]
|
[HttpPut("plans/{planId:guid}/sessions/{id:guid}")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> UpdateSession(
|
public async Task<ActionResult> UpdateSession(
|
||||||
@@ -292,9 +364,16 @@ public sealed class MakeupExamsController(
|
|||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> AutoArrange(
|
public async Task<ActionResult> AutoArrange(
|
||||||
Guid planId,
|
Guid planId,
|
||||||
|
ExamAutoArrangeRequest? request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await arrangementService.ArrangeAsync(planId, cancellationToken);
|
request ??= new ExamAutoArrangeRequest();
|
||||||
|
var result = await arrangementService.ArrangeAsync(
|
||||||
|
planId,
|
||||||
|
request.SessionIds,
|
||||||
|
request.AssignClassrooms,
|
||||||
|
request.AssignInvigilators,
|
||||||
|
cancellationToken);
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
return ConflictProblem(result.Message);
|
return ConflictProblem(result.Message);
|
||||||
return Ok(new { message = result.Message });
|
return Ok(new { message = result.Message });
|
||||||
@@ -555,19 +634,43 @@ public sealed class MakeupExamsController(
|
|||||||
x.TeachingTask!.TaskNumber,
|
x.TeachingTask!.TaskNumber,
|
||||||
CourseName = x.TeachingTask.Course!.Name,
|
CourseName = x.TeachingTask.Course!.Name,
|
||||||
ClassroomName = x.Classroom != null ? x.Classroom.Name : "待分配",
|
ClassroomName = x.Classroom != null ? x.Classroom.Name : "待分配",
|
||||||
x.StartsAt,
|
x.StartsAt
|
||||||
Students = x.Enrollments.OrderBy(e => e.Student!.StudentNumber)
|
|
||||||
.Select(e => new
|
|
||||||
{
|
|
||||||
e.StudentId,
|
|
||||||
e.Student!.StudentNumber,
|
|
||||||
e.Student.Name,
|
|
||||||
ClassName = e.Student.AdministrativeClass!.Name,
|
|
||||||
e.Reason,
|
|
||||||
e.MakeupScore
|
|
||||||
}).ToList()
|
|
||||||
}).FirstOrDefaultAsync(cancellationToken);
|
}).FirstOrDefaultAsync(cancellationToken);
|
||||||
return session is null ? NotFound() : Ok(session);
|
if (session is null) return NotFound();
|
||||||
|
|
||||||
|
var students = await db.MakeupExamEnrollments.AsNoTracking()
|
||||||
|
.Where(e => e.MakeupExamSessionId == id)
|
||||||
|
.OrderBy(e => e.Student!.StudentNumber)
|
||||||
|
.Select(e => new
|
||||||
|
{
|
||||||
|
e.StudentId,
|
||||||
|
e.Student!.StudentNumber,
|
||||||
|
e.Student.Name,
|
||||||
|
ClassName = e.Student.AdministrativeClass!.Name,
|
||||||
|
e.Reason,
|
||||||
|
e.MakeupScore
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
session.Id,
|
||||||
|
session.TeachingTaskId,
|
||||||
|
session.TaskNumber,
|
||||||
|
session.CourseName,
|
||||||
|
session.ClassroomName,
|
||||||
|
session.StartsAt,
|
||||||
|
Students = students.Select((student, index) => new
|
||||||
|
{
|
||||||
|
student.StudentId,
|
||||||
|
student.StudentNumber,
|
||||||
|
student.Name,
|
||||||
|
student.ClassName,
|
||||||
|
student.Reason,
|
||||||
|
student.MakeupScore,
|
||||||
|
SeatNumber = (index + 1).ToString("D3")
|
||||||
|
})
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
@@ -699,7 +802,7 @@ public sealed class MakeupExamsController(
|
|||||||
.Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken);
|
.Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken);
|
||||||
if (!studentId.HasValue)
|
if (!studentId.HasValue)
|
||||||
return ConflictProblem("当前账号未关联学生档案。");
|
return ConflictProblem("当前账号未关联学生档案。");
|
||||||
return Ok(await db.MakeupExamEnrollments.AsNoTracking()
|
var schedule = await db.MakeupExamEnrollments.AsNoTracking()
|
||||||
.Where(x => x.StudentId == studentId &&
|
.Where(x => x.StudentId == studentId &&
|
||||||
x.MakeupExamSession!.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published)
|
x.MakeupExamSession!.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published)
|
||||||
.OrderBy(x => x.MakeupExamSession!.ExamDate)
|
.OrderBy(x => x.MakeupExamSession!.ExamDate)
|
||||||
@@ -724,7 +827,49 @@ public sealed class MakeupExamsController(
|
|||||||
x.MakeupScore,
|
x.MakeupScore,
|
||||||
IsMakeup = true
|
IsMakeup = true
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken);
|
||||||
|
if (schedule.Count == 0) return Ok(schedule);
|
||||||
|
|
||||||
|
var sessionIds = schedule.Select(x => x.Id).Distinct().ToArray();
|
||||||
|
var seatRows = await db.MakeupExamEnrollments.AsNoTracking()
|
||||||
|
.WhereIn(sessionIds, x => x.MakeupExamSessionId)
|
||||||
|
.OrderBy(x => x.Student!.StudentNumber)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.MakeupExamSessionId,
|
||||||
|
x.StudentId,
|
||||||
|
x.Student!.StudentNumber
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var seatNumbers = seatRows
|
||||||
|
.GroupBy(x => x.MakeupExamSessionId)
|
||||||
|
.SelectMany(group => group.Select((row, index) => new
|
||||||
|
{
|
||||||
|
row.MakeupExamSessionId,
|
||||||
|
row.StudentId,
|
||||||
|
SeatNumber = (index + 1).ToString("D3")
|
||||||
|
}))
|
||||||
|
.ToDictionary(x => (x.MakeupExamSessionId, x.StudentId), x => x.SeatNumber);
|
||||||
|
|
||||||
|
return Ok(schedule.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.PlanName,
|
||||||
|
x.ExamDate,
|
||||||
|
x.StartPeriod,
|
||||||
|
x.PeriodCount,
|
||||||
|
x.StartsAt,
|
||||||
|
x.EndsAt,
|
||||||
|
x.TaskNumber,
|
||||||
|
x.CourseCode,
|
||||||
|
x.CourseName,
|
||||||
|
x.ClassroomName,
|
||||||
|
x.BuildingName,
|
||||||
|
x.Reason,
|
||||||
|
x.MakeupScore,
|
||||||
|
SeatNumber = seatNumbers.GetValueOrDefault((x.Id, studentId.Value)),
|
||||||
|
x.IsMakeup
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
if (scope.IsInRole(SystemRoles.Teacher))
|
if (scope.IsInRole(SystemRoles.Teacher))
|
||||||
{
|
{
|
||||||
@@ -941,6 +1086,15 @@ public sealed record CreateMakeupExamSessionRequest(
|
|||||||
IReadOnlyCollection<Guid>? InvigilatorIds,
|
IReadOnlyCollection<Guid>? InvigilatorIds,
|
||||||
[MaxLength(500)] string? Notes);
|
[MaxLength(500)] string? Notes);
|
||||||
|
|
||||||
|
public sealed record CreateMakeupExamSessionsBatchRequest(
|
||||||
|
[Required] IReadOnlyCollection<Guid> TeachingTaskIds,
|
||||||
|
DateOnly ExamDate,
|
||||||
|
[Range(1, 30)] int StartPeriod,
|
||||||
|
[Range(1, 6)] int PeriodCount,
|
||||||
|
Guid? RequiredBuildingId,
|
||||||
|
[Range(1, 10)] int RequiredInvigilatorCount,
|
||||||
|
[MaxLength(500)] string? Notes);
|
||||||
|
|
||||||
public sealed record EnrollStudentsRequest(
|
public sealed record EnrollStudentsRequest(
|
||||||
[Required] IReadOnlyCollection<Guid> StudentIds);
|
[Required] IReadOnlyCollection<Guid> StudentIds);
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,14 @@ public sealed class ExamArrangementService(AppDbContext db)
|
|||||||
|
|
||||||
public async Task<ExamArrangementResult> ArrangeAsync(
|
public async Task<ExamArrangementResult> ArrangeAsync(
|
||||||
Guid planId,
|
Guid planId,
|
||||||
|
IReadOnlyCollection<Guid>? requestedSessionIds,
|
||||||
|
bool assignClassrooms,
|
||||||
|
bool assignInvigilators,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (!assignClassrooms && !assignInvigilators)
|
||||||
|
return ExamArrangementResult.Fail("请至少选择分配考场或分配监考教师。");
|
||||||
|
|
||||||
var plan = await db.ExamPlans
|
var plan = await db.ExamPlans
|
||||||
.Include(x => x.AcademicTerm)
|
.Include(x => x.AcademicTerm)
|
||||||
.Include(x => x.Sessions)
|
.Include(x => x.Sessions)
|
||||||
@@ -27,9 +33,23 @@ public sealed class ExamArrangementService(AppDbContext db)
|
|||||||
if (plan.Status != ExamPlanStatus.Draft)
|
if (plan.Status != ExamPlanStatus.Draft)
|
||||||
return ExamArrangementResult.Fail("只有草稿状态的考试计划可以自动编排。");
|
return ExamArrangementResult.Fail("只有草稿状态的考试计划可以自动编排。");
|
||||||
|
|
||||||
var sessions = plan.Sessions.ToList();
|
var requestedIds = (requestedSessionIds ?? [])
|
||||||
|
.Distinct()
|
||||||
|
.ToHashSet();
|
||||||
|
if (requestedIds.Count > 100)
|
||||||
|
return ExamArrangementResult.Fail("一次最多处理100个考试场次。");
|
||||||
|
if (requestedIds.Count > 0 &&
|
||||||
|
plan.Sessions.Count(x => requestedIds.Contains(x.Id)) != requestedIds.Count)
|
||||||
|
return ExamArrangementResult.Fail("所选场次不存在或不属于当前考试计划。");
|
||||||
|
|
||||||
|
var sessions = plan.Sessions
|
||||||
|
.Where(x => requestedIds.Count == 0 || requestedIds.Contains(x.Id))
|
||||||
|
.OrderBy(x => x.ExamDate)
|
||||||
|
.ThenBy(x => x.StartPeriod)
|
||||||
|
.ThenByDescending(x => x.RequiredInvigilatorCount)
|
||||||
|
.ToList();
|
||||||
if (sessions.Count == 0)
|
if (sessions.Count == 0)
|
||||||
return ExamArrangementResult.Fail("考试计划中没有场次。");
|
return ExamArrangementResult.Fail("没有可处理的考试场次。");
|
||||||
|
|
||||||
var termId = plan.AcademicTermId;
|
var termId = plan.AcademicTermId;
|
||||||
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
|
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
|
||||||
@@ -44,8 +64,8 @@ public sealed class ExamArrangementService(AppDbContext db)
|
|||||||
|
|
||||||
int assignedRooms = 0;
|
int assignedRooms = 0;
|
||||||
int assignedInvigilators = 0;
|
int assignedInvigilators = 0;
|
||||||
int skippedRooms = 0;
|
int unavailableRooms = 0;
|
||||||
int skippedInvigilators = 0;
|
int unavailableInvigilators = 0;
|
||||||
var messages = new List<string>();
|
var messages = new List<string>();
|
||||||
|
|
||||||
// Track occupied time slots to avoid conflicts
|
// Track occupied time slots to avoid conflicts
|
||||||
@@ -69,7 +89,7 @@ public sealed class ExamArrangementService(AppDbContext db)
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
// ── Auto-assign classroom ──
|
// ── Auto-assign classroom ──
|
||||||
if (!session.ClassroomId.HasValue)
|
if (assignClassrooms && !session.ClassroomId.HasValue)
|
||||||
{
|
{
|
||||||
var room = await FindBestClassroomAsync(
|
var room = await FindBestClassroomAsync(
|
||||||
session, studentCount, occupiedRooms, cancellationToken);
|
session, studentCount, occupiedRooms, cancellationToken);
|
||||||
@@ -83,20 +103,15 @@ public sealed class ExamArrangementService(AppDbContext db)
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
skippedRooms++;
|
unavailableRooms++;
|
||||||
messages.Add(
|
messages.Add(
|
||||||
$"“{session.TeachingTask!.Course!.Name}”:无可用考场(需≥{studentCount}座)");
|
$"“{session.TeachingTask!.Course!.Name}”:无可用考场(需≥{studentCount}座)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
skippedRooms++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Auto-assign invigilators ──
|
// ── Auto-assign invigilators ──
|
||||||
var currentInvigilatorCount = session.Invigilators.Count;
|
var currentInvigilatorCount = session.Invigilators.Count;
|
||||||
var needed = session.RequiredInvigilatorCount - currentInvigilatorCount;
|
var needed = session.RequiredInvigilatorCount - currentInvigilatorCount;
|
||||||
if (needed > 0)
|
if (assignInvigilators && needed > 0)
|
||||||
{
|
{
|
||||||
var courseTeacherIds = session.TeachingTask!.Teachers
|
var courseTeacherIds = session.TeachingTask!.Teachers
|
||||||
.Select(x => x.TeacherId).ToHashSet();
|
.Select(x => x.TeacherId).ToHashSet();
|
||||||
@@ -116,12 +131,11 @@ public sealed class ExamArrangementService(AppDbContext db)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (newlyAssigned.Count < needed)
|
if (newlyAssigned.Count < needed)
|
||||||
|
{
|
||||||
|
unavailableInvigilators += needed - newlyAssigned.Count;
|
||||||
messages.Add(
|
messages.Add(
|
||||||
$"“{session.TeachingTask!.Course!.Name}”:仅找到{newlyAssigned.Count}/{needed}名监考教师");
|
$"“{session.TeachingTask!.Course!.Name}”:仅找到{newlyAssigned.Count}/{needed}名监考教师");
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
skippedInvigilators++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,8 +143,9 @@ public sealed class ExamArrangementService(AppDbContext db)
|
|||||||
|
|
||||||
return new ExamArrangementResult(
|
return new ExamArrangementResult(
|
||||||
true,
|
true,
|
||||||
$"{assignedRooms}个考场、{assignedInvigilators}名监考已分配。" +
|
$"{sessions.Count}个场次处理完成,分配{assignedRooms}个考场、{assignedInvigilators}名监考教师。" +
|
||||||
(skippedRooms > 0 ? $" {skippedRooms}个场次无可用考场。" : "") +
|
(unavailableRooms > 0 ? $" {unavailableRooms}个场次暂无可用考场。" : "") +
|
||||||
|
(unavailableInvigilators > 0 ? $" 仍缺{unavailableInvigilators}名监考教师。" : "") +
|
||||||
(messages.Count > 0 ? $" 详情:{string.Join(";", messages.Take(10))}" : ""));
|
(messages.Count > 0 ? $" 详情:{string.Join(";", messages.Take(10))}" : ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,14 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
|||||||
|
|
||||||
public async Task<ExamArrangementResult> ArrangeAsync(
|
public async Task<ExamArrangementResult> ArrangeAsync(
|
||||||
Guid planId,
|
Guid planId,
|
||||||
|
IReadOnlyCollection<Guid>? requestedSessionIds,
|
||||||
|
bool assignClassrooms,
|
||||||
|
bool assignInvigilators,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (!assignClassrooms && !assignInvigilators)
|
||||||
|
return ExamArrangementResult.Fail("请至少选择分配考场或分配监考教师。");
|
||||||
|
|
||||||
var plan = await db.MakeupExamPlans
|
var plan = await db.MakeupExamPlans
|
||||||
.Include(x => x.AcademicTerm)
|
.Include(x => x.AcademicTerm)
|
||||||
.Include(x => x.Sessions)
|
.Include(x => x.Sessions)
|
||||||
@@ -27,9 +33,23 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
|||||||
if (plan.Status != MakeupExamPlanStatus.Draft)
|
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||||
return ExamArrangementResult.Fail("只有草稿状态的补考计划可以自动编排。");
|
return ExamArrangementResult.Fail("只有草稿状态的补考计划可以自动编排。");
|
||||||
|
|
||||||
var sessions = plan.Sessions.ToList();
|
var requestedIds = (requestedSessionIds ?? [])
|
||||||
|
.Distinct()
|
||||||
|
.ToHashSet();
|
||||||
|
if (requestedIds.Count > 100)
|
||||||
|
return ExamArrangementResult.Fail("一次最多处理100个补考场次。");
|
||||||
|
if (requestedIds.Count > 0 &&
|
||||||
|
plan.Sessions.Count(x => requestedIds.Contains(x.Id)) != requestedIds.Count)
|
||||||
|
return ExamArrangementResult.Fail("所选场次不存在或不属于当前补考计划。");
|
||||||
|
|
||||||
|
var sessions = plan.Sessions
|
||||||
|
.Where(x => requestedIds.Count == 0 || requestedIds.Contains(x.Id))
|
||||||
|
.OrderBy(x => x.ExamDate)
|
||||||
|
.ThenBy(x => x.StartPeriod)
|
||||||
|
.ThenByDescending(x => x.RequiredInvigilatorCount)
|
||||||
|
.ToList();
|
||||||
if (sessions.Count == 0)
|
if (sessions.Count == 0)
|
||||||
return ExamArrangementResult.Fail("补考计划中没有场次。");
|
return ExamArrangementResult.Fail("没有可处理的补考场次。");
|
||||||
|
|
||||||
var termId = plan.AcademicTermId;
|
var termId = plan.AcademicTermId;
|
||||||
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
|
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
|
||||||
@@ -44,8 +64,8 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
|||||||
|
|
||||||
int assignedRooms = 0;
|
int assignedRooms = 0;
|
||||||
int assignedInvigilators = 0;
|
int assignedInvigilators = 0;
|
||||||
int skippedRooms = 0;
|
int unavailableRooms = 0;
|
||||||
int skippedInvigilators = 0;
|
int unavailableInvigilators = 0;
|
||||||
var messages = new List<string>();
|
var messages = new List<string>();
|
||||||
|
|
||||||
var occupiedRooms = sessions
|
var occupiedRooms = sessions
|
||||||
@@ -66,7 +86,7 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
|||||||
.CountAsync(x => x.MakeupExamSessionId == session.Id, cancellationToken);
|
.CountAsync(x => x.MakeupExamSessionId == session.Id, cancellationToken);
|
||||||
|
|
||||||
// Auto-assign classroom
|
// Auto-assign classroom
|
||||||
if (!session.ClassroomId.HasValue)
|
if (assignClassrooms && !session.ClassroomId.HasValue)
|
||||||
{
|
{
|
||||||
var room = await FindBestClassroomAsync(
|
var room = await FindBestClassroomAsync(
|
||||||
session, enrolledCount, occupiedRooms, cancellationToken);
|
session, enrolledCount, occupiedRooms, cancellationToken);
|
||||||
@@ -80,20 +100,15 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
skippedRooms++;
|
unavailableRooms++;
|
||||||
messages.Add(
|
messages.Add(
|
||||||
$"\"{session.TeachingTask!.Course!.Name}\":无可用考场(需≥{enrolledCount}座)");
|
$"\"{session.TeachingTask!.Course!.Name}\":无可用考场(需≥{enrolledCount}座)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
skippedRooms++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-assign invigilators
|
// Auto-assign invigilators
|
||||||
var currentInvigilatorCount = session.Invigilators.Count;
|
var currentInvigilatorCount = session.Invigilators.Count;
|
||||||
var needed = session.RequiredInvigilatorCount - currentInvigilatorCount;
|
var needed = session.RequiredInvigilatorCount - currentInvigilatorCount;
|
||||||
if (needed > 0)
|
if (assignInvigilators && needed > 0)
|
||||||
{
|
{
|
||||||
var courseTeacherIds = session.TeachingTask!.Teachers
|
var courseTeacherIds = session.TeachingTask!.Teachers
|
||||||
.Select(x => x.TeacherId).ToHashSet();
|
.Select(x => x.TeacherId).ToHashSet();
|
||||||
@@ -113,12 +128,11 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (newlyAssigned.Count < needed)
|
if (newlyAssigned.Count < needed)
|
||||||
|
{
|
||||||
|
unavailableInvigilators += needed - newlyAssigned.Count;
|
||||||
messages.Add(
|
messages.Add(
|
||||||
$"\"{session.TeachingTask!.Course!.Name}\":仅找到{newlyAssigned.Count}/{needed}名监考教师");
|
$"\"{session.TeachingTask!.Course!.Name}\":仅找到{newlyAssigned.Count}/{needed}名监考教师");
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
skippedInvigilators++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,8 +140,9 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
|||||||
|
|
||||||
return new ExamArrangementResult(
|
return new ExamArrangementResult(
|
||||||
true,
|
true,
|
||||||
$"{assignedRooms}个考场、{assignedInvigilators}名监考已分配。" +
|
$"{sessions.Count}个场次处理完成,分配{assignedRooms}个考场、{assignedInvigilators}名监考教师。" +
|
||||||
(skippedRooms > 0 ? $" {skippedRooms}个场次无可用考场。" : "") +
|
(unavailableRooms > 0 ? $" {unavailableRooms}个场次暂无可用考场。" : "") +
|
||||||
|
(unavailableInvigilators > 0 ? $" 仍缺{unavailableInvigilators}名监考教师。" : "") +
|
||||||
(messages.Count > 0 ? $" 详情:{string.Join(";", messages.Take(10))}" : ""));
|
(messages.Count > 0 ? $" 详情:{string.Join(";", messages.Take(10))}" : ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Infrastructure.Exams;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class ExamArrangementServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Normal_arrangement_only_updates_selected_sessions_and_requested_resource()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options;
|
||||||
|
await using var db = new AppDbContext(options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
var seed = await SeedBaseDataAsync(db, "NORMAL");
|
||||||
|
|
||||||
|
var plan = new ExamPlan
|
||||||
|
{
|
||||||
|
AcademicTermId = seed.Term.Id,
|
||||||
|
Name = "期末考试"
|
||||||
|
};
|
||||||
|
var selected = NewExamSession(plan.Id, seed.FirstTask.Id);
|
||||||
|
var untouched = NewExamSession(plan.Id, seed.SecondTask.Id);
|
||||||
|
plan.Sessions.Add(selected);
|
||||||
|
plan.Sessions.Add(untouched);
|
||||||
|
db.ExamPlans.Add(plan);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await new ExamArrangementService(db).ArrangeAsync(
|
||||||
|
plan.Id,
|
||||||
|
[selected.Id],
|
||||||
|
assignClassrooms: true,
|
||||||
|
assignInvigilators: false,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.NotNull(selected.ClassroomId);
|
||||||
|
Assert.Null(untouched.ClassroomId);
|
||||||
|
Assert.Empty(selected.Invigilators);
|
||||||
|
Assert.Contains("1个场次处理完成", result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Makeup_arrangement_only_updates_selected_sessions_and_requested_resource()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options;
|
||||||
|
await using var db = new AppDbContext(options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
var seed = await SeedBaseDataAsync(db, "MAKEUP");
|
||||||
|
|
||||||
|
var plan = new MakeupExamPlan
|
||||||
|
{
|
||||||
|
AcademicTermId = seed.Term.Id,
|
||||||
|
Name = "补考计划"
|
||||||
|
};
|
||||||
|
var selected = NewMakeupExamSession(plan.Id, seed.FirstTask.Id);
|
||||||
|
var untouched = NewMakeupExamSession(plan.Id, seed.SecondTask.Id);
|
||||||
|
plan.Sessions.Add(selected);
|
||||||
|
plan.Sessions.Add(untouched);
|
||||||
|
db.MakeupExamPlans.Add(plan);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await new MakeupExamArrangementService(db).ArrangeAsync(
|
||||||
|
plan.Id,
|
||||||
|
[selected.Id],
|
||||||
|
assignClassrooms: true,
|
||||||
|
assignInvigilators: false,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.NotNull(selected.ClassroomId);
|
||||||
|
Assert.Null(untouched.ClassroomId);
|
||||||
|
Assert.Empty(selected.Invigilators);
|
||||||
|
Assert.Contains("1个场次处理完成", result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExamSession NewExamSession(Guid planId, Guid taskId) => new()
|
||||||
|
{
|
||||||
|
ExamPlanId = planId,
|
||||||
|
TeachingTaskId = taskId,
|
||||||
|
ExamDate = new DateOnly(2026, 12, 28),
|
||||||
|
StartPeriod = 1,
|
||||||
|
PeriodCount = 2,
|
||||||
|
StartsAt = new DateTime(2026, 12, 28, 8, 0, 0, DateTimeKind.Utc),
|
||||||
|
EndsAt = new DateTime(2026, 12, 28, 9, 50, 0, DateTimeKind.Utc),
|
||||||
|
RequiredInvigilatorCount = 1
|
||||||
|
};
|
||||||
|
|
||||||
|
private static MakeupExamSession NewMakeupExamSession(Guid planId, Guid taskId) => new()
|
||||||
|
{
|
||||||
|
MakeupExamPlanId = planId,
|
||||||
|
TeachingTaskId = taskId,
|
||||||
|
ExamDate = new DateOnly(2026, 12, 28),
|
||||||
|
StartPeriod = 1,
|
||||||
|
PeriodCount = 2,
|
||||||
|
StartsAt = new DateTime(2026, 12, 28, 8, 0, 0, DateTimeKind.Utc),
|
||||||
|
EndsAt = new DateTime(2026, 12, 28, 9, 50, 0, DateTimeKind.Utc),
|
||||||
|
RequiredInvigilatorCount = 1
|
||||||
|
};
|
||||||
|
|
||||||
|
private static async Task<SeedData> SeedBaseDataAsync(AppDbContext db, string suffix)
|
||||||
|
{
|
||||||
|
var term = new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = $"2026-{suffix}",
|
||||||
|
Name = $"2026 {suffix}",
|
||||||
|
AcademicYear = "2026-2027",
|
||||||
|
Season = TermSeason.Autumn,
|
||||||
|
StartDate = new DateOnly(2026, 9, 1),
|
||||||
|
EndDate = new DateOnly(2027, 1, 15)
|
||||||
|
};
|
||||||
|
var campus = new Campus { Code = $"C-{suffix}", Name = "主校区" };
|
||||||
|
var college = new College
|
||||||
|
{
|
||||||
|
Code = $"COL-{suffix}",
|
||||||
|
Name = "计算机学院",
|
||||||
|
CampusId = campus.Id
|
||||||
|
};
|
||||||
|
var building = new Building
|
||||||
|
{
|
||||||
|
Code = $"B-{suffix}",
|
||||||
|
Name = "第一教学楼",
|
||||||
|
CampusId = campus.Id
|
||||||
|
};
|
||||||
|
var classroom = new Classroom
|
||||||
|
{
|
||||||
|
Code = $"R-{suffix}",
|
||||||
|
Name = "101",
|
||||||
|
BuildingId = building.Id,
|
||||||
|
Capacity = 80
|
||||||
|
};
|
||||||
|
var firstCourse = new Course
|
||||||
|
{
|
||||||
|
Code = $"COURSE-1-{suffix}",
|
||||||
|
Name = "程序设计",
|
||||||
|
CollegeId = college.Id
|
||||||
|
};
|
||||||
|
var secondCourse = new Course
|
||||||
|
{
|
||||||
|
Code = $"COURSE-2-{suffix}",
|
||||||
|
Name = "大学英语",
|
||||||
|
CollegeId = college.Id
|
||||||
|
};
|
||||||
|
var firstTask = new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = $"TASK-1-{suffix}",
|
||||||
|
Name = "程序设计教学班",
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
CourseId = firstCourse.Id,
|
||||||
|
Status = TeachingTaskStatus.Published
|
||||||
|
};
|
||||||
|
var secondTask = new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = $"TASK-2-{suffix}",
|
||||||
|
Name = "大学英语教学班",
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
CourseId = secondCourse.Id,
|
||||||
|
Status = TeachingTaskStatus.Published
|
||||||
|
};
|
||||||
|
|
||||||
|
db.AddRange(
|
||||||
|
term,
|
||||||
|
campus,
|
||||||
|
college,
|
||||||
|
building,
|
||||||
|
classroom,
|
||||||
|
firstCourse,
|
||||||
|
secondCourse,
|
||||||
|
firstTask,
|
||||||
|
secondTask,
|
||||||
|
new ScheduleTimeSlot
|
||||||
|
{
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
PeriodNumber = 1,
|
||||||
|
Name = "第1节",
|
||||||
|
StartsAt = new TimeOnly(8, 0),
|
||||||
|
EndsAt = new TimeOnly(8, 45)
|
||||||
|
},
|
||||||
|
new ScheduleTimeSlot
|
||||||
|
{
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
PeriodNumber = 2,
|
||||||
|
Name = "第2节",
|
||||||
|
StartsAt = new TimeOnly(9, 0),
|
||||||
|
EndsAt = new TimeOnly(9, 50)
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return new SeedData(term, firstTask, secondTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record SeedData(
|
||||||
|
AcademicTerm Term,
|
||||||
|
TeachingTask FirstTask,
|
||||||
|
TeachingTask SecondTask);
|
||||||
|
}
|
||||||
+230
-20
@@ -25,11 +25,55 @@ const sessionDialog = ref(false)
|
|||||||
const editingSession = ref<any | null>(null)
|
const editingSession = ref<any | null>(null)
|
||||||
const rosterDrawer = ref(false)
|
const rosterDrawer = ref(false)
|
||||||
const roster = ref<any | null>(null)
|
const roster = ref<any | null>(null)
|
||||||
|
const selectedTaskIds = ref<string[]>([])
|
||||||
|
const selectedSessionIds = ref<string[]>([])
|
||||||
const planForm = reactive<Record<string, any>>({})
|
const planForm = reactive<Record<string, any>>({})
|
||||||
const sessionForm = reactive<Record<string, any>>({})
|
const sessionForm = reactive<Record<string, any>>({})
|
||||||
|
const taskFilter = reactive({ keyword: '', collegeId: '', courseNature: '' })
|
||||||
|
const sessionFilter = reactive({ keyword: '', allocation: '' })
|
||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
Draft: '草稿', Published: '已发布', Archived: '已归档',
|
Draft: '草稿', Published: '已发布', Archived: '已归档',
|
||||||
}
|
}
|
||||||
|
const courseNatureLabels: Record<string, string> = {
|
||||||
|
GeneralRequired: '公共必修',
|
||||||
|
GeneralElective: '公共选修',
|
||||||
|
MajorRequired: '专业必修',
|
||||||
|
MajorElective: '专业选修',
|
||||||
|
Practice: '实践教学',
|
||||||
|
}
|
||||||
|
const taskColleges = computed(() => {
|
||||||
|
const values = new Map<string, string>()
|
||||||
|
tasks.value.forEach((task: any) => values.set(task.collegeId, task.collegeName))
|
||||||
|
return Array.from(values, ([id, name]) => ({ id, name }))
|
||||||
|
})
|
||||||
|
const filteredTasks = computed(() => {
|
||||||
|
const keyword = taskFilter.keyword.trim().toLowerCase()
|
||||||
|
const arrangedIds = new Set((selected.value?.sessions ?? [])
|
||||||
|
.filter((session: any) => session.id !== editingSession.value?.id)
|
||||||
|
.map((session: any) => session.teachingTaskId))
|
||||||
|
return tasks.value.filter((task: any) => {
|
||||||
|
if (arrangedIds.has(task.id)) return false
|
||||||
|
if (taskFilter.collegeId && task.collegeId !== taskFilter.collegeId) return false
|
||||||
|
if (taskFilter.courseNature && task.courseNature !== taskFilter.courseNature) return false
|
||||||
|
if (!keyword) return true
|
||||||
|
return [task.taskNumber, task.courseCode, task.courseName, task.name,
|
||||||
|
...(task.teacherNames ?? []), ...(task.classNames ?? [])]
|
||||||
|
.some((value: any) => String(value ?? '').toLowerCase().includes(keyword))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const filteredSessions = computed(() => {
|
||||||
|
const keyword = sessionFilter.keyword.trim().toLowerCase()
|
||||||
|
return (selected.value?.sessions ?? []).filter((session: any) => {
|
||||||
|
if (sessionFilter.allocation === 'room' && session.classroomId) return false
|
||||||
|
if (sessionFilter.allocation === 'invigilator' &&
|
||||||
|
session.invigilatorIds.length >= session.requiredInvigilatorCount) return false
|
||||||
|
if (sessionFilter.allocation === 'complete' &&
|
||||||
|
(!session.classroomId || session.invigilatorIds.length < session.requiredInvigilatorCount)) return false
|
||||||
|
if (!keyword) return true
|
||||||
|
return [session.taskNumber, session.courseCode, session.courseName, session.taskName]
|
||||||
|
.some((value: any) => String(value ?? '').toLowerCase().includes(keyword))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
function timeText(startsAt: string) {
|
function timeText(startsAt: string) {
|
||||||
return new Date(startsAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
|
return new Date(startsAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||||
@@ -63,6 +107,18 @@ async function load() {
|
|||||||
}
|
}
|
||||||
async function selectPlan(id: string) {
|
async function selectPlan(id: string) {
|
||||||
selected.value = (await http.get(`/exams/plans/${id}`)).data
|
selected.value = (await http.get(`/exams/plans/${id}`)).data
|
||||||
|
selectedSessionIds.value = []
|
||||||
|
await loadPlanResources(selected.value.academicTermId)
|
||||||
|
}
|
||||||
|
async function loadPlanResources(academicTermId: string) {
|
||||||
|
const [taskRes, slotRes] = await Promise.all([
|
||||||
|
http.get('/teaching-tasks/options', {
|
||||||
|
params: { academicTermId, status: 'Published' },
|
||||||
|
}),
|
||||||
|
http.get('/exams/time-slots-for-term', { params: { academicTermId } }),
|
||||||
|
])
|
||||||
|
tasks.value = taskRes.data
|
||||||
|
timeSlots.value = slotRes.data
|
||||||
}
|
}
|
||||||
function openPlan() {
|
function openPlan() {
|
||||||
Object.assign(planForm, {
|
Object.assign(planForm, {
|
||||||
@@ -80,6 +136,8 @@ async function savePlan() {
|
|||||||
}
|
}
|
||||||
function openSession(existing?: any) {
|
function openSession(existing?: any) {
|
||||||
editingSession.value = existing ?? null
|
editingSession.value = existing ?? null
|
||||||
|
selectedTaskIds.value = existing ? [existing.teachingTaskId] : []
|
||||||
|
Object.assign(taskFilter, { keyword: '', collegeId: '', courseNature: '' })
|
||||||
const firstSlot = timeSlots.value[0]
|
const firstSlot = timeSlots.value[0]
|
||||||
Object.assign(sessionForm, {
|
Object.assign(sessionForm, {
|
||||||
teachingTaskId: existing?.teachingTaskId ?? undefined,
|
teachingTaskId: existing?.teachingTaskId ?? undefined,
|
||||||
@@ -100,7 +158,20 @@ async function saveSession() {
|
|||||||
if (editingSession.value) {
|
if (editingSession.value) {
|
||||||
await http.put(`/exams/plans/${selected.value.id}/sessions/${editingSession.value.id}`, payload)
|
await http.put(`/exams/plans/${selected.value.id}/sessions/${editingSession.value.id}`, payload)
|
||||||
} else {
|
} else {
|
||||||
await http.post(`/exams/plans/${selected.value.id}/sessions`, payload)
|
if (selectedTaskIds.value.length === 0) {
|
||||||
|
ElMessage.warning('请至少选择一个教学班')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const res = await http.post(`/exams/plans/${selected.value.id}/sessions/batch`, {
|
||||||
|
teachingTaskIds: selectedTaskIds.value,
|
||||||
|
examDate: payload.examDate,
|
||||||
|
startPeriod: payload.startPeriod,
|
||||||
|
periodCount: payload.periodCount,
|
||||||
|
requiredBuildingId: payload.requiredBuildingId,
|
||||||
|
requiredInvigilatorCount: payload.requiredInvigilatorCount,
|
||||||
|
notes: payload.notes,
|
||||||
|
})
|
||||||
|
ElMessage.success(`已批量创建 ${res.data.createdCount} 个考试场次`)
|
||||||
}
|
}
|
||||||
sessionDialog.value = false
|
sessionDialog.value = false
|
||||||
editingSession.value = null
|
editingSession.value = null
|
||||||
@@ -116,13 +187,52 @@ async function removeSession(row: any) {
|
|||||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function autoArrange() {
|
function selectFilteredTasks() {
|
||||||
|
selectedTaskIds.value = Array.from(new Set([
|
||||||
|
...selectedTaskIds.value,
|
||||||
|
...filteredTasks.value.map((task: any) => task.id),
|
||||||
|
])).slice(0, 100)
|
||||||
|
}
|
||||||
|
function clearFilteredTasks() {
|
||||||
|
const visibleIds = new Set(filteredTasks.value.map((task: any) => task.id))
|
||||||
|
selectedTaskIds.value = selectedTaskIds.value.filter(id => !visibleIds.has(id))
|
||||||
|
}
|
||||||
|
function toggleTaskSelection(id: string, checked: boolean) {
|
||||||
|
if (checked && selectedTaskIds.value.length >= 100) {
|
||||||
|
ElMessage.warning('一次最多选择100个教学班')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectedTaskIds.value = checked
|
||||||
|
? Array.from(new Set([...selectedTaskIds.value, id]))
|
||||||
|
: selectedTaskIds.value.filter(value => value !== id)
|
||||||
|
}
|
||||||
|
function selectFilteredSessions() {
|
||||||
|
selectedSessionIds.value = filteredSessions.value.map((session: any) => session.id)
|
||||||
|
}
|
||||||
|
function clearSessionSelection() {
|
||||||
|
selectedSessionIds.value = []
|
||||||
|
}
|
||||||
|
function toggleSessionSelection(id: string, checked: boolean) {
|
||||||
|
selectedSessionIds.value = checked
|
||||||
|
? Array.from(new Set([...selectedSessionIds.value, id]))
|
||||||
|
: selectedSessionIds.value.filter(value => value !== id)
|
||||||
|
}
|
||||||
|
async function autoArrange(mode: 'rooms' | 'invigilators' | 'all') {
|
||||||
try {
|
try {
|
||||||
|
const target = selectedSessionIds.value.length
|
||||||
|
? `所选 ${selectedSessionIds.value.length} 个场次`
|
||||||
|
: '当前计划全部场次'
|
||||||
|
const action = mode === 'rooms' ? '分配考场'
|
||||||
|
: mode === 'invigilators' ? '分配监考教师' : '分配考场和监考教师'
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
'系统将为未分配考场的场次自动匹配教室,为未满监考的场次自动分配教师。',
|
`系统将为${target}${action},已有安排不会被覆盖。`,
|
||||||
'自动编排', { type: 'info', confirmButtonText: '开始编排' })
|
`一键${action}`, { type: 'info', confirmButtonText: '开始分配' })
|
||||||
arrangeLoading.value = true
|
arrangeLoading.value = true
|
||||||
const res = await http.post(`/exams/plans/${selected.value.id}/auto-arrange`)
|
const res = await http.post(`/exams/plans/${selected.value.id}/auto-arrange`, {
|
||||||
|
sessionIds: selectedSessionIds.value,
|
||||||
|
assignClassrooms: mode !== 'invigilators',
|
||||||
|
assignInvigilators: mode !== 'rooms',
|
||||||
|
})
|
||||||
ElMessage.success(res.data.message)
|
ElMessage.success(res.data.message)
|
||||||
await selectPlan(selected.value.id)
|
await selectPlan(selected.value.id)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -164,21 +274,16 @@ function filteredRooms() {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
if (isManager.value) {
|
if (isManager.value) {
|
||||||
const currentTermId = (await http.get('/base-data/terms')).data.find((t: any) => t.isCurrent)?.id
|
const [termRes, roomRes, teacherRes, buildingRes] = await Promise.all([
|
||||||
const [termRes, taskRes, roomRes, teacherRes, buildingRes, slotRes] = await Promise.all([
|
|
||||||
http.get('/base-data/terms'),
|
http.get('/base-data/terms'),
|
||||||
http.get('/teaching-tasks', { params: { page: 1, pageSize: 200 } }),
|
|
||||||
http.get('/base-data/classrooms'),
|
http.get('/base-data/classrooms'),
|
||||||
http.get('/personnel/teachers', { params: { page: 1, pageSize: 200, teacherStatus: 'Active' } }),
|
http.get('/personnel/teachers', { params: { page: 1, pageSize: 200, teacherStatus: 'Active' } }),
|
||||||
http.get('/base-data/buildings'),
|
http.get('/base-data/buildings'),
|
||||||
currentTermId ? http.get('/exams/time-slots-for-term', { params: { academicTermId: currentTermId } }) : Promise.resolve({ data: [] }),
|
|
||||||
])
|
])
|
||||||
terms.value = termRes.data
|
terms.value = termRes.data
|
||||||
tasks.value = taskRes.data.items
|
|
||||||
rooms.value = roomRes.data
|
rooms.value = roomRes.data
|
||||||
teachers.value = teacherRes.data.items
|
teachers.value = teacherRes.data.items
|
||||||
buildings.value = buildingRes.data
|
buildings.value = buildingRes.data
|
||||||
timeSlots.value = slotRes.data
|
|
||||||
}
|
}
|
||||||
await load()
|
await load()
|
||||||
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
|
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
|
||||||
@@ -212,14 +317,35 @@ onMounted(async () => {
|
|||||||
<p>{{ selected.termName }} · {{ selected.sessions.length }} 个考试场次</p>
|
<p>{{ selected.termName }} · {{ selected.sessions.length }} 个考试场次</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="exam-actions">
|
<div class="exam-actions">
|
||||||
<el-button v-if="selected.status === 'Draft'" :icon="Setting" @click="autoArrange" :loading="arrangeLoading">自动编排</el-button>
|
<el-button v-if="selected.status === 'Draft'" @click="autoArrange('rooms')" :loading="arrangeLoading">一键分配考场</el-button>
|
||||||
<el-button v-if="selected.status === 'Draft'" :icon="Plus" @click="openSession()">安排场次</el-button>
|
<el-button v-if="selected.status === 'Draft'" @click="autoArrange('invigilators')" :loading="arrangeLoading">一键分配监考</el-button>
|
||||||
|
<el-button v-if="selected.status === 'Draft'" :icon="Setting" @click="autoArrange('all')" :loading="arrangeLoading">一键完成</el-button>
|
||||||
|
<el-button v-if="selected.status === 'Draft'" :icon="Plus" @click="openSession()">批量安排场次</el-button>
|
||||||
<el-button v-if="selected.status === 'Draft'" type="primary" :icon="Promotion" @click="publishPlan">发布计划</el-button>
|
<el-button v-if="selected.status === 'Draft'" type="primary" :icon="Promotion" @click="publishPlan">发布计划</el-button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<div class="exam-filter-bar">
|
||||||
|
<el-input v-model="sessionFilter.keyword" clearable placeholder="筛选课程、课程号或教学班号" />
|
||||||
|
<el-select v-model="sessionFilter.allocation" clearable placeholder="全部分配状态">
|
||||||
|
<el-option label="待分配考场" value="room" />
|
||||||
|
<el-option label="待补足监考" value="invigilator" />
|
||||||
|
<el-option label="已完成分配" value="complete" />
|
||||||
|
</el-select>
|
||||||
|
<span>显示 {{ filteredSessions.length }}/{{ selected.sessions.length }} 个场次</span>
|
||||||
|
<template v-if="selected.status === 'Draft'">
|
||||||
|
<el-button link type="primary" @click="selectFilteredSessions">选择筛选结果</el-button>
|
||||||
|
<el-button link @click="clearSessionSelection">清空选择</el-button>
|
||||||
|
<el-tag v-if="selectedSessionIds.length" type="info">已选 {{ selectedSessionIds.length }} 个</el-tag>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
<div class="exam-timeline">
|
<div class="exam-timeline">
|
||||||
<article v-for="session in selected.sessions" :key="session.id" :class="{ unassigned: !session.classroomId }">
|
<article v-for="session in filteredSessions" :key="session.id" :class="{ unassigned: !session.classroomId }">
|
||||||
<time>
|
<time>
|
||||||
|
<el-checkbox
|
||||||
|
v-if="selected.status === 'Draft'"
|
||||||
|
:model-value="selectedSessionIds.includes(session.id)"
|
||||||
|
@change="toggleSessionSelection(session.id, Boolean($event))"
|
||||||
|
>选择</el-checkbox>
|
||||||
<b>{{ dateOnlyText(session.examDate) }}</b>
|
<b>{{ dateOnlyText(session.examDate) }}</b>
|
||||||
<span>{{ periodLabel(session) }}</span>
|
<span>{{ periodLabel(session) }}</span>
|
||||||
</time>
|
</time>
|
||||||
@@ -245,7 +371,8 @@ onMounted(async () => {
|
|||||||
<el-button v-if="selected.status === 'Draft'" link type="danger" @click="removeSession(session)">移除</el-button>
|
<el-button v-if="selected.status === 'Draft'" link type="danger" @click="removeSession(session)">移除</el-button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<el-empty v-if="!selected.sessions.length" description="尚未安排考试场次,点击「安排场次」开始。" />
|
<el-empty v-if="!selected.sessions.length" description="尚未安排考试场次,点击「批量安排场次」开始。" />
|
||||||
|
<el-empty v-else-if="!filteredSessions.length" description="没有符合筛选条件的考试场次" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -260,6 +387,7 @@ onMounted(async () => {
|
|||||||
<span>{{ item.courseCode }} · {{ item.taskNumber }}</span>
|
<span>{{ item.courseCode }} · {{ item.taskNumber }}</span>
|
||||||
<h3>{{ item.courseName }}</h3>
|
<h3>{{ item.courseName }}</h3>
|
||||||
<p>{{ item.buildingName ? `${item.buildingName} · ${item.classroomName}` : '考场待定' }}</p>
|
<p>{{ item.buildingName ? `${item.buildingName} · ${item.classroomName}` : '考场待定' }}</p>
|
||||||
|
<el-tag v-if="!isTeacher && item.seatNumber" size="small" type="success">座位号 {{ item.seatNumber }}</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<footer>
|
<footer>
|
||||||
<el-icon><UserFilled /></el-icon>
|
<el-icon><UserFilled /></el-icon>
|
||||||
@@ -287,13 +415,49 @@ onMounted(async () => {
|
|||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- Session Dialog -->
|
<!-- Session Dialog -->
|
||||||
<el-dialog v-model="sessionDialog" :title="editingSession ? '编辑考试场次' : '安排考试场次'" width="720px" top="5vh">
|
<el-dialog v-model="sessionDialog" :title="editingSession ? '编辑考试场次' : '批量安排考试场次'" width="960px" top="3vh">
|
||||||
<el-form label-position="top">
|
<el-form label-position="top">
|
||||||
<el-form-item label="教学班">
|
<el-form-item v-if="editingSession" label="教学班">
|
||||||
<el-select v-model="sessionForm.teachingTaskId" filterable placeholder="选择教学班">
|
<el-select v-model="sessionForm.teachingTaskId" filterable placeholder="选择教学班">
|
||||||
<el-option v-for="x in tasks" :key="x.id" :label="`${x.taskNumber} · ${x.courseName}`" :value="x.id" />
|
<el-option v-for="x in tasks" :key="x.id" :label="`${x.taskNumber} · ${x.courseName}`" :value="x.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<template v-else>
|
||||||
|
<div class="task-filter-grid">
|
||||||
|
<el-input v-model="taskFilter.keyword" clearable placeholder="课程、教学班、教师或行政班" />
|
||||||
|
<el-select v-model="taskFilter.collegeId" clearable placeholder="全部开课单位">
|
||||||
|
<el-option v-for="x in taskColleges" :key="x.id" :label="x.name" :value="x.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="taskFilter.courseNature" clearable placeholder="全部课程性质">
|
||||||
|
<el-option v-for="(label, value) in courseNatureLabels" :key="value" :label="label" :value="value" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<div class="task-selection-actions">
|
||||||
|
<span>可选 {{ filteredTasks.length }} 个,已选 {{ selectedTaskIds.length }} 个</span>
|
||||||
|
<el-button link type="primary" @click="selectFilteredTasks">选择全部筛选结果</el-button>
|
||||||
|
<el-button link @click="clearFilteredTasks">清除筛选结果选择</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="filteredTasks" height="260" size="small">
|
||||||
|
<el-table-column label="选择" width="64">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-checkbox
|
||||||
|
:model-value="selectedTaskIds.includes(scope.row.id)"
|
||||||
|
@click.stop
|
||||||
|
@update:model-value="toggleTaskSelection(scope.row.id, Boolean($event))"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="taskNumber" label="教学班号" width="130" />
|
||||||
|
<el-table-column prop="courseName" label="课程" min-width="150" />
|
||||||
|
<el-table-column prop="collegeName" label="开课单位" min-width="130" />
|
||||||
|
<el-table-column label="任课教师" min-width="120">
|
||||||
|
<template #default="scope">{{ scope.row.teacherNames?.join('、') || '待定' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="行政班" min-width="150">
|
||||||
|
<template #default="scope">{{ scope.row.classNames?.join('、') || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</template>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<el-form-item label="考试日期">
|
<el-form-item label="考试日期">
|
||||||
<el-date-picker v-model="sessionForm.examDate" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" />
|
<el-date-picker v-model="sessionForm.examDate" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" />
|
||||||
@@ -319,12 +483,12 @@ onMounted(async () => {
|
|||||||
<el-input-number v-model="sessionForm.requiredInvigilatorCount" :min="1" :max="10" />
|
<el-input-number v-model="sessionForm.requiredInvigilatorCount" :min="1" :max="10" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
<el-form-item label="考场(可留空,由自动编排分配)">
|
<el-form-item v-if="editingSession" label="考场(可留空,由自动编排分配)">
|
||||||
<el-select v-model="sessionForm.classroomId" clearable filterable placeholder="留空由自动编排分配">
|
<el-select v-model="sessionForm.classroomId" clearable filterable placeholder="留空由自动编排分配">
|
||||||
<el-option v-for="x in filteredRooms()" :key="x.id" :label="classroomLabel(x)" :value="x.id" />
|
<el-option v-for="x in filteredRooms()" :key="x.id" :label="classroomLabel(x)" :value="x.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="监考教师(可留空,由自动编排分配)">
|
<el-form-item v-if="editingSession" label="监考教师(可留空,由自动编排分配)">
|
||||||
<el-select v-model="sessionForm.invigilatorIds" multiple filterable clearable placeholder="留空由自动编排分配">
|
<el-select v-model="sessionForm.invigilatorIds" multiple filterable clearable placeholder="留空由自动编排分配">
|
||||||
<el-option v-for="x in teachers" :key="x.id" :label="`${x.teacherNumber} · ${x.name}`" :value="x.id" />
|
<el-option v-for="x in teachers" :key="x.id" :label="`${x.teacherNumber} · ${x.name}`" :value="x.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
@@ -335,13 +499,16 @@ onMounted(async () => {
|
|||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="sessionDialog = false">取消</el-button>
|
<el-button @click="sessionDialog = false">取消</el-button>
|
||||||
<el-button type="primary" @click="saveSession">{{ editingSession ? '保存修改' : '保存场次' }}</el-button>
|
<el-button type="primary" @click="saveSession">
|
||||||
|
{{ editingSession ? '保存修改' : `批量创建 ${selectedTaskIds.length} 个场次` }}
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- Roster Drawer -->
|
<!-- Roster Drawer -->
|
||||||
<el-drawer v-model="rosterDrawer" title="考生名单" size="560px">
|
<el-drawer v-model="rosterDrawer" title="考生名单" size="560px">
|
||||||
<el-table v-if="roster" :data="roster.students">
|
<el-table v-if="roster" :data="roster.students">
|
||||||
|
<el-table-column prop="seatNumber" label="座位号" width="80" />
|
||||||
<el-table-column prop="studentNumber" label="学号" width="130" />
|
<el-table-column prop="studentNumber" label="学号" width="130" />
|
||||||
<el-table-column prop="name" label="姓名" width="90" />
|
<el-table-column prop="name" label="姓名" width="90" />
|
||||||
<el-table-column prop="className" label="行政班" />
|
<el-table-column prop="className" label="行政班" />
|
||||||
@@ -354,8 +521,51 @@ onMounted(async () => {
|
|||||||
.exam-actions {
|
.exam-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.exam-filter-bar,
|
||||||
|
.task-filter-grid,
|
||||||
|
.task-selection-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.exam-filter-bar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.exam-filter-bar .el-input {
|
||||||
|
width: min(320px, 100%);
|
||||||
|
}
|
||||||
|
.exam-filter-bar .el-select {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
.task-filter-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(240px, 1.5fr) minmax(160px, 1fr) minmax(160px, 1fr);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.task-selection-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.task-selection-actions span {
|
||||||
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
.exam-timeline article.unassigned {
|
.exam-timeline article.unassigned {
|
||||||
border-left-color: #e6a23c;
|
border-left-color: #e6a23c;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.exam-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.task-filter-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.task-selection-actions {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -35,11 +35,55 @@ const enrollmentSession = ref<any | null>(null)
|
|||||||
const eligibleStudents = ref<any[]>([])
|
const eligibleStudents = ref<any[]>([])
|
||||||
const eligibleLoading = ref(false)
|
const eligibleLoading = ref(false)
|
||||||
const selectedStudentIds = ref<string[]>([])
|
const selectedStudentIds = ref<string[]>([])
|
||||||
|
const selectedTaskIds = ref<string[]>([])
|
||||||
|
const selectedSessionIds = ref<string[]>([])
|
||||||
const planForm = reactive<Record<string, any>>({})
|
const planForm = reactive<Record<string, any>>({})
|
||||||
const sessionForm = reactive<Record<string, any>>({})
|
const sessionForm = reactive<Record<string, any>>({})
|
||||||
|
const taskFilter = reactive({ keyword: '', collegeId: '', courseNature: '' })
|
||||||
|
const sessionFilter = reactive({ keyword: '', allocation: '' })
|
||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
Draft: '草稿', Published: '已发布', Archived: '已归档',
|
Draft: '草稿', Published: '已发布', Archived: '已归档',
|
||||||
}
|
}
|
||||||
|
const courseNatureLabels: Record<string, string> = {
|
||||||
|
GeneralRequired: '公共必修',
|
||||||
|
GeneralElective: '公共选修',
|
||||||
|
MajorRequired: '专业必修',
|
||||||
|
MajorElective: '专业选修',
|
||||||
|
Practice: '实践教学',
|
||||||
|
}
|
||||||
|
const taskColleges = computed(() => {
|
||||||
|
const values = new Map<string, string>()
|
||||||
|
tasks.value.forEach((task: any) => values.set(task.collegeId, task.collegeName))
|
||||||
|
return Array.from(values, ([id, name]) => ({ id, name }))
|
||||||
|
})
|
||||||
|
const filteredTasks = computed(() => {
|
||||||
|
const keyword = taskFilter.keyword.trim().toLowerCase()
|
||||||
|
const arrangedIds = new Set((selected.value?.sessions ?? [])
|
||||||
|
.filter((session: any) => session.id !== editingSession.value?.id)
|
||||||
|
.map((session: any) => session.teachingTaskId))
|
||||||
|
return tasks.value.filter((task: any) => {
|
||||||
|
if (arrangedIds.has(task.id)) return false
|
||||||
|
if (taskFilter.collegeId && task.collegeId !== taskFilter.collegeId) return false
|
||||||
|
if (taskFilter.courseNature && task.courseNature !== taskFilter.courseNature) return false
|
||||||
|
if (!keyword) return true
|
||||||
|
return [task.taskNumber, task.courseCode, task.courseName, task.name,
|
||||||
|
...(task.teacherNames ?? []), ...(task.classNames ?? [])]
|
||||||
|
.some((value: any) => String(value ?? '').toLowerCase().includes(keyword))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const filteredSessions = computed(() => {
|
||||||
|
const keyword = sessionFilter.keyword.trim().toLowerCase()
|
||||||
|
return (selected.value?.sessions ?? []).filter((session: any) => {
|
||||||
|
if (sessionFilter.allocation === 'room' && session.classroomId) return false
|
||||||
|
if (sessionFilter.allocation === 'invigilator' &&
|
||||||
|
session.invigilatorIds.length >= session.requiredInvigilatorCount) return false
|
||||||
|
if (sessionFilter.allocation === 'complete' &&
|
||||||
|
(!session.classroomId || session.invigilatorIds.length < session.requiredInvigilatorCount)) return false
|
||||||
|
if (!keyword) return true
|
||||||
|
return [session.taskNumber, session.courseCode, session.courseName, session.taskName]
|
||||||
|
.some((value: any) => String(value ?? '').toLowerCase().includes(keyword))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
function timeText(startsAt: string) {
|
function timeText(startsAt: string) {
|
||||||
return new Date(startsAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
|
return new Date(startsAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||||
@@ -76,6 +120,18 @@ async function load() {
|
|||||||
}
|
}
|
||||||
async function selectPlan(id: string) {
|
async function selectPlan(id: string) {
|
||||||
selected.value = (await http.get(`/makeup-exams/plans/${id}`)).data
|
selected.value = (await http.get(`/makeup-exams/plans/${id}`)).data
|
||||||
|
selectedSessionIds.value = []
|
||||||
|
await loadPlanResources(selected.value.academicTermId)
|
||||||
|
}
|
||||||
|
async function loadPlanResources(academicTermId: string) {
|
||||||
|
const [taskRes, slotRes] = await Promise.all([
|
||||||
|
http.get('/teaching-tasks/options', {
|
||||||
|
params: { academicTermId, status: 'Published' },
|
||||||
|
}),
|
||||||
|
http.get('/makeup-exams/time-slots-for-term', { params: { academicTermId } }),
|
||||||
|
])
|
||||||
|
tasks.value = taskRes.data
|
||||||
|
timeSlots.value = slotRes.data
|
||||||
}
|
}
|
||||||
function openPlan() {
|
function openPlan() {
|
||||||
Object.assign(planForm, {
|
Object.assign(planForm, {
|
||||||
@@ -93,6 +149,8 @@ async function savePlan() {
|
|||||||
}
|
}
|
||||||
function openSession(existing?: any) {
|
function openSession(existing?: any) {
|
||||||
editingSession.value = existing ?? null
|
editingSession.value = existing ?? null
|
||||||
|
selectedTaskIds.value = existing ? [existing.teachingTaskId] : []
|
||||||
|
Object.assign(taskFilter, { keyword: '', collegeId: '', courseNature: '' })
|
||||||
const firstSlot = timeSlots.value[0]
|
const firstSlot = timeSlots.value[0]
|
||||||
Object.assign(sessionForm, {
|
Object.assign(sessionForm, {
|
||||||
teachingTaskId: existing?.teachingTaskId ?? undefined,
|
teachingTaskId: existing?.teachingTaskId ?? undefined,
|
||||||
@@ -113,7 +171,20 @@ async function saveSession() {
|
|||||||
if (editingSession.value) {
|
if (editingSession.value) {
|
||||||
await http.put(`/makeup-exams/plans/${selected.value.id}/sessions/${editingSession.value.id}`, payload)
|
await http.put(`/makeup-exams/plans/${selected.value.id}/sessions/${editingSession.value.id}`, payload)
|
||||||
} else {
|
} else {
|
||||||
await http.post(`/makeup-exams/plans/${selected.value.id}/sessions`, payload)
|
if (selectedTaskIds.value.length === 0) {
|
||||||
|
ElMessage.warning('请至少选择一个教学班')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const res = await http.post(`/makeup-exams/plans/${selected.value.id}/sessions/batch`, {
|
||||||
|
teachingTaskIds: selectedTaskIds.value,
|
||||||
|
examDate: payload.examDate,
|
||||||
|
startPeriod: payload.startPeriod,
|
||||||
|
periodCount: payload.periodCount,
|
||||||
|
requiredBuildingId: payload.requiredBuildingId,
|
||||||
|
requiredInvigilatorCount: payload.requiredInvigilatorCount,
|
||||||
|
notes: payload.notes,
|
||||||
|
})
|
||||||
|
ElMessage.success(`已批量创建 ${res.data.createdCount} 个补考场次`)
|
||||||
}
|
}
|
||||||
sessionDialog.value = false
|
sessionDialog.value = false
|
||||||
editingSession.value = null
|
editingSession.value = null
|
||||||
@@ -129,13 +200,52 @@ async function removeSession(row: any) {
|
|||||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function autoArrange() {
|
function selectFilteredTasks() {
|
||||||
|
selectedTaskIds.value = Array.from(new Set([
|
||||||
|
...selectedTaskIds.value,
|
||||||
|
...filteredTasks.value.map((task: any) => task.id),
|
||||||
|
])).slice(0, 100)
|
||||||
|
}
|
||||||
|
function clearFilteredTasks() {
|
||||||
|
const visibleIds = new Set(filteredTasks.value.map((task: any) => task.id))
|
||||||
|
selectedTaskIds.value = selectedTaskIds.value.filter(id => !visibleIds.has(id))
|
||||||
|
}
|
||||||
|
function toggleTaskSelection(id: string, checked: boolean) {
|
||||||
|
if (checked && selectedTaskIds.value.length >= 100) {
|
||||||
|
ElMessage.warning('一次最多选择100个教学班')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectedTaskIds.value = checked
|
||||||
|
? Array.from(new Set([...selectedTaskIds.value, id]))
|
||||||
|
: selectedTaskIds.value.filter(value => value !== id)
|
||||||
|
}
|
||||||
|
function selectFilteredSessions() {
|
||||||
|
selectedSessionIds.value = filteredSessions.value.map((session: any) => session.id)
|
||||||
|
}
|
||||||
|
function clearSessionSelection() {
|
||||||
|
selectedSessionIds.value = []
|
||||||
|
}
|
||||||
|
function toggleSessionSelection(id: string, checked: boolean) {
|
||||||
|
selectedSessionIds.value = checked
|
||||||
|
? Array.from(new Set([...selectedSessionIds.value, id]))
|
||||||
|
: selectedSessionIds.value.filter(value => value !== id)
|
||||||
|
}
|
||||||
|
async function autoArrange(mode: 'rooms' | 'invigilators' | 'all') {
|
||||||
try {
|
try {
|
||||||
|
const target = selectedSessionIds.value.length
|
||||||
|
? `所选 ${selectedSessionIds.value.length} 个场次`
|
||||||
|
: '当前计划全部场次'
|
||||||
|
const action = mode === 'rooms' ? '分配考场'
|
||||||
|
: mode === 'invigilators' ? '分配监考教师' : '分配考场和监考教师'
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
'系统将为未分配考场的场次自动匹配教室,为未满监考的场次自动分配教师。',
|
`系统将为${target}${action},已有安排不会被覆盖。`,
|
||||||
'自动编排', { type: 'info', confirmButtonText: '开始编排' })
|
`一键${action}`, { type: 'info', confirmButtonText: '开始分配' })
|
||||||
arrangeLoading.value = true
|
arrangeLoading.value = true
|
||||||
const res = await http.post(`/makeup-exams/plans/${selected.value.id}/auto-arrange`)
|
const res = await http.post(`/makeup-exams/plans/${selected.value.id}/auto-arrange`, {
|
||||||
|
sessionIds: selectedSessionIds.value,
|
||||||
|
assignClassrooms: mode !== 'invigilators',
|
||||||
|
assignInvigilators: mode !== 'rooms',
|
||||||
|
})
|
||||||
ElMessage.success(res.data.message)
|
ElMessage.success(res.data.message)
|
||||||
await selectPlan(selected.value.id)
|
await selectPlan(selected.value.id)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -307,21 +417,16 @@ function stopPolling() {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
if (isManager.value) {
|
if (isManager.value) {
|
||||||
const currentTermId = (await http.get('/base-data/terms')).data.find((t: any) => t.isCurrent)?.id
|
const [termRes, roomRes, teacherRes, buildingRes] = await Promise.all([
|
||||||
const [termRes, taskRes, roomRes, teacherRes, buildingRes, slotRes] = await Promise.all([
|
|
||||||
http.get('/base-data/terms'),
|
http.get('/base-data/terms'),
|
||||||
http.get('/teaching-tasks', { params: { page: 1, pageSize: 200 } }),
|
|
||||||
http.get('/base-data/classrooms'),
|
http.get('/base-data/classrooms'),
|
||||||
http.get('/personnel/teachers', { params: { page: 1, pageSize: 200, teacherStatus: 'Active' } }),
|
http.get('/personnel/teachers', { params: { page: 1, pageSize: 200, teacherStatus: 'Active' } }),
|
||||||
http.get('/base-data/buildings'),
|
http.get('/base-data/buildings'),
|
||||||
currentTermId ? http.get('/makeup-exams/time-slots-for-term', { params: { academicTermId: currentTermId } }) : Promise.resolve({ data: [] }),
|
|
||||||
])
|
])
|
||||||
terms.value = termRes.data
|
terms.value = termRes.data
|
||||||
tasks.value = taskRes.data.items
|
|
||||||
rooms.value = roomRes.data
|
rooms.value = roomRes.data
|
||||||
teachers.value = teacherRes.data.items
|
teachers.value = teacherRes.data.items
|
||||||
buildings.value = buildingRes.data
|
buildings.value = buildingRes.data
|
||||||
timeSlots.value = slotRes.data
|
|
||||||
}
|
}
|
||||||
await load()
|
await load()
|
||||||
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
|
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
|
||||||
@@ -356,8 +461,10 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="exam-actions">
|
<div class="exam-actions">
|
||||||
<el-button v-if="selected.status === 'Draft'" :icon="MagicStick" type="success" @click="startAutoCreate">一键生成</el-button>
|
<el-button v-if="selected.status === 'Draft'" :icon="MagicStick" type="success" @click="startAutoCreate">一键生成</el-button>
|
||||||
<el-button v-if="selected.status === 'Draft'" :icon="Setting" @click="autoArrange" :loading="arrangeLoading">自动编排</el-button>
|
<el-button v-if="selected.status === 'Draft'" @click="autoArrange('rooms')" :loading="arrangeLoading">一键分配考场</el-button>
|
||||||
<el-button v-if="selected.status === 'Draft'" :icon="Plus" @click="openSession()">安排补考场次</el-button>
|
<el-button v-if="selected.status === 'Draft'" @click="autoArrange('invigilators')" :loading="arrangeLoading">一键分配监考</el-button>
|
||||||
|
<el-button v-if="selected.status === 'Draft'" :icon="Setting" @click="autoArrange('all')" :loading="arrangeLoading">一键完成</el-button>
|
||||||
|
<el-button v-if="selected.status === 'Draft'" :icon="Plus" @click="openSession()">批量安排场次</el-button>
|
||||||
<el-button v-if="selected.status === 'Draft'" type="primary" :icon="Promotion" @click="publishPlan">发布计划</el-button>
|
<el-button v-if="selected.status === 'Draft'" type="primary" :icon="Promotion" @click="publishPlan">发布计划</el-button>
|
||||||
<el-button v-if="selected.status === 'Published'" type="info" @click="archivePlan">归档</el-button>
|
<el-button v-if="selected.status === 'Published'" type="info" @click="archivePlan">归档</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -369,9 +476,28 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
</el-alert>
|
</el-alert>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="exam-filter-bar">
|
||||||
|
<el-input v-model="sessionFilter.keyword" clearable placeholder="筛选课程、课程号或教学班号" />
|
||||||
|
<el-select v-model="sessionFilter.allocation" clearable placeholder="全部分配状态">
|
||||||
|
<el-option label="待分配考场" value="room" />
|
||||||
|
<el-option label="待补足监考" value="invigilator" />
|
||||||
|
<el-option label="已完成分配" value="complete" />
|
||||||
|
</el-select>
|
||||||
|
<span>显示 {{ filteredSessions.length }}/{{ selected.sessions.length }} 个场次</span>
|
||||||
|
<template v-if="selected.status === 'Draft'">
|
||||||
|
<el-button link type="primary" @click="selectFilteredSessions">选择筛选结果</el-button>
|
||||||
|
<el-button link @click="clearSessionSelection">清空选择</el-button>
|
||||||
|
<el-tag v-if="selectedSessionIds.length" type="info">已选 {{ selectedSessionIds.length }} 个</el-tag>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
<div class="exam-timeline">
|
<div class="exam-timeline">
|
||||||
<article v-for="session in selected.sessions" :key="session.id" :class="{ unassigned: !session.classroomId }">
|
<article v-for="session in filteredSessions" :key="session.id" :class="{ unassigned: !session.classroomId }">
|
||||||
<time>
|
<time>
|
||||||
|
<el-checkbox
|
||||||
|
v-if="selected.status === 'Draft'"
|
||||||
|
:model-value="selectedSessionIds.includes(session.id)"
|
||||||
|
@change="toggleSessionSelection(session.id, Boolean($event))"
|
||||||
|
>选择</el-checkbox>
|
||||||
<b>{{ dateOnlyText(session.examDate) }}</b>
|
<b>{{ dateOnlyText(session.examDate) }}</b>
|
||||||
<span>{{ periodLabel(session) }}</span>
|
<span>{{ periodLabel(session) }}</span>
|
||||||
</time>
|
</time>
|
||||||
@@ -381,7 +507,7 @@ onMounted(async () => {
|
|||||||
<p>
|
<p>
|
||||||
<template v-if="session.classroomId">{{ session.buildingName }} · {{ session.classroomName }} · {{ session.classroomCapacity }}座</template>
|
<template v-if="session.classroomId">{{ session.buildingName }} · {{ session.classroomName }} · {{ session.classroomCapacity }}座</template>
|
||||||
<template v-else><el-tag size="small" type="warning">待分配考场</el-tag></template>
|
<template v-else><el-tag size="small" type="warning">待分配考场</el-tag></template>
|
||||||
· {{ session.studentCount }} 人
|
· {{ session.enrolledCount ?? session.studentCount ?? 0 }} 人
|
||||||
<template v-if="session.requiredBuildingName"> · 限{{ session.requiredBuildingName }}</template>
|
<template v-if="session.requiredBuildingName"> · 限{{ session.requiredBuildingName }}</template>
|
||||||
<template v-if="session.requiredInvigilatorCount > 1"> · {{ session.requiredInvigilatorCount }}名监考</template>
|
<template v-if="session.requiredInvigilatorCount > 1"> · {{ session.requiredInvigilatorCount }}名监考</template>
|
||||||
</p>
|
</p>
|
||||||
@@ -398,7 +524,8 @@ onMounted(async () => {
|
|||||||
<el-button v-if="selected.status === 'Draft'" link type="danger" @click="removeSession(session)">移除</el-button>
|
<el-button v-if="selected.status === 'Draft'" link type="danger" @click="removeSession(session)">移除</el-button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<el-empty v-if="!selected.sessions.length" description="尚未安排补考场次,点击「安排补考场次」开始。" />
|
<el-empty v-if="!selected.sessions.length" description="尚未安排补考场次,点击「批量安排场次」开始。" />
|
||||||
|
<el-empty v-else-if="!filteredSessions.length" description="没有符合筛选条件的补考场次" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -443,6 +570,7 @@ onMounted(async () => {
|
|||||||
<span>{{ item.courseCode }} · {{ item.taskNumber }}</span>
|
<span>{{ item.courseCode }} · {{ item.taskNumber }}</span>
|
||||||
<h3>{{ item.courseName }}</h3>
|
<h3>{{ item.courseName }}</h3>
|
||||||
<p>{{ item.buildingName ? `${item.buildingName} · ${item.classroomName}` : '考场待定' }}</p>
|
<p>{{ item.buildingName ? `${item.buildingName} · ${item.classroomName}` : '考场待定' }}</p>
|
||||||
|
<el-tag v-if="item.seatNumber" size="small" type="success">座位号 {{ item.seatNumber }}</el-tag>
|
||||||
<p v-if="item.reason" style="margin-top: 4px">
|
<p v-if="item.reason" style="margin-top: 4px">
|
||||||
<span>补考原因:{{ item.reason === 1 ? '不及格' : item.reason === 2 ? '缺考' : '缓考通过' }}</span>
|
<span>补考原因:{{ item.reason === 1 ? '不及格' : item.reason === 2 ? '缺考' : '缓考通过' }}</span>
|
||||||
<span v-if="item.makeupScore != null"> · 成绩:<b>{{ item.makeupScore }}</b></span>
|
<span v-if="item.makeupScore != null"> · 成绩:<b>{{ item.makeupScore }}</b></span>
|
||||||
@@ -494,13 +622,49 @@ onMounted(async () => {
|
|||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- Session Dialog -->
|
<!-- Session Dialog -->
|
||||||
<el-dialog v-model="sessionDialog" :title="editingSession ? '编辑补考场次' : '安排补考场次'" width="720px" top="5vh">
|
<el-dialog v-model="sessionDialog" :title="editingSession ? '编辑补考场次' : '批量安排补考场次'" width="960px" top="3vh">
|
||||||
<el-form label-position="top">
|
<el-form label-position="top">
|
||||||
<el-form-item label="教学班">
|
<el-form-item v-if="editingSession" label="教学班">
|
||||||
<el-select v-model="sessionForm.teachingTaskId" filterable placeholder="选择教学班">
|
<el-select v-model="sessionForm.teachingTaskId" filterable placeholder="选择教学班">
|
||||||
<el-option v-for="x in tasks" :key="x.id" :label="`${x.taskNumber} · ${x.courseName}`" :value="x.id" />
|
<el-option v-for="x in tasks" :key="x.id" :label="`${x.taskNumber} · ${x.courseName}`" :value="x.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<template v-else>
|
||||||
|
<div class="task-filter-grid">
|
||||||
|
<el-input v-model="taskFilter.keyword" clearable placeholder="课程、教学班、教师或行政班" />
|
||||||
|
<el-select v-model="taskFilter.collegeId" clearable placeholder="全部开课单位">
|
||||||
|
<el-option v-for="x in taskColleges" :key="x.id" :label="x.name" :value="x.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="taskFilter.courseNature" clearable placeholder="全部课程性质">
|
||||||
|
<el-option v-for="(label, value) in courseNatureLabels" :key="value" :label="label" :value="value" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<div class="task-selection-actions">
|
||||||
|
<span>可选 {{ filteredTasks.length }} 个,已选 {{ selectedTaskIds.length }} 个</span>
|
||||||
|
<el-button link type="primary" @click="selectFilteredTasks">选择全部筛选结果</el-button>
|
||||||
|
<el-button link @click="clearFilteredTasks">清除筛选结果选择</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="filteredTasks" height="260" size="small">
|
||||||
|
<el-table-column label="选择" width="64">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-checkbox
|
||||||
|
:model-value="selectedTaskIds.includes(scope.row.id)"
|
||||||
|
@click.stop
|
||||||
|
@update:model-value="toggleTaskSelection(scope.row.id, Boolean($event))"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="taskNumber" label="教学班号" width="130" />
|
||||||
|
<el-table-column prop="courseName" label="课程" min-width="150" />
|
||||||
|
<el-table-column prop="collegeName" label="开课单位" min-width="130" />
|
||||||
|
<el-table-column label="任课教师" min-width="120">
|
||||||
|
<template #default="scope">{{ scope.row.teacherNames?.join('、') || '待定' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="行政班" min-width="150">
|
||||||
|
<template #default="scope">{{ scope.row.classNames?.join('、') || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</template>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<el-form-item label="考试日期">
|
<el-form-item label="考试日期">
|
||||||
<el-date-picker v-model="sessionForm.examDate" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" />
|
<el-date-picker v-model="sessionForm.examDate" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" />
|
||||||
@@ -526,12 +690,12 @@ onMounted(async () => {
|
|||||||
<el-input-number v-model="sessionForm.requiredInvigilatorCount" :min="1" :max="10" />
|
<el-input-number v-model="sessionForm.requiredInvigilatorCount" :min="1" :max="10" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
<el-form-item label="考场(可留空,由自动编排分配)">
|
<el-form-item v-if="editingSession" label="考场(可留空,由自动编排分配)">
|
||||||
<el-select v-model="sessionForm.classroomId" clearable filterable placeholder="留空由自动编排分配">
|
<el-select v-model="sessionForm.classroomId" clearable filterable placeholder="留空由自动编排分配">
|
||||||
<el-option v-for="x in filteredRooms()" :key="x.id" :label="classroomLabel(x)" :value="x.id" />
|
<el-option v-for="x in filteredRooms()" :key="x.id" :label="classroomLabel(x)" :value="x.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="监考教师(可留空,由自动编排分配)">
|
<el-form-item v-if="editingSession" label="监考教师(可留空,由自动编排分配)">
|
||||||
<el-select v-model="sessionForm.invigilatorIds" multiple filterable clearable placeholder="留空由自动编排分配">
|
<el-select v-model="sessionForm.invigilatorIds" multiple filterable clearable placeholder="留空由自动编排分配">
|
||||||
<el-option v-for="x in teachers" :key="x.id" :label="`${x.teacherNumber} · ${x.name}`" :value="x.id" />
|
<el-option v-for="x in teachers" :key="x.id" :label="`${x.teacherNumber} · ${x.name}`" :value="x.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
@@ -542,13 +706,16 @@ onMounted(async () => {
|
|||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="sessionDialog = false">取消</el-button>
|
<el-button @click="sessionDialog = false">取消</el-button>
|
||||||
<el-button type="primary" @click="saveSession">{{ editingSession ? '保存修改' : '保存场次' }}</el-button>
|
<el-button type="primary" @click="saveSession">
|
||||||
|
{{ editingSession ? '保存修改' : `批量创建 ${selectedTaskIds.length} 个场次` }}
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- Roster Drawer -->
|
<!-- Roster Drawer -->
|
||||||
<el-drawer v-model="rosterDrawer" title="考生名单" size="600px">
|
<el-drawer v-model="rosterDrawer" title="考生名单" size="600px">
|
||||||
<el-table v-if="roster" :data="roster.students">
|
<el-table v-if="roster" :data="roster.students">
|
||||||
|
<el-table-column prop="seatNumber" label="座位号" width="80" />
|
||||||
<el-table-column prop="studentNumber" label="学号" width="120" />
|
<el-table-column prop="studentNumber" label="学号" width="120" />
|
||||||
<el-table-column prop="name" label="姓名" width="80" />
|
<el-table-column prop="name" label="姓名" width="80" />
|
||||||
<el-table-column prop="className" label="行政班" width="130" />
|
<el-table-column prop="className" label="行政班" width="130" />
|
||||||
@@ -629,6 +796,37 @@ onMounted(async () => {
|
|||||||
.exam-actions {
|
.exam-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.exam-filter-bar,
|
||||||
|
.task-filter-grid,
|
||||||
|
.task-selection-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.exam-filter-bar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.exam-filter-bar .el-input {
|
||||||
|
width: min(320px, 100%);
|
||||||
|
}
|
||||||
|
.exam-filter-bar .el-select {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
.task-filter-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(240px, 1.5fr) minmax(160px, 1fr) minmax(160px, 1fr);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.task-selection-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.task-selection-actions span {
|
||||||
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
.exam-timeline article.unassigned {
|
.exam-timeline article.unassigned {
|
||||||
border-left-color: #e6a23c;
|
border-left-color: #e6a23c;
|
||||||
@@ -641,4 +839,16 @@ onMounted(async () => {
|
|||||||
.enrollment-header p {
|
.enrollment-header p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.exam-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.task-filter-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.task-selection-actions {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user