共创模块A: 详情页世代/授权标签 + 一键 Fork 主操作 + 概览 + 溯源条 + 热门衍生
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

This commit is contained in:
2026-10-06 17:06:50 +08:00
parent 172bc22404
commit 475acf0f41
9 changed files with 657 additions and 43 deletions
@@ -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 (
<div className="co-creation-hot" aria-label="热门衍生作品">
{hottest.map((node) => (
<button
key={node.gameId}
type="button"
className="co-creation-hot__card"
aria-label={`热门衍生《${node.title}》· ${coCreationGenerationShortLabel(node.generation)}`}
onClick={() => onOpenGameDetail(node.gameId)}
>
<span className="co-creation-hot__cover">
<GameCover game={gameForCover(node)} objectKey={node.coverObjectKey} />
</span>
<span className="co-creation-hot__title">{node.title}</span>
<span className="co-creation-hot__meta">
{coCreationGenerationShortLabel(node.generation)} ·{' '}
{node.authorName?.trim() || '未署名作者'} · {node.playCount} 次游玩
</span>
</button>
))}
</div>
);
}
/** `GameCover` 只读 title / coverColor / icon / coverObjectKey;族谱节点没有完整游戏行。 */
function gameForCover(node: GameDistributionLineageNode) {
return {
title: node.title,
coverColor: '#F3E4D0',
icon: '🎮',
coverObjectKey: node.coverObjectKey,
} as never;
}
export default CoCreationHotDerived;
@@ -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 (
<div className="co-creation-lineage" aria-label="血缘溯源">
{chain.map((node, index) => {
const isCurrent = node.gameId === currentGameId;
return (
<span key={node.gameId} className="co-creation-lineage__item">
{index > 0 ? (
<span className="co-creation-lineage__arrow" aria-hidden="true">
→
</span>
) : null}
<button
type="button"
className={`co-creation-lineage__node${
isCurrent ? ' co-creation-lineage__node--current' : ''
}`}
aria-current={isCurrent ? 'true' : undefined}
aria-label={`${coCreationGenerationShortLabel(node.generation)}作品《${node.title}》· ${node.authorName?.trim() || '未署名作者'}`}
onClick={() => {
if (!isCurrent) onOpenGameDetail(node.gameId);
}}
>
<span className="co-creation-lineage__avatar" aria-hidden="true">
{coCreationAvatarInitial(node.authorName)}
</span>
<span className="co-creation-lineage__text">
<span className="co-creation-lineage__generation">
{coCreationGenerationShortLabel(node.generation)}
</span>
<span className="co-creation-lineage__title">{node.title}</span>
<span className="co-creation-lineage__author">
{node.authorName?.trim() || '未署名作者'}
</span>
</span>
</button>
</span>
);
})}
</div>
);
}
export default CoCreationLineageStrip;
@@ -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=<themeId>`)。 */
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)}
/>
<PlatformMudPointConfirmDialog
@@ -496,6 +507,7 @@ function GameDetailContent({
onOpenCreator,
onOpenCompare,
onOpenTheme,
onOpenGameDetail,
}: {
game: GameDistributionGame;
ownerView: boolean;
@@ -516,6 +528,7 @@ function GameDetailContent({
onOpenCreator?: (id: string) => 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: (
<span className="game-fork-card">
<span
className={`game-fork-badge game-fork-badge--${forkAuthorization}`}
>
{
GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[
forkAuthorization
]
}
<span className="co-creation-tags">
{/* 世代标签(文档口径:0 代原创 / 1 代二创 / 2 代三创 / N 代迭代)。 */}
<span className="co-creation-tag co-creation-tag--generation">
{coCreationGenerationTagLabel(lineage?.generation ?? 0)}
</span>
{/* 授权类型标签:沿用平台既有 constants(含「允许」前缀),不另造一套文案。 */}
<span
className={`co-creation-tag co-creation-tag--authorization game-fork-badge game-fork-badge--${forkAuthorization}`}
>
{
GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[
forkAuthorization
]
}
</span>
</span>
{lineage ? (
<>
@@ -791,8 +861,8 @@ function GameDetailContent({
*/}
<span className="game-fork-lineage">
{lineage.parentTitle
? `第 ${lineage.generation} 代作品 · 改编自《${lineage.parentTitle}》`
: `第 ${lineage.generation} 代作品 · 原作品已不可用`}
? `${coCreationGenerationTagLabel(lineage.generation)} · 改编自《${lineage.parentTitle}》`
: `${coCreationGenerationTagLabel(lineage.generation)} · 原作品已不可用`}
</span>
{lineage.parentAuthorName ? (
<span className="game-fork-lineage__author">
@@ -801,11 +871,17 @@ function GameDetailContent({
) : null}
</>
) : null}
{forkCount > 0 ? (
<span className="game-fork-derived">
已被改编 {forkCount} 次
{/* 数据概览:累计衍生作品数 + 累计创作世代数(后者等后端字段,缺失不渲染)。 */}
<span className="co-creation-stats">
<span className="co-creation-stat">
累计衍生作品 {forkCount}
</span>
) : null}
{descendantGenerationCount === undefined ? null : (
<span className="co-creation-stat">
累计创作世代数 {descendantGenerationCount}
</span>
)}
</span>
{onOpenLineage && publiclyVisible ? (
/*
族谱入口放在共创信息卡里:它属于「这部作品的改编关系」这件事,
@@ -858,30 +934,59 @@ function GameDetailContent({
))}
</span>
) : null}
{canRemix ? (
{publiclyVisible ? (
/*
Fork 入口:服务端取件通道要求 Bearer + 已公开 + 授权非禁止。
未登录按仓库既有登录门禁处理——入口照常出现(显示「登录后可 Fork」),
点击走登录弹窗,而不是静默消失。
已登录时点击直接唤起客户端;没失焦(没客户端接手)才降级出下面的手动面板。
主操作「一键 Fork 共创」(文档口径):**始终显示**。
- 授权为「禁止共创」时置灰(aria-disabled + 视觉弱化)并在悬停/点击给出原因,
不再静默隐藏;
- 未登录按仓库既有登录门禁:入口照常出现,点击走登录弹窗;
- 已登录且授权开放:点击直接唤起客户端,没失焦(没客户端接手)才降级出下面面板。
*/
<>
<button
type="button"
className="game-fork-remix-link"
className={`game-fork-remix-link co-creation-fork-primary${
forkAuthorization === 'forbidden'
? ' co-creation-fork-primary--blocked'
: ''
}`}
aria-disabled={forkAuthorization === 'forbidden'}
aria-expanded={
isAuthenticated ? isForkPanelOpen : undefined
isAuthenticated && forkAuthorization !== 'forbidden'
? isForkPanelOpen
: undefined
}
aria-controls={
isAuthenticated ? 'game-fork-remix-panel' : undefined
isAuthenticated && forkAuthorization !== 'forbidden'
? 'game-fork-remix-panel'
: undefined
}
onClick={() =>
isAuthenticated ? handleForkLaunch() : onLogin()
title={
forkAuthorization === 'forbidden'
? '作者未开放共创授权(禁止共创),无法 Fork 这部作品'
: undefined
}
onClick={() => {
if (forkAuthorization === 'forbidden') {
setForkLaunchNotice(
'作者未开放共创授权(禁止共创),无法 Fork 这部作品。',
);
return;
}
if (!isAuthenticated) {
onLogin();
return;
}
handleForkLaunch();
}}
>
{isAuthenticated ? 'Fork 这个作品' : '登录后可 Fork'}
{isAuthenticated
? '一键 Fork 共创'
: '登录后一键 Fork'}
</button>
{isAuthenticated && forkLaunchNotice ? (
{(isAuthenticated ||
forkAuthorization === 'forbidden') &&
forkLaunchNotice ? (
<span className="game-fork-remix-status" role="status">
{forkLaunchNotice}
</span>
@@ -945,6 +1050,19 @@ function GameDetailContent({
) : null}
</>
) : null}
{lineageChain.length > 1 && onOpenGameDetail ? (
<CoCreationLineageStrip
chain={lineageChain}
currentGameId={game.id}
onOpenGameDetail={onOpenGameDetail}
/>
) : null}
{derivedNodes.length > 0 && onOpenGameDetail ? (
<CoCreationHotDerived
nodes={derivedNodes}
onOpenGameDetail={onOpenGameDetail}
/>
) : null}
</span>
),
},
@@ -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 入口', () => {
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
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 入口', () => {
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
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 入口', () => {
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
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 入口', () => {
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
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(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
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(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
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(
<GameDetailPage
gameId="game-current"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenGameDetail={onOpenGameDetail}
/>,
);
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(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenGameDetail={onOpenGameDetail}
/>,
);
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 }));
@@ -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]! : '?';
}
@@ -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;
@@ -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),
@@ -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<string>();
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` 为真时再加上
* 它整棵子树。其余节点在渲染层降透明度。
@@ -1087,6 +1087,7 @@ export function PlatformEntryFlowShellImpl({
onOpenLineage={openGameLineage}
onOpenCompare={openGameCompare}
onOpenTheme={openGameTheme}
onOpenGameDetail={openGameDetail}
/>
) : selectionStage === 'game-lineage' ? (
<GameLineagePage