公开目录游标分页: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
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:
@@ -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}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
Reference in New Issue
Block a user