新增作品详情页「改造这个作品」入口(复制作品 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
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user