通知中心游标分页

This commit is contained in:
biss committed 2026-08-23 09:21:04 +08:00
1 parent 172577f668
commit f5db2f6e77
4 files changed
+160 -64

No files matched your search

@@ -213,7 +213,7 @@ public sealed class NotificationsController(
Guid? administrativeClassId, Guid? administrativeClassId,
Guid? teachingTaskId, Guid? teachingTaskId,
string? keyword, string? keyword,
int page = 1, string? cursor = null,
int pageSize = 30, int pageSize = 30,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
@@ -241,17 +241,22 @@ public sealed class NotificationsController(
if (filter.Role is not null && !SystemRoles.All.Contains(filter.Role)) if (filter.Role is not null && !SystemRoles.All.Contains(filter.Role))
return ValidationProblem("所选身份类型无效。"); return ValidationProblem("所选身份类型无效。");
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 10, 100); pageSize = Math.Clamp(pageSize, 10, 100);
if (!CursorToken.TryDecode<RecipientCursor>(cursor, out var after))
return BadRequest("收件人游标无效。");
var source = ApplyRecipientFilter( var source = ApplyRecipientFilter(
ScopedRecipientQuery(scope, resolvedCollegeId), ScopedRecipientQuery(scope, resolvedCollegeId),
filter); filter);
var total = await source.CountAsync(cancellationToken); 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 var users = await source
.OrderBy(x => x.DisplayName) .OrderBy(x => x.DisplayName)
.ThenBy(x => x.UserName) .ThenBy(x => x.Id)
.Skip((page - 1) * pageSize) .Take(pageSize + 1)
.Take(pageSize)
.Select(user => new .Select(user => new
{ {
user.Id, user.Id,
@@ -276,6 +281,8 @@ public sealed class NotificationsController(
.FirstOrDefault() .FirstOrDefault()
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var hasMore = users.Count > pageSize;
users = users.Take(pageSize).ToList();
var userIds = users.Select(x => x.Id).ToArray(); var userIds = users.Select(x => x.Id).ToArray();
var roleRows = await db.UserRoles.AsNoTracking() var roleRows = await db.UserRoles.AsNoTracking()
.Where(x => userIds.Contains(x.UserId)) .Where(x => userIds.Contains(x.UserId))
@@ -295,6 +302,7 @@ public sealed class NotificationsController(
group => group.Key, group => group.Key,
group => group.Select(x => x.RoleName).Distinct().ToArray()); group => group.Select(x => x.RoleName).Distinct().ToArray());
var last = users.LastOrDefault();
return Ok(new return Ok(new
{ {
Items = users.Select(user => new Items = users.Select(user => new
@@ -308,11 +316,16 @@ public sealed class NotificationsController(
Roles = rolesByUser.GetValueOrDefault(user.Id, []) Roles = rolesByUser.GetValueOrDefault(user.Id, [])
}), }),
Total = total, Total = total,
Page = page, HasMore = hasMore,
NextCursor = hasMore && last is not null
? CursorToken.Encode(new RecipientCursor(last.DisplayName, last.Id))
: null,
PageSize = pageSize PageSize = pageSize
}); });
} }
private sealed record RecipientCursor(string DisplayName, Guid Id);
[HttpPost("send")] [HttpPost("send")]
[Authorize(Roles = Senders)] [Authorize(Roles = Senders)]
public async Task<ActionResult> Send( public async Task<ActionResult> Send(
@@ -485,20 +498,24 @@ public sealed class NotificationsController(
[HttpGet("sent")] [HttpGet("sent")]
[Authorize(Roles = Senders)] [Authorize(Roles = Senders)]
public async Task<ActionResult> GetSent( public async Task<ActionResult> GetSent(
int page = 1, string? cursor = null,
int pageSize = 20, int pageSize = 20,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 1, 100); pageSize = Math.Clamp(pageSize, 1, 100);
if (!CursorToken.TryDecode<NotificationCursor>(cursor, out var after))
return BadRequest("已发送消息游标无效。");
var userId = currentUserDataScope.Current.UserId; var userId = currentUserDataScope.Current.UserId;
var source = db.MessageDispatches.AsNoTracking() var source = db.MessageDispatches.AsNoTracking()
.Where(x => x.SenderUserId == userId); .Where(x => x.SenderUserId == userId);
var total = await source.CountAsync(cancellationToken); if (after is not null)
var items = await source {
.OrderByDescending(x => x.CreatedAt) source = source.Where(x => x.CreatedAt < after.CreatedAt ||
.Skip((page - 1) * pageSize) x.CreatedAt == after.CreatedAt && x.Id.CompareTo(after.Id) < 0);
.Take(pageSize) }
var rows = await source
.OrderByDescending(x => x.CreatedAt).ThenByDescending(x => x.Id)
.Take(pageSize + 1)
.Select(x => new .Select(x => new
{ {
x.Id, x.Id,
@@ -511,7 +528,18 @@ public sealed class NotificationsController(
x.CreatedAt x.CreatedAt
}) })
.ToListAsync(cancellationToken); .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")] [HttpPost("{id:guid}/read")]
@@ -138,15 +138,17 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
[HttpGet("records")] [HttpGet("records")]
[Authorize(Roles = Managers + "," + SystemRoles.Counselor)] [Authorize(Roles = Managers + "," + SystemRoles.Counselor)]
public async Task<ActionResult<PagedResult<WarningRecordListItem>>> GetRecords( public async Task<ActionResult<CursorPagedResult<WarningRecordListItem>>> GetRecords(
Guid? academicTermId, Guid? academicTermId,
WarningType? type, WarningType? type,
int page = 1, string? cursor = null,
int pageSize = 20, int pageSize = 20,
CancellationToken ct = default) CancellationToken ct = default)
{ {
if (page < 1 || pageSize is < 1 or > 100) if (pageSize is < 1 or > 100)
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。"); return ValidationProblem("每批条数应在 1 至 100 之间。");
if (!CursorToken.TryDecode<WarningCursor>(cursor, out var after))
return BadRequest("预警记录游标无效。");
var q = db.WarningRecords.AsNoTracking().AsQueryable(); var q = db.WarningRecords.AsNoTracking().AsQueryable();
if (scope.Current.Scope == DataScope.College || scope.Current.IsInRole(SystemRoles.Counselor)) 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 (academicTermId.HasValue) q = q.Where(x => x.AcademicTermId == academicTermId);
if (type.HasValue) q = q.Where(x => x.Type == type); if (type.HasValue) q = q.Where(x => x.Type == type);
var total = await q.CountAsync(ct); if (after is not null)
var items = await q {
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) .OrderByDescending(x => x.CreatedAt)
.ThenByDescending(x => x.Id) .ThenByDescending(x => x.Id)
.Skip((page - 1) * pageSize) .Take(pageSize + 1)
.Take(pageSize)
.Select(x => new WarningRecordListItem( .Select(x => new WarningRecordListItem(
x.Id, x.Id,
x.StudentId, x.StudentId,
@@ -180,9 +185,20 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
x.AcknowledgeComment, x.AcknowledgeComment,
x.CreatedAt)) x.CreatedAt))
.ToListAsync(ct); .ToListAsync(ct);
return Ok(new PagedResult<WarningRecordListItem>(items, total, page, pageSize)); var hasMore = rows.Count > pageSize;
var items = rows.Take(pageSize).ToArray();
var last = items.LastOrDefault();
return Ok(new CursorPagedResult<WarningRecordListItem>(
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 ═══════════ // ═══════════ Student ═══════════
[HttpGet("my-warnings")] [HttpGet("my-warnings")]
[Authorize(Roles = SystemRoles.Student)] [Authorize(Roles = SystemRoles.Student)]
+63 -24
View File
@@ -28,7 +28,6 @@ const activeTab = ref<'inbox' | 'compose' | 'sent'>('inbox')
const notifications = ref<any[]>([]) const notifications = ref<any[]>([])
const sentMessages = ref<any[]>([]) const sentMessages = ref<any[]>([])
const unreadCount = ref(0) const unreadCount = ref(0)
const sentTotal = ref(0)
const loading = ref(false) const loading = ref(false)
const markingAllRead = ref(false) const markingAllRead = ref(false)
const sending = ref(false) const sending = ref(false)
@@ -39,7 +38,13 @@ const inboxCursors = ref<string[]>([])
const nextInboxCursor = ref<string>() const nextInboxCursor = ref<string>()
const inboxHasMore = ref(false) const inboxHasMore = ref(false)
const sentPage = ref(1) const sentPage = ref(1)
const sentCursors = ref<string[]>([])
const nextSentCursor = ref<string>()
const sentHasMore = ref(false)
const recipientPage = ref(1) const recipientPage = ref(1)
const recipientCursors = ref<string[]>([])
const nextRecipientCursor = ref<string>()
const recipientHasMore = ref(false)
const pageSize = 20 const pageSize = 20
const recipientPageSize = 30 const recipientPageSize = 30
const unreadOnly = ref(false) const unreadOnly = ref(false)
@@ -169,14 +174,19 @@ async function previousInboxPage() {
} }
async function loadSent(reset = false) { async function loadSent(reset = false) {
if (reset) sentPage.value = 1 if (reset) {
sentPage.value = 1
sentCursors.value = []
nextSentCursor.value = undefined
}
loading.value = true loading.value = true
try { try {
const { data } = await http.get('/notifications/sent', { const { data } = await http.get('/notifications/sent', {
params: { page: sentPage.value, pageSize }, params: { cursor: sentCursors.value.at(-1), pageSize },
}) })
sentMessages.value = data.items sentMessages.value = data.items
sentTotal.value = data.total sentHasMore.value = data.hasMore
nextSentCursor.value = data.nextCursor
} catch (error) { } catch (error) {
ElMessage.error(apiErrorMessage(error)) ElMessage.error(apiErrorMessage(error))
} finally { } 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() { async function loadComposer() {
if (!canSend.value || composer.value) return if (!canSend.value || composer.value) return
composerLoading.value = true composerLoading.value = true
@@ -206,14 +230,18 @@ function recipientParams() {
administrativeClassId: recipientFilters.administrativeClassId || undefined, administrativeClassId: recipientFilters.administrativeClassId || undefined,
teachingTaskId: recipientFilters.teachingTaskId || undefined, teachingTaskId: recipientFilters.teachingTaskId || undefined,
keyword: recipientFilters.keyword.trim() || undefined, keyword: recipientFilters.keyword.trim() || undefined,
page: recipientPage.value, cursor: recipientCursors.value.at(-1),
pageSize: recipientPageSize, pageSize: recipientPageSize,
} }
} }
async function loadRecipients(reset = false) { async function loadRecipients(reset = false) {
if (!isAdministratorComposer.value) return if (!isAdministratorComposer.value) return
if (reset) recipientPage.value = 1 if (reset) {
recipientPage.value = 1
recipientCursors.value = []
nextRecipientCursor.value = undefined
}
recipientLoading.value = true recipientLoading.value = true
try { try {
const { data } = await http.get('/notifications/recipients', { const { data } = await http.get('/notifications/recipients', {
@@ -221,6 +249,8 @@ async function loadRecipients(reset = false) {
}) })
recipientItems.value = data.items recipientItems.value = data.items
recipientTotal.value = data.total recipientTotal.value = data.total
recipientHasMore.value = data.hasMore
nextRecipientCursor.value = data.nextCursor
} catch (error) { } catch (error) {
ElMessage.error(apiErrorMessage(error)) ElMessage.error(apiErrorMessage(error))
} finally { } 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') { async function changeRecipientMode(value: 'Scope' | 'Filtered' | 'Selected') {
recipientMode.value = value recipientMode.value = value
if (value !== 'Scope') await loadRecipients(true) if (value !== 'Scope') await loadRecipients(true)
@@ -685,16 +729,11 @@ onMounted(() => load())
description="没有符合条件的可用账号" description="没有符合条件的可用账号"
/> />
</div> </div>
<el-pagination <div v-if="recipientCursors.length || recipientHasMore" class="cursor-pager">
v-if="recipientTotal > recipientPageSize" <el-button size="small" :disabled="!recipientCursors.length || recipientLoading" @click="previousRecipientPage">上一批</el-button>
v-model:current-page="recipientPage" <span>第 {{ recipientPage }} 批</span>
small <el-button size="small" type="primary" :disabled="!recipientHasMore || recipientLoading" @click="nextRecipientPage">下一批</el-button>
background </div>
:page-size="recipientPageSize"
:total="recipientTotal"
layout="prev, pager, next"
@current-change="loadRecipients()"
/>
</div> </div>
<div <div
@@ -828,14 +867,14 @@ onMounted(() => load())
<el-empty v-if="!loading && !sentMessages.length" description="还没有发送记录" /> <el-empty v-if="!loading && !sentMessages.length" description="还没有发送记录" />
</section> </section>
<div v-if="sentTotal > pageSize" class="message-pager"> <div v-if="sentCursors.length || sentHasMore" class="message-pager">
<el-pagination <el-button :disabled="!sentCursors.length || loading" @click="previousSentPage">
v-model:current-page="sentPage" 上一批
:page-size="pageSize" </el-button>
:total="sentTotal" <span>第 {{ sentPage }} 批</span>
layout="prev, pager, next, total" <el-button type="primary" :disabled="!sentHasMore || loading" @click="nextSentPage">
@current-change="loadSent()" 下一批
/> </el-button>
</div> </div>
</template> </template>
Loaded 3 of 4 files, more files were not shown because too many files have changed in this diff. Show more