From 3b59ab3d264084f473152b9f0ce66ea1af9eb125 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 7 Oct 2026 20:28:13 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BD=9C=E8=80=85=E6=9C=AC=E4=BA=BA=EF=BC=88ow?= =?UTF-8?q?ner=3D1=EF=BC=89=E4=B8=80=E5=BE=8B=E7=AE=97=E5=85=B1=E5=88=9B?= =?UTF-8?q?=E8=80=85=E4=BE=A7=E5=B9=B6=E5=B8=A6=E4=B8=8A=E6=9D=A5=E6=BA=90?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=EF=BC=9B=E6=B8=B8=E7=8E=A9=E9=A1=B5=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E6=94=B9=E4=B8=BA=E6=8C=89=E6=9D=A5=E5=A4=84=EF=BC=88?= =?UTF-8?q?=E6=96=B0=E5=8F=82=E6=95=B0=20backTo=3D=E7=AB=99=E5=86=85?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=EF=BC=8C=E5=90=AB=E5=88=B7=E6=96=B0/?= =?UTF-8?q?=E6=B7=B1=E9=93=BE=EF=BC=8C=E7=AB=99=E5=A4=96=E5=80=BC=E6=8B=A6?= =?UTF-8?q?=E6=8E=89=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../game-distribution/GameDetailPage.tsx | 8 ++- .../game-distribution/gameViewerRole.ts | 44 +++++++++++-- .../PlatformEntryActiveFlowShell.test.tsx | 25 ++++++-- .../PlatformEntryActiveFlowShell.tsx | 64 ++++++++++++++++--- 4 files changed, 119 insertions(+), 22 deletions(-) diff --git a/src/components/game-distribution/GameDetailPage.tsx b/src/components/game-distribution/GameDetailPage.tsx index b4ee3367e..9da615082 100644 --- a/src/components/game-distribution/GameDetailPage.tsx +++ b/src/components/game-distribution/GameDetailPage.tsx @@ -553,12 +553,16 @@ function GameDetailContent({ }) { /** * 观看者所处的**入口上下文**(2026-10-07 产品口径更正:游客 = 点开游戏界面的玩家, - * 不是「未登录访客」):由导航层写在 URL 上的来源决定,**与登录态无关**。 + * 不是「未登录访客」):由导航层写在 URL 上的来源决定,**与登录态无关**; + * 作者本人进自己的作品(`?owner=1`)**一律算共创者侧**。 * **判据只有一处**(`resolveGameViewerContext` + `isCoCreator`,见该模块的说明), * 页面上所有差异都用这一个布尔量驱动,不在 JSX 里散写来源判断。 */ const coCreator = isCoCreator( - resolveGameViewerContext(readDetailEntrySource()), + resolveGameViewerContext({ + source: readDetailEntrySource(), + isAuthorViewer: ownerView, + }), ); /** * 只有已公开作品才有公开评价/评分投影;审核中、审核未通过、未公开、已下架 diff --git a/src/components/game-distribution/gameViewerRole.ts b/src/components/game-distribution/gameViewerRole.ts index 72f0608f6..c559f864b 100644 --- a/src/components/game-distribution/gameViewerRole.ts +++ b/src/components/game-distribution/gameViewerRole.ts @@ -37,6 +37,30 @@ export const CO_CREATION_ORIGIN_STAGES: readonly string[] = [ export type GameViewerContext = 'player' | 'co-creator'; +/** + * 游玩页 URL 上的**返回目标**参数:`backTo=`。 + * + * 语义(2026-10-07 用户口径「看他从哪来的」):进入游玩页时把**来处**记在 URL 上,游玩页的 + * 「返回」按钮(以及浏览器返回的兜底)据此回到来处,刷新/深链都不丢。 + * 只接受**站内相对路径**(单个 `/` 开头),绝对 URL / 协议相对 `//host` / 畸形值一律忽略 ⇒ 调用方走兜底。 + * 它只表达「回哪」,**不**表达「哪一侧」——哪一侧仍然只由 `from` 决定,不新造第二套来源口径。 + */ +export const PLAY_BACK_TO_QUERY_PARAM = 'backTo'; + +/** + * 校验并归一化 `backTo`:返回可直接用于站内跳转的路径,或 `null`(不合法 ⇒ 调用方走兜底)。 + * 防开放重定向:只放行 `/[^/]...` 这种单斜杠开头的站内路径。 + */ +export function resolveInternalBackTo( + raw: string | null | undefined, +): string | null { + const value = (raw ?? '').trim(); + if (!value.startsWith('/') || value.startsWith('//')) return null; + if (/[\r\n\t]/u.test(value)) return null; + if (value === '/') return null; + return value; +} + /** * 「已经在共创侧」的页面:**继续往下跳(或原地刷新)时要把来源标记带着走**, * 别中途掉回玩家侧。 @@ -55,11 +79,23 @@ function isCoCreationSource(source: string | null | undefined) { /** * 来源标记 → 观看者所处的一侧。**缺省 / 未知一律 = 玩家侧**。 + * + * `isAuthorViewer`(观看者就是这部作品的作者,即 `GameDetailPage` 的 `ownerView`,来自 `?owner=1`) + * **一律算共创者侧**(2026-10-07 产品拍板):作者从「我的游戏」进自己的作品时必须看得到血缘入口, + * 不该因为「从游戏侧进来」被降级。 + * ⚠️ 前端不做权限校验:`owner=1` 是前端标记(shell 只在「我的游戏」里写它),手改 URL 也能让页面 + * 按共创者侧渲染 —— 它只影响「看不看得到血缘入口」这类**展示降级**,真正的作者数据仍由后端要求 + * Bearer + owner 接口把关(未登录/非作者拿不到数据,页面落到 `找不到这款游戏`)。所以 + * 「未登录 + owner」在**数据上**不可能同时成立,在**链接上**可以让前端按共创者侧渲染,不构成越权。 */ -export function resolveGameViewerContext( - source: string | null | undefined, -): GameViewerContext { - return isCoCreationSource(source) ? 'co-creator' : 'player'; +export function resolveGameViewerContext(input: { + /** URL 上的来源标记(`from`)。 */ + source?: string | null; + /** 观看者就是这部作品的作者(`?owner=1`)。 */ + isAuthorViewer?: boolean; +}): GameViewerContext { + if (input.isAuthorViewer) return 'co-creator'; + return isCoCreationSource(input.source) ? 'co-creator' : 'player'; } /** diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx index 6a46391f8..89eb1975e 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx @@ -1253,7 +1253,7 @@ describe('游戏详情返回', () => { ); await clickPlay(); expect(fromCoCreation).toHaveBeenCalledWith('game-play', { - path: '/games/play?id=game-1&from=co-creation', + path: '/games/play?id=game-1&from=co-creation&backTo=%2Fgames%2Fdetail%3Fid%3Dgame-1%26from%3Dco-creation', }); first.unmount(); @@ -1269,7 +1269,7 @@ describe('游戏详情返回', () => { ); await clickPlay(); expect(fromPlayer).toHaveBeenCalledWith('game-play', { - path: '/games/play?id=game-1', + path: '/games/play?id=game-1&backTo=%2Fgames%2Fdetail%3Fid%3Dgame-1', }); }); @@ -1302,10 +1302,21 @@ describe('游戏详情返回', () => { ).toBe(false); } // 来源值 → 一侧 - expect(resolveGameViewerContext('co-creation')).toBe('co-creator'); - expect(resolveGameViewerContext('CO-CREATION')).toBe('co-creator'); - expect(resolveGameViewerContext(null)).toBe('player'); - expect(resolveGameViewerContext('game-leaf')).toBe('player'); + expect(resolveGameViewerContext({ source: 'co-creation' })).toBe( + 'co-creator', + ); + expect(resolveGameViewerContext({ source: 'CO-CREATION' })).toBe( + 'co-creator', + ); + expect(resolveGameViewerContext({ source: null })).toBe('player'); + expect(resolveGameViewerContext({ source: 'game-leaf' })).toBe('player'); + // 作者本人(?owner=1)一律共创者侧,与入口来源无关(产品拍板) + expect(resolveGameViewerContext({ isAuthorViewer: true })).toBe( + 'co-creator', + ); + expect( + resolveGameViewerContext({ source: 'co-creation', isAuthorViewer: true }), + ).toBe('co-creator'); expect(isCoCreator('player')).toBe(false); expect(isCoCreator('co-creator')).toBe(true); }); @@ -1403,7 +1414,7 @@ describe('作品管理 owner 作用域', () => { fireEvent.click(await screen.findByRole('button', { name: '查看详情' })); expect(setSelectionStage).toHaveBeenCalledWith('game-detail', { - path: '/games/detail?id=game-1&owner=1', + path: '/games/detail?id=game-1&owner=1&from=co-creation', }); }); diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx index e06985882..7c618e259 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx @@ -26,6 +26,7 @@ import { PlatformProfileWalletLedgerModal } from '../../../packages/shared/src/c import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime'; import { resolveProfileMudPointDisplayTotal } from '../../../packages/shared/src/utils/mudPoints'; import { + APP_STAGE_ROUTES, hasAppHistoryBackEntry, pushAppHistoryPath, replaceAppHistoryPath, @@ -48,7 +49,9 @@ import { setCreatorViewer } from '../creator/creatorRelationshipState'; import { CO_CREATION_DETAIL_SOURCE, isCoCreator, + PLAY_BACK_TO_QUERY_PARAM, resolveGameViewerContext, + resolveInternalBackTo, shouldCarryCoCreationSource, } from '../game-distribution/gameViewerRole'; import { PaymentCheckoutView } from './PaymentCheckoutView'; @@ -588,9 +591,13 @@ export function PlatformEntryFlowShellImpl({ * 从共创侧(共创 Tab / 族谱页 / 对比页)打开详情时带上 `from=co-creation`, * 详情页据此决定「看不看得到血缘图与共创标签」;其余来源 = 玩家侧(不带标记)。 * 已经在共创侧详情页里继续跳(血缘链 / 主题 / 相关作品)时保留标记,不中途掉回玩家侧。 + * **作者本人**(`owner=1`)也带上标记:产品拍板「作者进自己的作品一律算共创者侧」, + * 而且 rail/dock 高亮读的就是 URL 上的 `from`(同一个谓词),标记到位就自动跟随。 * 用 URL 而不是内存 stage:刷新、深链、分享链接都稳定。 */ - const source = currentCoCreationSource(); + const source = options?.owner + ? CO_CREATION_DETAIL_SOURCE + : currentCoCreationSource(); if (source) params.set('from', source); const path = `/games/detail?${params.toString()}`; setSelectionStage('game-detail', { path }); @@ -599,15 +606,33 @@ export function PlatformEntryFlowShellImpl({ ); /** - * 打开游玩页。**来源标记要一路带过去**:从共创侧详情点「开始游戏 / 立即玩」进游玩页时, - * 游玩页的 rail/dock 高亮仍按共创侧(读 URL 上的 `from`,见 `isCoCreationContext` 那一带); - * 游玩页自己刷新 / 分享 / 再往下跳时同样保持,玩家侧则不带标记。 + * 打开游玩页。两件事都要带上: + * 1. **来源标记** `from`:游玩页的 rail/dock 高亮仍按共创侧(读 URL,见 `isCoCreationContext`); + * 2. **返回目标** `backTo`(2026-10-07 用户口径「看他从哪来的」):把**来处**写在 URL 上, + * 游玩页的「返回」据此回到来处(刷新/深链不丢);已经在游玩页里再进游玩页时**保留原来处**。 + * 只写站内路径(`APP_STAGE_ROUTES` 里认识的路径),不写绝对 URL。 */ const openGamePlay = useCallback( (gameId: string) => { const params = new URLSearchParams({ id: gameId }); const source = currentCoCreationSource(); if (source) params.set('from', source); + const currentStage = resolveSelectionStageFromPath( + window.location.pathname, + ); + const currentLocation = `${window.location.pathname}${window.location.search}`; + const existingBackTo = resolveInternalBackTo( + new URLSearchParams(window.location.search).get( + PLAY_BACK_TO_QUERY_PARAM, + ), + ); + const backTo = + currentStage === 'game-play' + ? existingBackTo + : Object.values(APP_STAGE_ROUTES).includes(window.location.pathname) + ? currentLocation + : null; + if (backTo) params.set(PLAY_BACK_TO_QUERY_PARAM, backTo); const path = `/games/play?${params.toString()}`; setSelectionStage('game-play', { path }); }, @@ -711,19 +736,40 @@ export function PlatformEntryFlowShellImpl({ setSelectionStage('games', { path: '/games' }); }, [setSelectionStage]); - /** 游玩页同理,兜底是它自己的详情页,避免详情↔游玩互相塞历史条目。 */ + /** + * 游玩页的「返回」按**来处**回(2026-10-07 用户口径「看他从哪来的」): + * 1. URL 上有合法的 `backTo`(进入游玩页时由 `openGamePlay` 写上)→ **直接回那里**, + * 刷新/深链后同样成立;回详情时用 `resolveSelectionStageFromPath` 反推 stage。 + * 2. 没有 `backTo` → 走既有的浏览器原生返回(站内历史)。 + * 3. 都没有(直接深链进游玩页)→ 兜底回**它自己的详情页**,没有 id 才回 `/games`。 + * 参数只表达「回哪」,不表达「哪一侧」;站外值由 `resolveInternalBackTo` 拦掉(防开放重定向)。 + */ const backFromGamePlay = useCallback(() => { + const params = new URLSearchParams(window.location.search); + const backTo = resolveInternalBackTo(params.get(PLAY_BACK_TO_QUERY_PARAM)); + if (backTo) { + const stage = resolveSelectionStageFromPath( + new URL(backTo, window.location.origin).pathname, + ); + replaceAppHistoryPath(backTo); + setSelectionStage(stage, { path: backTo }); + return; + } if (hasAppHistoryBackEntry()) { window.history.back(); return; } - const currentGameId = new URLSearchParams(window.location.search).get('id'); + const currentGameId = params.get('id'); + const source = currentCoCreationSource(); + const fallbackParams = new URLSearchParams(); + if (currentGameId) fallbackParams.set('id', currentGameId); + if (source) fallbackParams.set('from', source); const path = currentGameId - ? `/games/detail?id=${encodeURIComponent(currentGameId)}` + ? `/games/detail?${fallbackParams.toString()}` : '/games'; replaceAppHistoryPath(path); setSelectionStage(currentGameId ? 'game-detail' : 'games', { path }); - }, [setSelectionStage]); + }, [currentCoCreationSource, setSelectionStage]); const openEditorProject = useCallback( (projectId: string, options?: { guide?: boolean; tool?: string }) => { @@ -871,7 +917,7 @@ export function PlatformEntryFlowShellImpl({ */ const isCoCreationContext = isGamesStage && - (isCoCreator(resolveGameViewerContext(gameEntrySource)) || + (isCoCreator(resolveGameViewerContext({ source: gameEntrySource })) || shouldCarryCoCreationSource({ stage: selectionStage, currentSource: gameEntrySource,