族谱卡:封面 16:9 横版、轴统一主题色、控件入画布浮层、去掉第三个动作
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m57s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m8s
Project CI / Repository checks (pull_request) Failing after 42s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m21s
Project CI / Frontend tests (pull_request) Successful in 2m49s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m26s
Project CI / Native shell tests (pull_request) Successful in 7m6s

This commit is contained in:
2026-10-06 15:28:00 +08:00
parent ce4a50532b
commit 6cea0a8cd9
6 changed files with 268 additions and 96 deletions
@@ -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)}`,
);
}
}
@@ -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 () => {
@@ -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);
}}
>
<span className="game-lineage-node__cover">
<span
className="game-lineage-node__cover"
// 16:9 横版(与发布面板封面预览同一约定):容器比例由常量给出,CSS 不再另写一套。
style={{ aspectRatio: LINEAGE_COVER_ASPECT }}
>
<GameCover
game={coverGame}
objectKey={layoutNode.coverObjectKey}
@@ -221,23 +225,6 @@ function LineageTreeNodeCard({
>
Fork
</button>
<button
type="button"
className="game-lineage-node__pin"
aria-pressed={isPinned}
aria-label={
isPinned
? `取消选中 ${displayTitle} 的路径`
: `选中 ${displayTitle} 的路径`
}
onClick={(event) => {
// 键盘可达的「选中路径」:点击版的同一件事。
event.stopPropagation();
onTogglePin(isPinned ? '' : gameId);
}}
>
{isPinned ? '已选中' : '选中路径'}
</button>
</span>
</>
)}
@@ -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({
<span className="game-eyebrow">创作族谱</span>
<h2>{lineage?.root?.title?.trim() || '创作族谱'}</h2>
</div>
<div className="game-toolbar__actions">
<PlatformActionButton
tone="secondary"
size="sm"
shape="pill"
onClick={() => zoomBy(1 / 1.2)}
aria-label="缩小"
>
<Minus aria-hidden="true" />
</PlatformActionButton>
<span className="game-lineage-zoom" role="status">
{Math.round(viewport.scale * 100)}%
{nodeCountLabel ? (
<span className="game-lineage-summary" role="status">
{nodeCountLabel}
</span>
<PlatformActionButton
tone="secondary"
size="sm"
shape="pill"
onClick={() => zoomBy(1.2)}
aria-label="放大"
>
<Plus aria-hidden="true" />
</PlatformActionButton>
<PlatformActionButton
tone="secondary"
size="sm"
shape="pill"
onClick={() => fitToView(true)}
aria-label="适应窗口"
>
<Expand aria-hidden="true" /> 适应窗口
</PlatformActionButton>
<PlatformActionButton
tone="secondary"
size="sm"
shape="pill"
onClick={() => void loadLineage()}
disabled={isLoading}
>
<RefreshCcw aria-hidden="true" /> 刷新
</PlatformActionButton>
</div>
) : null}
</div>
)}
@@ -776,12 +732,15 @@ export function GameLineagePage({
因此放大或平移后轴标签始终在画布左侧可读,而不会被内容层带出去裁掉。
*/}
<div className="game-lineage-canvas__axis" aria-hidden="true">
{layout.axis.map((entry) => (
{layout.axis.map((entry, index) => (
<span
key={entry.generation}
className="game-lineage-axis"
style={{
top: viewport.offsetY + entry.y * viewport.scale,
// 行高跟着缩放走:轴上的行与画布里的行始终对齐。
height: (layout.nodes[0]?.height ?? 0) * viewport.scale,
borderTopWidth: index === 0 ? 0 : 1,
}}
>
<span className="game-lineage-axis__label">
@@ -793,6 +752,68 @@ export function GameLineagePage({
</span>
))}
</div>
{/*
画布内悬浮控件:放在**右下角**——左侧是代际轴、顶部是路径面包屑,
右下角是地图/画布类应用的习惯位置,也最不容易遮住根节点与主干。
*/}
<div
className="game-lineage-controls"
role="group"
aria-label="画布控件"
// 悬浮控件不是画布内容:按下时不要触发画布平移。
onPointerDown={(event) => event.stopPropagation()}
>
<button
type="button"
className="game-lineage-controls__button"
aria-label="缩小"
title="缩小"
disabled={viewport.scale <= MIN_SCALE + 0.001}
onClick={() => zoomBy(1 / 1.2)}
>
<Minus aria-hidden="true" />
</button>
<span className="game-lineage-zoom" role="status">
{Math.round(viewport.scale * 100)}%
</span>
<button
type="button"
className="game-lineage-controls__button"
aria-label="放大"
title="放大"
disabled={viewport.scale >= MAX_SCALE - 0.001}
onClick={() => zoomBy(1.2)}
>
<Plus aria-hidden="true" />
</button>
<span
className="game-lineage-controls__divider"
aria-hidden="true"
/>
<button
type="button"
className="game-lineage-controls__button game-lineage-controls__button--wide"
aria-label="适应窗口"
title="适应窗口"
onClick={() => fitToView(true)}
>
<Expand aria-hidden="true" />
<span className="game-lineage-controls__label">
适应窗口
</span>
</button>
<button
type="button"
className="game-lineage-controls__button game-lineage-controls__button--wide"
aria-label="刷新"
title="刷新"
disabled={isLoading}
onClick={() => void loadLineage()}
>
<RefreshCcw aria-hidden="true" />
<span className="game-lineage-controls__label">刷新</span>
</button>
</div>
</div>
{derivedCount === 0 ? (
<PlatformEmptyState surface="subpanel" size="panel">
@@ -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%;
@@ -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);
@@ -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;
/** 相邻代际之间的纵向间隙。 */