新增「原版与二创对比」页与路由登记(含 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
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:
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -39,6 +39,9 @@ describe('activeAppTitle', () => {
|
||||
expect(resolveAppTitleForSelectionStage('game-lineage')).toBe(
|
||||
'创作族谱 - 陶泥儿',
|
||||
);
|
||||
expect(resolveAppTitleForSelectionStage('game-compare')).toBe(
|
||||
'原版与二创对比 - 陶泥儿',
|
||||
);
|
||||
expect(resolveAppTitleForSelectionStage('game-publish')).toBe(
|
||||
'发布游戏 - 陶泥儿',
|
||||
);
|
||||
|
||||
@@ -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': '创作族谱 - 陶泥儿',
|
||||
|
||||
Reference in New Issue
Block a user