公开目录游标分页:listGames 透传 limit/cursor+nextCursor;广场与创作者主页接「加载更多」(去重追加/末页隐藏/失败保留内容)
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 13:09:41 +08:00
parent 9f13a3b45c
commit fde0567a0c
8 changed files with 525 additions and 83 deletions
+68 -12
View File
@@ -71,12 +71,17 @@ function CreatorProfileContent({
const [profile, setProfile] = useState<CreatorProfile | null>(null);
const [error, setError] = useState('');
const [games, setGames] = useState<GameDistributionGame[] | null>(null);
/** 下一页游标(公开目录真分页):非空才渲染「加载更多」。 */
const [nextCursor, setNextCursor] = useState<string | null>(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 ? (
<p>还没有公开游戏</p>
) : (
<div className="game-grid">
{games.map((game) => (
<GameCard
key={game.id}
game={game}
cover={<GameCover game={game} />}
onOpen={() => onOpenGame(game.id)}
/>
))}
</div>
<>
<div className="game-grid">
{games.map((game) => (
<GameCard
key={game.id}
game={game}
cover={<GameCover game={game} />}
onOpen={() => onOpenGame(game.id)}
/>
))}
</div>
{nextCursor || loadMoreError ? (
<div className="game-empty-actions">
{loadMoreError ? (
<span role="status">{loadMoreError}</span>
) : null}
{nextCursor ? (
<button
type="button"
className="creator-follow"
disabled={isLoadingMore}
onClick={() => void loadMoreGames()}
>
{isLoadingMore ? '正在加载…' : '加载更多'}
</button>
) : null}
</div>
) : null}
</>
)}
</>
)}
+79 -1
View File
@@ -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> = {},
): 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(
<CreatorPage
id="a"
onOpenConnections={callbacks.onOpenConnections}
onOpenGame={vi.fn()}
onBack={vi.fn()}
/>,
);
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(
@@ -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(<GameGalleryPage onOpenDetail={vi.fn()} />);
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(<GameGalleryPage onOpenDetail={vi.fn()} />);
// 首屏 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(<GameGalleryPage onOpenDetail={vi.fn()} />);
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(<GameGalleryPage onOpenDetail={vi.fn()} />);
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<GameDistributionGame[]>();
const second = deferred<GameDistributionGame[]>();
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(<GameGalleryPage onOpenDetail={vi.fn()} />);
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(<GameGalleryPage onOpenDetail={vi.fn()} />);
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(<GameGalleryPage onOpenDetail={vi.fn()} />);
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(
<div className="platform-tab-panel">
@@ -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(
<div className="platform-tab-panel">
<GameGalleryPage onOpenDetail={onOpenDetail} />
@@ -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(<GameGalleryPage onOpenDetail={vi.fn()} />);
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(<GameGalleryPage onOpenDetail={vi.fn()} />);
expect(await screen.findByText('没有封面')).toBeTruthy();
@@ -67,8 +67,12 @@ export function GameGalleryPage({
const [keywordInput, setKeywordInput] = useState(initialFilters.keyword);
const [activeKeyword, setActiveKeyword] = useState(initialFilters.keyword);
const [games, setGames] = useState<GameDistributionGame[]>([]);
/** 下一页游标:非空才渲染「加载更多」(末页服务端给 null)。 */
const [nextCursor, setNextCursor] = useState<string | null>(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<HTMLElement | null>(null);
const gameListRef = useRef<HTMLDivElement | null>(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({
))}
</div>
) : null}
{/*
游标分页的「加载更多」:只要有下一页游标就露出(哪怕当前设备筛选暂时没有可见条目,
否则用户会卡在「空态 + 再也点不到下一页」)。失败**不清空**已加载内容,只给提示 + 重试。
*/}
{!isLoading && !error && (nextCursor || loadMoreError) ? (
<div className="game-empty-actions">
{loadMoreError ? (
<span className="game-gallery-more__error" role="status">
{loadMoreError}
</span>
) : null}
{nextCursor ? (
<PlatformActionButton
tone="secondary"
size="sm"
shape="pill"
disabled={isLoadingMore}
onClick={() => void loadMoreGames()}
>
{isLoadingMore ? '正在加载…' : '加载更多'}
</PlatformActionButton>
) : null}
</div>
) : null}
</div>
);
}
@@ -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),所以这里只需要管「区块自己的标题 + 全屏入口」这一段。
@@ -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(<StatefulPlatformEntryFlowShell initialStage="games" />);
@@ -980,7 +985,10 @@ describe('游戏发布灰度入口', () => {
});
it('游戏广场等舞台仍保持页签容器滚动', async () => {
gameDistributionMock.listGames.mockResolvedValue([]);
gameDistributionMock.listGames.mockResolvedValue({
games: [],
nextCursor: null,
});
const { container } = render(
<StatefulPlatformEntryFlowShell initialStage="games" />,
@@ -996,7 +1004,10 @@ describe('游戏发布灰度入口', () => {
it('灰度命中时广场展示发布入口', async () => {
loginAsAuthor();
gameDistributionMock.listGames.mockResolvedValue([]);
gameDistributionMock.listGames.mockResolvedValue({
games: [],
nextCursor: null,
});
render(<StatefulPlatformEntryFlowShell initialStage="games" />);
@@ -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,
+41 -3
View File
@@ -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 () => {
+45 -5
View File
@@ -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<T>(
);
}
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<GameListPage> {
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<unknown>(
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 & {