From fde0567a0cab0a3e4dd68555030333e21eafc965 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 7 Oct 2026 13:09:41 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=AC=E5=BC=80=E7=9B=AE=E5=BD=95=E6=B8=B8?= =?UTF-8?q?=E6=A0=87=E5=88=86=E9=A1=B5=EF=BC=9AlistGames=20=E9=80=8F?= =?UTF-8?q?=E4=BC=A0=20limit/cursor+nextCursor=EF=BC=9B=E5=B9=BF=E5=9C=BA?= =?UTF-8?q?=E4=B8=8E=E5=88=9B=E4=BD=9C=E8=80=85=E4=B8=BB=E9=A1=B5=E6=8E=A5?= =?UTF-8?q?=E3=80=8C=E5=8A=A0=E8=BD=BD=E6=9B=B4=E5=A4=9A=E3=80=8D=EF=BC=88?= =?UTF-8?q?=E5=8E=BB=E9=87=8D=E8=BF=BD=E5=8A=A0/=E6=9C=AB=E9=A1=B5?= =?UTF-8?q?=E9=9A=90=E8=97=8F/=E5=A4=B1=E8=B4=A5=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E5=86=85=E5=AE=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/creator/CreatorPage.tsx | 80 +++++- src/components/creator/CreatorPages.test.tsx | 80 +++++- .../GameDistributionPages.test.tsx | 240 ++++++++++++++---- .../game-distribution/GameGalleryPage.tsx | 66 ++++- .../game-distribution/gameDistribution.css | 19 ++ .../PlatformEntryActiveFlowShell.test.tsx | 29 ++- src/services/gameDistributionClient.test.ts | 44 +++- src/services/gameDistributionClient.ts | 50 +++- 8 files changed, 525 insertions(+), 83 deletions(-) diff --git a/src/components/creator/CreatorPage.tsx b/src/components/creator/CreatorPage.tsx index 3d462a2e6..072614f83 100644 --- a/src/components/creator/CreatorPage.tsx +++ b/src/components/creator/CreatorPage.tsx @@ -71,12 +71,17 @@ function CreatorProfileContent({ const [profile, setProfile] = useState(null); const [error, setError] = useState(''); const [games, setGames] = useState(null); + /** 下一页游标(公开目录真分页):非空才渲染「加载更多」。 */ + const [nextCursor, setNextCursor] = useState(null); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [loadMoreError, setLoadMoreError] = useState(''); const [gameError, setGameError] = useState(''); const serial = useRef(0); const load = useCallback(() => { const request = ++serial.current; setError(''); setGameError(''); + setLoadMoreError(''); void getCreator(id) .then((value) => { if (serial.current === request) setProfile(value); @@ -85,13 +90,45 @@ function CreatorProfileContent({ if (serial.current === request) setError(reason.message); }); void listGames({ authorId: id }) - .then((value) => { - if (serial.current === request) setGames(value); + .then((page) => { + if (serial.current !== request) return; + setGames(page.games); + setNextCursor(page.nextCursor); }) .catch((reason) => { if (serial.current === request) setGameError(reason.message); }); }, [id]); + /** + * 创作者主页的「加载更多」:与广场同一套(游标原样回传、按 gameId 去重追加、 + * 失败保留已加载内容)。创作者的作品同样可能超过一页(默认 48)。 + */ + const loadMoreGames = useCallback(() => { + if (!nextCursor || isLoadingMore) return; + const request = serial.current; + setIsLoadingMore(true); + setLoadMoreError(''); + void listGames({ authorId: id, cursor: nextCursor }) + .then((page) => { + if (serial.current !== request) return; + setGames((current) => { + const base = current ?? []; + const seen = new Set(base.map((game) => game.id)); + const appended = page.games.filter((game) => !seen.has(game.id)); + return appended.length === 0 ? base : [...base, ...appended]; + }); + setNextCursor(page.nextCursor); + }) + .catch((reason) => { + if (serial.current !== request) return; + setLoadMoreError( + reason instanceof Error ? reason.message : '加载下一页失败', + ); + }) + .finally(() => { + if (serial.current === request) setIsLoadingMore(false); + }); + }, [id, isLoadingMore, nextCursor]); useEffect(() => { load(); window.addEventListener('focus', load); @@ -161,16 +198,35 @@ function CreatorProfileContent({ ) : games.length === 0 ? (

还没有公开游戏

) : ( -
- {games.map((game) => ( - } - onOpen={() => onOpenGame(game.id)} - /> - ))} -
+ <> +
+ {games.map((game) => ( + } + onOpen={() => onOpenGame(game.id)} + /> + ))} +
+ {nextCursor || loadMoreError ? ( +
+ {loadMoreError ? ( + {loadMoreError} + ) : null} + {nextCursor ? ( + + ) : null} +
+ ) : null} + )} )} diff --git a/src/components/creator/CreatorPages.test.tsx b/src/components/creator/CreatorPages.test.tsx index 2db17b131..84cf1d96b 100644 --- a/src/components/creator/CreatorPages.test.tsx +++ b/src/components/creator/CreatorPages.test.tsx @@ -11,10 +11,42 @@ import { import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import type { CreatorConnection } from '../../../packages/shared/src/contracts/creator'; +import type { GameDistributionGame } from '../../services/gameDistributionClient'; import { CreatorConnectionsPage } from './CreatorConnectionsPage'; import { CreatorPage } from './CreatorPage'; import { CreatorAuthor } from './CreatorRelationshipControl'; import { seedRelationship, setCreatorViewer } from './creatorRelationshipState'; + +/** 主页公开游戏列表的最小公开投影(分页用例只需要 id / 标题 / 封面位)。 */ +function createGame( + overrides: Partial = {}, +): GameDistributionGame { + return { + id: 'game-1', + title: '作品', + summary: '', + description: '', + category: '动作', + tags: [], + coverColor: '#d77a51', + icon: '✦', + author: { id: 'a', name: '作者' }, + deviceSupport: { desktop: true, mobile: true, touch: true }, + status: 'published', + publicationRevision: 1, + currentVersion: { + id: 'version-1', + version: '1.0.0', + entryUrl: 'https://play.example.test/v1/index.html', + sha256: 'sha256', + publishedAt: '2026-09-18T00:00:00.000Z', + controls: ['键盘'], + }, + playCount: 0, + createdAt: '2026-09-18T00:00:00.000Z', + ...overrides, + } as GameDistributionGame; +} const api = vi.hoisted(() => ({ viewer: 'a' as string | null, list: vi.fn(), @@ -78,7 +110,7 @@ beforeEach(() => { api.read.mockResolvedValue({ relationship: following }); api.list.mockResolvedValue({ items: [row()], nextCursor: null, total: 1 }); api.profile.mockResolvedValue({ user, followingCount: 1, followerCount: 2 }); - api.games.mockResolvedValue([]); + api.games.mockResolvedValue({ games: [], nextCursor: null }); }); afterEach(cleanup); it.each(['following', 'followers'] as const)( @@ -196,6 +228,52 @@ it('主页查询作者游戏,自己不显示关注按钮,空游戏仍有主 fireEvent.click(screen.getByRole('link', { name: '2 粉丝' })); expect(callbacks.onOpenConnections).toHaveBeenCalledWith('a', 'followers'); }); +it('主页作品列表支持游标「加载更多」:去重追加、末页隐藏入口、失败保留内容', async () => { + api.games + .mockResolvedValueOnce({ + games: [ + createGame({ id: 'game-1', title: '作者第一款' }), + createGame({ id: 'game-2', title: '作者第二款' }), + ], + nextCursor: 'c1', + }) + .mockRejectedValueOnce(new Error('网络开小差了')) + .mockResolvedValueOnce({ + // 第二页故意含重复 id + games: [ + createGame({ id: 'game-2', title: '作者第二款' }), + createGame({ id: 'game-3', title: '作者第三款' }), + ], + nextCursor: null, + }); + render( + , + ); + + await screen.findByText('作者第一款'); + expect(api.games).toHaveBeenCalledWith({ authorId: 'a' }); + // 有下一页就露出「加载更多」。 + fireEvent.click(await screen.findByRole('button', { name: '加载更多' })); + // 失败不清空已加载内容,只提示。 + expect(await screen.findByText('网络开小差了')).toBeTruthy(); + expect(screen.getByText('作者第一款')).toBeTruthy(); + + // 重试成功:追加去重(game-2 不重复)、末页隐藏入口。 + fireEvent.click(screen.getByRole('button', { name: '加载更多' })); + await screen.findByText('作者第三款'); + expect(api.games.mock.calls[1]?.[0]).toEqual({ + authorId: 'a', + cursor: 'c1', + }); + expect(screen.getAllByText('作者第二款')).toHaveLength(1); + expect(screen.queryByRole('button', { name: '加载更多' })).toBeNull(); + expect(screen.queryByText('网络开小差了')).toBeNull(); +}); it('游客点击关注仅唤起登录,自己作者也能打开主页', async () => { api.viewer = null; const view = render( diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index 9f949eb6f..5b02084f5 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -227,7 +227,7 @@ beforeEach(() => { relationship: { isSelf: false, isFollowing: true, isFollowedBy: false }, }); setViewportWidth(1024); - listGamesMock.mockResolvedValue([]); + listGamesMock.mockResolvedValue({ games: [], nextCursor: null }); getGameMock.mockResolvedValue(createGame()); getGameLineageMock.mockResolvedValue(null); getDerivedGamesMock.mockReset(); @@ -259,15 +259,116 @@ describe('GameGalleryPage', () => { [{ averageScore: null, ratingCount: 0 }, '暂无评分'], [undefined, '评分暂不可用'], ])('显示列表返回的评分摘要 %#', async (ratingSummary, expected) => { - listGamesMock.mockResolvedValueOnce([createGame({ ratingSummary })]); + listGamesMock.mockResolvedValueOnce({ + games: [createGame({ ratingSummary })], + nextCursor: null, + }); render(); expect(await screen.findByText(expected)).toBeTruthy(); }); + it('游标分页:加载更多按 gameId 去重追加,末页隐藏入口', async () => { + const firstPage = Array.from({ length: 48 }, (_, index) => + createGame({ id: `game-${index + 1}`, title: `目录第 ${index + 1} 款` }), + ); + // 第二页故意含一条与本页重复的 id(服务端稳定序下不该发生,但客户端必须去重)+ 两条新的。 + const secondPage = [ + createGame({ id: 'game-48', title: '目录第 48 款' }), + createGame({ id: 'game-49', title: '目录第 49 款' }), + createGame({ id: 'game-50', title: '目录第 50 款' }), + ]; + listGamesMock + .mockResolvedValueOnce({ games: firstPage, nextCursor: 'c1' }) + .mockResolvedValueOnce({ games: secondPage, nextCursor: null }); + render(); + + // 首屏 48 条(服务端默认页大小)+ 「加载更多」入口:不再是「永远只有 48 条」。 + await screen.findByText('目录第 48 款'); + const grid = document.querySelectorAll('.game-grid > *'); + expect(grid.length).toBe(48); + // 第一次请求不带游标(缺省页大小交给服务端)。 + expect(listGamesMock.mock.calls[0]?.[0]).toEqual({ + keyword: '', + category: '全部', + }); + + fireEvent.click(screen.getByRole('button', { name: '加载更多' })); + await screen.findByText('目录第 50 款'); + // 第二次请求原样回传服务端游标(不自己拼)。 + expect(listGamesMock.mock.calls[1]?.[0]).toEqual({ + keyword: '', + category: '全部', + cursor: 'c1', + }); + // 追加 + 去重:48 + 2(重复的 game-48 只留一份)。 + expect(document.querySelectorAll('.game-grid > *').length).toBe(50); + expect(screen.getAllByText('目录第 48 款')).toHaveLength(1); + expect(screen.queryByText('目录第 51 款')).toBeNull(); + // 末页 nextCursor = null:入口消失。 + expect(screen.queryByRole('button', { name: '加载更多' })).toBeNull(); + }); + + it('加载更多失败:保留已加载内容 + 可读提示,重试成功后追加', async () => { + listGamesMock + .mockResolvedValueOnce({ + games: [createGame({ id: 'game-1', title: '第一页作品' })], + nextCursor: 'c1', + }) + .mockRejectedValueOnce(new Error('网络开小差了')) + .mockResolvedValueOnce({ + games: [createGame({ id: 'game-2', title: '第二页作品' })], + nextCursor: null, + }); + render(); + + await screen.findByText('第一页作品'); + fireEvent.click(await screen.findByRole('button', { name: '加载更多' })); + + // 失败:已加载内容**不清空**,提示可读,入口还在(可重试)。 + expect(await screen.findByText('网络开小差了')).toBeTruthy(); + expect(screen.getByText('第一页作品')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '加载更多' })); + + await screen.findByText('第二页作品'); + expect(screen.getByText('第一页作品')).toBeTruthy(); + expect(screen.queryByText('网络开小差了')).toBeNull(); + expect(screen.queryByRole('button', { name: '加载更多' })).toBeNull(); + }); + + it('窄屏同样有「加载更多」且不横向溢出', async () => { + setViewportWidth(390); + listGamesMock.mockResolvedValueOnce({ + games: [createGame({ id: 'game-1', title: '窄屏第一页' })], + nextCursor: 'c1', + }); + render(); + + expect(await screen.findByText('窄屏第一页')).toBeTruthy(); + const more = screen.getByRole('button', { name: '加载更多' }); + expect(more).toBeTruthy(); + // 入口在页面容器内、不是浮层:容器宽度受约束(jsdom 无量宽,这里钉结构与样式契约)。 + expect(more.closest('.game-empty-actions')).toBeTruthy(); + const css = await import('node:fs').then((fs) => + fs.readFileSync( + 'src/components/game-distribution/gameDistribution.css', + 'utf8', + ), + ); + expect(css).toMatch( + /\.game-empty-actions \{[\s\S]{0,300}?flex-wrap: wrap;/u, + ); + }); + it('ignores a stale response after search keyword changes', async () => { - const first = deferred(); - const second = deferred(); + const first = deferred<{ + games: GameDistributionGame[]; + nextCursor: string | null; + }>(); + const second = deferred<{ + games: GameDistributionGame[]; + nextCursor: string | null; + }>(); listGamesMock .mockImplementationOnce(() => first.promise) .mockImplementationOnce(() => second.promise); @@ -283,9 +384,12 @@ describe('GameGalleryPage', () => { await waitFor(() => expect(listGamesMock).toHaveBeenCalledTimes(2)); const newerGame = createGame({ id: 'game-new', title: '新搜索结果' }); - second.resolve([newerGame]); + second.resolve({ games: [newerGame], nextCursor: null }); await screen.findByText('新搜索结果'); - first.resolve([createGame({ id: 'game-old', title: '旧搜索结果' })]); + first.resolve({ + games: [createGame({ id: 'game-old', title: '旧搜索结果' })], + nextCursor: null, + }); await waitFor(() => expect(screen.queryByText('旧搜索结果')).toBeNull()); }); }); @@ -1073,18 +1177,21 @@ describe('GameGalleryPage 筛选与返回上下文', () => { it('设备筛选按真实 deviceSupport 过滤,并写回地址栏', async () => { resetGalleryContext(); - listGamesMock.mockResolvedValue([ - createGame({ - id: 'game-desktop', - title: '桌面专属', - deviceSupport: { desktop: true, mobile: false, touch: false }, - }), - createGame({ - id: 'game-mobile', - title: '移动可玩', - deviceSupport: { desktop: false, mobile: true, touch: true }, - }), - ]); + listGamesMock.mockResolvedValue({ + games: [ + createGame({ + id: 'game-desktop', + title: '桌面专属', + deviceSupport: { desktop: true, mobile: false, touch: false }, + }), + createGame({ + id: 'game-mobile', + title: '移动可玩', + deviceSupport: { desktop: false, mobile: true, touch: true }, + }), + ], + nextCursor: null, + }); render(); expect(await screen.findByText('桌面专属')).toBeTruthy(); @@ -1101,13 +1208,16 @@ describe('GameGalleryPage 筛选与返回上下文', () => { it('筛选结果为空时提供重置筛选,并清理地址栏条件', async () => { resetGalleryContext(); - listGamesMock.mockResolvedValue([ - createGame({ - id: 'game-desktop', - title: '桌面专属', - deviceSupport: { desktop: true, mobile: false, touch: false }, - }), - ]); + listGamesMock.mockResolvedValue({ + games: [ + createGame({ + id: 'game-desktop', + title: '桌面专属', + deviceSupport: { desktop: true, mobile: false, touch: false }, + }), + ], + nextCursor: null, + }); render(); expect(await screen.findByText('桌面专属')).toBeTruthy(); @@ -1131,7 +1241,10 @@ describe('GameGalleryPage 筛选与返回上下文', () => { '', '/games?keyword=%E6%98%9F%E8%BD%A8&category=%E5%8A%A8%E4%BD%9C&device=%E7%A7%BB%E5%8A%A8', ); - listGamesMock.mockResolvedValue([createGame({ title: '星轨防线' })]); + listGamesMock.mockResolvedValue({ + games: [createGame({ title: '星轨防线' })], + nextCursor: null, + }); render(); await waitFor(() => @@ -1159,18 +1272,21 @@ describe('GameGalleryPage 筛选与返回上下文', () => { JSON.stringify({ keyword: '', category: '全部', device: '移动' }), ); window.sessionStorage.setItem('game-gallery-scroll:|全部|移动', '320'); - listGamesMock.mockResolvedValue([ - createGame({ - id: 'game-desktop', - title: '桌面专属', - deviceSupport: { desktop: true, mobile: false, touch: false }, - }), - createGame({ - id: 'game-mobile', - title: '移动可玩', - deviceSupport: { desktop: false, mobile: true, touch: true }, - }), - ]); + listGamesMock.mockResolvedValue({ + games: [ + createGame({ + id: 'game-desktop', + title: '桌面专属', + deviceSupport: { desktop: true, mobile: false, touch: false }, + }), + createGame({ + id: 'game-mobile', + title: '移动可玩', + deviceSupport: { desktop: false, mobile: true, touch: true }, + }), + ], + nextCursor: null, + }); const { container } = render(
@@ -1189,7 +1305,10 @@ describe('GameGalleryPage 筛选与返回上下文', () => { it('打开详情时保存目录滚动位置,返回后按同一容器复位', async () => { resetGalleryContext(); const onOpenDetail = vi.fn(); - listGamesMock.mockResolvedValue([createGame({ title: '星轨防线' })]); + listGamesMock.mockResolvedValue({ + games: [createGame({ title: '星轨防线' })], + nextCursor: null, + }); const { container } = render(
@@ -1214,10 +1333,13 @@ describe('GameGalleryPage 筛选与返回上下文', () => { it('首屏「立即试玩」滚动到列表,而不是打开第一款游戏详情', async () => { resetGalleryContext(); const onOpenDetail = vi.fn(); - listGamesMock.mockResolvedValue([ - createGame({ id: 'game-1', title: '星轨防线' }), - createGame({ id: 'game-2', title: '第二款' }), - ]); + listGamesMock.mockResolvedValue({ + games: [ + createGame({ id: 'game-1', title: '星轨防线' }), + createGame({ id: 'game-2', title: '第二款' }), + ], + nextCursor: null, + }); const scrollTo = vi.fn(); const originalScrollTo = Object.getOwnPropertyDescriptor( Element.prototype, @@ -1325,9 +1447,12 @@ describe('游戏封面与截图展示', () => { it('广场卡片用签名地址展示真实封面', async () => { resetGalleryContext(); - listGamesMock.mockResolvedValue([ - createGame({ coverObjectKey: 'game-distribution/cover/alpha.png' }), - ]); + listGamesMock.mockResolvedValue({ + games: [ + createGame({ coverObjectKey: 'game-distribution/cover/alpha.png' }), + ], + nextCursor: null, + }); render(); const card = (await screen.findByText('星轨防线')).closest('button'); @@ -1345,14 +1470,21 @@ describe('游戏封面与截图展示', () => { ? `https://assets.example.test/${objectKey}` : '', ); - listGamesMock.mockResolvedValue([ - createGame({ id: 'game-none', title: '没有封面', coverObjectKey: null }), - createGame({ - id: 'game-broken', - title: '换签失败', - coverObjectKey: 'game-distribution/cover/broken.png', - }), - ]); + listGamesMock.mockResolvedValue({ + games: [ + createGame({ + id: 'game-none', + title: '没有封面', + coverObjectKey: null, + }), + createGame({ + id: 'game-broken', + title: '换签失败', + coverObjectKey: 'game-distribution/cover/broken.png', + }), + ], + nextCursor: null, + }); render(); expect(await screen.findByText('没有封面')).toBeTruthy(); diff --git a/src/components/game-distribution/GameGalleryPage.tsx b/src/components/game-distribution/GameGalleryPage.tsx index 57f2f7b17..bc6db79c8 100644 --- a/src/components/game-distribution/GameGalleryPage.tsx +++ b/src/components/game-distribution/GameGalleryPage.tsx @@ -67,8 +67,12 @@ export function GameGalleryPage({ const [keywordInput, setKeywordInput] = useState(initialFilters.keyword); const [activeKeyword, setActiveKeyword] = useState(initialFilters.keyword); const [games, setGames] = useState([]); + /** 下一页游标:非空才渲染「加载更多」(末页服务端给 null)。 */ + const [nextCursor, setNextCursor] = useState(null); const [isLoading, setIsLoading] = useState(true); + const [isLoadingMore, setIsLoadingMore] = useState(false); const [error, setError] = useState(''); + const [loadMoreError, setLoadMoreError] = useState(''); const requestSerialRef = useRef(0); const scrollContainerRef = useRef(null); const gameListRef = useRef(null); @@ -123,10 +127,12 @@ export function GameGalleryPage({ const requestSerial = ++requestSerialRef.current; setIsLoading(true); setError(''); + setLoadMoreError(''); void listGames({ keyword: activeKeyword, category }) - .then((nextGames) => { + .then((page) => { if (requestSerial !== requestSerialRef.current) return; - setGames(nextGames); + setGames(page.games); + setNextCursor(page.nextCursor); }) .catch((loadError: unknown) => { if (requestSerial !== requestSerialRef.current) return; @@ -139,6 +145,38 @@ export function GameGalleryPage({ }); }, [activeKeyword, category]); + /** + * 「加载更多」:用服务端给的 `nextCursor` 取下一页并**按 gameId 去重后追加**。 + * + * 不静默截断、也不重置:失败时**保留已加载的内容**并给可读提示(用户能继续看已有的、可重试)。 + * 游标对客户端不透明,这里绝不自己拼。 + */ + const loadMoreGames = useCallback(() => { + if (!nextCursor || isLoadingMore) return; + const requestSerial = requestSerialRef.current; + setIsLoadingMore(true); + setLoadMoreError(''); + void listGames({ keyword: activeKeyword, category, cursor: nextCursor }) + .then((page) => { + if (requestSerial !== requestSerialRef.current) return; + setGames((current) => { + const seen = new Set(current.map((game) => game.id)); + const appended = page.games.filter((game) => !seen.has(game.id)); + return appended.length === 0 ? current : [...current, ...appended]; + }); + setNextCursor(page.nextCursor); + }) + .catch((loadError: unknown) => { + if (requestSerial !== requestSerialRef.current) return; + setLoadMoreError( + loadError instanceof Error ? loadError.message : '加载下一页失败', + ); + }) + .finally(() => { + if (requestSerial === requestSerialRef.current) setIsLoadingMore(false); + }); + }, [activeKeyword, category, isLoadingMore, nextCursor]); + useEffect(() => { if (lastPropKeywordRef.current === searchKeyword) return; lastPropKeywordRef.current = searchKeyword; @@ -397,6 +435,30 @@ export function GameGalleryPage({ ))}
) : null} + {/* + 游标分页的「加载更多」:只要有下一页游标就露出(哪怕当前设备筛选暂时没有可见条目, + 否则用户会卡在「空态 + 再也点不到下一页」)。失败**不清空**已加载内容,只给提示 + 重试。 + */} + {!isLoading && !error && (nextCursor || loadMoreError) ? ( +
+ {loadMoreError ? ( + + {loadMoreError} + + ) : null} + {nextCursor ? ( + void loadMoreGames()} + > + {isLoadingMore ? '正在加载…' : '加载更多'} + + ) : null} +
+ ) : null}
); } diff --git a/src/components/game-distribution/gameDistribution.css b/src/components/game-distribution/gameDistribution.css index 55a030850..4554d09b8 100644 --- a/src/components/game-distribution/gameDistribution.css +++ b/src/components/game-distribution/gameDistribution.css @@ -696,6 +696,25 @@ margin: 0; } +/* + * 空态/列表底部的操作区(广场「加载更多」、主题页「加载更多」、我的收藏「加载更多」共用)。 + * 之前只有类名没有样式,窄屏上按钮与提示可能横向挤成一排 —— 这里统一成可换行、居中、不溢出的行。 + */ +.game-empty-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.5rem; + margin-top: 1rem; + min-width: 0; +} +.game-gallery-more__error { + color: var(--platform-text-soft); + font-size: 0.8rem; + overflow-wrap: anywhere; +} + /* * 详情页**正文之后**的整宽血缘图区块(需求 2026-10-06:图可能很大,~230px 的侧栏塞不下)。 * 区块内嵌族谱页组件(embedded),所以这里只需要管「区块自己的标题 + 全屏入口」这一段。 diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx index c54b9c3b9..976c3c4e7 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx @@ -584,7 +584,9 @@ describe('PlatformEntryActiveFlowShell', () => { // 只读浏览页(族谱 / 共创主题)属于「作品展示」,不该被欢迎弹窗盖住首屏。 expect(screen.queryByRole('dialog', { name: '欢迎' })).toBeNull(); - expect(container.querySelector('.platform-mobile-home-welcome-dialog')).toBeNull(); + expect( + container.querySelector('.platform-mobile-home-welcome-dialog'), + ).toBeNull(); }, ); @@ -955,7 +957,10 @@ describe('游戏发布灰度入口', () => { agcTemplateLibraryEnabled: false, gameDistributionPublishEnabled: false, }); - gameDistributionMock.listGames.mockResolvedValue([]); + gameDistributionMock.listGames.mockResolvedValue({ + games: [], + nextCursor: null, + }); render(); @@ -980,7 +985,10 @@ describe('游戏发布灰度入口', () => { }); it('游戏广场等舞台仍保持页签容器滚动', async () => { - gameDistributionMock.listGames.mockResolvedValue([]); + gameDistributionMock.listGames.mockResolvedValue({ + games: [], + nextCursor: null, + }); const { container } = render( , @@ -996,7 +1004,10 @@ describe('游戏发布灰度入口', () => { it('灰度命中时广场展示发布入口', async () => { loginAsAuthor(); - gameDistributionMock.listGames.mockResolvedValue([]); + gameDistributionMock.listGames.mockResolvedValue({ + games: [], + nextCursor: null, + }); render(); @@ -1023,7 +1034,10 @@ describe('创作者页面深链与返回', () => { wechatBound: false, }; authUiMock.value.canAccessProtectedData = true; - gameDistributionMock.listGames.mockResolvedValue([]); + gameDistributionMock.listGames.mockResolvedValue({ + games: [], + nextCursor: null, + }); creatorClientMock.getCreator.mockResolvedValue({ user: authUiMock.value.user, followingCount: 0, @@ -1527,7 +1541,10 @@ describe('创作族谱', () => { describe('共创主题', () => { it('广场「共创」页签切到主题列表', async () => { window.history.replaceState(null, '', '/games'); - gameDistributionMock.listGames.mockResolvedValue([]); + gameDistributionMock.listGames.mockResolvedValue({ + games: [], + nextCursor: null, + }); gameDistributionMock.listThemes.mockResolvedValue({ themes: [], nextCursor: null, diff --git a/src/services/gameDistributionClient.test.ts b/src/services/gameDistributionClient.test.ts index dd8434eec..c2918c54f 100644 --- a/src/services/gameDistributionClient.test.ts +++ b/src/services/gameDistributionClient.test.ts @@ -92,9 +92,47 @@ describe('gameDistributionClient', () => { ), ), ); - const games = await listGames({ keyword: '星轨', category: '动作' }); - expect(games).toHaveLength(1); - expect(games[0]?.id).toBe('game-1'); + const page = await listGames({ keyword: '星轨', category: '动作' }); + expect(page.games).toHaveLength(1); + expect(page.games[0]?.id).toBe('game-1'); + // 没有 nextCursor 就是末页。 + expect(page.nextCursor).toBeNull(); + }); + + it('游标分页:limit / cursor 原样透传,nextCursor 原样回传', async () => { + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response( + JSON.stringify({ + games: [ + { id: 'game-2', title: '第二页', currentVersion: { id: 'v2' } }, + ], + nextCursor: '1759000000000000:game-2', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ), + ); + vi.stubGlobal('fetch', fetchMock); + const page = await listGames({ + keyword: '星轨', + category: '动作', + limit: 20, + cursor: '1759000000000000:game-1', + }); + // 参数名与顺序:服务端按 `limit`/`cursor` 读;游标对客户端不透明,原样带回。 + expect(fetchMock.mock.calls[0][0]).toBe( + `/api/game-distribution/games?keyword=${encodeURIComponent('星轨')}&category=${encodeURIComponent('动作')}&limit=20&cursor=${encodeURIComponent('1759000000000000:game-1')}`, + ); + expect(page.nextCursor).toBe('1759000000000000:game-2'); + expect(page.games.map((game) => game.id)).toEqual(['game-2']); + + // 不传 limit / cursor 时不带这两个参数(服务端取默认 48)。 + await listGames({}); + expect(fetchMock.mock.calls[1][0]).toBe('/api/game-distribution/games'); + // limit<=0 与空游标视同缺省,不发误导参数。 + await listGames({ limit: 0, cursor: ' ' }); + expect(fetchMock.mock.calls[2][0]).toBe('/api/game-distribution/games'); }); it('作者过滤由查询参数交给后端,显式空值不退回全站游戏', async () => { diff --git a/src/services/gameDistributionClient.ts b/src/services/gameDistributionClient.ts index 1579faba1..27038d7ff 100644 --- a/src/services/gameDistributionClient.ts +++ b/src/services/gameDistributionClient.ts @@ -56,6 +56,21 @@ export type GameListQuery = { authorId?: string; keyword?: string; category?: GameCategory | '全部'; + /** + * 一页条数。缺省 = 服务端默认 **48**;上限 **100**(超界服务端**截断**不报错,`0` 视作缺省)。 + */ + limit?: number; + /** + * 上一页响应里的 `nextCursor`,**原样回传**即可(对客户端不透明;末页为 `null`)。 + * 自己拼游标会破坏服务端的稳定全序(`created_at` 倒序 + `game_id` 升序),所以不允许。 + */ + cursor?: string; +}; + +/** 公开目录的一页:作品 + 下一页游标(末页 `nextCursor === null`)。 */ +export type GameListPage = { + games: GameDistributionGame[]; + nextCursor: string | null; }; export type GameVersionCreateResult = { @@ -127,22 +142,47 @@ async function requestJson( ); } -export async function listGames(query: GameListQuery = {}) { +/** + * 公开目录(`/games`)。**游标分页**(2026-10-07 服务端已实现真分页): + * - 只传 `limit` / `cursor`,`nextCursor` **原样透传**给调用方; + * - 服务端排序是稳定全序(`created_at` 倒序 + `game_id` 升序),所以「追加下一页」不会重不会漏; + * - 末页 `nextCursor = null`,调用方据此隐藏「加载更多」; + * - 非法游标服务端返回 400 + 稳定码 `CATALOG_INVALID_CURSOR`(前端只需展示消息、不要重试同一游标)。 + * + * 旧实现在这里丢掉 `nextCursor` 且不传 `limit`/`cursor`,于是第 49 条之后的作品在网页上 + * **永久不可见**——所以返回类型从「数组」改成「一页」,让调用方无法再假装「一次拿全」。 + */ +export async function listGames( + query: GameListQuery = {}, +): Promise { const params = new URLSearchParams(); if (query.authorId !== undefined) params.set('authorId', query.authorId); if (query.keyword?.trim()) params.set('keyword', query.keyword.trim()); if (query.category && query.category !== '全部') params.set('category', query.category); + if (typeof query.limit === 'number' && query.limit > 0) { + params.set('limit', String(Math.trunc(query.limit))); + } + if (query.cursor?.trim()) params.set('cursor', query.cursor.trim()); const search = params.toString(); - const payload = await requestJson( + const payload = await requestJson<{ games?: unknown; nextCursor?: unknown }>( `/games${search ? `?${search}` : ''}`, { method: 'GET' }, '读取游戏目录失败', PUBLIC_GAME_REQUEST_OPTIONS, ); - return normalizeGameList( - Array.isArray(payload) ? payload : (payload as { games?: unknown[] }).games, - ); + return { + games: normalizeGameList( + Array.isArray(payload) + ? payload + : (payload as { games?: unknown[] }).games, + ), + nextCursor: + typeof (payload as { nextCursor?: unknown }).nextCursor === 'string' && + (payload as { nextCursor: string }).nextCursor.trim() + ? (payload as { nextCursor: string }).nextCursor.trim() + : null, + }; } export type GameDistributionMyGame = GameDistributionGame & {