已修改完成,同时解决了“考生名单都是 0 人”的根因。

考生范围统一为“行政班关联学生 + 已选课学生”,并自动去重;考场分配、人数显示、冲突检测、学生考试安排均使用同一口径。[TeachingTaskRosterQuery.cs (line 40)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Teaching/TeachingTaskRosterQuery.cs:40)
新增考场签名单 Excel 导出接口。[ExamsController.cs (line 568)](E:/jiaowu/src/Jiaowu.Api/Controllers/ExamsController.cs:568)
考试计划页面新增“导出考场签名单”按钮。[ExamsView.vue (line 255)](E:/jiaowu/web/src/views/ExamsView.vue:255)
工作簿包含“考场汇总”以及每个考试场次独立的 A4 签到表,字段包括座位号、学号、姓名、行政班、考生签名和备注。[ExamSignInWorkbookExporter.cs (line 12)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Exams/ExamSignInWorkbookExporter.cs:12)
未分配考场或监考教师时,也能导出并显示“待分配”。
This commit is contained in:
2026-07-27 18:24:32 +08:00 Unverified
parent 2e422acd19
commit fb2acc6751
9 changed files with 856 additions and 71 deletions
+199 -61
View File
@@ -4,7 +4,9 @@ using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Exams;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -113,13 +115,52 @@ public sealed class ExamsController(
item.RequiredInvigilatorCount,
item.Notes,
InvigilatorIds = item.Invigilators.Select(i => i.TeacherId),
InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name),
StudentCount = db.CourseEnrollments.Count(e =>
e.Status == CourseEnrollmentStatus.Enrolled &&
e.CourseSelectionOffering!.TeachingTaskId == item.TeachingTaskId)
InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name)
})
}).FirstOrDefaultAsync(cancellationToken);
return plan is null ? NotFound() : Ok(plan);
if (plan is null) return NotFound();
var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync(
db,
plan.Sessions.Select(x => x.TeachingTaskId),
cancellationToken))
.GroupBy(x => x.TeachingTaskId)
.ToDictionary(x => x.Key, x => x.Count());
return Ok(new
{
plan.Id,
plan.Name,
plan.AcademicTermId,
plan.TermName,
plan.Status,
plan.Notes,
plan.PublishedAt,
Sessions = plan.Sessions.Select(item => new
{
item.Id,
item.TeachingTaskId,
item.TaskNumber,
item.TaskName,
item.CourseCode,
item.CourseName,
item.ClassroomId,
item.ClassroomName,
item.BuildingName,
item.ClassroomCapacity,
item.ExamDate,
item.StartPeriod,
item.PeriodCount,
item.StartsAt,
item.EndsAt,
item.RequiredBuildingId,
item.RequiredBuildingName,
item.RequiredInvigilatorCount,
item.Notes,
item.InvigilatorIds,
item.InvigilatorNames,
StudentCount = rosterCounts.GetValueOrDefault(item.TeachingTaskId)
})
});
}
// ═══════════════════════════════════════════
@@ -209,16 +250,11 @@ public sealed class ExamsController(
if (duplicateTaskIds.Count > 0)
return ConflictProblem($"所选教学班中有 {duplicateTaskIds.Count} 个已在当前计划中安排考试。");
var enrollmentPairs = await db.CourseEnrollments.AsNoTracking()
.Where(x => x.Status == CourseEnrollmentStatus.Enrolled)
.WhereIn(taskIds, x => x.CourseSelectionOffering!.TeachingTaskId)
.Select(x => new
{
x.StudentId,
x.CourseSelectionOffering!.TeachingTaskId
})
.ToListAsync(cancellationToken);
var conflictedStudentCount = enrollmentPairs
var rosterPairs = await TeachingTaskRosterQuery.LoadForTasksAsync(
db,
taskIds,
cancellationToken);
var conflictedStudentCount = rosterPairs
.GroupBy(x => x.StudentId)
.Count(group => group.Select(x => x.TeachingTaskId).Distinct().Skip(1).Any());
if (conflictedStudentCount > 0)
@@ -470,7 +506,7 @@ public sealed class ExamsController(
if (plan is null) return NotFound();
if (plan.Status != ExamPlanStatus.Draft)
return ConflictProblem("只有草稿考试计划可以发布。");
if (plan.Sessions.Count == 0)
if (!plan.Sessions.Any())
return ConflictProblem("至少安排一个考试场次后才能发布。");
var unassigned = plan.Sessions.Count(x =>
@@ -505,18 +541,10 @@ public sealed class ExamsController(
}).FirstOrDefaultAsync(cancellationToken);
if (session is null) return NotFound();
var students = await db.CourseEnrollments.AsNoTracking()
.Where(e => e.Status == CourseEnrollmentStatus.Enrolled &&
e.CourseSelectionOffering!.TeachingTaskId == session.TeachingTaskId)
.OrderBy(e => e.Student!.StudentNumber)
.Select(e => new
{
e.StudentId,
e.Student!.StudentNumber,
e.Student.Name,
ClassName = e.Student.AdministrativeClass!.Name
})
.ToListAsync(cancellationToken);
var students = await TeachingTaskRosterQuery.LoadForTasksAsync(
db,
[session.TeachingTaskId],
cancellationToken);
return Ok(new
{
@@ -537,6 +565,82 @@ public sealed class ExamsController(
});
}
[HttpGet("plans/{id:guid}/sign-in-sheets.xlsx")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> ExportSignInSheets(
Guid id,
CancellationToken cancellationToken)
{
var plan = await db.ExamPlans.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Name,
TermName = x.AcademicTerm!.Name,
Sessions = x.Sessions
.OrderBy(session => session.ExamDate)
.ThenBy(session => session.StartPeriod)
.Select(session => new
{
session.Id,
session.TeachingTaskId,
session.ExamDate,
session.StartsAt,
session.EndsAt,
CourseCode = session.TeachingTask!.Course!.Code,
CourseName = session.TeachingTask.Course.Name,
session.TeachingTask.TaskNumber,
BuildingName = session.Classroom != null
? session.Classroom.Building!.Name
: null,
ClassroomName = session.Classroom != null
? session.Classroom.Name
: null,
InvigilatorNames = session.Invigilators
.OrderBy(item => item.Teacher!.TeacherNumber)
.Select(item => item.Teacher!.Name)
})
})
.FirstOrDefaultAsync(cancellationToken);
if (plan is null) return NotFound();
if (!plan.Sessions.Any())
return ConflictProblem("当前考试计划没有可导出的考试场次。");
var roster = await TeachingTaskRosterQuery.LoadForTasksAsync(
db,
plan.Sessions.Select(x => x.TeachingTaskId),
cancellationToken);
var studentsByTask = roster.ToLookup(x => x.TeachingTaskId);
var data = new ExamSignInWorkbookData(
plan.Name,
plan.TermName,
plan.Sessions.Select(session => new ExamSignInSessionData(
session.Id,
session.ExamDate,
session.StartsAt,
session.EndsAt,
session.CourseCode,
session.CourseName,
session.TaskNumber,
session.BuildingName,
session.ClassroomName,
session.InvigilatorNames.ToList(),
studentsByTask[session.TeachingTaskId]
.Select(student => new ExamSignInStudentData(
student.StudentId,
student.StudentNumber,
student.Name,
student.ClassName))
.ToList()))
.ToList());
var bytes = ExamSignInWorkbookExporter.Create(data);
return File(
bytes,
ExcelWorkbookHelper.ContentType,
$"考场签名单-{FileNamePart(plan.Name)}.xlsx");
}
// ═══════════════════════════════════════════
// My schedule
// ═══════════════════════════════════════════
@@ -551,11 +655,12 @@ public sealed class ExamsController(
.Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken);
if (!studentId.HasValue)
return ConflictProblem("当前账号未关联学生档案。");
var rosterTaskIds = TeachingTaskRosterQuery.TaskIdsForStudent(
db,
studentId.Value);
var schedule = await db.ExamSessions.AsNoTracking()
.Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published &&
db.CourseEnrollments.Any(e => e.StudentId == studentId &&
e.Status == CourseEnrollmentStatus.Enrolled &&
e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId))
rosterTaskIds.Contains(x.TeachingTaskId))
.OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod)
.Select(x => new
{
@@ -579,17 +684,10 @@ public sealed class ExamsController(
if (schedule.Count == 0) return Ok(schedule);
var taskIds = schedule.Select(x => x.TeachingTaskId).Distinct().ToArray();
var seatRows = await db.CourseEnrollments.AsNoTracking()
.Where(x => x.Status == CourseEnrollmentStatus.Enrolled)
.WhereIn(taskIds, x => x.CourseSelectionOffering!.TeachingTaskId)
.OrderBy(x => x.Student!.StudentNumber)
.Select(x => new
{
x.CourseSelectionOffering!.TeachingTaskId,
x.StudentId,
x.Student!.StudentNumber
})
.ToListAsync(cancellationToken);
var seatRows = await TeachingTaskRosterQuery.LoadForTasksAsync(
db,
taskIds,
cancellationToken);
var seatNumbers = seatRows
.GroupBy(x => x.TeachingTaskId)
.SelectMany(group => group.Select((row, index) => new
@@ -621,7 +719,7 @@ public sealed class ExamsController(
}
if (scope.IsInRole(SystemRoles.Teacher))
{
return Ok(await db.ExamSessions.AsNoTracking()
var schedule = await db.ExamSessions.AsNoTracking()
.Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published &&
x.Invigilators.Any(i => i.Teacher!.UserId == scope.UserId))
.OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod)
@@ -640,12 +738,34 @@ public sealed class ExamsController(
ClassroomName = x.Classroom != null ? x.Classroom.Name : null,
BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null,
InvigilatorNames = x.Invigilators.Select(i => i.Teacher!.Name),
StudentCount = db.CourseEnrollments.Count(e =>
e.Status == CourseEnrollmentStatus.Enrolled &&
e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId),
x.TeachingTaskId,
IsExam = true
})
.ToListAsync(cancellationToken));
.ToListAsync(cancellationToken);
var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync(
db,
schedule.Select(x => x.TeachingTaskId),
cancellationToken))
.GroupBy(x => x.TeachingTaskId)
.ToDictionary(x => x.Key, x => x.Count());
return Ok(schedule.Select(x => new
{
x.Id,
x.PlanName,
x.ExamDate,
x.StartPeriod,
x.PeriodCount,
x.StartsAt,
x.EndsAt,
x.TaskNumber,
x.CourseCode,
x.CourseName,
x.ClassroomName,
x.BuildingName,
x.InvigilatorNames,
StudentCount = rosterCounts.GetValueOrDefault(x.TeachingTaskId),
x.IsExam
}));
}
return Ok(Array.Empty<object>());
}
@@ -704,9 +824,9 @@ public sealed class ExamsController(
if (task is null || task.AcademicTermId != plan.AcademicTermId)
return ValidationProblem("教学班与考试计划必须属于同一学期。");
var studentCount = await db.CourseEnrollments.CountAsync(x =>
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.TeachingTaskId == task.Id, cancellationToken);
var studentCount = await TeachingTaskRosterQuery
.ForTask(db, task.Id)
.CountAsync(cancellationToken);
if (classroomId.HasValue)
{
@@ -748,17 +868,25 @@ public sealed class ExamsController(
return ConflictProblem("监考教师在该时段已有考试任务。");
}
var studentIds = db.CourseEnrollments.Where(x =>
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.TeachingTaskId == task.Id)
.Select(x => x.StudentId);
if (await overlaps.AnyAsync(other =>
db.CourseEnrollments.Any(e =>
studentIds.Contains(e.StudentId) &&
e.Status == CourseEnrollmentStatus.Enrolled &&
e.CourseSelectionOffering!.TeachingTaskId == other.TeachingTaskId),
cancellationToken))
return ConflictProblem("存在学生考试时间冲突。");
var overlappingTaskIds = await overlaps
.Select(x => x.TeachingTaskId)
.Distinct()
.ToListAsync(cancellationToken);
if (overlappingTaskIds.Count > 0)
{
var rosterRows = await TeachingTaskRosterQuery.LoadForTasksAsync(
db,
overlappingTaskIds.Append(task.Id),
cancellationToken);
var currentStudentIds = rosterRows
.Where(x => x.TeachingTaskId == task.Id)
.Select(x => x.StudentId)
.ToHashSet();
if (rosterRows.Any(x =>
x.TeachingTaskId != task.Id &&
currentStudentIds.Contains(x.StudentId)))
return ConflictProblem("存在学生考试时间冲突。");
}
return null;
}
@@ -789,6 +917,16 @@ public sealed class ExamsController(
});
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string FileNamePart(string value)
{
var invalidCharacters = Path.GetInvalidFileNameChars().ToHashSet();
var normalized = new string(value
.Select(character =>
invalidCharacters.Contains(character) ? '-' : character)
.ToArray()).Trim();
if (normalized.Length == 0) return "考试计划";
return normalized.Length <= 80 ? normalized : normalized[..80];
}
}
// ═══════════════════════════════════════════