详情页:血缘图搬到正文之后的整宽区块(内嵌族谱页复用),侧栏卡只留标签/概览/入口
Project CI / Backend tests (pull_request) Failing after 28s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 5m45s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m55s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m58s
Project CI / Repository checks (pull_request) Failing after 16s
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

This commit is contained in:
2026-10-07 11:00:30 +08:00
parent 70bba0f7b2
commit 7301d675ae
6 changed files with 317 additions and 449 deletions
@@ -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=<root>&from=<当前作品>`。 */
onOpenFullView?: (rootGameId: string, fromGameId?: string) => void;
}) {
return (
<section className="game-lineage-block" aria-label="创作血缘">
<div className="game-lineage-block__heading">
<div className="game-lineage-block__title">
<span className="game-eyebrow">创作血缘</span>
<h2>这部作品的血缘图</h2>
</div>
{onOpenFullView ? (
<button
type="button"
className="game-fork-lineage-link"
onClick={() => onOpenFullView(rootGameId, currentGameId)}
>
全屏查看族谱
</button>
) : null}
</div>
<GameLineagePage
embedded
rootGameId={rootGameId}
highlightGameId={currentGameId}
onOpenDetail={onOpenGameDetail}
/>
</section>
);
}
export default CoCreationLineageSection;
@@ -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=<root>`)。
*/
export function CoCreationLineageStrip({
chain,
currentGameId,
onOpenGameDetail,
}: {
/** 从根到当前作品的顺序列表。 */
chain: readonly GameDistributionLineageNode[];
currentGameId: string;
onOpenGameDetail: (gameId: string) => void;
}) {
const scrollRef = useRef<HTMLDivElement>(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<number, GameDistributionLineageNode>();
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 (
<div className="co-creation-chain">
<div
className="co-creation-chain__scroll"
ref={scrollRef}
onScroll={handleScroll}
aria-label="创作血缘链"
tabIndex={0}
>
{chain.map((node, index) => {
const isCurrent = node.gameId === currentGameId;
return (
<span key={node.gameId} className="co-creation-chain__row">
{index > 0 ? (
<span
className={`co-creation-chain__connector${
trunkGameIds.has(node.gameId) ? ' co-creation-chain__connector--trunk' : ''
}`}
aria-hidden="true"
/>
) : null}
<button
type="button"
className={`co-creation-chain__node${
isCurrent ? ' co-creation-chain__node--current' : ''
}`}
aria-current={isCurrent ? 'true' : undefined}
aria-label={`${generationLabel(node.generation)}《${node.title}》· ${
node.authorName?.trim() || '未署名作者'
}`}
onClick={() => {
if (!isCurrent) onOpenGameDetail(node.gameId);
}}
>
<LineageNodeContent
layout="inline"
title={node.title}
authorName={node.authorName ?? null}
generation={node.generation}
coverObjectKey={node.coverObjectKey}
isAnchor={isCurrent}
isTrunk={trunkGameIds.has(node.gameId)}
/>
</button>
</span>
);
})}
</div>
{hiddenAboveCount > 0 ? (
<span className="co-creation-chain__notice" role="status">
↑ 还有 {hiddenAboveCount} 代(共 {chain.length} 代)
</span>
) : null}
{ancestors.length > 0 ? (
<span className="co-creation-chain__legend">
根在最上、当前作品在最下,可纵向滚动
</span>
) : null}
</div>
);
}
function generationLabel(generation: number) {
return generation <= 0 ? '0 代原创' : `${generation} 代`;
}
export default CoCreationLineageStrip;
@@ -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 ? (
<CoCreationLineageStrip
chain={lineageChain}
currentGameId={game.id}
onOpenGameDetail={onOpenGameDetail}
/>
) : null}
{derivedNodes.length > 0 && onOpenGameDetail ? (
<CoCreationHotDerived
nodes={derivedNodes}
@@ -1193,6 +1194,19 @@ function GameDetailContent({
</section>
) : null}
</GameDetailDisplay>
{/*
血缘图放在**正文之后**(需求 2026-10-06):图可能很大,~230px 的侧栏只够画一条链。
整宽区块内嵌的就是族谱页组件(`GameLineagePage embedded`),不新建第二套树渲染;
侧栏共创卡保留世代 / 授权 / 累计数据与四个入口(含「查看创作族谱」全屏入口)。
*/}
{showLineageBlock && onOpenGameDetail ? (
<CoCreationLineageSection
rootGameId={lineage?.rootGameId ?? game.id}
currentGameId={game.id}
onOpenGameDetail={onOpenGameDetail}
onOpenFullView={onOpenLineage}
/>
) : null}
<GameReviews
state={reviews}
canReview={canReview}
@@ -3096,46 +3096,6 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐)
expect(await screen.findByLabelText('创作族谱大纲')).toBeTruthy();
});
/** 读画布视口状态:内容层的 `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
);
});
}
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(
<GameDetailPage
gameId="game-current"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenGameDetail={onOpenGameDetail}
onOpenGameDetail={vi.fn()}
onOpenLineage={vi.fn()}
/>,
);
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(
<GameDetailPage
gameId="game-current"
onBack={vi.fn()}
@@ -3414,60 +3397,36 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
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(
<GameDetailPage
gameId="game-mid"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenGameDetail={vi.fn()}
/>,
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(
<GameDetailPage
gameId="game-current"
@@ -3477,34 +3436,15 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
/>,
);
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(
<GameDetailPage
gameId="game-current"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenGameDetail={vi.fn()}
/>,
);
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 () => {
@@ -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,
@@ -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 {