共创模块A: 详情页世代/授权标签 + 一键 Fork 主操作 + 概览 + 溯源条 + 热门衍生
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:
@@ -0,0 +1,60 @@
|
||||
import type { GameDistributionLineageNode } from '../../services/gameDistributionClient';
|
||||
import { coCreationGenerationShortLabel } from './coCreationCopy';
|
||||
import { GameCover } from './GameCover';
|
||||
|
||||
/**
|
||||
* 热门衍生栏:当前作品下热度最高的 5 个分支作品(横向卡片)。
|
||||
*
|
||||
* 热度口径:**`playCount` 倒序**,同热度按创建顺序(服务端顺序)稳定兜底。
|
||||
* `/derived` 目前不保证服务端排序,所以排序在客户端做;后端若补了排序,
|
||||
* 这段逻辑仍然成立(只是排序变成幂等)。
|
||||
*/
|
||||
export const CO_CREATION_HOT_DERIVED_LIMIT = 5;
|
||||
|
||||
export function CoCreationHotDerived({
|
||||
nodes,
|
||||
onOpenGameDetail,
|
||||
}: {
|
||||
nodes: readonly GameDistributionLineageNode[];
|
||||
onOpenGameDetail: (gameId: string) => void;
|
||||
}) {
|
||||
const hottest = [...nodes]
|
||||
.sort((left, right) => right.playCount - left.playCount)
|
||||
.slice(0, CO_CREATION_HOT_DERIVED_LIMIT);
|
||||
if (hottest.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="co-creation-hot" aria-label="热门衍生作品">
|
||||
{hottest.map((node) => (
|
||||
<button
|
||||
key={node.gameId}
|
||||
type="button"
|
||||
className="co-creation-hot__card"
|
||||
aria-label={`热门衍生《${node.title}》· ${coCreationGenerationShortLabel(node.generation)}`}
|
||||
onClick={() => onOpenGameDetail(node.gameId)}
|
||||
>
|
||||
<span className="co-creation-hot__cover">
|
||||
<GameCover game={gameForCover(node)} objectKey={node.coverObjectKey} />
|
||||
</span>
|
||||
<span className="co-creation-hot__title">{node.title}</span>
|
||||
<span className="co-creation-hot__meta">
|
||||
{coCreationGenerationShortLabel(node.generation)} ·{' '}
|
||||
{node.authorName?.trim() || '未署名作者'} · {node.playCount} 次游玩
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** `GameCover` 只读 title / coverColor / icon / coverObjectKey;族谱节点没有完整游戏行。 */
|
||||
function gameForCover(node: GameDistributionLineageNode) {
|
||||
return {
|
||||
title: node.title,
|
||||
coverColor: '#F3E4D0',
|
||||
icon: '🎮',
|
||||
coverObjectKey: node.coverObjectKey,
|
||||
} as never;
|
||||
}
|
||||
|
||||
export default CoCreationHotDerived;
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { GameDistributionLineageNode } from '../../services/gameDistributionClient';
|
||||
import {
|
||||
coCreationAvatarInitial,
|
||||
coCreationGenerationShortLabel,
|
||||
} from './coCreationCopy';
|
||||
|
||||
/**
|
||||
* 血缘溯源条:横向完整链路「根原创 → 1 代 → 2 代 → … → 当前作品」。
|
||||
*
|
||||
* 数据来自既有 `/games/{id}/lineage`(在详情页请求一次,按 `parentGameId` 上溯成链,
|
||||
* 不用整棵大树、也不新增接口)。每个节点显示作者字标 + 世代,点击跳该代作品详情。
|
||||
*
|
||||
* 头像:`/lineage` 节点只带 `authorName`(没有头像字段),因此用姓名首字的圆形字标降级,
|
||||
* 不伪造头像图片;后端补字段后换成真实头像即可(结构不用改)。
|
||||
*/
|
||||
export function CoCreationLineageStrip({
|
||||
chain,
|
||||
currentGameId,
|
||||
onOpenGameDetail,
|
||||
}: {
|
||||
/** 从根到当前作品的顺序列表。 */
|
||||
chain: readonly GameDistributionLineageNode[];
|
||||
currentGameId: string;
|
||||
onOpenGameDetail: (gameId: string) => void;
|
||||
}) {
|
||||
if (chain.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className="co-creation-lineage" aria-label="血缘溯源">
|
||||
{chain.map((node, index) => {
|
||||
const isCurrent = node.gameId === currentGameId;
|
||||
return (
|
||||
<span key={node.gameId} className="co-creation-lineage__item">
|
||||
{index > 0 ? (
|
||||
<span className="co-creation-lineage__arrow" aria-hidden="true">
|
||||
→
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={`co-creation-lineage__node${
|
||||
isCurrent ? ' co-creation-lineage__node--current' : ''
|
||||
}`}
|
||||
aria-current={isCurrent ? 'true' : undefined}
|
||||
aria-label={`${coCreationGenerationShortLabel(node.generation)}作品《${node.title}》· ${node.authorName?.trim() || '未署名作者'}`}
|
||||
onClick={() => {
|
||||
if (!isCurrent) onOpenGameDetail(node.gameId);
|
||||
}}
|
||||
>
|
||||
<span className="co-creation-lineage__avatar" aria-hidden="true">
|
||||
{coCreationAvatarInitial(node.authorName)}
|
||||
</span>
|
||||
<span className="co-creation-lineage__text">
|
||||
<span className="co-creation-lineage__generation">
|
||||
{coCreationGenerationShortLabel(node.generation)}
|
||||
</span>
|
||||
<span className="co-creation-lineage__title">{node.title}</span>
|
||||
<span className="co-creation-lineage__author">
|
||||
{node.authorName?.trim() || '未署名作者'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CoCreationLineageStrip;
|
||||
@@ -18,9 +18,12 @@ import { ApiClientError } from '../../services/apiClient';
|
||||
import {
|
||||
type GameDistributionGame,
|
||||
type GameDistributionInputMode,
|
||||
type GameDistributionLineageNode,
|
||||
type GameDistributionMyGame,
|
||||
type GameDistributionVersionStatusEntry,
|
||||
getDerivedGames,
|
||||
getGame,
|
||||
getGameLineage,
|
||||
getMyGame,
|
||||
loadVersionRecoveryActions,
|
||||
purchaseGame,
|
||||
@@ -34,6 +37,9 @@ import { PlatformMudPointConfirmDialog } from '../common/PlatformMudPointConfirm
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { useCopyFeedback } from '../common/useCopyFeedback';
|
||||
import { CreatorAuthor } from '../creator/CreatorRelationshipControl';
|
||||
import { coCreationGenerationTagLabel } from './coCreationCopy';
|
||||
import { CoCreationHotDerived } from './CoCreationHotDerived';
|
||||
import { CoCreationLineageStrip } from './CoCreationLineageStrip';
|
||||
import {
|
||||
normalizeGameEntryUrl,
|
||||
normalizeGameId,
|
||||
@@ -52,6 +58,7 @@ import {
|
||||
VERSION_STATUS_LABEL,
|
||||
VERSION_STATUS_NEEDS_AUTHOR_ACTION,
|
||||
} from './gameVersionHistoryModel';
|
||||
import { collectLineageAncestorChain } from './lineageTreeLayout';
|
||||
import { useGameAuthorDisplayName } from './useGameAuthorName';
|
||||
import { useGameReviews } from './useGameReviews';
|
||||
|
||||
@@ -72,6 +79,8 @@ type GameDetailPageProps = {
|
||||
onOpenCompare?: (leftGameId: string, rightGameId: string) => void;
|
||||
/** 打开所属共创主题页(`/games/theme?id=<themeId>`)。 */
|
||||
onOpenTheme?: (themeId: string) => void;
|
||||
/** 打开另一部作品的详情页(血缘溯源条 / 热门衍生卡片跳转用)。 */
|
||||
onOpenGameDetail?: (gameId: string) => void;
|
||||
/**
|
||||
* 打开平台充值面板;余额不足以购买付费作品时由宿主提供入口。
|
||||
* 宿主没有充值能力时(例如独立嵌入)传入空值,页面只保留余额提示。
|
||||
@@ -166,6 +175,7 @@ export function GameDetailPage({
|
||||
onOpenCreator,
|
||||
onOpenCompare,
|
||||
onOpenTheme,
|
||||
onOpenGameDetail,
|
||||
onOpenRecharge,
|
||||
ownerView = false,
|
||||
}: GameDetailPageProps) {
|
||||
@@ -452,6 +462,7 @@ export function GameDetailPage({
|
||||
onOpenLineage={onOpenLineage}
|
||||
onOpenCompare={onOpenCompare}
|
||||
onOpenTheme={onOpenTheme}
|
||||
onOpenGameDetail={onOpenGameDetail}
|
||||
isAuthenticated={Boolean(authUi?.user)}
|
||||
/>
|
||||
<PlatformMudPointConfirmDialog
|
||||
@@ -496,6 +507,7 @@ function GameDetailContent({
|
||||
onOpenCreator,
|
||||
onOpenCompare,
|
||||
onOpenTheme,
|
||||
onOpenGameDetail,
|
||||
}: {
|
||||
game: GameDistributionGame;
|
||||
ownerView: boolean;
|
||||
@@ -516,6 +528,7 @@ function GameDetailContent({
|
||||
onOpenCreator?: (id: string) => void;
|
||||
onOpenCompare?: (leftGameId: string, rightGameId: string) => void;
|
||||
onOpenTheme?: (themeId: string) => void;
|
||||
onOpenGameDetail?: (gameId: string) => void;
|
||||
}) {
|
||||
/**
|
||||
* 只有已公开作品才有公开评价/评分投影;审核中、审核未通过、未公开、已下架
|
||||
@@ -553,6 +566,57 @@ function GameDetailContent({
|
||||
const forkCount = game.forkCount ?? 0;
|
||||
// 所属共创主题:匿名也返回;没有主题时是空数组(不渲染空容器)。
|
||||
const themes = game.themes ?? [];
|
||||
/**
|
||||
* 共创模块的附加数据:血缘链(根 → … → 当前)与热门衍生(`/derived`)。
|
||||
* 两者都是**公开读接口**,失败只影响这一块,不影响作品主信息(各自 catch 成空)。
|
||||
*/
|
||||
const [lineageChain, setLineageChain] = useState<
|
||||
GameDistributionLineageNode[]
|
||||
>([]);
|
||||
const [derivedNodes, setDerivedNodes] = useState<
|
||||
GameDistributionLineageNode[]
|
||||
>([]);
|
||||
/**
|
||||
* 「累计创作世代数」:另一个 session 正在给公开详情补这个字段(暂定名
|
||||
* `descendantGenerationCount`)。**按「字段可能存在」写**:缺失时整项不渲染,
|
||||
* 不拿 `forkCount` 或别的数字凑数(避免误导)。字段到位后这里自然生效。
|
||||
*/
|
||||
const descendantGenerationCount =
|
||||
'descendantGenerationCount' in game &&
|
||||
typeof game.descendantGenerationCount === 'number'
|
||||
? game.descendantGenerationCount
|
||||
: undefined;
|
||||
const loadCoCreationData = useCallback(async () => {
|
||||
if (!publiclyVisible) {
|
||||
setLineageChain([]);
|
||||
setDerivedNodes([]);
|
||||
return;
|
||||
}
|
||||
// 每个调用各自兜底(含同步抛错):附加数据失败不能影响作品主信息。
|
||||
const [tree, derived] = await Promise.all([
|
||||
(async () => {
|
||||
try {
|
||||
return await getGameLineage(game.id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
(async () => {
|
||||
try {
|
||||
return await getDerivedGames(game.id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
]).catch(() => [null, null] as const);
|
||||
setLineageChain(
|
||||
tree ? collectLineageAncestorChain(tree.nodes, game.id) : [],
|
||||
);
|
||||
setDerivedNodes(derived?.nodes ?? []);
|
||||
}, [game.id, publiclyVisible]);
|
||||
useEffect(() => {
|
||||
void loadCoCreationData();
|
||||
}, [loadCoCreationData]);
|
||||
// Fork 的主路径是唤起客户端;只有「没失焦(没客户端接手)」或「平台不可能唤起」才降级成
|
||||
// 「复制作品 ID / 链接 → 到客户端粘贴」。降级面板同理只在降级时出现。
|
||||
const [isForkPanelOpen, setIsForkPanelOpen] = useState(false);
|
||||
@@ -570,7 +634,6 @@ function GameDetailContent({
|
||||
typeof window === 'undefined'
|
||||
? `/games/detail?id=${encodeURIComponent(game.id)}`
|
||||
: `${window.location.origin}/games/detail?id=${encodeURIComponent(game.id)}`;
|
||||
const canRemix = publiclyVisible && forkAuthorization !== 'forbidden';
|
||||
// 收藏态:服务端只在**已认证**时返回 `collected`,匿名请求完全没有这个键。
|
||||
// 因此「缺字段」一律按不可判定处理(未登录 / 过期 token 被按匿名处理),
|
||||
// 绝不落成「未收藏」——否则未登录用户会看到一个假的已收藏/未收藏结论。
|
||||
@@ -773,14 +836,21 @@ function GameDetailContent({
|
||||
label: '共创',
|
||||
value: (
|
||||
<span className="game-fork-card">
|
||||
<span
|
||||
className={`game-fork-badge game-fork-badge--${forkAuthorization}`}
|
||||
>
|
||||
{
|
||||
GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[
|
||||
forkAuthorization
|
||||
]
|
||||
}
|
||||
<span className="co-creation-tags">
|
||||
{/* 世代标签(文档口径:0 代原创 / 1 代二创 / 2 代三创 / N 代迭代)。 */}
|
||||
<span className="co-creation-tag co-creation-tag--generation">
|
||||
{coCreationGenerationTagLabel(lineage?.generation ?? 0)}
|
||||
</span>
|
||||
{/* 授权类型标签:沿用平台既有 constants(含「允许」前缀),不另造一套文案。 */}
|
||||
<span
|
||||
className={`co-creation-tag co-creation-tag--authorization game-fork-badge game-fork-badge--${forkAuthorization}`}
|
||||
>
|
||||
{
|
||||
GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[
|
||||
forkAuthorization
|
||||
]
|
||||
}
|
||||
</span>
|
||||
</span>
|
||||
{lineage ? (
|
||||
<>
|
||||
@@ -791,8 +861,8 @@ function GameDetailContent({
|
||||
*/}
|
||||
<span className="game-fork-lineage">
|
||||
{lineage.parentTitle
|
||||
? `第 ${lineage.generation} 代作品 · 改编自《${lineage.parentTitle}》`
|
||||
: `第 ${lineage.generation} 代作品 · 原作品已不可用`}
|
||||
? `${coCreationGenerationTagLabel(lineage.generation)} · 改编自《${lineage.parentTitle}》`
|
||||
: `${coCreationGenerationTagLabel(lineage.generation)} · 原作品已不可用`}
|
||||
</span>
|
||||
{lineage.parentAuthorName ? (
|
||||
<span className="game-fork-lineage__author">
|
||||
@@ -801,11 +871,17 @@ function GameDetailContent({
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{forkCount > 0 ? (
|
||||
<span className="game-fork-derived">
|
||||
已被改编 {forkCount} 次
|
||||
{/* 数据概览:累计衍生作品数 + 累计创作世代数(后者等后端字段,缺失不渲染)。 */}
|
||||
<span className="co-creation-stats">
|
||||
<span className="co-creation-stat">
|
||||
累计衍生作品 {forkCount}
|
||||
</span>
|
||||
) : null}
|
||||
{descendantGenerationCount === undefined ? null : (
|
||||
<span className="co-creation-stat">
|
||||
累计创作世代数 {descendantGenerationCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{onOpenLineage && publiclyVisible ? (
|
||||
/*
|
||||
族谱入口放在共创信息卡里:它属于「这部作品的改编关系」这件事,
|
||||
@@ -858,30 +934,59 @@ function GameDetailContent({
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
{canRemix ? (
|
||||
{publiclyVisible ? (
|
||||
/*
|
||||
Fork 入口:服务端取件通道要求 Bearer + 已公开 + 授权非禁止。
|
||||
未登录按仓库既有登录门禁处理——入口照常出现(显示「登录后可 Fork」),
|
||||
点击走登录弹窗,而不是静默消失。
|
||||
已登录时点击直接唤起客户端;没失焦(没客户端接手)才降级出下面的手动面板。
|
||||
主操作「一键 Fork 共创」(文档口径):**始终显示**。
|
||||
- 授权为「禁止共创」时置灰(aria-disabled + 视觉弱化)并在悬停/点击给出原因,
|
||||
不再静默隐藏;
|
||||
- 未登录按仓库既有登录门禁:入口照常出现,点击走登录弹窗;
|
||||
- 已登录且授权开放:点击直接唤起客户端,没失焦(没客户端接手)才降级出下面面板。
|
||||
*/
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="game-fork-remix-link"
|
||||
className={`game-fork-remix-link co-creation-fork-primary${
|
||||
forkAuthorization === 'forbidden'
|
||||
? ' co-creation-fork-primary--blocked'
|
||||
: ''
|
||||
}`}
|
||||
aria-disabled={forkAuthorization === 'forbidden'}
|
||||
aria-expanded={
|
||||
isAuthenticated ? isForkPanelOpen : undefined
|
||||
isAuthenticated && forkAuthorization !== 'forbidden'
|
||||
? isForkPanelOpen
|
||||
: undefined
|
||||
}
|
||||
aria-controls={
|
||||
isAuthenticated ? 'game-fork-remix-panel' : undefined
|
||||
isAuthenticated && forkAuthorization !== 'forbidden'
|
||||
? 'game-fork-remix-panel'
|
||||
: undefined
|
||||
}
|
||||
onClick={() =>
|
||||
isAuthenticated ? handleForkLaunch() : onLogin()
|
||||
title={
|
||||
forkAuthorization === 'forbidden'
|
||||
? '作者未开放共创授权(禁止共创),无法 Fork 这部作品'
|
||||
: undefined
|
||||
}
|
||||
onClick={() => {
|
||||
if (forkAuthorization === 'forbidden') {
|
||||
setForkLaunchNotice(
|
||||
'作者未开放共创授权(禁止共创),无法 Fork 这部作品。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!isAuthenticated) {
|
||||
onLogin();
|
||||
return;
|
||||
}
|
||||
handleForkLaunch();
|
||||
}}
|
||||
>
|
||||
{isAuthenticated ? 'Fork 这个作品' : '登录后可 Fork'}
|
||||
{isAuthenticated
|
||||
? '一键 Fork 共创'
|
||||
: '登录后一键 Fork'}
|
||||
</button>
|
||||
{isAuthenticated && forkLaunchNotice ? (
|
||||
{(isAuthenticated ||
|
||||
forkAuthorization === 'forbidden') &&
|
||||
forkLaunchNotice ? (
|
||||
<span className="game-fork-remix-status" role="status">
|
||||
{forkLaunchNotice}
|
||||
</span>
|
||||
@@ -945,6 +1050,19 @@ function GameDetailContent({
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{lineageChain.length > 1 && onOpenGameDetail ? (
|
||||
<CoCreationLineageStrip
|
||||
chain={lineageChain}
|
||||
currentGameId={game.id}
|
||||
onOpenGameDetail={onOpenGameDetail}
|
||||
/>
|
||||
) : null}
|
||||
{derivedNodes.length > 0 && onOpenGameDetail ? (
|
||||
<CoCreationHotDerived
|
||||
nodes={derivedNodes}
|
||||
onOpenGameDetail={onOpenGameDetail}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -41,6 +41,7 @@ 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 getDerivedGamesMock = vi.hoisted(() => vi.fn());
|
||||
const getMyGameMock = vi.hoisted(() => vi.fn());
|
||||
const loadVersionRecoveryActionsMock = vi.hoisted(() => vi.fn());
|
||||
const recordGamePlayMock = vi.hoisted(() => vi.fn());
|
||||
@@ -70,6 +71,7 @@ vi.mock('../../services/gameDistributionClient', () => ({
|
||||
getTheme: getThemeMock,
|
||||
getGame: getGameMock,
|
||||
getGameLineage: getGameLineageMock,
|
||||
getDerivedGames: getDerivedGamesMock,
|
||||
getMyGame: getMyGameMock,
|
||||
loadVersionRecoveryActions: loadVersionRecoveryActionsMock,
|
||||
recordGamePlay: recordGamePlayMock,
|
||||
@@ -227,6 +229,8 @@ beforeEach(() => {
|
||||
listGamesMock.mockResolvedValue([]);
|
||||
getGameMock.mockResolvedValue(createGame());
|
||||
getGameLineageMock.mockResolvedValue(null);
|
||||
getDerivedGamesMock.mockReset();
|
||||
getDerivedGamesMock.mockResolvedValue(null);
|
||||
getMyGameMock.mockResolvedValue(null);
|
||||
loadVersionRecoveryActionsMock.mockResolvedValue({});
|
||||
listGameReviewsMock.mockResolvedValue({
|
||||
@@ -422,11 +426,11 @@ describe('GameDetailPage', () => {
|
||||
);
|
||||
|
||||
expect(await screen.findByText('允许非商用共创')).toBeTruthy();
|
||||
expect(screen.getByText('第 2 代作品 · 改编自《原初之光》')).toBeTruthy();
|
||||
expect(screen.getByText('2 代三创 · 改编自《原初之光》')).toBeTruthy();
|
||||
expect(screen.getByText('由 原作者 制作')).toBeTruthy();
|
||||
expect(screen.getByText('已被改编 3 次')).toBeTruthy();
|
||||
// 已公开 + 授权开放 → 「Fork 这个作品」入口出现(M1 时它还不存在,这里钉住新契约)。
|
||||
expect(screen.getByRole('button', { name: 'Fork 这个作品' })).toBeTruthy();
|
||||
expect(screen.getByText('累计衍生作品 3')).toBeTruthy();
|
||||
// 已公开 + 授权开放 → 「一键 Fork 共创」入口出现(M1 时它还不存在,这里钉住新契约)。
|
||||
expect(screen.getByRole('button', { name: '一键 Fork 共创' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('旧数据缺少授权与血缘字段时只按禁止共创渲染', async () => {
|
||||
@@ -461,7 +465,7 @@ describe('GameDetailPage', () => {
|
||||
);
|
||||
|
||||
expect(await screen.findByText('允许全开放共创')).toBeTruthy();
|
||||
expect(screen.getByText('第 2 代作品 · 原作品已不可用')).toBeTruthy();
|
||||
expect(screen.getByText('2 代三创 · 原作品已不可用')).toBeTruthy();
|
||||
expect(screen.queryByText(/改编自/u)).toBeNull();
|
||||
// 血缘作者行(卡内 `.game-fork-lineage__author`)不该出现;hero 里的
|
||||
// 「由 测试作者 制作」是作品自身署名,不在断言范围内。
|
||||
@@ -1751,7 +1755,7 @@ describe('GameDetailPage Fork 入口', () => {
|
||||
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||||
);
|
||||
|
||||
const entry = await screen.findByRole('button', { name: 'Fork 这个作品' });
|
||||
const entry = await screen.findByRole('button', { name: '一键 Fork 共创' });
|
||||
// 未展开前不露出获取方式。
|
||||
expect(screen.queryByText(/从平台作品开始创作/u)).toBeNull();
|
||||
|
||||
@@ -1803,7 +1807,7 @@ describe('GameDetailPage Fork 入口', () => {
|
||||
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||||
);
|
||||
|
||||
const entry = await screen.findByRole('button', { name: 'Fork 这个作品' });
|
||||
const entry = await screen.findByRole('button', { name: '一键 Fork 共创' });
|
||||
vi.useFakeTimers();
|
||||
fireEvent.click(entry);
|
||||
expect(openForkProtocolMock).toHaveBeenCalledWith('game-1');
|
||||
@@ -1833,7 +1837,7 @@ describe('GameDetailPage Fork 入口', () => {
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: 'Fork 这个作品' }),
|
||||
await screen.findByRole('button', { name: '一键 Fork 共创' }),
|
||||
);
|
||||
|
||||
expect(openForkProtocolMock).not.toHaveBeenCalled();
|
||||
@@ -1842,7 +1846,7 @@ describe('GameDetailPage Fork 入口', () => {
|
||||
expect(screen.getByRole('button', { name: '复制作品 ID' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('授权为禁止共创时不出现 Fork 入口', async () => {
|
||||
it('授权为禁止共创时主操作仍在但置灰,点击给出原因', async () => {
|
||||
getGameMock.mockResolvedValueOnce(
|
||||
createGame({ forkAuthorization: 'forbidden' }),
|
||||
);
|
||||
@@ -1850,9 +1854,16 @@ describe('GameDetailPage Fork 入口', () => {
|
||||
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByRole('heading', { name: '星轨防线' });
|
||||
expect(screen.queryByRole('button', { name: 'Fork 这个作品' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '登录后可 Fork' })).toBeNull();
|
||||
const blocked = await screen.findByRole('button', { name: '一键 Fork 共创' });
|
||||
// 不静默隐藏:按钮在,但是 aria-disabled + 有原因(悬停 title)。
|
||||
expect(blocked.getAttribute('aria-disabled')).toBe('true');
|
||||
expect(blocked.getAttribute('title')).toContain('禁止共创');
|
||||
// 点击给出可读原因(不发起唤起)。
|
||||
fireEvent.click(blocked);
|
||||
expect(
|
||||
await screen.findByText(/作者未开放共创授权(禁止共创),无法 Fork 这部作品。/u),
|
||||
).toBeTruthy();
|
||||
expect(openForkProtocolMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('未公开作品不出现 Fork 入口', async () => {
|
||||
@@ -1871,11 +1882,11 @@ describe('GameDetailPage Fork 入口', () => {
|
||||
);
|
||||
|
||||
await screen.findByText('作品状态');
|
||||
expect(screen.queryByRole('button', { name: 'Fork 这个作品' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '登录后可 Fork' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '一键 Fork 共创' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '登录后一键 Fork' })).toBeNull();
|
||||
});
|
||||
|
||||
it('未登录时入口显示「登录后可 Fork」,点击走仓库既有登录门禁而不是静默隐藏', async () => {
|
||||
it('未登录时入口显示「登录后一键 Fork」,点击走仓库既有登录门禁而不是静默隐藏', async () => {
|
||||
authUiMock.value.user = null;
|
||||
authUiMock.value.canAccessProtectedData = false;
|
||||
getGameMock.mockResolvedValueOnce(
|
||||
@@ -1885,7 +1896,7 @@ describe('GameDetailPage Fork 入口', () => {
|
||||
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||||
);
|
||||
|
||||
const gate = await screen.findByRole('button', { name: '登录后可 Fork' });
|
||||
const gate = await screen.findByRole('button', { name: '登录后一键 Fork' });
|
||||
expect(screen.queryByText(/从平台作品开始创作/u)).toBeNull();
|
||||
|
||||
fireEvent.click(gate);
|
||||
@@ -2728,6 +2739,135 @@ describe('GameLineagePage 可视化与交互', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GameDetailPage 共创模块(文档对齐)', () => {
|
||||
it('世代/授权标签按文档口径渲染,并显示累计衍生作品数', async () => {
|
||||
getGameMock.mockResolvedValueOnce(
|
||||
createGame({
|
||||
forkAuthorization: 'nonCommercial',
|
||||
forkCount: 3,
|
||||
lineage: {
|
||||
generation: 2,
|
||||
rootGameId: 'game-root',
|
||||
rootTitle: '根原创',
|
||||
parentGameId: 'game-parent',
|
||||
parentTitle: '原初之光',
|
||||
parentAuthorName: '原作者',
|
||||
},
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('2 代三创')).toBeTruthy();
|
||||
expect(screen.getByText('允许非商用共创')).toBeTruthy();
|
||||
expect(screen.getByText('累计衍生作品 3')).toBeTruthy();
|
||||
expect(screen.getByText('2 代三创 · 改编自《原初之光》')).toBeTruthy();
|
||||
// 主操作按文档叫「一键 Fork 共创」。
|
||||
expect(
|
||||
screen.getByRole('button', { name: '一键 Fork 共创' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('累计创作世代数字段缺失时不渲染该概览项(不拿别的数字凑数)', async () => {
|
||||
getGameMock.mockResolvedValueOnce(createGame({ forkCount: 2 }));
|
||||
render(
|
||||
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('累计衍生作品 2')).toBeTruthy();
|
||||
expect(screen.queryByText(/累计创作世代数/u)).toBeNull();
|
||||
});
|
||||
|
||||
it('血缘溯源条按「根 → 1 代 → 当前」渲染,点中间一代可跳该作品', async () => {
|
||||
getGameMock.mockResolvedValueOnce(createGame({ id: 'game-current' }));
|
||||
getGameLineageMock.mockResolvedValueOnce({
|
||||
rootGameId: 'game-root',
|
||||
root: null,
|
||||
nodes: [
|
||||
createLineageNode({
|
||||
gameId: 'game-root',
|
||||
title: '根原创',
|
||||
authorName: '根作者',
|
||||
generation: 0,
|
||||
parentGameId: null,
|
||||
}),
|
||||
createLineageNode({
|
||||
gameId: 'game-mid',
|
||||
title: '一代作品',
|
||||
authorName: '一代作者',
|
||||
generation: 1,
|
||||
parentGameId: 'game-root',
|
||||
}),
|
||||
createLineageNode({
|
||||
gameId: 'game-current',
|
||||
title: '当前作品',
|
||||
authorName: '当前作者',
|
||||
generation: 2,
|
||||
parentGameId: 'game-mid',
|
||||
}),
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
const onOpenGameDetail = vi.fn();
|
||||
render(
|
||||
<GameDetailPage
|
||||
gameId="game-current"
|
||||
onBack={vi.fn()}
|
||||
onPlay={vi.fn()}
|
||||
onOpenGameDetail={onOpenGameDetail}
|
||||
/>,
|
||||
);
|
||||
|
||||
const strip = await screen.findByLabelText('血缘溯源');
|
||||
expect(strip.textContent).toContain('根原创');
|
||||
expect(strip.textContent).toContain('一代作品');
|
||||
expect(strip.textContent).toContain('当前作品');
|
||||
// 头像位是姓名首字(链路节点没有头像字段,不伪造头像图)。
|
||||
expect(strip.textContent).toContain('根');
|
||||
// 当前作品标记为 current 且点击不跳转;点中间一代跳该作品。
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: /0 代作品《根原创》/u }),
|
||||
);
|
||||
expect(onOpenGameDetail).toHaveBeenCalledWith('game-root');
|
||||
fireEvent.click(screen.getByRole('button', { name: /2 代作品《当前作品》/u }));
|
||||
expect(onOpenGameDetail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('热门衍生栏按 playCount 倒序取前 5 个并可在点击后进入作品', async () => {
|
||||
getGameMock.mockResolvedValueOnce(createGame({ id: 'game-1' }));
|
||||
getDerivedGamesMock.mockResolvedValueOnce({
|
||||
gameId: 'game-1',
|
||||
nodes: [
|
||||
createLineageNode({ gameId: 'child-low', title: '低热度', playCount: 1, generation: 1, parentGameId: 'game-1' }),
|
||||
createLineageNode({ gameId: 'child-hot', title: '高热度', playCount: 99, generation: 1, parentGameId: 'game-1' }),
|
||||
createLineageNode({ gameId: 'child-mid', title: '中热度', playCount: 50, generation: 1, parentGameId: 'game-1' }),
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
const onOpenGameDetail = vi.fn();
|
||||
render(
|
||||
<GameDetailPage
|
||||
gameId="game-1"
|
||||
onBack={vi.fn()}
|
||||
onPlay={vi.fn()}
|
||||
onOpenGameDetail={onOpenGameDetail}
|
||||
/>,
|
||||
);
|
||||
|
||||
const hot = await screen.findByLabelText('热门衍生作品');
|
||||
const titles = ['高热度', '中热度', '低热度'];
|
||||
for (const title of titles) {
|
||||
expect(hot.textContent).toContain(title);
|
||||
}
|
||||
expect(hot.textContent?.indexOf('高热度')).toBeLessThan(
|
||||
hot.textContent?.indexOf('中热度') ?? -1,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /热门衍生《高热度》/u }));
|
||||
expect(onOpenGameDetail).toHaveBeenCalledWith('child-hot');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GameDetailPage 收藏按钮', () => {
|
||||
it('可判定为已收藏时显示「已收藏」,点击取消收藏', async () => {
|
||||
getGameMock.mockResolvedValueOnce(createGame({ collected: true }));
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 共创模块的用户可见文案口径(需求文档「功能交互 · 前台用户端」)。
|
||||
*
|
||||
* 世代表述按文档给的三档命名:`0 代原创 / 1 代二创 / 2 代三创 / N 代迭代`。
|
||||
* 树与溯源条上用短标签(`0 代` / `N 代`),详情页标签行用全称。
|
||||
*/
|
||||
export function coCreationGenerationTagLabel(generation: number) {
|
||||
if (generation <= 0) return '0 代原创';
|
||||
if (generation === 1) return '1 代二创';
|
||||
if (generation === 2) return '2 代三创';
|
||||
return `${generation} 代迭代`;
|
||||
}
|
||||
|
||||
/** 短标签:溯源条 / 图谱节点上的角标。 */
|
||||
export function coCreationGenerationShortLabel(generation: number) {
|
||||
return generation <= 0 ? '0 代' : `${generation} 代`;
|
||||
}
|
||||
|
||||
/** 作者头像降级:链路节点目前没有头像字段,用「姓名首字」圆形字标代替(不伪造头像图)。 */
|
||||
export function coCreationAvatarInitial(authorName: string | null | undefined) {
|
||||
const trimmed = (authorName ?? '').trim();
|
||||
return trimmed ? [...trimmed][0]! : '?';
|
||||
}
|
||||
@@ -723,6 +723,158 @@
|
||||
color: var(--platform-accent-strong);
|
||||
font-weight: 700;
|
||||
}
|
||||
/* 共创模块(详情页):世代/授权标签、数据概览、血缘溯源条、热门衍生栏。 */
|
||||
.game-detail-info-card .co-creation-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
justify-items: start;
|
||||
}
|
||||
.game-detail-info-card .co-creation-tag {
|
||||
border-radius: 999px;
|
||||
padding: 0.12rem 0.6rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.game-detail-info-card .co-creation-tag--generation {
|
||||
background: rgba(199, 101, 61, 0.14);
|
||||
color: var(--platform-accent-strong);
|
||||
}
|
||||
.game-detail-info-card .co-creation-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.game-detail-info-card .co-creation-stat {
|
||||
color: var(--platform-text-soft);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
.game-detail-info-card .co-creation-fork-primary {
|
||||
align-self: stretch;
|
||||
justify-content: center;
|
||||
padding: 0.45rem 0.9rem;
|
||||
border-color: var(--platform-accent-strong);
|
||||
background: rgba(199, 101, 61, 0.14);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
.game-detail-info-card .co-creation-fork-primary--blocked {
|
||||
border-color: var(--platform-surface-border);
|
||||
background: var(--platform-surface-muted, rgba(0, 0, 0, 0.04));
|
||||
color: var(--platform-text-soft);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
/* 血缘溯源条:横向链路,可横向滚动不撑破卡片。 */
|
||||
.game-detail-info-card .co-creation-lineage {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.2rem;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.2rem;
|
||||
}
|
||||
.game-detail-info-card .co-creation-lineage__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.game-detail-info-card .co-creation-lineage__arrow {
|
||||
color: var(--platform-text-soft);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.game-detail-info-card .co-creation-lineage__node {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 0.6rem;
|
||||
padding: 0.3rem 0.45rem;
|
||||
background: var(--platform-surface, #fff);
|
||||
color: var(--platform-text-base);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.game-detail-info-card .co-creation-lineage__node--current {
|
||||
border-color: var(--platform-accent-strong);
|
||||
box-shadow: 0 0 0 2px rgba(199, 101, 61, 0.16);
|
||||
cursor: default;
|
||||
}
|
||||
.game-detail-info-card .co-creation-lineage__avatar {
|
||||
display: grid;
|
||||
flex: none;
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(199, 101, 61, 0.18);
|
||||
color: var(--platform-accent-strong);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.game-detail-info-card .co-creation-lineage__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.game-detail-info-card .co-creation-lineage__generation {
|
||||
color: var(--platform-accent-strong);
|
||||
font-weight: 700;
|
||||
}
|
||||
.game-detail-info-card .co-creation-lineage__title {
|
||||
color: var(--platform-text-strong);
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.game-detail-info-card .co-creation-lineage__author {
|
||||
color: var(--platform-text-soft);
|
||||
}
|
||||
/* 热门衍生栏:横向卡片。 */
|
||||
.game-detail-info-card .co-creation-hot {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(7rem, 100%), 1fr));
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.game-detail-info-card .co-creation-hot__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 0.6rem;
|
||||
padding: 0.3rem;
|
||||
background: var(--platform-surface, #fff);
|
||||
color: var(--platform-text-base);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.game-detail-info-card .co-creation-hot__cover {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border-radius: 0.4rem;
|
||||
background: var(--platform-surface-border);
|
||||
}
|
||||
.game-detail-info-card .co-creation-hot__cover .game-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
}
|
||||
.game-detail-info-card .co-creation-hot__title {
|
||||
color: var(--platform-text-strong);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.game-detail-info-card .co-creation-hot__meta {
|
||||
color: var(--platform-text-soft);
|
||||
font-size: 0.68rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* 画布内悬浮控件(右下角):圆角胶囊、浅色底 + 毛玻璃、内边距统一、轻阴影。 */
|
||||
.game-lineage-controls {
|
||||
position: absolute;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import type { GameDistributionLineageNode } from '../../services/gameDistributionClient';
|
||||
import {
|
||||
buildLineageTreeLayout,
|
||||
collectLineageAncestorChain,
|
||||
collectLineageHighlight,
|
||||
generationAxisLabel,
|
||||
LINEAGE_COLUMN_GAP,
|
||||
@@ -259,6 +260,34 @@ describe('buildLineageTreeLayout', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectLineageAncestorChain', () => {
|
||||
const nodes = [
|
||||
node('root', 0, null, '根原创'),
|
||||
node('a', 1, 'root', '一代'),
|
||||
node('b', 2, 'a', '二代'),
|
||||
node('b2', 2, 'a', '二代旁支'),
|
||||
node('orphan', 1, 'game-missing', '孤儿'),
|
||||
];
|
||||
|
||||
it('从根到目标按顺序返回(供「根原创 → 1 代 → … → 当前」展示)', () => {
|
||||
expect(collectLineageAncestorChain(nodes, 'b').map((item) => item.gameId)).toEqual(
|
||||
['root', 'a', 'b'],
|
||||
);
|
||||
});
|
||||
|
||||
it('父不在可见集合里时链就停在那里(父作品不可用口径一致)', () => {
|
||||
expect(
|
||||
collectLineageAncestorChain(nodes, 'orphan').map((item) => item.gameId),
|
||||
).toEqual(['orphan']);
|
||||
});
|
||||
|
||||
it('未知节点返回空链,且遇到自环不会死循环', () => {
|
||||
expect(collectLineageAncestorChain(nodes, 'game-missing-chain')).toEqual([]);
|
||||
const cyclic = [node('x', 1, 'y'), node('y', 0, 'x')];
|
||||
expect(collectLineageAncestorChain(cyclic, 'x')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectLineageHighlight', () => {
|
||||
const tree = buildLineageTreeLayout([
|
||||
node('root', 0, null),
|
||||
|
||||
@@ -324,6 +324,27 @@ export function buildLineageTreeLayout(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 血缘链:从根到 `gameId` 的**有序**节点列表(血缘溯源条用)。
|
||||
*
|
||||
* 与 `collectLineageHighlight` 的区别:那个返回集合(给高亮用),这个返回顺序(给「根 → 1 代 → … → 当前」
|
||||
* 展示与点击用)。父节点不在可见集合里时链就停在那里(与既有的「父作品不可用」口径一致)。
|
||||
*/
|
||||
export function collectLineageAncestorChain<
|
||||
T extends { gameId: string; parentGameId?: string | null; generation: number },
|
||||
>(nodes: readonly T[], gameId: string): T[] {
|
||||
const byId = new Map(nodes.map((node) => [node.gameId, node]));
|
||||
const chain: T[] = [];
|
||||
let cursor = byId.get(gameId);
|
||||
const guard = new Set<string>();
|
||||
while (cursor && !guard.has(cursor.gameId)) {
|
||||
guard.add(cursor.gameId);
|
||||
chain.push(cursor);
|
||||
cursor = cursor.parentGameId ? byId.get(cursor.parentGameId) : undefined;
|
||||
}
|
||||
return chain.reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* hover / 聚焦一个节点时要高亮的集合:它自己 + 它的祖先路径;`includeSubtree` 为真时再加上
|
||||
* 它整棵子树。其余节点在渲染层降透明度。
|
||||
|
||||
@@ -1087,6 +1087,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
onOpenLineage={openGameLineage}
|
||||
onOpenCompare={openGameCompare}
|
||||
onOpenTheme={openGameTheme}
|
||||
onOpenGameDetail={openGameDetail}
|
||||
/>
|
||||
) : selectionStage === 'game-lineage' ? (
|
||||
<GameLineagePage
|
||||
|
||||
Reference in New Issue
Block a user