实现游戏共创 M3:创作族谱树与衍生列表

- 新增 module-game-distribution/src/lineage.rs:锚点可读性、可见性过滤、稳定排序、上限截断的纯函数,附 15 个单测(复用 M1 的软删除判定,不重写第二套规则)
- 新增公开只读 procedure get_game_distribution_lineage / list_game_distribution_derived_games;锚点必须公开可读(未公开/已软删除/不存在一律按不存在处理),树内只出现未软删除且已公开的节点,不补 null 占位节点
- api-server 新增 GET /games/{id}/lineage 与 /games/{id}/derived(匿名可读、no-store),响应走结构化 DTO,节点键集合由测试钉死,杜绝下发对象键/素材键
- 作者自有游戏聚合追加 forkCount(与公开详情 forkCount 同口径),供「被改编 N」入口使用
- shared-contracts 与 TS 契约新增 LineageNode / LineageResponse / DerivedResponse 三型并登记 DTO 一致性检查
- 前端新增 /games/lineage 族谱页,登记 stage 类型、路由表、页面标题与壳层渲染分支;详情页共创卡新增「查看创作族谱」入口(仅已公开作品)
- 我的作品页新增「被改编 N」行内展开,列出直接子代的标题/作者/代际/状态,含空态与失败重试(仅已公开作品)
- 补前端组件/路由/标题/客户端用例与 api-server 路由、负载映射用例;同步里程碑验收标准与技术方案 §3.4 的锚点可读性口径
This commit is contained in:
2026-10-05 01:02:03 +08:00
parent 3d51d1309a
commit 39669763fe
36 changed files with 2780 additions and 27 deletions
@@ -47,6 +47,11 @@ type GameDetailPageProps = {
gameId: string | null;
onBack: () => void;
onPlay: (gameId: string) => void;
/**
* 打开创作族谱页;参数是根作品 ID 与「当前作品」ID(用于族谱页高亮)。
* 回调缺失时不渲染族谱入口(测试或没有导航能力的宿主)。
*/
onOpenLineage?: (rootGameId: string, fromGameId?: string) => void;
/**
* 作者视角:走 `GET /my-games/{gameId}` 读取自己名下作品。
*
@@ -107,6 +112,7 @@ export function GameDetailPage({
gameId,
onBack,
onPlay,
onOpenLineage,
ownerView = false,
}: GameDetailPageProps) {
const authUi = useAuthUi();
@@ -264,6 +270,7 @@ export function GameDetailPage({
onLogin={() => authUi?.openLoginModal()}
onBack={onBack}
onPlay={onPlay}
onOpenLineage={onOpenLineage}
/>
);
}
@@ -278,6 +285,7 @@ function GameDetailContent({
onLogin,
onBack,
onPlay,
onOpenLineage,
}: {
game: GameDistributionGame;
ownerView: boolean;
@@ -288,6 +296,7 @@ function GameDetailContent({
onLogin: () => void;
onBack: () => void;
onPlay: (gameId: string) => void;
onOpenLineage?: (rootGameId: string, fromGameId?: string) => void;
}) {
/**
* 只有已公开作品才有公开评价/评分投影;审核中、审核未通过、未公开、已下架
@@ -384,6 +393,22 @@ function GameDetailContent({
已被改编 {forkCount} 次
</span>
) : null}
{onOpenLineage && publiclyVisible ? (
/*
族谱入口放在共创信息卡里:它属于「这部作品的改编关系」这件事,
点进去是以母版为顶的整棵族谱树;`from` 带上当前作品供页面高亮。
只有已公开作品才渲染:族谱接口要求锚点公开可读,未公开作品点进去必然 404。
*/
<button
type="button"
className="game-fork-lineage-link"
onClick={() =>
onOpenLineage(lineage?.rootGameId ?? game.id, game.id)
}
>
查看创作族谱
</button>
) : null}
</span>
),
},
@@ -14,16 +14,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { GameDistributionVersionStatus } from '../../../packages/shared/src/contracts/gameDistribution';
import type {
GameDistributionGame,
GameDistributionLineageNode,
GameDistributionMyGame,
GameDistributionVersionStatusEntry,
} from '../../services/gameDistributionClient';
import { GameDetailPage } from './GameDetailPage';
import { GameGalleryPage } from './GameGalleryPage';
import { GameLineagePage } from './GameLineagePage';
import { GAME_PLAY_STARTUP_TIMEOUT_MS, GamePlayPage } from './GamePlayPage';
import { resolveNonPublicCoverLabel } from './gameVersionHistoryModel';
const listGamesMock = vi.hoisted(() => vi.fn());
const getGameMock = vi.hoisted(() => vi.fn());
const getGameLineageMock = vi.hoisted(() => vi.fn());
const getMyGameMock = vi.hoisted(() => vi.fn());
const loadVersionRecoveryActionsMock = vi.hoisted(() => vi.fn());
const recordGamePlayMock = vi.hoisted(() => vi.fn());
@@ -34,6 +37,7 @@ const getMyGameReviewMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/gameDistributionClient', () => ({
listGames: listGamesMock,
getGame: getGameMock,
getGameLineage: getGameLineageMock,
getMyGame: getMyGameMock,
loadVersionRecoveryActions: loadVersionRecoveryActionsMock,
recordGamePlay: recordGamePlayMock,
@@ -131,6 +135,7 @@ beforeEach(() => {
setViewportWidth(1024);
listGamesMock.mockResolvedValue([]);
getGameMock.mockResolvedValue(createGame());
getGameLineageMock.mockResolvedValue(null);
getMyGameMock.mockResolvedValue(null);
loadVersionRecoveryActionsMock.mockResolvedValue({});
listGameReviewsMock.mockResolvedValue({
@@ -913,3 +918,261 @@ describe('游戏封面与截图展示', () => {
);
});
});
function createLineageNode(
overrides: Partial<GameDistributionLineageNode> = {},
): GameDistributionLineageNode {
return {
gameId: 'game-1',
title: '作品',
authorName: '作者',
generation: 1,
parentGameId: null,
playCount: 0,
status: 'published',
...overrides,
};
}
describe('GameLineagePage', () => {
it('渲染整棵树、高亮来源作品,并把父作品不可用的节点降级展示', async () => {
getGameLineageMock.mockResolvedValueOnce({
rootGameId: 'game-root',
root: createLineageNode({
gameId: 'game-root',
title: '母版',
generation: 0,
parentGameId: null,
}),
nodes: [
createLineageNode({
gameId: 'game-root',
title: '母版',
generation: 0,
parentGameId: null,
playCount: 30,
}),
createLineageNode({
gameId: 'game-mid',
title: '中间代',
generation: 1,
parentGameId: 'game-root',
}),
createLineageNode({
gameId: 'game-leaf',
title: '叶节点',
generation: 2,
parentGameId: 'game-removed',
}),
],
truncated: false,
});
const onOpenDetail = vi.fn();
render(
<GameLineagePage
rootGameId="game-root"
highlightGameId="game-leaf"
onBack={vi.fn()}
onOpenDetail={onOpenDetail}
/>,
);
expect(await screen.findByText('叶节点')).toBeTruthy();
expect(screen.getByText('中间代')).toBeTruthy();
// 当前作品高亮在它自己的节点卡片上。
expect(
screen.getByText('叶节点').closest('.game-lineage-node')?.className,
).toContain('game-lineage-node--anchor');
// 父作品不在可见集合里:只标注「原作品已不可用」,不回显父作品内容。
expect(screen.getByText('原作品已不可用')).toBeTruthy();
// 任一公开节点都能进入对应详情页。
fireEvent.click(screen.getByRole('button', { name: '母版' }));
expect(onOpenDetail).toHaveBeenCalledWith('game-root');
});
it('没有衍生作品时给出明确空态', async () => {
getGameLineageMock.mockResolvedValueOnce({
rootGameId: 'game-root',
root: createLineageNode({
gameId: 'game-root',
title: '母版',
generation: 0,
parentGameId: null,
}),
nodes: [
createLineageNode({
gameId: 'game-root',
title: '母版',
generation: 0,
parentGameId: null,
}),
],
truncated: false,
});
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
expect(
await screen.findByText('这部作品还没有衍生作品,欢迎从它开始共创。'),
).toBeTruthy();
});
it('超出节点上限时如实标注截断,不静默丢弃', async () => {
getGameLineageMock.mockResolvedValueOnce({
rootGameId: 'game-root',
root: createLineageNode({
gameId: 'game-root',
title: '母版',
generation: 0,
}),
nodes: [
createLineageNode({ gameId: 'game-root', title: '母版', generation: 0 }),
createLineageNode({ gameId: 'game-2', title: '第二部', generation: 1 }),
],
truncated: true,
});
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
expect(
await screen.findByText('这部作品衍生较多,这里只展示前 2 个节点。'),
).toBeTruthy();
});
it('根作品不可用时按代际回退展示,不空白也不报错', async () => {
getGameLineageMock.mockResolvedValueOnce({
rootGameId: 'game-root',
root: null,
nodes: [
createLineageNode({
gameId: 'game-leaf',
title: '叶节点',
generation: 1,
parentGameId: 'game-root',
}),
],
truncated: false,
});
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
expect(
await screen.findByText(
'母版已不可用,下面按代际关系展示仍可阅读的衍生作品。',
),
).toBeTruthy();
expect(screen.getByText('叶节点')).toBeTruthy();
expect(screen.getByText('原作品已不可用')).toBeTruthy();
});
it('未公开作品的详情页不渲染族谱入口', async () => {
getMyGameMock.mockResolvedValueOnce({
...createGame({ status: 'unpublished' }),
latestVersion: null,
versions: [],
} as GameDistributionMyGame);
render(
<GameDetailPage
gameId="game-1"
ownerView
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenLineage={vi.fn()}
/>,
);
await screen.findByText('作品状态');
expect(screen.queryByRole('button', { name: '查看创作族谱' })).toBeNull();
});
it('加载失败走平台错误态并可重试', async () => {
getGameLineageMock
.mockRejectedValueOnce(new Error('网络错误'))
.mockResolvedValueOnce({
rootGameId: 'game-root',
root: createLineageNode({ gameId: 'game-root', title: '母版' }),
nodes: [
createLineageNode({ gameId: 'game-root', title: '母版' }),
createLineageNode({ gameId: 'game-2', title: '第二部' }),
],
truncated: false,
});
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
expect(await screen.findByText('网络错误')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '重试' }));
expect(await screen.findByText('第二部')).toBeTruthy();
});
it('缺少根作品编号时不发请求并提示编号无效', async () => {
render(
<GameLineagePage
rootGameId=""
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
expect(await screen.findByText('游戏编号无效')).toBeTruthy();
expect(getGameLineageMock).not.toHaveBeenCalled();
});
it('详情页共创卡里的族谱入口带上根作品与当前作品', async () => {
getGameMock.mockResolvedValueOnce(
createGame({
lineage: {
generation: 1,
rootGameId: 'game-root',
rootTitle: '母版',
parentGameId: 'game-root',
parentTitle: '母版',
parentAuthorName: '原作者',
},
}),
);
const onOpenLineage = vi.fn();
render(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenLineage={onOpenLineage}
/>,
);
fireEvent.click(
await screen.findByRole('button', { name: '查看创作族谱' }),
);
expect(onOpenLineage).toHaveBeenCalledWith('game-root', 'game-1');
});
it('没有族谱导航回调时不渲染族谱入口', async () => {
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
await screen.findByRole('heading', { name: '星轨防线' });
expect(screen.queryByRole('button', { name: '查看创作族谱' })).toBeNull();
});
});
@@ -0,0 +1,265 @@
import './gameDistribution.css';
import { ArrowLeft, RefreshCcw } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
type GameDistributionLineageNode,
type GameDistributionLineageResponse,
getGameLineage,
} from '../../services/gameDistributionClient';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
type GameLineagePageProps = {
/** 路由 `?id=` 里的根作品 ID;为空时给出明确的无效编号提示,不发请求。 */
rootGameId: string | null;
/**
* 用户是从作品详情页的哪个节点进来的,用来高亮「当前作品」。
* 缺失时退回高亮根节点(直接打开族谱链接的场景)。
*/
highlightGameId?: string | null;
onBack: () => void;
onOpenDetail: (gameId: string) => void;
};
/**
* 按代际给节点分组;节点在服务端已按「代际升序、同代创建时间升序」排好,
* 这里只做分组,不重新排序。
*/
function groupNodesByGeneration(
nodes: readonly GameDistributionLineageNode[],
): [number, GameDistributionLineageNode[]][] {
const groups = new Map<number, GameDistributionLineageNode[]>();
for (const node of nodes) {
const bucket = groups.get(node.generation);
if (bucket) {
bucket.push(node);
} else {
groups.set(node.generation, [node]);
}
}
return [...groups.entries()].sort(([left], [right]) => left - right);
}
function generationLabel(generation: number) {
return generation === 0 ? '母版' : `第 ${generation} 代`;
}
function LineageNodeCard({
node,
isAnchor,
hasVisibleParent,
onOpenDetail,
}: {
node: GameDistributionLineageNode;
isAnchor: boolean;
hasVisibleParent: boolean;
onOpenDetail: (gameId: string) => void;
}) {
const title = node.title.trim() || '未命名作品';
// 父作品不在可见集合里(已软删除或未公开)时不得猜测它的内容,只标注「原作品已不可用」;
// 节点自身照常留在树上,不出现空白或断链。
const parentUnavailable = Boolean(node.parentGameId) && !hasVisibleParent;
const authorName = node.authorName?.trim() ?? '';
return (
<article
className={`game-lineage-node${isAnchor ? ' game-lineage-node--anchor' : ''}`}
aria-current={isAnchor ? 'true' : undefined}
>
<div className="game-lineage-node__head">
<span className="game-lineage-node__generation">
{generationLabel(node.generation)}
</span>
{isAnchor ? (
<span className="game-lineage-node__anchor">当前作品</span>
) : null}
</div>
<button
type="button"
className="game-lineage-node__title"
onClick={() => onOpenDetail(node.gameId)}
>
{title}
</button>
<p className="game-lineage-node__meta">
<span>{authorName || '未署名作者'}</span>
<span aria-hidden="true">·</span>
<span>{node.playCount.toLocaleString()} 次游玩</span>
</p>
{parentUnavailable ? (
<p className="game-lineage-node__parent-unavailable">原作品已不可用</p>
) : null}
</article>
);
}
/**
* 创作族谱页:以根作品为顶展示整棵改编树,任一公开节点都能点进对应作品详情。
*
* 公开只读页面,未登录也能浏览;加载失败走平台既有错误态,无衍生作品给明确空态。
* 父作品已删或未公开时该节点只降级为「原作品已不可用」,不回显父作品内容、不产生断链。
*/
export function GameLineagePage({
rootGameId,
highlightGameId = null,
onBack,
onOpenDetail,
}: GameLineagePageProps) {
const normalizedRootId = rootGameId?.trim() ?? '';
const [lineage, setLineage] = useState<GameDistributionLineageResponse | null>(
null,
);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const loadLineage = useCallback(async () => {
if (!normalizedRootId) {
setLineage(null);
setError('');
return;
}
setIsLoading(true);
setError('');
try {
const result = await getGameLineage(normalizedRootId);
if (!result) {
setError('游戏编号无效');
return;
}
setLineage(result);
} catch (loadError) {
setError(
loadError instanceof Error ? loadError.message : '读取创作族谱失败',
);
} finally {
setIsLoading(false);
}
}, [normalizedRootId]);
useEffect(() => {
void loadLineage();
}, [loadLineage]);
const nodes = lineage?.nodes ?? [];
const groups = useMemo(() => groupNodesByGeneration(nodes), [nodes]);
const visibleIds = useMemo(
() => new Set(nodes.map((node) => node.gameId)),
[nodes],
);
const anchorId = highlightGameId?.trim() || normalizedRootId;
const derivedCount = nodes.filter((node) => node.generation > 0).length;
const rootTitle = lineage?.root?.title?.trim() ?? '';
return (
<div className="game-page game-lineage-page">
<button type="button" className="game-back-button" onClick={onBack}>
<ArrowLeft /> 返回
</button>
<div className="game-toolbar">
<div className="game-toolbar__title">
<span className="game-eyebrow">创作族谱</span>
<h2>{rootTitle || '创作族谱'}</h2>
</div>
{normalizedRootId ? (
<PlatformActionButton
tone="secondary"
size="sm"
shape="pill"
onClick={() => void loadLineage()}
disabled={isLoading}
>
<RefreshCcw aria-hidden="true" /> 刷新
</PlatformActionButton>
) : null}
</div>
{!normalizedRootId ? (
<PlatformStatusMessage tone="error" surface="platform">
游戏编号无效
</PlatformStatusMessage>
) : null}
{isLoading ? (
<div className="game-loading-panel">正在加载创作族谱…</div>
) : null}
{!isLoading && error ? (
<PlatformStatusMessage tone="error" surface="platform">
<span>{error}</span>
<button
type="button"
className="game-lineage-retry"
onClick={() => void loadLineage()}
>
重试
</button>
</PlatformStatusMessage>
) : null}
{!isLoading && !error && lineage ? (
<>
{lineage.truncated ? (
<p className="game-lineage-notice" role="status">
这部作品衍生较多,这里只展示前 {nodes.length} 个节点。
</p>
) : null}
{lineage.root ? null : (
<p className="game-lineage-notice game-lineage-notice--muted" role="status">
母版已不可用,下面按代际关系展示仍可阅读的衍生作品。
</p>
)}
{groups.length === 0 ? (
<PlatformEmptyState surface="subpanel" size="panel">
<span>这部作品还没有可展示的族谱节点。</span>
</PlatformEmptyState>
) : (
<div className="game-lineage-tree">
{groups.map(([generation, groupNodes]) => (
<section
key={generation}
className="game-lineage-generation"
aria-label={generationLabel(generation)}
>
<h3 className="game-lineage-generation__label">
{generationLabel(generation)}
<span className="game-lineage-generation__count">
{groupNodes.length}
</span>
</h3>
<div className="game-lineage-generation__nodes">
{groupNodes.map((node) => (
<LineageNodeCard
key={node.gameId}
node={node}
isAnchor={node.gameId === anchorId}
hasVisibleParent={
!node.parentGameId ||
visibleIds.has(node.parentGameId)
}
onOpenDetail={onOpenDetail}
/>
))}
</div>
</section>
))}
</div>
)}
{derivedCount === 0 ? (
<PlatformEmptyState surface="subpanel" size="panel">
<span>这部作品还没有衍生作品,欢迎从它开始共创。</span>
</PlatformEmptyState>
) : null}
</>
) : null}
<p className="game-detail-footnote">
族谱只展示已公开的作品;已删除或未公开的来源作品会标注为「原作品已不可用」。
</p>
</div>
);
}
export default GameLineagePage;
@@ -8,6 +8,7 @@ import { beforeEach, expect, test, vi } from 'vitest';
import {
cancelGameVersion,
deleteMyGame,
getDerivedGames,
type GameDistributionMyGame,
getGameVersion,
listMyGames,
@@ -23,6 +24,7 @@ import { MyGamesPage } from './MyGamesPage';
vi.mock('../../services/gameDistributionClient', () => ({
cancelGameVersion: vi.fn(),
deleteMyGame: vi.fn(),
getDerivedGames: vi.fn(),
getGameVersion: vi.fn(),
listMyGames: vi.fn(),
loadVersionRecoveryActions: vi.fn(),
@@ -111,6 +113,7 @@ function renderPage(
beforeEach(() => {
vi.mocked(cancelGameVersion).mockReset();
vi.mocked(deleteMyGame).mockReset();
vi.mocked(getDerivedGames).mockReset();
vi.mocked(getGameVersion).mockReset();
vi.mocked(listMyGames).mockReset();
vi.mocked(loadVersionRecoveryActions).mockReset();
@@ -663,3 +666,104 @@ test('共创授权已达全开放时入口变为只读展示', async () => {
expect(await screen.findByText('共创授权已达上限')).toBeTruthy();
expect(screen.queryByRole('button', { name: '共创授权' })).toBeNull();
});
test('「被改编 N」入口按需拉取并列出直接子代,点击可进入详情', async () => {
vi.mocked(listMyGames).mockResolvedValue([
createGame({ status: 'published', forkCount: 1 }),
]);
vi.mocked(getDerivedGames).mockResolvedValue({
gameId: 'game-1',
nodes: [
{
gameId: 'game-child',
title: '衍生作品',
authorName: '改编者',
generation: 1,
parentGameId: 'game-1',
playCount: 5,
status: 'published',
},
],
truncated: false,
});
const onOpenDetail = vi.fn();
render(
<AuthUiContext.Provider value={createAuthValue()}>
<MyGamesPage onBack={vi.fn()} onOpenDetail={onOpenDetail} />
</AuthUiContext.Provider>,
);
const toggle = await screen.findByRole('button', { name: /被改编 1/ });
// 展开前不请求衍生列表。
expect(getDerivedGames).not.toHaveBeenCalled();
fireEvent.click(toggle);
await waitFor(() => expect(getDerivedGames).toHaveBeenCalledWith('game-1'));
expect(await screen.findByText('衍生作品')).toBeTruthy();
expect(screen.getByText(/改编者 · 第 1 代 · 已公开/)).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '衍生作品' }));
expect(onOpenDetail).toHaveBeenCalledWith('game-child');
});
test('没有衍生作品时展开给出空态', async () => {
vi.mocked(listMyGames).mockResolvedValue([
createGame({ status: 'published', forkCount: 0 }),
]);
vi.mocked(getDerivedGames).mockResolvedValue({
gameId: 'game-1',
nodes: [],
truncated: false,
});
renderPage(createAuthValue());
fireEvent.click(await screen.findByRole('button', { name: /被改编 0/ }));
expect(await screen.findByText('还没有衍生作品。')).toBeTruthy();
});
test('衍生列表读取失败时行内给出错误与重试', async () => {
vi.mocked(listMyGames).mockResolvedValue([
createGame({ status: 'published', forkCount: 2 }),
]);
vi.mocked(getDerivedGames)
.mockRejectedValueOnce(new Error('网络错误'))
.mockResolvedValueOnce({
gameId: 'game-1',
nodes: [
{
gameId: 'game-child',
title: '衍生作品',
authorName: '改编者',
generation: 1,
parentGameId: 'game-1',
playCount: 5,
status: 'published',
},
],
truncated: true,
});
renderPage(createAuthValue());
fireEvent.click(await screen.findByRole('button', { name: /被改编 2/ }));
expect(await screen.findByText('网络错误')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '重试' }));
expect(await screen.findByText('衍生作品')).toBeTruthy();
expect(
screen.getByText('衍生作品较多,仅展示前 1 部。'),
).toBeTruthy();
});
test('未公开作品不渲染「被改编 N」入口', async () => {
vi.mocked(listMyGames).mockResolvedValue([
createGame({ status: 'unpublished', forkCount: 0 }),
]);
renderPage(createAuthValue());
expect(await screen.findByText('我的游戏')).toBeTruthy();
expect(screen.queryByRole('button', { name: /被改编/ })).toBeNull();
expect(getDerivedGames).not.toHaveBeenCalled();
});
@@ -15,6 +15,8 @@ import {
import {
cancelGameVersion,
deleteMyGame,
getDerivedGames,
type GameDistributionLineageNode,
type GameDistributionMyGame,
type GameDistributionUpdateMyGameResponse,
type GameDistributionVersionStatusEntry,
@@ -196,6 +198,15 @@ export function MyGamesPage({
useState<ForkAuthorizationDraft | null>(null);
const [savingForkAuthorizationGameId, setSavingForkAuthorizationGameId] =
useState('');
/** 「被改编 N」行内展开:展开的是哪个作品,以及按作品缓存的直接子代。 */
const [expandedDerivedGameId, setExpandedDerivedGameId] = useState('');
const [derivedByGameId, setDerivedByGameId] = useState<
Record<string, { nodes: GameDistributionLineageNode[]; truncated: boolean }>
>({});
const [loadingDerivedGameId, setLoadingDerivedGameId] = useState('');
const [derivedErrorByGameId, setDerivedErrorByGameId] = useState<
Record<string, string>
>({});
const [notice, setNotice] = useState('');
const [error, setError] = useState('');
const canLoad = Boolean(authUi?.user && authUi.canAccessProtectedData);
@@ -222,6 +233,10 @@ export function MyGamesPage({
try {
const entries = await listMyGames();
setGames(entries);
// 列表刷新后「被改编 N」计数也刷新了,行内展开的旧子代列表一并作废,避免展示陈旧数据。
setExpandedDerivedGameId('');
setDerivedByGameId({});
setDerivedErrorByGameId({});
// 列表投影不带 recoveryAction,只为还需要作者动作的最新版本补一次版本回读;
// 单个回读失败只丢失该版本的下一步提示,不影响列表。
const pendingVersionIds = entries
@@ -291,6 +306,41 @@ export function MyGamesPage({
);
}
async function loadDerivedNodes(game: GameDistributionMyGame) {
setLoadingDerivedGameId(game.id);
setDerivedErrorByGameId((current) => ({ ...current, [game.id]: '' }));
try {
const result = await getDerivedGames(game.id);
if (!result) {
setDerivedErrorByGameId((current) => ({
...current,
[game.id]: '读取衍生作品失败',
}));
return;
}
setDerivedByGameId((current) => ({
...current,
[game.id]: { nodes: result.nodes, truncated: result.truncated },
}));
} catch (loadError: unknown) {
setDerivedErrorByGameId((current) => ({
...current,
[game.id]:
loadError instanceof Error ? loadError.message : '读取衍生作品失败',
}));
} finally {
setLoadingDerivedGameId('');
}
}
function handleToggleDerived(game: GameDistributionMyGame) {
const isOpen = expandedDerivedGameId === game.id;
setExpandedDerivedGameId(isOpen ? '' : game.id);
if (!isOpen && !derivedByGameId[game.id]) {
void loadDerivedNodes(game);
}
}
async function handleCancelVersion(
game: GameDistributionMyGame,
version: GameDistributionVersionStatusEntry,
@@ -554,6 +604,11 @@ export function MyGamesPage({
const forkAuthorization = resolveGameForkAuthorization(
game.forkAuthorization,
);
const forkCount = game.forkCount ?? 0;
const isDerivedOpen = expandedDerivedGameId === game.id;
const derivedEntry = derivedByGameId[game.id] ?? null;
const derivedError = derivedErrorByGameId[game.id] ?? '';
const isDerivedLoading = loadingDerivedGameId === game.id;
const forkAuthorizationLabel =
GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[
forkAuthorization
@@ -641,6 +696,87 @@ export function MyGamesPage({
{isHistoryOpen ? '收起' : '展开'}
</button>
) : null}
{/*
「被改编 N」与版本历史同属作品关系的展开信息,放在同一节奏里,
不占用下方动作行的位置;点开后才按需拉取直接子代。
只在已公开作品上渲染:衍生接口要求锚点公开可读,未公开/草稿作品点进去必然 404。
*/}
{game.status === 'published' ? (
<>
<button
type="button"
className="my-game-card__derived-toggle"
aria-expanded={isDerivedOpen}
aria-controls={`game-derived-${game.id}`}
onClick={() => handleToggleDerived(game)}
>
被改编 {forkCount}
{isDerivedOpen ? '收起' : '展开'}
</button>
{isDerivedOpen ? (
<div
className="my-game-card__derived"
id={`game-derived-${game.id}`}
>
{isDerivedLoading ? (
<p className="my-game-card__derived-empty">
正在加载衍生作品…
</p>
) : null}
{!isDerivedLoading && derivedError ? (
<p className="my-game-card__derived-error">
{derivedError}
<button
type="button"
className="game-lineage-retry"
onClick={() => void loadDerivedNodes(game)}
>
重试
</button>
</p>
) : null}
{!isDerivedLoading && !derivedError && derivedEntry
? derivedEntry.nodes.map((node) => (
<div
key={node.gameId}
className="my-game-card__derived-item"
>
<button
type="button"
className="my-game-card__derived-title"
onClick={() => onOpenDetail(node.gameId)}
>
{node.title.trim() || '未命名作品'}
</button>
<span className="my-game-card__derived-meta">
{node.authorName?.trim() || '未署名作者'} ·
第 {node.generation} 代 ·{' '}
{GAME_STATUS_LABEL[node.status] ??
node.status}
</span>
</div>
))
: null}
{!isDerivedLoading &&
!derivedError &&
derivedEntry &&
derivedEntry.nodes.length === 0 ? (
<p className="my-game-card__derived-empty">
还没有衍生作品。
</p>
) : null}
{!isDerivedLoading &&
!derivedError &&
derivedEntry?.truncated ? (
<p className="my-game-card__derived-truncated">
衍生作品较多,仅展示前{' '}
{derivedEntry.nodes.length} 部。
</p>
) : null}
</div>
) : null}
</>
) : null}
{isHistoryOpen ? (
<GameVersionHistory
id={`game-version-history-${game.id}`}
@@ -419,6 +419,22 @@
.game-detail-info-card .game-fork-derived {
color: var(--platform-text-soft);
}
/* 族谱入口:与卡内文字同节奏的次级按钮,窄屏也靠网格换行收在侧栏里。 */
.game-detail-info-card .game-fork-lineage-link {
justify-self: start;
border: 1px solid var(--platform-surface-border);
border-radius: 999px;
padding: 0.22rem 0.7rem;
background: transparent;
color: var(--platform-accent-strong);
font: inherit;
font-size: 0.76rem;
font-weight: 700;
cursor: pointer;
}
.game-detail-info-card .game-fork-lineage-link:hover {
background: rgba(199, 101, 61, 0.12);
}
.game-loading-panel {
display: grid;
min-height: 18rem;
@@ -1175,3 +1191,200 @@
width: clamp(7rem, 14vw, 12rem);
}
}
/*
* 创作族谱页。树按代际分段,每段用自适应网格排节点卡片:窄屏单列、宽屏多列,
* 长标题与较大的游玩数都靠 `overflow-wrap: anywhere` + 网格换行收在容器内,
* 不产生横向溢出,也不需要桌面端才有的交互。
*/
.game-lineage-page {
max-width: 70rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.game-lineage-notice {
margin: 0;
padding: 0.6rem 0.8rem;
border: 1px solid var(--platform-surface-border);
border-radius: 0.6rem;
background: var(--platform-surface-muted, rgba(0, 0, 0, 0.02));
color: var(--platform-text-base);
font-size: 0.82rem;
line-height: 1.6;
overflow-wrap: anywhere;
}
.game-lineage-notice--muted {
color: var(--platform-text-soft);
}
.game-lineage-retry {
margin-left: 0.6rem;
border: 1px solid currentColor;
border-radius: 999px;
padding: 0.2rem 0.7rem;
background: transparent;
color: inherit;
font: inherit;
font-size: 0.78rem;
cursor: pointer;
}
.game-lineage-tree {
display: flex;
flex-direction: column;
gap: 1rem;
}
.game-lineage-generation {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.game-lineage-generation__label {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin: 0;
color: var(--platform-text-strong);
font-size: 0.9rem;
font-weight: 700;
}
.game-lineage-generation__count {
border-radius: 999px;
padding: 0.05rem 0.5rem;
background: var(--platform-surface-border);
color: var(--platform-text-soft);
font-size: 0.72rem;
font-weight: 600;
}
.game-lineage-generation__nodes {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
gap: 0.6rem;
}
.game-lineage-node {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
border: 1px solid var(--platform-surface-border);
border-radius: 0.75rem;
padding: 0.7rem 0.8rem;
background: var(--platform-surface, #fff);
}
.game-lineage-node--anchor {
border-color: var(--platform-accent-strong);
box-shadow: 0 0 0 2px rgba(199, 101, 61, 0.18);
}
.game-lineage-node__head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
}
.game-lineage-node__generation,
.game-lineage-node__anchor {
border-radius: 999px;
padding: 0.1rem 0.5rem;
font-size: 0.7rem;
font-weight: 700;
}
.game-lineage-node__generation {
background: var(--platform-surface-border);
color: var(--platform-text-soft);
}
.game-lineage-node__anchor {
background: rgba(199, 101, 61, 0.16);
color: var(--platform-accent-strong);
}
.game-lineage-node__title {
border: none;
padding: 0;
background: transparent;
color: var(--platform-text-strong);
font: inherit;
font-size: 0.95rem;
font-weight: 700;
text-align: left;
overflow-wrap: anywhere;
cursor: pointer;
}
.game-lineage-node__title:hover {
text-decoration: underline;
}
.game-lineage-node__meta {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin: 0;
color: var(--platform-text-soft);
font-size: 0.78rem;
overflow-wrap: anywhere;
}
.game-lineage-node__parent-unavailable {
margin: 0;
color: var(--platform-text-soft);
font-size: 0.74rem;
}
/*
* 我的作品页「被改编 N」行内展开:与版本历史同一节奏,不挤占动作行的位置。
*/
.my-game-card__derived-toggle {
align-self: flex-start;
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;
}
.my-game-card__derived-toggle:hover:not(:disabled) {
color: var(--platform-text-strong);
}
.my-game-card__derived {
display: flex;
flex-direction: column;
gap: 0.4rem;
border: 1px solid var(--platform-surface-border);
border-radius: 0.6rem;
padding: 0.6rem 0.7rem;
background: var(--platform-surface-muted, rgba(0, 0, 0, 0.02));
}
.my-game-card__derived-empty,
.my-game-card__derived-error,
.my-game-card__derived-truncated {
margin: 0;
color: var(--platform-text-soft);
font-size: 0.78rem;
overflow-wrap: anywhere;
}
.my-game-card__derived-item {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.my-game-card__derived-title {
border: none;
padding: 0;
background: transparent;
color: var(--platform-text-strong);
font: inherit;
font-size: 0.85rem;
font-weight: 700;
text-align: left;
overflow-wrap: anywhere;
cursor: pointer;
}
.my-game-card__derived-title:hover {
text-decoration: underline;
}
.my-game-card__derived-meta {
color: var(--platform-text-soft);
font-size: 0.76rem;
overflow-wrap: anywhere;
}
@@ -49,7 +49,9 @@ const gameDistributionMock = vi.hoisted(() => ({
createGame: vi.fn(),
createGameVersion: vi.fn(),
deleteMyGame: vi.fn(),
getDerivedGames: vi.fn(),
getGame: vi.fn(),
getGameLineage: vi.fn(),
getGameVersion: vi.fn(),
getMyGame: vi.fn(),
listGames: vi.fn(),
@@ -1172,3 +1174,116 @@ describe('作品管理 owner 作用域', () => {
expect(gameDistributionMock.getMyGame).not.toHaveBeenCalled();
});
});
describe('创作族谱', () => {
const lineageTree = {
rootGameId: 'game-root',
root: {
gameId: 'game-root',
title: '母版',
authorName: '原作者',
generation: 0,
parentGameId: null,
playCount: 3,
status: 'published',
},
nodes: [
{
gameId: 'game-root',
title: '母版',
authorName: '原作者',
generation: 0,
parentGameId: null,
playCount: 3,
status: 'published',
},
{
gameId: 'game-leaf',
title: '叶节点',
authorName: '改编者',
generation: 1,
parentGameId: 'game-root',
playCount: 1,
status: 'published',
},
],
truncated: false,
};
it('族谱页按根作品渲染树,未登录也能浏览并跳转节点', async () => {
window.history.replaceState(
null,
'',
'/games/lineage?id=game-root&from=game-leaf',
);
gameDistributionMock.getGameLineage.mockResolvedValue(lineageTree);
const setSelectionStage = vi.fn();
render(
<PlatformEntryFlowShellImpl
selectionStage="game-lineage"
setSelectionStage={setSelectionStage}
/>,
);
expect(await screen.findByText('叶节点')).toBeTruthy();
expect(gameDistributionMock.getGameLineage).toHaveBeenCalledWith(
'game-root',
);
fireEvent.click(screen.getByRole('button', { name: '母版' }));
expect(setSelectionStage).toHaveBeenCalledWith('game-detail', {
path: '/games/detail?id=game-root',
});
});
it('详情页的族谱入口带上根作品与当前作品', async () => {
window.history.replaceState(null, '', '/games/detail?id=game-1');
gameDistributionMock.getGame.mockResolvedValue({
id: 'game-1',
title: '衍生作品',
summary: '已发布',
description: '',
category: '动作',
tags: [],
coverColor: '#d77a51',
icon: '✦',
author: { id: 'author-1', name: '测试作者' },
deviceSupport: { desktop: true, mobile: true, touch: true },
status: 'published',
currentVersion: {
id: 'version-1',
version: '1.0.0',
entryUrl: 'https://play.example.test/releases/version-1/index.html',
sha256: 'sha256',
publishedAt: '2026-09-18T00:00:00.000Z',
controls: ['键盘'],
},
playCount: 1,
createdAt: '2026-09-18T00:00:00.000Z',
forkCount: 0,
lineage: {
generation: 1,
rootGameId: 'game-root',
rootTitle: '母版',
parentGameId: 'game-root',
parentTitle: '母版',
parentAuthorName: '原作者',
},
});
const setSelectionStage = vi.fn();
render(
<PlatformEntryFlowShellImpl
selectionStage="game-detail"
setSelectionStage={setSelectionStage}
/>,
);
fireEvent.click(
await screen.findByRole('button', { name: '查看创作族谱' }),
);
expect(setSelectionStage).toHaveBeenCalledWith('game-lineage', {
path: '/games/lineage?id=game-root&from=game-1',
});
});
});
@@ -100,6 +100,11 @@ const GamePlayPage = lazy(async () => {
return { default: module.GamePlayPage };
});
const GameLineagePage = lazy(async () => {
const module = await import('../game-distribution/GameLineagePage');
return { default: module.GameLineagePage };
});
type ActiveRailButtonProps = {
active: boolean;
emphasized?: boolean;
@@ -473,6 +478,32 @@ export function PlatformEntryFlowShellImpl({
[setSelectionStage],
);
/**
* 打开创作族谱页。路由按规范只要求 `?id=<rootGameId>`;这里额外带上 `from=<当前作品>`,
* 让页面对「用户点进来时所在的作品」做高亮,直接打开链接时缺省高亮根节点。
*/
const openGameLineage = useCallback(
(rootGameId: string, fromGameId?: string) => {
const params = new URLSearchParams({ id: rootGameId });
if (fromGameId && fromGameId !== rootGameId) {
params.set('from', fromGameId);
}
const path = `/games/lineage?${params.toString()}`;
setSelectionStage('game-lineage', { path });
},
[setSelectionStage],
);
/** 族谱页从详情页进来,返回优先走浏览器原生返回,兜底回广场。 */
const backFromGameLineage = useCallback(() => {
if (hasAppHistoryBackEntry()) {
window.history.back();
return;
}
replaceAppHistoryPath('/games');
setSelectionStage('games', { path: '/games' });
}, [setSelectionStage]);
/**
* 详情页可能从广场或「我的游戏」进入,返回优先走浏览器原生返回,这样能回到
* 用户真正的来源页;直接打开详情链接等没有可回退的应用内历史时再兜底回广场。
@@ -604,6 +635,7 @@ export function PlatformEntryFlowShellImpl({
selectionStage === 'game-detail' ||
selectionStage === 'game-play' ||
selectionStage === 'game-mine' ||
selectionStage === 'game-lineage' ||
selectionStage === 'game-publish';
/**
* 游玩页自己撑满剩余高度,面板不能再滚动,否则移动端会出现整页可滚的
@@ -614,6 +646,7 @@ export function PlatformEntryFlowShellImpl({
typeof window === 'undefined' ? '' : window.location.search,
);
const gameId = gameSearchParams.get('id');
const lineageHighlightGameId = gameSearchParams.get('from');
const publishGameId = gameSearchParams.get('game');
const avatarUrl = authUi?.user?.avatarUrl?.trim() || null;
const avatarLabel = resolveActiveUserAvatarLabel(authUi?.user);
@@ -870,6 +903,14 @@ export function PlatformEntryFlowShellImpl({
ownerView={gameSearchParams.get('owner') === '1'}
onBack={backFromGameDetail}
onPlay={openGamePlay}
onOpenLineage={openGameLineage}
/>
) : selectionStage === 'game-lineage' ? (
<GameLineagePage
rootGameId={gameId}
highlightGameId={lineageHighlightGameId}
onBack={backFromGameLineage}
onOpenDetail={openGameDetail}
/>
) : (
<GamePlayPage gameId={gameId} onBack={backFromGamePlay} />
@@ -10,6 +10,7 @@ export type SelectionStage =
| 'game-detail'
| 'game-play'
| 'game-mine'
| 'game-lineage'
| 'game-publish';
export type PlatformEntryFlowShellProps = {
+6
View File
@@ -27,6 +27,9 @@ describe('appPageRoutes', () => {
expect(resolveSelectionStageFromPath('/games/detail/')).toBe('game-detail');
expect(resolveSelectionStageFromPath('/games/play')).toBe('game-play');
expect(resolveSelectionStageFromPath('/games/mine')).toBe('game-mine');
expect(resolveSelectionStageFromPath('/games/lineage')).toBe(
'game-lineage',
);
expect(resolveSelectionStageFromPath('/games/publish')).toBe(
'game-publish',
);
@@ -44,6 +47,9 @@ describe('appPageRoutes', () => {
expect(resolvePathForSelectionStage('profile')).toBe('/profile');
expect(resolvePathForSelectionStage('games')).toBe('/games');
expect(resolvePathForSelectionStage('game-mine')).toBe('/games/mine');
expect(resolvePathForSelectionStage('game-lineage')).toBe(
'/games/lineage',
);
expect(resolvePathForSelectionStage('game-publish')).toBe('/games/publish');
expect(resolvePathForSelectionStage('profile-payment')).toBe(
'/profile/payment',
+1
View File
@@ -15,6 +15,7 @@ const STAGE_ROUTE_ENTRIES = [
['game-detail', '/games/detail'],
['game-play', '/games/play'],
['game-mine', '/games/mine'],
['game-lineage', '/games/lineage'],
['game-publish', '/games/publish'],
] as const satisfies readonly (readonly [SelectionStage, string])[];
+3
View File
@@ -36,6 +36,9 @@ describe('activeAppTitle', () => {
expect(resolveAppTitleForSelectionStage('game-mine')).toBe(
'我的游戏 - 陶泥儿',
);
expect(resolveAppTitleForSelectionStage('game-lineage')).toBe(
'创作族谱 - 陶泥儿',
);
expect(resolveAppTitleForSelectionStage('game-publish')).toBe(
'发布游戏 - 陶泥儿',
);
+1
View File
@@ -16,6 +16,7 @@ const APP_TITLE_BY_SELECTION_STAGE: Record<SelectionStage, string> = {
'game-detail': '游戏详情 - 陶泥儿',
'game-play': '正在游玩 - 陶泥儿',
'game-mine': '我的游戏 - 陶泥儿',
'game-lineage': '创作族谱 - 陶泥儿',
'game-publish': '发布游戏 - 陶泥儿',
};
@@ -5,7 +5,9 @@ import { clearStoredAccessToken, setStoredAccessToken } from './apiClient';
import {
cancelGameVersion,
createGameVersion,
getDerivedGames,
getGame,
getGameLineage,
getGameVersion,
getMyGameReview,
listGameReviews,
@@ -85,6 +87,42 @@ describe('gameDistributionClient', () => {
expect(await getGame('')).toBeNull();
});
it('族谱与衍生列表走公开只读路径、不缓存,并拒绝空 id', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(
JSON.stringify({
rootGameId: 'game-root',
root: null,
nodes: [],
truncated: false,
gameId: 'game-1',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
),
);
vi.stubGlobal('fetch', fetchMock);
await getGameLineage('game-1');
await getDerivedGames('game-1');
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'/api/game-distribution/games/game-1/lineage',
);
expect(fetchMock.mock.calls[1]?.[0]).toBe(
'/api/game-distribution/games/game-1/derived',
);
for (const [, init] of fetchMock.mock.calls) {
expect(init).toMatchObject({ method: 'GET', cache: 'no-store' });
expect(init?.headers?.Authorization).toBeUndefined();
}
expect(await getGameLineage('')).toBeNull();
expect(await getDerivedGames(' ')).toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('游玩上报携带稳定的匿名 clientId,且不携带未登录 bearer', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
+35
View File
@@ -4,9 +4,11 @@ import type {
GameDistributionCategory,
GameDistributionCreateGameRequest,
GameDistributionCreateVersionRequest,
GameDistributionDerivedResponse,
GameDistributionDeviceSupport,
GameDistributionForkAuthorization,
GameDistributionGame,
GameDistributionLineageResponse,
GameDistributionMyReviewResponse,
GameDistributionRecoveryAction,
GameDistributionReviewsResponse,
@@ -27,11 +29,14 @@ import { getGamePlayClientId } from './gamePlayClientId';
export type {
GameDistributionAuthor,
GameDistributionDerivedResponse,
GameDistributionDeviceSupport,
GameDistributionForkAuthorization,
GameDistributionGame,
GameDistributionInputMode,
GameDistributionLineage,
GameDistributionLineageNode,
GameDistributionLineageResponse,
GameDistributionRatingSummary,
GameDistributionRecoveryAction,
GameDistributionReview,
@@ -357,6 +362,36 @@ export async function getGame(gameId: string) {
);
}
/**
* 读取以某作品的根为顶的创作族谱树。公开只读接口,匿名可读;
* 作品不存在或已软删除时服务端返回 404,这里按失败抛出,由页面走既有错误态。
*/
export async function getGameLineage(gameId: string) {
const normalizedId = gameId.trim();
if (!normalizedId) return null;
return requestJson<GameDistributionLineageResponse>(
`/games/${encodeURIComponent(normalizedId)}/lineage`,
{ method: 'GET', cache: 'no-store' },
'读取创作族谱失败',
PUBLIC_GAME_REQUEST_OPTIONS,
);
}
/**
* 读取某作品的直接衍生作品(「被改编」列表)。只含对外可见的直接子代,
* 与公开详情里 `forkCount` 的口径一致。
*/
export async function getDerivedGames(gameId: string) {
const normalizedId = gameId.trim();
if (!normalizedId) return null;
return requestJson<GameDistributionDerivedResponse>(
`/games/${encodeURIComponent(normalizedId)}/derived`,
{ method: 'GET', cache: 'no-store' },
'读取衍生作品失败',
PUBLIC_GAME_REQUEST_OPTIONS,
);
}
/**
* 上报一次「开始游戏」。调用方按 fire-and-forget 处理:失败只静默忽略,绝不阻断进入游戏。
*/