图谱卡改用广场卡组件(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
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:
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user