From 96309c895c40cd870514e5e6c82d33f9c1fe9b0c Mon Sep 17 00:00:00 2001
From: Suzumiya
Date: Tue, 6 Oct 2026 00:56:31 +0800
Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E3=80=8C=E5=8E=9F=E7=89=88?=
=?UTF-8?q?=E4=B8=8E=E4=BA=8C=E5=88=9B=E5=AF=B9=E6=AF=94=E3=80=8D=E9=A1=B5?=
=?UTF-8?q?=E4=B8=8E=E8=B7=AF=E7=94=B1=E7=99=BB=E8=AE=B0=EF=BC=88=E5=90=AB?=
=?UTF-8?q?=20nginx=C3=973=20+=20Pingora=20allowlist=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增路由 /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 排序
---
deploy/container/nginx.conf | 2 +-
deploy/nginx/genarrative-dev-http.conf | 2 +-
deploy/nginx/genarrative.conf | 2 +-
server-rs/crates/pingora-gateway/src/main.rs | 1 +
.../game-distribution/GameComparePage.tsx | 204 ++++++++++++++++++
.../game-distribution/GameDetailPage.tsx | 22 ++
.../GameDistributionPages.test.tsx | 160 +++++++++++++-
.../game-distribution/GameLineagePage.tsx | 16 ++
.../game-distribution/GamePlayFrame.tsx | 38 ++++
.../game-distribution/GamePlayPage.tsx | 12 +-
.../game-distribution/gameDistribution.css | 96 +++++++++
.../PlatformEntryActiveFlowShell.test.tsx | 64 ++++++
.../PlatformEntryActiveFlowShell.tsx | 40 ++++
.../platformEntryActiveTypes.ts | 1 +
src/routing/activeAppPageRoutes.test.ts | 4 +
src/routing/activeAppPageRoutes.ts | 1 +
src/services/activeAppTitle.test.ts | 3 +
src/services/activeAppTitle.ts | 1 +
18 files changed, 658 insertions(+), 11 deletions(-)
create mode 100644 src/components/game-distribution/GameComparePage.tsx
create mode 100644 src/components/game-distribution/GamePlayFrame.tsx
diff --git a/deploy/container/nginx.conf b/deploy/container/nginx.conf
index 4a486d7dd..18ae95044 100644
--- a/deploy/container/nginx.conf
+++ b/deploy/container/nginx.conf
@@ -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;
}
diff --git a/deploy/nginx/genarrative-dev-http.conf b/deploy/nginx/genarrative-dev-http.conf
index e3fbd8aff..41a1bb440 100644
--- a/deploy/nginx/genarrative-dev-http.conf
+++ b/deploy/nginx/genarrative-dev-http.conf
@@ -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) {
diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf
index 7db51c305..43a42017f 100644
--- a/deploy/nginx/genarrative.conf
+++ b/deploy/nginx/genarrative.conf
@@ -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) {
diff --git a/server-rs/crates/pingora-gateway/src/main.rs b/server-rs/crates/pingora-gateway/src/main.rs
index 6303ec1a3..c09f5e2fa 100644
--- a/server-rs/crates/pingora-gateway/src/main.rs
+++ b/server-rs/crates/pingora-gateway/src/main.rs
@@ -68,6 +68,7 @@ const MAIN_SPA_PATHS: &[&str] = &[
"/design-system",
"/editor/canvas",
"/games",
+ "/games/compare",
"/games/detail",
"/games/lineage",
"/games/mine",
diff --git a/src/components/game-distribution/GameComparePage.tsx b/src/components/game-distribution/GameComparePage.tsx
new file mode 100644
index 000000000..7eabef115
--- /dev/null
+++ b/src/components/game-distribution/GameComparePage.tsx
@@ -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(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 (
+
+
+ {isLoading ? (
+ 正在加载作品…
+ ) : error || !game ? (
+
+ {error || '这部作品不可读或未公开'}
+ {normalizedGameId ? (
+
+ ) : null}
+
+ ) : !entryUrl ? (
+
+ 这部作品暂无可玩版本,无法并排对比。
+
+
+ ) : (
+
+ setIsFrameReady(true)}
+ />
+
+ )}
+
+ );
+}
+
+/**
+ * 「原版与二创对比」页:左侧原版(父作品)、右侧二创(当前作品),各自独立取数与降级。
+ *
+ * 公开只读页面,不触发登录门禁;iframe 复用游玩页同一份嵌入组件(`GamePlayFrame`),
+ * 因此发行入口口径与安全属性不会与游玩页漂移。
+ */
+export function GameComparePage({
+ leftGameId,
+ rightGameId,
+ onBack,
+ onOpenDetail,
+}: GameComparePageProps) {
+ return (
+
+
+
+
+ 创作族谱
+
原版与二创对比
+
+
{
+ const target = normalizeGameId(leftGameId);
+ if (target) onOpenDetail(target);
+ }}
+ disabled={!normalizeGameId(leftGameId)}
+ >
+ 查看原版详情
+
+
+
+
+
+
+
+ 两侧各自加载:任一侧不可读或没有可玩版本时只降级那一侧,不影响另一侧。
+
+
+ );
+}
+
+export default GameComparePage;
diff --git a/src/components/game-distribution/GameDetailPage.tsx b/src/components/game-distribution/GameDetailPage.tsx
index 3e2374878..1318b05b1 100644
--- a/src/components/game-distribution/GameDetailPage.tsx
+++ b/src/components/game-distribution/GameDetailPage.tsx
@@ -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({
查看创作族谱
) : null}
+ {publiclyVisible && lineage?.parentGameId && onOpenCompare ? (
+ /*
+ 有父作品(二创作品)时给出「与原版对比」:左侧原版、右侧当前作品,
+ 两侧各自取数与降级(父作品不可读时只降级那一侧)。
+ */
+
+ ) : null}
{canRemix ? (
/*
Fork 入口:服务端取件通道要求 Bearer + 已公开 + 授权非禁止。
diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx
index 6adfd5fa6..44654b809 100644
--- a/src/components/game-distribution/GameDistributionPages.test.tsx
+++ b/src/components/game-distribution/GameDistributionPages.test.tsx
@@ -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(
+ ,
+ );
+
+ fireEvent.click(await screen.findByRole('button', { name: '与原版对比' }));
+ expect(onOpenCompare).toHaveBeenCalledWith('game-parent', 'game-1');
+ });
+
+ it('母版(无父作品)不出现对比入口', async () => {
+ getGameMock.mockResolvedValueOnce(createGame());
+ render(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ expect(await screen.findByText('作品不存在或未公开')).toBeTruthy();
+ expect(screen.getByText('二创作品')).toBeTruthy();
+ // 降级那一侧不渲染 iframe,另一侧照常。
+ expect(document.querySelectorAll('iframe.game-player-frame')).toHaveLength(1);
+ });
+});
diff --git a/src/components/game-distribution/GameLineagePage.tsx b/src/components/game-distribution/GameLineagePage.tsx
index b2af5a662..e4b4f91fe 100644
--- a/src/components/game-distribution/GameLineagePage.tsx
+++ b/src/components/game-distribution/GameLineagePage.tsx
@@ -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 (
原作品已不可用
) : null}
+ {hasVisibleParent && compareParentGameId && onOpenCompare ? (
+
+ ) : null}
);
}
@@ -107,6 +121,7 @@ export function GameLineagePage({
highlightGameId = null,
onBack,
onOpenDetail,
+ onOpenCompare,
}: GameLineagePageProps) {
const normalizedRootId = rootGameId?.trim() ?? '';
const [lineage, setLineage] = useState(
@@ -249,6 +264,7 @@ export function GameLineagePage({
visibleIds.has(node.parentGameId)
}
onOpenDetail={onOpenDetail}
+ onOpenCompare={onOpenCompare}
/>
))}
diff --git a/src/components/game-distribution/GamePlayFrame.tsx b/src/components/game-distribution/GamePlayFrame.tsx
new file mode 100644
index 000000000..ddd71c4ed
--- /dev/null
+++ b/src/components/game-distribution/GamePlayFrame.tsx
@@ -0,0 +1,38 @@
+import type { Ref } from 'react';
+
+/**
+ * 平台游戏的同源 iframe 嵌入:游玩页与「原版与二创对比」页共用同一份构建方式。
+ *
+ * 抽出来是为了不出现第二套嵌入逻辑——`sandbox` / `allow` / `referrerPolicy` 三项一旦
+ * 在别处复刻就会漂移,而它们是这个嵌入的安全边界(只允许脚本、不给同源、不带 referrer)。
+ * 入口地址仍由调用方用 `normalizeGameEntryUrl` 归一:平台同源路径 `/games//`,
+ * 历史数据里的绝对地址也按同一口径收敛。
+ */
+export function GamePlayFrame({
+ entryUrl,
+ title,
+ ready,
+ frameRef,
+ onLoad,
+}: {
+ entryUrl: string;
+ /** 无障碍标题,形如「<作品名> 在线游玩」。 */
+ title: string;
+ /** 是否已加载完成:`.game-player-frame--ready` 控制就绪后的可见态。 */
+ ready: boolean;
+ frameRef?: Ref;
+ onLoad?: () => void;
+}) {
+ return (
+
+ );
+}
diff --git a/src/components/game-distribution/GamePlayPage.tsx b/src/components/game-distribution/GamePlayPage.tsx
index 908efda23..69a5d6cf9 100644
--- a/src/components/game-distribution/GamePlayPage.tsx
+++ b/src/components/game-distribution/GamePlayPage.tsx
@@ -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) {
) : null}
{hasStarted ? (
-