考试排考
This commit is contained in:
@@ -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 (studentCount > room.Capacity)
|
|
||||||
return ConflictProblem($"考场容量不足:需容纳 {studentCount} 人,教室容量为 {room.Capacity}。");
|
if (classroomId.HasValue)
|
||||||
var teacherIds = request.InvigilatorIds.Distinct().ToArray();
|
{
|
||||||
if (teacherIds.Length == 0) return ValidationProblem("至少安排一名监考教师。");
|
var room = await db.Classrooms.AsNoTracking()
|
||||||
if (await db.Teachers.CountAsync(x => teacherIds.Contains(x.Id) &&
|
.FirstOrDefaultAsync(
|
||||||
x.Status == TeacherStatus.Active, cancellationToken) != teacherIds.Length)
|
x => x.Id == classroomId.Value && x.IsEnabled, cancellationToken);
|
||||||
return ValidationProblem("存在无效监考教师。");
|
if (room is null) return ValidationProblem("所选考场不存在或已停用。");
|
||||||
|
if (studentCount > room.Capacity)
|
||||||
|
return ConflictProblem(
|
||||||
|
$"考场容量不足:需容纳 {studentCount} 人,教室容量为 {room.Capacity}。");
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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))
|
|
||||||
return ConflictProblem("该时段考场已被占用。");
|
if (classroomId.HasValue)
|
||||||
if (await overlaps.AnyAsync(x =>
|
{
|
||||||
x.Invigilators.Any(i => teacherIds.Contains(i.TeacherId)), cancellationToken))
|
if (await overlaps.AnyAsync(
|
||||||
return ConflictProblem("监考教师在该时段已有考试任务。");
|
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("监考教师在该时段已有考试任务。");
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
"""
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+158
@@ -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,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
@@ -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>
|
||||||
|
|||||||
@@ -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 class="status-badge-group">
|
||||||
|
<i :class="detail.status.toLowerCase()">{{ statusLabels[detail.status] }}</i>
|
||||||
|
<small v-if="detail.status === 'Submitted' && detail.needsCollegeReview">
|
||||||
|
需 {{ detail.needsCollegeReview }} 审核
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<i :class="detail.status.toLowerCase()">{{ statusLabels[detail.status] }}</i>
|
|
||||||
</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 }">
|
||||||
<b class="total-score" :class="scoreClass(row.totalScore)">{{ row.totalScore ?? '—' }}</b>
|
<div class="total-score-cell">
|
||||||
|
<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 class="grade-actions-buttons">
|
||||||
|
<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.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.canPublish" type="primary" :icon="Promotion" @click="publishSheet">发布成绩</el-button>
|
||||||
</div>
|
</div>
|
||||||
<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.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.canPublish" type="primary" :icon="Promotion" @click="publishSheet">发布成绩</el-button>
|
|
||||||
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user