族谱大纲/画布版式:角标成组不叠印 + 内容列限宽居中 + 缩略图 16:9 + 卡片等高;节点显示游玩次数子树合计(▶N)
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (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 12:37:12 +08:00
parent 76ee18133e
commit a7f781834a
6 changed files with 528 additions and 54 deletions
@@ -34,6 +34,7 @@ import { GamePlayPage } from './GamePlayPage';
import { GameThemePage } from './GameThemePage';
import { GameThemesPage } from './GameThemesPage';
import { resolveNonPublicCoverLabel } from './gameVersionHistoryModel';
import { LINEAGE_COVER_ASPECT } from './lineageTreeLayout';
const listGamesMock = vi.hoisted(() => vi.fn());
const setGameCollectionMock = vi.hoisted(() => vi.fn());
@@ -1956,7 +1957,7 @@ describe('原版与二创对比入口', () => {
expect(screen.queryByRole('button', { name: '与原版对比' })).toBeNull();
});
it('树节点只保留代际/标题/作者/Fork:不渲染游玩数与对比入口', async () => {
it('树节点:显示游玩次数**子树合计**角标(▶ N),仍不渲染「与原版对比」', async () => {
getGameLineageMock.mockResolvedValueOnce({
rootGameId: 'game-root',
root: createLineageNode({
@@ -1964,6 +1965,7 @@ describe('原版与二创对比入口', () => {
title: '母版',
generation: 0,
parentGameId: null,
playCount: 1,
}),
nodes: [
createLineageNode({
@@ -1971,18 +1973,21 @@ describe('原版与二创对比入口', () => {
title: '母版',
generation: 0,
parentGameId: null,
playCount: 1,
}),
createLineageNode({
gameId: 'game-leaf',
title: '叶节点',
generation: 1,
parentGameId: 'game-root',
playCount: 4,
}),
createLineageNode({
gameId: 'game-orphan',
title: '孤儿节点',
generation: 1,
parentGameId: 'game-missing',
playCount: 7,
}),
],
truncated: false,
@@ -1998,8 +2003,24 @@ describe('原版与二创对比入口', () => {
);
expect(await screen.findByText('叶节点')).toBeTruthy();
// 树节点只保留「代际 + 标题 + 作者 + Fork」:游玩数与「与原版对比」留在详情页,
// 树里不再重复渲染(6 个节点还行,多了会吵)。
// 口径变更(2026-10-07,用户口径):**节点也显示游玩次数**,显示值 = 该节点的**子树合计**
// (`collectLineagePlayCountTotals().total`),不是自身值。
const rootCard = await cardOf('母版');
// 母版合计 = 自身 1 + 叶节点 4 = 5(孤儿节点挂在缺失的父下,不算它的后代)。
expect(
rootCard.querySelector('.game-lineage-node__play-count')?.textContent,
).toBe('▶ 5');
const leafCard = await cardOf('叶节点');
expect(
leafCard.querySelector('.game-lineage-node__play-count')?.textContent,
).toBe('▶ 4');
// 角标的 title / aria-label 说明它**含衍生**,避免把合计误解成自身。
const chip = rootCard.querySelector(
'.game-lineage-node__play-count',
) as HTMLElement;
expect(chip.getAttribute('title')).toContain('含衍生作品');
expect(chip.getAttribute('aria-label')).toContain('含衍生作品');
// 「与原版对比」仍然留在详情页,不在树里重复渲染。
expect(screen.queryByRole('button', { name: '与原版对比' })).toBeNull();
expect(screen.queryByText(/次游玩/u)).toBeNull();
expect(screen.getByRole('button', { name: 'Fork 叶节点' })).toBeTruthy();
@@ -2451,6 +2472,37 @@ function buildChainDataset() {
* 父代 / 子代数据集:root → mid → current(锚点)→ child1/child2(直接子代),
* child1 → grandchild(孙代,不算「直接子代」);root → uncle 是旁支。
*/
/** 演示树形状(`共创演示·A0`):A0 → A1/A2/A3;A1 → A11/A12;A11 → A111;A2 → A21,全部 0 热度。 */
const demoShapeTree = {
rootGameId: 'A0',
root: createLineageNode({
gameId: 'A0',
title: '母版甲',
generation: 0,
parentGameId: null,
playCount: 0,
}),
nodes: [
['A0', '母版甲', 0, null],
['A1', '一代甲', 1, 'A0'],
['A2', '一代乙', 1, 'A0'],
['A3', '一代丙', 1, 'A0'],
['A11', '二代甲', 2, 'A1'],
['A12', '二代乙', 2, 'A1'],
['A21', '二代丙', 2, 'A2'],
['A111', '三代甲', 3, 'A11'],
].map(([gameId, title, generation, parentGameId]) =>
createLineageNode({
gameId: gameId as string,
title: title as string,
generation: generation as number,
parentGameId: parentGameId as string | null,
playCount: 0,
}),
),
truncated: false,
};
const relationTree = {
rootGameId: 'game-root',
root: createLineageNode({
@@ -2731,6 +2783,171 @@ describe('GameLineagePage 可视化与交互', () => {
);
});
it('大纲行角标成组、绝不叠印(代际/主干/当前作品/关系同组,CSS 里不再有各自的绝对定位)', async () => {
setViewportWidth(375);
getGameLineageMock.mockResolvedValueOnce(demoShapeTree);
render(
<GameLineagePage
rootGameId="A0"
highlightGameId="A111"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const outline = await screen.findByLabelText('创作族谱大纲');
const rows = [...outline.querySelectorAll('.lineage-outline__row')];
expect(rows.length).toBe(8);
for (const row of rows) {
const badges = row.querySelector('.game-lineage-node__badges');
expect(badges).toBeTruthy();
// 角标组的直接子元素 = 全部角标(没有任何角标被留在封面里各排各的)。
for (const chip of [...badges!.children]) {
expect(chip.parentElement).toBe(badges);
}
expect(
row.querySelector(
'.game-lineage-node__cover > .game-lineage-node__badge',
),
).toBeNull();
expect(
row.querySelector(
'.game-lineage-node__cover > .game-lineage-node__trunk',
),
).toBeNull();
}
// 主干在大纲里也出现(A0 / A1 / A11 / A111 是各自代的主干),且不再与代际角标各占各的绝对位置。
expect(
[...outline.querySelectorAll('.game-lineage-node__trunk')].length,
).toBe(4);
// jsdom 不做布局(拿不到真实几何),所以这里钉的是**结构 + CSS 契约**:
// 角标组内一律静态定位,旧的「代际左上 / 主干右上」绝对定位必须已被删除。
const css = await import('node:fs').then((fs) =>
fs.readFileSync(
'src/components/game-distribution/gameDistribution.css',
'utf8',
),
);
expect(css).toContain('.game-lineage-node__badges {');
expect(css).toMatch(
/\.game-lineage-node__badges \.game-lineage-node__badge,[\s\S]{0,600}?position: static;/u,
);
expect(css).not.toMatch(
/\.game-lineage-node__badge \{[^}]*?position: absolute/u,
);
expect(css).not.toMatch(
/\.game-lineage-node__trunk \{[^}]*?position: absolute/u,
);
});
it('大纲限宽居中、操作留在内容列右端;缩略图与画布统一 16:9', async () => {
setViewportWidth(375);
getGameLineageMock.mockResolvedValueOnce(demoShapeTree);
render(
<GameLineagePage
rootGameId="A0"
highlightGameId="A111"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const outline = await screen.findByLabelText('创作族谱大纲');
const rows = [...outline.querySelectorAll('.lineage-outline__row')];
expect(rows.length).toBe(8);
// 操作是行的**最后一个子元素**(在行内 = 内容列右端),不会飘到屏幕最右。
for (const row of rows) {
expect(row.lastElementChild?.className).toContain(
'lineage-outline__actions',
);
}
// 内容列限宽 + 居中:宽屏不再整屏出血。
const css = await import('node:fs').then((fs) =>
fs.readFileSync(
'src/components/game-distribution/gameDistribution.css',
'utf8',
),
);
expect(css).toMatch(/\.lineage-outline \{[\s\S]{0,500}?max-width: 54rem;/u);
expect(css).toMatch(/\.lineage-outline \{[\s\S]{0,500}?margin: 0 auto;/u);
// 缩略图 16:9(与画布同一比例),不再被角标撑成窄竖条。
expect(css).toMatch(
/\.game-lineage-node__content--inline \.game-lineage-node__cover \{[\s\S]{0,300}?aspect-ratio: 16 \/ 9;/u,
);
expect(LINEAGE_COVER_ASPECT).toBeCloseTo(16 / 9, 5);
});
it('初始适配:装得下就全览、装不下就退回可读缩放并明确提示', async () => {
// 情形 1:视口够大 → 宽高同时 fit,所有可见节点都在视口内、无提示。
const wideWidth = vi
.spyOn(Element.prototype, 'clientWidth', 'get')
.mockReturnValue(1600);
const wideHeight = vi
.spyOn(Element.prototype, 'clientHeight', 'get')
.mockReturnValue(1200);
try {
getGameLineageMock.mockResolvedValueOnce(demoShapeTree);
const first = render(
<GameLineagePage
rootGameId="A0"
highlightGameId="A111"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const canvas = (await screen.findByLabelText(
'创作族谱树',
)) as HTMLElement;
await waitFor(() =>
expect(canvas.querySelectorAll('.game-lineage-node').length).toBe(8),
);
const view = readCanvasViewport(canvas);
expect(visibleCanvasCards(canvas, view).length).toBe(8);
expect(screen.queryByText(/其余代际在视口外/u)).toBeNull();
first.unmount();
// 情形 2:视口装不下全览(低于可读下限)→ 停在可读缩放 + 明确提示,不静默丢代际。
wideWidth.mockReturnValue(420);
wideHeight.mockReturnValue(320);
getGameLineageMock.mockResolvedValueOnce(demoShapeTree);
render(
<GameLineagePage
rootGameId="A0"
highlightGameId="A111"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const smallCanvas = (await screen.findByLabelText(
'创作族谱树',
)) as HTMLElement;
await waitFor(() =>
expect(smallCanvas.querySelectorAll('.game-lineage-node').length).toBe(
8,
),
);
const smallView = readCanvasViewport(smallCanvas);
const visible = visibleCanvasCards(smallCanvas, smallView);
const floored = Math.abs(smallView.scale - 0.6) < 0.001;
// 二选一(都不允许「既没全览又没提示」):要么全部可见,要么触底可读缩放 + 提示出现。
expect(floored || visible.length === 8).toBe(true);
if (floored) {
expect(
screen.getByText(
'树较大:已按可读缩放显示(其余代际在视口外),拖拽平移查看,或点「适应窗口」看全树。',
),
).toBeTruthy();
// 触底时仍然把当前作品放进视口(不是把它也丢出去)。
expect(visible).toContain(
smallCanvas.querySelector('.game-lineage-node--anchor'),
);
}
} finally {
wideWidth.mockRestore();
wideHeight.mockRestore();
}
});
it('节点是真实 DOM 卡片:主体点击=选中、标题/打开作品=进详情、当前作品有标记且父在上子在下', async () => {
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
const onOpenDetail = vi.fn();
@@ -3593,6 +3810,69 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐)
expect(outline.querySelectorAll('.lineage-outline__row').length).toBe(7);
});
it('大纲行显示游玩次数**子树合计**(▶ N),与角标同行、不进文字块、不额外占行', async () => {
setViewportWidth(375);
getGameLineageMock.mockResolvedValueOnce(tree);
const first = render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const outline = await screen.findByLabelText('创作族谱大纲');
const rowOf = (title: string) =>
[...outline.querySelectorAll('.lineage-outline__row')].find((row) =>
row.textContent?.includes(title),
) as HTMLElement;
const chipOf = (title: string) =>
rowOf(title).querySelector(
'.game-lineage-node__badges > .game-lineage-node__play-count',
) as HTMLElement;
// 显示的是**合计**(自身 + 全部后代),不是自身值:
// 热门二创自身 999、子代 深代作品 3 → ▶ 1002;冷门二创自身 1 → ▶ 1;母版 5+1002+1 → ▶ 1008。
expect(chipOf('热门二创').textContent).toBe('▶ 1002');
expect(chipOf('冷门二创').textContent).toBe('▶ 1');
expect(chipOf('根原创').textContent).toBe('▶ 1008');
expect(chipOf('深代作品').textContent).toBe('▶ 3');
// 角标说明含衍生(读屏 + 悬停都能看到口径)。
expect(chipOf('热门二创').getAttribute('title')).toContain('含衍生作品');
expect(chipOf('热门二创').getAttribute('aria-label')).toBe(
'游玩次数(含衍生作品)1002',
);
// 与角标同一组(同一行),**不在**文字块里 → 不会给它单开一行。
expect(
rowOf('热门二创').querySelector(
'.game-lineage-node__text .game-lineage-node__play-count',
),
).toBeNull();
// 全 0 也显示(列稳定、不会一会儿有一会儿没有),并且同样只在角标组里。
first.unmount();
getGameLineageMock.mockResolvedValueOnce(demoShapeTree);
const zero = render(
<GameLineagePage
rootGameId="A0"
highlightGameId="A111"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const zeroOutline = await zero.findByLabelText('创作族谱大纲');
const zeroChips = [
...zeroOutline.querySelectorAll(
'.lineage-outline__row .game-lineage-node__badges > .game-lineage-node__play-count',
),
];
expect(zeroChips.length).toBe(8);
for (const chip of zeroChips) {
expect(chip.textContent).toBe('▶ 0');
}
zero.unmount();
});
it('窄屏仍能点进详情页与查看完整族谱入口(大纲模式不丢入口)', async () => {
setViewportWidth(375);
getGameLineageMock.mockResolvedValueOnce(tree);
@@ -91,6 +91,7 @@ function LineageTreeNodeCard({
isPinned,
compact,
relation,
playCount,
onOpenDetail,
onActivate,
onDeactivate,
@@ -107,6 +108,8 @@ function LineageTreeNodeCard({
compact: boolean;
/** 相对「当前作品」的角色:父代 / 子代 / 自己 / 旁支(打 `data-relation` + 角标)。 */
relation: LineageRelationRole;
/** 游玩次数**子树合计**(显示口径):紧凑角标 `▶ N`,不额外占一行。 */
playCount: LineagePlayCountBreakdown | null;
onOpenDetail: (gameId: string) => void;
onActivate: (gameId: string) => void;
onDeactivate: () => void;
@@ -198,6 +201,7 @@ function LineageTreeNodeCard({
isTrunk={layoutNode.isTrunk}
compact={compact}
relation={relation}
playCount={playCount}
/>
</button>
<span className="game-lineage-node__actions">
@@ -316,6 +320,16 @@ export function GameLineagePage({
}, [loadLineage]);
const nodes = useMemo(() => lineage?.nodes ?? [], [lineage]);
/**
* 游玩次数**显示**口径 = 该节点的**子树合计**(父代展示自己 + 全部后代,见 §3.6.3)。
* 同一棵树只算一次:既给节点角标(`▶ N`),也回传给宿主(主题页母版行)。
* **注意**:主干线与「按热度排序」的判据仍是**自身** `playCount`(服务端字段原值),
* 与这里的显示口径**不同**——两处注释互相指认,别改错。
*/
const playCountTotals = useMemo(
() => collectLineagePlayCountTotals(nodes),
[nodes],
);
/**
* 树一变就把「每个节点的游玩次数分解」交给宿主(主题页的母版行要显示子树合计)。
* 回调存在 ref 里,所以宿主不必包 `useCallback`,也不会因为回调换身份而反复触发。
@@ -325,8 +339,8 @@ export function GameLineagePage({
useEffect(() => {
const callback = playCountTotalsCallbackRef.current;
if (!callback || !lineage) return;
callback(collectLineagePlayCountTotals(lineage.nodes));
}, [lineage]);
callback(playCountTotals);
}, [lineage, playCountTotals]);
// 高亮锚点(= 「当前作品」,也是「父代 / 子代」的基准)必须早于筛选链路声明。
const anchorId = highlightGameId?.trim() || normalizedRootId;
/**
@@ -516,14 +530,14 @@ export function GameLineagePage({
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 fitWidth = availableWidth / Math.max(layout.width, 1);
const fitBoth = Math.min(
fitWidth,
availableHeight / Math.max(layout.height, 1),
);
const target = mode === 'width' ? fitWidth : fitBoth;
// 低于可读下限时不强行全览:用可读缩放 + 平移,并把「还有代际在视口外」写进提示
//(不静默把最深的代际丢在视口外)。
const floored = mode === 'both' && target < READABLE_MIN_SCALE;
const scale = Math.min(
MAX_SCALE,
@@ -531,14 +545,14 @@ export function GameLineagePage({
);
setFitHint(
floored
? '树较大,已按可读缩放显示:拖拽平移查看,或点「适应窗口」看全树。'
? '树较大:已按可读缩放显示(其余代际在视口外),拖拽平移查看,或点「适应窗口」看全树。'
: scale > target + 0.001
? '树较宽,已按最小缩放显示:拖拽平移查看。'
: '',
);
// 水平居中:内容宽度已含左侧代际轴,避免右侧留一大片空白。
// 水平与垂直都按内容居中(内容比视口小时不会把空白全堆在下边/右边)。
let offsetX = Math.max(FIT_PADDING, (width - layout.width * scale) / 2);
let offsetY = FIT_PADDING;
let offsetY = Math.max(FIT_PADDING, (height - layout.height * scale) / 2);
const anchorNode = layout.nodes.find((item) => item.gameId === anchorId);
if (anchorNode && width > 0 && height > 0) {
const left = offsetX + anchorNode.x * scale;
@@ -960,6 +974,7 @@ export function GameLineagePage({
trunkGameIds={trunkGameIds}
selectedGameId={pinnedGameId}
relationByGameId={relationRoles}
playCountByGameId={playCountTotals}
onSelect={(gameId) =>
setPinnedGameId((current) =>
current === gameId ? '' : gameId,
@@ -1028,6 +1043,9 @@ export function GameLineagePage({
relation={
relationRoles.get(layoutNode.gameId) ?? 'side'
}
playCount={
playCountTotals.get(layoutNode.gameId) ?? null
}
onTogglePin={setPinnedGameId}
onOpenDetail={onOpenDetail}
onActivate={setActiveGameId}
@@ -1,6 +1,9 @@
import type { GameDistributionGame } from '../../services/gameDistributionClient';
import { GameCover } from './GameCover';
import type { LineageRelationRole } from './lineageTreeLayout';
import type {
LineagePlayCountBreakdown,
LineageRelationRole,
} from './lineageTreeLayout';
import { generationAxisLabel } from './lineageTreeLayout';
/**
@@ -23,6 +26,7 @@ export function LineageNodeContent({
isTrunk = false,
compact = false,
relation = 'side',
playCount = null,
}: {
title: string | null;
authorName: string | null;
@@ -40,6 +44,11 @@ export function LineageNodeContent({
* 是可读文本而不是只靠颜色,因此可断言。
*/
relation?: LineageRelationRole;
/**
* 该节点的游玩次数**子树合计**(`collectLineagePlayCountTotals` 的结果)。
* 传了就显示紧凑角标 `▶ N`(`title` 说明含衍生);`null` / 不传则不显示(拿不到树时)。
*/
playCount?: LineagePlayCountBreakdown | null;
}) {
const displayTitle = isStub ? '原作品已不可用' : (title ?? '未命名作品');
const authorLabel = isStub
@@ -53,35 +62,67 @@ export function LineageNodeContent({
coverObjectKey,
} as unknown as GameDistributionGame;
/**
* 角标组:代际 / 主干 / 当前作品 / 父代-子代 **一律**放在同一个 flex 容器里。
*
* 之前这几枚角标各自 `position: absolute`(代际左上、主干右上、当前作品在流内),
* 在窄卡里会**互相叠印**(大纲行里 `第 1 代` 与 `主干` 叠成乱码字形)。改成「同一组、
* 有 gap 的 flex 行」后,重叠在结构上不可能发生,画布与大纲共用同一组角标。
*/
const badges = (
<span className="game-lineage-node__badges">
<span className="game-lineage-node__badge">
{generationAxisLabel(generation)}
</span>
{isTrunk ? (
<span className="game-lineage-node__trunk" title="该代热度最高的版本">
主干
</span>
) : null}
{isAnchor ? (
<span className="game-lineage-node__anchor">当前作品</span>
) : null}
{relation === 'ancestor' || relation === 'descendant' ? (
// 「父代 / 子代」是可读文本角标(不是只靠描边颜色),所以能被断言、也能被读屏。
<span
className={`game-lineage-node__relation game-lineage-node__relation--${relation}`}
data-relation={relation}
>
{relation === 'ancestor' ? '父代' : '子代'}
</span>
) : null}
{playCount ? (
/*
游玩次数 = **该节点的子树合计**(`collectLineagePlayCountTotals().total`,口径见 §3.6.3)。
紧凑呈现:与角标同行的小 pill(▶ 数字),`title`/`aria-label` 说明它**含衍生作品**,
避免把「父代显示自己 + 全部后代」误解成「这一个作品自己有多少人玩」。
全 0 也显示(列稳定,不会一会儿有一个字一会儿没有)。
注意:**主干线 / 热度排序的判据仍是自身 playCount**,与这里的显示口径不同。
*/
<span
className="game-lineage-node__play-count"
title={`游玩次数(含衍生作品):自身 ${playCount.own} + 衍生 ${playCount.inherited}`}
aria-label={`游玩次数(含衍生作品)${playCount.total}`}
>
▶ {playCount.total}
</span>
) : null}
</span>
);
return (
<span
className={`game-lineage-node__content game-lineage-node__content--${layout}`}
>
<span className="game-lineage-node__cover">
<GameCover game={coverGame} objectKey={coverObjectKey} />
<span className="game-lineage-node__badge">
{generationAxisLabel(generation)}
</span>
{isTrunk ? (
<span className="game-lineage-node__trunk" title="该代热度最高的版本">
主干
</span>
) : null}
{isAnchor ? (
<span className="game-lineage-node__anchor">当前作品</span>
) : null}
{/* 画布卡片:角标组作为封面上的浮层(整组一起排,不会各自乱定位)。 */}
{layout === 'stacked' ? badges : null}
</span>
{/* 大纲行:角标组独立成列(在封面右侧、操作左侧),所以不会挤进封面里叠字。 */}
{layout === 'inline' ? badges : null}
<span className="game-lineage-node__text">
<span className="game-lineage-node__title">{displayTitle}</span>
{relation === 'ancestor' || relation === 'descendant' ? (
// 「父代 / 子代」是可读文本角标(不是只靠描边颜色),所以能被断言、也能被读屏。
<span
className={`game-lineage-node__relation game-lineage-node__relation--${relation}`}
data-relation={relation}
>
{relation === 'ancestor' ? '父代' : '子代'}
</span>
) : null}
{/*
作者名在两种形态都渲染(画布卡片曾因重构一度漏掉,导致节点少了作者信息、
也踩到了 e2e 的作者名断言):只有窄屏图谱的紧凑卡片才省掉它。
@@ -1,5 +1,8 @@
import { LineageNodeContent } from './LineageNodeContent';
import type { LineageRelationRole } from './lineageTreeLayout';
import type {
LineagePlayCountBreakdown,
LineageRelationRole,
} from './lineageTreeLayout';
/**
* 移动端**大纲式缩进树**(默认形态):全宽行 + 左缩进 + 肘形导线。
@@ -17,6 +20,7 @@ export function LineageOutlineList({
trunkGameIds,
selectedGameId,
relationByGameId,
playCountByGameId,
onSelect,
onOpenDetail,
onOpenFork,
@@ -35,6 +39,8 @@ export function LineageOutlineList({
selectedGameId: string;
/** 每个节点相对「当前作品」的角色(父代 / 子代 / 自己 / 旁支):与画布同一份口径。 */
relationByGameId: ReadonlyMap<string, LineageRelationRole>;
/** 每个节点的游玩次数**子树合计**(显示口径,与画布同一份数据):紧凑角标 `▶ N`。 */
playCountByGameId: ReadonlyMap<string, LineagePlayCountBreakdown>;
onSelect: (gameId: string) => void;
onOpenDetail: (gameId: string) => void;
onOpenFork: (gameId: string) => void;
@@ -76,6 +82,8 @@ export function LineageOutlineList({
node.isStub
? '标题与作者名不对外展示'
: node.authorName?.trim() || '未署名作者'
}· 游玩次数(含衍生作品)${
playCountByGameId.get(node.gameId)?.total ?? 0
}`}
onClick={() => onSelect(node.gameId)}
>
@@ -89,6 +97,7 @@ export function LineageOutlineList({
isStub={node.isStub}
isTrunk={trunkGameIds.has(node.gameId)}
relation={relation}
playCount={playCountByGameId.get(node.gameId) ?? null}
/>
</button>
{node.isStub ? null : (
@@ -1014,15 +1014,10 @@
.game-lineage-edge--trunk {
stroke-width: 6;
}
/* 「主干」角标:只给颜色,定位/尺寸由角标组(.game-lineage-node__badges)统一管。 */
.game-lineage-node__trunk {
position: absolute;
top: 0.35rem;
right: 0.35rem;
border-radius: 999px;
padding: 0.1rem 0.45rem;
background: var(--platform-accent-strong);
color: #fff;
font-size: 0.64rem;
font-weight: 700;
}
/* 右侧信息面板:浮动叠加在画布右上(控件在右下,避免互相遮挡)。 */
@@ -1263,11 +1258,58 @@
.game-lineage-node__content--inline {
flex-direction: row;
align-items: center;
gap: 0.45rem;
gap: 0.5rem;
width: 100%;
min-width: 0;
padding: 0.15rem 0;
}
/*
* 角标组:代际 / 主干 / 当前作品 / 父代-子代 的**唯一排版容器**(flex + gap + 可换行)。
*
* 为什么必须成组:这几枚角标以前各自 position: absolute(代际左上、主干右上、「当前作品」在流内),
* 在窄容器里会叠印成乱码字形(大纲行的「第 1 代」与「主干」就是这个问题)。
* 现在它们同组排布 —— 重叠在结构上不可能发生,画布与大纲共用同一组角标。
*/
.game-lineage-node__badges {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem;
min-width: 0;
}
/* 组内角标一律静态定位(颜色 / 圆角仍由各自规则给)。 */
.game-lineage-node__badges .game-lineage-node__badge,
.game-lineage-node__badges .game-lineage-node__trunk,
.game-lineage-node__badges .game-lineage-node__anchor,
.game-lineage-node__badges .game-lineage-node__play-count,
.game-lineage-node__badges .game-lineage-node__relation {
position: static;
top: auto;
right: auto;
bottom: auto;
left: auto;
margin: 0;
border-radius: 999px;
padding: 0.05rem 0.4rem;
font-size: 0.6rem;
line-height: 1.6;
white-space: nowrap;
}
/* 画布卡片:整组角标作为封面左上角的浮层(一组一起排,不再各自定位)。 */
.game-lineage-node__content--stacked .game-lineage-node__badges {
position: absolute;
top: 0.3rem;
left: 0.3rem;
right: 0.3rem;
justify-content: flex-start;
z-index: 1;
}
/* 大纲行:角标组在封面右侧、操作左侧,靠右对齐,标题占满剩余空间。 */
.game-lineage-node__content--inline .game-lineage-node__badges {
flex: 0 1 auto;
justify-content: flex-end;
}
.game-lineage-node__text {
display: flex;
flex-direction: column;
@@ -1285,6 +1327,16 @@
overflow-wrap: anywhere;
word-break: normal;
}
/*
* 画布卡片:标题**固定 2 行高度**(1 行也占满 2 行)。
* 目的:同一层的卡片等高(连线端点对齐),1 行标题与 2 行标题不再把卡片高度拉得参差。
* 大纲行不受影响(那里的行高由 `min-height: 3.5rem` 统一)。
*/
.game-lineage-node__content--stacked .game-lineage-node__title {
/* 2 行标题的自然高度 ≈ 3.2em(实测),1 行标题也占满它 → 同层卡片等高。 */
min-height: 3.2em;
}
.game-lineage-node__meta {
display: flex;
min-width: 0;
@@ -1299,7 +1351,9 @@
/* 链节点:横版小缩略图(64×36),矮而宽,触控目标 ≥44px。 */
.game-lineage-node__content--inline .game-lineage-node__cover {
flex: 0 0 auto;
width: 4rem;
width: 4.5rem;
height: auto;
/* 与画布统一的 16:9 横版缩略图(曾在大纲行里被角标撑成窄竖条)。 */
aspect-ratio: 16 / 9;
border-radius: 0.35rem;
}
@@ -1329,6 +1383,18 @@
text-align: left;
cursor: pointer;
}
/*
* 游玩次数角标:**该节点的子树合计**(`collectLineagePlayCountTotals().total`,口径见 §3.6.3)。
* 紧凑 pill(▶ N)+ `title` 说明「含衍生作品」,与其它角标同行所以**不撑高卡片**;全 0 也显示。
* 注意:主干线 / 热度排序的判据是**自身** playCount,与这个显示口径不同。
*/
.game-lineage-node__play-count {
background: rgba(0, 0, 0, 0.62);
color: #fff;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.game-lineage-node__cover {
position: relative;
display: block;
@@ -1348,15 +1414,10 @@
height: 100%;
border-radius: 0;
}
/* 代际角标:只给颜色,定位/尺寸由角标组统一管。 */
.game-lineage-node__badge {
position: absolute;
top: 0.35rem;
left: 0.35rem;
border-radius: 999px;
padding: 0.1rem 0.5rem;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 0.68rem;
font-weight: 700;
}
.game-lineage-node__strip {
@@ -2346,11 +2407,13 @@
display: flex;
flex-direction: column;
gap: 0.15rem;
margin: 0;
/* 内容列限宽 + 居中:大纲不再整屏出血(宽屏上曾是 1300px+ 横条,中间大片空白)。
操作按钮固定在行右端 = 内容列右端,不会飘到屏幕最右侧。 */
margin: 0 auto;
padding: 0;
list-style: none;
width: 100%;
max-width: 100%;
max-width: 54rem;
overflow-x: hidden;
}
.lineage-outline__row {
@@ -2359,6 +2422,8 @@
gap: 0.2rem;
min-width: 0;
max-width: 100%;
/* 统一基线:所有行同高(缩略图 16:9 + 标题最多 2 行),卡片高度不再参差。 */
min-height: 3.5rem;
box-sizing: border-box;
}
.lineage-outline__guide {
@@ -2410,6 +2475,8 @@
display: flex;
flex: 0 0 auto;
gap: 0.25rem;
/* 与标题同一视觉组:贴文字右侧、行内右端,不跨出限宽内容列。 */
margin-left: 0.25rem;
}
/* 大纲行里的标题:单行省略(不逐字换行)。 */
.lineage-outline .game-lineage-node__title {
@@ -517,6 +517,65 @@ describe('主干线判据 = 自身 playCount(不是子树合计)', () => {
});
});
describe('主干唯一性(playCount 全 0 的演示树,输入顺序 = 服务端稳定序)', () => {
/**
* 演示树形状(`共创演示·A0` 那棵):A0 → A1/A2/A3;A1 → A11/A12;A11 → A111;
* A2 → A21。全部 `playCount = 0`(演示数据没有播放)。
* 服务端 `/lineage` 契约保证「代际升序、同代按创建时间升序」,这里按该顺序喂入。
*/
const demoTree = [
node('A0', 0, null),
node('A1', 1, 'A0'),
node('A2', 1, 'A0'),
node('A3', 1, 'A0'),
node('A11', 2, 'A1'),
node('A12', 2, 'A1'),
node('A21', 2, 'A2'),
node('A111', 3, 'A11'),
].map((item) => ({ ...item, playCount: 0 }));
it('每代恰好一个主干(并列 0 热度时取同代中创建时间最早的),A2/A3 不是主干', () => {
const layout = buildLineageTreeLayout(demoTree);
const trunkByGeneration = new Map<number, string[]>();
for (const item of layout.nodes) {
if (!item.isTrunk) continue;
const bucket = trunkByGeneration.get(item.generation) ?? [];
bucket.push(item.gameId);
trunkByGeneration.set(item.generation, bucket);
}
// 每代最多一个(这就是「主干泛滥」的反例断言)。
for (const [generation, ids] of trunkByGeneration) {
expect(ids, `第 ${generation} 代的主干`).toHaveLength(1);
}
expect(trunkByGeneration.get(0)).toEqual(['A0']);
expect(trunkByGeneration.get(1)).toEqual(['A1']);
expect(trunkByGeneration.get(2)).toEqual(['A11']);
expect(trunkByGeneration.get(3)).toEqual(['A111']);
const byId = new Map(layout.nodes.map((item) => [item.gameId, item]));
expect(byId.get('A2')!.isTrunk).toBe(false);
expect(byId.get('A3')!.isTrunk).toBe(false);
expect(byId.get('A12')!.isTrunk).toBe(false);
expect(byId.get('A21')!.isTrunk).toBe(false);
});
it('同代并列零热度:结果与输入顺序绑定(稳定、不随渲染变化)', () => {
// 同一棵树跑两次必须完全一致;把同代顺序换一下,主干跟着换成更早创建的那个。
const first = buildLineageTreeLayout(demoTree);
const second = buildLineageTreeLayout(demoTree);
expect(first.nodes.map((item) => item.isTrunk)).toEqual(
second.nodes.map((item) => item.isTrunk),
);
const reordered = buildLineageTreeLayout([
node('A0', 0, null),
{ ...node('A2', 1, 'A0'), playCount: 0 },
{ ...node('A1', 1, 'A0'), playCount: 0 },
]);
const byId = new Map(reordered.nodes.map((item) => [item.gameId, item]));
expect(byId.get('A2')!.isTrunk).toBe(true);
expect(byId.get('A1')!.isTrunk).toBe(false);
});
});
describe('collectLineageHighlight', () => {
const tree = buildLineageTreeLayout([
node('root', 0, null),