补考增强

This commit is contained in:
2026-07-25 19:38:39 +08:00 Unverified
parent 44e90ae35f
commit 1da6543a38
7 changed files with 714 additions and 10 deletions
@@ -18,10 +18,13 @@ public sealed class MakeupExamsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope,
MakeupExamEligibilityService eligibilityService,
MakeupExamArrangementService arrangementService) : ControllerBase
MakeupExamArrangementService arrangementService,
MakeupExamAutoJobQueue autoJobQueue) : ControllerBase
{
private const string Managers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
private const string ScoreEnterers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.Teacher;
// ═══════════════════════════════════════════
// Plans
@@ -284,6 +287,74 @@ public sealed class MakeupExamsController(
return Ok(new { message = result.Message });
}
// ═══════════════════════════════════════════
// Auto-create (background job)
// ═══════════════════════════════════════════
[HttpPost("plans/{planId:guid}/auto-create")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> AutoCreate(
Guid planId,
CancellationToken cancellationToken)
{
var plan = await db.MakeupExamPlans
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
if (plan is null) return NotFound();
if (plan.Status != MakeupExamPlanStatus.Draft)
return ConflictProblem("只有草稿状态的补考计划可以自动生成。");
// Check for existing active job
var existing = await db.MakeupExamAutoJobs.AsNoTracking()
.Where(x => x.MakeupExamPlanId == planId &&
(x.Status == MakeupExamAutoJobStatus.Queued ||
x.Status == MakeupExamAutoJobStatus.Running))
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (existing is not null)
return Ok(new { jobId = existing.Id, status = existing.Status.ToString(),
message = "该计划已有正在执行的任务。" });
var job = new MakeupExamAutoJob
{
MakeupExamPlanId = planId
};
db.MakeupExamAutoJobs.Add(job);
await db.SaveChangesAsync(cancellationToken);
autoJobQueue.Enqueue(job.Id);
return AcceptedAtAction(nameof(GetAutoJob), new { jobId = job.Id },
new { jobId = job.Id, status = job.Status.ToString() });
}
[HttpGet("auto-jobs/{jobId:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetAutoJob(
Guid jobId,
CancellationToken cancellationToken)
{
var job = await db.MakeupExamAutoJobs.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == jobId, cancellationToken);
if (job is null) return NotFound();
return Ok(new
{
job.Id,
job.MakeupExamPlanId,
Status = job.Status.ToString(),
job.TotalCourses,
job.ProcessedCourses,
job.CreatedSessions,
job.EnrolledStudents,
Messages = !string.IsNullOrEmpty(job.MessagesJson)
? System.Text.Json.JsonSerializer.Deserialize<List<string>>(job.MessagesJson)
: new List<string>(),
job.ErrorMessage,
job.CreatedAt,
job.StartedAt,
job.CompletedAt
});
}
// ═══════════════════════════════════════════
// Enrollments
// ═══════════════════════════════════════════
@@ -390,7 +461,7 @@ public sealed class MakeupExamsController(
// ═══════════════════════════════════════════
[HttpPut("sessions/{id:guid}/scores")]
[Authorize(Roles = Managers)]
[Authorize(Roles = ScoreEnterers)]
public async Task<ActionResult> RecordScores(
Guid id,
List<RecordMakeupScoreRequest> scores,
@@ -399,11 +470,23 @@ public sealed class MakeupExamsController(
var session = await db.MakeupExamSessions
.Include(x => x.MakeupExamPlan)
.Include(x => x.Enrollments)
.Include(x => x.TeachingTask!)
.ThenInclude(x => x.Teachers)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (session is null) return NotFound();
if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Published)
return ConflictProblem("只有已发布的补考计划可以录入成绩。");
// Teachers can only record scores for courses they teach
if (!IsManager())
{
var userId = currentUserDataScope.Current.UserId;
var teaches = session.TeachingTask!.Teachers
.Any(t => t.Teacher!.UserId == userId);
if (!teaches)
return Forbid();
}
var enrollmentByStudent = session.Enrollments.ToDictionary(e => e.StudentId);
foreach (var entry in scores)
@@ -413,19 +496,22 @@ public sealed class MakeupExamsController(
enrollment.MakeupScore = entry.Score;
// Update the grade record
// Update the grade record — 补考合格按60分记
if (enrollment.SourceGradeRecordId.HasValue)
{
var record = await db.GradeRecords
.Include(x => x.GradeSheet)
.FirstOrDefaultAsync(x => x.Id == enrollment.SourceGradeRecordId.Value,
cancellationToken);
if (record is not null)
{
record.ExamStatus = GradeExamStatus.Makeup;
record.TotalScore = entry.Score;
record.GradePoint = GradeCalculator.CalculateGradePoint(entry.Score);
record.Notes = "补考成绩";
// Cap passing score at 60
var cappedScore = entry.Score >= 60 ? 60m : entry.Score;
record.TotalScore = cappedScore;
record.GradePoint = GradeCalculator.CalculateGradePoint(cappedScore);
record.Notes = entry.Score >= 60
? $"补考合格(原始{entry.Score}分,按60分记)"
: $"补考不合格({entry.Score}分)";
}
}
}
@@ -650,6 +736,50 @@ public sealed class MakeupExamsController(
return Ok(Array.Empty<object>());
}
// ═══════════════════════════════════════════
// My teaching sessions (for score entry)
// ═══════════════════════════════════════════
[HttpGet("my-teaching-sessions")]
[Authorize(Roles = SystemRoles.Teacher)]
public async Task<ActionResult> GetMyTeachingSessions(CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
return Ok(await db.MakeupExamSessions.AsNoTracking()
.Where(x => x.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published &&
x.TeachingTask!.Teachers.Any(t => t.Teacher!.UserId == userId))
.OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod)
.Select(x => new
{
x.Id,
PlanName = x.MakeupExamPlan!.Name,
x.ExamDate,
x.StartPeriod,
x.PeriodCount,
x.StartsAt,
x.EndsAt,
x.TeachingTask!.TaskNumber,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
ClassroomName = x.Classroom != null ? x.Classroom.Name : null,
BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null,
EnrolledCount = x.Enrollments.Count,
GradedCount = x.Enrollments.Count(e => e.MakeupScore != null),
Enrollments = x.Enrollments.OrderBy(e => e.Student!.StudentNumber)
.Select(e => new
{
e.StudentId,
e.Student!.StudentNumber,
e.Student.Name,
ClassName = e.Student.AdministrativeClass!.Name,
e.Reason,
e.MakeupScore
}).ToList(),
IsMakeup = true
})
.ToListAsync(cancellationToken));
}
// ═══════════════════════════════════════════
// Private helpers
// ═══════════════════════════════════════════
@@ -69,3 +69,26 @@ public enum MakeupReason
Absent = 2,
DeferredApproved = 3
}
public sealed class MakeupExamAutoJob : EntityBase
{
public Guid MakeupExamPlanId { get; set; }
public MakeupExamPlan? MakeupExamPlan { get; set; }
public MakeupExamAutoJobStatus Status { get; set; } = MakeupExamAutoJobStatus.Queued;
public int TotalCourses { get; set; }
public int ProcessedCourses { get; set; }
public int CreatedSessions { get; set; }
public int EnrolledStudents { get; set; }
public string? MessagesJson { get; set; }
public string? ErrorMessage { get; set; }
public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
}
public enum MakeupExamAutoJobStatus
{
Queued = 1,
Running = 2,
Succeeded = 3,
Failed = 4
}
@@ -0,0 +1,342 @@
using System.Text.Json;
using System.Threading.Channels;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Exams;
public sealed class MakeupExamAutoJobQueue
{
private readonly Channel<Guid> _channel = Channel.CreateUnbounded<Guid>(
new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false
});
public void Enqueue(Guid jobId)
{
if (!_channel.Writer.TryWrite(jobId))
throw new InvalidOperationException("补考自动生成任务队列当前不可用。");
}
public IAsyncEnumerable<Guid> ReadAllAsync(CancellationToken cancellationToken) =>
_channel.Reader.ReadAllAsync(cancellationToken);
}
public sealed class MakeupExamAutoJobWorker(
IServiceScopeFactory scopeFactory,
MakeupExamAutoJobQueue queue,
ILogger<MakeupExamAutoJobWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
await RecoverInterruptedJobsAsync(stoppingToken);
}
catch (Exception exception)
{
logger.LogWarning(
exception,
"Could not recover makeup exam auto jobs (table may not exist yet).");
}
try
{
await foreach (var jobId in queue.ReadAllAsync(stoppingToken))
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var processor = scope.ServiceProvider
.GetRequiredService<MakeupExamAutoJobProcessor>();
await processor.ProcessAsync(jobId, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogError(
exception,
"Unexpected failure while dispatching makeup exam auto job {JobId}.",
jobId);
}
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
logger.LogInformation("Makeup exam auto job worker is stopping.");
}
}
private async Task RecoverInterruptedJobsAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var jobs = await db.MakeupExamAutoJobs
.Where(x =>
x.Status == MakeupExamAutoJobStatus.Queued ||
x.Status == MakeupExamAutoJobStatus.Running)
.OrderBy(x => x.CreatedAt)
.ToListAsync(cancellationToken);
foreach (var job in jobs)
{
job.Status = MakeupExamAutoJobStatus.Queued;
job.StartedAt = null;
job.CompletedAt = null;
job.ErrorMessage = null;
}
if (jobs.Count > 0)
await db.SaveChangesAsync(cancellationToken);
foreach (var job in jobs)
queue.Enqueue(job.Id);
if (jobs.Count > 0)
{
logger.LogInformation(
"Recovered {JobCount} queued or interrupted makeup exam auto jobs.",
jobs.Count);
}
}
}
public sealed class MakeupExamAutoJobProcessor(
AppDbContext db,
MakeupExamEligibilityService eligibilityService,
ILogger<MakeupExamAutoJobProcessor> logger)
{
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
{
try
{
var job = await db.MakeupExamAutoJobs
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
if (job is null ||
job.Status is MakeupExamAutoJobStatus.Succeeded
or MakeupExamAutoJobStatus.Failed)
{
return;
}
var plan = await db.MakeupExamPlans
.Include(x => x.Sessions)
.FirstOrDefaultAsync(x => x.Id == job.MakeupExamPlanId, stoppingToken);
if (plan is null || plan.Status != MakeupExamPlanStatus.Draft)
throw new InvalidOperationException("补考计划草稿不存在或已不允许修改。");
job.Status = MakeupExamAutoJobStatus.Running;
job.StartedAt = DateTime.UtcNow;
job.CompletedAt = null;
job.ErrorMessage = null;
job.MessagesJson = null;
await db.SaveChangesAsync(stoppingToken);
// Get time slots for the term
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
.Where(x => x.AcademicTermId == plan.AcademicTermId && x.IsEnabled)
.OrderBy(x => x.PeriodNumber)
.ToListAsync(stoppingToken);
if (timeSlots.Count == 0)
throw new InvalidOperationException("当前学期未配置上课时间表。");
var slotLookup = timeSlots.ToDictionary(x => x.PeriodNumber);
// Get all published teaching tasks for this term with grade sheets
var tasks = await db.TeachingTasks.AsNoTracking()
.Where(x => x.AcademicTermId == plan.AcademicTermId &&
x.Status == TeachingTaskStatus.Published)
.Select(x => new { x.Id, x.TaskNumber, x.Name, CourseName = x.Course!.Name })
.OrderBy(x => x.TaskNumber)
.ToListAsync(stoppingToken);
job.TotalCourses = tasks.Count;
job.ProcessedCourses = 0;
job.CreatedSessions = 0;
job.EnrolledStudents = 0;
await db.SaveChangesAsync(stoppingToken);
var messages = new List<string>();
var existingTaskIds = plan.Sessions.Select(s => s.TeachingTaskId).ToHashSet();
// Get available classrooms for mixed-room assignment
var allRooms = await db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled)
.OrderBy(x => x.Capacity)
.Select(x => new { x.Id, x.Name, x.Capacity, x.BuildingId })
.ToListAsync(stoppingToken);
// Get available teachers
var allTeachers = await db.Teachers.AsNoTracking()
.Where(x => x.Status == TeacherStatus.Active)
.Select(x => new { x.Id, x.Name })
.ToListAsync(stoppingToken);
// Auto-assign dates: spread sessions across available dates
// Start from tomorrow, skip weekends, assign one session per time slot per day
var startDate = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7));
var dateCursor = startDate;
var periodSlots = timeSlots
.Where(x => x.PeriodNumber <= timeSlots.Count - 1)
.Select(x => x.PeriodNumber)
.ToList();
foreach (var task in tasks)
{
stoppingToken.ThrowIfCancellationRequested();
// Skip if session already exists for this task
if (existingTaskIds.Contains(task.Id))
{
job.ProcessedCourses++;
continue;
}
// Query eligible students
var eligible = await eligibilityService.GetEligibleStudentsAsync(
task.Id, stoppingToken);
if (eligible.Count == 0)
{
job.ProcessedCourses++;
continue;
}
// Skip weekends for date assignment
while (dateCursor.DayOfWeek == DayOfWeek.Saturday ||
dateCursor.DayOfWeek == DayOfWeek.Sunday)
dateCursor = dateCursor.AddDays(1);
// Determine time slot (rotate through available period slots)
var periodIdx = (job.CreatedSessions) % Math.Max(periodSlots.Count, 1);
var startPeriod = periodSlots[periodIdx];
var periodCount = 2;
// Resolve time
var startSlot = slotLookup.GetValueOrDefault(startPeriod);
var endSlot = slotLookup.GetValueOrDefault(startPeriod + periodCount - 1);
if (startSlot is null || endSlot is null)
{
job.ProcessedCourses++;
continue;
}
var startsAt = dateCursor.ToDateTime(startSlot.StartsAt, DateTimeKind.Utc);
var endsAt = dateCursor.ToDateTime(endSlot.EndsAt, DateTimeKind.Utc);
// Find a classroom (mixed-subject: no room conflict check)
var room = allRooms.FirstOrDefault(r => r.Capacity >= eligible.Count);
// Create session
var session = new MakeupExamSession
{
MakeupExamPlanId = plan.Id,
TeachingTaskId = task.Id,
ClassroomId = room?.Id,
ExamDate = dateCursor,
StartPeriod = startPeriod,
PeriodCount = periodCount,
StartsAt = startsAt,
EndsAt = endsAt,
RequiredInvigilatorCount = 2,
Enrollments = eligible.Select(e => new MakeupExamEnrollment
{
StudentId = e.StudentId,
Reason = e.Reason,
SourceGradeRecordId = e.SourceGradeRecordId,
SourceDeferredExamId = e.SourceDeferredExamId
}).ToList()
};
// Auto-assign invigilators
var excludeTeacherIds = new HashSet<Guid>();
var assigned = 0;
foreach (var teacher in allTeachers)
{
if (assigned >= 2) break;
if (excludeTeacherIds.Contains(teacher.Id)) continue;
session.Invigilators.Add(new MakeupExamSessionInvigilator
{
MakeupExamSessionId = session.Id,
TeacherId = teacher.Id
});
excludeTeacherIds.Add(teacher.Id);
assigned++;
}
db.MakeupExamSessions.Add(session);
existingTaskIds.Add(task.Id);
job.CreatedSessions++;
job.EnrolledStudents += eligible.Count;
job.ProcessedCourses++;
messages.Add(
$"\"{task.CourseName}\"→{dateCursor:MM/dd} 第{startPeriod}节 " +
$"{(room != null ? room.Name : "")} · {eligible.Count}人");
// Advance date cursor every few sessions
if (job.CreatedSessions % periodSlots.Count == 0)
dateCursor = dateCursor.AddDays(1);
// Persist progress periodically
if (job.ProcessedCourses % 10 == 0 || job.ProcessedCourses >= job.TotalCourses)
await PersistProgressAsync(job, stoppingToken);
}
job.Status = MakeupExamAutoJobStatus.Succeeded;
job.MessagesJson = JsonSerializer.Serialize(messages.Take(20).ToList());
job.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(stoppingToken);
logger.LogInformation(
"Makeup exam auto job {JobId} completed: {Sessions} sessions, {Students} students.",
job.Id, job.CreatedSessions, job.EnrolledStudents);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
logger.LogInformation(
"Makeup exam auto job {JobId} was interrupted by application shutdown.", jobId);
throw;
}
catch (Exception exception)
{
logger.LogError(exception, "Makeup exam auto job {JobId} failed.", jobId);
await MarkFailedAsync(jobId, exception);
}
}
private async Task PersistProgressAsync(
MakeupExamAutoJob job,
CancellationToken cancellationToken)
{
try
{
await db.SaveChangesAsync(cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
}
private async Task MarkFailedAsync(Guid jobId, Exception exception)
{
db.ChangeTracker.Clear();
var job = await db.MakeupExamAutoJobs.FirstOrDefaultAsync(
x => x.Id == jobId, CancellationToken.None);
if (job is null) return;
var message = exception.GetBaseException().Message;
job.Status = MakeupExamAutoJobStatus.Failed;
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
job.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(CancellationToken.None);
}
}
@@ -61,6 +61,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
Set<MakeupExamSessionInvigilator>();
public DbSet<MakeupExamEnrollment> MakeupExamEnrollments =>
Set<MakeupExamEnrollment>();
public DbSet<MakeupExamAutoJob> MakeupExamAutoJobs => Set<MakeupExamAutoJob>();
public DbSet<CourseAdjustment> CourseAdjustments => Set<CourseAdjustment>();
public DbSet<CourseExemption> CourseExemptions => Set<CourseExemption>();
public DbSet<DeferredExam> DeferredExams => Set<DeferredExam>();
@@ -650,6 +651,14 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.HasOne(x => x.SourceDeferredExam).WithMany()
.HasForeignKey(x => x.SourceDeferredExamId).OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<MakeupExamAutoJob>(entity =>
{
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
entity.HasIndex(x => new { x.MakeupExamPlanId, x.CreatedAt });
entity.HasIndex(x => new { x.Status, x.CreatedAt });
entity.HasOne(x => x.MakeupExamPlan).WithMany()
.HasForeignKey(x => x.MakeupExamPlanId).OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<StudentStatusChange>(entity =>
{
entity.Property(x => x.Reason).HasMaxLength(1000);
@@ -306,6 +306,14 @@ public sealed class DevelopmentSqliteMigrator(
MakeupExamsMigration,
makeupExamsExist ? [] : MakeupExamStatements,
cancellationToken);
var makeupAutoJobsExist = await db.Database
.SqlQueryRaw<int>("SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'MakeupExamAutoJobs'")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
MakeupExamAutoJobsMigration,
makeupAutoJobsExist ? [] : MakeupExamAutoJobStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -1675,4 +1683,13 @@ public sealed class DevelopmentSqliteMigrator(
"""CREATE TABLE "MakeupExamEnrollments" ("MakeupExamSessionId" TEXT NOT NULL, "StudentId" TEXT NOT NULL, "Reason" INTEGER NOT NULL, "SourceGradeRecordId" TEXT NULL, "SourceDeferredExamId" TEXT NULL, "MakeupScore" TEXT NULL, CONSTRAINT "PK_MakeupExamEnrollments" PRIMARY KEY ("MakeupExamSessionId", "StudentId"), CONSTRAINT "FK_MakeupExamEnrollments_MakeupExamSessions_MakeupExamSessionId" FOREIGN KEY ("MakeupExamSessionId") REFERENCES "MakeupExamSessions" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_MakeupExamEnrollments_Students_StudentId" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_MakeupExamEnrollments_GradeRecords_SourceGradeRecordId" FOREIGN KEY ("SourceGradeRecordId") REFERENCES "GradeRecords" ("Id") ON DELETE SET NULL, CONSTRAINT "FK_MakeupExamEnrollments_DeferredExams_SourceDeferredExamId" FOREIGN KEY ("SourceDeferredExamId") REFERENCES "DeferredExams" ("Id") ON DELETE SET NULL);""",
"""CREATE INDEX "IX_MakeupExamEnrollments_StudentId_MakeupExamSessionId" ON "MakeupExamEnrollments" ("StudentId", "MakeupExamSessionId");""",
];
private const string MakeupExamAutoJobsMigration = "MakeupExamAutoJobs";
private static readonly string[] MakeupExamAutoJobStatements =
[
"""CREATE TABLE "MakeupExamAutoJobs" ("Id" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, "MakeupExamPlanId" TEXT NOT NULL, "Status" INTEGER NOT NULL, "TotalCourses" INTEGER NOT NULL, "ProcessedCourses" INTEGER NOT NULL, "CreatedSessions" INTEGER NOT NULL, "EnrolledStudents" INTEGER NOT NULL, "MessagesJson" TEXT NULL, "ErrorMessage" TEXT NULL, "StartedAt" TEXT NULL, "CompletedAt" TEXT NULL, CONSTRAINT "PK_MakeupExamAutoJobs" PRIMARY KEY ("Id"), CONSTRAINT "FK_MakeupExamAutoJobs_MakeupExamPlans_MakeupExamPlanId" FOREIGN KEY ("MakeupExamPlanId") REFERENCES "MakeupExamPlans" ("Id") ON DELETE CASCADE);""",
"""CREATE INDEX "IX_MakeupExamAutoJobs_MakeupExamPlanId_CreatedAt" ON "MakeupExamAutoJobs" ("MakeupExamPlanId", "CreatedAt");""",
"""CREATE INDEX "IX_MakeupExamAutoJobs_Status_CreatedAt" ON "MakeupExamAutoJobs" ("Status", "CreatedAt");""",
];
}
+3
View File
@@ -105,6 +105,9 @@ builder.Services.AddHostedService<WarningCheckWorker>();
builder.Services.AddScoped<ExamArrangementService>();
builder.Services.AddScoped<MakeupExamEligibilityService>();
builder.Services.AddScoped<MakeupExamArrangementService>();
builder.Services.AddSingleton<MakeupExamAutoJobQueue>();
builder.Services.AddHostedService<MakeupExamAutoJobWorker>();
builder.Services.AddScoped<MakeupExamAutoJobProcessor>();
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+183 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Plus, Promotion, Refresh, UserFilled, Setting, Search } from '@element-plus/icons-vue'
import { Plus, Promotion, Refresh, UserFilled, Setting, Search, MagicStick } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
@@ -11,6 +11,11 @@ const isTeacher = computed(() => auth.user?.roles.includes('Teacher') && !isMana
const plans = ref<any[]>([])
const selected = ref<any | null>(null)
const personal = ref<any[]>([])
const teachingSessions = ref<any[]>([])
const scoreDialogVisible = ref(false)
const scoreSession = ref<any | null>(null)
const scoreMap = reactive<Record<string, number | null>>({})
const scoreSaving = ref(false)
const terms = ref<any[]>([])
const tasks = ref<any[]>([])
const rooms = ref<any[]>([])
@@ -55,6 +60,9 @@ async function load() {
try {
if (!isManager.value) {
personal.value = (await http.get('/makeup-exams/my-schedule')).data
if (isTeacher.value) {
teachingSessions.value = (await http.get('/makeup-exams/my-teaching-sessions')).data
}
return
}
plans.value = (await http.get('/makeup-exams/plans')).data
@@ -205,6 +213,94 @@ async function archivePlan() {
}
}
// ── Score entry (teacher) ──
function openScoreDialog(session: any) {
scoreSession.value = session
Object.keys(scoreMap).forEach(k => delete scoreMap[k])
if (session.enrollments) {
for (const e of session.enrollments) {
scoreMap[e.studentId] = e.makeupScore ?? null
}
}
scoreDialogVisible.value = true
}
function onScoreInput(studentId: string, val: any) {
scoreMap[studentId] = val ?? null
}
async function submitScores() {
if (!scoreSession.value) return
const entries = Object.entries(scoreMap)
.filter(([, v]) => v !== null && v !== undefined)
.map(([studentId, score]) => ({ studentId, score: Number(score) }))
if (entries.length === 0) { ElMessage.warning('没有可保存的成绩'); return }
scoreSaving.value = true
try {
await http.put(`/makeup-exams/sessions/${scoreSession.value.id}/scores`, entries)
ElMessage.success(`已保存 ${entries.length} 条补考成绩(合格按60分记)`)
scoreDialogVisible.value = false
await load()
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
finally { scoreSaving.value = false }
}
// ── Auto-create job ──
const autoJobId = ref<string | null>(null)
const autoJobStatus = ref<string | null>(null)
const autoJobProgress = ref(0)
const autoJobMessage = ref('')
let pollTimer: ReturnType<typeof setInterval> | null = null
async function startAutoCreate() {
try {
await ElMessageBox.confirm(
'系统将自动扫描所有已发布教学任务,查询需补考的学生,创建补考场次并登记学生。不同补考科目可混编在同一考场。',
'一键生成补考安排', { type: 'info', confirmButtonText: '开始生成' })
const res = await http.post(`/makeup-exams/plans/${selected.value.id}/auto-create`)
autoJobId.value = res.data.jobId
autoJobStatus.value = 'Queued'
autoJobProgress.value = 0
autoJobMessage.value = '任务已提交,正在排队...'
startPolling()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
function startPolling() {
if (pollTimer) clearInterval(pollTimer)
pollTimer = setInterval(pollAutoJob, 1500)
}
async function pollAutoJob() {
if (!autoJobId.value) return
try {
const res = await http.get(`/makeup-exams/auto-jobs/${autoJobId.value}`)
const job = res.data
autoJobStatus.value = job.status
if (job.totalCourses > 0) {
autoJobProgress.value = Math.round((job.processedCourses / job.totalCourses) * 100)
}
autoJobMessage.value = job.status === 'Succeeded'
? `已完成:${job.createdSessions} 个补考场次,${job.enrolledStudents} 名学生`
: job.status === 'Failed'
? `失败:${job.errorMessage || '未知错误'}`
: `处理中:${job.processedCourses}/${job.totalCourses} 门课程`
if (job.status === 'Succeeded' || job.status === 'Failed') {
stopPolling()
if (job.status === 'Succeeded') {
ElMessage.success(autoJobMessage.value)
await selectPlan(selected.value.id)
} else {
ElMessage.error(autoJobMessage.value)
}
}
} catch (_) { stopPolling() }
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
}
onMounted(async () => {
try {
if (isManager.value) {
@@ -256,12 +352,20 @@ onMounted(async () => {
<p>{{ selected.termName }} · {{ selected.sessions.length }} 个补考场次</p>
</div>
<div class="exam-actions">
<el-button v-if="selected.status === 'Draft'" :icon="MagicStick" type="success" @click="startAutoCreate">一键生成</el-button>
<el-button v-if="selected.status === 'Draft'" :icon="Setting" @click="autoArrange" :loading="arrangeLoading">自动编排</el-button>
<el-button v-if="selected.status === 'Draft'" :icon="Plus" @click="openSession()">安排补考场次</el-button>
<el-button v-if="selected.status === 'Draft'" type="primary" :icon="Promotion" @click="publishPlan">发布计划</el-button>
<el-button v-if="selected.status === 'Published'" type="info" @click="archivePlan">归档</el-button>
</div>
</header>
<div v-if="autoJobId && autoJobStatus !== 'Succeeded' && autoJobStatus !== 'Failed'" style="margin-bottom: 16px">
<el-alert :title="autoJobMessage" type="info" :closable="false">
<template #default>
<el-progress :percentage="autoJobProgress" :stroke-width="8" />
</template>
</el-alert>
</div>
<div class="exam-timeline">
<article v-for="session in selected.sessions" :key="session.id" :class="{ unassigned: !session.classroomId }">
<time>
@@ -296,7 +400,37 @@ onMounted(async () => {
</section>
</template>
<section v-else class="exam-ticket-grid" v-loading="loading">
<!-- Teacher: teaching sessions for score entry -->
<section v-if="isTeacher && teachingSessions.length" class="exam-board" v-loading="loading" style="margin-top: 0">
<header>
<div>
<span>MAKE-UP EXAM GRADING</span>
<h3>任课补考成绩录入</h3>
<p>以下为您任课班级的补考场次补考合格按60分记入最终成绩</p>
</div>
</header>
<div class="exam-timeline">
<article v-for="session in teachingSessions" :key="session.id">
<time>
<b>{{ dateOnlyText(session.examDate) }}</b>
<span>{{ periodLabel(session) }}</span>
</time>
<div>
<span>{{ session.courseCode }} · {{ session.taskNumber }}</span>
<h4>{{ session.courseName }}</h4>
<p>
{{ session.buildingName ? `${session.buildingName} · ${session.classroomName}` : '考场待定' }}
· {{ session.enrolledCount }} · 已录 {{ session.gradedCount }}
</p>
</div>
<div class="exam-row-actions">
<el-button type="warning" @click="openScoreDialog(session)">录入成绩</el-button>
</div>
</article>
</div>
</section>
<section v-else-if="!isTeacher" class="exam-ticket-grid" v-loading="loading">
<article v-for="item in personal" :key="item.id">
<div class="exam-ticket-date">
<b>{{ dateOnlyText(item.examDate) }}</b>
@@ -313,12 +447,32 @@ onMounted(async () => {
</div>
<footer>
<el-icon><UserFilled /></el-icon>
{{ isTeacher ? `${item.enrolledCount ?? item.studentCount ?? 0} 名考生` : `监考:${item.invigilatorNames?.join('、') || '待定'}` }}
监考{{ item.invigilatorNames?.join('、') || '待定' }}
</footer>
</article>
<el-empty v-if="!personal.length" description="暂无已发布补考安排" />
</section>
<!-- Teacher invigilation schedule -->
<section v-if="isTeacher && personal.length" class="exam-ticket-grid" v-loading="loading" style="margin-top: 0">
<h3 style="margin-bottom: 16px; font-size: 16px; color: #666">补考监考安排</h3>
<article v-for="item in personal" :key="'inv-' + item.id">
<div class="exam-ticket-date">
<b>{{ dateOnlyText(item.examDate) }}</b>
<span>{{ timeText(item.startsAt) }}{{ timeText(item.endsAt) }}</span>
</div>
<div>
<span>{{ item.courseCode }} · {{ item.taskNumber }}</span>
<h3>{{ item.courseName }}</h3>
<p>{{ item.buildingName ? `${item.buildingName} · ${item.classroomName}` : '考场待定' }}</p>
</div>
<footer>
<el-icon><UserFilled /></el-icon>
{{ item.enrolledCount ?? item.studentCount ?? 0 }} 名考生
</footer>
</article>
</section>
<!-- Plan Dialog -->
<el-dialog v-model="planDialog" title="新建补考计划" width="600px">
<el-form label-position="top">
@@ -439,6 +593,32 @@ onMounted(async () => {
<el-button type="primary" @click="enrollSelectedStudents" :disabled="!selectedStudentIds.length">登记所选 {{ selectedStudentIds.length }} 名学生</el-button>
</template>
</el-dialog>
<!-- Score Dialog (teacher) -->
<el-dialog v-model="scoreDialogVisible" title="录入补考成绩" width="680px">
<template v-if="scoreSession">
<p style="margin-bottom: 12px"><b>{{ scoreSession.courseName }}</b> · {{ dateOnlyText(scoreSession.examDate) }} · {{ scoreSession.enrolledCount }} 名考生</p>
<el-alert type="warning" title="补考合格按60分记入最终成绩" :closable="false" style="margin-bottom: 12px" />
<el-table :data="scoreSession.enrollments" size="small">
<el-table-column prop="studentNumber" label="学号" width="120" />
<el-table-column prop="name" label="姓名" width="80" />
<el-table-column label="补考原因" width="90">
<template #default="scope">
<span>{{ scope.row.reason === 1 ? '不及格' : scope.row.reason === 2 ? '缺考' : '缓考通过' }}</span>
</template>
</el-table-column>
<el-table-column label="补考成绩" width="150">
<template #default="scope">
<el-input-number :model-value="scoreMap[scope.row.studentId]" @update:model-value="onScoreInput(scope.row.studentId, $event)" :min="0" :max="100" :precision="1" size="small" controls-position="right" placeholder="0-100" />
</template>
</el-table-column>
</el-table>
</template>
<template #footer>
<el-button @click="scoreDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitScores" :loading="scoreSaving">保存成绩</el-button>
</template>
</el-dialog>
</div>
</template>