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())
/>
-