这部作品还没有衍生作品,欢迎从它开始共创。
) : null}
+ {/*
+ 图**下方**的「父代 / 子代」两块清单(2026-10-07 用户口径):
+ 详情页共创卡不再堆派生作品卡,改为在图谱里一次看清「从哪来、下一步是谁」。
+ 数据全部来自**同一次** `/lineage`(零额外请求):
+ - 父代 = 祖先链(根 → 直接父),顺序与面包屑同一份;
+ - 子代 = **直接子代**,按**热度(自身 playCount)降序**、同热度按服务端顺序稳定兜底。
+ 选「直接子代」的理由:① 与平台既有 `forkCount`/「被改编 N」同口径,块里的卡片数
+ 与数字对得上;② 与画布上「只看父代与子代」开关同义;③ 再往下的层级由上面的树
+ 表达,这里只做「一步关系」的快速跳转。
+ */}
+
>
)}
>
diff --git a/src/components/game-distribution/LineageRelationLists.tsx b/src/components/game-distribution/LineageRelationLists.tsx
new file mode 100644
index 000000000..690c9e83c
--- /dev/null
+++ b/src/components/game-distribution/LineageRelationLists.tsx
@@ -0,0 +1,96 @@
+import type { GameDistributionLineageNode } from '../../services/gameDistributionClient';
+import { LineageNodeContent } from './LineageNodeContent';
+import type {
+ LineagePlayCountBreakdown,
+ LineageRelationRole,
+} from './lineageTreeLayout';
+
+/**
+ * 图谱**下方**的「父代 / 子代」两块清单(2026-10-07 用户口径)。
+ *
+ * 为什么放在这里而不是详情页共创卡里:用户要的是「所有父代子代都放到图谱下面,卡片只留数据和 tag」。
+ * 数据全部来自**同一次** `/lineage`(零额外请求),卡片复用族谱的 `LineageNodeContent`(与画布/大纲
+ * 同一套封面色块与省略规则),因此不会出现「第三套卡片」。
+ *
+ * 排序口径:
+ * - 父代 = 祖先链(根 → 直接父),与面包屑同一份顺序(由调用方算好);
+ * - 子代 = **直接子代**、按**热度(自身 `playCount`)降序**,同热度保持服务端顺序(创建时间序)。
+ * 选直接子代:与 `forkCount`/「被改编 N」同口径;再深的层级由上面的树表达。
+ */
+export function LineageRelationLists({
+ ancestors,
+ children,
+ currentGameId,
+ playCountTotals,
+ onOpenDetail,
+}: {
+ /** 祖先链(根 → 直接父),**不含**当前作品。 */
+ ancestors: readonly GameDistributionLineageNode[];
+ /** 直接子代(调用方已按热度降序排好)。 */
+ children: readonly GameDistributionLineageNode[];
+ currentGameId: string;
+ playCountTotals: ReadonlyMap
;
+ onOpenDetail: (gameId: string) => void;
+}) {
+ const renderRow = (
+ node: GameDistributionLineageNode,
+ role: LineageRelationRole,
+ ) => (
+
+
+
+ );
+
+ return (
+
+
+
+ 父代
+ {ancestors.length}
+
+ {ancestors.length === 0 ? (
+
+ 这部作品是母版,没有父代(它的后代都在上面的树里)。
+
+ ) : (
+
+ {ancestors.map((node) => renderRow(node, 'ancestor'))}
+
+ )}
+
+
+
+ 子代
+ {children.length}
+ 直接子代 · 按热度
+
+ {children.length === 0 ? (
+ 这部作品还没有直接子代。
+ ) : (
+
+ {children.map((node) => renderRow(node, 'descendant'))}
+
+ )}
+
+
+ );
+}
+
+export default LineageRelationLists;
diff --git a/src/components/game-distribution/gameDistribution.css b/src/components/game-distribution/gameDistribution.css
index e0162cee1..a776c4360 100644
--- a/src/components/game-distribution/gameDistribution.css
+++ b/src/components/game-distribution/gameDistribution.css
@@ -985,8 +985,24 @@
)
0 0 / 22px 22px;
cursor: grab;
- /* 拖拽平移不要触发浏览器自身的滚动/缩放。 */
+ /* 拖动平移的交互硬化(2026-10-07):
+ * - `touch-action: none`:拖拽不触发浏览器自身的滚动/缩放,触屏同手势也不打架;
+ * - `user-select: none`:按住拖动不选中文本(否则再拖会把文字拖走);
+ * - `overscroll-behavior: contain`:滚轮到边界也不把滚动链交给外部页面。 */
touch-action: none;
+ user-select: none;
+ -webkit-user-select: none;
+ overscroll-behavior: contain;
+}
+.game-lineage-canvas--panning {
+ cursor: grabbing;
+}
+/* 画布里的图片不参与原生拖拽(否则会出现「把封面图拖走」)。 */
+.game-lineage-canvas img,
+.game-lineage-canvas .game-cover__image {
+ -webkit-user-drag: none;
+ user-drag: none;
+ pointer-events: none;
}
.game-lineage-canvas:active {
cursor: grabbing;
@@ -2657,3 +2673,76 @@
padding-bottom: env(safe-area-inset-bottom, 0px);
}
}
+
+/*
+ * 图谱下方的「父代 / 子代」两块清单(2026-10-07):与大纲同一套行样式(复用 LineageNodeContent),
+ * 只把列表收窄到内容列宽并居中,避免又变成整屏出血的横条。
+ */
+.lineage-relations {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ margin-top: 1.25rem;
+}
+.lineage-relation {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+}
+.lineage-relation__title {
+ display: flex;
+ align-items: baseline;
+ gap: 0.4rem;
+ margin: 0;
+ font-size: 0.95rem;
+ font-weight: 700;
+}
+.lineage-relation__count {
+ border: 1px solid var(--platform-neutral-border);
+ border-radius: 999px;
+ padding: 0 0.4rem;
+ background: var(--platform-neutral-bg);
+ color: var(--platform-neutral-text);
+ font-size: 0.7rem;
+ font-weight: 600;
+}
+.lineage-relation__hint {
+ color: var(--platform-text-muted);
+ font-size: 0.72rem;
+ font-weight: 500;
+}
+.lineage-relation__empty {
+ margin: 0;
+ color: var(--platform-text-soft);
+ font-size: 0.8rem;
+}
+.lineage-relation__list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ max-width: 54rem;
+}
+.lineage-relation__row {
+ display: flex;
+ min-width: 0;
+}
+.lineage-relation__main {
+ display: block;
+ flex: 1 1 auto;
+ min-width: 0;
+ min-height: 2.75rem;
+ border: 1px solid var(--platform-surface-border);
+ border-radius: 0.6rem;
+ padding: 0.3rem 0.5rem;
+ background: var(--platform-surface, #fff);
+ color: inherit;
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+.lineage-relation__main:hover {
+ border-color: var(--platform-surface-hover-border);
+}
diff --git a/src/components/game-distribution/lineageTreeLayout.test.ts b/src/components/game-distribution/lineageTreeLayout.test.ts
index 966d7af25..96d79e8f2 100644
--- a/src/components/game-distribution/lineageTreeLayout.test.ts
+++ b/src/components/game-distribution/lineageTreeLayout.test.ts
@@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest';
import type { GameDistributionLineageNode } from '../../services/gameDistributionClient';
import {
buildLineageTreeLayout,
+ clampLineagePan,
collectLineageAncestorChain,
collectLineageHighlight,
collectLineageImmediateScope,
@@ -515,6 +516,28 @@ describe('estimateLineageCardHeight(纯函数估算)', () => {
});
});
+describe('clampLineagePan(平移边界:不把内容拖飞)', () => {
+ it('内容比视口大:至少保留 keep 像素可见', () => {
+ // 内容 1000 宽、视口 400、keep 96 → offset ∈ [-904, 304]
+ expect(clampLineagePan(-5000, 400, 1000)).toBe(96 - 1000);
+ expect(clampLineagePan(5000, 400, 1000)).toBe(400 - 96);
+ // 区间内原样返回
+ expect(clampLineagePan(-100, 400, 1000)).toBe(-100);
+ });
+
+ it('内容比视口小:允许在小范围内自由摆放(区间取两侧 min/max)', () => {
+ // 内容 200、视口 400、keep 96 → leftBound=-104、rightBound=304 → 区间 [-104, 304]
+ expect(clampLineagePan(-9999, 400, 200)).toBe(-104);
+ expect(clampLineagePan(9999, 400, 200)).toBe(304);
+ expect(clampLineagePan(100, 400, 200)).toBe(100);
+ });
+
+ it('视口/内容尺寸拿不到(jsdom 或未挂载)时不做限制', () => {
+ expect(clampLineagePan(-9999, 0, 1000)).toBe(-9999);
+ expect(clampLineagePan(-9999, 400, 0)).toBe(-9999);
+ });
+});
+
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 90b2a3380..d8954d3a9 100644
--- a/src/components/game-distribution/lineageTreeLayout.ts
+++ b/src/components/game-distribution/lineageTreeLayout.ts
@@ -572,6 +572,32 @@ export function collectLineageAncestorChain<
return chain.reverse();
}
+/** 平移时至少保留多少像素的内容可见(防止把画布拖飞、找不回来)。 */
+export const LINEAGE_PAN_KEEP_PX = 96;
+
+/**
+ * 平移边界:把候选 `offset` 夹到「至少留 `keep` 像素内容可见」的区间里。
+ *
+ * - 内容在视口里占 `[offset, offset + contentSize]`;
+ * - 约束:`offset + contentSize >= keep` 且 `offset <= viewportSize - keep`;
+ * - 内容比视口小的时候区间会反向,这里取两侧的 min/max 作区间,仍然允许在小范围内自由摆放。
+ *
+ * 纯函数,供画布拖动/缩放路径复用(单测直接钉住边界)。
+ */
+export function clampLineagePan(
+ offset: number,
+ viewportSize: number,
+ contentSize: number,
+ keep = LINEAGE_PAN_KEEP_PX,
+): number {
+ if (viewportSize <= 0 || contentSize <= 0) return offset;
+ const leftBound = keep - contentSize;
+ const rightBound = viewportSize - keep;
+ const lower = Math.min(leftBound, rightBound);
+ const upper = Math.max(leftBound, rightBound);
+ return Math.min(upper, Math.max(lower, offset));
+}
+
/** 节点相对「当前作品」的角色(文档口径的**父代 / 子代**)。 */
export type LineageRelationRole = 'self' | 'ancestor' | 'descendant' | 'side';
diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx
index e3d0774ad..7e6604618 100644
--- a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx
+++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx
@@ -1434,7 +1434,12 @@ describe('创作族谱', () => {
expect(gameDistributionMock.getGameLineage).toHaveBeenCalledWith(
'game-root',
);
- fireEvent.click(screen.getByRole('button', { name: /《母版》/u }));
+ // 图下方的「父代/子代」块里也有同名节点,所以这里精确到**树里**的那一个。
+ const treeNode = screen
+ .getAllByRole('button', { name: /《母版》/u })
+ .find((button) => button.closest('.lineage-relations') === null);
+ expect(treeNode).toBeTruthy();
+ fireEvent.click(treeNode as HTMLElement);
expect(setSelectionStage).toHaveBeenCalledWith('game-detail', {
path: '/games/detail?id=game-root',
});