diff --git a/src/components/game-distribution/GameDetailPage.tsx b/src/components/game-distribution/GameDetailPage.tsx
index 470f568f2..bddfb2df5 100644
--- a/src/components/game-distribution/GameDetailPage.tsx
+++ b/src/components/game-distribution/GameDetailPage.tsx
@@ -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 (
{isAuthenticated && isForkPanelOpen
? createPortal(
diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx
index 5524b33f1..95f658343 100644
--- a/src/components/game-distribution/GameDistributionPages.test.tsx
+++ b/src/components/game-distribution/GameDistributionPages.test.tsx
@@ -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 () => {
diff --git a/src/components/game-distribution/gameViewerRole.ts b/src/components/game-distribution/gameViewerRole.ts
index 5fe428de6..e492e0a8f 100644
--- a/src/components/game-distribution/gameViewerRole.ts
+++ b/src/components/game-distribution/gameViewerRole.ts
@@ -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=
`(高亮当前作品)含义不同,不能被当成来源。
+ */
+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';
}
diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx
index 8a68b5da4..51cce4cfc 100644
--- a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx
+++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx
@@ -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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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');
diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx
index a4e2863b4..d3a73ea97 100644
--- a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx
+++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx
@@ -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 });
},