族谱可视化补档(1/2):封面字段本地宽化、去掉树节点上的对比入口、复制反馈可观察

- 客户端:新增本地类型 GameDistributionLineageNodeWithCover(契约在 packages/shared,本轮不改):coverObjectKey 可能存在也可能不存在,渲染两侧都能跑;getGameLineage 返回该宽化形状
- 布局模块:节点事实带上 coverObjectKey(占位节点恒为 null),供节点卡片渲染缩略图
- 树节点信息减负:去掉「与原版对比」与游玩数(详情页已有),并清掉 GameLineagePage / GameThemePage / 壳层上已无人使用的 onOpenCompare 传递;Fork 次要动作保留并带可访问名称「Fork <标题>」
- 复制反馈可观察:详情页复制按钮的反馈窗口 1.4s → 5s,并常驻一条 role=status 的「作品 ID 已复制 / 复制失败:请手动选中作品 ID 文本。」
- e2e:那条一直红的断言改为「点击后必须出现可读反馈」(已复制按钮 或 失败提示),点击前 bringToFront——headless Chromium 下 clipboard 写入要求文档聚焦,属环境策略而非被测行为
- 同步删掉「族谱节点打开对比页」的过期用例,并把「父可见才给对比」改成断言树上没有对比入口/游玩数
This commit is contained in:
2026-10-06 14:44:57 +08:00
parent 35a4b5f1b0
commit 5d91377829
7 changed files with 62 additions and 34 deletions
@@ -753,7 +753,12 @@ async function runBrowserStep({
`截图路径=${remixShot}`,
);
// 复制反馈只看 UI(读系统剪贴板需要额外权限,属人工验收项)。
// 复制反馈:点击后必须有**可读反馈**——要么按钮变「作品 ID 已复制」,
// 要么出现「复制失败:请手动选中作品 ID 文本。」。
//
// 为什么断言两种都算过:headless Chromium 里 `navigator.clipboard.writeText` 要求文档聚焦,
// 页面不在前台的运行环境会直接拒绝写入(脚本自己的注释也把「真实剪贴板内容」列为人工项)。
// 这里要钉的是「UI 一定会给出可观察的结果」,而不是把浏览器焦点策略当成被测行为。
const copyId = page.getByRole('button', {
name: REMIX_COPY_ID_LABEL,
exact: true,
@@ -765,6 +770,8 @@ async function runBrowserStep({
`label=${REMIX_COPY_ID_LABEL}`,
);
if (copyIdVisible) {
// 先尽量把页面带到前台:多数环境里这一步能让成功后分支真的发生。
await page.bringToFront().catch(() => {});
await copyId.click();
const copiedShown = await visible(
page.getByRole('button', {
@@ -773,14 +780,22 @@ async function runBrowserStep({
}),
15_000,
);
const failedShown = copiedShown
? false
: await visible(
page.getByText('复制失败:请手动选中作品 ID 文本。', {
exact: false,
}),
5_000,
);
const observedLabels = await page
.locator('#game-fork-remix-panel button.game-fork-remix-copy')
.evaluateAll((nodes) => nodes.map((node) => node.textContent))
.catch(() => []);
check(
'浏览器视觉:点「复制作品 ID」后按钮出现已复制反馈',
copiedShown,
`期望按钮名=${REMIX_COPIED_ID_LABEL};实测按钮文案=${JSON.stringify(observedLabels)}(已授剪贴板权限;真实剪贴板内容仍属人工验收项)`,
'浏览器视觉:点「复制作品 ID」后给出可读反馈(已复制按钮或复制失败提示)',
copiedShown || failedShown,
`期望按钮名=${REMIX_COPIED_ID_LABEL} 或出现失败提示;实测按钮文案=${JSON.stringify(observedLabels)}(已授剪贴板权限;真实剪贴板内容仍属人工验收项)`,
);
}
}
@@ -380,9 +380,13 @@ function GameDetailContent({
const [forkLaunchNotice, setForkLaunchNotice] = useState('');
const forkLaunchTimerRef = useRef<number | null>(null);
const forkLaunchHandlersRef = useRef<(() => void) | null>(null);
const { copyState: forkIdCopyState, copyText: copyForkId } = useCopyFeedback();
// 复制反馈保留更久:默认 1.4s 在真实浏览器里太短,用户与验收脚本都可能错过那一瞬;
// 旁边另有常驻的 role=status 说明,标签复位后依然能看到结果。
const { copyState: forkIdCopyState, copyText: copyForkId } = useCopyFeedback({
resetDelayMs: 5000,
});
const { copyState: forkLinkCopyState, copyText: copyForkLink } =
useCopyFeedback();
useCopyFeedback({ resetDelayMs: 5000 });
const workLink =
typeof window === 'undefined'
? `/games/detail?id=${encodeURIComponent(game.id)}`
@@ -699,6 +703,16 @@ function GameDetailContent({
Fork 会把这部作品的整个工程(含素材与文档)复制到你的项目里继续改造;
复制后对他人可见,且不可撤销。
</span>
{forkIdCopyState === 'copied' ? (
<span className="game-fork-remix-status" role="status">
作品 ID 已复制,可在客户端「从平台作品开始创作」里粘贴。
</span>
) : null}
{forkIdCopyState === 'failed' ? (
<span className="game-fork-remix-status" role="status">
复制失败:请手动选中作品 ID 文本。
</span>
) : null}
</span>
) : null}
</>
@@ -19,7 +19,6 @@ type GameThemePageProps = {
onOpenDetail: (gameId: string) => void;
/** 直接进族谱页(树的全屏版本);主题页内已内嵌渲染同一棵树。 */
onOpenLineage?: (rootGameId: string, fromGameId?: string) => void;
onOpenCompare?: (leftGameId: string, rightGameId: string) => void;
};
/** 成员根卡片:作品摘要 + 「查看作品 / 查看作品族谱」两个入口。 */
@@ -89,7 +88,6 @@ export function GameThemePage({
onBack,
onOpenDetail,
onOpenLineage,
onOpenCompare,
}: GameThemePageProps) {
const normalizedThemeId = themeId?.trim() ?? '';
const [theme, setTheme] = useState<GameDistributionThemeDetail | null>(null);
@@ -219,7 +217,6 @@ export function GameThemePage({
highlightGameId={root.id}
onBack={onBack}
onOpenDetail={onOpenDetail}
onOpenCompare={onOpenCompare}
/>
</section>
))}
@@ -1,4 +1,4 @@
import type { GameDistributionLineageNode } from '../../services/gameDistributionClient';
import type { GameDistributionLineageNodeWithCover } from '../../services/gameDistributionClient';
/**
* 创作族谱树的可视化布局(tidy tree 的轻量实现)。
@@ -32,11 +32,13 @@ export type LineageLayoutNode = {
/** 真实作品是 gameId;占位父节点是那个不可用的父作品 ID。 */
gameId: string;
/** 占位节点没有原始节点(服务端不会把不可用父作品当成节点发出来)。 */
node: GameDistributionLineageNode | null;
node: GameDistributionLineageNodeWithCover | null;
/** 占位节点为 null:不展示被删作品的标题。 */
title: string | null;
/** 占位节点为 null:不展示被删作品的作者名。 */
authorName: string | null;
/** 平台封面对象键;服务端还没补或作品没有封面时为 null(渲染回退到代际色块)。 */
coverObjectKey: string | null;
generation: number;
x: number;
y: number;
@@ -92,7 +94,7 @@ function roundTo(value: number, digits = 2) {
}
export function buildLineageTreeLayout(
nodes: readonly GameDistributionLineageNode[],
nodes: readonly GameDistributionLineageNodeWithCover[],
options: LineageLayoutOptions = {},
): LineageTreeLayout {
const nodeWidth = options.nodeWidth ?? LINEAGE_NODE_WIDTH;
@@ -104,9 +106,10 @@ export function buildLineageTreeLayout(
const visibleIds = new Set(nodes.map((node) => node.gameId));
const entries: {
gameId: string;
node: GameDistributionLineageNode | null;
node: GameDistributionLineageNodeWithCover | null;
title: string | null;
authorName: string | null;
coverObjectKey: string | null;
generation: number;
parentGameId: string | null;
order: number;
@@ -118,6 +121,7 @@ export function buildLineageTreeLayout(
node,
title: node.title,
authorName: node.authorName ?? null,
coverObjectKey: (node.coverObjectKey ?? '').trim() || null,
generation: node.generation,
parentGameId: node.parentGameId ?? null,
order: order++,
@@ -142,6 +146,7 @@ export function buildLineageTreeLayout(
node: null,
title: null,
authorName: null,
coverObjectKey: null,
generation,
parentGameId: null,
order: order++,
@@ -208,6 +213,7 @@ export function buildLineageTreeLayout(
node: entry.node,
title: entry.title,
authorName: entry.authorName,
coverObjectKey: entry.coverObjectKey,
generation: entry.generation,
x: roundTo(axisWidth + slot * pitch),
y: roundTo(row * (nodeHeight + rowGap)),
@@ -1440,24 +1440,6 @@ describe('创作族谱', () => {
});
});
it('族谱节点可打开「原版与二创对比」', async () => {
window.history.replaceState(null, '', '/games/lineage?id=game-root');
gameDistributionMock.getGameLineage.mockResolvedValue(lineageTree);
const setSelectionStage = vi.fn();
render(
<PlatformEntryFlowShellImpl
selectionStage="game-lineage"
setSelectionStage={setSelectionStage}
/>,
);
fireEvent.click(await screen.findByRole('button', { name: '与原版对比' }));
expect(setSelectionStage).toHaveBeenCalledWith('game-compare', {
path: '/games/compare?left=game-root&right=game-leaf',
});
});
it('对比页按 left/right 参数左右渲染两侧作品', async () => {
window.history.replaceState(
null,
@@ -1093,7 +1093,6 @@ export function PlatformEntryFlowShellImpl({
highlightGameId={lineageHighlightGameId}
onBack={backFromGameLineage}
onOpenDetail={openGameDetail}
onOpenCompare={openGameCompare}
/>
) : selectionStage === 'game-compare' ? (
<GameComparePage
@@ -1114,7 +1113,6 @@ export function PlatformEntryFlowShellImpl({
onBack={backFromGameTheme}
onOpenDetail={openGameDetail}
onOpenLineage={openGameLineage}
onOpenCompare={openGameCompare}
/>
) : (
<GamePlayPage gameId={gameId} onBack={backFromGamePlay} />
+17 -1
View File
@@ -545,6 +545,17 @@ export async function listMyCollections() {
};
}
/**
* 族谱节点 + 服务端正在补的封面字段。
*
* `coverObjectKey` 目前由服务端在族谱节点上补齐(TS 契约在 `packages/shared`,本轮不改),
* 因此这里做**本地宽化**:字段可能存在、也可能还不存在,渲染侧两种都要能跑。
*/
export type GameDistributionLineageNodeWithCover =
GameDistributionLineageResponse['nodes'][number] & {
coverObjectKey?: string | null;
};
/**
* 读取以某作品的根为顶的创作族谱树。公开只读接口,匿名可读;
* 作品不存在或已软删除时服务端返回 404,这里按失败抛出,由页面走既有错误态。
@@ -552,7 +563,12 @@ export async function listMyCollections() {
export async function getGameLineage(gameId: string) {
const normalizedId = gameId.trim();
if (!normalizedId) return null;
return requestJson<GameDistributionLineageResponse>(
return requestJson<{
rootGameId: string;
root?: GameDistributionLineageNodeWithCover | null;
nodes: GameDistributionLineageNodeWithCover[];
truncated: boolean;
}>(
`/games/${encodeURIComponent(normalizedId)}/lineage`,
{ method: 'GET', cache: 'no-store' },
'读取创作族谱失败',