({
@@ -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({
) : (
<>
+
+
+
+ {/*
+ 改动类型筛选依赖「本次核心改动说明」字段(后端还没上)。
+ 按「字段可能存在」处理:**禁用 + 说明**,不假装能筛。
+ */}
+
+
+ {/*
+ 右侧信息面板:只在**选中节点**时出现,浮动叠加(不推挤画布)。
+ 简介 / 授权类型只为选中的这一个节点请求一次公开详情;
+ 「核心改动说明」是后端待补字段,缺失时整行不渲染。
+ */}
+ {selectedLayoutNode ? (
+
+ ) : null}
{derivedCount === 0 ? (
diff --git a/src/components/game-distribution/gameDistribution.css b/src/components/game-distribution/gameDistribution.css
index a375aef2d..dfaeb8188 100644
--- a/src/components/game-distribution/gameDistribution.css
+++ b/src/components/game-distribution/gameDistribution.css
@@ -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;
+}
diff --git a/src/components/game-distribution/lineageTreeLayout.test.ts b/src/components/game-distribution/lineageTreeLayout.test.ts
index 2edebb21e..21e4d1a0d 100644
--- a/src/components/game-distribution/lineageTreeLayout.test.ts
+++ b/src/components/game-distribution/lineageTreeLayout.test.ts
@@ -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, '根原创'),
diff --git a/src/components/game-distribution/lineageTreeLayout.ts b/src/components/game-distribution/lineageTreeLayout.ts
index a8fdc18b5..0f3da5320 100644
--- a/src/components/game-distribution/lineageTreeLayout.ts
+++ b/src/components/game-distribution/lineageTreeLayout.ts
@@ -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();
+ 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,
});
}