补考系统
This commit is contained in:
@@ -0,0 +1,797 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Exams;
|
||||||
|
using Jiaowu.Api.Infrastructure.Grades;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/makeup-exams")]
|
||||||
|
public sealed class MakeupExamsController(
|
||||||
|
AppDbContext db,
|
||||||
|
ICurrentUserDataScope currentUserDataScope,
|
||||||
|
MakeupExamEligibilityService eligibilityService,
|
||||||
|
MakeupExamArrangementService arrangementService) : ControllerBase
|
||||||
|
{
|
||||||
|
private const string Managers =
|
||||||
|
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// Plans
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
[HttpGet("plans")]
|
||||||
|
public async Task<ActionResult> GetPlans(
|
||||||
|
Guid? academicTermId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var source = db.MakeupExamPlans.AsNoTracking().AsQueryable();
|
||||||
|
if (academicTermId.HasValue)
|
||||||
|
source = source.Where(x => x.AcademicTermId == academicTermId);
|
||||||
|
if (!IsManager())
|
||||||
|
source = source.Where(x => x.Status == MakeupExamPlanStatus.Published);
|
||||||
|
return Ok(await source.OrderByDescending(x => x.AcademicTerm!.StartDate)
|
||||||
|
.ThenByDescending(x => x.CreatedAt)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name,
|
||||||
|
x.Status, SessionCount = x.Sessions.Count, x.Notes, x.PublishedAt
|
||||||
|
}).ToListAsync(cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("plans")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> CreatePlan(
|
||||||
|
MakeupExamPlanRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!await db.AcademicTerms.AnyAsync(
|
||||||
|
x => x.Id == request.AcademicTermId && x.IsEnabled,
|
||||||
|
cancellationToken))
|
||||||
|
return ValidationProblem("所选学期不存在或已停用。");
|
||||||
|
var plan = new MakeupExamPlan
|
||||||
|
{
|
||||||
|
AcademicTermId = request.AcademicTermId,
|
||||||
|
Name = request.Name.Trim(),
|
||||||
|
Notes = Normalize(request.Notes)
|
||||||
|
};
|
||||||
|
db.MakeupExamPlans.Add(plan);
|
||||||
|
return await SaveAsync(plan.Id, true, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("plans/{id:guid}")]
|
||||||
|
public async Task<ActionResult> GetPlan(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var manager = IsManager();
|
||||||
|
var plan = await db.MakeupExamPlans.AsNoTracking()
|
||||||
|
.Where(x => x.Id == id && (manager || x.Status == MakeupExamPlanStatus.Published))
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name,
|
||||||
|
x.Status, x.Notes, x.PublishedAt,
|
||||||
|
Sessions = x.Sessions.OrderBy(item => item.ExamDate)
|
||||||
|
.ThenBy(item => item.StartPeriod).Select(item => new
|
||||||
|
{
|
||||||
|
item.Id,
|
||||||
|
item.TeachingTaskId,
|
||||||
|
item.TeachingTask!.TaskNumber,
|
||||||
|
TaskName = item.TeachingTask.Name,
|
||||||
|
CourseCode = item.TeachingTask.Course!.Code,
|
||||||
|
CourseName = item.TeachingTask.Course.Name,
|
||||||
|
item.ClassroomId,
|
||||||
|
ClassroomName = item.Classroom != null ? item.Classroom.Name : null,
|
||||||
|
BuildingName = item.Classroom != null ? item.Classroom.Building!.Name : null,
|
||||||
|
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),
|
||||||
|
InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name),
|
||||||
|
EnrolledCount = item.Enrollments.Count,
|
||||||
|
Enrollments = item.Enrollments.Select(e => new
|
||||||
|
{
|
||||||
|
e.StudentId,
|
||||||
|
e.Student!.StudentNumber,
|
||||||
|
e.Student.Name,
|
||||||
|
ClassName = e.Student.AdministrativeClass!.Name,
|
||||||
|
e.Reason,
|
||||||
|
e.SourceGradeRecordId,
|
||||||
|
e.SourceDeferredExamId,
|
||||||
|
e.MakeupScore
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}).FirstOrDefaultAsync(cancellationToken);
|
||||||
|
return plan is null ? NotFound() : Ok(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("plans/{id:guid}/publish")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var plan = await db.MakeupExamPlans
|
||||||
|
.Include(x => x.Sessions)
|
||||||
|
.ThenInclude(x => x.Invigilators)
|
||||||
|
.Include(x => x.Sessions)
|
||||||
|
.ThenInclude(x => x.Enrollments)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (plan is null) return NotFound();
|
||||||
|
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||||
|
return ConflictProblem("只有草稿补考计划可以发布。");
|
||||||
|
if (plan.Sessions.Count == 0)
|
||||||
|
return ConflictProblem("至少安排一个考试场次后才能发布。");
|
||||||
|
|
||||||
|
var unassigned = plan.Sessions.Count(x =>
|
||||||
|
!x.ClassroomId.HasValue || x.Invigilators.Count == 0);
|
||||||
|
if (unassigned > 0)
|
||||||
|
return ConflictProblem(
|
||||||
|
$"还有 {unassigned} 个场次未分配考场或监考教师,请先完成自动编排。");
|
||||||
|
|
||||||
|
var empty = plan.Sessions.Count(x => x.Enrollments.Count == 0);
|
||||||
|
if (empty > 0)
|
||||||
|
return ConflictProblem(
|
||||||
|
$"还有 {empty} 个场次没有登记补考学生。");
|
||||||
|
|
||||||
|
plan.Status = MakeupExamPlanStatus.Published;
|
||||||
|
plan.PublishedAt = DateTime.UtcNow;
|
||||||
|
return await SaveAsync(id, false, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("plans/{id:guid}/archive")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> Archive(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var plan = await db.MakeupExamPlans.FindAsync([id], cancellationToken);
|
||||||
|
if (plan is null) return NotFound();
|
||||||
|
if (plan.Status != MakeupExamPlanStatus.Published)
|
||||||
|
return ConflictProblem("只有已发布的补考计划可以归档。");
|
||||||
|
plan.Status = MakeupExamPlanStatus.Archived;
|
||||||
|
return await SaveAsync(id, false, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// Sessions
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
[HttpPost("plans/{planId:guid}/sessions")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> CreateSession(
|
||||||
|
Guid planId,
|
||||||
|
CreateMakeupExamSessionRequest 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 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;
|
||||||
|
|
||||||
|
var session = new MakeupExamSession
|
||||||
|
{
|
||||||
|
MakeupExamPlanId = planId,
|
||||||
|
TeachingTaskId = request.TeachingTaskId,
|
||||||
|
ClassroomId = request.ClassroomId,
|
||||||
|
ExamDate = request.ExamDate,
|
||||||
|
StartPeriod = request.StartPeriod,
|
||||||
|
PeriodCount = request.PeriodCount,
|
||||||
|
StartsAt = startsAt,
|
||||||
|
EndsAt = endsAt,
|
||||||
|
RequiredBuildingId = request.RequiredBuildingId,
|
||||||
|
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
|
||||||
|
Notes = Normalize(request.Notes),
|
||||||
|
Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(id =>
|
||||||
|
new MakeupExamSessionInvigilator { TeacherId = id }).ToList()
|
||||||
|
};
|
||||||
|
db.MakeupExamSessions.Add(session);
|
||||||
|
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,
|
||||||
|
CreateMakeupExamSessionRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var session = await db.MakeupExamSessions
|
||||||
|
.Include(x => x.MakeupExamPlan)
|
||||||
|
.Include(x => x.Invigilators)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id && x.MakeupExamPlanId == planId, cancellationToken);
|
||||||
|
if (session is null) return NotFound();
|
||||||
|
if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Draft)
|
||||||
|
return ConflictProblem("已发布的补考计划不能调整场次。");
|
||||||
|
|
||||||
|
var timeResult = ResolveExamTime(session.MakeupExamPlan.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.MakeupExamPlan, 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.MakeupExamSessionInvigilators.RemoveRange(session.Invigilators);
|
||||||
|
session.Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(tid =>
|
||||||
|
new MakeupExamSessionInvigilator { TeacherId = tid }).ToList();
|
||||||
|
|
||||||
|
return await SaveAsync(id, false, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("plans/{planId:guid}/sessions/{id:guid}")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> DeleteSession(
|
||||||
|
Guid planId,
|
||||||
|
Guid id,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var session = await db.MakeupExamSessions.Include(x => x.MakeupExamPlan)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id && x.MakeupExamPlanId == planId, cancellationToken);
|
||||||
|
if (session is null) return NotFound();
|
||||||
|
if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Draft)
|
||||||
|
return ConflictProblem("已发布的补考计划不能调整场次。");
|
||||||
|
db.MakeupExamSessions.Remove(session);
|
||||||
|
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 arrangementService.ArrangeAsync(planId, cancellationToken);
|
||||||
|
if (!result.Success)
|
||||||
|
return ConflictProblem(result.Message);
|
||||||
|
return Ok(new { message = result.Message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// Enrollments
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
[HttpGet("eligible-students")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> GetEligibleStudents(
|
||||||
|
Guid teachingTaskId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var students = await eligibilityService.GetEligibleStudentsAsync(
|
||||||
|
teachingTaskId, cancellationToken);
|
||||||
|
return Ok(students);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("sessions/{id:guid}/enroll")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> EnrollStudents(
|
||||||
|
Guid id,
|
||||||
|
EnrollStudentsRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var session = await db.MakeupExamSessions
|
||||||
|
.Include(x => x.MakeupExamPlan)
|
||||||
|
.Include(x => x.Enrollments)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (session is null) return NotFound();
|
||||||
|
if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Draft)
|
||||||
|
return ConflictProblem("已发布的补考计划不能调整登记。");
|
||||||
|
|
||||||
|
var eligible = await eligibilityService.GetEligibleStudentsAsync(
|
||||||
|
session.TeachingTaskId, cancellationToken);
|
||||||
|
var eligibleIds = eligible.Select(e => e.StudentId).ToHashSet();
|
||||||
|
|
||||||
|
var alreadyEnrolled = session.Enrollments.Select(e => e.StudentId).ToHashSet();
|
||||||
|
var validIds = request.StudentIds
|
||||||
|
.Where(sid => !alreadyEnrolled.Contains(sid))
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (validIds.Count == 0)
|
||||||
|
return ConflictProblem("所选学生均已登记或无效。");
|
||||||
|
|
||||||
|
var invalidIds = validIds.Where(sid => !eligibleIds.Contains(sid)).ToList();
|
||||||
|
if (invalidIds.Count > 0)
|
||||||
|
return ConflictProblem($"{invalidIds.Count} 名学生不符合补考资格。");
|
||||||
|
|
||||||
|
// Check for time conflicts with other makeup sessions
|
||||||
|
var conflictIds = await db.MakeupExamEnrollments.AsNoTracking()
|
||||||
|
.Where(x => validIds.Contains(x.StudentId) &&
|
||||||
|
x.MakeupExamSession!.StartsAt < session.EndsAt &&
|
||||||
|
session.StartsAt < x.MakeupExamSession.EndsAt)
|
||||||
|
.Select(x => x.StudentId)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (conflictIds.Count > 0)
|
||||||
|
{
|
||||||
|
var conflictNumbers = await db.Students
|
||||||
|
.Where(s => conflictIds.Contains(s.Id))
|
||||||
|
.Select(s => s.StudentNumber)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
return ConflictProblem(
|
||||||
|
$"以下学生该时段已有其他补考:{string.Join("、", conflictNumbers)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var studentId in validIds)
|
||||||
|
{
|
||||||
|
var info = eligible.First(e => e.StudentId == studentId);
|
||||||
|
session.Enrollments.Add(new MakeupExamEnrollment
|
||||||
|
{
|
||||||
|
MakeupExamSessionId = id,
|
||||||
|
StudentId = studentId,
|
||||||
|
Reason = info.Reason,
|
||||||
|
SourceGradeRecordId = info.SourceGradeRecordId,
|
||||||
|
SourceDeferredExamId = info.SourceDeferredExamId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await SaveAsync(id, false, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("sessions/{id:guid}/enrollments/{studentId:guid}")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> RemoveEnrollment(
|
||||||
|
Guid id,
|
||||||
|
Guid studentId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var enrollment = await db.MakeupExamEnrollments
|
||||||
|
.Include(x => x.MakeupExamSession)
|
||||||
|
.ThenInclude(x => x!.MakeupExamPlan)
|
||||||
|
.FirstOrDefaultAsync(x => x.MakeupExamSessionId == id &&
|
||||||
|
x.StudentId == studentId, cancellationToken);
|
||||||
|
if (enrollment is null) return NotFound();
|
||||||
|
if (enrollment.MakeupExamSession!.MakeupExamPlan!.Status != MakeupExamPlanStatus.Draft)
|
||||||
|
return ConflictProblem("已发布的补考计划不能调整登记。");
|
||||||
|
db.MakeupExamEnrollments.Remove(enrollment);
|
||||||
|
return await SaveAsync(id, false, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// Scores
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
[HttpPut("sessions/{id:guid}/scores")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> RecordScores(
|
||||||
|
Guid id,
|
||||||
|
List<RecordMakeupScoreRequest> scores,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var session = await db.MakeupExamSessions
|
||||||
|
.Include(x => x.MakeupExamPlan)
|
||||||
|
.Include(x => x.Enrollments)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (session is null) return NotFound();
|
||||||
|
if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Published)
|
||||||
|
return ConflictProblem("只有已发布的补考计划可以录入成绩。");
|
||||||
|
|
||||||
|
var enrollmentByStudent = session.Enrollments.ToDictionary(e => e.StudentId);
|
||||||
|
|
||||||
|
foreach (var entry in scores)
|
||||||
|
{
|
||||||
|
if (!enrollmentByStudent.TryGetValue(entry.StudentId, out var enrollment))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
enrollment.MakeupScore = entry.Score;
|
||||||
|
|
||||||
|
// Update the grade record
|
||||||
|
if (enrollment.SourceGradeRecordId.HasValue)
|
||||||
|
{
|
||||||
|
var record = await db.GradeRecords
|
||||||
|
.Include(x => x.GradeSheet)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == enrollment.SourceGradeRecordId.Value,
|
||||||
|
cancellationToken);
|
||||||
|
if (record is not null)
|
||||||
|
{
|
||||||
|
record.ExamStatus = GradeExamStatus.Makeup;
|
||||||
|
record.TotalScore = entry.Score;
|
||||||
|
record.GradePoint = GradeCalculator.CalculateGradePoint(entry.Score);
|
||||||
|
record.Notes = "补考成绩";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await SaveAsync(id, false, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// Roster
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
[HttpGet("sessions/{id:guid}/roster")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> GetRoster(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var session = await db.MakeupExamSessions.AsNoTracking()
|
||||||
|
.Where(x => x.Id == id)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.TeachingTaskId,
|
||||||
|
x.TeachingTask!.TaskNumber,
|
||||||
|
CourseName = x.TeachingTask.Course!.Name,
|
||||||
|
ClassroomName = x.Classroom != null ? x.Classroom.Name : "待分配",
|
||||||
|
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);
|
||||||
|
return session is null ? NotFound() : Ok(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
var occupiedQuery = db.MakeupExamSessions.AsNoTracking()
|
||||||
|
.Where(x => x.ClassroomId != null &&
|
||||||
|
x.StartsAt < endsAt && startsAt < x.EndsAt);
|
||||||
|
if (planId.HasValue)
|
||||||
|
occupiedQuery = occupiedQuery.Where(x => x.MakeupExamPlanId == 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.MakeupExamSessionInvigilators.AsNoTracking()
|
||||||
|
.Where(x => x.MakeupExamSession!.StartsAt < endsAt &&
|
||||||
|
startsAt < x.MakeupExamSession!.EndsAt);
|
||||||
|
if (planId.HasValue)
|
||||||
|
busyQuery = busyQuery.Where(x =>
|
||||||
|
x.MakeupExamSession!.MakeupExamPlanId == planId.Value);
|
||||||
|
if (excludeSessionId.HasValue)
|
||||||
|
busyQuery = busyQuery.Where(x => x.MakeupExamSessionId != 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));
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// My schedule
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
[HttpGet("my-schedule")]
|
||||||
|
public async Task<ActionResult> GetMySchedule(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var scope = currentUserDataScope.Current;
|
||||||
|
if (scope.IsInRole(SystemRoles.Student))
|
||||||
|
{
|
||||||
|
var studentId = await db.Students.Where(x => x.UserId == scope.UserId)
|
||||||
|
.Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (!studentId.HasValue)
|
||||||
|
return ConflictProblem("当前账号未关联学生档案。");
|
||||||
|
return Ok(await db.MakeupExamEnrollments.AsNoTracking()
|
||||||
|
.Where(x => x.StudentId == studentId &&
|
||||||
|
x.MakeupExamSession!.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published)
|
||||||
|
.OrderBy(x => x.MakeupExamSession!.ExamDate)
|
||||||
|
.ThenBy(x => x.MakeupExamSession!.StartPeriod)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.MakeupExamSession!.Id,
|
||||||
|
PlanName = x.MakeupExamSession.MakeupExamPlan!.Name,
|
||||||
|
x.MakeupExamSession.ExamDate,
|
||||||
|
x.MakeupExamSession.StartPeriod,
|
||||||
|
x.MakeupExamSession.PeriodCount,
|
||||||
|
x.MakeupExamSession.StartsAt,
|
||||||
|
x.MakeupExamSession.EndsAt,
|
||||||
|
x.MakeupExamSession.TeachingTask!.TaskNumber,
|
||||||
|
CourseCode = x.MakeupExamSession.TeachingTask.Course!.Code,
|
||||||
|
CourseName = x.MakeupExamSession.TeachingTask.Course.Name,
|
||||||
|
ClassroomName = x.MakeupExamSession.Classroom != null
|
||||||
|
? x.MakeupExamSession.Classroom.Name : null,
|
||||||
|
BuildingName = x.MakeupExamSession.Classroom != null
|
||||||
|
? x.MakeupExamSession.Classroom.Building!.Name : null,
|
||||||
|
x.Reason,
|
||||||
|
x.MakeupScore,
|
||||||
|
IsMakeup = true
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken));
|
||||||
|
}
|
||||||
|
if (scope.IsInRole(SystemRoles.Teacher))
|
||||||
|
{
|
||||||
|
return Ok(await db.MakeupExamSessions.AsNoTracking()
|
||||||
|
.Where(x => x.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published &&
|
||||||
|
x.Invigilators.Any(i => i.Teacher!.UserId == scope.UserId))
|
||||||
|
.OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
PlanName = x.MakeupExamPlan!.Name,
|
||||||
|
x.ExamDate,
|
||||||
|
x.StartPeriod,
|
||||||
|
x.PeriodCount,
|
||||||
|
x.StartsAt,
|
||||||
|
x.EndsAt,
|
||||||
|
x.TeachingTask!.TaskNumber,
|
||||||
|
CourseCode = x.TeachingTask.Course!.Code,
|
||||||
|
CourseName = x.TeachingTask.Course.Name,
|
||||||
|
ClassroomName = x.Classroom != null ? x.Classroom.Name : null,
|
||||||
|
BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null,
|
||||||
|
InvigilatorNames = x.Invigilators.Select(i => i.Teacher!.Name),
|
||||||
|
EnrolledCount = x.Enrollments.Count,
|
||||||
|
IsMakeup = true
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken));
|
||||||
|
}
|
||||||
|
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(
|
||||||
|
MakeupExamPlan plan,
|
||||||
|
Guid? currentId,
|
||||||
|
Guid teachingTaskId,
|
||||||
|
Guid? classroomId,
|
||||||
|
IReadOnlyCollection<Guid>? invigilatorIds,
|
||||||
|
DateTime startsAt,
|
||||||
|
DateTime endsAt,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (startsAt >= endsAt) return ValidationProblem("考试开始时间必须早于结束时间。");
|
||||||
|
var task = await db.TeachingTasks.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken);
|
||||||
|
if (task is null || task.AcademicTermId != plan.AcademicTermId)
|
||||||
|
return ValidationProblem("教学班与补考计划必须属于同一学期。");
|
||||||
|
|
||||||
|
if (classroomId.HasValue)
|
||||||
|
{
|
||||||
|
var room = await db.Classrooms.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
x => x.Id == classroomId.Value && x.IsEnabled, cancellationToken);
|
||||||
|
if (room is null) return ValidationProblem("所选考场不存在或已停用。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var teacherIds = (invigilatorIds ?? []).Distinct().ToArray();
|
||||||
|
if (teacherIds.Length > 0)
|
||||||
|
{
|
||||||
|
if (await db.Teachers.CountAsync(x => teacherIds.Contains(x.Id) &&
|
||||||
|
x.Status == TeacherStatus.Active, cancellationToken) != teacherIds.Length)
|
||||||
|
return ValidationProblem("存在无效监考教师。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var overlaps = db.MakeupExamSessions.Where(x => x.MakeupExamPlanId == plan.Id &&
|
||||||
|
x.Id != currentId && x.StartsAt < endsAt && startsAt < x.EndsAt);
|
||||||
|
|
||||||
|
if (classroomId.HasValue)
|
||||||
|
{
|
||||||
|
if (await overlaps.AnyAsync(
|
||||||
|
x => x.ClassroomId == classroomId.Value, cancellationToken))
|
||||||
|
return ConflictProblem("该时段考场已被占用。");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (teacherIds.Length > 0)
|
||||||
|
{
|
||||||
|
if (await overlaps.AnyAsync(x =>
|
||||||
|
x.Invigilators.Any(i => teacherIds.Contains(i.TeacherId)),
|
||||||
|
cancellationToken))
|
||||||
|
return ConflictProblem("监考教师在该时段已有补考任务。");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsManager() =>
|
||||||
|
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
||||||
|
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
|
||||||
|
|
||||||
|
private async Task<ActionResult> SaveAsync(Guid id, bool created,
|
||||||
|
CancellationToken token)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync(token);
|
||||||
|
return created ? Created(string.Empty, new { id }) : NoContent();
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
return ConflictProblem("补考场次重复,或关联数据已发生变化。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "无法完成补考操作",
|
||||||
|
Detail = detail,
|
||||||
|
Status = StatusCodes.Status409Conflict
|
||||||
|
});
|
||||||
|
private static string? Normalize(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// Request/Response models
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
public sealed record MakeupExamPlanRequest(
|
||||||
|
Guid AcademicTermId,
|
||||||
|
[Required, MaxLength(120)] string Name,
|
||||||
|
[MaxLength(500)] string? Notes);
|
||||||
|
|
||||||
|
public sealed record CreateMakeupExamSessionRequest(
|
||||||
|
Guid TeachingTaskId,
|
||||||
|
Guid? ClassroomId,
|
||||||
|
DateOnly ExamDate,
|
||||||
|
[Range(1, 30)] int StartPeriod,
|
||||||
|
[Range(1, 6)] int PeriodCount,
|
||||||
|
Guid? RequiredBuildingId,
|
||||||
|
[Range(1, 10)] int RequiredInvigilatorCount,
|
||||||
|
IReadOnlyCollection<Guid>? InvigilatorIds,
|
||||||
|
[MaxLength(500)] string? Notes);
|
||||||
|
|
||||||
|
public sealed record EnrollStudentsRequest(
|
||||||
|
[Required] IReadOnlyCollection<Guid> StudentIds);
|
||||||
|
|
||||||
|
public sealed record RecordMakeupScoreRequest(
|
||||||
|
Guid StudentId,
|
||||||
|
[Range(0, 100)] decimal Score);
|
||||||
@@ -65,5 +65,6 @@ public enum GradeExamStatus
|
|||||||
Normal = 1,
|
Normal = 1,
|
||||||
Absent = 2,
|
Absent = 2,
|
||||||
Deferred = 3,
|
Deferred = 3,
|
||||||
Exempt = 4
|
Exempt = 4,
|
||||||
|
Makeup = 5
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using Jiaowu.Api.Domain.Common;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Domain.Academic;
|
||||||
|
|
||||||
|
public sealed class MakeupExamPlan : EntityBase
|
||||||
|
{
|
||||||
|
public Guid AcademicTermId { get; set; }
|
||||||
|
public AcademicTerm? AcademicTerm { get; set; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public MakeupExamPlanStatus Status { get; set; } = MakeupExamPlanStatus.Draft;
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public DateTime? PublishedAt { get; set; }
|
||||||
|
public ICollection<MakeupExamSession> Sessions { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class MakeupExamSession : EntityBase
|
||||||
|
{
|
||||||
|
public Guid MakeupExamPlanId { get; set; }
|
||||||
|
public MakeupExamPlan? MakeupExamPlan { get; set; }
|
||||||
|
public Guid TeachingTaskId { get; set; }
|
||||||
|
public TeachingTask? TeachingTask { get; set; }
|
||||||
|
public Guid? ClassroomId { 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 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 ICollection<MakeupExamSessionInvigilator> Invigilators { get; set; } = [];
|
||||||
|
public ICollection<MakeupExamEnrollment> Enrollments { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class MakeupExamSessionInvigilator
|
||||||
|
{
|
||||||
|
public Guid MakeupExamSessionId { get; set; }
|
||||||
|
public MakeupExamSession? MakeupExamSession { get; set; }
|
||||||
|
public Guid TeacherId { get; set; }
|
||||||
|
public Teacher? Teacher { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class MakeupExamEnrollment
|
||||||
|
{
|
||||||
|
public Guid MakeupExamSessionId { get; set; }
|
||||||
|
public MakeupExamSession? MakeupExamSession { get; set; }
|
||||||
|
public Guid StudentId { get; set; }
|
||||||
|
public Student? Student { get; set; }
|
||||||
|
public MakeupReason Reason { get; set; }
|
||||||
|
public Guid? SourceGradeRecordId { get; set; }
|
||||||
|
public GradeRecord? SourceGradeRecord { get; set; }
|
||||||
|
public Guid? SourceDeferredExamId { get; set; }
|
||||||
|
public DeferredExam? SourceDeferredExam { get; set; }
|
||||||
|
public decimal? MakeupScore { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum MakeupExamPlanStatus
|
||||||
|
{
|
||||||
|
Draft = 1,
|
||||||
|
Published = 2,
|
||||||
|
Archived = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum MakeupReason
|
||||||
|
{
|
||||||
|
Failed = 1,
|
||||||
|
Absent = 2,
|
||||||
|
DeferredApproved = 3
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||||
|
|
||||||
|
public sealed class MakeupExamArrangementService(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.MakeupExamPlans
|
||||||
|
.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 != MakeupExamPlanStatus.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>();
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
ComputeTimesFromSlots(session, timeSlotLookup);
|
||||||
|
|
||||||
|
var enrolledCount = await db.MakeupExamEnrollments
|
||||||
|
.CountAsync(x => x.MakeupExamSessionId == session.Id, cancellationToken);
|
||||||
|
|
||||||
|
// Auto-assign classroom
|
||||||
|
if (!session.ClassroomId.HasValue)
|
||||||
|
{
|
||||||
|
var room = await FindBestClassroomAsync(
|
||||||
|
session, enrolledCount, 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}\":无可用考场(需≥{enrolledCount}座)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 MakeupExamSessionInvigilator
|
||||||
|
{
|
||||||
|
MakeupExamSessionId = 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(
|
||||||
|
MakeupExamSession 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(
|
||||||
|
MakeupExamSession session,
|
||||||
|
int enrolledCount,
|
||||||
|
List<RoomOccupancy> occupied,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = db.Classrooms.AsNoTracking()
|
||||||
|
.Where(x => x.IsEnabled && x.Capacity >= enrolledCount);
|
||||||
|
|
||||||
|
if (session.RequiredBuildingId.HasValue)
|
||||||
|
query = query.Where(x => x.BuildingId == session.RequiredBuildingId.Value);
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
var dbOccupiedRooms = await db.MakeupExamSessions.AsNoTracking()
|
||||||
|
.Where(x => x.MakeupExamPlanId == session.MakeupExamPlanId &&
|
||||||
|
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(
|
||||||
|
MakeupExamSession 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.MakeupExamSessionInvigilators.AsNoTracking()
|
||||||
|
.Where(x => x.MakeupExamSession!.MakeupExamPlanId == session.MakeupExamPlanId &&
|
||||||
|
x.MakeupExamSessionId != session.Id &&
|
||||||
|
x.MakeupExamSession!.StartsAt < session.EndsAt &&
|
||||||
|
session.StartsAt < x.MakeupExamSession.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||||
|
|
||||||
|
public sealed class MakeupExamEligibilityService(AppDbContext db)
|
||||||
|
{
|
||||||
|
public async Task<List<EligibleStudent>> GetEligibleStudentsAsync(
|
||||||
|
Guid teachingTaskId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var gradeSheet = await db.GradeSheets.AsNoTracking()
|
||||||
|
.Where(x => x.TeachingTaskId == teachingTaskId)
|
||||||
|
.Select(x => new { x.Id, x.TeachingTask!.AcademicTermId })
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (gradeSheet is null)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
// Find students already enrolled in any makeup session for this teaching task
|
||||||
|
var alreadyEnrolledIds = await db.MakeupExamEnrollments.AsNoTracking()
|
||||||
|
.Where(x => x.MakeupExamSession!.TeachingTaskId == teachingTaskId &&
|
||||||
|
x.MakeupExamSession!.MakeupExamPlan!.Status != MakeupExamPlanStatus.Archived)
|
||||||
|
.Select(x => x.StudentId)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var enrolledSet = alreadyEnrolledIds.ToHashSet();
|
||||||
|
var results = new Dictionary<Guid, EligibleStudent>();
|
||||||
|
|
||||||
|
// 1. Approved deferred exams (highest priority)
|
||||||
|
var deferredStudents = await db.DeferredExams.AsNoTracking()
|
||||||
|
.Where(x => x.TeachingTaskId == teachingTaskId &&
|
||||||
|
x.Status == ApprovalStatus.Approved &&
|
||||||
|
!enrolledSet.Contains(x.StudentId))
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.StudentId,
|
||||||
|
x.Student!.StudentNumber,
|
||||||
|
x.Student.Name,
|
||||||
|
ClassName = x.Student.AdministrativeClass!.Name,
|
||||||
|
x.Id
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var s in deferredStudents)
|
||||||
|
{
|
||||||
|
if (!results.ContainsKey(s.StudentId))
|
||||||
|
{
|
||||||
|
results[s.StudentId] = new EligibleStudent(
|
||||||
|
s.StudentId, s.StudentNumber, s.Name, s.ClassName,
|
||||||
|
MakeupReason.DeferredApproved, null,
|
||||||
|
SourceDeferredExamId: s.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Absent students
|
||||||
|
var absentStudents = await db.GradeRecords.AsNoTracking()
|
||||||
|
.Where(x => x.GradeSheetId == gradeSheet.Id &&
|
||||||
|
x.ExamStatus == GradeExamStatus.Absent &&
|
||||||
|
!enrolledSet.Contains(x.StudentId))
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.StudentId,
|
||||||
|
x.Student!.StudentNumber,
|
||||||
|
x.Student.Name,
|
||||||
|
ClassName = x.Student.AdministrativeClass!.Name,
|
||||||
|
GradeRecordId = (Guid?)x.Id
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var s in absentStudents)
|
||||||
|
{
|
||||||
|
if (!results.ContainsKey(s.StudentId))
|
||||||
|
{
|
||||||
|
results[s.StudentId] = new EligibleStudent(
|
||||||
|
s.StudentId, s.StudentNumber, s.Name, s.ClassName,
|
||||||
|
MakeupReason.Absent, null,
|
||||||
|
SourceGradeRecordId: s.GradeRecordId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Failed students (TotalScore < 60, Normal status)
|
||||||
|
var failedStudents = await db.GradeRecords.AsNoTracking()
|
||||||
|
.Where(x => x.GradeSheetId == gradeSheet.Id &&
|
||||||
|
x.ExamStatus == GradeExamStatus.Normal &&
|
||||||
|
x.TotalScore < 60 &&
|
||||||
|
!enrolledSet.Contains(x.StudentId))
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.StudentId,
|
||||||
|
x.Student!.StudentNumber,
|
||||||
|
x.Student.Name,
|
||||||
|
ClassName = x.Student.AdministrativeClass!.Name,
|
||||||
|
x.TotalScore,
|
||||||
|
GradeRecordId = (Guid?)x.Id
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var s in failedStudents)
|
||||||
|
{
|
||||||
|
if (!results.ContainsKey(s.StudentId))
|
||||||
|
{
|
||||||
|
results[s.StudentId] = new EligibleStudent(
|
||||||
|
s.StudentId, s.StudentNumber, s.Name, s.ClassName,
|
||||||
|
MakeupReason.Failed, s.TotalScore,
|
||||||
|
SourceGradeRecordId: s.GradeRecordId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.. results.Values.OrderBy(x => x.StudentNumber)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record EligibleStudent(
|
||||||
|
Guid StudentId,
|
||||||
|
string StudentNumber,
|
||||||
|
string Name,
|
||||||
|
string ClassName,
|
||||||
|
MakeupReason Reason,
|
||||||
|
decimal? TotalScore = null,
|
||||||
|
Guid? SourceGradeRecordId = null,
|
||||||
|
Guid? SourceDeferredExamId = null,
|
||||||
|
string? Detail = null);
|
||||||
@@ -22,7 +22,7 @@ public static class GradeCalculator
|
|||||||
IReadOnlyCollection<GradeItem> items,
|
IReadOnlyCollection<GradeItem> items,
|
||||||
GradeExamStatus examStatus)
|
GradeExamStatus examStatus)
|
||||||
{
|
{
|
||||||
if (examStatus != GradeExamStatus.Normal)
|
if (examStatus != GradeExamStatus.Normal && examStatus != GradeExamStatus.Makeup)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
if (regularWeight > 0 && !regularScore.HasValue)
|
if (regularWeight > 0 && !regularScore.HasValue)
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
public DbSet<ExamSession> ExamSessions => Set<ExamSession>();
|
public DbSet<ExamSession> ExamSessions => Set<ExamSession>();
|
||||||
public DbSet<ExamSessionInvigilator> ExamSessionInvigilators =>
|
public DbSet<ExamSessionInvigilator> ExamSessionInvigilators =>
|
||||||
Set<ExamSessionInvigilator>();
|
Set<ExamSessionInvigilator>();
|
||||||
|
public DbSet<MakeupExamPlan> MakeupExamPlans => Set<MakeupExamPlan>();
|
||||||
|
public DbSet<MakeupExamSession> MakeupExamSessions => Set<MakeupExamSession>();
|
||||||
|
public DbSet<MakeupExamSessionInvigilator> MakeupExamSessionInvigilators =>
|
||||||
|
Set<MakeupExamSessionInvigilator>();
|
||||||
|
public DbSet<MakeupExamEnrollment> MakeupExamEnrollments =>
|
||||||
|
Set<MakeupExamEnrollment>();
|
||||||
public DbSet<CourseAdjustment> CourseAdjustments => Set<CourseAdjustment>();
|
public DbSet<CourseAdjustment> CourseAdjustments => Set<CourseAdjustment>();
|
||||||
public DbSet<CourseExemption> CourseExemptions => Set<CourseExemption>();
|
public DbSet<CourseExemption> CourseExemptions => Set<CourseExemption>();
|
||||||
public DbSet<DeferredExam> DeferredExams => Set<DeferredExam>();
|
public DbSet<DeferredExam> DeferredExams => Set<DeferredExam>();
|
||||||
@@ -598,6 +604,52 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.HasOne(x => x.Teacher).WithMany()
|
entity.HasOne(x => x.Teacher).WithMany()
|
||||||
.HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict);
|
||||||
});
|
});
|
||||||
|
builder.Entity<MakeupExamPlan>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.Name).HasMaxLength(120);
|
||||||
|
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||||
|
entity.HasIndex(x => new { x.AcademicTermId, x.Status });
|
||||||
|
entity.HasOne(x => x.AcademicTerm).WithMany()
|
||||||
|
.HasForeignKey(x => x.AcademicTermId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
builder.Entity<MakeupExamSession>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||||
|
entity.HasIndex(x => new { x.MakeupExamPlanId, x.StartsAt });
|
||||||
|
entity.HasIndex(x => x.TeachingTaskId);
|
||||||
|
entity.HasIndex(x => new { x.MakeupExamPlanId, x.ExamDate });
|
||||||
|
entity.HasIndex(x => x.RequiredBuildingId);
|
||||||
|
entity.HasOne(x => x.MakeupExamPlan).WithMany(x => x.Sessions)
|
||||||
|
.HasForeignKey(x => x.MakeupExamPlanId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(x => x.TeachingTask).WithMany()
|
||||||
|
.HasForeignKey(x => x.TeachingTaskId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne(x => x.Classroom).WithMany()
|
||||||
|
.HasForeignKey(x => x.ClassroomId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
entity.HasOne(x => x.RequiredBuilding).WithMany()
|
||||||
|
.HasForeignKey(x => x.RequiredBuildingId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
});
|
||||||
|
builder.Entity<MakeupExamSessionInvigilator>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(x => new { x.MakeupExamSessionId, x.TeacherId });
|
||||||
|
entity.HasOne(x => x.MakeupExamSession).WithMany(x => x.Invigilators)
|
||||||
|
.HasForeignKey(x => x.MakeupExamSessionId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(x => x.Teacher).WithMany()
|
||||||
|
.HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
builder.Entity<MakeupExamEnrollment>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(x => new { x.MakeupExamSessionId, x.StudentId });
|
||||||
|
entity.HasIndex(x => new { x.StudentId, x.MakeupExamSessionId });
|
||||||
|
entity.Property(x => x.MakeupScore).HasPrecision(5, 1);
|
||||||
|
entity.HasOne(x => x.MakeupExamSession).WithMany(x => x.Enrollments)
|
||||||
|
.HasForeignKey(x => x.MakeupExamSessionId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(x => x.Student).WithMany()
|
||||||
|
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne(x => x.SourceGradeRecord).WithMany()
|
||||||
|
.HasForeignKey(x => x.SourceGradeRecordId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
entity.HasOne(x => x.SourceDeferredExam).WithMany()
|
||||||
|
.HasForeignKey(x => x.SourceDeferredExamId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
});
|
||||||
builder.Entity<StudentStatusChange>(entity =>
|
builder.Entity<StudentStatusChange>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(x => x.Reason).HasMaxLength(1000);
|
entity.Property(x => x.Reason).HasMaxLength(1000);
|
||||||
|
|||||||
@@ -298,6 +298,14 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
TeachingEvaluationMigration,
|
TeachingEvaluationMigration,
|
||||||
evaluationSetupsExist ? [] : TeachingEvaluationStatements,
|
evaluationSetupsExist ? [] : TeachingEvaluationStatements,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
|
var makeupExamsExist = await db.Database
|
||||||
|
.SqlQueryRaw<int>("SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'MakeupExamPlans'")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
MakeupExamsMigration,
|
||||||
|
makeupExamsExist ? [] : MakeupExamStatements,
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ApplyMigrationAsync(
|
private async Task ApplyMigrationAsync(
|
||||||
@@ -1651,4 +1659,20 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"""CREATE UNIQUE INDEX "IX_EvaluationRecords_SetupId_StudentId_TaskId" ON "EvaluationRecords" ("EvaluationSetupId", "StudentId", "TeachingTaskId");""",
|
"""CREATE UNIQUE INDEX "IX_EvaluationRecords_SetupId_StudentId_TaskId" ON "EvaluationRecords" ("EvaluationSetupId", "StudentId", "TeachingTaskId");""",
|
||||||
"""CREATE TABLE "EvaluationScores" ("EvaluationRecordId" TEXT NOT NULL, "EvaluationDimensionId" TEXT NOT NULL, "Score" INTEGER NOT NULL, CONSTRAINT "PK_EvaluationScores" PRIMARY KEY ("EvaluationRecordId", "EvaluationDimensionId"), CONSTRAINT "FK_EvaluationScores_EvaluationRecords_EvaluationRecordId" FOREIGN KEY ("EvaluationRecordId") REFERENCES "EvaluationRecords" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_EvaluationScores_EvaluationDimensions_EvaluationDimensionId" FOREIGN KEY ("EvaluationDimensionId") REFERENCES "EvaluationDimensions" ("Id") ON DELETE RESTRICT);""",
|
"""CREATE TABLE "EvaluationScores" ("EvaluationRecordId" TEXT NOT NULL, "EvaluationDimensionId" TEXT NOT NULL, "Score" INTEGER NOT NULL, CONSTRAINT "PK_EvaluationScores" PRIMARY KEY ("EvaluationRecordId", "EvaluationDimensionId"), CONSTRAINT "FK_EvaluationScores_EvaluationRecords_EvaluationRecordId" FOREIGN KEY ("EvaluationRecordId") REFERENCES "EvaluationRecords" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_EvaluationScores_EvaluationDimensions_EvaluationDimensionId" FOREIGN KEY ("EvaluationDimensionId") REFERENCES "EvaluationDimensions" ("Id") ON DELETE RESTRICT);""",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private const string MakeupExamsMigration = "MakeupExams";
|
||||||
|
|
||||||
|
private static readonly string[] MakeupExamStatements =
|
||||||
|
[
|
||||||
|
"""CREATE TABLE "MakeupExamPlans" ("Id" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, "AcademicTermId" TEXT NOT NULL, "Name" TEXT NOT NULL, "Status" INTEGER NOT NULL, "Notes" TEXT NULL, "PublishedAt" TEXT NULL, CONSTRAINT "PK_MakeupExamPlans" PRIMARY KEY ("Id"), CONSTRAINT "FK_MakeupExamPlans_AcademicTerms_AcademicTermId" FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE RESTRICT);""",
|
||||||
|
"""CREATE INDEX "IX_MakeupExamPlans_AcademicTermId_Status" ON "MakeupExamPlans" ("AcademicTermId", "Status");""",
|
||||||
|
"""CREATE TABLE "MakeupExamSessions" ("Id" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, "MakeupExamPlanId" 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, CONSTRAINT "PK_MakeupExamSessions" PRIMARY KEY ("Id"), CONSTRAINT "FK_MakeupExamSessions_MakeupExamPlans_MakeupExamPlanId" FOREIGN KEY ("MakeupExamPlanId") REFERENCES "MakeupExamPlans" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_MakeupExamSessions_TeachingTasks_TeachingTaskId" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_MakeupExamSessions_Classrooms_ClassroomId" FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id") ON DELETE SET NULL, CONSTRAINT "FK_MakeupExamSessions_Buildings_RequiredBuildingId" FOREIGN KEY ("RequiredBuildingId") REFERENCES "Buildings" ("Id") ON DELETE SET NULL);""",
|
||||||
|
"""CREATE INDEX "IX_MakeupExamSessions_MakeupExamPlanId_StartsAt" ON "MakeupExamSessions" ("MakeupExamPlanId", "StartsAt");""",
|
||||||
|
"""CREATE INDEX "IX_MakeupExamSessions_TeachingTaskId" ON "MakeupExamSessions" ("TeachingTaskId");""",
|
||||||
|
"""CREATE INDEX "IX_MakeupExamSessions_MakeupExamPlanId_ExamDate" ON "MakeupExamSessions" ("MakeupExamPlanId", "ExamDate");""",
|
||||||
|
"""CREATE INDEX "IX_MakeupExamSessions_RequiredBuildingId" ON "MakeupExamSessions" ("RequiredBuildingId");""",
|
||||||
|
"""CREATE TABLE "MakeupExamSessionInvigilators" ("MakeupExamSessionId" TEXT NOT NULL, "TeacherId" TEXT NOT NULL, CONSTRAINT "PK_MakeupExamSessionInvigilators" PRIMARY KEY ("MakeupExamSessionId", "TeacherId"), CONSTRAINT "FK_MakeupExamSessionInvigilators_MakeupExamSessions_MakeupExamSessionId" FOREIGN KEY ("MakeupExamSessionId") REFERENCES "MakeupExamSessions" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_MakeupExamSessionInvigilators_Teachers_TeacherId" FOREIGN KEY ("TeacherId") REFERENCES "Teachers" ("Id") ON DELETE RESTRICT);""",
|
||||||
|
"""CREATE TABLE "MakeupExamEnrollments" ("MakeupExamSessionId" TEXT NOT NULL, "StudentId" TEXT NOT NULL, "Reason" INTEGER NOT NULL, "SourceGradeRecordId" TEXT NULL, "SourceDeferredExamId" TEXT NULL, "MakeupScore" TEXT NULL, CONSTRAINT "PK_MakeupExamEnrollments" PRIMARY KEY ("MakeupExamSessionId", "StudentId"), CONSTRAINT "FK_MakeupExamEnrollments_MakeupExamSessions_MakeupExamSessionId" FOREIGN KEY ("MakeupExamSessionId") REFERENCES "MakeupExamSessions" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_MakeupExamEnrollments_Students_StudentId" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_MakeupExamEnrollments_GradeRecords_SourceGradeRecordId" FOREIGN KEY ("SourceGradeRecordId") REFERENCES "GradeRecords" ("Id") ON DELETE SET NULL, CONSTRAINT "FK_MakeupExamEnrollments_DeferredExams_SourceDeferredExamId" FOREIGN KEY ("SourceDeferredExamId") REFERENCES "DeferredExams" ("Id") ON DELETE SET NULL);""",
|
||||||
|
"""CREATE INDEX "IX_MakeupExamEnrollments_StudentId_MakeupExamSessionId" ON "MakeupExamEnrollments" ("StudentId", "MakeupExamSessionId");""",
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ builder.Services.AddSingleton<SchedulePublishJobQueue>();
|
|||||||
builder.Services.AddHostedService<SchedulePublishJobWorker>();
|
builder.Services.AddHostedService<SchedulePublishJobWorker>();
|
||||||
builder.Services.AddHostedService<WarningCheckWorker>();
|
builder.Services.AddHostedService<WarningCheckWorker>();
|
||||||
builder.Services.AddScoped<ExamArrangementService>();
|
builder.Services.AddScoped<ExamArrangementService>();
|
||||||
|
builder.Services.AddScoped<MakeupExamEligibilityService>();
|
||||||
|
builder.Services.AddScoped<MakeupExamArrangementService>();
|
||||||
|
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
|||||||
@@ -159,6 +159,13 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
|
|||||||
label: isStudent.value ? '我的考试' : isTeacher.value ? '我的监考' : '考试管理',
|
label: isStudent.value ? '我的考试' : isTeacher.value ? '我的监考' : '考试管理',
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
...whenVisible(
|
||||||
|
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'Teacher', 'Student']),
|
||||||
|
{
|
||||||
|
path: '/makeup-exams',
|
||||||
|
label: isStudent.value ? '我的补考' : isTeacher.value ? '补考监考' : '补考安排',
|
||||||
|
},
|
||||||
|
),
|
||||||
...whenVisible(
|
...whenVisible(
|
||||||
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher']),
|
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher']),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -195,6 +195,12 @@ const router = createRouter({
|
|||||||
component: () => import('../views/ExamsView.vue'),
|
component: () => import('../views/ExamsView.vue'),
|
||||||
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'Teacher', 'Student'] },
|
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'Teacher', 'Student'] },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'makeup-exams',
|
||||||
|
name: 'makeup-exams',
|
||||||
|
component: () => import('../views/MakeupExamsView.vue'),
|
||||||
|
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'Teacher', 'Student'] },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'course-adjustments',
|
path: 'course-adjustments',
|
||||||
name: 'course-adjustments',
|
name: 'course-adjustments',
|
||||||
|
|||||||
@@ -0,0 +1,461 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { Plus, Promotion, Refresh, UserFilled, Setting, Search } from '@element-plus/icons-vue'
|
||||||
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const isManager = computed(() =>
|
||||||
|
auth.user?.roles.some((r) => ['SuperAdmin', 'AcademicAdmin'].includes(r)) ?? false)
|
||||||
|
const isTeacher = computed(() => auth.user?.roles.includes('Teacher') && !isManager.value)
|
||||||
|
const plans = ref<any[]>([])
|
||||||
|
const selected = ref<any | null>(null)
|
||||||
|
const personal = ref<any[]>([])
|
||||||
|
const terms = ref<any[]>([])
|
||||||
|
const tasks = ref<any[]>([])
|
||||||
|
const rooms = ref<any[]>([])
|
||||||
|
const teachers = ref<any[]>([])
|
||||||
|
const buildings = ref<any[]>([])
|
||||||
|
const timeSlots = ref<any[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const arrangeLoading = ref(false)
|
||||||
|
const planDialog = ref(false)
|
||||||
|
const sessionDialog = ref(false)
|
||||||
|
const editingSession = ref<any | null>(null)
|
||||||
|
const rosterDrawer = ref(false)
|
||||||
|
const roster = ref<any | null>(null)
|
||||||
|
const enrollmentDialog = ref(false)
|
||||||
|
const enrollmentSession = ref<any | null>(null)
|
||||||
|
const eligibleStudents = ref<any[]>([])
|
||||||
|
const eligibleLoading = ref(false)
|
||||||
|
const selectedStudentIds = ref<string[]>([])
|
||||||
|
const planForm = reactive<Record<string, any>>({})
|
||||||
|
const sessionForm = reactive<Record<string, any>>({})
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
Draft: '草稿', Published: '已发布', Archived: '已归档',
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
if (!isManager.value) {
|
||||||
|
personal.value = (await http.get('/makeup-exams/my-schedule')).data
|
||||||
|
return
|
||||||
|
}
|
||||||
|
plans.value = (await http.get('/makeup-exams/plans')).data
|
||||||
|
const plan = plans.value.find((x) => x.id === selected.value?.id) ?? plans.value[0]
|
||||||
|
if (plan) await selectPlan(plan.id)
|
||||||
|
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
async function selectPlan(id: string) {
|
||||||
|
selected.value = (await http.get(`/makeup-exams/plans/${id}`)).data
|
||||||
|
}
|
||||||
|
function openPlan() {
|
||||||
|
Object.assign(planForm, {
|
||||||
|
academicTermId: terms.value.find((x) => x.isCurrent)?.id,
|
||||||
|
name: '', notes: '',
|
||||||
|
})
|
||||||
|
planDialog.value = true
|
||||||
|
}
|
||||||
|
async function savePlan() {
|
||||||
|
try {
|
||||||
|
await http.post('/makeup-exams/plans', planForm)
|
||||||
|
planDialog.value = false
|
||||||
|
await load()
|
||||||
|
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
|
||||||
|
}
|
||||||
|
function openSession(existing?: any) {
|
||||||
|
editingSession.value = existing ?? null
|
||||||
|
const firstSlot = timeSlots.value[0]
|
||||||
|
Object.assign(sessionForm, {
|
||||||
|
teachingTaskId: existing?.teachingTaskId ?? undefined,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
async function saveSession() {
|
||||||
|
try {
|
||||||
|
const payload = { ...sessionForm }
|
||||||
|
if (editingSession.value) {
|
||||||
|
await http.put(`/makeup-exams/plans/${selected.value.id}/sessions/${editingSession.value.id}`, payload)
|
||||||
|
} else {
|
||||||
|
await http.post(`/makeup-exams/plans/${selected.value.id}/sessions`, payload)
|
||||||
|
}
|
||||||
|
sessionDialog.value = false
|
||||||
|
editingSession.value = null
|
||||||
|
await selectPlan(selected.value.id)
|
||||||
|
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
|
||||||
|
}
|
||||||
|
async function removeSession(row: any) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`移除"${row.courseName}"补考场次?`, '移除补考场次', { type: 'warning' })
|
||||||
|
await http.delete(`/makeup-exams/plans/${selected.value.id}/sessions/${row.id}`)
|
||||||
|
await selectPlan(selected.value.id)
|
||||||
|
} catch (error: any) {
|
||||||
|
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(`/makeup-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() {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('发布后考试时间、考场与监考安排将锁定。', '发布补考计划', {
|
||||||
|
type: 'warning', confirmButtonText: '确认发布',
|
||||||
|
})
|
||||||
|
await http.post(`/makeup-exams/plans/${selected.value.id}/publish`)
|
||||||
|
await load()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function showRoster(row: any) {
|
||||||
|
try {
|
||||||
|
roster.value = (await http.get(`/makeup-exams/sessions/${row.id}/roster`)).data
|
||||||
|
rosterDrawer.value = true
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEnrollment(session: any) {
|
||||||
|
enrollmentSession.value = session
|
||||||
|
eligibleStudents.value = []
|
||||||
|
selectedStudentIds.value = []
|
||||||
|
enrollmentDialog.value = true
|
||||||
|
}
|
||||||
|
async function loadEligibleStudents() {
|
||||||
|
if (!enrollmentSession.value) return
|
||||||
|
eligibleLoading.value = true
|
||||||
|
try {
|
||||||
|
eligibleStudents.value = (await http.get('/makeup-exams/eligible-students', {
|
||||||
|
params: { teachingTaskId: enrollmentSession.value.teachingTaskId },
|
||||||
|
})).data
|
||||||
|
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
|
||||||
|
finally { eligibleLoading.value = false }
|
||||||
|
}
|
||||||
|
function onEnrollmentSelection(rows: any[]) {
|
||||||
|
selectedStudentIds.value = rows.map((r: any) => r.studentId)
|
||||||
|
}
|
||||||
|
async function enrollSelectedStudents() {
|
||||||
|
if (!enrollmentSession.value || selectedStudentIds.value.length === 0) return
|
||||||
|
try {
|
||||||
|
await http.post(`/makeup-exams/sessions/${enrollmentSession.value.id}/enroll`, {
|
||||||
|
studentIds: selectedStudentIds.value,
|
||||||
|
})
|
||||||
|
ElMessage.success(`已登记 ${selectedStudentIds.value.length} 名学生`)
|
||||||
|
enrollmentDialog.value = false
|
||||||
|
await selectPlan(selected.value.id)
|
||||||
|
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
|
||||||
|
}
|
||||||
|
async function archivePlan() {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('归档后将不再对学生和教师显示。', '归档补考计划', {
|
||||||
|
type: 'info', confirmButtonText: '确认归档',
|
||||||
|
})
|
||||||
|
await http.post(`/makeup-exams/plans/${selected.value.id}/archive`)
|
||||||
|
await load()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
if (isManager.value) {
|
||||||
|
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('/teaching-tasks', { params: { page: 1, pageSize: 200 } }),
|
||||||
|
http.get('/base-data/classrooms'),
|
||||||
|
http.get('/personnel/teachers', { params: { page: 1, pageSize: 200, teacherStatus: 'Active' } }),
|
||||||
|
http.get('/base-data/buildings'),
|
||||||
|
currentTermId ? http.get('/makeup-exams/time-slots-for-term', { params: { academicTermId: currentTermId } }) : Promise.resolve({ data: [] }),
|
||||||
|
])
|
||||||
|
terms.value = termRes.data
|
||||||
|
tasks.value = taskRes.data.items
|
||||||
|
rooms.value = roomRes.data
|
||||||
|
teachers.value = teacherRes.data.items
|
||||||
|
buildings.value = buildingRes.data
|
||||||
|
timeSlots.value = slotRes.data
|
||||||
|
}
|
||||||
|
await load()
|
||||||
|
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-stack exam-page">
|
||||||
|
<section class="page-intro">
|
||||||
|
<div>
|
||||||
|
<span class="section-kicker">MAKE-UP EXAMINATION</span>
|
||||||
|
<h2>{{ isManager ? '补考安排' : isTeacher ? '补考监考' : '我的补考' }}</h2>
|
||||||
|
<p>{{ isManager ? '为不及格、缺考或缓考通过的学生安排补考,登记考生并录入成绩。' : '查看已发布的补考日程。' }}</p>
|
||||||
|
</div>
|
||||||
|
<el-button v-if="isManager" type="primary" :icon="Plus" @click="openPlan">新建补考计划</el-button>
|
||||||
|
<el-button v-else :icon="Refresh" @click="load">刷新日程</el-button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<template v-if="isManager">
|
||||||
|
<section class="exam-plan-strip">
|
||||||
|
<button v-for="plan in plans" :key="plan.id" :class="{ active: selected?.id === plan.id }" @click="selectPlan(plan.id)">
|
||||||
|
<span>{{ plan.termName }}</span><b>{{ plan.name }}</b>
|
||||||
|
<small>{{ plan.sessionCount }} 个场次</small><i>{{ statusLabels[plan.status] }}</i>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
<section v-if="selected" class="exam-board" v-loading="loading">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<span>MAKE-UP 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 === 'Published'" type="info" @click="archivePlan">归档</el-button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="exam-timeline">
|
||||||
|
<article v-for="session in selected.sessions" :key="session.id" :class="{ unassigned: !session.classroomId }">
|
||||||
|
<time>
|
||||||
|
<b>{{ dateOnlyText(session.examDate) }}</b>
|
||||||
|
<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">
|
||||||
|
<el-button link type="primary" @click="showRoster(session)">考生名单</el-button>
|
||||||
|
<el-button v-if="selected.status === 'Draft'" link type="success" @click="openEnrollment(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>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<el-empty v-if="!selected.sessions.length" description="尚未安排补考场次,点击「安排补考场次」开始。" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<section v-else class="exam-ticket-grid" v-loading="loading">
|
||||||
|
<article v-for="item in personal" :key="item.id">
|
||||||
|
<div class="exam-ticket-date">
|
||||||
|
<b>{{ dateOnlyText(item.examDate) }}</b>
|
||||||
|
<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>
|
||||||
|
<p v-if="item.reason" style="margin-top: 4px">
|
||||||
|
<span>补考原因:{{ item.reason === 1 ? '不及格' : item.reason === 2 ? '缺考' : '缓考通过' }}</span>
|
||||||
|
<span v-if="item.makeupScore != null"> · 成绩:<b>{{ item.makeupScore }}</b></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<el-icon><UserFilled /></el-icon>
|
||||||
|
{{ isTeacher ? `${item.enrolledCount ?? item.studentCount ?? 0} 名考生` : `监考:${item.invigilatorNames?.join('、') || '待定'}` }}
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
<el-empty v-if="!personal.length" description="暂无已发布补考安排" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Plan Dialog -->
|
||||||
|
<el-dialog v-model="planDialog" title="新建补考计划" width="600px">
|
||||||
|
<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-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>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="planDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="savePlan">保存草稿</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- Session Dialog -->
|
||||||
|
<el-dialog v-model="sessionDialog" :title="editingSession ? '编辑补考场次' : '安排补考场次'" width="720px" top="5vh">
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="教学班">
|
||||||
|
<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-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>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="sessionDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="saveSession">{{ editingSession ? '保存修改' : '保存场次' }}</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- Roster Drawer -->
|
||||||
|
<el-drawer v-model="rosterDrawer" title="考生名单" size="600px">
|
||||||
|
<el-table v-if="roster" :data="roster.students">
|
||||||
|
<el-table-column prop="studentNumber" label="学号" width="120" />
|
||||||
|
<el-table-column prop="name" label="姓名" width="80" />
|
||||||
|
<el-table-column prop="className" label="行政班" width="130" />
|
||||||
|
<el-table-column label="补考原因" width="90">
|
||||||
|
<template #default="scope">
|
||||||
|
<span>{{ scope.row.reason === 1 ? '不及格' : scope.row.reason === 2 ? '缺考' : scope.row.reason === 3 ? '缓考通过' : '-' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="成绩" width="70">
|
||||||
|
<template #default="scope">
|
||||||
|
{{ scope.row.makeupScore ?? '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-drawer>
|
||||||
|
|
||||||
|
<!-- Enrollment Dialog -->
|
||||||
|
<el-dialog v-model="enrollmentDialog" title="登记补考学生" width="700px">
|
||||||
|
<div v-if="enrollmentSession">
|
||||||
|
<p><b>{{ enrollmentSession.courseName }}</b> · {{ dateOnlyText(enrollmentSession.examDate) }} {{ periodLabel(enrollmentSession) }}</p>
|
||||||
|
<p style="margin-bottom: 12px">当前已登记:<b>{{ enrollmentSession.enrolledCount ?? enrollmentSession.studentCount }}</b> 人</p>
|
||||||
|
<el-button type="primary" :icon="Search" @click="loadEligibleStudents" :loading="eligibleLoading" style="margin-bottom: 12px">查询补考资格</el-button>
|
||||||
|
<el-table v-if="eligibleStudents.length" :data="eligibleStudents" @selection-change="onEnrollmentSelection" ref="enrollmentTable">
|
||||||
|
<el-table-column type="selection" width="40" />
|
||||||
|
<el-table-column prop="studentNumber" label="学号" width="120" />
|
||||||
|
<el-table-column prop="name" label="姓名" width="80" />
|
||||||
|
<el-table-column prop="className" label="行政班" width="140" />
|
||||||
|
<el-table-column label="补考原因" width="100">
|
||||||
|
<template #default="scope">
|
||||||
|
<span>{{ scope.row.reason === 1 ? '不及格' : scope.row.reason === 2 ? '缺考' : '缓考通过' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="详情" min-width="120">
|
||||||
|
<template #default="scope">
|
||||||
|
<span v-if="scope.row.reason === 1 && scope.row.totalScore != null">原始成绩 {{ scope.row.totalScore }}</span>
|
||||||
|
<span v-else-if="scope.row.reason === 3">已批准缓考</span>
|
||||||
|
<span v-else>缺考</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-empty v-else-if="!eligibleLoading" description="该教学班暂无需要补考的学生,或所有符合资格的学生均已登记。" />
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="enrollmentDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="enrollSelectedStudents" :disabled="!selectedStudentIds.length">登记所选 {{ selectedStudentIds.length }} 名学生</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.exam-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.exam-timeline article.unassigned {
|
||||||
|
border-left-color: #e6a23c;
|
||||||
|
}
|
||||||
|
.enrollment-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.enrollment-header p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user