详情页:血缘图搬到正文之后的整宽区块(内嵌族谱页复用),侧栏卡只留标签/概览/入口
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
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user