diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index e9a9552a2..a155c98e8 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -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( - , + , ); 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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 })); diff --git a/src/components/game-distribution/GameLineagePage.tsx b/src/components/game-distribution/GameLineagePage.tsx index edee00007..9333af468 100644 --- a/src/components/game-distribution/GameLineagePage.tsx +++ b/src/components/game-distribution/GameLineagePage.tsx @@ -1,23 +1,42 @@ import './gameDistribution.css'; -import { ArrowLeft, RefreshCcw } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { ArrowLeft, Expand, Minus, Plus, RefreshCcw } from 'lucide-react'; +import { + type PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type WheelEvent as ReactWheelEvent, +} from 'react'; import { - type GameDistributionLineageNode, type GameDistributionLineageResponse, getGameLineage, } from '../../services/gameDistributionClient'; +import { CopyFeedbackButton } from '../common/CopyFeedbackButton'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformEmptyState } from '../common/PlatformEmptyState'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; +import { useCopyFeedback } from '../common/useCopyFeedback'; +import { + FORK_LAUNCH_FALLBACK_DELAY_MS, + openForkProtocol, +} from './gameForkProtocol'; +import { + buildLineageTreeLayout, + collectLineageHighlight, + generationAxisLabel, + type LineageLayoutNode, +} from './lineageTreeLayout'; type GameLineagePageProps = { /** 路由 `?id=` 里的根作品 ID;为空时给出明确的无效编号提示,不发请求。 */ rootGameId: string | null; /** - * 用户是从作品详情页的哪个节点进来的,用来高亮「当前作品」。 - * 缺失时退回高亮根节点(直接打开族谱链接的场景)。 + * 用户是从作品详情页的哪个节点进来的,用来标记「当前作品」。 + * 缺失时退回标记根节点(直接打开族谱链接的场景)。 */ highlightGameId?: string | null; onBack: () => void; @@ -25,101 +44,151 @@ type GameLineagePageProps = { /** 打开「原版与二创对比」;参数是原版(父作品)与二创(该节点)。 */ onOpenCompare?: (leftGameId: string, rightGameId: string) => void; /** - * 内嵌模式:隐藏页面外壳(返回按钮 / 页头 / 脚注),只渲染树本身。 + * 内嵌模式:隐藏页面外壳(返回按钮 / 页头 / 脚注),只渲染可视化树本身。 * 供主题页逐根渲染多棵树复用同一份树查询与渲染(不新建第二套树逻辑)。 */ embedded?: boolean; }; +const MIN_SCALE = 0.35; +const MAX_SCALE = 1.8; +const FIT_PADDING = 24; + +type ViewportState = { scale: number; offsetX: number; offsetY: number }; + /** - * 按代际给节点分组;节点在服务端已按「代际升序、同代创建时间升序」排好, - * 这里只做分组,不重新排序。 + * 一个树节点:真实 HTML 卡片(可聚焦、可点击、有可访问名称),不是 SVG 矩形。 + * + * 交互分工:主点击(含键盘 Enter/Space)进作品详情;次要动作 Fork 放在卡片内部的独立 + * ` -

- {authorName || '未署名作者'} - - {node.playCount.toLocaleString()} 次游玩 -

- {parentUnavailable ? ( -

原作品已不可用

+ + {displayTitle} + {isStub ? ( + // 占位节点只有 ID 与代际:标题就是降级文案本身,不再重复一遍,也不展示作者名。 + + 标题与作者名不对外展示 + + ) : ( + + {authorLabel} + + {layoutNode.node?.playCount.toLocaleString() ?? 0} 次游玩 + + )} + {isInteractive ? ( + + + {onOpenCompare && compareParentGameId && parentAvailable ? ( + + ) : null} + ) : null} - {hasVisibleParent && compareParentGameId && onOpenCompare ? ( - - ) : null} - + ); } /** - * 创作族谱页:以根作品为顶展示整棵改编树,任一公开节点都能点进对应作品详情。 + * 创作族谱页:把 `/games/{id}/lineage` 的树画成**节点-连线**可视化。 * - * 公开只读页面,未登录也能浏览;加载失败走平台既有错误态,无衍生作品给明确空态。 - * 父作品已删或未公开时该节点只降级为「原作品已不可用」,不回显父作品内容、不产生断链。 + * 分工:连线与缩放/平移靠一个 `` + 一层 CSS transform 的内容层;节点是**真实 HTML 卡片** + * (可点击、可聚焦、有可访问名称),因此交互、读屏与选中态都是 DOM 语义,而不是 SVG 伪元素。 + * 布局计算全在 `lineageTreeLayout`(纯函数,useMemo 缓存),hover 只改变高亮集合、不触发重排。 */ export function GameLineagePage({ rootGameId, @@ -130,11 +199,29 @@ export function GameLineagePage({ embedded = false, }: GameLineagePageProps) { const normalizedRootId = rootGameId?.trim() ?? ''; - const [lineage, setLineage] = useState( - null, - ); + const [lineage, setLineage] = + useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); + const [activeGameId, setActiveGameId] = useState(''); + const [includeSubtree, setIncludeSubtree] = useState(true); + const [viewport, setViewport] = useState({ + scale: 1, + offsetX: 0, + offsetY: 0, + }); + const [forkNotice, setForkNotice] = useState(''); + const [forkFallbackGameId, setForkFallbackGameId] = useState(''); + const { copyState: forkCopyState, copyText: copyForkId } = useCopyFeedback(); + const viewportRef = useRef(null); + const forkTimerRef = useRef(null); + const forkHandlersRef = useRef<(() => void) | null>(null); + const dragRef = useRef<{ + x: number; + y: number; + offsetX: number; + offsetY: number; + } | null>(null); const loadLineage = useCallback(async () => { if (!normalizedRootId) { @@ -164,18 +251,170 @@ export function GameLineagePage({ void loadLineage(); }, [loadLineage]); - // `lineage?.nodes ?? []` 在缺省分支每次渲染都会产生新数组,直接喂给下面的 useMemo 会让依赖 - // 每次渲染都变;这里把派生结果本身稳定下来:值仍是同一表达式的结果,identity 只在 `lineage` - // 变化时改变,因此两个下游 useMemo 的取值不变、只少做无谓重算。 const nodes = useMemo(() => lineage?.nodes ?? [], [lineage]); - const groups = useMemo(() => groupNodesByGeneration(nodes), [nodes]); - const visibleIds = useMemo( - () => new Set(nodes.map((node) => node.gameId)), - [nodes], + const layout = useMemo(() => buildLineageTreeLayout(nodes), [nodes]); + const highlighted = useMemo( + () => + activeGameId + ? collectLineageHighlight(layout.nodes, activeGameId, { + includeSubtree, + }) + : null, + [activeGameId, includeSubtree, layout.nodes], + ); + const stubIds = useMemo( + () => + new Set( + layout.nodes.filter((item) => item.isStub).map((item) => item.gameId), + ), + [layout.nodes], ); const anchorId = highlightGameId?.trim() || normalizedRootId; const derivedCount = nodes.filter((node) => node.generation > 0).length; - const rootTitle = lineage?.root?.title?.trim() ?? ''; + + /** 适应窗口:把整棵树缩放进画布可视区域,并居中。 */ + const fitToView = useCallback(() => { + const viewportElement = viewportRef.current; + if (!viewportElement || layout.nodes.length === 0) return; + const availableWidth = viewportElement.clientWidth - FIT_PADDING * 2; + const availableHeight = viewportElement.clientHeight - FIT_PADDING * 2; + const scale = Math.min( + MAX_SCALE, + Math.max( + MIN_SCALE, + Math.min( + availableWidth / Math.max(layout.width, 1), + availableHeight / Math.max(layout.height, 1), + 1, + ), + ), + ); + setViewport({ + scale, + offsetX: Math.max( + FIT_PADDING, + (viewportElement.clientWidth - layout.width * scale) / 2, + ), + offsetY: FIT_PADDING, + }); + }, [layout.height, layout.nodes.length, layout.width]); + + // 初次进入(或换了根作品)自动适配一次;之后交给用户自己缩放/平移,不抢视野。 + const fittedForRef = useRef(''); + useEffect(() => { + if (layout.nodes.length === 0) return; + const key = `${normalizedRootId}:${layout.nodes.length}:${layout.width}`; + if (fittedForRef.current === key) return; + fittedForRef.current = key; + fitToView(); + }, [fitToView, layout.nodes.length, layout.width, normalizedRootId]); + + useEffect( + () => () => { + if (forkTimerRef.current !== null) { + window.clearTimeout(forkTimerRef.current); + } + const handOff = forkHandlersRef.current; + if (handOff) { + window.removeEventListener('blur', handOff); + document.removeEventListener('visibilitychange', handOff); + } + }, + [], + ); + + const zoomBy = useCallback((factor: number) => { + setViewport((current) => { + const scale = Math.min( + MAX_SCALE, + Math.max(MIN_SCALE, current.scale * factor), + ); + const viewportElement = viewportRef.current; + const centerX = (viewportElement?.clientWidth ?? 0) / 2; + const centerY = (viewportElement?.clientHeight ?? 0) / 2; + // 以画布中心为锚点缩放,避免「越缩越偏」。 + const ratio = scale / current.scale; + return { + scale, + offsetX: centerX - (centerX - current.offsetX) * ratio, + offsetY: centerY - (centerY - current.offsetY) * ratio, + }; + }); + }, []); + + const handleWheel = (event: ReactWheelEvent) => { + if (!event.ctrlKey && Math.abs(event.deltaY) < 1) return; + event.preventDefault(); + zoomBy(event.deltaY < 0 ? 1.08 : 1 / 1.08); + }; + + const handlePointerDown = (event: ReactPointerEvent) => { + dragRef.current = { + x: event.clientX, + y: event.clientY, + offsetX: viewport.offsetX, + offsetY: viewport.offsetY, + }; + }; + + const handlePointerMove = (event: ReactPointerEvent) => { + const drag = dragRef.current; + if (!drag) return; + setViewport((current) => ({ + ...current, + offsetX: drag.offsetX + (event.clientX - drag.x), + offsetY: drag.offsetY + (event.clientY - drag.y), + })); + }; + + const endDrag = () => { + dragRef.current = null; + }; + + /** 节点上的 Fork 次要动作:复用详情页同一条唤起 + 降级链路(协议模块 + 复制回退)。 */ + const launchForkFromNode = useCallback((gameId: string) => { + if (forkTimerRef.current !== null) { + window.clearTimeout(forkTimerRef.current); + forkTimerRef.current = null; + } + const previousHandOff = forkHandlersRef.current; + if (previousHandOff) { + window.removeEventListener('blur', previousHandOff); + document.removeEventListener('visibilitychange', previousHandOff); + forkHandlersRef.current = null; + } + setForkFallbackGameId(''); + setForkNotice('正在唤起陶泥儿客户端…'); + const handOffToClient = () => { + if (forkTimerRef.current !== null) { + window.clearTimeout(forkTimerRef.current); + forkTimerRef.current = null; + } + window.removeEventListener('blur', handOffToClient); + document.removeEventListener('visibilitychange', handOffToClient); + forkHandlersRef.current = null; + setForkNotice( + '已唤起陶泥儿客户端。若没有自动打开,请用作品 ID 手动 Fork。', + ); + setForkFallbackGameId(gameId); + }; + forkHandlersRef.current = handOffToClient; + window.addEventListener('blur', handOffToClient, { once: true }); + document.addEventListener('visibilitychange', handOffToClient, { + once: true, + }); + openForkProtocol(gameId); + forkTimerRef.current = window.setTimeout(() => { + forkTimerRef.current = null; + window.removeEventListener('blur', handOffToClient); + document.removeEventListener('visibilitychange', handOffToClient); + forkHandlersRef.current = null; + setForkNotice( + '没有检测到陶泥儿客户端,已切换为手动方式:复制作品 ID 到客户端 Fork。', + ); + setForkFallbackGameId(gameId); + }, FORK_LAUNCH_FALLBACK_DELAY_MS); + }, []); return (
创作族谱 -

{rootTitle || '创作族谱'}

+

{lineage?.root?.title?.trim() || '创作族谱'}

- {normalizedRootId ? ( +
+ zoomBy(1 / 1.2)} + aria-label="缩小" + > + + + {Math.round(viewport.scale * 100)}% + + zoomBy(1.2)} + aria-label="放大" + > + + + - ) : null} +
)} @@ -211,11 +480,9 @@ export function GameLineagePage({ 游戏编号无效 ) : null} - {isLoading ? (
正在加载创作族谱…
) : null} - {!isLoading && error ? ( {error} @@ -237,51 +504,120 @@ export function GameLineagePage({

) : null} {lineage.root ? null : ( -

+

母版已不可用,下面按代际关系展示仍可阅读的衍生作品。

)} - {/* - 空态只有一条,避免「双重空态」:`nodes` 为空说明整棵树没有任何可见节点 - (连母版都不可读),此时只给整体空态;只有「有节点但没有任何衍生」时才给衍生空态。 - 两者互斥,不会同时出现。 - */} {nodes.length === 0 ? ( 这部作品还没有可展示的族谱节点。 ) : ( <> -
- {groups.map(([generation, groupNodes]) => ( -
+ + {forkNotice ? ( + + {forkNotice} + + ) : null} + {forkFallbackGameId ? ( + void copyForkId(forkFallbackGameId)} + /> + ) : null} +
+
+
+

- {generationLabel(generation)} - - {groupNodes.length} + {layout.edges.map((edge) => ( + + ))} + + {layout.axis.map((entry) => ( + + + {entry.label} -

-
- {groupNodes.map((node) => ( - - ))} -
- - ))} + + {entry.count} + + + ))} + {layout.nodes.map((layoutNode) => ( + setActiveGameId('')} + onOpenFork={launchForkFromNode} + /> + ))} +
{derivedCount === 0 ? ( @@ -295,7 +631,8 @@ export function GameLineagePage({ {embedded ? null : (

- 族谱只展示已公开的作品;已删除或未公开的来源作品会标注为「原作品已不可用」。 + 拖拽平移、滚轮或按钮缩放;悬停或键盘聚焦一个节点会高亮它的祖先路径。已删除或未公开的 + 来源作品只显示「原作品已不可用」。

)} diff --git a/src/components/game-distribution/gameDistribution.css b/src/components/game-distribution/gameDistribution.css index 85ad651db..16fe67916 100644 --- a/src/components/game-distribution/gameDistribution.css +++ b/src/components/game-distribution/gameDistribution.css @@ -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; diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx index c11cca556..7f594d87c 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx @@ -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'); // 树复用既有族谱查询:按根请求一次。