考试排考

This commit is contained in:
2026-07-25 14:51:17 +08:00 Unverified
parent 36049af692
commit 4e7da6709e
12 changed files with 1306 additions and 118 deletions
+375 -49
View File
@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Exams;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -14,11 +15,16 @@ namespace Jiaowu.Api.Controllers;
[Route("api/exams")] [Route("api/exams")]
public sealed class ExamsController( public sealed class ExamsController(
AppDbContext db, AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase ICurrentUserDataScope currentUserDataScope,
ExamArrangementService examArrangementService) : ControllerBase
{ {
private const string Managers = private const string Managers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin; SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
// ═══════════════════════════════════════════
// Plans
// ═══════════════════════════════════════════
[HttpGet("plans")] [HttpGet("plans")]
public async Task<ActionResult> GetPlans( public async Task<ActionResult> GetPlans(
Guid? academicTermId, Guid? academicTermId,
@@ -68,16 +74,29 @@ public sealed class ExamsController(
{ {
x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name, x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name,
x.Status, x.Notes, x.PublishedAt, x.Status, x.Notes, x.PublishedAt,
Sessions = x.Sessions.OrderBy(item => item.StartsAt).Select(item => new Sessions = x.Sessions.OrderBy(item => item.ExamDate)
.ThenBy(item => item.StartPeriod).Select(item => new
{ {
item.Id, item.TeachingTaskId, item.TeachingTask!.TaskNumber, item.Id,
item.TeachingTaskId,
item.TeachingTask!.TaskNumber,
TaskName = item.TeachingTask.Name, TaskName = item.TeachingTask.Name,
CourseCode = item.TeachingTask.Course!.Code, CourseCode = item.TeachingTask.Course!.Code,
CourseName = item.TeachingTask.Course.Name, CourseName = item.TeachingTask.Course.Name,
item.ClassroomId, ClassroomName = item.Classroom!.Name, item.ClassroomId,
BuildingName = item.Classroom.Building!.Name, ClassroomName = item.Classroom != null ? item.Classroom.Name : null,
ClassroomCapacity = item.Classroom.Capacity, BuildingName = item.Classroom != null ? item.Classroom.Building!.Name : null,
item.StartsAt, item.EndsAt, item.Notes, ClassroomCapacity = item.Classroom != null ? (int?)item.Classroom.Capacity : null,
item.ExamDate,
item.StartPeriod,
item.PeriodCount,
item.StartsAt,
item.EndsAt,
item.RequiredBuildingId,
RequiredBuildingName = item.RequiredBuilding != null
? item.RequiredBuilding.Name : null,
item.RequiredInvigilatorCount,
item.Notes,
InvigilatorIds = item.Invigilators.Select(i => i.TeacherId), InvigilatorIds = item.Invigilators.Select(i => i.TeacherId),
InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name), InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name),
StudentCount = db.CourseEnrollments.Count(e => StudentCount = db.CourseEnrollments.Count(e =>
@@ -88,34 +107,97 @@ public sealed class ExamsController(
return plan is null ? NotFound() : Ok(plan); return plan is null ? NotFound() : Ok(plan);
} }
// ═══════════════════════════════════════════
// Sessions
// ═══════════════════════════════════════════
[HttpPost("plans/{planId:guid}/sessions")] [HttpPost("plans/{planId:guid}/sessions")]
[Authorize(Roles = Managers)] [Authorize(Roles = Managers)]
public async Task<ActionResult> CreateSession( public async Task<ActionResult> CreateSession(
Guid planId, Guid planId,
ExamSessionRequest request, CreateExamSessionRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var plan = await db.ExamPlans.FindAsync([planId], cancellationToken); var plan = await db.ExamPlans.FindAsync([planId], cancellationToken);
if (plan is null) return NotFound(); if (plan is null) return NotFound();
if (plan.Status != ExamPlanStatus.Draft) if (plan.Status != ExamPlanStatus.Draft)
return ConflictProblem("已发布的考试计划不能调整场次。"); return ConflictProblem("已发布的考试计划不能调整场次。");
var validation = await ValidateSessionAsync(plan, null, request, cancellationToken);
// Resolve time from slots
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 validation = await ValidateSessionAsync(plan, null,
request.TeachingTaskId, request.ClassroomId, request.InvigilatorIds,
startsAt, endsAt, cancellationToken);
if (validation is not null) return validation; if (validation is not null) return validation;
var session = new ExamSession var session = new ExamSession
{ {
ExamPlanId = planId, ExamPlanId = planId,
TeachingTaskId = request.TeachingTaskId, TeachingTaskId = request.TeachingTaskId,
ClassroomId = request.ClassroomId, ClassroomId = request.ClassroomId,
StartsAt = request.StartsAt.ToUniversalTime(), ExamDate = request.ExamDate,
EndsAt = request.EndsAt.ToUniversalTime(), StartPeriod = request.StartPeriod,
PeriodCount = request.PeriodCount,
StartsAt = startsAt,
EndsAt = endsAt,
RequiredBuildingId = request.RequiredBuildingId,
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
Notes = Normalize(request.Notes), Notes = Normalize(request.Notes),
Invigilators = request.InvigilatorIds.Distinct().Select(id => Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(id =>
new ExamSessionInvigilator { TeacherId = id }).ToList() new ExamSessionInvigilator { TeacherId = id }).ToList()
}; };
db.ExamSessions.Add(session); db.ExamSessions.Add(session);
return await SaveAsync(session.Id, true, cancellationToken); return await SaveAsync(session.Id, true, cancellationToken);
} }
[HttpPut("plans/{planId:guid}/sessions/{id:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> UpdateSession(
Guid planId,
Guid id,
CreateExamSessionRequest request,
CancellationToken cancellationToken)
{
var session = await db.ExamSessions
.Include(x => x.ExamPlan)
.Include(x => x.Invigilators)
.FirstOrDefaultAsync(x => x.Id == id && x.ExamPlanId == planId, cancellationToken);
if (session is null) return NotFound();
if (session.ExamPlan!.Status != ExamPlanStatus.Draft)
return ConflictProblem("已发布的考试计划不能调整场次。");
var timeResult = ResolveExamTime(session.ExamPlan.AcademicTermId,
request.ExamDate, request.StartPeriod, request.PeriodCount);
if (timeResult.Error is not null) return timeResult.Error;
var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt);
var validation = await ValidateSessionAsync(session.ExamPlan, id,
request.TeachingTaskId, request.ClassroomId, request.InvigilatorIds,
startsAt, endsAt, cancellationToken);
if (validation is not null) return validation;
session.TeachingTaskId = request.TeachingTaskId;
session.ClassroomId = request.ClassroomId;
session.ExamDate = request.ExamDate;
session.StartPeriod = request.StartPeriod;
session.PeriodCount = request.PeriodCount;
session.StartsAt = startsAt;
session.EndsAt = endsAt;
session.RequiredBuildingId = request.RequiredBuildingId;
session.RequiredInvigilatorCount = request.RequiredInvigilatorCount;
session.Notes = Normalize(request.Notes);
db.ExamSessionInvigilators.RemoveRange(session.Invigilators);
session.Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(tid =>
new ExamSessionInvigilator { TeacherId = tid }).ToList();
return await SaveAsync(id, false, cancellationToken);
}
[HttpDelete("plans/{planId:guid}/sessions/{id:guid}")] [HttpDelete("plans/{planId:guid}/sessions/{id:guid}")]
[Authorize(Roles = Managers)] [Authorize(Roles = Managers)]
public async Task<ActionResult> DeleteSession( public async Task<ActionResult> DeleteSession(
@@ -132,6 +214,141 @@ public sealed class ExamsController(
return await SaveAsync(id, false, cancellationToken); return await SaveAsync(id, false, cancellationToken);
} }
// ═══════════════════════════════════════════
// Auto-arrange
// ═══════════════════════════════════════════
[HttpPost("plans/{planId:guid}/auto-arrange")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> AutoArrange(
Guid planId,
CancellationToken cancellationToken)
{
var result = await examArrangementService.ArrangeAsync(planId, cancellationToken);
if (!result.Success)
return ConflictProblem(result.Message);
return Ok(new { message = result.Message });
}
// ═══════════════════════════════════════════
// Query helpers
// ═══════════════════════════════════════════
[HttpGet("available-classrooms")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetAvailableClassrooms(
Guid academicTermId,
DateOnly examDate,
int startPeriod,
int periodCount,
Guid? requiredBuildingId,
Guid? planId,
Guid? excludeSessionId,
CancellationToken cancellationToken)
{
var timeResult = ResolveExamTime(
academicTermId, examDate, startPeriod, periodCount);
if (timeResult.Error is not null) return timeResult.Error;
var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt);
var query = db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled);
if (requiredBuildingId.HasValue)
query = query.Where(x => x.BuildingId == requiredBuildingId.Value);
// Exclude classrooms already occupied
var occupiedQuery = db.ExamSessions.AsNoTracking()
.Where(x => x.ClassroomId != null &&
x.StartsAt < endsAt && startsAt < x.EndsAt);
if (planId.HasValue)
occupiedQuery = occupiedQuery.Where(x => x.ExamPlanId == planId.Value);
if (excludeSessionId.HasValue)
occupiedQuery = occupiedQuery.Where(x => x.Id != excludeSessionId.Value);
var occupiedIds = await occupiedQuery.Select(x => x.ClassroomId!.Value)
.ToListAsync(cancellationToken);
if (occupiedIds.Count > 0)
query = query.Where(x => !occupiedIds.Contains(x.Id));
return Ok(await query.OrderBy(x => x.Building!.Name)
.ThenBy(x => x.Capacity)
.Select(x => new
{
x.Id,
x.Name,
x.Capacity,
x.RoomType,
x.BuildingId,
BuildingName = x.Building!.Name
}).ToListAsync(cancellationToken));
}
[HttpGet("available-teachers")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetAvailableTeachers(
Guid academicTermId,
DateOnly examDate,
int startPeriod,
int periodCount,
Guid? planId,
Guid? excludeSessionId,
CancellationToken cancellationToken)
{
var timeResult = ResolveExamTime(
academicTermId, examDate, startPeriod, periodCount);
if (timeResult.Error is not null) return timeResult.Error;
var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt);
var busyQuery = db.ExamSessionInvigilators.AsNoTracking()
.Where(x => x.ExamSession!.StartsAt < endsAt &&
startsAt < x.ExamSession!.EndsAt);
if (planId.HasValue)
busyQuery = busyQuery.Where(x => x.ExamSession!.ExamPlanId == planId.Value);
if (excludeSessionId.HasValue)
busyQuery = busyQuery.Where(x => x.ExamSessionId != excludeSessionId.Value);
var busyIds = await busyQuery.Select(x => x.TeacherId)
.ToListAsync(cancellationToken);
return Ok(await db.Teachers.AsNoTracking()
.Where(x => x.Status == TeacherStatus.Active &&
!busyIds.Contains(x.Id))
.OrderBy(x => x.Name)
.Select(x => new
{
x.Id,
x.TeacherNumber,
x.Name,
x.Title,
CollegeName = x.College!.Name
}).ToListAsync(cancellationToken));
}
[HttpGet("time-slots-for-term")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetTimeSlotsForTerm(
Guid academicTermId,
CancellationToken cancellationToken) =>
Ok(await db.ScheduleTimeSlots.AsNoTracking()
.Where(x => x.AcademicTermId == academicTermId && x.IsEnabled)
.OrderBy(x => x.PeriodNumber)
.Select(x => new
{
x.Id,
x.PeriodNumber,
x.Name,
StartsAt = x.StartsAt.ToString("HH:mm"),
EndsAt = x.EndsAt.ToString("HH:mm"),
x.IsEnabled
})
.ToListAsync(cancellationToken));
// ═══════════════════════════════════════════
// Publish
// ═══════════════════════════════════════════
[HttpPost("plans/{id:guid}/publish")] [HttpPost("plans/{id:guid}/publish")]
[Authorize(Roles = Managers)] [Authorize(Roles = Managers)]
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken) public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
@@ -143,11 +360,22 @@ public sealed class ExamsController(
return ConflictProblem("只有草稿考试计划可以发布。"); return ConflictProblem("只有草稿考试计划可以发布。");
if (plan.Sessions.Count == 0) if (plan.Sessions.Count == 0)
return ConflictProblem("至少安排一个考试场次后才能发布。"); return ConflictProblem("至少安排一个考试场次后才能发布。");
var unassigned = plan.Sessions.Count(x =>
!x.ClassroomId.HasValue || x.Invigilators.Count == 0);
if (unassigned > 0)
return ConflictProblem(
$"还有 {unassigned} 个场次未分配考场或监考教师,请先完成自动编排。");
plan.Status = ExamPlanStatus.Published; plan.Status = ExamPlanStatus.Published;
plan.PublishedAt = DateTime.UtcNow; plan.PublishedAt = DateTime.UtcNow;
return await SaveAsync(id, false, cancellationToken); return await SaveAsync(id, false, cancellationToken);
} }
// ═══════════════════════════════════════════
// Roster
// ═══════════════════════════════════════════
[HttpGet("sessions/{id:guid}/roster")] [HttpGet("sessions/{id:guid}/roster")]
[Authorize(Roles = Managers)] [Authorize(Roles = Managers)]
public async Task<ActionResult> GetRoster(Guid id, CancellationToken cancellationToken) public async Task<ActionResult> GetRoster(Guid id, CancellationToken cancellationToken)
@@ -156,22 +384,31 @@ public sealed class ExamsController(
.Where(x => x.Id == id) .Where(x => x.Id == id)
.Select(x => new .Select(x => new
{ {
x.Id, x.TeachingTaskId, x.TeachingTask!.TaskNumber, x.Id,
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
CourseName = x.TeachingTask.Course!.Name, CourseName = x.TeachingTask.Course!.Name,
ClassroomName = x.Classroom!.Name, x.StartsAt, ClassroomName = x.Classroom != null ? x.Classroom.Name : "待分配",
x.StartsAt,
Students = db.CourseEnrollments Students = db.CourseEnrollments
.Where(e => e.Status == CourseEnrollmentStatus.Enrolled && .Where(e => e.Status == CourseEnrollmentStatus.Enrolled &&
e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId) e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId)
.OrderBy(e => e.Student!.StudentNumber) .OrderBy(e => e.Student!.StudentNumber)
.Select(e => new .Select(e => new
{ {
e.StudentId, e.Student!.StudentNumber, e.Student.Name, e.StudentId,
e.Student!.StudentNumber,
e.Student.Name,
ClassName = e.Student.AdministrativeClass!.Name ClassName = e.Student.AdministrativeClass!.Name
}).ToList() }).ToList()
}).FirstOrDefaultAsync(cancellationToken); }).FirstOrDefaultAsync(cancellationToken);
return session is null ? NotFound() : Ok(session); return session is null ? NotFound() : Ok(session);
} }
// ═══════════════════════════════════════════
// My schedule
// ═══════════════════════════════════════════
[HttpGet("my-schedule")] [HttpGet("my-schedule")]
public async Task<ActionResult> GetMySchedule(CancellationToken cancellationToken) public async Task<ActionResult> GetMySchedule(CancellationToken cancellationToken)
{ {
@@ -180,20 +417,30 @@ public sealed class ExamsController(
{ {
var studentId = await db.Students.Where(x => x.UserId == scope.UserId) var studentId = await db.Students.Where(x => x.UserId == scope.UserId)
.Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken); .Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken);
if (!studentId.HasValue) return ConflictProblem("当前账号未关联学生档案。"); if (!studentId.HasValue)
return ConflictProblem("当前账号未关联学生档案。");
return Ok(await db.ExamSessions.AsNoTracking() return Ok(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 &&
e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId)) e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId))
.OrderBy(x => x.StartsAt).Select(x => new .OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod)
.Select(x => new
{ {
x.Id, PlanName = x.ExamPlan!.Name, x.StartsAt, x.EndsAt, x.Id,
x.TeachingTask!.TaskNumber, CourseCode = x.TeachingTask.Course!.Code, PlanName = x.ExamPlan!.Name,
x.ExamDate,
x.StartPeriod,
x.PeriodCount,
x.StartsAt,
x.EndsAt,
x.TeachingTask!.TaskNumber,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name, CourseName = x.TeachingTask.Course.Name,
ClassroomName = x.Classroom!.Name, ClassroomName = x.Classroom != null ? x.Classroom.Name : null,
BuildingName = x.Classroom.Building!.Name, 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),
IsExam = true
}) })
.ToListAsync(cancellationToken)); .ToListAsync(cancellationToken));
} }
@@ -202,57 +449,127 @@ public sealed class ExamsController(
return Ok(await db.ExamSessions.AsNoTracking() return Ok(await db.ExamSessions.AsNoTracking()
.Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published && .Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published &&
x.Invigilators.Any(i => i.Teacher!.UserId == scope.UserId)) x.Invigilators.Any(i => i.Teacher!.UserId == scope.UserId))
.OrderBy(x => x.StartsAt).Select(x => new .OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod)
.Select(x => new
{ {
x.Id, PlanName = x.ExamPlan!.Name, x.StartsAt, x.EndsAt, x.Id,
x.TeachingTask!.TaskNumber, CourseCode = x.TeachingTask.Course!.Code, PlanName = x.ExamPlan!.Name,
x.ExamDate,
x.StartPeriod,
x.PeriodCount,
x.StartsAt,
x.EndsAt,
x.TeachingTask!.TaskNumber,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name, CourseName = x.TeachingTask.Course.Name,
ClassroomName = x.Classroom!.Name, ClassroomName = x.Classroom != null ? x.Classroom.Name : null,
BuildingName = x.Classroom.Building!.Name, 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),
StudentCount = db.CourseEnrollments.Count(e => StudentCount = db.CourseEnrollments.Count(e =>
e.Status == CourseEnrollmentStatus.Enrolled && e.Status == CourseEnrollmentStatus.Enrolled &&
e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId) e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId),
IsExam = true
}) })
.ToListAsync(cancellationToken)); .ToListAsync(cancellationToken));
} }
return Ok(Array.Empty<object>()); return Ok(Array.Empty<object>());
} }
// ═══════════════════════════════════════════
// Private helpers
// ═══════════════════════════════════════════
private (DateTime StartsAt, DateTime EndsAt, ActionResult? Error) ResolveExamTime(
Guid academicTermId,
DateOnly examDate,
int startPeriod,
int periodCount)
{
var slots = db.ScheduleTimeSlots.AsNoTracking()
.Where(x => x.AcademicTermId == academicTermId && x.IsEnabled)
.OrderBy(x => x.PeriodNumber)
.ToList();
if (slots.Count == 0)
return (default, default,
ValidationProblem("当前学期未配置上课时间表,请先在排课设置中配置节次时间。"));
var slotDict = slots.ToDictionary(x => x.PeriodNumber);
var startSlot = slotDict.GetValueOrDefault(startPeriod);
var endSlot = slotDict.GetValueOrDefault(startPeriod + periodCount - 1);
if (startSlot is null)
return (default, default,
ValidationProblem($"起始节次 {startPeriod} 不在已配置的时间表中。"));
if (endSlot is null)
return (default, default,
ValidationProblem(
$"结束节次 {startPeriod + periodCount - 1} 不在已配置的时间表中。"));
return (
examDate.ToDateTime(startSlot.StartsAt, DateTimeKind.Utc),
examDate.ToDateTime(endSlot.EndsAt, DateTimeKind.Utc),
null
);
}
private async Task<ActionResult?> ValidateSessionAsync( private async Task<ActionResult?> ValidateSessionAsync(
ExamPlan plan, ExamPlan plan,
Guid? currentId, Guid? currentId,
ExamSessionRequest request, Guid teachingTaskId,
Guid? classroomId,
IReadOnlyCollection<Guid>? invigilatorIds,
DateTime startsAt,
DateTime endsAt,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var starts = request.StartsAt.ToUniversalTime(); if (startsAt >= endsAt) return ValidationProblem("考试开始时间必须早于结束时间。");
var ends = request.EndsAt.ToUniversalTime();
if (starts >= ends) return ValidationProblem("考试开始时间必须早于结束时间。");
var task = await db.TeachingTasks.AsNoTracking() var task = await db.TeachingTasks.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken); .FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken);
if (task is null || task.AcademicTermId != plan.AcademicTermId) if (task is null || task.AcademicTermId != plan.AcademicTermId)
return ValidationProblem("教学班与考试计划必须属于同一学期。"); return ValidationProblem("教学班与考试计划必须属于同一学期。");
var room = await db.Classrooms.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == request.ClassroomId && x.IsEnabled, cancellationToken);
if (room is null) return ValidationProblem("所选考场不存在或已停用。");
var studentCount = await db.CourseEnrollments.CountAsync(x => var studentCount = await db.CourseEnrollments.CountAsync(x =>
x.Status == CourseEnrollmentStatus.Enrolled && x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.TeachingTaskId == task.Id, cancellationToken); x.CourseSelectionOffering!.TeachingTaskId == task.Id, cancellationToken);
if (classroomId.HasValue)
{
var room = await db.Classrooms.AsNoTracking()
.FirstOrDefaultAsync(
x => x.Id == classroomId.Value && x.IsEnabled, cancellationToken);
if (room is null) return ValidationProblem("所选考场不存在或已停用。");
if (studentCount > room.Capacity) if (studentCount > room.Capacity)
return ConflictProblem($"考场容量不足:需容纳 {studentCount} 人,教室容量为 {room.Capacity}。"); return ConflictProblem(
var teacherIds = request.InvigilatorIds.Distinct().ToArray(); $"考场容量不足:需容纳 {studentCount} 人,教室容量为 {room.Capacity}。");
if (teacherIds.Length == 0) return ValidationProblem("至少安排一名监考教师。"); }
var teacherIds = (invigilatorIds ?? []).Distinct().ToArray();
if (teacherIds.Length > 0)
{
if (await db.Teachers.CountAsync(x => teacherIds.Contains(x.Id) && if (await db.Teachers.CountAsync(x => teacherIds.Contains(x.Id) &&
x.Status == TeacherStatus.Active, cancellationToken) != teacherIds.Length) x.Status == TeacherStatus.Active, cancellationToken) != teacherIds.Length)
return ValidationProblem("存在无效监考教师。"); return ValidationProblem("存在无效监考教师。");
}
var overlaps = db.ExamSessions.Where(x => x.ExamPlanId == plan.Id && var overlaps = db.ExamSessions.Where(x => x.ExamPlanId == plan.Id &&
x.Id != currentId && x.StartsAt < ends && starts < x.EndsAt); x.Id != currentId && x.StartsAt < endsAt && startsAt < x.EndsAt);
if (await overlaps.AnyAsync(x => x.ClassroomId == room.Id, cancellationToken))
if (classroomId.HasValue)
{
if (await overlaps.AnyAsync(
x => x.ClassroomId == classroomId.Value, cancellationToken))
return ConflictProblem("该时段考场已被占用。"); return ConflictProblem("该时段考场已被占用。");
}
if (teacherIds.Length > 0)
{
if (await overlaps.AnyAsync(x => if (await overlaps.AnyAsync(x =>
x.Invigilators.Any(i => teacherIds.Contains(i.TeacherId)), cancellationToken)) x.Invigilators.Any(i => teacherIds.Contains(i.TeacherId)),
cancellationToken))
return ConflictProblem("监考教师在该时段已有考试任务。"); return ConflictProblem("监考教师在该时段已有考试任务。");
}
var studentIds = db.CourseEnrollments.Where(x => var studentIds = db.CourseEnrollments.Where(x =>
x.Status == CourseEnrollmentStatus.Enrolled && x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.TeachingTaskId == task.Id) x.CourseSelectionOffering!.TeachingTaskId == task.Id)
@@ -271,7 +588,8 @@ public sealed class ExamsController(
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin); currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
private async Task<ActionResult> SaveAsync(Guid id, bool created, CancellationToken token) private async Task<ActionResult> SaveAsync(Guid id, bool created,
CancellationToken token)
{ {
try try
{ {
@@ -286,22 +604,30 @@ public sealed class ExamsController(
private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails
{ {
Title = "无法完成考务操作", Detail = detail, Title = "无法完成考务操作",
Detail = detail,
Status = StatusCodes.Status409Conflict Status = StatusCodes.Status409Conflict
}); });
private static string? Normalize(string? value) => private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim(); string.IsNullOrWhiteSpace(value) ? null : value.Trim();
} }
// ═══════════════════════════════════════════
// Request/Response models
// ═══════════════════════════════════════════
public sealed record ExamPlanRequest( public sealed record ExamPlanRequest(
Guid AcademicTermId, Guid AcademicTermId,
[Required, MaxLength(120)] string Name, [Required, MaxLength(120)] string Name,
[MaxLength(500)] string? Notes); [MaxLength(500)] string? Notes);
public sealed record ExamSessionRequest( public sealed record CreateExamSessionRequest(
Guid TeachingTaskId, Guid TeachingTaskId,
Guid ClassroomId, Guid? ClassroomId,
DateTime StartsAt, DateOnly ExamDate,
DateTime EndsAt, [Range(1, 30)] int StartPeriod,
IReadOnlyCollection<Guid> InvigilatorIds, [Range(1, 6)] int PeriodCount,
Guid? RequiredBuildingId,
[Range(1, 10)] int RequiredInvigilatorCount,
IReadOnlyCollection<Guid>? InvigilatorIds,
[MaxLength(500)] string? Notes); [MaxLength(500)] string? Notes);
+45 -9
View File
@@ -176,7 +176,8 @@ public sealed class GradesController(
TermName = x.TeachingTask.AcademicTerm!.Name, TermName = x.TeachingTask.AcademicTerm!.Name,
CourseCode = x.TeachingTask.Course!.Code, CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name, CourseName = x.TeachingTask.Course.Name,
CollegeName = x.TeachingTask.Course.College!.Name, CourseCollegeId = x.TeachingTask.Course.CollegeId,
CourseCollegeName = x.TeachingTask.Course.College!.Name,
x.TeachingTask.Course.Credits, x.TeachingTask.Course.Credits,
TeacherNames = x.TeachingTask.Teachers TeacherNames = x.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary) .OrderByDescending(item => item.IsPrimary)
@@ -231,13 +232,21 @@ public sealed class GradesController(
.Include(x => x.Teachers) .Include(x => x.Teachers)
.ThenInclude(x => x.Teacher) .ThenInclude(x => x.Teacher)
.SingleAsync(x => x.Id == sheet.TeachingTaskId, cancellationToken); .SingleAsync(x => x.Id == sheet.TeachingTaskId, cancellationToken);
// Determine if current user is from the course's college
var scope = currentUserDataScope.Current;
var isCourseCollegeReviewer = IsReviewer() &&
(scope.Scope == DataScope.All ||
scope.RestrictedCollegeId == sheet.CourseCollegeId);
return Ok(new return Ok(new
{ {
Sheet = sheet, Sheet = sheet,
CanEdit = CanEditScores(task) && CanEdit = CanEditScores(task) &&
sheet.Status is GradeSheetStatus.Draft or GradeSheetStatus.Returned, sheet.Status is GradeSheetStatus.Draft or GradeSheetStatus.Returned,
CanReview = IsReviewer() && sheet.Status == GradeSheetStatus.Submitted, CanReview = isCourseCollegeReviewer && sheet.Status == GradeSheetStatus.Submitted,
CanPublish = IsPublisher() && sheet.Status == GradeSheetStatus.Approved CanPublish = IsPublisher() && sheet.Status == GradeSheetStatus.Approved,
NeedsCollegeReview = sheet.CourseCollegeName
}); });
} }
@@ -376,6 +385,11 @@ public sealed class GradesController(
return ConflictProblem("只有草稿或已退回成绩单可以提交。"); return ConflictProblem("只有草稿或已退回成绩单可以提交。");
if (sheet.Records.Count == 0) if (sheet.Records.Count == 0)
return ConflictProblem("成绩单没有学生记录。"); return ConflictProblem("成绩单没有学生记录。");
// Final recalculation pass before submit
foreach (var record in sheet.Records)
Recalculate(sheet, record);
if (sheet.Records.Any(x => if (sheet.Records.Any(x =>
x.ExamStatus == GradeExamStatus.Normal && x.ExamStatus == GradeExamStatus.Normal &&
!x.TotalScore.HasValue)) !x.TotalScore.HasValue))
@@ -390,11 +404,22 @@ public sealed class GradesController(
[Authorize(Roles = Reviewers)] [Authorize(Roles = Reviewers)]
public async Task<ActionResult> Approve(Guid id, CancellationToken cancellationToken) public async Task<ActionResult> Approve(Guid id, CancellationToken cancellationToken)
{ {
var sheet = await AccessibleSheets().FirstOrDefaultAsync( var sheet = await db.GradeSheets
x => x.Id == id, .Include(x => x.TeachingTask)
cancellationToken); .ThenInclude(x => x!.Course)
.ThenInclude(x => x!.College)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound(); if (sheet is null) return NotFound();
if (!IsReviewer()) return Forbid(); if (!IsReviewer()) return Forbid();
// College-specific check: CollegeAdmin must belong to the course's college
var scope = currentUserDataScope.Current;
if (scope.Scope == DataScope.College &&
sheet.TeachingTask!.Course!.CollegeId != scope.RestrictedCollegeId)
return ConflictProblem(
$"该课程属于{sheet.TeachingTask.Course.College!.Name}" +
"您只能审核本学院课程的成绩单。");
if (sheet.Status != GradeSheetStatus.Submitted) if (sheet.Status != GradeSheetStatus.Submitted)
return ConflictProblem("只有待审核成绩单可以通过审核。"); return ConflictProblem("只有待审核成绩单可以通过审核。");
sheet.Status = GradeSheetStatus.Approved; sheet.Status = GradeSheetStatus.Approved;
@@ -410,11 +435,22 @@ public sealed class GradesController(
GradeReviewRequest request, GradeReviewRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var sheet = await AccessibleSheets().FirstOrDefaultAsync( var sheet = await db.GradeSheets
x => x.Id == id, .Include(x => x.TeachingTask)
cancellationToken); .ThenInclude(x => x!.Course)
.ThenInclude(x => x!.College)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound(); if (sheet is null) return NotFound();
if (!IsReviewer()) return Forbid(); if (!IsReviewer()) return Forbid();
// College-specific check
var scope = currentUserDataScope.Current;
if (scope.Scope == DataScope.College &&
sheet.TeachingTask!.Course!.CollegeId != scope.RestrictedCollegeId)
return ConflictProblem(
$"该课程属于{sheet.TeachingTask.Course.College!.Name}" +
"您只能审核本学院课程的成绩单。");
if (sheet.Status != GradeSheetStatus.Submitted) if (sheet.Status != GradeSheetStatus.Submitted)
return ConflictProblem("只有待审核成绩单可以退回。"); return ConflictProblem("只有待审核成绩单可以退回。");
if (string.IsNullOrWhiteSpace(request.Comment)) if (string.IsNullOrWhiteSpace(request.Comment))
@@ -19,10 +19,16 @@ public sealed class ExamSession : EntityBase
public ExamPlan? ExamPlan { get; set; } public ExamPlan? ExamPlan { get; set; }
public Guid TeachingTaskId { get; set; } public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; } public TeachingTask? TeachingTask { get; set; }
public Guid ClassroomId { get; set; } public Guid? ClassroomId { get; set; }
public Classroom? Classroom { get; set; } public Classroom? Classroom { get; set; }
public DateOnly ExamDate { get; set; }
public int StartPeriod { get; set; }
public int PeriodCount { get; set; } = 2;
public DateTime StartsAt { get; set; } public DateTime StartsAt { get; set; }
public DateTime EndsAt { get; set; } public DateTime EndsAt { get; set; }
public Guid? RequiredBuildingId { get; set; }
public Building? RequiredBuilding { get; set; }
public int RequiredInvigilatorCount { get; set; } = 2;
public string? Notes { get; set; } public string? Notes { get; set; }
public ICollection<ExamSessionInvigilator> Invigilators { get; set; } = []; public ICollection<ExamSessionInvigilator> Invigilators { get; set; } = [];
} }
@@ -0,0 +1,227 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Exams;
public sealed class ExamArrangementService(AppDbContext db)
{
private sealed record RoomOccupancy(Guid ClassroomId, DateTime StartsAt, DateTime EndsAt);
private sealed record InvigilatorOccupancy(Guid TeacherId, DateTime StartsAt, DateTime EndsAt);
public async Task<ExamArrangementResult> ArrangeAsync(
Guid planId,
CancellationToken cancellationToken)
{
var plan = await db.ExamPlans
.Include(x => x.AcademicTerm)
.Include(x => x.Sessions)
.ThenInclude(x => x.Invigilators)
.Include(x => x.Sessions)
.ThenInclude(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
if (plan is null)
return ExamArrangementResult.Fail("考试计划不存在。");
if (plan.Status != ExamPlanStatus.Draft)
return ExamArrangementResult.Fail("只有草稿状态的考试计划可以自动编排。");
var sessions = plan.Sessions.ToList();
if (sessions.Count == 0)
return ExamArrangementResult.Fail("考试计划中没有场次。");
var termId = plan.AcademicTermId;
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
.Where(x => x.AcademicTermId == termId && x.IsEnabled)
.OrderBy(x => x.PeriodNumber)
.ToListAsync(cancellationToken);
if (timeSlots.Count == 0)
return ExamArrangementResult.Fail("当前学期未配置上课时间表,无法计算考试时间段。");
var timeSlotLookup = timeSlots.ToDictionary(x => x.PeriodNumber);
int assignedRooms = 0;
int assignedInvigilators = 0;
int skippedRooms = 0;
int skippedInvigilators = 0;
var messages = new List<string>();
// Track occupied time slots to avoid conflicts
var occupiedRooms = sessions
.Where(x => x.ClassroomId.HasValue)
.Select(x => new RoomOccupancy(x.ClassroomId!.Value, x.StartsAt, x.EndsAt))
.ToList();
var occupiedInvigilators = sessions
.SelectMany(x => x.Invigilators.Select(i =>
new InvigilatorOccupancy(i.TeacherId, x.StartsAt, x.EndsAt)))
.ToList();
foreach (var session in sessions)
{
// Compute StartsAt/EndsAt from time slots
ComputeTimesFromSlots(session, timeSlotLookup);
var studentCount = await db.CourseEnrollments.CountAsync(
x => x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.TeachingTaskId == session.TeachingTaskId,
cancellationToken);
// ── Auto-assign classroom ──
if (!session.ClassroomId.HasValue)
{
var room = await FindBestClassroomAsync(
session, studentCount, occupiedRooms, cancellationToken);
if (room is not null)
{
session.ClassroomId = room.Id;
occupiedRooms.Add(new RoomOccupancy(room.Id, session.StartsAt, session.EndsAt));
assignedRooms++;
messages.Add(
$"“{session.TeachingTask!.Course!.Name}”→{room.Name}({room.Capacity}座)");
}
else
{
skippedRooms++;
messages.Add(
$"“{session.TeachingTask!.Course!.Name}”:无可用考场(需≥{studentCount}座)");
}
}
else
{
skippedRooms++;
}
// ── Auto-assign invigilators ──
var currentInvigilatorCount = session.Invigilators.Count;
var needed = session.RequiredInvigilatorCount - currentInvigilatorCount;
if (needed > 0)
{
var courseTeacherIds = session.TeachingTask!.Teachers
.Select(x => x.TeacherId).ToHashSet();
var newlyAssigned = await FindInvigilatorsAsync(
session, needed, courseTeacherIds,
occupiedInvigilators, cancellationToken);
foreach (var teacher in newlyAssigned)
{
session.Invigilators.Add(new ExamSessionInvigilator
{
ExamSessionId = session.Id,
TeacherId = teacher.Id
});
occupiedInvigilators.Add(new InvigilatorOccupancy(
teacher.Id, session.StartsAt, session.EndsAt));
assignedInvigilators++;
}
if (newlyAssigned.Count < needed)
messages.Add(
$"“{session.TeachingTask!.Course!.Name}”:仅找到{newlyAssigned.Count}/{needed}名监考教师");
}
else
{
skippedInvigilators++;
}
}
await db.SaveChangesAsync(cancellationToken);
return new ExamArrangementResult(
true,
$"{assignedRooms}个考场、{assignedInvigilators}名监考已分配。" +
(skippedRooms > 0 ? $" {skippedRooms}个场次无可用考场。" : "") +
(messages.Count > 0 ? $" 详情:{string.Join("", messages.Take(10))}" : ""));
}
private static void ComputeTimesFromSlots(
ExamSession session,
Dictionary<int, ScheduleTimeSlot> timeSlotLookup)
{
var startSlot = timeSlotLookup.GetValueOrDefault(session.StartPeriod);
var endSlot = timeSlotLookup.GetValueOrDefault(
session.StartPeriod + session.PeriodCount - 1);
if (startSlot is null || endSlot is null) return;
var examDate = session.ExamDate;
session.StartsAt = examDate.ToDateTime(startSlot.StartsAt, DateTimeKind.Utc);
session.EndsAt = examDate.ToDateTime(endSlot.EndsAt, DateTimeKind.Utc);
}
private async Task<Classroom?> FindBestClassroomAsync(
ExamSession session,
int studentCount,
List<RoomOccupancy> occupied,
CancellationToken cancellationToken)
{
var query = db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled && x.Capacity >= studentCount);
if (session.RequiredBuildingId.HasValue)
query = query.Where(x => x.BuildingId == session.RequiredBuildingId.Value);
// Exclude classrooms already occupied in-memory
var occupiedRoomIds = occupied
.Where(x => ExamConflictRules.TimeOverlaps(
x.StartsAt, x.EndsAt, session.StartsAt, session.EndsAt))
.Select(x => x.ClassroomId)
.ToHashSet();
if (occupiedRoomIds.Count > 0)
query = query.Where(x => !occupiedRoomIds.Contains(x.Id));
// Exclude classrooms occupied by DB sessions not yet tracked in memory
var dbOccupiedRooms = await db.ExamSessions.AsNoTracking()
.Where(x => x.ExamPlanId == session.ExamPlanId &&
x.Id != session.Id &&
x.ClassroomId != null &&
x.StartsAt < session.EndsAt &&
session.StartsAt < x.EndsAt)
.Select(x => x.ClassroomId!.Value)
.ToListAsync(cancellationToken);
if (dbOccupiedRooms.Count > 0)
query = query.Where(x => !dbOccupiedRooms.Contains(x.Id));
return await query
.OrderBy(x => x.Capacity)
.FirstOrDefaultAsync(cancellationToken);
}
private async Task<List<Teacher>> FindInvigilatorsAsync(
ExamSession session,
int needed,
HashSet<Guid> excludeTeacherIds,
List<InvigilatorOccupancy> occupied,
CancellationToken cancellationToken)
{
var busyTeacherIds = occupied
.Where(x => ExamConflictRules.TimeOverlaps(
x.StartsAt, x.EndsAt, session.StartsAt, session.EndsAt))
.Select(x => x.TeacherId)
.ToHashSet();
var dbBusyIds = await db.ExamSessionInvigilators.AsNoTracking()
.Where(x => x.ExamSession!.ExamPlanId == session.ExamPlanId &&
x.ExamSessionId != session.Id &&
x.ExamSession!.StartsAt < session.EndsAt &&
session.StartsAt < x.ExamSession.EndsAt)
.Select(x => x.TeacherId)
.ToListAsync(cancellationToken);
foreach (var id in dbBusyIds) busyTeacherIds.Add(id);
foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id);
return await db.Teachers.AsNoTracking()
.Where(x => x.Status == TeacherStatus.Active &&
!busyTeacherIds.Contains(x.Id))
.OrderBy(x => Guid.NewGuid())
.Take(needed)
.ToListAsync(cancellationToken);
}
}
public sealed record ExamArrangementResult(bool Success, string Message)
{
public static ExamArrangementResult Fail(string message) => new(false, message);
}
@@ -564,13 +564,16 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.Property(x => x.Notes).HasMaxLength(500); entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt }); entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt });
entity.HasIndex(x => x.TeachingTaskId); entity.HasIndex(x => x.TeachingTaskId);
entity.HasIndex(x => x.ClassroomId); entity.HasIndex(x => new { x.ExamPlanId, x.ExamDate });
entity.HasIndex(x => x.RequiredBuildingId);
entity.HasOne(x => x.ExamPlan).WithMany(x => x.Sessions) entity.HasOne(x => x.ExamPlan).WithMany(x => x.Sessions)
.HasForeignKey(x => x.ExamPlanId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(x => x.ExamPlanId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.TeachingTask).WithMany() entity.HasOne(x => x.TeachingTask).WithMany()
.HasForeignKey(x => x.TeachingTaskId).OnDelete(DeleteBehavior.Restrict); .HasForeignKey(x => x.TeachingTaskId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Classroom).WithMany() entity.HasOne(x => x.Classroom).WithMany()
.HasForeignKey(x => x.ClassroomId).OnDelete(DeleteBehavior.Restrict); .HasForeignKey(x => x.ClassroomId).OnDelete(DeleteBehavior.SetNull);
entity.HasOne(x => x.RequiredBuilding).WithMany()
.HasForeignKey(x => x.RequiredBuildingId).OnDelete(DeleteBehavior.SetNull);
}); });
builder.Entity<ExamSessionInvigilator>(entity => builder.Entity<ExamSessionInvigilator>(entity =>
{ {
@@ -583,8 +583,12 @@ public sealed class DatabaseInitializer(
{ {
TeachingTaskId = task.Id, TeachingTaskId = task.Id,
ClassroomId = room.Id, ClassroomId = room.Id,
ExamDate = new DateOnly(2027, 1, 8),
StartPeriod = 1,
PeriodCount = 2,
StartsAt = new DateTime(2027, 1, 8, 9, 0, 0, DateTimeKind.Utc), StartsAt = new DateTime(2027, 1, 8, 9, 0, 0, DateTimeKind.Utc),
EndsAt = new DateTime(2027, 1, 8, 11, 0, 0, DateTimeKind.Utc), EndsAt = new DateTime(2027, 1, 8, 11, 0, 0, DateTimeKind.Utc),
RequiredInvigilatorCount = 2,
Invigilators = Invigilators =
[ [
new ExamSessionInvigilator { TeacherId = teacher.Id } new ExamSessionInvigilator { TeacherId = teacher.Id }
@@ -32,6 +32,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260725_18_schedule_publish_jobs"; "20260725_18_schedule_publish_jobs";
private const string FlexibleGradesMigration = private const string FlexibleGradesMigration =
"20260725_19_flexible_grades"; "20260725_19_flexible_grades";
private const string ExamSchedulingOptimizationMigration =
"20260725_20_exam_scheduling_optimization";
public async Task MigrateAsync(CancellationToken cancellationToken = default) public async Task MigrateAsync(CancellationToken cancellationToken = default)
{ {
@@ -208,6 +210,21 @@ public sealed class DevelopmentSqliteMigrator(
AttendanceMigration, AttendanceMigration,
attendanceSheetsExist ? [] : AttendanceStatements, attendanceSheetsExist ? [] : AttendanceStatements,
cancellationToken); cancellationToken);
var examSchedulingOptimizationExists = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM pragma_table_info('ExamSessions')
WHERE name = 'ExamDate'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
ExamSchedulingOptimizationMigration,
examSchedulingOptimizationExists
? []
: ExamSchedulingOptimizationStatements,
cancellationToken);
} }
private async Task ApplyMigrationAsync( private async Task ApplyMigrationAsync(
@@ -1367,4 +1384,81 @@ public sealed class DevelopmentSqliteMigrator(
ON "SchedulePublishJobs" ("RequestedByUserId"); ON "SchedulePublishJobs" ("RequestedByUserId");
""" """
]; ];
private static readonly string[] ExamSchedulingOptimizationStatements =
[
"""
ALTER TABLE "ExamSessions" ADD COLUMN "ExamDate" TEXT NOT NULL DEFAULT '2027-01-01';
""",
"""
ALTER TABLE "ExamSessions" ADD COLUMN "StartPeriod" INTEGER NOT NULL DEFAULT 1;
""",
"""
ALTER TABLE "ExamSessions" ADD COLUMN "PeriodCount" INTEGER NOT NULL DEFAULT 2;
""",
"""
ALTER TABLE "ExamSessions" ADD COLUMN "RequiredBuildingId" TEXT NULL;
""",
"""
ALTER TABLE "ExamSessions" ADD COLUMN "RequiredInvigilatorCount" INTEGER NOT NULL DEFAULT 2;
""",
"""
CREATE TABLE "ExamSessions_Temp" (
"Id" TEXT NOT NULL CONSTRAINT "PK_ExamSessions" PRIMARY KEY,
"ExamPlanId" TEXT NOT NULL,
"TeachingTaskId" TEXT NOT NULL,
"ClassroomId" TEXT NULL,
"ExamDate" TEXT NOT NULL,
"StartPeriod" INTEGER NOT NULL,
"PeriodCount" INTEGER NOT NULL,
"StartsAt" TEXT NOT NULL,
"EndsAt" TEXT NOT NULL,
"RequiredBuildingId" TEXT NULL,
"RequiredInvigilatorCount" INTEGER NOT NULL,
"Notes" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_ExamSessions_ExamPlans_ExamPlanId"
FOREIGN KEY ("ExamPlanId") REFERENCES "ExamPlans" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_ExamSessions_TeachingTasks_TeachingTaskId"
FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT,
CONSTRAINT "FK_ExamSessions_Classrooms_ClassroomId"
FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id") ON DELETE SET NULL,
CONSTRAINT "FK_ExamSessions_Buildings_RequiredBuildingId"
FOREIGN KEY ("RequiredBuildingId") REFERENCES "Buildings" ("Id") ON DELETE SET NULL
);
""",
"""
INSERT INTO "ExamSessions_Temp"
("Id","ExamPlanId","TeachingTaskId","ClassroomId","ExamDate",
"StartPeriod","PeriodCount","StartsAt","EndsAt",
"RequiredBuildingId","RequiredInvigilatorCount",
"Notes","CreatedAt","UpdatedAt")
SELECT "Id","ExamPlanId","TeachingTaskId","ClassroomId",
'2027-01-01',
1,2,
"StartsAt","EndsAt",
NULL,2,
"Notes","CreatedAt","UpdatedAt"
FROM "ExamSessions";
""",
"DROP TABLE \"ExamSessions\";",
"ALTER TABLE \"ExamSessions_Temp\" RENAME TO \"ExamSessions\";",
"""
CREATE INDEX "IX_ExamSessions_ExamPlanId_StartsAt"
ON "ExamSessions" ("ExamPlanId", "StartsAt");
""",
"""
CREATE INDEX "IX_ExamSessions_TeachingTaskId"
ON "ExamSessions" ("TeachingTaskId");
""",
"""
CREATE INDEX "IX_ExamSessions_ExamPlanId_ExamDate"
ON "ExamSessions" ("ExamPlanId", "ExamDate");
""",
"""
CREATE INDEX "IX_ExamSessions_RequiredBuildingId"
ON "ExamSessions" ("RequiredBuildingId");
"""
];
} }
@@ -0,0 +1,158 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class ExamSchedulingOptimization : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Drop old FK/index on ClassroomId
migrationBuilder.DropForeignKey(
name: "FK_ExamSessions_Classrooms_ClassroomId",
table: "ExamSessions");
migrationBuilder.DropIndex(
name: "IX_ExamSessions_ClassroomId",
table: "ExamSessions");
// Make ClassroomId nullable
migrationBuilder.AlterColumn<Guid>(
name: "ClassroomId",
table: "ExamSessions",
type: "char(36)",
nullable: true,
oldClrType: typeof(Guid),
oldType: "char(36)");
// Add new columns
migrationBuilder.AddColumn<DateOnly>(
name: "ExamDate",
table: "ExamSessions",
type: "date",
nullable: false,
defaultValue: new DateOnly(2027, 1, 1));
migrationBuilder.AddColumn<int>(
name: "StartPeriod",
table: "ExamSessions",
type: "int",
nullable: false,
defaultValue: 1);
migrationBuilder.AddColumn<int>(
name: "PeriodCount",
table: "ExamSessions",
type: "int",
nullable: false,
defaultValue: 2);
migrationBuilder.AddColumn<Guid>(
name: "RequiredBuildingId",
table: "ExamSessions",
type: "char(36)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RequiredInvigilatorCount",
table: "ExamSessions",
type: "int",
nullable: false,
defaultValue: 2);
// Re-add FK/index on ClassroomId (nullable, SetNull)
migrationBuilder.CreateIndex(
name: "IX_ExamSessions_ClassroomId",
table: "ExamSessions",
column: "ClassroomId");
migrationBuilder.AddForeignKey(
name: "FK_ExamSessions_Classrooms_ClassroomId",
table: "ExamSessions",
column: "ClassroomId",
principalTable: "Classrooms",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
// New indices
migrationBuilder.CreateIndex(
name: "IX_ExamSessions_ExamPlanId_ExamDate",
table: "ExamSessions",
columns: new[] { "ExamPlanId", "ExamDate" });
migrationBuilder.CreateIndex(
name: "IX_ExamSessions_RequiredBuildingId",
table: "ExamSessions",
column: "RequiredBuildingId");
migrationBuilder.AddForeignKey(
name: "FK_ExamSessions_Buildings_RequiredBuildingId",
table: "ExamSessions",
column: "RequiredBuildingId",
principalTable: "Buildings",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Remove new FK/index
migrationBuilder.DropForeignKey(
name: "FK_ExamSessions_Buildings_RequiredBuildingId",
table: "ExamSessions");
migrationBuilder.DropForeignKey(
name: "FK_ExamSessions_Classrooms_ClassroomId",
table: "ExamSessions");
migrationBuilder.DropIndex(
name: "IX_ExamSessions_ClassroomId",
table: "ExamSessions");
migrationBuilder.DropIndex(
name: "IX_ExamSessions_ExamPlanId_ExamDate",
table: "ExamSessions");
migrationBuilder.DropIndex(
name: "IX_ExamSessions_RequiredBuildingId",
table: "ExamSessions");
// Drop new columns
migrationBuilder.DropColumn(name: "RequiredInvigilatorCount", table: "ExamSessions");
migrationBuilder.DropColumn(name: "RequiredBuildingId", table: "ExamSessions");
migrationBuilder.DropColumn(name: "PeriodCount", table: "ExamSessions");
migrationBuilder.DropColumn(name: "StartPeriod", table: "ExamSessions");
migrationBuilder.DropColumn(name: "ExamDate", table: "ExamSessions");
// Restore ClassroomId to non-nullable
migrationBuilder.AlterColumn<Guid>(
name: "ClassroomId",
table: "ExamSessions",
type: "char(36)",
nullable: false,
defaultValue: Guid.Empty,
oldClrType: typeof(Guid),
oldType: "char(36)",
oldNullable: true);
// Restore original FK/index
migrationBuilder.CreateIndex(
name: "IX_ExamSessions_ClassroomId",
table: "ExamSessions",
column: "ClassroomId");
migrationBuilder.AddForeignKey(
name: "FK_ExamSessions_Classrooms_ClassroomId",
table: "ExamSessions",
column: "ClassroomId",
principalTable: "Classrooms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
}
}
@@ -109,6 +109,11 @@ public sealed class TimetableDataService(AppDbContext db)
term.Id, term.Id,
studentId, studentId,
cancellationToken); cancellationToken);
// Load exam sessions for student/teacher timetables
var examEntries = await LoadExamEntriesAsync(
resourceType, resourceId, term.Id, studentId, slots, cancellationToken);
return new TimetableData( return new TimetableData(
term, term,
subject, subject,
@@ -117,7 +122,8 @@ public sealed class TimetableDataService(AppDbContext db)
plan, plan,
slots, slots,
entries, entries,
flexibleCourses); flexibleCourses,
examEntries);
} }
private async Task<TimetableSubjectDto?> LoadSubjectAsync( private async Task<TimetableSubjectDto?> LoadSubjectAsync(
@@ -286,6 +292,101 @@ public sealed class TimetableDataService(AppDbContext db)
x.Notes)) x.Notes))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
} }
private async Task<List<TimetableEntryDto>> LoadExamEntriesAsync(
TimetableResourceType resourceType,
Guid resourceId,
Guid academicTermId,
Guid? studentId,
IReadOnlyList<TimetableSlotDto> slots,
CancellationToken cancellationToken)
{
if (resourceType == TimetableResourceType.Classroom) return [];
var slotLookup = slots.ToDictionary(x => x.PeriodNumber);
IQueryable<ExamSession> source = db.ExamSessions.AsNoTracking()
.Where(x => x.ExamPlan!.AcademicTermId == academicTermId &&
x.ExamPlan.Status == ExamPlanStatus.Published &&
x.ClassroomId != null);
if (resourceType == TimetableResourceType.Teacher)
{
source = source.Where(x =>
x.Invigilators.Any(i => i.TeacherId == resourceId));
}
else if (studentId.HasValue)
{
source = source.Where(x =>
db.CourseEnrollments.Any(e =>
e.StudentId == studentId.Value &&
e.Status == CourseEnrollmentStatus.Enrolled &&
e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId));
}
else
{
// Class timetable: exams for the class's teaching tasks
source = source.Where(x =>
x.TeachingTask!.Classes.Any(c =>
c.AdministrativeClassId == resourceId));
}
var sessions = await source
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartPeriod)
.Select(x => new
{
x.Id,
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
TeacherNames = x.TeachingTask.Teachers
.OrderByDescending(t => t.IsPrimary)
.Select(t => t.Teacher!.Name),
ClassNames = x.TeachingTask.Classes
.Select(c => c.AdministrativeClass!.Name),
ClassroomName = x.Classroom!.Name,
BuildingName = x.Classroom.Building!.Name,
CampusName = x.Classroom.Building.Campus!.Name,
x.ExamDate,
x.StartPeriod,
x.PeriodCount,
x.ExamPlan!.Name,
InvigilatorNames = x.Invigilators
.Select(i => i.Teacher!.Name),
x.Notes
})
.ToListAsync(cancellationToken);
return sessions.Select(x =>
{
var dayOfWeek = x.ExamDate.DayOfWeek == 0 ? 7 : (int)x.ExamDate.DayOfWeek;
return new TimetableEntryDto(
x.Id,
x.TeachingTaskId,
x.TaskNumber,
x.TaskName,
x.CourseCode,
x.CourseName,
x.TeacherNames.Concat(
new[] { "监考:" + string.Join("、", x.InvigilatorNames) }),
x.ClassNames,
x.ClassroomName,
x.BuildingName,
x.CampusName,
dayOfWeek,
x.StartPeriod,
x.PeriodCount,
1, 1,
WeekPattern.All,
x.Notes,
true,
x.Name,
x.ExamDate);
}).ToList();
}
} }
public enum TimetableResourceType public enum TimetableResourceType
@@ -303,7 +404,8 @@ public sealed record TimetableData(
TimetablePlanDto? Plan, TimetablePlanDto? Plan,
IReadOnlyList<TimetableSlotDto> Slots, IReadOnlyList<TimetableSlotDto> Slots,
IReadOnlyList<TimetableEntryDto> Entries, IReadOnlyList<TimetableEntryDto> Entries,
IReadOnlyList<FlexibleCourseDto> FlexibleCourses); IReadOnlyList<FlexibleCourseDto> FlexibleCourses,
IReadOnlyList<TimetableEntryDto> ExamEntries);
public sealed record TimetableTermDto( public sealed record TimetableTermDto(
Guid Id, Guid Id,
@@ -362,7 +464,10 @@ public sealed record TimetableEntryDto(
int StartWeek, int StartWeek,
int EndWeek, int EndWeek,
WeekPattern WeekPattern, WeekPattern WeekPattern,
string? Notes); string? Notes,
bool IsExam = false,
string? ExamPlanName = null,
DateOnly? ExamDate = null);
public sealed record FlexibleCourseDto( public sealed record FlexibleCourseDto(
Guid Id, Guid Id,
+2
View File
@@ -2,6 +2,7 @@ using System.Text;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Exams;
using Jiaowu.Api.Infrastructure.Middleware; using Jiaowu.Api.Infrastructure.Middleware;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Scheduling; using Jiaowu.Api.Infrastructure.Scheduling;
@@ -100,6 +101,7 @@ builder.Services.AddScoped<SchedulePlanPublisher>();
builder.Services.AddScoped<SchedulePublishJobProcessor>(); builder.Services.AddScoped<SchedulePublishJobProcessor>();
builder.Services.AddSingleton<SchedulePublishJobQueue>(); builder.Services.AddSingleton<SchedulePublishJobQueue>();
builder.Services.AddHostedService<SchedulePublishJobWorker>(); builder.Services.AddHostedService<SchedulePublishJobWorker>();
builder.Services.AddScoped<ExamArrangementService>();
builder.Services builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+197 -36
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { Plus, Promotion, Refresh, UserFilled } from '@element-plus/icons-vue' import { Plus, Promotion, Refresh, UserFilled, Setting } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
@@ -15,9 +15,13 @@ const terms = ref<any[]>([])
const tasks = ref<any[]>([]) const tasks = ref<any[]>([])
const rooms = ref<any[]>([]) const rooms = ref<any[]>([])
const teachers = ref<any[]>([]) const teachers = ref<any[]>([])
const buildings = ref<any[]>([])
const timeSlots = ref<any[]>([])
const loading = ref(false) const loading = ref(false)
const arrangeLoading = ref(false)
const planDialog = ref(false) const planDialog = ref(false)
const sessionDialog = ref(false) const sessionDialog = ref(false)
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 planForm = reactive<Record<string, any>>({}) const planForm = reactive<Record<string, any>>({})
@@ -32,7 +36,20 @@ function dateText(value: string) {
hour: '2-digit', minute: '2-digit', hour12: false, hour: '2-digit', minute: '2-digit', hour12: false,
}).format(new Date(value)) }).format(new Date(value))
} }
function toIso(value: string) { return new Date(value.replace(' ', 'T')).toISOString() } function timeText(startsAt: string) {
return new Date(startsAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
}
function dateOnlyText(value: string) {
if (!value) return ''
return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', weekday: 'short' }).format(new Date(value))
}
function periodLabel(session: any) {
const end = session.startPeriod + session.periodCount - 1
const startSlot = timeSlots.value.find((s: any) => s.periodNumber === session.startPeriod)
const endSlot = timeSlots.value.find((s: any) => s.periodNumber === end)
const timeRange = startSlot && endSlot ? `${startSlot.startsAt}${endSlot.endsAt}` : ''
return `${session.startPeriod}-${end}${timeRange ? ' · ' + timeRange : ''}`
}
async function load() { async function load() {
loading.value = true loading.value = true
@@ -64,33 +81,57 @@ async function savePlan() {
await load() await load()
} catch (error) { ElMessage.error(apiErrorMessage(error)) } } catch (error) { ElMessage.error(apiErrorMessage(error)) }
} }
function openSession() { function openSession(existing?: any) {
editingSession.value = existing ?? null
const firstSlot = timeSlots.value[0]
Object.assign(sessionForm, { Object.assign(sessionForm, {
teachingTaskId: undefined, classroomId: undefined, teachingTaskId: existing?.teachingTaskId ?? undefined,
startsAt: '', endsAt: '', invigilatorIds: [], notes: '', classroomId: existing?.classroomId ?? undefined,
examDate: existing?.examDate ?? '',
startPeriod: existing?.startPeriod ?? (firstSlot?.periodNumber ?? 1),
periodCount: existing?.periodCount ?? 2,
requiredBuildingId: existing?.requiredBuildingId ?? undefined,
requiredInvigilatorCount: existing?.requiredInvigilatorCount ?? 2,
invigilatorIds: existing?.invigilatorIds ?? [],
notes: existing?.notes ?? '',
}) })
sessionDialog.value = true sessionDialog.value = true
} }
async function saveSession() { async function saveSession() {
try { try {
await http.post(`/exams/plans/${selected.value.id}/sessions`, { const payload = { ...sessionForm }
...sessionForm, if (editingSession.value) {
startsAt: toIso(sessionForm.startsAt), await http.put(`/exams/plans/${selected.value.id}/sessions/${editingSession.value.id}`, payload)
endsAt: toIso(sessionForm.endsAt), } else {
}) await http.post(`/exams/plans/${selected.value.id}/sessions`, payload)
}
sessionDialog.value = false sessionDialog.value = false
editingSession.value = null
await selectPlan(selected.value.id) await selectPlan(selected.value.id)
} catch (error) { ElMessage.error(apiErrorMessage(error)) } } catch (error) { ElMessage.error(apiErrorMessage(error)) }
} }
async function removeSession(row: any) { async function removeSession(row: any) {
try { try {
await ElMessageBox.confirm(`移除${row.courseName}考试场次?`, '移除场次', { type: 'warning' }) await ElMessageBox.confirm(`移除"${row.courseName}"考试场次?`, '移除场次', { type: 'warning' })
await http.delete(`/exams/plans/${selected.value.id}/sessions/${row.id}`) await http.delete(`/exams/plans/${selected.value.id}/sessions/${row.id}`)
await selectPlan(selected.value.id) await selectPlan(selected.value.id)
} catch (error: any) { } catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error)) if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
} }
} }
async function autoArrange() {
try {
await ElMessageBox.confirm(
'系统将为未分配考场的场次自动匹配教室,为未满监考的场次自动分配教师。',
'自动编排', { type: 'info', confirmButtonText: '开始编排' })
arrangeLoading.value = true
const res = await http.post(`/exams/plans/${selected.value.id}/auto-arrange`)
ElMessage.success(res.data.message)
await selectPlan(selected.value.id)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
} finally { arrangeLoading.value = false }
}
async function publishPlan() { async function publishPlan() {
try { try {
await ElMessageBox.confirm('发布后考试时间、考场与监考安排将锁定。', '发布考试计划', { await ElMessageBox.confirm('发布后考试时间、考场与监考安排将锁定。', '发布考试计划', {
@@ -108,20 +149,39 @@ async function showRoster(row: any) {
rosterDrawer.value = true rosterDrawer.value = true
} catch (error) { ElMessage.error(apiErrorMessage(error)) } } catch (error) { ElMessage.error(apiErrorMessage(error)) }
} }
function periodOptions() {
const max = timeSlots.value.length > 0 ? timeSlots.value[timeSlots.value.length - 1].periodNumber : 12
return Array.from({ length: max }, (_, i) => ({ value: i + 1, label: `${i + 1}` }))
}
function periodCountOptions() {
return [1, 2, 3, 4].map(n => ({ value: n, label: `${n} 小节` }))
}
function classroomLabel(room: any) {
return `${room.name} · ${room.capacity}座 · ${room.buildingName}`
}
function filteredRooms() {
if (!sessionForm.requiredBuildingId) return rooms.value
return rooms.value.filter((r: any) => r.buildingId === sessionForm.requiredBuildingId)
}
onMounted(async () => { onMounted(async () => {
try { try {
if (isManager.value) { if (isManager.value) {
const [termRes, taskRes, roomRes, teacherRes] = await Promise.all([ const currentTermId = (await http.get('/base-data/terms')).data.find((t: any) => t.isCurrent)?.id
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: 100 } }), 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: 100, teacherStatus: 'Active' } }), http.get('/personnel/teachers', { params: { page: 1, pageSize: 200, teacherStatus: 'Active' } }),
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 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
timeSlots.value = slotRes.data
} }
await load() await load()
} catch (error) { ElMessage.error(apiErrorMessage(error)) } } catch (error) { ElMessage.error(apiErrorMessage(error)) }
@@ -134,7 +194,7 @@ onMounted(async () => {
<div> <div>
<span class="section-kicker">EXAMINATION OFFICE</span> <span class="section-kicker">EXAMINATION OFFICE</span>
<h2>{{ isManager ? '考试安排与考场' : isTeacher ? '我的监考' : '我的考试' }}</h2> <h2>{{ isManager ? '考试安排与考场' : isTeacher ? '我的监考' : '我的考试' }}</h2>
<p>{{ isManager ? '集中安排考试时间、考场与监考教师,并在发布前消除冲突。' : '查看学校已经正式发布的考试日程。' }}</p> <p>{{ isManager ? '基于课表节次安排考试,自动匹配考场与监考教师。' : '查看学校已经正式发布的考试日程。' }}</p>
</div> </div>
<el-button v-if="isManager" type="primary" :icon="Plus" @click="openPlan">新建考试计划</el-button> <el-button v-if="isManager" type="primary" :icon="Plus" @click="openPlan">新建考试计划</el-button>
<el-button v-else :icon="Refresh" @click="load">刷新日程</el-button> <el-button v-else :icon="Refresh" @click="load">刷新日程</el-button>
@@ -149,55 +209,156 @@ onMounted(async () => {
</section> </section>
<section v-if="selected" class="exam-board" v-loading="loading"> <section v-if="selected" class="exam-board" v-loading="loading">
<header> <header>
<div><span>EXAM TIMELINE</span><h3>{{ selected.name }}</h3><p>{{ selected.termName }} · {{ selected.sessions.length }} 个考试场次</p></div>
<div> <div>
<el-button v-if="selected.status === 'Draft'" :icon="Plus" @click="openSession">安排场次</el-button> <span>EXAM TIMELINE</span>
<h3>{{ selected.name }}</h3>
<p>{{ selected.termName }} · {{ selected.sessions.length }} 个考试场次</p>
</div>
<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'" :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-timeline"> <div class="exam-timeline">
<article v-for="session in selected.sessions" :key="session.id"> <article v-for="session in selected.sessions" :key="session.id" :class="{ unassigned: !session.classroomId }">
<time><b>{{ dateText(session.startsAt).split(' ')[0] }}</b><span>{{ dateText(session.startsAt).split(' ').slice(1).join(' ') }}</span></time> <time>
<div><span>{{ session.courseCode }} · {{ session.taskNumber }}</span><h4>{{ session.courseName }}</h4><p>{{ session.buildingName }} · {{ session.classroomName }} · {{ session.studentCount }} </p></div> <b>{{ dateOnlyText(session.examDate) }}</b>
<div class="exam-staff"><span>监考</span><b>{{ session.invigilatorNames.join('、') }}</b><small>{{ dateText(session.startsAt) }}{{ dateText(session.endsAt).split(' ').slice(-1)[0] }}</small></div> <span>{{ periodLabel(session) }}</span>
</time>
<div>
<span>{{ session.courseCode }} · {{ session.taskNumber }}</span>
<h4>{{ session.courseName }}</h4>
<p>
<template v-if="session.classroomId">{{ session.buildingName }} · {{ session.classroomName }} · {{ session.classroomCapacity }}</template>
<template v-else><el-tag size="small" type="warning">待分配考场</el-tag></template>
· {{ session.studentCount }}
<template v-if="session.requiredBuildingName"> · {{ session.requiredBuildingName }}</template>
<template v-if="session.requiredInvigilatorCount > 1"> · {{ session.requiredInvigilatorCount }}名监考</template>
</p>
</div>
<div class="exam-staff">
<span>监考</span>
<b>{{ session.invigilatorNames.length ? session.invigilatorNames.join('、') : '待分配' }}</b>
<small>{{ timeText(session.startsAt) }}{{ timeText(session.endsAt) }}</small>
</div>
<div class="exam-row-actions"> <div class="exam-row-actions">
<el-button link type="primary" @click="showRoster(session)">考生名单</el-button> <el-button link type="primary" @click="showRoster(session)">考生名单</el-button>
<el-button v-if="selected.status === 'Draft'" link type="primary" @click="openSession(session)">编辑</el-button>
<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="尚未安排考试场次,点击「安排场次」开始。" />
</div> </div>
</section> </section>
</template> </template>
<section v-else class="exam-ticket-grid" v-loading="loading"> <section v-else class="exam-ticket-grid" v-loading="loading">
<article v-for="item in personal" :key="item.id"> <article v-for="item in personal" :key="item.id">
<div class="exam-ticket-date"><b>{{ dateText(item.startsAt).split(' ')[0] }}</b><span>{{ dateText(item.startsAt).split(' ').slice(1).join(' ') }}</span></div> <div class="exam-ticket-date">
<div><span>{{ item.courseCode }} · {{ item.taskNumber }}</span><h3>{{ item.courseName }}</h3><p>{{ item.buildingName }} · {{ item.classroomName }}</p></div> <b>{{ dateOnlyText(item.examDate) }}</b>
<footer><el-icon><UserFilled /></el-icon>{{ isTeacher ? `${item.studentCount} 名考生` : `监考:${item.invigilatorNames.join('、')}` }}</footer> <span>{{ timeText(item.startsAt) }}{{ timeText(item.endsAt) }}</span>
</div>
<div>
<span>{{ item.courseCode }} · {{ item.taskNumber }}</span>
<h3>{{ item.courseName }}</h3>
<p>{{ item.buildingName ? `${item.buildingName} · ${item.classroomName}` : '考场待定' }}</p>
</div>
<footer>
<el-icon><UserFilled /></el-icon>
{{ isTeacher ? `${item.studentCount} 名考生` : `监考:${item.invigilatorNames.join('、') || '待定'}` }}
</footer>
</article> </article>
<el-empty v-if="!personal.length" description="暂无已发布考试安排" /> <el-empty v-if="!personal.length" description="暂无已发布考试安排" />
</section> </section>
<!-- Plan Dialog -->
<el-dialog v-model="planDialog" title="新建考试计划" width="600px"> <el-dialog v-model="planDialog" title="新建考试计划" width="600px">
<el-form label-position="top"> <el-form label-position="top">
<el-form-item label="学期"><el-select v-model="planForm.academicTermId"><el-option v-for="x in terms" :key="x.id" :label="x.name" :value="x.id" /></el-select></el-form-item> <el-form-item label="学期">
<el-form-item label="计划名称"><el-input v-model="planForm.name" /></el-form-item> <el-select v-model="planForm.academicTermId">
<el-form-item label="说明"><el-input v-model="planForm.notes" type="textarea" /></el-form-item> <el-option v-for="x in terms" :key="x.id" :label="x.name" :value="x.id" />
</el-select>
</el-form-item>
<el-form-item label="计划名称"><el-input v-model="planForm.name" maxlength="120" /></el-form-item>
<el-form-item label="说明"><el-input v-model="planForm.notes" type="textarea" maxlength="500" /></el-form-item>
</el-form> </el-form>
<template #footer><el-button @click="planDialog=false">取消</el-button><el-button type="primary" @click="savePlan">保存草稿</el-button></template> <template #footer>
<el-button @click="planDialog = false">取消</el-button>
<el-button type="primary" @click="savePlan">保存草稿</el-button>
</template>
</el-dialog> </el-dialog>
<el-dialog v-model="sessionDialog" title="安排考试场次" width="680px">
<!-- Session Dialog -->
<el-dialog v-model="sessionDialog" :title="editingSession ? '编辑考试场次' : '安排考试场次'" width="720px" top="5vh">
<el-form label-position="top"> <el-form label-position="top">
<el-form-item label="教学班"><el-select v-model="sessionForm.teachingTaskId" filterable><el-option v-for="x in tasks" :key="x.id" :label="`${x.taskNumber} · ${x.courseName}`" :value="x.id" /></el-select></el-form-item> <el-form-item label="教学班">
<el-form-item label="考场"><el-select v-model="sessionForm.classroomId"><el-option v-for="x in rooms" :key="x.id" :label="`${x.name} · ${x.capacity} 座`" :value="x.id" /></el-select></el-form-item> <el-select v-model="sessionForm.teachingTaskId" filterable placeholder="选择教学班">
<div class="form-grid"><el-form-item label="开始时间"><el-date-picker v-model="sessionForm.startsAt" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" /></el-form-item><el-form-item label="结束时间"><el-date-picker v-model="sessionForm.endsAt" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" /></el-form-item></div> <el-option v-for="x in tasks" :key="x.id" :label="`${x.taskNumber} · ${x.courseName}`" :value="x.id" />
<el-form-item label="监考教师"><el-select v-model="sessionForm.invigilatorIds" multiple filterable><el-option v-for="x in teachers" :key="x.id" :label="`${x.teacherNumber} · ${x.name}`" :value="x.id" /></el-select></el-form-item> </el-select>
</el-form-item>
<div class="form-grid">
<el-form-item label="考试日期">
<el-date-picker v-model="sessionForm.examDate" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" />
</el-form-item>
<el-form-item label="起始节次">
<el-select v-model="sessionForm.startPeriod">
<el-option v-for="opt in periodOptions()" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="持续节数">
<el-select v-model="sessionForm.periodCount">
<el-option v-for="opt in periodCountOptions()" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
</div>
<div class="form-grid">
<el-form-item label="教学楼限制">
<el-select v-model="sessionForm.requiredBuildingId" clearable placeholder="不限教学楼">
<el-option v-for="x in buildings" :key="x.id" :label="x.name" :value="x.id" />
</el-select>
</el-form-item>
<el-form-item label="监考人数">
<el-input-number v-model="sessionForm.requiredInvigilatorCount" :min="1" :max="10" />
</el-form-item>
</div>
<el-form-item label="考场(可留空,由自动编排分配)">
<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-select>
</el-form-item>
<el-form-item label="监考教师(可留空,由自动编排分配)">
<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-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="sessionForm.notes" maxlength="500" placeholder="可选" />
</el-form-item>
</el-form> </el-form>
<template #footer><el-button @click="sessionDialog=false">取消</el-button><el-button type="primary" @click="saveSession">保存场次</el-button></template> <template #footer>
<el-button @click="sessionDialog = false">取消</el-button>
<el-button type="primary" @click="saveSession">{{ editingSession ? '保存修改' : '保存场次' }}</el-button>
</template>
</el-dialog> </el-dialog>
<!-- 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-column prop="studentNumber" label="学号" width="130" /><el-table-column prop="name" label="姓名" width="90" /><el-table-column prop="className" label="行政班" /></el-table> <el-table v-if="roster" :data="roster.students">
<el-table-column prop="studentNumber" label="学号" width="130" />
<el-table-column prop="name" label="姓名" width="90" />
<el-table-column prop="className" label="行政班" />
</el-table>
</el-drawer> </el-drawer>
</div> </div>
</template> </template>
<style scoped>
.exam-actions {
display: flex;
gap: 8px;
}
.exam-timeline article.unassigned {
border-left-color: #e6a23c;
}
</style>
+70 -4
View File
@@ -45,6 +45,13 @@ const statusLabels: Record<string, string> = {
Published: '已发布', Published: '已发布',
Returned: '已退回', Returned: '已退回',
} }
const statusMeta: Record<string, { icon: string; description: string }> = {
Draft: { icon: '📝', description: '教师正在录入成绩' },
Submitted: { icon: '📤', description: '已提交,等待开课学院审核' },
Approved: { icon: '✅', description: '学院审核通过,待校级发布' },
Published: { icon: '📢', description: '成绩已向学生发布' },
Returned: { icon: '↩️', description: '学院退回,需教师修改后重新提交' },
}
const examStatusLabels: Record<string, string> = { const examStatusLabels: Record<string, string> = {
Normal: '正常', Normal: '正常',
Absent: '缺考', Absent: '缺考',
@@ -281,6 +288,32 @@ function itemScore(record: any, gradeItemId: string) {
return record.itemScores?.find((itemScore: any) => itemScore.gradeItemId === gradeItemId) return record.itemScores?.find((itemScore: any) => itemScore.gradeItemId === gradeItemId)
} }
/** Client-side preview calculation — mirrors backend GradeCalculator.CalculateTotal */
function calcPreviewTotal(record: any): number | null {
if (!detail.value) return null
if (record.examStatus !== 'Normal') return null
const { regularWeight, finalWeight, items } = detail.value
const itemWeightById: Record<string, number> = {}
for (const item of items ?? []) itemWeightById[item.id] = item.weight
// Check required scores are present
if (regularWeight > 0 && (record.regularScore == null || record.regularScore === '')) return null
if (finalWeight > 0 && (record.finalScore == null || record.finalScore === '')) return null
for (const itemScore of record.itemScores ?? []) {
const weight = itemWeightById[itemScore.gradeItemId]
if (weight > 0 && (itemScore.score == null || itemScore.score === '')) return null
}
let total = (Number(record.regularScore) || 0) * regularWeight / 100
for (const itemScore of record.itemScores ?? []) {
const weight = itemWeightById[itemScore.gradeItemId] || 0
total += (Number(itemScore.score) || 0) * weight / 100
}
total += (Number(record.finalScore) || 0) * finalWeight / 100
return Math.round(total * 10) / 10
}
onMounted(async () => { onMounted(async () => {
try { try {
terms.value = (await http.get('/base-data/terms')).data terms.value = (await http.get('/base-data/terms')).data
@@ -395,8 +428,14 @@ onMounted(async () => {
<span>{{ detail.courseCode }} · {{ detail.taskNumber }}</span> <span>{{ detail.courseCode }} · {{ detail.taskNumber }}</span>
<h3>{{ detail.courseName }}成绩登记册</h3> <h3>{{ detail.courseName }}成绩登记册</h3>
<p>{{ detail.termName }} · {{ detail.teacherNames.join('、') }} · {{ detail.classNames.join('、') }}</p> <p>{{ detail.termName }} · {{ detail.teacherNames.join('、') }} · {{ detail.classNames.join('、') }}</p>
<p v-if="detail.courseCollegeName" class="college-meta">开课学院{{ detail.courseCollegeName }}</p>
</div> </div>
<div class="status-badge-group">
<i :class="detail.status.toLowerCase()">{{ statusLabels[detail.status] }}</i> <i :class="detail.status.toLowerCase()">{{ statusLabels[detail.status] }}</i>
<small v-if="detail.status === 'Submitted' && detail.needsCollegeReview">
{{ detail.needsCollegeReview }} 审核
</small>
</div>
</header> </header>
<section class="grade-register-ruler"> <section class="grade-register-ruler">
@@ -465,9 +504,14 @@ onMounted(async () => {
<span v-else>{{ row.finalScore ?? '—' }}</span> <span v-else>{{ row.finalScore ?? '—' }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="总评" width="80"> <el-table-column label="总评" width="110">
<template #default="{ row }"> <template #default="{ row }">
<div class="total-score-cell">
<b class="total-score" :class="scoreClass(row.totalScore)">{{ row.totalScore ?? '—' }}</b> <b class="total-score" :class="scoreClass(row.totalScore)">{{ row.totalScore ?? '—' }}</b>
<span v-if="detail.canEdit && row.examStatus === 'Normal'" class="preview-score" :class="scoreClass(calcPreviewTotal(row))">
参考 {{ calcPreviewTotal(row) ?? '—' }}
</span>
</div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="考试状态" width="115"> <el-table-column label="考试状态" width="115">
@@ -488,15 +532,20 @@ onMounted(async () => {
</div> </div>
<footer class="grade-actions"> <footer class="grade-actions">
<div> <div class="workflow-status">
<span>当前流程</span> <span>{{ statusMeta[detail.status]?.icon }} {{ statusLabels[detail.status] }}</span>
<b>{{ statusLabels[detail.status] }}</b> <small>{{ statusMeta[detail.status]?.description }}</small>
<small v-if="detail.needsCollegeReview && (detail.status === 'Submitted' || detail.status === 'Approved')" class="college-hint">
开课学院:{{ detail.needsCollegeReview }}
</small>
</div> </div>
<div class="grade-actions-buttons">
<el-button v-if="detail.canEdit" :icon="EditPen" @click="saveRecords">保存成绩</el-button> <el-button v-if="detail.canEdit" :icon="EditPen" @click="saveRecords">保存成绩</el-button>
<el-button v-if="detail.canEdit" type="primary" :icon="Promotion" @click="submitSheet">提交审核</el-button> <el-button v-if="detail.canEdit" type="primary" :icon="Promotion" @click="submitSheet">提交审核</el-button>
<el-button v-if="detail.canReview" type="danger" plain @click="openReturn">退回修改</el-button> <el-button v-if="detail.canReview" type="danger" plain @click="openReturn">退回修改</el-button>
<el-button v-if="detail.canReview" type="success" :icon="Check" @click="approveSheet">审核通过</el-button> <el-button v-if="detail.canReview" type="success" :icon="Check" @click="approveSheet">审核通过</el-button>
<el-button v-if="detail.canPublish" type="primary" :icon="Promotion" @click="publishSheet">发布成绩</el-button> <el-button v-if="detail.canPublish" type="primary" :icon="Promotion" @click="publishSheet">发布成绩</el-button>
</div>
</footer> </footer>
</template> </template>
</main> </main>
@@ -581,4 +630,21 @@ onMounted(async () => {
.item-row { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: #eef5f0; border-radius: 4px; } .item-row { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: #eef5f0; border-radius: 4px; }
.item-row > span:first-child { flex: 1; font-size: 13px; font-weight: 650; } .item-row > span:first-child { flex: 1; font-size: 13px; font-weight: 650; }
.add-item-row { display: flex; align-items: center; gap: 8px; } .add-item-row { display: flex; align-items: center; gap: 8px; }
/* Preview score */
.total-score-cell { display: flex; flex-direction: column; align-items: center; gap: 2px; }
.preview-score { font-size: 10px; opacity: .7; white-space: nowrap; }
.preview-score.failed { color: #b34e48; }
.preview-score.excellent { color: #2d8975; }
/* College review */
.college-meta { color: var(--muted); font-size: 12px; margin-top: 2px; }
.status-badge-group { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
.status-badge-group small { font-size: 11px; color: var(--muted); }
.workflow-status { display: flex; flex-direction: column; gap: 2px; }
.workflow-status span { font-weight: 650; font-size: 13px; }
.workflow-status small { color: var(--muted); font-size: 11px; }
.workflow-status .college-hint { color: #6b4e16; font-weight: 600; }
.grade-actions { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; padding: 12px 16px; border-top: 1px solid #e4e7ed; background: #fafbfc; }
.grade-actions-buttons { display: flex; gap: 8px; flex-wrap: wrap; }
</style> </style>