实现游戏共创 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:
@@ -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 = {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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])[];
|
||||
|
||||
|
||||
@@ -36,6 +36,9 @@ describe('activeAppTitle', () => {
|
||||
expect(resolveAppTitleForSelectionStage('game-mine')).toBe(
|
||||
'我的游戏 - 陶泥儿',
|
||||
);
|
||||
expect(resolveAppTitleForSelectionStage('game-lineage')).toBe(
|
||||
'创作族谱 - 陶泥儿',
|
||||
);
|
||||
expect(resolveAppTitleForSelectionStage('game-publish')).toBe(
|
||||
'发布游戏 - 陶泥儿',
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 处理:失败只静默忽略,绝不阻断进入游戏。
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user