共创页签改为只列支持共创的作品(复用广场卡片+forkable=1+加载更多),主题列表页保留但撤下页签入口
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

This commit is contained in:
2026-10-07 14:25:38 +08:00
parent bfe336a53c
commit 1bb74aab6f
13 changed files with 347 additions and 44 deletions
@@ -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(
<GameGalleryPage
scope="forkable"
onOpenDetail={vi.fn()}
onOpenGallery={onOpenGallery}
/>,
);
// ① 请求带 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(<GameGalleryPage scope="forkable" onOpenDetail={vi.fn()} />);
expect(await screen.findByText('开放共创')).toBeTruthy();
expect(screen.queryByText('禁止共创')).toBeNull();
});
it('scope=forkable:空态说明「暂时没有开放共创的作品」,加载更多仍可用', async () => {
// ③ 空态
listGamesMock.mockResolvedValueOnce({ games: [], nextCursor: null });
const empty = render(
<GameGalleryPage scope="forkable" onOpenDetail={vi.fn()} />,
);
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(<GameGalleryPage scope="forkable" onOpenDetail={vi.fn()} />);
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[];
@@ -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<GameGalleryFilters | null>(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({
<section className="game-hero">
<div className="game-hero__copy">
<span className="game-eyebrow">
<Sparkles aria-hidden="true" /> 陶泥儿游戏分发
<Sparkles aria-hidden="true" />{' '}
{forkableOnly ? '陶泥儿共创' : '陶泥儿游戏分发'}
</span>
<h1>
把灵感做成游戏,
<br />
<em>现在就开始玩。</em>
</h1>
<p>
来自 AGC
创作者的轻量游戏集合。打开网页即可游玩,每一次尝试都值得被看见。
</p>
{forkableOnly ? (
<>
<h1>
这些作品欢迎你
<br />
<em>接着做下去。</em>
</h1>
<p>
这里只列出已开放共创的作品。打开卡片里的「开始共创」,
就能把整个工程复制到你的项目里继续改造。
</p>
</>
) : (
<>
<h1>
把灵感做成游戏,
<br />
<em>现在就开始玩。</em>
</h1>
<p>
来自 AGC
创作者的轻量游戏集合。打开网页即可游玩,每一次尝试都值得被看见。
</p>
</>
)}
<div className="game-hero__actions">
<PlatformActionButton
size="md"
@@ -298,8 +347,10 @@ export function GameGalleryPage({
<div className="game-toolbar" ref={gameListRef}>
<div className="game-toolbar__title">
<span className="game-eyebrow">发现好玩的</span>
<h2>游戏广场</h2>
<span className="game-eyebrow">
{forkableOnly ? '可以共创的作品' : '发现好玩的'}
</span>
<h2>{forkableOnly ? '共创广场' : '游戏广场'}</h2>
</div>
<div className="game-toolbar__actions">
<label className="game-search">
@@ -317,22 +368,41 @@ export function GameGalleryPage({
</label>
</div>
</div>
{onOpenThemes ? (
{onOpenGallery || onOpenThemes ? (
<div className="game-category-tabs" role="tablist" aria-label="游戏区">
<button
type="button"
role="tab"
aria-selected
className="game-category-tab game-category-tab--active"
aria-selected={!forkableOnly}
className={
forkableOnly
? 'game-category-tab'
: 'game-category-tab game-category-tab--active'
}
onClick={() => {
if (!forkableOnly) return;
onOpenGallery?.();
}}
>
游戏
</button>
{/*
「共创」页签展示的是**支持共创的作品卡片**(与游戏页签同一套卡片、同一套分页),
不再指向共创主题列表——主题列表页与路由仍然保留,只是这里没有入口了。
*/}
<button
type="button"
role="tab"
aria-selected={false}
className="game-category-tab"
onClick={onOpenThemes}
aria-selected={forkableOnly}
className={
forkableOnly
? 'game-category-tab game-category-tab--active'
: 'game-category-tab'
}
onClick={() => {
if (forkableOnly) return;
onOpenThemes?.();
}}
>
共创
</button>
@@ -402,12 +472,18 @@ export function GameGalleryPage({
) : null}
{!isLoading && !error && visibleGames.length === 0 ? (
<PlatformEmptyState surface="subpanel" size="panel">
<strong>还没有找到对应游戏</strong>
<strong>
{forkableOnly && !hasActiveFilters
? '暂时没有开放共创的作品'
: '还没有找到对应游戏'}
</strong>
<br />
<span>
{hasActiveFilters
? '换个关键词、分类或设备条件再试试。'
: '新的游戏正在路上,稍后再来看看。'}
: forkableOnly
? '作者把作品开放共创授权后,它会出现在这里。'
: '新的游戏正在路上,稍后再来看看。'}
</span>
{hasActiveFilters ? (
<div className="game-empty-actions">
@@ -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)) {
@@ -33,6 +33,8 @@ export const MOBILE_WELCOME_EXEMPT_STAGES: Readonly<Record<string, true>> = {
'game-lineage': true,
// 共创主题列表:`/games/themes`(公开只读)
'game-themes': true,
// 「共创」页签:只列支持共创的作品(公开只读,本身就是「作品展示」)
'game-co-creation': true,
// 共创主题详情:`/games/theme?id=<themeId>`(公开只读)
'game-theme': true,
};
@@ -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(
<PlatformEntryFlowShellImpl
selectionStage="game-co-creation"
setSelectionStage={setSelectionStage}
/>,
);
// 卡片来自广场同一套组件(标题可见),而不是主题卡。
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: [
@@ -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' ? (
<GameGalleryPage
searchKeyword={activeSearchKeyword}
scope="forkable"
onOpenDetail={openGameDetail}
onOpenGallery={openGames}
/>
) : selectionStage === 'game-themes' ? (
<GameThemesPage
onBack={backFromGameThemes}
@@ -12,6 +12,7 @@ export type SelectionStage =
| 'game-detail'
| 'game-compare'
| 'game-themes'
| 'game-co-creation'
| 'game-theme'
| 'game-play'
| 'game-mine'
+7 -3
View File
@@ -30,6 +30,9 @@ describe('appPageRoutes', () => {
'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',
+3
View File
@@ -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'],
+3
View File
@@ -45,6 +45,9 @@ describe('activeAppTitle', () => {
expect(resolveAppTitleForSelectionStage('game-themes')).toBe(
'共创主题 - 陶泥儿',
);
expect(resolveAppTitleForSelectionStage('game-co-creation')).toBe(
'共创 - 陶泥儿',
);
expect(resolveAppTitleForSelectionStage('game-theme')).toBe(
'共创主题详情 - 陶泥儿',
);
+1
View File
@@ -18,6 +18,7 @@ const APP_TITLE_BY_SELECTION_STAGE: Record<SelectionStage, string> = {
'game-detail': '游戏详情 - 陶泥儿',
'game-compare': '原版与二创对比 - 陶泥儿',
'game-themes': '共创主题 - 陶泥儿',
'game-co-creation': '共创 - 陶泥儿',
'game-theme': '共创主题详情 - 陶泥儿',
'game-play': '正在游玩 - 陶泥儿',
'game-mine': '我的游戏 - 陶泥儿',
@@ -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()
+9
View File
@@ -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 }>(