diff --git a/src/components/game-distribution/GameDetailPage.tsx b/src/components/game-distribution/GameDetailPage.tsx index 027e34dba..470f568f2 100644 --- a/src/components/game-distribution/GameDetailPage.tsx +++ b/src/components/game-distribution/GameDetailPage.tsx @@ -61,6 +61,7 @@ import { VERSION_STATUS_LABEL, VERSION_STATUS_NEEDS_AUTHOR_ACTION, } from './gameVersionHistoryModel'; +import { isCoCreator } from './gameViewerRole'; import { collectLineageAncestorChain, collectLineagePlayCountTotals, @@ -538,6 +539,12 @@ function GameDetailContent({ onOpenTheme?: (themeId: string) => void; onOpenGameDetail?: (gameId: string) => void; }) { + /** + * 观看者角色(2026-10-07 产品口径:普通用户 = 游客 / 共创者 = 开发游戏的)。 + * **判据只有一处**(`isCoCreator`,当前口径 = 登录态,见该模块的「待产品确认」说明), + * 页面上所有角色差异都用这一个布尔量驱动,不在 JSX 里散写 `isAuthenticated`。 + */ + const coCreator = isCoCreator(isAuthenticated); /** * 只有已公开作品才有公开评价/评分投影;审核中、审核未通过、未公开、已下架 * 一律不请求评价接口,封面位改为状态文案。 @@ -599,10 +606,13 @@ function GameDetailContent({ GameDistributionLineageNode[] >([]); /** - * 正文之后那块整宽血缘图是否渲染:公开可见 + 有可跳转详情的回调 + **确实有亲戚** - * (有父代链或已有衍生作品)。孤立原创作品的详情页不该多出一块只有一个节点的图。 + * 正文之后那块整宽血缘图是否渲染:**共创者**(游客整块不渲染)+ 公开可见 + + * 有可跳转详情的回调 + **确实有亲戚**(有父代链或已有衍生作品)。 + * 孤立原创作品的详情页不该多出一块只有一个节点的图。 + * 游客口径:血缘/族谱是「共创者」的结构信息,游客(只玩游戏)不渲染图、也不渲染图下的父代/子代清单。 */ const showLineageBlock = + coCreator && publiclyVisible && Boolean(onOpenGameDetail) && (lineageChain.length > 1 || derivedNodes.length > 0); @@ -866,7 +876,11 @@ function GameDetailContent({ publishedVersion, ]); return ( -
+
@@ -909,23 +923,31 @@ function GameDetailContent({ label: '共创', value: ( - - {/* 世代标签(文档口径:0 代原创 / 1 代二创 / 2 代三创 / N 代迭代)。 */} - - {coCreationGenerationTagLabel(lineage?.generation ?? 0)} + {/* + 游客(只玩游戏)不显示**共创标签**:世代(第 N 代 / 母版)与授权档位 + (允许共创 / 非商用 / 禁止共创)都是「共创者视角」的档位标签。 + 共创者与改动前完全一致。 + */} + {coCreator ? ( + + {/* 世代标签(文档口径:0 代原创 / 1 代二创 / 2 代三创 / N 代迭代)。 */} + + {coCreationGenerationTagLabel(lineage?.generation ?? 0)} + + {/* 授权类型标签:沿用平台既有 constants(含「允许」前缀),不另造一套文案。 */} + + { + GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[ + forkAuthorization + ] + } + - {/* 授权类型标签:沿用平台既有 constants(含「允许」前缀),不另造一套文案。 */} - - { - GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[ - forkAuthorization - ] - } - - - {lineage ? ( + ) : null} + {/* 血缘关系文字行(改编自父代《…》/ 父代作者)同样只给共创者看。 */} + {coCreator && lineage ? ( <> {/* 父作品被软删除时服务端不再回传它的标题与作者名(见 @@ -971,7 +993,8 @@ function GameDetailContent({ )} - {onOpenLineage && publiclyVisible ? ( + {/* 进入血缘视图的入口(族谱 / 与原版对比)也只给共创者。 */} + {coCreator && onOpenLineage && publiclyVisible ? ( /* 族谱入口放在共创信息卡里:它属于「这部作品的改编关系」这件事, 点进去是以母版为顶的整棵族谱树;`from` 带上当前作品供页面高亮。 @@ -987,7 +1010,10 @@ function GameDetailContent({ 查看创作族谱 ) : null} - {publiclyVisible && lineage?.parentGameId && onOpenCompare ? ( + {coCreator && + publiclyVisible && + lineage?.parentGameId && + onOpenCompare ? ( /* 有父作品(二创作品)时给出「与原版对比」:左侧原版、右侧当前作品, 两侧各自取数与降级(父作品不可读时只降级那一侧)。 @@ -1076,17 +1102,27 @@ function GameDetailContent({ {/* 「一键开始共创」从共创信息卡搬到主操作行(2026-10-07 用户口径): 它是这部作品的主操作之一,和「立即玩 / 收藏」并列;共创卡内不再重复出现该按钮。 - 视觉权重保持主操作(实心强调色),未登录 → 登录门禁,禁止共创 → 置灰 + 原因。 + **角色差异**(判据 `isCoCreator`): + - 共创者:与改动前**完全一致**——实心强调 + lg 尺寸(48px / 0.94rem),文案「一键开始共创」, + 禁止共创时置灰 + 原因; + - 游客:**不占用重点色**,缩成次要小按钮(描边 + 次要底色、32px / 0.78rem,见 + `.game-fork-remix-guest`),文案如实写「登录后开始共创」——不喊「一键共创」这种大口号。 + 但**仍然可点可达**:点击走仓库既有的登录门禁(`onLogin`),既不禁用也不隐藏。 */} {publiclyVisible ? ( <> {isAuthenticated && isForkPanelOpen ? createPortal( diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index eb3da1789..5524b33f1 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -1,5 +1,7 @@ /* @vitest-environment jsdom */ +import { readFileSync } from 'node:fs'; + import { PLATFORM_GAME_LOADING_TIMEOUT_MS } from '@genarrative/shared/components'; import { act, @@ -2316,7 +2318,7 @@ describe('GameDetailPage 开始共创入口', () => { expect(screen.getByText(/当前设备无法直接唤起客户端/u)).toBeTruthy(); }); - it('未登录时入口显示「登录后一键开始共创」,点击走仓库既有登录门禁而不是静默隐藏', async () => { + it('游客(未登录)入口是次要小按钮「登录后开始共创」,点击走仓库既有登录门禁而不是静默隐藏', async () => { authUiMock.value.user = null; authUiMock.value.canAccessProtectedData = false; getGameMock.mockResolvedValueOnce( @@ -2327,8 +2329,10 @@ describe('GameDetailPage 开始共创入口', () => { ); const gate = await screen.findByRole('button', { - name: '登录后一键开始共创', + name: '登录后开始共创', }); + // 文案如实:不给游客喊「一键开始共创」这种大口号。 + expect(screen.queryByRole('button', { name: '一键开始共创' })).toBeNull(); expect(screen.queryByText(/从平台作品开始创作/u)).toBeNull(); fireEvent.click(gate); @@ -2336,6 +2340,37 @@ describe('GameDetailPage 开始共创入口', () => { // 未登录既不唤起客户端,也不展开获取方式。 expect(openForkProtocolMock).not.toHaveBeenCalled(); expect(screen.queryByText(/从平台作品开始创作/u)).toBeNull(); + // 角色差异可观测:根节点带 `data-viewer-role`,按钮走「游客」变体(不占重点色)。 + expect( + document + .querySelector('.game-detail-page') + ?.getAttribute('data-viewer-role'), + ).toBe('guest'); + expect(gate.getAttribute('data-viewer-role')).toBe('guest'); + 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 () => { + // jsdom 不算几何:用 CSS 源码契约把「主次差异」钉死(真机几何由 smoke 记录)。 + const css = readFileSync( + 'src/components/game-distribution/gameDistribution.css', + 'utf8', + ); + expect(css).toMatch( + /\.game-fork-remix-link\.game-fork-remix-guest \{[\s\S]{0,400}?min-height: 2rem;/u, + ); + expect(css).toMatch( + /\.game-fork-remix-link\.game-fork-remix-guest \{[\s\S]{0,400}?font-size: 0\.78rem;/u, + ); + expect(css).toMatch( + /\.game-fork-remix-link\.game-fork-remix-guest \{[\s\S]{0,500}?background: var\(--platform-button-secondary-fill\);/u, + ); + // 主操作(共创者那份)仍是实心强调 + lg 尺寸,两者不是同一套度量。 + expect(css).toMatch( + /\.game-fork-remix-link\.co-creation-fork-primary \{[\s\S]{0,400}?min-height: 3rem;/u, + ); + expect(css).toMatch(/background: var\(--platform-button-primary-solid\);/u); }); }); @@ -2428,23 +2463,24 @@ describe('原版与二创对比入口', () => { ); expect(await findTreeText('叶节点')).toBeTruthy(); - // 口径变更(2026-10-07,用户口径):**节点也显示游玩次数**,显示值 = 该节点的**子树合计** - // (`collectLineagePlayCountTotals().total`),不是自身值。 + // 口径变更(2026-10-07):节点显示**子树合计**(`collectLineagePlayCountTotals().total`), + // 不是自身值;但**只显示一次** —— 卡面本体(广场卡)的「N 次游玩」就是它, + // 角标里那枚重复的 `▶ N` 已删除(截图反馈「▶N 同时出现在角标与 meta 行」)。 const rootCard = await cardOf('母版'); // 母版合计 = 自身 1 + 叶节点 4 = 5(孤儿节点挂在缺失的父下,不算它的后代)。 + expect(rootCard.querySelector('.game-lineage-node__play-count')).toBeNull(); expect( - rootCard.querySelector('.game-lineage-node__play-count')?.textContent, - ).toBe('▶ 5'); + cardButtonOf(rootCard)?.querySelector('.game-card__play-count') + ?.textContent, + ).toBe('5 次游玩'); const leafCard = await cardOf('叶节点'); + expect(leafCard.querySelector('.game-lineage-node__play-count')).toBeNull(); expect( - leafCard.querySelector('.game-lineage-node__play-count')?.textContent, - ).toBe('▶ 4'); - // 角标的 title / aria-label 说明它**含衍生**,避免把合计误解成自身。 - const chip = rootCard.querySelector( - '.game-lineage-node__play-count', - ) as HTMLElement; - expect(chip.getAttribute('title')).toContain('含衍生作品'); - expect(chip.getAttribute('aria-label')).toContain('含衍生作品'); + cardButtonOf(leafCard)?.querySelector('.game-card__play-count') + ?.textContent, + ).toBe('4 次游玩'); + // 「含衍生」的口径说明挂在节点容器上(卡面那一行没法挂 title),悬停仍能看到分解。 + expect(rootCard.getAttribute('title')).toContain('含衍生作品'); // 「与原版对比」仍然留在详情页,不在树里重复渲染。 expect(screen.queryByRole('button', { name: '与原版对比' })).toBeNull(); // 卡面**没有**动作按钮(整卡即入口);游玩数由卡片自身承载(子树合计口径)。 @@ -3335,7 +3371,7 @@ describe('GameLineagePage 可视化与交互', () => { expect(LINEAGE_COVER_ASPECT).toBeCloseTo(16 / 9, 5); }); - it('初始适配:装得下就全览、装不下就退回可读缩放并明确提示', async () => { + it('初始适配:装得下就全览、装不下就停在可读下限(横向铺满)并明确提示', async () => { // 情形 1:视口够大 → 宽高同时 fit,所有可见节点都在视口内、无提示。 const wideWidth = vi .spyOn(Element.prototype, 'clientWidth', 'get') @@ -3366,7 +3402,7 @@ describe('GameLineagePage 可视化与交互', () => { expect(screen.queryByText(/其余代际在视口外/u)).toBeNull(); first.unmount(); - // 情形 2:视口装不下全览(低于可读下限)→ 停在可读缩放 + 明确提示,不静默丢代际。 + // 情形 2:视口装不下全览(低于可读下限)→ 停在可读下限 + 明确提示,不静默丢代际。 wideWidth.mockReturnValue(420); wideHeight.mockReturnValue(320); getGameLineageMock.mockResolvedValueOnce(demoShapeTree); @@ -3388,8 +3424,8 @@ describe('GameLineagePage 可视化与交互', () => { ); const smallView = readCanvasViewport(smallCanvas); const visible = visibleCanvasCards(smallCanvas, smallView); - const floored = Math.abs(smallView.scale - 0.6) < 0.001; - // 二选一(都不允许「既没全览又没提示」):要么全部可见,要么触底可读缩放 + 提示出现。 + const floored = Math.abs(smallView.scale - 0.8) < 0.001; + // 二选一(都不允许「既没全览又没提示」):要么全部可见,要么停在可读下限 + 提示出现。 expect(floored || visible.length === 8).toBe(true); if (floored) { expect( @@ -3397,7 +3433,7 @@ describe('GameLineagePage 可视化与交互', () => { '树较大:已按可读缩放显示(其余代际在视口外),拖拽平移查看,或点「总览」看全树。', ), ).toBeTruthy(); - // 触底时仍然把当前作品放进视口(不是把它也丢出去)。 + // 停在可读下限时仍然把当前作品放进视口(不是把它也丢出去)。 expect(visible).toContain( smallCanvas.querySelector('.game-lineage-node--anchor'), ); @@ -3408,6 +3444,53 @@ describe('GameLineagePage 可视化与交互', () => { } }); + it('停在可读下限时横向铺满视口:树又宽又高时不留左右大片空白', async () => { + // 宽而矮的视口:按宽度适配(>1)比按高度全览(<1)大得多 —— 旧口径会取 0.6 的可读下限, + // 于是内容只占视口宽度的一半(截图反馈「留白过多」);新口径取「宽度适配」把横向铺满。 + const widthSpy = vi + .spyOn(Element.prototype, 'clientWidth', 'get') + .mockReturnValue(1600); + const heightSpy = vi + .spyOn(Element.prototype, 'clientHeight', 'get') + .mockReturnValue(380); + try { + getGameLineageMock.mockResolvedValueOnce(demoShapeTree); + render( + , + ); + const canvas = (await screen.findByLabelText( + '创作族谱树', + )) as HTMLElement; + await waitFor(() => + expect(canvas.querySelectorAll('.game-lineage-node').length).toBe(8), + ); + const content = canvas.querySelector( + '.game-lineage-canvas__content', + ) as HTMLElement; + const layoutWidth = Number.parseFloat(content.style.width); + const view = readCanvasViewport(canvas); + // 可读下限之上的横向铺满 = 按宽度适配(FIT_PADDING=24 ⇒ 可用宽度 1600-48)。 + const fitWidth = (1600 - 48) / layoutWidth; + expect(view.scale).toBeGreaterThan(0.8); + expect(view.scale).toBeCloseTo(fitWidth, 3); + // 内容宽度(含 FIT_PADDING 的一侧)铺到视口边缘附近:空白 ≤ 上下留白量级。 + expect(layoutWidth * view.scale).toBeGreaterThan(1500); + expect( + screen.getByText( + '树较大:已按可读缩放显示(其余代际在视口外),拖拽平移查看,或点「总览」看全树。', + ), + ).toBeTruthy(); + } finally { + widthSpy.mockRestore(); + heightSpy.mockRestore(); + } + }); + it('画布卡片就是广场卡组件:角标走卡面 badge 槽、卡内没有动作按钮、整卡点击进详情', async () => { getGameLineageMock.mockResolvedValueOnce(relationTree); const onOpenDetail = vi.fn(); @@ -3429,6 +3512,18 @@ describe('GameLineagePage 可视化与交互', () => { ) as HTMLElement; expect(badges).toBeTruthy(); expect(within(badges).getByText('当前作品')).toBeTruthy(); + // ①' 角标**不再压在封面上**:角标槽排在封面之后、信息区之前(DOM 顺序即渲染顺序)。 + const cover = cardButton.querySelector('.game-cover') as HTMLElement; + const body = cardButton.querySelector('.game-card__body') as HTMLElement; + expect(cover.compareDocumentPosition(badges)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + expect(badges.compareDocumentPosition(body)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + // ①'' 竖版画布有左侧代际轴 → 卡上不再重复「第 N 代」;玩数由卡面承载 → 角标里没有 ▶N。 + 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(); @@ -3450,6 +3545,17 @@ describe('GameLineagePage 可视化与交互', () => { expect(css).toMatch( /\.game-card--lineage \.game-cover \{[\s\S]{0,200}?aspect-ratio: 16 \/ 9;/u, ); + // 角标槽必须离开「卡内绝对定位浮层」(共享卡默认位置)→ 静态流式、排在封面下方。 + expect(css).toMatch( + /\.game-card--lineage \.game-card__badges \{[\s\S]{0,300}?position: static;/u, + ); + expect(css).toMatch( + /\.game-card--lineage \.game-card__badges > \* \{[\s\S]{0,300}?position: static;/u, + ); + // 窄卡上标题独占一行、游玩数不再和标题抢行宽。 + expect(css).toMatch( + /\.game-card--lineage \.game-card__title-row \{[\s\S]{0,200}?flex-direction: column;/u, + ); expect(css).toMatch( /\.game-lineage-node__trunk \{[\s\S]{0,300}?--platform-warm-bg/u, ); @@ -3537,10 +3643,10 @@ describe('GameLineagePage 可视化与交互', () => { const wheel = (init: Record) => fireEvent.wheel(canvas, init); // 返回 false 表示事件被 preventDefault - // 先等初始适配稳定(jsdom 下退到可读缩放下限 60%),否则会把「适配后的首次写入」误当成滚轮的效果。 + // 先等初始适配稳定(jsdom 下退到可读缩放下限 80%),否则会把「适配后的首次写入」误当成滚轮的效果。 await waitFor(() => expect(document.querySelector('.game-lineage-zoom')?.textContent).toBe( - '60%', + '80%', ), ); const start = read(); @@ -3755,10 +3861,10 @@ describe('GameLineagePage 可视化与交互', () => { await cardOf('母版'); // 初次适配在 effect 里落地:先等它生效(jsdom 无布局尺寸 → min(scaleX, scaleY) 远小于 1, - // 因此命中可读下限 60%)。 + // 因此命中可读下限 80%)。 await waitFor(() => expect(document.querySelector('.game-lineage-zoom')?.textContent).toBe( - '60%', + '80%', ), ); expect(screen.getByText(/已按可读缩放显示/u)).toBeTruthy(); @@ -3895,12 +4001,12 @@ describe('GameLineagePage 可视化与交互', () => { await cardOf('母版'); const zoomLabel = () => document.querySelector('.game-lineage-zoom')?.textContent ?? ''; - // 初次适配是 effect 里跑的:先等它落定(jsdom 无布局尺寸 → 命中可读下限 60%)。 - await waitFor(() => expect(zoomLabel()).toBe('60%')); + // 初次适配是 effect 里跑的:先等它落定(jsdom 无布局尺寸 → 命中可读下限 80%)。 + await waitFor(() => expect(zoomLabel()).toBe('80%')); fireEvent.click(screen.getByRole('button', { name: '放大' })); - expect(zoomLabel()).toBe('72%'); + expect(zoomLabel()).toBe('96%'); fireEvent.click(screen.getByRole('button', { name: '缩小' })); - expect(zoomLabel()).toBe('60%'); + expect(zoomLabel()).toBe('80%'); // 总览(强制全览)在 jsdom 下退到最小缩放下限。 fireEvent.click(screen.getByRole('button', { name: '总览' })); expect(zoomLabel()).toBe('35%'); @@ -4343,6 +4449,74 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐) expect(crumbs[crumbs.length - 1]?.textContent).toContain('深代作品'); }); + it('视图形态是分段控件:当前形态与 aria-pressed 一一对应(不再「画面是画布、按钮却是大纲已按下」)', async () => { + getGameLineageMock.mockResolvedValueOnce(tree); + render( + , + ); + + const canvasButton = await screen.findByRole('button', { + name: '图谱视图', + }); + const outlineButton = screen.getByRole('button', { name: '大纲视图' }); + // 桌面默认画布 → 「图谱视图」按下、「大纲视图」未按下,且两个选项常驻(不是一个翻转按钮)。 + expect(document.querySelector('.game-lineage-canvas')).not.toBeNull(); + expect(canvasButton.getAttribute('aria-pressed')).toBe('true'); + expect(outlineButton.getAttribute('aria-pressed')).toBe('false'); + expect( + 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', + ), + ).toBe('true'); + expect( + screen.getByRole('button', { name: '图谱视图' }).getAttribute( + 'aria-pressed', + ), + ).toBe('false'); + }); + + it('画布控件栏:缩小 / 读数 / 放大 / 总览 / 刷新 五件齐全,且没有会被读成「搜索」的放大镜图标', async () => { + getGameLineageMock.mockResolvedValueOnce(tree); + render( + , + ); + + const controls = await screen.findByLabelText('画布控件'); + const names = [...controls.querySelectorAll('button')].map((button) => + button.getAttribute('aria-label'), + ); + expect(names).toEqual(['缩小', '放大', '总览', '刷新']); + expect(controls.querySelector('.game-lineage-zoom')?.textContent).toMatch( + /^\d+%$/u, + ); + // 图标是「−/+」:放大镜(lucide ZoomIn/ZoomOut)在控件栏里会被读成「搜索」, + // 而这里根本没有搜索按钮 —— 用图标名钉住不许退回。 + const source = readFileSync( + 'src/components/game-distribution/GameLineagePage.tsx', + 'utf8', + ); + expect(source).toContain('