族谱树可视化(2/2):节点-连线树、视野控制与节点交互
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

- 渲染分工:连线与代际轴在一个 SVG 里(坐标即内容层坐标,随内容层缩放);节点是绝对定位的真实 HTML 卡片——要真的可点击/可聚焦/可读屏,不能画成 SVG 矩形
- 交互:拖拽平移(pointer + touch-action:none)、滚轮缩放、放大/缩小按钮(0.35~1.8)、适应窗口、初次进入自动适配一次;hover/键盘聚焦高亮祖先路径 + 整棵子树(可勾选关闭子树),其余节点与连线降透明;当前作品常驻 aria-current 与「当前作品」标记
- 可达性:role=button + tabIndex=0,可访问名形如「第 1 代作品《标题》· 作者」;Enter/Space 等价点击;节点内 Fork 是独立 button 并 stopPropagation,不抢主点击;Fork 复用详情页同一条唤起 + 降级链路(openForkProtocol + FORK_LAUNCH_FALLBACK_DELAY_MS + 复制作品 ID 回退)
- 不可用父节点:布局补占位节点(只有 ID 与代际),标题即「原作品已不可用」,副行写「标题与作者名不对外展示」,不渲染作者名与 Fork/对比动作;可聚焦、不可点进详情(role 为空 + aria-disabled)
- 边界:截断提示、母版不可用提示、空树空态、加载/失败态照既有组件;超宽扇出与超深链由布局保证不重叠(单测钉住)
- 样式:画布 clamp + 点阵背景、连线活跃/降透明、prefers-reduced-motion 关闭过渡、窄屏可横向平移不溢出;删除上一版分组列表的死样式
- 测试:新增 5 条交互/可视化用例,另把两处旧可访问名查询改为 /《母版》/
This commit is contained in:
2026-10-06 14:09:46 +08:00
parent 7fbcf1f017
commit 1f85af8668
4 changed files with 850 additions and 182 deletions
@@ -399,9 +399,7 @@ describe('GameDetailPage', () => {
expect(screen.getByText('由 原作者 制作')).toBeTruthy();
expect(screen.getByText('已被改编 3 次')).toBeTruthy();
// 已公开 + 授权开放 → 「Fork 这个作品」入口出现(M1 时它还不存在,这里钉住新契约)。
expect(
screen.getByRole('button', { name: 'Fork 这个作品' }),
).toBeTruthy();
expect(screen.getByRole('button', { name: 'Fork 这个作品' })).toBeTruthy();
});
it('旧数据缺少授权与血缘字段时只按禁止共创渲染', async () => {
@@ -436,9 +434,7 @@ describe('GameDetailPage', () => {
);
expect(await screen.findByText('允许全开放共创')).toBeTruthy();
expect(
screen.getByText('第 2 代作品 · 原作品已不可用'),
).toBeTruthy();
expect(screen.getByText('第 2 代作品 · 原作品已不可用')).toBeTruthy();
expect(screen.queryByText(/改编自/u)).toBeNull();
// 血缘作者行(卡内 `.game-fork-lineage__author`)不该出现;hero 里的
// 「由 测试作者 制作」是作品自身署名,不在断言范围内。
@@ -1134,7 +1130,7 @@ describe('GameLineagePage', () => {
// 父作品不在可见集合里:只标注「原作品已不可用」,不回显父作品内容。
expect(screen.getByText('原作品已不可用')).toBeTruthy();
// 任一公开节点都能进入对应详情页。
fireEvent.click(screen.getByRole('button', { name: '母版' }));
fireEvent.click(screen.getByRole('button', { name: /《母版》/u }));
expect(onOpenDetail).toHaveBeenCalledWith('game-root');
});
@@ -1222,13 +1218,13 @@ describe('GameLineagePage', () => {
/>,
);
expect(await screen.findByRole('button', { name: '母版' })).toBeTruthy();
expect(
await screen.findByRole('button', { name: /《母版》/u }),
).toBeTruthy();
expect(
screen.getByText('这部作品还没有衍生作品,欢迎从它开始共创。'),
).toBeTruthy();
expect(
screen.queryByText('这部作品还没有可展示的族谱节点。'),
).toBeNull();
expect(screen.queryByText('这部作品还没有可展示的族谱节点。')).toBeNull();
});
it('超出节点上限时如实标注截断,不静默丢弃', async () => {
@@ -1240,7 +1236,11 @@ describe('GameLineagePage', () => {
generation: 0,
}),
nodes: [
createLineageNode({ gameId: 'game-root', title: '母版', generation: 0 }),
createLineageNode({
gameId: 'game-root',
title: '母版',
generation: 0,
}),
createLineageNode({ gameId: 'game-2', title: '第二部', generation: 1 }),
],
truncated: true,
@@ -1336,11 +1336,7 @@ describe('GameLineagePage', () => {
it('缺少根作品编号时不发请求并提示编号无效', async () => {
render(
<GameLineagePage
rootGameId=""
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
<GameLineagePage rootGameId="" onBack={vi.fn()} onOpenDetail={vi.fn()} />,
);
expect(await screen.findByText('游戏编号无效')).toBeTruthy();
@@ -1435,9 +1431,7 @@ describe('GameDetailPage Fork 入口', () => {
expect(copyTextToClipboardMock).toHaveBeenCalledWith(
expect.stringContaining('/games/detail?id=game-1'),
);
expect(
screen.getByRole('button', { name: '作品链接已复制' }),
).toBeTruthy();
expect(screen.getByRole('button', { name: '作品链接已复制' })).toBeTruthy();
vi.useRealTimers();
});
@@ -1628,7 +1622,9 @@ describe('原版与二创对比入口', () => {
expect(await screen.findByText('叶节点')).toBeTruthy();
// 两个节点各有一个可见父引用?只有叶节点的父(game-root)在树里。
const compareButtons = screen.getAllByRole('button', { name: '与原版对比' });
const compareButtons = screen.getAllByRole('button', {
name: '与原版对比',
});
expect(compareButtons).toHaveLength(1);
fireEvent.click(compareButtons[0]!);
expect(onOpenCompare).toHaveBeenCalledWith('game-root', 'game-leaf');
@@ -1695,7 +1691,9 @@ describe('GameComparePage', () => {
expect(await screen.findByText('作品不存在或未公开')).toBeTruthy();
expect(screen.getByText('二创作品')).toBeTruthy();
// 降级那一侧不渲染 iframe,另一侧照常。
expect(document.querySelectorAll('iframe.game-player-frame')).toHaveLength(1);
expect(document.querySelectorAll('iframe.game-player-frame')).toHaveLength(
1,
);
});
});
@@ -1866,9 +1864,9 @@ describe('GameThemePage', () => {
expect(getGameLineageMock).toHaveBeenCalledWith('game-root-1');
expect(getGameLineageMock).toHaveBeenCalledWith('game-root-2');
// 内嵌模式不渲染族谱页外壳(页头/返回按钮),但保留「全屏查看」入口。
expect(screen.getAllByRole('button', { name: '全屏查看族谱' })).toHaveLength(
2,
);
expect(
screen.getAllByRole('button', { name: '全屏查看族谱' }),
).toHaveLength(2);
fireEvent.click(
screen.getAllByRole('button', { name: '全屏查看族谱' })[0]!,
);
@@ -1980,6 +1978,202 @@ describe('GameDetailPage 共创主题入口', () => {
});
});
describe('GameLineagePage 可视化与交互', () => {
const treeWithSibling = {
rootGameId: 'game-root',
root: createLineageNode({
gameId: 'game-root',
title: '母版',
generation: 0,
parentGameId: null,
}),
nodes: [
createLineageNode({
gameId: 'game-root',
title: '母版',
generation: 0,
parentGameId: null,
}),
createLineageNode({
gameId: 'game-mid',
title: '中间代',
generation: 1,
parentGameId: 'game-root',
}),
createLineageNode({
gameId: 'game-leaf',
title: '叶节点',
generation: 2,
parentGameId: 'game-mid',
}),
createLineageNode({
gameId: 'game-other',
title: '旁支',
generation: 1,
parentGameId: 'game-root',
}),
],
truncated: false,
};
it('节点是真实 DOM 卡片:点击进详情、Enter 等价、当前作品有标记且父在上子在下', async () => {
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
const onOpenDetail = vi.fn();
render(
<GameLineagePage
rootGameId="game-root"
highlightGameId="game-leaf"
onBack={vi.fn()}
onOpenDetail={onOpenDetail}
/>,
);
const leafCard = await screen.findByRole('button', { name: /《叶节点》/u });
const rootCard = screen.getByRole('button', { name: /《母版》/u });
// 当前作品标记
expect(leafCard.getAttribute('aria-current')).toBe('true');
expect(rootCard.getAttribute('aria-current')).toBeNull();
expect(within(leafCard).getByText('当前作品')).toBeTruthy();
// 分层:子节点在父节点下方
expect(parseFloat(leafCard.style.top)).toBeGreaterThan(
parseFloat(rootCard.style.top),
);
// 贝塞尔连线
const paths = document.querySelectorAll('path.game-lineage-edge');
expect(paths.length).toBeGreaterThan(0);
expect(paths[0]!.getAttribute('d')).toContain(' C ');
// 键盘 Enter 与点击等价
fireEvent.keyDown(leafCard, { key: 'Enter' });
expect(onOpenDetail).toHaveBeenCalledWith('game-leaf');
fireEvent.click(rootCard);
expect(onOpenDetail).toHaveBeenCalledWith('game-root');
});
it('hover 一个节点高亮祖先路径与子树,其余节点降透明;移开后恢复', async () => {
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const midCard = await screen.findByRole('button', { name: /《中间代》/u });
const rootCard = screen.getByRole('button', { name: /《母版》/u });
const leafCard = screen.getByRole('button', { name: /《叶节点》/u });
const otherCard = screen.getByRole('button', { name: /《旁支》/u });
fireEvent.mouseEnter(midCard);
// 祖先(母版)+ 自身 + 子树(叶节点)都不降透明,旁支降透明。
expect(rootCard.className).not.toContain('game-lineage-node--dimmed');
expect(midCard.className).not.toContain('game-lineage-node--dimmed');
expect(leafCard.className).not.toContain('game-lineage-node--dimmed');
expect(otherCard.className).toContain('game-lineage-node--dimmed');
// 连线同样有活跃/降透明区分。
expect(
document.querySelectorAll('path.game-lineage-edge--active').length,
).toBeGreaterThan(0);
expect(
document.querySelectorAll('path.game-lineage-edge--dimmed').length,
).toBeGreaterThan(0);
fireEvent.mouseLeave(midCard);
expect(otherCard.className).not.toContain('game-lineage-node--dimmed');
expect(
document.querySelectorAll('path.game-lineage-edge--dimmed').length,
).toBe(0);
});
it('缩放按钮改变百分比,「适应窗口」重新适配', async () => {
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
await screen.findByRole('button', { name: /《母版》/u });
const zoomLabel = () => screen.getByRole('status').textContent ?? '';
const before = Number.parseInt(zoomLabel(), 10);
fireEvent.click(screen.getByRole('button', { name: '放大' }));
expect(Number.parseInt(zoomLabel(), 10)).toBeGreaterThan(before);
fireEvent.click(screen.getByRole('button', { name: '缩小' }));
expect(Number.parseInt(zoomLabel(), 10)).toBe(before);
fireEvent.click(screen.getByRole('button', { name: '适应窗口' }));
expect(zoomLabel()).toMatch(/%$/u);
});
it('节点的 Fork 次要动作不触发导航;未失焦时给出降级提示与复制入口', async () => {
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
const onOpenDetail = vi.fn();
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={onOpenDetail}
/>,
);
const leafCard = await screen.findByRole('button', { name: /《叶节点》/u });
const forkButton = within(leafCard).getByRole('button', { name: 'Fork' });
vi.useFakeTimers();
fireEvent.click(forkButton);
// 次要动作直通唤起,且不抢主点击。
expect(openForkProtocolMock).toHaveBeenCalledWith('game-leaf');
expect(onOpenDetail).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(FORK_LAUNCH_FALLBACK_DELAY_MS + 10);
});
expect(screen.getByText(/没有检测到陶泥儿客户端/u)).toBeTruthy();
fireEvent.click(
screen.getByRole('button', { name: /复制 game-leaf 去 Fork/u }),
);
await act(async () => {});
expect(copyTextToClipboardMock).toHaveBeenCalledWith('game-leaf');
vi.useRealTimers();
});
it('不可用父节点:可聚焦、不可点进详情,且不出现被删作品的标题与作者名', async () => {
getGameLineageMock.mockResolvedValueOnce({
rootGameId: 'game-root',
root: null,
nodes: [
createLineageNode({
gameId: 'game-leaf',
title: '叶节点',
generation: 1,
parentGameId: 'game-deleted',
}),
],
truncated: false,
});
const onOpenDetail = vi.fn();
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={onOpenDetail}
/>,
);
const stubTitle = await screen.findByText('原作品已不可用');
const stubCard = stubTitle.closest('.game-lineage-node') as HTMLElement;
expect(stubCard.getAttribute('tabindex')).toBe('0');
expect(stubCard.getAttribute('role')).toBeNull();
expect(stubCard.getAttribute('aria-disabled')).toBe('true');
// 被删作品只以「不可用」形式出现:没有标题、没有作者名、也没有 Fork/对比动作。
expect(document.querySelector('.game-lineage-node--stub')).toBeTruthy();
expect(screen.queryByText(/作者 game-deleted/u)).toBeNull();
expect(within(stubCard).queryByRole('button')).toBeNull();
fireEvent.click(stubCard);
expect(onOpenDetail).not.toHaveBeenCalled();
});
});
describe('GameDetailPage 收藏按钮', () => {
it('可判定为已收藏时显示「已收藏」,点击取消收藏', async () => {
getGameMock.mockResolvedValueOnce(createGame({ collected: true }));
File diff suppressed because it is too large Load Diff
@@ -723,6 +723,175 @@
color: var(--platform-accent-strong);
font-weight: 700;
}
/*
* 族谱树可视化:一个可平移/缩放的画布 + 内容层(CSS transform)。
* 连线在 SVG 里(坐标就是内容层坐标,随内容层一起缩放),节点是绝对定位的真实 HTML 卡片
* —— 因为节点要真的可点击 / 可聚焦 / 可读屏,不能画成 SVG 矩形。
*/
.game-lineage-viewport-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.game-lineage-toggle {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: var(--platform-text-soft);
font-size: 0.78rem;
}
.game-lineage-fork-notice {
color: var(--platform-text-soft);
font-size: 0.78rem;
overflow-wrap: anywhere;
}
.game-lineage-fork-copy {
border: 1px solid var(--platform-surface-border);
border-radius: 999px;
padding: 0.2rem 0.7rem;
background: transparent;
color: var(--platform-text-base);
font: inherit;
font-size: 0.74rem;
font-weight: 600;
cursor: pointer;
}
.game-lineage-canvas {
position: relative;
height: clamp(22rem, 62vh, 46rem);
max-width: 100%;
overflow: hidden;
border: 1px solid var(--platform-surface-border);
border-radius: 0.85rem;
background: radial-gradient(
circle at 1px 1px,
var(--platform-surface-border) 1px,
transparent 0
)
0 0 / 22px 22px;
cursor: grab;
/* 拖拽平移不要触发浏览器自身的滚动/缩放。 */
touch-action: none;
}
.game-lineage-canvas:active {
cursor: grabbing;
}
.game-lineage-canvas__content {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
transition: transform 180ms ease-out;
will-change: transform;
}
.game-lineage-canvas__edges {
position: absolute;
top: 0;
left: 0;
overflow: visible;
pointer-events: none;
}
.game-lineage-edge {
fill: none;
stroke: var(--platform-surface-border);
stroke-width: 2;
transition:
stroke 160ms ease,
opacity 160ms ease;
}
.game-lineage-edge--active {
stroke: var(--platform-accent-strong);
stroke-width: 2.5;
}
.game-lineage-edge--dimmed {
opacity: 0.18;
}
.game-lineage-axis {
position: absolute;
left: 0;
display: flex;
align-items: center;
gap: 0.3rem;
color: var(--platform-text-soft);
font-size: 0.74rem;
font-weight: 700;
white-space: nowrap;
}
.game-lineage-axis__count {
border-radius: 999px;
padding: 0.05rem 0.45rem;
background: var(--platform-surface-border);
color: var(--platform-text-soft);
font-size: 0.68rem;
font-weight: 600;
}
.game-lineage-node {
position: absolute;
box-sizing: border-box;
cursor: pointer;
transition:
opacity 160ms ease,
box-shadow 160ms ease;
}
.game-lineage-node--dimmed {
opacity: 0.34;
}
.game-lineage-node--stub {
border-style: dashed;
cursor: default;
}
.game-lineage-node:focus-visible {
outline: 2px solid var(--platform-accent-strong);
outline-offset: 2px;
}
.game-lineage-node__title {
color: var(--platform-text-strong);
font-size: 0.9rem;
font-weight: 700;
overflow-wrap: anywhere;
}
.game-lineage-node__actions {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.game-lineage-node__fork,
.game-lineage-node__compare {
border: 1px solid var(--platform-surface-border);
border-radius: 999px;
padding: 0.14rem 0.6rem;
background: transparent;
color: var(--platform-text-base);
font: inherit;
font-size: 0.72rem;
font-weight: 600;
cursor: pointer;
}
.game-lineage-node__fork:hover,
.game-lineage-node__compare:hover {
color: var(--platform-text-strong);
}
.game-lineage-zoom {
color: var(--platform-text-soft);
font-size: 0.76rem;
font-variant-numeric: tabular-nums;
}
/* 尊重「减少动效」偏好:只保留状态变化,不做过渡动画。 */
@media (prefers-reduced-motion: reduce) {
.game-lineage-canvas__content,
.game-lineage-node,
.game-lineage-edge {
transition: none;
}
}
/* 窄屏:节点尺寸适合触摸,画布可横向平移(内容层平移而不是页面溢出)。 */
@media (max-width: 720px) {
.game-lineage-canvas {
height: clamp(20rem, 56vh, 34rem);
}
}
.game-loading-panel {
display: grid;
min-height: 18rem;
@@ -1507,40 +1676,6 @@
font-size: 0.78rem;
cursor: pointer;
}
.game-lineage-tree {
display: flex;
flex-direction: column;
gap: 1rem;
}
.game-lineage-generation {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.game-lineage-generation__label {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin: 0;
color: var(--platform-text-strong);
font-size: 0.9rem;
font-weight: 700;
}
.game-lineage-generation__count {
border-radius: 999px;
padding: 0.05rem 0.5rem;
background: var(--platform-surface-border);
color: var(--platform-text-soft);
font-size: 0.72rem;
font-weight: 600;
}
.game-lineage-generation__nodes {
display: grid;
/* `min(13rem, 100%)`:桌面端列宽不变,视口窄于 13rem 时下限跟着收缩,
避免固定下限把内容撑出容器产生横向溢出。 */
grid-template-columns: repeat(auto-fill, minmax(min(13rem, 100%), 1fr));
gap: 0.6rem;
}
.game-lineage-node {
display: flex;
flex-direction: column;
@@ -1383,7 +1383,7 @@ describe('创作族谱', () => {
expect(gameDistributionMock.getGameLineage).toHaveBeenCalledWith(
'game-root',
);
fireEvent.click(screen.getByRole('button', { name: '母版' }));
fireEvent.click(screen.getByRole('button', { name: /《母版》/u }));
expect(setSelectionStage).toHaveBeenCalledWith('game-detail', {
path: '/games/detail?id=game-root',
});
@@ -1598,7 +1598,9 @@ describe('共创主题', () => {
/>,
);
expect(await screen.findByRole('heading', { name: '精选共创' })).toBeTruthy();
expect(
await screen.findByRole('heading', { name: '精选共创' }),
).toBeTruthy();
expect(screen.getByText('根作品')).toBeTruthy();
expect(gameDistributionMock.getTheme).toHaveBeenCalledWith('theme-1');
// 树复用既有族谱查询:按根请求一次。