From 5905eaabf94465e5ea4138e138795d38228527a6 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 7 Oct 2026 16:50:40 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BE=E8=B0=B1=E5=8D=A1=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E5=B9=BF=E5=9C=BA=E5=8D=A1=E7=BB=84=E4=BB=B6(GameCard)?= =?UTF-8?q?=EF=BC=9A=E5=8D=A1=E9=9D=A2=E5=8E=BB=E6=8E=89=E5=8A=A8=E4=BD=9C?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E3=80=81=E6=95=B4=E5=8D=A1=E7=82=B9=E5=87=BB?= =?UTF-8?q?=E8=BF=9B=E8=AF=A6=E6=83=85=E3=80=81=E8=A7=92=E6=A0=87=E8=B5=B0?= =?UTF-8?q?=E5=8D=A1=E9=9D=A2=20badge=20=E6=A7=BD=EF=BC=9B=E5=85=B1?= =?UTF-8?q?=E4=BA=AB=E5=8D=A1=E7=BA=AF=E5=8A=A0=E6=B3=95=E6=89=A9=E5=B1=95?= =?UTF-8?q?(=E7=BB=93=E6=9E=84=E5=8C=96=E5=AD=90=E9=9B=86+badges+className?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shared/src/components/GameCard/index.tsx | 76 +++++- .../shared/src/components/GameCard/style.css | 21 ++ .../GameDistributionPages.test.tsx | 228 +++++++++--------- .../game-distribution/GameLineagePage.tsx | 177 +++++++------- .../LineageRelationLists.tsx | 95 +++++--- .../game-distribution/gameDistribution.css | 40 +++ .../PlatformEntryActiveFlowShell.test.tsx | 15 +- 7 files changed, 403 insertions(+), 249 deletions(-) diff --git a/packages/shared/src/components/GameCard/index.tsx b/packages/shared/src/components/GameCard/index.tsx index 683e20c1b..581077ca1 100644 --- a/packages/shared/src/components/GameCard/index.tsx +++ b/packages/shared/src/components/GameCard/index.tsx @@ -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 ( - ); diff --git a/packages/shared/src/components/GameCard/style.css b/packages/shared/src/components/GameCard/style.css index 59225d1f9..e5dcac3ab 100644 --- a/packages/shared/src/components/GameCard/style.css +++ b/packages/shared/src/components/GameCard/style.css @@ -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; +} diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index 65f48cde6..444b238e9 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -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( , ); 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'); }); diff --git a/src/components/game-distribution/GameLineagePage.tsx b/src/components/game-distribution/GameLineagePage.tsx index 74589cb44..e6bcfe7b4 100644 --- a/src/components/game-distribution/GameLineagePage.tsx +++ b/src/components/game-distribution/GameLineagePage.tsx @@ -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 = ( + <> + + {generationAxisLabel(generation)} + + {layoutNode.isTrunk ? ( + + 主干 + + ) : null} + {isAnchor ? ( + 当前作品 + ) : null} + {relation === 'ancestor' || relation === 'descendant' ? ( + + {relation === 'ancestor' ? '父代' : '子代'} + + ) : null} + {playCount ? ( + + ▶ {playCount.total} + + ) : null} + + ); + return (
{ - if (isStub) onActivate(gameId); - else onTogglePin(isPinned ? '' : gameId); - }} + aria-current={isAnchor ? 'true' : undefined} + // 悬停/聚焦仍然驱动「高亮祖先链 + 右侧信息面板」(不抢点击)。 onMouseEnter={() => onActivate(gameId)} onMouseLeave={onDeactivate} onFocus={() => onActivate(gameId)} onBlur={onDeactivate} > - {isStub ? ( - // 占位节点没有可打开的作品,也不展示被删作品的标题与作者名:只保留可聚焦的降级说明。 - - - {generationAxisLabel(generation)} - - {displayTitle} - - {authorLabel} - - - ) : ( - <> - - - {/* - 两个**等权重**的动作按钮:次要(描边)= 打开作品,主要(填充)= 开始共创。 - 同一尺寸 / 同一圆角 / 同一内边距,窄卡里允许换行但绝不裁切(见 CSS)。 - */} - - - - - )} + {/* + 卡面 = **广场作品卡的同一个组件**(`@genarrative/shared/components` 的 `GameCard`): + 整卡就是入口(点击 → 进详情),卡上不再放「打开作品 / 开始共创」按钮 + —— 用户口径:看封面决定不了共创,必须点进去看。 + 占位节点(父作品不可用)点击不导航,只作为可聚焦的降级说明。 + */} + + } + badges={badges} + onOpen={() => { + if (isStub) return; + onOpenDetail(gameId); + }} + />
); } @@ -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(''); + }} /> ))} @@ -1323,9 +1323,7 @@ export function GameLineagePage({ onClick={() => fitToView(true)} >