网页端共创主题:共创 Tab + 主题页(逐根内嵌作品树)+ 详情页主题入口 + 网关清单
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend 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 crates (pull_request) Has been cancelled
Project CI / Backend 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 2/2 (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 / AI game creator shell Rust lane 1/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 / 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 2/2 (pull_request) Has been cancelled
- 客户端:新增 listThemes(游标分页,读 nextCursor)与 getTheme(404 由页面渲染成降级态);getGame 的返回类型增量 themes(匿名也返回;无主题时是空数组而不是缺键) - 共创 Tab:广场页新增「游戏 / 共创」页签,复用既有 game-category-tabs 结构与类名,切到 /games/themes - 主题列表页 GameThemesPage:卡片展示 name / summary / badge / memberCount;只有 nextCursor 非空才渲染「加载更多」并在点击时追加;空态与失败重试照既有写法 - 主题页 GameThemePage:标题 / 简介 / 角标 + 成员根清单;**逐根内嵌渲染作品树**——直接复用族谱页(GameLineagePage 新增 embedded 变体,只隐藏页面外壳:返回按钮 / 页头 / 脚注),树查询、可见性过滤、稳定排序与截断全部走 M3 那一份实现,不新建第二套树查询或树渲染(每根一次 /lineage 的 N+1 是技术方案 §3.10.4 已接受的取舍);已发布但可见成员为空 → 明确空态而不是错误;主题不存在 / 未发布 → 错误态 - 详情页:共创信息卡渲染所属主题角标(点击进主题页),第 N 代作品同样可见(服务端按根反查);无主题时不渲染空容器 - 路由登记:新增 /games/themes 与 /games/theme(详情用 ?id=),四处同批登记(STAGE_ROUTE_ENTRIES + SelectionStage 类型 + 壳层 lazy/回调/渲染分支 + 页面标题),并同步三份 nginx SPA allowlist 与 Pingora MAIN_SPA_PATHS:check:nginx-spa-routes(20 SPA routes / 3 模板)与 check:pingora-route-parity 均绿 - 测试:主题列表三例(渲染与卡片跳转 / 有游标时的加载更多与追加 / 空态与失败重试)、主题页三例(逐根两棵树且复用 lineage 查询 + 全屏入口参数 / 空成员空态 / 404 错误态)、详情页主题入口两例(渲染并可跳转 / 无主题不渲染容器)、路由与标题登记断言、壳层三例(页签切换 / 列表进主题页 / 主题页按 ?id= 渲染并复用族谱查询)
This commit is contained in:
@@ -66,6 +66,8 @@ type GameDetailPageProps = {
|
||||
* 只有存在父作品的二创作品才渲染该入口。
|
||||
*/
|
||||
onOpenCompare?: (leftGameId: string, rightGameId: string) => void;
|
||||
/** 打开所属共创主题页(`/games/theme?id=<themeId>`)。 */
|
||||
onOpenTheme?: (themeId: string) => void;
|
||||
/**
|
||||
* 作者视角:走 `GET /my-games/{gameId}` 读取自己名下作品。
|
||||
*
|
||||
@@ -137,6 +139,7 @@ export function GameDetailPage({
|
||||
onOpenLineage,
|
||||
onOpenCreator,
|
||||
onOpenCompare,
|
||||
onOpenTheme,
|
||||
ownerView = false,
|
||||
}: GameDetailPageProps) {
|
||||
const authUi = useAuthUi();
|
||||
@@ -297,6 +300,7 @@ export function GameDetailPage({
|
||||
onPlay={onPlay}
|
||||
onOpenLineage={onOpenLineage}
|
||||
onOpenCompare={onOpenCompare}
|
||||
onOpenTheme={onOpenTheme}
|
||||
isAuthenticated={Boolean(authUi?.user)}
|
||||
/>
|
||||
);
|
||||
@@ -316,6 +320,7 @@ function GameDetailContent({
|
||||
onOpenLineage,
|
||||
onOpenCreator,
|
||||
onOpenCompare,
|
||||
onOpenTheme,
|
||||
}: {
|
||||
game: GameDistributionGame;
|
||||
ownerView: boolean;
|
||||
@@ -331,6 +336,7 @@ function GameDetailContent({
|
||||
onOpenLineage?: (rootGameId: string, fromGameId?: string) => void;
|
||||
onOpenCreator?: (id: string) => void;
|
||||
onOpenCompare?: (leftGameId: string, rightGameId: string) => void;
|
||||
onOpenTheme?: (themeId: string) => void;
|
||||
}) {
|
||||
/**
|
||||
* 只有已公开作品才有公开评价/评分投影;审核中、审核未通过、未公开、已下架
|
||||
@@ -366,6 +372,8 @@ function GameDetailContent({
|
||||
);
|
||||
const lineage = game.lineage ?? null;
|
||||
const forkCount = game.forkCount ?? 0;
|
||||
// 所属共创主题:匿名也返回;没有主题时是空数组(不渲染空容器)。
|
||||
const themes = game.themes ?? [];
|
||||
// Fork 的主路径是唤起客户端;只有「没失焦(没客户端接手)」或「平台不可能唤起」才降级成
|
||||
// 「复制作品 ID / 链接 → 到客户端粘贴」。降级面板同理只在降级时出现。
|
||||
const [isForkPanelOpen, setIsForkPanelOpen] = useState(false);
|
||||
@@ -603,6 +611,29 @@ function GameDetailContent({
|
||||
与原版对比
|
||||
</button>
|
||||
) : null}
|
||||
{onOpenTheme && themes.length > 0 ? (
|
||||
/*
|
||||
所属共创主题入口:服务端按作品的**根**反查,所以第 N 代作品也会有值;
|
||||
没有所属主题时是空数组(不是缺键),这里不渲染空容器。
|
||||
*/
|
||||
<span className="game-fork-theme-list">
|
||||
{themes.map((theme) => (
|
||||
<button
|
||||
key={theme.themeId}
|
||||
type="button"
|
||||
className="game-fork-theme-link"
|
||||
onClick={() => onOpenTheme(theme.themeId)}
|
||||
>
|
||||
{theme.badge ? (
|
||||
<span className="game-fork-theme-badge">
|
||||
{theme.badge}
|
||||
</span>
|
||||
) : null}
|
||||
{theme.name}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
{canRemix ? (
|
||||
/*
|
||||
Fork 入口:服务端取件通道要求 Bearer + 已公开 + 授权非禁止。
|
||||
|
||||
@@ -15,6 +15,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { GameDistributionVersionStatus } from '../../../packages/shared/src/contracts/gameDistribution';
|
||||
import type {
|
||||
GameDistributionGame,
|
||||
GameDistributionGameWithCollection,
|
||||
GameDistributionLineageNode,
|
||||
GameDistributionMyGame,
|
||||
GameDistributionVersionStatusEntry,
|
||||
@@ -26,10 +27,14 @@ import { FORK_LAUNCH_FALLBACK_DELAY_MS } from './gameForkProtocol';
|
||||
import { GameGalleryPage } from './GameGalleryPage';
|
||||
import { GameLineagePage } from './GameLineagePage';
|
||||
import { GamePlayPage } from './GamePlayPage';
|
||||
import { GameThemePage } from './GameThemePage';
|
||||
import { GameThemesPage } from './GameThemesPage';
|
||||
import { resolveNonPublicCoverLabel } from './gameVersionHistoryModel';
|
||||
|
||||
const listGamesMock = vi.hoisted(() => vi.fn());
|
||||
const setGameCollectionMock = vi.hoisted(() => vi.fn());
|
||||
const listThemesMock = vi.hoisted(() => vi.fn());
|
||||
const getThemeMock = vi.hoisted(() => vi.fn());
|
||||
const getGameMock = vi.hoisted(() => vi.fn());
|
||||
const getGameLineageMock = vi.hoisted(() => vi.fn());
|
||||
const getMyGameMock = vi.hoisted(() => vi.fn());
|
||||
@@ -55,6 +60,8 @@ vi.mock('../../services/creatorProfileClient', async (original) => ({
|
||||
vi.mock('../../services/gameDistributionClient', () => ({
|
||||
listGames: listGamesMock,
|
||||
setGameCollection: setGameCollectionMock,
|
||||
listThemes: listThemesMock,
|
||||
getTheme: getThemeMock,
|
||||
getGame: getGameMock,
|
||||
getGameLineage: getGameLineageMock,
|
||||
getMyGame: getMyGameMock,
|
||||
@@ -121,8 +128,8 @@ function deferred<T>() {
|
||||
}
|
||||
|
||||
function createGame(
|
||||
overrides: Partial<GameDistributionGame> = {},
|
||||
): GameDistributionGame {
|
||||
overrides: Partial<GameDistributionGameWithCollection> = {},
|
||||
): GameDistributionGameWithCollection {
|
||||
return {
|
||||
id: 'game-1',
|
||||
title: '星轨防线',
|
||||
@@ -182,6 +189,10 @@ beforeEach(() => {
|
||||
openForkProtocolMock.mockReset();
|
||||
setGameCollectionMock.mockReset();
|
||||
setGameCollectionMock.mockResolvedValue({ collected: true });
|
||||
listThemesMock.mockReset();
|
||||
listThemesMock.mockResolvedValue({ themes: [], nextCursor: null });
|
||||
getThemeMock.mockReset();
|
||||
getThemeMock.mockResolvedValue(null);
|
||||
setCreatorViewer(null);
|
||||
getCreatorRelationshipMock.mockResolvedValue({
|
||||
relationship: { isSelf: false, isFollowing: false, isFollowedBy: false },
|
||||
@@ -1688,6 +1699,257 @@ describe('GameComparePage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GameThemesPage', () => {
|
||||
it('渲染主题卡片(名称 / 角标 / 可见成员数)并可进入主题页', async () => {
|
||||
listThemesMock.mockResolvedValue({
|
||||
themes: [
|
||||
{
|
||||
themeId: 'theme-1',
|
||||
name: '精选共创',
|
||||
summary: '运营精选的共创主题',
|
||||
badge: '精选',
|
||||
memberCount: 2,
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
const onOpenTheme = vi.fn();
|
||||
render(
|
||||
<GameThemesPage
|
||||
onBack={vi.fn()}
|
||||
onOpenGallery={vi.fn()}
|
||||
onOpenTheme={onOpenTheme}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('精选共创')).toBeTruthy();
|
||||
expect(screen.getByText('精选')).toBeTruthy();
|
||||
expect(screen.getByText('2 棵作品树')).toBeTruthy();
|
||||
// 末页 nextCursor 为 null:不渲染「加载更多」。
|
||||
expect(screen.queryByRole('button', { name: '加载更多' })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /精选共创/u }));
|
||||
expect(onOpenTheme).toHaveBeenCalledWith('theme-1');
|
||||
});
|
||||
|
||||
it('有 nextCursor 时渲染「加载更多」并用游标追加下一页', async () => {
|
||||
listThemesMock
|
||||
.mockResolvedValueOnce({
|
||||
themes: [
|
||||
{
|
||||
themeId: 'theme-1',
|
||||
name: '第一页主题',
|
||||
summary: '',
|
||||
badge: '',
|
||||
memberCount: 1,
|
||||
},
|
||||
],
|
||||
nextCursor: '100:theme-1',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
themes: [
|
||||
{
|
||||
themeId: 'theme-2',
|
||||
name: '第二页主题',
|
||||
summary: '',
|
||||
badge: '',
|
||||
memberCount: 3,
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
render(
|
||||
<GameThemesPage
|
||||
onBack={vi.fn()}
|
||||
onOpenGallery={vi.fn()}
|
||||
onOpenTheme={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '加载更多' }));
|
||||
await waitFor(() =>
|
||||
expect(listThemesMock).toHaveBeenLastCalledWith('100:theme-1'),
|
||||
);
|
||||
expect(await screen.findByText('第二页主题')).toBeTruthy();
|
||||
// 追加而不是替换。
|
||||
expect(screen.getByText('第一页主题')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '加载更多' })).toBeNull();
|
||||
});
|
||||
|
||||
it('没有主题时给出空态,失败时给出错误与重试', async () => {
|
||||
listThemesMock.mockResolvedValueOnce({ themes: [], nextCursor: null });
|
||||
render(
|
||||
<GameThemesPage
|
||||
onBack={vi.fn()}
|
||||
onOpenGallery={vi.fn()}
|
||||
onOpenTheme={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
await screen.findByText('还没有已发布的共创主题,稍后再来看看。'),
|
||||
).toBeTruthy();
|
||||
|
||||
cleanup();
|
||||
listThemesMock.mockRejectedValueOnce(new Error('网络错误'));
|
||||
render(
|
||||
<GameThemesPage
|
||||
onBack={vi.fn()}
|
||||
onOpenGallery={vi.fn()}
|
||||
onOpenTheme={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByText('网络错误')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '重试' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GameThemePage', () => {
|
||||
const rootGame = (id: string, title: string) =>
|
||||
createGame({
|
||||
id,
|
||||
title,
|
||||
lineage: {
|
||||
generation: 0,
|
||||
rootGameId: id,
|
||||
rootTitle: title,
|
||||
parentGameId: id,
|
||||
parentTitle: title,
|
||||
parentAuthorName: '原作者',
|
||||
},
|
||||
});
|
||||
|
||||
it('渲染主题头与逐根的内嵌作品树(复用族谱页)', async () => {
|
||||
getThemeMock.mockResolvedValue({
|
||||
themeId: 'theme-1',
|
||||
name: '精选共创',
|
||||
summary: '运营精选的共创主题',
|
||||
badge: '精选',
|
||||
memberCount: 2,
|
||||
roots: [rootGame('game-root-1', '根一'), rootGame('game-root-2', '根二')],
|
||||
});
|
||||
getGameLineageMock.mockImplementation((rootGameId: string) =>
|
||||
Promise.resolve({
|
||||
rootGameId,
|
||||
root: createLineageNode({
|
||||
gameId: rootGameId,
|
||||
title: `树 ${rootGameId}`,
|
||||
generation: 0,
|
||||
parentGameId: null,
|
||||
}),
|
||||
nodes: [
|
||||
createLineageNode({
|
||||
gameId: rootGameId,
|
||||
title: `树 ${rootGameId}`,
|
||||
generation: 0,
|
||||
parentGameId: null,
|
||||
}),
|
||||
],
|
||||
truncated: false,
|
||||
}),
|
||||
);
|
||||
const onOpenLineage = vi.fn();
|
||||
render(
|
||||
<GameThemePage
|
||||
themeId="theme-1"
|
||||
onBack={vi.fn()}
|
||||
onOpenDetail={vi.fn()}
|
||||
onOpenLineage={onOpenLineage}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('精选共创')).toBeTruthy();
|
||||
expect(screen.getByText('运营精选的共创主题')).toBeTruthy();
|
||||
// 两个根各内嵌一棵树:树数据来自同一个 `/lineage` 查询。
|
||||
expect(await screen.findByText('树 game-root-1')).toBeTruthy();
|
||||
expect(screen.getByText('树 game-root-2')).toBeTruthy();
|
||||
await waitFor(() => expect(getGameLineageMock).toHaveBeenCalledTimes(2));
|
||||
expect(getGameLineageMock).toHaveBeenCalledWith('game-root-1');
|
||||
expect(getGameLineageMock).toHaveBeenCalledWith('game-root-2');
|
||||
// 内嵌模式不渲染族谱页外壳(页头/返回按钮),但保留「全屏查看」入口。
|
||||
expect(screen.getAllByRole('button', { name: '全屏查看族谱' })).toHaveLength(
|
||||
2,
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getAllByRole('button', { name: '全屏查看族谱' })[0]!,
|
||||
);
|
||||
expect(onOpenLineage).toHaveBeenCalledWith('game-root-1', 'game-root-1');
|
||||
});
|
||||
|
||||
it('已发布但没有可见成员时给出空态而不是错误', async () => {
|
||||
getThemeMock.mockResolvedValue({
|
||||
themeId: 'theme-2',
|
||||
name: '空主题',
|
||||
summary: '',
|
||||
badge: '',
|
||||
memberCount: 0,
|
||||
roots: [],
|
||||
});
|
||||
render(
|
||||
<GameThemePage
|
||||
themeId="theme-2"
|
||||
onBack={vi.fn()}
|
||||
onOpenDetail={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText('这个主题还没有可展示的作品树。'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '重试' })).toBeNull();
|
||||
});
|
||||
|
||||
it('主题不存在或未发布时走错误态(服务端 404 不返回空壳)', async () => {
|
||||
getThemeMock.mockRejectedValueOnce(new Error('主题不存在'));
|
||||
render(
|
||||
<GameThemePage
|
||||
themeId="theme-missing"
|
||||
onBack={vi.fn()}
|
||||
onOpenDetail={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('主题不存在')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '重试' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GameDetailPage 共创主题入口', () => {
|
||||
it('渲染所属共创主题角标并可进入主题页', async () => {
|
||||
getGameMock.mockResolvedValueOnce(
|
||||
createGame({
|
||||
themes: [{ themeId: 'theme-1', name: '精选共创', badge: '精选' }],
|
||||
}),
|
||||
);
|
||||
const onOpenTheme = vi.fn();
|
||||
render(
|
||||
<GameDetailPage
|
||||
gameId="game-1"
|
||||
onBack={vi.fn()}
|
||||
onPlay={vi.fn()}
|
||||
onOpenTheme={onOpenTheme}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /精选共创/u }));
|
||||
expect(onOpenTheme).toHaveBeenCalledWith('theme-1');
|
||||
});
|
||||
|
||||
it('没有所属主题时不渲染主题容器(空数组不是缺键)', async () => {
|
||||
getGameMock.mockResolvedValueOnce(createGame({ themes: [] }));
|
||||
render(
|
||||
<GameDetailPage
|
||||
gameId="game-1"
|
||||
onBack={vi.fn()}
|
||||
onPlay={vi.fn()}
|
||||
onOpenTheme={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByRole('heading', { name: '星轨防线' });
|
||||
expect(document.querySelector('.game-fork-theme-list')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GameDetailPage 收藏按钮', () => {
|
||||
it('可判定为已收藏时显示「已收藏」,点击取消收藏', async () => {
|
||||
getGameMock.mockResolvedValueOnce(createGame({ collected: true }));
|
||||
|
||||
@@ -35,6 +35,8 @@ type GameGalleryPageProps = {
|
||||
onOpenDetail: (gameId: string) => void;
|
||||
onOpenMyGames?: () => void;
|
||||
onOpenPublish?: () => void;
|
||||
/** 切到「共创」页签(`/games/themes`);缺失时不渲染该页签。 */
|
||||
onOpenThemes?: () => void;
|
||||
};
|
||||
|
||||
export function GameGalleryPage({
|
||||
@@ -42,6 +44,7 @@ export function GameGalleryPage({
|
||||
onOpenDetail,
|
||||
onOpenMyGames,
|
||||
onOpenPublish,
|
||||
onOpenThemes,
|
||||
}: GameGalleryPageProps) {
|
||||
const initialFiltersRef = useRef<GameGalleryFilters | null>(null);
|
||||
if (initialFiltersRef.current === null) {
|
||||
@@ -276,6 +279,27 @@ export function GameGalleryPage({
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{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"
|
||||
>
|
||||
游戏
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={false}
|
||||
className="game-category-tab"
|
||||
onClick={onOpenThemes}
|
||||
>
|
||||
共创
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="game-category-tabs" role="tablist" aria-label="游戏分类">
|
||||
{CATEGORIES.map((item) => (
|
||||
<button
|
||||
|
||||
@@ -24,6 +24,11 @@ type GameLineagePageProps = {
|
||||
onOpenDetail: (gameId: string) => void;
|
||||
/** 打开「原版与二创对比」;参数是原版(父作品)与二创(该节点)。 */
|
||||
onOpenCompare?: (leftGameId: string, rightGameId: string) => void;
|
||||
/**
|
||||
* 内嵌模式:隐藏页面外壳(返回按钮 / 页头 / 脚注),只渲染树本身。
|
||||
* 供主题页逐根渲染多棵树复用同一份树查询与渲染(不新建第二套树逻辑)。
|
||||
*/
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -122,6 +127,7 @@ export function GameLineagePage({
|
||||
onBack,
|
||||
onOpenDetail,
|
||||
onOpenCompare,
|
||||
embedded = false,
|
||||
}: GameLineagePageProps) {
|
||||
const normalizedRootId = rootGameId?.trim() ?? '';
|
||||
const [lineage, setLineage] = useState<GameDistributionLineageResponse | null>(
|
||||
@@ -172,27 +178,33 @@ export function GameLineagePage({
|
||||
const rootTitle = lineage?.root?.title?.trim() ?? '';
|
||||
|
||||
return (
|
||||
<div className="game-page game-lineage-page">
|
||||
<button type="button" className="game-back-button" onClick={onBack}>
|
||||
<ArrowLeft /> 返回
|
||||
</button>
|
||||
<div className="game-toolbar">
|
||||
<div className="game-toolbar__title">
|
||||
<span className="game-eyebrow">创作族谱</span>
|
||||
<h2>{rootTitle || '创作族谱'}</h2>
|
||||
<div
|
||||
className={`game-page game-lineage-page${embedded ? ' game-lineage-page--embedded' : ''}`}
|
||||
>
|
||||
{embedded ? null : (
|
||||
<button type="button" className="game-back-button" onClick={onBack}>
|
||||
<ArrowLeft /> 返回
|
||||
</button>
|
||||
)}
|
||||
{embedded ? null : (
|
||||
<div className="game-toolbar">
|
||||
<div className="game-toolbar__title">
|
||||
<span className="game-eyebrow">创作族谱</span>
|
||||
<h2>{rootTitle || '创作族谱'}</h2>
|
||||
</div>
|
||||
{normalizedRootId ? (
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => void loadLineage()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCcw aria-hidden="true" /> 刷新
|
||||
</PlatformActionButton>
|
||||
) : null}
|
||||
</div>
|
||||
{normalizedRootId ? (
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => void loadLineage()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCcw aria-hidden="true" /> 刷新
|
||||
</PlatformActionButton>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!normalizedRootId ? (
|
||||
<PlatformStatusMessage tone="error" surface="platform">
|
||||
@@ -281,9 +293,11 @@ export function GameLineagePage({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<p className="game-detail-footnote">
|
||||
族谱只展示已公开的作品;已删除或未公开的来源作品会标注为「原作品已不可用」。
|
||||
</p>
|
||||
{embedded ? null : (
|
||||
<p className="game-detail-footnote">
|
||||
族谱只展示已公开的作品;已删除或未公开的来源作品会标注为「原作品已不可用」。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import './gameDistribution.css';
|
||||
|
||||
import { ArrowLeft, RefreshCcw } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
type GameDistributionThemeDetail,
|
||||
getTheme,
|
||||
} from '../../services/gameDistributionClient';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { GameLineagePage } from './GameLineagePage';
|
||||
import { useGameAuthorDisplayName } from './useGameAuthorName';
|
||||
|
||||
type GameThemePageProps = {
|
||||
themeId: string | null;
|
||||
onBack: () => void;
|
||||
onOpenDetail: (gameId: string) => void;
|
||||
/** 直接进族谱页(树的全屏版本);主题页内已内嵌渲染同一棵树。 */
|
||||
onOpenLineage?: (rootGameId: string, fromGameId?: string) => void;
|
||||
onOpenCompare?: (leftGameId: string, rightGameId: string) => void;
|
||||
};
|
||||
|
||||
/** 成员根卡片:作品摘要 + 「查看作品 / 查看作品族谱」两个入口。 */
|
||||
function ThemeRootCard({
|
||||
root,
|
||||
onOpenDetail,
|
||||
onOpenLineage,
|
||||
}: {
|
||||
root: {
|
||||
id: string;
|
||||
title: string;
|
||||
author: { id: string; name: string };
|
||||
playCount: number;
|
||||
lineage?: { generation: number } | null;
|
||||
};
|
||||
onOpenDetail: (gameId: string) => void;
|
||||
onOpenLineage?: (rootGameId: string, fromGameId?: string) => void;
|
||||
}) {
|
||||
const authorName = useGameAuthorDisplayName(root.author);
|
||||
const generation = root.lineage?.generation ?? 0;
|
||||
return (
|
||||
<header className="game-theme-root__head">
|
||||
<span className="game-theme-root__generation">
|
||||
{generation === 0 ? '母版' : `第 ${generation} 代`}
|
||||
</span>
|
||||
<h3 className="game-theme-root__title">{root.title}</h3>
|
||||
<p className="game-theme-root__meta">
|
||||
<span>{authorName?.trim() || '未署名作者'}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{root.playCount.toLocaleString()} 次游玩</span>
|
||||
</p>
|
||||
<div className="game-theme-root__actions">
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => onOpenDetail(root.id)}
|
||||
>
|
||||
查看作品
|
||||
</PlatformActionButton>
|
||||
{onOpenLineage ? (
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => onOpenLineage(root.id, root.id)}
|
||||
>
|
||||
全屏查看族谱
|
||||
</PlatformActionButton>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 主题页:标题 / 简介 / 角标 + 成员根清单,**逐根内嵌渲染作品树**。
|
||||
*
|
||||
* 树不在这里重建:内嵌的就是族谱页组件(`GameLineagePage embedded`),因此树查询、可见性过滤、
|
||||
* 排序与截断全部复用 M3 那一份实现,主题页不引入第二套树规则。代价是每个根一次 `/lineage`
|
||||
* 请求(技术方案 §3.10.4 已接受的 N+1 取舍)。
|
||||
*
|
||||
* 已发布但可见成员为空 → 明确空态(不是错误);主题不存在 / 未发布 → 服务端 404,这里走错误态。
|
||||
*/
|
||||
export function GameThemePage({
|
||||
themeId,
|
||||
onBack,
|
||||
onOpenDetail,
|
||||
onOpenLineage,
|
||||
onOpenCompare,
|
||||
}: GameThemePageProps) {
|
||||
const normalizedThemeId = themeId?.trim() ?? '';
|
||||
const [theme, setTheme] = useState<GameDistributionThemeDetail | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const loadTheme = useCallback(async () => {
|
||||
if (!normalizedThemeId) {
|
||||
setTheme(null);
|
||||
setError('主题编号无效');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const detail = await getTheme(normalizedThemeId);
|
||||
if (!detail) {
|
||||
setError('主题不存在或尚未发布');
|
||||
return;
|
||||
}
|
||||
setTheme(detail);
|
||||
} catch (loadError: unknown) {
|
||||
setError(
|
||||
loadError instanceof Error
|
||||
? loadError.message
|
||||
: '主题不存在或尚未发布',
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [normalizedThemeId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadTheme();
|
||||
}, [loadTheme]);
|
||||
|
||||
return (
|
||||
<div className="game-page game-theme-page">
|
||||
<button type="button" className="game-back-button" onClick={onBack}>
|
||||
<ArrowLeft /> 返回
|
||||
</button>
|
||||
<div className="game-toolbar">
|
||||
<div className="game-toolbar__title">
|
||||
<span className="game-eyebrow">共创主题</span>
|
||||
<h2>{theme?.name?.trim() || '共创主题'}</h2>
|
||||
</div>
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
disabled={isLoading}
|
||||
onClick={() => void loadTheme()}
|
||||
>
|
||||
<RefreshCcw aria-hidden="true" /> 刷新
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
|
||||
{theme?.badge || theme?.summary ? (
|
||||
<div className="game-theme-summary">
|
||||
{theme.badge ? (
|
||||
<span className="game-theme-summary__badge">{theme.badge}</span>
|
||||
) : null}
|
||||
{theme.summary ? (
|
||||
<p className="game-theme-summary__text">{theme.summary}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading && !theme ? (
|
||||
<div className="game-loading-panel">正在加载共创主题…</div>
|
||||
) : null}
|
||||
{!isLoading && error ? (
|
||||
<PlatformStatusMessage tone="error" surface="platform">
|
||||
<span>{error}</span>
|
||||
{normalizedThemeId ? (
|
||||
<button
|
||||
type="button"
|
||||
className="game-lineage-retry"
|
||||
onClick={() => void loadTheme()}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
) : null}
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
{!isLoading && !error && theme && theme.roots.length === 0 ? (
|
||||
<PlatformEmptyState surface="subpanel" size="panel">
|
||||
<span>这个主题还没有可展示的作品树。</span>
|
||||
</PlatformEmptyState>
|
||||
) : null}
|
||||
{!error && theme && theme.roots.length > 0 ? (
|
||||
<div className="game-theme-trees">
|
||||
{theme.roots.map((root) => (
|
||||
<section
|
||||
key={root.id}
|
||||
className="game-theme-root"
|
||||
aria-label={`${root.title} 的作品树`}
|
||||
>
|
||||
<ThemeRootCard
|
||||
root={root}
|
||||
onOpenDetail={onOpenDetail}
|
||||
onOpenLineage={onOpenLineage}
|
||||
/>
|
||||
{/*
|
||||
内嵌族谱页:树查询与渲染完全复用 M3 实现(embedded 只隐藏页面外壳:
|
||||
返回按钮 / 页头 / 脚注),不在主题侧重建第二套树。
|
||||
*/}
|
||||
<GameLineagePage
|
||||
embedded
|
||||
rootGameId={root.id}
|
||||
highlightGameId={root.id}
|
||||
onBack={onBack}
|
||||
onOpenDetail={onOpenDetail}
|
||||
onOpenCompare={onOpenCompare}
|
||||
/>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="game-detail-footnote">
|
||||
树只展示已公开的作品;成员作品下架或删除后会从主题里消失,重新公开后自动回来。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GameThemePage;
|
||||
@@ -0,0 +1,168 @@
|
||||
import './gameDistribution.css';
|
||||
|
||||
import { ArrowLeft, RefreshCcw } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
type GameDistributionThemeSummary,
|
||||
listThemes,
|
||||
} from '../../services/gameDistributionClient';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
|
||||
type GameThemesPageProps = {
|
||||
onBack: () => void;
|
||||
/** 「游戏广场」页签:回到 `/games`(同一个 stage 的既有页面)。 */
|
||||
onOpenGallery: () => void;
|
||||
onOpenTheme: (themeId: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 共创主题列表(游戏区的「共创」页签)。
|
||||
*
|
||||
* 匿名可读;分页沿用服务端游标惯例(默认 20 / 上限 50 / 末页 nextCursor 为 null):
|
||||
* 只有 `nextCursor` 非空才渲染「加载更多」,点击时把已加载的页追加在后面。
|
||||
*/
|
||||
export function GameThemesPage({
|
||||
onBack,
|
||||
onOpenGallery,
|
||||
onOpenTheme,
|
||||
}: GameThemesPageProps) {
|
||||
const [themes, setThemes] = useState<GameDistributionThemeSummary[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const loadThemes = useCallback(async (cursor?: string) => {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const page = await listThemes(cursor);
|
||||
setThemes((current) =>
|
||||
cursor ? [...current, ...page.themes] : page.themes,
|
||||
);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (loadError: unknown) {
|
||||
setError(
|
||||
loadError instanceof Error ? loadError.message : '读取共创主题失败',
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadThemes();
|
||||
}, [loadThemes]);
|
||||
|
||||
return (
|
||||
<div className="game-page game-themes-page">
|
||||
<button type="button" className="game-back-button" onClick={onBack}>
|
||||
<ArrowLeft /> 返回
|
||||
</button>
|
||||
<div className="game-toolbar">
|
||||
<div className="game-toolbar__title">
|
||||
<span className="game-eyebrow">游戏</span>
|
||||
<h2>共创主题</h2>
|
||||
</div>
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
disabled={isLoading}
|
||||
onClick={() => void loadThemes()}
|
||||
>
|
||||
<RefreshCcw aria-hidden="true" /> 刷新
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
<div className="game-category-tabs" role="tablist" aria-label="游戏区">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={false}
|
||||
className="game-category-tab"
|
||||
onClick={onOpenGallery}
|
||||
>
|
||||
游戏
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected
|
||||
className="game-category-tab game-category-tab--active"
|
||||
>
|
||||
共创
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<PlatformStatusMessage tone="error" surface="platform">
|
||||
<span>{error}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="game-lineage-retry"
|
||||
onClick={() => void loadThemes()}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
{isLoading && themes.length === 0 ? (
|
||||
<div className="game-loading-panel">正在加载共创主题…</div>
|
||||
) : null}
|
||||
{!isLoading && !error && themes.length === 0 ? (
|
||||
<PlatformEmptyState surface="subpanel" size="panel">
|
||||
<span>还没有已发布的共创主题,稍后再来看看。</span>
|
||||
</PlatformEmptyState>
|
||||
) : null}
|
||||
{themes.length > 0 ? (
|
||||
<>
|
||||
<ul className="game-theme-list">
|
||||
{themes.map((theme) => (
|
||||
<li key={theme.themeId}>
|
||||
<button
|
||||
type="button"
|
||||
className="game-theme-card"
|
||||
onClick={() => onOpenTheme(theme.themeId)}
|
||||
>
|
||||
{theme.badge ? (
|
||||
<span className="game-theme-card__badge">{theme.badge}</span>
|
||||
) : null}
|
||||
<span className="game-theme-card__name">{theme.name}</span>
|
||||
{theme.summary ? (
|
||||
<span className="game-theme-card__summary">
|
||||
{theme.summary}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="game-theme-card__meta">
|
||||
{theme.memberCount} 棵作品树
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{nextCursor ? (
|
||||
<div className="game-empty-actions">
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
disabled={isLoading}
|
||||
onClick={() => void loadThemes(nextCursor)}
|
||||
>
|
||||
加载更多
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<p className="game-detail-footnote">
|
||||
共创主题由平台命名,一个主题可以包含多棵作品树;作品之间仍各自独立展示。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GameThemesPage;
|
||||
@@ -567,6 +567,156 @@
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* 共创主题:列表卡片与主题页(成员根 + 内嵌作品树)。 */
|
||||
.game-theme-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(18rem, 100%), 1fr));
|
||||
gap: 0.7rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.game-theme-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.8rem 0.9rem;
|
||||
background: var(--platform-surface, #fff);
|
||||
color: var(--platform-text-base);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.game-theme-card:hover {
|
||||
border-color: var(--platform-accent-strong);
|
||||
}
|
||||
.game-theme-card__badge {
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.5rem;
|
||||
background: rgba(199, 101, 61, 0.14);
|
||||
color: var(--platform-accent-strong);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.game-theme-card__name {
|
||||
color: var(--platform-text-strong);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.game-theme-card__summary,
|
||||
.game-theme-card__meta {
|
||||
color: var(--platform-text-soft);
|
||||
font-size: 0.78rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.game-theme-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.game-theme-summary__badge {
|
||||
border-radius: 999px;
|
||||
padding: 0.12rem 0.6rem;
|
||||
background: rgba(199, 101, 61, 0.14);
|
||||
color: var(--platform-accent-strong);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.game-theme-summary__text {
|
||||
margin: 0;
|
||||
color: var(--platform-text-soft);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.game-theme-trees {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
}
|
||||
.game-theme-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 0.85rem;
|
||||
padding: 0.8rem 0.9rem;
|
||||
}
|
||||
.game-theme-root__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.game-theme-root__generation {
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.5rem;
|
||||
background: var(--platform-surface-border);
|
||||
color: var(--platform-text-soft);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.game-theme-root__title {
|
||||
margin: 0;
|
||||
color: var(--platform-text-strong);
|
||||
font-size: 0.95rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.game-theme-root__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
margin: 0;
|
||||
color: var(--platform-text-soft);
|
||||
font-size: 0.78rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.game-theme-root__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
/* 内嵌的族谱页:去掉页面级留白,避免主题页里出现两层「页面」盒。 */
|
||||
.game-lineage-page--embedded {
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
}
|
||||
.game-detail-info-card .game-fork-theme-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
justify-items: start;
|
||||
}
|
||||
.game-detail-info-card .game-fork-theme-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 999px;
|
||||
padding: 0.18rem 0.6rem;
|
||||
background: transparent;
|
||||
color: var(--platform-text-base);
|
||||
font: inherit;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.game-detail-info-card .game-fork-theme-link:hover {
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
.game-detail-info-card .game-fork-theme-badge {
|
||||
color: var(--platform-accent-strong);
|
||||
font-weight: 700;
|
||||
}
|
||||
.game-loading-panel {
|
||||
display: grid;
|
||||
min-height: 18rem;
|
||||
|
||||
@@ -55,6 +55,8 @@ const gameDistributionMock = vi.hoisted(() => ({
|
||||
getDerivedGames: vi.fn(),
|
||||
getGame: vi.fn(),
|
||||
getGameLineage: vi.fn(),
|
||||
getTheme: vi.fn(),
|
||||
listThemes: vi.fn(),
|
||||
getGameVersion: vi.fn(),
|
||||
getMyGame: vi.fn(),
|
||||
listGames: vi.fn(),
|
||||
@@ -1494,3 +1496,106 @@ describe('创作族谱', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('共创主题', () => {
|
||||
it('广场「共创」页签切到主题列表', async () => {
|
||||
window.history.replaceState(null, '', '/games');
|
||||
gameDistributionMock.listGames.mockResolvedValue([]);
|
||||
gameDistributionMock.listThemes.mockResolvedValue({
|
||||
themes: [],
|
||||
nextCursor: null,
|
||||
});
|
||||
const setSelectionStage = vi.fn();
|
||||
|
||||
render(
|
||||
<PlatformEntryFlowShellImpl
|
||||
selectionStage="games"
|
||||
setSelectionStage={setSelectionStage}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole('tab', { name: '共创' }));
|
||||
expect(setSelectionStage).toHaveBeenCalledWith('game-themes', {
|
||||
path: '/games/themes',
|
||||
});
|
||||
});
|
||||
|
||||
it('主题列表页渲染主题并进入主题页', async () => {
|
||||
window.history.replaceState(null, '', '/games/themes');
|
||||
gameDistributionMock.listThemes.mockResolvedValue({
|
||||
themes: [
|
||||
{
|
||||
themeId: 'theme-1',
|
||||
name: '精选共创',
|
||||
summary: '运营精选',
|
||||
badge: '精选',
|
||||
memberCount: 1,
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
const setSelectionStage = vi.fn();
|
||||
|
||||
render(
|
||||
<PlatformEntryFlowShellImpl
|
||||
selectionStage="game-themes"
|
||||
setSelectionStage={setSelectionStage}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /精选共创/u }));
|
||||
expect(setSelectionStage).toHaveBeenCalledWith('game-theme', {
|
||||
path: '/games/theme?id=theme-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('主题详情按 ?id= 渲染主题名与成员根,并复用族谱查询', async () => {
|
||||
window.history.replaceState(null, '', '/games/theme?id=theme-1');
|
||||
gameDistributionMock.getTheme.mockResolvedValue({
|
||||
themeId: 'theme-1',
|
||||
name: '精选共创',
|
||||
summary: '运营精选',
|
||||
badge: '精选',
|
||||
memberCount: 1,
|
||||
roots: [
|
||||
{
|
||||
id: 'game-root-1',
|
||||
title: '根作品',
|
||||
summary: '根',
|
||||
description: '',
|
||||
category: '动作',
|
||||
tags: [],
|
||||
coverColor: '#d77a51',
|
||||
icon: '✦',
|
||||
author: { id: 'author-1', name: '测试作者' },
|
||||
deviceSupport: { desktop: true, mobile: true, touch: true },
|
||||
status: 'published',
|
||||
currentVersion: null,
|
||||
playCount: 1,
|
||||
createdAt: '2026-09-18T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
gameDistributionMock.getGameLineage.mockResolvedValue({
|
||||
rootGameId: 'game-root-1',
|
||||
root: null,
|
||||
nodes: [],
|
||||
truncated: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<PlatformEntryFlowShellImpl
|
||||
selectionStage="game-theme"
|
||||
setSelectionStage={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: '精选共创' })).toBeTruthy();
|
||||
expect(screen.getByText('根作品')).toBeTruthy();
|
||||
expect(gameDistributionMock.getTheme).toHaveBeenCalledWith('theme-1');
|
||||
// 树复用既有族谱查询:按根请求一次。
|
||||
expect(gameDistributionMock.getGameLineage).toHaveBeenCalledWith(
|
||||
'game-root-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,6 +125,16 @@ const GameComparePage = lazy(async () => {
|
||||
return { default: module.GameComparePage };
|
||||
});
|
||||
|
||||
const GameThemesPage = lazy(async () => {
|
||||
const module = await import('../game-distribution/GameThemesPage');
|
||||
return { default: module.GameThemesPage };
|
||||
});
|
||||
|
||||
const GameThemePage = lazy(async () => {
|
||||
const module = await import('../game-distribution/GameThemePage');
|
||||
return { default: module.GameThemePage };
|
||||
});
|
||||
|
||||
type ActiveRailButtonProps = {
|
||||
active: boolean;
|
||||
emphasized?: boolean;
|
||||
@@ -593,6 +603,38 @@ export function PlatformEntryFlowShellImpl({
|
||||
setSelectionStage('games', { path: '/games' });
|
||||
}, [setSelectionStage]);
|
||||
|
||||
/** 共创主题列表(`/games` 的「共创」页签)。 */
|
||||
const openGameThemes = useCallback(() => {
|
||||
setSelectionStage('game-themes', { path: '/games/themes' });
|
||||
}, [setSelectionStage]);
|
||||
|
||||
const backFromGameThemes = useCallback(() => {
|
||||
if (hasAppHistoryBackEntry()) {
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
replaceAppHistoryPath('/games');
|
||||
setSelectionStage('games', { path: '/games' });
|
||||
}, [setSelectionStage]);
|
||||
|
||||
/** 主题详情:复用 `?id=` 约定(与 `/games/lineage?id=`、`/games/detail?id=` 同款)。 */
|
||||
const openGameTheme = useCallback(
|
||||
(themeId: string) => {
|
||||
const path = `/games/theme?id=${encodeURIComponent(themeId)}`;
|
||||
setSelectionStage('game-theme', { path });
|
||||
},
|
||||
[setSelectionStage],
|
||||
);
|
||||
|
||||
const backFromGameTheme = useCallback(() => {
|
||||
if (hasAppHistoryBackEntry()) {
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
replaceAppHistoryPath('/games/themes');
|
||||
setSelectionStage('game-themes', { path: '/games/themes' });
|
||||
}, [setSelectionStage]);
|
||||
|
||||
/**
|
||||
* 详情页可能从广场或「我的游戏」进入,返回优先走浏览器原生返回,这样能回到
|
||||
* 用户真正的来源页;直接打开详情链接等没有可回退的应用内历史时再兜底回广场。
|
||||
@@ -728,6 +770,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
selectionStage === 'games' ||
|
||||
selectionStage === 'game-detail' ||
|
||||
selectionStage === 'game-compare' ||
|
||||
selectionStage === 'game-themes' ||
|
||||
selectionStage === 'game-theme' ||
|
||||
selectionStage === 'game-play' ||
|
||||
selectionStage === 'game-mine' ||
|
||||
selectionStage === 'game-lineage' ||
|
||||
@@ -1002,6 +1046,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
searchKeyword={activeSearchKeyword}
|
||||
onOpenDetail={openGameDetail}
|
||||
onOpenMyGames={openMyGames}
|
||||
onOpenThemes={openGameThemes}
|
||||
onOpenPublish={
|
||||
gamePublishGateAllowed ? openGamePublish : undefined
|
||||
}
|
||||
@@ -1037,6 +1082,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
onPlay={openGamePlay}
|
||||
onOpenLineage={openGameLineage}
|
||||
onOpenCompare={openGameCompare}
|
||||
onOpenTheme={openGameTheme}
|
||||
/>
|
||||
) : selectionStage === 'game-lineage' ? (
|
||||
<GameLineagePage
|
||||
@@ -1053,6 +1099,20 @@ export function PlatformEntryFlowShellImpl({
|
||||
onBack={backFromGameCompare}
|
||||
onOpenDetail={openGameDetail}
|
||||
/>
|
||||
) : selectionStage === 'game-themes' ? (
|
||||
<GameThemesPage
|
||||
onBack={backFromGameThemes}
|
||||
onOpenGallery={openGames}
|
||||
onOpenTheme={openGameTheme}
|
||||
/>
|
||||
) : selectionStage === 'game-theme' ? (
|
||||
<GameThemePage
|
||||
themeId={gameId}
|
||||
onBack={backFromGameTheme}
|
||||
onOpenDetail={openGameDetail}
|
||||
onOpenLineage={openGameLineage}
|
||||
onOpenCompare={openGameCompare}
|
||||
/>
|
||||
) : (
|
||||
<GamePlayPage gameId={gameId} onBack={backFromGamePlay} />
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,8 @@ export type SelectionStage =
|
||||
| 'games'
|
||||
| 'game-detail'
|
||||
| 'game-compare'
|
||||
| 'game-themes'
|
||||
| 'game-theme'
|
||||
| 'game-play'
|
||||
| 'game-mine'
|
||||
| 'game-lineage'
|
||||
|
||||
@@ -29,6 +29,8 @@ describe('appPageRoutes', () => {
|
||||
expect(resolveSelectionStageFromPath('/games/compare')).toBe(
|
||||
'game-compare',
|
||||
);
|
||||
expect(resolveSelectionStageFromPath('/games/themes')).toBe('game-themes');
|
||||
expect(resolveSelectionStageFromPath('/games/theme')).toBe('game-theme');
|
||||
expect(resolveSelectionStageFromPath('/games/play')).toBe('game-play');
|
||||
expect(resolveSelectionStageFromPath('/games/mine')).toBe('game-mine');
|
||||
expect(resolveSelectionStageFromPath('/games/lineage')).toBe(
|
||||
@@ -51,6 +53,8 @@ describe('appPageRoutes', () => {
|
||||
expect(resolvePathForSelectionStage('profile')).toBe('/profile');
|
||||
expect(resolvePathForSelectionStage('games')).toBe('/games');
|
||||
expect(resolvePathForSelectionStage('game-compare')).toBe('/games/compare');
|
||||
expect(resolvePathForSelectionStage('game-themes')).toBe('/games/themes');
|
||||
expect(resolvePathForSelectionStage('game-theme')).toBe('/games/theme');
|
||||
expect(resolvePathForSelectionStage('game-mine')).toBe('/games/mine');
|
||||
expect(resolvePathForSelectionStage('game-lineage')).toBe(
|
||||
'/games/lineage',
|
||||
|
||||
@@ -16,6 +16,8 @@ const STAGE_ROUTE_ENTRIES = [
|
||||
['games', '/games'],
|
||||
['game-detail', '/games/detail'],
|
||||
['game-compare', '/games/compare'],
|
||||
['game-themes', '/games/themes'],
|
||||
['game-theme', '/games/theme'],
|
||||
['game-play', '/games/play'],
|
||||
['game-mine', '/games/mine'],
|
||||
['game-lineage', '/games/lineage'],
|
||||
|
||||
@@ -42,6 +42,12 @@ describe('activeAppTitle', () => {
|
||||
expect(resolveAppTitleForSelectionStage('game-compare')).toBe(
|
||||
'原版与二创对比 - 陶泥儿',
|
||||
);
|
||||
expect(resolveAppTitleForSelectionStage('game-themes')).toBe(
|
||||
'共创主题 - 陶泥儿',
|
||||
);
|
||||
expect(resolveAppTitleForSelectionStage('game-theme')).toBe(
|
||||
'共创主题详情 - 陶泥儿',
|
||||
);
|
||||
expect(resolveAppTitleForSelectionStage('game-publish')).toBe(
|
||||
'发布游戏 - 陶泥儿',
|
||||
);
|
||||
|
||||
@@ -17,6 +17,8 @@ const APP_TITLE_BY_SELECTION_STAGE: Record<SelectionStage, string> = {
|
||||
games: '游戏 - 陶泥儿',
|
||||
'game-detail': '游戏详情 - 陶泥儿',
|
||||
'game-compare': '原版与二创对比 - 陶泥儿',
|
||||
'game-themes': '共创主题 - 陶泥儿',
|
||||
'game-theme': '共创主题详情 - 陶泥儿',
|
||||
'game-play': '正在游玩 - 陶泥儿',
|
||||
'game-mine': '我的游戏 - 陶泥儿',
|
||||
'game-lineage': '创作族谱 - 陶泥儿',
|
||||
|
||||
@@ -373,8 +373,114 @@ export async function getGame(gameId: string) {
|
||||
*/
|
||||
export type GameDistributionGameWithCollection = GameDistributionGame & {
|
||||
collected?: boolean;
|
||||
/**
|
||||
* 该作品所属的公开共创主题(先取作品的根再按根反查,因此**第 N 代作品也会有值**)。
|
||||
* 匿名也返回:没有所属主题时是 `[]` 而不是缺键。
|
||||
*/
|
||||
themes?: GameDistributionThemeReference[];
|
||||
};
|
||||
|
||||
/** 作品详情里的主题入口:只够渲染一个跳转(简介与成员数在主题页取)。 */
|
||||
export type GameDistributionThemeReference = {
|
||||
themeId: string;
|
||||
name: string;
|
||||
badge: string;
|
||||
};
|
||||
|
||||
/** 共创主题摘要(公开列表条目)。 */
|
||||
export type GameDistributionThemeSummary = {
|
||||
themeId: string;
|
||||
name: string;
|
||||
summary: string;
|
||||
badge: string;
|
||||
/** 当前**可见**成员数(与主题详情 `roots` 长度同口径,不含草稿/已下架成员)。 */
|
||||
memberCount: number;
|
||||
};
|
||||
|
||||
/** 主题详情:摘要 + 成员根清单(逐条是公开目录同一份投影)。 */
|
||||
export type GameDistributionThemeDetail = GameDistributionThemeSummary & {
|
||||
roots: GameDistributionGame[];
|
||||
};
|
||||
|
||||
function normalizeThemeList(value: unknown): GameDistributionThemeSummary[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.flatMap((item) => {
|
||||
if (!item || typeof item !== 'object') return [];
|
||||
const theme = item as Partial<GameDistributionThemeSummary>;
|
||||
const themeId = typeof theme.themeId === 'string' ? theme.themeId.trim() : '';
|
||||
const name = typeof theme.name === 'string' ? theme.name.trim() : '';
|
||||
if (!themeId || !name) return [];
|
||||
return [
|
||||
{
|
||||
themeId,
|
||||
name,
|
||||
summary: typeof theme.summary === 'string' ? theme.summary : '',
|
||||
badge: typeof theme.badge === 'string' ? theme.badge : '',
|
||||
memberCount:
|
||||
typeof theme.memberCount === 'number' && theme.memberCount >= 0
|
||||
? theme.memberCount
|
||||
: 0,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 共创主题列表(匿名可读)。分页与 `/my-collections` 同口径:服务端默认 20 / 上限 50,
|
||||
* `nextCursor` 是真实游标(末页为 `null`),客户端只在非空时渲染「加载更多」。
|
||||
*/
|
||||
export async function listThemes(cursor?: string) {
|
||||
const params = new URLSearchParams();
|
||||
const normalizedCursor = cursor?.trim();
|
||||
if (normalizedCursor) params.set('cursor', normalizedCursor);
|
||||
const query = params.toString();
|
||||
const payload = await requestJson<{
|
||||
themes?: unknown;
|
||||
nextCursor?: string | null;
|
||||
}>(
|
||||
`/themes${query ? `?${query}` : ''}`,
|
||||
{ method: 'GET', cache: 'no-store' },
|
||||
'读取共创主题失败',
|
||||
PUBLIC_GAME_REQUEST_OPTIONS,
|
||||
);
|
||||
return {
|
||||
themes: normalizeThemeList(payload.themes),
|
||||
nextCursor: payload.nextCursor ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 共创主题详情(匿名可读)。主题不存在 / 未发布一律 404(服务端不返回空壳),
|
||||
* 这里按失败抛出,由主题页渲染成「主题不存在或尚未发布」的降级态。
|
||||
*/
|
||||
export async function getTheme(
|
||||
themeId: string,
|
||||
): Promise<GameDistributionThemeDetail | null> {
|
||||
const normalizedThemeId = themeId.trim();
|
||||
if (!normalizedThemeId) return null;
|
||||
const payload = await requestJson<{
|
||||
themeId?: string;
|
||||
name?: string;
|
||||
summary?: string;
|
||||
badge?: string;
|
||||
memberCount?: number;
|
||||
roots?: unknown;
|
||||
}>(
|
||||
`/themes/${encodeURIComponent(normalizedThemeId)}`,
|
||||
{ method: 'GET', cache: 'no-store' },
|
||||
'读取共创主题失败',
|
||||
PUBLIC_GAME_REQUEST_OPTIONS,
|
||||
);
|
||||
return {
|
||||
themeId: payload.themeId ?? normalizedThemeId,
|
||||
name: payload.name ?? '',
|
||||
summary: payload.summary ?? '',
|
||||
badge: payload.badge ?? '',
|
||||
memberCount: payload.memberCount ?? 0,
|
||||
roots: normalizeGameList(payload.roots),
|
||||
};
|
||||
}
|
||||
|
||||
/** 收藏切换的响应;DELETE 不带 `replayed`。 */
|
||||
export type GameDistributionCollectionState = {
|
||||
collected: boolean;
|
||||
|
||||
Reference in New Issue
Block a user