族谱树可视化(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
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:
@@ -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');
|
||||
// 树复用既有族谱查询:按根请求一次。
|
||||
|
||||
Reference in New Issue
Block a user