From ec8f27cb287a18b887cdca33fe4a9cfb6a5bb93b Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 7 Oct 2026 01:51:21 +0800 Subject: [PATCH] =?UTF-8?q?=E6=97=8F=E8=B0=B1=E7=A7=BB=E5=8A=A8=E7=AB=AF?= =?UTF-8?q?=E5=9B=BE=E8=B0=B1=E8=A7=86=E5=9B=BE=EF=BC=9A=E6=A8=AA=E7=89=88?= =?UTF-8?q?=E7=94=BB=E5=B8=83=20+=20=E8=BF=9B=E5=85=A5=E6=97=B6=E6=8C=89?= =?UTF-8?q?=E5=AE=BD=E5=BA=A6=E9=80=82=E9=85=8D=E5=B9=B6=E5=AE=9A=E4=BD=8D?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E4=BD=9C=E5=93=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...�技术方案】游戏共创与作品Fork-2026-10-03.md | 2 +- .../GameDistributionPages.test.tsx | 140 ++++++++++++ .../game-distribution/GameLineagePage.tsx | 199 ++++++++++++------ 3 files changed, 277 insertions(+), 64 deletions(-) diff --git a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md index ea645121d..d397794bb 100644 --- a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md +++ b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md @@ -459,7 +459,7 @@ A 路线里那个必须提前知道的互斥点已经按既有做法绕开:`cr - **「累计创作世代数」在前端计算**:=本作品子树深度(子树最大代际 − 本作品代际),用详情页**已经请求的同一份 `/lineage` 数据**算,不新增请求、也不加服务端字段。 - **移动端形态**(跟随仓库既有窄断点,`useIsMobileViewport`): - **默认大纲式缩进树**(`LineageOutlineList`):全宽行、层级用左缩进 + 肘形导线表达、主干链导线加粗用主题色;**无横向滚动**、无需缩放平移;行高 ≥44px,点击行 = 选中(无 hover)。 - - **图谱视图为可选开关**:切到画布后支持拖拽平移 / 缩放;布局层支持 `orientation: 'horizontal'`(根在左、代际向右,兄弟分支沿纵向排开)——窄屏上比顶→下更省横向空间。 + - **图谱视图为可选开关**(形态 B = **横版画布**,`orientation: 'horizontal'`:根在左、代际向右、兄弟分支沿纵向排开,窄屏上比顶→下更省横向空间):切到画布后支持拖拽平移 / 缩放;横版**不渲染左侧代际轴**,代际信息由节点自带角标承担。**初次进入按宽度适配**(整棵树横向看完;触及最小缩放时给出「可拖拽平移」提示),并把**当前作品**用最小位移拉进视口——贴最近的边,父代 / 兄弟仍留在视口里,不会出现「停在 100% 缩放、一个节点都看不到」的空屏。桌面仍为纵向画布 + 宽高同时适配并居中。 - **信息面板用 bottom sheet**:从底部升起(含简介 / 授权 / 核心改动说明 / 前往体验 / Fork 此版本),遮罩点击或关闭按钮收起;桌面保持画布右上浮层。 - 面包屑横向可滚动且**当前项常驻可见**;尊重 `prefers-reduced-motion`;底部留 `env(safe-area-inset-bottom)`。 - **移动端门限豁免(2026-10-06)**:平台原有的「移动端仅支持作品展示」欢迎弹窗只看 `!isDesktopLayout`、忽略路由,会把公开只读的族谱树 / 共创主题页首屏盖住。现在按**显式白名单**豁免这三个 stage:`game-lineage` / `game-themes` / `game-theme`(清单在 `PlatformActiveMobileWelcomeDialog.tsx` 的 `MOBILE_WELCOME_EXEMPT_STAGES`,键名即 stage 名,不做前缀/范围匹配)。**创作 / 项目 / 发布 / 我的 / 平台首页仍保持门限**——那些才是「移动端不支持体验创作工具」的部分;弹窗文案未改(与豁免后的事实不矛盾:族谱与主题页本身就是作品展示)。 diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index f82904b8d..59202243e 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -3096,6 +3096,146 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐) expect(await screen.findByLabelText('创作族谱大纲')).toBeTruthy(); }); + /** 读画布视口状态:内容层的 `transform: translate(...) scale(...)`。 */ + function readCanvasViewport(canvas: HTMLElement) { + const content = canvas.querySelector( + '.game-lineage-canvas__content', + ) as HTMLElement; + const match = + /translate\(([-\d.]+)px, ([-\d.]+)px\) scale\(([\d.]+)\)/u.exec( + content.style.transform, + ); + if (!match) { + throw new Error(`无法解析画布 transform: ${content.style.transform}`); + } + return { + offsetX: Number.parseFloat(match[1]!), + offsetY: Number.parseFloat(match[2]!), + scale: Number.parseFloat(match[3]!), + }; + } + + /** 视口内可见的节点卡片:把内容层的 transform 应用到卡片矩形,再与画布矩形求交。 */ + function visibleCanvasCards( + canvas: HTMLElement, + view: { offsetX: number; offsetY: number; scale: number }, + ) { + return [...canvas.querySelectorAll('.game-lineage-node')].filter((node) => { + const card = node as HTMLElement; + const left = + view.offsetX + Number.parseFloat(card.style.left) * view.scale; + const top = view.offsetY + Number.parseFloat(card.style.top) * view.scale; + const width = Number.parseFloat(card.style.width) * view.scale; + const height = Number.parseFloat(card.style.minHeight) * view.scale; + return ( + left + width > 0 && + top + height > 0 && + left < canvas.clientWidth && + top < canvas.clientHeight + ); + }); + } + + it('图谱视图初次进入即适配并定位当前作品(不是 zoom 100% 的空屏)', async () => { + setViewportWidth(390); + getGameLineageMock.mockResolvedValueOnce(tree); + // jsdom 不做布局:给画布一个「真机尺寸」,适配与定位才有可断言的数值。 + const widthSpy = vi + .spyOn(Element.prototype, 'clientWidth', 'get') + .mockReturnValue(390); + const heightSpy = vi + .spyOn(Element.prototype, 'clientHeight', 'get') + .mockReturnValue(667); + try { + render( + , + ); + + await screen.findByLabelText('创作族谱大纲'); + fireEvent.click(screen.getByRole('button', { name: '图谱视图' })); + + const canvas = (await screen.findByLabelText( + '创作族谱树', + )) as HTMLElement; + const view = readCanvasViewport(canvas); + // 窄屏图谱视图是**横版**:代际沿 x 推进,且不再渲染左侧代际轴(角标承担代际信息)。 + expect(canvas.querySelector('.game-lineage-canvas__axis')).toBeNull(); + expect( + new Set( + [...canvas.querySelectorAll('.game-lineage-node')].map((card) => + Number.parseFloat((card as HTMLElement).style.left), + ), + ).size, + ).toBeGreaterThan(1); + expect(Math.round(view.scale * 100)).not.toBe(100); + expect(canvas.querySelector('.game-lineage-zoom')?.textContent).toBe( + `${Math.round(view.scale * 100)}%`, + ); + + const visible = visibleCanvasCards(canvas, view); + // 「一个节点都看不到」是最初的回归:这里至少要看到 3 个,且当前作品在视口内。 + expect(visible.length).toBeGreaterThanOrEqual(3); + expect(visible).toContain( + canvas.querySelector('.game-lineage-node--anchor'), + ); + expect( + visible.some((card) => card.getAttribute('data-generation') === '0'), + ).toBe(true); + } finally { + widthSpy.mockRestore(); + heightSpy.mockRestore(); + } + }); + + it('图谱视图缩放触底时把当前作品滚进视口(保上下文,不只剩空屏)', async () => { + setViewportWidth(320); + getGameLineageMock.mockResolvedValueOnce(tree); + const widthSpy = vi + .spyOn(Element.prototype, 'clientWidth', 'get') + .mockReturnValue(200); + const heightSpy = vi + .spyOn(Element.prototype, 'clientHeight', 'get') + .mockReturnValue(420); + try { + render( + , + ); + + await screen.findByLabelText('创作族谱大纲'); + fireEvent.click(screen.getByRole('button', { name: '图谱视图' })); + + const canvas = (await screen.findByLabelText( + '创作族谱树', + )) as HTMLElement; + const view = readCanvasViewport(canvas); + expect(view.scale).toBeGreaterThan(0); + expect(Math.round(view.scale * 100)).not.toBe(100); + + const visible = visibleCanvasCards(canvas, view); + expect(visible).toContain( + canvas.querySelector('.game-lineage-node--anchor'), + ); + // 当前作品进来了,但不是「只剩它」:父代仍在视口里,用户看得到上下文。 + expect(visible.length).toBeGreaterThanOrEqual(2); + expect( + visible.some((card) => card.getAttribute('data-generation') === '1'), + ).toBe(true); + } finally { + widthSpy.mockRestore(); + heightSpy.mockRestore(); + } + }); + it('窄屏仍能点进详情页与查看完整族谱入口(大纲模式不丢入口)', async () => { setViewportWidth(375); getGameLineageMock.mockResolvedValueOnce(tree); diff --git a/src/components/game-distribution/GameLineagePage.tsx b/src/components/game-distribution/GameLineagePage.tsx index d32035fe2..ca3535af0 100644 --- a/src/components/game-distribution/GameLineagePage.tsx +++ b/src/components/game-distribution/GameLineagePage.tsx @@ -325,6 +325,8 @@ export function GameLineagePage({ ); // 窄屏用紧凑布局(更小的卡片与间距 + 更大的相对字号),避免「桌面画布缩小 + 字太小」。 const compactTree = isMobileViewport; + /** 窄屏图谱视图用横版画布:代际信息交给节点角标,因此不渲染左侧代际轴层。 */ + const horizontalCanvas = compactTree; const layout = useMemo( () => buildLineageTreeLayout( @@ -337,6 +339,9 @@ export function GameLineagePage({ columnGap: 22, rowGap: 88, axisWidth: 72, + // 窄屏图谱视图用横版(根在左、代际向右、兄弟沿纵向排开):窄屏上比顶→下更省横向空间, + // 纵向溢出交给滑动。代际信息由节点自带角标承担,所以横版不渲染代际轴(见下面的轴层)。 + orientation: 'horizontal', } : undefined, ), @@ -441,6 +446,68 @@ export function GameLineagePage({ fitToView(); }, [fitToView, layout.nodes.length, layout.width, normalizedRootId]); + /** + * 窄屏「图谱视图」的初次定位:按**宽度**适配(整棵树横向看完),再把当前作品拉进视口。 + * + * 为什么单独一条:窄屏默认是大纲形态,画布**根本没挂载**(`viewportRef.current` 为 null), + * 上面那次自动适配其实什么都没做;用户切到图谱视图时画布是新挂载的,会停在 + * `scale 1 / offset 0`——实测一个节点都看不到。桌面仍然是「宽高同时满足 + 居中」, + * 不受这里影响。 + */ + const focusMobileCanvasOnAnchor = useCallback(() => { + const viewportElement = viewportRef.current; + if (!viewportElement || layout.nodes.length === 0) return; + const width = viewportElement.clientWidth; + const height = viewportElement.clientHeight; + const availableWidth = Math.max(1, width - FIT_PADDING * 2); + const fitWidth = availableWidth / Math.max(layout.width, 1); + const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, fitWidth)); + setFitHint( + scale > fitWidth + 0.001 + ? '树较宽,已按最小缩放显示:拖拽平移查看。' + : '', + ); + let offsetX = Math.max(FIT_PADDING, (width - layout.width * scale) / 2); + let offsetY = FIT_PADDING; + const anchorNode = layout.nodes.find((item) => item.gameId === anchorId); + if (anchorNode && width > 0 && height > 0) { + const left = offsetX + anchorNode.x * scale; + const right = left + anchorNode.width * scale; + const top = offsetY + anchorNode.y * scale; + const bottom = top + anchorNode.height * scale; + // 只做**最小位移**把当前作品拉进视口(贴到最近的边),不做粗暴对中—— + // 这样父代 / 兄弟仍在视口里,用户一眼能看到上下文。 + if (left < FIT_PADDING) offsetX += FIT_PADDING - left; + else if (right > width - FIT_PADDING) { + offsetX -= right - (width - FIT_PADDING); + } + if (top < FIT_PADDING) offsetY += FIT_PADDING - top; + else if (bottom > height - FIT_PADDING) { + offsetY -= bottom - (height - FIT_PADDING); + } + } + setViewport({ scale, offsetX, offsetY }); + }, [anchorId, layout.nodes, layout.width]); + + // 每次真正「进入图谱视图」(或换了根作品 / 锚点)定位一次;筛选、缩放、平移不抢视野。 + const mobileFocusedForRef = useRef(''); + useEffect(() => { + if (effectiveViewMode !== 'canvas' || !isMobileViewport) return; + if (layout.nodes.length === 0) return; + const key = `${normalizedRootId}:${layout.nodes.length}:${layout.width}:${anchorId}`; + if (mobileFocusedForRef.current === key) return; + mobileFocusedForRef.current = key; + focusMobileCanvasOnAnchor(); + }, [ + anchorId, + effectiveViewMode, + focusMobileCanvasOnAnchor, + isMobileViewport, + layout.nodes.length, + layout.width, + normalizedRootId, + ]); + useEffect( () => () => { if (forkTimerRef.current !== null) { @@ -753,40 +820,40 @@ export function GameLineagePage({ /> ) : null} - {pinnedChain.length > 0 ? ( -
- {pinnedChain.map((crumb, index) => ( - - {index > 0 ? : null} - - - ))} - -
- ) : null} + {pinnedChain.map((crumb, index) => ( + + {index > 0 ? : null} + + + ))} + + + ) : null} {effectiveViewMode === 'outline' ? (