族谱交互硬化(滚轮不外溢/拖动不选文本/平移边界) + 开始共创上移主操作行与未安装跳下载入口 + 共创卡精简与图谱页父代子代块
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

This commit is contained in:
2026-10-07 15:16:30 +08:00
parent 576c28354b
commit f83f4c53a5
11 changed files with 863 additions and 282 deletions
@@ -12,6 +12,7 @@ import {
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ClientDownloadEntry } from './ClientDownloadEntry';
import { requestClientDownload } from './clientDownloadLauncher';
const fetchMock = vi.fn();
const installerUrl = (version: string) =>
@@ -39,6 +40,30 @@ const macDownload = (architecture: 'aarch64' | 'x86_64') => ({
});
describe('官网下载入口', () => {
it('任何地方请求下载入口都能打开同一个弹窗(开始共创未检测到客户端时的落点)', async () => {
fetchMock.mockResolvedValueOnce(releaseResponse('0.2.0'));
render(<ClientDownloadEntry />);
// 还没点:不请求、不渲染弹窗。
expect(fetchMock).not.toHaveBeenCalled();
expect(screen.queryByText(/最新版本/u)).toBeNull();
act(() => {
requestClientDownload();
});
// 与点「下载客户端」完全一样:拉取 /api/client-downloads 并给出平台下载链接。
expect(
(
await screen.findByRole('link', { name: '下载 Windows 版' })
).getAttribute('href'),
).toBe(installerUrl('0.2.0'));
expect(fetchMock).toHaveBeenCalledWith(
'/api/client-downloads',
expect.objectContaining({ cache: 'no-store' }),
);
});
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal('fetch', fetchMock);
@@ -11,6 +11,7 @@ import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { UnifiedModal } from '../common/UnifiedModal';
import { subscribeClientDownloadRequest } from './clientDownloadLauncher';
const DOWNLOAD_ERROR = '暂时无法获取最新版本,请稍后重试';
@@ -47,6 +48,9 @@ export function ClientDownloadEntry({
const [attempt, setAttempt] = useState(0);
const [state, setState] = useState<DownloadState>({ status: 'loading' });
// 「开始共创」检测不到客户端时也会打开这里:同一个下载入口,不另写一套下载页。
useEffect(() => subscribeClientDownloadRequest(() => setOpen(true)), []);
useEffect(() => {
if (!open) return;
const controller = new AbortController();
@@ -0,0 +1,26 @@
/**
* 「打开客户端下载入口」的跨组件触发器。
*
* 为什么需要它:下载 UI(`ClientDownloadEntry`)自带弹窗与 `/api/client-downloads` 取数逻辑,
* 而「开始共创」在检测不到客户端时也要落到**同一个**下载入口(不能自己再写一套下载页/直链)。
* 这里用最小的进程内事件做收口:任何地方 `requestClientDownload()`,挂着的下载入口自己打开,
* 平台顶栏那个「下载客户端」按钮仍然是同一个组件、同一个目标。
*
* 局限(有意为之):纯 web 拿不到系统级「是否安装」信息,所以这只是「把用户送到正确的下载入口」,
* 不做任何安装状态的判定或缓存。
*/
export const CLIENT_DOWNLOAD_REQUEST_EVENT = 'genarrative:open-client-download';
/** 打开客户端下载入口(与顶栏「下载客户端」同一个弹窗)。 */
export function requestClientDownload() {
if (typeof window === 'undefined') return;
window.dispatchEvent(new Event(CLIENT_DOWNLOAD_REQUEST_EVENT));
}
/** 订阅下载入口请求;返回取消订阅函数。 */
export function subscribeClientDownloadRequest(handler: () => void) {
if (typeof window === 'undefined') return () => {};
window.addEventListener(CLIENT_DOWNLOAD_REQUEST_EVENT, handler);
return () =>
window.removeEventListener(CLIENT_DOWNLOAD_REQUEST_EVENT, handler);
}
@@ -36,9 +36,9 @@ import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformMudPointConfirmDialog } from '../common/PlatformMudPointConfirmDialog';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { useCopyFeedback } from '../common/useCopyFeedback';
import { requestClientDownload } from '../creation-home/clientDownloadLauncher';
import { CreatorAuthor } from '../creator/CreatorRelationshipControl';
import { coCreationGenerationTagLabel } from './coCreationCopy';
import { CoCreationHotDerived } from './CoCreationHotDerived';
import { CoCreationLineageSection } from './CoCreationLineageSection';
import {
normalizeGameEntryUrl,
@@ -751,8 +751,10 @@ function GameDetailContent({
if (isMobileViewport) {
setIsForkPanelOpen(true);
setForkLaunchNotice(
'当前设备无法直接唤起客户端,请用下面的作品 ID 或作品链接在客户端里开始共创。',
'当前设备无法直接唤起客户端,已在下方给出作品 ID;也可以先下载电脑版客户端。',
);
// 移动端平台客户端是桌面版:直接打开同一个下载入口(不自己造下载页)。
requestClientDownload();
return;
}
setForkLaunchNotice('正在唤起陶泥儿客户端…');
@@ -778,11 +780,13 @@ function GameDetailContent({
document.removeEventListener('visibilitychange', handOff);
forkLaunchHandlersRef.current = null;
}
// 未失焦 = 没有客户端接手:展示降级面板,不静默无反应。
// 未失焦 = 没有客户端接手(未安装 / 协议未注册 / 老客户端不响应深链):
// 打开**与顶栏「下载客户端」同一个**下载入口,并给一句人话提示 + 保留手动复制路径。
setForkLaunchNotice(
'没有检测到陶泥儿客户端,已切换为手动方式:把下面的作品 ID 或作品链接粘贴到客户端。',
'没有检测到陶泥儿客户端,已打开下载入口;也可以复制下面的作品 ID 手动开始共创。',
);
setIsForkPanelOpen(true);
requestClientDownload();
}, FORK_LAUNCH_FALLBACK_DELAY_MS);
}
const mobilePlaybackBlocked = isMobileViewport && !game.deviceSupport.mobile;
@@ -980,125 +984,6 @@ function GameDetailContent({
))}
</span>
) : null}
{publiclyVisible ? (
/*
主操作「一键开始共创」(文档口径):**始终显示**。
- 授权为「禁止共创」时置灰(aria-disabled + 视觉弱化)并在悬停/点击给出原因,
不再静默隐藏;
- 未登录按仓库既有登录门禁:入口照常出现,点击走登录弹窗;
- 已登录且授权开放:点击直接唤起客户端,没失焦(没客户端接手)才降级出下面面板。
*/
<>
<button
type="button"
className={`game-fork-remix-link co-creation-fork-primary${
forkAuthorization === 'forbidden'
? ' co-creation-fork-primary--blocked'
: ''
}`}
aria-disabled={forkAuthorization === 'forbidden'}
aria-expanded={
isAuthenticated && forkAuthorization !== 'forbidden'
? isForkPanelOpen
: undefined
}
aria-controls={
isAuthenticated && forkAuthorization !== 'forbidden'
? 'game-fork-remix-panel'
: undefined
}
title={
forkAuthorization === 'forbidden'
? '作者未开放共创授权(禁止共创),无法开始共创这部作品'
: undefined
}
onClick={() => {
if (forkAuthorization === 'forbidden') {
setForkLaunchNotice(
'作者未开放共创授权(禁止共创),无法开始共创这部作品。',
);
return;
}
if (!isAuthenticated) {
onLogin();
return;
}
handleForkLaunch();
}}
>
{isAuthenticated ? '一键开始共创' : '登录后一键开始共创'}
</button>
{(isAuthenticated || forkAuthorization === 'forbidden') &&
forkLaunchNotice ? (
<span className="game-fork-remix-status" role="status">
{forkLaunchNotice}
</span>
) : null}
{isAuthenticated && isForkPanelOpen ? (
<span
className="game-fork-remix-panel"
id="game-fork-remix-panel"
>
<span className="game-fork-remix-steps">
在陶泥儿客户端里打开「从平台作品开始创作」,粘贴下面的作品
ID 或作品链接。
</span>
<span className="game-fork-remix-value">
作品 ID:<code>{game.id}</code>
</span>
<span className="game-fork-remix-value">
作品链接:<code>{workLink}</code>
</span>
<span className="game-fork-remix-actions">
<CopyFeedbackButton
state={forkIdCopyState}
idleLabel="复制作品 ID"
copiedLabel="作品 ID 已复制"
failedLabel="复制失败"
className="game-fork-remix-copy"
onClick={() => void copyForkId(game.id)}
/>
<CopyFeedbackButton
state={forkLinkCopyState}
idleLabel="复制作品链接"
copiedLabel="作品链接已复制"
failedLabel="复制失败"
className="game-fork-remix-copy"
onClick={() => void copyForkLink(workLink)}
/>
</span>
<span className="game-fork-remix-note">
开始共创会把这部作品的整个工程(含素材与文档)复制到你的项目里继续改造;
复制后对他人可见,且不可撤销。
</span>
{forkIdCopyState === 'copied' ? (
<span
className="game-fork-remix-status"
role="status"
>
作品 ID
已复制,可在客户端「从平台作品开始创作」里粘贴。
</span>
) : null}
{forkIdCopyState === 'failed' ? (
<span
className="game-fork-remix-status"
role="status"
>
复制失败:请手动选中作品 ID 文本。
</span>
) : null}
</span>
) : null}
</>
) : null}
{derivedNodes.length > 0 && onOpenGameDetail ? (
<CoCreationHotDerived
nodes={derivedNodes}
playCountTotals={lineageTotals}
onOpenGameDetail={onOpenGameDetail}
/>
) : null}
</span>
),
},
@@ -1154,6 +1039,109 @@ function GameDetailContent({
{collectionNotice}
</span>
) : null}
{/*
「一键开始共创」从共创信息卡搬到主操作行(2026-10-07 用户口径):
它是这部作品的主操作之一,和「立即玩 / 收藏」并列;共创卡内不再重复出现该按钮。
视觉权重保持主操作(实心强调色),未登录 → 登录门禁,禁止共创 → 置灰 + 原因。
*/}
{publiclyVisible ? (
<>
<button
type="button"
className={`game-fork-remix-link co-creation-fork-primary${
forkAuthorization === 'forbidden'
? ' co-creation-fork-primary--blocked'
: ''
}`}
aria-disabled={forkAuthorization === 'forbidden'}
aria-expanded={
isAuthenticated && forkAuthorization !== 'forbidden'
? isForkPanelOpen
: undefined
}
aria-controls={
isAuthenticated && forkAuthorization !== 'forbidden'
? 'game-fork-remix-panel'
: undefined
}
title={
forkAuthorization === 'forbidden'
? '作者未开放共创授权(禁止共创),无法开始共创这部作品'
: undefined
}
onClick={() => {
if (forkAuthorization === 'forbidden') {
setForkLaunchNotice(
'作者未开放共创授权(禁止共创),无法开始共创这部作品。',
);
return;
}
if (!isAuthenticated) {
onLogin();
return;
}
handleForkLaunch();
}}
>
{isAuthenticated ? '一键开始共创' : '登录后一键开始共创'}
</button>
{(isAuthenticated || forkAuthorization === 'forbidden') &&
forkLaunchNotice ? (
<span className="game-fork-remix-status" role="status">
{forkLaunchNotice}
</span>
) : null}
{isAuthenticated && isForkPanelOpen ? (
<span
className="game-fork-remix-panel"
id="game-fork-remix-panel"
>
<span className="game-fork-remix-steps">
在陶泥儿客户端里打开「从平台作品开始创作」,粘贴下面的作品
ID 或作品链接。
</span>
<span className="game-fork-remix-value">
作品 ID:<code>{game.id}</code>
</span>
<span className="game-fork-remix-value">
作品链接:<code>{workLink}</code>
</span>
<span className="game-fork-remix-actions">
<CopyFeedbackButton
state={forkIdCopyState}
idleLabel="复制作品 ID"
copiedLabel="作品 ID 已复制"
failedLabel="复制失败"
className="game-fork-remix-copy"
onClick={() => void copyForkId(game.id)}
/>
<CopyFeedbackButton
state={forkLinkCopyState}
idleLabel="复制作品链接"
copiedLabel="作品链接已复制"
failedLabel="复制失败"
className="game-fork-remix-copy"
onClick={() => void copyForkLink(workLink)}
/>
</span>
<span className="game-fork-remix-note">
开始共创会把这部作品的整个工程(含素材与文档)复制到你的项目里继续改造;
复制后对他人可见,且不可撤销。
</span>
{forkIdCopyState === 'copied' ? (
<span className="game-fork-remix-status" role="status">
作品 ID 已复制,可在客户端「从平台作品开始创作」里粘贴。
</span>
) : null}
{forkIdCopyState === 'failed' ? (
<span className="game-fork-remix-status" role="status">
复制失败:请手动选中作品 ID 文本。
</span>
) : null}
</span>
) : null}
</>
) : null}
{!isPaidLocked && mobilePlaybackBlocked ? (
<span
id="game-mobile-playback-warning"
File diff suppressed because it is too large Load Diff
@@ -8,7 +8,6 @@ import {
useMemo,
useRef,
useState,
type WheelEvent as ReactWheelEvent,
} from 'react';
import {
@@ -29,8 +28,11 @@ import {
import { LineageInfoPanel } from './LineageInfoPanel';
import { LineageNodeContent } from './LineageNodeContent';
import { LineageOutlineList } from './LineageOutlineList';
import { LineageRelationLists } from './LineageRelationLists';
import {
buildLineageTreeLayout,
clampLineagePan,
collectLineageAncestorChain,
collectLineageHighlight,
collectLineageImmediateScope,
collectLineagePlayCountTotals,
@@ -296,6 +298,8 @@ export function GameLineagePage({
const currentCrumbRef = useRef<HTMLButtonElement>(null);
const forkTimerRef = useRef<number | null>(null);
const forkHandlersRef = useRef<(() => void) | null>(null);
/** 拖动中:驱动 CSS(user-select: none / touch-action: none / 光标)。 */
const [isPanning, setIsPanning] = useState(false);
const dragRef = useRef<{
x: number;
y: number;
@@ -359,6 +363,29 @@ export function GameLineagePage({
* 每个节点相对当前作品的角色:`self` / `ancestor`(父代)/ `descendant`(子代)/ `side`(旁支)。
* 用**未筛选**的整棵树算,所以世代筛选、只看父子、排序都不会改口径。
*/
/** 祖先链(根 → 直接父):与信息面板面包屑同一份顺序函数。 */
const ancestorChain = useMemo(() => {
const chain = collectLineageAncestorChain(nodes, anchorId);
// 去掉自己:这是「父代」清单。
return chain.filter((node) => node.gameId !== anchorId);
}, [anchorId, nodes]);
/**
* **直接子代**(按热度降序;同热度按服务端顺序 = 创建时间序稳定兜底)。
* 选「直接子代」而不是整棵子树:与 `forkCount`/「被改编 N」同口径,且再深的层级交给上面的树。
*/
const directChildren = useMemo(() => {
const children = nodes.filter(
(node) => (node.parentGameId ?? null) === anchorId,
);
return children
.map((node, index) => ({ node, index }))
.sort(
(left, right) =>
right.node.playCount - left.node.playCount ||
left.index - right.index,
)
.map((item) => item.node);
}, [anchorId, nodes]);
const relationRoles = useMemo(
() => collectLineageRelationRoles(nodes, anchorId),
[anchorId, nodes],
@@ -700,13 +727,72 @@ export function GameLineagePage({
});
}, []);
const handleWheel = (event: ReactWheelEvent<HTMLDivElement>) => {
if (!event.ctrlKey && Math.abs(event.deltaY) < 1) return;
event.preventDefault();
zoomBy(event.deltaY < 0 ? 1.08 : 1 / 1.08);
};
/**
* 画布是否真的挂载了内容:树是**异步加载**的,首帧 `viewportRef.current` 还是 null,
* 所以原生监听必须依赖「画布有内容」这个信号重新绑定,否则会一次都没挂上。
*/
const canvasHasContent =
effectiveViewMode === 'canvas' && layout.nodes.length > 0;
/**
* 滚轮:**只缩放画布,不滚动外部页面**。
*
* 为什么用原生监听而不是 React 的 `onWheel`:React 17+ 把 `wheel` 挂在根容器上且**是 passive 的**
* (`preventDefault` 会被忽略,页面照样滚)——这正是「滚轮同时缩放 + 滚页面」的根因。
* 这里绑在**画布元素本身**(不是 `window`),显式 `{ passive: false }`,滚轮就不会外溢。
*/
useEffect(() => {
const element = viewportRef.current;
if (!element) return;
const onWheel = (event: WheelEvent) => {
// 阻止页面滚动与浏览器的「捏合缩放整页」;缩放只作用在本画布。
event.preventDefault();
zoomBy(event.deltaY < 0 ? 1.08 : 1 / 1.08);
};
element.addEventListener('wheel', onWheel, { passive: false });
return () => element.removeEventListener('wheel', onWheel);
}, [canvasHasContent, zoomBy]);
/**
* 拖动平移期间禁止文本选择 / 原生拖拽(否则按住划一下就把文字选走、再拖变成拖文本)。
* `selectstart` / `dragstart` 不是 React 合成事件,所以在画布上挂原生监听一次。
*/
useEffect(() => {
const element = viewportRef.current;
if (!element) return;
const block = (event: Event) => {
if (!dragRef.current) return;
event.preventDefault();
};
const blockDragStart = (event: Event) => event.preventDefault();
element.addEventListener('selectstart', block);
element.addEventListener('dragstart', blockDragStart);
return () => {
element.removeEventListener('selectstart', block);
element.removeEventListener('dragstart', blockDragStart);
};
}, [canvasHasContent]);
/**
* 拖动期间把 `user-select` 压到 body 上。
*
* 为什么不止靠画布容器的 CSS:指针可能划到画布外(尤其配合 pointer capture),
* 而选择行为发生在 document 级别;拖动期间全局禁选最稳,松手即恢复。
*/
useEffect(() => {
if (!isPanning) return;
const previous = document.body.style.userSelect;
document.body.style.userSelect = 'none';
return () => {
document.body.style.userSelect = previous;
};
}, [isPanning]);
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
// 只响应主键 / 触摸 / 笔;中键与右键留给浏览器(新标签页打开、右键菜单)。
if (event.pointerType === 'mouse' && event.button !== 0) return;
// 指针捕获:移出画布/越界后仍然跟手,不用把监听挂到 window 上。
event.currentTarget.setPointerCapture?.(event.pointerId);
setIsPanning(true);
dragRef.current = {
x: event.clientX,
y: event.clientY,
@@ -718,15 +804,35 @@ export function GameLineagePage({
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag) return;
setViewport((current) => ({
...current,
offsetX: drag.offsetX + (event.clientX - drag.x),
offsetY: drag.offsetY + (event.clientY - drag.y),
}));
event.preventDefault();
const viewportElement = viewportRef.current;
setViewport((current) => {
const width = viewportElement?.clientWidth ?? 0;
const height = viewportElement?.clientHeight ?? 0;
const contentWidth = layout.width * current.scale;
const contentHeight = layout.height * current.scale;
return {
...current,
offsetX: clampLineagePan(
drag.offsetX + (event.clientX - drag.x),
width,
contentWidth,
),
offsetY: clampLineagePan(
drag.offsetY + (event.clientY - drag.y),
height,
contentHeight,
),
};
});
};
const endDrag = () => {
const endDrag = (event?: ReactPointerEvent<HTMLDivElement>) => {
dragRef.current = null;
setIsPanning(false);
if (event?.currentTarget?.hasPointerCapture?.(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
};
/** 节点上的「开始共创」次要动作:复用详情页同一条唤起 + 降级链路(协议模块 + 复制回退)。 */
@@ -1009,13 +1115,13 @@ export function GameLineagePage({
/>
) : (
<div
className="game-lineage-canvas"
className={`game-lineage-canvas${isPanning ? ' game-lineage-canvas--panning' : ''}`}
ref={viewportRef}
onWheel={handleWheel}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerLeave={endDrag}
onPointerCancel={endDrag}
onLostPointerCapture={endDrag}
aria-label="创作族谱树"
>
<div
@@ -1205,6 +1311,23 @@ export function GameLineagePage({
<span>这部作品还没有衍生作品,欢迎从它开始共创。</span>
</PlatformEmptyState>
) : null}
{/*
图**下方**的「父代 / 子代」两块清单(2026-10-07 用户口径):
详情页共创卡不再堆派生作品卡,改为在图谱里一次看清「从哪来、下一步是谁」。
数据全部来自**同一次** `/lineage`(零额外请求):
- 父代 = 祖先链(根 → 直接父),顺序与面包屑同一份;
- 子代 = **直接子代**,按**热度(自身 playCount)降序**、同热度按服务端顺序稳定兜底。
选「直接子代」的理由:① 与平台既有 `forkCount`/「被改编 N」同口径,块里的卡片数
与数字对得上;② 与画布上「只看父代与子代」开关同义;③ 再往下的层级由上面的树
表达,这里只做「一步关系」的快速跳转。
*/}
<LineageRelationLists
ancestors={ancestorChain}
children={directChildren}
currentGameId={anchorId}
playCountTotals={playCountTotals}
onOpenDetail={onOpenDetail}
/>
</>
)}
</>
@@ -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<string, LineagePlayCountBreakdown>;
onOpenDetail: (gameId: string) => void;
}) {
const renderRow = (
node: GameDistributionLineageNode,
role: LineageRelationRole,
) => (
<li className="lineage-relation__row" key={node.gameId}>
<button
type="button"
className="lineage-relation__main"
aria-label={`打开《${node.title}》详情`}
onClick={() => onOpenDetail(node.gameId)}
>
<LineageNodeContent
layout="inline"
title={node.title}
authorName={node.authorName ?? null}
generation={node.generation}
coverObjectKey={node.coverObjectKey ?? null}
isAnchor={node.gameId === currentGameId}
isStub={false}
relation={role}
playCount={playCountTotals.get(node.gameId) ?? null}
/>
</button>
</li>
);
return (
<div className="lineage-relations">
<section className="lineage-relation" aria-label="父代">
<h3 className="lineage-relation__title">
父代
<span className="lineage-relation__count">{ancestors.length}</span>
</h3>
{ancestors.length === 0 ? (
<p className="lineage-relation__empty">
这部作品是母版,没有父代(它的后代都在上面的树里)。
</p>
) : (
<ul className="lineage-relation__list">
{ancestors.map((node) => renderRow(node, 'ancestor'))}
</ul>
)}
</section>
<section className="lineage-relation" aria-label="子代">
<h3 className="lineage-relation__title">
子代
<span className="lineage-relation__count">{children.length}</span>
<span className="lineage-relation__hint">直接子代 · 按热度</span>
</h3>
{children.length === 0 ? (
<p className="lineage-relation__empty">这部作品还没有直接子代。</p>
) : (
<ul className="lineage-relation__list">
{children.map((node) => renderRow(node, 'descendant'))}
</ul>
)}
</section>
</div>
);
}
export default LineageRelationLists;
@@ -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);
}
@@ -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, '根原创'),
@@ -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';
@@ -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',
});