diff --git a/src/Jiaowu.Api/Controllers/ExamsController.cs b/src/Jiaowu.Api/Controllers/ExamsController.cs index 44a000b..b33e499 100644 --- a/src/Jiaowu.Api/Controllers/ExamsController.cs +++ b/src/Jiaowu.Api/Controllers/ExamsController.cs @@ -108,8 +108,21 @@ public sealed class ExamsController( } [HttpGet("plans/{id:guid}")] - public async Task GetPlan(Guid id, CancellationToken cancellationToken) + public async Task 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 plan = await db.ExamPlans.AsNoTracking() .Where(x => x.Id == id && (manager || x.Status == ExamPlanStatus.Published)) @@ -122,71 +135,157 @@ public sealed class ExamsController( x.Status, x.Notes, x.PublishedAt, - Sessions = x.Sessions.OrderBy(item => item.ExamDate) - .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) - }) + TotalSessionCount = x.Sessions.Count }).FirstOrDefaultAsync(cancellationToken); if (plan is null) return NotFound(); - var roomEntities = await db.ExamRooms.AsNoTracking() - .Include(x => x.Classroom) - .ThenInclude(x => x!.Building) - .Include(x => x.SessionLinks) - .Include(x => x.Seats) - .Include(x => x.Invigilators) - .ThenInclude(x => x.Teacher) - .Where(x => x.ExamPlanId == id) + var sessionQuery = db.ExamSessions.AsNoTracking() + .Where(x => x.ExamPlanId == id); + if (keyword is not null) + { + sessionQuery = sessionQuery.Where(x => + x.TeachingTask!.TaskNumber.Contains(keyword) || + x.TeachingTask.Name.Contains(keyword) || + 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); - var roomsBySession = roomEntities - .SelectMany(room => room.SessionLinks.Select(link => new + var pageSessionIds = sessions.Select(x => x.Id).ToArray(); + 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, Room = new { - link.ExamRoomId, + room.ExamRoomId, room.ClassroomId, - ClassroomName = room.Classroom!.Name, - BuildingName = room.Classroom.Building!.Name, - ClassroomCapacity = room.Classroom.Capacity, - SeatCount = room.Seats.Count(seat => - seat.ExamSessionId == link.ExamSessionId), - TotalSeatCount = room.Seats.Count, - IsMixed = room.SessionLinks.Count > 1, - InvigilatorIds = room.Invigilators + room.ClassroomName, + room.BuildingName, + room.ClassroomCapacity, + link.SeatCount, + room.TotalSeatCount, + IsMixed = room.SessionCount > 1, + InvigilatorIds = invigilatorsByRoom[room.ExamRoomId] .Select(invigilator => invigilator.TeacherId) .ToList(), - InvigilatorNames = room.Invigilators - .Select(invigilator => invigilator.Teacher!.Name) + InvigilatorNames = invigilatorsByRoom[room.ExamRoomId] + .Select(invigilator => invigilator.TeacherName) .ToList() } })) .ToLookup(x => x.ExamSessionId, x => x.Room); var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync( db, - plan.Sessions.Select(x => x.TeachingTaskId), + sessions.Select(x => x.TeachingTaskId), cancellationToken)) .GroupBy(x => x.TeachingTaskId) .ToDictionary(x => x.Key, x => x.Count()); @@ -199,7 +298,12 @@ public sealed class ExamsController( plan.Status, plan.Notes, 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.TeachingTaskId, diff --git a/tests/Jiaowu.Api.Tests/ExamPlanPaginationTests.cs b/tests/Jiaowu.Api.Tests/ExamPlanPaginationTests.cs new file mode 100644 index 0000000..9ec97d0 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/ExamPlanPaginationTests.cs @@ -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() + .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(result).Value!; + + private static int ReadInt(object value, string property) => + (int)value.GetType().GetProperty(property)!.GetValue(value)!; + + private static List ReadItems(object value, string property) => + Assert.IsAssignableFrom( + value.GetType().GetProperty(property)!.GetValue(value)) + .Cast() + .ToList(); + + private sealed class AllScope : ICurrentUserDataScope + { + public CurrentUserScope Current { get; } = new( + Guid.NewGuid(), + "测试管理员", + null, + DataScope.All, + new HashSet([SystemRoles.SuperAdmin])); + } +} diff --git a/tests/Jiaowu.Api.Tests/TeachingWorkflowRosterTests.cs b/tests/Jiaowu.Api.Tests/TeachingWorkflowRosterTests.cs index 91bdb90..fde7621 100644 --- a/tests/Jiaowu.Api.Tests/TeachingWorkflowRosterTests.cs +++ b/tests/Jiaowu.Api.Tests/TeachingWorkflowRosterTests.cs @@ -256,10 +256,27 @@ public sealed class TeachingWorkflowRosterTests Assert.IsType(publishResult); var planResult = Assert.IsType( - await exams.GetPlan(examPlan.Id, CancellationToken.None)); + await exams.GetPlan( + examPlan.Id, + cancellationToken: CancellationToken.None)); var planSessions = ReadEnumerableProperty(planResult.Value!, "Sessions"); 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()); + Assert.Equal( + [teacher.Name], + ReadEnumerableProperty(returnedRoom, "InvigilatorNames").Cast()); var examSessionId = examPlan.Sessions.Single().Id; var rosterResult = Assert.IsType( diff --git a/web/src/views/ExamsView.vue b/web/src/views/ExamsView.vue index 0f3c404..a29c983 100644 --- a/web/src/views/ExamsView.vue +++ b/web/src/views/ExamsView.vue @@ -12,6 +12,11 @@ const isManager = computed(() => const isTeacher = computed(() => auth.user?.roles.includes('Teacher') && !isManager.value) const plans = ref([]) const selected = ref(null) +const sessionPage = ref(1) +const sessionPageSize = ref(20) +const sessionTotal = ref(0) +const planSessionTotal = ref(0) +const sessionLoading = ref(false) const personal = ref([]) const terms = ref([]) const tasks = ref([]) @@ -52,9 +57,10 @@ const taskColleges = computed(() => { }) const filteredTasks = computed(() => { const keyword = taskFilter.keyword.trim().toLowerCase() - const arrangedIds = new Set((selected.value?.sessions ?? []) - .filter((session: any) => session.id !== editingSession.value?.id) - .map((session: any) => session.teachingTaskId)) + const arrangedIds = new Set(selected.value?.scheduledTeachingTaskIds ?? []) + if (editingSession.value?.teachingTaskId) { + arrangedIds.delete(editingSession.value.teachingTaskId) + } return tasks.value.filter((task: any) => { if (arrangedIds.has(task.id)) 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)) }) }) -const filteredSessions = computed(() => { - 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)) - }) -}) +const filteredSessions = computed(() => selected.value?.sessions ?? []) function sessionRooms(session: any) { return session.examRooms ?? [] @@ -85,14 +79,6 @@ function sessionRooms(session: any) { function hasRoomAssignment(session: any) { 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) { const roomAssignments = sessionRooms(session) const names = roomAssignments.length > 0 @@ -131,6 +117,9 @@ async function load() { else { selected.value = null selectedSessionIds.value = [] + sessionPage.value = 1 + sessionTotal.value = 0 + planSessionTotal.value = 0 tasks.value = [] timeSlots.value = [] } @@ -138,10 +127,63 @@ async function load() { finally { loading.value = false } } async function selectPlan(id: string) { - selected.value = (await http.get(`/exams/plans/${id}`)).data + sessionPage.value = 1 selectedSessionIds.value = [] + await loadSelectedPlan(id) 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) { const [taskRes, slotRes] = await Promise.all([ http.get('/teaching-tasks/options', { @@ -218,14 +260,15 @@ async function saveSession() { } sessionDialog.value = false editingSession.value = null - await selectPlan(selected.value.id) + await reloadSelectedPlan() } catch (error) { ElMessage.error(apiErrorMessage(error)) } } async function removeSession(row: any) { try { await ElMessageBox.confirm(`移除"${row.courseName}"考试场次?`, '移除场次', { type: 'warning' }) 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) { if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error)) } @@ -252,7 +295,8 @@ async function removeSelectedSessions() { { sessionIds: selectedSessionIds.value }, ) ElMessage.success(`已移除 ${res.data.removedCount} 个考试场次`) - await selectPlan(selected.value.id) + selectedSessionIds.value = [] + await reloadSelectedPlan() } catch (error: any) { if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error)) } finally { @@ -279,7 +323,14 @@ function toggleTaskSelection(id: string, checked: boolean) { : selectedTaskIds.value.filter(value => value !== id) } 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() { selectedSessionIds.value = [] @@ -309,7 +360,7 @@ async function autoArrange(mode: 'rooms' | 'invigilators' | 'all') { assignInvigilators: mode !== 'rooms', }) ElMessage.success(res.data.message) - await selectPlan(selected.value.id) + await reloadSelectedPlan() } catch (error: any) { if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error)) } finally { arrangeLoading.value = false } @@ -329,7 +380,7 @@ async function deleteDraftPlan() { if (!selected.value || selected.value.status !== 'Draft') return try { await ElMessageBox.confirm( - `确定删除草稿考试计划“${selected.value.name}”吗?计划内 ${selected.value.sessions.length} 个考试场次也会一并删除,此操作无法撤销。`, + `确定删除草稿考试计划“${selected.value.name}”吗?计划内 ${planSessionTotal.value} 个考试场次也会一并删除,此操作无法撤销。`, '删除考试计划', { type: 'warning', @@ -427,13 +478,13 @@ onMounted(async () => {
EXAM TIMELINE

{{ selected.name }}

-

{{ selected.termName }} · {{ selected.sessions.length }} 个考试场次

+

{{ selected.termName }} · {{ planSessionTotal }} 个考试场次

导出考场签名单 一键分配考场 @@ -452,15 +503,32 @@ onMounted(async () => {
- - + + - 显示 {{ filteredSessions.length }}/{{ selected.sessions.length }} 个场次 + 查询 + + 本页 {{ filteredSessions.length }} 个,共 {{ sessionTotal }} 个符合条件 + +
-
+
- - + +
+ @@ -704,6 +790,10 @@ onMounted(async () => { display: block; margin: 3px 0; } +.exam-pagination { + justify-content: flex-end; + margin-top: 18px; +} @media (max-width: 720px) { .exam-actions { justify-content: flex-start; @@ -715,5 +805,8 @@ onMounted(async () => { align-items: flex-start; flex-direction: column; } + .exam-pagination { + justify-content: center; + } }