已修改完成,同时解决了“考生名单都是 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:
1 parent
2e422acd19
commit
fb2acc6751
9 files changed
+856
-71
No files matched your search
@@ -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);
|
||||||
return ConflictProblem("存在学生考试时间冲突。");
|
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;
|
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);
|
||||||
Loaded 3 of 9 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user