From f5db2f6e77c99822dee6a40336e23001fd319627 Mon Sep 17 00:00:00 2001 From: biss Date: Sun, 23 Aug 2026 09:21:04 +0800 Subject: [PATCH] =?UTF-8?q?=E9=80=9A=E7=9F=A5=E4=B8=AD=E5=BF=83=E6=B8=B8?= =?UTF-8?q?=E6=A0=87=E5=88=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/NotificationsController.cs | 56 +++++++++--- .../Controllers/WarningsController.cs | 34 ++++++-- web/src/views/NotificationCenterView.vue | 87 ++++++++++++++----- web/src/views/WarningsView.vue | 47 ++++++---- 4 files changed, 160 insertions(+), 64 deletions(-) diff --git a/src/Jiaowu.Api/Controllers/NotificationsController.cs b/src/Jiaowu.Api/Controllers/NotificationsController.cs index cbef664..b089c21 100644 --- a/src/Jiaowu.Api/Controllers/NotificationsController.cs +++ b/src/Jiaowu.Api/Controllers/NotificationsController.cs @@ -213,7 +213,7 @@ public sealed class NotificationsController( Guid? administrativeClassId, Guid? teachingTaskId, string? keyword, - int page = 1, + string? cursor = null, int pageSize = 30, CancellationToken cancellationToken = default) { @@ -241,17 +241,22 @@ public sealed class NotificationsController( if (filter.Role is not null && !SystemRoles.All.Contains(filter.Role)) return ValidationProblem("所选身份类型无效。"); - page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 10, 100); + if (!CursorToken.TryDecode(cursor, out var after)) + return BadRequest("收件人游标无效。"); var source = ApplyRecipientFilter( ScopedRecipientQuery(scope, resolvedCollegeId), filter); var total = await source.CountAsync(cancellationToken); + if (after is not null) + { + source = source.Where(x => x.DisplayName.CompareTo(after.DisplayName) > 0 || + x.DisplayName == after.DisplayName && x.Id.CompareTo(after.Id) > 0); + } var users = await source .OrderBy(x => x.DisplayName) - .ThenBy(x => x.UserName) - .Skip((page - 1) * pageSize) - .Take(pageSize) + .ThenBy(x => x.Id) + .Take(pageSize + 1) .Select(user => new { user.Id, @@ -276,6 +281,8 @@ public sealed class NotificationsController( .FirstOrDefault() }) .ToListAsync(cancellationToken); + var hasMore = users.Count > pageSize; + users = users.Take(pageSize).ToList(); var userIds = users.Select(x => x.Id).ToArray(); var roleRows = await db.UserRoles.AsNoTracking() .Where(x => userIds.Contains(x.UserId)) @@ -295,6 +302,7 @@ public sealed class NotificationsController( group => group.Key, group => group.Select(x => x.RoleName).Distinct().ToArray()); + var last = users.LastOrDefault(); return Ok(new { Items = users.Select(user => new @@ -308,11 +316,16 @@ public sealed class NotificationsController( Roles = rolesByUser.GetValueOrDefault(user.Id, []) }), Total = total, - Page = page, + HasMore = hasMore, + NextCursor = hasMore && last is not null + ? CursorToken.Encode(new RecipientCursor(last.DisplayName, last.Id)) + : null, PageSize = pageSize }); } + private sealed record RecipientCursor(string DisplayName, Guid Id); + [HttpPost("send")] [Authorize(Roles = Senders)] public async Task Send( @@ -485,20 +498,24 @@ public sealed class NotificationsController( [HttpGet("sent")] [Authorize(Roles = Senders)] public async Task GetSent( - 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.MessageDispatches.AsNoTracking() .Where(x => x.SenderUserId == userId); - var total = await source.CountAsync(cancellationToken); - var items = await source - .OrderByDescending(x => x.CreatedAt) - .Skip((page - 1) * pageSize) - .Take(pageSize) + 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) + .Take(pageSize + 1) .Select(x => new { x.Id, @@ -511,7 +528,18 @@ public sealed class NotificationsController( x.CreatedAt }) .ToListAsync(cancellationToken); - return Ok(new { Items = items, Total = total }); + var hasMore = rows.Count > pageSize; + var items = rows.Take(pageSize).ToArray(); + var last = items.LastOrDefault(); + return Ok(new + { + Items = items, + HasMore = hasMore, + NextCursor = hasMore && last is not null + ? CursorToken.Encode(new NotificationCursor(last.CreatedAt, last.Id)) + : null, + PageSize = pageSize + }); } [HttpPost("{id:guid}/read")] diff --git a/src/Jiaowu.Api/Controllers/WarningsController.cs b/src/Jiaowu.Api/Controllers/WarningsController.cs index 2a30d47..14a543f 100644 --- a/src/Jiaowu.Api/Controllers/WarningsController.cs +++ b/src/Jiaowu.Api/Controllers/WarningsController.cs @@ -138,15 +138,17 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc [HttpGet("records")] [Authorize(Roles = Managers + "," + SystemRoles.Counselor)] - public async Task>> GetRecords( + public async Task>> GetRecords( Guid? academicTermId, WarningType? type, - int page = 1, + string? cursor = null, int pageSize = 20, CancellationToken ct = 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 q = db.WarningRecords.AsNoTracking().AsQueryable(); if (scope.Current.Scope == DataScope.College || scope.Current.IsInRole(SystemRoles.Counselor)) @@ -160,12 +162,15 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc if (academicTermId.HasValue) q = q.Where(x => x.AcademicTermId == academicTermId); if (type.HasValue) q = q.Where(x => x.Type == type); - var total = await q.CountAsync(ct); - var items = await q + if (after is not null) + { + q = q.Where(x => x.CreatedAt < after.CreatedAt || + x.CreatedAt == after.CreatedAt && x.Id.CompareTo(after.Id) < 0); + } + var rows = await q .OrderByDescending(x => x.CreatedAt) .ThenByDescending(x => x.Id) - .Skip((page - 1) * pageSize) - .Take(pageSize) + .Take(pageSize + 1) .Select(x => new WarningRecordListItem( x.Id, x.StudentId, @@ -180,9 +185,20 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc x.AcknowledgeComment, x.CreatedAt)) .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 WarningCursor(last.CreatedAt, last.Id)) + : null, + hasMore, + pageSize)); } + private sealed record WarningCursor(DateTime CreatedAt, Guid Id); + // ═══════════ Student ═══════════ [HttpGet("my-warnings")] [Authorize(Roles = SystemRoles.Student)] diff --git a/web/src/views/NotificationCenterView.vue b/web/src/views/NotificationCenterView.vue index 1248984..72d731a 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 sentTotal = ref(0) const loading = ref(false) const markingAllRead = ref(false) const sending = ref(false) @@ -39,7 +38,13 @@ const inboxCursors = ref([]) const nextInboxCursor = ref() const inboxHasMore = ref(false) const sentPage = ref(1) +const sentCursors = ref([]) +const nextSentCursor = ref() +const sentHasMore = ref(false) const recipientPage = ref(1) +const recipientCursors = ref([]) +const nextRecipientCursor = ref() +const recipientHasMore = ref(false) const pageSize = 20 const recipientPageSize = 30 const unreadOnly = ref(false) @@ -169,14 +174,19 @@ async function previousInboxPage() { } async function loadSent(reset = false) { - if (reset) sentPage.value = 1 + if (reset) { + sentPage.value = 1 + sentCursors.value = [] + nextSentCursor.value = undefined + } loading.value = true try { const { data } = await http.get('/notifications/sent', { - params: { page: sentPage.value, pageSize }, + params: { cursor: sentCursors.value.at(-1), pageSize }, }) sentMessages.value = data.items - sentTotal.value = data.total + sentHasMore.value = data.hasMore + nextSentCursor.value = data.nextCursor } catch (error) { ElMessage.error(apiErrorMessage(error)) } finally { @@ -184,6 +194,20 @@ async function loadSent(reset = false) { } } +async function nextSentPage() { + if (!nextSentCursor.value || loading.value) return + sentCursors.value.push(nextSentCursor.value) + sentPage.value++ + await loadSent() +} + +async function previousSentPage() { + if (!sentCursors.value.length || loading.value) return + sentCursors.value.pop() + sentPage.value-- + await loadSent() +} + async function loadComposer() { if (!canSend.value || composer.value) return composerLoading.value = true @@ -206,14 +230,18 @@ function recipientParams() { administrativeClassId: recipientFilters.administrativeClassId || undefined, teachingTaskId: recipientFilters.teachingTaskId || undefined, keyword: recipientFilters.keyword.trim() || undefined, - page: recipientPage.value, + cursor: recipientCursors.value.at(-1), pageSize: recipientPageSize, } } async function loadRecipients(reset = false) { if (!isAdministratorComposer.value) return - if (reset) recipientPage.value = 1 + if (reset) { + recipientPage.value = 1 + recipientCursors.value = [] + nextRecipientCursor.value = undefined + } recipientLoading.value = true try { const { data } = await http.get('/notifications/recipients', { @@ -221,6 +249,8 @@ async function loadRecipients(reset = false) { }) recipientItems.value = data.items recipientTotal.value = data.total + recipientHasMore.value = data.hasMore + nextRecipientCursor.value = data.nextCursor } catch (error) { ElMessage.error(apiErrorMessage(error)) } finally { @@ -228,6 +258,20 @@ async function loadRecipients(reset = false) { } } +async function nextRecipientPage() { + if (!nextRecipientCursor.value || recipientLoading.value) return + recipientCursors.value.push(nextRecipientCursor.value) + recipientPage.value++ + await loadRecipients() +} + +async function previousRecipientPage() { + if (!recipientCursors.value.length || recipientLoading.value) return + recipientCursors.value.pop() + recipientPage.value-- + await loadRecipients() +} + async function changeRecipientMode(value: 'Scope' | 'Filtered' | 'Selected') { recipientMode.value = value if (value !== 'Scope') await loadRecipients(true) @@ -685,16 +729,11 @@ onMounted(() => load()) description="没有符合条件的可用账号" /> - +
+ 上一批 + 第 {{ recipientPage }} 批 + 下一批 +
load()) -
- +
+ + 上一批 + + 第 {{ sentPage }} 批 + + 下一批 +
diff --git a/web/src/views/WarningsView.vue b/web/src/views/WarningsView.vue index 4480c99..a093a6b 100644 --- a/web/src/views/WarningsView.vue +++ b/web/src/views/WarningsView.vue @@ -19,7 +19,9 @@ const loading = ref(false) const detecting = ref(false) const filterType = ref('') const recordsPage = ref(1) -const recordsTotal = ref(0) +const recordsCursors = ref([]) +const nextRecordsCursor = ref() +const recordsHasMore = ref(false) const recordsPageSize = 20 const riskCases = ref([]) const riskDialogVisible = ref(false) @@ -49,7 +51,11 @@ const ruleRows = reactive([ ]) async function load(resetRecordsPage = false) { - if (resetRecordsPage) recordsPage.value = 1 + if (resetRecordsPage) { + recordsPage.value = 1 + recordsCursors.value = [] + nextRecordsCursor.value = undefined + } loading.value = true try { if (isWarningManager.value && termId.value) { @@ -77,16 +83,13 @@ async function load(resetRecordsPage = false) { params: { academicTermId: termId.value || undefined, type: filterType.value || undefined, - page: recordsPage.value, + cursor: recordsCursors.value.at(-1), pageSize: recordsPageSize, }, }) records.value = response.data.items - recordsTotal.value = response.data.total - if (records.value.length === 0 && recordsPage.value > 1) { - recordsPage.value-- - await load() - } + recordsHasMore.value = response.data.hasMore + nextRecordsCursor.value = response.data.nextCursor riskCases.value = (await http.get('/warnings/risk-cases', { params: { mineOnly: isCounselor.value } })).data } if (isStudent.value) { @@ -96,6 +99,20 @@ async function load(resetRecordsPage = false) { } catch (e) { ElMessage.error(apiErrorMessage(e)) } finally { loading.value = false } } +async function nextRecordsPage() { + if (!nextRecordsCursor.value || loading.value) return + recordsCursors.value.push(nextRecordsCursor.value) + recordsPage.value++ + await load() +} + +async function previousRecordsPage() { + if (!recordsCursors.value.length || loading.value) return + recordsCursors.value.pop() + recordsPage.value-- + await load() +} + async function saveRules() { try { const payload = ruleRows.map(r => ({ type: r.type, name: r.name, threshold: r.threshold, isEnabled: r.isEnabled, notifyStudent: r.notifyStudent, notifyCounselor: r.notifyCounselor, description: r.description, autoCheckEnabled: r.autoCheckEnabled, checkDayOfWeek: r.checkDayOfWeek, checkHour: r.checkHour, checkMinute: r.checkMinute })) @@ -220,15 +237,11 @@ onMounted(async () => { - +
+ 上一批 + 第 {{ recordsPage }} 批 + 下一批 +