图谱卡改用广场卡组件(GameCard):卡面去掉动作按钮、整卡点击进详情、角标走卡面 badge 槽;共享卡纯加法扩展(结构化子集+badges+className)
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

This commit is contained in:
2026-10-07 16:50:40 +08:00
parent 832c1787f9
commit 5905eaabf9
7 changed files with 403 additions and 249 deletions
@@ -4,18 +4,59 @@ import type { ReactNode } from 'react';
import type { GameDistributionGame } from '../../contracts/gameDistribution';
import { GameRatingSummaryText } from '../GameRatingSummaryText';
/**
* 卡面真正用到的字段(结构化子集)。
*
* 为什么收窄成子集:族谱/血缘图里的节点只有公开投影(标题、作者名、游玩数、封面、代际…),
* 拿不到 `summary` / `category` / `ratingSummary`。把它写成子集后,**图谱卡与广场卡用的是
* 同一个组件**(不是复制一份看起来一样的样式),缺失的字段行**不渲染**而不是渲染空行。
* 完整的 `GameDistributionGame` 仍然可以直接传进来(向后兼容)。
*/
export type GameCardModel = {
title: string;
playCount: number;
summary?: string | null;
category?: string | null;
ratingSummary?: GameDistributionGame['ratingSummary'] | null;
author?: { name?: string | null } | null;
};
export function GameCard({
game,
cover,
badges,
onOpen,
className,
}: {
game: GameDistributionGame;
game: GameCardModel;
cover: ReactNode;
/**
* 卡面角标槽(可选):图谱用「第 N 代 / 主卡 / ▶N 游玩合计」这类**标记**,
* 不承载动作按钮 —— 卡本身就是一个整体入口。
*/
badges?: ReactNode;
onOpen: () => void;
className?: string;
}) {
const summary = (game.summary ?? '').trim();
const category = (game.category ?? '').trim();
const authorName = (game.author?.name ?? '').trim();
/**
* 行存在性按「**字段是否在对象上**」判断,而不是按值是否为空:
* 广场/共创页签传的是完整作品对象(字段都在,哪怕是空串/未评分)→ 渲染行为与改动前**完全一致**;
* 族谱节点只有一个公开投影(字段真的不在)→ 不渲染空行。这样扩展是**纯加法**。
*/
const hasField = (key: PropertyKey) =>
Object.prototype.hasOwnProperty.call(game, key);
return (
<button type="button" className="game-card" onClick={onOpen}>
<button
type="button"
className={className ? `game-card ${className}` : 'game-card'}
onClick={onOpen}
>
{cover}
{badges ? <span className="game-card__badges">{badges}</span> : null}
<span className="game-card__body">
<span className="game-card__title-row">
<strong>{game.title}</strong>
@@ -23,15 +64,28 @@ export function GameCard({
{game.playCount.toLocaleString()} 次游玩
</span>
</span>
<span className="game-card__summary">{game.summary}</span>
<span className="game-card__rating">
<GameRatingSummaryText summary={game.ratingSummary} />
</span>
<span className="game-card__meta">
<span>{game.category}</span>
<span>·</span>
<span>{game.author.name}</span>
</span>
{hasField('summary') || summary ? (
<span className="game-card__summary">{game.summary}</span>
) : null}
{hasField('ratingSummary') || game.ratingSummary ? (
<span className="game-card__rating">
<GameRatingSummaryText summary={game.ratingSummary ?? undefined} />
</span>
) : null}
{hasField('category') ||
hasField('author') ||
category ||
authorName ? (
<span className="game-card__meta">
{hasField('category') || category ? (
<span>{game.category}</span>
) : null}
{hasField('category') || category ? <span>·</span> : null}
{hasField('author') || authorName ? (
<span>{game.author?.name}</span>
) : null}
</span>
) : null}
</span>
</button>
);
@@ -63,3 +63,24 @@
color: var(--platform-text-soft);
font-size: 0.7rem;
}
/* 卡面角标槽(可选):图谱用「第 N 代 / 主卡 / ▶N」这类标记;仍然整卡可点,不承载动作按钮。 */
.game-card {
position: relative;
}
.game-card__badges {
position: absolute;
top: 0.55rem;
left: 0.55rem;
right: 0.55rem;
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
pointer-events: none;
}
/* 图谱卡是高而窄的卡:标题允许两行(广场卡仍是单行省略)。 */
.game-card--lineage .game-card__title-row strong {
white-space: normal;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
@@ -1785,7 +1785,7 @@ describe('GameLineagePage', () => {
// 父作品不在可见集合里:只标注「原作品已不可用」,不回显父作品内容。
expect(screen.getByText('原作品已不可用')).toBeTruthy();
// 任一公开节点都能进入对应详情页。
fireEvent.click(screen.getByRole('button', { name: '打开《母版》详情' }));
fireEvent.click(openButton('母版'));
expect(onOpenDetail).toHaveBeenCalledWith('game-root');
});
@@ -1873,9 +1873,7 @@ describe('GameLineagePage', () => {
/>,
);
expect(
await screen.findByRole('button', { name: '打开《母版》详情' }),
).toBeTruthy();
expect(await cardOf('母版')).toBeTruthy();
expect(
screen.getByText('这部作品还没有衍生作品,欢迎从它开始共创。'),
).toBeTruthy();
@@ -2386,10 +2384,12 @@ describe('原版与二创对比入口', () => {
expect(chip.getAttribute('aria-label')).toContain('含衍生作品');
// 「与原版对比」仍然留在详情页,不在树里重复渲染。
expect(screen.queryByRole('button', { name: '与原版对比' })).toBeNull();
expect(screen.queryByText(/次游玩/u)).toBeNull();
// 卡面**没有**动作按钮(整卡即入口);游玩数由卡片自身承载(子树合计口径)。
expect(screen.queryByRole('button', { name: '打开作品' })).toBeNull();
expect(
screen.getByRole('button', { name: '开始共创 叶节点' }),
).toBeTruthy();
screen.queryByRole('button', { name: '开始共创 叶节点' }),
).toBeNull();
expect(within(await cardOf('叶节点')).getByText(/次游玩/u)).toBeTruthy();
});
});
@@ -2931,24 +2931,45 @@ const relationTree = {
* 为什么不用「先拿容器再查」:容器(画布/大纲)是异步挂载的,基于容器的查询可能早于挂载而回退到
* 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;
/** 树里的节点卡(外层定位/状态 wrapper)。 */
const treeNodeCards = () =>
[...document.querySelectorAll('.game-lineage-node')].filter(
(node) => !node.closest('.lineage-relations'),
);
/** 卡内标题文本(卡现在就是广场卡组件:标题在 `.game-card__title-row > strong`)。 */
const cardTitleOf = (node: Element) =>
(
node.querySelector('.game-card__title-row > strong')?.textContent ?? ''
).trim();
/** 按标题找树里的节点卡(wrapper)——等它渲染出来。 */
const cardOf = async (title: string) =>
waitFor(() => {
const found = treeNodeCards().find((node) => cardTitleOf(node) === title);
if (!found) throw new Error(`树里还没有《${title}》`);
return found as HTMLElement;
});
return button.closest('.game-lineage-node') as HTMLElement;
/**
* 节点卡**本身就是一个按钮**(广场卡的整体点击入口)——没有「打开作品」这类卡内按钮。
* 需要「点开详情」时点它即可。
*/
const cardButtonOf = (node: Element) =>
node.querySelector('.game-card') as HTMLElement | null;
const openButton = (title: string) => {
const node = treeNodeCards().find((item) => cardTitleOf(item) === title);
const button = node ? cardButtonOf(node) : null;
if (!button) throw new Error(`树里找不到《${title}》卡片`);
return button;
};
/** 在树区域里按文本查找(等挂载 + 排除关系块)。 */
const findTreeText = async (text: string) => {
@@ -3016,7 +3037,7 @@ describe('GameLineagePage 可视化与交互', () => {
'.game-lineage-canvas',
) as HTMLElement;
expect(
Array.from(canvas.querySelectorAll('.game-lineage-node__author'))
Array.from(canvas.querySelectorAll('.game-card__meta'))
.map((element) => element.textContent)
.sort(),
).toEqual(['一代作者', '当前作者', '根作者']);
@@ -3027,7 +3048,8 @@ describe('GameLineagePage 可视化与交互', () => {
[...canvas.querySelectorAll('.game-lineage-node')]
.map(
(card) =>
card.querySelector('.game-lineage-node__title')?.textContent ?? '',
card.querySelector('.game-card__title-row > strong')?.textContent ??
'',
)
.sort();
@@ -3347,42 +3369,39 @@ describe('GameLineagePage 可视化与交互', () => {
}
});
it('画布卡片:角标移到封面下方、按钮「打开作品 / 开始共创」等权重主次分明、作者行独立一行', async () => {
it('画布卡片就是广场卡组件:角标走卡面 badge 槽、卡内没有动作按钮、整卡点击进详情', async () => {
getGameLineageMock.mockResolvedValueOnce(relationTree);
const onOpenDetail = vi.fn();
render(
<GameLineagePage
rootGameId="game-root"
highlightGameId="game-current"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
onOpenDetail={onOpenDetail}
/>,
);
const card = await cardOf('当前作品');
const content = card.querySelector('.game-lineage-node__content')!;
const cover = content.querySelector('.game-lineage-node__cover')!;
// ① 角标**不再压在封面里**:封面内没有任何角标;角标组是内容区的第二块(封面之后、文字之前)。
expect(cover.querySelector('.game-lineage-node__badges')).toBeNull();
expect(cover.querySelector('.game-lineage-node__trunk')).toBeNull();
const blockOrder = [...content.children].map((el) => el.className);
expect(blockOrder[0]).toContain('game-lineage-node__cover');
expect(blockOrder[1]).toContain('game-lineage-node__badges');
expect(blockOrder[2]).toContain('game-lineage-node__text');
// ② 两个等权重按钮:文案与主次类名固定(「开始共创」是本次改名)。
const openButton = within(card).getByRole('button', { name: '打开作品' });
const coCreate = within(card).getByRole('button', {
name: '开始共创 当前作品',
});
expect(openButton.className).toContain('game-lineage-node__open-action');
expect(coCreate.className).toContain('game-lineage-node__fork');
expect(coCreate.textContent).toBe('开始共创');
// ③ 作者行独立一行(`.game-lineage-node__meta > __author`),且不与标题同一节点。
const meta = card.querySelector('.game-lineage-node__meta')!;
expect(meta.querySelector('.game-lineage-node__author')).toBeTruthy();
expect(meta.contains(card.querySelector('.game-lineage-node__title'))).toBe(
false,
const cardButton = cardButtonOf(card)!;
// ① 卡面 = 广场卡组件本身(同一个 `.game-card`),角标走它的 badge 槽。
expect(cardButton.className).toContain('game-card');
const badges = cardButton.querySelector(
'.game-card__badges',
) as HTMLElement;
expect(badges).toBeTruthy();
expect(within(badges).getByText('当前作品')).toBeTruthy();
// ② 卡内**没有任何动作按钮**(打开作品 / 开始共创 都已从卡面移除)。
expect(within(cardButton).queryByRole('button')).toBeNull();
expect(within(cardButton).queryByText('打开作品')).toBeNull();
expect(within(cardButton).queryByText('开始共创')).toBeNull();
// ③ 作者走广场卡的 meta 行。
expect(cardButton.querySelector('.game-card__meta')?.textContent).toMatch(
/作者/u,
);
// CSS 契约(jsdom 不算样式):等权重、触控目标、主次配色、角标配色一律取自平台变量。
// ④ 整卡点击 = 进详情。
fireEvent.click(cardButton);
expect(onOpenDetail).toHaveBeenCalledWith('game-current');
// ⑤ 卡面适配与角标配色(沿用上一轮的变体色,未新增色值)。
const css = await import('node:fs').then((fs) =>
fs.readFileSync(
'src/components/game-distribution/gameDistribution.css',
@@ -3390,18 +3409,8 @@ describe('GameLineagePage 可视化与交互', () => {
),
);
expect(css).toMatch(
/\.game-lineage-node__actions > button \{[\s\S]{0,500}?flex: 1 1 0;/u,
/\.game-card--lineage \.game-cover \{[\s\S]{0,200}?aspect-ratio: 16 \/ 9;/u,
);
expect(css).toMatch(
/\.game-lineage-node__actions > button \{[\s\S]{0,500}?min-height: 2rem;/u,
);
expect(css).toMatch(
/\.game-lineage-node__fork \{[\s\S]{0,300}?--platform-button-primary-solid/u,
);
expect(css).toMatch(
/\.game-lineage-node__open-action \{[\s\S]{0,300}?--platform-button-secondary-fill/u,
);
// 角标配色表:主干=暖色、当前作品=主色实心、父代=cool、子代=success、代际/游玩数=中性。
expect(css).toMatch(
/\.game-lineage-node__trunk \{[\s\S]{0,300}?--platform-warm-bg/u,
);
@@ -3414,18 +3423,7 @@ describe('GameLineagePage 可视化与交互', () => {
expect(css).toMatch(
/\.game-lineage-node__relation--descendant \{[\s\S]{0,300}?--platform-success-bg/u,
);
expect(css).toMatch(
/\.game-lineage-node__badge \{[\s\S]{0,300}?--platform-neutral-bg/u,
);
// 角标不再用「深色药丸浮在图上」的写法(旧的 rgba(0,0,0,.55/.62) 已删除)。
expect(css).not.toMatch(
/game-lineage-node__badge \{[\s\S]{0,200}?rgba\(0, 0, 0/u,
);
expect(css).not.toMatch(
/game-lineage-node__play-count \{[\s\S]{0,200}?rgba\(0, 0, 0/u,
);
});
it('大纲行:按钮改名「打开作品 / 开始共创」,共创按钮用主要(填充)样式', async () => {
setViewportWidth(375);
getGameLineageMock.mockResolvedValueOnce(relationTree);
@@ -3570,11 +3568,9 @@ describe('GameLineagePage 可视化与交互', () => {
fireEvent.pointerDown(canvas, { pointerId: 1, clientX: 100, clientY: 100 });
expect(canvas.className).not.toContain('game-lineage-canvas--panning');
expect(document.body.style.userSelect).toBe('');
const openButton = screen
.getAllByRole('button', { name: '打开作品' })
.find((button) => button.closest('.lineage-relations') === null);
expect(openButton).toBeTruthy();
fireEvent.click(openButton as HTMLElement);
const cardButton = cardButtonOf(await cardOf('叶节点'));
expect(cardButton).toBeTruthy();
fireEvent.click(cardButton as HTMLElement);
expect(onOpenDetail).toHaveBeenCalledTimes(1);
fireEvent.pointerUp(canvas, { pointerId: 1, clientX: 100, clientY: 100 });
@@ -3654,14 +3650,21 @@ describe('GameLineagePage 可视化与交互', () => {
expect(document.activeElement).toBe(leafOpen);
fireEvent.click(leafOpen);
expect(onOpenDetail).toHaveBeenCalledWith('game-leaf');
fireEvent.click(within(leafCard).getByRole('button', { name: '打开作品' }));
expect(onOpenDetail).toHaveBeenCalledWith('game-leaf');
// 卡面没有动作按钮:整卡(`.game-card`)点击就是「进详情」。
expect(
within(leafCard).queryByRole('button', { name: '打开作品' }),
).toBeNull();
expect(
within(leafCard).queryByRole('button', { name: /开始共创/u }),
).toBeNull();
// 卡片主体点击 = 选中(不是打开详情),再点一次取消。
fireEvent.click(leafCard);
// 整卡点击 = 进详情(选中/高亮改由悬停或键盘聚焦驱动)。
fireEvent.click(cardButtonOf(leafCard) as HTMLElement);
expect(onOpenDetail).toHaveBeenCalledTimes(2);
// 选中/高亮是 hover 语义:移入出现、移开消失(点击不再兼做选中)。
fireEvent.mouseEnter(leafCard);
expect(leafCard.className).toContain('game-lineage-node--pinned');
fireEvent.click(leafCard);
fireEvent.mouseLeave(leafCard);
expect(leafCard.className).not.toContain('game-lineage-node--pinned');
});
@@ -3711,7 +3714,7 @@ describe('GameLineagePage 可视化与交互', () => {
/>,
);
await screen.findByRole('button', { name: '打开《母版》详情' });
await cardOf('母版');
// 初次适配在 effect 里落地:先等它生效(jsdom 无布局尺寸 → min(scaleX, scaleY) 远小于 1,
// 因此命中可读下限 60%)。
await waitFor(() =>
@@ -3738,7 +3741,7 @@ describe('GameLineagePage 可视化与交互', () => {
/>,
);
await screen.findByRole('button', { name: '打开《母版》详情' });
await cardOf('母版');
const axisLayer = document.querySelector('.game-lineage-canvas__axis');
expect(axisLayer).not.toBeNull();
expect(axisLayer?.querySelectorAll('.game-lineage-axis').length).toBe(3);
@@ -3809,7 +3812,8 @@ describe('GameLineagePage 可视化与交互', () => {
);
const leafCard = await cardOf('叶节点');
fireEvent.click(leafCard);
// 选中/高亮由悬停或键盘聚焦驱动(点击现在是「进详情」)。
fireEvent.mouseEnter(leafCard);
// 面包屑:母版 → 中间代 → 叶节点,祖先移出视口也能读。
const breadcrumb = screen.getByLabelText('已选中的路径');
@@ -3832,7 +3836,7 @@ describe('GameLineagePage 可视化与交互', () => {
document.querySelectorAll('path.game-lineage-edge--active').length,
).toBeGreaterThan(0);
fireEvent.click(leafCard);
fireEvent.mouseLeave(leafCard);
expect(screen.queryByLabelText('已选中的路径')).toBeNull();
expect((await cardOf('旁支')).className).not.toContain(
'game-lineage-node--dimmed',
@@ -3849,7 +3853,7 @@ describe('GameLineagePage 可视化与交互', () => {
/>,
);
await screen.findByRole('button', { name: '打开《母版》详情' });
await cardOf('母版');
const zoomLabel = () =>
document.querySelector('.game-lineage-zoom')?.textContent ?? '';
// 初次适配是 effect 里跑的:先等它落定(jsdom 无布局尺寸 → 命中可读下限 60%)。
@@ -3863,7 +3867,7 @@ describe('GameLineagePage 可视化与交互', () => {
expect(zoomLabel()).toBe('35%');
});
it('节点的「开始共创」次要动作不触发导航;未失焦时给出降级提示与复制入口', async () => {
it('图谱卡不再打印动作:卡内没有「打开作品 / 开始共创」,整卡点击只进详情', async () => {
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
const onOpenDetail = vi.fn();
render(
@@ -3875,27 +3879,15 @@ describe('GameLineagePage 可视化与交互', () => {
);
const leafCard = await cardOf('叶节点');
const forkButton = within(leafCard).getByRole('button', {
name: '开始共创 叶节点',
});
vi.useFakeTimers();
fireEvent.click(forkButton);
// 次要动作直通唤起,且不抢主点击。
expect(openForkProtocolMock).toHaveBeenCalledWith('game-leaf');
expect(onOpenDetail).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(FORK_LAUNCH_FALLBACK_DELAY_MS + 10);
});
expect(screen.getByText(/没有检测到陶泥儿客户端/u)).toBeTruthy();
fireEvent.click(
screen.getByRole('button', { name: /复制 game-leaf 去开始共创/u }),
);
await act(async () => {});
expect(copyTextToClipboardMock).toHaveBeenCalledWith('game-leaf');
vi.useRealTimers();
const cardButton = cardButtonOf(leafCard)!;
expect(within(cardButton).queryByRole('button')).toBeNull();
expect(within(cardButton).queryByText('开始共创')).toBeNull();
expect(within(cardButton).queryByText('打开作品')).toBeNull();
// 整卡点击 = 进详情(唤起客户端的主入口在详情页主操作行 / 大纲行)。
fireEvent.click(cardButton);
expect(onOpenDetail).toHaveBeenCalledWith('game-leaf');
expect(openForkProtocolMock).not.toHaveBeenCalled();
});
it('不可用父节点:可聚焦、不可点进详情,且不出现被删作品的标题与作者名', async () => {
getGameLineageMock.mockResolvedValueOnce({
rootGameId: 'game-root',
@@ -3927,8 +3919,8 @@ describe('GameLineagePage 可视化与交互', () => {
// 被删作品只以「不可用」形式出现:没有标题、没有作者名、也没有开始共创/对比动作。
expect(document.querySelector('.game-lineage-node--stub')).toBeTruthy();
expect(screen.queryByText(/作者 game-deleted/u)).toBeNull();
expect(within(stubCard).queryByRole('button')).toBeNull();
fireEvent.click(stubCard);
// 占位节点渲染的仍是广场卡(本身是 button),但点击不导航。
fireEvent.click(cardButtonOf(stubCard) as HTMLElement);
expect(onOpenDetail).not.toHaveBeenCalled();
});
});
@@ -4135,7 +4127,7 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐)
const hotCard = await cardOf('热门二创');
// 面板只在选中后出现。
expect(screen.queryByLabelText('选中作品信息')).toBeNull();
fireEvent.click(hotCard);
fireEvent.mouseEnter(hotCard);
const panel = await screen.findByLabelText('选中作品信息');
expect(
@@ -4332,7 +4324,7 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐)
);
expect(screen.queryByLabelText('创作族谱大纲')).toBeNull();
expect(screen.getByLabelText('画布控件')).toBeTruthy();
fireEvent.click(await cardOf('热门二创'));
fireEvent.mouseEnter(await cardOf('热门二创'));
expect(await screen.findByLabelText('选中作品信息')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '大纲视图' }));
expect(await screen.findByLabelText('创作族谱大纲')).toBeTruthy();
@@ -4400,8 +4392,9 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐)
expect(
canvas.querySelectorAll('.game-lineage-node--compact').length,
).toBe(cards.length);
expect(canvas.querySelectorAll('.game-lineage-node__author').length).toBe(
0,
// 卡就是广场卡:紧凑态也显示作者行(不再省行)。
expect(canvas.querySelectorAll('.game-card__meta').length).toBe(
cards.length,
);
} finally {
widthSpy.mockRestore();
@@ -5009,7 +5002,8 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
// 自身值只出现在分解里,不再单独显示成「7 次游玩」。
expect(screen.queryByText('7 次游玩')).toBeNull();
// hero 也是同一口径(子树合计),与卡里一致。
expect(await screen.findByText('15 次游玩')).toBeTruthy();
const hero = document.querySelector('.game-detail-hero') as HTMLElement;
expect(await within(hero).findByText('15 次游玩')).toBeTruthy();
expect(screen.getByText('累计衍生作品 3')).toBeTruthy();
});
@@ -5119,20 +5113,22 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
// 父代 = 祖先链(根 → 直接父),不含当前作品。
const ancestors = await screen.findByRole('region', { name: '父代' });
const ancestorNames = [
...ancestors.querySelectorAll('.game-lineage-node__title'),
...ancestors.querySelectorAll('.game-card__title-row > strong'),
].map((element) => element.textContent);
expect(ancestorNames).toEqual(['母版', '中间代']);
// 子代 = **直接子代**(不含孙代),按热度(自身 playCount)降序。
const children = screen.getByRole('region', { name: '子代' });
expect(
[...children.querySelectorAll('.game-lineage-node__title')].map(
[...children.querySelectorAll('.game-card__title-row > strong')].map(
(element) => element.textContent,
),
).toEqual(['热门子代', '冷门子代']);
expect(within(children).queryByText('孙代作品')).toBeNull();
// 点击进详情。
// 点击进详情(清单里也是广场卡:整卡就是按钮)。
fireEvent.click(
within(children).getByRole('button', { name: '打开《热门子代》详情' }),
children.querySelector(
'.lineage-relation__item .game-card',
) as HTMLElement,
);
expect(onOpenDetail).toHaveBeenCalledWith('child-hot');
});
@@ -1,5 +1,6 @@
import './gameDistribution.css';
import { GameCard } from '@genarrative/shared/components';
import { ArrowLeft, Expand, RefreshCcw, ZoomIn, ZoomOut } from 'lucide-react';
import {
type PointerEvent as ReactPointerEvent,
@@ -20,13 +21,13 @@ import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { useCopyFeedback } from '../common/useCopyFeedback';
import { GameCover } from './GameCover';
import { useIsMobileViewport } from './gameDistributionGuards';
import {
FORK_LAUNCH_FALLBACK_DELAY_MS,
openForkProtocol,
} from './gameForkProtocol';
import { LineageInfoPanel } from './LineageInfoPanel';
import { LineageNodeContent } from './LineageNodeContent';
import { LineageOutlineList } from './LineageOutlineList';
import { LineageRelationLists } from './LineageRelationLists';
import {
@@ -108,8 +109,6 @@ function LineageTreeNodeCard({
onOpenDetail,
onActivate,
onDeactivate,
onOpenFork,
onTogglePin,
}: {
layoutNode: LineageLayoutNode;
isAnchor: boolean;
@@ -126,19 +125,64 @@ function LineageTreeNodeCard({
onOpenDetail: (gameId: string) => void;
onActivate: (gameId: string) => void;
onDeactivate: () => void;
onOpenFork: (gameId: string) => void;
onTogglePin: (gameId: string) => void;
}) {
const { gameId, title, authorName, generation, isStub } = layoutNode;
const displayTitle = isStub ? '原作品已不可用' : (title ?? '未命名作品');
const authorLabel = isStub
? '标题与作者名不对外展示'
: (authorName ?? '').trim() || '未署名作者';
const accessibleName = isStub
? `原作品已不可用 · ${generationAxisLabel(generation)}`
: `${generationAxisLabel(generation)}作品《${displayTitle}》· ${authorLabel}`;
// 父作品不可用的占位节点只能聚焦(不可点进详情,也没有「开始共创」动作)。
/**
* `GameCover` 需要一份最小作品投影:族谱节点只有公开投影(标题/作者/游玩数/封面对象键),
* 其余字段仅为满足类型,不参与渲染(卡面数据由 `GameCard` 的 props 传真实值)。
*/
const coverGame = {
id: gameId,
title: displayTitle,
coverColor: '#d77a51',
icon: '✦',
coverObjectKey: layoutNode.coverObjectKey ?? null,
playCount: playCount?.total ?? layoutNode.node?.playCount ?? 0,
author: { id: 'lineage', name: authorLabel || '未署名作者' },
} as unknown as GameDistributionGame;
/**
* 卡面角标槽(不承载动作按钮):世代 / 主干 / 当前作品 / 父代-子代 / ▶子树合计。
* 卡片本体 = 广场用的同一个 `GameCard`(整卡可点 → 进详情)。
*/
const badges = (
<>
<span className="game-lineage-node__generation">
{generationAxisLabel(generation)}
</span>
{layoutNode.isTrunk ? (
<span className="game-lineage-node__trunk" title="该代热度最高的版本">
主干
</span>
) : null}
{isAnchor ? (
<span className="game-lineage-node__anchor">当前作品</span>
) : null}
{relation === 'ancestor' || relation === 'descendant' ? (
<span
className={`game-lineage-node__relation game-lineage-node__relation--${relation}`}
data-relation={relation}
>
{relation === 'ancestor' ? '父代' : '子代'}
</span>
) : null}
{playCount ? (
<span
className="game-lineage-node__play-count"
title={`游玩次数(含衍生作品):自身 ${playCount.own} + 衍生 ${playCount.inherited}`}
aria-label={`游玩次数(含衍生作品)${playCount.total}`}
>
▶ {playCount.total}
</span>
) : null}
</>
);
return (
<div
className={[
@@ -166,87 +210,39 @@ function LineageTreeNodeCard({
data-generation={generation}
data-branch={layoutNode.branchIndex}
data-relation={relation}
// 卡片主体点击 = **选中**(持续高亮祖先链 + 子树);打开详情在标题/「打开作品」按钮上。
// 占位节点只能聚焦(不进详情);当前作品在无障碍层也标出来。
tabIndex={isStub ? 0 : undefined}
aria-label={accessibleName}
aria-current={isAnchor ? 'true' : undefined}
aria-disabled={isStub ? 'true' : undefined}
onClick={() => {
if (isStub) onActivate(gameId);
else onTogglePin(isPinned ? '' : gameId);
}}
aria-current={isAnchor ? 'true' : undefined}
// 悬停/聚焦仍然驱动「高亮祖先链 + 右侧信息面板」(不抢点击)。
onMouseEnter={() => onActivate(gameId)}
onMouseLeave={onDeactivate}
onFocus={() => onActivate(gameId)}
onBlur={onDeactivate}
>
{isStub ? (
// 占位节点没有可打开的作品,也不展示被删作品的标题与作者名:只保留可聚焦的降级说明。
<span className="game-lineage-node__stub-body">
<span className="game-lineage-node__generation">
{generationAxisLabel(generation)}
</span>
<span className="game-lineage-node__title">{displayTitle}</span>
<span className="game-lineage-node__meta">
<span>{authorLabel}</span>
</span>
</span>
) : (
<>
<button
type="button"
className="game-lineage-node__open"
aria-label={`打开《${displayTitle}》详情`}
onClick={(event) => {
// 主行动作:打开详情。stopPropagation 保证它不会被「选中」吞掉。
event.stopPropagation();
onOpenDetail(gameId);
}}
>
<LineageNodeContent
layout="stacked"
title={layoutNode.title}
authorName={layoutNode.authorName}
generation={generation}
coverObjectKey={layoutNode.coverObjectKey}
isAnchor={isAnchor}
isStub={isStub}
isTrunk={layoutNode.isTrunk}
compact={compact}
relation={relation}
playCount={playCount}
/>
</button>
<span className="game-lineage-node__actions">
{/*
两个**等权重**的动作按钮:次要(描边)= 打开作品,主要(填充)= 开始共创。
同一尺寸 / 同一圆角 / 同一内边距,窄卡里允许换行但绝不裁切(见 CSS)。
*/}
<button
type="button"
className="game-lineage-node__open-action"
onClick={(event) => {
event.stopPropagation();
onOpenDetail(gameId);
}}
>
打开作品
</button>
<button
type="button"
className="game-lineage-node__fork"
aria-label={`开始共创 ${displayTitle}`}
onClick={(event) => {
// 次要动作不抢主点击。
event.stopPropagation();
onOpenFork(gameId);
}}
>
开始共创
</button>
</span>
</>
)}
{/*
卡面 = **广场作品卡的同一个组件**(`@genarrative/shared/components` 的 `GameCard`):
整卡就是入口(点击 → 进详情),卡上不再放「打开作品 / 开始共创」按钮
—— 用户口径:看封面决定不了共创,必须点进去看。
占位节点(父作品不可用)点击不导航,只作为可聚焦的降级说明。
*/}
<GameCard
className="game-card--lineage"
game={{
title: displayTitle,
// 显示口径:游玩次数 = 该节点的**子树合计**(与卡片自身的「N 次游玩」一致)。
playCount: playCount?.total ?? layoutNode.node?.playCount ?? 0,
author: !isStub && authorLabel ? { name: authorLabel } : undefined,
}}
cover={
<GameCover game={coverGame} objectKey={layoutNode.coverObjectKey} />
}
badges={badges}
onOpen={() => {
if (isStub) return;
onOpenDetail(gameId);
}}
/>
</div>
);
}
@@ -1237,11 +1233,15 @@ export function GameLineagePage({
playCount={
playCountTotals.get(layoutNode.gameId) ?? null
}
onTogglePin={setPinnedGameId}
onOpenDetail={onOpenDetail}
onActivate={setActiveGameId}
onDeactivate={() => setActiveGameId('')}
onOpenFork={launchForkFromNode}
onActivate={(gameId: string) => {
setActiveGameId(gameId);
setPinnedGameId(gameId);
}}
onDeactivate={() => {
setActiveGameId('');
setPinnedGameId('');
}}
/>
))}
</div>
@@ -1323,9 +1323,7 @@ export function GameLineagePage({
onClick={() => fitToView(true)}
>
<Expand aria-hidden="true" />
<span className="game-lineage-controls__label">
总览
</span>
<span className="game-lineage-controls__label">总览</span>
</button>
<button
type="button"
@@ -1385,7 +1383,6 @@ export function GameLineagePage({
<LineageRelationLists
ancestors={ancestorChain}
children={directChildren}
currentGameId={anchorId}
playCountTotals={playCountTotals}
onOpenDetail={onOpenDetail}
/>
@@ -1,5 +1,8 @@
import { GameCard } from '@genarrative/shared/components';
import type { GameDistributionLineageNode } from '../../services/gameDistributionClient';
import { LineageNodeContent } from './LineageNodeContent';
import type { GameDistributionGame } from '../../services/gameDistributionClient';
import { GameCover } from './GameCover';
import type {
LineagePlayCountBreakdown,
LineageRelationRole,
@@ -20,7 +23,6 @@ import type {
export function LineageRelationLists({
ancestors,
children,
currentGameId,
playCountTotals,
onOpenDetail,
}: {
@@ -28,35 +30,72 @@ export function LineageRelationLists({
ancestors: readonly GameDistributionLineageNode[];
/** 直接子代(调用方已按热度降序排好)。 */
children: readonly GameDistributionLineageNode[];
currentGameId: string;
playCountTotals: ReadonlyMap<string, LineagePlayCountBreakdown>;
onOpenDetail: (gameId: string) => void;
}) {
const renderRow = (
/**
* 单个节点 → **广场作品卡的同一个组件**(`GameCard`)。
* 整卡可点 → 进详情;卡面不再放动作按钮(用户口径:看封面决定不了共创,必须点进去看)。
* 世代 / 父代-子代 / ▶子树合计 通过卡片的 `badges` 槽承载。
*/
const renderCard = (
node: GameDistributionLineageNode,
role: LineageRelationRole,
) => (
<li className="lineage-relation__row" key={node.gameId}>
<button
type="button"
className="lineage-relation__main"
aria-label={`打开《${node.title}》详情`}
onClick={() => onOpenDetail(node.gameId)}
>
<LineageNodeContent
layout="inline"
title={node.title}
authorName={node.authorName ?? null}
generation={node.generation}
coverObjectKey={node.coverObjectKey ?? null}
isAnchor={node.gameId === currentGameId}
isStub={false}
relation={role}
playCount={playCountTotals.get(node.gameId) ?? null}
) => {
const breakdown = playCountTotals.get(node.gameId) ?? null;
const author = (node.authorName ?? '').trim() || '未署名作者';
return (
<li className="lineage-relation__item" key={node.gameId}>
<GameCard
className="game-card--lineage"
game={{
title: node.title,
playCount: breakdown?.total ?? node.playCount,
author: { name: author },
}}
cover={
<GameCover
game={
{
id: node.gameId,
title: node.title,
coverColor: '#d77a51',
icon: '✦',
coverObjectKey: node.coverObjectKey ?? null,
playCount: breakdown?.total ?? node.playCount,
author: { id: 'lineage', name: author },
} as unknown as GameDistributionGame
}
objectKey={node.coverObjectKey ?? null}
/>
}
badges={
<>
<span className="game-lineage-node__generation">
第 {node.generation} 代
</span>
<span
className={`game-lineage-node__relation game-lineage-node__relation--${role}`}
data-relation={role}
>
{role === 'ancestor' ? '父代' : '子代'}
</span>
{breakdown ? (
<span
className="game-lineage-node__play-count"
title={`游玩次数(含衍生作品):自身 ${breakdown.own} + 衍生 ${breakdown.inherited}`}
aria-label={`游玩次数(含衍生作品)${breakdown.total}`}
>
▶ {breakdown.total}
</span>
) : null}
</>
}
onOpen={() => onOpenDetail(node.gameId)}
/>
</button>
</li>
);
</li>
);
};
return (
<div className="lineage-relations">
@@ -70,8 +109,8 @@ export function LineageRelationLists({
这部作品是母版,没有父代(它的后代都在上面的树里)。
</p>
) : (
<ul className="lineage-relation__list">
{ancestors.map((node) => renderRow(node, 'ancestor'))}
<ul className="lineage-relation__grid">
{ancestors.map((node) => renderCard(node, 'ancestor'))}
</ul>
)}
</section>
@@ -85,7 +124,7 @@ export function LineageRelationLists({
<p className="lineage-relation__empty">这部作品还没有直接子代。</p>
) : (
<ul className="lineage-relation__list">
{children.map((node) => renderRow(node, 'descendant'))}
{children.map((node) => renderCard(node, 'descendant'))}
</ul>
)}
</section>
@@ -2933,3 +2933,43 @@
text-decoration: underline;
cursor: pointer;
}
/*
* 图谱里的卡**就是**广场作品卡的同一个组件(`GameCard`):这里只做「放进画布/清单」所需的尺寸适配,
* 不改卡内视觉(标题/游玩数/作者等仍由该组件自己的样式决定)。
* 封面继续遵守 16:9 + 铺满(`.game-cover` 的 min-height 共享样式在卡里归零)。
*/
.game-lineage-node > .game-card--lineage {
width: 100%;
min-height: 100%;
}
.game-card--lineage .game-cover {
width: 100%;
aspect-ratio: 16 / 9;
min-height: 0;
border-radius: 0;
}
.game-card--lineage .game-cover__image,
.game-card--lineage .game-cover img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
/* 图下方「父代 / 子代」清单:与广场一致的卡片网格。 */
.lineage-relation__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
gap: 0.75rem;
margin: 0;
padding: 0;
list-style: none;
}
.lineage-relation__item {
display: flex;
min-width: 0;
}
.lineage-relation__item > .game-card--lineage {
width: 100%;
}
@@ -1434,10 +1434,17 @@ describe('创作族谱', () => {
expect(gameDistributionMock.getGameLineage).toHaveBeenCalledWith(
'game-root',
);
// 图下方的「父代/子代」块里也有同名节点,所以这里精确到**树里**的那一个。
const treeNode = screen
.getAllByRole('button', { name: /《母版》/u })
.find((button) => button.closest('.lineage-relations') === null);
// 图下方的「父代/子代」块里也有同名节点,所以这里精确到**树里**的那一个:
// 树节点卡就是广场卡(卡内标题文本 = 作品标题,整卡一个 button)。
const treeNode = [
...document.querySelectorAll('.game-lineage-canvas .game-lineage-node'),
]
.find(
(node) =>
node.querySelector('.game-card__title-row > strong')?.textContent ===
'母版',
)
?.querySelector('.game-card');
expect(treeNode).toBeTruthy();
fireEvent.click(treeNode as HTMLElement);
expect(setSelectionStage).toHaveBeenCalledWith('game-detail', {