已修改完成,同时解决了“考生名单都是 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
+198 -60
View File
@@ -4,7 +4,9 @@ using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Exams; using Jiaowu.Api.Infrastructure.Exams;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -113,13 +115,52 @@ public sealed class ExamsController(
item.RequiredInvigilatorCount, item.RequiredInvigilatorCount,
item.Notes, 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 =>
e.Status == CourseEnrollmentStatus.Enrolled &&
e.CourseSelectionOffering!.TeachingTaskId == item.TeachingTaskId)
}) })
}).FirstOrDefaultAsync(cancellationToken); }).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) if (duplicateTaskIds.Count > 0)
return ConflictProblem($"所选教学班中有 {duplicateTaskIds.Count} 个已在当前计划中安排考试。"); return ConflictProblem($"所选教学班中有 {duplicateTaskIds.Count} 个已在当前计划中安排考试。");
var enrollmentPairs = await db.CourseEnrollments.AsNoTracking() var rosterPairs = await TeachingTaskRosterQuery.LoadForTasksAsync(
.Where(x => x.Status == CourseEnrollmentStatus.Enrolled) db,
.WhereIn(taskIds, x => x.CourseSelectionOffering!.TeachingTaskId) taskIds,
.Select(x => new cancellationToken);
{ var conflictedStudentCount = rosterPairs
x.StudentId,
x.CourseSelectionOffering!.TeachingTaskId
})
.ToListAsync(cancellationToken);
var conflictedStudentCount = enrollmentPairs
.GroupBy(x => x.StudentId) .GroupBy(x => x.StudentId)
.Count(group => group.Select(x => x.TeachingTaskId).Distinct().Skip(1).Any()); .Count(group => group.Select(x => x.TeachingTaskId).Distinct().Skip(1).Any());
if (conflictedStudentCount > 0) if (conflictedStudentCount > 0)
@@ -470,7 +506,7 @@ public sealed class ExamsController(
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("只有草稿考试计划可以发布。");
if (plan.Sessions.Count == 0) if (!plan.Sessions.Any())
return ConflictProblem("至少安排一个考试场次后才能发布。"); return ConflictProblem("至少安排一个考试场次后才能发布。");
var unassigned = plan.Sessions.Count(x => var unassigned = plan.Sessions.Count(x =>
@@ -505,18 +541,10 @@ public sealed class ExamsController(
}).FirstOrDefaultAsync(cancellationToken); }).FirstOrDefaultAsync(cancellationToken);
if (session is null) return NotFound(); if (session is null) return NotFound();
var students = await db.CourseEnrollments.AsNoTracking() var students = await TeachingTaskRosterQuery.LoadForTasksAsync(
.Where(e => e.Status == CourseEnrollmentStatus.Enrolled && db,
e.CourseSelectionOffering!.TeachingTaskId == session.TeachingTaskId) [session.TeachingTaskId],
.OrderBy(e => e.Student!.StudentNumber) cancellationToken);
.Select(e => new
{
e.StudentId,
e.Student!.StudentNumber,
e.Student.Name,
ClassName = e.Student.AdministrativeClass!.Name
})
.ToListAsync(cancellationToken);
return Ok(new 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 // My schedule
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
@@ -551,11 +655,12 @@ public sealed class ExamsController(
.Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken); .Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken);
if (!studentId.HasValue) if (!studentId.HasValue)
return ConflictProblem("当前账号未关联学生档案。"); return ConflictProblem("当前账号未关联学生档案。");
var rosterTaskIds = TeachingTaskRosterQuery.TaskIdsForStudent(
db,
studentId.Value);
var schedule = await db.ExamSessions.AsNoTracking() var schedule = 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 && rosterTaskIds.Contains(x.TeachingTaskId))
e.Status == CourseEnrollmentStatus.Enrolled &&
e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId))
.OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod) .OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod)
.Select(x => new .Select(x => new
{ {
@@ -579,17 +684,10 @@ public sealed class ExamsController(
if (schedule.Count == 0) return Ok(schedule); if (schedule.Count == 0) return Ok(schedule);
var taskIds = schedule.Select(x => x.TeachingTaskId).Distinct().ToArray(); var taskIds = schedule.Select(x => x.TeachingTaskId).Distinct().ToArray();
var seatRows = await db.CourseEnrollments.AsNoTracking() var seatRows = await TeachingTaskRosterQuery.LoadForTasksAsync(
.Where(x => x.Status == CourseEnrollmentStatus.Enrolled) db,
.WhereIn(taskIds, x => x.CourseSelectionOffering!.TeachingTaskId) taskIds,
.OrderBy(x => x.Student!.StudentNumber) cancellationToken);
.Select(x => new
{
x.CourseSelectionOffering!.TeachingTaskId,
x.StudentId,
x.Student!.StudentNumber
})
.ToListAsync(cancellationToken);
var seatNumbers = seatRows var seatNumbers = seatRows
.GroupBy(x => x.TeachingTaskId) .GroupBy(x => x.TeachingTaskId)
.SelectMany(group => group.Select((row, index) => new .SelectMany(group => group.Select((row, index) => new
@@ -621,7 +719,7 @@ public sealed class ExamsController(
} }
if (scope.IsInRole(SystemRoles.Teacher)) if (scope.IsInRole(SystemRoles.Teacher))
{ {
return Ok(await db.ExamSessions.AsNoTracking() var schedule = 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.ExamDate).ThenBy(x => x.StartPeriod) .OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod)
@@ -640,12 +738,34 @@ public sealed class ExamsController(
ClassroomName = x.Classroom != null ? x.Classroom.Name : null, ClassroomName = x.Classroom != null ? x.Classroom.Name : null,
BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null, 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 => x.TeachingTaskId,
e.Status == CourseEnrollmentStatus.Enrolled &&
e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId),
IsExam = true 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>()); return Ok(Array.Empty<object>());
} }
@@ -704,9 +824,9 @@ public sealed class ExamsController(
if (task is null || task.AcademicTermId != plan.AcademicTermId) if (task is null || task.AcademicTermId != plan.AcademicTermId)
return ValidationProblem("教学班与考试计划必须属于同一学期。"); return ValidationProblem("教学班与考试计划必须属于同一学期。");
var studentCount = await db.CourseEnrollments.CountAsync(x => var studentCount = await TeachingTaskRosterQuery
x.Status == CourseEnrollmentStatus.Enrolled && .ForTask(db, task.Id)
x.CourseSelectionOffering!.TeachingTaskId == task.Id, cancellationToken); .CountAsync(cancellationToken);
if (classroomId.HasValue) if (classroomId.HasValue)
{ {
@@ -748,17 +868,25 @@ public sealed class ExamsController(
return ConflictProblem("监考教师在该时段已有考试任务。"); return ConflictProblem("监考教师在该时段已有考试任务。");
} }
var studentIds = db.CourseEnrollments.Where(x => var overlappingTaskIds = await overlaps
x.Status == CourseEnrollmentStatus.Enrolled && .Select(x => x.TeachingTaskId)
x.CourseSelectionOffering!.TeachingTaskId == task.Id) .Distinct()
.Select(x => x.StudentId); .ToListAsync(cancellationToken);
if (await overlaps.AnyAsync(other => if (overlappingTaskIds.Count > 0)
db.CourseEnrollments.Any(e => {
studentIds.Contains(e.StudentId) && var rosterRows = await TeachingTaskRosterQuery.LoadForTasksAsync(
e.Status == CourseEnrollmentStatus.Enrolled && db,
e.CourseSelectionOffering!.TeachingTaskId == other.TeachingTaskId), overlappingTaskIds.Append(task.Id),
cancellationToken)) 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 ConflictProblem("存在学生考试时间冲突。");
}
return null; return null;
} }
@@ -789,6 +917,16 @@ public sealed class ExamsController(
}); });
private static string? Normalize(string? value) => private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim(); 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];
}
} }
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
@@ -1,5 +1,6 @@
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Exams; namespace Jiaowu.Api.Infrastructure.Exams;
@@ -64,6 +65,12 @@ public sealed class ExamArrangementService(AppDbContext db)
return ExamArrangementResult.Fail("当前学期未配置上课时间表,无法计算考试时间段。"); return ExamArrangementResult.Fail("当前学期未配置上课时间表,无法计算考试时间段。");
var timeSlotLookup = timeSlots.ToDictionary(x => x.PeriodNumber); var timeSlotLookup = timeSlots.ToDictionary(x => x.PeriodNumber);
var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync(
db,
sessions.Select(x => x.TeachingTaskId),
cancellationToken))
.GroupBy(x => x.TeachingTaskId)
.ToDictionary(x => x.Key, x => x.Count());
int assignedRooms = 0; int assignedRooms = 0;
int assignedInvigilators = 0; int assignedInvigilators = 0;
@@ -86,10 +93,7 @@ public sealed class ExamArrangementService(AppDbContext db)
{ {
// Compute StartsAt/EndsAt from time slots // Compute StartsAt/EndsAt from time slots
ComputeTimesFromSlots(session, timeSlotLookup); ComputeTimesFromSlots(session, timeSlotLookup);
var studentCount = await db.CourseEnrollments.CountAsync( var studentCount = rosterCounts.GetValueOrDefault(session.TeachingTaskId);
x => x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.TeachingTaskId == session.TeachingTaskId,
cancellationToken);
// ── Auto-assign classroom ── // ── Auto-assign classroom ──
if (assignClassrooms && !session.ClassroomId.HasValue) if (assignClassrooms && !session.ClassroomId.HasValue)
@@ -0,0 +1,300 @@
using ClosedXML.Excel;
namespace Jiaowu.Api.Infrastructure.Exams;
public static class ExamSignInWorkbookExporter
{
private static readonly XLColor Navy = XLColor.FromHtml("#173E72");
private static readonly XLColor Teal = XLColor.FromHtml("#24706A");
private static readonly XLColor Pale = XLColor.FromHtml("#EFF3F6");
private static readonly XLColor Rule = XLColor.FromHtml("#C8D3DA");
public static byte[] Create(ExamSignInWorkbookData data)
{
using var workbook = new XLWorkbook();
AddSummarySheet(workbook, data);
var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"考场汇总"
};
foreach (var session in data.Sessions
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartsAt)
.ThenBy(x => x.BuildingName)
.ThenBy(x => x.ClassroomName)
.ThenBy(x => x.CourseCode))
{
AddSignInSheet(
workbook,
data,
session,
UniqueSheetName(session, usedNames));
}
using var stream = new MemoryStream();
workbook.SaveAs(stream);
return stream.ToArray();
}
private static void AddSummarySheet(
XLWorkbook workbook,
ExamSignInWorkbookData data)
{
var sheet = workbook.Worksheets.Add("考场汇总");
sheet.Style.Font.FontName = "Microsoft YaHei";
sheet.ShowGridLines = false;
sheet.Range("A1:G1").Merge();
sheet.Cell("A1").Value = $"{data.PlanName} · 考场签名单";
StyleTitle(sheet.Range("A1:G1"));
sheet.Row(1).Height = 34;
sheet.Range("A2:G2").Merge();
sheet.Cell("A2").Value =
$"{data.TermName} · 共 {data.Sessions.Count} 个场次 · 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}";
StyleSubTitle(sheet.Range("A2:G2"));
sheet.Row(2).Height = 24;
var headers = new[] { "日期", "时间", "考场", "课程", "教学班", "考生人数", "监考教师" };
for (var column = 1; column <= headers.Length; column++)
sheet.Cell(4, column).Value = headers[column - 1];
StyleHeader(sheet.Range(4, 1, 4, headers.Length));
var row = 5;
foreach (var session in data.Sessions
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartsAt)
.ThenBy(x => x.BuildingName)
.ThenBy(x => x.ClassroomName))
{
sheet.Cell(row, 1).Value = session.ExamDate.ToDateTime(TimeOnly.MinValue);
sheet.Cell(row, 1).Style.DateFormat.Format = "yyyy-mm-dd";
sheet.Cell(row, 2).Value = $"{session.StartsAt:HH:mm}-{session.EndsAt:HH:mm}";
sheet.Cell(row, 3).Value = Location(session);
sheet.Cell(row, 4).Value = $"{session.CourseCode} {session.CourseName}";
sheet.Cell(row, 5).Value = session.TaskNumber;
sheet.Cell(row, 6).Value = session.Students.Count;
sheet.Cell(row, 7).Value = Invigilators(session);
row++;
}
if (row > 5)
{
var table = sheet.Range(4, 1, row - 1, headers.Length);
table.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
table.Style.Border.InsideBorderColor = Rule;
table.Style.Border.OutsideBorder = XLBorderStyleValues.Medium;
table.Style.Border.OutsideBorderColor = Rule;
table.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
sheet.Rows(5, row - 1).Height = 25;
}
sheet.Column(1).Width = 13;
sheet.Column(2).Width = 15;
sheet.Column(3).Width = 24;
sheet.Column(4).Width = 30;
sheet.Column(5).Width = 22;
sheet.Column(6).Width = 11;
sheet.Column(7).Width = 24;
sheet.Columns(3, 7).Style.Alignment.WrapText = true;
sheet.SheetView.FreezeRows(4);
sheet.PageSetup.PageOrientation = XLPageOrientation.Landscape;
sheet.PageSetup.PaperSize = XLPaperSize.A4Paper;
sheet.PageSetup.FitToPages(1, 0);
sheet.PageSetup.Margins
.SetLeft(0.25).SetRight(0.25).SetTop(0.35).SetBottom(0.35);
}
private static void AddSignInSheet(
XLWorkbook workbook,
ExamSignInWorkbookData data,
ExamSignInSessionData session,
string sheetName)
{
var sheet = workbook.Worksheets.Add(sheetName);
sheet.Style.Font.FontName = "Microsoft YaHei";
sheet.ShowGridLines = false;
sheet.Range("A1:G1").Merge();
sheet.Cell("A1").Value = "考试考场签名单";
StyleTitle(sheet.Range("A1:G1"));
sheet.Cell("A1").Style.Alignment.Horizontal =
XLAlignmentHorizontalValues.Center;
sheet.Row(1).Height = 36;
sheet.Range("A2:G2").Merge();
sheet.Cell("A2").Value = $"{data.PlanName} · {data.TermName}";
StyleSubTitle(sheet.Range("A2:G2"));
sheet.Cell("A2").Style.Alignment.Horizontal =
XLAlignmentHorizontalValues.Center;
sheet.Row(2).Height = 23;
sheet.Range("A3:D3").Merge();
sheet.Cell("A3").Value =
$"课程:{session.CourseCode} {session.CourseName}{session.TaskNumber}";
sheet.Range("E3:G3").Merge();
sheet.Cell("E3").Value = $"考生人数:{session.Students.Count} 人";
sheet.Range("A4:D4").Merge();
sheet.Cell("A4").Value =
$"考试时间:{session.ExamDate:yyyy-MM-dd} {session.StartsAt:HH:mm}-{session.EndsAt:HH:mm}";
sheet.Range("E4:G4").Merge();
sheet.Cell("E4").Value = $"考场:{Location(session)}";
sheet.Range("A5:D5").Merge();
sheet.Cell("A5").Value = $"监考教师:{Invigilators(session)}";
sheet.Range("E5:G5").Merge();
sheet.Cell("E5").Value = "应到:______ 实到:______ 缺考:______";
var metadata = sheet.Range("A3:G5");
metadata.Style.Fill.BackgroundColor = XLColor.White;
metadata.Style.Font.FontColor = XLColor.FromHtml("#304B60");
metadata.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
metadata.Style.Alignment.WrapText = true;
metadata.Style.Border.BottomBorder = XLBorderStyleValues.Thin;
metadata.Style.Border.BottomBorderColor = Rule;
sheet.Rows(3, 5).Height = 24;
var headers = new[] { "序号", "座位号", "学号", "姓名", "行政班", "考生签名", "备注" };
for (var column = 1; column <= headers.Length; column++)
sheet.Cell(7, column).Value = headers[column - 1];
StyleHeader(sheet.Range(7, 1, 7, headers.Length));
sheet.Row(7).Height = 27;
var row = 8;
foreach (var student in session.Students.OrderBy(x => x.StudentNumber))
{
var index = row - 7;
sheet.Cell(row, 1).Value = index;
sheet.Cell(row, 2).Value = index.ToString("D3");
sheet.Cell(row, 3).Value = student.StudentNumber;
sheet.Cell(row, 4).Value = student.Name;
sheet.Cell(row, 5).Value = student.ClassName;
sheet.Row(row).Height = 28;
row++;
}
if (session.Students.Count == 0)
{
sheet.Range("A8:G8").Merge();
sheet.Cell("A8").Value = "本场次暂无考生";
sheet.Cell("A8").Style
.Font.SetFontColor(XLColor.FromHtml("#8A5A20"))
.Fill.SetBackgroundColor(XLColor.FromHtml("#FFF8EA"))
.Alignment.SetHorizontal(XLAlignmentHorizontalValues.Center);
sheet.Row(8).Height = 28;
row = 9;
}
var roster = sheet.Range(7, 1, row - 1, headers.Length);
roster.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
roster.Style.Border.InsideBorderColor = Rule;
roster.Style.Border.OutsideBorder = XLBorderStyleValues.Medium;
roster.Style.Border.OutsideBorderColor = XLColor.FromHtml("#9CADB7");
roster.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
sheet.Range(8, 1, row - 1, 4).Style.Alignment.Horizontal =
XLAlignmentHorizontalValues.Center;
sheet.Range(8, 5, row - 1, 7).Style.Alignment.WrapText = true;
sheet.Column(1).Width = 7;
sheet.Column(2).Width = 10;
sheet.Column(3).Width = 16;
sheet.Column(4).Width = 12;
sheet.Column(5).Width = 20;
sheet.Column(6).Width = 22;
sheet.Column(7).Width = 17;
sheet.SheetView.FreezeRows(7);
sheet.PageSetup.PageOrientation = XLPageOrientation.Portrait;
sheet.PageSetup.PaperSize = XLPaperSize.A4Paper;
sheet.PageSetup.FitToPages(1, 0);
sheet.PageSetup.Margins
.SetLeft(0.25).SetRight(0.25).SetTop(0.3).SetBottom(0.3);
}
private static void StyleTitle(IXLRange range) =>
range.Style
.Font.SetBold()
.Font.SetFontSize(18)
.Font.SetFontColor(XLColor.White)
.Fill.SetBackgroundColor(Navy)
.Alignment.SetVertical(XLAlignmentVerticalValues.Center);
private static void StyleSubTitle(IXLRange range) =>
range.Style
.Font.SetFontColor(XLColor.FromHtml("#52677A"))
.Fill.SetBackgroundColor(Pale)
.Alignment.SetVertical(XLAlignmentVerticalValues.Center);
private static void StyleHeader(IXLRange range) =>
range.Style
.Font.SetBold()
.Font.SetFontColor(XLColor.White)
.Fill.SetBackgroundColor(Teal)
.Alignment.SetHorizontal(XLAlignmentHorizontalValues.Center)
.Alignment.SetVertical(XLAlignmentVerticalValues.Center);
private static string Location(ExamSignInSessionData session) =>
string.Join(" · ", new[]
{
session.BuildingName,
session.ClassroomName
}.Where(x => !string.IsNullOrWhiteSpace(x))) is { Length: > 0 } value
? value
: "待分配考场";
private static string Invigilators(ExamSignInSessionData session) =>
session.InvigilatorNames.Count > 0
? string.Join('、', session.InvigilatorNames)
: "待分配";
private static string UniqueSheetName(
ExamSignInSessionData session,
ISet<string> usedNames)
{
var location = string.IsNullOrWhiteSpace(session.ClassroomName)
? "待分配"
: session.ClassroomName;
var raw = $"{session.ExamDate:MMdd}-{location}-{session.CourseCode}";
var invalidCharacters = new HashSet<char>(":\\/?*[]");
var sanitized = new string(raw
.Select(character => invalidCharacters.Contains(character) ? '-' : character)
.ToArray()).Trim(' ', '\'');
if (sanitized.Length == 0) sanitized = "考场";
if (sanitized.Length > 31) sanitized = sanitized[..31];
var candidate = sanitized;
var suffix = 2;
while (!usedNames.Add(candidate))
{
var marker = $"-{suffix++}";
var prefixLength = Math.Min(sanitized.Length, 31 - marker.Length);
candidate = sanitized[..prefixLength] + marker;
}
return candidate;
}
}
public sealed record ExamSignInWorkbookData(
string PlanName,
string TermName,
IReadOnlyList<ExamSignInSessionData> Sessions);
public sealed record ExamSignInSessionData(
Guid SessionId,
DateOnly ExamDate,
DateTime StartsAt,
DateTime EndsAt,
string CourseCode,
string CourseName,
string TaskNumber,
string? BuildingName,
string? ClassroomName,
IReadOnlyList<string> InvigilatorNames,
IReadOnlyList<ExamSignInStudentData> Students);
public sealed record ExamSignInStudentData(
Guid StudentId,
string StudentNumber,
string Name,
string ClassName);
@@ -1,5 +1,6 @@
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Teaching; namespace Jiaowu.Api.Infrastructure.Teaching;
@@ -15,4 +16,107 @@ public static class TeachingTaskRosterQuery
enrollment.StudentId == student.Id && enrollment.StudentId == student.Id &&
enrollment.Status == CourseEnrollmentStatus.Enrolled && enrollment.Status == CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!.TeachingTaskId == teachingTaskId)); enrollment.CourseSelectionOffering!.TeachingTaskId == teachingTaskId));
public static IQueryable<Guid> TaskIdsForStudent(AppDbContext db, Guid studentId)
{
var classTaskIds = db.Students
.Where(student =>
student.Id == studentId &&
student.Status == StudentStatus.Active)
.SelectMany(student => db.TeachingTaskClasses
.Where(assignment =>
assignment.AdministrativeClassId ==
student.AdministrativeClassId)
.Select(assignment => assignment.TeachingTaskId));
var enrolledTaskIds = db.CourseEnrollments
.Where(enrollment =>
enrollment.StudentId == studentId &&
enrollment.Status == CourseEnrollmentStatus.Enrolled)
.Select(enrollment =>
enrollment.CourseSelectionOffering!.TeachingTaskId);
return classTaskIds.Concat(enrolledTaskIds).Distinct();
}
public static async Task<IReadOnlyList<TeachingTaskRosterEntry>> LoadForTasksAsync(
AppDbContext db,
IEnumerable<Guid> teachingTaskIds,
CancellationToken cancellationToken)
{
var taskIds = teachingTaskIds.Distinct().ToArray();
if (taskIds.Length == 0) return [];
var assignments = await db.TeachingTaskClasses.AsNoTracking()
.WhereIn(taskIds, x => x.TeachingTaskId)
.Select(x => new
{
x.TeachingTaskId,
x.AdministrativeClassId
})
.ToListAsync(cancellationToken);
var classIds = assignments
.Select(x => x.AdministrativeClassId)
.Distinct()
.ToArray();
var classStudents = classIds.Length == 0
? []
: await db.Students.AsNoTracking()
.Where(x => x.Status == StudentStatus.Active)
.WhereIn(classIds, x => x.AdministrativeClassId)
.Select(x => new RosterStudent(
x.Id,
x.StudentNumber,
x.Name,
x.AdministrativeClassId,
x.AdministrativeClass!.Name))
.ToListAsync(cancellationToken);
var enrolledStudents = await db.CourseEnrollments.AsNoTracking()
.Where(x => x.Status == CourseEnrollmentStatus.Enrolled)
.WhereIn(taskIds, x => x.CourseSelectionOffering!.TeachingTaskId)
.Select(x => new TeachingTaskRosterEntry(
x.CourseSelectionOffering!.TeachingTaskId,
x.StudentId,
x.Student!.StudentNumber,
x.Student.Name,
x.Student.AdministrativeClass!.Name))
.ToListAsync(cancellationToken);
var result = enrolledStudents.ToDictionary(
x => (x.TeachingTaskId, x.StudentId));
var studentsByClass = classStudents.ToLookup(x => x.AdministrativeClassId);
foreach (var assignment in assignments)
{
foreach (var student in studentsByClass[assignment.AdministrativeClassId])
{
result.TryAdd(
(assignment.TeachingTaskId, student.StudentId),
new TeachingTaskRosterEntry(
assignment.TeachingTaskId,
student.StudentId,
student.StudentNumber,
student.Name,
student.ClassName));
}
}
return result.Values
.OrderBy(x => x.TeachingTaskId)
.ThenBy(x => x.StudentNumber)
.ToList();
}
private sealed record RosterStudent(
Guid StudentId,
string StudentNumber,
string Name,
Guid AdministrativeClassId,
string ClassName);
} }
public sealed record TeachingTaskRosterEntry(
Guid TeachingTaskId,
Guid StudentId,
string StudentNumber,
string Name,
string ClassName);
@@ -48,6 +48,7 @@ public sealed class ExamArrangementServiceTests
.SingleAsync(x => x.Id == untouched.Id); .SingleAsync(x => x.Id == untouched.Id);
Assert.True(result.Success); Assert.True(result.Success);
Assert.NotNull(persistedSelected.ClassroomId); Assert.NotNull(persistedSelected.ClassroomId);
Assert.Equal(seed.LargeClassroom.Id, persistedSelected.ClassroomId);
Assert.Null(persistedUntouched.ClassroomId); Assert.Null(persistedUntouched.ClassroomId);
Assert.Single(persistedSelected.Invigilators); Assert.Single(persistedSelected.Invigilators);
Assert.Empty(persistedUntouched.Invigilators); Assert.Empty(persistedUntouched.Invigilators);
@@ -159,6 +160,27 @@ public sealed class ExamArrangementServiceTests
BuildingId = building.Id, BuildingId = building.Id,
Capacity = 80 Capacity = 80
}; };
var smallClassroom = new Classroom
{
Code = $"R-SMALL-{suffix}",
Name = "102",
BuildingId = building.Id,
Capacity = 1
};
var major = new Major
{
Code = $"MAJOR-{suffix}",
Name = "计算机科学与技术",
CollegeId = college.Id,
DegreeType = "工学学士"
};
var administrativeClass = new AdministrativeClass
{
Code = $"CLASS-{suffix}",
Name = "计科 2026-1 班",
MajorId = major.Id,
Grade = 2026
};
var firstCourse = new Course var firstCourse = new Course
{ {
Code = $"COURSE-1-{suffix}", Code = $"COURSE-1-{suffix}",
@@ -177,7 +199,14 @@ public sealed class ExamArrangementServiceTests
Name = "程序设计教学班", Name = "程序设计教学班",
AcademicTermId = term.Id, AcademicTermId = term.Id,
CourseId = firstCourse.Id, CourseId = firstCourse.Id,
Status = TeachingTaskStatus.Published Status = TeachingTaskStatus.Published,
Classes =
[
new TeachingTaskClass
{
AdministrativeClassId = administrativeClass.Id
}
]
}; };
var secondTask = new TeachingTask var secondTask = new TeachingTask
{ {
@@ -201,6 +230,25 @@ public sealed class ExamArrangementServiceTests
college, college,
building, building,
classroom, classroom,
smallClassroom,
major,
administrativeClass,
new Student
{
StudentNumber = $"STUDENT-1-{suffix}",
Name = "学生甲",
AdministrativeClassId = administrativeClass.Id,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 1)
},
new Student
{
StudentNumber = $"STUDENT-2-{suffix}",
Name = "学生乙",
AdministrativeClassId = administrativeClass.Id,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 1)
},
firstCourse, firstCourse,
secondCourse, secondCourse,
firstTask, firstTask,
@@ -223,11 +271,12 @@ public sealed class ExamArrangementServiceTests
EndsAt = new TimeOnly(9, 50) EndsAt = new TimeOnly(9, 50)
}); });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return new SeedData(term, firstTask, secondTask); return new SeedData(term, firstTask, secondTask, classroom);
} }
private sealed record SeedData( private sealed record SeedData(
AcademicTerm Term, AcademicTerm Term,
TeachingTask FirstTask, TeachingTask FirstTask,
TeachingTask SecondTask); TeachingTask SecondTask,
Classroom LargeClassroom);
} }
@@ -0,0 +1,60 @@
using ClosedXML.Excel;
using Jiaowu.Api.Infrastructure.Exams;
namespace Jiaowu.Api.Tests;
public sealed class ExamSignInWorkbookExporterTests
{
[Fact]
public void Create_builds_summary_and_printable_session_sheets()
{
var sessionId = Guid.NewGuid();
var session = new ExamSignInSessionData(
sessionId,
new DateOnly(2027, 1, 8),
new DateTime(2027, 1, 8, 8, 0, 0, DateTimeKind.Utc),
new DateTime(2027, 1, 8, 9, 50, 0, DateTimeKind.Utc),
"CS101",
"程序设计基础",
"2026-1-CS101-01",
"第一教学楼",
"101",
["张老师", "李老师"],
[
new ExamSignInStudentData(
Guid.NewGuid(),
"202601001",
"周同学",
"计科 2026-1 班")
]);
var data = new ExamSignInWorkbookData(
"2026—2027 学年第一学期期末考试",
"2026—2027 学年第一学期",
[
session,
session with { SessionId = Guid.NewGuid() }
]);
var bytes = ExamSignInWorkbookExporter.Create(data);
using var stream = new MemoryStream(bytes);
using var workbook = new XLWorkbook(stream);
Assert.Equal(3, workbook.Worksheets.Count);
Assert.Equal("考场汇总", workbook.Worksheet(1).Name);
Assert.Equal(1, workbook.Worksheet("考场汇总").Cell("F5").GetDouble());
Assert.Equal(1, workbook.Worksheet("考场汇总").Cell("F6").GetDouble());
Assert.Equal(
workbook.Worksheets.Count,
workbook.Worksheets.Select(x => x.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count());
var signInSheet = workbook.Worksheet(2);
Assert.Equal("考试考场签名单", signInSheet.Cell("A1").GetString());
Assert.Contains("第一教学楼", signInSheet.Cell("E4").GetString());
Assert.Equal("202601001", signInSheet.Cell("C8").GetString());
Assert.Equal("周同学", signInSheet.Cell("D8").GetString());
Assert.Equal(XLPageOrientation.Portrait, signInSheet.PageSetup.PageOrientation);
Assert.Equal(XLPaperSize.A4Paper, signInSheet.PageSetup.PaperSize);
}
}
@@ -119,15 +119,28 @@ public sealed class MySqlMigrationTests
public void MySql_teaching_task_roster_query_is_translatable() public void MySql_teaching_task_roster_query_is_translatable()
{ {
using var db = new AppDbContext(CreateMySqlOptions()); using var db = new AppDbContext(CreateMySqlOptions());
var studentId =
Guid.Parse("22222222-2222-2222-2222-222222222222");
var sql = TeachingTaskRosterQuery var sql = TeachingTaskRosterQuery
.ForTask( .ForTask(
db, db,
Guid.Parse("11111111-1111-1111-1111-111111111111")) Guid.Parse("11111111-1111-1111-1111-111111111111"))
.ToQueryString(); .ToQueryString();
var taskIdsSql = TeachingTaskRosterQuery
.TaskIdsForStudent(db, studentId)
.ToQueryString();
Assert.Contains("TeachingTaskClasses", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains("TeachingTaskClasses", sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains("CourseEnrollments", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains("CourseEnrollments", sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains(
"TeachingTaskClasses",
taskIdsSql,
StringComparison.OrdinalIgnoreCase);
Assert.Contains(
"CourseEnrollments",
taskIdsSql,
StringComparison.OrdinalIgnoreCase);
} }
[Fact] [Fact]
@@ -1,7 +1,10 @@
using ClosedXML.Excel;
using Jiaowu.Api.Controllers; using Jiaowu.Api.Controllers;
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.Caching;
using Jiaowu.Api.Infrastructure.Exams;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
@@ -12,7 +15,7 @@ namespace Jiaowu.Api.Tests;
public sealed class TeachingWorkflowRosterTests public sealed class TeachingWorkflowRosterTests
{ {
[Fact] [Fact]
public async Task ClassAssignedStudents_AppearAcrossTeacherWorkflows() public async Task ClassAssignedStudents_AppearAcrossTeachingAndExamWorkflows()
{ {
await using var connection = new SqliteConnection("Data Source=:memory:"); await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync(); await connection.OpenAsync();
@@ -23,6 +26,7 @@ public sealed class TeachingWorkflowRosterTests
await db.Database.EnsureCreatedAsync(); await db.Database.EnsureCreatedAsync();
var teacherUserId = Guid.NewGuid(); var teacherUserId = Guid.NewGuid();
var studentUserId = Guid.NewGuid();
var teacherUser = new ApplicationUser var teacherUser = new ApplicationUser
{ {
Id = teacherUserId, Id = teacherUserId,
@@ -30,6 +34,13 @@ public sealed class TeachingWorkflowRosterTests
NormalizedUserName = "T001", NormalizedUserName = "T001",
DisplayName = "张老师" DisplayName = "张老师"
}; };
var studentUser = new ApplicationUser
{
Id = studentUserId,
UserName = "202601001",
NormalizedUserName = "202601001",
DisplayName = "周同学"
};
var college = new College { Code = "CS", Name = "计算机学院" }; var college = new College { Code = "CS", Name = "计算机学院" };
var major = new Major var major = new Major
{ {
@@ -51,7 +62,8 @@ public sealed class TeachingWorkflowRosterTests
Name = "周同学", Name = "周同学",
AdministrativeClassId = administrativeClass.Id, AdministrativeClassId = administrativeClass.Id,
EnrollmentYear = 2026, EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 1) EnrollmentDate = new DateOnly(2026, 9, 1),
UserId = studentUserId
}; };
var teacher = new Teacher var teacher = new Teacher
{ {
@@ -107,6 +119,7 @@ public sealed class TeachingWorkflowRosterTests
}; };
db.AddRange( db.AddRange(
teacherUser, teacherUser,
studentUser,
college, college,
major, major,
administrativeClass, administrativeClass,
@@ -117,6 +130,28 @@ public sealed class TeachingWorkflowRosterTests
task); task);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var examPlan = new ExamPlan
{
AcademicTermId = term.Id,
Name = "期末考试",
Status = ExamPlanStatus.Published,
Sessions =
[
new ExamSession
{
TeachingTaskId = task.Id,
ExamDate = new DateOnly(2027, 1, 8),
StartPeriod = 1,
PeriodCount = 2,
StartsAt = new DateTime(2027, 1, 8, 8, 0, 0, DateTimeKind.Utc),
EndsAt = new DateTime(2027, 1, 8, 9, 50, 0, DateTimeKind.Utc),
RequiredInvigilatorCount = 1
}
]
};
db.ExamPlans.Add(examPlan);
await db.SaveChangesAsync();
var scope = new TeacherDataScope(teacherUserId); var scope = new TeacherDataScope(teacherUserId);
var attendance = new AttendanceController(db, scope); var attendance = new AttendanceController(db, scope);
var attendanceResult = await attendance.CreateSheet( var attendanceResult = await attendance.CreateSheet(
@@ -153,6 +188,39 @@ public sealed class TeachingWorkflowRosterTests
Assert.Single(ReadItems(await adjustments.GetTaskOptions( Assert.Single(ReadItems(await adjustments.GetTaskOptions(
term.Id, term.Id,
CancellationToken.None))); CancellationToken.None)));
var managerScope = new ManagerDataScope();
var exams = new ExamsController(
db,
managerScope,
new ExamArrangementService(db),
NoOpAppCache.Instance);
var planResult = Assert.IsType<OkObjectResult>(
await exams.GetPlan(examPlan.Id, CancellationToken.None));
var planSessions = ReadEnumerableProperty(planResult.Value!, "Sessions");
Assert.Equal(1, ReadIntProperty(planSessions.Single(), "StudentCount"));
var examSessionId = examPlan.Sessions.Single().Id;
var rosterResult = Assert.IsType<OkObjectResult>(
await exams.GetRoster(examSessionId, CancellationToken.None));
Assert.Single(ReadEnumerableProperty(rosterResult.Value!, "Students"));
var studentExams = new ExamsController(
db,
new StudentDataScope(studentUserId),
new ExamArrangementService(db),
NoOpAppCache.Instance);
Assert.Single(ReadItems(
await studentExams.GetMySchedule(CancellationToken.None)));
var exportResult = Assert.IsType<FileContentResult>(
await exams.ExportSignInSheets(examPlan.Id, CancellationToken.None));
using var workbookStream = new MemoryStream(exportResult.FileContents);
using var workbook = new XLWorkbook(workbookStream);
Assert.Equal(2, workbook.Worksheets.Count);
var signInSheet = workbook.Worksheets.Single(x => x.Name != "考场汇总");
Assert.Equal("202601001", signInSheet.Cell("C8").GetString());
Assert.Equal("周同学", signInSheet.Cell("D8").GetString());
} }
private static List<object> ReadItems(ActionResult result) private static List<object> ReadItems(ActionResult result)
@@ -166,6 +234,12 @@ public sealed class TeachingWorkflowRosterTests
private static int ReadIntProperty(object value, string name) => private static int ReadIntProperty(object value, string name) =>
(int)value.GetType().GetProperty(name)!.GetValue(value)!; (int)value.GetType().GetProperty(name)!.GetValue(value)!;
private static List<object> ReadEnumerableProperty(object value, string name) =>
Assert.IsAssignableFrom<System.Collections.IEnumerable>(
value.GetType().GetProperty(name)!.GetValue(value))
.Cast<object>()
.ToList();
private sealed class TeacherDataScope(Guid userId) : ICurrentUserDataScope private sealed class TeacherDataScope(Guid userId) : ICurrentUserDataScope
{ {
public CurrentUserScope Current { get; } = new( public CurrentUserScope Current { get; } = new(
@@ -175,4 +249,24 @@ public sealed class TeachingWorkflowRosterTests
DataScope.Self, DataScope.Self,
new HashSet<string>([SystemRoles.Teacher])); new HashSet<string>([SystemRoles.Teacher]));
} }
private sealed class StudentDataScope(Guid userId) : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
userId,
"周同学",
null,
DataScope.Self,
new HashSet<string>([SystemRoles.Student]));
}
private sealed class ManagerDataScope : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
Guid.NewGuid(),
"考试管理员",
null,
DataScope.All,
new HashSet<string>([SystemRoles.AcademicAdmin]));
}
} }
+24 -1
View File
@@ -1,7 +1,8 @@
<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, Setting } from '@element-plus/icons-vue' import { Download, Plus, Promotion, Refresh, UserFilled, Setting } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile } from '../api/excel'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms' import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
@@ -20,6 +21,7 @@ const buildings = ref<any[]>([])
const timeSlots = ref<any[]>([]) const timeSlots = ref<any[]>([])
const loading = ref(false) const loading = ref(false)
const arrangeLoading = ref(false) const arrangeLoading = ref(false)
const exportLoading = 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 editingSession = ref<any | null>(null)
@@ -250,6 +252,21 @@ async function publishPlan() {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error)) if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
} }
} }
async function exportSignInSheets() {
if (!selected.value) return
exportLoading.value = true
try {
await downloadApiFile(
`/exams/plans/${selected.value.id}/sign-in-sheets.xlsx`,
`考场签名单-${selected.value.name}.xlsx`,
)
ElMessage.success('考场签名单已导出')
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
exportLoading.value = false
}
}
async function showRoster(row: any) { async function showRoster(row: any) {
try { try {
roster.value = (await http.get(`/exams/sessions/${row.id}/roster`)).data roster.value = (await http.get(`/exams/sessions/${row.id}/roster`)).data
@@ -317,6 +334,12 @@ onMounted(async () => {
<p>{{ selected.termName }} · {{ selected.sessions.length }} 个考试场次</p> <p>{{ selected.termName }} · {{ selected.sessions.length }} 个考试场次</p>
</div> </div>
<div class="exam-actions"> <div class="exam-actions">
<el-button
:icon="Download"
:loading="exportLoading"
:disabled="selected.sessions.length === 0"
@click="exportSignInSheets"
>导出考场签名单</el-button>
<el-button v-if="selected.status === 'Draft'" @click="autoArrange('rooms')" :loading="arrangeLoading">一键分配考场</el-button> <el-button v-if="selected.status === 'Draft'" @click="autoArrange('rooms')" :loading="arrangeLoading">一键分配考场</el-button>
<el-button v-if="selected.status === 'Draft'" @click="autoArrange('invigilators')" :loading="arrangeLoading">一键分配监考</el-button> <el-button v-if="selected.status === 'Draft'" @click="autoArrange('invigilators')" :loading="arrangeLoading">一键分配监考</el-button>
<el-button v-if="selected.status === 'Draft'" :icon="Setting" @click="autoArrange('all')" :loading="arrangeLoading">一键完成</el-button> <el-button v-if="selected.status === 'Draft'" :icon="Setting" @click="autoArrange('all')" :loading="arrangeLoading">一键完成</el-button>