From 475acf0f41e683808452e11dd11738dc47d42165 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 6 Oct 2026 17:06:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=B1=E5=88=9B=E6=A8=A1=E5=9D=97A:=20?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=A1=B5=E4=B8=96=E4=BB=A3/=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E6=A0=87=E7=AD=BE=20+=20=E4=B8=80=E9=94=AE=20Fork=20?= =?UTF-8?q?=E4=B8=BB=E6=93=8D=E4=BD=9C=20+=20=E6=A6=82=E8=A7=88=20+=20?= =?UTF-8?q?=E6=BA=AF=E6=BA=90=E6=9D=A1=20+=20=E7=83=AD=E9=97=A8=E8=A1=8D?= =?UTF-8?q?=E7=94=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CoCreationHotDerived.tsx | 60 ++++++ .../CoCreationLineageStrip.tsx | 70 +++++++ .../game-distribution/GameDetailPage.tsx | 172 +++++++++++++++--- .../GameDistributionPages.test.tsx | 172 ++++++++++++++++-- .../game-distribution/coCreationCopy.ts | 23 +++ .../game-distribution/gameDistribution.css | 152 ++++++++++++++++ .../lineageTreeLayout.test.ts | 29 +++ .../game-distribution/lineageTreeLayout.ts | 21 +++ .../PlatformEntryActiveFlowShell.tsx | 1 + 9 files changed, 657 insertions(+), 43 deletions(-) create mode 100644 src/components/game-distribution/CoCreationHotDerived.tsx create mode 100644 src/components/game-distribution/CoCreationLineageStrip.tsx create mode 100644 src/components/game-distribution/coCreationCopy.ts diff --git a/src/components/game-distribution/CoCreationHotDerived.tsx b/src/components/game-distribution/CoCreationHotDerived.tsx new file mode 100644 index 000000000..3d10430da --- /dev/null +++ b/src/components/game-distribution/CoCreationHotDerived.tsx @@ -0,0 +1,60 @@ +import type { GameDistributionLineageNode } from '../../services/gameDistributionClient'; +import { coCreationGenerationShortLabel } from './coCreationCopy'; +import { GameCover } from './GameCover'; + +/** + * 热门衍生栏:当前作品下热度最高的 5 个分支作品(横向卡片)。 + * + * 热度口径:**`playCount` 倒序**,同热度按创建顺序(服务端顺序)稳定兜底。 + * `/derived` 目前不保证服务端排序,所以排序在客户端做;后端若补了排序, + * 这段逻辑仍然成立(只是排序变成幂等)。 + */ +export const CO_CREATION_HOT_DERIVED_LIMIT = 5; + +export function CoCreationHotDerived({ + nodes, + onOpenGameDetail, +}: { + nodes: readonly GameDistributionLineageNode[]; + onOpenGameDetail: (gameId: string) => void; +}) { + const hottest = [...nodes] + .sort((left, right) => right.playCount - left.playCount) + .slice(0, CO_CREATION_HOT_DERIVED_LIMIT); + if (hottest.length === 0) return null; + + return ( +
+ {hottest.map((node) => ( + + ))} +
+ ); +} + +/** `GameCover` 只读 title / coverColor / icon / coverObjectKey;族谱节点没有完整游戏行。 */ +function gameForCover(node: GameDistributionLineageNode) { + return { + title: node.title, + coverColor: '#F3E4D0', + icon: '🎮', + coverObjectKey: node.coverObjectKey, + } as never; +} + +export default CoCreationHotDerived; diff --git a/src/components/game-distribution/CoCreationLineageStrip.tsx b/src/components/game-distribution/CoCreationLineageStrip.tsx new file mode 100644 index 000000000..02b0f5970 --- /dev/null +++ b/src/components/game-distribution/CoCreationLineageStrip.tsx @@ -0,0 +1,70 @@ +import type { GameDistributionLineageNode } from '../../services/gameDistributionClient'; +import { + coCreationAvatarInitial, + coCreationGenerationShortLabel, +} from './coCreationCopy'; + +/** + * 血缘溯源条:横向完整链路「根原创 → 1 代 → 2 代 → … → 当前作品」。 + * + * 数据来自既有 `/games/{id}/lineage`(在详情页请求一次,按 `parentGameId` 上溯成链, + * 不用整棵大树、也不新增接口)。每个节点显示作者字标 + 世代,点击跳该代作品详情。 + * + * 头像:`/lineage` 节点只带 `authorName`(没有头像字段),因此用姓名首字的圆形字标降级, + * 不伪造头像图片;后端补字段后换成真实头像即可(结构不用改)。 + */ +export function CoCreationLineageStrip({ + chain, + currentGameId, + onOpenGameDetail, +}: { + /** 从根到当前作品的顺序列表。 */ + chain: readonly GameDistributionLineageNode[]; + currentGameId: string; + onOpenGameDetail: (gameId: string) => void; +}) { + if (chain.length <= 1) return null; + + return ( +
+ {chain.map((node, index) => { + const isCurrent = node.gameId === currentGameId; + return ( + + {index > 0 ? ( + + ) : null} + + + ); + })} +
+ ); +} + +export default CoCreationLineageStrip; diff --git a/src/components/game-distribution/GameDetailPage.tsx b/src/components/game-distribution/GameDetailPage.tsx index 78173e698..6910a6757 100644 --- a/src/components/game-distribution/GameDetailPage.tsx +++ b/src/components/game-distribution/GameDetailPage.tsx @@ -18,9 +18,12 @@ import { ApiClientError } from '../../services/apiClient'; import { type GameDistributionGame, type GameDistributionInputMode, + type GameDistributionLineageNode, type GameDistributionMyGame, type GameDistributionVersionStatusEntry, + getDerivedGames, getGame, + getGameLineage, getMyGame, loadVersionRecoveryActions, purchaseGame, @@ -34,6 +37,9 @@ import { PlatformMudPointConfirmDialog } from '../common/PlatformMudPointConfirm import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { useCopyFeedback } from '../common/useCopyFeedback'; import { CreatorAuthor } from '../creator/CreatorRelationshipControl'; +import { coCreationGenerationTagLabel } from './coCreationCopy'; +import { CoCreationHotDerived } from './CoCreationHotDerived'; +import { CoCreationLineageStrip } from './CoCreationLineageStrip'; import { normalizeGameEntryUrl, normalizeGameId, @@ -52,6 +58,7 @@ import { VERSION_STATUS_LABEL, VERSION_STATUS_NEEDS_AUTHOR_ACTION, } from './gameVersionHistoryModel'; +import { collectLineageAncestorChain } from './lineageTreeLayout'; import { useGameAuthorDisplayName } from './useGameAuthorName'; import { useGameReviews } from './useGameReviews'; @@ -72,6 +79,8 @@ type GameDetailPageProps = { onOpenCompare?: (leftGameId: string, rightGameId: string) => void; /** 打开所属共创主题页(`/games/theme?id=`)。 */ onOpenTheme?: (themeId: string) => void; + /** 打开另一部作品的详情页(血缘溯源条 / 热门衍生卡片跳转用)。 */ + onOpenGameDetail?: (gameId: string) => void; /** * 打开平台充值面板;余额不足以购买付费作品时由宿主提供入口。 * 宿主没有充值能力时(例如独立嵌入)传入空值,页面只保留余额提示。 @@ -166,6 +175,7 @@ export function GameDetailPage({ onOpenCreator, onOpenCompare, onOpenTheme, + onOpenGameDetail, onOpenRecharge, ownerView = false, }: GameDetailPageProps) { @@ -452,6 +462,7 @@ export function GameDetailPage({ onOpenLineage={onOpenLineage} onOpenCompare={onOpenCompare} onOpenTheme={onOpenTheme} + onOpenGameDetail={onOpenGameDetail} isAuthenticated={Boolean(authUi?.user)} /> void; onOpenCompare?: (leftGameId: string, rightGameId: string) => void; onOpenTheme?: (themeId: string) => void; + onOpenGameDetail?: (gameId: string) => void; }) { /** * 只有已公开作品才有公开评价/评分投影;审核中、审核未通过、未公开、已下架 @@ -553,6 +566,57 @@ function GameDetailContent({ const forkCount = game.forkCount ?? 0; // 所属共创主题:匿名也返回;没有主题时是空数组(不渲染空容器)。 const themes = game.themes ?? []; + /** + * 共创模块的附加数据:血缘链(根 → … → 当前)与热门衍生(`/derived`)。 + * 两者都是**公开读接口**,失败只影响这一块,不影响作品主信息(各自 catch 成空)。 + */ + const [lineageChain, setLineageChain] = useState< + GameDistributionLineageNode[] + >([]); + const [derivedNodes, setDerivedNodes] = useState< + GameDistributionLineageNode[] + >([]); + /** + * 「累计创作世代数」:另一个 session 正在给公开详情补这个字段(暂定名 + * `descendantGenerationCount`)。**按「字段可能存在」写**:缺失时整项不渲染, + * 不拿 `forkCount` 或别的数字凑数(避免误导)。字段到位后这里自然生效。 + */ + const descendantGenerationCount = + 'descendantGenerationCount' in game && + typeof game.descendantGenerationCount === 'number' + ? game.descendantGenerationCount + : undefined; + const loadCoCreationData = useCallback(async () => { + if (!publiclyVisible) { + setLineageChain([]); + setDerivedNodes([]); + return; + } + // 每个调用各自兜底(含同步抛错):附加数据失败不能影响作品主信息。 + const [tree, derived] = await Promise.all([ + (async () => { + try { + return await getGameLineage(game.id); + } catch { + return null; + } + })(), + (async () => { + try { + return await getDerivedGames(game.id); + } catch { + return null; + } + })(), + ]).catch(() => [null, null] as const); + setLineageChain( + tree ? collectLineageAncestorChain(tree.nodes, game.id) : [], + ); + setDerivedNodes(derived?.nodes ?? []); + }, [game.id, publiclyVisible]); + useEffect(() => { + void loadCoCreationData(); + }, [loadCoCreationData]); // Fork 的主路径是唤起客户端;只有「没失焦(没客户端接手)」或「平台不可能唤起」才降级成 // 「复制作品 ID / 链接 → 到客户端粘贴」。降级面板同理只在降级时出现。 const [isForkPanelOpen, setIsForkPanelOpen] = useState(false); @@ -570,7 +634,6 @@ function GameDetailContent({ typeof window === 'undefined' ? `/games/detail?id=${encodeURIComponent(game.id)}` : `${window.location.origin}/games/detail?id=${encodeURIComponent(game.id)}`; - const canRemix = publiclyVisible && forkAuthorization !== 'forbidden'; // 收藏态:服务端只在**已认证**时返回 `collected`,匿名请求完全没有这个键。 // 因此「缺字段」一律按不可判定处理(未登录 / 过期 token 被按匿名处理), // 绝不落成「未收藏」——否则未登录用户会看到一个假的已收藏/未收藏结论。 @@ -773,14 +836,21 @@ function GameDetailContent({ label: '共创', value: ( - - { - GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[ - forkAuthorization - ] - } + + {/* 世代标签(文档口径:0 代原创 / 1 代二创 / 2 代三创 / N 代迭代)。 */} + + {coCreationGenerationTagLabel(lineage?.generation ?? 0)} + + {/* 授权类型标签:沿用平台既有 constants(含「允许」前缀),不另造一套文案。 */} + + { + GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[ + forkAuthorization + ] + } + {lineage ? ( <> @@ -791,8 +861,8 @@ function GameDetailContent({ */} {lineage.parentTitle - ? `第 ${lineage.generation} 代作品 · 改编自《${lineage.parentTitle}》` - : `第 ${lineage.generation} 代作品 · 原作品已不可用`} + ? `${coCreationGenerationTagLabel(lineage.generation)} · 改编自《${lineage.parentTitle}》` + : `${coCreationGenerationTagLabel(lineage.generation)} · 原作品已不可用`} {lineage.parentAuthorName ? ( @@ -801,11 +871,17 @@ function GameDetailContent({ ) : null} ) : null} - {forkCount > 0 ? ( - - 已被改编 {forkCount} 次 + {/* 数据概览:累计衍生作品数 + 累计创作世代数(后者等后端字段,缺失不渲染)。 */} + + + 累计衍生作品 {forkCount} - ) : null} + {descendantGenerationCount === undefined ? null : ( + + 累计创作世代数 {descendantGenerationCount} + + )} + {onOpenLineage && publiclyVisible ? ( /* 族谱入口放在共创信息卡里:它属于「这部作品的改编关系」这件事, @@ -858,30 +934,59 @@ function GameDetailContent({ ))} ) : null} - {canRemix ? ( + {publiclyVisible ? ( /* - Fork 入口:服务端取件通道要求 Bearer + 已公开 + 授权非禁止。 - 未登录按仓库既有登录门禁处理——入口照常出现(显示「登录后可 Fork」), - 点击走登录弹窗,而不是静默消失。 - 已登录时点击直接唤起客户端;没失焦(没客户端接手)才降级出下面的手动面板。 + 主操作「一键 Fork 共创」(文档口径):**始终显示**。 + - 授权为「禁止共创」时置灰(aria-disabled + 视觉弱化)并在悬停/点击给出原因, + 不再静默隐藏; + - 未登录按仓库既有登录门禁:入口照常出现,点击走登录弹窗; + - 已登录且授权开放:点击直接唤起客户端,没失焦(没客户端接手)才降级出下面面板。 */ <> - {isAuthenticated && forkLaunchNotice ? ( + {(isAuthenticated || + forkAuthorization === 'forbidden') && + forkLaunchNotice ? ( {forkLaunchNotice} @@ -945,6 +1050,19 @@ function GameDetailContent({ ) : null} ) : null} + {lineageChain.length > 1 && onOpenGameDetail ? ( + + ) : null} + {derivedNodes.length > 0 && onOpenGameDetail ? ( + + ) : null} ), }, diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index c2aa3d1aa..6812b6914 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -41,6 +41,7 @@ const listThemesMock = vi.hoisted(() => vi.fn()); const getThemeMock = vi.hoisted(() => vi.fn()); const getGameMock = vi.hoisted(() => vi.fn()); const getGameLineageMock = vi.hoisted(() => vi.fn()); +const getDerivedGamesMock = vi.hoisted(() => vi.fn()); const getMyGameMock = vi.hoisted(() => vi.fn()); const loadVersionRecoveryActionsMock = vi.hoisted(() => vi.fn()); const recordGamePlayMock = vi.hoisted(() => vi.fn()); @@ -70,6 +71,7 @@ vi.mock('../../services/gameDistributionClient', () => ({ getTheme: getThemeMock, getGame: getGameMock, getGameLineage: getGameLineageMock, + getDerivedGames: getDerivedGamesMock, getMyGame: getMyGameMock, loadVersionRecoveryActions: loadVersionRecoveryActionsMock, recordGamePlay: recordGamePlayMock, @@ -227,6 +229,8 @@ beforeEach(() => { listGamesMock.mockResolvedValue([]); getGameMock.mockResolvedValue(createGame()); getGameLineageMock.mockResolvedValue(null); + getDerivedGamesMock.mockReset(); + getDerivedGamesMock.mockResolvedValue(null); getMyGameMock.mockResolvedValue(null); loadVersionRecoveryActionsMock.mockResolvedValue({}); listGameReviewsMock.mockResolvedValue({ @@ -422,11 +426,11 @@ describe('GameDetailPage', () => { ); expect(await screen.findByText('允许非商用共创')).toBeTruthy(); - expect(screen.getByText('第 2 代作品 · 改编自《原初之光》')).toBeTruthy(); + expect(screen.getByText('2 代三创 · 改编自《原初之光》')).toBeTruthy(); expect(screen.getByText('由 原作者 制作')).toBeTruthy(); - expect(screen.getByText('已被改编 3 次')).toBeTruthy(); - // 已公开 + 授权开放 → 「Fork 这个作品」入口出现(M1 时它还不存在,这里钉住新契约)。 - expect(screen.getByRole('button', { name: 'Fork 这个作品' })).toBeTruthy(); + expect(screen.getByText('累计衍生作品 3')).toBeTruthy(); + // 已公开 + 授权开放 → 「一键 Fork 共创」入口出现(M1 时它还不存在,这里钉住新契约)。 + expect(screen.getByRole('button', { name: '一键 Fork 共创' })).toBeTruthy(); }); it('旧数据缺少授权与血缘字段时只按禁止共创渲染', async () => { @@ -461,7 +465,7 @@ describe('GameDetailPage', () => { ); expect(await screen.findByText('允许全开放共创')).toBeTruthy(); - expect(screen.getByText('第 2 代作品 · 原作品已不可用')).toBeTruthy(); + expect(screen.getByText('2 代三创 · 原作品已不可用')).toBeTruthy(); expect(screen.queryByText(/改编自/u)).toBeNull(); // 血缘作者行(卡内 `.game-fork-lineage__author`)不该出现;hero 里的 // 「由 测试作者 制作」是作品自身署名,不在断言范围内。 @@ -1751,7 +1755,7 @@ describe('GameDetailPage Fork 入口', () => { , ); - const entry = await screen.findByRole('button', { name: 'Fork 这个作品' }); + const entry = await screen.findByRole('button', { name: '一键 Fork 共创' }); // 未展开前不露出获取方式。 expect(screen.queryByText(/从平台作品开始创作/u)).toBeNull(); @@ -1803,7 +1807,7 @@ describe('GameDetailPage Fork 入口', () => { , ); - const entry = await screen.findByRole('button', { name: 'Fork 这个作品' }); + const entry = await screen.findByRole('button', { name: '一键 Fork 共创' }); vi.useFakeTimers(); fireEvent.click(entry); expect(openForkProtocolMock).toHaveBeenCalledWith('game-1'); @@ -1833,7 +1837,7 @@ describe('GameDetailPage Fork 入口', () => { ); fireEvent.click( - await screen.findByRole('button', { name: 'Fork 这个作品' }), + await screen.findByRole('button', { name: '一键 Fork 共创' }), ); expect(openForkProtocolMock).not.toHaveBeenCalled(); @@ -1842,7 +1846,7 @@ describe('GameDetailPage Fork 入口', () => { expect(screen.getByRole('button', { name: '复制作品 ID' })).toBeTruthy(); }); - it('授权为禁止共创时不出现 Fork 入口', async () => { + it('授权为禁止共创时主操作仍在但置灰,点击给出原因', async () => { getGameMock.mockResolvedValueOnce( createGame({ forkAuthorization: 'forbidden' }), ); @@ -1850,9 +1854,16 @@ describe('GameDetailPage Fork 入口', () => { , ); - await screen.findByRole('heading', { name: '星轨防线' }); - expect(screen.queryByRole('button', { name: 'Fork 这个作品' })).toBeNull(); - expect(screen.queryByRole('button', { name: '登录后可 Fork' })).toBeNull(); + 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), + ).toBeTruthy(); + expect(openForkProtocolMock).not.toHaveBeenCalled(); }); it('未公开作品不出现 Fork 入口', async () => { @@ -1871,11 +1882,11 @@ 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(); + expect(screen.queryByRole('button', { name: '登录后一键 Fork' })).toBeNull(); }); - it('未登录时入口显示「登录后可 Fork」,点击走仓库既有登录门禁而不是静默隐藏', async () => { + it('未登录时入口显示「登录后一键 Fork」,点击走仓库既有登录门禁而不是静默隐藏', async () => { authUiMock.value.user = null; authUiMock.value.canAccessProtectedData = false; getGameMock.mockResolvedValueOnce( @@ -1885,7 +1896,7 @@ describe('GameDetailPage Fork 入口', () => { , ); - const gate = await screen.findByRole('button', { name: '登录后可 Fork' }); + const gate = await screen.findByRole('button', { name: '登录后一键 Fork' }); expect(screen.queryByText(/从平台作品开始创作/u)).toBeNull(); fireEvent.click(gate); @@ -2728,6 +2739,135 @@ describe('GameLineagePage 可视化与交互', () => { }); }); +describe('GameDetailPage 共创模块(文档对齐)', () => { + it('世代/授权标签按文档口径渲染,并显示累计衍生作品数', async () => { + getGameMock.mockResolvedValueOnce( + createGame({ + forkAuthorization: 'nonCommercial', + forkCount: 3, + lineage: { + generation: 2, + rootGameId: 'game-root', + rootTitle: '根原创', + parentGameId: 'game-parent', + parentTitle: '原初之光', + parentAuthorName: '原作者', + }, + }), + ); + render( + , + ); + + expect(await screen.findByText('2 代三创')).toBeTruthy(); + expect(screen.getByText('允许非商用共创')).toBeTruthy(); + expect(screen.getByText('累计衍生作品 3')).toBeTruthy(); + expect(screen.getByText('2 代三创 · 改编自《原初之光》')).toBeTruthy(); + // 主操作按文档叫「一键 Fork 共创」。 + expect( + screen.getByRole('button', { name: '一键 Fork 共创' }), + ).toBeTruthy(); + }); + + it('累计创作世代数字段缺失时不渲染该概览项(不拿别的数字凑数)', async () => { + getGameMock.mockResolvedValueOnce(createGame({ forkCount: 2 })); + render( + , + ); + + expect(await screen.findByText('累计衍生作品 2')).toBeTruthy(); + expect(screen.queryByText(/累计创作世代数/u)).toBeNull(); + }); + + it('血缘溯源条按「根 → 1 代 → 当前」渲染,点中间一代可跳该作品', async () => { + getGameMock.mockResolvedValueOnce(createGame({ id: 'game-current' })); + getGameLineageMock.mockResolvedValueOnce({ + rootGameId: 'game-root', + root: null, + nodes: [ + createLineageNode({ + gameId: 'game-root', + title: '根原创', + authorName: '根作者', + generation: 0, + parentGameId: null, + }), + createLineageNode({ + gameId: 'game-mid', + title: '一代作品', + authorName: '一代作者', + generation: 1, + parentGameId: 'game-root', + }), + createLineageNode({ + gameId: 'game-current', + title: '当前作品', + authorName: '当前作者', + generation: 2, + parentGameId: 'game-mid', + }), + ], + truncated: false, + }); + const onOpenGameDetail = vi.fn(); + render( + , + ); + + const strip = await screen.findByLabelText('血缘溯源'); + expect(strip.textContent).toContain('根原创'); + expect(strip.textContent).toContain('一代作品'); + expect(strip.textContent).toContain('当前作品'); + // 头像位是姓名首字(链路节点没有头像字段,不伪造头像图)。 + expect(strip.textContent).toContain('根'); + // 当前作品标记为 current 且点击不跳转;点中间一代跳该作品。 + fireEvent.click( + screen.getByRole('button', { name: /0 代作品《根原创》/u }), + ); + expect(onOpenGameDetail).toHaveBeenCalledWith('game-root'); + fireEvent.click(screen.getByRole('button', { name: /2 代作品《当前作品》/u })); + expect(onOpenGameDetail).toHaveBeenCalledTimes(1); + }); + + it('热门衍生栏按 playCount 倒序取前 5 个并可在点击后进入作品', async () => { + getGameMock.mockResolvedValueOnce(createGame({ id: 'game-1' })); + 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' }), + ], + truncated: false, + }); + const onOpenGameDetail = vi.fn(); + render( + , + ); + + const hot = await screen.findByLabelText('热门衍生作品'); + const titles = ['高热度', '中热度', '低热度']; + for (const title of titles) { + expect(hot.textContent).toContain(title); + } + expect(hot.textContent?.indexOf('高热度')).toBeLessThan( + hot.textContent?.indexOf('中热度') ?? -1, + ); + fireEvent.click(screen.getByRole('button', { name: /热门衍生《高热度》/u })); + expect(onOpenGameDetail).toHaveBeenCalledWith('child-hot'); + }); +}); + describe('GameDetailPage 收藏按钮', () => { it('可判定为已收藏时显示「已收藏」,点击取消收藏', async () => { getGameMock.mockResolvedValueOnce(createGame({ collected: true })); diff --git a/src/components/game-distribution/coCreationCopy.ts b/src/components/game-distribution/coCreationCopy.ts new file mode 100644 index 000000000..abaa4d3c8 --- /dev/null +++ b/src/components/game-distribution/coCreationCopy.ts @@ -0,0 +1,23 @@ +/** + * 共创模块的用户可见文案口径(需求文档「功能交互 · 前台用户端」)。 + * + * 世代表述按文档给的三档命名:`0 代原创 / 1 代二创 / 2 代三创 / N 代迭代`。 + * 树与溯源条上用短标签(`0 代` / `N 代`),详情页标签行用全称。 + */ +export function coCreationGenerationTagLabel(generation: number) { + if (generation <= 0) return '0 代原创'; + if (generation === 1) return '1 代二创'; + if (generation === 2) return '2 代三创'; + return `${generation} 代迭代`; +} + +/** 短标签:溯源条 / 图谱节点上的角标。 */ +export function coCreationGenerationShortLabel(generation: number) { + return generation <= 0 ? '0 代' : `${generation} 代`; +} + +/** 作者头像降级:链路节点目前没有头像字段,用「姓名首字」圆形字标代替(不伪造头像图)。 */ +export function coCreationAvatarInitial(authorName: string | null | undefined) { + const trimmed = (authorName ?? '').trim(); + return trimmed ? [...trimmed][0]! : '?'; +} diff --git a/src/components/game-distribution/gameDistribution.css b/src/components/game-distribution/gameDistribution.css index e3606f23d..a375aef2d 100644 --- a/src/components/game-distribution/gameDistribution.css +++ b/src/components/game-distribution/gameDistribution.css @@ -723,6 +723,158 @@ color: var(--platform-accent-strong); font-weight: 700; } +/* 共创模块(详情页):世代/授权标签、数据概览、血缘溯源条、热门衍生栏。 */ +.game-detail-info-card .co-creation-tags { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + justify-items: start; +} +.game-detail-info-card .co-creation-tag { + border-radius: 999px; + padding: 0.12rem 0.6rem; + font-size: 0.72rem; + font-weight: 700; +} +.game-detail-info-card .co-creation-tag--generation { + background: rgba(199, 101, 61, 0.14); + color: var(--platform-accent-strong); +} +.game-detail-info-card .co-creation-stats { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + font-variant-numeric: tabular-nums; +} +.game-detail-info-card .co-creation-stat { + color: var(--platform-text-soft); + font-size: 0.76rem; +} +.game-detail-info-card .co-creation-fork-primary { + align-self: stretch; + justify-content: center; + padding: 0.45rem 0.9rem; + border-color: var(--platform-accent-strong); + background: rgba(199, 101, 61, 0.14); + font-size: 0.86rem; +} +.game-detail-info-card .co-creation-fork-primary--blocked { + border-color: var(--platform-surface-border); + background: var(--platform-surface-muted, rgba(0, 0, 0, 0.04)); + color: var(--platform-text-soft); + cursor: not-allowed; +} +/* 血缘溯源条:横向链路,可横向滚动不撑破卡片。 */ +.game-detail-info-card .co-creation-lineage { + display: flex; + align-items: stretch; + gap: 0.2rem; + max-width: 100%; + overflow-x: auto; + padding-bottom: 0.2rem; +} +.game-detail-info-card .co-creation-lineage__item { + display: inline-flex; + align-items: center; + gap: 0.2rem; +} +.game-detail-info-card .co-creation-lineage__arrow { + color: var(--platform-text-soft); + font-size: 0.8rem; +} +.game-detail-info-card .co-creation-lineage__node { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border: 1px solid var(--platform-surface-border); + border-radius: 0.6rem; + padding: 0.3rem 0.45rem; + background: var(--platform-surface, #fff); + color: var(--platform-text-base); + font: inherit; + text-align: left; + cursor: pointer; +} +.game-detail-info-card .co-creation-lineage__node--current { + border-color: var(--platform-accent-strong); + box-shadow: 0 0 0 2px rgba(199, 101, 61, 0.16); + cursor: default; +} +.game-detail-info-card .co-creation-lineage__avatar { + display: grid; + flex: none; + width: 1.6rem; + height: 1.6rem; + place-items: center; + border-radius: 50%; + background: rgba(199, 101, 61, 0.18); + color: var(--platform-accent-strong); + font-size: 0.72rem; + font-weight: 700; +} +.game-detail-info-card .co-creation-lineage__text { + display: flex; + flex-direction: column; + min-width: 0; + font-size: 0.7rem; + line-height: 1.3; +} +.game-detail-info-card .co-creation-lineage__generation { + color: var(--platform-accent-strong); + font-weight: 700; +} +.game-detail-info-card .co-creation-lineage__title { + color: var(--platform-text-strong); + font-weight: 600; + overflow-wrap: anywhere; +} +.game-detail-info-card .co-creation-lineage__author { + color: var(--platform-text-soft); +} +/* 热门衍生栏:横向卡片。 */ +.game-detail-info-card .co-creation-hot { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(7rem, 100%), 1fr)); + gap: 0.4rem; +} +.game-detail-info-card .co-creation-hot__card { + display: flex; + flex-direction: column; + gap: 0.2rem; + min-width: 0; + border: 1px solid var(--platform-surface-border); + border-radius: 0.6rem; + padding: 0.3rem; + background: var(--platform-surface, #fff); + color: var(--platform-text-base); + font: inherit; + text-align: left; + cursor: pointer; +} +.game-detail-info-card .co-creation-hot__cover { + display: block; + width: 100%; + aspect-ratio: 16 / 9; + overflow: hidden; + border-radius: 0.4rem; + background: var(--platform-surface-border); +} +.game-detail-info-card .co-creation-hot__cover .game-cover { + width: 100%; + height: 100%; + border-radius: 0; +} +.game-detail-info-card .co-creation-hot__title { + color: var(--platform-text-strong); + font-size: 0.74rem; + font-weight: 600; + overflow-wrap: anywhere; +} +.game-detail-info-card .co-creation-hot__meta { + color: var(--platform-text-soft); + font-size: 0.68rem; + overflow-wrap: anywhere; +} /* 画布内悬浮控件(右下角):圆角胶囊、浅色底 + 毛玻璃、内边距统一、轻阴影。 */ .game-lineage-controls { position: absolute; diff --git a/src/components/game-distribution/lineageTreeLayout.test.ts b/src/components/game-distribution/lineageTreeLayout.test.ts index 5dd334765..2edebb21e 100644 --- a/src/components/game-distribution/lineageTreeLayout.test.ts +++ b/src/components/game-distribution/lineageTreeLayout.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'; import type { GameDistributionLineageNode } from '../../services/gameDistributionClient'; import { buildLineageTreeLayout, + collectLineageAncestorChain, collectLineageHighlight, generationAxisLabel, LINEAGE_COLUMN_GAP, @@ -259,6 +260,34 @@ describe('buildLineageTreeLayout', () => { }); }); +describe('collectLineageAncestorChain', () => { + const nodes = [ + node('root', 0, null, '根原创'), + node('a', 1, 'root', '一代'), + node('b', 2, 'a', '二代'), + node('b2', 2, 'a', '二代旁支'), + node('orphan', 1, 'game-missing', '孤儿'), + ]; + + it('从根到目标按顺序返回(供「根原创 → 1 代 → … → 当前」展示)', () => { + expect(collectLineageAncestorChain(nodes, 'b').map((item) => item.gameId)).toEqual( + ['root', 'a', 'b'], + ); + }); + + it('父不在可见集合里时链就停在那里(父作品不可用口径一致)', () => { + expect( + collectLineageAncestorChain(nodes, 'orphan').map((item) => item.gameId), + ).toEqual(['orphan']); + }); + + it('未知节点返回空链,且遇到自环不会死循环', () => { + expect(collectLineageAncestorChain(nodes, 'game-missing-chain')).toEqual([]); + const cyclic = [node('x', 1, 'y'), node('y', 0, 'x')]; + expect(collectLineageAncestorChain(cyclic, 'x')).toHaveLength(2); + }); +}); + describe('collectLineageHighlight', () => { const tree = buildLineageTreeLayout([ node('root', 0, null), diff --git a/src/components/game-distribution/lineageTreeLayout.ts b/src/components/game-distribution/lineageTreeLayout.ts index a5abf8820..a8fdc18b5 100644 --- a/src/components/game-distribution/lineageTreeLayout.ts +++ b/src/components/game-distribution/lineageTreeLayout.ts @@ -324,6 +324,27 @@ export function buildLineageTreeLayout( }; } +/** + * 血缘链:从根到 `gameId` 的**有序**节点列表(血缘溯源条用)。 + * + * 与 `collectLineageHighlight` 的区别:那个返回集合(给高亮用),这个返回顺序(给「根 → 1 代 → … → 当前」 + * 展示与点击用)。父节点不在可见集合里时链就停在那里(与既有的「父作品不可用」口径一致)。 + */ +export function collectLineageAncestorChain< + 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[] = []; + let cursor = byId.get(gameId); + const guard = new Set(); + while (cursor && !guard.has(cursor.gameId)) { + guard.add(cursor.gameId); + chain.push(cursor); + cursor = cursor.parentGameId ? byId.get(cursor.parentGameId) : undefined; + } + return chain.reverse(); +} + /** * hover / 聚焦一个节点时要高亮的集合:它自己 + 它的祖先路径;`includeSubtree` 为真时再加上 * 它整棵子树。其余节点在渲染层降透明度。 diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx index 77f7549bf..78cd2da6e 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx @@ -1087,6 +1087,7 @@ export function PlatformEntryFlowShellImpl({ onOpenLineage={openGameLineage} onOpenCompare={openGameCompare} onOpenTheme={openGameTheme} + onOpenGameDetail={openGameDetail} /> ) : selectionStage === 'game-lineage' ? (