From bdb2e5452177eb60588d36cef970097efab53159 Mon Sep 17 00:00:00 2001 From: biss Date: Sun, 23 Aug 2026 09:11:48 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B8=B8=E6=A0=87=E5=88=86=E9=A1=B5=E5=88=9D?= =?UTF-8?q?=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Jiaowu.Api/Contracts/CursorToken.cs | 29 ++++++++++++ src/Jiaowu.Api/Contracts/PagedResult.cs | 14 ++++++ .../Controllers/NotificationsController.cs | 29 +++++++++--- web/src/services/nativeHome.ts | 2 +- web/src/views/NotificationCenterView.vue | 45 ++++++++++++++----- 5 files changed, 99 insertions(+), 20 deletions(-) create mode 100644 src/Jiaowu.Api/Contracts/CursorToken.cs diff --git a/src/Jiaowu.Api/Contracts/CursorToken.cs b/src/Jiaowu.Api/Contracts/CursorToken.cs new file mode 100644 index 0000000..d09d926 --- /dev/null +++ b/src/Jiaowu.Api/Contracts/CursorToken.cs @@ -0,0 +1,29 @@ +using System.Text; +using System.Text.Json; + +namespace Jiaowu.Api.Contracts; + +/// Encodes endpoint-specific keyset values without exposing a query offset. +public static class CursorToken +{ + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web); + + public static string Encode(T value) => + Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(value, Options))) + .TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + public static bool TryDecode(string? token, out T? value) + { + value = default; + if (string.IsNullOrWhiteSpace(token)) return true; + try + { + var normalized = token.Replace('-', '+').Replace('_', '/'); + normalized = normalized.PadRight(normalized.Length + (4 - normalized.Length % 4) % 4, '='); + value = JsonSerializer.Deserialize(Encoding.UTF8.GetString(Convert.FromBase64String(normalized)), Options); + return value is not null; + } + catch (JsonException) { return false; } + catch (FormatException) { return false; } + } +} diff --git a/src/Jiaowu.Api/Contracts/PagedResult.cs b/src/Jiaowu.Api/Contracts/PagedResult.cs index e1998d8..a0514bf 100644 --- a/src/Jiaowu.Api/Contracts/PagedResult.cs +++ b/src/Jiaowu.Api/Contracts/PagedResult.cs @@ -1,5 +1,19 @@ namespace Jiaowu.Api.Contracts; +/// +/// A forward-only keyset page. is opaque to clients and +/// must be sent back unchanged as the cursor query parameter. +/// +public sealed record CursorPagedResult( + IReadOnlyCollection Items, + string? NextCursor, + bool HasMore, + int PageSize); + +/// +/// Legacy offset-page response. New list endpoints must use +/// instead. +/// public sealed record PagedResult( IReadOnlyCollection Items, int Total, diff --git a/src/Jiaowu.Api/Controllers/NotificationsController.cs b/src/Jiaowu.Api/Controllers/NotificationsController.cs index 1f7c763..cbef664 100644 --- a/src/Jiaowu.Api/Controllers/NotificationsController.cs +++ b/src/Jiaowu.Api/Controllers/NotificationsController.cs @@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations; using System.Data; using System.Net; using System.Text.RegularExpressions; +using Jiaowu.Api.Contracts; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; @@ -34,12 +35,13 @@ public sealed class NotificationsController( bool? unreadOnly, NotificationCategory? category, string? keyword, - int page = 1, + string? cursor = null, int pageSize = 20, CancellationToken cancellationToken = default) { - page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 1, 100); + if (!CursorToken.TryDecode(cursor, out var after)) + return BadRequest("通知游标无效。"); var userId = currentUserDataScope.Current.UserId; var source = db.Notifications.AsNoTracking() .Where(x => x.UserId == userId); @@ -55,12 +57,16 @@ public sealed class NotificationsController( x.Content.Contains(value)); } - 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 rows = await source .OrderByDescending(x => x.CreatedAt) .ThenByDescending(x => x.Id) - .Skip((page - 1) * pageSize) - .Take(pageSize) + .Take(pageSize + 1) .Select(x => new { x.Id, @@ -79,6 +85,9 @@ public sealed class NotificationsController( x.CreatedAt }) .ToListAsync(cancellationToken); + var hasMore = rows.Count > pageSize; + var items = rows.Take(pageSize).ToArray(); + var last = items.LastOrDefault(); var unreadCount = await db.Notifications .CountAsync(x => x.UserId == userId && !x.IsRead, cancellationToken); @@ -91,12 +100,18 @@ public sealed class NotificationsController( return Ok(new { Items = items, - Total = total, + HasMore = hasMore, + NextCursor = hasMore && last is not null + ? CursorToken.Encode(new NotificationCursor(last.CreatedAt, last.Id)) + : null, + PageSize = pageSize, UnreadCount = unreadCount, UnreadByCategory = unreadByCategory }); } + private sealed record NotificationCursor(DateTime CreatedAt, Guid Id); + [HttpGet("unread-count")] public async Task GetUnreadCount(CancellationToken cancellationToken) { diff --git a/web/src/services/nativeHome.ts b/web/src/services/nativeHome.ts index 79a36f9..8f45ef8 100644 --- a/web/src/services/nativeHome.ts +++ b/web/src/services/nativeHome.ts @@ -180,7 +180,7 @@ export async function syncNativeWidgets(force = false) { const [timetableResult, examResult, notificationResult, attendanceResult] = await Promise.allSettled([ http.get('/timetables/mine'), http.get('/exams/my-schedule'), - http.get('/notifications', { params: { page: 1, pageSize: 3, unreadOnly: true } }), + http.get('/notifications', { params: { pageSize: 3, unreadOnly: true } }), roles.includes('Student') ? http.get('/attendance/open-check-ins') : Promise.resolve({ data: [] }), diff --git a/web/src/views/NotificationCenterView.vue b/web/src/views/NotificationCenterView.vue index 671cbc2..1248984 100644 --- a/web/src/views/NotificationCenterView.vue +++ b/web/src/views/NotificationCenterView.vue @@ -28,7 +28,6 @@ const activeTab = ref<'inbox' | 'compose' | 'sent'>('inbox') const notifications = ref([]) const sentMessages = ref([]) const unreadCount = ref(0) -const total = ref(0) const sentTotal = ref(0) const loading = ref(false) const markingAllRead = ref(false) @@ -36,6 +35,9 @@ const sending = ref(false) const composerLoading = ref(false) const recipientLoading = ref(false) const page = ref(1) +const inboxCursors = ref([]) +const nextInboxCursor = ref() +const inboxHasMore = ref(false) const sentPage = ref(1) const recipientPage = ref(1) const pageSize = 20 @@ -125,12 +127,16 @@ function categoryClass(value: string) { } async function load(reset = false) { - if (reset) page.value = 1 + if (reset) { + page.value = 1 + inboxCursors.value = [] + nextInboxCursor.value = undefined + } loading.value = true try { const { data } = await http.get('/notifications', { params: { - page: page.value, + cursor: inboxCursors.value.at(-1), pageSize, unreadOnly: unreadOnly.value || undefined, category: category.value || undefined, @@ -138,7 +144,8 @@ async function load(reset = false) { }, }) notifications.value = data.items - total.value = data.total + inboxHasMore.value = data.hasMore + nextInboxCursor.value = data.nextCursor unreadCount.value = data.unreadCount } catch (error) { ElMessage.error(apiErrorMessage(error)) @@ -147,6 +154,20 @@ async function load(reset = false) { } } +async function nextInboxPage() { + if (!nextInboxCursor.value || loading.value) return + inboxCursors.value.push(nextInboxCursor.value) + page.value++ + await load() +} + +async function previousInboxPage() { + if (!inboxCursors.value.length || loading.value) return + inboxCursors.value.pop() + page.value-- + await load() +} + async function loadSent(reset = false) { if (reset) sentPage.value = 1 loading.value = true @@ -490,14 +511,14 @@ onMounted(() => load()) /> -
- +
+ + 上一批 + + 第 {{ page }} 批 + + 下一批 +