diff --git a/src/components/game-distribution/CoCreationLineageSection.tsx b/src/components/game-distribution/CoCreationLineageSection.tsx new file mode 100644 index 000000000..2e30ad3a7 --- /dev/null +++ b/src/components/game-distribution/CoCreationLineageSection.tsx @@ -0,0 +1,55 @@ +import { GameLineagePage } from './GameLineagePage'; + +/** + * 详情页正文之后的**整宽血缘图区块**。 + * + * 需求(2026-10-06):「这个图可能非常大,不建议放在侧边栏里」→ 从 ~230px 的共创信息卡里 + * 搬到正文之后,直接展示与这部作品相关的**整棵树**。 + * + * 渲染**就是**族谱页组件(`GameLineagePage embedded`):树查询、可见性过滤(未公开 / 已删不出现)、 + * 布局与连线、移动端大纲形态、初始定位到当前作品全部复用同一份实现,详情页不引入第二套树渲染。 + * 区块自己只负责:标题 + 「全屏查看族谱」入口(复用共创卡的按钮样式),并把当前作品作为 + * 高亮锚点传下去——图里因此会标出「当前作品」、高亮它的父代(祖先链)与子代。 + */ +export function CoCreationLineageSection({ + rootGameId, + currentGameId, + onOpenGameDetail, + onOpenFullView, +}: { + /** 树的作用域:当前作品所在分支的**根**(详情 DTO 的 `lineage.rootGameId`);默认展示以根为顶的整棵树。 */ + rootGameId: string; + /** 当前作品:作为高亮锚点与「父代 / 子代」区分的基准。 */ + currentGameId: string; + onOpenGameDetail: (gameId: string) => void; + /** 「全屏查看族谱」:跳到 `/games/lineage?id=&from=<当前作品>`。 */ + onOpenFullView?: (rootGameId: string, fromGameId?: string) => void; +}) { + return ( +
+
+
+ 创作血缘 +

这部作品的血缘图

+
+ {onOpenFullView ? ( + + ) : null} +
+ +
+ ); +} + +export default CoCreationLineageSection; diff --git a/src/components/game-distribution/CoCreationLineageStrip.tsx b/src/components/game-distribution/CoCreationLineageStrip.tsx deleted file mode 100644 index 0d1dec35e..000000000 --- a/src/components/game-distribution/CoCreationLineageStrip.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; - -import type { GameDistributionLineageNode } from '../../services/gameDistributionClient'; -import { LineageNodeContent } from './LineageNodeContent'; - -/** - * 详情页「共创」卡里的**血缘链**:竖向单链(根在最上、当前作品在最下)。 - * - * 复用族谱那一套:链条顺序来自 `collectLineageAncestorChain`(族谱布局模块的纯函数)、 - * 节点内容来自 `LineageNodeContent`(与族谱画布同一个组件),这里只负责**嵌入态的排布**: - * 窄容器(~220–260px)里**只能纵向滚动**,横向裁掉(`overflow-x: hidden`), - * 初次进入与「当前作品 id 变化」时自动滚到底部,让当前游戏可见。 - * - * 为什么只画祖先链、不画整棵树:右栏只有 200 多像素宽,塞整棵树必然又挤成一片; - * 整棵树由既有的「查看创作族谱」入口负责(点击进入 `/games/lineage?id=`)。 - */ -export function CoCreationLineageStrip({ - chain, - currentGameId, - onOpenGameDetail, -}: { - /** 从根到当前作品的顺序列表。 */ - chain: readonly GameDistributionLineageNode[]; - 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 ? ( - - ); - })} -
- {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/GameDetailPage.tsx b/src/components/game-distribution/GameDetailPage.tsx index e82e86031..6f3abf3ef 100644 --- a/src/components/game-distribution/GameDetailPage.tsx +++ b/src/components/game-distribution/GameDetailPage.tsx @@ -39,7 +39,7 @@ import { useCopyFeedback } from '../common/useCopyFeedback'; import { CreatorAuthor } from '../creator/CreatorRelationshipControl'; import { coCreationGenerationTagLabel } from './coCreationCopy'; import { CoCreationHotDerived } from './CoCreationHotDerived'; -import { CoCreationLineageStrip } from './CoCreationLineageStrip'; +import { CoCreationLineageSection } from './CoCreationLineageSection'; import { normalizeGameEntryUrl, normalizeGameId, @@ -579,6 +579,14 @@ function GameDetailContent({ const [derivedNodes, setDerivedNodes] = useState< GameDistributionLineageNode[] >([]); + /** + * 正文之后那块整宽血缘图是否渲染:公开可见 + 有可跳转详情的回调 + **确实有亲戚** + * (有父代链或已有衍生作品)。孤立原创作品的详情页不该多出一块只有一个节点的图。 + */ + const showLineageBlock = + publiclyVisible && + Boolean(onOpenGameDetail) && + (lineageChain.length > 1 || derivedNodes.length > 0); /** * 「累计创作世代数」=**本作品子树的深度**(子树最大代际 − 本作品代际)。 * 用详情页已经拿到的 `/lineage` 同一份数据在前端算,不发新请求; @@ -1050,13 +1058,6 @@ function GameDetailContent({ ) : null} ) : null} - {lineageChain.length > 1 && onOpenGameDetail ? ( - - ) : null} {derivedNodes.length > 0 && onOpenGameDetail ? ( ) : null} + {/* + 血缘图放在**正文之后**(需求 2026-10-06):图可能很大,~230px 的侧栏只够画一条链。 + 整宽区块内嵌的就是族谱页组件(`GameLineagePage embedded`),不新建第二套树渲染; + 侧栏共创卡保留世代 / 授权 / 累计数据与四个入口(含「查看创作族谱」全屏入口)。 + */} + {showLineageBlock && onOpenGameDetail ? ( + + ) : null} { - const card = node as HTMLElement; - const left = - view.offsetX + Number.parseFloat(card.style.left) * view.scale; - const top = view.offsetY + Number.parseFloat(card.style.top) * view.scale; - const width = Number.parseFloat(card.style.width) * view.scale; - const height = Number.parseFloat(card.style.minHeight) * view.scale; - return ( - left + width > 0 && - top + height > 0 && - left < canvas.clientWidth && - top < canvas.clientHeight - ); - }); - } - it('图谱视图初次进入即适配并定位当前作品(不是 zoom 100% 的空屏)', async () => { setViewportWidth(390); getGameLineageMock.mockResolvedValueOnce(tree); @@ -3273,6 +3233,44 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐) }); }); +/** 读画布视口状态:内容层的 `transform: translate(...) scale(...)`。 */ +function readCanvasViewport(canvas: HTMLElement) { + const content = canvas.querySelector( + '.game-lineage-canvas__content', + ) as HTMLElement; + const match = /translate\(([-\d.]+)px, ([-\d.]+)px\) scale\(([\d.]+)\)/u.exec( + content.style.transform, + ); + if (!match) { + throw new Error(`无法解析画布 transform: ${content.style.transform}`); + } + return { + offsetX: Number.parseFloat(match[1]!), + offsetY: Number.parseFloat(match[2]!), + scale: Number.parseFloat(match[3]!), + }; +} + +/** 视口内可见的节点卡片:把内容层的 transform 应用到卡片矩形,再与画布矩形求交。 */ +function visibleCanvasCards( + canvas: HTMLElement, + view: { offsetX: number; offsetY: number; scale: number }, +) { + return [...canvas.querySelectorAll('.game-lineage-node')].filter((node) => { + const card = node as HTMLElement; + const left = view.offsetX + Number.parseFloat(card.style.left) * view.scale; + const top = view.offsetY + Number.parseFloat(card.style.top) * view.scale; + const width = Number.parseFloat(card.style.width) * view.scale; + const height = Number.parseFloat(card.style.minHeight) * view.scale; + return ( + left + width > 0 && + top + height > 0 && + left < canvas.clientWidth && + top < canvas.clientHeight + ); + }); +} + describe('GameDetailPage 共创模块(文档对齐)', () => { it('世代/授权标签按文档口径渲染,并显示累计衍生作品数', async () => { getGameMock.mockResolvedValueOnce( @@ -3311,102 +3309,87 @@ describe('GameDetailPage 共创模块(文档对齐)', () => { expect(screen.queryByText(/累计创作世代数/u)).toBeNull(); }); - it('血缘溯源条按「根 → 1 代 → 当前」渲染,点中间一代可跳该作品', async () => { - getGameMock.mockResolvedValueOnce(createGame({ id: 'game-current' })); - getGameLineageMock.mockResolvedValueOnce({ - 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, + it('血缘图搬到正文之后的整宽区块:侧栏共创卡里不再有图', async () => { + getGameMock.mockResolvedValueOnce( + createGame({ + id: 'game-current', + lineage: { + rootGameId: 'game-root', parentGameId: 'game-mid', - }), - ], - truncated: false, - }); - const onOpenGameDetail = vi.fn(); + parentTitle: '一代作品', + parentAuthorName: '一代作者', + generation: 2, + }, + }), + ); + getGameLineageMock.mockResolvedValue(buildChainDataset()); render( , ); - const strip = await screen.findByLabelText('创作血缘链'); - expect(strip.textContent).toContain('根原创'); - expect(strip.textContent).toContain('一代作品'); - expect(strip.textContent).toContain('当前作品'); - // 顺序必须是 根 → 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', + const block = (await screen.findByLabelText('创作血缘')).closest( + '.game-lineage-block', ) as HTMLElement; - expect(currentNode.getAttribute('aria-current')).toBe('true'); - expect(currentNode.textContent).toContain('当前作品'); - // 点击根节点跳该作品;点当前作品不跳(本就是当前页)。 - fireEvent.click( - screen.getByRole('button', { name: /0 代原创《根原创》/u }), + // ① 图不在侧栏:共创卡里没有任何树渲染(画布 / 节点卡片 / 大纲都没有)。 + const card = document.querySelector( + '.game-detail-side .game-detail-info-card .game-fork-card', + ) as HTMLElement; + expect(card).toBeTruthy(); + expect(card.querySelector('.game-lineage-canvas')).toBeNull(); + expect(card.querySelector('.game-lineage-node')).toBeNull(); + expect(card.querySelector('.lineage-outline')).toBeNull(); + // 侧栏卡保留:世代 / 授权标签、概览数据、四个入口。 + expect(within(card).getByText('2 代三创')).toBeTruthy(); + expect( + within(card).getByRole('button', { name: '查看创作族谱' }), + ).toBeTruthy(); + expect( + within(card).getByRole('button', { name: '一键 Fork 共创' }), + ).toBeTruthy(); + // ② 图在**正文之后**:`.game-detail-page` 的直接子级,且排在正文容器之后。 + const page = document.querySelector('.game-detail-page') as HTMLElement; + const children = [...page.children]; + expect(children.indexOf(block)).toBeGreaterThan( + children.findIndex((element) => + element.classList.contains('game-detail-display'), + ), ); - expect(onOpenGameDetail).toHaveBeenCalledWith('game-root'); - 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); + // 内嵌族谱页自己拉一次 /lineage:等树真的渲染出来再断言归属。 + const blockCanvas = (await within(block).findByLabelText( + '创作族谱树', + )) as HTMLElement; + expect(blockCanvas.querySelectorAll('.game-lineage-node').length).toBe(3); + expect( + within(block).getByRole('heading', { name: '这部作品的血缘图' }), + ).toBeTruthy(); + expect( + within(block).getByRole('button', { name: '全屏查看族谱' }), + ).toBeTruthy(); }); - 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, - }); + it('区块默认以根为作用域,并把当前作品定位进视口', async () => { + // jsdom 不做布局:给画布一个真机尺寸,初始适配与定位才有可断言的数值。 + const widthSpy = vi + .spyOn(Element.prototype, 'clientWidth', 'get') + .mockReturnValue(1280); + const heightSpy = vi + .spyOn(Element.prototype, 'clientHeight', 'get') + .mockReturnValue(720); try { - getGameMock.mockResolvedValueOnce(createGame({ id: 'game-current' })); - getGameLineageMock.mockResolvedValueOnce(buildChainDataset()); - const first = render( + getGameMock.mockResolvedValueOnce( + createGame({ + id: 'game-current', + lineage: { rootGameId: 'game-root', parentGameId: 'game-mid' }, + }), + ); + getGameLineageMock.mockResolvedValue(buildChainDataset()); + render( { onOpenGameDetail={vi.fn()} />, ); - 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 block = (await screen.findByLabelText('创作血缘')) as HTMLElement; + const canvas = (await within(block).findByLabelText( + '创作族谱树', + )) as HTMLElement; + await within(block).findByText('根原创'); + // 作用域是**根**:母版节点就在树里(不是以当前作品为顶的局部树)。 + expect(canvas.textContent).toContain('根原创'); + const view = readCanvasViewport(canvas); + expect(Math.round(view.scale * 100)).not.toBe(100); + const visible = visibleCanvasCards(canvas, view); + expect(visible).toContain( + canvas.querySelector('.game-lineage-node--anchor'), ); - const next = await screen.findByLabelText('创作血缘链'); - await waitFor(() => expect(next.scrollTop).toBe(300)); + expect(visible.length).toBeGreaterThanOrEqual(2); } finally { - if (scrollHeightDescriptor) { - Object.defineProperty( - Element.prototype, - 'scrollHeight', - scrollHeightDescriptor, - ); - } - if (clientHeightDescriptor) { - Object.defineProperty( - Element.prototype, - 'clientHeight', - clientHeightDescriptor, - ); - } + widthSpy.mockRestore(); + heightSpy.mockRestore(); } }); - it('血缘链节点卡片:标题不逐字换行、容器不横向滚动(防退化)', async () => { - const css = await import('node:fs').then((fs) => - fs.readFileSync( - 'src/components/game-distribution/gameDistribution.css', - 'utf8', - ), + it('窄断点:正文区块走大纲形态且不横向溢出', async () => { + setViewportWidth(390); + getGameMock.mockResolvedValueOnce( + createGame({ + id: 'game-current', + lineage: { rootGameId: 'game-root', parentGameId: 'game-mid' }, + }), ); - // 防退化:这几条是「窄容器里不被挤成竖排」的根因,改坏了必须变红。 - 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()); + getGameLineageMock.mockResolvedValue(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('创作血缘链'); + const block = (await screen.findByLabelText('创作血缘')) as HTMLElement; + const outline = await within(block).findByLabelText('创作族谱大纲'); + expect(block.querySelector('.game-lineage-canvas')).toBeNull(); + // 大纲是全宽行 + 左缩进:绝不横向滚。 + expect(outline.scrollWidth <= outline.clientWidth).toBe(true); + // 图仍然只有一套渲染:大纲行里的节点内容复用同一个组件。 expect( - scroll.querySelectorAll('.game-lineage-node__content--inline').length, + outline.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 ca3535af0..cda7a793e 100644 --- a/src/components/game-distribution/GameLineagePage.tsx +++ b/src/components/game-distribution/GameLineagePage.tsx @@ -44,7 +44,7 @@ type GameLineagePageProps = { * 缺失时退回标记根节点(直接打开族谱链接的场景)。 */ highlightGameId?: string | null; - onBack: () => void; + onBack?: () => void; onOpenDetail: (gameId: string) => void; /** * 内嵌模式:隐藏页面外壳(返回按钮 / 页头 / 脚注),只渲染可视化树本身。 @@ -436,60 +436,85 @@ export function GameLineagePage({ [layout.height, layout.nodes.length, layout.width], ); - // 初次进入(或换了根作品)自动适配一次;之后交给用户自己缩放/平移,不抢视野。 + /** + * 初次进入的定位:`mode='both'`(桌面:宽高同时满足并居中)/ `mode='width'`(窄屏图谱视图: + * 整棵树横向看完)。两者都会把**当前作品**用最小位移拉进视口——详情页内嵌的整宽血缘区块 + * 与独立族谱页都用它,进入时就能看到「我这一支」,不必自己找。 + * + * 为什么按形态分开:窄屏默认是大纲形态,画布**根本没挂载**(`viewportRef.current` 为 null), + * 桌面那条自动适配其实什么都没做;用户切到图谱视图时画布是新挂载的,会停在 + * `scale 1 / offset 0`——实测一个节点都看不到。所以窄屏在「进入图谱视图」时再定位一次。 + */ + const fitToInitialView = useCallback( + (mode: 'both' | 'width') => { + const viewportElement = viewportRef.current; + if (!viewportElement || layout.nodes.length === 0) return; + const width = viewportElement.clientWidth; + const height = viewportElement.clientHeight; + const availableWidth = Math.max(1, width - FIT_PADDING * 2); + const availableHeight = Math.max(1, height - FIT_PADDING * 2); + const target = + mode === 'width' + ? availableWidth / Math.max(layout.width, 1) + : Math.min( + availableWidth / Math.max(layout.width, 1), + availableHeight / Math.max(layout.height, 1), + ); + // 全览会缩到低于可读下限时,改用可读缩放:信息先看得清,全览交给「适应窗口」按钮。 + const floored = mode === 'both' && target < READABLE_MIN_SCALE; + const scale = Math.min( + MAX_SCALE, + Math.max(MIN_SCALE, floored ? READABLE_MIN_SCALE : target), + ); + setFitHint( + floored + ? '树较大,已按可读缩放显示:拖拽平移查看,或点「适应窗口」看全树。' + : scale > target + 0.001 + ? '树较宽,已按最小缩放显示:拖拽平移查看。' + : '', + ); + // 水平居中:内容宽度已含左侧代际轴,避免右侧留一大片空白。 + let offsetX = Math.max(FIT_PADDING, (width - layout.width * scale) / 2); + let offsetY = FIT_PADDING; + const anchorNode = layout.nodes.find((item) => item.gameId === anchorId); + if (anchorNode && width > 0 && height > 0) { + const left = offsetX + anchorNode.x * scale; + const right = left + anchorNode.width * scale; + const top = offsetY + anchorNode.y * scale; + const bottom = top + anchorNode.height * scale; + // 只做**最小位移**把当前作品拉进视口(贴到最近的边),不做粗暴对中—— + // 这样父代 / 子代 / 兄弟仍在视口里,用户一眼能看到上下文。 + if (left < FIT_PADDING) offsetX += FIT_PADDING - left; + else if (right > width - FIT_PADDING) { + offsetX -= right - (width - FIT_PADDING); + } + if (top < FIT_PADDING) offsetY += FIT_PADDING - top; + else if (bottom > height - FIT_PADDING) { + offsetY -= bottom - (height - FIT_PADDING); + } + } + setViewport({ scale, offsetX, offsetY }); + }, + [anchorId, layout.height, layout.nodes, layout.width], + ); + + // 初次进入(或换了根作品 / 锚点 / 可见节点)自动适配一次;之后交给用户自己缩放/平移,不抢视野。 const fittedForRef = useRef(''); useEffect(() => { if (layout.nodes.length === 0) return; - const key = `${normalizedRootId}:${layout.nodes.length}:${layout.width}`; + const key = `${normalizedRootId}:${layout.nodes.length}:${layout.width}:${anchorId}`; if (fittedForRef.current === key) return; fittedForRef.current = key; - fitToView(); - }, [fitToView, layout.nodes.length, layout.width, normalizedRootId]); + fitToInitialView('both'); + }, [ + anchorId, + fitToInitialView, + layout.nodes.length, + layout.width, + normalizedRootId, + ]); - /** - * 窄屏「图谱视图」的初次定位:按**宽度**适配(整棵树横向看完),再把当前作品拉进视口。 - * - * 为什么单独一条:窄屏默认是大纲形态,画布**根本没挂载**(`viewportRef.current` 为 null), - * 上面那次自动适配其实什么都没做;用户切到图谱视图时画布是新挂载的,会停在 - * `scale 1 / offset 0`——实测一个节点都看不到。桌面仍然是「宽高同时满足 + 居中」, - * 不受这里影响。 - */ - const focusMobileCanvasOnAnchor = useCallback(() => { - const viewportElement = viewportRef.current; - if (!viewportElement || layout.nodes.length === 0) return; - const width = viewportElement.clientWidth; - const height = viewportElement.clientHeight; - const availableWidth = Math.max(1, width - FIT_PADDING * 2); - const fitWidth = availableWidth / Math.max(layout.width, 1); - const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, fitWidth)); - setFitHint( - scale > fitWidth + 0.001 - ? '树较宽,已按最小缩放显示:拖拽平移查看。' - : '', - ); - let offsetX = Math.max(FIT_PADDING, (width - layout.width * scale) / 2); - let offsetY = FIT_PADDING; - const anchorNode = layout.nodes.find((item) => item.gameId === anchorId); - if (anchorNode && width > 0 && height > 0) { - const left = offsetX + anchorNode.x * scale; - const right = left + anchorNode.width * scale; - const top = offsetY + anchorNode.y * scale; - const bottom = top + anchorNode.height * scale; - // 只做**最小位移**把当前作品拉进视口(贴到最近的边),不做粗暴对中—— - // 这样父代 / 兄弟仍在视口里,用户一眼能看到上下文。 - if (left < FIT_PADDING) offsetX += FIT_PADDING - left; - else if (right > width - FIT_PADDING) { - offsetX -= right - (width - FIT_PADDING); - } - if (top < FIT_PADDING) offsetY += FIT_PADDING - top; - else if (bottom > height - FIT_PADDING) { - offsetY -= bottom - (height - FIT_PADDING); - } - } - setViewport({ scale, offsetX, offsetY }); - }, [anchorId, layout.nodes, layout.width]); - - // 每次真正「进入图谱视图」(或换了根作品 / 锚点)定位一次;筛选、缩放、平移不抢视野。 + // 窄屏「进入图谱视图」时按宽度适配并定位一次;筛选、缩放、平移不抢视野。 const mobileFocusedForRef = useRef(''); useEffect(() => { if (effectiveViewMode !== 'canvas' || !isMobileViewport) return; @@ -497,11 +522,11 @@ export function GameLineagePage({ const key = `${normalizedRootId}:${layout.nodes.length}:${layout.width}:${anchorId}`; if (mobileFocusedForRef.current === key) return; mobileFocusedForRef.current = key; - focusMobileCanvasOnAnchor(); + fitToInitialView('width'); }, [ anchorId, effectiveViewMode, - focusMobileCanvasOnAnchor, + fitToInitialView, isMobileViewport, layout.nodes.length, layout.width, diff --git a/src/components/game-distribution/gameDistribution.css b/src/components/game-distribution/gameDistribution.css index 1e6c76d26..2f4f9c93e 100644 --- a/src/components/game-distribution/gameDistribution.css +++ b/src/components/game-distribution/gameDistribution.css @@ -695,6 +695,35 @@ max-width: none; margin: 0; } + +/* + * 详情页**正文之后**的整宽血缘图区块(需求 2026-10-06:图可能很大,~230px 的侧栏塞不下)。 + * 区块内嵌族谱页组件(embedded),所以这里只需要管「区块自己的标题 + 全屏入口」这一段。 + */ +.game-lineage-block { + display: flex; + flex-direction: column; + gap: 0.6rem; + margin-top: 1.4rem; + min-width: 0; +} +.game-lineage-block__heading { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: 0.5rem; +} +.game-lineage-block__title { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; +} +.game-lineage-block__title h2 { + margin: 0; + font-size: 1.05rem; +} .game-detail-info-card .game-fork-theme-list { display: flex; flex-wrap: wrap; @@ -764,71 +793,6 @@ color: var(--platform-text-soft); cursor: not-allowed; } -/* - * 详情页共创卡里的血缘链(竖向单链):根在最上、当前作品在最下。 - * 容器窄(右栏 ~220–260px)→ **只允许纵向滚动**,横向裁掉,避免被挤成逐字换行; - * 首屏与当前作品变化时由组件滚到底部(当前游戏可见)。 - */ -.game-detail-info-card .co-creation-chain { - display: flex; - flex-direction: column; - gap: 0.25rem; - min-width: 0; -} -.game-detail-info-card .co-creation-chain__scroll { - display: flex; - flex-direction: column; - gap: 0; - max-height: 20rem; - overflow-y: auto; - overflow-x: hidden; - padding-right: 0.15rem; - border: 1px solid var(--platform-surface-border); - border-radius: 0.6rem; - background: var(--platform-surface-muted, rgba(104, 54, 36, 0.03)); -} -.game-detail-info-card .co-creation-chain__row { - display: flex; - flex-direction: column; - min-width: 0; -} -/* 父→子短竖线:主干链节的连线加粗、用主题色(与桌面「主干线粗实线」同语义)。 */ -.game-detail-info-card .co-creation-chain__connector { - width: 2px; - height: 0.7rem; - margin-left: 1.6rem; - background: var(--platform-surface-border); -} -.game-detail-info-card .co-creation-chain__connector--trunk { - width: 4px; - background: var(--platform-accent-strong); -} -.game-detail-info-card .co-creation-chain__node { - display: block; - width: 100%; - min-width: 0; - border: 1px solid transparent; - border-radius: 0.5rem; - padding: 0.3rem 0.4rem; - background: transparent; - color: inherit; - font: inherit; - text-align: left; - cursor: pointer; -} -.game-detail-info-card .co-creation-chain__node:hover { - background: rgba(199, 101, 61, 0.08); -} -.game-detail-info-card .co-creation-chain__node--current { - border-color: var(--platform-accent-strong); - background: rgba(199, 101, 61, 0.12); -} -.game-detail-info-card .co-creation-chain__notice, -.game-detail-info-card .co-creation-chain__legend { - color: var(--platform-text-soft); - font-size: 0.7rem; - overflow-wrap: anywhere; -} /* 热门衍生栏:横向卡片。 */ .game-detail-info-card .co-creation-hot { display: grid; @@ -1344,9 +1308,6 @@ font-size: 0.6rem; padding: 0.05rem 0.35rem; } -.game-detail-info-card .co-creation-chain__node { - min-height: 2.75rem; -} /* 卡片主体 = 选中;标题/封面 = 打开详情。封面做主视觉(大图 + 圆角 + 轻阴影)。 */ .game-lineage-node {