From a7f781834adb0eb5b29f00b5e7f149f1bd72be46 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 7 Oct 2026 12:37:12 +0800 Subject: [PATCH] =?UTF-8?q?=E6=97=8F=E8=B0=B1=E5=A4=A7=E7=BA=B2/=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E7=89=88=E5=BC=8F=EF=BC=9A=E8=A7=92=E6=A0=87=E6=88=90?= =?UTF-8?q?=E7=BB=84=E4=B8=8D=E5=8F=A0=E5=8D=B0=20+=20=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E5=88=97=E9=99=90=E5=AE=BD=E5=B1=85=E4=B8=AD=20+=20=E7=BC=A9?= =?UTF-8?q?=E7=95=A5=E5=9B=BE=2016:9=20+=20=E5=8D=A1=E7=89=87=E7=AD=89?= =?UTF-8?q?=E9=AB=98=EF=BC=9B=E8=8A=82=E7=82=B9=E6=98=BE=E7=A4=BA=E6=B8=B8?= =?UTF-8?q?=E7=8E=A9=E6=AC=A1=E6=95=B0=E5=AD=90=E6=A0=91=E5=90=88=E8=AE=A1?= =?UTF-8?q?(=E2=96=B6N)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GameDistributionPages.test.tsx | 286 +++++++++++++++++- .../game-distribution/GameLineagePage.tsx | 44 ++- .../game-distribution/LineageNodeContent.tsx | 83 +++-- .../game-distribution/LineageOutlineList.tsx | 11 +- .../game-distribution/gameDistribution.css | 99 +++++- .../lineageTreeLayout.test.ts | 59 ++++ 6 files changed, 528 insertions(+), 54 deletions(-) diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index 2c5c86c11..9f949eb6f 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -34,6 +34,7 @@ import { GamePlayPage } from './GamePlayPage'; import { GameThemePage } from './GameThemePage'; import { GameThemesPage } from './GameThemesPage'; import { resolveNonPublicCoverLabel } from './gameVersionHistoryModel'; +import { LINEAGE_COVER_ASPECT } from './lineageTreeLayout'; const listGamesMock = vi.hoisted(() => vi.fn()); const setGameCollectionMock = vi.hoisted(() => vi.fn()); @@ -1956,7 +1957,7 @@ describe('原版与二创对比入口', () => { expect(screen.queryByRole('button', { name: '与原版对比' })).toBeNull(); }); - it('树节点只保留代际/标题/作者/Fork:不渲染游玩数与对比入口', async () => { + it('树节点:显示游玩次数**子树合计**角标(▶ N),仍不渲染「与原版对比」', async () => { getGameLineageMock.mockResolvedValueOnce({ rootGameId: 'game-root', root: createLineageNode({ @@ -1964,6 +1965,7 @@ describe('原版与二创对比入口', () => { title: '母版', generation: 0, parentGameId: null, + playCount: 1, }), nodes: [ createLineageNode({ @@ -1971,18 +1973,21 @@ describe('原版与二创对比入口', () => { title: '母版', generation: 0, parentGameId: null, + playCount: 1, }), createLineageNode({ gameId: 'game-leaf', title: '叶节点', generation: 1, parentGameId: 'game-root', + playCount: 4, }), createLineageNode({ gameId: 'game-orphan', title: '孤儿节点', generation: 1, parentGameId: 'game-missing', + playCount: 7, }), ], truncated: false, @@ -1998,8 +2003,24 @@ describe('原版与二创对比入口', () => { ); expect(await screen.findByText('叶节点')).toBeTruthy(); - // 树节点只保留「代际 + 标题 + 作者 + Fork」:游玩数与「与原版对比」留在详情页, - // 树里不再重复渲染(6 个节点还行,多了会吵)。 + // 口径变更(2026-10-07,用户口径):**节点也显示游玩次数**,显示值 = 该节点的**子树合计** + // (`collectLineagePlayCountTotals().total`),不是自身值。 + const rootCard = await cardOf('母版'); + // 母版合计 = 自身 1 + 叶节点 4 = 5(孤儿节点挂在缺失的父下,不算它的后代)。 + expect( + rootCard.querySelector('.game-lineage-node__play-count')?.textContent, + ).toBe('▶ 5'); + const leafCard = await cardOf('叶节点'); + expect( + leafCard.querySelector('.game-lineage-node__play-count')?.textContent, + ).toBe('▶ 4'); + // 角标的 title / aria-label 说明它**含衍生**,避免把合计误解成自身。 + const chip = rootCard.querySelector( + '.game-lineage-node__play-count', + ) as HTMLElement; + expect(chip.getAttribute('title')).toContain('含衍生作品'); + expect(chip.getAttribute('aria-label')).toContain('含衍生作品'); + // 「与原版对比」仍然留在详情页,不在树里重复渲染。 expect(screen.queryByRole('button', { name: '与原版对比' })).toBeNull(); expect(screen.queryByText(/次游玩/u)).toBeNull(); expect(screen.getByRole('button', { name: 'Fork 叶节点' })).toBeTruthy(); @@ -2451,6 +2472,37 @@ function buildChainDataset() { * 父代 / 子代数据集:root → mid → current(锚点)→ child1/child2(直接子代), * child1 → grandchild(孙代,不算「直接子代」);root → uncle 是旁支。 */ +/** 演示树形状(`共创演示·A0`):A0 → A1/A2/A3;A1 → A11/A12;A11 → A111;A2 → A21,全部 0 热度。 */ +const demoShapeTree = { + rootGameId: 'A0', + root: createLineageNode({ + gameId: 'A0', + title: '母版甲', + generation: 0, + parentGameId: null, + playCount: 0, + }), + nodes: [ + ['A0', '母版甲', 0, null], + ['A1', '一代甲', 1, 'A0'], + ['A2', '一代乙', 1, 'A0'], + ['A3', '一代丙', 1, 'A0'], + ['A11', '二代甲', 2, 'A1'], + ['A12', '二代乙', 2, 'A1'], + ['A21', '二代丙', 2, 'A2'], + ['A111', '三代甲', 3, 'A11'], + ].map(([gameId, title, generation, parentGameId]) => + createLineageNode({ + gameId: gameId as string, + title: title as string, + generation: generation as number, + parentGameId: parentGameId as string | null, + playCount: 0, + }), + ), + truncated: false, +}; + const relationTree = { rootGameId: 'game-root', root: createLineageNode({ @@ -2731,6 +2783,171 @@ describe('GameLineagePage 可视化与交互', () => { ); }); + it('大纲行角标成组、绝不叠印(代际/主干/当前作品/关系同组,CSS 里不再有各自的绝对定位)', async () => { + setViewportWidth(375); + getGameLineageMock.mockResolvedValueOnce(demoShapeTree); + render( + , + ); + + const outline = await screen.findByLabelText('创作族谱大纲'); + const rows = [...outline.querySelectorAll('.lineage-outline__row')]; + expect(rows.length).toBe(8); + for (const row of rows) { + const badges = row.querySelector('.game-lineage-node__badges'); + expect(badges).toBeTruthy(); + // 角标组的直接子元素 = 全部角标(没有任何角标被留在封面里各排各的)。 + for (const chip of [...badges!.children]) { + expect(chip.parentElement).toBe(badges); + } + expect( + row.querySelector( + '.game-lineage-node__cover > .game-lineage-node__badge', + ), + ).toBeNull(); + expect( + row.querySelector( + '.game-lineage-node__cover > .game-lineage-node__trunk', + ), + ).toBeNull(); + } + // 主干在大纲里也出现(A0 / A1 / A11 / A111 是各自代的主干),且不再与代际角标各占各的绝对位置。 + expect( + [...outline.querySelectorAll('.game-lineage-node__trunk')].length, + ).toBe(4); + // jsdom 不做布局(拿不到真实几何),所以这里钉的是**结构 + CSS 契约**: + // 角标组内一律静态定位,旧的「代际左上 / 主干右上」绝对定位必须已被删除。 + const css = await import('node:fs').then((fs) => + fs.readFileSync( + 'src/components/game-distribution/gameDistribution.css', + 'utf8', + ), + ); + expect(css).toContain('.game-lineage-node__badges {'); + expect(css).toMatch( + /\.game-lineage-node__badges \.game-lineage-node__badge,[\s\S]{0,600}?position: static;/u, + ); + expect(css).not.toMatch( + /\.game-lineage-node__badge \{[^}]*?position: absolute/u, + ); + expect(css).not.toMatch( + /\.game-lineage-node__trunk \{[^}]*?position: absolute/u, + ); + }); + + it('大纲限宽居中、操作留在内容列右端;缩略图与画布统一 16:9', async () => { + setViewportWidth(375); + getGameLineageMock.mockResolvedValueOnce(demoShapeTree); + render( + , + ); + + const outline = await screen.findByLabelText('创作族谱大纲'); + const rows = [...outline.querySelectorAll('.lineage-outline__row')]; + expect(rows.length).toBe(8); + // 操作是行的**最后一个子元素**(在行内 = 内容列右端),不会飘到屏幕最右。 + for (const row of rows) { + expect(row.lastElementChild?.className).toContain( + 'lineage-outline__actions', + ); + } + // 内容列限宽 + 居中:宽屏不再整屏出血。 + const css = await import('node:fs').then((fs) => + fs.readFileSync( + 'src/components/game-distribution/gameDistribution.css', + 'utf8', + ), + ); + expect(css).toMatch(/\.lineage-outline \{[\s\S]{0,500}?max-width: 54rem;/u); + expect(css).toMatch(/\.lineage-outline \{[\s\S]{0,500}?margin: 0 auto;/u); + // 缩略图 16:9(与画布同一比例),不再被角标撑成窄竖条。 + expect(css).toMatch( + /\.game-lineage-node__content--inline \.game-lineage-node__cover \{[\s\S]{0,300}?aspect-ratio: 16 \/ 9;/u, + ); + expect(LINEAGE_COVER_ASPECT).toBeCloseTo(16 / 9, 5); + }); + + it('初始适配:装得下就全览、装不下就退回可读缩放并明确提示', async () => { + // 情形 1:视口够大 → 宽高同时 fit,所有可见节点都在视口内、无提示。 + const wideWidth = vi + .spyOn(Element.prototype, 'clientWidth', 'get') + .mockReturnValue(1600); + const wideHeight = vi + .spyOn(Element.prototype, 'clientHeight', 'get') + .mockReturnValue(1200); + try { + getGameLineageMock.mockResolvedValueOnce(demoShapeTree); + const first = render( + , + ); + const canvas = (await screen.findByLabelText( + '创作族谱树', + )) as HTMLElement; + await waitFor(() => + expect(canvas.querySelectorAll('.game-lineage-node').length).toBe(8), + ); + const view = readCanvasViewport(canvas); + expect(visibleCanvasCards(canvas, view).length).toBe(8); + expect(screen.queryByText(/其余代际在视口外/u)).toBeNull(); + first.unmount(); + + // 情形 2:视口装不下全览(低于可读下限)→ 停在可读缩放 + 明确提示,不静默丢代际。 + wideWidth.mockReturnValue(420); + wideHeight.mockReturnValue(320); + getGameLineageMock.mockResolvedValueOnce(demoShapeTree); + render( + , + ); + const smallCanvas = (await screen.findByLabelText( + '创作族谱树', + )) as HTMLElement; + await waitFor(() => + expect(smallCanvas.querySelectorAll('.game-lineage-node').length).toBe( + 8, + ), + ); + const smallView = readCanvasViewport(smallCanvas); + const visible = visibleCanvasCards(smallCanvas, smallView); + const floored = Math.abs(smallView.scale - 0.6) < 0.001; + // 二选一(都不允许「既没全览又没提示」):要么全部可见,要么触底可读缩放 + 提示出现。 + expect(floored || visible.length === 8).toBe(true); + if (floored) { + expect( + screen.getByText( + '树较大:已按可读缩放显示(其余代际在视口外),拖拽平移查看,或点「适应窗口」看全树。', + ), + ).toBeTruthy(); + // 触底时仍然把当前作品放进视口(不是把它也丢出去)。 + expect(visible).toContain( + smallCanvas.querySelector('.game-lineage-node--anchor'), + ); + } + } finally { + wideWidth.mockRestore(); + wideHeight.mockRestore(); + } + }); + it('节点是真实 DOM 卡片:主体点击=选中、标题/打开作品=进详情、当前作品有标记且父在上子在下', async () => { getGameLineageMock.mockResolvedValueOnce(treeWithSibling); const onOpenDetail = vi.fn(); @@ -3593,6 +3810,69 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐) expect(outline.querySelectorAll('.lineage-outline__row').length).toBe(7); }); + it('大纲行显示游玩次数**子树合计**(▶ N),与角标同行、不进文字块、不额外占行', async () => { + setViewportWidth(375); + getGameLineageMock.mockResolvedValueOnce(tree); + const first = render( + , + ); + + const outline = await screen.findByLabelText('创作族谱大纲'); + const rowOf = (title: string) => + [...outline.querySelectorAll('.lineage-outline__row')].find((row) => + row.textContent?.includes(title), + ) as HTMLElement; + const chipOf = (title: string) => + rowOf(title).querySelector( + '.game-lineage-node__badges > .game-lineage-node__play-count', + ) as HTMLElement; + + // 显示的是**合计**(自身 + 全部后代),不是自身值: + // 热门二创自身 999、子代 深代作品 3 → ▶ 1002;冷门二创自身 1 → ▶ 1;母版 5+1002+1 → ▶ 1008。 + expect(chipOf('热门二创').textContent).toBe('▶ 1002'); + expect(chipOf('冷门二创').textContent).toBe('▶ 1'); + expect(chipOf('根原创').textContent).toBe('▶ 1008'); + expect(chipOf('深代作品').textContent).toBe('▶ 3'); + // 角标说明含衍生(读屏 + 悬停都能看到口径)。 + expect(chipOf('热门二创').getAttribute('title')).toContain('含衍生作品'); + expect(chipOf('热门二创').getAttribute('aria-label')).toBe( + '游玩次数(含衍生作品)1002', + ); + // 与角标同一组(同一行),**不在**文字块里 → 不会给它单开一行。 + expect( + rowOf('热门二创').querySelector( + '.game-lineage-node__text .game-lineage-node__play-count', + ), + ).toBeNull(); + + // 全 0 也显示(列稳定、不会一会儿有一会儿没有),并且同样只在角标组里。 + first.unmount(); + getGameLineageMock.mockResolvedValueOnce(demoShapeTree); + const zero = render( + , + ); + const zeroOutline = await zero.findByLabelText('创作族谱大纲'); + const zeroChips = [ + ...zeroOutline.querySelectorAll( + '.lineage-outline__row .game-lineage-node__badges > .game-lineage-node__play-count', + ), + ]; + expect(zeroChips.length).toBe(8); + for (const chip of zeroChips) { + expect(chip.textContent).toBe('▶ 0'); + } + zero.unmount(); + }); + it('窄屏仍能点进详情页与查看完整族谱入口(大纲模式不丢入口)', async () => { setViewportWidth(375); getGameLineageMock.mockResolvedValueOnce(tree); diff --git a/src/components/game-distribution/GameLineagePage.tsx b/src/components/game-distribution/GameLineagePage.tsx index 295c4ffe8..2cdf25ead 100644 --- a/src/components/game-distribution/GameLineagePage.tsx +++ b/src/components/game-distribution/GameLineagePage.tsx @@ -91,6 +91,7 @@ function LineageTreeNodeCard({ isPinned, compact, relation, + playCount, onOpenDetail, onActivate, onDeactivate, @@ -107,6 +108,8 @@ function LineageTreeNodeCard({ compact: boolean; /** 相对「当前作品」的角色:父代 / 子代 / 自己 / 旁支(打 `data-relation` + 角标)。 */ relation: LineageRelationRole; + /** 游玩次数**子树合计**(显示口径):紧凑角标 `▶ N`,不额外占一行。 */ + playCount: LineagePlayCountBreakdown | null; onOpenDetail: (gameId: string) => void; onActivate: (gameId: string) => void; onDeactivate: () => void; @@ -198,6 +201,7 @@ function LineageTreeNodeCard({ isTrunk={layoutNode.isTrunk} compact={compact} relation={relation} + playCount={playCount} /> @@ -316,6 +320,16 @@ export function GameLineagePage({ }, [loadLineage]); const nodes = useMemo(() => lineage?.nodes ?? [], [lineage]); + /** + * 游玩次数**显示**口径 = 该节点的**子树合计**(父代展示自己 + 全部后代,见 §3.6.3)。 + * 同一棵树只算一次:既给节点角标(`▶ N`),也回传给宿主(主题页母版行)。 + * **注意**:主干线与「按热度排序」的判据仍是**自身** `playCount`(服务端字段原值), + * 与这里的显示口径**不同**——两处注释互相指认,别改错。 + */ + const playCountTotals = useMemo( + () => collectLineagePlayCountTotals(nodes), + [nodes], + ); /** * 树一变就把「每个节点的游玩次数分解」交给宿主(主题页的母版行要显示子树合计)。 * 回调存在 ref 里,所以宿主不必包 `useCallback`,也不会因为回调换身份而反复触发。 @@ -325,8 +339,8 @@ export function GameLineagePage({ useEffect(() => { const callback = playCountTotalsCallbackRef.current; if (!callback || !lineage) return; - callback(collectLineagePlayCountTotals(lineage.nodes)); - }, [lineage]); + callback(playCountTotals); + }, [lineage, playCountTotals]); // 高亮锚点(= 「当前作品」,也是「父代 / 子代」的基准)必须早于筛选链路声明。 const anchorId = highlightGameId?.trim() || normalizedRootId; /** @@ -516,14 +530,14 @@ export function GameLineagePage({ const height = viewportElement.clientHeight; const availableWidth = Math.max(1, width - FIT_PADDING * 2); const availableHeight = Math.max(1, height - FIT_PADDING * 2); - const target = - mode === 'width' - ? availableWidth / Math.max(layout.width, 1) - : Math.min( - availableWidth / Math.max(layout.width, 1), - availableHeight / Math.max(layout.height, 1), - ); - // 全览会缩到低于可读下限时,改用可读缩放:信息先看得清,全览交给「适应窗口」按钮。 + const fitWidth = availableWidth / Math.max(layout.width, 1); + const fitBoth = Math.min( + fitWidth, + availableHeight / Math.max(layout.height, 1), + ); + const target = mode === 'width' ? fitWidth : fitBoth; + // 低于可读下限时不强行全览:用可读缩放 + 平移,并把「还有代际在视口外」写进提示 + //(不静默把最深的代际丢在视口外)。 const floored = mode === 'both' && target < READABLE_MIN_SCALE; const scale = Math.min( MAX_SCALE, @@ -531,14 +545,14 @@ export function GameLineagePage({ ); setFitHint( floored - ? '树较大,已按可读缩放显示:拖拽平移查看,或点「适应窗口」看全树。' + ? '树较大:已按可读缩放显示(其余代际在视口外),拖拽平移查看,或点「适应窗口」看全树。' : scale > target + 0.001 ? '树较宽,已按最小缩放显示:拖拽平移查看。' : '', ); - // 水平居中:内容宽度已含左侧代际轴,避免右侧留一大片空白。 + // 水平与垂直都按内容居中(内容比视口小时不会把空白全堆在下边/右边)。 let offsetX = Math.max(FIT_PADDING, (width - layout.width * scale) / 2); - let offsetY = FIT_PADDING; + let offsetY = Math.max(FIT_PADDING, (height - layout.height * scale) / 2); const anchorNode = layout.nodes.find((item) => item.gameId === anchorId); if (anchorNode && width > 0 && height > 0) { const left = offsetX + anchorNode.x * scale; @@ -960,6 +974,7 @@ export function GameLineagePage({ trunkGameIds={trunkGameIds} selectedGameId={pinnedGameId} relationByGameId={relationRoles} + playCountByGameId={playCountTotals} onSelect={(gameId) => setPinnedGameId((current) => current === gameId ? '' : gameId, @@ -1028,6 +1043,9 @@ export function GameLineagePage({ relation={ relationRoles.get(layoutNode.gameId) ?? 'side' } + playCount={ + playCountTotals.get(layoutNode.gameId) ?? null + } onTogglePin={setPinnedGameId} onOpenDetail={onOpenDetail} onActivate={setActiveGameId} diff --git a/src/components/game-distribution/LineageNodeContent.tsx b/src/components/game-distribution/LineageNodeContent.tsx index 384a7a3b0..2fb4d8561 100644 --- a/src/components/game-distribution/LineageNodeContent.tsx +++ b/src/components/game-distribution/LineageNodeContent.tsx @@ -1,6 +1,9 @@ import type { GameDistributionGame } from '../../services/gameDistributionClient'; import { GameCover } from './GameCover'; -import type { LineageRelationRole } from './lineageTreeLayout'; +import type { + LineagePlayCountBreakdown, + LineageRelationRole, +} from './lineageTreeLayout'; import { generationAxisLabel } from './lineageTreeLayout'; /** @@ -23,6 +26,7 @@ export function LineageNodeContent({ isTrunk = false, compact = false, relation = 'side', + playCount = null, }: { title: string | null; authorName: string | null; @@ -40,6 +44,11 @@ export function LineageNodeContent({ * 是可读文本而不是只靠颜色,因此可断言。 */ relation?: LineageRelationRole; + /** + * 该节点的游玩次数**子树合计**(`collectLineagePlayCountTotals` 的结果)。 + * 传了就显示紧凑角标 `▶ N`(`title` 说明含衍生);`null` / 不传则不显示(拿不到树时)。 + */ + playCount?: LineagePlayCountBreakdown | null; }) { const displayTitle = isStub ? '原作品已不可用' : (title ?? '未命名作品'); const authorLabel = isStub @@ -53,35 +62,67 @@ export function LineageNodeContent({ coverObjectKey, } as unknown as GameDistributionGame; + /** + * 角标组:代际 / 主干 / 当前作品 / 父代-子代 **一律**放在同一个 flex 容器里。 + * + * 之前这几枚角标各自 `position: absolute`(代际左上、主干右上、当前作品在流内), + * 在窄卡里会**互相叠印**(大纲行里 `第 1 代` 与 `主干` 叠成乱码字形)。改成「同一组、 + * 有 gap 的 flex 行」后,重叠在结构上不可能发生,画布与大纲共用同一组角标。 + */ + const badges = ( + + + {generationAxisLabel(generation)} + + {isTrunk ? ( + + 主干 + + ) : null} + {isAnchor ? ( + 当前作品 + ) : null} + {relation === 'ancestor' || relation === 'descendant' ? ( + // 「父代 / 子代」是可读文本角标(不是只靠描边颜色),所以能被断言、也能被读屏。 + + {relation === 'ancestor' ? '父代' : '子代'} + + ) : null} + {playCount ? ( + /* + 游玩次数 = **该节点的子树合计**(`collectLineagePlayCountTotals().total`,口径见 §3.6.3)。 + 紧凑呈现:与角标同行的小 pill(▶ 数字),`title`/`aria-label` 说明它**含衍生作品**, + 避免把「父代显示自己 + 全部后代」误解成「这一个作品自己有多少人玩」。 + 全 0 也显示(列稳定,不会一会儿有一个字一会儿没有)。 + 注意:**主干线 / 热度排序的判据仍是自身 playCount**,与这里的显示口径不同。 + */ + + ▶ {playCount.total} + + ) : null} + + ); + return ( - - {generationAxisLabel(generation)} - - {isTrunk ? ( - - 主干 - - ) : null} - {isAnchor ? ( - 当前作品 - ) : null} + {/* 画布卡片:角标组作为封面上的浮层(整组一起排,不会各自乱定位)。 */} + {layout === 'stacked' ? badges : null} + {/* 大纲行:角标组独立成列(在封面右侧、操作左侧),所以不会挤进封面里叠字。 */} + {layout === 'inline' ? badges : null} {displayTitle} - {relation === 'ancestor' || relation === 'descendant' ? ( - // 「父代 / 子代」是可读文本角标(不是只靠描边颜色),所以能被断言、也能被读屏。 - - {relation === 'ancestor' ? '父代' : '子代'} - - ) : null} {/* 作者名在两种形态都渲染(画布卡片曾因重构一度漏掉,导致节点少了作者信息、 也踩到了 e2e 的作者名断言):只有窄屏图谱的紧凑卡片才省掉它。 diff --git a/src/components/game-distribution/LineageOutlineList.tsx b/src/components/game-distribution/LineageOutlineList.tsx index 40afc417f..ba0d21254 100644 --- a/src/components/game-distribution/LineageOutlineList.tsx +++ b/src/components/game-distribution/LineageOutlineList.tsx @@ -1,5 +1,8 @@ import { LineageNodeContent } from './LineageNodeContent'; -import type { LineageRelationRole } from './lineageTreeLayout'; +import type { + LineagePlayCountBreakdown, + LineageRelationRole, +} from './lineageTreeLayout'; /** * 移动端**大纲式缩进树**(默认形态):全宽行 + 左缩进 + 肘形导线。 @@ -17,6 +20,7 @@ export function LineageOutlineList({ trunkGameIds, selectedGameId, relationByGameId, + playCountByGameId, onSelect, onOpenDetail, onOpenFork, @@ -35,6 +39,8 @@ export function LineageOutlineList({ selectedGameId: string; /** 每个节点相对「当前作品」的角色(父代 / 子代 / 自己 / 旁支):与画布同一份口径。 */ relationByGameId: ReadonlyMap; + /** 每个节点的游玩次数**子树合计**(显示口径,与画布同一份数据):紧凑角标 `▶ N`。 */ + playCountByGameId: ReadonlyMap; onSelect: (gameId: string) => void; onOpenDetail: (gameId: string) => void; onOpenFork: (gameId: string) => void; @@ -76,6 +82,8 @@ export function LineageOutlineList({ node.isStub ? '标题与作者名不对外展示' : node.authorName?.trim() || '未署名作者' + }· 游玩次数(含衍生作品)${ + playCountByGameId.get(node.gameId)?.total ?? 0 }`} onClick={() => onSelect(node.gameId)} > @@ -89,6 +97,7 @@ export function LineageOutlineList({ isStub={node.isStub} isTrunk={trunkGameIds.has(node.gameId)} relation={relation} + playCount={playCountByGameId.get(node.gameId) ?? null} /> {node.isStub ? null : ( diff --git a/src/components/game-distribution/gameDistribution.css b/src/components/game-distribution/gameDistribution.css index 3c94a22a8..55a030850 100644 --- a/src/components/game-distribution/gameDistribution.css +++ b/src/components/game-distribution/gameDistribution.css @@ -1014,15 +1014,10 @@ .game-lineage-edge--trunk { stroke-width: 6; } +/* 「主干」角标:只给颜色,定位/尺寸由角标组(.game-lineage-node__badges)统一管。 */ .game-lineage-node__trunk { - position: absolute; - top: 0.35rem; - right: 0.35rem; - border-radius: 999px; - padding: 0.1rem 0.45rem; background: var(--platform-accent-strong); color: #fff; - font-size: 0.64rem; font-weight: 700; } /* 右侧信息面板:浮动叠加在画布右上(控件在右下,避免互相遮挡)。 */ @@ -1263,11 +1258,58 @@ .game-lineage-node__content--inline { flex-direction: row; align-items: center; - gap: 0.45rem; + gap: 0.5rem; width: 100%; min-width: 0; padding: 0.15rem 0; } + +/* + * 角标组:代际 / 主干 / 当前作品 / 父代-子代 的**唯一排版容器**(flex + gap + 可换行)。 + * + * 为什么必须成组:这几枚角标以前各自 position: absolute(代际左上、主干右上、「当前作品」在流内), + * 在窄容器里会叠印成乱码字形(大纲行的「第 1 代」与「主干」就是这个问题)。 + * 现在它们同组排布 —— 重叠在结构上不可能发生,画布与大纲共用同一组角标。 + */ +.game-lineage-node__badges { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem; + min-width: 0; +} +/* 组内角标一律静态定位(颜色 / 圆角仍由各自规则给)。 */ +.game-lineage-node__badges .game-lineage-node__badge, +.game-lineage-node__badges .game-lineage-node__trunk, +.game-lineage-node__badges .game-lineage-node__anchor, +.game-lineage-node__badges .game-lineage-node__play-count, +.game-lineage-node__badges .game-lineage-node__relation { + position: static; + top: auto; + right: auto; + bottom: auto; + left: auto; + margin: 0; + border-radius: 999px; + padding: 0.05rem 0.4rem; + font-size: 0.6rem; + line-height: 1.6; + white-space: nowrap; +} +/* 画布卡片:整组角标作为封面左上角的浮层(一组一起排,不再各自定位)。 */ +.game-lineage-node__content--stacked .game-lineage-node__badges { + position: absolute; + top: 0.3rem; + left: 0.3rem; + right: 0.3rem; + justify-content: flex-start; + z-index: 1; +} +/* 大纲行:角标组在封面右侧、操作左侧,靠右对齐,标题占满剩余空间。 */ +.game-lineage-node__content--inline .game-lineage-node__badges { + flex: 0 1 auto; + justify-content: flex-end; +} .game-lineage-node__text { display: flex; flex-direction: column; @@ -1285,6 +1327,16 @@ overflow-wrap: anywhere; word-break: normal; } + +/* + * 画布卡片:标题**固定 2 行高度**(1 行也占满 2 行)。 + * 目的:同一层的卡片等高(连线端点对齐),1 行标题与 2 行标题不再把卡片高度拉得参差。 + * 大纲行不受影响(那里的行高由 `min-height: 3.5rem` 统一)。 + */ +.game-lineage-node__content--stacked .game-lineage-node__title { + /* 2 行标题的自然高度 ≈ 3.2em(实测),1 行标题也占满它 → 同层卡片等高。 */ + min-height: 3.2em; +} .game-lineage-node__meta { display: flex; min-width: 0; @@ -1299,7 +1351,9 @@ /* 链节点:横版小缩略图(64×36),矮而宽,触控目标 ≥44px。 */ .game-lineage-node__content--inline .game-lineage-node__cover { flex: 0 0 auto; - width: 4rem; + width: 4.5rem; + height: auto; + /* 与画布统一的 16:9 横版缩略图(曾在大纲行里被角标撑成窄竖条)。 */ aspect-ratio: 16 / 9; border-radius: 0.35rem; } @@ -1329,6 +1383,18 @@ text-align: left; cursor: pointer; } +/* + * 游玩次数角标:**该节点的子树合计**(`collectLineagePlayCountTotals().total`,口径见 §3.6.3)。 + * 紧凑 pill(▶ N)+ `title` 说明「含衍生作品」,与其它角标同行所以**不撑高卡片**;全 0 也显示。 + * 注意:主干线 / 热度排序的判据是**自身** playCount,与这个显示口径不同。 + */ +.game-lineage-node__play-count { + background: rgba(0, 0, 0, 0.62); + color: #fff; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + .game-lineage-node__cover { position: relative; display: block; @@ -1348,15 +1414,10 @@ height: 100%; border-radius: 0; } +/* 代际角标:只给颜色,定位/尺寸由角标组统一管。 */ .game-lineage-node__badge { - position: absolute; - top: 0.35rem; - left: 0.35rem; - border-radius: 999px; - padding: 0.1rem 0.5rem; background: rgba(0, 0, 0, 0.55); color: #fff; - font-size: 0.68rem; font-weight: 700; } .game-lineage-node__strip { @@ -2346,11 +2407,13 @@ display: flex; flex-direction: column; gap: 0.15rem; - margin: 0; + /* 内容列限宽 + 居中:大纲不再整屏出血(宽屏上曾是 1300px+ 横条,中间大片空白)。 + 操作按钮固定在行右端 = 内容列右端,不会飘到屏幕最右侧。 */ + margin: 0 auto; padding: 0; list-style: none; width: 100%; - max-width: 100%; + max-width: 54rem; overflow-x: hidden; } .lineage-outline__row { @@ -2359,6 +2422,8 @@ gap: 0.2rem; min-width: 0; max-width: 100%; + /* 统一基线:所有行同高(缩略图 16:9 + 标题最多 2 行),卡片高度不再参差。 */ + min-height: 3.5rem; box-sizing: border-box; } .lineage-outline__guide { @@ -2410,6 +2475,8 @@ display: flex; flex: 0 0 auto; gap: 0.25rem; + /* 与标题同一视觉组:贴文字右侧、行内右端,不跨出限宽内容列。 */ + margin-left: 0.25rem; } /* 大纲行里的标题:单行省略(不逐字换行)。 */ .lineage-outline .game-lineage-node__title { diff --git a/src/components/game-distribution/lineageTreeLayout.test.ts b/src/components/game-distribution/lineageTreeLayout.test.ts index 80e4fc154..de0126fc0 100644 --- a/src/components/game-distribution/lineageTreeLayout.test.ts +++ b/src/components/game-distribution/lineageTreeLayout.test.ts @@ -517,6 +517,65 @@ describe('主干线判据 = 自身 playCount(不是子树合计)', () => { }); }); +describe('主干唯一性(playCount 全 0 的演示树,输入顺序 = 服务端稳定序)', () => { + /** + * 演示树形状(`共创演示·A0` 那棵):A0 → A1/A2/A3;A1 → A11/A12;A11 → A111; + * A2 → A21。全部 `playCount = 0`(演示数据没有播放)。 + * 服务端 `/lineage` 契约保证「代际升序、同代按创建时间升序」,这里按该顺序喂入。 + */ + const demoTree = [ + node('A0', 0, null), + node('A1', 1, 'A0'), + node('A2', 1, 'A0'), + node('A3', 1, 'A0'), + node('A11', 2, 'A1'), + node('A12', 2, 'A1'), + node('A21', 2, 'A2'), + node('A111', 3, 'A11'), + ].map((item) => ({ ...item, playCount: 0 })); + + it('每代恰好一个主干(并列 0 热度时取同代中创建时间最早的),A2/A3 不是主干', () => { + const layout = buildLineageTreeLayout(demoTree); + const trunkByGeneration = new Map(); + for (const item of layout.nodes) { + if (!item.isTrunk) continue; + const bucket = trunkByGeneration.get(item.generation) ?? []; + bucket.push(item.gameId); + trunkByGeneration.set(item.generation, bucket); + } + // 每代最多一个(这就是「主干泛滥」的反例断言)。 + for (const [generation, ids] of trunkByGeneration) { + expect(ids, `第 ${generation} 代的主干`).toHaveLength(1); + } + expect(trunkByGeneration.get(0)).toEqual(['A0']); + expect(trunkByGeneration.get(1)).toEqual(['A1']); + expect(trunkByGeneration.get(2)).toEqual(['A11']); + expect(trunkByGeneration.get(3)).toEqual(['A111']); + const byId = new Map(layout.nodes.map((item) => [item.gameId, item])); + expect(byId.get('A2')!.isTrunk).toBe(false); + expect(byId.get('A3')!.isTrunk).toBe(false); + expect(byId.get('A12')!.isTrunk).toBe(false); + expect(byId.get('A21')!.isTrunk).toBe(false); + }); + + it('同代并列零热度:结果与输入顺序绑定(稳定、不随渲染变化)', () => { + // 同一棵树跑两次必须完全一致;把同代顺序换一下,主干跟着换成更早创建的那个。 + const first = buildLineageTreeLayout(demoTree); + const second = buildLineageTreeLayout(demoTree); + expect(first.nodes.map((item) => item.isTrunk)).toEqual( + second.nodes.map((item) => item.isTrunk), + ); + const reordered = buildLineageTreeLayout([ + node('A0', 0, null), + { ...node('A2', 1, 'A0'), playCount: 0 }, + { ...node('A1', 1, 'A0'), playCount: 0 }, + ]); + const byId = new Map(reordered.nodes.map((item) => [item.gameId, item])); + expect(byId.get('A2')!.isTrunk).toBe(true); + expect(byId.get('A1')!.isTrunk).toBe(false); + }); +}); + describe('collectLineageHighlight', () => { const tree = buildLineageTreeLayout([ node('root', 0, null),