From bd73acfccd0056b98c95e92a567c571d22d01ff7 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 6 Oct 2026 18:14:26 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=B1=E5=88=9B=E8=AF=A6=E6=83=85/=E6=97=8F?= =?UTF-8?q?=E8=B0=B1:=20=E4=B8=96=E4=BB=A3=E6=95=B0=E6=94=B9=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E6=8C=89=E5=AD=90=E6=A0=91=E6=B7=B1=E5=BA=A6=E8=AE=A1?= =?UTF-8?q?=E7=AE=97=EF=BC=9B=E9=9D=A2=E6=9D=BF=E6=A0=B8=E5=BF=83=E6=94=B9?= =?UTF-8?q?=E5=8A=A8=E8=AF=B4=E6=98=8E=E8=AF=BB=E7=89=88=E6=9C=AC=E6=91=98?= =?UTF-8?q?=E8=A6=81=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../game-distribution/GameDetailPage.tsx | 34 ++++----- .../GameDistributionPages.test.tsx | 72 ++++++++++++++----- .../game-distribution/GameLineagePage.tsx | 31 ++++---- .../lineageTreeLayout.test.ts | 44 ++++++++++-- .../game-distribution/lineageTreeLayout.ts | 48 ++++++++++++- 5 files changed, 175 insertions(+), 54 deletions(-) diff --git a/src/components/game-distribution/GameDetailPage.tsx b/src/components/game-distribution/GameDetailPage.tsx index 6910a6757..e82e86031 100644 --- a/src/components/game-distribution/GameDetailPage.tsx +++ b/src/components/game-distribution/GameDetailPage.tsx @@ -58,7 +58,10 @@ import { VERSION_STATUS_LABEL, VERSION_STATUS_NEEDS_AUTHOR_ACTION, } from './gameVersionHistoryModel'; -import { collectLineageAncestorChain } from './lineageTreeLayout'; +import { + collectLineageAncestorChain, + collectLineageSubtreeDepth, +} from './lineageTreeLayout'; import { useGameAuthorDisplayName } from './useGameAuthorName'; import { useGameReviews } from './useGameReviews'; @@ -577,15 +580,11 @@ function GameDetailContent({ GameDistributionLineageNode[] >([]); /** - * 「累计创作世代数」:另一个 session 正在给公开详情补这个字段(暂定名 - * `descendantGenerationCount`)。**按「字段可能存在」写**:缺失时整项不渲染, - * 不拿 `forkCount` 或别的数字凑数(避免误导)。字段到位后这里自然生效。 + * 「累计创作世代数」=**本作品子树的深度**(子树最大代际 − 本作品代际)。 + * 用详情页已经拿到的 `/lineage` 同一份数据在前端算,不发新请求; + * 空树 / 只有自己时是 0(界面上给出「暂无衍生」文案)。 */ - const descendantGenerationCount = - 'descendantGenerationCount' in game && - typeof game.descendantGenerationCount === 'number' - ? game.descendantGenerationCount - : undefined; + const [descendantDepth, setDescendantDepth] = useState(null); const loadCoCreationData = useCallback(async () => { if (!publiclyVisible) { setLineageChain([]); @@ -612,6 +611,9 @@ function GameDetailContent({ setLineageChain( tree ? collectLineageAncestorChain(tree.nodes, game.id) : [], ); + setDescendantDepth( + tree ? collectLineageSubtreeDepth(tree.nodes, game.id) : null, + ); setDerivedNodes(derived?.nodes ?? []); }, [game.id, publiclyVisible]); useEffect(() => { @@ -871,14 +873,15 @@ function GameDetailContent({ ) : null} ) : null} - {/* 数据概览:累计衍生作品数 + 累计创作世代数(后者等后端字段,缺失不渲染)。 */} + {/* 数据概览:累计衍生作品数 + 累计创作世代数(子树深度,前端用同一份 lineage 数据算)。 */} 累计衍生作品 {forkCount} - {descendantGenerationCount === undefined ? null : ( + {descendantDepth === null ? null : ( - 累计创作世代数 {descendantGenerationCount} + 累计创作世代数 {descendantDepth} + {descendantDepth === 0 ? '(暂无衍生)' : ''} )} @@ -980,12 +983,9 @@ function GameDetailContent({ handleForkLaunch(); }} > - {isAuthenticated - ? '一键 Fork 共创' - : '登录后一键 Fork'} + {isAuthenticated ? '一键 Fork 共创' : '登录后一键 Fork'} - {(isAuthenticated || - forkAuthorization === 'forbidden') && + {(isAuthenticated || forkAuthorization === 'forbidden') && forkLaunchNotice ? ( {forkLaunchNotice} diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index 1a235ae3d..7177d1034 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -1854,14 +1854,18 @@ describe('GameDetailPage Fork 入口', () => { , ); - const blocked = await screen.findByRole('button', { name: '一键 Fork 共创' }); + const blocked = await screen.findByRole('button', { + name: '一键 Fork 共创', + }); // 不静默隐藏:按钮在,但是 aria-disabled + 有原因(悬停 title)。 expect(blocked.getAttribute('aria-disabled')).toBe('true'); expect(blocked.getAttribute('title')).toContain('禁止共创'); // 点击给出可读原因(不发起唤起)。 fireEvent.click(blocked); expect( - await screen.findByText(/作者未开放共创授权(禁止共创),无法 Fork 这部作品。/u), + await screen.findByText( + /作者未开放共创授权(禁止共创),无法 Fork 这部作品。/u, + ), ).toBeTruthy(); expect(openForkProtocolMock).not.toHaveBeenCalled(); }); @@ -1883,7 +1887,9 @@ describe('GameDetailPage Fork 入口', () => { await screen.findByText('作品状态'); expect(screen.queryByRole('button', { name: '一键 Fork 共创' })).toBeNull(); - expect(screen.queryByRole('button', { name: '登录后一键 Fork' })).toBeNull(); + expect( + screen.queryByRole('button', { name: '登录后一键 Fork' }), + ).toBeNull(); }); it('未登录时入口显示「登录后一键 Fork」,点击走仓库既有登录门禁而不是静默隐藏', async () => { @@ -2351,9 +2357,9 @@ describe('GameDetailPage 共创主题入口', () => { const openButton = (title: string) => screen.getByRole('button', { name: `打开《${title}》详情` }); const cardOf = async (title: string) => - ( - await screen.findByRole('button', { name: `打开《${title}》详情` }) - ).closest('.game-lineage-node') as HTMLElement; + (await screen.findByRole('button', { name: `打开《${title}》详情` })).closest( + '.game-lineage-node', + ) as HTMLElement; describe('GameLineagePage 可视化与交互', () => { const treeWithSibling = { @@ -2795,9 +2801,7 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐) await screen.findByText('深代作品'); const changeType = screen.getByLabelText('按改动类型筛选'); expect((changeType as HTMLSelectElement).disabled).toBe(true); - expect( - screen.getByText('核心改动说明字段尚未上线'), - ).toBeTruthy(); + expect(screen.getByText('改动说明是自由文本,暂无可筛选分类')).toBeTruthy(); fireEvent.change(screen.getByLabelText('按世代筛选'), { target: { value: '1' }, @@ -2858,6 +2862,11 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐) createGame({ summary: '这是一个二创作品的简介', forkAuthorization: 'nonCommercial', + currentVersion: { + id: 'v1', + versionNumber: 1, + changeSummary: '把跳台改成三段,并重画全部背景', + }, }), ); const onOpenDetail = vi.fn(); @@ -2875,10 +2884,15 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐) fireEvent.click(hotCard); const panel = await screen.findByLabelText('选中作品信息'); - expect(await within(panel).findByText('这是一个二创作品的简介')).toBeTruthy(); + expect( + await within(panel).findByText('这是一个二创作品的简介'), + ).toBeTruthy(); expect(within(panel).getByText(/允许非商用共创/u)).toBeTruthy(); // 「核心改动说明」字段还没上:不渲染这一行(不假装有数据)。 - expect(within(panel).queryByText(/核心改动说明/u)).toBeNull(); + // 核心改动说明来自版本摘要层(selectedGame.currentVersion.changeSummary)。 + expect( + within(panel).getByText(/核心改动说明:把跳台改成三段/u), + ).toBeTruthy(); fireEvent.click(within(panel).getByRole('button', { name: '前往体验' })); expect(onOpenDetail).toHaveBeenCalledWith('hot'); @@ -2913,9 +2927,7 @@ describe('GameDetailPage 共创模块(文档对齐)', () => { expect(screen.getByText('累计衍生作品 3')).toBeTruthy(); expect(screen.getByText('2 代三创 · 改编自《原初之光》')).toBeTruthy(); // 主操作按文档叫「一键 Fork 共创」。 - expect( - screen.getByRole('button', { name: '一键 Fork 共创' }), - ).toBeTruthy(); + expect(screen.getByRole('button', { name: '一键 Fork 共创' })).toBeTruthy(); }); it('累计创作世代数字段缺失时不渲染该概览项(不拿别的数字凑数)', async () => { @@ -2979,7 +2991,9 @@ describe('GameDetailPage 共创模块(文档对齐)', () => { screen.getByRole('button', { name: /0 代作品《根原创》/u }), ); expect(onOpenGameDetail).toHaveBeenCalledWith('game-root'); - fireEvent.click(screen.getByRole('button', { name: /2 代作品《当前作品》/u })); + fireEvent.click( + screen.getByRole('button', { name: /2 代作品《当前作品》/u }), + ); expect(onOpenGameDetail).toHaveBeenCalledTimes(1); }); @@ -2988,9 +3002,27 @@ describe('GameDetailPage 共创模块(文档对齐)', () => { getDerivedGamesMock.mockResolvedValueOnce({ gameId: 'game-1', nodes: [ - createLineageNode({ gameId: 'child-low', title: '低热度', playCount: 1, generation: 1, parentGameId: 'game-1' }), - createLineageNode({ gameId: 'child-hot', title: '高热度', playCount: 99, generation: 1, parentGameId: 'game-1' }), - createLineageNode({ gameId: 'child-mid', title: '中热度', playCount: 50, generation: 1, parentGameId: 'game-1' }), + createLineageNode({ + gameId: 'child-low', + title: '低热度', + playCount: 1, + generation: 1, + parentGameId: 'game-1', + }), + createLineageNode({ + gameId: 'child-hot', + title: '高热度', + playCount: 99, + generation: 1, + parentGameId: 'game-1', + }), + createLineageNode({ + gameId: 'child-mid', + title: '中热度', + playCount: 50, + generation: 1, + parentGameId: 'game-1', + }), ], truncated: false, }); @@ -3012,7 +3044,9 @@ describe('GameDetailPage 共创模块(文档对齐)', () => { expect(hot.textContent?.indexOf('高热度')).toBeLessThan( hot.textContent?.indexOf('中热度') ?? -1, ); - fireEvent.click(screen.getByRole('button', { name: /热门衍生《高热度》/u })); + fireEvent.click( + screen.getByRole('button', { name: /热门衍生《高热度》/u }), + ); expect(onOpenGameDetail).toHaveBeenCalledWith('child-hot'); }); }); diff --git a/src/components/game-distribution/GameLineagePage.tsx b/src/components/game-distribution/GameLineagePage.tsx index f0c98adf8..3c049873d 100644 --- a/src/components/game-distribution/GameLineagePage.tsx +++ b/src/components/game-distribution/GameLineagePage.tsx @@ -195,7 +195,10 @@ function LineageTreeNodeCard({ {generationAxisLabel(generation)} {layoutNode.isTrunk ? ( - + 主干 ) : null} @@ -343,7 +346,8 @@ export function GameLineagePage({ .map((item) => item.node); }, [generationFilter, nodes, sortByHeat]); const generationsPresent = useMemo( - () => [...new Set(nodes.map((node) => node.generation))].sort((a, b) => a - b), + () => + [...new Set(nodes.map((node) => node.generation))].sort((a, b) => a - b), [nodes], ); // 窄屏用紧凑布局(更小的卡片与间距 + 更大的相对字号),避免「桌面画布缩小 + 字太小」。 @@ -497,12 +501,9 @@ export function GameLineagePage({ const selectedForkAuthorization = selectedGame ? resolveGameForkAuthorization(selectedGame.forkAuthorization) : null; + /** 核心改动说明在**版本摘要层**(`currentVersion.changeSummary`),从这一次公开详情里读。 */ const selectedCoreChangeNote = - selectedGame && 'coreChangeNote' in selectedGame - ? typeof selectedGame.coreChangeNote === 'string' - ? selectedGame.coreChangeNote - : '' - : ''; + selectedGame?.currentVersion?.changeSummary?.trim() ?? ''; const zoomBy = useCallback((factor: number) => { setViewport((current) => { @@ -672,10 +673,16 @@ export function GameLineagePage({ 世代 - 核心改动说明字段尚未上线 + 改动说明是自由文本,暂无可筛选分类 diff --git a/src/components/game-distribution/lineageTreeLayout.test.ts b/src/components/game-distribution/lineageTreeLayout.test.ts index 21e4d1a0d..f9348d591 100644 --- a/src/components/game-distribution/lineageTreeLayout.test.ts +++ b/src/components/game-distribution/lineageTreeLayout.test.ts @@ -7,6 +7,7 @@ import { buildLineageTreeLayout, collectLineageAncestorChain, collectLineageHighlight, + collectLineageSubtreeDepth, generationAxisLabel, LINEAGE_COLUMN_GAP, LINEAGE_COVER_ASPECT, @@ -274,7 +275,10 @@ describe('主干线(每代热度最高的版本)', () => { expect(byId.get('hot')!.isTrunk).toBe(true); expect(byId.get('cold')!.isTrunk).toBe(false); const trunkEdges = layout.edges.filter((edge) => edge.isTrunk); - expect(trunkEdges.map((edge) => edge.id)).toEqual(['root->hot', 'hot->hot-child']); + expect(trunkEdges.map((edge) => edge.id)).toEqual([ + 'root->hot', + 'hot->hot-child', + ]); }); it('热度更高的节点成为主干(显式 playCount 覆盖)', () => { @@ -293,6 +297,34 @@ describe('主干线(每代热度最高的版本)', () => { }); }); +describe('collectLineageSubtreeDepth', () => { + it('深度 = 子树最大代际 − 本作品代际', () => { + const nodes = [ + node('root', 0, null), + node('a', 1, 'root'), + node('b', 2, 'a'), + node('c', 3, 'b'), + node('sibling', 1, 'root'), + ]; + expect(collectLineageSubtreeDepth(nodes, 'root')).toBe(3); + expect(collectLineageSubtreeDepth(nodes, 'a')).toBe(2); + expect(collectLineageSubtreeDepth(nodes, 'sibling')).toBe(0); + }); + + it('单节点 / 未知作品 / 断链都返回 0', () => { + expect(collectLineageSubtreeDepth([node('root', 0, null)], 'root')).toBe(0); + expect(collectLineageSubtreeDepth([node('root', 0, null)], 'game-x')).toBe( + 0, + ); + const orphan = [ + node('orphan', 1, 'game-missing'), + node('child', 2, 'orphan'), + ]; + // 父不可用不影响「本作品自己的子树」深度。 + expect(collectLineageSubtreeDepth(orphan, 'orphan')).toBe(1); + }); +}); + describe('collectLineageAncestorChain', () => { const nodes = [ node('root', 0, null, '根原创'), @@ -303,9 +335,9 @@ describe('collectLineageAncestorChain', () => { ]; it('从根到目标按顺序返回(供「根原创 → 1 代 → … → 当前」展示)', () => { - expect(collectLineageAncestorChain(nodes, 'b').map((item) => item.gameId)).toEqual( - ['root', 'a', 'b'], - ); + expect( + collectLineageAncestorChain(nodes, 'b').map((item) => item.gameId), + ).toEqual(['root', 'a', 'b']); }); it('父不在可见集合里时链就停在那里(父作品不可用口径一致)', () => { @@ -315,7 +347,9 @@ describe('collectLineageAncestorChain', () => { }); it('未知节点返回空链,且遇到自环不会死循环', () => { - expect(collectLineageAncestorChain(nodes, 'game-missing-chain')).toEqual([]); + expect(collectLineageAncestorChain(nodes, 'game-missing-chain')).toEqual( + [], + ); const cyclic = [node('x', 1, 'y'), node('y', 0, 'x')]; expect(collectLineageAncestorChain(cyclic, 'x')).toHaveLength(2); }); diff --git a/src/components/game-distribution/lineageTreeLayout.ts b/src/components/game-distribution/lineageTreeLayout.ts index 0f3da5320..1605a475c 100644 --- a/src/components/game-distribution/lineageTreeLayout.ts +++ b/src/components/game-distribution/lineageTreeLayout.ts @@ -346,6 +346,48 @@ export function buildLineageTreeLayout( }; } +/** + * 本作品的**子树深度** = 子树最大代际 − 本作品代际(文档「累计创作世代数」的语义)。 + * + * 用同一份 `/lineage` 数据算,不发新请求;BFS 沿 `parentGameId` 的**反向**关系向下走。 + * 空树 / 找不到本作品 → 0。注意:节点上限 200 时只在可见集合上计算(截断场景会在页面上标注)。 + */ +export function collectLineageSubtreeDepth< + T extends { + gameId: string; + parentGameId?: string | null; + generation: number; + }, +>(nodes: readonly T[], gameId: string): number { + const self = nodes.find((node) => node.gameId === gameId); + if (!self) return 0; + const childrenByParent = new Map(); + for (const node of nodes) { + const parentGameId = node.parentGameId ?? null; + if (!parentGameId) continue; + const bucket = childrenByParent.get(parentGameId); + if (bucket) bucket.push(node.gameId); + else childrenByParent.set(parentGameId, [node.gameId]); + } + const generationById = new Map( + nodes.map((node) => [node.gameId, node.generation]), + ); + const seen = new Set([gameId]); + const queue = [...(childrenByParent.get(gameId) ?? [])]; + let maxGeneration = self.generation; + while (queue.length > 0) { + const current = queue.shift() as string; + if (seen.has(current)) continue; + seen.add(current); + maxGeneration = Math.max( + maxGeneration, + generationById.get(current) ?? self.generation, + ); + queue.push(...(childrenByParent.get(current) ?? [])); + } + return Math.max(0, maxGeneration - self.generation); +} + /** * 血缘链:从根到 `gameId` 的**有序**节点列表(血缘溯源条用)。 * @@ -353,7 +395,11 @@ export function buildLineageTreeLayout( * 展示与点击用)。父节点不在可见集合里时链就停在那里(与既有的「父作品不可用」口径一致)。 */ export function collectLineageAncestorChain< - T extends { gameId: string; parentGameId?: string | null; generation: number }, + T extends { + gameId: string; + parentGameId?: string | null; + generation: number; + }, >(nodes: readonly T[], gameId: string): T[] { const byId = new Map(nodes.map((node) => [node.gameId, node])); const chain: T[] = [];