自动排考
This commit is contained in:
@@ -108,8 +108,21 @@ public sealed class ExamsController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("plans/{id:guid}")]
|
[HttpGet("plans/{id:guid}")]
|
||||||
public async Task<ActionResult> GetPlan(Guid id, CancellationToken cancellationToken)
|
public async Task<ActionResult> GetPlan(
|
||||||
|
Guid id,
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
string? keyword = null,
|
||||||
|
string? allocation = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
page = Math.Max(1, page);
|
||||||
|
pageSize = Math.Clamp(pageSize, 10, 100);
|
||||||
|
keyword = Normalize(keyword);
|
||||||
|
allocation = Normalize(allocation)?.ToLowerInvariant();
|
||||||
|
if (allocation is not null and not ("room" or "invigilator" or "complete"))
|
||||||
|
return ValidationProblem("分配状态筛选值无效。");
|
||||||
|
|
||||||
var manager = IsManager();
|
var manager = IsManager();
|
||||||
var plan = await db.ExamPlans.AsNoTracking()
|
var plan = await db.ExamPlans.AsNoTracking()
|
||||||
.Where(x => x.Id == id && (manager || x.Status == ExamPlanStatus.Published))
|
.Where(x => x.Id == id && (manager || x.Status == ExamPlanStatus.Published))
|
||||||
@@ -122,71 +135,157 @@ public sealed class ExamsController(
|
|||||||
x.Status,
|
x.Status,
|
||||||
x.Notes,
|
x.Notes,
|
||||||
x.PublishedAt,
|
x.PublishedAt,
|
||||||
Sessions = x.Sessions.OrderBy(item => item.ExamDate)
|
TotalSessionCount = x.Sessions.Count
|
||||||
.ThenBy(item => item.StartPeriod).Select(item => new
|
|
||||||
{
|
|
||||||
item.Id,
|
|
||||||
item.TeachingTaskId,
|
|
||||||
item.TeachingTask!.TaskNumber,
|
|
||||||
TaskName = item.TeachingTask.Name,
|
|
||||||
CourseCode = item.TeachingTask.Course!.Code,
|
|
||||||
CourseName = item.TeachingTask.Course.Name,
|
|
||||||
item.ClassroomId,
|
|
||||||
ClassroomName = item.Classroom != null ? item.Classroom.Name : null,
|
|
||||||
BuildingName = item.Classroom != null ? item.Classroom.Building!.Name : null,
|
|
||||||
ClassroomCapacity = item.Classroom != null ? (int?)item.Classroom.Capacity : null,
|
|
||||||
item.ExamDate,
|
|
||||||
item.StartPeriod,
|
|
||||||
item.PeriodCount,
|
|
||||||
item.StartsAt,
|
|
||||||
item.EndsAt,
|
|
||||||
item.RequiredBuildingId,
|
|
||||||
RequiredBuildingName = item.RequiredBuilding != null
|
|
||||||
? item.RequiredBuilding.Name : null,
|
|
||||||
item.RequiredInvigilatorCount,
|
|
||||||
item.Notes,
|
|
||||||
InvigilatorIds = item.Invigilators.Select(i => i.TeacherId),
|
|
||||||
InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name)
|
|
||||||
})
|
|
||||||
}).FirstOrDefaultAsync(cancellationToken);
|
}).FirstOrDefaultAsync(cancellationToken);
|
||||||
if (plan is null) return NotFound();
|
if (plan is null) return NotFound();
|
||||||
|
|
||||||
var roomEntities = await db.ExamRooms.AsNoTracking()
|
var sessionQuery = db.ExamSessions.AsNoTracking()
|
||||||
.Include(x => x.Classroom)
|
.Where(x => x.ExamPlanId == id);
|
||||||
.ThenInclude(x => x!.Building)
|
if (keyword is not null)
|
||||||
.Include(x => x.SessionLinks)
|
{
|
||||||
.Include(x => x.Seats)
|
sessionQuery = sessionQuery.Where(x =>
|
||||||
.Include(x => x.Invigilators)
|
x.TeachingTask!.TaskNumber.Contains(keyword) ||
|
||||||
.ThenInclude(x => x.Teacher)
|
x.TeachingTask.Name.Contains(keyword) ||
|
||||||
.Where(x => x.ExamPlanId == id)
|
x.TeachingTask.Course!.Code.Contains(keyword) ||
|
||||||
|
x.TeachingTask.Course.Name.Contains(keyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionQuery = allocation switch
|
||||||
|
{
|
||||||
|
"room" => sessionQuery.Where(x =>
|
||||||
|
!x.RoomLinks.Any() && !x.ClassroomId.HasValue),
|
||||||
|
"invigilator" => sessionQuery.Where(x =>
|
||||||
|
(x.RoomLinks.Any() && x.RoomLinks.Any(link =>
|
||||||
|
link.ExamRoom!.Invigilators.Count <
|
||||||
|
x.RequiredInvigilatorCount)) ||
|
||||||
|
(!x.RoomLinks.Any() &&
|
||||||
|
x.Invigilators.Count < x.RequiredInvigilatorCount)),
|
||||||
|
"complete" => sessionQuery.Where(x =>
|
||||||
|
(x.RoomLinks.Any() || x.ClassroomId.HasValue) &&
|
||||||
|
((x.RoomLinks.Any() && x.RoomLinks.All(link =>
|
||||||
|
link.ExamRoom!.Invigilators.Count >=
|
||||||
|
x.RequiredInvigilatorCount)) ||
|
||||||
|
(!x.RoomLinks.Any() &&
|
||||||
|
x.Invigilators.Count >= x.RequiredInvigilatorCount))),
|
||||||
|
_ => sessionQuery
|
||||||
|
};
|
||||||
|
|
||||||
|
var filteredSessionCount = await sessionQuery.CountAsync(cancellationToken);
|
||||||
|
var lastPage = Math.Max(1,
|
||||||
|
(int)Math.Ceiling(filteredSessionCount / (double)pageSize));
|
||||||
|
page = Math.Min(page, lastPage);
|
||||||
|
var sessions = await sessionQuery
|
||||||
|
.OrderBy(item => item.ExamDate)
|
||||||
|
.ThenBy(item => item.StartPeriod)
|
||||||
|
.ThenBy(item => item.TeachingTask!.TaskNumber)
|
||||||
|
.ThenBy(item => item.Id)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.Select(item => new
|
||||||
|
{
|
||||||
|
item.Id,
|
||||||
|
item.TeachingTaskId,
|
||||||
|
item.TeachingTask!.TaskNumber,
|
||||||
|
TaskName = item.TeachingTask.Name,
|
||||||
|
CourseCode = item.TeachingTask.Course!.Code,
|
||||||
|
CourseName = item.TeachingTask.Course.Name,
|
||||||
|
item.ClassroomId,
|
||||||
|
ClassroomName = item.Classroom != null ? item.Classroom.Name : null,
|
||||||
|
BuildingName = item.Classroom != null
|
||||||
|
? item.Classroom.Building!.Name
|
||||||
|
: null,
|
||||||
|
ClassroomCapacity = item.Classroom != null
|
||||||
|
? (int?)item.Classroom.Capacity
|
||||||
|
: null,
|
||||||
|
item.ExamDate,
|
||||||
|
item.StartPeriod,
|
||||||
|
item.PeriodCount,
|
||||||
|
item.StartsAt,
|
||||||
|
item.EndsAt,
|
||||||
|
item.RequiredBuildingId,
|
||||||
|
RequiredBuildingName = item.RequiredBuilding != null
|
||||||
|
? item.RequiredBuilding.Name
|
||||||
|
: null,
|
||||||
|
item.RequiredInvigilatorCount,
|
||||||
|
item.Notes,
|
||||||
|
InvigilatorIds = item.Invigilators.Select(i => i.TeacherId),
|
||||||
|
InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name)
|
||||||
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
var roomsBySession = roomEntities
|
var pageSessionIds = sessions.Select(x => x.Id).ToArray();
|
||||||
.SelectMany(room => room.SessionLinks.Select(link => new
|
var scheduledTeachingTaskIds = await db.ExamSessions.AsNoTracking()
|
||||||
|
.Where(x => x.ExamPlanId == id)
|
||||||
|
.OrderBy(x => x.TeachingTaskId)
|
||||||
|
.Select(x => x.TeachingTaskId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var roomSessionLinks = await db.ExamRoomSessions.AsNoTracking()
|
||||||
|
.WhereIn(pageSessionIds, x => x.ExamSessionId)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.ExamRoomId,
|
||||||
|
x.ExamSessionId,
|
||||||
|
SeatCount = x.ExamRoom!.Seats.Count(seat =>
|
||||||
|
seat.ExamSessionId == x.ExamSessionId)
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var pageRoomIds = roomSessionLinks.Select(x => x.ExamRoomId).Distinct().ToArray();
|
||||||
|
var roomSummaries = await db.ExamRooms.AsNoTracking()
|
||||||
|
.WhereIn(pageRoomIds, x => x.Id)
|
||||||
|
.OrderBy(x => x.Classroom!.Building!.Name)
|
||||||
|
.ThenBy(x => x.Classroom!.Name)
|
||||||
|
.ThenBy(x => x.Id)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
ExamRoomId = x.Id,
|
||||||
|
x.ClassroomId,
|
||||||
|
ClassroomName = x.Classroom!.Name,
|
||||||
|
BuildingName = x.Classroom.Building!.Name,
|
||||||
|
ClassroomCapacity = x.Classroom.Capacity,
|
||||||
|
TotalSeatCount = x.Seats.Count,
|
||||||
|
SessionCount = x.SessionLinks.Count
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var roomInvigilators = await db.ExamRoomInvigilators.AsNoTracking()
|
||||||
|
.WhereIn(pageRoomIds, x => x.ExamRoomId)
|
||||||
|
.OrderBy(x => x.Teacher!.TeacherNumber)
|
||||||
|
.ThenBy(x => x.Teacher!.Name)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.ExamRoomId,
|
||||||
|
x.TeacherId,
|
||||||
|
TeacherName = x.Teacher!.Name
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var linksByRoom = roomSessionLinks.ToLookup(x => x.ExamRoomId);
|
||||||
|
var invigilatorsByRoom = roomInvigilators.ToLookup(x => x.ExamRoomId);
|
||||||
|
var roomsBySession = roomSummaries
|
||||||
|
.SelectMany(room => linksByRoom[room.ExamRoomId].Select(link => new
|
||||||
{
|
{
|
||||||
link.ExamSessionId,
|
link.ExamSessionId,
|
||||||
Room = new
|
Room = new
|
||||||
{
|
{
|
||||||
link.ExamRoomId,
|
room.ExamRoomId,
|
||||||
room.ClassroomId,
|
room.ClassroomId,
|
||||||
ClassroomName = room.Classroom!.Name,
|
room.ClassroomName,
|
||||||
BuildingName = room.Classroom.Building!.Name,
|
room.BuildingName,
|
||||||
ClassroomCapacity = room.Classroom.Capacity,
|
room.ClassroomCapacity,
|
||||||
SeatCount = room.Seats.Count(seat =>
|
link.SeatCount,
|
||||||
seat.ExamSessionId == link.ExamSessionId),
|
room.TotalSeatCount,
|
||||||
TotalSeatCount = room.Seats.Count,
|
IsMixed = room.SessionCount > 1,
|
||||||
IsMixed = room.SessionLinks.Count > 1,
|
InvigilatorIds = invigilatorsByRoom[room.ExamRoomId]
|
||||||
InvigilatorIds = room.Invigilators
|
|
||||||
.Select(invigilator => invigilator.TeacherId)
|
.Select(invigilator => invigilator.TeacherId)
|
||||||
.ToList(),
|
.ToList(),
|
||||||
InvigilatorNames = room.Invigilators
|
InvigilatorNames = invigilatorsByRoom[room.ExamRoomId]
|
||||||
.Select(invigilator => invigilator.Teacher!.Name)
|
.Select(invigilator => invigilator.TeacherName)
|
||||||
.ToList()
|
.ToList()
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
.ToLookup(x => x.ExamSessionId, x => x.Room);
|
.ToLookup(x => x.ExamSessionId, x => x.Room);
|
||||||
var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync(
|
var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync(
|
||||||
db,
|
db,
|
||||||
plan.Sessions.Select(x => x.TeachingTaskId),
|
sessions.Select(x => x.TeachingTaskId),
|
||||||
cancellationToken))
|
cancellationToken))
|
||||||
.GroupBy(x => x.TeachingTaskId)
|
.GroupBy(x => x.TeachingTaskId)
|
||||||
.ToDictionary(x => x.Key, x => x.Count());
|
.ToDictionary(x => x.Key, x => x.Count());
|
||||||
@@ -199,7 +298,12 @@ public sealed class ExamsController(
|
|||||||
plan.Status,
|
plan.Status,
|
||||||
plan.Notes,
|
plan.Notes,
|
||||||
plan.PublishedAt,
|
plan.PublishedAt,
|
||||||
Sessions = plan.Sessions.Select(item => new
|
plan.TotalSessionCount,
|
||||||
|
FilteredSessionCount = filteredSessionCount,
|
||||||
|
SessionPage = page,
|
||||||
|
SessionPageSize = pageSize,
|
||||||
|
ScheduledTeachingTaskIds = scheduledTeachingTaskIds,
|
||||||
|
Sessions = sessions.Select(item => new
|
||||||
{
|
{
|
||||||
item.Id,
|
item.Id,
|
||||||
item.TeachingTaskId,
|
item.TeachingTaskId,
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
using Jiaowu.Api.Controllers;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
|
using Jiaowu.Api.Infrastructure.Exams;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class ExamPlanPaginationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPlan_PaginatesSearchesAndFiltersSessionsOnServer()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
await using var db = new AppDbContext(
|
||||||
|
new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
var college = new College { Code = "CS", Name = "计算机学院" };
|
||||||
|
var course = new Course
|
||||||
|
{
|
||||||
|
Code = "CS101",
|
||||||
|
Name = "程序设计基础",
|
||||||
|
CollegeId = college.Id,
|
||||||
|
Credits = 4,
|
||||||
|
TotalHours = 64,
|
||||||
|
LectureHours = 48,
|
||||||
|
PracticeHours = 16,
|
||||||
|
Nature = CourseNature.MajorRequired,
|
||||||
|
AssessmentMethod = AssessmentMethod.Examination
|
||||||
|
};
|
||||||
|
var term = new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = "2026-1",
|
||||||
|
Name = "2026—2027 学年第一学期",
|
||||||
|
AcademicYear = "2026-2027",
|
||||||
|
Season = TermSeason.Autumn,
|
||||||
|
StartDate = new DateOnly(2026, 9, 1),
|
||||||
|
EndDate = new DateOnly(2027, 1, 20)
|
||||||
|
};
|
||||||
|
var tasks = Enumerable.Range(1, 11)
|
||||||
|
.Select(number => new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = $"2026-1-CS101-{number:00}",
|
||||||
|
Name = number == 11 ? "专项课程教学班" : $"程序设计教学班 {number}",
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
CourseId = course.Id,
|
||||||
|
Capacity = 60,
|
||||||
|
Status = TeachingTaskStatus.Published
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
var plan = new ExamPlan
|
||||||
|
{
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
Name = "期末考试",
|
||||||
|
Status = ExamPlanStatus.Draft,
|
||||||
|
Sessions = tasks.Select((task, index) => new ExamSession
|
||||||
|
{
|
||||||
|
TeachingTaskId = task.Id,
|
||||||
|
ExamDate = new DateOnly(2027, 1, 8).AddDays(index),
|
||||||
|
StartPeriod = 1,
|
||||||
|
PeriodCount = 2,
|
||||||
|
StartsAt = new DateTime(2027, 1, 8, 8, 0, 0, DateTimeKind.Utc)
|
||||||
|
.AddDays(index),
|
||||||
|
EndsAt = new DateTime(2027, 1, 8, 9, 50, 0, DateTimeKind.Utc)
|
||||||
|
.AddDays(index),
|
||||||
|
RequiredInvigilatorCount = 1
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
db.AddRange(college, course, term);
|
||||||
|
db.AddRange(tasks);
|
||||||
|
db.Add(plan);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = new ExamsController(
|
||||||
|
db,
|
||||||
|
new AllScope(),
|
||||||
|
new ExamArrangementService(db),
|
||||||
|
NoOpAppCache.Instance);
|
||||||
|
|
||||||
|
var secondPage = ReadPlan(await controller.GetPlan(
|
||||||
|
plan.Id,
|
||||||
|
page: 2,
|
||||||
|
pageSize: 10,
|
||||||
|
cancellationToken: CancellationToken.None));
|
||||||
|
Assert.Equal(11, ReadInt(secondPage, "TotalSessionCount"));
|
||||||
|
Assert.Equal(11, ReadInt(secondPage, "FilteredSessionCount"));
|
||||||
|
Assert.Equal(2, ReadInt(secondPage, "SessionPage"));
|
||||||
|
Assert.Single(ReadItems(secondPage, "Sessions"));
|
||||||
|
Assert.Equal(11, ReadItems(secondPage, "ScheduledTeachingTaskIds").Count);
|
||||||
|
|
||||||
|
var searchPage = ReadPlan(await controller.GetPlan(
|
||||||
|
plan.Id,
|
||||||
|
keyword: "专项",
|
||||||
|
cancellationToken: CancellationToken.None));
|
||||||
|
Assert.Equal(11, ReadInt(searchPage, "TotalSessionCount"));
|
||||||
|
Assert.Equal(1, ReadInt(searchPage, "FilteredSessionCount"));
|
||||||
|
Assert.Single(ReadItems(searchPage, "Sessions"));
|
||||||
|
|
||||||
|
var completedPage = ReadPlan(await controller.GetPlan(
|
||||||
|
plan.Id,
|
||||||
|
allocation: "complete",
|
||||||
|
cancellationToken: CancellationToken.None));
|
||||||
|
Assert.Equal(0, ReadInt(completedPage, "FilteredSessionCount"));
|
||||||
|
Assert.Empty(ReadItems(completedPage, "Sessions"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object ReadPlan(ActionResult result) =>
|
||||||
|
Assert.IsType<OkObjectResult>(result).Value!;
|
||||||
|
|
||||||
|
private static int ReadInt(object value, string property) =>
|
||||||
|
(int)value.GetType().GetProperty(property)!.GetValue(value)!;
|
||||||
|
|
||||||
|
private static List<object> ReadItems(object value, string property) =>
|
||||||
|
Assert.IsAssignableFrom<System.Collections.IEnumerable>(
|
||||||
|
value.GetType().GetProperty(property)!.GetValue(value))
|
||||||
|
.Cast<object>()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
private sealed class AllScope : ICurrentUserDataScope
|
||||||
|
{
|
||||||
|
public CurrentUserScope Current { get; } = new(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"测试管理员",
|
||||||
|
null,
|
||||||
|
DataScope.All,
|
||||||
|
new HashSet<string>([SystemRoles.SuperAdmin]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -256,10 +256,27 @@ public sealed class TeachingWorkflowRosterTests
|
|||||||
Assert.IsType<NoContentResult>(publishResult);
|
Assert.IsType<NoContentResult>(publishResult);
|
||||||
|
|
||||||
var planResult = Assert.IsType<OkObjectResult>(
|
var planResult = Assert.IsType<OkObjectResult>(
|
||||||
await exams.GetPlan(examPlan.Id, CancellationToken.None));
|
await exams.GetPlan(
|
||||||
|
examPlan.Id,
|
||||||
|
cancellationToken: CancellationToken.None));
|
||||||
var planSessions = ReadEnumerableProperty(planResult.Value!, "Sessions");
|
var planSessions = ReadEnumerableProperty(planResult.Value!, "Sessions");
|
||||||
Assert.Equal(1, ReadIntProperty(planSessions.Single(), "StudentCount"));
|
Assert.Equal(1, ReadIntProperty(planSessions.Single(), "StudentCount"));
|
||||||
Assert.Single(ReadEnumerableProperty(planSessions.Single(), "ExamRooms"));
|
var returnedRoom = Assert.Single(
|
||||||
|
ReadEnumerableProperty(planSessions.Single(), "ExamRooms"));
|
||||||
|
Assert.Equal(
|
||||||
|
classroom.Id,
|
||||||
|
returnedRoom.GetType().GetProperty("ClassroomId")!.GetValue(returnedRoom));
|
||||||
|
Assert.Equal(1, ReadIntProperty(returnedRoom, "SeatCount"));
|
||||||
|
Assert.Equal(1, ReadIntProperty(returnedRoom, "TotalSeatCount"));
|
||||||
|
Assert.False(
|
||||||
|
(bool)returnedRoom.GetType().GetProperty("IsMixed")!
|
||||||
|
.GetValue(returnedRoom)!);
|
||||||
|
Assert.Equal(
|
||||||
|
[teacher.Id],
|
||||||
|
ReadEnumerableProperty(returnedRoom, "InvigilatorIds").Cast<Guid>());
|
||||||
|
Assert.Equal(
|
||||||
|
[teacher.Name],
|
||||||
|
ReadEnumerableProperty(returnedRoom, "InvigilatorNames").Cast<string>());
|
||||||
|
|
||||||
var examSessionId = examPlan.Sessions.Single().Id;
|
var examSessionId = examPlan.Sessions.Single().Id;
|
||||||
var rosterResult = Assert.IsType<OkObjectResult>(
|
var rosterResult = Assert.IsType<OkObjectResult>(
|
||||||
|
|||||||
+133
-40
@@ -12,6 +12,11 @@ const isManager = computed(() =>
|
|||||||
const isTeacher = computed(() => auth.user?.roles.includes('Teacher') && !isManager.value)
|
const isTeacher = computed(() => auth.user?.roles.includes('Teacher') && !isManager.value)
|
||||||
const plans = ref<any[]>([])
|
const plans = ref<any[]>([])
|
||||||
const selected = ref<any | null>(null)
|
const selected = ref<any | null>(null)
|
||||||
|
const sessionPage = ref(1)
|
||||||
|
const sessionPageSize = ref(20)
|
||||||
|
const sessionTotal = ref(0)
|
||||||
|
const planSessionTotal = ref(0)
|
||||||
|
const sessionLoading = ref(false)
|
||||||
const personal = ref<any[]>([])
|
const personal = ref<any[]>([])
|
||||||
const terms = ref<any[]>([])
|
const terms = ref<any[]>([])
|
||||||
const tasks = ref<any[]>([])
|
const tasks = ref<any[]>([])
|
||||||
@@ -52,9 +57,10 @@ const taskColleges = computed(() => {
|
|||||||
})
|
})
|
||||||
const filteredTasks = computed(() => {
|
const filteredTasks = computed(() => {
|
||||||
const keyword = taskFilter.keyword.trim().toLowerCase()
|
const keyword = taskFilter.keyword.trim().toLowerCase()
|
||||||
const arrangedIds = new Set((selected.value?.sessions ?? [])
|
const arrangedIds = new Set<string>(selected.value?.scheduledTeachingTaskIds ?? [])
|
||||||
.filter((session: any) => session.id !== editingSession.value?.id)
|
if (editingSession.value?.teachingTaskId) {
|
||||||
.map((session: any) => session.teachingTaskId))
|
arrangedIds.delete(editingSession.value.teachingTaskId)
|
||||||
|
}
|
||||||
return tasks.value.filter((task: any) => {
|
return tasks.value.filter((task: any) => {
|
||||||
if (arrangedIds.has(task.id)) return false
|
if (arrangedIds.has(task.id)) return false
|
||||||
if (taskFilter.collegeId && task.collegeId !== taskFilter.collegeId) return false
|
if (taskFilter.collegeId && task.collegeId !== taskFilter.collegeId) return false
|
||||||
@@ -65,19 +71,7 @@ const filteredTasks = computed(() => {
|
|||||||
.some((value: any) => String(value ?? '').toLowerCase().includes(keyword))
|
.some((value: any) => String(value ?? '').toLowerCase().includes(keyword))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
const filteredSessions = computed(() => {
|
const filteredSessions = computed(() => selected.value?.sessions ?? [])
|
||||||
const keyword = sessionFilter.keyword.trim().toLowerCase()
|
|
||||||
return (selected.value?.sessions ?? []).filter((session: any) => {
|
|
||||||
if (sessionFilter.allocation === 'room' && hasRoomAssignment(session)) return false
|
|
||||||
if (sessionFilter.allocation === 'invigilator' &&
|
|
||||||
hasCompleteInvigilators(session)) return false
|
|
||||||
if (sessionFilter.allocation === 'complete' &&
|
|
||||||
(!hasRoomAssignment(session) || !hasCompleteInvigilators(session))) return false
|
|
||||||
if (!keyword) return true
|
|
||||||
return [session.taskNumber, session.courseCode, session.courseName, session.taskName]
|
|
||||||
.some((value: any) => String(value ?? '').toLowerCase().includes(keyword))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
function sessionRooms(session: any) {
|
function sessionRooms(session: any) {
|
||||||
return session.examRooms ?? []
|
return session.examRooms ?? []
|
||||||
@@ -85,14 +79,6 @@ function sessionRooms(session: any) {
|
|||||||
function hasRoomAssignment(session: any) {
|
function hasRoomAssignment(session: any) {
|
||||||
return sessionRooms(session).length > 0 || Boolean(session.classroomId)
|
return sessionRooms(session).length > 0 || Boolean(session.classroomId)
|
||||||
}
|
}
|
||||||
function hasCompleteInvigilators(session: any) {
|
|
||||||
const roomAssignments = sessionRooms(session)
|
|
||||||
if (roomAssignments.length > 0) {
|
|
||||||
return roomAssignments.every((room: any) =>
|
|
||||||
room.invigilatorIds.length >= session.requiredInvigilatorCount)
|
|
||||||
}
|
|
||||||
return session.invigilatorIds.length >= session.requiredInvigilatorCount
|
|
||||||
}
|
|
||||||
function sessionInvigilatorNames(session: any) {
|
function sessionInvigilatorNames(session: any) {
|
||||||
const roomAssignments = sessionRooms(session)
|
const roomAssignments = sessionRooms(session)
|
||||||
const names = roomAssignments.length > 0
|
const names = roomAssignments.length > 0
|
||||||
@@ -131,6 +117,9 @@ async function load() {
|
|||||||
else {
|
else {
|
||||||
selected.value = null
|
selected.value = null
|
||||||
selectedSessionIds.value = []
|
selectedSessionIds.value = []
|
||||||
|
sessionPage.value = 1
|
||||||
|
sessionTotal.value = 0
|
||||||
|
planSessionTotal.value = 0
|
||||||
tasks.value = []
|
tasks.value = []
|
||||||
timeSlots.value = []
|
timeSlots.value = []
|
||||||
}
|
}
|
||||||
@@ -138,10 +127,63 @@ async function load() {
|
|||||||
finally { loading.value = false }
|
finally { loading.value = false }
|
||||||
}
|
}
|
||||||
async function selectPlan(id: string) {
|
async function selectPlan(id: string) {
|
||||||
selected.value = (await http.get(`/exams/plans/${id}`)).data
|
sessionPage.value = 1
|
||||||
selectedSessionIds.value = []
|
selectedSessionIds.value = []
|
||||||
|
await loadSelectedPlan(id)
|
||||||
await loadPlanResources(selected.value.academicTermId)
|
await loadPlanResources(selected.value.academicTermId)
|
||||||
}
|
}
|
||||||
|
async function loadSelectedPlan(id: string) {
|
||||||
|
sessionLoading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await http.get(`/exams/plans/${id}`, {
|
||||||
|
params: {
|
||||||
|
page: sessionPage.value,
|
||||||
|
pageSize: sessionPageSize.value,
|
||||||
|
keyword: sessionFilter.keyword.trim() || undefined,
|
||||||
|
allocation: sessionFilter.allocation || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
selected.value = data
|
||||||
|
sessionPage.value = data.sessionPage
|
||||||
|
sessionPageSize.value = data.sessionPageSize
|
||||||
|
sessionTotal.value = data.filteredSessionCount
|
||||||
|
planSessionTotal.value = data.totalSessionCount
|
||||||
|
const planSummary = plans.value.find((plan: any) => plan.id === data.id)
|
||||||
|
if (planSummary) planSummary.sessionCount = data.totalSessionCount
|
||||||
|
} finally {
|
||||||
|
sessionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function reloadSelectedPlan() {
|
||||||
|
if (!selected.value) return
|
||||||
|
await loadSelectedPlan(selected.value.id)
|
||||||
|
}
|
||||||
|
async function applySessionFilters() {
|
||||||
|
if (!selected.value) return
|
||||||
|
sessionPage.value = 1
|
||||||
|
try {
|
||||||
|
await reloadSelectedPlan()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function changeSessionPage(page: number) {
|
||||||
|
sessionPage.value = page
|
||||||
|
try {
|
||||||
|
await reloadSelectedPlan()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function changeSessionPageSize(pageSize: number) {
|
||||||
|
sessionPageSize.value = pageSize
|
||||||
|
sessionPage.value = 1
|
||||||
|
try {
|
||||||
|
await reloadSelectedPlan()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
async function loadPlanResources(academicTermId: string) {
|
async function loadPlanResources(academicTermId: string) {
|
||||||
const [taskRes, slotRes] = await Promise.all([
|
const [taskRes, slotRes] = await Promise.all([
|
||||||
http.get('/teaching-tasks/options', {
|
http.get('/teaching-tasks/options', {
|
||||||
@@ -218,14 +260,15 @@ async function saveSession() {
|
|||||||
}
|
}
|
||||||
sessionDialog.value = false
|
sessionDialog.value = false
|
||||||
editingSession.value = null
|
editingSession.value = null
|
||||||
await selectPlan(selected.value.id)
|
await reloadSelectedPlan()
|
||||||
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
|
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
|
||||||
}
|
}
|
||||||
async function removeSession(row: any) {
|
async function removeSession(row: any) {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(`移除"${row.courseName}"考试场次?`, '移除场次', { type: 'warning' })
|
await ElMessageBox.confirm(`移除"${row.courseName}"考试场次?`, '移除场次', { type: 'warning' })
|
||||||
await http.delete(`/exams/plans/${selected.value.id}/sessions/${row.id}`)
|
await http.delete(`/exams/plans/${selected.value.id}/sessions/${row.id}`)
|
||||||
await selectPlan(selected.value.id)
|
selectedSessionIds.value = selectedSessionIds.value.filter(id => id !== row.id)
|
||||||
|
await reloadSelectedPlan()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||||||
}
|
}
|
||||||
@@ -252,7 +295,8 @@ async function removeSelectedSessions() {
|
|||||||
{ sessionIds: selectedSessionIds.value },
|
{ sessionIds: selectedSessionIds.value },
|
||||||
)
|
)
|
||||||
ElMessage.success(`已移除 ${res.data.removedCount} 个考试场次`)
|
ElMessage.success(`已移除 ${res.data.removedCount} 个考试场次`)
|
||||||
await selectPlan(selected.value.id)
|
selectedSessionIds.value = []
|
||||||
|
await reloadSelectedPlan()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -279,7 +323,14 @@ function toggleTaskSelection(id: string, checked: boolean) {
|
|||||||
: selectedTaskIds.value.filter(value => value !== id)
|
: selectedTaskIds.value.filter(value => value !== id)
|
||||||
}
|
}
|
||||||
function selectFilteredSessions() {
|
function selectFilteredSessions() {
|
||||||
selectedSessionIds.value = filteredSessions.value.map((session: any) => session.id)
|
const selectedIds = Array.from(new Set([
|
||||||
|
...selectedSessionIds.value,
|
||||||
|
...filteredSessions.value.map((session: any) => session.id),
|
||||||
|
]))
|
||||||
|
if (selectedIds.length > 100) {
|
||||||
|
ElMessage.warning('一次最多选择100个考试场次')
|
||||||
|
}
|
||||||
|
selectedSessionIds.value = selectedIds.slice(0, 100)
|
||||||
}
|
}
|
||||||
function clearSessionSelection() {
|
function clearSessionSelection() {
|
||||||
selectedSessionIds.value = []
|
selectedSessionIds.value = []
|
||||||
@@ -309,7 +360,7 @@ async function autoArrange(mode: 'rooms' | 'invigilators' | 'all') {
|
|||||||
assignInvigilators: mode !== 'rooms',
|
assignInvigilators: mode !== 'rooms',
|
||||||
})
|
})
|
||||||
ElMessage.success(res.data.message)
|
ElMessage.success(res.data.message)
|
||||||
await selectPlan(selected.value.id)
|
await reloadSelectedPlan()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||||||
} finally { arrangeLoading.value = false }
|
} finally { arrangeLoading.value = false }
|
||||||
@@ -329,7 +380,7 @@ async function deleteDraftPlan() {
|
|||||||
if (!selected.value || selected.value.status !== 'Draft') return
|
if (!selected.value || selected.value.status !== 'Draft') return
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
`确定删除草稿考试计划“${selected.value.name}”吗?计划内 ${selected.value.sessions.length} 个考试场次也会一并删除,此操作无法撤销。`,
|
`确定删除草稿考试计划“${selected.value.name}”吗?计划内 ${planSessionTotal.value} 个考试场次也会一并删除,此操作无法撤销。`,
|
||||||
'删除考试计划',
|
'删除考试计划',
|
||||||
{
|
{
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
@@ -427,13 +478,13 @@ onMounted(async () => {
|
|||||||
<div>
|
<div>
|
||||||
<span>EXAM TIMELINE</span>
|
<span>EXAM TIMELINE</span>
|
||||||
<h3>{{ selected.name }}</h3>
|
<h3>{{ selected.name }}</h3>
|
||||||
<p>{{ selected.termName }} · {{ selected.sessions.length }} 个考试场次</p>
|
<p>{{ selected.termName }} · {{ planSessionTotal }} 个考试场次</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="exam-actions">
|
<div class="exam-actions">
|
||||||
<el-button
|
<el-button
|
||||||
:icon="Download"
|
:icon="Download"
|
||||||
:loading="exportLoading"
|
:loading="exportLoading"
|
||||||
:disabled="selected.sessions.length === 0"
|
:disabled="planSessionTotal === 0"
|
||||||
@click="exportSignInSheets"
|
@click="exportSignInSheets"
|
||||||
>导出考场签名单</el-button>
|
>导出考场签名单</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>
|
||||||
@@ -452,15 +503,32 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="exam-filter-bar">
|
<div class="exam-filter-bar">
|
||||||
<el-input v-model="sessionFilter.keyword" clearable placeholder="筛选课程、课程号或教学班号" />
|
<el-input
|
||||||
<el-select v-model="sessionFilter.allocation" clearable placeholder="全部分配状态">
|
v-model="sessionFilter.keyword"
|
||||||
|
clearable
|
||||||
|
placeholder="筛选课程、课程号或教学班号"
|
||||||
|
@keyup.enter="applySessionFilters"
|
||||||
|
@clear="applySessionFilters"
|
||||||
|
/>
|
||||||
|
<el-select
|
||||||
|
v-model="sessionFilter.allocation"
|
||||||
|
clearable
|
||||||
|
placeholder="全部分配状态"
|
||||||
|
@change="applySessionFilters"
|
||||||
|
>
|
||||||
<el-option label="待分配考场" value="room" />
|
<el-option label="待分配考场" value="room" />
|
||||||
<el-option label="待补足监考" value="invigilator" />
|
<el-option label="待补足监考" value="invigilator" />
|
||||||
<el-option label="已完成分配" value="complete" />
|
<el-option label="已完成分配" value="complete" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<span>显示 {{ filteredSessions.length }}/{{ selected.sessions.length }} 个场次</span>
|
<el-button :loading="sessionLoading" @click="applySessionFilters">查询</el-button>
|
||||||
|
<span>
|
||||||
|
本页 {{ filteredSessions.length }} 个,共 {{ sessionTotal }} 个符合条件
|
||||||
|
<template v-if="sessionTotal !== planSessionTotal">
|
||||||
|
(计划共 {{ planSessionTotal }} 个)
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
<template v-if="selected.status === 'Draft'">
|
<template v-if="selected.status === 'Draft'">
|
||||||
<el-button link type="primary" @click="selectFilteredSessions">选择筛选结果</el-button>
|
<el-button link type="primary" @click="selectFilteredSessions">选择本页</el-button>
|
||||||
<el-button link @click="clearSessionSelection">清空选择</el-button>
|
<el-button link @click="clearSessionSelection">清空选择</el-button>
|
||||||
<el-tag v-if="selectedSessionIds.length" type="info">已选 {{ selectedSessionIds.length }} 个</el-tag>
|
<el-tag v-if="selectedSessionIds.length" type="info">已选 {{ selectedSessionIds.length }} 个</el-tag>
|
||||||
<el-button
|
<el-button
|
||||||
@@ -473,7 +541,7 @@ onMounted(async () => {
|
|||||||
>批量移除已选场次</el-button>
|
>批量移除已选场次</el-button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="exam-timeline">
|
<div class="exam-timeline" v-loading="sessionLoading">
|
||||||
<article v-for="session in filteredSessions" :key="session.id" :class="{ unassigned: !hasRoomAssignment(session) }">
|
<article v-for="session in filteredSessions" :key="session.id" :class="{ unassigned: !hasRoomAssignment(session) }">
|
||||||
<time>
|
<time>
|
||||||
<el-checkbox
|
<el-checkbox
|
||||||
@@ -513,9 +581,27 @@ onMounted(async () => {
|
|||||||
<el-button v-if="selected.status === 'Draft'" link type="danger" @click="removeSession(session)">移除</el-button>
|
<el-button v-if="selected.status === 'Draft'" link type="danger" @click="removeSession(session)">移除</el-button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<el-empty v-if="!selected.sessions.length" description="尚未安排考试场次,点击「批量安排场次」开始。" />
|
<el-empty
|
||||||
<el-empty v-else-if="!filteredSessions.length" description="没有符合筛选条件的考试场次" />
|
v-if="planSessionTotal === 0"
|
||||||
|
description="尚未安排考试场次,点击「批量安排场次」开始。"
|
||||||
|
/>
|
||||||
|
<el-empty
|
||||||
|
v-else-if="sessionTotal === 0"
|
||||||
|
description="没有符合筛选条件的考试场次"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<el-pagination
|
||||||
|
v-if="sessionTotal > 0"
|
||||||
|
class="exam-pagination"
|
||||||
|
background
|
||||||
|
:current-page="sessionPage"
|
||||||
|
:page-size="sessionPageSize"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="sessionTotal"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="changeSessionPage"
|
||||||
|
@size-change="changeSessionPageSize"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -704,6 +790,10 @@ onMounted(async () => {
|
|||||||
display: block;
|
display: block;
|
||||||
margin: 3px 0;
|
margin: 3px 0;
|
||||||
}
|
}
|
||||||
|
.exam-pagination {
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.exam-actions {
|
.exam-actions {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
@@ -715,5 +805,8 @@ onMounted(async () => {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
.exam-pagination {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user