From b99dcb7ef2d6b8e7c96a4c94063b5ccd4865f7b5 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 6 Oct 2026 17:16:46 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=B1=E5=88=9B=E6=A8=A1=E5=9D=97B:=20?= =?UTF-8?q?=E6=97=8F=E8=B0=B1=E9=A1=B5=E9=A1=B6=E9=83=A8=E7=AD=9B=E9=80=89?= =?UTF-8?q?=E5=99=A8=20+=20=E4=B8=BB=E5=B9=B2=E7=BA=BF(=E6=AF=8F=E4=BB=A3?= =?UTF-8?q?=E7=83=AD=E5=BA=A6=E6=9C=80=E9=AB=98)=20+=20=E5=8F=B3=E4=BE=A7?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GameDistributionPages.test.tsx | 165 ++++++++++++++- .../game-distribution/GameLineagePage.tsx | 192 +++++++++++++++++- .../game-distribution/gameDistribution.css | 118 +++++++++++ .../lineageTreeLayout.test.ts | 33 +++ .../game-distribution/lineageTreeLayout.ts | 22 ++ 5 files changed, 518 insertions(+), 12 deletions(-) diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index 6812b6914..1a235ae3d 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -2347,15 +2347,15 @@ describe('GameDetailPage 共创主题入口', () => { }); }); -describe('GameLineagePage 可视化与交互', () => { - /** 节点卡片上的主行动作按钮(打开详情)与它的卡片容器。 */ - 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; +/** 节点卡片上的主行动作按钮(打开详情)与它的卡片容器:多个 describe 共用。 */ +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; +describe('GameLineagePage 可视化与交互', () => { const treeWithSibling = { rootGameId: 'game-root', root: createLineageNode({ @@ -2739,6 +2739,155 @@ describe('GameLineagePage 可视化与交互', () => { }); }); +describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐)', () => { + const tree = { + rootGameId: 'game-root', + root: createLineageNode({ + gameId: 'game-root', + title: '根原创', + generation: 0, + parentGameId: null, + playCount: 5, + }), + nodes: [ + createLineageNode({ + gameId: 'game-root', + title: '根原创', + generation: 0, + parentGameId: null, + playCount: 5, + }), + createLineageNode({ + gameId: 'hot', + title: '热门二创', + generation: 1, + parentGameId: 'game-root', + playCount: 999, + }), + createLineageNode({ + gameId: 'cold', + title: '冷门二创', + generation: 1, + parentGameId: 'game-root', + playCount: 1, + }), + createLineageNode({ + gameId: 'deep', + title: '深代作品', + generation: 2, + parentGameId: 'hot', + playCount: 3, + }), + ], + truncated: false, + }; + + it('顶部筛选器:世代筛选只留该代及祖先,改动类型禁用并说明原因', async () => { + getGameLineageMock.mockResolvedValueOnce(tree); + render( + , + ); + + await screen.findByText('深代作品'); + const changeType = screen.getByLabelText('按改动类型筛选'); + expect((changeType as HTMLSelectElement).disabled).toBe(true); + expect( + screen.getByText('核心改动说明字段尚未上线'), + ).toBeTruthy(); + + fireEvent.change(screen.getByLabelText('按世代筛选'), { + target: { value: '1' }, + }); + // 保留第 0、1 代(树不断链),第 2 代被筛掉。 + expect(screen.queryByText('深代作品')).toBeNull(); + expect(await cardOf('热门二创')).toBeTruthy(); + expect(await cardOf('根原创')).toBeTruthy(); + }); + + it('按热度排序:同代内 playCount 高的排在前面', async () => { + getGameLineageMock.mockResolvedValueOnce(tree); + render( + , + ); + + await screen.findByText('热门二创'); + fireEvent.click(screen.getByRole('checkbox', { name: '按热度排序' })); + + const coldCard = await cardOf('冷门二创'); + const hotCard = await cardOf('热门二创'); + expect(parseFloat(hotCard.style.left)).toBeLessThan( + parseFloat(coldCard.style.left), + ); + }); + + it('主干线:每代热度最高的节点带「主干」角标,主干边单独标记', async () => { + getGameLineageMock.mockResolvedValueOnce(tree); + render( + , + ); + + await screen.findByText('热门二创'); + // 第 1 代主干 = 热门二创(999 > 1);冷门二创不是主干。 + expect( + (await cardOf('热门二创')).querySelector('.game-lineage-node__trunk'), + ).not.toBeNull(); + expect( + (await cardOf('冷门二创')).querySelector('.game-lineage-node__trunk'), + ).toBeNull(); + // 主干线:root→hot 与 hot→deep 两条。 + expect( + document.querySelectorAll('path.game-lineage-edge--trunk').length, + ).toBe(2); + }); + + it('选中节点后右侧信息面板给出简介/授权与「前往体验 / Fork 此版本」', async () => { + getGameLineageMock.mockResolvedValueOnce(tree); + getGameMock.mockResolvedValueOnce( + createGame({ + summary: '这是一个二创作品的简介', + forkAuthorization: 'nonCommercial', + }), + ); + const onOpenDetail = vi.fn(); + render( + , + ); + + const hotCard = await cardOf('热门二创'); + // 面板只在选中后出现。 + expect(screen.queryByLabelText('选中作品信息')).toBeNull(); + fireEvent.click(hotCard); + + const panel = await screen.findByLabelText('选中作品信息'); + expect(await within(panel).findByText('这是一个二创作品的简介')).toBeTruthy(); + expect(within(panel).getByText(/允许非商用共创/u)).toBeTruthy(); + // 「核心改动说明」字段还没上:不渲染这一行(不假装有数据)。 + expect(within(panel).queryByText(/核心改动说明/u)).toBeNull(); + + fireEvent.click(within(panel).getByRole('button', { name: '前往体验' })); + expect(onOpenDetail).toHaveBeenCalledWith('hot'); + + fireEvent.click(within(panel).getByRole('button', { name: 'Fork 此版本' })); + expect(openForkProtocolMock).toHaveBeenCalledWith('hot'); + }); +}); + describe('GameDetailPage 共创模块(文档对齐)', () => { it('世代/授权标签按文档口径渲染,并显示累计衍生作品数', async () => { getGameMock.mockResolvedValueOnce( diff --git a/src/components/game-distribution/GameLineagePage.tsx b/src/components/game-distribution/GameLineagePage.tsx index b2229d2f9..f0c98adf8 100644 --- a/src/components/game-distribution/GameLineagePage.tsx +++ b/src/components/game-distribution/GameLineagePage.tsx @@ -11,9 +11,14 @@ import { type WheelEvent as ReactWheelEvent, } from 'react'; +import { + GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS, + resolveGameForkAuthorization, +} from '../../../packages/shared/src/contracts/gameDistribution'; import { type GameDistributionGame, type GameDistributionLineageResponse, + getGame, getGameLineage, } from '../../services/gameDistributionClient'; import { CopyFeedbackButton } from '../common/CopyFeedbackButton'; @@ -189,6 +194,11 @@ function LineageTreeNodeCard({ {generationAxisLabel(generation)} + {layoutNode.isTrunk ? ( + + 主干 + + ) : null} {isAnchor ? ( 当前作品 ) : null} @@ -253,6 +263,11 @@ export function GameLineagePage({ const [error, setError] = useState(''); const [activeGameId, setActiveGameId] = useState(''); const [pinnedGameId, setPinnedGameId] = useState(''); + const [generationFilter, setGenerationFilter] = useState(null); + const [sortByHeat, setSortByHeat] = useState(false); + const [selectedGame, setSelectedGame] = useState( + null, + ); const isMobileViewport = useIsMobileViewport(); const [includeSubtree, setIncludeSubtree] = useState(true); const [viewport, setViewport] = useState({ @@ -307,12 +322,36 @@ export function GameLineagePage({ }, [loadLineage]); const nodes = useMemo(() => lineage?.nodes ?? [], [lineage]); + /** + * 顶部筛选器:按世代筛选(保留该代及其祖先,树不断链)与按热度排序 + * (同代内 `playCount` 倒序;关闭时保持服务端顺序 = 创建时间序)。 + */ + const filteredNodes = useMemo(() => { + const kept = + generationFilter === null + ? [...nodes] + : nodes.filter((node) => node.generation <= generationFilter); + if (!sortByHeat) return kept; + return kept + .map((node, index) => ({ node, index })) + .sort( + (left, right) => + left.node.generation - right.node.generation || + right.node.playCount - left.node.playCount || + left.index - right.index, + ) + .map((item) => item.node); + }, [generationFilter, nodes, sortByHeat]); + const generationsPresent = useMemo( + () => [...new Set(nodes.map((node) => node.generation))].sort((a, b) => a - b), + [nodes], + ); // 窄屏用紧凑布局(更小的卡片与间距 + 更大的相对字号),避免「桌面画布缩小 + 字太小」。 const compactTree = isMobileViewport; const layout = useMemo( () => buildLineageTreeLayout( - nodes, + filteredNodes, compactTree ? { // 紧凑态:卡片 176 宽 → 16:9 封面 99 高 + 信息条;度量仍满足「同层 ≥ columnGap」。 @@ -324,7 +363,7 @@ export function GameLineagePage({ } : undefined, ), - [compactTree, nodes], + [compactTree, filteredNodes], ); // 高亮目标:锁定(点击「锁定路径」)优先于 hover/聚焦;锁定后即使祖先移出视口,路径仍由面包屑兜底。 const highlightTargetId = pinnedGameId || activeGameId; @@ -430,6 +469,41 @@ export function GameLineagePage({ [], ); + /** + * 右侧信息面板的数据:只为**选中的那一个节点**请求一次公开详情(简介 / 授权类型)。 + * 「核心改动说明」等后端字段,缺失就不渲染这一行。 + */ + useEffect(() => { + if (!pinnedGameId) { + setSelectedGame(null); + return; + } + let alive = true; + void (async () => { + try { + const detail = await getGame(pinnedGameId); + if (alive) setSelectedGame(detail ?? null); + } catch { + if (alive) setSelectedGame(null); + } + })(); + return () => { + alive = false; + }; + }, [pinnedGameId]); + const selectedLayoutNode = pinnedGameId + ? (layout.nodes.find((item) => item.gameId === pinnedGameId) ?? null) + : null; + const selectedForkAuthorization = selectedGame + ? resolveGameForkAuthorization(selectedGame.forkAuthorization) + : null; + const selectedCoreChangeNote = + selectedGame && 'coreChangeNote' in selectedGame + ? typeof selectedGame.coreChangeNote === 'string' + ? selectedGame.coreChangeNote + : '' + : ''; + const zoomBy = useCallback((factor: number) => { setViewport((current) => { const scale = Math.min( @@ -593,6 +667,47 @@ export function GameLineagePage({ ) : ( <> +
+ + + {/* + 改动类型筛选依赖「本次核心改动说明」字段(后端还没上)。 + 按「字段可能存在」处理:**禁用 + 说明**,不假装能筛。 + */} + +
+ {/* + 右侧信息面板:只在**选中节点**时出现,浮动叠加(不推挤画布)。 + 简介 / 授权类型只为选中的这一个节点请求一次公开详情; + 「核心改动说明」是后端待补字段,缺失时整行不渲染。 + */} + {selectedLayoutNode ? ( + + ) : null} {derivedCount === 0 ? ( diff --git a/src/components/game-distribution/gameDistribution.css b/src/components/game-distribution/gameDistribution.css index a375aef2d..dfaeb8188 100644 --- a/src/components/game-distribution/gameDistribution.css +++ b/src/components/game-distribution/gameDistribution.css @@ -1018,6 +1018,109 @@ transition: transform 180ms ease-out; will-change: transform; } +/* 顶部筛选器(文档「顶部操作栏」):世代 / 热度排序 / 改动类型(待字段,禁用)。 */ +.game-lineage-filters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} +.game-lineage-filter { + display: inline-flex; + align-items: center; + gap: 0.3rem; + color: var(--platform-text-soft); + font-size: 0.78rem; +} +.game-lineage-filter select { + border: 1px solid var(--platform-surface-border); + border-radius: 0.5rem; + padding: 0.15rem 0.35rem; + background: var(--platform-surface, #fff); + color: var(--platform-text-base); + font: inherit; + font-size: 0.78rem; +} +.game-lineage-filter--disabled { + opacity: 0.55; +} +.game-lineage-filter__note { + color: var(--platform-text-soft); + font-size: 0.7rem; +} +/* 主干线:文档要求「每代热度最高的版本用粗实线连接」。 */ +.game-lineage-edge--trunk { + stroke-width: 6; +} +.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; +} +/* 右侧信息面板:浮动叠加在画布右上(控件在右下,避免互相遮挡)。 */ +.game-lineage-panel { + position: absolute; + z-index: 4; + top: 0.6rem; + right: 0.6rem; + display: flex; + flex-direction: column; + gap: 0.3rem; + width: min(18rem, calc(100% - 1.2rem)); + max-height: calc(100% - 4.5rem); + overflow-y: auto; + padding: 0.7rem 0.8rem; + border: 1px solid var(--platform-surface-border); + border-radius: 0.8rem; + background: var(--platform-surface, #fff); + box-shadow: 0 10px 26px rgba(76, 41, 27, 0.18); +} +.game-lineage-panel__title { + margin: 0; + color: var(--platform-text-strong); + font-size: 0.9rem; + overflow-wrap: anywhere; +} +.game-lineage-panel__meta, +.game-lineage-panel__note, +.game-lineage-panel__summary { + margin: 0; + color: var(--platform-text-soft); + font-size: 0.76rem; + line-height: 1.5; + overflow-wrap: anywhere; +} +.game-lineage-panel__summary { + color: var(--platform-text-base); +} +.game-lineage-panel__actions { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: 0.15rem; +} +.game-lineage-panel__button { + border: 1px solid var(--platform-surface-border); + border-radius: 999px; + padding: 0.2rem 0.7rem; + background: transparent; + color: var(--platform-text-base); + font: inherit; + font-size: 0.76rem; + font-weight: 600; + cursor: pointer; +} +.game-lineage-panel__button--primary { + border-color: var(--platform-accent-strong); + background: rgba(199, 101, 61, 0.14); + color: var(--platform-accent-strong); +} .game-lineage-canvas__edges { position: absolute; top: 0; @@ -2198,3 +2301,18 @@ font-size: 0.76rem; overflow-wrap: anywhere; } +/* 个人创作数据概览(我的作品页):与详情页共创模块同一视觉语言。 */ +.co-creation-overview { + display: flex; + flex-wrap: wrap; + gap: 0.8rem; + padding: 0.55rem 0.7rem; + border: 1px solid var(--platform-surface-border); + border-radius: 0.7rem; + background: var(--platform-surface-muted, rgba(104, 54, 36, 0.04)); +} +.co-creation-overview .co-creation-stat { + color: var(--platform-text-soft); + font-size: 0.78rem; + font-variant-numeric: tabular-nums; +} diff --git a/src/components/game-distribution/lineageTreeLayout.test.ts b/src/components/game-distribution/lineageTreeLayout.test.ts index 2edebb21e..21e4d1a0d 100644 --- a/src/components/game-distribution/lineageTreeLayout.test.ts +++ b/src/components/game-distribution/lineageTreeLayout.test.ts @@ -260,6 +260,39 @@ describe('buildLineageTreeLayout', () => { }); }); +describe('主干线(每代热度最高的版本)', () => { + it('按 playCount 选出每代主干节点,主干边标记 isTrunk', () => { + const layout = buildLineageTreeLayout([ + node('root', 0, null), + node('hot', 1, 'root'), + node('cold', 1, 'root'), + node('hot-child', 2, 'hot'), + ]); + const byId = new Map(layout.nodes.map((item) => [item.gameId, item])); + // node() 里 playCount = generation * 10,同代并列 → 取先出现的那个。 + expect(byId.get('root')!.isTrunk).toBe(true); + 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']); + }); + + it('热度更高的节点成为主干(显式 playCount 覆盖)', () => { + const cold = { + ...node('cold', 1, 'root'), + playCount: 1000, + }; + const layout = buildLineageTreeLayout([ + node('root', 0, null), + node('hot', 1, 'root'), + cold, + ]); + const byId = new Map(layout.nodes.map((item) => [item.gameId, item])); + expect(byId.get('cold')!.isTrunk).toBe(true); + expect(byId.get('hot')!.isTrunk).toBe(false); + }); +}); + describe('collectLineageAncestorChain', () => { const nodes = [ node('root', 0, null, '根原创'), diff --git a/src/components/game-distribution/lineageTreeLayout.ts b/src/components/game-distribution/lineageTreeLayout.ts index a8fdc18b5..0f3da5320 100644 --- a/src/components/game-distribution/lineageTreeLayout.ts +++ b/src/components/game-distribution/lineageTreeLayout.ts @@ -69,6 +69,11 @@ export type LineageLayoutNode = { * 不必等 hover。占位父节点属于它子节点所在的分支。 */ branchIndex: number; + /** + * 主干线节点:**该代 `playCount` 最高**的那个(并列取先出现的;占位节点不参与)。 + * 文档要求「每代热度最高的版本标为主干线,用粗实线连接」。 + */ + isTrunk: boolean; }; export type LineageLayoutEdge = { @@ -81,6 +86,8 @@ export type LineageLayoutEdge = { to: { x: number; y: number }; /** 该边属于哪棵顶级子树,用于默认态按分支着色。 */ branchIndex: number; + /** 主干线:父与子都是各自代际的热度最高节点(渲染成粗实线)。 */ + isTrunk: boolean; }; export type LineageGenerationAxisEntry = { @@ -259,9 +266,23 @@ export function buildLineageTreeLayout( height: nodeHeight, isStub: entry.node === null, branchIndex: branchByGameId.get(entry.gameId) ?? 0, + isTrunk: false, }; }); + // 主干线:每代取 `playCount` 最高的真实节点(占位节点不参与、并列取先出现的)。 + const trunkByGeneration = new Map(); + for (const node of layoutNodes) { + if (node.isStub) continue; + const current = trunkByGeneration.get(node.generation); + if (!current || node.node!.playCount > current.node!.playCount) { + trunkByGeneration.set(node.generation, node); + } + } + for (const node of layoutNodes) { + node.isTrunk = trunkByGeneration.get(node.generation) === node; + } + const layoutById = new Map(layoutNodes.map((node) => [node.gameId, node])); const edges: LineageLayoutEdge[] = []; for (const node of layoutNodes) { @@ -304,6 +325,7 @@ export function buildLineageTreeLayout( to, path, branchIndex: parent.branchIndex, + isTrunk: parent.isTrunk && node.isTrunk, }); }