From 0d4db90db1c5cff7f4f205b2ea11f03874516683 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 04:22:07 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BD=9C=E5=93=81=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E9=A1=B5=E3=80=8C=E6=94=B9=E9=80=A0=E8=BF=99=E4=B8=AA?= =?UTF-8?q?=E4=BD=9C=E5=93=81=E3=80=8D=E5=85=A5=E5=8F=A3=EF=BC=88=E5=A4=8D?= =?UTF-8?q?=E5=88=B6=E4=BD=9C=E5=93=81=20ID/=E9=93=BE=E6=8E=A5=20=E2=86=92?= =?UTF-8?q?=20=E5=AE=A2=E6=88=B7=E7=AB=AF=E7=B2=98=E8=B4=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 共创信息卡内新增入口:仅当作品已公开且 forkAuthorization != forbidden 时出现;授权为禁止共创时保持现状不出现 - 交互:点击展开获取方式——可复制的作品 ID 与完整作品链接(/games/detail?id=,AGC 解析器接受的形态)+ 操作指引「打开陶泥儿客户端 → 首页『从平台作品开始创作』→ 粘贴」 - 复制反馈复用平台既有 CopyFeedbackButton + useCopyFeedback(统一剪贴板实现与「已复制 / 复制失败」状态) - 未登录:入口照常出现但显示「登录后可改造」,点击走仓库既有登录门禁(authUi.openLoginModal,与同页「登录后评价」同一做法),不静默隐藏、也不假装可唤起客户端 - 文案与 AGC 侧口径一致:可玩参考 + 素材、参考产物在 reference/<作品ID>/ 目录、成品包没有源码不能直接再次发布;不写「一键复刻工程」 - 不新增路由、不发 API 请求、不动服务端:网页端无法唤起客户端(未注册 deep link),只做「复制 → 粘贴」 - 测试:新增 4 例(公开+非禁止出现入口并可复制、forbidden 不出现、未公开不出现、未登录走登录门禁);并把 M1 遗留的「不提供改造入口」断言改写为新契约 - 样式:.game-fork-remix-* 落在共创信息卡内,窄屏靠网格与 overflow-wrap 收在容器里 --- .../game-distribution/GameDetailPage.tsx | 87 +++++++++++- .../GameDistributionPages.test.tsx | 133 +++++++++++++++++- .../game-distribution/gameDistribution.css | 68 +++++++++ 3 files changed, 280 insertions(+), 8 deletions(-) diff --git a/src/components/game-distribution/GameDetailPage.tsx b/src/components/game-distribution/GameDetailPage.tsx index 3ea86f6fd..907dec1ef 100644 --- a/src/components/game-distribution/GameDetailPage.tsx +++ b/src/components/game-distribution/GameDetailPage.tsx @@ -6,7 +6,6 @@ import { } from '@genarrative/shared/components'; import { ArrowLeft, Gamepad2, Maximize2 } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; - import { GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS, type GameDistributionRecoveryAction, @@ -24,8 +23,10 @@ import { loadVersionRecoveryActions, } from '../../services/gameDistributionClient'; import { useAuthUi } from '../auth/AuthUiContext'; +import { CopyFeedbackButton } from '../common/CopyFeedbackButton'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; +import { useCopyFeedback } from '../common/useCopyFeedback'; import { normalizeGameEntryUrl, normalizeGameId, @@ -271,6 +272,7 @@ export function GameDetailPage({ onBack={onBack} onPlay={onPlay} onOpenLineage={onOpenLineage} + isAuthenticated={Boolean(authUi?.user)} /> ); } @@ -282,6 +284,7 @@ function GameDetailContent({ recoveryActions, isMobileViewport, canReview, + isAuthenticated, onLogin, onBack, onPlay, @@ -293,6 +296,8 @@ function GameDetailContent({ recoveryActions: Record; isMobileViewport: boolean; canReview: boolean; + /** 是否已登录;未登录时「改造这个作品」按仓库既有门禁显示「登录后可改造」并触发登录弹窗。 */ + isAuthenticated: boolean; onLogin: () => void; onBack: () => void; onPlay: (gameId: string) => void; @@ -332,6 +337,17 @@ function GameDetailContent({ ); const lineage = game.lineage ?? null; const forkCount = game.forkCount ?? 0; + // 「改造这个作品」的获取方式:网页不能唤起客户端(未注册 deep link),所以给出可复制的 + // 作品 ID 与完整作品链接,让用户到客户端的「从平台作品开始创作」里粘贴。 + const [isForkPanelOpen, setIsForkPanelOpen] = useState(false); + const { copyState: forkIdCopyState, copyText: copyForkId } = useCopyFeedback(); + const { copyState: forkLinkCopyState, copyText: copyForkLink } = + useCopyFeedback(); + const workLink = + typeof window === 'undefined' + ? `/games/detail?id=${encodeURIComponent(game.id)}` + : `${window.location.origin}/games/detail?id=${encodeURIComponent(game.id)}`; + const canRemix = publiclyVisible && forkAuthorization !== 'forbidden'; const mobilePlaybackBlocked = isMobileViewport && !game.deviceSupport.mobile; const playbackBlocked = !version || mobilePlaybackBlocked; return ( @@ -409,6 +425,75 @@ function GameDetailContent({ 查看创作族谱 ) : null} + {canRemix ? ( + /* + 「改造这个作品」:服务端取件通道要求 Bearer + 已公开 + 授权非禁止。 + 未登录按仓库既有登录门禁处理——入口照常出现(显示「登录后可改造」), + 点击走登录弹窗,而不是静默消失。 + 网页不能唤起客户端(未注册 deep link),所以展开的是「复制 ID/链接 → 到客户端粘贴」。 + */ + <> + + {isAuthenticated && isForkPanelOpen ? ( + + + 打开陶泥儿客户端 → 首页「从平台作品开始创作」→ + 粘贴下面的作品 ID 或作品链接。 + + + 作品 ID:{game.id} + + + 作品链接:{workLink} + + + void copyForkId(game.id)} + /> + void copyForkLink(workLink)} + /> + + + 客户端会把成品包作为可玩参考与素材 + 放进新项目的 reference/{game.id}/{' '} + 目录;成品包里没有源码, + 不能直接再次发布 + ,请在客户端的新工程里改造后发布。 + + + ) : null} + + ) : null} ), }, diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index d583cf32b..d0051e4c0 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -45,13 +45,27 @@ vi.mock('../../services/gameDistributionClient', () => ({ getMyGameReview: getMyGameReviewMock, })); -// 作者视角详情页要按登录身份渲染评价区,这里固定一个可访问受保护数据的用户。 -vi.mock('../auth/AuthUiContext', () => ({ - useAuthUi: () => ({ - user: { id: 'author-1' }, +/** + * 详情页的登录态:默认一个可访问受保护数据的用户(作者视角评价区要用)。 + * 「未登录」用例会把它改成 null 再断言登录门禁行为;openLoginModal 用同一实例便于断言。 + */ +const authUiMock = vi.hoisted(() => ({ + value: { + user: { id: 'author-1' } as { id: string } | null, canAccessProtectedData: true, openLoginModal: vi.fn(), - }), + }, +})); + +vi.mock('../auth/AuthUiContext', () => ({ + useAuthUi: () => authUiMock.value, +})); + +/** 改造入口的复制走统一的剪贴板服务;这里只断言「复制了什么」。 */ +const copyTextToClipboardMock = vi.hoisted(() => vi.fn()); + +vi.mock('../../services/clipboard', () => ({ + copyTextToClipboard: copyTextToClipboardMock, })); // 只替换换签结果,测试不发起任何真实请求;返回空串即代表签名失败或字段为空。 @@ -132,6 +146,11 @@ afterEach(() => { beforeEach(() => { vi.clearAllMocks(); + authUiMock.value.user = { id: 'author-1' }; + authUiMock.value.canAccessProtectedData = true; + authUiMock.value.openLoginModal.mockReset(); + copyTextToClipboardMock.mockReset(); + copyTextToClipboardMock.mockResolvedValue(true); setViewportWidth(1024); listGamesMock.mockResolvedValue([]); getGameMock.mockResolvedValue(createGame()); @@ -252,7 +271,7 @@ describe('GameDetailPage', () => { expect(await screen.findByText('暂未发布可玩版本')).toBeTruthy(); }); - it('展示共创授权、代际与溯源信息,且不提供改造入口', async () => { + it('展示共创授权、代际与溯源信息,并给出改造入口', async () => { getGameMock.mockResolvedValueOnce( createGame({ forkAuthorization: 'nonCommercial', @@ -275,7 +294,10 @@ describe('GameDetailPage', () => { expect(screen.getByText('第 2 代作品 · 改编自《原初之光》')).toBeTruthy(); expect(screen.getByText('由 原作者 制作')).toBeTruthy(); expect(screen.getByText('已被改编 3 次')).toBeTruthy(); - expect(screen.queryByRole('button', { name: /改造/u })).toBeNull(); + // 已公开 + 授权开放 → 「改造这个作品」入口出现(M1 时它还不存在,这里钉住新契约)。 + expect( + screen.getByRole('button', { name: '改造这个作品' }), + ).toBeTruthy(); }); it('旧数据缺少授权与血缘字段时只按禁止共创渲染', async () => { @@ -1237,3 +1259,100 @@ describe('GameLineagePage', () => { expect(screen.queryByRole('button', { name: '查看创作族谱' })).toBeNull(); }); }); + +describe('GameDetailPage 改造入口', () => { + it('已公开且授权开放时出现入口,展开后给出可复制的作品 ID 与操作指引', async () => { + getGameMock.mockResolvedValueOnce( + createGame({ forkAuthorization: 'nonCommercial' }), + ); + render( + , + ); + + const entry = await screen.findByRole('button', { name: '改造这个作品' }); + // 未展开前不露出获取方式。 + expect(screen.queryByText(/打开陶泥儿客户端/u)).toBeNull(); + + fireEvent.click(entry); + expect( + screen.getByText( + '打开陶泥儿客户端 → 首页「从平台作品开始创作」→ 粘贴下面的作品 ID 或作品链接。', + ), + ).toBeTruthy(); + expect(screen.getByText('game-1')).toBeTruthy(); + expect(screen.getByText(/\/games\/detail\?id=game-1$/u)).toBeTruthy(); + // 诚实口径:可玩参考 + 素材、参考产物目录、不能直接再次发布。 + expect(screen.getByText(/reference\/game-1\//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(); + + fireEvent.click(screen.getByRole('button', { name: '复制作品链接' })); + await waitFor(() => + expect(copyTextToClipboardMock).toHaveBeenCalledWith( + expect.stringContaining('/games/detail?id=game-1'), + ), + ); + expect( + await screen.findByRole('button', { name: '作品链接已复制' }), + ).toBeTruthy(); + }); + + it('授权为禁止共创时不出现改造入口', async () => { + getGameMock.mockResolvedValueOnce( + createGame({ forkAuthorization: 'forbidden' }), + ); + render( + , + ); + + await screen.findByRole('heading', { name: '星轨防线' }); + expect(screen.queryByRole('button', { name: '改造这个作品' })).toBeNull(); + expect(screen.queryByRole('button', { name: '登录后可改造' })).toBeNull(); + }); + + it('未公开作品不出现改造入口', async () => { + getMyGameMock.mockResolvedValueOnce({ + ...createGame({ status: 'unpublished', forkAuthorization: 'full' }), + latestVersion: null, + versions: [], + } as GameDistributionMyGame); + render( + , + ); + + await screen.findByText('作品状态'); + expect(screen.queryByRole('button', { name: '改造这个作品' })).toBeNull(); + expect(screen.queryByRole('button', { name: '登录后可改造' })).toBeNull(); + }); + + it('未登录时入口显示「登录后可改造」,点击走仓库既有登录门禁而不是静默隐藏', async () => { + authUiMock.value.user = null; + authUiMock.value.canAccessProtectedData = false; + getGameMock.mockResolvedValueOnce( + createGame({ forkAuthorization: 'full' }), + ); + render( + , + ); + + const gate = await screen.findByRole('button', { name: '登录后可改造' }); + expect(screen.queryByText(/打开陶泥儿客户端/u)).toBeNull(); + + fireEvent.click(gate); + expect(authUiMock.value.openLoginModal).toHaveBeenCalledTimes(1); + // 未登录不得展开获取方式(也就不会给出取不到的说明)。 + expect(screen.queryByText(/打开陶泥儿客户端/u)).toBeNull(); + }); +}); diff --git a/src/components/game-distribution/gameDistribution.css b/src/components/game-distribution/gameDistribution.css index f11efce32..29d0b99a0 100644 --- a/src/components/game-distribution/gameDistribution.css +++ b/src/components/game-distribution/gameDistribution.css @@ -435,6 +435,74 @@ .game-detail-info-card .game-fork-lineage-link:hover { background: rgba(199, 101, 61, 0.12); } +/* + * 「改造这个作品」:入口 + 展开后的获取方式面板。都在共创信息卡内, + * 窄屏靠网格换行与 `overflow-wrap: anywhere` 收在侧栏里,不产生横向溢出。 + */ +.game-detail-info-card .game-fork-remix-link { + justify-self: start; + border: 1px solid var(--platform-accent-strong); + border-radius: 999px; + padding: 0.22rem 0.75rem; + background: rgba(199, 101, 61, 0.12); + color: var(--platform-accent-strong); + font: inherit; + font-size: 0.76rem; + font-weight: 700; + cursor: pointer; +} +.game-detail-info-card .game-fork-remix-link:hover { + background: rgba(199, 101, 61, 0.2); +} +.game-detail-info-card .game-fork-remix-panel { + display: grid; + justify-items: start; + gap: 0.35rem; + width: 100%; + max-width: 100%; + box-sizing: border-box; + border: 1px solid var(--platform-surface-border); + border-radius: 0.6rem; + padding: 0.55rem 0.65rem; + background: var(--platform-surface-muted, rgba(0, 0, 0, 0.02)); + font-size: 0.76rem; + line-height: 1.6; +} +.game-detail-info-card .game-fork-remix-steps { + color: var(--platform-text-base); + overflow-wrap: anywhere; +} +.game-detail-info-card .game-fork-remix-value { + min-width: 0; + color: var(--platform-text-soft); + overflow-wrap: anywhere; +} +.game-detail-info-card .game-fork-remix-value code { + color: var(--platform-text-strong); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.72rem; + overflow-wrap: anywhere; +} +.game-detail-info-card .game-fork-remix-actions { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} +.game-detail-info-card .game-fork-remix-copy { + border: 1px solid var(--platform-surface-border); + border-radius: 999px; + padding: 0.2rem 0.7rem; + background: var(--platform-surface, #fff); + color: var(--platform-text-base); + font: inherit; + font-size: 0.74rem; + font-weight: 600; + cursor: pointer; +} +.game-detail-info-card .game-fork-remix-note { + color: var(--platform-text-soft); + overflow-wrap: anywhere; +} .game-loading-panel { display: grid; min-height: 18rem;