族谱交互硬化(滚轮不外溢/拖动不选文本/平移边界) + 开始共创上移主操作行与未安装跳下载入口 + 共创卡精简与图谱页父代子代块
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
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:
@@ -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',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user