From f83f4c53a576823620570a90bdd3993fb81e3fdb Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 7 Oct 2026 15:16:30 +0800 Subject: [PATCH] =?UTF-8?q?=E6=97=8F=E8=B0=B1=E4=BA=A4=E4=BA=92=E7=A1=AC?= =?UTF-8?q?=E5=8C=96(=E6=BB=9A=E8=BD=AE=E4=B8=8D=E5=A4=96=E6=BA=A2/?= =?UTF-8?q?=E6=8B=96=E5=8A=A8=E4=B8=8D=E9=80=89=E6=96=87=E6=9C=AC/?= =?UTF-8?q?=E5=B9=B3=E7=A7=BB=E8=BE=B9=E7=95=8C)=20+=20=E5=BC=80=E5=A7=8B?= =?UTF-8?q?=E5=85=B1=E5=88=9B=E4=B8=8A=E7=A7=BB=E4=B8=BB=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E8=A1=8C=E4=B8=8E=E6=9C=AA=E5=AE=89=E8=A3=85=E8=B7=B3=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E5=85=A5=E5=8F=A3=20+=20=E5=85=B1=E5=88=9B=E5=8D=A1?= =?UTF-8?q?=E7=B2=BE=E7=AE=80=E4=B8=8E=E5=9B=BE=E8=B0=B1=E9=A1=B5=E7=88=B6?= =?UTF-8?q?=E4=BB=A3=E5=AD=90=E4=BB=A3=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ClientDownloadEntry.test.tsx | 25 + .../creation-home/ClientDownloadEntry.tsx | 4 + .../creation-home/clientDownloadLauncher.ts | 26 + .../game-distribution/GameDetailPage.tsx | 234 +++++---- .../GameDistributionPages.test.tsx | 460 ++++++++++++------ .../game-distribution/GameLineagePage.tsx | 153 +++++- .../LineageRelationLists.tsx | 96 ++++ .../game-distribution/gameDistribution.css | 91 +++- .../lineageTreeLayout.test.ts | 23 + .../game-distribution/lineageTreeLayout.ts | 26 + .../PlatformEntryActiveFlowShell.test.tsx | 7 +- 11 files changed, 863 insertions(+), 282 deletions(-) create mode 100644 src/components/creation-home/clientDownloadLauncher.ts create mode 100644 src/components/game-distribution/LineageRelationLists.tsx diff --git a/src/components/creation-home/ClientDownloadEntry.test.tsx b/src/components/creation-home/ClientDownloadEntry.test.tsx index 36a3df98a..57a546922 100644 --- a/src/components/creation-home/ClientDownloadEntry.test.tsx +++ b/src/components/creation-home/ClientDownloadEntry.test.tsx @@ -12,6 +12,7 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ClientDownloadEntry } from './ClientDownloadEntry'; +import { requestClientDownload } from './clientDownloadLauncher'; const fetchMock = vi.fn(); const installerUrl = (version: string) => @@ -39,6 +40,30 @@ const macDownload = (architecture: 'aarch64' | 'x86_64') => ({ }); describe('官网下载入口', () => { + it('任何地方请求下载入口都能打开同一个弹窗(开始共创未检测到客户端时的落点)', async () => { + fetchMock.mockResolvedValueOnce(releaseResponse('0.2.0')); + render(); + + // 还没点:不请求、不渲染弹窗。 + expect(fetchMock).not.toHaveBeenCalled(); + expect(screen.queryByText(/最新版本/u)).toBeNull(); + + act(() => { + requestClientDownload(); + }); + + // 与点「下载客户端」完全一样:拉取 /api/client-downloads 并给出平台下载链接。 + expect( + ( + await screen.findByRole('link', { name: '下载 Windows 版' }) + ).getAttribute('href'), + ).toBe(installerUrl('0.2.0')); + expect(fetchMock).toHaveBeenCalledWith( + '/api/client-downloads', + expect.objectContaining({ cache: 'no-store' }), + ); + }); + beforeEach(() => { fetchMock.mockReset(); vi.stubGlobal('fetch', fetchMock); diff --git a/src/components/creation-home/ClientDownloadEntry.tsx b/src/components/creation-home/ClientDownloadEntry.tsx index 6d228ec92..0c4e18be4 100644 --- a/src/components/creation-home/ClientDownloadEntry.tsx +++ b/src/components/creation-home/ClientDownloadEntry.tsx @@ -11,6 +11,7 @@ import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformSubpanel } from '../common/PlatformSubpanel'; import { UnifiedModal } from '../common/UnifiedModal'; +import { subscribeClientDownloadRequest } from './clientDownloadLauncher'; const DOWNLOAD_ERROR = '暂时无法获取最新版本,请稍后重试'; @@ -47,6 +48,9 @@ export function ClientDownloadEntry({ const [attempt, setAttempt] = useState(0); const [state, setState] = useState({ status: 'loading' }); + // 「开始共创」检测不到客户端时也会打开这里:同一个下载入口,不另写一套下载页。 + useEffect(() => subscribeClientDownloadRequest(() => setOpen(true)), []); + useEffect(() => { if (!open) return; const controller = new AbortController(); diff --git a/src/components/creation-home/clientDownloadLauncher.ts b/src/components/creation-home/clientDownloadLauncher.ts new file mode 100644 index 000000000..c9d0b1052 --- /dev/null +++ b/src/components/creation-home/clientDownloadLauncher.ts @@ -0,0 +1,26 @@ +/** + * 「打开客户端下载入口」的跨组件触发器。 + * + * 为什么需要它:下载 UI(`ClientDownloadEntry`)自带弹窗与 `/api/client-downloads` 取数逻辑, + * 而「开始共创」在检测不到客户端时也要落到**同一个**下载入口(不能自己再写一套下载页/直链)。 + * 这里用最小的进程内事件做收口:任何地方 `requestClientDownload()`,挂着的下载入口自己打开, + * 平台顶栏那个「下载客户端」按钮仍然是同一个组件、同一个目标。 + * + * 局限(有意为之):纯 web 拿不到系统级「是否安装」信息,所以这只是「把用户送到正确的下载入口」, + * 不做任何安装状态的判定或缓存。 + */ +export const CLIENT_DOWNLOAD_REQUEST_EVENT = 'genarrative:open-client-download'; + +/** 打开客户端下载入口(与顶栏「下载客户端」同一个弹窗)。 */ +export function requestClientDownload() { + if (typeof window === 'undefined') return; + window.dispatchEvent(new Event(CLIENT_DOWNLOAD_REQUEST_EVENT)); +} + +/** 订阅下载入口请求;返回取消订阅函数。 */ +export function subscribeClientDownloadRequest(handler: () => void) { + if (typeof window === 'undefined') return () => {}; + window.addEventListener(CLIENT_DOWNLOAD_REQUEST_EVENT, handler); + return () => + window.removeEventListener(CLIENT_DOWNLOAD_REQUEST_EVENT, handler); +} diff --git a/src/components/game-distribution/GameDetailPage.tsx b/src/components/game-distribution/GameDetailPage.tsx index ca0b01579..8556f7dd6 100644 --- a/src/components/game-distribution/GameDetailPage.tsx +++ b/src/components/game-distribution/GameDetailPage.tsx @@ -36,9 +36,9 @@ import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformMudPointConfirmDialog } from '../common/PlatformMudPointConfirmDialog'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { useCopyFeedback } from '../common/useCopyFeedback'; +import { requestClientDownload } from '../creation-home/clientDownloadLauncher'; import { CreatorAuthor } from '../creator/CreatorRelationshipControl'; import { coCreationGenerationTagLabel } from './coCreationCopy'; -import { CoCreationHotDerived } from './CoCreationHotDerived'; import { CoCreationLineageSection } from './CoCreationLineageSection'; import { normalizeGameEntryUrl, @@ -751,8 +751,10 @@ function GameDetailContent({ if (isMobileViewport) { setIsForkPanelOpen(true); setForkLaunchNotice( - '当前设备无法直接唤起客户端,请用下面的作品 ID 或作品链接在客户端里开始共创。', + '当前设备无法直接唤起客户端,已在下方给出作品 ID;也可以先下载电脑版客户端。', ); + // 移动端平台客户端是桌面版:直接打开同一个下载入口(不自己造下载页)。 + requestClientDownload(); return; } setForkLaunchNotice('正在唤起陶泥儿客户端…'); @@ -778,11 +780,13 @@ function GameDetailContent({ document.removeEventListener('visibilitychange', handOff); forkLaunchHandlersRef.current = null; } - // 未失焦 = 没有客户端接手:展示降级面板,不静默无反应。 + // 未失焦 = 没有客户端接手(未安装 / 协议未注册 / 老客户端不响应深链): + // 打开**与顶栏「下载客户端」同一个**下载入口,并给一句人话提示 + 保留手动复制路径。 setForkLaunchNotice( - '没有检测到陶泥儿客户端,已切换为手动方式:把下面的作品 ID 或作品链接粘贴到客户端。', + '没有检测到陶泥儿客户端,已打开下载入口;也可以复制下面的作品 ID 手动开始共创。', ); setIsForkPanelOpen(true); + requestClientDownload(); }, FORK_LAUNCH_FALLBACK_DELAY_MS); } const mobilePlaybackBlocked = isMobileViewport && !game.deviceSupport.mobile; @@ -980,125 +984,6 @@ function GameDetailContent({ ))} ) : null} - {publiclyVisible ? ( - /* - 主操作「一键开始共创」(文档口径):**始终显示**。 - - 授权为「禁止共创」时置灰(aria-disabled + 视觉弱化)并在悬停/点击给出原因, - 不再静默隐藏; - - 未登录按仓库既有登录门禁:入口照常出现,点击走登录弹窗; - - 已登录且授权开放:点击直接唤起客户端,没失焦(没客户端接手)才降级出下面面板。 - */ - <> - - {(isAuthenticated || forkAuthorization === 'forbidden') && - forkLaunchNotice ? ( - - {forkLaunchNotice} - - ) : null} - {isAuthenticated && isForkPanelOpen ? ( - - - 在陶泥儿客户端里打开「从平台作品开始创作」,粘贴下面的作品 - ID 或作品链接。 - - - 作品 ID:{game.id} - - - 作品链接:{workLink} - - - void copyForkId(game.id)} - /> - void copyForkLink(workLink)} - /> - - - 开始共创会把这部作品的整个工程(含素材与文档)复制到你的项目里继续改造; - 复制后对他人可见,且不可撤销。 - - {forkIdCopyState === 'copied' ? ( - - 作品 ID - 已复制,可在客户端「从平台作品开始创作」里粘贴。 - - ) : null} - {forkIdCopyState === 'failed' ? ( - - 复制失败:请手动选中作品 ID 文本。 - - ) : null} - - ) : null} - - ) : null} - {derivedNodes.length > 0 && onOpenGameDetail ? ( - - ) : null} ), }, @@ -1154,6 +1039,109 @@ function GameDetailContent({ {collectionNotice} ) : null} + {/* + 「一键开始共创」从共创信息卡搬到主操作行(2026-10-07 用户口径): + 它是这部作品的主操作之一,和「立即玩 / 收藏」并列;共创卡内不再重复出现该按钮。 + 视觉权重保持主操作(实心强调色),未登录 → 登录门禁,禁止共创 → 置灰 + 原因。 + */} + {publiclyVisible ? ( + <> + + {(isAuthenticated || forkAuthorization === 'forbidden') && + forkLaunchNotice ? ( + + {forkLaunchNotice} + + ) : null} + {isAuthenticated && isForkPanelOpen ? ( + + + 在陶泥儿客户端里打开「从平台作品开始创作」,粘贴下面的作品 + ID 或作品链接。 + + + 作品 ID:{game.id} + + + 作品链接:{workLink} + + + void copyForkId(game.id)} + /> + void copyForkLink(workLink)} + /> + + + 开始共创会把这部作品的整个工程(含素材与文档)复制到你的项目里继续改造; + 复制后对他人可见,且不可撤销。 + + {forkIdCopyState === 'copied' ? ( + + 作品 ID 已复制,可在客户端「从平台作品开始创作」里粘贴。 + + ) : null} + {forkIdCopyState === 'failed' ? ( + + 复制失败:请手动选中作品 ID 文本。 + + ) : null} + + ) : null} + + ) : null} {!isPaidLocked && mobilePlaybackBlocked ? ( ({ /** 开始共创唤起:只打桩「发出唤起」这一动作,协议常量仍用真实值(测试按同一阈值推进定时器)。 */ const openForkProtocolMock = vi.hoisted(() => vi.fn()); +const requestClientDownloadMock = vi.hoisted(() => vi.fn()); +vi.mock('../creation-home/clientDownloadLauncher', () => ({ + requestClientDownload: requestClientDownloadMock, + subscribeClientDownloadRequest: () => () => {}, +})); vi.mock('./gameForkProtocol', async (original) => ({ ...(await original()), openForkProtocol: openForkProtocolMock, @@ -213,6 +218,7 @@ beforeEach(() => { copyTextToClipboardMock.mockReset(); copyTextToClipboardMock.mockResolvedValue(true); openForkProtocolMock.mockReset(); + requestClientDownloadMock.mockReset(); setGameCollectionMock.mockReset(); setGameCollectionMock.mockResolvedValue({ collected: true }); listThemesMock.mockReset(); @@ -220,20 +226,27 @@ beforeEach(() => { getThemeMock.mockReset(); getThemeMock.mockResolvedValue(null); setCreatorViewer(null); + getCreatorRelationshipMock.mockReset(); getCreatorRelationshipMock.mockResolvedValue({ relationship: { isSelf: false, isFollowing: false, isFollowedBy: false }, }); + changeCreatorRelationshipMock.mockReset(); changeCreatorRelationshipMock.mockResolvedValue({ relationship: { isSelf: false, isFollowing: true, isFollowedBy: false }, }); setViewportWidth(1024); + listGamesMock.mockReset(); listGamesMock.mockResolvedValue({ games: [], nextCursor: null }); + getGameMock.mockReset(); getGameMock.mockResolvedValue(createGame()); + getGameLineageMock.mockReset(); getGameLineageMock.mockResolvedValue(null); getDerivedGamesMock.mockReset(); getDerivedGamesMock.mockResolvedValue(null); + getMyGameMock.mockReset(); getMyGameMock.mockResolvedValue(null); loadVersionRecoveryActionsMock.mockResolvedValue({}); + listGameReviewsMock.mockReset(); listGameReviewsMock.mockResolvedValue({ reviews: [], page: 1, @@ -242,7 +255,9 @@ beforeEach(() => { totalPages: 0, ratingSummary: { averageScore: null, ratingCount: 0 }, }); + getMyGameReviewMock.mockReset(); getMyGameReviewMock.mockResolvedValue({ review: null }); + recordGamePlayMock.mockReset(); recordGamePlayMock.mockResolvedValue(undefined); purchaseGameMock.mockReset(); createGamePlaySessionMock.mockReset(); @@ -1735,7 +1750,7 @@ describe('GameLineagePage', () => { />, ); - expect(await screen.findByText('叶节点')).toBeTruthy(); + expect(await findTreeText('叶节点')).toBeTruthy(); expect(screen.getByText('中间代')).toBeTruthy(); // 当前作品高亮在它自己的节点卡片上。 expect( @@ -1899,7 +1914,7 @@ describe('GameLineagePage', () => { '母版已不可用,下面按代际关系展示仍可阅读的衍生作品。', ), ).toBeTruthy(); - expect(screen.getByText('叶节点')).toBeTruthy(); + expect((await treeTexts('叶节点')).length).toBeGreaterThan(0); expect(screen.getByText('原作品已不可用')).toBeTruthy(); }); @@ -2142,6 +2157,78 @@ describe('GameDetailPage 开始共创入口', () => { ).toBeNull(); }); + it('未安装客户端:唤起后没被接管 → 打开同一个下载入口 + 给出人话提示', async () => { + getGameMock.mockResolvedValueOnce( + createGame({ id: 'game-1', forkAuthorization: 'full' }), + ); + render( + , + ); + + // 先拿到按钮再开假定时器:findBy* 依赖真实定时器。 + const entry = await screen.findByRole('button', { name: '一键开始共创' }); + vi.useFakeTimers(); + fireEvent.click(entry); + // 唤起已发出,但还没有任何「客户端接手」信号。 + expect(openForkProtocolMock).toHaveBeenCalledWith('game-1'); + expect(requestClientDownloadMock).not.toHaveBeenCalled(); + expect(screen.getByText('正在唤起陶泥儿客户端…')).toBeTruthy(); + + await act(async () => { + vi.advanceTimersByTime(FORK_LAUNCH_FALLBACK_DELAY_MS + 50); + }); + + // 超时 = 没有客户端接手:打开与顶栏「下载客户端」**同一个**入口 + 保留手动复制路径。 + expect(requestClientDownloadMock).toHaveBeenCalledTimes(1); + expect( + screen.getByText(/没有检测到陶泥儿客户端,已打开下载入口/u), + ).toBeTruthy(); + expect(screen.getByRole('button', { name: '复制作品 ID' })).toBeTruthy(); + vi.useRealTimers(); + }); + + it('已安装客户端:失焦被接管 → 不打开下载入口', async () => { + getGameMock.mockResolvedValueOnce( + createGame({ id: 'game-1', forkAuthorization: 'full' }), + ); + render( + , + ); + + const entry = await screen.findByRole('button', { name: '一键开始共创' }); + vi.useFakeTimers(); + fireEvent.click(entry); + // 浏览器把焦点交给外部协议处理器 → 视为客户端接手。 + act(() => { + window.dispatchEvent(new Event('blur')); + }); + await act(async () => { + vi.advanceTimersByTime(FORK_LAUNCH_FALLBACK_DELAY_MS + 50); + }); + + expect(requestClientDownloadMock).not.toHaveBeenCalled(); + expect(screen.getByText(/已唤起陶泥儿客户端/u)).toBeTruthy(); + vi.useRealTimers(); + }); + + it('移动端:不尝试唤起,直接给出下载入口与手动复制路径', async () => { + setViewportWidth(390); + getGameMock.mockResolvedValueOnce( + createGame({ id: 'game-1', forkAuthorization: 'full' }), + ); + render( + , + ); + + fireEvent.click( + await screen.findByRole('button', { name: '一键开始共创' }), + ); + + expect(openForkProtocolMock).not.toHaveBeenCalled(); + expect(requestClientDownloadMock).toHaveBeenCalledTimes(1); + expect(screen.getByText(/当前设备无法直接唤起客户端/u)).toBeTruthy(); + }); + it('未登录时入口显示「登录后一键开始共创」,点击走仓库既有登录门禁而不是静默隐藏', async () => { authUiMock.value.user = null; authUiMock.value.canAccessProtectedData = false; @@ -2253,7 +2340,7 @@ describe('原版与二创对比入口', () => { />, ); - expect(await screen.findByText('叶节点')).toBeTruthy(); + expect(await findTreeText('叶节点')).toBeTruthy(); // 口径变更(2026-10-07,用户口径):**节点也显示游玩次数**,显示值 = 该节点的**子树合计** // (`collectLineagePlayCountTotals().total`),不是自身值。 const rootCard = await cardOf('母版'); @@ -2812,12 +2899,40 @@ const relationTree = { }; /** 节点卡片上的主行动作按钮(打开详情)与它的卡片容器:多个 describe 共用。 */ -const openButton = (title: string) => - screen.getByRole('button', { name: `打开《${title}》详情` }); -const cardOf = async (title: string) => - (await screen.findByRole('button', { name: `打开《${title}》详情` })).closest( - '.game-lineage-node', - ) as HTMLElement; +/** + * 「树里的元素」判定:**图下方的父代/子代块**(`.lineage-relations`)不算树。 + * + * 为什么不用「先拿容器再查」:容器(画布/大纲)是异步挂载的,基于容器的查询可能早于挂载而回退到 + * body,于是撞上关系块里的同名文案。这里改成「全页查 + 过滤掉关系块」,既不用等容器也不会撞。 + */ +const isInTree = (element: Element) => + element.closest('.lineage-relations') === null; +const treeTexts = async (text: string | RegExp) => + (await screen.findAllByText(text)).filter(isInTree); +const treeButtons = (name: string) => + screen.getAllByRole('button', { name }).filter(isInTree); +const openButton = (title: string) => { + const [button] = treeButtons(`打开《${title}》详情`); + if (!button) throw new Error(`树里找不到《${title}》节点`); + return button; +}; +const cardOf = async (title: string) => { + const button = await waitFor(() => { + const [found] = treeButtons(`打开《${title}》详情`); + if (!found) throw new Error(`树里还没有《${title}》节点`); + return found; + }); + return button.closest('.game-lineage-node') as HTMLElement; +}; +/** 在树区域里按文本查找(等挂载 + 排除关系块)。 */ +const findTreeText = async (text: string) => { + const [element] = await waitFor(async () => { + const found = await treeTexts(text); + if (found.length === 0) throw new Error(`树里还没有「${text}」`); + return found; + }); + return element; +}; describe('GameLineagePage 可视化与交互', () => { const treeWithSibling = { @@ -2868,11 +2983,14 @@ describe('GameLineagePage 可视化与交互', () => { />, ); - await screen.findByRole('button', { name: '打开《根原创》详情' }); + await cardOf('根原创'); // 画布(stacked)卡片必须有作者行:抽出 LineageNodeContent 时作者行一度只留在 inline // 布局,族谱树页面因此丢了作者名(并被 check-game-distribution-lineage-e2e 抓到)。 + const canvas = document.querySelector( + '.game-lineage-canvas', + ) as HTMLElement; expect( - Array.from(document.querySelectorAll('.game-lineage-node__author')) + Array.from(canvas.querySelectorAll('.game-lineage-node__author')) .map((element) => element.textContent) .sort(), ).toEqual(['一代作者', '当前作者', '根作者']); @@ -3329,6 +3447,82 @@ describe('GameLineagePage 可视化与交互', () => { ).toBeNull(); }); + it('滚轮只缩放画布:原生非 passive 监听(不踩 React passive 的坑),且不绑 window', async () => { + const source = await import('node:fs').then((fs) => + fs.readFileSync( + 'src/components/game-distribution/GameLineagePage.tsx', + 'utf8', + ), + ); + // React 的 onWheel 是 passive 的(preventDefault 无效、页面照样滚)→ 画布上不能再用它。 + expect(source).not.toContain('onWheel={handleWheel}'); + expect(source).toContain("addEventListener('wheel'"); + expect(source).toContain('{ passive: false }'); + // 绑在画布元素上(viewportRef.current),不是 window。 + expect(source).toContain('viewportRef.current'); + expect(source).not.toMatch(/window\.addEventListener\('wheel'/u); + }); + + it('拖动平移:进入 panning 态(禁选中/拖文本)、阻止 selectstart、指针捕获跟手', async () => { + getGameLineageMock.mockResolvedValueOnce(treeWithSibling); + render( + , + ); + + const canvas = (await screen.findByLabelText('创作族谱树')) as HTMLElement; + // 拖动前:不是 panning 态、全局也允许选择。 + expect(canvas.className).not.toContain('game-lineage-canvas--panning'); + expect(document.body.style.userSelect).toBe(''); + + fireEvent.pointerDown(canvas, { pointerId: 1, clientX: 100, clientY: 100 }); + // 拖动中:panning 态(CSS 里 user-select/touch-action 生效)+ **全局禁选**(松手即恢复)。 + expect(canvas.className).toContain('game-lineage-canvas--panning'); + expect(document.body.style.userSelect).toBe('none'); + // 原生 selectstart / dragstart 也被挂上了阻止(双保险:不依赖事件默认行为是否真的会选中)。 + canvas.dispatchEvent( + new Event('selectstart', { bubbles: true, cancelable: true }), + ); + canvas.dispatchEvent( + new Event('dragstart', { bubbles: true, cancelable: true }), + ); + + // 平移跟手:移动后内容层 transform 的位移发生变化。 + const content = canvas.querySelector( + '.game-lineage-canvas__content', + ) as HTMLElement; + const before = content.style.transform; + fireEvent.pointerMove(canvas, { pointerId: 1, clientX: 160, clientY: 130 }); + expect(content.style.transform).not.toBe(before); + + fireEvent.pointerUp(canvas, { pointerId: 1, clientX: 160, clientY: 130 }); + expect(canvas.className).not.toContain('game-lineage-canvas--panning'); + expect(document.body.style.userSelect).toBe(''); + }); + + it('画布交互样式契约:禁选中/禁原生拖拽/滚轮到边界不外溢、图片不可拖', async () => { + const css = await import('node:fs').then((fs) => + fs.readFileSync( + 'src/components/game-distribution/gameDistribution.css', + 'utf8', + ), + ); + const canvasRule = css.slice( + css.indexOf('.game-lineage-canvas {'), + css.indexOf('.game-lineage-canvas {') + 1200, + ); + expect(canvasRule).toContain('user-select: none'); + expect(canvasRule).toContain('touch-action: none'); + expect(canvasRule).toContain('overscroll-behavior: contain'); + expect(css).toContain('.game-lineage-canvas--panning'); + expect(css).toMatch( + /\.game-lineage-canvas img,[\s\S]{0,200}?-webkit-user-drag: none;/u, + ); + }); + it('节点是真实 DOM 卡片:主体点击=选中、标题/打开作品=进详情、当前作品有标记且父在上子在下', async () => { getGameLineageMock.mockResolvedValueOnce(treeWithSibling); const onOpenDetail = vi.fn(); @@ -3721,7 +3915,7 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐) />, ); - await screen.findByText('热门二创'); + await findTreeText('热门二创'); fireEvent.click(screen.getByRole('checkbox', { name: '按热度排序' })); const coldCard = await cardOf('冷门二创'); @@ -3782,7 +3976,7 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐) />, ); - await screen.findByText('自身热'); + await findTreeText('自身热'); fireEvent.click(screen.getByRole('checkbox', { name: '按热度排序' })); // 判据是自身:自身热(100)排在 后代热(自身 5 / 合计 205)**前面**。 expect(parseFloat((await cardOf('自身热')).style.left)).toBeLessThan( @@ -3807,7 +4001,7 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐) />, ); - await screen.findByText('热门二创'); + await findTreeText('热门二创'); // 第 1 代主干 = 热门二创(999 > 1);冷门二创不是主干。 expect( (await cardOf('热门二创')).querySelector('.game-lineage-node__trunk'), @@ -4401,8 +4595,13 @@ describe('GameDetailPage 共创模块(文档对齐)', () => { expect( within(card).getByRole('button', { name: '查看创作族谱' }), ).toBeTruthy(); + // 2026-10-07:主操作「一键开始共创」已搬到**主操作行**,共创卡内不再重复出现。 expect( - within(card).getByRole('button', { name: '一键开始共创' }), + within(card).queryByRole('button', { name: '一键开始共创' }), + ).toBeNull(); + const actionsRow = card.closest('.game-detail-display') as HTMLElement; + expect( + within(actionsRow).getByRole('button', { name: '一键开始共创' }), ).toBeTruthy(); // ② 图在**正文之后**:`.game-detail-page` 的直接子级,且排在正文容器之后。 const page = document.querySelector('.game-detail-page') as HTMLElement; @@ -4454,7 +4653,9 @@ describe('GameDetailPage 共创模块(文档对齐)', () => { const canvas = (await within(block).findByLabelText( '创作族谱树', )) as HTMLElement; - await within(block).findByText('根原创'); + await within( + block.querySelector('.game-lineage-canvas') as HTMLElement, + ).findByText('根原创'); // 作用域是**根**:母版节点就在树里(不是以当前作品为顶的局部树)。 expect(canvas.textContent).toContain('根原创'); const view = readCanvasViewport(canvas); @@ -4581,185 +4782,160 @@ describe('GameDetailPage 共创模块(文档对齐)', () => { expect(screen.getByText('累计衍生作品 3')).toBeTruthy(); }); - it('热门衍生栏按「子树合计」显示与排序;拿不到树时回退自身值', async () => { - getGameMock.mockResolvedValueOnce(createGame({ id: 'game-1' })); + it('共创卡只留标签 + 数据 + 入口:不再渲染派生作品卡列表(热门衍生栏已并入图谱)', async () => { + getGameMock.mockResolvedValueOnce( + createGame({ id: 'game-current', forkCount: 3, lineage: null }), + ); + getGameLineageMock.mockResolvedValue(buildChainDataset()); getDerivedGamesMock.mockResolvedValueOnce({ - gameId: 'game-1', + gameId: 'game-current', nodes: [ createLineageNode({ - gameId: 'child-low', - title: '低热度', - playCount: 1, + gameId: 'child-1', + title: '被改编出来的作品', + playCount: 12, generation: 1, - parentGameId: 'game-1', - }), - createLineageNode({ - gameId: 'child-hot', - title: '高热度', - playCount: 99, - generation: 1, - parentGameId: 'game-1', - }), - createLineageNode({ - gameId: 'child-mid', - title: '中热度', - playCount: 50, - generation: 1, - parentGameId: 'game-1', + parentGameId: 'game-current', }), ], truncated: false, }); - // 树里「低热度」有一个 200 播放的孙代 → 子树合计 201,超过「高热度」的 99。 + render( + , + ); + + const card = (await screen.findByText('累计衍生作品 3')).closest( + '.game-fork-card', + ) as HTMLElement; + // 卡里保留:世代/授权标签、数据概览、查看创作族谱、主题 tag(若有)。 + expect(within(card).getByText('累计衍生作品 3')).toBeTruthy(); + expect( + within(card).getByRole('button', { name: '查看创作族谱' }), + ).toBeTruthy(); + // 卡里**不再有**派生作品卡列表(热门衍生栏 / 任何父代子代清单)。 + expect(card.querySelector('.co-creation-hot')).toBeNull(); + expect(screen.queryByLabelText('热门衍生作品')).toBeNull(); + expect(within(card).queryByText('被改编出来的作品')).toBeNull(); + }); + + it('图谱页:图下方给出「父代」「子代」两块清单,子代按热度降序、点击可进详情', async () => { getGameLineageMock.mockResolvedValue({ - rootGameId: 'game-1', + rootGameId: 'game-root', root: null, nodes: [ createLineageNode({ - gameId: 'game-1', + gameId: 'game-root', title: '母版', generation: 0, parentGameId: null, - playCount: 10, - }), - createLineageNode({ - gameId: 'child-low', - title: '低热度', playCount: 1, - generation: 1, - parentGameId: 'game-1', }), createLineageNode({ - gameId: 'child-grand', - title: '孙代爆款', - playCount: 200, + gameId: 'game-mid', + title: '中间代', + generation: 1, + parentGameId: 'game-root', + playCount: 2, + }), + createLineageNode({ + gameId: 'game-current', + title: '当前作品', generation: 2, - parentGameId: 'child-low', + parentGameId: 'game-mid', + playCount: 3, }), - ], - truncated: false, - }); - const onOpenGameDetail = vi.fn(); - const { unmount } = render( - , - ); - - const hot = await screen.findByLabelText('热门衍生作品'); - // 显示与排序都按合计:低热度 201 → 高热度 99 → 中热度 50。 - await waitFor(() => - expect( - [...hot.querySelectorAll('.co-creation-hot__title')].map( - (element) => element.textContent, - ), - ).toEqual(['低热度', '高热度', '中热度']), - ); - expect(hot.textContent).toContain('201 次游玩'); - unmount(); - - // 拿不到树 → 回退服务端自身值(不暗示含衍生),排序也回退到自身值。 - getGameMock.mockResolvedValueOnce(createGame({ id: 'game-1' })); - getDerivedGamesMock.mockResolvedValueOnce({ - gameId: 'game-1', - nodes: [ createLineageNode({ - gameId: 'child-low', - title: '低热度', + gameId: 'child-cold', + title: '冷门子代', + generation: 3, + parentGameId: 'game-current', playCount: 1, - generation: 1, - parentGameId: 'game-1', }), createLineageNode({ gameId: 'child-hot', - title: '高热度', + title: '热门子代', + generation: 3, + parentGameId: 'game-current', playCount: 99, - generation: 1, - parentGameId: 'game-1', + }), + createLineageNode({ + gameId: 'grandchild', + title: '孙代作品', + generation: 4, + parentGameId: 'child-hot', + playCount: 5, }), ], truncated: false, }); - getGameLineageMock.mockRejectedValue(new Error('拿不到树')); + const onOpenDetail = vi.fn(); render( - , ); - const hotFallback = await screen.findByLabelText('热门衍生作品'); - expect(hotFallback.textContent).toContain('99 次游玩'); + // 父代 = 祖先链(根 → 直接父),不含当前作品。 + const ancestors = await screen.findByRole('region', { name: '父代' }); + const ancestorNames = [ + ...ancestors.querySelectorAll('.game-lineage-node__title'), + ].map((element) => element.textContent); + expect(ancestorNames).toEqual(['母版', '中间代']); + // 子代 = **直接子代**(不含孙代),按热度(自身 playCount)降序。 + const children = screen.getByRole('region', { name: '子代' }); expect( - [...hotFallback.querySelectorAll('.co-creation-hot__title')].map( + [...children.querySelectorAll('.game-lineage-node__title')].map( (element) => element.textContent, ), - ).toEqual(['高热度', '低热度']); - // 没有树就没有「游玩次数合计」这一项,也不出现「含衍生」的说法。 - expect(screen.queryByLabelText('游玩次数合计')).toBeNull(); - expect(document.body.textContent).not.toContain('含衍生'); + ).toEqual(['热门子代', '冷门子代']); + expect(within(children).queryByText('孙代作品')).toBeNull(); + // 点击进详情。 + fireEvent.click( + within(children).getByRole('button', { name: '打开《热门子代》详情' }), + ); + expect(onOpenDetail).toHaveBeenCalledWith('child-hot'); }); - it('热门衍生栏按 playCount 倒序取前 5 个并可在点击后进入作品', async () => { - getGameMock.mockResolvedValueOnce(createGame({ id: 'game-1' })); - getDerivedGamesMock.mockResolvedValueOnce({ - gameId: 'game-1', + it('图谱页:没有父代/子代时两块给空态说明(不空白)', async () => { + getGameLineageMock.mockResolvedValueOnce({ + rootGameId: 'solo', + root: null, nodes: [ createLineageNode({ - gameId: 'child-low', - title: '低热度', - playCount: 1, - generation: 1, - parentGameId: 'game-1', - }), - createLineageNode({ - gameId: 'child-hot', - title: '高热度', - playCount: 99, - generation: 1, - parentGameId: 'game-1', - }), - createLineageNode({ - gameId: 'child-mid', - title: '中热度', - playCount: 50, - generation: 1, - parentGameId: 'game-1', + gameId: 'solo', + title: '孤作品', + generation: 0, + parentGameId: null, + playCount: 0, }), ], truncated: false, }); - const onOpenGameDetail = vi.fn(); render( - , ); - const hot = await screen.findByLabelText('热门衍生作品'); - const titles = ['高热度', '中热度', '低热度']; - for (const title of titles) { - expect(hot.textContent).toContain(title); - } - expect(hot.textContent?.indexOf('高热度')).toBeLessThan( - hot.textContent?.indexOf('中热度') ?? -1, - ); - fireEvent.click( - screen.getByRole('button', { name: /热门衍生《高热度》/u }), - ); - expect(onOpenGameDetail).toHaveBeenCalledWith('child-hot'); + expect( + await screen.findByText( + '这部作品是母版,没有父代(它的后代都在上面的树里)。', + ), + ).toBeTruthy(); + expect(screen.getByText('这部作品还没有直接子代。')).toBeTruthy(); }); -}); -describe('GameDetailPage 收藏按钮', () => { it('可判定为已收藏时显示「已收藏」,点击取消收藏', async () => { getGameMock.mockResolvedValueOnce(createGame({ collected: true })); setGameCollectionMock.mockResolvedValueOnce({ collected: false }); diff --git a/src/components/game-distribution/GameLineagePage.tsx b/src/components/game-distribution/GameLineagePage.tsx index 569fb5831..b1a11c755 100644 --- a/src/components/game-distribution/GameLineagePage.tsx +++ b/src/components/game-distribution/GameLineagePage.tsx @@ -8,7 +8,6 @@ import { useMemo, useRef, useState, - type WheelEvent as ReactWheelEvent, } from 'react'; import { @@ -29,8 +28,11 @@ import { import { LineageInfoPanel } from './LineageInfoPanel'; import { LineageNodeContent } from './LineageNodeContent'; import { LineageOutlineList } from './LineageOutlineList'; +import { LineageRelationLists } from './LineageRelationLists'; import { buildLineageTreeLayout, + clampLineagePan, + collectLineageAncestorChain, collectLineageHighlight, collectLineageImmediateScope, collectLineagePlayCountTotals, @@ -296,6 +298,8 @@ export function GameLineagePage({ const currentCrumbRef = useRef(null); const forkTimerRef = useRef(null); const forkHandlersRef = useRef<(() => void) | null>(null); + /** 拖动中:驱动 CSS(user-select: none / touch-action: none / 光标)。 */ + const [isPanning, setIsPanning] = useState(false); const dragRef = useRef<{ x: number; y: number; @@ -359,6 +363,29 @@ export function GameLineagePage({ * 每个节点相对当前作品的角色:`self` / `ancestor`(父代)/ `descendant`(子代)/ `side`(旁支)。 * 用**未筛选**的整棵树算,所以世代筛选、只看父子、排序都不会改口径。 */ + /** 祖先链(根 → 直接父):与信息面板面包屑同一份顺序函数。 */ + const ancestorChain = useMemo(() => { + const chain = collectLineageAncestorChain(nodes, anchorId); + // 去掉自己:这是「父代」清单。 + return chain.filter((node) => node.gameId !== anchorId); + }, [anchorId, nodes]); + /** + * **直接子代**(按热度降序;同热度按服务端顺序 = 创建时间序稳定兜底)。 + * 选「直接子代」而不是整棵子树:与 `forkCount`/「被改编 N」同口径,且再深的层级交给上面的树。 + */ + const directChildren = useMemo(() => { + const children = nodes.filter( + (node) => (node.parentGameId ?? null) === anchorId, + ); + return children + .map((node, index) => ({ node, index })) + .sort( + (left, right) => + right.node.playCount - left.node.playCount || + left.index - right.index, + ) + .map((item) => item.node); + }, [anchorId, nodes]); const relationRoles = useMemo( () => collectLineageRelationRoles(nodes, anchorId), [anchorId, nodes], @@ -700,13 +727,72 @@ export function GameLineagePage({ }); }, []); - const handleWheel = (event: ReactWheelEvent) => { - if (!event.ctrlKey && Math.abs(event.deltaY) < 1) return; - event.preventDefault(); - zoomBy(event.deltaY < 0 ? 1.08 : 1 / 1.08); - }; + /** + * 画布是否真的挂载了内容:树是**异步加载**的,首帧 `viewportRef.current` 还是 null, + * 所以原生监听必须依赖「画布有内容」这个信号重新绑定,否则会一次都没挂上。 + */ + const canvasHasContent = + effectiveViewMode === 'canvas' && layout.nodes.length > 0; + /** + * 滚轮:**只缩放画布,不滚动外部页面**。 + * + * 为什么用原生监听而不是 React 的 `onWheel`:React 17+ 把 `wheel` 挂在根容器上且**是 passive 的** + * (`preventDefault` 会被忽略,页面照样滚)——这正是「滚轮同时缩放 + 滚页面」的根因。 + * 这里绑在**画布元素本身**(不是 `window`),显式 `{ passive: false }`,滚轮就不会外溢。 + */ + useEffect(() => { + const element = viewportRef.current; + if (!element) return; + const onWheel = (event: WheelEvent) => { + // 阻止页面滚动与浏览器的「捏合缩放整页」;缩放只作用在本画布。 + event.preventDefault(); + zoomBy(event.deltaY < 0 ? 1.08 : 1 / 1.08); + }; + element.addEventListener('wheel', onWheel, { passive: false }); + return () => element.removeEventListener('wheel', onWheel); + }, [canvasHasContent, zoomBy]); + + /** + * 拖动平移期间禁止文本选择 / 原生拖拽(否则按住划一下就把文字选走、再拖变成拖文本)。 + * `selectstart` / `dragstart` 不是 React 合成事件,所以在画布上挂原生监听一次。 + */ + useEffect(() => { + const element = viewportRef.current; + if (!element) return; + const block = (event: Event) => { + if (!dragRef.current) return; + event.preventDefault(); + }; + const blockDragStart = (event: Event) => event.preventDefault(); + element.addEventListener('selectstart', block); + element.addEventListener('dragstart', blockDragStart); + return () => { + element.removeEventListener('selectstart', block); + element.removeEventListener('dragstart', blockDragStart); + }; + }, [canvasHasContent]); + + /** + * 拖动期间把 `user-select` 压到 body 上。 + * + * 为什么不止靠画布容器的 CSS:指针可能划到画布外(尤其配合 pointer capture), + * 而选择行为发生在 document 级别;拖动期间全局禁选最稳,松手即恢复。 + */ + useEffect(() => { + if (!isPanning) return; + const previous = document.body.style.userSelect; + document.body.style.userSelect = 'none'; + return () => { + document.body.style.userSelect = previous; + }; + }, [isPanning]); const handlePointerDown = (event: ReactPointerEvent) => { + // 只响应主键 / 触摸 / 笔;中键与右键留给浏览器(新标签页打开、右键菜单)。 + if (event.pointerType === 'mouse' && event.button !== 0) return; + // 指针捕获:移出画布/越界后仍然跟手,不用把监听挂到 window 上。 + event.currentTarget.setPointerCapture?.(event.pointerId); + setIsPanning(true); dragRef.current = { x: event.clientX, y: event.clientY, @@ -718,15 +804,35 @@ export function GameLineagePage({ 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), - })); + event.preventDefault(); + const viewportElement = viewportRef.current; + setViewport((current) => { + const width = viewportElement?.clientWidth ?? 0; + const height = viewportElement?.clientHeight ?? 0; + const contentWidth = layout.width * current.scale; + const contentHeight = layout.height * current.scale; + return { + ...current, + offsetX: clampLineagePan( + drag.offsetX + (event.clientX - drag.x), + width, + contentWidth, + ), + offsetY: clampLineagePan( + drag.offsetY + (event.clientY - drag.y), + height, + contentHeight, + ), + }; + }); }; - const endDrag = () => { + const endDrag = (event?: ReactPointerEvent) => { dragRef.current = null; + setIsPanning(false); + if (event?.currentTarget?.hasPointerCapture?.(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } }; /** 节点上的「开始共创」次要动作:复用详情页同一条唤起 + 降级链路(协议模块 + 复制回退)。 */ @@ -1009,13 +1115,13 @@ export function GameLineagePage({ /> ) : (
这部作品还没有衍生作品,欢迎从它开始共创。 ) : null} + {/* + 图**下方**的「父代 / 子代」两块清单(2026-10-07 用户口径): + 详情页共创卡不再堆派生作品卡,改为在图谱里一次看清「从哪来、下一步是谁」。 + 数据全部来自**同一次** `/lineage`(零额外请求): + - 父代 = 祖先链(根 → 直接父),顺序与面包屑同一份; + - 子代 = **直接子代**,按**热度(自身 playCount)降序**、同热度按服务端顺序稳定兜底。 + 选「直接子代」的理由:① 与平台既有 `forkCount`/「被改编 N」同口径,块里的卡片数 + 与数字对得上;② 与画布上「只看父代与子代」开关同义;③ 再往下的层级由上面的树 + 表达,这里只做「一步关系」的快速跳转。 + */} + )} diff --git a/src/components/game-distribution/LineageRelationLists.tsx b/src/components/game-distribution/LineageRelationLists.tsx new file mode 100644 index 000000000..690c9e83c --- /dev/null +++ b/src/components/game-distribution/LineageRelationLists.tsx @@ -0,0 +1,96 @@ +import type { GameDistributionLineageNode } from '../../services/gameDistributionClient'; +import { LineageNodeContent } from './LineageNodeContent'; +import type { + LineagePlayCountBreakdown, + LineageRelationRole, +} from './lineageTreeLayout'; + +/** + * 图谱**下方**的「父代 / 子代」两块清单(2026-10-07 用户口径)。 + * + * 为什么放在这里而不是详情页共创卡里:用户要的是「所有父代子代都放到图谱下面,卡片只留数据和 tag」。 + * 数据全部来自**同一次** `/lineage`(零额外请求),卡片复用族谱的 `LineageNodeContent`(与画布/大纲 + * 同一套封面色块与省略规则),因此不会出现「第三套卡片」。 + * + * 排序口径: + * - 父代 = 祖先链(根 → 直接父),与面包屑同一份顺序(由调用方算好); + * - 子代 = **直接子代**、按**热度(自身 `playCount`)降序**,同热度保持服务端顺序(创建时间序)。 + * 选直接子代:与 `forkCount`/「被改编 N」同口径;再深的层级由上面的树表达。 + */ +export function LineageRelationLists({ + ancestors, + children, + currentGameId, + playCountTotals, + onOpenDetail, +}: { + /** 祖先链(根 → 直接父),**不含**当前作品。 */ + ancestors: readonly GameDistributionLineageNode[]; + /** 直接子代(调用方已按热度降序排好)。 */ + children: readonly GameDistributionLineageNode[]; + currentGameId: string; + playCountTotals: ReadonlyMap; + onOpenDetail: (gameId: string) => void; +}) { + const renderRow = ( + node: GameDistributionLineageNode, + role: LineageRelationRole, + ) => ( +
  • + +
  • + ); + + return ( +
    +
    +

    + 父代 + {ancestors.length} +

    + {ancestors.length === 0 ? ( +

    + 这部作品是母版,没有父代(它的后代都在上面的树里)。 +

    + ) : ( +
      + {ancestors.map((node) => renderRow(node, 'ancestor'))} +
    + )} +
    +
    +

    + 子代 + {children.length} + 直接子代 · 按热度 +

    + {children.length === 0 ? ( +

    这部作品还没有直接子代。

    + ) : ( +
      + {children.map((node) => renderRow(node, 'descendant'))} +
    + )} +
    +
    + ); +} + +export default LineageRelationLists; diff --git a/src/components/game-distribution/gameDistribution.css b/src/components/game-distribution/gameDistribution.css index e0162cee1..a776c4360 100644 --- a/src/components/game-distribution/gameDistribution.css +++ b/src/components/game-distribution/gameDistribution.css @@ -985,8 +985,24 @@ ) 0 0 / 22px 22px; cursor: grab; - /* 拖拽平移不要触发浏览器自身的滚动/缩放。 */ + /* 拖动平移的交互硬化(2026-10-07): + * - `touch-action: none`:拖拽不触发浏览器自身的滚动/缩放,触屏同手势也不打架; + * - `user-select: none`:按住拖动不选中文本(否则再拖会把文字拖走); + * - `overscroll-behavior: contain`:滚轮到边界也不把滚动链交给外部页面。 */ touch-action: none; + user-select: none; + -webkit-user-select: none; + overscroll-behavior: contain; +} +.game-lineage-canvas--panning { + cursor: grabbing; +} +/* 画布里的图片不参与原生拖拽(否则会出现「把封面图拖走」)。 */ +.game-lineage-canvas img, +.game-lineage-canvas .game-cover__image { + -webkit-user-drag: none; + user-drag: none; + pointer-events: none; } .game-lineage-canvas:active { cursor: grabbing; @@ -2657,3 +2673,76 @@ padding-bottom: env(safe-area-inset-bottom, 0px); } } + +/* + * 图谱下方的「父代 / 子代」两块清单(2026-10-07):与大纲同一套行样式(复用 LineageNodeContent), + * 只把列表收窄到内容列宽并居中,避免又变成整屏出血的横条。 + */ +.lineage-relations { + display: flex; + flex-direction: column; + gap: 1rem; + margin-top: 1.25rem; +} +.lineage-relation { + display: flex; + flex-direction: column; + gap: 0.4rem; +} +.lineage-relation__title { + display: flex; + align-items: baseline; + gap: 0.4rem; + margin: 0; + font-size: 0.95rem; + font-weight: 700; +} +.lineage-relation__count { + border: 1px solid var(--platform-neutral-border); + border-radius: 999px; + padding: 0 0.4rem; + background: var(--platform-neutral-bg); + color: var(--platform-neutral-text); + font-size: 0.7rem; + font-weight: 600; +} +.lineage-relation__hint { + color: var(--platform-text-muted); + font-size: 0.72rem; + font-weight: 500; +} +.lineage-relation__empty { + margin: 0; + color: var(--platform-text-soft); + font-size: 0.8rem; +} +.lineage-relation__list { + display: flex; + flex-direction: column; + gap: 0.3rem; + margin: 0; + padding: 0; + list-style: none; + max-width: 54rem; +} +.lineage-relation__row { + display: flex; + min-width: 0; +} +.lineage-relation__main { + display: block; + flex: 1 1 auto; + min-width: 0; + min-height: 2.75rem; + border: 1px solid var(--platform-surface-border); + border-radius: 0.6rem; + padding: 0.3rem 0.5rem; + background: var(--platform-surface, #fff); + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} +.lineage-relation__main:hover { + border-color: var(--platform-surface-hover-border); +} diff --git a/src/components/game-distribution/lineageTreeLayout.test.ts b/src/components/game-distribution/lineageTreeLayout.test.ts index 966d7af25..96d79e8f2 100644 --- a/src/components/game-distribution/lineageTreeLayout.test.ts +++ b/src/components/game-distribution/lineageTreeLayout.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'; import type { GameDistributionLineageNode } from '../../services/gameDistributionClient'; import { buildLineageTreeLayout, + clampLineagePan, collectLineageAncestorChain, collectLineageHighlight, collectLineageImmediateScope, @@ -515,6 +516,28 @@ describe('estimateLineageCardHeight(纯函数估算)', () => { }); }); +describe('clampLineagePan(平移边界:不把内容拖飞)', () => { + it('内容比视口大:至少保留 keep 像素可见', () => { + // 内容 1000 宽、视口 400、keep 96 → offset ∈ [-904, 304] + expect(clampLineagePan(-5000, 400, 1000)).toBe(96 - 1000); + expect(clampLineagePan(5000, 400, 1000)).toBe(400 - 96); + // 区间内原样返回 + expect(clampLineagePan(-100, 400, 1000)).toBe(-100); + }); + + it('内容比视口小:允许在小范围内自由摆放(区间取两侧 min/max)', () => { + // 内容 200、视口 400、keep 96 → leftBound=-104、rightBound=304 → 区间 [-104, 304] + expect(clampLineagePan(-9999, 400, 200)).toBe(-104); + expect(clampLineagePan(9999, 400, 200)).toBe(304); + expect(clampLineagePan(100, 400, 200)).toBe(100); + }); + + it('视口/内容尺寸拿不到(jsdom 或未挂载)时不做限制', () => { + expect(clampLineagePan(-9999, 0, 1000)).toBe(-9999); + expect(clampLineagePan(-9999, 400, 0)).toBe(-9999); + }); +}); + 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 90b2a3380..d8954d3a9 100644 --- a/src/components/game-distribution/lineageTreeLayout.ts +++ b/src/components/game-distribution/lineageTreeLayout.ts @@ -572,6 +572,32 @@ export function collectLineageAncestorChain< return chain.reverse(); } +/** 平移时至少保留多少像素的内容可见(防止把画布拖飞、找不回来)。 */ +export const LINEAGE_PAN_KEEP_PX = 96; + +/** + * 平移边界:把候选 `offset` 夹到「至少留 `keep` 像素内容可见」的区间里。 + * + * - 内容在视口里占 `[offset, offset + contentSize]`; + * - 约束:`offset + contentSize >= keep` 且 `offset <= viewportSize - keep`; + * - 内容比视口小的时候区间会反向,这里取两侧的 min/max 作区间,仍然允许在小范围内自由摆放。 + * + * 纯函数,供画布拖动/缩放路径复用(单测直接钉住边界)。 + */ +export function clampLineagePan( + offset: number, + viewportSize: number, + contentSize: number, + keep = LINEAGE_PAN_KEEP_PX, +): number { + if (viewportSize <= 0 || contentSize <= 0) return offset; + const leftBound = keep - contentSize; + const rightBound = viewportSize - keep; + const lower = Math.min(leftBound, rightBound); + const upper = Math.max(leftBound, rightBound); + return Math.min(upper, Math.max(lower, offset)); +} + /** 节点相对「当前作品」的角色(文档口径的**父代 / 子代**)。 */ export type LineageRelationRole = 'self' | 'ancestor' | 'descendant' | 'side'; diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx index e3d0774ad..7e6604618 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx @@ -1434,7 +1434,12 @@ describe('创作族谱', () => { expect(gameDistributionMock.getGameLineage).toHaveBeenCalledWith( 'game-root', ); - fireEvent.click(screen.getByRole('button', { name: /《母版》/u })); + // 图下方的「父代/子代」块里也有同名节点,所以这里精确到**树里**的那一个。 + const treeNode = screen + .getAllByRole('button', { name: /《母版》/u }) + .find((button) => button.closest('.lineage-relations') === null); + expect(treeNode).toBeTruthy(); + fireEvent.click(treeNode as HTMLElement); expect(setSelectionStage).toHaveBeenCalledWith('game-detail', { path: '/games/detail?id=game-root', });