diff --git a/DESIGN.md b/DESIGN.md index 4f8b120..c750e5b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -581,6 +581,7 @@ Pokemon 列表功能: - 满足任意条件 - 满足全部条件 - 按自定义排序展示 +- 列表首屏只读取一页数据;滚动到列表底部时继续读取下一页,不一次性加载全部 Pokemon。 - Pokemon 列表卡片只展示 Pokemon 图片和下方的 `#ID 名称`;不展示喜欢的环境、属性、特长、喜欢的东西或编辑元信息。 - Pokemon 卡片在已配置图片时展示所选图片缩略图;未配置图片时保留默认 Poké Ball 标记。 - Event Pokemon 列表功能与 Pokemon 列表相同,但只展示 `is_event_item = true` 的 Pokemon;Pokemon 列表只展示 `is_event_item = false` 的 Pokemon。 @@ -663,6 +664,7 @@ Items 与 Event Items 使用相同数据模型: - 按用途筛选 - 按标签筛选 - 按自定义排序展示 +- 公开列表首屏只读取一页数据;滚动到列表底部时继续读取下一页,不一次性加载全部 Items 或 Event Items。 - All 视图在满足写入权限时支持对 Grid Item 右键插入新物品到前/后,并支持直接拖曳 Item 调整排序;插入与拖曳只作用于当前展示的 Items 列表,不影响 Event Items 入口。 - 新增物品入口支持当前浏览器 Session 的默认值菜单;用户可为新建物品预设分类、用途、客制化勾选项和入手方式。默认值只影响 `/items/new` 与 `/event-items/new` 的新建表单初始值,不影响编辑已有物品,不改变 API、数据库模型、权限或审计行为;Event Items 仍由 `/event-items/new` 入口决定 `is_event_item`。 - 物品列表桌面端使用 12 列紧凑 Grid,每个格子只展示物品图标;有用途的物品在卡片左上角以斜 Ribbon 展示用途名称;物品名称通过 hover / focus Tooltip 展示。 @@ -1071,16 +1073,16 @@ API 暴露边界: - `GET /api/system-wordings` - `GET /api/options` - `GET /api/project-updates`:读取站点项目公开更新信息;支持 `cursor` / `limit` 分页读取最近提交;仅返回净化后的仓库、最近提交和发布版本展示字段。 -- `GET /api/daily-checklist` -- `GET /api/pokemon`:支持 `isEventItem=true|false` 按普通 Pokemon / Event Pokemon 拆分列表;未传时返回全部 Pokemon 以兼容管理端和实体选择器 +- `GET /api/daily-checklist`:公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容管理端排序。 +- `GET /api/pokemon`:支持 `isEventItem=true|false` 按普通 Pokemon / Event Pokemon 拆分列表;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回全部 Pokemon 以兼容管理端和实体选择器。 - `GET /api/pokemon/:id` -- `GET /api/habitats`:支持 `isEventItem=true|false` 按普通栖息地 / Event Habitats 拆分列表;未传时返回全部栖息地以兼容管理端和实体选择器 +- `GET /api/habitats`:支持 `isEventItem=true|false` 按普通栖息地 / Event Habitats 拆分列表;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回全部栖息地以兼容管理端和实体选择器。 - `GET /api/habitats/:id` -- `GET /api/items`:支持 `isEventItem=true|false` 按普通 Items / Event Items 拆分列表;默认返回所有物品,包括已配置 Ancient Artifact 分类的物品;传入 `ancientArtifactCategoryId` 时可额外筛选对应 Ancient Artifact 分类下的物品 +- `GET /api/items`:支持 `isEventItem=true|false` 按普通 Items / Event Items 拆分列表;默认返回所有物品,包括已配置 Ancient Artifact 分类的物品;传入 `ancientArtifactCategoryId` 时可额外筛选对应 Ancient Artifact 分类下的物品;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容管理端、实体选择器和排序。 - `GET /api/items/:id` -- `GET /api/ancient-artifacts`:支持 `search`、`categoryId` 和 `tagIds` 筛选 +- `GET /api/ancient-artifacts`:支持 `search`、`categoryId` 和 `tagIds` 筛选;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容排序。 - `GET /api/ancient-artifacts/:id` -- `GET /api/recipes` +- `GET /api/recipes`:公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容排序。 - `GET /api/recipes/:id` - `GET /api/dish` - `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `categoryId` 按 Life Category 筛选;支持 `language` 按审核语言区筛选,`all` 表示全部语言区;支持 `gameVersionId` 按 Game Version 筛选;支持 `rateable` 按可评分 Category 筛选;支持 `sort` 为 `latest`、`oldest` 或 `top-rated`。 diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 0d30169..327c897 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -11,7 +11,7 @@ import { Buffer } from 'node:buffer'; import { readFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import type { PoolClient } from 'pg'; +import type { PoolClient, QueryResultRow } from 'pg'; import { requestAiModerationReview, type AiModerationStatus @@ -21,6 +21,11 @@ import { createLifePostReactionNotification, createUserFollowNotification } from type QueryValue = string | string[] | undefined; type QueryParams = Record; +type ListPage = { + items: T[]; + nextCursor: string | null; + hasMore: boolean; +}; type DbClient = PoolClient; type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'artifacts' | 'recipes' | 'checklist'; @@ -707,6 +712,67 @@ function asString(value: QueryValue): string | undefined { return Array.isArray(value) ? value[0] : value; } +const defaultPublicListLimit = 24; +const maxPublicListLimit = 72; + +function isPagedListRequest(paramsQuery: QueryParams): boolean { + return asString(paramsQuery.limit) !== undefined || asString(paramsQuery.cursor) !== undefined; +} + +function cleanPublicListLimit(value: QueryValue): number { + const limit = Number(asString(value)); + return Number.isInteger(limit) && limit > 0 ? Math.min(limit, maxPublicListLimit) : defaultPublicListLimit; +} + +function encodeOffsetCursor(offset: number): string { + return Buffer.from(JSON.stringify({ offset }), 'utf8').toString('base64url'); +} + +function decodeOffsetCursor(value: QueryValue): number { + const rawValue = asString(value); + if (!rawValue) { + return 0; + } + + try { + const payload = JSON.parse(Buffer.from(rawValue, 'base64url').toString('utf8')) as { offset?: unknown }; + const offset = Number(payload.offset); + return Number.isInteger(offset) && offset >= 0 ? offset : 0; + } catch { + return 0; + } +} + +async function queryMaybePaged( + sql: string, + params: unknown[], + paramsQuery: QueryParams +): Promise> { + if (!isPagedListRequest(paramsQuery)) { + return query(sql, params); + } + + const limit = cleanPublicListLimit(paramsQuery.limit); + const offset = decodeOffsetCursor(paramsQuery.cursor); + const pagedParams = [...params, limit + 1, offset]; + const rows = await query( + ` + ${sql} + LIMIT $${pagedParams.length - 1} + OFFSET $${pagedParams.length} + `, + pagedParams + ); + const items = rows.slice(0, limit); + const nextOffset = offset + items.length; + + return { + items, + nextCursor: rows.length > limit ? encodeOffsetCursor(nextOffset) : null, + hasMore: rows.length > limit + }; +} + export function cleanLocale(value: unknown): string { const locale = typeof value === 'string' ? value.trim() : ''; return localePattern.test(locale) ? locale : defaultLocale; @@ -2698,14 +2764,16 @@ function cleanDailyChecklistPayload(payload: Record): DailyChec }; } -export async function listDailyChecklistItems(locale = defaultLocale) { +export async function listDailyChecklistItems(paramsQuery: QueryParams = {}, locale = defaultLocale) { const title = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale); - return query( + return queryMaybePaged( ` SELECT c.id, ${title} AS title, c.title AS "baseTitle", ${translationsSelect('daily-checklist-items', 'c.id')} AS translations FROM daily_checklist_items c ORDER BY c.sort_order, c.id - ` + `, + [], + paramsQuery ); } @@ -3007,7 +3075,7 @@ export async function reorderDailyChecklistItems(payload: Record 0 ? `WHERE ${conditions.join(' AND ')}` : ''; - return query(`${pokemonProjection(locale)} ${whereClause} ORDER BY ${orderByEntity('p')}`, params); + return queryMaybePaged(`${pokemonProjection(locale)} ${whereClause} ORDER BY ${orderByEntity('p')}`, params, paramsQuery); } export async function getPokemon(id: number, locale = defaultLocale) { @@ -6278,7 +6346,7 @@ export async function listHabitats(paramsQuery: QueryParams = {}, locale = defau const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; - return query(` + return queryMaybePaged(` SELECT h.id, ${habitatName} AS name, @@ -6314,7 +6382,7 @@ export async function listHabitats(paramsQuery: QueryParams = {}, locale = defau ${auditJoins('h', 'habitat_created_user', 'habitat_updated_user')} ${whereClause} ORDER BY ${orderByEntity('h')} - `, params); + `, params, paramsQuery); } export async function getHabitat(id: number, locale = defaultLocale) { @@ -6633,7 +6701,7 @@ export async function listItems(paramsQuery: QueryParams, locale = defaultLocale const orderClause = recipeOrder ? `ORDER BY CASE WHEN item_recipe.id IS NULL THEN 1 ELSE 0 END, item_recipe.sort_order, item_recipe.id, ${orderByEntity('i')}` : `ORDER BY ${orderByEntity('i')}`; - return query(`${itemProjection(locale)} ${whereClause} ${orderClause}`, params); + return queryMaybePaged(`${itemProjection(locale)} ${whereClause} ${orderClause}`, params, paramsQuery); } export async function getItem(id: number, locale = defaultLocale) { @@ -7135,7 +7203,7 @@ export async function listAncientArtifacts(paramsQuery: QueryParams = {}, locale } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; - return query(`${ancientArtifactProjection(locale)} ${whereClause} ORDER BY ${orderByEntity('i')}`, params); + return queryMaybePaged(`${ancientArtifactProjection(locale)} ${whereClause} ORDER BY ${orderByEntity('i')}`, params, paramsQuery); } export async function getAncientArtifact(id: number, locale = defaultLocale) { @@ -7278,7 +7346,7 @@ export async function listRecipes(paramsQuery: QueryParams = {}, locale = defaul } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; - return query(` + return queryMaybePaged(` SELECT r.id, ${resultItemName} AS name, @@ -7294,7 +7362,7 @@ export async function listRecipes(paramsQuery: QueryParams = {}, locale = defaul ${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')} ${whereClause} ORDER BY ${orderByEntity('r')} - `, params); + `, params, paramsQuery); } export async function getRecipe(id: number, locale = defaultLocale) { diff --git a/backend/src/server.ts b/backend/src/server.ts index 1d134af..c97c8dd 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1198,7 +1198,9 @@ app.get('/api/project-updates', async (request) => getProjectUpdates(request.query as Record) ); -app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request))); +app.get('/api/daily-checklist', async (request) => + listDailyChecklistItems(request.query as Record, requestLocale(request)) +); app.get('/api/users/:id/profile', async (request, reply) => { const { id } = request.params as { id: string }; diff --git a/frontend/src/components/EntityDiscussionPanel.vue b/frontend/src/components/EntityDiscussionPanel.vue index 079a7c7..47aff4c 100644 --- a/frontend/src/components/EntityDiscussionPanel.vue +++ b/frontend/src/components/EntityDiscussionPanel.vue @@ -2,6 +2,7 @@ import { Icon } from '@iconify/vue'; import { computed, onMounted, onUnmounted, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; +import LoadMoreSentinel from './LoadMoreSentinel.vue'; import StatusBadge from './StatusBadge.vue'; import Tabs, { type TabOption } from './Tabs.vue'; import { iconCancel, iconComment, iconDelete, iconReactionLike, iconReply, iconWarning } from '../icons'; @@ -776,17 +777,7 @@ onUnmounted(() => { -
- -
+
diff --git a/frontend/src/components/LoadMoreSentinel.vue b/frontend/src/components/LoadMoreSentinel.vue new file mode 100644 index 0000000..b03d781 --- /dev/null +++ b/frontend/src/components/LoadMoreSentinel.vue @@ -0,0 +1,68 @@ + + + diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index da80301..6f249de 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -109,6 +109,19 @@ export interface ProjectUpdatesParams { limit?: number; } +export interface ListPage { + items: T[]; + nextCursor: string | null; + hasMore: boolean; +} + +export interface PublicListParams { + cursor?: string | null; + limit?: number; +} + +export type PublicListQueryParams = Record & PublicListParams; + export interface EntityImage { path: string; url: string; @@ -997,11 +1010,11 @@ export interface RateLimitSettingsPayload { policies: Record; } -export function buildQuery(params: Record): string { +export function buildQuery(params: Record): string { const search = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { - if (value !== undefined && value !== '') { + if (value !== undefined && value !== null && value !== '') { search.set(key, String(value)); } }); @@ -1279,6 +1292,13 @@ export const api = { deletePermission: (id: string | number) => deleteJson(`/api/admin/permissions/${id}`), options: () => getJson('/api/options'), dailyChecklist: () => getJson('/api/daily-checklist'), + dailyChecklistPage: (params: PublicListParams = {}) => + getJson>( + `/api/daily-checklist${buildQuery({ + cursor: params.cursor ?? undefined, + limit: params.limit + })}` + ), lifePosts: (params: LifePostsParams = {}) => getJson( `/api/life-posts${buildQuery({ @@ -1395,6 +1415,14 @@ export const api = { deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`), pokemon: (params: Record) => getJson(`/api/pokemon${buildQuery(params)}`), + pokemonPage: (params: PublicListQueryParams) => + getJson>( + `/api/pokemon${buildQuery({ + ...params, + cursor: params.cursor ?? undefined, + limit: params.limit + })}` + ), pokemonDetail: (id: string | number) => getJson(`/api/pokemon/${id}`), pokemonFetchOptions: (search: string, signal?: AbortSignal, all = false) => getJson( @@ -1411,6 +1439,14 @@ export const api = { reorderPokemon: (ids: number[]) => sendJson('/api/admin/pokemon/order', 'PUT', { ids }), habitats: (params: Record = {}) => getJson(`/api/habitats${buildQuery(params)}`), + habitatsPage: (params: PublicListQueryParams = {}) => + getJson>( + `/api/habitats${buildQuery({ + ...params, + cursor: params.cursor ?? undefined, + limit: params.limit + })}` + ), habitatDetail: (id: string | number) => getJson(`/api/habitats/${id}`), createHabitat: (payload: HabitatPayload) => sendJson('/api/habitats', 'POST', payload), updateHabitat: (id: string | number, payload: HabitatPayload) => @@ -1419,6 +1455,14 @@ export const api = { reorderHabitats: (ids: number[]) => sendJson('/api/admin/habitats/order', 'PUT', { ids }), items: (params: Record) => getJson(`/api/items${buildQuery(params)}`), + itemsPage: (params: PublicListQueryParams) => + getJson>( + `/api/items${buildQuery({ + ...params, + cursor: params.cursor ?? undefined, + limit: params.limit + })}` + ), itemDetail: (id: string | number) => getJson(`/api/items/${id}`), createItem: (payload: ItemPayload) => sendJson('/api/items', 'POST', payload), updateItem: (id: string | number, payload: ItemPayload) => sendJson(`/api/items/${id}`, 'PUT', payload), @@ -1426,6 +1470,14 @@ export const api = { reorderItems: (ids: number[]) => sendJson('/api/admin/items/order', 'PUT', { ids }), ancientArtifacts: (params: Record = {}) => getJson(`/api/ancient-artifacts${buildQuery(params)}`), + ancientArtifactsPage: (params: PublicListQueryParams = {}) => + getJson>( + `/api/ancient-artifacts${buildQuery({ + ...params, + cursor: params.cursor ?? undefined, + limit: params.limit + })}` + ), ancientArtifactDetail: (id: string | number) => getJson(`/api/ancient-artifacts/${id}`), createAncientArtifact: (payload: AncientArtifactPayload) => sendJson('/api/ancient-artifacts', 'POST', payload), @@ -1436,6 +1488,14 @@ export const api = { sendJson('/api/admin/ancient-artifacts/order', 'PUT', { ids }), recipes: (params: Record = {}) => getJson(`/api/recipes${buildQuery(params)}`), + recipesPage: (params: PublicListQueryParams = {}) => + getJson>( + `/api/recipes${buildQuery({ + ...params, + cursor: params.cursor ?? undefined, + limit: params.limit + })}` + ), recipeDetail: (id: string | number) => getJson(`/api/recipes/${id}`), createRecipe: (payload: RecipePayload) => sendJson('/api/recipes', 'POST', payload), updateRecipe: (id: string | number, payload: RecipePayload) => diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 87b8fa5..39a49df 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -2946,6 +2946,10 @@ button:disabled, min-height: 1px; } +.load-more-sentinel { + min-height: 1px; +} + .life-feed__retry { display: flex; justify-content: center; diff --git a/frontend/src/views/AncientArtifactList.vue b/frontend/src/views/AncientArtifactList.vue index 03398a9..99b3394 100644 --- a/frontend/src/views/AncientArtifactList.vue +++ b/frontend/src/views/AncientArtifactList.vue @@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n'; import { useRoute } from 'vue-router'; import EntityCard from '../components/EntityCard.vue'; import FilterPanel from '../components/FilterPanel.vue'; +import LoadMoreSentinel from '../components/LoadMoreSentinel.vue'; import PageHeader from '../components/PageHeader.vue'; import Skeleton from '../components/Skeleton.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue'; @@ -19,6 +20,9 @@ const options = ref(null); const artifacts = ref([]); const currentUser = ref(null); const loading = ref(true); +const loadingMore = ref(false); +const nextCursor = ref(null); +const hasMoreArtifacts = ref(false); const search = ref(''); const categoryId = ref(''); const tagIds = ref([]); @@ -26,6 +30,8 @@ const tagIds = ref([]); const categorySkeletonWidths = ['64px', '132px', '132px', '86px']; const filterSkeletonWidths = ['52px', '36px']; const skeletonCardCount = 6; +const listPageSize = 24; +let loadRequestId = 0; const categoryTabs = computed(() => [ { value: '', label: t('common.all') }, @@ -43,10 +49,50 @@ function artifactCardImage(artifact: AncientArtifact) { return artifact.image ? { src: artifact.image.url, alt: t('media.imageAlt', { name: artifact.name }) } : undefined; } -async function loadArtifacts() { - loading.value = true; - artifacts.value = await api.ancientArtifacts(artifactQuery.value); - loading.value = false; +async function loadArtifacts(reset = true) { + if (!reset && (loading.value || loadingMore.value || !hasMoreArtifacts.value)) { + return; + } + + const requestId = ++loadRequestId; + if (reset) { + loading.value = true; + loadingMore.value = false; + nextCursor.value = null; + hasMoreArtifacts.value = false; + } else { + loadingMore.value = true; + } + + try { + const page = await api.ancientArtifactsPage({ + ...artifactQuery.value, + cursor: reset ? null : nextCursor.value, + limit: listPageSize + }); + + if (requestId !== loadRequestId) { + return; + } + + if (reset) { + artifacts.value = page.items; + } else { + const existingIds = new Set(artifacts.value.map((item) => item.id)); + artifacts.value = [...artifacts.value, ...page.items.filter((item) => !existingIds.has(item.id))]; + } + nextCursor.value = page.nextCursor; + hasMoreArtifacts.value = page.hasMore; + } finally { + if (requestId === loadRequestId) { + loading.value = false; + loadingMore.value = false; + } + } +} + +function loadMoreArtifacts() { + void loadArtifacts(false); } onMounted(async () => { @@ -61,7 +107,9 @@ onMounted(async () => { await loadArtifacts(); }); -watch(artifactQuery, loadArtifacts); +watch(artifactQuery, () => { + void loadArtifacts(); +});