共创页签改为只列支持共创的作品(复用广场卡片+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
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:
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -45,6 +45,9 @@ describe('activeAppTitle', () => {
|
||||
expect(resolveAppTitleForSelectionStage('game-themes')).toBe(
|
||||
'共创主题 - 陶泥儿',
|
||||
);
|
||||
expect(resolveAppTitleForSelectionStage('game-co-creation')).toBe(
|
||||
'共创 - 陶泥儿',
|
||||
);
|
||||
expect(resolveAppTitleForSelectionStage('game-theme')).toBe(
|
||||
'共创主题详情 - 陶泥儿',
|
||||
);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }>(
|
||||
|
||||
Reference in New Issue
Block a user