diff --git a/scripts/check-game-distribution-lineage-e2e.mjs b/scripts/check-game-distribution-lineage-e2e.mjs index 04735e77a..fc0c4b554 100644 --- a/scripts/check-game-distribution-lineage-e2e.mjs +++ b/scripts/check-game-distribution-lineage-e2e.mjs @@ -760,9 +760,12 @@ async function runBrowserStep({ // 复制反馈:点击后必须有**可读反馈**——要么按钮变「作品 ID 已复制」, // 要么出现「复制失败:请手动选中作品 ID 文本。」。 // - // 为什么断言两种都算过:headless Chromium 里 `navigator.clipboard.writeText` 要求文档聚焦, - // 页面不在前台的运行环境会直接拒绝写入(脚本自己的注释也把「真实剪贴板内容」列为人工项)。 - // 这里要钉的是「UI 一定会给出可观察的结果」,而不是把浏览器焦点策略当成被测行为。 + // 为什么不用 Playwright 的真实鼠标 click():复验实测该自动化会话里**连页面标题这类对照元素 + // 的 document 级 pointerdown/mousedown/click 都恒为 0**(见下面 probe 的计数),说明这个会话 + // 的鼠标事件通道本身不产生事件,用它只会测出「环境不通」而不是「按钮不工作」。 + // 独立复验用同一页面 `btn.click()` 调用时整条链路完全正常(文案变化 + 状态行 + 剪贴板内容正确), + // 因此这里改为**派发 DOM click**(与真实用户点击走同一个 React onClick handler), + // 并显式断言剪贴板内容,避免为了迁就环境而放宽语义。产品代码未为此改动。 const copyId = page.getByRole('button', { name: REMIX_COPY_ID_LABEL, exact: true, @@ -774,9 +777,26 @@ async function runBrowserStep({ `label=${REMIX_COPY_ID_LABEL}`, ); if (copyIdVisible) { - // 先尽量把页面带到前台:多数环境里这一步能让成功后分支真的发生。 + // 诊断探针:记录 document 级鼠标事件计数,作为「本会话鼠标通道是否可用」的证据。 + const mouseProbe = await page + .evaluate(() => { + const counters = { pointerdown: 0, mousedown: 0, click: 0 }; + const bump = (key) => () => { + counters[key] += 1; + }; + document.addEventListener('pointerdown', bump('pointerdown'), true); + document.addEventListener('mousedown', bump('mousedown'), true); + document.addEventListener('click', bump('click'), true); + window.__lineageMouseProbe = counters; + return counters; + }) + .catch(() => null); await page.bringToFront().catch(() => {}); - await copyId.click(); + await copyId.hover({ timeout: 5_000 }).catch(() => {}); + // 派发 DOM click:不依赖该会话的鼠标通道,但会真正触发 React 的 onClick。 + await copyId.evaluate((element) => { + element.click(); + }); const copiedShown = await visible( page.getByRole('button', { name: REMIX_COPIED_ID_LABEL, @@ -800,10 +820,33 @@ async function runBrowserStep({ .locator('#game-fork-remix-panel .game-fork-remix-status') .evaluateAll((nodes) => nodes.map((node) => node.textContent)) .catch(() => []); + const clipboardText = await page + .evaluate(async () => { + try { + return await navigator.clipboard.readText(); + } catch { + return ''; + } + }) + .catch(() => ''); check( '浏览器视觉:点「复制作品 ID」后给出可读反馈(已复制按钮或复制失败提示)', copiedShown || failedShown, - `期望按钮名=${REMIX_COPIED_ID_LABEL} 或出现失败提示;实测按钮文案=${JSON.stringify(observedLabels)} 面板状态行=${JSON.stringify(observedStatusTexts)}(已授剪贴板权限;真实剪贴板内容仍属人工验收项)`, + `期望按钮名=${REMIX_COPIED_ID_LABEL} 或出现失败提示;实测按钮文案=${JSON.stringify(observedLabels)} 面板状态行=${JSON.stringify(observedStatusTexts)}`, + ); + // 剪贴板内容:派发 click 后 handler 真正跑过,权限已授的情况下这里能读到作品 ID。 + check( + '浏览器视觉:复制后剪贴板内容为该作品 ID', + clipboardText.trim() === parentId, + `clipboard=${JSON.stringify(clipboardText.trim())} 期望=${parentId}`, + ); + const mouseProbeAfter = await page + .evaluate(() => window.__lineageMouseProbe ?? null) + .catch(() => null); + check( + '诊断:本会话鼠标事件通道计数(0 = 自动化上下文不产生鼠标事件,非产品缺陷)', + true, + `before=${JSON.stringify(mouseProbe)} after=${JSON.stringify(mouseProbeAfter)}`, ); } } diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index dac251ef5..aa6f54b54 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -2213,9 +2213,7 @@ describe('GameLineagePage 可视化与交互', () => { ); const leafCard = await cardOf('叶节点'); - fireEvent.click( - within(leafCard).getByRole('button', { name: '选中 叶节点 的路径' }), - ); + fireEvent.click(leafCard); // 面包屑:母版 → 中间代 → 叶节点,祖先移出视口也能读。 const breadcrumb = screen.getByLabelText('已选中的路径'); @@ -2238,9 +2236,7 @@ describe('GameLineagePage 可视化与交互', () => { document.querySelectorAll('path.game-lineage-edge--active').length, ).toBeGreaterThan(0); - fireEvent.click( - within(leafCard).getByRole('button', { name: '取消选中 叶节点 的路径' }), - ); + fireEvent.click(leafCard); expect(screen.queryByLabelText('已选中的路径')).toBeNull(); expect((await cardOf('旁支')).className).not.toContain( 'game-lineage-node--dimmed', @@ -2265,9 +2261,15 @@ describe('GameLineagePage 可视化与交互', () => { expect( within(leafCard).getByRole('button', { name: 'Fork 叶节点' }), ).toBeTruthy(); + // 卡片上只保留「打开作品 / Fork」两个动作按钮(第三个次要动作已按复验意见移除), + // 封面+标题本身也是一个打开详情的按钮。 + expect(within(leafCard).getByRole('button', { name: '打开作品' })).toBeTruthy(); expect( - within(leafCard).getByRole('button', { name: '选中 叶节点 的路径' }), + within(leafCard).getByRole('button', { name: 'Fork 叶节点' }), ).toBeTruthy(); + expect( + within(leafCard).queryByRole('button', { name: /选中|锁定/u }), + ).toBeNull(); }); it('缩放按钮改变百分比,「适应窗口」重新适配', async () => { diff --git a/src/components/game-distribution/GameLineagePage.tsx b/src/components/game-distribution/GameLineagePage.tsx index 0adceabb1..b2229d2f9 100644 --- a/src/components/game-distribution/GameLineagePage.tsx +++ b/src/components/game-distribution/GameLineagePage.tsx @@ -17,7 +17,6 @@ import { 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'; @@ -31,6 +30,7 @@ import { buildLineageTreeLayout, collectLineageHighlight, generationAxisLabel, + LINEAGE_COVER_ASPECT, type LineageLayoutNode, } from './lineageTreeLayout'; @@ -177,7 +177,11 @@ function LineageTreeNodeCard({ onOpenDetail(gameId); }} > - + Fork - )} @@ -328,11 +315,12 @@ export function GameLineagePage({ nodes, compactTree ? { - nodeWidth: 168, - nodeHeight: 78, - columnGap: 20, - rowGap: 86, - axisWidth: 62, + // 紧凑态:卡片 176 宽 → 16:9 封面 99 高 + 信息条;度量仍满足「同层 ≥ columnGap」。 + nodeWidth: 176, + nodeHeight: 148, + columnGap: 22, + rowGap: 88, + axisWidth: 72, } : undefined, ), @@ -372,6 +360,10 @@ export function GameLineagePage({ }, [layout.nodes, pathIds, pinnedGameId]); const anchorId = highlightGameId?.trim() || normalizedRootId; const derivedCount = nodes.filter((node) => node.generation > 0).length; + const nodeCountLabel = + layout.nodes.length > 0 + ? `${layout.nodes.length} 个节点${lineage?.truncated ? '(已截断)' : ''}` + : ''; /** 适应窗口:宽高**两个维度同时**满足(取更小的那个比例),并居中。 */ const fitToView = useCallback( @@ -546,47 +538,11 @@ export function GameLineagePage({ 创作族谱

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

-
- zoomBy(1 / 1.2)} - aria-label="缩小" - > - - - {Math.round(viewport.scale * 100)}% + {nodeCountLabel ? ( + + {nodeCountLabel} - zoomBy(1.2)} - aria-label="放大" - > - - fitToView(true)} - aria-label="适应窗口" - > - - void loadLineage()} - disabled={isLoading} - > - -
+ ) : null} )} @@ -776,12 +732,15 @@ export function GameLineagePage({ 因此放大或平移后轴标签始终在画布左侧可读,而不会被内容层带出去裁掉。 */} + {/* + 画布内悬浮控件:放在**右下角**——左侧是代际轴、顶部是路径面包屑, + 右下角是地图/画布类应用的习惯位置,也最不容易遮住根节点与主干。 + */} +
event.stopPropagation()} + > + + + {Math.round(viewport.scale * 100)}% + + +
{derivedCount === 0 ? ( diff --git a/src/components/game-distribution/gameDistribution.css b/src/components/game-distribution/gameDistribution.css index f0a742032..e3606f23d 100644 --- a/src/components/game-distribution/gameDistribution.css +++ b/src/components/game-distribution/gameDistribution.css @@ -723,6 +723,79 @@ color: var(--platform-accent-strong); font-weight: 700; } +/* 画布内悬浮控件(右下角):圆角胶囊、浅色底 + 毛玻璃、内边距统一、轻阴影。 */ +.game-lineage-controls { + position: absolute; + z-index: 3; + right: 0.6rem; + bottom: 0.6rem; + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.3rem 0.4rem; + border: 1px solid var(--platform-surface-border); + border-radius: 999px; + background: var(--platform-surface, #fff); + box-shadow: 0 8px 22px rgba(76, 41, 27, 0.16); + backdrop-filter: blur(10px); +} +.game-lineage-controls__button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.25rem; + width: 2rem; + height: 2rem; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--platform-text-strong); + font: inherit; + cursor: pointer; +} +.game-lineage-controls__button:hover:not(:disabled) { + background: rgba(199, 101, 61, 0.14); +} +.game-lineage-controls__button:disabled { + opacity: 0.38; + cursor: not-allowed; +} +.game-lineage-controls__button--wide { + width: auto; + padding: 0 0.65rem; +} +.game-lineage-controls__label { + font-size: 0.76rem; + font-weight: 600; +} +.game-lineage-controls__divider { + width: 1px; + height: 1.2rem; + background: var(--platform-surface-border); +} +/* 缩放读数:与 ± 按钮视觉等重、等宽对齐(数字不跳动)。 */ +.game-lineage-zoom { + min-width: 3.1rem; + text-align: center; + color: var(--platform-text-strong); + font-size: 0.86rem; + font-weight: 700; + font-variant-numeric: tabular-nums; +} +.game-lineage-summary { + color: var(--platform-text-soft); + font-size: 0.78rem; + font-variant-numeric: tabular-nums; +} +@media (max-width: 720px) { + .game-lineage-controls__label { + display: none; + } + .game-lineage-controls__button--wide { + width: 2rem; + padding: 0; + } +} /* * 族谱树可视化:一个可平移/缩放的画布 + 内容层(CSS transform)。 @@ -821,35 +894,39 @@ .game-lineage-edge--dimmed { opacity: 0.08; } -/* 代际轴:画布左侧的固定层(在内容层的 transform 之外),放大/平移后仍然可读、不被裁。 */ +/* 代际轴:画布左侧的固定层(在内容层的 transform 之外),放大/平移后仍然可读、不被裁。 + 背景用平台 surface 淡色调(**不是白色条**),每个代际行只画一条分隔线, + 因此整体看起来是一条连续的轴,而不是一条条白条。 */ .game-lineage-canvas__axis { position: absolute; top: 0; bottom: 0; left: 0; - width: 92px; + width: 96px; pointer-events: none; - background: linear-gradient( - 90deg, - var(--platform-surface, #fff) 72%, - transparent - ); + border-right: 1px solid var(--platform-surface-border); + background: var(--platform-surface-muted, rgba(104, 54, 36, 0.055)); } .game-lineage-axis { position: absolute; - left: 0.5rem; + left: 0; + right: 0; + box-sizing: border-box; display: flex; align-items: center; gap: 0.3rem; - color: var(--platform-text-soft); - font-size: 0.74rem; + padding-left: 0.55rem; + border-top: 1px solid var(--platform-surface-border); + color: var(--platform-text-strong); + font-size: 0.76rem; font-weight: 700; + font-variant-numeric: tabular-nums; white-space: nowrap; } .game-lineage-axis__count { border-radius: 999px; padding: 0.05rem 0.45rem; - background: var(--platform-surface-border); + background: var(--platform-surface, #fff); color: var(--platform-text-soft); font-size: 0.68rem; font-weight: 600; @@ -920,10 +997,7 @@ color: var(--platform-text-strong); background: rgba(199, 101, 61, 0.12); } -/* 窄屏紧凑卡:封面比例更扁、标题字号相对更大,字段更少(作者行不渲染)。 */ -.game-lineage-node--compact .game-lineage-node__cover { - aspect-ratio: 176 / 96; -} +/* 窄屏紧凑卡:卡片更小、标题字号相对更大、字段更少(作者行不渲染);封面仍是 16:9。 */ .game-lineage-node--compact .game-lineage-node__title { font-size: 0.88rem; } @@ -983,10 +1057,16 @@ position: relative; display: block; width: 100%; - aspect-ratio: 224 / 120; + /* 横版封面:比例由 LINEAGE_COVER_ASPECT 内联给出(16:9,与发布面板封面预览一致)。 */ overflow: hidden; background: var(--platform-surface-border); } +.game-lineage-node__cover img, +.game-lineage-node__cover .game-cover__image { + width: 100%; + height: 100%; + object-fit: cover; +} .game-lineage-node__cover .game-cover { width: 100%; height: 100%; diff --git a/src/components/game-distribution/lineageTreeLayout.test.ts b/src/components/game-distribution/lineageTreeLayout.test.ts index 21ccf5ec4..5dd334765 100644 --- a/src/components/game-distribution/lineageTreeLayout.test.ts +++ b/src/components/game-distribution/lineageTreeLayout.test.ts @@ -8,6 +8,8 @@ import { collectLineageHighlight, generationAxisLabel, LINEAGE_COLUMN_GAP, + LINEAGE_COVER_ASPECT, + LINEAGE_NODE_HEIGHT, LINEAGE_NODE_WIDTH, } from './lineageTreeLayout'; @@ -47,6 +49,15 @@ function assertNoOverlap( } describe('buildLineageTreeLayout', () => { + it('封面宽高比与平台封面约定一致(16:9 横版)', () => { + // 发布面板的封面预览就写死 16/9(gameDistribution.css 的 .game-publish-media__preview), + // 这里把它钉成常量:节点卡片误改成竖版会在单测里变红。 + expect(LINEAGE_COVER_ASPECT).toBeCloseTo(16 / 9, 5); + expect(LINEAGE_NODE_WIDTH / LINEAGE_COVER_ASPECT).toBeLessThan( + LINEAGE_NODE_HEIGHT, + ); + }); + it('单节点:一个布局节点、零连线、一条代际轴', () => { const layout = buildLineageTreeLayout([node('game-root', 0, null)]); expect(layout.nodes).toHaveLength(1); diff --git a/src/components/game-distribution/lineageTreeLayout.ts b/src/components/game-distribution/lineageTreeLayout.ts index ee1a3a442..a5abf8820 100644 --- a/src/components/game-distribution/lineageTreeLayout.ts +++ b/src/components/game-distribution/lineageTreeLayout.ts @@ -20,8 +20,23 @@ import type { GameDistributionLineageNode } from '../../services/gameDistributio * 与「竖直段/水平段存在」而不是解析路径。 */ -export const LINEAGE_NODE_WIDTH = 224; -export const LINEAGE_NODE_HEIGHT = 184; +/** + * 封面宽高比:与平台既有封面约定一致 = **16 / 9 横版**。 + * + * 依据(仓库现役约定,不是我自定的): + * - 发布面板的封面预览 `.game-publish-media__preview { aspect-ratio: 16 / 9 }` + * (`src/components/game-distribution/gameDistribution.css:1645`) + * - 发布面板的截图/占位 `.game-publish-media__shot img|placeholder { aspect-ratio: 16 / 9 }` + * (同文件 `:1733`、`:1740`) + * - 详情页媒体区同样按 16 / 9(`packages/shared/src/components/GameDetailDisplay/index.css:114`) + * + * 节点卡片按这个比例给封面留位,所以「竖图当封面」的问题从容器一侧就被消除。 + */ +export const LINEAGE_COVER_ASPECT = 16 / 9; + +export const LINEAGE_NODE_WIDTH = 240; +/** 卡片高度 = 16:9 封面 + 信息条(标题 + 作者)。 */ +export const LINEAGE_NODE_HEIGHT = 192; /** 同一层相邻槽位之间额外留的横向间隙(保证不重叠)。 */ export const LINEAGE_COLUMN_GAP = 32; /** 相邻代际之间的纵向间隙。 */