新增「原版与二创对比」页与路由登记(含 nginx×3 + Pingora allowlist)
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

- 新增路由 /games/compare(stage game-compare):STAGE_ROUTE_ENTRIES + SelectionStage 类型 + 壳层 lazy 引入/导航回调/渲染分支 + 页面标题,四处同批登记
- 三份 nginx SPA allowlist(deploy/nginx/genarrative.conf、deploy/nginx/genarrative-dev-http.conf、deploy/container/nginx.conf)与 Pingora MAIN_SPA_PATHS 同步放行 /games/compare(保持既有排序):check:nginx-spa-routes 18 SPA routes、check:pingora-route-parity 均绿
- 对比页左右两侧各自取数与降级:任一侧不可读(不存在/未公开/已软删除)或没有可玩版本时只降级那一侧,另一侧照常;显示标题、作者与代际;桌面左右并排、窄屏上下堆叠
- iframe 复用游玩页同一份嵌入:抽出 GamePlayFrame(sandbox=allow-scripts、allow=fullscreen、referrerPolicy=no-referrer、.game-player-frame),游玩页改为渲染它,不再存在第二套嵌入逻辑
- 入口:二创作品详情页在存在父作品时给出「与原版对比」;族谱节点仅在其父作品可见时给出同一入口
- 测试:对比页两侧渲染与 iframe 复用属性、单侧降级后另一侧照常;详情页入口(有/无父作品);族谱节点入口(父可见才出现);路由与标题登记;壳层「族谱节点→对比页」导航与对比 stage 渲染
- 顺手:按 eslint 自动修复两个文件的 import 排序
This commit is contained in:
2026-10-06 00:56:31 +08:00
parent 4cf00ac07d
commit 96309c895c
18 changed files with 658 additions and 11 deletions
+1 -1
View File
@@ -157,7 +157,7 @@ http {
try_files /index.html =404;
}
location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|creators|creators/connections|games|games/detail|games/lineage|games/mine|games/play|games/publish)/?$" {
location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|creators|creators/connections|games|games/compare|games/detail|games/lineage|games/mine|games/play|games/publish)/?$" {
try_files $uri /index.html =404;
}
+1 -1
View File
@@ -206,7 +206,7 @@ server {
try_files /index.html =404;
}
location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|creators|creators/connections|games|games/detail|games/lineage|games/mine|games/play|games/publish)/?$" {
location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|creators|creators/connections|games|games/compare|games/detail|games/lineage|games/mine|games/play|games/publish)/?$" {
error_page 503 /maintenance.html;
if ($genarrative_maintenance) {
+1 -1
View File
@@ -234,7 +234,7 @@ server {
try_files /index.html =404;
}
location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|creators|creators/connections|games|games/detail|games/lineage|games/mine|games/play|games/publish)/?$" {
location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|creators|creators/connections|games|games/compare|games/detail|games/lineage|games/mine|games/play|games/publish)/?$" {
error_page 503 /maintenance.html;
if ($genarrative_maintenance) {
@@ -68,6 +68,7 @@ const MAIN_SPA_PATHS: &[&str] = &[
"/design-system",
"/editor/canvas",
"/games",
"/games/compare",
"/games/detail",
"/games/lineage",
"/games/mine",
@@ -0,0 +1,204 @@
import './gameDistribution.css';
import { ArrowLeft } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
type GameDistributionGame,
getGame,
} from '../../services/gameDistributionClient';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { normalizeGameEntryUrl, normalizeGameId } from './gameDistributionGuards';
import { GamePlayFrame } from './GamePlayFrame';
import { useGameAuthorDisplayName } from './useGameAuthorName';
type GameComparePageProps = {
leftGameId: string | null;
rightGameId: string | null;
onBack: () => void;
onOpenDetail: (gameId: string) => void;
};
function generationLabel(game: GameDistributionGame) {
const generation = game.lineage?.generation ?? 0;
return generation === 0 ? '母版' : `第 ${generation} 代`;
}
/**
* 单侧对比栏:自己负责取数与降级,不阻塞另一侧。
*
* 作品不可读(不存在 / 未公开 / 已软删除)时给出明确文案而不是空白——与族谱页同一口径:
* 公开页面不展示未公开作品,也不假装它存在。
*/
function GameCompareSide({
role,
gameId,
onOpenDetail,
}: {
role: '原版' | '二创';
gameId: string | null;
onOpenDetail: (gameId: string) => void;
}) {
const normalizedGameId = normalizeGameId(gameId);
const [game, setGame] = useState<GameDistributionGame | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const [isFrameReady, setIsFrameReady] = useState(false);
const requestSerialRef = useRef(0);
const authorName = useGameAuthorDisplayName(game?.author);
const loadGame = useCallback(() => {
const requestSerial = ++requestSerialRef.current;
setIsFrameReady(false);
setGame(null);
setError('');
if (!normalizedGameId) {
setIsLoading(false);
setError('缺少作品编号');
return;
}
setIsLoading(true);
void getGame(normalizedGameId)
.then((value) => {
if (requestSerial !== requestSerialRef.current) return;
if (!value) {
setError('这部作品不可读或未公开');
return;
}
setGame(value);
})
.catch((loadError: unknown) => {
if (requestSerial !== requestSerialRef.current) return;
setError(
loadError instanceof Error
? loadError.message
: '这部作品不可读或未公开',
);
})
.finally(() => {
if (requestSerial === requestSerialRef.current) setIsLoading(false);
});
}, [normalizedGameId]);
useEffect(() => {
loadGame();
return () => {
requestSerialRef.current += 1;
};
}, [loadGame]);
const entryUrl = game?.currentVersion
? normalizeGameEntryUrl(game.currentVersion.entryUrl)
: '';
return (
<section className="game-compare-side" aria-label={role}>
<header className="game-compare-side__head">
<span className="game-compare-side__role">{role}</span>
<h3 className="game-compare-side__title">
{game?.title?.trim() || '(未命名作品)'}
</h3>
{game ? (
<p className="game-compare-side__meta">
<span>{authorName?.trim() || '未署名作者'}</span>
<span aria-hidden="true">·</span>
<span>{generationLabel(game)}</span>
</p>
) : null}
</header>
{isLoading ? (
<div className="game-loading-panel">正在加载作品…</div>
) : error || !game ? (
<PlatformStatusMessage tone="error" surface="platform">
<span>{error || '这部作品不可读或未公开'}</span>
{normalizedGameId ? (
<button
type="button"
className="game-lineage-retry"
onClick={() => loadGame()}
>
重试
</button>
) : null}
</PlatformStatusMessage>
) : !entryUrl ? (
<PlatformStatusMessage tone="error" surface="platform">
<span>这部作品暂无可玩版本,无法并排对比。</span>
<button
type="button"
className="game-lineage-retry"
onClick={() => onOpenDetail(game.id)}
>
查看详情
</button>
</PlatformStatusMessage>
) : (
<div className="game-compare-side__frame">
<GamePlayFrame
entryUrl={entryUrl}
title={`${game.title} 在线游玩`}
ready={isFrameReady}
onLoad={() => setIsFrameReady(true)}
/>
</div>
)}
</section>
);
}
/**
* 「原版与二创对比」页:左侧原版(父作品)、右侧二创(当前作品),各自独立取数与降级。
*
* 公开只读页面,不触发登录门禁;iframe 复用游玩页同一份嵌入组件(`GamePlayFrame`),
* 因此发行入口口径与安全属性不会与游玩页漂移。
*/
export function GameComparePage({
leftGameId,
rightGameId,
onBack,
onOpenDetail,
}: GameComparePageProps) {
return (
<div className="game-page game-compare-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"
onClick={() => {
const target = normalizeGameId(leftGameId);
if (target) onOpenDetail(target);
}}
disabled={!normalizeGameId(leftGameId)}
>
查看原版详情
</PlatformActionButton>
</div>
<div className="game-compare-grid">
<GameCompareSide
role="原版"
gameId={leftGameId}
onOpenDetail={onOpenDetail}
/>
<GameCompareSide
role="二创"
gameId={rightGameId}
onOpenDetail={onOpenDetail}
/>
</div>
<p className="game-detail-footnote">
两侧各自加载:任一侧不可读或没有可玩版本时只降级那一侧,不影响另一侧。
</p>
</div>
);
}
export default GameComparePage;
@@ -60,6 +60,11 @@ type GameDetailPageProps = {
* 回调缺失时不渲染族谱入口(测试或没有导航能力的宿主)。
*/
onOpenLineage?: (rootGameId: string, fromGameId?: string) => void;
/**
* 打开「原版与二创对比」页;参数是原版(父作品)与二创(当前作品)。
* 只有存在父作品的二创作品才渲染该入口。
*/
onOpenCompare?: (leftGameId: string, rightGameId: string) => void;
/**
* 作者视角:走 `GET /my-games/{gameId}` 读取自己名下作品。
*
@@ -122,6 +127,7 @@ export function GameDetailPage({
onPlay,
onOpenLineage,
onOpenCreator,
onOpenCompare,
ownerView = false,
}: GameDetailPageProps) {
const authUi = useAuthUi();
@@ -281,6 +287,7 @@ export function GameDetailPage({
onBack={onBack}
onPlay={onPlay}
onOpenLineage={onOpenLineage}
onOpenCompare={onOpenCompare}
isAuthenticated={Boolean(authUi?.user)}
/>
);
@@ -299,6 +306,7 @@ function GameDetailContent({
onPlay,
onOpenLineage,
onOpenCreator,
onOpenCompare,
}: {
game: GameDistributionGame;
ownerView: boolean;
@@ -313,6 +321,7 @@ function GameDetailContent({
onPlay: (gameId: string) => void;
onOpenLineage?: (rootGameId: string, fromGameId?: string) => void;
onOpenCreator?: (id: string) => void;
onOpenCompare?: (leftGameId: string, rightGameId: string) => void;
}) {
/**
* 只有已公开作品才有公开评价/评分投影;审核中、审核未通过、未公开、已下架
@@ -523,6 +532,19 @@ function GameDetailContent({
查看创作族谱
</button>
) : null}
{publiclyVisible && lineage?.parentGameId && onOpenCompare ? (
/*
有父作品(二创作品)时给出「与原版对比」:左侧原版、右侧当前作品,
两侧各自取数与降级(父作品不可读时只降级那一侧)。
*/
<button
type="button"
className="game-fork-compare-link"
onClick={() => onOpenCompare(lineage.parentGameId, game.id)}
>
与原版对比
</button>
) : null}
{canRemix ? (
/*
Fork 入口:服务端取件通道要求 Bearer + 已公开 + 授权非禁止。
@@ -20,9 +20,10 @@ import type {
GameDistributionVersionStatusEntry,
} from '../../services/gameDistributionClient';
import { setCreatorViewer } from '../creator/creatorRelationshipState';
import { GameComparePage } from './GameComparePage';
import { GameDetailPage } from './GameDetailPage';
import { GameGalleryPage } from './GameGalleryPage';
import { FORK_LAUNCH_FALLBACK_DELAY_MS } from './gameForkProtocol';
import { GameGalleryPage } from './GameGalleryPage';
import { GameLineagePage } from './GameLineagePage';
import { GamePlayPage } from './GamePlayPage';
import { resolveNonPublicCoverLabel } from './gameVersionHistoryModel';
@@ -1525,3 +1526,160 @@ describe('GameDetailPage Fork 入口', () => {
expect(screen.queryByText(/从平台作品开始创作/u)).toBeNull();
});
});
describe('原版与二创对比入口', () => {
it('二创作品详情页给出「与原版对比」,带上原版与二创 id', async () => {
getGameMock.mockResolvedValueOnce(
createGame({
lineage: {
generation: 1,
rootGameId: 'game-parent',
rootTitle: '原版作品',
parentGameId: 'game-parent',
parentTitle: '原版作品',
parentAuthorName: '原作者',
},
}),
);
const onOpenCompare = vi.fn();
render(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenCompare={onOpenCompare}
/>,
);
fireEvent.click(await screen.findByRole('button', { name: '与原版对比' }));
expect(onOpenCompare).toHaveBeenCalledWith('game-parent', 'game-1');
});
it('母版(无父作品)不出现对比入口', async () => {
getGameMock.mockResolvedValueOnce(createGame());
render(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenCompare={vi.fn()}
/>,
);
await screen.findByRole('heading', { name: '星轨防线' });
expect(screen.queryByRole('button', { name: '与原版对比' })).toBeNull();
});
it('族谱节点只在其父作品可见时给出对比入口', async () => {
getGameLineageMock.mockResolvedValueOnce({
rootGameId: 'game-root',
root: createLineageNode({
gameId: 'game-root',
title: '母版',
generation: 0,
parentGameId: null,
}),
nodes: [
createLineageNode({
gameId: 'game-root',
title: '母版',
generation: 0,
parentGameId: null,
}),
createLineageNode({
gameId: 'game-leaf',
title: '叶节点',
generation: 1,
parentGameId: 'game-root',
}),
createLineageNode({
gameId: 'game-orphan',
title: '孤儿节点',
generation: 1,
parentGameId: 'game-missing',
}),
],
truncated: false,
});
const onOpenCompare = vi.fn();
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
onOpenCompare={onOpenCompare}
/>,
);
expect(await screen.findByText('叶节点')).toBeTruthy();
// 两个节点各有一个可见父引用?只有叶节点的父(game-root)在树里。
const compareButtons = screen.getAllByRole('button', { name: '与原版对比' });
expect(compareButtons).toHaveLength(1);
fireEvent.click(compareButtons[0]!);
expect(onOpenCompare).toHaveBeenCalledWith('game-root', 'game-leaf');
});
});
describe('GameComparePage', () => {
it('左右并排渲染两侧作品并复用游玩页的同源 iframe', async () => {
getGameMock.mockImplementation((gameId: string) =>
Promise.resolve(
gameId === 'game-parent'
? createGame({ id: 'game-parent', title: '原版作品' })
: createGame({
id: 'game-child',
title: '二创作品',
lineage: {
generation: 1,
rootGameId: 'game-parent',
rootTitle: '原版作品',
parentGameId: 'game-parent',
parentTitle: '原版作品',
parentAuthorName: '原作者',
},
}),
),
);
render(
<GameComparePage
leftGameId="game-parent"
rightGameId="game-child"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
expect(await screen.findByText('原版作品')).toBeTruthy();
expect(screen.getByText('二创作品')).toBeTruthy();
expect(screen.getByText('母版')).toBeTruthy();
expect(screen.getByText('第 1 代')).toBeTruthy();
const frames = document.querySelectorAll('iframe.game-player-frame');
expect(frames).toHaveLength(2);
// 复用游玩页的嵌入属性:只允许脚本、不给同源、不带 referrer。
expect(frames[0]?.getAttribute('sandbox')).toBe('allow-scripts');
expect(frames[0]?.getAttribute('referrerpolicy')).toBe('no-referrer');
expect(frames[0]?.getAttribute('src')).toContain('/releases/version-1/');
});
it('任一侧不可读时只降级那一侧,另一侧照常渲染', async () => {
getGameMock.mockImplementation((gameId: string) =>
gameId === 'game-parent'
? Promise.reject(new Error('作品不存在或未公开'))
: Promise.resolve(createGame({ id: 'game-child', title: '二创作品' })),
);
render(
<GameComparePage
leftGameId="game-parent"
rightGameId="game-child"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
expect(await screen.findByText('作品不存在或未公开')).toBeTruthy();
expect(screen.getByText('二创作品')).toBeTruthy();
// 降级那一侧不渲染 iframe,另一侧照常。
expect(document.querySelectorAll('iframe.game-player-frame')).toHaveLength(1);
});
});
@@ -22,6 +22,8 @@ type GameLineagePageProps = {
highlightGameId?: string | null;
onBack: () => void;
onOpenDetail: (gameId: string) => void;
/** 打开「原版与二创对比」;参数是原版(父作品)与二创(该节点)。 */
onOpenCompare?: (leftGameId: string, rightGameId: string) => void;
};
/**
@@ -52,17 +54,20 @@ function LineageNodeCard({
isAnchor,
hasVisibleParent,
onOpenDetail,
onOpenCompare,
}: {
node: GameDistributionLineageNode;
isAnchor: boolean;
hasVisibleParent: boolean;
onOpenDetail: (gameId: string) => void;
onOpenCompare?: (leftGameId: string, rightGameId: string) => void;
}) {
const title = node.title.trim() || '未命名作品';
// 父作品不在可见集合里(已软删除或未公开)时不得猜测它的内容,只标注「原作品已不可用」;
// 节点自身照常留在树上,不出现空白或断链。
const parentUnavailable = Boolean(node.parentGameId) && !hasVisibleParent;
const authorName = node.authorName?.trim() ?? '';
const compareParentGameId = node.parentGameId;
return (
<article
@@ -92,6 +97,15 @@ function LineageNodeCard({
{parentUnavailable ? (
<p className="game-lineage-node__parent-unavailable">原作品已不可用</p>
) : null}
{hasVisibleParent && compareParentGameId && onOpenCompare ? (
<button
type="button"
className="game-lineage-node__compare"
onClick={() => onOpenCompare(compareParentGameId, node.gameId)}
>
与原版对比
</button>
) : null}
</article>
);
}
@@ -107,6 +121,7 @@ export function GameLineagePage({
highlightGameId = null,
onBack,
onOpenDetail,
onOpenCompare,
}: GameLineagePageProps) {
const normalizedRootId = rootGameId?.trim() ?? '';
const [lineage, setLineage] = useState<GameDistributionLineageResponse | null>(
@@ -249,6 +264,7 @@ export function GameLineagePage({
visibleIds.has(node.parentGameId)
}
onOpenDetail={onOpenDetail}
onOpenCompare={onOpenCompare}
/>
))}
</div>
@@ -0,0 +1,38 @@
import type { Ref } from 'react';
/**
* 平台游戏的同源 iframe 嵌入:游玩页与「原版与二创对比」页共用同一份构建方式。
*
* 抽出来是为了不出现第二套嵌入逻辑——`sandbox` / `allow` / `referrerPolicy` 三项一旦
* 在别处复刻就会漂移,而它们是这个嵌入的安全边界(只允许脚本、不给同源、不带 referrer)。
* 入口地址仍由调用方用 `normalizeGameEntryUrl` 归一:平台同源路径 `/games/<gameId>/`,
* 历史数据里的绝对地址也按同一口径收敛。
*/
export function GamePlayFrame({
entryUrl,
title,
ready,
frameRef,
onLoad,
}: {
entryUrl: string;
/** 无障碍标题,形如「<作品名> 在线游玩」。 */
title: string;
/** 是否已加载完成:`.game-player-frame--ready` 控制就绪后的可见态。 */
ready: boolean;
frameRef?: Ref<HTMLIFrameElement>;
onLoad?: () => void;
}) {
return (
<iframe
ref={frameRef}
title={title}
src={entryUrl}
className={`game-player-frame ${ready ? 'game-player-frame--ready' : ''}`}
sandbox="allow-scripts"
allow="fullscreen"
referrerPolicy="no-referrer"
onLoad={onLoad}
/>
);
}
@@ -27,6 +27,7 @@ import {
useIsMobileViewport,
useIsPortraitViewport,
} from './gameDistributionGuards';
import { GamePlayFrame } from './GamePlayFrame';
type GamePlayPageProps = { gameId: string | null; onBack: () => void };
@@ -236,15 +237,12 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
</PlatformStatusMessage>
) : null}
{hasStarted ? (
<iframe
ref={frameRef}
<GamePlayFrame
key={`${game.currentVersion.id}:${frameRunId}`}
frameRef={frameRef}
title={`${game.title} 在线游玩`}
src={game.currentVersion.entryUrl}
className={`game-player-frame ${startupState === 'ready' ? 'game-player-frame--ready' : ''}`}
sandbox="allow-scripts"
allow="fullscreen"
referrerPolicy="no-referrer"
entryUrl={game.currentVersion.entryUrl}
ready={startupState === 'ready'}
onLoad={() => setStartupState('ready')}
/>
) : null}
@@ -443,6 +443,102 @@
font-size: 0.74rem;
overflow-wrap: anywhere;
}
/*
* 「原版与二创对比」:桌面端左右并排,窄屏改上下堆叠(不出现横向溢出)。
* iframe 复用游玩页的 `.game-player-frame`,这里只给两侧容器定尺寸与堆叠规则。
*/
.game-compare-page {
max-width: 90rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.game-compare-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
align-items: start;
}
.game-compare-side {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
}
.game-compare-side__head {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.game-compare-side__role {
align-self: flex-start;
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-compare-side__title {
margin: 0;
color: var(--platform-text-strong);
font-size: 0.95rem;
overflow-wrap: anywhere;
}
.game-compare-side__meta {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin: 0;
color: var(--platform-text-soft);
font-size: 0.78rem;
overflow-wrap: anywhere;
}
.game-compare-side__frame {
position: relative;
aspect-ratio: 4 / 3;
min-height: 18rem;
border: 1px solid var(--platform-surface-border);
border-radius: 0.75rem;
overflow: hidden;
}
@media (max-width: 900px) {
.game-compare-grid {
grid-template-columns: minmax(0, 1fr);
}
.game-compare-side__frame {
min-height: 15rem;
}
}
.game-detail-info-card .game-fork-compare-link {
justify-self: start;
border: 1px solid var(--platform-surface-border);
border-radius: 999px;
padding: 0.22rem 0.7rem;
background: transparent;
color: var(--platform-text-base);
font: inherit;
font-size: 0.76rem;
font-weight: 600;
cursor: pointer;
}
.game-detail-info-card .game-fork-compare-link:hover {
color: var(--platform-text-strong);
}
.game-lineage-node__compare {
align-self: flex-start;
border: 1px solid var(--platform-surface-border);
border-radius: 999px;
padding: 0.18rem 0.65rem;
background: transparent;
color: var(--platform-text-base);
font: inherit;
font-size: 0.74rem;
font-weight: 600;
cursor: pointer;
}
.game-loading-panel {
display: grid;
min-height: 18rem;
@@ -1429,4 +1429,68 @@ describe('创作族谱', () => {
path: '/games/lineage?id=game-root&from=game-1',
});
});
it('族谱节点可打开「原版与二创对比」', async () => {
window.history.replaceState(null, '', '/games/lineage?id=game-root');
gameDistributionMock.getGameLineage.mockResolvedValue(lineageTree);
const setSelectionStage = vi.fn();
render(
<PlatformEntryFlowShellImpl
selectionStage="game-lineage"
setSelectionStage={setSelectionStage}
/>,
);
fireEvent.click(await screen.findByRole('button', { name: '与原版对比' }));
expect(setSelectionStage).toHaveBeenCalledWith('game-compare', {
path: '/games/compare?left=game-root&right=game-leaf',
});
});
it('对比页按 left/right 参数左右渲染两侧作品', async () => {
window.history.replaceState(
null,
'',
'/games/compare?left=game-1&right=game-2',
);
gameDistributionMock.getGame.mockImplementation((gameId: string) =>
Promise.resolve({
id: gameId,
title: `对比作品 ${gameId}`,
summary: '并排对比',
description: '',
category: '动作',
tags: [],
coverColor: '#d77a51',
icon: '✦',
author: { id: 'author-1', name: '测试作者' },
deviceSupport: { desktop: true, mobile: true, touch: true },
status: 'published',
currentVersion: {
id: `version-${gameId}`,
version: '1.0.0',
entryUrl: `https://play.example.test/releases/${gameId}/index.html`,
sha256: 'sha256',
publishedAt: '2026-09-18T00:00:00.000Z',
controls: ['键盘'],
},
playCount: 1,
createdAt: '2026-09-18T00:00:00.000Z',
}),
);
render(
<PlatformEntryFlowShellImpl
selectionStage="game-compare"
setSelectionStage={vi.fn()}
/>,
);
expect(await screen.findByText('对比作品 game-1')).toBeTruthy();
expect(screen.getByText('对比作品 game-2')).toBeTruthy();
expect(document.querySelectorAll('iframe.game-player-frame')).toHaveLength(
2,
);
});
});
@@ -120,6 +120,11 @@ const GameLineagePage = lazy(async () => {
return { default: module.GameLineagePage };
});
const GameComparePage = lazy(async () => {
const module = await import('../game-distribution/GameComparePage');
return { default: module.GameComparePage };
});
type ActiveRailButtonProps = {
active: boolean;
emphasized?: boolean;
@@ -565,6 +570,29 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage('games', { path: '/games' });
}, [setSelectionStage]);
/** 打开「原版与二创对比」:左侧原版(父作品)、右侧二创(当前作品)。 */
const openGameCompare = useCallback(
(leftGameId: string, rightGameId: string) => {
const params = new URLSearchParams({
left: leftGameId,
right: rightGameId,
});
const path = `/games/compare?${params.toString()}`;
setSelectionStage('game-compare', { path });
},
[setSelectionStage],
);
/** 对比页同理:原生返回优先,兜底回广场。 */
const backFromGameCompare = useCallback(() => {
if (hasAppHistoryBackEntry()) {
window.history.back();
return;
}
replaceAppHistoryPath('/games');
setSelectionStage('games', { path: '/games' });
}, [setSelectionStage]);
/**
* 详情页可能从广场或「我的游戏」进入,返回优先走浏览器原生返回,这样能回到
* 用户真正的来源页;直接打开详情链接等没有可回退的应用内历史时再兜底回广场。
@@ -699,6 +727,7 @@ export function PlatformEntryFlowShellImpl({
const isGamesStage =
selectionStage === 'games' ||
selectionStage === 'game-detail' ||
selectionStage === 'game-compare' ||
selectionStage === 'game-play' ||
selectionStage === 'game-mine' ||
selectionStage === 'game-lineage' ||
@@ -713,6 +742,8 @@ export function PlatformEntryFlowShellImpl({
);
const gameId = gameSearchParams.get('id');
const lineageHighlightGameId = gameSearchParams.get('from');
const compareLeftGameId = gameSearchParams.get('left');
const compareRightGameId = gameSearchParams.get('right');
const creatorConnectionTab = gameSearchParams.get('tab');
const publishGameId = gameSearchParams.get('game');
const avatarUrl = authUi?.user?.avatarUrl?.trim() || null;
@@ -1005,6 +1036,7 @@ export function PlatformEntryFlowShellImpl({
onBack={backFromGameDetail}
onPlay={openGamePlay}
onOpenLineage={openGameLineage}
onOpenCompare={openGameCompare}
/>
) : selectionStage === 'game-lineage' ? (
<GameLineagePage
@@ -1012,6 +1044,14 @@ export function PlatformEntryFlowShellImpl({
highlightGameId={lineageHighlightGameId}
onBack={backFromGameLineage}
onOpenDetail={openGameDetail}
onOpenCompare={openGameCompare}
/>
) : selectionStage === 'game-compare' ? (
<GameComparePage
leftGameId={compareLeftGameId}
rightGameId={compareRightGameId}
onBack={backFromGameCompare}
onOpenDetail={openGameDetail}
/>
) : (
<GamePlayPage gameId={gameId} onBack={backFromGamePlay} />
@@ -10,6 +10,7 @@ export type SelectionStage =
| 'image-editor'
| 'games'
| 'game-detail'
| 'game-compare'
| 'game-play'
| 'game-mine'
| 'game-lineage'
+4
View File
@@ -26,6 +26,9 @@ describe('appPageRoutes', () => {
);
expect(resolveSelectionStageFromPath('/games')).toBe('games');
expect(resolveSelectionStageFromPath('/games/detail/')).toBe('game-detail');
expect(resolveSelectionStageFromPath('/games/compare')).toBe(
'game-compare',
);
expect(resolveSelectionStageFromPath('/games/play')).toBe('game-play');
expect(resolveSelectionStageFromPath('/games/mine')).toBe('game-mine');
expect(resolveSelectionStageFromPath('/games/lineage')).toBe(
@@ -47,6 +50,7 @@ describe('appPageRoutes', () => {
expect(resolvePathForSelectionStage('project')).toBe('/project');
expect(resolvePathForSelectionStage('profile')).toBe('/profile');
expect(resolvePathForSelectionStage('games')).toBe('/games');
expect(resolvePathForSelectionStage('game-compare')).toBe('/games/compare');
expect(resolvePathForSelectionStage('game-mine')).toBe('/games/mine');
expect(resolvePathForSelectionStage('game-lineage')).toBe(
'/games/lineage',
+1
View File
@@ -15,6 +15,7 @@ const STAGE_ROUTE_ENTRIES = [
['image-editor', '/editor/canvas'],
['games', '/games'],
['game-detail', '/games/detail'],
['game-compare', '/games/compare'],
['game-play', '/games/play'],
['game-mine', '/games/mine'],
['game-lineage', '/games/lineage'],
+3
View File
@@ -39,6 +39,9 @@ describe('activeAppTitle', () => {
expect(resolveAppTitleForSelectionStage('game-lineage')).toBe(
'创作族谱 - 陶泥儿',
);
expect(resolveAppTitleForSelectionStage('game-compare')).toBe(
'原版与二创对比 - 陶泥儿',
);
expect(resolveAppTitleForSelectionStage('game-publish')).toBe(
'发布游戏 - 陶泥儿',
);
+1
View File
@@ -16,6 +16,7 @@ const APP_TITLE_BY_SELECTION_STAGE: Record<SelectionStage, string> = {
'image-editor': '美术编辑器 - 陶泥儿',
games: '游戏 - 陶泥儿',
'game-detail': '游戏详情 - 陶泥儿',
'game-compare': '原版与二创对比 - 陶泥儿',
'game-play': '正在游玩 - 陶泥儿',
'game-mine': '我的游戏 - 陶泥儿',
'game-lineage': '创作族谱 - 陶泥儿',