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 ( +
+
+ {role} +

+ {game?.title?.trim() || '(未命名作品)'} +

+ {game ? ( +

+ {authorName?.trim() || '未署名作者'} + + {generationLabel(game)} +

+ ) : null} +
+ {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 ( +