作者本人(owner=1)一律算共创者侧并带上来源标记;游玩页返回改为按来处(新参数 backTo=站内路径,含刷新/深链,站外值拦掉)
Project CI / Backend tests (pull_request) Failing after 34s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m24s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m8s
Project CI / Repository checks (pull_request) Failing after 40s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m26s
Project CI / Frontend tests (pull_request) Successful in 3m5s
Project CI / AI game creator shell web tests (pull_request) Failing after 2m57s
Project CI / Native shell tests (pull_request) Successful in 9m14s
Project CI / Backend tests (pull_request) Failing after 34s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m24s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m8s
Project CI / Repository checks (pull_request) Failing after 40s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m26s
Project CI / Frontend tests (pull_request) Successful in 3m5s
Project CI / AI game creator shell web tests (pull_request) Failing after 2m57s
Project CI / Native shell tests (pull_request) Successful in 9m14s
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
);
|
||||
/**
|
||||
* 只有已公开作品才有公开评价/评分投影;审核中、审核未通过、未公开、已下架
|
||||
|
||||
@@ -37,6 +37,30 @@ export const CO_CREATION_ORIGIN_STAGES: readonly string[] = [
|
||||
|
||||
export type GameViewerContext = 'player' | 'co-creator';
|
||||
|
||||
/**
|
||||
* 游玩页 URL 上的**返回目标**参数:`backTo=<encodeURIComponent(站内路径)>`。
|
||||
*
|
||||
* 语义(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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user