Fork 入口主路径改为唤起客户端(genarrative://fork),未失焦才降级为手动路径

- 新增 src/components/game-distribution/gameForkProtocol.ts 收口唤起:协议 genarrative://fork?gameId=<id>,用顶层 location.href 赋值(协议未注册时桌面浏览器只是「什么都不发生」,不会把页面导航走也不会被弹窗拦截,失败可观察),并导出降级等待窗口 FORK_LAUNCH_FALLBACK_DELAY_MS=1200
- 详情页点击「Fork 这个作品」直接唤起;失焦(window blur)或页面转入后台(visibilitychange=hidden)都视为客户端接手 → 只给状态反馈,不出降级面板
- 未失焦 → 经过降级窗口后展示手动面板(作品 ID / 完整作品链接 / 复制按钮 / 客户端指引 / 一句话告知)
- 移动端与未知平台不尝试唤起(协议未注册时移动浏览器行为不可控),直接给手动路径
- 每条路径都有可观察反馈:正在唤起 / 已唤起 / 没有检测到客户端 / 当前设备无法直接唤起;定时器与失焦监听的清理覆盖卸载、重复点击、超时三条路径
- 顺手修掉同文件一个既有竞态:作者昵称补查的断言紧跟 findByRole(effect 尚未发出请求)导致偶发 0 次调用、让整份文件的门禁随机变红,改为 waitFor 等待
This commit is contained in:
2026-10-06 00:50:28 +08:00
parent b6fe63b551
commit 928fb0e616
4 changed files with 218 additions and 20 deletions
@@ -34,6 +34,10 @@ import {
normalizeGameId,
useIsMobileViewport,
} from './gameDistributionGuards';
import {
FORK_LAUNCH_FALLBACK_DELAY_MS,
openForkProtocol,
} from './gameForkProtocol';
import { GAME_METADATA_INPUT_MODE_LABELS } from './gameMetadataOptions';
import { GameRatingSummary, GameReviews } from './GameReviews';
import { GameVersionHistory } from './GameVersionHistory';
@@ -344,9 +348,12 @@ function GameDetailContent({
);
const lineage = game.lineage ?? null;
const forkCount = game.forkCount ?? 0;
// Fork 的获取方式:主路径是唤起客户端(见下方 handleForkLaunch),失败才降级为
// 「复制作品 ID / 链接 → 到客户端粘贴」。
// Fork 的主路径是唤起客户端;只有「没失焦(没客户端接手)」或「平台不可能唤起」才降级成
// 「复制作品 ID / 链接 → 到客户端粘贴」。降级面板同理只在降级时出现。
const [isForkPanelOpen, setIsForkPanelOpen] = useState(false);
const [forkLaunchNotice, setForkLaunchNotice] = useState('');
const forkLaunchTimerRef = useRef<number | null>(null);
const forkLaunchHandlersRef = useRef<(() => void) | null>(null);
const { copyState: forkIdCopyState, copyText: copyForkId } = useCopyFeedback();
const { copyState: forkLinkCopyState, copyText: copyForkLink } =
useCopyFeedback();
@@ -355,6 +362,79 @@ function GameDetailContent({
? `/games/detail?id=${encodeURIComponent(game.id)}`
: `${window.location.origin}/games/detail?id=${encodeURIComponent(game.id)}`;
const canRemix = publiclyVisible && forkAuthorization !== 'forbidden';
/**
* 清理唤起流程留下的定时器与失焦监听;组件卸载、重复点击、超时三条路径都走这里,
* 避免监听器滞留到下一次渲染或卸载之后。
*/
const clearForkLaunch = useCallback(() => {
if (forkLaunchTimerRef.current !== null) {
window.clearTimeout(forkLaunchTimerRef.current);
forkLaunchTimerRef.current = null;
}
const handOff = forkLaunchHandlersRef.current;
if (handOff) {
window.removeEventListener('blur', handOff);
document.removeEventListener('visibilitychange', handOff);
forkLaunchHandlersRef.current = null;
}
}, []);
useEffect(() => clearForkLaunch, [clearForkLaunch]);
function handleForkLaunch() {
if (!isAuthenticated) {
onLogin();
return;
}
// 降级面板已展开时再点一次 = 收起。
if (isForkPanelOpen) {
clearForkLaunch();
setIsForkPanelOpen(false);
setForkLaunchNotice('');
return;
}
clearForkLaunch();
setForkLaunchNotice('');
// 移动端/未知平台不尝试唤起:协议没注册时移动浏览器只是「什么都没发生」,
// 还可能先弹一个系统确认框;直接给手动路径更干脆。
if (isMobileViewport) {
setIsForkPanelOpen(true);
setForkLaunchNotice(
'当前设备无法直接唤起客户端,请用下面的作品 ID 或作品链接在客户端里 Fork。',
);
return;
}
setForkLaunchNotice('正在唤起陶泥儿客户端…');
// 失焦(blur)或页面转入后台(visibilitychange=hidden)都视为客户端接手:
// 浏览器把焦点交给外部协议处理器时两个信号都会出现,但不同平台只给其中一个。
const handOffToClient = () => {
clearForkLaunch();
setForkLaunchNotice(
'已唤起陶泥儿客户端。若没有自动打开,请再点一次「Fork 这个作品」手动获取作品 ID。',
);
};
forkLaunchHandlersRef.current = handOffToClient;
window.addEventListener('blur', handOffToClient, { once: true });
document.addEventListener('visibilitychange', handOffToClient, {
once: true,
});
openForkProtocol(game.id);
forkLaunchTimerRef.current = window.setTimeout(() => {
forkLaunchTimerRef.current = null;
const handOff = forkLaunchHandlersRef.current;
if (handOff) {
window.removeEventListener('blur', handOff);
document.removeEventListener('visibilitychange', handOff);
forkLaunchHandlersRef.current = null;
}
// 未失焦 = 没有客户端接手:展示降级面板,不静默无反应。
setForkLaunchNotice(
'没有检测到陶泥儿客户端,已切换为手动方式:把下面的作品 ID 或作品链接粘贴到客户端。',
);
setIsForkPanelOpen(true);
}, FORK_LAUNCH_FALLBACK_DELAY_MS);
}
const mobilePlaybackBlocked = isMobileViewport && !game.deviceSupport.mobile;
const playbackBlocked = !version || mobilePlaybackBlocked;
return (
@@ -448,6 +528,7 @@ function GameDetailContent({
Fork 入口:服务端取件通道要求 Bearer + 已公开 + 授权非禁止。
未登录按仓库既有登录门禁处理——入口照常出现(显示「登录后可 Fork」),
点击走登录弹窗,而不是静默消失。
已登录时点击直接唤起客户端;没失焦(没客户端接手)才降级出下面的手动面板。
*/
<>
<button
@@ -460,13 +541,16 @@ function GameDetailContent({
isAuthenticated ? 'game-fork-remix-panel' : undefined
}
onClick={() =>
isAuthenticated
? setIsForkPanelOpen((open) => !open)
: onLogin()
isAuthenticated ? handleForkLaunch() : onLogin()
}
>
{isAuthenticated ? 'Fork 这个作品' : '登录后可 Fork'}
</button>
{isAuthenticated && forkLaunchNotice ? (
<span className="game-fork-remix-status" role="status">
{forkLaunchNotice}
</span>
) : null}
{isAuthenticated && isForkPanelOpen ? (
<span
className="game-fork-remix-panel"
@@ -22,6 +22,7 @@ import type {
import { setCreatorViewer } from '../creator/creatorRelationshipState';
import { GameDetailPage } from './GameDetailPage';
import { GameGalleryPage } from './GameGalleryPage';
import { FORK_LAUNCH_FALLBACK_DELAY_MS } from './gameForkProtocol';
import { GameLineagePage } from './GameLineagePage';
import { GamePlayPage } from './GamePlayPage';
import { resolveNonPublicCoverLabel } from './gameVersionHistoryModel';
@@ -83,6 +84,14 @@ vi.mock('../../services/clipboard', () => ({
copyTextToClipboard: copyTextToClipboardMock,
}));
/** Fork 唤起:只打桩「发出唤起」这一动作,协议常量仍用真实值(测试按同一阈值推进定时器)。 */
const openForkProtocolMock = vi.hoisted(() => vi.fn());
vi.mock('./gameForkProtocol', async (original) => ({
...(await original<typeof import('./gameForkProtocol')>()),
openForkProtocol: openForkProtocolMock,
}));
// 只替换换签结果,测试不发起任何真实请求;返回空串即代表签名失败或字段为空。
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: (
@@ -156,6 +165,7 @@ function setViewportWidth(width: number) {
}
afterEach(() => {
vi.useRealTimers();
cleanup();
});
@@ -166,6 +176,7 @@ beforeEach(() => {
authUiMock.value.openLoginModal.mockReset();
copyTextToClipboardMock.mockReset();
copyTextToClipboardMock.mockResolvedValue(true);
openForkProtocolMock.mockReset();
setCreatorViewer(null);
getCreatorRelationshipMock.mockResolvedValue({
relationship: { isSelf: false, isFollowing: false, isFollowedBy: false },
@@ -257,7 +268,11 @@ describe('GameDetailPage', () => {
const link = await screen.findByRole('link', { name: '查看创作者主页' });
expect(link.getAttribute('href')).toBe('/creators?id=author-2');
expect(link.querySelector('strong')?.textContent).toBe('');
expect(getPublicAuthUserByIdMock).toHaveBeenCalledWith('author-2');
// 补查由 effect 发起、提交后一拍才发出:这里等它发生,避免断言与 React 提交竞态
// (此前偶发 0 次调用,会让整份文件的门禁随机变红)。
await waitFor(() =>
expect(getPublicAuthUserByIdMock).toHaveBeenCalledWith('author-2'),
);
await waitFor(() => {
expect(
(screen.getByRole('button', { name: '关注' }) as HTMLButtonElement)
@@ -1356,7 +1371,7 @@ describe('GameLineagePage', () => {
});
describe('GameDetailPage Fork 入口', () => {
it('已公开且授权开放时出现入口,展开后给出可复制的作品 ID 与操作指引', async () => {
it('已公开且授权开放时出现入口;唤起没被接手时降级给出可复制的作品 ID 与指引', async () => {
getGameMock.mockResolvedValueOnce(
createGame({ forkAuthorization: 'nonCommercial' }),
);
@@ -1368,7 +1383,19 @@ describe('GameDetailPage Fork 入口', () => {
// 未展开前不露出获取方式。
expect(screen.queryByText(/从平台作品开始创作/u)).toBeNull();
// 进入唤起流程后再切假定时器:先让详情页的异步加载在真实定时器下完成。
vi.useFakeTimers();
fireEvent.click(entry);
// 主路径是唤起客户端,而不是先给手动方式。
expect(openForkProtocolMock).toHaveBeenCalledWith('game-1');
expect(screen.getByText('正在唤起陶泥儿客户端…')).toBeTruthy();
expect(screen.queryByText(/从平台作品开始创作/u)).toBeNull();
// 页面没有失焦 → 没有客户端接手 → 经过降级窗口后给手动路径。
await act(async () => {
vi.advanceTimersByTime(FORK_LAUNCH_FALLBACK_DELAY_MS + 10);
});
expect(screen.getByText(/没有检测到陶泥儿客户端/u)).toBeTruthy();
expect(
screen.getByText(
'在陶泥儿客户端里打开「从平台作品开始创作」,粘贴下面的作品 ID 或作品链接。',
@@ -1377,26 +1404,72 @@ describe('GameDetailPage Fork 入口', () => {
expect(screen.getByText('game-1')).toBeTruthy();
expect(screen.getByText(/\/games\/detail\?id=game-1$/u)).toBeTruthy();
// 冻结口径:Fork = 复制整个工程继续改造;只保留一句话告知(对他人可见 + 不可撤销)。
expect(screen.getByText(/整个工程(含素材与文档)复制到你的项目里继续改造/u)).toBeTruthy();
expect(
screen.getByText(/整个工程(含素材与文档)复制到你的项目里继续改造/u),
).toBeTruthy();
expect(screen.getByText(/复制后对他人可见,且不可撤销/u)).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '复制作品 ID' }));
await waitFor(() =>
expect(copyTextToClipboardMock).toHaveBeenCalledWith('game-1'),
);
expect(
await screen.findByRole('button', { name: '作品 ID 已复制' }),
).toBeTruthy();
await act(async () => {});
expect(copyTextToClipboardMock).toHaveBeenCalledWith('game-1');
expect(screen.getByRole('button', { name: '作品 ID 已复制' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '复制作品链接' }));
await waitFor(() =>
expect(copyTextToClipboardMock).toHaveBeenCalledWith(
expect.stringContaining('/games/detail?id=game-1'),
),
await act(async () => {});
expect(copyTextToClipboardMock).toHaveBeenCalledWith(
expect.stringContaining('/games/detail?id=game-1'),
);
expect(
await screen.findByRole('button', { name: '作品链接已复制' }),
screen.getByRole('button', { name: '作品链接已复制' }),
).toBeTruthy();
vi.useRealTimers();
});
it('桌面端唤起后页面失焦即认为客户端接手:只给状态反馈,不出降级面板', async () => {
getGameMock.mockResolvedValueOnce(
createGame({ forkAuthorization: 'full' }),
);
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
const entry = await screen.findByRole('button', { name: 'Fork 这个作品' });
vi.useFakeTimers();
fireEvent.click(entry);
expect(openForkProtocolMock).toHaveBeenCalledWith('game-1');
// 失焦 = 浏览器把焦点交给了外部协议处理器。
act(() => {
window.dispatchEvent(new Event('blur'));
});
await act(async () => {
vi.advanceTimersByTime(FORK_LAUNCH_FALLBACK_DELAY_MS + 10);
});
expect(screen.getByText(/已唤起陶泥儿客户端/u)).toBeTruthy();
// 已接手就不算降级:不出现手动获取的作品 ID 与复制按钮。
expect(screen.queryByText(/作品 ID:/u)).toBeNull();
expect(screen.queryByRole('button', { name: '复制作品 ID' })).toBeNull();
vi.useRealTimers();
});
it('移动端不尝试唤起,直接给手动路径', async () => {
setViewportWidth(375);
getGameMock.mockResolvedValueOnce(
createGame({ forkAuthorization: 'nonCommercial' }),
);
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
fireEvent.click(
await screen.findByRole('button', { name: 'Fork 这个作品' }),
);
expect(openForkProtocolMock).not.toHaveBeenCalled();
expect(screen.getByText(/当前设备无法直接唤起客户端/u)).toBeTruthy();
expect(screen.getByText('game-1')).toBeTruthy();
expect(screen.getByRole('button', { name: '复制作品 ID' })).toBeTruthy();
});
it('授权为禁止共创时不出现 Fork 入口', async () => {
@@ -1447,7 +1520,8 @@ describe('GameDetailPage Fork 入口', () => {
fireEvent.click(gate);
expect(authUiMock.value.openLoginModal).toHaveBeenCalledTimes(1);
// 未登录不得展开获取方式(也就不会给出取不到的说明)。
// 未登录既不唤起客户端,也不展开获取方式。
expect(openForkProtocolMock).not.toHaveBeenCalled();
expect(screen.queryByText(/从平台作品开始创作/u)).toBeNull();
});
});
@@ -438,6 +438,11 @@
color: var(--platform-text-soft);
overflow-wrap: anywhere;
}
.game-detail-info-card .game-fork-remix-status {
color: var(--platform-text-soft);
font-size: 0.74rem;
overflow-wrap: anywhere;
}
.game-loading-panel {
display: grid;
min-height: 18rem;
@@ -0,0 +1,35 @@
/**
* 网页端唤起陶泥儿客户端的唯一收口。
*
* 客户端已约定协议 `genarrative://fork?gameId=<gameId>`(scheme 由 AGC 侧注册)。
* 这里不判断「客户端是否装了」——浏览器不会告诉我们;判定只能靠「页面有没有失焦」,
* 所以本模块只负责「发出唤起」这一件事,失焦判定与降级留在调用方。
*/
/** 与 AGC 约定的 Fork 协议前缀。 */
export const FORK_PROTOCOL_URL_PREFIX = 'genarrative://fork';
/**
* 唤起后等待「页面失焦」的窗口。超过它仍没失焦就认为没有客户端接手,走手动降级。
*
* 取值权衡:太大则没装客户端的用户要干等;太小则系统弹「是否打开陶泥儿?」的确认框期间
* 就可能误判成失败。1.2s 覆盖常见桌面浏览器的协议确认与进程启动。
*/
export const FORK_LAUNCH_FALLBACK_DELAY_MS = 1200;
export function buildForkProtocolUrl(gameId: string) {
return `${FORK_PROTOCOL_URL_PREFIX}?gameId=${encodeURIComponent(gameId)}`;
}
/**
* 唤起客户端。
*
* 用顶层 `location.href` 赋值(不是 iframe / `window.open`):
* - 协议**未注册**时桌面浏览器只会「什么都不发生」,不会把当前页导航走,也不会弹空白标签页;
* - iframe 方式在部分浏览器被当作下载/被拦截,且拿不到任何可观察信号,反而不如顶层导航干脆;
* - `window.open` 会被弹窗拦截器拦掉,失败信号同样不可观察。
* 因此顶层导航 + 失焦判定 + 定时降级是这里最可控的组合。
*/
export function openForkProtocol(gameId: string) {
window.location.href = buildForkProtocolUrl(gameId);
}