diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx
index 5b02084f5..c717b8026 100644
--- a/src/components/game-distribution/GameDistributionPages.test.tsx
+++ b/src/components/game-distribution/GameDistributionPages.test.tsx
@@ -2155,7 +2155,9 @@ describe('原版与二创对比入口', () => {
// 「与原版对比」仍然留在详情页,不在树里重复渲染。
expect(screen.queryByRole('button', { name: '与原版对比' })).toBeNull();
expect(screen.queryByText(/次游玩/u)).toBeNull();
- expect(screen.getByRole('button', { name: 'Fork 叶节点' })).toBeTruthy();
+ expect(
+ screen.getByRole('button', { name: '开始共创 叶节点' }),
+ ).toBeTruthy();
});
});
@@ -3016,7 +3018,9 @@ describe('GameLineagePage 可视化与交互', () => {
.mockReturnValue(1600);
const wideHeight = vi
.spyOn(Element.prototype, 'clientHeight', 'get')
- .mockReturnValue(1200);
+ // 卡片改成信息区版式后整棵树更高(4 层 × ~400 + 间距 ≈ 1900),
+ // 要装下并在可读下限之上,视口就得足够高。
+ .mockReturnValue(1500);
try {
getGameLineageMock.mockResolvedValueOnce(demoShapeTree);
const first = render(
@@ -3080,6 +3084,124 @@ describe('GameLineagePage 可视化与交互', () => {
}
});
+ it('画布卡片:角标移到封面下方、按钮「打开作品 / 开始共创」等权重主次分明、作者行独立一行', async () => {
+ getGameLineageMock.mockResolvedValueOnce(relationTree);
+ render(
+ ,
+ );
+
+ const card = await cardOf('当前作品');
+ const content = card.querySelector('.game-lineage-node__content')!;
+ const cover = content.querySelector('.game-lineage-node__cover')!;
+ // ① 角标**不再压在封面里**:封面内没有任何角标;角标组是内容区的第二块(封面之后、文字之前)。
+ expect(cover.querySelector('.game-lineage-node__badges')).toBeNull();
+ expect(cover.querySelector('.game-lineage-node__trunk')).toBeNull();
+ const blockOrder = [...content.children].map((el) => el.className);
+ expect(blockOrder[0]).toContain('game-lineage-node__cover');
+ expect(blockOrder[1]).toContain('game-lineage-node__badges');
+ expect(blockOrder[2]).toContain('game-lineage-node__text');
+ // ② 两个等权重按钮:文案与主次类名固定(「开始共创」是本次改名)。
+ const openButton = within(card).getByRole('button', { name: '打开作品' });
+ const coCreate = within(card).getByRole('button', {
+ name: '开始共创 当前作品',
+ });
+ expect(openButton.className).toContain('game-lineage-node__open-action');
+ expect(coCreate.className).toContain('game-lineage-node__fork');
+ expect(coCreate.textContent).toBe('开始共创');
+ // ③ 作者行独立一行(`.game-lineage-node__meta > __author`),且不与标题同一节点。
+ const meta = card.querySelector('.game-lineage-node__meta')!;
+ expect(meta.querySelector('.game-lineage-node__author')).toBeTruthy();
+ expect(meta.contains(card.querySelector('.game-lineage-node__title'))).toBe(
+ false,
+ );
+ // CSS 契约(jsdom 不算样式):等权重、触控目标、主次配色、角标配色一律取自平台变量。
+ const css = await import('node:fs').then((fs) =>
+ fs.readFileSync(
+ 'src/components/game-distribution/gameDistribution.css',
+ 'utf8',
+ ),
+ );
+ expect(css).toMatch(
+ /\.game-lineage-node__actions > button \{[\s\S]{0,500}?flex: 1 1 0;/u,
+ );
+ expect(css).toMatch(
+ /\.game-lineage-node__actions > button \{[\s\S]{0,500}?min-height: 2rem;/u,
+ );
+ expect(css).toMatch(
+ /\.game-lineage-node__fork \{[\s\S]{0,300}?--platform-button-primary-solid/u,
+ );
+ expect(css).toMatch(
+ /\.game-lineage-node__open-action \{[\s\S]{0,300}?--platform-button-secondary-fill/u,
+ );
+ // 角标配色表:主干=暖色、当前作品=主色实心、父代=cool、子代=success、代际/游玩数=中性。
+ expect(css).toMatch(
+ /\.game-lineage-node__trunk \{[\s\S]{0,300}?--platform-warm-bg/u,
+ );
+ expect(css).toMatch(
+ /\.game-lineage-node__anchor \{[\s\S]{0,300}?background: var\(--platform-accent\)/u,
+ );
+ expect(css).toMatch(
+ /\.game-lineage-node__relation--ancestor \{[\s\S]{0,300}?--platform-cool-bg/u,
+ );
+ expect(css).toMatch(
+ /\.game-lineage-node__relation--descendant \{[\s\S]{0,300}?--platform-success-bg/u,
+ );
+ expect(css).toMatch(
+ /\.game-lineage-node__badge \{[\s\S]{0,300}?--platform-neutral-bg/u,
+ );
+ // 角标不再用「深色药丸浮在图上」的写法(旧的 rgba(0,0,0,.55/.62) 已删除)。
+ expect(css).not.toMatch(
+ /game-lineage-node__badge \{[\s\S]{0,200}?rgba\(0, 0, 0/u,
+ );
+ expect(css).not.toMatch(
+ /game-lineage-node__play-count \{[\s\S]{0,200}?rgba\(0, 0, 0/u,
+ );
+ });
+
+ it('大纲行:按钮改名「打开作品 / 开始共创」,共创按钮用主要(填充)样式', async () => {
+ setViewportWidth(375);
+ getGameLineageMock.mockResolvedValueOnce(relationTree);
+ render(
+ ,
+ );
+
+ const outline = await screen.findByLabelText('创作族谱大纲');
+ const row = outline.querySelector('.lineage-outline__row')!;
+ const actions = row.querySelector('.lineage-outline__actions')!;
+ expect(actions.querySelectorAll('button')).toHaveLength(2);
+ expect(
+ within(actions as HTMLElement).getByRole('button', { name: /打开/u })
+ .textContent,
+ ).toBe('打开作品');
+ const coCreate = within(actions as HTMLElement).getByRole('button', {
+ name: /开始共创/u,
+ });
+ expect(coCreate.textContent).toBe('开始共创');
+ expect(coCreate.className).toContain('lineage-outline__action--co-create');
+ // 主要/次要的视觉区分同样落在大纲行(不只画布卡片)。
+ const outlineCss = await import('node:fs').then((fs) =>
+ fs.readFileSync(
+ 'src/components/game-distribution/gameDistribution.css',
+ 'utf8',
+ ),
+ );
+ expect(outlineCss).toMatch(
+ /\.lineage-outline__action--co-create \{[\s\S]{0,200}?--platform-button-primary-solid/u,
+ );
+ // 全树的 Fork 旧文案在两种形态里都已不存在(信息面板的「Fork 此版本」不在树节点上,另见报告)。
+ expect(outline.textContent).not.toContain('Fork');
+ });
+
it('节点是真实 DOM 卡片:主体点击=选中、标题/打开作品=进详情、当前作品有标记且父在上子在下', async () => {
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
const onOpenDetail = vi.fn();
@@ -3325,7 +3447,7 @@ describe('GameLineagePage 可视化与交互', () => {
expect(zoomLabel()).toBe('35%');
});
- it('节点的 Fork 次要动作不触发导航;未失焦时给出降级提示与复制入口', async () => {
+ it('节点的「开始共创」次要动作不触发导航;未失焦时给出降级提示与复制入口', async () => {
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
const onOpenDetail = vi.fn();
render(
@@ -3338,7 +3460,7 @@ describe('GameLineagePage 可视化与交互', () => {
const leafCard = await cardOf('叶节点');
const forkButton = within(leafCard).getByRole('button', {
- name: 'Fork 叶节点',
+ name: '开始共创 叶节点',
});
vi.useFakeTimers();
fireEvent.click(forkButton);
@@ -4021,7 +4143,7 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐)
within(outline).getByRole('button', { name: '打开《根原创》详情' }),
).toBeTruthy();
expect(
- within(outline).getByRole('button', { name: 'Fork 根原创' }),
+ within(outline).getByRole('button', { name: '开始共创 根原创' }),
).toBeTruthy();
});
});
diff --git a/src/components/game-distribution/GameLineagePage.tsx b/src/components/game-distribution/GameLineagePage.tsx
index 2cdf25ead..015daf1b5 100644
--- a/src/components/game-distribution/GameLineagePage.tsx
+++ b/src/components/game-distribution/GameLineagePage.tsx
@@ -35,7 +35,9 @@ import {
collectLineageImmediateScope,
collectLineagePlayCountTotals,
collectLineageRelationRoles,
+ estimateLineageCardHeight,
generationAxisLabel,
+ LINEAGE_CARD_BASE_HEIGHT,
type LineageLayoutNode,
type LineagePlayCountBreakdown,
type LineageRelationRole,
@@ -66,6 +68,12 @@ type GameLineagePageProps = {
) => void;
};
+/**
+ * 窄屏紧凑卡片的基础高(176 宽:16:9 封面 + 角标行 + 标题 + 作者 / 操作)。
+ * 由真机实测校准(2026-10-07):1 行标题 350px、2 行 373px → 取 352(+24/行)留余量。
+ */
+const LINEAGE_COMPACT_CARD_BASE_HEIGHT = 352;
+
const MIN_SCALE = 0.35;
const MAX_SCALE = 1.8;
const FIT_PADDING = 24;
@@ -205,6 +213,10 @@ function LineageTreeNodeCard({
/>
+ {/*
+ 两个**等权重**的动作按钮:次要(描边)= 打开作品,主要(填充)= 开始共创。
+ 同一尺寸 / 同一圆角 / 同一内边距,窄卡里允许换行但绝不裁切(见 CSS)。
+ */}
>
@@ -405,26 +417,38 @@ export function GameLineagePage({
const compactTree = isMobileViewport;
/** 窄屏图谱视图用横版画布:代际信息交给节点角标,因此不渲染左侧代际轴层。 */
const horizontalCanvas = compactTree;
- const layout = useMemo(
- () =>
- buildLineageTreeLayout(
- filteredNodes,
- compactTree
- ? {
- // 紧凑态:卡片 176 宽 → 16:9 封面 99 高 + 信息条;度量仍满足「同层 ≥ columnGap」。
- nodeWidth: 176,
- nodeHeight: 148,
- columnGap: 22,
- rowGap: 88,
- axisWidth: 72,
- // 窄屏图谱视图用横版(根在左、代际向右、兄弟沿纵向排开):窄屏上比顶→下更省横向空间,
- // 纵向溢出交给滑动。代际信息由节点自带角标承担,所以横版不渲染代际轴(见下面的轴层)。
- orientation: 'horizontal',
- }
- : undefined,
- ),
- [compactTree, filteredNodes],
- );
+ const layout = useMemo(() => {
+ // 卡片高度估算(纯函数、确定性):层高 = 该层最高卡片 + rowGap,据此排下一层 ——
+ // 同层里出现一张「2 行标题」的高卡也不会被下一层压住。渲染后不再测量 DOM。
+ const cardHeights = new Map(
+ filteredNodes.map((node) => [
+ node.gameId,
+ estimateLineageCardHeight({
+ title: node.title,
+ baseHeight: compactTree
+ ? LINEAGE_COMPACT_CARD_BASE_HEIGHT
+ : LINEAGE_CARD_BASE_HEIGHT,
+ }),
+ ]),
+ );
+ return buildLineageTreeLayout(
+ filteredNodes,
+ compactTree
+ ? {
+ // 紧凑态:卡片 176 宽 → 16:9 封面 99 高 + 角标行 + 标题 + 作者 / 操作。
+ nodeWidth: 176,
+ nodeHeight: LINEAGE_COMPACT_CARD_BASE_HEIGHT,
+ columnGap: 22,
+ rowGap: 88,
+ axisWidth: 72,
+ // 窄屏图谱视图用横版(根在左、代际向右、兄弟沿纵向排开):窄屏上比顶→下更省横向空间,
+ // 纵向溢出交给滑动。代际信息由节点自带角标承担,所以横版不渲染代际轴(见下面的轴层)。
+ orientation: 'horizontal',
+ cardHeights,
+ }
+ : { cardHeights },
+ );
+ }, [compactTree, filteredNodes]);
// 高亮目标:锁定(点击「锁定路径」)优先于 hover/聚焦;锁定后即使祖先移出视口,路径仍由面包屑兜底。
const highlightTargetId = pinnedGameId || activeGameId;
const highlighted = useMemo(
@@ -1070,9 +1094,9 @@ export function GameLineagePage({
className="game-lineage-axis"
style={{
top: viewport.offsetY + entry.y * viewport.scale,
- // 行高跟着缩放走:轴上的行与画布里的行始终对齐。
- height:
- (layout.nodes[0]?.height ?? 0) * viewport.scale,
+ // 行高跟着缩放走,且取**该层层高**(层高按最高卡算):
+ // 轴上的行与画布里的行始终对齐。
+ height: entry.height * viewport.scale,
borderTopWidth: index === 0 ? 0 : 1,
}}
>
diff --git a/src/components/game-distribution/LineageNodeContent.tsx b/src/components/game-distribution/LineageNodeContent.tsx
index 2fb4d8561..b8aeece23 100644
--- a/src/components/game-distribution/LineageNodeContent.tsx
+++ b/src/components/game-distribution/LineageNodeContent.tsx
@@ -116,11 +116,12 @@ export function LineageNodeContent({
>
- {/* 画布卡片:角标组作为封面上的浮层(整组一起排,不会各自乱定位)。 */}
- {layout === 'stacked' ? badges : null}
- {/* 大纲行:角标组独立成列(在封面右侧、操作左侧),所以不会挤进封面里叠字。 */}
- {layout === 'inline' ? badges : null}
+ {/*
+ 角标组在封面**下方**(卡片信息区第一行):不再压封面、不再用深色药丸浮在图上。
+ 画布卡片与大纲行都用同一组(角标语义 / 配色 / 顺序完全一致),组内 flex + gap + 可换行。
+ */}
+ {badges}
{displayTitle}
{/*
diff --git a/src/components/game-distribution/LineageOutlineList.tsx b/src/components/game-distribution/LineageOutlineList.tsx
index ba0d21254..d857ee763 100644
--- a/src/components/game-distribution/LineageOutlineList.tsx
+++ b/src/components/game-distribution/LineageOutlineList.tsx
@@ -108,15 +108,15 @@ export function LineageOutlineList({
aria-label={`打开《${node.title}》详情`}
onClick={() => onOpenDetail(node.gameId)}
>
- 打开
+ 打开作品
)}
diff --git a/src/components/game-distribution/gameDistribution.css b/src/components/game-distribution/gameDistribution.css
index 4554d09b8..b8921e30d 100644
--- a/src/components/game-distribution/gameDistribution.css
+++ b/src/components/game-distribution/gameDistribution.css
@@ -1033,10 +1033,11 @@
.game-lineage-edge--trunk {
stroke-width: 6;
}
-/* 「主干」角标:只给颜色,定位/尺寸由角标组(.game-lineage-node__badges)统一管。 */
+/* 「主干」角标 = 暖色(强调)。定位/尺寸由角标组统一管。 */
.game-lineage-node__trunk {
- background: var(--platform-accent-strong);
- color: #fff;
+ border: 1px solid var(--platform-warm-border);
+ background: var(--platform-warm-bg);
+ color: var(--platform-warm-text);
font-weight: 700;
}
/* 右侧信息面板:浮动叠加在画布右上(控件在右下,避免互相遮挡)。 */
@@ -1297,6 +1298,12 @@
gap: 0.25rem;
min-width: 0;
}
+/*
+ * 角标配色表(每类语义一色,**全部取自平台既有 `--platform-*` 变量,不新增色值**):
+ * 代际 = 中性 | 主干 = 暖色(强调) | 当前作品 = 主色实心
+ * 父代 = cool | 子代 = success | ▶游玩数 = 中性
+ * 全部带 1px 边框(浅色卡片上也有边界);组内静态定位 + flex + gap(永不重叠)。
+ */
/* 组内角标一律静态定位(颜色 / 圆角仍由各自规则给)。 */
.game-lineage-node__badges .game-lineage-node__badge,
.game-lineage-node__badges .game-lineage-node__trunk,
@@ -1315,14 +1322,10 @@
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;
+ padding: 0.35rem 0.5rem 0;
}
/* 大纲行:角标组在封面右侧、操作左侧,靠右对齐,标题占满剩余空间。 */
.game-lineage-node__content--inline .game-lineage-node__badges {
@@ -1348,19 +1351,20 @@
}
/*
- * 画布卡片:标题**固定 2 行高度**(1 行也占满 2 行)。
- * 目的:同一层的卡片等高(连线端点对齐),1 行标题与 2 行标题不再把卡片高度拉得参差。
- * 大纲行不受影响(那里的行高由 `min-height: 3.5rem` 统一)。
+ * 画布卡片:标题按真实行数占高(1 行 = 1 行)。
+ * 「同层等高」现在由**布局**负责:`buildLineageTreeLayout` 给同层每个节点同一个 `height`
+ * (= 该层最高卡片的估算高度),卡片按 `min-height` 撑到它 —— 所以不再需要用
+ * `min-height` 把每一行标题都硬撑成 2 行(那会让所有卡片白高一行)。
*/
-.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;
gap: 0.3rem;
overflow: hidden;
+ color: var(--platform-text-muted);
+ font-size: 0.7rem;
+ font-weight: 500;
}
.game-lineage-node__author {
overflow: hidden;
@@ -1408,8 +1412,9 @@
* 注意:主干线 / 热度排序的判据是**自身** playCount,与这个显示口径不同。
*/
.game-lineage-node__play-count {
- background: rgba(0, 0, 0, 0.62);
- color: #fff;
+ border: 1px solid var(--platform-neutral-border);
+ background: var(--platform-neutral-bg);
+ color: var(--platform-text-base);
font-weight: 700;
font-variant-numeric: tabular-nums;
}
@@ -1433,10 +1438,11 @@
height: 100%;
border-radius: 0;
}
-/* 代际角标:只给颜色,定位/尺寸由角标组统一管。 */
+/* 代际角标 = 中性。定位/尺寸由角标组统一管。 */
.game-lineage-node__badge {
- background: rgba(0, 0, 0, 0.55);
- color: #fff;
+ border: 1px solid var(--platform-neutral-border);
+ background: var(--platform-neutral-bg);
+ color: var(--platform-neutral-text);
font-weight: 700;
}
.game-lineage-node__strip {
@@ -1453,22 +1459,41 @@
padding: 0.7rem 0.8rem;
min-width: 0;
}
+/* 卡片底部操作行:两个**等权重**按钮并排(等宽、同高、同圆角),窄卡允许换行但不裁切。 */
.game-lineage-node__actions {
display: flex;
flex-wrap: wrap;
- gap: 0.3rem;
+ gap: 0.35rem;
+ padding: 0.45rem 0.5rem 0.55rem;
}
-.game-lineage-node__fork {
- border: 1px solid var(--platform-surface-border);
+.game-lineage-node__actions > button {
+ flex: 1 1 0;
+ min-width: 6.4rem;
+ min-height: 2rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
border-radius: 999px;
- padding: 0.14rem 0.6rem;
- background: transparent;
- color: var(--platform-text-base);
+ padding: 0 0.6rem;
font: inherit;
- font-size: 0.72rem;
+ font-size: 0.74rem;
font-weight: 600;
cursor: pointer;
}
+/* 次要动作(描边)。 */
+.game-lineage-node__actions > .game-lineage-node__open-action {
+ border: 1px solid var(--platform-surface-border);
+ background: var(--platform-button-secondary-fill);
+ color: var(--platform-button-secondary-text);
+}
+/* 主要动作 = 开始共创(填充/着色)。 */
+.game-lineage-node__fork {
+ border: 1px solid var(--platform-button-primary-border);
+ background: var(--platform-button-primary-solid);
+ color: var(--platform-button-primary-text);
+ font: inherit;
+ cursor: pointer;
+}
.game-lineage-node__fork:hover {
color: var(--platform-text-strong);
}
@@ -2306,9 +2331,12 @@
background: var(--platform-surface-border);
color: var(--platform-text-soft);
}
+/* 「当前作品」= 主色实心(最强的一枚)。 */
.game-lineage-node__anchor {
- background: rgba(199, 101, 61, 0.16);
- color: var(--platform-accent-strong);
+ border: 1px solid var(--platform-accent);
+ background: var(--platform-accent);
+ color: var(--platform-button-primary-text);
+ font-weight: 700;
}
.game-lineage-node__title {
border: none;
@@ -2490,6 +2518,13 @@
font-weight: 600;
cursor: pointer;
}
+
+/* 大纲行的两个动作与画布卡片同一套主次:打开作品 = 描边(次要),开始共创 = 填充(主要)。 */
+.lineage-outline__action--co-create {
+ border-color: var(--platform-button-primary-border);
+ background: var(--platform-button-primary-solid);
+ color: var(--platform-button-primary-text);
+}
.lineage-outline__actions {
display: flex;
flex: 0 0 auto;
@@ -2541,13 +2576,19 @@
line-height: 1.6;
white-space: nowrap;
}
+/* 父代 = cool 色系。 */
.game-lineage-node__relation--ancestor {
- color: var(--platform-accent-strong);
- background: rgba(199, 101, 61, 0.1);
+ border: 1px solid var(--platform-cool-border);
+ background: var(--platform-cool-bg);
+ color: var(--platform-cool-text);
+ font-weight: 700;
}
+/* 子代 = success 色系。 */
.game-lineage-node__relation--descendant {
- color: var(--platform-text-base);
- background: var(--platform-surface-muted, rgba(0, 0, 0, 0.05));
+ border: 1px solid var(--platform-success-border);
+ background: var(--platform-success-bg);
+ color: var(--platform-success-text);
+ font-weight: 700;
}
/* 画布卡片按角色加描边(辅助:主表达仍是上面的角标与 data-relation)。 */
.game-lineage-node[data-relation='ancestor'] {
diff --git a/src/components/game-distribution/lineageTreeLayout.test.ts b/src/components/game-distribution/lineageTreeLayout.test.ts
index de0126fc0..966d7af25 100644
--- a/src/components/game-distribution/lineageTreeLayout.test.ts
+++ b/src/components/game-distribution/lineageTreeLayout.test.ts
@@ -11,11 +11,15 @@ import {
collectLineagePlayCountTotals,
collectLineageRelationRoles,
collectLineageSubtreeDepth,
+ estimateLineageCardHeight,
generationAxisLabel,
+ LINEAGE_CARD_BASE_HEIGHT,
+ LINEAGE_CARD_TITLE_LINE_HEIGHT,
LINEAGE_COLUMN_GAP,
LINEAGE_COVER_ASPECT,
LINEAGE_NODE_HEIGHT,
LINEAGE_NODE_WIDTH,
+ LINEAGE_ROW_GAP,
} from './lineageTreeLayout';
function node(
@@ -68,7 +72,14 @@ describe('buildLineageTreeLayout', () => {
expect(layout.nodes).toHaveLength(1);
expect(layout.edges).toHaveLength(0);
expect(layout.axis).toEqual([
- { generation: 0, label: '母版', y: 0, count: 1 },
+ {
+ generation: 0,
+ label: '母版',
+ y: 0,
+ // 层高默认取节点高(没传 cardHeights 时退回 nodeHeight)。
+ height: LINEAGE_NODE_HEIGHT,
+ count: 1,
+ },
]);
expect(layout.nodes[0]!.isStub).toBe(false);
expect(layout.width).toBeGreaterThan(LINEAGE_NODE_WIDTH);
@@ -371,6 +382,139 @@ describe('collectLineageSubtreeDepth', () => {
});
});
+describe('层高自适应:第 N 层层高 = 该层最高卡片(不重叠)', () => {
+ /**
+ * 树形状:root → mid(同层还有 sibling)→ deep1/deep2。
+ * 用 `cardHeights` 显式给 mid 一个「更高」的卡片(模拟 2 行标题),验证层高按它撑开。
+ */
+ const nodes = [
+ node('root', 0, null),
+ node('mid', 1, 'root'),
+ node('sibling', 1, 'root'),
+ node('deep1', 2, 'mid'),
+ node('deep2', 2, 'mid'),
+ ];
+
+ it('层高取该层最大卡片高度,下一层 y = 上一层 y + 层高 + rowGap', () => {
+ const heights = new Map([
+ ['root', 300],
+ ['mid', 400],
+ ['sibling', 300],
+ ['deep1', 300],
+ ['deep2', 320],
+ ]);
+ const layout = buildLineageTreeLayout(nodes, { cardHeights: heights });
+ const byId = new Map(layout.nodes.map((item) => [item.gameId, item]));
+ // 第 1 层被 400 的卡片撑开:两个兄弟都取 400(层内等高)。
+ expect(byId.get('mid')!.height).toBe(400);
+ expect(byId.get('sibling')!.height).toBe(400);
+ // 第 2 层取 320。
+ expect(byId.get('deep1')!.height).toBe(320);
+ expect(byId.get('deep2')!.height).toBe(320);
+ // y:0 → 300 + rowGap → 300 + gap + 400 + gap。
+ expect(byId.get('root')!.y).toBe(0);
+ expect(byId.get('mid')!.y).toBe(300 + LINEAGE_ROW_GAP);
+ expect(byId.get('deep1')!.y).toBe(300 + LINEAGE_ROW_GAP * 2 + 400);
+ // 代际轴跟着层高走(不再拿「第一个节点的高度」凑)。
+ expect(layout.axis.map((entry) => entry.height)).toEqual([300, 400, 320]);
+ expect(layout.axis.map((entry) => entry.y)).toEqual([
+ 0,
+ 300 + LINEAGE_ROW_GAP,
+ 300 + LINEAGE_ROW_GAP * 2 + 400,
+ ]);
+ });
+
+ it('任意两层之间不重叠:层间空隙 ≥ rowGap,且层高 ≥ 该层每张卡的高度', () => {
+ const heights = new Map([
+ ['root', 260],
+ ['mid', 420], // 同层有一张「2 行标题」的高卡
+ ['sibling', 300],
+ ['deep1', 340],
+ ['deep2', 300],
+ ]);
+ const layout = buildLineageTreeLayout(nodes, { cardHeights: heights });
+ const layers = [...new Set(layout.nodes.map((item) => item.generation))]
+ .sort((a, b) => a - b)
+ .map((generation) =>
+ layout.nodes.filter((item) => item.generation === generation),
+ );
+ for (let index = 0; index < layers.length; index += 1) {
+ const layer = layers[index]!;
+ // 层内等高
+ expect(new Set(layer.map((item) => item.height)).size).toBe(1);
+ // 层高 ≥ 该层每张卡自己的高度
+ for (const item of layer) {
+ expect(item.height).toBeGreaterThanOrEqual(heights.get(item.gameId)!);
+ }
+ const next = layers[index + 1];
+ if (!next) continue;
+ const layerBottom = layer[0]!.y + layer[0]!.height;
+ // 层间不重叠:下一层顶部至少隔 rowGap
+ expect(next[0]!.y - layerBottom).toBeGreaterThanOrEqual(LINEAGE_ROW_GAP);
+ }
+ // 内容总高覆盖最后一层
+ const lastLayer = layers.at(-1)!;
+ expect(layout.height).toBeGreaterThanOrEqual(
+ lastLayer[0]!.y + lastLayer[0]!.height,
+ );
+ });
+
+ it('没传 cardHeights(或值非法)时退回 nodeHeight,几何与旧口径一致', () => {
+ const layout = buildLineageTreeLayout(nodes);
+ const byId = new Map(layout.nodes.map((item) => [item.gameId, item]));
+ expect(byId.get('mid')!.height).toBe(LINEAGE_NODE_HEIGHT);
+ expect(byId.get('deep1')!.y).toBe(
+ (LINEAGE_NODE_HEIGHT + LINEAGE_ROW_GAP) * 2,
+ );
+ // 横版:槽位高取全局最大(否则同一槽位的兄弟会互相压住)。
+ const horizontal = buildLineageTreeLayout(nodes, {
+ orientation: 'horizontal',
+ cardHeights: new Map([['deep2', 380]]),
+ });
+ expect(new Set(horizontal.nodes.map((item) => item.height)).size).toBe(1);
+ });
+});
+
+describe('estimateLineageCardHeight(纯函数估算)', () => {
+ it('一行标题 = 基础高;两行标题多一个行高;超过两行仍按两行封顶', () => {
+ const short = estimateLineageCardHeight({ title: '共创演示·A0' });
+ expect(short).toBe(LINEAGE_CARD_BASE_HEIGHT);
+ const twoLines = estimateLineageCardHeight({
+ title: '共创演示·A1|星海工坊·深海回声|超长标题超长标题',
+ });
+ expect(twoLines).toBe(
+ LINEAGE_CARD_BASE_HEIGHT + LINEAGE_CARD_TITLE_LINE_HEIGHT,
+ );
+ const veryLong = estimateLineageCardHeight({
+ title:
+ '共创演示·A11|深海回声·潮汐秘境|这是一条远超过两行的超长标题用于封顶断言',
+ });
+ expect(veryLong).toBe(
+ LINEAGE_CARD_BASE_HEIGHT + LINEAGE_CARD_TITLE_LINE_HEIGHT,
+ );
+ });
+
+ it('占位节点与空标题都有稳定的行数(不抛错、不返回 0)', () => {
+ expect(estimateLineageCardHeight({ title: null, isStub: true })).toBe(
+ LINEAGE_CARD_BASE_HEIGHT,
+ );
+ expect(estimateLineageCardHeight({ title: ' ' })).toBe(
+ LINEAGE_CARD_BASE_HEIGHT,
+ );
+ expect(estimateLineageCardHeight({ title: null })).toBeGreaterThan(0);
+ });
+
+ it('西文标题按 0.55 折算宽度(不会因为「字符数少」把行数算少)', () => {
+ // 40 个西文字符 ≈ 22 个全角单位 → 2 行。
+ const ascii = estimateLineageCardHeight({
+ title: 'CoCreation Demo Workspace Alpha Beta Gam',
+ });
+ expect(ascii).toBe(
+ LINEAGE_CARD_BASE_HEIGHT + LINEAGE_CARD_TITLE_LINE_HEIGHT,
+ );
+ });
+});
+
describe('collectLineageAncestorChain', () => {
const nodes = [
node('root', 0, null, '根原创'),
diff --git a/src/components/game-distribution/lineageTreeLayout.ts b/src/components/game-distribution/lineageTreeLayout.ts
index cd1c9b2ce..90b2a3380 100644
--- a/src/components/game-distribution/lineageTreeLayout.ts
+++ b/src/components/game-distribution/lineageTreeLayout.ts
@@ -94,8 +94,10 @@ export type LineageLayoutEdge = {
export type LineageGenerationAxisEntry = {
generation: number;
label: string;
- /** 该代际所在行的顶部 y。 */
+ /** 该代际所在行的顶部 y(竖版 = 累计层高)。 */
y: number;
+ /** 该层的高度(= 该层卡片的最大高度);渲染代际轴时按它画行高,不再拿「第一个节点的高度」凑。 */
+ height: number;
/** 该行实际渲染的节点数(含占位节点)。 */
count: number;
};
@@ -122,8 +124,72 @@ export type LineageLayoutOptions = {
* 布局算法本身不变(只是把「行/列」这两个轴的像素映射与连线端点对调)。
*/
orientation?: 'vertical' | 'horizontal';
+ /**
+ * 每个节点卡片的高度(`estimateLineageCardHeight` 的结果)。
+ *
+ * 竖版:**第 N 层的层高 = 该层卡片的最大高度**,下一层的 y 按累计层高 + `rowGap` 排 ——
+ * 这样「同层有一张 2 行标题的高卡」也不会被下一层压住(层内所有卡片同高:都取该层最大值)。
+ * 横版:兄弟沿 y 排开,槽位高取全局最大值。
+ * 缺省(不传 / 某节点没值)退回 `nodeHeight`。
+ */
+ cardHeights?: ReadonlyMap;
};
+/**
+ * 画布卡片的高度估算(**纯函数、确定性**,布局据此算「每层多高」)。
+ *
+ * 为什么必须估算而不是等渲染后测量:布局是纯函数(要能单测、要能离线复算),
+ * 一旦依赖 `getBoundingClientRect` 就会引入抖动与「先渲染再重排」的死循环。
+ * 估算公式 = 基础高 + (标题行数 - 1) × 行高,标题行数按**字符宽度**估(CJK 记 1、其余记 0.55),
+ * 上限 2 行(与 CSS 的 `-webkit-line-clamp: 2` 一致)。
+ * 常量由**真机实测校准**(2026-10-07,演示树):1 行标题的卡 376px、2 行 399px ——
+ * 这里取 380 / 24 留 4~5px 余量,保证「估算 ≥ 实测」,层间只会更宽不会重叠。
+ */
+export const LINEAGE_CARD_BASE_HEIGHT = 380;
+/** 标题每多一行增加的高度(与 CSS 的标题行高一致)。 */
+export const LINEAGE_CARD_TITLE_LINE_HEIGHT = 24;
+/** 标题最多 2 行(CSS line-clamp 同值)。 */
+export const LINEAGE_CARD_TITLE_MAX_LINES = 2;
+/** 240 宽卡片一行大约能放下的「全角字符」数(估算用,偏小更安全)。 */
+export const LINEAGE_CARD_TITLE_CHARS_PER_LINE = 15;
+
+function titleVisualWidth(title: string) {
+ let width = 0;
+ for (const char of title) {
+ // CJK / 全角标点占一个「全角单位」,西文与半角按 0.55 折算(经验值,宁可高估行数)。
+ width +=
+ /[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe6f\uff00-\uff60]/u.test(
+ char,
+ )
+ ? 1
+ : 0.55;
+ }
+ return width;
+}
+
+/**
+ * 给定标题,估算画布卡片高度(像素)。**同一层里取最大值**就是那一层的高度
+ * (`buildLineageTreeLayout` 的 `cardHeights` 输入就是这么用的)。
+ */
+export function estimateLineageCardHeight(input: {
+ title?: string | null;
+ isStub?: boolean;
+ baseHeight?: number;
+}): number {
+ const base = input.baseHeight ?? LINEAGE_CARD_BASE_HEIGHT;
+ const title = input.isStub
+ ? '原作品已不可用'
+ : (input.title ?? '').trim() || '未命名作品';
+ const lines = Math.min(
+ LINEAGE_CARD_TITLE_MAX_LINES,
+ Math.max(
+ 1,
+ Math.ceil(titleVisualWidth(title) / LINEAGE_CARD_TITLE_CHARS_PER_LINE),
+ ),
+ );
+ return base + (lines - 1) * LINEAGE_CARD_TITLE_LINE_HEIGHT;
+}
+
export function generationAxisLabel(generation: number) {
return generation === 0 ? '母版' : `第 ${generation} 代`;
}
@@ -252,8 +318,43 @@ export function buildLineageTreeLayout(
generations.map((generation, index) => [generation, index] as const),
);
const pitch = nodeWidth + columnGap;
- const slotPitch = nodeHeight + rowGap;
const orientation = options.orientation ?? 'vertical';
+ /**
+ * 每个节点卡片的高度(调用方用 `estimateLineageCardHeight` 估算),缺省退回 `nodeHeight`。
+ * 布局**不测量 DOM**:所有几何都由这张表推出,纯函数可离线复算、可单测。
+ */
+ const cardHeightOf = (gameId: string) => {
+ const provided = options.cardHeights?.get(gameId);
+ return provided && Number.isFinite(provided) && provided > 0
+ ? provided
+ : nodeHeight;
+ };
+ // 竖版:**层高 = 该层卡片的最大高度**(层内所有卡片同高 → 同层等高,且下一层不会被压住)。
+ const layerHeightByGeneration = new Map();
+ for (const entry of entries) {
+ const height = cardHeightOf(entry.gameId);
+ const current = layerHeightByGeneration.get(entry.generation);
+ layerHeightByGeneration.set(
+ entry.generation,
+ current === undefined ? height : Math.max(current, height),
+ );
+ }
+ /** 竖版:每层顶部 y(上一层的层高 + rowGap 累计)。 */
+ const rowTopByGeneration = new Map();
+ {
+ let cursor = 0;
+ for (const generation of generations) {
+ rowTopByGeneration.set(generation, roundTo(cursor));
+ cursor +=
+ (layerHeightByGeneration.get(generation) ?? nodeHeight) + rowGap;
+ }
+ }
+ // 横版:兄弟沿 y 排开,槽位高取全局最大卡片高(否则同一槽位上的兄弟会互相压住)。
+ const slotHeight = Math.max(
+ nodeHeight,
+ ...[...new Set(entries.map((entry) => entry.gameId))].map(cardHeightOf),
+ );
+ const slotPitch = slotHeight + rowGap;
// 只在真正用到的槽位区间上铺像素:把左侧空槽位裁掉,内容因此更紧凑,
// 「整棵树居中」时右侧也不会留下大片空白。
const usedSlots = [...slotByGameId.values()];
@@ -265,6 +366,8 @@ export function buildLineageTreeLayout(
const row = rowByGeneration.get(entry.generation) ?? 0;
// 竖版:代际→y、槽位→x;横版:代际→x、槽位→y(同一套槽位算法,只换轴)。
const horizontal = orientation === 'horizontal';
+ const layerHeight =
+ layerHeightByGeneration.get(entry.generation) ?? nodeHeight;
return {
gameId: entry.gameId,
node: entry.node,
@@ -277,9 +380,10 @@ export function buildLineageTreeLayout(
: roundTo(axisWidth + (slot - minSlot) * pitch),
y: horizontal
? roundTo((slot - minSlot) * slotPitch)
- : roundTo(row * slotPitch),
+ : (rowTopByGeneration.get(entry.generation) ?? 0),
width: nodeWidth,
- height: nodeHeight,
+ // 层内同高:竖版取该层最大高度、横版取全局最大高度(卡片按 min-height 撑到同高)。
+ height: horizontal ? slotHeight : layerHeight,
isStub: entry.node === null,
branchIndex: branchByGameId.get(entry.gameId) ?? 0,
isTrunk: false,
@@ -376,23 +480,28 @@ export function buildLineageTreeLayout(
const axis: LineageGenerationAxisEntry[] = generations.map((generation) => ({
generation,
label: generationAxisLabel(generation),
- y: roundTo((rowByGeneration.get(generation) ?? 0) * (nodeHeight + rowGap)),
+ y: rowTopByGeneration.get(generation) ?? 0,
+ height: layerHeightByGeneration.get(generation) ?? nodeHeight,
count: layoutNodes.filter((node) => node.generation === generation).length,
}));
const maxSlotCount = slotByGameId.size > 0 ? maxSlot - minSlot + 1 : 0;
const horizontal = orientation === 'horizontal';
+ // 竖版纵向总高 = 各层层高累加 + 每层的行间距(层高按最高卡算,见上面的 layerHeightByGeneration)。
+ const verticalHeight = generations.reduce(
+ (sum, generation) =>
+ sum + (layerHeightByGeneration.get(generation) ?? nodeHeight) + rowGap,
+ 0,
+ );
return {
nodes: layoutNodes,
edges,
axis,
- // 竖版:横向由槽位数决定、纵向由代际数决定;横版反过来(代际沿横向推进)。
+ // 竖版:横向由槽位数决定、纵向由代际层高决定;横版反过来(代际沿横向推进)。
width: roundTo(
axisWidth + (horizontal ? generations.length : maxSlotCount) * pitch,
),
- height: roundTo(
- (horizontal ? maxSlotCount : generations.length) * slotPitch,
- ),
+ height: roundTo(horizontal ? maxSlotCount * slotPitch : verticalHeight),
};
}