共创详情/族谱: 世代数改前端按子树深度计算;面板核心改动说明读版本摘要层
Project CI / Backend tests (pull_request) Failing after 25s
Project CI / Native shell tests (pull_request) Failing after 4m25s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 5m41s
Project CI / Repository checks (pull_request) Failing after 17s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m0s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m59s
Project CI / Frontend tests (pull_request) Successful in 2m8s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m29s

This commit is contained in:
2026-10-06 18:14:26 +08:00
parent 4f888d8c94
commit bd73acfccd
5 changed files with 175 additions and 54 deletions
@@ -58,7 +58,10 @@ import {
VERSION_STATUS_LABEL,
VERSION_STATUS_NEEDS_AUTHOR_ACTION,
} from './gameVersionHistoryModel';
import { collectLineageAncestorChain } from './lineageTreeLayout';
import {
collectLineageAncestorChain,
collectLineageSubtreeDepth,
} from './lineageTreeLayout';
import { useGameAuthorDisplayName } from './useGameAuthorName';
import { useGameReviews } from './useGameReviews';
@@ -577,15 +580,11 @@ function GameDetailContent({
GameDistributionLineageNode[]
>([]);
/**
* 「累计创作世代数」:另一个 session 正在给公开详情补这个字段(暂定名
* `descendantGenerationCount`)。**按「字段可能存在」写**:缺失时整项不渲染,
* 不拿 `forkCount` 或别的数字凑数(避免误导)。字段到位后这里自然生效。
* 「累计创作世代数」=**本作品子树的深度**(子树最大代际 − 本作品代际)。
* 用详情页已经拿到的 `/lineage` 同一份数据在前端算,不发新请求;
* 空树 / 只有自己时是 0(界面上给出「暂无衍生」文案)。
*/
const descendantGenerationCount =
'descendantGenerationCount' in game &&
typeof game.descendantGenerationCount === 'number'
? game.descendantGenerationCount
: undefined;
const [descendantDepth, setDescendantDepth] = useState<number | null>(null);
const loadCoCreationData = useCallback(async () => {
if (!publiclyVisible) {
setLineageChain([]);
@@ -612,6 +611,9 @@ function GameDetailContent({
setLineageChain(
tree ? collectLineageAncestorChain(tree.nodes, game.id) : [],
);
setDescendantDepth(
tree ? collectLineageSubtreeDepth(tree.nodes, game.id) : null,
);
setDerivedNodes(derived?.nodes ?? []);
}, [game.id, publiclyVisible]);
useEffect(() => {
@@ -871,14 +873,15 @@ function GameDetailContent({
) : null}
</>
) : null}
{/* 数据概览:累计衍生作品数 + 累计创作世代数(后者等后端字段,缺失不渲染)。 */}
{/* 数据概览:累计衍生作品数 + 累计创作世代数(子树深度,前端用同一份 lineage 数据算)。 */}
<span className="co-creation-stats">
<span className="co-creation-stat">
累计衍生作品 {forkCount}
</span>
{descendantGenerationCount === undefined ? null : (
{descendantDepth === null ? null : (
<span className="co-creation-stat">
累计创作世代数 {descendantGenerationCount}
累计创作世代数 {descendantDepth}
{descendantDepth === 0 ? '(暂无衍生)' : ''}
</span>
)}
</span>
@@ -980,12 +983,9 @@ function GameDetailContent({
handleForkLaunch();
}}
>
{isAuthenticated
? '一键 Fork 共创'
: '登录后一键 Fork'}
{isAuthenticated ? '一键 Fork 共创' : '登录后一键 Fork'}
</button>
{(isAuthenticated ||
forkAuthorization === 'forbidden') &&
{(isAuthenticated || forkAuthorization === 'forbidden') &&
forkLaunchNotice ? (
<span className="game-fork-remix-status" role="status">
{forkLaunchNotice}
@@ -1854,14 +1854,18 @@ describe('GameDetailPage Fork 入口', () => {
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
const blocked = await screen.findByRole('button', { name: '一键 Fork 共创' });
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),
await screen.findByText(
/作者未开放共创授权(禁止共创),无法 Fork 这部作品。/u,
),
).toBeTruthy();
expect(openForkProtocolMock).not.toHaveBeenCalled();
});
@@ -1883,7 +1887,9 @@ 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();
});
it('未登录时入口显示「登录后一键 Fork」,点击走仓库既有登录门禁而不是静默隐藏', async () => {
@@ -2351,9 +2357,9 @@ describe('GameDetailPage 共创主题入口', () => {
const openButton = (title: string) =>
screen.getByRole('button', { name: `打开《${title}》详情` });
const cardOf = async (title: string) =>
(
await screen.findByRole('button', { name: `打开《${title}》详情` })
).closest('.game-lineage-node') as HTMLElement;
(await screen.findByRole('button', { name: `打开《${title}》详情` })).closest(
'.game-lineage-node',
) as HTMLElement;
describe('GameLineagePage 可视化与交互', () => {
const treeWithSibling = {
@@ -2795,9 +2801,7 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐)
await screen.findByText('深代作品');
const changeType = screen.getByLabelText('按改动类型筛选');
expect((changeType as HTMLSelectElement).disabled).toBe(true);
expect(
screen.getByText('核心改动说明字段尚未上线'),
).toBeTruthy();
expect(screen.getByText('改动说明是自由文本,暂无可筛选分类')).toBeTruthy();
fireEvent.change(screen.getByLabelText('按世代筛选'), {
target: { value: '1' },
@@ -2858,6 +2862,11 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐)
createGame({
summary: '这是一个二创作品的简介',
forkAuthorization: 'nonCommercial',
currentVersion: {
id: 'v1',
versionNumber: 1,
changeSummary: '把跳台改成三段,并重画全部背景',
},
}),
);
const onOpenDetail = vi.fn();
@@ -2875,10 +2884,15 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐)
fireEvent.click(hotCard);
const panel = await screen.findByLabelText('选中作品信息');
expect(await within(panel).findByText('这是一个二创作品的简介')).toBeTruthy();
expect(
await within(panel).findByText('这是一个二创作品的简介'),
).toBeTruthy();
expect(within(panel).getByText(/允许非商用共创/u)).toBeTruthy();
// 「核心改动说明」字段还没上:不渲染这一行(不假装有数据)。
expect(within(panel).queryByText(/核心改动说明/u)).toBeNull();
// 核心改动说明来自版本摘要层(selectedGame.currentVersion.changeSummary)。
expect(
within(panel).getByText(/核心改动说明:把跳台改成三段/u),
).toBeTruthy();
fireEvent.click(within(panel).getByRole('button', { name: '前往体验' }));
expect(onOpenDetail).toHaveBeenCalledWith('hot');
@@ -2913,9 +2927,7 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
expect(screen.getByText('累计衍生作品 3')).toBeTruthy();
expect(screen.getByText('2 代三创 · 改编自《原初之光》')).toBeTruthy();
// 主操作按文档叫「一键 Fork 共创」。
expect(
screen.getByRole('button', { name: '一键 Fork 共创' }),
).toBeTruthy();
expect(screen.getByRole('button', { name: '一键 Fork 共创' })).toBeTruthy();
});
it('累计创作世代数字段缺失时不渲染该概览项(不拿别的数字凑数)', async () => {
@@ -2979,7 +2991,9 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
screen.getByRole('button', { name: /0 代作品《根原创》/u }),
);
expect(onOpenGameDetail).toHaveBeenCalledWith('game-root');
fireEvent.click(screen.getByRole('button', { name: /2 代作品《当前作品》/u }));
fireEvent.click(
screen.getByRole('button', { name: /2 代作品《当前作品》/u }),
);
expect(onOpenGameDetail).toHaveBeenCalledTimes(1);
});
@@ -2988,9 +3002,27 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
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' }),
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,
});
@@ -3012,7 +3044,9 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
expect(hot.textContent?.indexOf('高热度')).toBeLessThan(
hot.textContent?.indexOf('中热度') ?? -1,
);
fireEvent.click(screen.getByRole('button', { name: /热门衍生《高热度》/u }));
fireEvent.click(
screen.getByRole('button', { name: /热门衍生《高热度》/u }),
);
expect(onOpenGameDetail).toHaveBeenCalledWith('child-hot');
});
});
@@ -195,7 +195,10 @@ function LineageTreeNodeCard({
{generationAxisLabel(generation)}
</span>
{layoutNode.isTrunk ? (
<span className="game-lineage-node__trunk" title="该代热度最高的版本">
<span
className="game-lineage-node__trunk"
title="该代热度最高的版本"
>
主干
</span>
) : null}
@@ -343,7 +346,8 @@ export function GameLineagePage({
.map((item) => item.node);
}, [generationFilter, nodes, sortByHeat]);
const generationsPresent = useMemo(
() => [...new Set(nodes.map((node) => node.generation))].sort((a, b) => a - b),
() =>
[...new Set(nodes.map((node) => node.generation))].sort((a, b) => a - b),
[nodes],
);
// 窄屏用紧凑布局(更小的卡片与间距 + 更大的相对字号),避免「桌面画布缩小 + 字太小」。
@@ -497,12 +501,9 @@ export function GameLineagePage({
const selectedForkAuthorization = selectedGame
? resolveGameForkAuthorization(selectedGame.forkAuthorization)
: null;
/** 核心改动说明在**版本摘要层**(`currentVersion.changeSummary`),从这一次公开详情里读。 */
const selectedCoreChangeNote =
selectedGame && 'coreChangeNote' in selectedGame
? typeof selectedGame.coreChangeNote === 'string'
? selectedGame.coreChangeNote
: ''
: '';
selectedGame?.currentVersion?.changeSummary?.trim() ?? '';
const zoomBy = useCallback((factor: number) => {
setViewport((current) => {
@@ -672,10 +673,16 @@ export function GameLineagePage({
<span>世代</span>
<select
aria-label="按世代筛选"
value={generationFilter === null ? 'all' : String(generationFilter)}
value={
generationFilter === null
? 'all'
: String(generationFilter)
}
onChange={(event) => {
const value = event.target.value;
setGenerationFilter(value === 'all' ? null : Number(value));
setGenerationFilter(
value === 'all' ? null : Number(value),
);
}}
>
<option value="all">全部</option>
@@ -695,8 +702,8 @@ export function GameLineagePage({
按热度排序
</label>
{/*
改动类型筛选依赖「本次核心改动说明」字段(后端还没上)。
按「字段可能存在」处理:**禁用 + 说明**,不假装能筛。
改动类型筛选:`changeSummary` 字段已上线,但它是**自由文本**(服务端没有分类枚举),
因此仍然**禁用 + 说明**,不编造筛选值域;等后端给出可枚举的改动类型再接。
*/}
<label className="game-lineage-filter game-lineage-filter--disabled">
<span>改动类型</span>
@@ -704,7 +711,7 @@ export function GameLineagePage({
<option>暂不可用</option>
</select>
<span className="game-lineage-filter__note">
核心改动说明字段尚未上线
改动说明是自由文本,暂无可筛选分类
</span>
</label>
</div>
@@ -7,6 +7,7 @@ import {
buildLineageTreeLayout,
collectLineageAncestorChain,
collectLineageHighlight,
collectLineageSubtreeDepth,
generationAxisLabel,
LINEAGE_COLUMN_GAP,
LINEAGE_COVER_ASPECT,
@@ -274,7 +275,10 @@ describe('主干线(每代热度最高的版本)', () => {
expect(byId.get('hot')!.isTrunk).toBe(true);
expect(byId.get('cold')!.isTrunk).toBe(false);
const trunkEdges = layout.edges.filter((edge) => edge.isTrunk);
expect(trunkEdges.map((edge) => edge.id)).toEqual(['root->hot', 'hot->hot-child']);
expect(trunkEdges.map((edge) => edge.id)).toEqual([
'root->hot',
'hot->hot-child',
]);
});
it('热度更高的节点成为主干(显式 playCount 覆盖)', () => {
@@ -293,6 +297,34 @@ describe('主干线(每代热度最高的版本)', () => {
});
});
describe('collectLineageSubtreeDepth', () => {
it('深度 = 子树最大代际 − 本作品代际', () => {
const nodes = [
node('root', 0, null),
node('a', 1, 'root'),
node('b', 2, 'a'),
node('c', 3, 'b'),
node('sibling', 1, 'root'),
];
expect(collectLineageSubtreeDepth(nodes, 'root')).toBe(3);
expect(collectLineageSubtreeDepth(nodes, 'a')).toBe(2);
expect(collectLineageSubtreeDepth(nodes, 'sibling')).toBe(0);
});
it('单节点 / 未知作品 / 断链都返回 0', () => {
expect(collectLineageSubtreeDepth([node('root', 0, null)], 'root')).toBe(0);
expect(collectLineageSubtreeDepth([node('root', 0, null)], 'game-x')).toBe(
0,
);
const orphan = [
node('orphan', 1, 'game-missing'),
node('child', 2, 'orphan'),
];
// 父不可用不影响「本作品自己的子树」深度。
expect(collectLineageSubtreeDepth(orphan, 'orphan')).toBe(1);
});
});
describe('collectLineageAncestorChain', () => {
const nodes = [
node('root', 0, null, '根原创'),
@@ -303,9 +335,9 @@ describe('collectLineageAncestorChain', () => {
];
it('从根到目标按顺序返回(供「根原创 → 1 代 → … → 当前」展示)', () => {
expect(collectLineageAncestorChain(nodes, 'b').map((item) => item.gameId)).toEqual(
['root', 'a', 'b'],
);
expect(
collectLineageAncestorChain(nodes, 'b').map((item) => item.gameId),
).toEqual(['root', 'a', 'b']);
});
it('父不在可见集合里时链就停在那里(父作品不可用口径一致)', () => {
@@ -315,7 +347,9 @@ describe('collectLineageAncestorChain', () => {
});
it('未知节点返回空链,且遇到自环不会死循环', () => {
expect(collectLineageAncestorChain(nodes, 'game-missing-chain')).toEqual([]);
expect(collectLineageAncestorChain(nodes, 'game-missing-chain')).toEqual(
[],
);
const cyclic = [node('x', 1, 'y'), node('y', 0, 'x')];
expect(collectLineageAncestorChain(cyclic, 'x')).toHaveLength(2);
});
@@ -346,6 +346,48 @@ export function buildLineageTreeLayout(
};
}
/**
* 本作品的**子树深度** = 子树最大代际 − 本作品代际(文档「累计创作世代数」的语义)。
*
* 用同一份 `/lineage` 数据算,不发新请求;BFS 沿 `parentGameId` 的**反向**关系向下走。
* 空树 / 找不到本作品 → 0。注意:节点上限 200 时只在可见集合上计算(截断场景会在页面上标注)。
*/
export function collectLineageSubtreeDepth<
T extends {
gameId: string;
parentGameId?: string | null;
generation: number;
},
>(nodes: readonly T[], gameId: string): number {
const self = nodes.find((node) => node.gameId === gameId);
if (!self) return 0;
const childrenByParent = new Map<string, string[]>();
for (const node of nodes) {
const parentGameId = node.parentGameId ?? null;
if (!parentGameId) continue;
const bucket = childrenByParent.get(parentGameId);
if (bucket) bucket.push(node.gameId);
else childrenByParent.set(parentGameId, [node.gameId]);
}
const generationById = new Map(
nodes.map((node) => [node.gameId, node.generation]),
);
const seen = new Set<string>([gameId]);
const queue = [...(childrenByParent.get(gameId) ?? [])];
let maxGeneration = self.generation;
while (queue.length > 0) {
const current = queue.shift() as string;
if (seen.has(current)) continue;
seen.add(current);
maxGeneration = Math.max(
maxGeneration,
generationById.get(current) ?? self.generation,
);
queue.push(...(childrenByParent.get(current) ?? []));
}
return Math.max(0, maxGeneration - self.generation);
}
/**
* 血缘链:从根到 `gameId` 的**有序**节点列表(血缘溯源条用)。
*
@@ -353,7 +395,11 @@ export function buildLineageTreeLayout(
* 展示与点击用)。父节点不在可见集合里时链就停在那里(与既有的「父作品不可用」口径一致)。
*/
export function collectLineageAncestorChain<
T extends { gameId: string; parentGameId?: string | null; generation: number },
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[] = [];