feat(comments): paginate life post and entity discussion comments

Implement cursor-based pagination for Life and Entity comments
Optimize Life Post queries to return comment counts and previews
Add "Load more" functionality to frontend discussion panels
This commit is contained in:
2026-05-03 15:20:05 +08:00
parent 0c76d6bfc8
commit 960898c858
8 changed files with 488 additions and 45 deletions

View File

@@ -23,6 +23,7 @@ const { locale, t } = useI18n();
const comments = ref<EntityDiscussionComment[]>([]);
const currentUser = ref<AuthUser | null>(null);
const loading = ref(true);
const loadingMore = ref(false);
const authReady = ref(false);
const body = ref('');
const replyBodies = ref<Record<number, string>>({});
@@ -33,8 +34,12 @@ const formError = ref('');
const commentErrors = ref<Record<string, string>>({});
const commentInput = ref<HTMLTextAreaElement | null>(null);
const commentMaxLength = 1000;
const discussionPageSize = 20;
let requestId = 0;
let removeAuthListener: (() => void) | null = null;
const nextCursor = ref<string | null>(null);
const hasMoreComments = ref(false);
const commentTotal = ref(0);
function can(permissionKey: string) {
return currentUser.value?.permissions.includes(permissionKey) === true;
@@ -42,7 +47,6 @@ function can(permissionKey: string) {
const canComment = computed(() => can('discussions.comments.create'));
const charactersLeft = computed(() => Math.max(0, commentMaxLength - body.value.length));
const commentTotal = computed(() => comments.value.reduce((total, comment) => total + 1 + comment.replies.length, 0));
async function loadCurrentUser() {
authReady.value = false;
@@ -64,15 +68,34 @@ async function loadCurrentUser() {
}
}
async function loadDiscussion() {
function mergeComments(existing: EntityDiscussionComment[], incoming: EntityDiscussionComment[]) {
const ids = new Set(existing.map((comment) => comment.id));
return [...existing, ...incoming.filter((comment) => !ids.has(comment.id))];
}
async function loadDiscussion(reset = true) {
if (!reset && (loadingMore.value || !hasMoreComments.value)) {
return;
}
const nextRequestId = ++requestId;
loading.value = true;
if (reset) {
loading.value = true;
} else {
loadingMore.value = true;
}
loadError.value = '';
try {
const rows = await api.entityDiscussion(props.entityType, props.entityId);
const page = await api.entityDiscussion(props.entityType, props.entityId, {
limit: discussionPageSize,
cursor: reset ? null : nextCursor.value
});
if (nextRequestId === requestId) {
comments.value = rows;
comments.value = reset ? page.items : mergeComments(comments.value, page.items);
nextCursor.value = page.nextCursor;
hasMoreComments.value = page.hasMore;
commentTotal.value = page.total;
}
} catch (error) {
if (nextRequestId === requestId) {
@@ -81,6 +104,7 @@ async function loadDiscussion() {
} finally {
if (nextRequestId === requestId) {
loading.value = false;
loadingMore.value = false;
}
}
}
@@ -168,6 +192,7 @@ async function submitComment() {
try {
const comment = await api.createEntityDiscussionComment(props.entityType, props.entityId, { body: nextBody });
comments.value = [...comments.value, comment];
commentTotal.value += 1;
body.value = '';
} catch (error) {
formError.value = error instanceof Error && error.message ? error.message : t('discussion.commentFailed');
@@ -190,6 +215,7 @@ async function submitReply(comment: EntityDiscussionComment) {
try {
const reply = await api.createEntityDiscussionReply(props.entityType, props.entityId, comment.id, { body: nextBody });
comment.replies.push(reply);
commentTotal.value += 1;
cancelReply(comment.id);
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('discussion.replyFailed'));
@@ -239,6 +265,9 @@ watch(
() => {
resetComposer();
comments.value = [];
nextCursor.value = null;
hasMoreComments.value = false;
commentTotal.value = 0;
void loadDiscussion();
}
);
@@ -419,6 +448,18 @@ onUnmounted(() => {
</div>
</div>
</article>
<div v-if="hasMoreComments" class="life-feed__retry">
<button
class="ui-button ui-button--ghost ui-button--small"
type="button"
:disabled="loadingMore"
@click="loadDiscussion(false)"
>
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
{{ loadingMore ? t('common.loading') : t('discussion.loadMore') }}
</button>
</div>
</div>
<div v-else class="entity-discussion-empty">

View File

@@ -260,7 +260,8 @@ export interface LifePost {
author: UserSummary | null;
updatedBy: UserSummary | null;
tags: NamedEntity[];
comments: LifeComment[];
commentPreview: LifeComment[];
commentCount: number;
reactionCounts: LifeReactionCounts;
myReaction: LifeReactionType | null;
}
@@ -278,6 +279,11 @@ export interface LifePostsParams {
tagId?: string | number;
}
export interface CommentPageParams {
cursor?: string | null;
limit?: number;
}
export interface LifeComment {
id: number;
postId: number;
@@ -290,6 +296,13 @@ export interface LifeComment {
replies: LifeComment[];
}
export interface LifeCommentsPage {
items: LifeComment[];
nextCursor: string | null;
hasMore: boolean;
total: number;
}
export interface RecipeDetail extends Recipe {
acquisition_methods: NamedEntity[];
editHistory: EditHistoryEntry[];
@@ -566,6 +579,13 @@ export interface EntityDiscussionComment {
replies: EntityDiscussionComment[];
}
export interface EntityDiscussionCommentsPage {
items: EntityDiscussionComment[];
nextCursor: string | null;
hasMore: boolean;
total: number;
}
export interface UserCommentActivity {
id: number;
source: ProfileCommentSource;
@@ -833,11 +853,23 @@ export const api = {
deleteLifeReaction: (id: string | number) => deleteAndGetJson<LifePost>(`/api/life-posts/${id}/reaction`),
createLifeComment: (postId: string | number, payload: LifeCommentPayload) =>
sendJson<LifeComment>(`/api/life-posts/${postId}/comments`, 'POST', payload),
lifeComments: (postId: string | number, params: CommentPageParams = {}) =>
getJson<LifeCommentsPage>(
`/api/life-posts/${postId}/comments${buildQuery({
cursor: params.cursor ?? undefined,
limit: params.limit
})}`
),
createLifeCommentReply: (postId: string | number, commentId: string | number, payload: LifeCommentPayload) =>
sendJson<LifeComment>(`/api/life-posts/${postId}/comments/${commentId}/replies`, 'POST', payload),
deleteLifeComment: (id: string | number) => deleteJson(`/api/life-comments/${id}`),
entityDiscussion: (entityType: DiscussionEntityType, entityId: string | number) =>
getJson<EntityDiscussionComment[]>(`/api/discussions/${entityType}/${entityId}/comments`),
entityDiscussion: (entityType: DiscussionEntityType, entityId: string | number, params: CommentPageParams = {}) =>
getJson<EntityDiscussionCommentsPage>(
`/api/discussions/${entityType}/${entityId}/comments${buildQuery({
cursor: params.cursor ?? undefined,
limit: params.limit
})}`
),
createEntityDiscussionComment: (
entityType: DiscussionEntityType,
entityId: string | number,

View File

@@ -38,6 +38,17 @@ import {
type NamedEntity
} from '../services/api';
type LifeCommentPageState = {
items: LifeComment[];
nextCursor: string | null;
hasMore: boolean;
total: number;
loading: boolean;
loadingMore: boolean;
loaded: boolean;
error: string;
};
const { locale, t } = useI18n();
const posts = ref<LifePost[]>([]);
const lifeTags = ref<NamedEntity[]>([]);
@@ -59,6 +70,7 @@ const commentBodies = ref<Record<number, string>>({});
const replyBodies = ref<Record<number, string>>({});
const replyTargetId = ref<number | null>(null);
const expandedComments = ref<Record<number, boolean>>({});
const commentPages = ref<Record<number, LifeCommentPageState>>({});
const commentBusyKey = ref('');
const commentErrors = ref<Record<string, string>>({});
const reactionPickerPostId = ref<number | null>(null);
@@ -67,6 +79,7 @@ const reactionErrors = ref<Record<number, string>>({});
const bodyInput = ref<HTMLTextAreaElement | null>(null);
const loadMoreSentinel = ref<HTMLElement | null>(null);
const lifePostPageSize = 20;
const lifeCommentPageSize = 20;
const bodyMaxLength = 2000;
const commentMaxLength = 1000;
const skeletonPostCount = 3;
@@ -158,6 +171,8 @@ async function loadPosts() {
return;
}
posts.value = page.items;
expandedComments.value = {};
commentPages.value = {};
nextCursor.value = page.nextCursor;
hasMorePosts.value = page.hasMore;
} catch (error) {
@@ -338,8 +353,36 @@ function replyKey(commentId: number) {
return `reply-${commentId}`;
}
function initialCommentPage(post: LifePost): LifeCommentPageState {
return {
items: post.commentPreview,
nextCursor: null,
hasMore: post.commentCount > post.commentPreview.reduce((count, comment) => count + 1 + comment.replies.length, 0),
total: post.commentCount,
loading: false,
loadingMore: false,
loaded: false,
error: ''
};
}
function commentPage(post: LifePost) {
return commentPages.value[post.id] ?? initialCommentPage(post);
}
function setCommentPage(postId: number, page: LifeCommentPageState) {
commentPages.value = {
...commentPages.value,
[postId]: page
};
}
function commentsForPost(post: LifePost) {
return commentPage(post).items;
}
function commentCount(post: LifePost) {
return post.comments.reduce((count, comment) => count + 1 + comment.replies.length, 0);
return commentPage(post).total;
}
function reactionTotal(post: LifePost) {
@@ -375,6 +418,13 @@ function replacePost(updatedPost: LifePost) {
return;
}
const existingComments = commentPages.value[updatedPost.id];
if (existingComments) {
setCommentPage(updatedPost.id, {
...existingComments,
total: updatedPost.commentCount
});
}
posts.value = posts.value.map((post) => (post.id === updatedPost.id ? updatedPost : post));
}
@@ -389,8 +439,56 @@ function setCommentsExpanded(postId: number, expanded: boolean) {
};
}
function toggleComments(postId: number) {
setCommentsExpanded(postId, !areCommentsExpanded(postId));
function mergeComments(existing: LifeComment[], incoming: LifeComment[]) {
const ids = new Set(existing.map((comment) => comment.id));
return [...existing, ...incoming.filter((comment) => !ids.has(comment.id))];
}
async function loadComments(post: LifePost, reset = false) {
const existing = commentPage(post);
if (existing.loading || existing.loadingMore || (!reset && existing.loaded && !existing.hasMore)) {
return;
}
const cursor = reset || !existing.loaded ? null : existing.nextCursor;
setCommentPage(post.id, {
...existing,
items: reset || !existing.loaded ? [] : existing.items,
loading: reset || !existing.loaded,
loadingMore: !reset && existing.loaded,
error: ''
});
try {
const page = await api.lifeComments(post.id, { limit: lifeCommentPageSize, cursor });
const nextItems = reset || !existing.loaded ? page.items : mergeComments(existing.items, page.items);
setCommentPage(post.id, {
items: nextItems,
nextCursor: page.nextCursor,
hasMore: page.hasMore,
total: page.total,
loading: false,
loadingMore: false,
loaded: true,
error: ''
});
post.commentCount = page.total;
} catch (error) {
setCommentPage(post.id, {
...existing,
loading: false,
loadingMore: false,
error: error instanceof Error && error.message ? error.message : t('errors.loadFailed')
});
}
}
function toggleComments(post: LifePost) {
const expanded = !areCommentsExpanded(post.id);
setCommentsExpanded(post.id, expanded);
if (expanded) {
void loadComments(post);
}
}
function isCommentBusy(key: string) {
@@ -420,6 +518,10 @@ function clearCommentError(key: string) {
commentErrors.value = nextErrors;
}
function updateCommentPage(post: LifePost, updater: (page: LifeCommentPageState) => LifeCommentPageState) {
setCommentPage(post.id, updater(commentPage(post)));
}
function setReactionError(postId: number, message: string) {
reactionErrors.value = { ...reactionErrors.value, [postId]: message };
}
@@ -555,7 +657,14 @@ async function submitComment(post: LifePost) {
try {
const comment = await api.createLifeComment(post.id, { body: nextBody });
post.comments.push(comment);
const nextTotal = commentCount(post) + 1;
post.commentCount = nextTotal;
updateCommentPage(post, (page) => ({
...page,
items: mergeComments(page.items, [comment]),
total: nextTotal,
loaded: page.loaded || areCommentsExpanded(post.id)
}));
commentBodies.value[post.id] = '';
setCommentsExpanded(post.id, true);
} catch (error) {
@@ -578,7 +687,13 @@ async function submitReply(post: LifePost, comment: LifeComment) {
try {
const reply = await api.createLifeCommentReply(post.id, comment.id, { body: nextBody });
const nextTotal = commentCount(post) + 1;
post.commentCount = nextTotal;
comment.replies.push(reply);
updateCommentPage(post, (page) => ({
...page,
total: nextTotal
}));
setCommentsExpanded(post.id, true);
cancelReply(comment.id);
} catch (error) {
@@ -615,7 +730,7 @@ async function deleteComment(post: LifePost, comment: LifeComment) {
try {
await api.deleteLifeComment(comment.id);
markCommentDeleted(post.comments, comment.id);
markCommentDeleted(commentsForPost(post), comment.id);
if (replyTargetId.value === comment.id) {
cancelReply(comment.id);
}
@@ -921,7 +1036,7 @@ onUnmounted(() => {
:aria-controls="`life-comments-${post.id}`"
:aria-expanded="areCommentsExpanded(post.id)"
:aria-label="areCommentsExpanded(post.id) ? t('pages.life.hideComments') : t('pages.life.comment')"
@click="toggleComments(post.id)"
@click="toggleComments(post)"
>
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">
@@ -955,7 +1070,7 @@ onUnmounted(() => {
:aria-controls="`life-comments-${post.id}`"
:aria-expanded="areCommentsExpanded(post.id)"
:aria-label="t('pages.life.commentsCount', { count: commentCount(post) })"
@click="toggleComments(post.id)"
@click="toggleComments(post)"
>
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
<span>{{ commentCount(post) }}</span>
@@ -1000,9 +1115,23 @@ onUnmounted(() => {
</button>
</form>
<div v-if="post.comments.length" class="life-comment-list">
<div v-if="commentPage(post).loading && !commentsForPost(post).length" class="life-comment-list" :aria-label="t('pages.life.loadingComments')">
<article v-for="index in 2" :key="`life-comments-loading-${post.id}-${index}`" class="life-comment">
<div class="life-comment__main">
<Skeleton variant="box" width="36px" height="36px" />
<div class="life-comment__content">
<Skeleton width="132px" />
<Skeleton width="86%" />
</div>
</div>
</article>
</div>
<p v-else-if="commentPage(post).error" class="life-form__error" role="alert">{{ commentPage(post).error }}</p>
<div v-else-if="commentsForPost(post).length" class="life-comment-list">
<article
v-for="comment in post.comments"
v-for="comment in commentsForPost(post)"
:key="comment.id"
class="life-comment"
:class="{ 'is-deleted': comment.deleted }"
@@ -1116,6 +1245,18 @@ onUnmounted(() => {
</div>
<p v-else class="life-comments__empty">{{ t('pages.life.noComments') }}</p>
<div v-if="commentPage(post).hasMore && !commentPage(post).loading" class="life-feed__retry">
<button
class="ui-button ui-button--ghost ui-button--small"
type="button"
:disabled="commentPage(post).loadingMore"
@click="loadComments(post)"
>
<Icon :icon="iconChevronDown" class="ui-icon" aria-hidden="true" />
{{ commentPage(post).loadingMore ? t('common.loading') : t('pages.life.loadMoreComments') }}
</button>
</div>
</section>
</article>

View File

@@ -548,7 +548,7 @@ function authorInitial(post: LifePost): string {
}
function commentTotal(post: LifePost): number {
return post.comments.reduce((total, comment) => total + 1 + comment.replies.length, 0);
return post.commentCount;
}
function reactionTotal(post: LifePost): number {