新增作品详情页「改造这个作品」入口(复制作品 ID/链接 → 客户端粘贴)
Project CI / AI game creator shell Rust lane 1/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 2/2 (pull_request) Has been cancelled

- 共创信息卡内新增入口:仅当作品已公开且 forkAuthorization != forbidden 时出现;授权为禁止共创时保持现状不出现
- 交互:点击展开获取方式——可复制的作品 ID 与完整作品链接(/games/detail?id=<gameId>,AGC 解析器接受的形态)+ 操作指引「打开陶泥儿客户端 → 首页『从平台作品开始创作』→ 粘贴」
- 复制反馈复用平台既有 CopyFeedbackButton + useCopyFeedback(统一剪贴板实现与「已复制 / 复制失败」状态)
- 未登录:入口照常出现但显示「登录后可改造」,点击走仓库既有登录门禁(authUi.openLoginModal,与同页「登录后评价」同一做法),不静默隐藏、也不假装可唤起客户端
- 文案与 AGC 侧口径一致:可玩参考 + 素材、参考产物在 reference/<作品ID>/ 目录、成品包没有源码不能直接再次发布;不写「一键复刻工程」
- 不新增路由、不发 API 请求、不动服务端:网页端无法唤起客户端(未注册 deep link),只做「复制 → 粘贴」
- 测试:新增 4 例(公开+非禁止出现入口并可复制、forbidden 不出现、未公开不出现、未登录走登录门禁);并把 M1 遗留的「不提供改造入口」断言改写为新契约
- 样式:.game-fork-remix-* 落在共创信息卡内,窄屏靠网格与 overflow-wrap 收在容器里
This commit is contained in:
2026-10-05 04:22:07 +08:00
parent b995cc8dd4
commit 392cb3bb09
3 changed files with 280 additions and 8 deletions
@@ -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<string, GameDistributionRecoveryAction>;
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({
查看创作族谱
</button>
) : null}
{canRemix ? (
/*
「改造这个作品」:服务端取件通道要求 Bearer + 已公开 + 授权非禁止。
未登录按仓库既有登录门禁处理——入口照常出现(显示「登录后可改造」),
点击走登录弹窗,而不是静默消失。
网页不能唤起客户端(未注册 deep link),所以展开的是「复制 ID/链接 → 到客户端粘贴」。
*/
<>
<button
type="button"
className="game-fork-remix-link"
aria-expanded={
isAuthenticated ? isForkPanelOpen : undefined
}
aria-controls={
isAuthenticated ? 'game-fork-remix-panel' : undefined
}
onClick={() =>
isAuthenticated
? setIsForkPanelOpen((open) => !open)
: onLogin()
}
>
{isAuthenticated ? '改造这个作品' : '登录后可改造'}
</button>
{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">
客户端会把成品包作为<strong>可玩参考与素材</strong>
放进新项目的 <code>reference/{game.id}/</code>{' '}
目录;成品包里没有源码,
<strong>不能直接再次发布</strong>
,请在客户端的新工程里改造后发布。
</span>
</span>
) : null}
</>
) : null}
</span>
),
},
@@ -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(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
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(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
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(
<GameDetailPage
gameId="game-1"
ownerView
onBack={vi.fn()}
onPlay={vi.fn()}
/>,
);
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(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
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();
});
});
@@ -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;