From 1bb74aab6fcf17ad125a0dd3cd5f210f6079a7de Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 7 Oct 2026 14:25:38 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=B1=E5=88=9B=E9=A1=B5=E7=AD=BE=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=8F=AA=E5=88=97=E6=94=AF=E6=8C=81=E5=85=B1=E5=88=9B?= =?UTF-8?q?=E7=9A=84=E4=BD=9C=E5=93=81=EF=BC=88=E5=A4=8D=E7=94=A8=E5=B9=BF?= =?UTF-8?q?=E5=9C=BA=E5=8D=A1=E7=89=87+forkable=3D1+=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E6=9B=B4=E5=A4=9A=EF=BC=89=EF=BC=8C=E4=B8=BB=E9=A2=98=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E9=A1=B5=E4=BF=9D=E7=95=99=E4=BD=86=E6=92=A4=E4=B8=8B?= =?UTF-8?q?=E9=A1=B5=E7=AD=BE=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GameDistributionPages.test.tsx | 117 ++++++++++++++++ .../game-distribution/GameGalleryPage.tsx | 130 ++++++++++++++---- ...PlatformActiveMobileWelcomeDialog.test.tsx | 9 +- .../PlatformActiveMobileWelcomeDialog.tsx | 2 + .../PlatformEntryActiveFlowShell.test.tsx | 64 ++++++++- .../PlatformEntryActiveFlowShell.tsx | 23 +++- .../platformEntryActiveTypes.ts | 1 + src/routing/activeAppPageRoutes.test.ts | 10 +- src/routing/activeAppPageRoutes.ts | 3 + src/services/activeAppTitle.test.ts | 3 + src/services/activeAppTitle.ts | 1 + src/services/gameDistributionClient.test.ts | 19 +++ src/services/gameDistributionClient.ts | 9 ++ 13 files changed, 347 insertions(+), 44 deletions(-) diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index 484400289..ec9e849e6 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -360,6 +360,123 @@ describe('GameGalleryPage', () => { ); }); + it('scope=forkable(共创页签):请求带 forkable、渲染游戏卡片而非主题卡、无主题入口', async () => { + listGamesMock.mockResolvedValueOnce({ + games: [ + createGame({ + id: 'game-forkable', + title: '可以共创的作品', + forkAuthorization: 'full', + }), + ], + nextCursor: null, + }); + const onOpenGallery = vi.fn(); + render( + , + ); + + // ① 请求带 forkable(服务端未上线时参数会被忽略,客户端还有同义兜底过滤)。 + await screen.findByText('可以共创的作品'); + expect(listGamesMock.mock.calls[0]?.[0]).toEqual({ + keyword: '', + category: '全部', + forkable: true, + }); + // ② 卡片来自广场同一套组件(`.game-grid` + 卡片),不是主题卡。 + expect(document.querySelectorAll('.game-grid > *').length).toBe(1); + expect(document.querySelector('.game-themes-page')).toBeNull(); + expect(screen.queryByText(/棵作品树/u)).toBeNull(); + // ⑤ 「共创」页签是激活态,且**没有**任何指向主题列表的入口。 + expect( + screen.getByRole('tab', { name: '共创' }).getAttribute('aria-selected'), + ).toBe('true'); + expect( + screen.getByRole('tab', { name: '游戏' }).getAttribute('aria-selected'), + ).toBe('false'); + // 「游戏」页签调回调回广场(不自己改 URL)。 + fireEvent.click(screen.getByRole('tab', { name: '游戏' })); + expect(onOpenGallery).toHaveBeenCalledTimes(1); + }); + + it('scope=forkable:客户端兜底过滤掉禁止共创的作品(与服务端 forkable 同义)', async () => { + listGamesMock.mockResolvedValueOnce({ + games: [ + createGame({ + id: 'game-open', + title: '开放共创', + forkAuthorization: 'full', + }), + createGame({ + id: 'game-forbidden', + title: '禁止共创', + forkAuthorization: 'forbidden', + }), + ], + nextCursor: null, + }); + render(); + + expect(await screen.findByText('开放共创')).toBeTruthy(); + expect(screen.queryByText('禁止共创')).toBeNull(); + }); + + it('scope=forkable:空态说明「暂时没有开放共创的作品」,加载更多仍可用', async () => { + // ③ 空态 + listGamesMock.mockResolvedValueOnce({ games: [], nextCursor: null }); + const empty = render( + , + ); + expect(await screen.findByText('暂时没有开放共创的作品')).toBeTruthy(); + expect( + screen.getByText('作者把作品开放共创授权后,它会出现在这里。'), + ).toBeTruthy(); + empty.unmount(); + + // ④ 加载更多在共创页签同样可用,且**第二页请求也带 forkable**。 + listGamesMock + .mockResolvedValueOnce({ + games: [ + createGame({ + id: 'game-1', + title: '第一页共创作品', + forkAuthorization: 'full', + }), + ], + nextCursor: 'c1', + }) + .mockResolvedValueOnce({ + games: [ + createGame({ + id: 'game-2', + title: '第二页共创作品', + forkAuthorization: 'full', + }), + ], + nextCursor: null, + }); + render(); + + await screen.findByText('第一页共创作品'); + fireEvent.click(await screen.findByRole('button', { name: '加载更多' })); + await screen.findByText('第二页共创作品'); + // 「加载更多」那一次请求 = 带游标的那一次(前面的调用都是首屏加载)。 + const loadMoreCall = listGamesMock.mock.calls.find( + ([query]) => query && typeof query === 'object' && 'cursor' in query, + ); + expect(loadMoreCall?.[0]).toEqual({ + keyword: '', + category: '全部', + cursor: 'c1', + forkable: true, + }); + expect(screen.queryByRole('button', { name: '加载更多' })).toBeNull(); + }); + it('ignores a stale response after search keyword changes', async () => { const first = deferred<{ games: GameDistributionGame[]; diff --git a/src/components/game-distribution/GameGalleryPage.tsx b/src/components/game-distribution/GameGalleryPage.tsx index bc6db79c8..c6b701763 100644 --- a/src/components/game-distribution/GameGalleryPage.tsx +++ b/src/components/game-distribution/GameGalleryPage.tsx @@ -5,6 +5,7 @@ import { GameCard } from '@genarrative/shared/components'; import { Gamepad2, Monitor, Search, Smartphone, Sparkles } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { resolveGameForkAuthorization } from '../../../packages/shared/src/contracts/gameDistribution'; import { type GameCategory, type GameDistributionGame, @@ -35,17 +36,35 @@ type GameGalleryPageProps = { onOpenDetail: (gameId: string) => void; onOpenMyGames?: () => void; onOpenPublish?: () => void; - /** 切到「共创」页签(`/games/themes`);缺失时不渲染该页签。 */ + /** + * 页签作用域:`all` = 游戏广场(全部公开作品,默认);`forkable` = **共创页签** + * (只列**支持共创**的作品,请求带 `forkable=1`)。 + */ + scope?: 'all' | 'forkable'; + /** 切到「游戏」页签(`/games`);缺失时不渲染该页签。 */ + onOpenGallery?: () => void; + /** + * 切到「共创」页签(只列支持共创的作品);缺失时不渲染该页签。 + * 注意:它**不再**指向共创主题列表——主题列表页与路由仍保留,只是没有页签入口了。 + */ onOpenThemes?: () => void; }; +/** 页签作用域 → 只保留了「支持共创」这一条口径的过滤(与 `forkable=1` 同义)。 */ +function isForkable(game: GameDistributionGame) { + return resolveGameForkAuthorization(game.forkAuthorization) !== 'forbidden'; +} + export function GameGalleryPage({ searchKeyword = '', onOpenDetail, onOpenMyGames, onOpenPublish, + scope = 'all', + onOpenGallery, onOpenThemes, }: GameGalleryPageProps) { + const forkableOnly = scope === 'forkable'; const initialFiltersRef = useRef(null); if (initialFiltersRef.current === null) { const queryFilters = @@ -128,7 +147,12 @@ export function GameGalleryPage({ setIsLoading(true); setError(''); setLoadMoreError(''); - void listGames({ keyword: activeKeyword, category }) + void listGames({ + keyword: activeKeyword, + category, + // 「共创」页签只列支持共创的作品;服务端口径未上线期间下面还有一层同义兜底过滤。 + ...(forkableOnly ? { forkable: true } : {}), + }) .then((page) => { if (requestSerial !== requestSerialRef.current) return; setGames(page.games); @@ -143,7 +167,7 @@ export function GameGalleryPage({ .finally(() => { if (requestSerial === requestSerialRef.current) setIsLoading(false); }); - }, [activeKeyword, category]); + }, [activeKeyword, category, forkableOnly]); /** * 「加载更多」:用服务端给的 `nextCursor` 取下一页并**按 gameId 去重后追加**。 @@ -156,7 +180,12 @@ export function GameGalleryPage({ const requestSerial = requestSerialRef.current; setIsLoadingMore(true); setLoadMoreError(''); - void listGames({ keyword: activeKeyword, category, cursor: nextCursor }) + void listGames({ + keyword: activeKeyword, + category, + cursor: nextCursor, + ...(forkableOnly ? { forkable: true } : {}), + }) .then((page) => { if (requestSerial !== requestSerialRef.current) return; setGames((current) => { @@ -175,7 +204,7 @@ export function GameGalleryPage({ .finally(() => { if (requestSerial === requestSerialRef.current) setIsLoadingMore(false); }); - }, [activeKeyword, category, isLoadingMore, nextCursor]); + }, [activeKeyword, category, forkableOnly, isLoadingMore, nextCursor]); useEffect(() => { if (lastPropKeywordRef.current === searchKeyword) return; @@ -218,8 +247,11 @@ export function GameGalleryPage({ }, [scrollKey]); const visibleGames = useMemo( - () => games.filter((game) => matchesGameDeviceFilter(game, device)), - [device, games], + () => + games + .filter((game) => (forkableOnly ? isForkable(game) : true)) + .filter((game) => matchesGameDeviceFilter(game, device)), + [device, forkableOnly, games], ); const hasActiveFilters = activeKeyword.trim().length > 0 || category !== '全部' || device !== '全部'; @@ -269,17 +301,34 @@ export function GameGalleryPage({
- -

- 把灵感做成游戏, -
- 现在就开始玩。 -

-

- 来自 AGC - 创作者的轻量游戏集合。打开网页即可游玩,每一次尝试都值得被看见。 -

+ {forkableOnly ? ( + <> +

+ 这些作品欢迎你 +
+ 接着做下去。 +

+

+ 这里只列出已开放共创的作品。打开卡片里的「开始共创」, + 就能把整个工程复制到你的项目里继续改造。 +

+ + ) : ( + <> +

+ 把灵感做成游戏, +
+ 现在就开始玩。 +

+

+ 来自 AGC + 创作者的轻量游戏集合。打开网页即可游玩,每一次尝试都值得被看见。 +

+ + )}
- 发现好玩的 -

游戏广场

+ + {forkableOnly ? '可以共创的作品' : '发现好玩的'} + +

{forkableOnly ? '共创广场' : '游戏广场'}

- {onOpenThemes ? ( + {onOpenGallery || onOpenThemes ? (
+ {/* + 「共创」页签展示的是**支持共创的作品卡片**(与游戏页签同一套卡片、同一套分页), + 不再指向共创主题列表——主题列表页与路由仍然保留,只是这里没有入口了。 + */} @@ -402,12 +472,18 @@ export function GameGalleryPage({ ) : null} {!isLoading && !error && visibleGames.length === 0 ? ( - 还没有找到对应游戏 + + {forkableOnly && !hasActiveFilters + ? '暂时没有开放共创的作品' + : '还没有找到对应游戏'} +
{hasActiveFilters ? '换个关键词、分类或设备条件再试试。' - : '新的游戏正在路上,稍后再来看看。'} + : forkableOnly + ? '作者把作品开放共创授权后,它会出现在这里。' + : '新的游戏正在路上,稍后再来看看。'} {hasActiveFilters ? (
diff --git a/src/components/platform-entry/PlatformActiveMobileWelcomeDialog.test.tsx b/src/components/platform-entry/PlatformActiveMobileWelcomeDialog.test.tsx index 21b33b349..ade916523 100644 --- a/src/components/platform-entry/PlatformActiveMobileWelcomeDialog.test.tsx +++ b/src/components/platform-entry/PlatformActiveMobileWelcomeDialog.test.tsx @@ -47,9 +47,12 @@ test('shows the active mobile welcome when entering the mobile site shell', () = test('mobile welcome exempts read-only browsing stages(族谱 / 共创主题)', () => { // 防退化:白名单内容必须与实现一致(不是只断言某一个 stage)。 - expect( - Object.keys(MOBILE_WELCOME_EXEMPT_STAGES).sort(), - ).toEqual(['game-lineage', 'game-theme', 'game-themes']); + expect(Object.keys(MOBILE_WELCOME_EXEMPT_STAGES).sort()).toEqual([ + 'game-co-creation', + 'game-lineage', + 'game-theme', + 'game-themes', + ]); // 每个豁免 stage(含带 ?id= 的公开深链)在移动端都不再弹。 for (const stage of Object.keys(MOBILE_WELCOME_EXEMPT_STAGES)) { diff --git a/src/components/platform-entry/PlatformActiveMobileWelcomeDialog.tsx b/src/components/platform-entry/PlatformActiveMobileWelcomeDialog.tsx index 54338e941..384fd50ad 100644 --- a/src/components/platform-entry/PlatformActiveMobileWelcomeDialog.tsx +++ b/src/components/platform-entry/PlatformActiveMobileWelcomeDialog.tsx @@ -33,6 +33,8 @@ export const MOBILE_WELCOME_EXEMPT_STAGES: Readonly> = { 'game-lineage': true, // 共创主题列表:`/games/themes`(公开只读) 'game-themes': true, + // 「共创」页签:只列支持共创的作品(公开只读,本身就是「作品展示」) + 'game-co-creation': true, // 共创主题详情:`/games/theme?id=`(公开只读) 'game-theme': true, }; diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx index 976c3c4e7..e3d0774ad 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx @@ -1538,8 +1538,8 @@ describe('创作族谱', () => { }); }); -describe('共创主题', () => { - it('广场「共创」页签切到主题列表', async () => { +describe('共创页签(只列支持共创的作品)', () => { + it('广场「共创」页签切到 game-co-creation(不再是主题列表)', async () => { window.history.replaceState(null, '', '/games'); gameDistributionMock.listGames.mockResolvedValue({ games: [], @@ -1559,12 +1559,66 @@ describe('共创主题', () => { ); fireEvent.click(await screen.findByRole('tab', { name: '共创' })); - expect(setSelectionStage).toHaveBeenCalledWith('game-themes', { - path: '/games/themes', + expect(setSelectionStage).toHaveBeenCalledWith('game-co-creation', { + path: '/games/co-creation', }); + // 主题列表接口在这条路径上**不该**被请求(页签已不指向主题)。 + expect(gameDistributionMock.listThemes).not.toHaveBeenCalled(); }); - it('主题列表页渲染主题并进入主题页', async () => { + it('game-co-creation 渲染的是游戏卡片,且请求带 forkable=1', async () => { + window.history.replaceState(null, '', '/games/co-creation'); + gameDistributionMock.listGames.mockResolvedValue({ + games: [ + { + id: 'game-forkable', + title: '可以共创的作品', + summary: '简介', + description: '描述', + category: '动作', + tags: [], + coverColor: '#d77a51', + icon: '✦', + author: { id: 'author-1', 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: 3, + createdAt: '2026-09-18T00:00:00.000Z', + forkAuthorization: 'full', + }, + ], + nextCursor: null, + }); + const setSelectionStage = vi.fn(); + + render( + , + ); + + // 卡片来自广场同一套组件(标题可见),而不是主题卡。 + expect(await screen.findByText('可以共创的作品')).toBeTruthy(); + expect(gameDistributionMock.listGames).toHaveBeenCalledWith( + expect.objectContaining({ forkable: true }), + ); + // 共创页签里不该出现主题列表页。 + expect(document.querySelector('.game-themes-page')).toBeNull(); + }); +}); + +describe('共创主题', () => { + it('主题列表页(保留路由、无页签入口)渲染主题并进入主题页', async () => { window.history.replaceState(null, '', '/games/themes'); gameDistributionMock.listThemes.mockResolvedValue({ themes: [ diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx index 78cd2da6e..95e2f3741 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx @@ -606,9 +606,12 @@ export function PlatformEntryFlowShellImpl({ setSelectionStage('games', { path: '/games' }); }, [setSelectionStage]); - /** 共创主题列表(`/games` 的「共创」页签)。 */ - const openGameThemes = useCallback(() => { - setSelectionStage('game-themes', { path: '/games/themes' }); + /** + * 「共创」页签:**只列支持共创的作品**(游戏卡片 + 同一套游标分页)。 + * 它不再指向共创主题列表——主题列表页(stage `game-themes`)与路由保留,只是没有页签入口。 + */ + const openGameCoCreation = useCallback(() => { + setSelectionStage('game-co-creation', { path: '/games/co-creation' }); }, [setSelectionStage]); const backFromGameThemes = useCallback(() => { @@ -634,8 +637,8 @@ export function PlatformEntryFlowShellImpl({ window.history.back(); return; } - replaceAppHistoryPath('/games/themes'); - setSelectionStage('game-themes', { path: '/games/themes' }); + replaceAppHistoryPath('/games/co-creation'); + setSelectionStage('game-co-creation', { path: '/games/co-creation' }); }, [setSelectionStage]); /** @@ -774,6 +777,7 @@ export function PlatformEntryFlowShellImpl({ selectionStage === 'game-detail' || selectionStage === 'game-compare' || selectionStage === 'game-themes' || + selectionStage === 'game-co-creation' || selectionStage === 'game-theme' || selectionStage === 'game-play' || selectionStage === 'game-mine' || @@ -1049,7 +1053,7 @@ export function PlatformEntryFlowShellImpl({ searchKeyword={activeSearchKeyword} onOpenDetail={openGameDetail} onOpenMyGames={openMyGames} - onOpenThemes={openGameThemes} + onOpenThemes={openGameCoCreation} onOpenPublish={ gamePublishGateAllowed ? openGamePublish : undefined } @@ -1103,6 +1107,13 @@ export function PlatformEntryFlowShellImpl({ onBack={backFromGameCompare} onOpenDetail={openGameDetail} /> + ) : selectionStage === 'game-co-creation' ? ( + ) : selectionStage === 'game-themes' ? ( { 'game-compare', ); expect(resolveSelectionStageFromPath('/games/themes')).toBe('game-themes'); + expect(resolveSelectionStageFromPath('/games/co-creation')).toBe( + 'game-co-creation', + ); expect(resolveSelectionStageFromPath('/games/theme')).toBe('game-theme'); expect(resolveSelectionStageFromPath('/games/play')).toBe('game-play'); expect(resolveSelectionStageFromPath('/games/mine')).toBe('game-mine'); @@ -54,11 +57,12 @@ describe('appPageRoutes', () => { expect(resolvePathForSelectionStage('games')).toBe('/games'); expect(resolvePathForSelectionStage('game-compare')).toBe('/games/compare'); expect(resolvePathForSelectionStage('game-themes')).toBe('/games/themes'); + expect(resolvePathForSelectionStage('game-co-creation')).toBe( + '/games/co-creation', + ); expect(resolvePathForSelectionStage('game-theme')).toBe('/games/theme'); expect(resolvePathForSelectionStage('game-mine')).toBe('/games/mine'); - expect(resolvePathForSelectionStage('game-lineage')).toBe( - '/games/lineage', - ); + expect(resolvePathForSelectionStage('game-lineage')).toBe('/games/lineage'); expect(resolvePathForSelectionStage('game-publish')).toBe('/games/publish'); expect(resolvePathForSelectionStage('profile-payment')).toBe( '/profile/payment', diff --git a/src/routing/activeAppPageRoutes.ts b/src/routing/activeAppPageRoutes.ts index d366ce807..5b4ed76a4 100644 --- a/src/routing/activeAppPageRoutes.ts +++ b/src/routing/activeAppPageRoutes.ts @@ -17,6 +17,9 @@ const STAGE_ROUTE_ENTRIES = [ ['game-detail', '/games/detail'], ['game-compare', '/games/compare'], ['game-themes', '/games/themes'], + // 「共创」页签:只列**支持共创**的作品(游戏卡片 + 同一套游标分页)。 + // 主题列表(`/games/themes`)与主题详情仍保留路由,只是页签不再指向它们。 + ['game-co-creation', '/games/co-creation'], ['game-theme', '/games/theme'], ['game-play', '/games/play'], ['game-mine', '/games/mine'], diff --git a/src/services/activeAppTitle.test.ts b/src/services/activeAppTitle.test.ts index beb61e0b0..e8dbd564d 100644 --- a/src/services/activeAppTitle.test.ts +++ b/src/services/activeAppTitle.test.ts @@ -45,6 +45,9 @@ describe('activeAppTitle', () => { expect(resolveAppTitleForSelectionStage('game-themes')).toBe( '共创主题 - 陶泥儿', ); + expect(resolveAppTitleForSelectionStage('game-co-creation')).toBe( + '共创 - 陶泥儿', + ); expect(resolveAppTitleForSelectionStage('game-theme')).toBe( '共创主题详情 - 陶泥儿', ); diff --git a/src/services/activeAppTitle.ts b/src/services/activeAppTitle.ts index 2111df2e3..a76a2a382 100644 --- a/src/services/activeAppTitle.ts +++ b/src/services/activeAppTitle.ts @@ -18,6 +18,7 @@ const APP_TITLE_BY_SELECTION_STAGE: Record = { 'game-detail': '游戏详情 - 陶泥儿', 'game-compare': '原版与二创对比 - 陶泥儿', 'game-themes': '共创主题 - 陶泥儿', + 'game-co-creation': '共创 - 陶泥儿', 'game-theme': '共创主题详情 - 陶泥儿', 'game-play': '正在游玩 - 陶泥儿', 'game-mine': '我的游戏 - 陶泥儿', diff --git a/src/services/gameDistributionClient.test.ts b/src/services/gameDistributionClient.test.ts index c2918c54f..61daf11ba 100644 --- a/src/services/gameDistributionClient.test.ts +++ b/src/services/gameDistributionClient.test.ts @@ -135,6 +135,25 @@ describe('gameDistributionClient', () => { expect(fetchMock.mock.calls[2][0]).toBe('/api/game-distribution/games'); }); + it('forkable 只发 1、省略时不带该参数(服务端未上线也不影响)', async () => { + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ games: [], nextCursor: null }), { + status: 200, + }), + ), + ); + vi.stubGlobal('fetch', fetchMock); + await listGames({ forkable: true }); + expect(fetchMock.mock.calls[0][0]).toBe( + '/api/game-distribution/games?forkable=1', + ); + await listGames({ forkable: false }); + expect(fetchMock.mock.calls[1][0]).toBe('/api/game-distribution/games'); + await listGames(); + expect(fetchMock.mock.calls[2][0]).toBe('/api/game-distribution/games'); + }); + it('作者过滤由查询参数交给后端,显式空值不退回全站游戏', async () => { const fetchMock = vi .fn() diff --git a/src/services/gameDistributionClient.ts b/src/services/gameDistributionClient.ts index 27038d7ff..cb798ee70 100644 --- a/src/services/gameDistributionClient.ts +++ b/src/services/gameDistributionClient.ts @@ -60,6 +60,14 @@ export type GameListQuery = { * 一页条数。缺省 = 服务端默认 **48**;上限 **100**(超界服务端**截断**不报错,`0` 视作缺省)。 */ limit?: number; + /** + * 只返回**支持共创**的作品(`forkAuthorization != 'forbidden'`)。 + * + * 契约(2026-10-07 冻结):请求参数 `forkable=1`;省略 = 全部(与现状一致)。 + * 分页语义完全沿用(默认 48 / 上限 100、`created_at` 倒序 + game_id 升序、游标不透明、 + * 末页 `null`、非法游标 `CATALOG_INVALID_CURSOR`)。 + */ + forkable?: boolean; /** * 上一页响应里的 `nextCursor`,**原样回传**即可(对客户端不透明;末页为 `null`)。 * 自己拼游标会破坏服务端的稳定全序(`created_at` 倒序 + `game_id` 升序),所以不允许。 @@ -163,6 +171,7 @@ export async function listGames( if (typeof query.limit === 'number' && query.limit > 0) { params.set('limit', String(Math.trunc(query.limit))); } + if (query.forkable) params.set('forkable', '1'); if (query.cursor?.trim()) params.set('cursor', query.cursor.trim()); const search = params.toString(); const payload = await requestJson<{ games?: unknown; nextCursor?: unknown }>(