网页端共创主题:共创 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

- 客户端:新增 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:
2026-10-06 03:45:38 +08:00
parent 58da0e67db
commit 909f3d5fa2
19 changed files with 1186 additions and 28 deletions
@@ -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'
+4
View File
@@ -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',
+2
View File
@@ -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'],
+6
View File
@@ -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(
'发布游戏 - 陶泥儿',
);
+2
View File
@@ -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': '创作族谱 - 陶泥儿',
+106
View File
@@ -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;