详情页差异改由「入口上下文」驱动:导航层按当前一侧写 from=co-creation,玩家侧(游戏 Tab/广场/搜索/收藏/我的游戏/默认深链)不看血缘与共创标签、开始共创弱化;登录态只决定能否共创
Project CI / Backend tests (pull_request) Failing after 34s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m48s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m1s
Project CI / Repository checks (pull_request) Failing after 25s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m10s
Project CI / Frontend tests (pull_request) Successful in 2m30s
Project CI / AI game creator shell web tests (pull_request) Failing after 2m32s
Project CI / Native shell tests (pull_request) Successful in 7m24s

This commit is contained in:
2026-10-07 18:06:21 +08:00
parent ffd7510143
commit d9cba5e402
5 changed files with 259 additions and 68 deletions
@@ -61,7 +61,7 @@ import {
VERSION_STATUS_LABEL,
VERSION_STATUS_NEEDS_AUTHOR_ACTION,
} from './gameVersionHistoryModel';
import { isCoCreator } from './gameViewerRole';
import { isCoCreator, resolveGameViewerContext } from './gameViewerRole';
import {
collectLineageAncestorChain,
collectLineagePlayCountTotals,
@@ -176,6 +176,18 @@ function GameDetailResolvedAsset({
);
}
/**
* 详情页的**入口来源**:导航层写在 URL 上的 `from`(`/games/detail?id=…&from=co-creation`)。
*
* 为什么用 query 而不是内存里的 stage:刷新 / 深链 / 分享链接都要稳定,
* 而 stage 只活在内存里(`/games/detail` 刷新后 stage 仍是 `game-detail`,来源会丢)。
* 非浏览器环境按「没有来源」处理 ⇒ 玩家侧。
*/
function readDetailEntrySource() {
if (typeof window === 'undefined') return null;
return new URLSearchParams(window.location.search).get('from');
}
export function GameDetailPage({
gameId,
onBack,
@@ -540,11 +552,14 @@ function GameDetailContent({
onOpenGameDetail?: (gameId: string) => void;
}) {
/**
* 观看者角色(2026-10-07 产品口径:普通用户 = 游客 / 共创者 = 开发游戏的)。
* **判据只有一处**(`isCoCreator`,当前口径 = 登录态,见该模块的「待产品确认」说明),
* 页面上所有角色差异都用这一个布尔量驱动,不在 JSX 里散写 `isAuthenticated`。
* 观看者所处的**入口上下文**(2026-10-07 产品口径更正:游客 = 点开游戏界面的玩家,
* 不是「未登录访客」):由导航层写在 URL 上的来源决定,**与登录态无关**。
* **判据只有一处**(`resolveGameViewerContext` + `isCoCreator`,见该模块的说明),
* 页面上所有差异都用这一个布尔量驱动,不在 JSX 里散写来源判断。
*/
const coCreator = isCoCreator(isAuthenticated);
const coCreator = isCoCreator(
resolveGameViewerContext(readDetailEntrySource()),
);
/**
* 只有已公开作品才有公开评价/评分投影;审核中、审核未通过、未公开、已下架
* 一律不请求评价接口,封面位改为状态文案。
@@ -878,8 +893,8 @@ function GameDetailContent({
return (
<div
className="game-detail-page"
// 角色差异的可观测面(探针/验收用):游客 / 共创者。
data-viewer-role={coCreator ? 'co-creator' : 'guest'}
// 入口上下文的可观测面(探针/验收用):玩家侧 / 共创者侧。
data-viewer-context={coCreator ? 'co-creator' : 'player'}
>
<button type="button" className="game-back-button" onClick={onBack}>
<ArrowLeft /> 返回
@@ -1102,12 +1117,14 @@ function GameDetailContent({
{/*
「一键开始共创」从共创信息卡搬到主操作行(2026-10-07 用户口径):
它是这部作品的主操作之一,和「立即玩 / 收藏」并列;共创卡内不再重复出现该按钮。
**角色差异**(判据 `isCoCreator`):
- 共创者:与改动前**完全一致**——实心强调 + lg 尺寸(48px / 0.94rem),文案「一键开始共创」,
禁止共创时置灰 + 原因;
- 游客:**不占用重点色**,缩成次要小按钮(描边 + 次要底色、32px / 0.78rem,见
`.game-fork-remix-guest`),文案如实写「登录后开始共创」——不喊「一键共创」这种大口号。
但**仍然可点可达**:点击走仓库既有的登录门禁(`onLogin`),既不禁用也不隐藏。
**入口上下文差异**(判据 `isCoCreator`,来源写在 URL 的 `from` 上):
- 共创者侧(共创 Tab / 族谱 / 对比页进来):与改动前**完全一致**——实心强调 + lg 尺寸
(48px / 0.94rem),文案「一键开始共创」,禁止共创时置灰 + 原因;
- 玩家侧(游戏 Tab / 广场 / 搜索 / 收藏 / 我的游戏 / 默认深链):**不占用重点色**,缩成
次要小按钮(描边 + 次要底色、32px / 0.78rem,见 `.game-fork-remix-guest`),
文案如实写「开始共创」/未登录时「登录后开始共创」——不喊「一键共创」这种大口号。
但**仍然可点可达**:未登录走仓库既有的登录门禁(`onLogin`),既不禁用也不隐藏,
登录态只决定「能不能真的开始共创」,不再决定「看不看得到血缘」。
*/}
{publiclyVisible ? (
<>
@@ -1122,7 +1139,7 @@ function GameDetailContent({
}`
: 'game-fork-remix-link game-fork-remix-guest'
}
data-viewer-role={coCreator ? 'co-creator' : 'guest'}
data-viewer-context={coCreator ? 'co-creator' : 'player'}
aria-disabled={forkAuthorization === 'forbidden'}
aria-expanded={
isAuthenticated && forkAuthorization !== 'forbidden'
@@ -1157,7 +1174,9 @@ function GameDetailContent({
? '一键开始共创'
: forkAuthorization === 'forbidden'
? '禁止共创'
: '登录后开始共创'}
: isAuthenticated
? '开始共创'
: '登录后开始共创'}
</button>
{isAuthenticated && isForkPanelOpen
? createPortal(
@@ -220,6 +220,16 @@ afterEach(() => {
beforeEach(() => {
vi.clearAllMocks();
/*
* 详情页的默认 fixture = **共创者侧**(`?from=co-creation`):这是 2026-10-07 之前
* 唯一被实现/验收过的一侧,绝大多数用例(血缘图、共创标签、一键开始共创)讲的都是它。
* 玩家侧的用例各自把 URL 显式设回 `/games/detail?id=…`(见「入口上下文」那两条)。
*/
window.history.replaceState(
null,
'',
'/games/detail?id=game-1&from=co-creation',
);
authUiMock.value.user = { id: 'author-1' };
authUiMock.value.canAccessProtectedData = true;
authUiMock.value.openLoginModal.mockReset();
@@ -2318,9 +2328,11 @@ describe('GameDetailPage 开始共创入口', () => {
expect(screen.getByText(/当前设备无法直接唤起客户端/u)).toBeTruthy();
});
it('游客(未登录)入口是次要小按钮「登录后开始共创」,点击走仓库既有登录门禁而不是静默隐藏', async () => {
it('玩家侧(默认入口,未登录)入口是次要小按钮「登录后开始共创」,点击走仓库既有登录门禁而不是静默隐藏', async () => {
authUiMock.value.user = null;
authUiMock.value.canAccessProtectedData = false;
// 入口上下文 = 玩家侧:URL 上没有共创来源标记(默认深链也是玩家侧)。
window.history.replaceState(null, '', '/games/detail?id=game-1');
getGameMock.mockResolvedValueOnce(
createGame({ forkAuthorization: 'full' }),
);
@@ -2331,7 +2343,7 @@ describe('GameDetailPage 开始共创入口', () => {
const gate = await screen.findByRole('button', {
name: '登录后开始共创',
});
// 文案如实:不给游客喊「一键开始共创」这种大口号。
// 文案如实:不给玩家侧喊「一键开始共创」这种大口号。
expect(screen.queryByRole('button', { name: '一键开始共创' })).toBeNull();
expect(screen.queryByText(/从平台作品开始创作/u)).toBeNull();
@@ -2340,18 +2352,18 @@ describe('GameDetailPage 开始共创入口', () => {
// 未登录既不唤起客户端,也不展开获取方式。
expect(openForkProtocolMock).not.toHaveBeenCalled();
expect(screen.queryByText(/从平台作品开始创作/u)).toBeNull();
// 角色差异可观测:根节点带 `data-viewer-role`,按钮走「游客」变体(不占重点色)。
// 入口上下文可观测:根节点带 `data-viewer-context`,按钮走「玩家侧」变体(不占重点色)。
expect(
document
.querySelector('.game-detail-page')
?.getAttribute('data-viewer-role'),
).toBe('guest');
expect(gate.getAttribute('data-viewer-role')).toBe('guest');
?.getAttribute('data-viewer-context'),
).toBe('player');
expect(gate.getAttribute('data-viewer-context')).toBe('player');
expect(gate.className).toContain('game-fork-remix-guest');
expect(gate.className).not.toContain('co-creation-fork-primary');
});
it('游客变体的度量与配色:次要描边 + 32px/0.78rem,明显小于共创者的主操作(48px/0.94rem)', async () => {
it('玩家侧变体的度量与配色:次要描边 + 32px/0.78rem,明显小于共创者侧的主操作(48px/0.94rem)', async () => {
// jsdom 不算几何:用 CSS 源码契约把「主次差异」钉死(真机几何由 smoke 记录)。
const css = readFileSync(
'src/components/game-distribution/gameDistribution.css',
@@ -3522,8 +3534,12 @@ describe('GameLineagePage 可视化与交互', () => {
Node.DOCUMENT_POSITION_FOLLOWING,
);
// ①'' 竖版画布有左侧代际轴 → 卡上不再重复「第 N 代」;玩数由卡面承载 → 角标里没有 ▶N。
expect(cardButton.querySelector('.game-lineage-node__generation')).toBeNull();
expect(cardButton.querySelector('.game-lineage-node__play-count')).toBeNull();
expect(
cardButton.querySelector('.game-lineage-node__generation'),
).toBeNull();
expect(
cardButton.querySelector('.game-lineage-node__play-count'),
).toBeNull();
// ② 卡内**没有任何动作按钮**(打开作品 / 开始共创 都已从卡面移除)。
expect(within(cardButton).queryByRole('button')).toBeNull();
expect(within(cardButton).queryByText('打开作品')).toBeNull();
@@ -4468,22 +4484,22 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐)
expect(canvasButton.getAttribute('aria-pressed')).toBe('true');
expect(outlineButton.getAttribute('aria-pressed')).toBe('false');
expect(
document.querySelector('.game-lineage-filter__group')?.getAttribute(
'aria-label',
),
document
.querySelector('.game-lineage-filter__group')
?.getAttribute('aria-label'),
).toBe('视图形态');
fireEvent.click(outlineButton);
expect(await screen.findByLabelText('创作族谱大纲')).toBeTruthy();
expect(
screen.getByRole('button', { name: '大纲视图' }).getAttribute(
'aria-pressed',
),
screen
.getByRole('button', { name: '大纲视图' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(
screen.getByRole('button', { name: '图谱视图' }).getAttribute(
'aria-pressed',
),
screen
.getByRole('button', { name: '图谱视图' })
.getAttribute('aria-pressed'),
).toBe('false');
});
@@ -4928,7 +4944,7 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
).toBeTruthy();
});
it('游客与共创者(同一份数据):游客看不到血缘图/族谱入口/共创标签,共创者完全保留', async () => {
it('入口上下文(同一登录态、同一份数据):玩家侧看不到血缘图/族谱入口/共创标签,共创者侧完全保留', async () => {
const gameWithLineage = () =>
createGame({
id: 'game-current',
@@ -4957,12 +4973,14 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
/>,
);
// ① 游客(未登录):血缘相关内容与共创标签一律不渲染,但公开数据与主题入口保留。
authUiMock.value.user = null;
authUiMock.value.canAccessProtectedData = false;
// ① 玩家侧(**已登录**,默认入口 URL):血缘相关内容与共创标签一律不渲染,
// 但公开数据与主题入口保留。→ 这条同时证明「差异来自入口,不来自登录态」。
authUiMock.value.user = { id: 'author-1' };
authUiMock.value.canAccessProtectedData = true;
window.history.replaceState(null, '', '/games/detail?id=game-current');
getGameMock.mockResolvedValueOnce(gameWithLineage());
getGameLineageMock.mockResolvedValueOnce(buildChainDataset());
const guest = renderDetail();
const player = renderDetail();
await screen.findByText('累计衍生作品 3');
expect(screen.queryByLabelText('创作血缘')).toBeNull();
expect(screen.queryByRole('button', { name: '全屏查看族谱' })).toBeNull();
@@ -4978,15 +4996,22 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
expect(
document
.querySelector('.game-detail-page')
?.getAttribute('data-viewer-role'),
).toBe('guest');
?.getAttribute('data-viewer-context'),
).toBe('player');
// 玩家侧的「开始共创」是次要小按钮:已登录也不再喊「一键」。
expect(
screen.getByRole('button', { name: '开始共创' }).className,
).toContain('game-fork-remix-guest');
// 图谱页自己的「图下方父代/子代清单」也不会出现(整块不渲染)。
expect(document.querySelector('.lineage-relation__grid')).toBeNull();
guest.unmount();
player.unmount();
// ② 共创者(已登录,默认 mock):与改动前一致 —— 图、入口、标签都在。
authUiMock.value.user = { id: 'author-1' };
authUiMock.value.canAccessProtectedData = true;
// ② 共创者侧(**同一登录态**,只把入口来源换成共创侧):与改动前一致 —— 图、入口、标签都在。
window.history.replaceState(
null,
'',
'/games/detail?id=game-current&from=co-creation',
);
getGameMock.mockResolvedValueOnce(gameWithLineage());
getGameLineageMock.mockResolvedValueOnce(buildChainDataset());
renderDetail();
@@ -5000,8 +5025,11 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
expect(
document
.querySelector('.game-detail-page')
?.getAttribute('data-viewer-role'),
?.getAttribute('data-viewer-context'),
).toBe('co-creator');
expect(
screen.getByRole('button', { name: '一键开始共创' }).className,
).toContain('co-creation-fork-primary');
});
it('「全屏查看族谱」= 真·全屏 overlay(portal 到 body / Esc 与关闭按钮 / 焦点与滚动恢复)', async () => {
@@ -1,18 +1,77 @@
/**
* 观看者角色(2026-10-07 产品口径):**普通用户 = 游客**(主要是玩游戏的),
* **共创者 = 开发游戏的**;两者看到的**游戏详情页**有细微差异(见 `GameDetailPage`)。
* 观看者所处的**入口上下文**(2026-10-07 产品口径更正):
* **游客 = 来玩游戏的玩家侧**(不是「未登录访客」),**共创者 = 开发游戏的那一侧**。
*
* ⚠️ **待产品确认的口径**:仓库现有信号里没有能表达「共创者」的那一位 ——
* - `capabilities`(`src/services/hostBridge.ts`)是 **AGC 宿主桥**的能力清单(剪贴板 / 分享 / 导航),
* 与「这个人在平台上开不开发游戏」无关;
* - `forkAuthorization` 是**作品级**的共创授权(禁止 / 非商用 / 全开放),是作品的属性而不是观看者的;
* - 钱包 / 会员档位(`usePlatformWalletStore`)与「是否共创者」也没有既有的推导关系;
* - 后端 DTO 里没有任何「账号能力位」字段(没有 `isCreator` / `creatorLevel` 之类)。
* 口径来源:产品会上定的「普通用户是游客(主要是玩游戏的),共创者是开发游戏的」,
* 并且明确「**游客是指点开游戏界面的用户**」——即差异取决于**从哪一侧点进来的**,
* 与**登录态无关**(登录态只决定「能不能真的开始共创」,不再决定「看不看得到血缘」)。
*
* 所以这里**临时**用登录态近似:**未登录 = 游客,已登录 = 共创者**。
* 不新增后端字段、不自己造权限系统:产品给出真正的判据(能力位 / 是否绑定客户端 / 是否发行过作品…)后,
* **只需要改这一个函数**——详情页里所有「游客 / 共创者」差异都收敛在这一处判断上。
* 因此这个模块的输入是**入口来源**,不是 `isAuthenticated`:
* - 导航层(`PlatformEntryActiveFlowShell`)在打开详情页时按**当前所在的一侧**写入 URL 标记
* `/games/detail?id=…&from=co-creation`;
* - 详情页只读这个标记(见 `GameDetailPage`),所以**刷新 / 深链 / 分享链接都稳定**;
* - 没有标记、或标记不是共创来源 ⇒ **玩家侧**(旧链接、直接把 URL 贴给别人的场景一律落玩家侧)。
*
* ⚠️ 若产品最终改为**账号角色 / 能力位**(例如后端下发 `isCreator` / `creatorLevel`),
* **只需要替换这一个模块的实现**:`isCoCreator` 是所有差异的唯一判据,调用点不用改。
*/
export function isCoCreator(isAuthenticated: boolean): boolean {
return isAuthenticated;
/** 详情页 URL 上的来源标记值:`/games/detail?id=…&from=co-creation`。 */
export const CO_CREATION_DETAIL_SOURCE = 'co-creation';
/**
* 「共创者侧」的导航阶段:从这些页面打开作品详情时算共创者侧。
* - `game-co-creation`:共创 Tab(侧边栏顶层入口)
* - `game-lineage`:创作族谱页
* - `game-compare`:原版与二创对比页(玩家的详情页不渲染对比入口,玩家到不了这里)
*
* 不在表里的(游戏 Tab / 广场 / 搜索 / 收藏 / 我的游戏 / 共创主题 / 默认深链)都算**玩家侧**;
* 其中「共创主题」页两边的用户都能到(详情页的主题入口对玩家也保留),
* 按「玩家也能到 ⇒ 落玩家侧」处理,避免玩家从主题页点进去又看到共创视图。
*/
export const CO_CREATION_ORIGIN_STAGES: readonly string[] = [
'game-co-creation',
'game-lineage',
'game-compare',
];
export type GameViewerContext = 'player' | 'co-creator';
function isCoCreationSource(source: string | null | undefined) {
return (source ?? '').trim().toLowerCase() === CO_CREATION_DETAIL_SOURCE;
}
/**
* 来源标记 → 观看者所处的一侧。**缺省 / 未知一律 = 玩家侧**。
*/
export function resolveGameViewerContext(
source: string | null | undefined,
): GameViewerContext {
return isCoCreationSource(source) ? 'co-creator' : 'player';
}
/**
* 导航层用:这一跳该不该带上「共创来源」标记?
*
* - 当前就在共创侧阶段(共创 Tab / 族谱 / 对比)→ 带上;
* - 当前已经在**共创侧的详情页**里(血缘链、主题、相关作品之间跳)→ **保留**来源,别中途掉回玩家侧;
* - 其余(游戏 Tab / 广场 / 我的游戏 / 默认深链)→ 不带。
*
* 只认 `co-creation` 这一个值:族谱页自己的 `from=<gameId>`(高亮当前作品)含义不同,不能被当成来源。
*/
export function shouldCarryCoCreationSource(input: {
stage: string;
currentSource?: string | null;
}): boolean {
if (CO_CREATION_ORIGIN_STAGES.includes(input.stage)) return true;
return (
input.stage === 'game-detail' && isCoCreationSource(input.currentSource)
);
}
/**
* 是不是「共创者」这一侧。**详情页所有差异都由它驱动**(一个谓词,判断不散落在 JSX 各处)。
*/
export function isCoCreator(context: GameViewerContext): boolean {
return context === 'co-creator';
}
@@ -1447,21 +1447,20 @@ describe('创作族谱', () => {
?.querySelector('.game-card');
expect(treeNode).toBeTruthy();
fireEvent.click(treeNode as HTMLElement);
// 从族谱页(共创者侧)打开作品详情:来源标记**保留**下去,别中途掉回玩家侧。
expect(setSelectionStage).toHaveBeenCalledWith('game-detail', {
path: '/games/detail?id=game-root',
path: '/games/detail?id=game-root&from=co-creation',
});
});
it('详情页的族谱入口带上根作品与当前作品', async () => {
// 族谱入口只给**共创者**(2026-10-07 角色口径:游客只玩游戏,看不到血缘入口),
// 所以这条用例先把观看者设成已登录(`isCoCreator` 当前口径 = 登录态)。
authUiMock.value.user = {
id: 'user-1',
publicUserCode: '100001',
displayName: '测试用户',
} as AuthUser;
authUiMock.value.canAccessProtectedData = true;
window.history.replaceState(null, '', '/games/detail?id=game-1');
// 族谱入口只给**共创者侧**(2026-10-07 口径更正:看血缘与否取决于**入口上下文**,
// 与登录态无关)——所以这里**不登录**,只把入口来源标成共创侧。
window.history.replaceState(
null,
'',
'/games/detail?id=game-1&from=co-creation',
);
gameDistributionMock.getGame.mockResolvedValue({
id: 'game-1',
title: '衍生作品',
@@ -1558,6 +1557,66 @@ describe('创作族谱', () => {
});
});
it('打开详情页的来源标记:从共创 Tab 进 = 共创者侧,从游戏广场进 = 玩家侧(不带标记)', async () => {
const oneGame = {
id: 'game-1',
title: '可以共创的作品',
summary: 's',
description: '',
category: '动作',
tags: [],
coverColor: '#d77a51',
icon: '✦',
author: { id: 'author-1', name: '测试作者' },
deviceSupport: { desktop: true, mobile: true, touch: true },
status: 'published',
currentVersion: null,
playCount: 1,
createdAt: '2026-09-18T00:00:00.000Z',
coverObjectKey: null,
screenshots: [],
forkCount: 0,
forkAuthorization: 'full',
};
gameDistributionMock.listGames.mockResolvedValue({
games: [oneGame],
nextCursor: null,
});
// ① 共创 Tab(`/games/co-creation`)→ 带 `from=co-creation`
window.history.replaceState(null, '', '/games/co-creation');
const fromCoCreation = vi.fn();
const first = render(
<PlatformEntryFlowShellImpl
selectionStage="game-co-creation"
setSelectionStage={fromCoCreation}
/>,
);
fireEvent.click(
await screen.findByRole('button', { name: /可以共创的作品/u }),
);
expect(fromCoCreation).toHaveBeenCalledWith('game-detail', {
path: '/games/detail?id=game-1&from=co-creation',
});
first.unmount();
// ② 游戏广场(`/games`)→ **不带**标记(默认玩家侧)
window.history.replaceState(null, '', '/games');
const fromPlaza = vi.fn();
render(
<PlatformEntryFlowShellImpl
selectionStage="games"
setSelectionStage={fromPlaza}
/>,
);
fireEvent.click(
await screen.findByRole('button', { name: /可以共创的作品/u }),
);
expect(fromPlaza).toHaveBeenCalledWith('game-detail', {
path: '/games/detail?id=game-1',
});
});
describe('共创顶层入口(只列支持共创的作品)', () => {
it('侧边栏「共创」直达 game-co-creation,游戏页不再有「游戏/共创」子页签', async () => {
window.history.replaceState(null, '', '/games');
@@ -29,6 +29,7 @@ import {
hasAppHistoryBackEntry,
pushAppHistoryPath,
replaceAppHistoryPath,
resolveSelectionStageFromPath,
} from '../../routing/activeAppPageRoutes';
import {
connectionsPath,
@@ -44,6 +45,10 @@ import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { ClientDownloadEntry } from '../creation-home/ClientDownloadEntry';
import { setCreatorViewer } from '../creator/creatorRelationshipState';
import {
CO_CREATION_DETAIL_SOURCE,
shouldCarryCoCreationSource,
} from '../game-distribution/gameViewerRole';
import { PaymentCheckoutView } from './PaymentCheckoutView';
import {
PlatformActiveMobileWelcomeDialog,
@@ -558,6 +563,27 @@ export function PlatformEntryFlowShellImpl({
// 作者视角要一直带着 owner 作用域:刷新或直接打开该链接时详情页仍读
// `GET /my-games/{gameId}`,否则作者点自己的未公开作品只会拿到公开接口的 404。
if (options?.owner) params.set('owner', '1');
/*
* **入口上下文**(2026-10-07 产品口径更正:游客 = 点开游戏界面的玩家,不是「未登录访客」):
* 从共创侧(共创 Tab / 族谱页 / 对比页)打开详情时带上 `from=co-creation`,
* 详情页据此决定「看不看得到血缘图与共创标签」;其余来源 = 玩家侧(不带标记)。
* 已经在共创侧详情页里继续跳(血缘链 / 主题 / 相关作品)时保留标记,不中途掉回玩家侧。
* 用 URL 而不是内存 stage:刷新、深链、分享链接都稳定。
*/
const currentStage = resolveSelectionStageFromPath(
window.location.pathname,
);
const currentSource = new URLSearchParams(window.location.search).get(
'from',
);
if (
shouldCarryCoCreationSource({
stage: currentStage,
currentSource,
})
) {
params.set('from', CO_CREATION_DETAIL_SOURCE);
}
const path = `/games/detail?${params.toString()}`;
setSelectionStage('game-detail', { path });
},