From eb20211f0e772a0a076d87f4ac11ce9adde524bc Mon Sep 17 00:00:00 2001 From: biss Date: Tue, 11 Aug 2026 16:34:52 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/CourseSelectionsController.cs | 100 ++++++++++++++++-- .../Controllers/OtherExamsController.cs | 35 +++++- .../OtherExamsControllerTests.cs | 32 ++++++ web/src/views/CourseSelectionView.vue | 73 ++++++++++++- web/src/views/OtherExamsView.vue | 31 +++++- 5 files changed, 253 insertions(+), 18 deletions(-) diff --git a/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs b/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs index 6511760..15194e5 100644 --- a/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs +++ b/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs @@ -399,8 +399,22 @@ public sealed class CourseSelectionsController( [HttpGet("offerings/{id:guid}/roster")] [Authorize(Roles = RosterReaders)] - public async Task GetRoster(Guid id, CancellationToken cancellationToken) + public async Task GetRoster( + Guid id, + string? keyword = null, + int? grade = null, + Guid? majorId = null, + Guid? administrativeClassId = null, + int studentPage = 1, + int studentPageSize = 20, + int waitlistPage = 1, + int waitlistPageSize = 20, + CancellationToken cancellationToken = default) { + studentPage = Math.Max(1, studentPage); + studentPageSize = Math.Clamp(studentPageSize, 10, 100); + waitlistPage = Math.Max(1, waitlistPage); + waitlistPageSize = Math.Clamp(waitlistPageSize, 10, 100); var offering = await db.CourseSelectionOfferings.AsNoTracking() .Where(x => x.Id == id) .Select(x => new @@ -428,11 +442,64 @@ public sealed class CourseSelectionsController( if (!isAssignedTeacher && !scope.CanAccessCollege(offering.CollegeId)) return Forbid(); - var students = await db.CourseEnrollments.AsNoTracking() + var enrolledSource = db.CourseEnrollments.AsNoTracking() .Where(x => x.CourseSelectionOfferingId == id && - x.Status == CourseEnrollmentStatus.Enrolled) + x.Status == CourseEnrollmentStatus.Enrolled); + var waitlistSource = db.CourseEnrollments.AsNoTracking() + .Where(x => + x.CourseSelectionOfferingId == id && + x.Status == CourseEnrollmentStatus.Waitlisted); + var enrolledCount = await enrolledSource.CountAsync(cancellationToken); + var waitlistedCount = await waitlistSource.CountAsync(cancellationToken); + var rosterSource = db.CourseEnrollments.AsNoTracking().Where(x => + x.CourseSelectionOfferingId == id && + (x.Status == CourseEnrollmentStatus.Enrolled || + x.Status == CourseEnrollmentStatus.Waitlisted)); + var filterOptions = await rosterSource + .Select(x => new + { + Grade = x.Student!.AdministrativeClass!.Grade, + MajorId = x.Student.AdministrativeClass.MajorId, + MajorName = x.Student.AdministrativeClass.Major!.Name, + ClassId = x.Student.AdministrativeClassId, + ClassName = x.Student.AdministrativeClass.Name + }) + .Distinct() + .ToListAsync(cancellationToken); + if (grade.HasValue) + { + enrolledSource = enrolledSource.Where(x => x.Student!.AdministrativeClass!.Grade == grade.Value); + waitlistSource = waitlistSource.Where(x => x.Student!.AdministrativeClass!.Grade == grade.Value); + } + if (majorId.HasValue) + { + enrolledSource = enrolledSource.Where(x => x.Student!.AdministrativeClass!.MajorId == majorId.Value); + waitlistSource = waitlistSource.Where(x => x.Student!.AdministrativeClass!.MajorId == majorId.Value); + } + if (administrativeClassId.HasValue) + { + enrolledSource = enrolledSource.Where(x => x.Student!.AdministrativeClassId == administrativeClassId.Value); + waitlistSource = waitlistSource.Where(x => x.Student!.AdministrativeClassId == administrativeClassId.Value); + } + if (!string.IsNullOrWhiteSpace(keyword)) + { + keyword = keyword.Trim(); + enrolledSource = enrolledSource.Where(x => + x.Student!.StudentNumber.Contains(keyword) || + x.Student.Name.Contains(keyword) || + x.Student.AdministrativeClass!.Name.Contains(keyword)); + waitlistSource = waitlistSource.Where(x => + x.Student!.StudentNumber.Contains(keyword) || + x.Student.Name.Contains(keyword) || + x.Student.AdministrativeClass!.Name.Contains(keyword)); + } + var enrolledTotal = await enrolledSource.CountAsync(cancellationToken); + var waitlistedTotal = await waitlistSource.CountAsync(cancellationToken); + var students = await enrolledSource .OrderBy(x => x.Student!.StudentNumber) + .Skip((studentPage - 1) * studentPageSize) + .Take(studentPageSize) .Select(x => new { x.Id, @@ -445,12 +512,11 @@ public sealed class CourseSelectionsController( x.EnrolledAt }) .ToListAsync(cancellationToken); - var waitlistedRows = await db.CourseEnrollments.AsNoTracking() - .Where(x => - x.CourseSelectionOfferingId == id && - x.Status == CourseEnrollmentStatus.Waitlisted) + var waitlistedRows = await waitlistSource .OrderBy(x => x.WaitlistedAt) .ThenBy(x => x.CreatedAt) + .Skip((waitlistPage - 1) * waitlistPageSize) + .Take(waitlistPageSize) .Select(x => new { x.Id, @@ -474,7 +540,7 @@ public sealed class CourseSelectionsController( item.MajorName, item.Grade, item.WaitlistedAt, - Position = index + 1 + Position = (waitlistPage - 1) * waitlistPageSize + index + 1 }) .ToList(); return Ok(new @@ -487,10 +553,24 @@ public sealed class CourseSelectionsController( offering.CourseName, offering.CourseNature, offering.Capacity, - EnrolledCount = students.Count, + EnrolledCount = enrolledCount, Students = students, - WaitlistedCount = waitlist.Count, + StudentPage = studentPage, + StudentPageSize = studentPageSize, + StudentTotal = enrolledTotal, + WaitlistedCount = waitlistedCount, Waitlist = waitlist, + WaitlistPage = waitlistPage, + WaitlistPageSize = waitlistPageSize, + WaitlistTotal = waitlistedTotal, + FilterOptions = new + { + Grades = filterOptions.Select(x => x.Grade).Distinct().OrderBy(x => x), + Majors = filterOptions.Select(x => new { x.MajorId, x.MajorName }).Distinct() + .OrderBy(x => x.MajorName), + Classes = filterOptions.Select(x => new { x.ClassId, x.ClassName, x.MajorId }) + .Distinct().OrderBy(x => x.ClassName) + }, CanManageWaitlist = offering.RoundStatus == CourseSelectionRoundStatus.Open && (scope.IsInRole(SystemRoles.SuperAdmin) || diff --git a/src/Jiaowu.Api/Controllers/OtherExamsController.cs b/src/Jiaowu.Api/Controllers/OtherExamsController.cs index 84f8ca9..21eac15 100644 --- a/src/Jiaowu.Api/Controllers/OtherExamsController.cs +++ b/src/Jiaowu.Api/Controllers/OtherExamsController.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.Globalization; +using Jiaowu.Api.Contracts; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; @@ -20,10 +21,38 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope [HttpGet("batches")] [Authorize(Roles = Managers)] - public async Task GetBatches(CancellationToken ct) => Ok(await db.OtherExamBatches - .AsNoTracking().OrderByDescending(x => x.ExamDate).ThenByDescending(x => x.CreatedAt) + public async Task GetBatches( + string? keyword = null, + OtherExamBatchStatus? status = null, + OtherExamMetricKind? metricKind = null, + DateOnly? examDateFrom = null, + DateOnly? examDateTo = null, + int page = 1, + int pageSize = 20, + CancellationToken ct = default) + { + page = Math.Max(1, page); + pageSize = Math.Clamp(pageSize, 10, 100); + var source = db.OtherExamBatches.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(keyword)) + { + keyword = keyword.Trim(); + source = source.Where(x => + (x.ExamCode != null && x.ExamCode.Contains(keyword)) || + x.Name.Contains(keyword) || + (x.Organizer != null && x.Organizer.Contains(keyword))); + } + if (status.HasValue) source = source.Where(x => x.Status == status.Value); + if (metricKind.HasValue) source = source.Where(x => x.MetricKind == metricKind.Value); + if (examDateFrom.HasValue) source = source.Where(x => x.ExamDate >= examDateFrom.Value); + if (examDateTo.HasValue) source = source.Where(x => x.ExamDate <= examDateTo.Value); + var total = await source.CountAsync(ct); + var items = await source.OrderByDescending(x => x.ExamDate).ThenByDescending(x => x.CreatedAt) + .Skip((page - 1) * pageSize).Take(pageSize) .Select(x => new { x.Id, x.ExamCode, x.Name, x.Organizer, x.ExamDate, x.MetricKind, x.MaxScore, x.LevelOptions, x.Status, x.PublicationCount, x.PublishedAt, ResultCount = x.Results.Count }) - .ToListAsync(ct)); + .ToListAsync(ct); + return Ok(new PagedResult(items, total, page, pageSize)); + } [HttpPost("batches")] [Authorize(Roles = Managers)] diff --git a/tests/Jiaowu.Api.Tests/OtherExamsControllerTests.cs b/tests/Jiaowu.Api.Tests/OtherExamsControllerTests.cs index 1eea5fe..07d86cc 100644 --- a/tests/Jiaowu.Api.Tests/OtherExamsControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/OtherExamsControllerTests.cs @@ -1,4 +1,5 @@ using Jiaowu.Api.Controllers; +using Jiaowu.Api.Contracts; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; @@ -11,6 +12,37 @@ namespace Jiaowu.Api.Tests; public sealed class OtherExamsControllerTests { + [Fact] + public async Task Batches_AreFilteredAndReturnedByPage() + { + 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(); + db.OtherExamBatches.AddRange(Enumerable.Range(1, 11).Select(number => + new OtherExamBatch + { + ExamCode = $"CET-{number:00}", + Name = $"英语等级考试 {number}", + ExamDate = new DateOnly(2026, 6, number), + MetricKind = OtherExamMetricKind.Score, + MaxScore = 100, + Status = number == 11 + ? OtherExamBatchStatus.Published + : OtherExamBatchStatus.Draft + })); + await db.SaveChangesAsync(); + + var controller = new OtherExamsController(db, new TestDataScope(Guid.NewGuid())); + var result = Assert.IsType(await controller.GetBatches( + keyword: "英语", status: OtherExamBatchStatus.Draft, page: 1, + pageSize: 10, ct: CancellationToken.None)); + var page = Assert.IsType>(result.Value); + Assert.Equal(10, page.Total); + Assert.Equal(10, page.Items.Count); + } + [Fact] public async Task ReplaceResults_ReplacesExistingResultsInsideTransaction() { diff --git a/web/src/views/CourseSelectionView.vue b/web/src/views/CourseSelectionView.vue index 2d026ce..62f45da 100644 --- a/web/src/views/CourseSelectionView.vue +++ b/web/src/views/CourseSelectionView.vue @@ -40,6 +40,13 @@ const editingRoundId = ref('') const editingOfferingId = ref('') const roster = ref(null) const rosterLoading = ref(false) +const rosterKeyword = ref('') +const rosterGrade = ref() +const rosterMajorId = ref() +const rosterClassId = ref() +const rosterStudentPage = ref(1) +const rosterWaitlistPage = ref(1) +const rosterPageSize = 20 const eligibleStudents = ref([]) const eligibleTotal = ref(0) const eligiblePage = ref(1) @@ -82,6 +89,8 @@ const selectedOfferings = computed(() => const previewOffering = computed(() => offerings.value.find((item) => item.id === previewOfferingId.value) ?? null, ) +const rosterClasses = computed(() => (roster.value?.filterOptions?.classes ?? []) + .filter((item: any) => !rosterMajorId.value || item.majorId === rosterMajorId.value)) const selectableTasks = computed(() => { const usedTaskIds = new Set( offerings.value @@ -540,6 +549,12 @@ async function deleteOffering(offering: any) { } async function showRoster(offering: any) { + rosterKeyword.value = '' + rosterGrade.value = undefined + rosterMajorId.value = undefined + rosterClassId.value = undefined + rosterStudentPage.value = 1 + rosterWaitlistPage.value = 1 rosterDrawer.value = true await loadRoster(offering.id) } @@ -548,7 +563,18 @@ async function loadRoster(offeringId: string) { rosterLoading.value = true try { roster.value = ( - await http.get(`/course-selections/offerings/${offeringId}/roster`) + await http.get(`/course-selections/offerings/${offeringId}/roster`, { + params: { + keyword: rosterKeyword.value.trim() || undefined, + grade: rosterGrade.value, + majorId: rosterMajorId.value, + administrativeClassId: rosterClassId.value, + studentPage: rosterStudentPage.value, + studentPageSize: rosterPageSize, + waitlistPage: rosterWaitlistPage.value, + waitlistPageSize: rosterPageSize, + }, + }) ).data } catch (error) { ElMessage.error(apiErrorMessage(error)) @@ -557,6 +583,12 @@ async function loadRoster(offeringId: string) { } } +function filterRoster() { + rosterStudentPage.value = 1 + rosterWaitlistPage.value = 1 + if (roster.value) void loadRoster(roster.value.id) +} + async function openProxyEnrollment() { studentKeyword.value = '' selectedStudentIds.value = [] @@ -1346,6 +1378,25 @@ onMounted(async () => { :closable="false" title="强制选课将忽略容量、时间冲突、学分上限和重复课程等限制,直接加入名单。" /> +
+ + + + + + + + + + + 查询 +
@@ -1363,6 +1414,12 @@ onMounted(async () => { +
@@ -1394,6 +1451,12 @@ onMounted(async () => { +
@@ -1528,6 +1591,11 @@ onMounted(async () => { .offering-ticket.waitlisted { border-color: #e6a23c; box-shadow: 0 10px 28px rgb(230 162 60 / 10%); } .seat-meter > small { display: block; margin-top: 5px; color: #b7791f; } .waitlist-panel { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--line); } +.roster-filter { display: flex; flex-wrap: wrap; gap: 8px; margin: 14px 0; } +.roster-filter .el-input { max-width: 260px; } +.roster-filter .el-select { width: 140px; } +.roster-summary ~ :deep(.el-pagination), +.waitlist-panel :deep(.el-pagination) { justify-content: flex-end; margin-top: 10px; } .waitlist-panel-head { display: flex; align-items: end; @@ -1541,6 +1609,9 @@ onMounted(async () => { .waitlist-panel-head small { color: var(--muted); text-align: right; } @media (max-width: 640px) { + .roster-filter { align-items: stretch; flex-direction: column; } + .roster-filter .el-input, + .roster-filter .el-select { width: 100%; max-width: none; } .waitlist-panel-head { align-items: start; flex-direction: column; } .waitlist-panel-head small { text-align: left; } } diff --git a/web/src/views/OtherExamsView.vue b/web/src/views/OtherExamsView.vue index 293afb0..d92e2e4 100644 --- a/web/src/views/OtherExamsView.vue +++ b/web/src/views/OtherExamsView.vue @@ -1,7 +1,7 @@