diff --git a/src/components/game-distribution/CoCreationLineageStrip.tsx b/src/components/game-distribution/CoCreationLineageStrip.tsx index 02b0f5970..0d1dec35e 100644 --- a/src/components/game-distribution/CoCreationLineageStrip.tsx +++ b/src/components/game-distribution/CoCreationLineageStrip.tsx @@ -1,17 +1,18 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + import type { GameDistributionLineageNode } from '../../services/gameDistributionClient'; -import { - coCreationAvatarInitial, - coCreationGenerationShortLabel, -} from './coCreationCopy'; +import { LineageNodeContent } from './LineageNodeContent'; /** - * 血缘溯源条:横向完整链路「根原创 → 1 代 → 2 代 → … → 当前作品」。 + * 详情页「共创」卡里的**血缘链**:竖向单链(根在最上、当前作品在最下)。 * - * 数据来自既有 `/games/{id}/lineage`(在详情页请求一次,按 `parentGameId` 上溯成链, - * 不用整棵大树、也不新增接口)。每个节点显示作者字标 + 世代,点击跳该代作品详情。 + * 复用族谱那一套:链条顺序来自 `collectLineageAncestorChain`(族谱布局模块的纯函数)、 + * 节点内容来自 `LineageNodeContent`(与族谱画布同一个组件),这里只负责**嵌入态的排布**: + * 窄容器(~220–260px)里**只能纵向滚动**,横向裁掉(`overflow-x: hidden`), + * 初次进入与「当前作品 id 变化」时自动滚到底部,让当前游戏可见。 * - * 头像:`/lineage` 节点只带 `authorName`(没有头像字段),因此用姓名首字的圆形字标降级, - * 不伪造头像图片;后端补字段后换成真实头像即可(结构不用改)。 + * 为什么只画祖先链、不画整棵树:右栏只有 200 多像素宽,塞整棵树必然又挤成一片; + * 整棵树由既有的「查看创作族谱」入口负责(点击进入 `/games/lineage?id=`)。 */ export function CoCreationLineageStrip({ chain, @@ -23,48 +24,104 @@ export function CoCreationLineageStrip({ currentGameId: string; onOpenGameDetail: (gameId: string) => void; }) { + const scrollRef = useRef(null); + /** 上面还有几代没露出来(滚动位置之上还有内容时给「↑ 还有 N 代」提示)。 */ + const [hiddenAboveCount, setHiddenAboveCount] = useState(0); + + // 默认滚到底部(当前作品在最下),并在当前作品变化时重新滚到底。 + useEffect(() => { + const element = scrollRef.current; + if (!element) return; + element.scrollTop = element.scrollHeight - element.clientHeight; + setHiddenAboveCount(0); + }, [currentGameId, chain.length]); + + const handleScroll = () => { + const element = scrollRef.current; + if (!element) return; + const above = element.scrollTop; + const nodeHeight = element.scrollHeight / Math.max(chain.length, 1); + setHiddenAboveCount(Math.max(0, Math.round(above / Math.max(nodeHeight, 1)))); + }; + + const ancestors = useMemo(() => chain.slice(0, -1), [chain]); + /** 主干:每代 playCount 最高者(与族谱页同一语义:主干链的导线加粗)。 */ + const trunkGameIds = useMemo(() => { + const bestByGeneration = new Map(); + for (const node of chain) { + const current = bestByGeneration.get(node.generation); + if (!current || node.playCount > current.playCount) { + bestByGeneration.set(node.generation, node); + } + } + return new Set([...bestByGeneration.values()].map((node) => node.gameId)); + }, [chain]); if (chain.length <= 1) return null; return ( -
- {chain.map((node, index) => { - const isCurrent = node.gameId === currentGameId; - return ( - - {index > 0 ? ( - - ) : null} - - - ); - })} +
+
+ {chain.map((node, index) => { + const isCurrent = node.gameId === currentGameId; + return ( + + {index > 0 ? ( + + ); + })} +
+ {hiddenAboveCount > 0 ? ( + + ↑ 还有 {hiddenAboveCount} 代(共 {chain.length} 代) + + ) : null} + {ancestors.length > 0 ? ( + + 根在最上、当前作品在最下,可纵向滚动 + + ) : null}
); } +function generationLabel(generation: number) { + return generation <= 0 ? '0 代原创' : `${generation} 代`; +} + export default CoCreationLineageStrip; diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index 7177d1034..0cada2f58 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -2353,6 +2353,38 @@ describe('GameDetailPage 共创主题入口', () => { }); }); +/** 4 节点、3 代的血缘链数据集(根 → 1 代 → 2 代 → 当前),供详情页血缘链用例共用。 */ +function buildChainDataset() { + return { + rootGameId: 'game-root', + root: null, + nodes: [ + createLineageNode({ + gameId: 'game-root', + title: '根原创', + authorName: '根作者', + generation: 0, + parentGameId: null, + }), + createLineageNode({ + gameId: 'game-mid', + title: '一代作品', + authorName: '一代作者', + generation: 1, + parentGameId: 'game-root', + }), + createLineageNode({ + gameId: 'game-current', + title: '当前作品', + authorName: '当前作者', + generation: 2, + parentGameId: 'game-mid', + }), + ], + truncated: false, + }; +} + /** 节点卡片上的主行动作按钮(打开详情)与它的卡片容器:多个 describe 共用。 */ const openButton = (title: string) => screen.getByRole('button', { name: `打开《${title}》详情` }); @@ -2980,21 +3012,160 @@ describe('GameDetailPage 共创模块(文档对齐)', () => { />, ); - const strip = await screen.findByLabelText('血缘溯源'); + const strip = await screen.findByLabelText('创作血缘链'); expect(strip.textContent).toContain('根原创'); expect(strip.textContent).toContain('一代作品'); expect(strip.textContent).toContain('当前作品'); - // 头像位是姓名首字(链路节点没有头像字段,不伪造头像图)。 - expect(strip.textContent).toContain('根'); - // 当前作品标记为 current 且点击不跳转;点中间一代跳该作品。 + // 顺序必须是 根 → 1 代 → 当前(DOM 顺序)。 + const chainText = strip.textContent ?? ''; + expect(chainText.indexOf('根原创')).toBeLessThan( + chainText.indexOf('一代作品'), + ); + expect(chainText.indexOf('一代作品')).toBeLessThan( + chainText.indexOf('当前作品'), + ); + // 当前作品有明确标记(aria-current + current 类)。 + const currentNode = strip.querySelector( + '.co-creation-chain__node--current', + ) as HTMLElement; + expect(currentNode.getAttribute('aria-current')).toBe('true'); + expect(currentNode.textContent).toContain('当前作品'); + // 点击根节点跳该作品;点当前作品不跳(本就是当前页)。 fireEvent.click( - screen.getByRole('button', { name: /0 代作品《根原创》/u }), + screen.getByRole('button', { name: /0 代原创《根原创》/u }), ); expect(onOpenGameDetail).toHaveBeenCalledWith('game-root'); - fireEvent.click( - screen.getByRole('button', { name: /2 代作品《当前作品》/u }), - ); + fireEvent.click(currentNode); expect(onOpenGameDetail).toHaveBeenCalledTimes(1); + // 节点是真实 button:键盘可达(Tab 聚焦 + Enter 原生激活)。 + const rootNode = screen.getByRole('button', { + name: /0 代原创《根原创》/u, + }); + expect(rootNode.tagName).toBe('BUTTON'); + rootNode.focus(); + expect(document.activeElement).toBe(rootNode); + }); + + it('血缘链只允许纵向滚动并默认滚到底部(当前作品可见)', async () => { + // jsdom 不计算布局:把可滚动尺寸打桩成「内容 400 / 可视 100」,才能断言「滚到底」。 + const scrollHeightDescriptor = Object.getOwnPropertyDescriptor( + Element.prototype, + 'scrollHeight', + ); + const clientHeightDescriptor = Object.getOwnPropertyDescriptor( + Element.prototype, + 'clientHeight', + ); + Object.defineProperty(Element.prototype, 'scrollHeight', { + configurable: true, + get: () => 400, + }); + Object.defineProperty(Element.prototype, 'clientHeight', { + configurable: true, + get: () => 100, + }); + try { + getGameMock.mockResolvedValueOnce(createGame({ id: 'game-current' })); + getGameLineageMock.mockResolvedValueOnce(buildChainDataset()); + const first = render( + , + ); + const scroll = await screen.findByLabelText('创作血缘链'); + // 默认滚到底:scrollTop = scrollHeight - clientHeight = 400 - 100。 + await waitFor(() => expect(scroll.scrollTop).toBe(300)); + // 只允许纵向滚动:容器类名对应的 CSS 里 overflow-x 是 hidden(另有 CSS 防退化断言)。 + expect(scroll.className).toContain('co-creation-chain__scroll'); + first.unmount(); + + // 当前作品 id 变化 → 重新滚到底(而不是停在之前的位置)。 + getGameMock.mockResolvedValueOnce(createGame({ id: 'game-mid' })); + getGameLineageMock.mockResolvedValueOnce(buildChainDataset()); + render( + , + ); + const next = await screen.findByLabelText('创作血缘链'); + await waitFor(() => expect(next.scrollTop).toBe(300)); + } finally { + if (scrollHeightDescriptor) { + Object.defineProperty( + Element.prototype, + 'scrollHeight', + scrollHeightDescriptor, + ); + } + if (clientHeightDescriptor) { + Object.defineProperty( + Element.prototype, + 'clientHeight', + clientHeightDescriptor, + ); + } + } + }); + + it('血缘链节点卡片:标题不逐字换行、容器不横向滚动(防退化)', async () => { + const css = await import('node:fs').then((fs) => + fs.readFileSync( + 'src/components/game-distribution/gameDistribution.css', + 'utf8', + ), + ); + // 防退化:这几条是「窄容器里不被挤成竖排」的根因,改坏了必须变红。 + expect(css).toContain('-webkit-line-clamp: 2'); + expect(css).toContain('overflow-x: hidden'); + expect(css).toContain('min-width: 0'); + expect(css).toContain('text-overflow: ellipsis'); + expect(css).toContain('.game-lineage-node__content--inline'); + + getGameMock.mockResolvedValueOnce(createGame({ id: 'game-current' })); + getGameLineageMock.mockResolvedValueOnce(buildChainDataset()); + render( + , + ); + + const scroll = await screen.findByLabelText('创作血缘链'); + expect(scroll.className).toContain('co-creation-chain__scroll'); + // 横向不溢出(jsdom 里两者均为 0,断言的是「没有设置横向滚动」这一契约)。 + expect(scroll.scrollWidth <= scroll.clientWidth).toBe(true); + const title = scroll.querySelector('.game-lineage-node__title'); + expect(title?.className).toContain('game-lineage-node__title'); + expect(title?.textContent).toBe('根原创'); + }); + + it('节点封面用横版小缩略图,链节点是矮而宽的横向排布', async () => { + getGameMock.mockResolvedValueOnce(createGame({ id: 'game-current' })); + getGameLineageMock.mockResolvedValueOnce(buildChainDataset()); + render( + , + ); + + const scroll = await screen.findByLabelText('创作血缘链'); + expect( + scroll.querySelectorAll('.game-lineage-node__content--inline').length, + ).toBeGreaterThan(0); + expect(scroll.querySelectorAll('.co-creation-chain__connector').length).toBe( + 2, + ); }); it('热门衍生栏按 playCount 倒序取前 5 个并可在点击后进入作品', async () => { diff --git a/src/components/game-distribution/GameLineagePage.tsx b/src/components/game-distribution/GameLineagePage.tsx index 3c049873d..2afc4c560 100644 --- a/src/components/game-distribution/GameLineagePage.tsx +++ b/src/components/game-distribution/GameLineagePage.tsx @@ -25,17 +25,16 @@ import { CopyFeedbackButton } from '../common/CopyFeedbackButton'; import { PlatformEmptyState } from '../common/PlatformEmptyState'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { useCopyFeedback } from '../common/useCopyFeedback'; -import { GameCover } from './GameCover'; import { useIsMobileViewport } from './gameDistributionGuards'; import { FORK_LAUNCH_FALLBACK_DELAY_MS, openForkProtocol, } from './gameForkProtocol'; +import { LineageNodeContent } from './LineageNodeContent'; import { buildLineageTreeLayout, collectLineageHighlight, generationAxisLabel, - LINEAGE_COVER_ASPECT, type LineageLayoutNode, } from './lineageTreeLayout'; @@ -109,15 +108,6 @@ function LineageTreeNodeCard({ ? `原作品已不可用 · ${generationAxisLabel(generation)}` : `${generationAxisLabel(generation)}作品《${displayTitle}》· ${authorLabel}`; // 父作品不可用的占位节点只能聚焦(不可点进详情,也没有 Fork 动作)。 - // 封面:族谱节点只带 `coverObjectKey`,没有完整游戏行;`GameCover` 只读 - // title / coverColor / icon / coverObjectKey 四个字段,这里用与 `game_payload` 相同的 - // 兜底色与图标补上,其余字段不参与渲染——不做逐节点详情请求。 - const coverGame = { - title: displayTitle, - coverColor: '#F3E4D0', - icon: '🎮', - coverObjectKey: layoutNode.coverObjectKey, - } as unknown as GameDistributionGame; return (
- - - - {generationAxisLabel(generation)} - - {layoutNode.isTrunk ? ( - - 主干 - - ) : null} - {isAnchor ? ( - 当前作品 - ) : null} - - - {displayTitle} - {compact ? null : ( - - {authorLabel} - - )} - +