diff --git a/DESIGN.md b/DESIGN.md index f8fb79b..2297df2 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -377,7 +377,8 @@ Life Post 可配置: - 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。 - 已注册并完成邮箱验证的用户可以对每条 Life Post 选择一个 Reaction;普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`。 - Life Reaction 的其他类型通过右键 / context menu 打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。 -- 当前没有图片上传、转发、分页、置顶或单独审核流程。 +- 信息流分页加载,初始展示最新一页,滚动到底部自动加载更多。 +- 当前没有图片上传、转发、置顶或单独审核流程。 - Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。 API 暴露边界: @@ -385,6 +386,7 @@ API 暴露边界: - Life Post 作者信息只返回 `id` 和 `displayName`。 - Life Comment 作者信息只返回 `id` 和 `displayName`。 - Life Reaction 对外只返回按类型汇总的数量和当前用户自己的 Reaction,不返回其他用户的 Reaction 明细。 +- Life Post 列表 API 返回分页结果:`items`、`nextCursor`、`hasMore`;`cursor` 是不透明分页令牌。 - API 不返回邮箱、token/hash、内部调试字段或不必要的审计 payload。 - 非作者不能编辑或删除其他用户的 Life Post。 - 非作者不能删除其他用户的 Life Comment。 @@ -426,7 +428,7 @@ API 暴露边界: - `GET /api/items/:id` - `GET /api/recipes` - `GET /api/recipes/:id` -- `GET /api/life-posts` +- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取。 认证 API: diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 5dd4dfa..4989997 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -1,5 +1,6 @@ import { parseIdList, parseMatchMode, sqlForRelationFilter } from './filter.ts'; import { pool, query, queryOne } from './db.ts'; +import { Buffer } from 'node:buffer'; import type { PoolClient } from 'pg'; type QueryValue = string | string[] | undefined; @@ -134,17 +135,29 @@ type LifePostRow = { id: number; body: string; createdAt: Date; + createdAtCursor: string; updatedAt: Date; author: { id: number; displayName: string } | null; updatedBy: { id: number; displayName: string } | null; }; -type LifePost = LifePostRow & { +type LifePost = Omit & { comments: LifeComment[]; reactionCounts: LifeReactionCounts; myReaction: LifeReactionType | null; }; +type LifePostCursor = { + createdAt: string; + id: number; +}; + +type LifePostsPage = { + items: LifePost[]; + nextCursor: string | null; + hasMore: boolean; +}; + type HabitatPayload = { name: string; translations: TranslationInput; @@ -215,6 +228,8 @@ const timeOfDays = ['早晨', '中午', '傍晚', '晚上']; const weathers = ['晴天', '阴天', '雨天']; const defaultLocale = 'en'; const localePattern = /^[a-z]{2}(-[A-Z]{2})?$/; +const defaultLifePostLimit = 20; +const maxLifePostLimit = 50; const lifeReactionTypes = ['like', 'helpful', 'fun', 'thanks'] as const; const pokemonStatLabels: Array<{ key: keyof PokemonStats; label: string }> = [ { key: 'hp', label: 'HP' }, @@ -1255,6 +1270,7 @@ function lifePostProjection(): string { lp.id, lp.body, lp.created_at AS "createdAt", + lp.created_at::text AS "createdAtCursor", lp.updated_at AS "updatedAt", CASE WHEN created_user.id IS NULL THEN NULL @@ -1270,6 +1286,63 @@ function lifePostProjection(): string { `; } +function cleanLifePostLimit(value: QueryValue): number { + const rawLimit = asString(value); + if (rawLimit === undefined || rawLimit === '') { + return defaultLifePostLimit; + } + + const limit = Number(rawLimit); + return Number.isInteger(limit) && limit > 0 ? Math.min(limit, maxLifePostLimit) : defaultLifePostLimit; +} + +function decodeLifePostCursor(value: QueryValue): LifePostCursor | null { + const rawCursor = asString(value); + if (!rawCursor) { + return null; + } + + try { + const cursor = JSON.parse(Buffer.from(rawCursor, 'base64url').toString('utf8')) as Partial; + const createdAt = typeof cursor.createdAt === 'string' ? cursor.createdAt : ''; + const id = Number(cursor.id); + + if (!createdAt || Number.isNaN(new Date(createdAt).getTime()) || !Number.isInteger(id) || id <= 0) { + throw validationError('Cursor is invalid'); + } + + return { createdAt, id }; + } catch (error) { + if (error instanceof Error && 'statusCode' in error) { + throw error; + } + throw validationError('Cursor is invalid'); + } +} + +function encodeLifePostCursor(post: LifePostRow): string { + return Buffer.from(JSON.stringify({ createdAt: post.createdAtCursor, id: post.id }), 'utf8').toString('base64url'); +} + +function hydrateLifePost( + post: LifePostRow, + commentsByPost: Map, + countsByPost: Map, + myReactionsByPost: Map +): LifePost { + return { + id: post.id, + body: post.body, + createdAt: post.createdAt, + updatedAt: post.updatedAt, + author: post.author, + updatedBy: post.updatedBy, + comments: commentsByPost.get(post.id) ?? [], + reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(), + myReaction: myReactionsByPost.get(post.id) ?? null + }; +} + function lifeCommentProjection(whereClause: string): string { return ` SELECT @@ -1406,22 +1479,41 @@ async function getLifeCommentById(id: number): Promise { return row ? { ...row, replies: [] } : null; } -export async function listLifePosts(userId: number | null = null): Promise { - const posts = await query(` - ${lifePostProjection()} - ORDER BY lp.created_at DESC, lp.id DESC - `); +export async function listLifePosts(paramsQuery: QueryParams = {}, userId: number | null = null): Promise { + const cursor = decodeLifePostCursor(paramsQuery.cursor); + const limit = cleanLifePostLimit(paramsQuery.limit); + const params: unknown[] = []; + let cursorClause = ''; + + if (cursor) { + params.push(cursor.createdAt, cursor.id); + cursorClause = ` + WHERE (lp.created_at, lp.id) < ($${params.length - 1}::timestamptz, $${params.length}::integer) + `; + } + + params.push(limit + 1); + const rows = await query( + ` + ${lifePostProjection()} + ${cursorClause} + ORDER BY lp.created_at DESC, lp.id DESC + LIMIT $${params.length} + `, + params + ); + const hasMore = rows.length > limit; + const posts = hasMore ? rows.slice(0, limit) : rows; const postIds = posts.map((post) => post.id); const commentsByPost = await lifeCommentsForPosts(postIds); const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, userId); - return posts.map((post) => ({ - ...post, - comments: commentsByPost.get(post.id) ?? [], - reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(), - myReaction: myReactionsByPost.get(post.id) ?? null - })); + return { + items: posts.map((post) => hydrateLifePost(post, commentsByPost, countsByPost, myReactionsByPost)), + nextCursor: hasMore && posts.length > 0 ? encodeLifePostCursor(posts[posts.length - 1]) : null, + hasMore + }; } async function getLifePostById(id: number, userId: number | null = null): Promise { @@ -1439,12 +1531,7 @@ async function getLifePostById(id: number, userId: number | null = null): Promis const commentsByPost = await lifeCommentsForPosts([post.id]); const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts([post.id], userId); - return { - ...post, - comments: commentsByPost.get(post.id) ?? [], - reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(), - myReaction: myReactionsByPost.get(post.id) ?? null - }; + return hydrateLifePost(post, commentsByPost, countsByPost, myReactionsByPost); } export async function createLifePost(payload: Record, userId: number) { diff --git a/backend/src/server.ts b/backend/src/server.ts index 860e4b0..7c12758 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -195,7 +195,7 @@ app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(reque app.get('/api/life-posts', async (request) => { const user = await optionalUser(request); - return listLifePosts(user?.id ?? null); + return listLifePosts(request.query as Record, user?.id ?? null); }); app.post('/api/life-posts', async (request, reply) => { diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 979dfe7..11fd07a 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -188,6 +188,17 @@ export interface LifePost { myReaction: LifeReactionType | null; } +export interface LifePostsPage { + items: LifePost[]; + nextCursor: string | null; + hasMore: boolean; +} + +export interface LifePostsParams { + cursor?: string | null; + limit?: number; +} + export interface LifeComment { id: number; postId: number; @@ -452,7 +463,10 @@ export const api = { logout: () => postEmpty('/api/auth/logout'), options: () => getJson('/api/options'), dailyChecklist: () => getJson('/api/daily-checklist'), - lifePosts: () => getJson('/api/life-posts'), + lifePosts: (params: LifePostsParams = {}) => + getJson( + `/api/life-posts${buildQuery({ cursor: params.cursor ?? undefined, limit: params.limit })}` + ), createLifePost: (payload: LifePostPayload) => sendJson('/api/life-posts', 'POST', payload), updateLifePost: (id: string | number, payload: LifePostPayload) => sendJson(`/api/life-posts/${id}`, 'PUT', payload), diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index c6c53a3..3be1452 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -1219,6 +1219,10 @@ button:disabled, gap: 14px; } +.life-feed__sentinel { + min-height: 1px; +} + .life-form__counter { justify-self: end; } diff --git a/frontend/src/views/LifeView.vue b/frontend/src/views/LifeView.vue index 757db68..e1b7979 100644 --- a/frontend/src/views/LifeView.vue +++ b/frontend/src/views/LifeView.vue @@ -1,6 +1,6 @@ @@ -503,7 +594,7 @@ onUnmounted(() => { -
+
@@ -777,6 +868,21 @@ onUnmounted(() => {

{{ t('pages.life.noComments') }}

+ +
+
+ + +
+ + + +
+ +

{{ t('pages.life.empty') }}