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 }); },