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' ? (