diff --git a/src/Jiaowu.Api/Contracts/PagedResult.cs b/src/Jiaowu.Api/Contracts/PagedResult.cs index a0514bf..aec242a 100644 --- a/src/Jiaowu.Api/Contracts/PagedResult.cs +++ b/src/Jiaowu.Api/Contracts/PagedResult.cs @@ -8,7 +8,8 @@ public sealed record CursorPagedResult( IReadOnlyCollection Items, string? NextCursor, bool HasMore, - int PageSize); + int PageSize, + int? Total = null); /// /// Legacy offset-page response. New list endpoints must use diff --git a/src/Jiaowu.Api/Controllers/AppUpdatesController.cs b/src/Jiaowu.Api/Controllers/AppUpdatesController.cs index 0af7405..d6f6d13 100644 --- a/src/Jiaowu.Api/Controllers/AppUpdatesController.cs +++ b/src/Jiaowu.Api/Controllers/AppUpdatesController.cs @@ -113,15 +113,17 @@ public sealed partial class AppUpdatesController(AppDbContext db) : ControllerBa [Authorize(Roles = SystemRoles.SuperAdmin)] [HttpGet("releases")] - public async Task>> GetReleases( - [FromQuery] int page = 1, + public async Task>> GetReleases( + [FromQuery] string? cursor = null, [FromQuery] int pageSize = 20, [FromQuery] string? platform = null, [FromQuery] string? channel = null, CancellationToken cancellationToken = default) { - if (page < 1 || pageSize is < 1 or > 100) - return ValidationProblem("页码必须大于零,每页数量必须在 1 到 100 之间。"); + if (pageSize is < 1 or > 100) + return ValidationProblem("每批数量必须在 1 到 100 之间。"); + if (!CursorToken.TryDecode(cursor, out var after)) + return BadRequest("更新包游标无效。"); var query = db.AppUpdateReleases.AsNoTracking().AsQueryable(); if (!string.IsNullOrWhiteSpace(platform)) @@ -137,11 +139,21 @@ public sealed partial class AppUpdatesController(AppDbContext db) : ControllerBa query = query.Where(x => x.Channel == parsedChannel); } - var total = await query.CountAsync(cancellationToken); - var rows = await query + if (after is not null) + { + query = query.Where(x => (x.PublishedAt ?? x.CreatedAt) < after.SortAt || + (x.PublishedAt ?? x.CreatedAt) == after.SortAt && x.Id.CompareTo(after.Id) < 0); + } + var keys = await query .OrderByDescending(x => x.PublishedAt ?? x.CreatedAt) - .Skip((page - 1) * pageSize) - .Take(pageSize) + .ThenByDescending(x => x.Id) + .Take(pageSize + 1) + .Select(x => new { x.Id, SortAt = x.PublishedAt ?? x.CreatedAt }) + .ToArrayAsync(cancellationToken); + var hasMore = keys.Length > pageSize; + var pageIds = keys.Take(pageSize).Select(x => x.Id).ToArray(); + var rows = await query.WhereIn(pageIds, x => x.Id) + .OrderByDescending(x => x.PublishedAt ?? x.CreatedAt).ThenByDescending(x => x.Id) .Select(x => new AppUpdateReleaseItem( x.Id, x.Platform, @@ -158,13 +170,16 @@ public sealed partial class AppUpdatesController(AppDbContext db) : ControllerBa x.PublishedByUserName, x.PublishedAt)) .ToArrayAsync(cancellationToken); - return Ok(new PagedResult( + var last = keys.Take(pageSize).LastOrDefault(); + return Ok(new CursorPagedResult( rows, - total, - page, + hasMore && last is not null ? CursorToken.Encode(new ReleaseCursor(last.SortAt, last.Id)) : null, + hasMore, pageSize)); } + private sealed record ReleaseCursor(DateTime SortAt, Guid Id); + [Authorize(Roles = SystemRoles.SuperAdmin)] [Consumes("multipart/form-data")] [RequestSizeLimit(MaximumBundleBytes + 1024 * 1024)] diff --git a/src/Jiaowu.Api/Controllers/CourseAdjustmentsController.cs b/src/Jiaowu.Api/Controllers/CourseAdjustmentsController.cs index 69f594c..4c05e1c 100644 --- a/src/Jiaowu.Api/Controllers/CourseAdjustmentsController.cs +++ b/src/Jiaowu.Api/Controllers/CourseAdjustmentsController.cs @@ -177,12 +177,14 @@ public sealed class CourseAdjustmentsController( public async Task GetMine( Guid? academicTermId, CourseAdjustmentStatus? status, - int page = 1, + string? cursor = null, int pageSize = 20, CancellationToken cancellationToken = default) { - if (page < 1 || pageSize is < 1 or > 100) - return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。"); + if (pageSize is < 1 or > 100) + return ValidationProblem("每批条数应在 1 至 100 之间。"); + if (!CursorToken.TryDecode(cursor, out var after)) + return BadRequest("调停课申请游标无效。"); var userId = currentUserDataScope.Current.UserId; var source = db.CourseAdjustments.AsNoTracking() @@ -193,15 +195,27 @@ public sealed class CourseAdjustmentsController( if (status.HasValue) source = source.Where(x => x.Status == status); - var total = await source.CountAsync(cancellationToken); - var items = await source + if (after is not null) + { + source = source.Where(x => x.CreatedAt < after.CreatedAt || + x.CreatedAt == after.CreatedAt && x.Id.CompareTo(after.Id) < 0); + } + var keys = await source .OrderByDescending(x => x.CreatedAt) .ThenByDescending(x => x.Id) - .Skip((page - 1) * pageSize) - .Take(pageSize) - .Select(AdjustmentProjection()) + .Take(pageSize + 1) + .Select(x => new { x.Id, x.CreatedAt }) .ToListAsync(cancellationToken); - return Ok(new PagedResult(items, total, page, pageSize)); + var hasMore = keys.Count > pageSize; + var pageIds = keys.Take(pageSize).Select(x => x.Id).ToArray(); + var items = await source.WhereIn(pageIds, x => x.Id) + .OrderByDescending(x => x.CreatedAt).ThenByDescending(x => x.Id) + .Select(AdjustmentProjection()).ToListAsync(cancellationToken); + var last = keys.Take(pageSize).LastOrDefault(); + return Ok(new CursorPagedResult(items, + hasMore && last is not null + ? CursorToken.Encode(new CreatedCursor(last.CreatedAt, last.Id)) : null, + hasMore, pageSize)); } // ═══════════════ Pending reviews ═══════════════ @@ -210,13 +224,15 @@ public sealed class CourseAdjustmentsController( [Authorize(Roles = Reviewers)] public async Task GetPendingReviews( Guid? academicTermId, - int page = 1, + string? cursor = null, int pageSize = 20, CancellationToken cancellationToken = default, Guid? adjustmentId = null) { - if (page < 1 || pageSize is < 1 or > 100) - return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。"); + if (pageSize is < 1 or > 100) + return ValidationProblem("每批条数应在 1 至 100 之间。"); + if (!CursorToken.TryDecode(cursor, out var after)) + return BadRequest("待审核调停课游标无效。"); var scope = currentUserDataScope.Current; var source = db.CourseAdjustments.AsNoTracking() @@ -230,17 +246,32 @@ public sealed class CourseAdjustmentsController( source = source.Where(x => x.TeachingTask!.AcademicTermId == academicTermId); - var total = await source.CountAsync(cancellationToken); - var items = await source + if (after is not null) + { + source = source.Where(x => x.SubmittedAt!.Value < after.SubmittedAt || + x.SubmittedAt == after.SubmittedAt && x.Id.CompareTo(after.Id) < 0); + } + var keys = await source .OrderByDescending(x => x.SubmittedAt) .ThenByDescending(x => x.Id) - .Skip((page - 1) * pageSize) - .Take(pageSize) - .Select(AdjustmentProjection()) + .Take(pageSize + 1) + .Select(x => new { x.Id, SubmittedAt = x.SubmittedAt!.Value }) .ToListAsync(cancellationToken); - return Ok(new PagedResult(items, total, page, pageSize)); + var hasMore = keys.Count > pageSize; + var pageIds = keys.Take(pageSize).Select(x => x.Id).ToArray(); + var items = await source.WhereIn(pageIds, x => x.Id) + .OrderByDescending(x => x.SubmittedAt).ThenByDescending(x => x.Id) + .Select(AdjustmentProjection()).ToListAsync(cancellationToken); + var last = keys.Take(pageSize).LastOrDefault(); + return Ok(new CursorPagedResult(items, + hasMore && last is not null + ? CursorToken.Encode(new SubmittedCursor(last.SubmittedAt, last.Id)) : null, + hasMore, pageSize)); } + private sealed record CreatedCursor(DateTime CreatedAt, Guid Id); + private sealed record SubmittedCursor(DateTime SubmittedAt, Guid Id); + // ═══════════════ Detail ═══════════════ [HttpGet("{id:guid}")] diff --git a/src/Jiaowu.Api/Controllers/CoursesController.cs b/src/Jiaowu.Api/Controllers/CoursesController.cs index c891f81..639c3d9 100644 --- a/src/Jiaowu.Api/Controllers/CoursesController.cs +++ b/src/Jiaowu.Api/Controllers/CoursesController.cs @@ -25,8 +25,8 @@ public sealed class CoursesController( SystemRoles.CollegeAdmin; [HttpGet] - public async Task>> Get( - int page = 1, + public async Task>> Get( + string? cursor = null, int pageSize = 20, string? keyword = null, Guid? collegeId = null, @@ -35,8 +35,9 @@ public sealed class CoursesController( bool? isEnabled = null, CancellationToken cancellationToken = default) { - page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 10, 100); + if (!CursorToken.TryDecode(cursor, out var after)) + return BadRequest("课程游标无效。"); var scope = currentUserDataScope.Current; var canManageAll = scope.IsInRole(SystemRoles.SuperAdmin) || scope.IsInRole(SystemRoles.AcademicAdmin); @@ -61,11 +62,18 @@ public sealed class CoursesController( } var total = await source.CountAsync(cancellationToken); - var items = await source + if (after is not null) + { + source = source.Where(x => x.SortOrder > after.SortOrder || + x.SortOrder == after.SortOrder && + (x.Code.CompareTo(after.Code) > 0 || + x.Code == after.Code && x.Id.CompareTo(after.Id) > 0)); + } + var rows = await source .OrderBy(x => x.SortOrder) .ThenBy(x => x.Code) - .Skip((page - 1) * pageSize) - .Take(pageSize) + .ThenBy(x => x.Id) + .Take(pageSize + 1) .Select(x => new { x.Id, @@ -106,10 +114,17 @@ public sealed class CoursesController( x.Nature == CourseNature.Practice) }) .ToListAsync(cancellationToken); - - return Ok(new PagedResult(items, total, page, pageSize)); + var hasMore = rows.Count > pageSize; + var items = rows.Take(pageSize).ToArray(); + var last = items.LastOrDefault(); + return Ok(new CursorPagedResult(items.Cast().ToArray(), + hasMore && last is not null + ? CursorToken.Encode(new CourseCursor(last.SortOrder, last.Code, last.Id)) : null, + hasMore, pageSize, total)); } + private sealed record CourseCursor(int SortOrder, string Code, Guid Id); + [HttpGet("options")] public async Task> GetOptions( Guid? collegeId = null, diff --git a/src/Jiaowu.Api/Controllers/CurriculumPlansController.cs b/src/Jiaowu.Api/Controllers/CurriculumPlansController.cs index 3c6db89..fff8d6b 100644 --- a/src/Jiaowu.Api/Controllers/CurriculumPlansController.cs +++ b/src/Jiaowu.Api/Controllers/CurriculumPlansController.cs @@ -24,8 +24,8 @@ public sealed class CurriculumPlansController( SystemRoles.CollegeAdmin; [HttpGet] - public async Task>> GetPlans( - int page = 1, + public async Task>> GetPlans( + string? cursor = null, int pageSize = 20, string? keyword = null, Guid? collegeId = null, @@ -34,8 +34,9 @@ public sealed class CurriculumPlansController( CurriculumPlanStatus? status = null, CancellationToken cancellationToken = default) { - page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 10, 100); + if (!CursorToken.TryDecode(cursor, out var after)) + return BadRequest("培养方案游标无效。"); var source = ScopedPlans().AsNoTracking(); if (collegeId.HasValue) source = source.Where(x => x.Major!.CollegeId == collegeId.Value); @@ -53,11 +54,24 @@ public sealed class CurriculumPlansController( } var total = await source.CountAsync(cancellationToken); - var items = await source + if (after is not null) + { + source = source.Where(x => x.EffectiveGrade < after.EffectiveGrade || + x.EffectiveGrade == after.EffectiveGrade && + (x.CreatedAt < after.CreatedAt || + x.CreatedAt == after.CreatedAt && x.Id.CompareTo(after.Id) < 0)); + } + var keys = await source .OrderByDescending(x => x.EffectiveGrade) .ThenByDescending(x => x.CreatedAt) - .Skip((page - 1) * pageSize) - .Take(pageSize) + .ThenByDescending(x => x.Id) + .Take(pageSize + 1) + .Select(x => new { x.Id, x.EffectiveGrade, x.CreatedAt }) + .ToListAsync(cancellationToken); + var hasMore = keys.Count > pageSize; + var ids = keys.Take(pageSize).Select(x => x.Id).ToArray(); + var items = await source.WhereIn(ids, x => x.Id) + .OrderByDescending(x => x.EffectiveGrade).ThenByDescending(x => x.CreatedAt).ThenByDescending(x => x.Id) .Select(x => new { x.Id, @@ -76,10 +90,14 @@ public sealed class CurriculumPlansController( x.UpdatedAt }) .ToListAsync(cancellationToken); - - return Ok(new PagedResult(items, total, page, pageSize)); + var last = keys.Take(pageSize).LastOrDefault(); + return Ok(new CursorPagedResult(items.Cast().ToArray(), + hasMore && last is not null ? CursorToken.Encode(new PlanCursor(last.EffectiveGrade, last.CreatedAt, last.Id)) : null, + hasMore, pageSize, total)); } + private sealed record PlanCursor(int EffectiveGrade, DateTime CreatedAt, Guid Id); + [HttpGet("filter-options")] public async Task GetFilterOptions(CancellationToken cancellationToken) { diff --git a/src/Jiaowu.Api/Controllers/DegreeAwardsController.cs b/src/Jiaowu.Api/Controllers/DegreeAwardsController.cs index 6184a8d..b31c290 100644 --- a/src/Jiaowu.Api/Controllers/DegreeAwardsController.cs +++ b/src/Jiaowu.Api/Controllers/DegreeAwardsController.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Jiaowu.Api.Contracts; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; @@ -62,14 +63,15 @@ public sealed class DegreeAwardsController( [Authorize(Roles = Reviewers)] public async Task GetBatch( Guid id, - [FromQuery] int page = 1, + [FromQuery] string? cursor = null, [FromQuery] int pageSize = 20, [FromQuery] string? keyword = null, [FromQuery] DegreeAwardConclusion? conclusion = null, CancellationToken token = default) { - page = Math.Max(page, 1); pageSize = Math.Clamp(pageSize, 10, 100); + if (!CursorToken.TryDecode(cursor, out var after)) + return BadRequest("学位授予结果游标无效。"); var batch = await db.DegreeAwardBatches.AsNoTracking() .FirstOrDefaultAsync(x => x.Id == id, token); if (batch is null) return NotFound(); @@ -104,9 +106,13 @@ public sealed class DegreeAwardsController( source = source.Where(x => x.Conclusion == conclusion.Value); var total = await source.CountAsync(token); - var items = await source.OrderBy(x => x.Student!.StudentNumber) - .Skip((page - 1) * pageSize) - .Take(pageSize) + if (after is not null) + { + source = source.Where(x => x.Student!.StudentNumber.CompareTo(after.StudentNumber) > 0 || + x.Student.StudentNumber == after.StudentNumber && x.Id.CompareTo(after.Id) > 0); + } + var rows = await source.OrderBy(x => x.Student!.StudentNumber).ThenBy(x => x.Id) + .Take(pageSize + 1) .Select(x => new { x.Id, @@ -124,6 +130,9 @@ public sealed class DegreeAwardsController( x.ReviewComment, x.ReviewedAt }).ToListAsync(token); + var hasMore = rows.Count > pageSize; + var items = rows.Take(pageSize).ToArray(); + var last = items.LastOrDefault(); return Ok(new { @@ -144,12 +153,16 @@ public sealed class DegreeAwardsController( { Items = items, Total = total, - Page = page, + HasMore = hasMore, + NextCursor = hasMore && last is not null + ? CursorToken.Encode(new DegreeResultCursor(last.StudentNumber, last.Id)) : null, PageSize = pageSize } }); } + private sealed record DegreeResultCursor(string StudentNumber, Guid Id); + [HttpPost("batches")] [Authorize(Roles = Managers)] public async Task Create( diff --git a/src/Jiaowu.Api/Controllers/GraduationClearanceController.cs b/src/Jiaowu.Api/Controllers/GraduationClearanceController.cs index ec87dda..869a7d0 100644 --- a/src/Jiaowu.Api/Controllers/GraduationClearanceController.cs +++ b/src/Jiaowu.Api/Controllers/GraduationClearanceController.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Jiaowu.Api.Contracts; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; @@ -62,14 +63,15 @@ public sealed class GraduationClearanceController( [Authorize(Roles = Workers)] public async Task GetBatch( Guid id, - [FromQuery] int page = 1, + [FromQuery] string? cursor = null, [FromQuery] int pageSize = 20, [FromQuery] string? keyword = null, [FromQuery] string? progress = null, CancellationToken token = default) { - page = Math.Max(page, 1); pageSize = Math.Clamp(pageSize, 10, 100); + if (!CursorToken.TryDecode(cursor, out var after)) + return BadRequest("无效的游标。"); var normalizedProgress = progress?.Trim(); if (normalizedProgress is not null and not "Pending" and not "Completed") return ValidationProblem("离校办理进度筛选值无效。"); @@ -122,10 +124,12 @@ public sealed class GraduationClearanceController( record.Status == GraduationClearanceRecordStatus.Pending)); var total = await students.CountAsync(token); - var studentPage = await students - .OrderBy(student => student.StudentNumber) - .Skip((page - 1) * pageSize) - .Take(pageSize) + if (after is not null) + students = students.Where(student => string.Compare(student.StudentNumber, after.StudentNumber) > 0 || + (student.StudentNumber == after.StudentNumber && student.Id.CompareTo(after.Id) > 0)); + var studentRows = await students + .OrderBy(student => student.StudentNumber).ThenBy(student => student.Id) + .Take(pageSize + 1) .Select(student => new { student.Id, @@ -134,6 +138,8 @@ public sealed class GraduationClearanceController( ClassName = student.AdministrativeClass!.Name, MajorName = student.AdministrativeClass.Major!.Name }).ToListAsync(token); + var hasMore = studentRows.Count > pageSize; + var studentPage = studentRows.Take(pageSize).ToArray(); var studentIds = studentPage.Select(student => student.Id).ToArray(); var pageRecords = await records .WhereIn(studentIds, x => x.StudentId) @@ -175,8 +181,10 @@ public sealed class GraduationClearanceController( { Items = studentItems, Total = total, - Page = page, - PageSize = pageSize + PageSize = pageSize, + HasMore = hasMore, + NextCursor = hasMore && studentPage.LastOrDefault() is { } last + ? CursorToken.Encode(new GraduationClearanceStudentCursor(last.StudentNumber, last.Id)) : null } }); } @@ -391,6 +399,8 @@ public sealed record GraduationClearanceBatchRequest( [MaxLength(500)] string? Notes, IReadOnlyList Items); +public sealed record GraduationClearanceStudentCursor(string StudentNumber, Guid Id); + public sealed record GraduationClearanceItemRequest( [Required, MinLength(2), MaxLength(30)] string Code, [Required, MinLength(2), MaxLength(100)] string Name, diff --git a/src/Jiaowu.Api/Controllers/OtherExamsController.cs b/src/Jiaowu.Api/Controllers/OtherExamsController.cs index a549d2f..9f257fd 100644 --- a/src/Jiaowu.Api/Controllers/OtherExamsController.cs +++ b/src/Jiaowu.Api/Controllers/OtherExamsController.cs @@ -28,12 +28,12 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope OtherExamMetricKind? metricKind = null, DateOnly? examDateFrom = null, DateOnly? examDateTo = null, - int page = 1, + string? cursor = null, int pageSize = 20, CancellationToken ct = default) { - page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 10, 100); + if (!CursorToken.TryDecode(cursor, out var after)) return BadRequest("无效的游标。"); var source = db.OtherExamBatches.AsNoTracking(); if (!string.IsNullOrWhiteSpace(keyword)) { @@ -48,11 +48,22 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope 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 }) + if (after is not null) + { + source = source.Where(x => x.ExamDate < after.ExamDate || + (x.ExamDate == after.ExamDate && (x.CreatedAt < after.CreatedAt || + (x.CreatedAt == after.CreatedAt && x.Id.CompareTo(after.Id) < 0)))); + } + var rows = await source.OrderByDescending(x => x.ExamDate).ThenByDescending(x => x.CreatedAt).ThenByDescending(x => x.Id) + .Take(pageSize + 1) + .Select(x => new { x.Id, x.ExamCode, x.Name, x.Organizer, x.ExamDate, x.CreatedAt, x.MetricKind, x.MaxScore, x.LevelOptions, x.Status, x.PublicationCount, x.PublishedAt, ResultCount = x.Results.Count }) .ToListAsync(ct); - return Ok(new PagedResult(items, total, page, pageSize)); + var hasMore = rows.Count > pageSize; + var items = rows.Take(pageSize).ToArray(); + var last = items.LastOrDefault(); + return Ok(new CursorPagedResult(items, hasMore && last is not null + ? CursorToken.Encode(new OtherExamBatchCursor(last.ExamDate, last.CreatedAt, last.Id)) : null, + hasMore, pageSize, total)); } [HttpPost("batches")] @@ -275,3 +286,4 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope public sealed record CreateOtherExamRequest([Required] string ExamCode, [Required] string Name, DateOnly ExamDate, OtherExamMetricKind MetricKind, decimal? MaxScore, string? LevelOptions, string? Organizer); public sealed record ReplaceOtherExamResultsRequest(List Results); public sealed record OtherExamResultRequest([Required] string StudentNumber, decimal? Score, string? Level, bool? IsPassed, string? Notes); +public sealed record OtherExamBatchCursor(DateOnly ExamDate, DateTime CreatedAt, Guid Id); diff --git a/src/Jiaowu.Api/Controllers/TeachingTasksController.cs b/src/Jiaowu.Api/Controllers/TeachingTasksController.cs index 39854a3..9c16e52 100644 --- a/src/Jiaowu.Api/Controllers/TeachingTasksController.cs +++ b/src/Jiaowu.Api/Controllers/TeachingTasksController.cs @@ -30,8 +30,8 @@ public sealed class TeachingTasksController( SystemRoles.AcademicAdmin; [HttpGet] - public async Task>> Get( - int page = 1, + public async Task>> Get( + string? cursor = null, int pageSize = 20, string? keyword = null, Guid? academicTermId = null, @@ -41,8 +41,9 @@ public sealed class TeachingTasksController( string? teacherKeyword = null, string? classKeyword = null) { - page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 10, 100); + if (!CursorToken.TryDecode(cursor, out var after)) + return BadRequest("教学任务游标无效。"); var source = ScopedTasks().AsNoTracking(); if (academicTermId.HasValue) source = source.Where(x => x.AcademicTermId == academicTermId); @@ -75,11 +76,18 @@ public sealed class TeachingTasksController( } var total = await source.CountAsync(cancellationToken); - var items = await source + if (after is not null) + { + source = source.Where(x => x.AcademicTerm!.StartDate < after.TermStartDate || + x.AcademicTerm.StartDate == after.TermStartDate && + (x.TaskNumber.CompareTo(after.TaskNumber) > 0 || + x.TaskNumber == after.TaskNumber && x.Id.CompareTo(after.Id) > 0)); + } + var rows = await source .OrderByDescending(x => x.AcademicTerm!.StartDate) .ThenBy(x => x.TaskNumber) - .Skip((page - 1) * pageSize) - .Take(pageSize) + .ThenBy(x => x.Id) + .Take(pageSize + 1) .Select(x => new { x.Id, @@ -87,6 +95,7 @@ public sealed class TeachingTasksController( x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name, + TermStartDate = x.AcademicTerm.StartDate, x.CourseId, CourseCode = x.Course!.Code, CourseName = x.Course.Name, @@ -114,9 +123,17 @@ public sealed class TeachingTasksController( x.UpdatedAt }) .ToListAsync(cancellationToken); - return Ok(new PagedResult(items, total, page, pageSize)); + var hasMore = rows.Count > pageSize; + var items = rows.Take(pageSize).ToArray(); + var last = items.LastOrDefault(); + return Ok(new CursorPagedResult(items.Cast().ToArray(), + hasMore && last is not null + ? CursorToken.Encode(new TaskCursor(last.TermStartDate, last.TaskNumber, last.Id)) : null, + hasMore, pageSize, total)); } + private sealed record TaskCursor(DateOnly TermStartDate, string TaskNumber, Guid Id); + [HttpGet("options")] public async Task GetOptions( Guid academicTermId, diff --git a/web/src/components/AppUpdateManagementPanel.vue b/web/src/components/AppUpdateManagementPanel.vue index e6b7704..b716f39 100644 --- a/web/src/components/AppUpdateManagementPanel.vue +++ b/web/src/components/AppUpdateManagementPanel.vue @@ -28,15 +28,17 @@ interface AppUpdateRelease { interface PageResult { items: T[] - total: number - page: number + nextCursor?: string + hasMore: boolean pageSize: number } const loading = ref(false) const uploadBusy = ref(false) const rows = ref([]) -const total = ref(0) +const cursors = ref([]) +const nextCursor = ref() +const hasMore = ref(false) const uploadVisible = ref(false) const selectedBundle = ref() const uploadFiles = ref([]) @@ -92,8 +94,8 @@ function statusType(value: UpdateStatus) { async function loadReleases() { loading.value = true try { - const params: Record = { - page: filter.page, + const params: Record = { + cursor: cursors.value.at(-1), pageSize: filter.pageSize, } if (filter.platform) params.platform = filter.platform @@ -103,7 +105,8 @@ async function loadReleases() { { params }, ) rows.value = data.items - total.value = data.total + hasMore.value = data.hasMore + nextCursor.value = data.nextCursor } catch (error) { ElMessage.error(apiErrorMessage(error)) } finally { @@ -113,9 +116,25 @@ async function loadReleases() { function search() { filter.page = 1 + cursors.value = [] + nextCursor.value = undefined loadReleases() } +async function nextPage() { + if (!nextCursor.value || loading.value) return + cursors.value.push(nextCursor.value) + filter.page++ + await loadReleases() +} + +async function previousPage() { + if (!cursors.value.length || loading.value) return + cursors.value.pop() + filter.page-- + await loadReleases() +} + function openUpload() { Object.assign(form, { platform: 'Android', @@ -316,14 +335,11 @@ onMounted(loadReleases) - +
+ 上一批 + 第 {{ filter.page }} 批 + 下一批 +
() const tab = ref(isManager.value ? 'reviews' : 'mine') const minePage = ref(1) -const mineTotal = ref(0) +const mineCursors = ref([]) +const nextMineCursor = ref() +const mineHasMore = ref(false) const reviewPage = ref(1) -const reviewTotal = ref(0) +const reviewCursors = ref([]) +const nextReviewCursor = ref() +const reviewHasMore = ref(false) const pageSize = 20 const targetAdjustmentId = computed(() => { const value = route.query.adjustmentId @@ -95,33 +99,67 @@ async function load(resetPages = false) { if (resetPages) { minePage.value = 1 reviewPage.value = 1 + mineCursors.value = [] + reviewCursors.value = [] + nextMineCursor.value = undefined + nextReviewCursor.value = undefined } loading.value = true reviewLoading.value = isManager.value try { if (isTeacher.value || (!isManager.value)) { const response = await http.get('/course-adjustments/mine', { - params: { academicTermId: termId.value, page: minePage.value, pageSize }, + params: { academicTermId: termId.value, cursor: mineCursors.value.at(-1), pageSize }, }) adjustments.value = response.data.items - mineTotal.value = response.data.total + mineHasMore.value = response.data.hasMore + nextMineCursor.value = response.data.nextCursor } if (isManager.value) { const response = await http.get('/course-adjustments/pending-reviews', { params: { academicTermId: targetAdjustmentId.value ? undefined : termId.value, adjustmentId: targetAdjustmentId.value, - page: reviewPage.value, + cursor: reviewCursors.value.at(-1), pageSize, }, }) pendingReviews.value = response.data.items - reviewTotal.value = response.data.total + reviewHasMore.value = response.data.hasMore + nextReviewCursor.value = response.data.nextCursor } } catch (e) { ElMessage.error(apiErrorMessage(e)) } finally { loading.value = false; reviewLoading.value = false } } +async function nextMinePage() { + if (!nextMineCursor.value || loading.value) return + mineCursors.value.push(nextMineCursor.value) + minePage.value++ + await load() +} + +async function previousMinePage() { + if (!mineCursors.value.length || loading.value) return + mineCursors.value.pop() + minePage.value-- + await load() +} + +async function nextReviewPage() { + if (!nextReviewCursor.value || reviewLoading.value) return + reviewCursors.value.push(nextReviewCursor.value) + reviewPage.value++ + await load() +} + +async function previousReviewPage() { + if (!reviewCursors.value.length || reviewLoading.value) return + reviewCursors.value.pop() + reviewPage.value-- + await load() +} + function openCreate() { Object.assign(form, { teachingTaskId: undefined, @@ -346,15 +384,11 @@ function showSub(type: string) { return type === 'Substitute' } - +
+ 上一批 + 第 {{ minePage }} 批 + 下一批 +
@@ -393,15 +427,11 @@ function showSub(type: string) { return type === 'Substitute' } - +
+ 上一批 + 第 {{ reviewPage }} 批 + 下一批 +
diff --git a/web/src/views/CoursesView.vue b/web/src/views/CoursesView.vue index 516d703..30d422c 100644 --- a/web/src/views/CoursesView.vue +++ b/web/src/views/CoursesView.vue @@ -9,6 +9,9 @@ const auth = useAuthStore() const loading = ref(false) const rows = ref([]) const total = ref(0) +const cursors = ref([]) +const nextCursor = ref() +const hasMore = ref(false) const colleges = ref([]) const categories = ref([]) const courseOptions = ref([]) @@ -100,9 +103,9 @@ function resetForm(row?: any) { async function load() { loading.value = true try { - const { data } = await http.get('/courses', { + const response = await http.get('/courses', { params: { - page: query.page, + cursor: cursors.value.at(-1), pageSize: query.pageSize, keyword: query.keyword || undefined, collegeId: query.collegeId, @@ -111,8 +114,11 @@ async function load() { isEnabled: query.isEnabled, }, }) + const { data } = response rows.value = data.items - total.value = data.total + total.value = Number(data.total ?? 0) + hasMore.value = data.hasMore + nextCursor.value = data.nextCursor } catch (error) { ElMessage.error(apiErrorMessage(error)) } finally { @@ -120,6 +126,26 @@ async function load() { } } +async function nextPage() { + if (!nextCursor.value || loading.value) return + cursors.value.push(nextCursor.value) + query.page++ + await load() +} + +async function previousPage() { + if (!cursors.value.length || loading.value) return + cursors.value.pop() + query.page-- + await load() +} + +function resetCursor() { + query.page = 1 + cursors.value = [] + nextCursor.value = undefined +} + async function resetFilters() { Object.assign(query, { page: 1, @@ -129,6 +155,7 @@ async function resetFilters() { nature: undefined, isEnabled: undefined, }) + resetCursor() await load() } @@ -177,7 +204,7 @@ async function handleImport(event: Event) { ElMessage.success( `导入完成:新增 ${data.created} 门,更新 ${data.updated} 门,共处理 ${data.total} 门。`, ) - query.page = 1 + resetCursor() await load() courseOptions.value = (await http.get('/courses/options')).data } catch (error) { @@ -284,7 +311,7 @@ onMounted(async () => { :prefix-icon="Search" clearable placeholder="搜索课程编码、中英文名称" - @keyup.enter="query.page = 1; load()" + @keyup.enter="resetCursor(); load()" /> { - 查询 + 查询 重置
@@ -378,13 +405,11 @@ onMounted(async () => {
共 {{ total }} 门 - +
+ 上一批 + 第 {{ query.page }} 批 + 下一批 +
diff --git a/web/src/views/CurriculumView.vue b/web/src/views/CurriculumView.vue index 828ab77..9019324 100644 --- a/web/src/views/CurriculumView.vue +++ b/web/src/views/CurriculumView.vue @@ -9,6 +9,9 @@ const loading = ref(false) const detailLoading = ref(false) const plans = ref([]) const total = ref(0) +const cursors = ref([]) +const nextCursor = ref() +const hasMore = ref(false) const selected = ref(null) const colleges = ref([]) const majors = ref([]) @@ -126,11 +129,15 @@ async function loadPlans(keepSelection = true) { const { data } = await http.get('/curriculum-plans', { params: { ...query, + page: undefined, + cursor: cursors.value.at(-1), keyword: query.keyword || undefined, }, }) plans.value = data.items total.value = data.total + hasMore.value = data.hasMore + nextCursor.value = data.nextCursor const targetId = keepSelection && selected.value ? selected.value.id : plans.value[0]?.id @@ -146,6 +153,26 @@ async function loadPlans(keepSelection = true) { } } +async function nextPage() { + if (!nextCursor.value || loading.value) return + cursors.value.push(nextCursor.value) + query.page++ + await loadPlans(false) +} + +async function previousPage() { + if (!cursors.value.length || loading.value) return + cursors.value.pop() + query.page-- + await loadPlans(false) +} + +function resetCursor() { + query.page = 1 + cursors.value = [] + nextCursor.value = undefined +} + async function loadDetail(id: string) { detailLoading.value = true try { @@ -257,6 +284,7 @@ function resetFilters() { effectiveGrade: undefined, status: undefined, }) + resetCursor() loadPlans(false) } @@ -507,7 +535,7 @@ onMounted(async () => {
- + { - 查询 + 查询 重置
@@ -551,7 +579,7 @@ onMounted(async () => {
共 {{ total }} 版 - +
上一批第 {{ query.page }} 批下一批
diff --git a/web/src/views/DegreeAwardsView.vue b/web/src/views/DegreeAwardsView.vue index c2c089e..cce72c5 100644 --- a/web/src/views/DegreeAwardsView.vue +++ b/web/src/views/DegreeAwardsView.vue @@ -18,6 +18,9 @@ const reviewTarget = ref(null) const keyword = ref('') const conclusionFilter = ref('') const resultPage = ref(1) +const resultCursors = ref([]) +const nextResultCursor = ref() +const resultHasMore = ref(false) const resultPageSize = 20 const resultRequestId = ref(0) let filterTimer: ReturnType | undefined @@ -61,6 +64,8 @@ async function load() { } async function selectBatch(id: string) { resultPage.value = 1 + resultCursors.value = [] + nextResultCursor.value = undefined await loadBatch(id) } async function loadBatch(id = selected.value?.id) { @@ -70,19 +75,26 @@ async function loadBatch(id = selected.value?.id) { try { const response = await http.get(`/degree-awards/batches/${id}`, { params: { - page: resultPage.value, + cursor: resultCursors.value.at(-1), pageSize: resultPageSize, keyword: keyword.value.trim() || undefined, conclusion: conclusionFilter.value || undefined, }, }) if (requestId === resultRequestId.value) selected.value = response.data + if (requestId === resultRequestId.value) { + resultHasMore.value = response.data.results.hasMore + nextResultCursor.value = response.data.results.nextCursor + } } catch (error) { if (requestId === resultRequestId.value) ElMessage.error(apiErrorMessage(error)) } finally { if (requestId === resultRequestId.value) loading.value = false } } + +async function nextResultPage() { if (nextResultCursor.value && !loading.value) { resultCursors.value.push(nextResultCursor.value); resultPage.value++; await loadBatch() } } +async function previousResultPage() { if (resultCursors.value.length && !loading.value) { resultCursors.value.pop(); resultPage.value--; await loadBatch() } } async function createBatch() { try { const response = await http.post('/degree-awards/batches', batchForm) @@ -140,6 +152,8 @@ async function publish() { watch([keyword, conclusionFilter], () => { resultPage.value = 1 + resultCursors.value = [] + nextResultCursor.value = undefined if (filterTimer) clearTimeout(filterTimer) filterTimer = setTimeout(() => loadBatch(), 300) }) @@ -211,14 +225,7 @@ onMounted(load) 人工复核 - +
上一批第 {{ resultPage }} 批下一批
diff --git a/web/src/views/GraduationClearanceView.vue b/web/src/views/GraduationClearanceView.vue index 0f19951..0133db9 100644 --- a/web/src/views/GraduationClearanceView.vue +++ b/web/src/views/GraduationClearanceView.vue @@ -20,6 +20,9 @@ const keyword = ref('') const progressFilter = ref('') const resultPage = ref(1) const resultPageSize = 20 +const resultCursors = ref([]) +const nextResultCursor = ref() +const resultHasMore = ref(false) const resultRequestId = ref(0) let filterTimer: ReturnType | undefined const batchForm = reactive({ @@ -80,6 +83,8 @@ async function load() { } async function selectBatch(id: string) { resultPage.value = 1 + resultCursors.value = [] + nextResultCursor.value = undefined await loadBatch(id) } async function loadBatch(id = selected.value?.id) { @@ -89,19 +94,35 @@ async function loadBatch(id = selected.value?.id) { try { const response = await http.get(`/graduation-clearance/batches/${id}`, { params: { - page: resultPage.value, + cursor: resultCursors.value.at(-1), pageSize: resultPageSize, keyword: keyword.value.trim() || undefined, progress: progressFilter.value || undefined, }, }) - if (requestId === resultRequestId.value) selected.value = response.data + if (requestId === resultRequestId.value) { + selected.value = response.data + resultHasMore.value = response.data.students.hasMore + nextResultCursor.value = response.data.students.nextCursor + } } catch (error) { if (requestId === resultRequestId.value) ElMessage.error(apiErrorMessage(error)) } finally { if (requestId === resultRequestId.value) loading.value = false } } +async function nextResultPage() { + if (!nextResultCursor.value || loading.value) return + resultCursors.value.push(nextResultCursor.value) + resultPage.value += 1 + await loadBatch() +} +async function previousResultPage() { + if (!resultCursors.value.length || loading.value) return + resultCursors.value.pop() + resultPage.value -= 1 + await loadBatch() +} function addItem() { batchForm.items.push({ code: '', name: '', responsibleUnit: '', @@ -167,6 +188,8 @@ async function closeBatch() { watch([keyword, progressFilter], () => { resultPage.value = 1 + resultCursors.value = [] + nextResultCursor.value = undefined if (filterTimer) clearTimeout(filterTimer) filterTimer = setTimeout(() => loadBatch(), 300) }) @@ -250,14 +273,11 @@ onMounted(load) - +
+ 上一批 + 第 {{ resultPage }} 批,共 {{ studentTotal }} 人 + 下一批 +
diff --git a/web/src/views/OtherExamsView.vue b/web/src/views/OtherExamsView.vue index 1025af6..8797095 100644 --- a/web/src/views/OtherExamsView.vue +++ b/web/src/views/OtherExamsView.vue @@ -18,6 +18,9 @@ const batchDateRange = ref([]) const batchPage = ref(1) const batchPageSize = 20 const batchTotal = ref(0) +const batchCursors = ref([]) +const nextBatchCursor = ref() +const batchHasMore = ref(false) const selected = ref(null) const results = ref([]) const mine = reactive({ best: [] as any[], history: [] as any[] }) @@ -35,7 +38,11 @@ async function load(resetPage = false) { try { if (isStudent.value) Object.assign(mine, (await http.get('/other-exams/mine')).data) else { - if (resetPage) batchPage.value = 1 + if (resetPage) { + batchPage.value = 1 + batchCursors.value = [] + nextBatchCursor.value = undefined + } const response = (await http.get('/other-exams/batches', { params: { keyword: batchKeyword.value.trim() || undefined, @@ -43,15 +50,29 @@ async function load(resetPage = false) { metricKind: batchMetricKind.value, examDateFrom: batchDateRange.value[0] || undefined, examDateTo: batchDateRange.value[1] || undefined, - page: batchPage.value, + cursor: batchCursors.value.at(-1), pageSize: batchPageSize, }, })).data batches.value = response.items batchTotal.value = response.total + batchHasMore.value = response.hasMore + nextBatchCursor.value = response.nextCursor } } catch (error) { ElMessage.error(apiErrorMessage(error)) } finally { loading.value = false } } +async function nextBatchPage() { + if (!nextBatchCursor.value || loading.value) return + batchCursors.value.push(nextBatchCursor.value) + batchPage.value += 1 + await load() +} +async function previousBatchPage() { + if (!batchCursors.value.length || loading.value) return + batchCursors.value.pop() + batchPage.value -= 1 + await load() +} async function openBatch(row: any) { try { const data = (await http.get(`/other-exams/batches/${row.id}`)).data @@ -121,7 +142,7 @@ onMounted(load)
历史记录

历史成绩

每次发布的结果都会保留
@@ -135,7 +156,7 @@ onMounted(load) .kicker { color:var(--teal); font-size:11px; letter-spacing:.17em; font-weight:800; }.exam-hero h1,.board-title h2,.panel-heading h2,.editor-heading h2 { color:var(--ink); margin:7px 0; letter-spacing:-.025em; }.exam-hero h1 { font-size:32px; }.exam-hero p,.editor-heading p { color:var(--muted); margin:0; line-height:1.7; }.result-board,.batch-panel,.editor-panel { background:#fff; border:1px solid var(--line); border-radius:14px; box-shadow:0 12px 30px rgba(32,61,73,.06); margin-bottom:18px; overflow:hidden; }.board-title,.panel-heading { display:flex; justify-content:space-between; align-items:center; padding:20px 22px 14px; }.board-title h2,.panel-heading h2,.editor-heading h2 { font-size:20px; }.board-note,.panel-heading>span { color:var(--muted); font-size:13px; }.history-board { opacity:.96; }.best-value { color:var(--navy); font-variant-numeric:tabular-nums; }.editor-heading { display:flex; justify-content:space-between; gap:20px; align-items:center; padding:20px 22px 14px; }.editor-actions { display:flex; flex-wrap:wrap; gap:8px; justify-content:flex-end; }.upload-button { display:inline-flex; align-items:center; gap:5px; border:1px solid #dcdfe6; border-radius:4px; padding:8px 14px; color:#606266; cursor:pointer; font-size:14px; }.upload-button:hover { color:var(--navy); border-color:var(--navy); }.upload-button input { display:none; }.editor-hint { margin:0 22px 14px; padding:11px 14px; border-left:3px solid #d4a72c; background:#fff9e8; color:#786223; font-size:13px; }.auto-attempt { display:inline-flex; align-items:center; padding:4px 8px; border-radius:20px; background:#edf6f5; color:var(--teal); font-size:12px; }.unresolved { color:#c27b18; }.score-table :deep(.el-input-number) { width:125px; }.score-table :deep(.el-table__cell) { padding:12px 0; } @media (max-width:760px) { .exam-hero,.editor-heading,.board-title,.panel-heading { display:block; }.exam-hero .el-button { margin-top:16px; }.editor-actions { justify-content:flex-start; margin-top:16px; }.board-note,.panel-heading>span { display:block; margin-top:5px; }.editor-hint { margin-left:14px; margin-right:14px; } } .editor-heading > div:first-child { min-width: 0; } -.batch-filters { display:flex; flex-wrap:wrap; gap:8px; padding:0 22px 16px; }.batch-filters .el-input { max-width:280px; }.batch-filters .el-select { width:120px; }.batch-panel :deep(.el-pagination) { justify-content:flex-end; padding:14px 22px; } +.batch-filters { display:flex; flex-wrap:wrap; gap:8px; padding:0 22px 16px; }.batch-filters .el-input { max-width:280px; }.batch-filters .el-select { width:120px; }.cursor-pager { display:flex; justify-content:flex-end; align-items:center; gap:8px; padding:14px 22px; color:var(--muted); font-size:13px; } .editor-actions { flex: 0 0 auto; } .upload-button { flex: 0 0 auto; min-width: 112px; white-space: nowrap; justify-content: center; line-height: 1.4; } .publish-confirm { color:var(--ink); line-height:1.7; }.publish-confirm p { margin:8px 0 0; color:var(--muted); font-size:13px; } diff --git a/web/src/views/TeachingTasksView.vue b/web/src/views/TeachingTasksView.vue index 52a1213..3687bb2 100644 --- a/web/src/views/TeachingTasksView.vue +++ b/web/src/views/TeachingTasksView.vue @@ -13,6 +13,9 @@ const canGeneratePublic = computed(() => isSuperAdmin.value || isAcademicAdmin.v const loading = ref(false) const rows = ref([]) const total = ref(0) +const cursors = ref([]) +const nextCursor = ref() +const hasMore = ref(false) const dialogVisible = ref(false) const generationDialog = ref(false) const generating = ref(false) @@ -269,6 +272,8 @@ async function load() { const { data } = await http.get('/teaching-tasks', { params: { ...query, + page: undefined, + cursor: cursors.value.at(-1), keyword: query.keyword || undefined, teacherKeyword: query.teacherKeyword.trim() || undefined, classKeyword: query.classKeyword.trim() || undefined, @@ -276,6 +281,8 @@ async function load() { }) rows.value = data.items total.value = data.total + hasMore.value = data.hasMore + nextCursor.value = data.nextCursor selectedRows.value = [] } catch (error) { ElMessage.error(apiErrorMessage(error)) @@ -284,6 +291,10 @@ async function load() { } } +async function nextPage() { if (nextCursor.value && !loading.value) { cursors.value.push(nextCursor.value); query.page++; await load() } } +async function previousPage() { if (cursors.value.length && !loading.value) { cursors.value.pop(); query.page--; await load() } } +function resetCursor() { query.page = 1; cursors.value = []; nextCursor.value = undefined } + function resetFilters() { Object.assign(query, { page: 1, @@ -294,6 +305,7 @@ function resetFilters() { teacherKeyword: '', classKeyword: '', }) + resetCursor() load() } @@ -616,7 +628,7 @@ onMounted(async () => {
- + @@ -626,9 +638,9 @@ onMounted(async () => { - - - 查询 + + + 查询 重置
@@ -723,7 +735,7 @@ onMounted(async () => {
共 {{ total }} 个教学班 - +
上一批第 {{ query.page }} 批下一批