From 928fb0e616e69acf8b59971116359ca393c12b71 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 6 Oct 2026 00:50:28 +0800 Subject: [PATCH] =?UTF-8?q?Fork=20=E5=85=A5=E5=8F=A3=E4=B8=BB=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E6=94=B9=E4=B8=BA=E5=94=A4=E8=B5=B7=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=EF=BC=88genarrative://fork=EF=BC=89=EF=BC=8C=E6=9C=AA?= =?UTF-8?q?=E5=A4=B1=E7=84=A6=E6=89=8D=E9=99=8D=E7=BA=A7=E4=B8=BA=E6=89=8B?= =?UTF-8?q?=E5=8A=A8=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 src/components/game-distribution/gameForkProtocol.ts 收口唤起:协议 genarrative://fork?gameId=,用顶层 location.href 赋值(协议未注册时桌面浏览器只是「什么都不发生」,不会把页面导航走也不会被弹窗拦截,失败可观察),并导出降级等待窗口 FORK_LAUNCH_FALLBACK_DELAY_MS=1200 - 详情页点击「Fork 这个作品」直接唤起;失焦(window blur)或页面转入后台(visibilitychange=hidden)都视为客户端接手 → 只给状态反馈,不出降级面板 - 未失焦 → 经过降级窗口后展示手动面板(作品 ID / 完整作品链接 / 复制按钮 / 客户端指引 / 一句话告知) - 移动端与未知平台不尝试唤起(协议未注册时移动浏览器行为不可控),直接给手动路径 - 每条路径都有可观察反馈:正在唤起 / 已唤起 / 没有检测到客户端 / 当前设备无法直接唤起;定时器与失焦监听的清理覆盖卸载、重复点击、超时三条路径 - 顺手修掉同文件一个既有竞态:作者昵称补查的断言紧跟 findByRole(effect 尚未发出请求)导致偶发 0 次调用、让整份文件的门禁随机变红,改为 waitFor 等待 --- .../game-distribution/GameDetailPage.tsx | 94 +++++++++++++++- .../GameDistributionPages.test.tsx | 104 +++++++++++++++--- .../game-distribution/gameDistribution.css | 5 + .../game-distribution/gameForkProtocol.ts | 35 ++++++ 4 files changed, 218 insertions(+), 20 deletions(-) create mode 100644 src/components/game-distribution/gameForkProtocol.ts diff --git a/src/components/game-distribution/GameDetailPage.tsx b/src/components/game-distribution/GameDetailPage.tsx index 081adf130..3e2374878 100644 --- a/src/components/game-distribution/GameDetailPage.tsx +++ b/src/components/game-distribution/GameDetailPage.tsx @@ -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(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」), 点击走登录弹窗,而不是静默消失。 + 已登录时点击直接唤起客户端;没失焦(没客户端接手)才降级出下面的手动面板。 */ <> + {isAuthenticated && forkLaunchNotice ? ( + + {forkLaunchNotice} + + ) : null} {isAuthenticated && isForkPanelOpen ? ( ({ copyTextToClipboard: copyTextToClipboardMock, })); +/** Fork 唤起:只打桩「发出唤起」这一动作,协议常量仍用真实值(测试按同一阈值推进定时器)。 */ +const openForkProtocolMock = vi.hoisted(() => vi.fn()); + +vi.mock('./gameForkProtocol', async (original) => ({ + ...(await original()), + 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( + , + ); + + 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( + , + ); + + 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(); }); }); diff --git a/src/components/game-distribution/gameDistribution.css b/src/components/game-distribution/gameDistribution.css index 1e4967e9f..537194c58 100644 --- a/src/components/game-distribution/gameDistribution.css +++ b/src/components/game-distribution/gameDistribution.css @@ -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; diff --git a/src/components/game-distribution/gameForkProtocol.ts b/src/components/game-distribution/gameForkProtocol.ts new file mode 100644 index 000000000..1251c634d --- /dev/null +++ b/src/components/game-distribution/gameForkProtocol.ts @@ -0,0 +1,35 @@ +/** + * 网页端唤起陶泥儿客户端的唯一收口。 + * + * 客户端已约定协议 `genarrative://fork?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); +}