共创模块B: 族谱页顶部筛选器 + 主干线(每代热度最高) + 右侧信息面板

This commit is contained in:
2026-10-06 17:16:46 +08:00
parent 475acf0f41
commit b99dcb7ef2
5 changed files with 518 additions and 12 deletions
@@ -2347,15 +2347,15 @@ describe('GameDetailPage 共创主题入口', () => {
});
});
describe('GameLineagePage 可视化与交互', () => {
/** 节点卡片上的主行动作按钮(打开详情)与它的卡片容器。 */
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;
/** 节点卡片上的主行动作按钮(打开详情)与它的卡片容器:多个 describe 共用。 */
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;
describe('GameLineagePage 可视化与交互', () => {
const treeWithSibling = {
rootGameId: 'game-root',
root: createLineageNode({
@@ -2739,6 +2739,155 @@ describe('GameLineagePage 可视化与交互', () => {
});
});
describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐)', () => {
const tree = {
rootGameId: 'game-root',
root: createLineageNode({
gameId: 'game-root',
title: '根原创',
generation: 0,
parentGameId: null,
playCount: 5,
}),
nodes: [
createLineageNode({
gameId: 'game-root',
title: '根原创',
generation: 0,
parentGameId: null,
playCount: 5,
}),
createLineageNode({
gameId: 'hot',
title: '热门二创',
generation: 1,
parentGameId: 'game-root',
playCount: 999,
}),
createLineageNode({
gameId: 'cold',
title: '冷门二创',
generation: 1,
parentGameId: 'game-root',
playCount: 1,
}),
createLineageNode({
gameId: 'deep',
title: '深代作品',
generation: 2,
parentGameId: 'hot',
playCount: 3,
}),
],
truncated: false,
};
it('顶部筛选器:世代筛选只留该代及祖先,改动类型禁用并说明原因', async () => {
getGameLineageMock.mockResolvedValueOnce(tree);
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
await screen.findByText('深代作品');
const changeType = screen.getByLabelText('按改动类型筛选');
expect((changeType as HTMLSelectElement).disabled).toBe(true);
expect(
screen.getByText('核心改动说明字段尚未上线'),
).toBeTruthy();
fireEvent.change(screen.getByLabelText('按世代筛选'), {
target: { value: '1' },
});
// 保留第 0、1 代(树不断链),第 2 代被筛掉。
expect(screen.queryByText('深代作品')).toBeNull();
expect(await cardOf('热门二创')).toBeTruthy();
expect(await cardOf('根原创')).toBeTruthy();
});
it('按热度排序:同代内 playCount 高的排在前面', async () => {
getGameLineageMock.mockResolvedValueOnce(tree);
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
await screen.findByText('热门二创');
fireEvent.click(screen.getByRole('checkbox', { name: '按热度排序' }));
const coldCard = await cardOf('冷门二创');
const hotCard = await cardOf('热门二创');
expect(parseFloat(hotCard.style.left)).toBeLessThan(
parseFloat(coldCard.style.left),
);
});
it('主干线:每代热度最高的节点带「主干」角标,主干边单独标记', async () => {
getGameLineageMock.mockResolvedValueOnce(tree);
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
await screen.findByText('热门二创');
// 第 1 代主干 = 热门二创(999 > 1);冷门二创不是主干。
expect(
(await cardOf('热门二创')).querySelector('.game-lineage-node__trunk'),
).not.toBeNull();
expect(
(await cardOf('冷门二创')).querySelector('.game-lineage-node__trunk'),
).toBeNull();
// 主干线:root→hot 与 hot→deep 两条。
expect(
document.querySelectorAll('path.game-lineage-edge--trunk').length,
).toBe(2);
});
it('选中节点后右侧信息面板给出简介/授权与「前往体验 / Fork 此版本」', async () => {
getGameLineageMock.mockResolvedValueOnce(tree);
getGameMock.mockResolvedValueOnce(
createGame({
summary: '这是一个二创作品的简介',
forkAuthorization: 'nonCommercial',
}),
);
const onOpenDetail = vi.fn();
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={onOpenDetail}
/>,
);
const hotCard = await cardOf('热门二创');
// 面板只在选中后出现。
expect(screen.queryByLabelText('选中作品信息')).toBeNull();
fireEvent.click(hotCard);
const panel = await screen.findByLabelText('选中作品信息');
expect(await within(panel).findByText('这是一个二创作品的简介')).toBeTruthy();
expect(within(panel).getByText(/允许非商用共创/u)).toBeTruthy();
// 「核心改动说明」字段还没上:不渲染这一行(不假装有数据)。
expect(within(panel).queryByText(/核心改动说明/u)).toBeNull();
fireEvent.click(within(panel).getByRole('button', { name: '前往体验' }));
expect(onOpenDetail).toHaveBeenCalledWith('hot');
fireEvent.click(within(panel).getByRole('button', { name: 'Fork 此版本' }));
expect(openForkProtocolMock).toHaveBeenCalledWith('hot');
});
});
describe('GameDetailPage 共创模块(文档对齐)', () => {
it('世代/授权标签按文档口径渲染,并显示累计衍生作品数', async () => {
getGameMock.mockResolvedValueOnce(
@@ -11,9 +11,14 @@ import {
type WheelEvent as ReactWheelEvent,
} from 'react';
import {
GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS,
resolveGameForkAuthorization,
} from '../../../packages/shared/src/contracts/gameDistribution';
import {
type GameDistributionGame,
type GameDistributionLineageResponse,
getGame,
getGameLineage,
} from '../../services/gameDistributionClient';
import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
@@ -189,6 +194,11 @@ function LineageTreeNodeCard({
<span className="game-lineage-node__badge">
{generationAxisLabel(generation)}
</span>
{layoutNode.isTrunk ? (
<span className="game-lineage-node__trunk" title="该代热度最高的版本">
主干
</span>
) : null}
{isAnchor ? (
<span className="game-lineage-node__anchor">当前作品</span>
) : null}
@@ -253,6 +263,11 @@ export function GameLineagePage({
const [error, setError] = useState('');
const [activeGameId, setActiveGameId] = useState('');
const [pinnedGameId, setPinnedGameId] = useState('');
const [generationFilter, setGenerationFilter] = useState<number | null>(null);
const [sortByHeat, setSortByHeat] = useState(false);
const [selectedGame, setSelectedGame] = useState<GameDistributionGame | null>(
null,
);
const isMobileViewport = useIsMobileViewport();
const [includeSubtree, setIncludeSubtree] = useState(true);
const [viewport, setViewport] = useState<ViewportState>({
@@ -307,12 +322,36 @@ export function GameLineagePage({
}, [loadLineage]);
const nodes = useMemo(() => lineage?.nodes ?? [], [lineage]);
/**
* 顶部筛选器:按世代筛选(保留该代及其祖先,树不断链)与按热度排序
* (同代内 `playCount` 倒序;关闭时保持服务端顺序 = 创建时间序)。
*/
const filteredNodes = useMemo(() => {
const kept =
generationFilter === null
? [...nodes]
: nodes.filter((node) => node.generation <= generationFilter);
if (!sortByHeat) return kept;
return kept
.map((node, index) => ({ node, index }))
.sort(
(left, right) =>
left.node.generation - right.node.generation ||
right.node.playCount - left.node.playCount ||
left.index - right.index,
)
.map((item) => item.node);
}, [generationFilter, nodes, sortByHeat]);
const generationsPresent = useMemo(
() => [...new Set(nodes.map((node) => node.generation))].sort((a, b) => a - b),
[nodes],
);
// 窄屏用紧凑布局(更小的卡片与间距 + 更大的相对字号),避免「桌面画布缩小 + 字太小」。
const compactTree = isMobileViewport;
const layout = useMemo(
() =>
buildLineageTreeLayout(
nodes,
filteredNodes,
compactTree
? {
// 紧凑态:卡片 176 宽 → 16:9 封面 99 高 + 信息条;度量仍满足「同层 ≥ columnGap」。
@@ -324,7 +363,7 @@ export function GameLineagePage({
}
: undefined,
),
[compactTree, nodes],
[compactTree, filteredNodes],
);
// 高亮目标:锁定(点击「锁定路径」)优先于 hover/聚焦;锁定后即使祖先移出视口,路径仍由面包屑兜底。
const highlightTargetId = pinnedGameId || activeGameId;
@@ -430,6 +469,41 @@ export function GameLineagePage({
[],
);
/**
* 右侧信息面板的数据:只为**选中的那一个节点**请求一次公开详情(简介 / 授权类型)。
* 「核心改动说明」等后端字段,缺失就不渲染这一行。
*/
useEffect(() => {
if (!pinnedGameId) {
setSelectedGame(null);
return;
}
let alive = true;
void (async () => {
try {
const detail = await getGame(pinnedGameId);
if (alive) setSelectedGame(detail ?? null);
} catch {
if (alive) setSelectedGame(null);
}
})();
return () => {
alive = false;
};
}, [pinnedGameId]);
const selectedLayoutNode = pinnedGameId
? (layout.nodes.find((item) => item.gameId === pinnedGameId) ?? null)
: null;
const selectedForkAuthorization = selectedGame
? resolveGameForkAuthorization(selectedGame.forkAuthorization)
: null;
const selectedCoreChangeNote =
selectedGame && 'coreChangeNote' in selectedGame
? typeof selectedGame.coreChangeNote === 'string'
? selectedGame.coreChangeNote
: ''
: '';
const zoomBy = useCallback((factor: number) => {
setViewport((current) => {
const scale = Math.min(
@@ -593,6 +667,47 @@ export function GameLineagePage({
</PlatformEmptyState>
) : (
<>
<div className="game-lineage-filters">
<label className="game-lineage-filter">
<span>世代</span>
<select
aria-label="按世代筛选"
value={generationFilter === null ? 'all' : String(generationFilter)}
onChange={(event) => {
const value = event.target.value;
setGenerationFilter(value === 'all' ? null : Number(value));
}}
>
<option value="all">全部</option>
{generationsPresent.map((generation) => (
<option key={generation} value={generation}>
{generationAxisLabel(generation)}
</option>
))}
</select>
</label>
<label className="game-lineage-filter game-lineage-toggle">
<input
type="checkbox"
checked={sortByHeat}
onChange={(event) => setSortByHeat(event.target.checked)}
/>
按热度排序
</label>
{/*
改动类型筛选依赖「本次核心改动说明」字段(后端还没上)。
按「字段可能存在」处理:**禁用 + 说明**,不假装能筛。
*/}
<label className="game-lineage-filter game-lineage-filter--disabled">
<span>改动类型</span>
<select aria-label="按改动类型筛选" disabled>
<option>暂不可用</option>
</select>
<span className="game-lineage-filter__note">
核心改动说明字段尚未上线
</span>
</label>
</div>
<div className="game-lineage-viewport-controls">
<label className="game-lineage-toggle">
<input
@@ -697,13 +812,14 @@ export function GameLineagePage({
['--lineage-branch' as string]: `var(--lineage-branch-${edge.branchIndex % 6})`,
}}
className={
highlighted &&
(edge.isTrunk ? 'game-lineage-edge--trunk ' : '') +
(highlighted &&
highlighted.has(edge.parentGameId) &&
highlighted.has(edge.childGameId)
? 'game-lineage-edge game-lineage-edge--active'
: highlighted
? 'game-lineage-edge game-lineage-edge--dimmed'
: 'game-lineage-edge'
: 'game-lineage-edge')
}
/>
))}
@@ -814,6 +930,74 @@ export function GameLineagePage({
<span className="game-lineage-controls__label">刷新</span>
</button>
</div>
{/*
右侧信息面板:只在**选中节点**时出现,浮动叠加(不推挤画布)。
简介 / 授权类型只为选中的这一个节点请求一次公开详情;
「核心改动说明」是后端待补字段,缺失时整行不渲染。
*/}
{selectedLayoutNode ? (
<aside
className="game-lineage-panel"
aria-label="选中作品信息"
>
<h3 className="game-lineage-panel__title">
{selectedLayoutNode.isStub
? '原作品已不可用'
: (selectedLayoutNode.title ?? '未命名作品')}
</h3>
<p className="game-lineage-panel__meta">
{generationAxisLabel(selectedLayoutNode.generation)} ·{' '}
{selectedLayoutNode.isStub
? '标题与作者名不对外展示'
: (selectedLayoutNode.authorName ?? '').trim() ||
'未署名作者'}
</p>
{selectedLayoutNode.isStub ? (
<p className="game-lineage-panel__note">
来源作品已删除或未公开,只保留代际位置。
</p>
) : (
<>
<p className="game-lineage-panel__summary">
{selectedGame?.summary?.trim() || '暂无简介'}
</p>
<p className="game-lineage-panel__meta">
授权:
{selectedForkAuthorization
? GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[
selectedForkAuthorization
]
: '读取中…'}
</p>
{selectedCoreChangeNote ? (
<p className="game-lineage-panel__note">
核心改动说明:{selectedCoreChangeNote}
</p>
) : null}
<span className="game-lineage-panel__actions">
<button
type="button"
className="game-lineage-panel__button"
onClick={() =>
onOpenDetail(selectedLayoutNode.gameId)
}
>
前往体验
</button>
<button
type="button"
className="game-lineage-panel__button game-lineage-panel__button--primary"
onClick={() =>
launchForkFromNode(selectedLayoutNode.gameId)
}
>
Fork 此版本
</button>
</span>
</>
)}
</aside>
) : null}
</div>
{derivedCount === 0 ? (
<PlatformEmptyState surface="subpanel" size="panel">
@@ -1018,6 +1018,109 @@
transition: transform 180ms ease-out;
will-change: transform;
}
/* 顶部筛选器(文档「顶部操作栏」):世代 / 热度排序 / 改动类型(待字段,禁用)。 */
.game-lineage-filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.game-lineage-filter {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: var(--platform-text-soft);
font-size: 0.78rem;
}
.game-lineage-filter select {
border: 1px solid var(--platform-surface-border);
border-radius: 0.5rem;
padding: 0.15rem 0.35rem;
background: var(--platform-surface, #fff);
color: var(--platform-text-base);
font: inherit;
font-size: 0.78rem;
}
.game-lineage-filter--disabled {
opacity: 0.55;
}
.game-lineage-filter__note {
color: var(--platform-text-soft);
font-size: 0.7rem;
}
/* 主干线:文档要求「每代热度最高的版本用粗实线连接」。 */
.game-lineage-edge--trunk {
stroke-width: 6;
}
.game-lineage-node__trunk {
position: absolute;
top: 0.35rem;
right: 0.35rem;
border-radius: 999px;
padding: 0.1rem 0.45rem;
background: var(--platform-accent-strong);
color: #fff;
font-size: 0.64rem;
font-weight: 700;
}
/* 右侧信息面板:浮动叠加在画布右上(控件在右下,避免互相遮挡)。 */
.game-lineage-panel {
position: absolute;
z-index: 4;
top: 0.6rem;
right: 0.6rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
width: min(18rem, calc(100% - 1.2rem));
max-height: calc(100% - 4.5rem);
overflow-y: auto;
padding: 0.7rem 0.8rem;
border: 1px solid var(--platform-surface-border);
border-radius: 0.8rem;
background: var(--platform-surface, #fff);
box-shadow: 0 10px 26px rgba(76, 41, 27, 0.18);
}
.game-lineage-panel__title {
margin: 0;
color: var(--platform-text-strong);
font-size: 0.9rem;
overflow-wrap: anywhere;
}
.game-lineage-panel__meta,
.game-lineage-panel__note,
.game-lineage-panel__summary {
margin: 0;
color: var(--platform-text-soft);
font-size: 0.76rem;
line-height: 1.5;
overflow-wrap: anywhere;
}
.game-lineage-panel__summary {
color: var(--platform-text-base);
}
.game-lineage-panel__actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.15rem;
}
.game-lineage-panel__button {
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;
}
.game-lineage-panel__button--primary {
border-color: var(--platform-accent-strong);
background: rgba(199, 101, 61, 0.14);
color: var(--platform-accent-strong);
}
.game-lineage-canvas__edges {
position: absolute;
top: 0;
@@ -2198,3 +2301,18 @@
font-size: 0.76rem;
overflow-wrap: anywhere;
}
/* 个人创作数据概览(我的作品页):与详情页共创模块同一视觉语言。 */
.co-creation-overview {
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
padding: 0.55rem 0.7rem;
border: 1px solid var(--platform-surface-border);
border-radius: 0.7rem;
background: var(--platform-surface-muted, rgba(104, 54, 36, 0.04));
}
.co-creation-overview .co-creation-stat {
color: var(--platform-text-soft);
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
}
@@ -260,6 +260,39 @@ describe('buildLineageTreeLayout', () => {
});
});
describe('主干线(每代热度最高的版本)', () => {
it('按 playCount 选出每代主干节点,主干边标记 isTrunk', () => {
const layout = buildLineageTreeLayout([
node('root', 0, null),
node('hot', 1, 'root'),
node('cold', 1, 'root'),
node('hot-child', 2, 'hot'),
]);
const byId = new Map(layout.nodes.map((item) => [item.gameId, item]));
// node() 里 playCount = generation * 10,同代并列 → 取先出现的那个。
expect(byId.get('root')!.isTrunk).toBe(true);
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']);
});
it('热度更高的节点成为主干(显式 playCount 覆盖)', () => {
const cold = {
...node('cold', 1, 'root'),
playCount: 1000,
};
const layout = buildLineageTreeLayout([
node('root', 0, null),
node('hot', 1, 'root'),
cold,
]);
const byId = new Map(layout.nodes.map((item) => [item.gameId, item]));
expect(byId.get('cold')!.isTrunk).toBe(true);
expect(byId.get('hot')!.isTrunk).toBe(false);
});
});
describe('collectLineageAncestorChain', () => {
const nodes = [
node('root', 0, null, '根原创'),
@@ -69,6 +69,11 @@ export type LineageLayoutNode = {
* 不必等 hover。占位父节点属于它子节点所在的分支。
*/
branchIndex: number;
/**
* 主干线节点:**该代 `playCount` 最高**的那个(并列取先出现的;占位节点不参与)。
* 文档要求「每代热度最高的版本标为主干线,用粗实线连接」。
*/
isTrunk: boolean;
};
export type LineageLayoutEdge = {
@@ -81,6 +86,8 @@ export type LineageLayoutEdge = {
to: { x: number; y: number };
/** 该边属于哪棵顶级子树,用于默认态按分支着色。 */
branchIndex: number;
/** 主干线:父与子都是各自代际的热度最高节点(渲染成粗实线)。 */
isTrunk: boolean;
};
export type LineageGenerationAxisEntry = {
@@ -259,9 +266,23 @@ export function buildLineageTreeLayout(
height: nodeHeight,
isStub: entry.node === null,
branchIndex: branchByGameId.get(entry.gameId) ?? 0,
isTrunk: false,
};
});
// 主干线:每代取 `playCount` 最高的真实节点(占位节点不参与、并列取先出现的)。
const trunkByGeneration = new Map<number, LineageLayoutNode>();
for (const node of layoutNodes) {
if (node.isStub) continue;
const current = trunkByGeneration.get(node.generation);
if (!current || node.node!.playCount > current.node!.playCount) {
trunkByGeneration.set(node.generation, node);
}
}
for (const node of layoutNodes) {
node.isTrunk = trunkByGeneration.get(node.generation) === node;
}
const layoutById = new Map(layoutNodes.map((node) => [node.gameId, node]));
const edges: LineageLayoutEdge[] = [];
for (const node of layoutNodes) {
@@ -304,6 +325,7 @@ export function buildLineageTreeLayout(
to,
path,
branchIndex: parent.branchIndex,
isTrunk: parent.isTrunk && node.isTrunk,
});
}