This commit is contained in:
2026-05-02 17:56:42 +08:00
parent 2311edb2e6
commit acc55d0e13
40 changed files with 2582 additions and 931 deletions

View File

@@ -0,0 +1,65 @@
/* @vitest-environment jsdom */
import {
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import * as clipboardService from '../../services/clipboard';
import { PublishShareModal } from './PublishShareModal';
import {
buildPublishShareText,
type PublishShareModalPayload,
} from './publishShareModalModel';
vi.mock('../../services/clipboard', () => ({
copyTextToClipboard: vi.fn(),
}));
const payload: PublishShareModalPayload = {
title: '暖灯猫街',
publicWorkCode: 'PZ-00000001',
stage: 'puzzle-gallery-detail',
};
afterEach(() => {
vi.clearAllMocks();
});
describe('PublishShareModal', () => {
test('builds the publish share text with title, code and public url', () => {
const text = buildPublishShareText(payload);
expect(text).toContain('邀请你来玩《暖灯猫街》');
expect(text).toContain('作品号PZ-00000001');
expect(text).toContain('/gallery/puzzle/detail?work=PZ-00000001');
});
test('renders share text and channel icons, then copies from main button', async () => {
vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(true);
render(
<PublishShareModal open payload={payload} onClose={() => {}} />,
);
const dialog = screen.getByRole('dialog', { name: '分享给朋友' });
expect(within(dialog).getByText(//u)).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '分享' })).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '分享到微信' })).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '分享到QQ' })).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '分享到抖音' })).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: '分享' }));
expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith(
expect.stringContaining('作品号PZ-00000001'),
);
await waitFor(() => {
expect(within(dialog).getByRole('button', { name: '已复制' })).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,145 @@
import { Check, Copy, MessageCircle, Music2 } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { copyTextToClipboard } from '../../services/clipboard';
import {
buildPublishShareText,
type PublishShareModalPayload,
} from './publishShareModalModel';
import { UnifiedModal } from './UnifiedModal';
type PublishShareModalProps = {
open: boolean;
payload: PublishShareModalPayload | null;
onClose: () => void;
};
const SHARE_CHANNELS = [
{
id: 'wechat',
label: '微信',
icon: MessageCircle,
className: 'bg-emerald-500 text-white',
},
{
id: 'qq',
label: 'QQ',
icon: MessageCircle,
className: 'bg-sky-500 text-white',
},
{
id: 'douyin',
label: '抖音',
icon: Music2,
className: 'bg-slate-950 text-white',
},
] as const;
/**
* 发布完成后的分享弹窗。
* 目前各渠道先统一复制分享文本,后续如接入微信/QQ/抖音 SDK可以只替换这里的渠道点击逻辑。
*/
export function PublishShareModal({
open,
payload,
onClose,
}: PublishShareModalProps) {
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const resetTimerRef = useRef<number | null>(null);
const shareText = useMemo(
() => (payload ? buildPublishShareText(payload) : ''),
[payload],
);
useEffect(
() => () => {
if (resetTimerRef.current !== null) {
window.clearTimeout(resetTimerRef.current);
}
},
[],
);
useEffect(() => {
setCopyState('idle');
}, [payload?.publicWorkCode]);
const copyShareText = () => {
if (!shareText) {
return;
}
void copyTextToClipboard(shareText).then((copied) => {
setCopyState(copied ? 'copied' : 'failed');
if (resetTimerRef.current !== null) {
window.clearTimeout(resetTimerRef.current);
}
resetTimerRef.current = window.setTimeout(() => {
resetTimerRef.current = null;
setCopyState('idle');
}, 1400);
});
};
return (
<UnifiedModal
open={open && Boolean(payload)}
title="分享给朋友"
onClose={onClose}
size="sm"
panelClassName="platform-remap-surface"
bodyClassName="space-y-4 px-4 py-4 sm:px-5 sm:py-5"
footerClassName="justify-center border-t-0 px-4 pb-5 pt-0 sm:px-5"
footer={
<div className="grid w-full grid-cols-3 gap-3">
{SHARE_CHANNELS.map((channel) => {
const Icon = channel.icon;
return (
<button
key={channel.id}
type="button"
onClick={copyShareText}
className="flex min-w-0 flex-col items-center gap-2 rounded-[1rem] px-2 py-2.5 text-xs font-bold text-[var(--platform-text-base)] transition hover:bg-white/62"
aria-label={`分享到${channel.label}`}
title={channel.label}
>
<span
className={`inline-flex h-11 w-11 items-center justify-center rounded-full shadow-sm ${channel.className}`}
>
<Icon className="h-5 w-5" />
</span>
<span>{channel.label}</span>
</button>
);
})}
</div>
}
>
<div className="rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-4">
<div className="whitespace-pre-wrap break-words text-sm leading-6 text-[var(--platform-text-strong)]">
{shareText}
</div>
</div>
<button
type="button"
onClick={copyShareText}
disabled={!shareText}
className="platform-button platform-button--primary w-full justify-center gap-2 disabled:cursor-not-allowed disabled:opacity-55"
>
{copyState === 'copied' ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
{copyState === 'copied'
? '已复制'
: copyState === 'failed'
? '复制失败'
: '分享'}
</button>
</UnifiedModal>
);
}

View File

@@ -0,0 +1,30 @@
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
import type { SelectionStage } from '../platform-entry/platformEntryTypes';
export type PublishShareModalPayload = {
title: string;
publicWorkCode: string;
stage: SelectionStage;
};
function buildShareUrl(payload: PublishShareModalPayload) {
const sharePath = buildPublicWorkStagePath(
payload.stage,
payload.publicWorkCode,
);
return typeof window === 'undefined'
? sharePath
: new URL(sharePath, window.location.origin).href;
}
export function buildPublishShareText(payload: PublishShareModalPayload) {
const publicWorkCode = payload.publicWorkCode.trim();
const title = payload.title.trim() || '我的作品';
return `邀请你来玩《${title}\n作品号${publicWorkCode}\n${buildShareUrl({
...payload,
publicWorkCode,
title,
})}`;
}

View File

@@ -319,6 +319,55 @@ test('creation hub shows delete action for persisted rpg drafts', () => {
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
});
test('creation hub published work delete action is available beside share without opening card', async () => {
const user = userEvent.setup();
const onDeletePuzzle = vi.fn();
const onOpenPuzzleDetail = vi.fn();
render(
<CustomWorldCreationHub
items={[]}
puzzleItems={[
{
workId: 'puzzle:work-delete',
profileId: 'puzzle-profile-delete',
ownerUserId: 'user-1',
authorDisplayName: '拼图作者',
levelName: '待删拼图',
summary: '已发布作品也可以从创作页删除。',
themeTags: ['灯塔'],
coverImageSrc: null,
publicationStatus: 'published',
updatedAt: new Date('2026-05-02T12:00:00.000Z').toISOString(),
publishedAt: new Date('2026-05-02T12:10:00.000Z').toISOString(),
playCount: 8,
remixCount: 2,
likeCount: 1,
publishReady: true,
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onOpenPuzzleDetail={onOpenPuzzleDetail}
onDeletePuzzle={onDeletePuzzle}
/>,
);
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
expect(screen.getByRole('button', { name: '分享' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '删除' }));
expect(onDeletePuzzle).toHaveBeenCalledWith(
expect.objectContaining({ profileId: 'puzzle-profile-delete' }),
);
expect(onOpenPuzzleDetail).not.toHaveBeenCalled();
});
test('creation hub opens persisted rpg drafts by card click', async () => {
const user = userEvent.setup();
const openedItems: CustomWorldWorkSummary[] = [];

View File

@@ -268,68 +268,70 @@ export function CustomWorldWorkCard({
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_100%_100%,rgba(255,255,255,0.18),transparent_34%),linear-gradient(180deg,rgba(255,255,255,0.08),rgba(0,0,0,0.08))]" />
<div className="pointer-events-none relative z-20 flex min-h-[8rem] flex-col sm:min-h-[10.5rem] xl:min-h-[9.75rem]">
{!isPublished && onDelete ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
disabled={deleteBusy}
aria-label={deleteBusy ? '删除中' : '删除'}
title={deleteBusy ? '删除中' : '删除作品'}
className="pointer-events-auto absolute right-0 top-0 z-30 grid h-7 w-7 place-items-center text-[var(--platform-text-soft)] transition hover:text-[var(--platform-button-danger-text)] disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:w-8"
>
{deleteBusy ? (
<span className="text-xs leading-none"></span>
) : (
<Trash2 aria-hidden="true" className="h-3.5 w-3.5" />
)}
</button>
) : null}
{isPublished ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
copyShareText();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
disabled={!item.canShare || !item.sharePath}
title={
!item.canShare || !item.sharePath
? '暂不可分享'
: shareState === 'copied'
? '已复制'
: shareState === 'failed'
? '复制失败'
: '分享作品'
}
aria-label={
!item.canShare || !item.sharePath
? '暂不可分享'
: shareState === 'copied'
? '分享内容已复制'
: shareState === 'failed'
? '分享内容复制失败'
: '分享'
}
className="pointer-events-auto absolute right-0 top-0 z-30 inline-flex h-7 min-w-7 items-center justify-center gap-1 whitespace-nowrap px-1.5 text-[var(--platform-text-soft)] transition hover:text-[var(--platform-cool-text)] disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:min-w-8"
>
{shareState === 'idle' ? (
<Share2 aria-hidden="true" className="h-3.5 w-3.5" />
) : (
<span className="text-[10px] font-semibold leading-none">
{shareState === 'copied' ? '已复制' : '复制失败'}
</span>
)}
</button>
) : null}
<div className="pointer-events-auto absolute right-0 top-0 z-30 flex items-center gap-1">
{onDelete ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
disabled={deleteBusy}
aria-label={deleteBusy ? '删除中' : '删除'}
title={deleteBusy ? '删除中' : '删除作品'}
className="grid h-7 w-7 place-items-center text-[var(--platform-text-soft)] transition hover:text-[var(--platform-button-danger-text)] disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:w-8"
>
{deleteBusy ? (
<span className="text-xs leading-none"></span>
) : (
<Trash2 aria-hidden="true" className="h-3.5 w-3.5" />
)}
</button>
) : null}
{isPublished ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
copyShareText();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
disabled={!item.canShare || !item.sharePath}
title={
!item.canShare || !item.sharePath
? '暂不可分享'
: shareState === 'copied'
? '已复制'
: shareState === 'failed'
? '复制失败'
: '分享作品'
}
aria-label={
!item.canShare || !item.sharePath
? '暂不可分享'
: shareState === 'copied'
? '分享内容已复制'
: shareState === 'failed'
? '分享内容复制失败'
: '分享'
}
className="inline-flex h-7 min-w-7 items-center justify-center gap-1 whitespace-nowrap px-1.5 text-[var(--platform-text-soft)] transition hover:text-[var(--platform-cool-text)] disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:min-w-8"
>
{shareState === 'idle' ? (
<Share2 aria-hidden="true" className="h-3.5 w-3.5" />
) : (
<span className="text-[10px] font-semibold leading-none">
{shareState === 'copied' ? '已复制' : '复制失败'}
</span>
)}
</button>
) : null}
</div>
<div className="flex items-start justify-between gap-2 pr-12 sm:gap-3 sm:pr-14">
<div className="flex max-h-[3rem] min-w-0 flex-wrap gap-1 overflow-hidden sm:max-h-none sm:gap-2">

View File

@@ -171,6 +171,9 @@ import { getRpgProfilePlayStats } from '../../services/rpg-entry/rpgProfileClien
import { requestRpgRuntimeJson } from '../../services/rpg-runtime/rpgRuntimeRequest';
import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
import { PublishShareModal } from '../common/PublishShareModal';
import type { PublishShareModalPayload } from '../common/publishShareModalModel';
import { UnifiedModal } from '../common/UnifiedModal';
import {
isBigFishGalleryEntry,
isMatch3DGalleryEntry,
@@ -227,6 +230,13 @@ type PuzzleSaveArchiveState = {
currentLevelId?: unknown;
};
type DeleteCreationWorkConfirmation = {
id: string;
title: string;
detail: string;
run: () => void;
};
async function resumePuzzleProfileSaveArchiveRaw(worldKey: string) {
return requestRpgRuntimeJson<
ProfileSaveArchiveResumeResponse<PuzzleSaveArchiveState>
@@ -345,7 +355,10 @@ function mapPublicWorkDetailToMatch3DWork(
workId: entry.workId,
profileId: entry.profileId,
ownerUserId: entry.ownerUserId,
sourceSessionId: null,
sourceSessionId:
'sourceSessionId' in entry && typeof entry.sourceSessionId === 'string'
? entry.sourceSessionId
: null,
gameName: entry.worldName,
themeText: entry.themeTags[0] ?? '经典消除',
summary: entry.summaryText,
@@ -403,7 +416,10 @@ function mapPublicWorkDetailToPuzzleWork(
workId: entry.workId,
profileId: entry.profileId,
ownerUserId: entry.ownerUserId,
sourceSessionId: null,
sourceSessionId:
'sourceSessionId' in entry && typeof entry.sourceSessionId === 'string'
? entry.sourceSessionId
: null,
authorDisplayName: entry.authorDisplayName,
levelName: entry.worldName,
summary: entry.summaryText,
@@ -1000,10 +1016,14 @@ export function PlatformEntryFlowShellImpl({
const [deletingCreationWorkId, setDeletingCreationWorkId] = useState<
string | null
>(null);
const [pendingDeleteCreationWork, setPendingDeleteCreationWork] =
useState<DeleteCreationWorkConfirmation | null>(null);
const [
claimingPuzzlePointIncentiveProfileId,
setClaimingPuzzlePointIncentiveProfileId,
] = useState<string | null>(null);
const [publishSharePayload, setPublishSharePayload] =
useState<PublishShareModalPayload | null>(null);
const isBigFishCreationVisible = isPlatformCreationTypeVisible('big-fish');
const [profilePlayStats, setProfilePlayStats] =
useState<ProfilePlayStatsResponse | null>(null);
@@ -1279,6 +1299,50 @@ export function PlatformEntryFlowShellImpl({
() => agentResultPreview?.qualityFindings ?? [],
[agentResultPreview],
);
const openPublishShareModal = useCallback(
(payload: PublishShareModalPayload) => {
const publicWorkCode = payload.publicWorkCode.trim();
if (!publicWorkCode) {
return;
}
setPublishSharePayload({
...payload,
publicWorkCode,
title: payload.title.trim() || '我的作品',
});
},
[],
);
const openRpgPublishShareModal = useCallback(
async (profile: CustomWorldProfile | null | undefined) => {
const profileId = profile?.id?.trim();
if (!profileId) {
return;
}
const profileName = profile?.name?.trim() || '我的作品';
const galleryEntries = await platformBootstrap
.refreshPublishedGallery()
.catch(() => [] as CustomWorldGalleryCard[]);
const galleryEntry = galleryEntries.find(
(entry) => entry.profileId === profileId,
);
const publicWorkCode = galleryEntry?.publicWorkCode?.trim();
if (!publicWorkCode) {
return;
}
openPublishShareModal({
title: galleryEntry?.worldName || profileName,
publicWorkCode,
stage: 'work-detail',
});
},
[openPublishShareModal, platformBootstrap],
);
const agentResultPreviewSourceLabel = useMemo(() => {
if (!agentResultPreview?.source) {
return null;
@@ -1347,6 +1411,13 @@ export function PlatformEntryFlowShellImpl({
const resultViewError =
autosaveCoordinator.customWorldAutoSaveError ??
sessionController.customWorldError;
const isSelectedPublicWorkOwned = Boolean(
authUi?.user?.id &&
selectedPublicWorkDetail?.ownerUserId === authUi.user.id,
);
const selectedPublicWorkActionMode = isSelectedPublicWorkOwned
? 'edit'
: 'remix';
useEffect(() => {
if (
@@ -1374,6 +1445,37 @@ export function PlatformEntryFlowShellImpl({
[authUi],
);
const requestDeleteCreationWork = useCallback(
(confirmation: DeleteCreationWorkConfirmation) => {
if (deletingCreationWorkId) {
return;
}
runProtectedAction(() => {
setPendingDeleteCreationWork(confirmation);
});
},
[deletingCreationWorkId, runProtectedAction],
);
const closeDeleteCreationWorkConfirmation = useCallback(() => {
if (deletingCreationWorkId) {
return;
}
setPendingDeleteCreationWork(null);
}, [deletingCreationWorkId]);
const confirmDeleteCreationWork = useCallback(() => {
const confirmation = pendingDeleteCreationWork;
if (!confirmation || deletingCreationWorkId) {
return;
}
setPendingDeleteCreationWork(null);
confirmation.run();
}, [deletingCreationWorkId, pendingDeleteCreationWork]);
const prepareCreationLaunch = useCallback(() => {
if (sessionController.isCreatingAgentSession) {
return false;
@@ -1433,6 +1535,11 @@ export function PlatformEntryFlowShellImpl({
if (payload.action === 'big_fish_publish_game') {
void refreshBigFishShelf();
void refreshBigFishGallery();
openPublishShareModal({
title: response.session.draft?.title ?? '大鱼吃小鱼',
publicWorkCode: buildBigFishPublicWorkCode(response.session.sessionId),
stage: 'big-fish-runtime',
});
}
if (payload.action !== 'big_fish_compile_draft') {
return;
@@ -1610,6 +1717,11 @@ export function PlatformEntryFlowShellImpl({
buildPuzzlePublicWorkCode(galleryDetail.item.profileId),
),
);
openPublishShareModal({
title: galleryDetail.item.workTitle || galleryDetail.item.levelName,
publicWorkCode: buildPuzzlePublicWorkCode(galleryDetail.item.profileId),
stage: 'puzzle-gallery-detail',
});
}
},
beforeExecuteAction: ({ payload }) => {
@@ -1805,6 +1917,7 @@ export function PlatformEntryFlowShellImpl({
setPuzzleError(null);
setDeletingCreationWorkId(null);
setClaimingPuzzlePointIncentiveProfileId(null);
setPublishSharePayload(null);
setProfilePlayStats(null);
setProfilePlayStatsError(null);
setIsProfilePlayStatsOpen(false);
@@ -2623,6 +2736,46 @@ export function PlatformEntryFlowShellImpl({
],
);
const remodelCurrentPuzzleRuntimeWork = useCallback((profileId: string) => {
const targetProfileId = profileId.trim();
if (!targetProfileId || isPublicWorkDetailBusy || isPuzzleBusy) {
return;
}
runProtectedAction(() => {
setIsPublicWorkDetailBusy(true);
setIsPuzzleBusy(true);
setPuzzleError(null);
setPublicWorkDetailError(null);
void remixPuzzleGalleryWork(targetProfileId)
.then((response) => {
puzzleFlow.setSession(response.session);
setPuzzleOperation(null);
setPuzzleRun(null);
enterCreateTab();
setSelectionStage('puzzle-result');
})
.catch((error) => {
setPuzzleError(resolvePuzzleErrorMessage(error, '改造拼图作品失败。'));
})
.finally(() => {
setIsPublicWorkDetailBusy(false);
setIsPuzzleBusy(false);
});
});
}, [
enterCreateTab,
isPublicWorkDetailBusy,
isPuzzleBusy,
puzzleFlow,
resolvePuzzleErrorMessage,
runProtectedAction,
setIsPuzzleBusy,
setPuzzleError,
setSelectionStage,
]);
const leaveAgentWorkspace = useCallback(() => {
enterCreateTab();
sessionController.resetSessionViewState();
@@ -2696,34 +2849,32 @@ export function PlatformEntryFlowShellImpl({
return;
}
runProtectedAction(() => {
const confirmed = window.confirm(
`确认删除作品《${entry.worldName}》吗?删除后会从你的作品列表和公开广场中移除。`,
);
if (!confirmed) {
return;
}
requestDeleteCreationWork({
id: entry.profileId,
title: entry.worldName,
detail: '删除后会从你的作品列表和公开广场中移除。',
run: () => {
setDeletingCreationWorkId(entry.profileId);
platformBootstrap.setPlatformError(null);
setDeletingCreationWorkId(entry.profileId);
platformBootstrap.setPlatformError(null);
void deleteRpgEntryWorldProfile(entry.profileId)
.then(async (entries) => {
platformBootstrap.setSavedCustomWorldEntries(entries);
await platformBootstrap.refreshCustomWorldWorks().catch(() => []);
await platformBootstrap.refreshPublishedGallery().catch(() => []);
})
.catch((error) => {
platformBootstrap.setPlatformError(
resolveRpgCreationErrorMessage(error, '删除自定义世界失败。'),
);
})
.finally(() => {
setDeletingCreationWorkId(null);
});
void deleteRpgEntryWorldProfile(entry.profileId)
.then(async (entries) => {
platformBootstrap.setSavedCustomWorldEntries(entries);
await platformBootstrap.refreshCustomWorldWorks().catch(() => []);
await platformBootstrap.refreshPublishedGallery().catch(() => []);
})
.catch((error) => {
platformBootstrap.setPlatformError(
resolveRpgCreationErrorMessage(error, '删除自定义世界失败。'),
);
})
.finally(() => {
setDeletingCreationWorkId(null);
});
},
});
},
[deletingCreationWorkId, platformBootstrap, runProtectedAction],
[deletingCreationWorkId, platformBootstrap, requestDeleteCreationWork],
);
const handleDeletePublishedWork = useCallback(
@@ -2732,47 +2883,51 @@ export function PlatformEntryFlowShellImpl({
return;
}
runProtectedAction(() => {
const confirmed = window.confirm(
`确认删除作品《${work.title}》吗?删除后会从你的作品列表和公开广场中移除。`,
);
if (!confirmed) {
return;
}
setDeletingCreationWorkId(work.workId);
platformBootstrap.setPlatformError(null);
requestDeleteCreationWork({
id: work.workId,
title: work.title,
detail:
work.status === 'published'
? '删除后会从你的作品列表和公开广场中移除。'
: '删除后会从你的作品列表中移除。',
run: () => {
setDeletingCreationWorkId(work.workId);
platformBootstrap.setPlatformError(null);
const deleteTask =
work.sourceType === 'published_profile' && work.profileId
? deleteRpgEntryWorldProfile(work.profileId).then(
async (entries) => {
platformBootstrap.setSavedCustomWorldEntries(entries);
await platformBootstrap
.refreshCustomWorldWorks()
.catch(() => []);
},
)
: work.sourceType === 'agent_session' && work.sessionId
? deleteRpgCreationAgentSession(work.sessionId).then((items) => {
platformBootstrap.setCustomWorldWorkEntries(items);
})
: Promise.reject(new Error('当前 RPG 作品缺少可删除 ID。'));
const deleteTask =
work.sourceType === 'published_profile' && work.profileId
? deleteRpgEntryWorldProfile(work.profileId).then(
async (entries) => {
platformBootstrap.setSavedCustomWorldEntries(entries);
await platformBootstrap
.refreshCustomWorldWorks()
.catch(() => []);
},
)
: work.sourceType === 'agent_session' && work.sessionId
? deleteRpgCreationAgentSession(work.sessionId).then(
(items) => {
platformBootstrap.setCustomWorldWorkEntries(items);
},
)
: Promise.reject(new Error('当前 RPG 作品缺少可删除 ID。'));
void deleteTask
.then(async () => {
await platformBootstrap.refreshPublishedGallery().catch(() => []);
})
.catch((error) => {
platformBootstrap.setPlatformError(
resolveRpgCreationErrorMessage(error, '删除自定义世界失败。'),
);
})
.finally(() => {
setDeletingCreationWorkId(null);
});
void deleteTask
.then(async () => {
await platformBootstrap.refreshPublishedGallery().catch(() => []);
})
.catch((error) => {
platformBootstrap.setPlatformError(
resolveRpgCreationErrorMessage(error, '删除自定义世界失败。'),
);
})
.finally(() => {
setDeletingCreationWorkId(null);
});
},
});
},
[deletingCreationWorkId, platformBootstrap, runProtectedAction],
[deletingCreationWorkId, platformBootstrap, requestDeleteCreationWork],
);
const handleDeleteBigFishWork = useCallback(
@@ -2781,37 +2936,39 @@ export function PlatformEntryFlowShellImpl({
return;
}
runProtectedAction(() => {
const confirmed = window.confirm(
`确认删除作品《${work.title}》吗?删除后会从你的作品列表中移除。`,
);
if (!confirmed) {
return;
}
requestDeleteCreationWork({
id: work.workId,
title: work.title,
detail:
work.status === 'published'
? '删除后会从你的作品列表和公开广场中移除。'
: '删除后会从你的作品列表中移除。',
run: () => {
setDeletingCreationWorkId(work.workId);
setBigFishError(null);
setDeletingCreationWorkId(work.workId);
setBigFishError(null);
void deleteBigFishWork(work.sourceSessionId)
.then(async (response) => {
setBigFishWorks(response.items);
await refreshBigFishGallery().catch(() => []);
})
.catch((error) => {
setBigFishError(
resolveBigFishErrorMessage(error, '删除大鱼吃小鱼作品失败。'),
);
})
.finally(() => {
setDeletingCreationWorkId(null);
});
void deleteBigFishWork(work.sourceSessionId)
.then(async (response) => {
setBigFishWorks(response.items);
await refreshBigFishGallery().catch(() => []);
})
.catch((error) => {
setBigFishError(
resolveBigFishErrorMessage(error, '删除大鱼吃小鱼作品失败。'),
);
})
.finally(() => {
setDeletingCreationWorkId(null);
});
},
});
},
[
deletingCreationWorkId,
refreshBigFishGallery,
requestDeleteCreationWork,
resolveBigFishErrorMessage,
runProtectedAction,
setBigFishError,
],
);
@@ -2821,40 +2978,42 @@ export function PlatformEntryFlowShellImpl({
return;
}
runProtectedAction(() => {
const displayName =
work.workTitle?.trim() || work.levelName.trim() || '未命名拼图';
const confirmed = window.confirm(
`确认删除作品《${displayName}》吗?删除后会从你的作品列表和公开广场中移除。`,
);
if (!confirmed) {
return;
}
const displayName =
work.workTitle?.trim() || work.levelName.trim() || '未命名拼图';
requestDeleteCreationWork({
id: work.workId,
title: displayName,
detail:
work.publicationStatus === 'published'
? '删除后会从你的作品列表和公开广场中移除。'
: '删除后会从你的作品列表中移除。',
run: () => {
setDeletingCreationWorkId(work.workId);
setPuzzleFormDraftPayload(null);
setPuzzleError(null);
setDeletingCreationWorkId(work.workId);
setPuzzleFormDraftPayload(null);
setPuzzleError(null);
void deletePuzzleWork(work.profileId)
.then((response) => {
setPuzzleWorks(response.items);
void refreshPuzzleGallery();
})
.catch((error) => {
setPuzzleError(
resolvePuzzleErrorMessage(error, '删除拼图作品失败。'),
);
})
.finally(() => {
setDeletingCreationWorkId(null);
});
void deletePuzzleWork(work.profileId)
.then((response) => {
setPuzzleWorks(response.items);
void refreshPuzzleGallery();
})
.catch((error) => {
setPuzzleError(
resolvePuzzleErrorMessage(error, '删除拼图作品失败。'),
);
})
.finally(() => {
setDeletingCreationWorkId(null);
});
},
});
},
[
deletingCreationWorkId,
refreshPuzzleGallery,
requestDeleteCreationWork,
resolvePuzzleErrorMessage,
runProtectedAction,
setPuzzleError,
],
);
@@ -2864,37 +3023,38 @@ export function PlatformEntryFlowShellImpl({
return;
}
runProtectedAction(() => {
const confirmed = window.confirm(
`确认删除作品《${work.gameName}》吗?删除后会从你的作品列表中移除。`,
);
if (!confirmed) {
return;
}
requestDeleteCreationWork({
id: work.workId,
title: work.gameName,
detail:
work.publicationStatus === 'published'
? '删除后会从你的作品列表和公开广场中移除。'
: '删除后会从你的作品列表中移除。',
run: () => {
setDeletingCreationWorkId(work.workId);
setMatch3DError(null);
setDeletingCreationWorkId(work.workId);
setMatch3DError(null);
void deleteMatch3DWork(work.profileId)
.then((response) => {
setMatch3DWorks(response.items);
void refreshMatch3DGallery();
})
.catch((error) => {
setMatch3DError(
resolveMatch3DErrorMessage(error, '删除抓大鹅作品失败。'),
);
})
.finally(() => {
setDeletingCreationWorkId(null);
});
void deleteMatch3DWork(work.profileId)
.then((response) => {
setMatch3DWorks(response.items);
void refreshMatch3DGallery();
})
.catch((error) => {
setMatch3DError(
resolveMatch3DErrorMessage(error, '删除抓大鹅作品失败。'),
);
})
.finally(() => {
setDeletingCreationWorkId(null);
});
},
});
},
[
deletingCreationWorkId,
refreshMatch3DGallery,
requestDeleteCreationWork,
resolveMatch3DErrorMessage,
runProtectedAction,
setMatch3DError,
],
);
@@ -3326,12 +3486,15 @@ export function PlatformEntryFlowShellImpl({
);
const openMatch3DDraft = useCallback(
async (item: Match3DWorkSummary) => {
async (
item: Match3DWorkSummary,
options: { forceDraft?: boolean } = {},
) => {
setMatch3DRun(null);
setMatch3DError(null);
setMatch3DProfile(null);
if (item.publicationStatus === 'published') {
if (item.publicationStatus === 'published' && !options.forceDraft) {
openPublicWorkDetail(mapMatch3DWorkToPublicWorkDetail(item));
return;
}
@@ -3368,6 +3531,19 @@ export function PlatformEntryFlowShellImpl({
],
);
const openBigFishDraft = useCallback(
async (item: BigFishWorkSummary) => {
setBigFishRun(null);
const restoredSession = await bigFishFlow.restoreDraft(
item.sourceSessionId,
);
if (!restoredSession) {
await refreshBigFishShelf().catch(() => undefined);
}
},
[bigFishFlow, refreshBigFishShelf],
);
const startBigFishRunFromWork = useCallback(
(
item: BigFishWorkSummary,
@@ -3580,12 +3756,94 @@ export function PlatformEntryFlowShellImpl({
],
);
const editOwnedPublicWork = useCallback(
(entry: PlatformPublicGalleryCard) => {
if (isPublicWorkDetailBusy) {
return;
}
runProtectedAction(() => {
setPublicWorkDetailError(null);
// 中文注释:自有公开作品必须恢复原草稿,不能复用 remix 复制链路。
if (isBigFishGalleryEntry(entry)) {
const work = mapPublicWorkDetailToBigFishWork(entry);
if (!work?.sourceSessionId?.trim()) {
setPublicWorkDetailError(
'这份大鱼吃小鱼作品缺少原草稿会话,暂时无法编辑。',
);
return;
}
void openBigFishDraft(work);
return;
}
if (isPuzzleGalleryEntry(entry)) {
const work =
selectedPuzzleDetail?.profileId === entry.profileId
? selectedPuzzleDetail
: mapPublicWorkDetailToPuzzleWork(entry);
if (!work?.sourceSessionId?.trim()) {
setPublicWorkDetailError(
'这份拼图作品缺少原草稿会话,暂时无法编辑。',
);
return;
}
void openPuzzleDraft(work);
return;
}
if (isMatch3DGalleryEntry(entry)) {
const work = mapPublicWorkDetailToMatch3DWork(entry);
if (!work?.sourceSessionId?.trim()) {
setPublicWorkDetailError(
'这份抓大鹅作品缺少原草稿会话,暂时无法编辑。',
);
return;
}
void openMatch3DDraft(work, { forceDraft: true });
return;
}
const editEntry =
selectedDetailEntry?.profileId === entry.profileId
? selectedDetailEntry
: null;
if (!editEntry) {
setPublicWorkDetailError('作品详情尚未读取完成。');
return;
}
void detailNavigation.openSavedCustomWorldEditor(editEntry);
});
},
[
detailNavigation,
isPublicWorkDetailBusy,
openBigFishDraft,
openMatch3DDraft,
openPuzzleDraft,
runProtectedAction,
selectedDetailEntry,
selectedPuzzleDetail,
],
);
const remixSelectedPublicWork = useCallback(() => {
if (!selectedPublicWorkDetail) {
return;
}
if (isSelectedPublicWorkOwned) {
editOwnedPublicWork(selectedPublicWorkDetail);
return;
}
remixPublicWork(selectedPublicWorkDetail);
}, [remixPublicWork, selectedPublicWorkDetail]);
}, [
editOwnedPublicWork,
isSelectedPublicWorkOwned,
remixPublicWork,
selectedPublicWorkDetail,
]);
const handlePublicCodeSearch = useCallback(
async (keyword: string) => {
@@ -3906,19 +4164,6 @@ export function PlatformEntryFlowShellImpl({
void handlePublicCodeSearch(publicWorkCode);
}, [handlePublicCodeSearch, initialPublicWorkCode]);
const openBigFishDraft = useCallback(
async (item: BigFishWorkSummary) => {
setBigFishRun(null);
const restoredSession = await bigFishFlow.restoreDraft(
item.sourceSessionId,
);
if (!restoredSession) {
await refreshBigFishShelf().catch(() => undefined);
}
},
[bigFishFlow, refreshBigFishShelf],
);
useEffect(() => {
if (selectionStage === 'platform') {
if (isBigFishCreationVisible) {
@@ -4209,6 +4454,7 @@ export function PlatformEntryFlowShellImpl({
isMatch3DBusy
}
error={publicWorkDetailError}
actionMode={selectedPublicWorkActionMode}
visibleCoverCount={resolveVisiblePuzzleDetailCoverCount(
selectedPublicWorkDetail,
puzzleRun,
@@ -4250,6 +4496,9 @@ export function PlatformEntryFlowShellImpl({
}
isBusy={detailNavigation.isMutatingDetail}
error={detailNavigation.detailError}
actionMode={
detailNavigation.isSelectedWorldOwned ? 'edit' : 'remix'
}
onBack={() => {
detailNavigation.setDetailError(null);
clearSelectedPublicWorkAuthor();
@@ -4262,9 +4511,13 @@ export function PlatformEntryFlowShellImpl({
}}
onStart={handleStartSelectedWorld}
onRemix={() => {
remixPublicWork(
mapRpgGalleryCardToPublicWorkDetail(selectedDetailEntry),
);
const publicWorkEntry =
mapRpgGalleryCardToPublicWorkDetail(selectedDetailEntry);
if (detailNavigation.isSelectedWorldOwned) {
editOwnedPublicWork(publicWorkEntry);
return;
}
remixPublicWork(publicWorkEntry);
}}
/>
) : (
@@ -4574,6 +4827,13 @@ export function PlatformEntryFlowShellImpl({
openPublicWorkDetail(
mapMatch3DWorkToPublicWorkDetail(profile),
);
openPublishShareModal({
title: profile.gameName,
publicWorkCode: buildMatch3DPublicWorkCode(
profile.profileId,
),
stage: 'work-detail',
});
}}
onStartTestRun={(profile) => {
setMatch3DProfile(profile);
@@ -4840,6 +5100,11 @@ export function PlatformEntryFlowShellImpl({
onBack={() => {
setSelectionStage(puzzleRuntimeReturnStage);
}}
onRemodelWork={
selectedPuzzleDetail?.publicationStatus === 'published'
? remodelCurrentPuzzleRuntimeWork
: undefined
}
onSwapPieces={(payload) => {
void swapPuzzlePiecesInRun(payload);
}}
@@ -4980,7 +5245,9 @@ export function PlatformEntryFlowShellImpl({
sessionController.agentSession?.stage !== 'published'
? async () => {
try {
await enterWorldCoordinator.publishCurrentResult();
const publishedProfile =
await enterWorldCoordinator.publishCurrentResult();
void openRpgPublishShareModal(publishedProfile);
} catch (error) {
sessionController.setCustomWorldError(
resolveRpgCreationErrorMessage(
@@ -5144,6 +5411,48 @@ export function PlatformEntryFlowShellImpl({
});
}}
/>
<PublishShareModal
open={Boolean(publishSharePayload)}
payload={publishSharePayload}
onClose={() => setPublishSharePayload(null)}
/>
<UnifiedModal
open={Boolean(pendingDeleteCreationWork)}
title="删除作品"
description={
pendingDeleteCreationWork
? `确认删除《${pendingDeleteCreationWork.title}》吗?`
: undefined
}
onClose={closeDeleteCreationWorkConfirmation}
closeDisabled={Boolean(deletingCreationWorkId)}
closeOnBackdrop={!deletingCreationWorkId}
size="sm"
footer={
<>
<button
type="button"
onClick={closeDeleteCreationWorkConfirmation}
disabled={Boolean(deletingCreationWorkId)}
className="platform-button platform-button--ghost min-h-0 rounded-full px-4 py-2 text-sm"
>
</button>
<button
type="button"
onClick={confirmDeleteCreationWork}
disabled={Boolean(deletingCreationWorkId)}
className="platform-button platform-button--danger min-h-0 rounded-full px-4 py-2 text-sm disabled:cursor-not-allowed disabled:opacity-55"
>
{deletingCreationWorkId ? '删除中' : '确认删除'}
</button>
</>
}
>
<div className="text-sm leading-6 text-[var(--platform-text-base)]">
{pendingDeleteCreationWork?.detail}
</div>
</UnifiedModal>
<AnimatePresence>
{(searchedPublicUser || publicSearchError) && (
<motion.div

View File

@@ -122,6 +122,24 @@ test('PlatformWorkDetailView calls like handler', () => {
expect(onLike).toHaveBeenCalledTimes(1);
});
test('PlatformWorkDetailView switches remix action label for owned work edit', () => {
render(
<PlatformWorkDetailView
entry={createPuzzleEntry()}
actionMode="edit"
isBusy={false}
error={null}
onBack={vi.fn()}
onLike={vi.fn()}
onStart={vi.fn()}
onRemix={vi.fn()}
/>,
);
expect(screen.getByRole('button', { name: '作品编辑' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '作品改造' })).toBeNull();
});
test('PlatformWorkDetailView cycles puzzle level cover slides', () => {
vi.useFakeTimers();
const { container } = render(

View File

@@ -8,6 +8,7 @@ import {
Gamepad2,
GitFork,
Heart,
PencilLine,
Play,
Share2,
} from 'lucide-react';
@@ -38,6 +39,7 @@ export interface PlatformWorkDetailViewProps {
onLike: () => void;
onStart: () => void;
onRemix: () => void;
actionMode?: 'remix' | 'edit';
}
function formatCompactCount(value: number) {
@@ -78,6 +80,7 @@ export function PlatformWorkDetailView({
onLike,
onStart,
onRemix,
actionMode = 'remix',
}: PlatformWorkDetailViewProps) {
const coverSlides = useMemo(
() => resolvePlatformWorldCoverSlides(entry),
@@ -111,6 +114,8 @@ export function PlatformWorkDetailView({
[entry],
);
const stats = resolvePlatformWorldStats(entry);
const workActionLabel = actionMode === 'edit' ? '作品编辑' : '作品改造';
const WorkActionIcon = actionMode === 'edit' ? PencilLine : GitFork;
const statItems = [
{
label: '游玩',
@@ -425,8 +430,8 @@ export function PlatformWorkDetailView({
onClick={onRemix}
disabled={isBusy}
>
<GitFork className="h-5 w-5" />
<WorkActionIcon className="h-5 w-5" />
{workActionLabel}
</button>
<button
type="button"

View File

@@ -100,8 +100,9 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
workDescription: '一套雨夜猫街主题拼图。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
imageModel: 'original',
imageModel: 'gpt-image-2',
});
expect(screen.getByText('消耗2光点')).toBeTruthy();
expect(screen.queryByRole('button', { name: '补充剩余设定' })).toBeNull();
expect(screen.queryByText('旧会话消息不再渲染为聊天入口。')).toBeNull();
});
@@ -130,7 +131,7 @@ test('puzzle workspace falls back to compile action for restored sessions', () =
workDescription: '雾港遗迹拼图',
pictureDescription: '潮雾中的灯塔与断桥',
referenceImageSrc: null,
imageModel: 'original',
imageModel: 'gpt-image-2',
candidateCount: 1,
});
});
@@ -158,6 +159,7 @@ test('puzzle workspace switches the image model from the description box', () =>
target: { value: '一只猫在雨夜灯牌下回头。' },
});
fireEvent.click(screen.getByRole('button', { name: '图片模型' }));
expect(screen.queryByRole('menuitemradio', { name: '原模型' })).toBeNull();
fireEvent.click(screen.getByRole('menuitemradio', { name: 'nanobanana2' }));
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
@@ -243,6 +245,6 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
workDescription: '旧街雨夜的拼图草稿。',
pictureDescription: '旧街灯牌下的猫和发光雨伞。',
referenceImageSrc: null,
imageModel: 'original',
imageModel: 'gpt-image-2',
});
});

View File

@@ -10,7 +10,7 @@ import type {
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import {
normalizePuzzleImageModel,
PUZZLE_IMAGE_MODEL_ORIGINAL,
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
type PuzzleImageModelId,
} from './puzzleImageModelOptions';
import { PuzzleImageModelPicker } from './PuzzleImageModelPicker';
@@ -42,7 +42,7 @@ const EMPTY_FORM_STATE: PuzzleFormState = {
pictureDescription: '',
referenceImageSrc: '',
referenceImageLabel: '',
imageModel: PUZZLE_IMAGE_MODEL_ORIGINAL,
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
};
function resolveInitialFormState(
@@ -97,7 +97,7 @@ function resolveInitialFormState(
session.draft?.summary || session.anchorPack.visualSubject.value || '',
referenceImageSrc: '',
referenceImageLabel: '',
imageModel: PUZZLE_IMAGE_MODEL_ORIGINAL,
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
};
}
@@ -439,7 +439,10 @@ export function PuzzleAgentWorkspace({
<span className="inline-flex items-center gap-2">
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<Sparkles className="h-4 w-4" />
稿
<span>稿</span>
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
2
</span>
</span>
</button>
</div>

View File

@@ -1,9 +1,7 @@
export const PUZZLE_IMAGE_MODEL_ORIGINAL = 'original';
export const PUZZLE_IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2';
export const PUZZLE_IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview';
export type PuzzleImageModelId =
| typeof PUZZLE_IMAGE_MODEL_ORIGINAL
| typeof PUZZLE_IMAGE_MODEL_GPT_IMAGE_2
| typeof PUZZLE_IMAGE_MODEL_NANOBANANA2;
@@ -11,7 +9,6 @@ export const PUZZLE_IMAGE_MODEL_OPTIONS: Array<{
id: PuzzleImageModelId;
label: string;
}> = [
{ id: PUZZLE_IMAGE_MODEL_ORIGINAL, label: '原模型' },
{ id: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, label: 'gpt-image-2' },
{ id: PUZZLE_IMAGE_MODEL_NANOBANANA2, label: 'nanobanana2' },
];
@@ -21,13 +18,13 @@ export function normalizePuzzleImageModel(
): PuzzleImageModelId {
return (
PUZZLE_IMAGE_MODEL_OPTIONS.find((option) => option.id === value)?.id ??
PUZZLE_IMAGE_MODEL_ORIGINAL
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2
);
}
export function getPuzzleImageModelLabel(model: PuzzleImageModelId) {
return (
PUZZLE_IMAGE_MODEL_OPTIONS.find((option) => option.id === model)?.label ??
'原模型'
'gpt-image-2'
);
}

View File

@@ -235,16 +235,26 @@ describe('PuzzleResultView', () => {
fireEvent.click(
within(dialog).getByRole('button', { name: //u }),
);
const confirmDialog = screen.getByRole('dialog', {
name: '确认消耗光点',
});
expect(within(confirmDialog).getByText('消耗 2 光点')).toBeTruthy();
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确定' }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1',
promptText: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: undefined,
imageModel: 'original',
imageModel: 'gpt-image-2',
candidateCount: 1,
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
summary: '一只猫在雨夜灯牌下回头。',
themeTags: ['猫咪', '雨夜', '暖灯'],
levelsJson: expect.any(String),
});
expect(screen.getByRole('progressbar', { name: '画面生成进度' })).toBeTruthy();
const generatePayload = onExecuteAction.mock.calls[0]![0];
expect(JSON.parse(generatePayload.levelsJson ?? '[]')).toEqual([
expect.objectContaining({
@@ -301,6 +311,7 @@ describe('PuzzleResultView', () => {
expect(
within(dialog).getByRole('button', { name: //u }),
).toBeTruthy();
expect(within(dialog).getByText('消耗2光点')).toBeTruthy();
expect(within(dialog).queryByText('画面图')).toBeNull();
expect(
within(dialog).queryByRole('button', { name: //u }),
@@ -359,14 +370,24 @@ describe('PuzzleResultView', () => {
target: { value: '新关卡里有一座发光钟楼。' },
});
fireEvent.click(within(dialog).getByRole('button', { name: //u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗光点' })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1775000000000-2',
promptText: '新关卡里有一座发光钟楼。',
referenceImageSrc: undefined,
imageModel: 'original',
imageModel: 'gpt-image-2',
candidateCount: 1,
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
summary: '新关卡里有一座发光钟楼。',
themeTags: ['猫咪', '雨夜', '暖灯'],
levelsJson: expect.any(String),
});
@@ -460,13 +481,23 @@ describe('PuzzleResultView', () => {
});
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗光点' })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenLastCalledWith({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1',
promptText: '屋檐下的猫与暖灯街角。',
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
imageModel: 'original',
imageModel: 'gpt-image-2',
candidateCount: 1,
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
summary: '屋檐下的猫与暖灯街角。',
themeTags: ['猫咪', '雨夜', '暖灯'],
levelsJson: expect.any(String),
});
});
@@ -491,6 +522,12 @@ describe('PuzzleResultView', () => {
fireEvent.click(
within(dialog).getByRole('button', { name: //u }),
);
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗光点' })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenCalledWith(
expect.objectContaining({

View File

@@ -27,7 +27,7 @@ import {
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { useAuthUi } from '../auth/AuthUiContext';
import {
PUZZLE_IMAGE_MODEL_ORIGINAL,
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
type PuzzleImageModelId,
} from '../puzzle-agent/puzzleImageModelOptions';
import { PuzzleImageModelPicker } from '../puzzle-agent/PuzzleImageModelPicker';
@@ -56,6 +56,8 @@ type DraftEditState = {
const PUZZLE_MIN_THEME_TAG_COUNT = 3;
const PUZZLE_MAX_THEME_TAG_COUNT = 6;
const PUZZLE_AUTOSAVE_DEBOUNCE_MS = 600;
const PUZZLE_IMAGE_GENERATION_POINT_COST = 2;
const PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS = 30;
function normalizeThemeTagInput(value: string) {
return [
@@ -597,11 +599,29 @@ function PuzzleLevelDetailDialog({
null,
);
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
const [isCostConfirmOpen, setIsCostConfirmOpen] = useState(false);
const [isGenerationProgressActive, setIsGenerationProgressActive] =
useState(false);
const [generationCountdown, setGenerationCountdown] = useState(0);
const generationBusySeenRef = useRef(false);
const [imageModel, setImageModel] = useState<PuzzleImageModelId>(
PUZZLE_IMAGE_MODEL_ORIGINAL,
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
);
const formalImageSrc = resolveLevelFormalImageSrc(level);
const hasFormalImage = Boolean(formalImageSrc);
const isGenerationProgressVisible = isGenerationProgressActive;
const generationSecondsLeft = isBusy
? Math.max(generationCountdown, 1)
: generationCountdown;
const generationProgressPercent = Math.max(
6,
Math.round(
((PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS -
Math.max(generationSecondsLeft, 0)) /
PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS) *
100,
),
);
const handleReferenceImageChange = async (
event: ChangeEvent<HTMLInputElement>,
@@ -626,6 +646,59 @@ function PuzzleLevelDetailDialog({
}
};
useEffect(() => {
if (!isGenerationProgressActive) {
return;
}
if (generationCountdown <= 0) {
if (!isBusy) {
setIsGenerationProgressActive(false);
}
return;
}
const timer = window.setTimeout(() => {
setGenerationCountdown((current) => Math.max(0, current - 1));
}, 1000);
return () => window.clearTimeout(timer);
}, [generationCountdown, isBusy, isGenerationProgressActive]);
useEffect(() => {
if (isGenerationProgressActive && isBusy) {
generationBusySeenRef.current = true;
return;
}
if (
isGenerationProgressActive &&
!isBusy &&
generationBusySeenRef.current
) {
generationBusySeenRef.current = false;
setIsGenerationProgressActive(false);
setGenerationCountdown(0);
}
if (!isBusy) {
setIsCostConfirmOpen(false);
}
}, [isBusy, isGenerationProgressActive]);
const executeGeneration = () => {
setIsCostConfirmOpen(false);
setIsGenerationProgressActive(true);
generationBusySeenRef.current = false;
setGenerationCountdown(PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS);
onGenerate(
level.levelId,
level.pictureDescription.trim() || undefined,
referenceImageSrc || undefined,
imageModel,
);
};
if (typeof document === 'undefined') {
return null;
}
@@ -801,25 +874,83 @@ function PuzzleLevelDetailDialog({
</button>
) : null}
<button
type="button"
disabled={isBusy}
onClick={() => {
onGenerate(
level.levelId,
level.pictureDescription.trim() || undefined,
referenceImageSrc || undefined,
imageModel,
);
}}
className="inline-flex w-full items-center justify-center gap-2 rounded-full bg-amber-600 px-4 py-3 text-sm font-bold text-white disabled:opacity-45"
>
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<Sparkles className="h-4 w-4" />
{hasFormalImage ? '重新生成画面' : '生成画面'}
</button>
{isGenerationProgressVisible ? (
<div
role="progressbar"
aria-label="画面生成进度"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={generationProgressPercent}
className="platform-progress-track relative h-12 overflow-hidden rounded-full"
>
<div
className="h-full rounded-full bg-amber-600 transition-[width] duration-300"
style={{ width: `${generationProgressPercent}%` }}
/>
<div className="absolute inset-0 flex items-center justify-center gap-2 px-4 text-sm font-bold text-white">
<Loader2 className="h-4 w-4 animate-spin" />
{generationSecondsLeft}
</div>
</div>
) : (
<button
type="button"
disabled={isBusy}
onClick={() => setIsCostConfirmOpen(true)}
className="inline-flex w-full items-center justify-center gap-2 rounded-full bg-amber-600 px-4 py-3 text-sm font-bold text-white disabled:opacity-45"
>
<Sparkles className="h-4 w-4" />
<span>{hasFormalImage ? '重新生成画面' : '生成画面'}</span>
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
{PUZZLE_IMAGE_GENERATION_POINT_COST}
</span>
</button>
)}
</div>
{isCostConfirmOpen ? (
<div
className="absolute inset-0 z-20 flex items-center justify-center bg-black/45 p-4 backdrop-blur-sm"
onClick={() => setIsCostConfirmOpen(false)}
>
<section
role="dialog"
aria-modal="true"
aria-label="确认消耗光点"
className="platform-modal-shell platform-remap-surface w-full max-w-sm overflow-hidden rounded-[1.5rem] shadow-[0_24px_80px_rgba(0,0,0,0.45)]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-amber-100 text-amber-700">
<Sparkles className="h-4 w-4" />
</span>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
</div>
<div className="px-5 py-4 text-sm font-semibold text-[var(--platform-text-base)]">
{PUZZLE_IMAGE_GENERATION_POINT_COST}
</div>
<div className="flex items-center justify-end gap-3 border-t border-[var(--platform-subpanel-border)] px-5 py-4">
<button
type="button"
onClick={() => setIsCostConfirmOpen(false)}
className="platform-button platform-button--ghost min-h-10 px-4 py-2 text-sm"
>
</button>
<button
type="button"
onClick={executeGeneration}
className="platform-button platform-button--primary min-h-10 px-5 py-2 text-sm"
>
</button>
</div>
</section>
</div>
) : null}
{isHistoryPickerOpen ? (
<PuzzleHistoryAssetPickerDialog
isBusy={isBusy}
@@ -1420,8 +1551,12 @@ export function PuzzleResultView({
levelId,
promptText,
referenceImageSrc,
imageModel: imageModel ?? PUZZLE_IMAGE_MODEL_ORIGINAL,
imageModel: imageModel ?? PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
candidateCount: 1,
workTitle: editState.workTitle.trim(),
workDescription: editState.workDescription.trim(),
summary: activeLevel.pictureDescription.trim(),
themeTags: editState.themeTags,
levelsJson: JSON.stringify(editState.levels),
});
}}

View File

@@ -171,6 +171,75 @@ test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
vi.useRealTimers();
});
test('首次点击左上返回弹出作品改造引导,保存并退出后不再重复弹出', () => {
const onBack = vi.fn();
const onRemodelWork = vi.fn();
window.localStorage.clear();
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={clearedRun}
onBack={onBack}
onRemodelWork={onRemodelWork}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '返回上一页' }));
const dialog = screen.getByRole('dialog', {
name: /\s*!/u,
});
expect(dialog).toBeTruthy();
expect(onBack).not.toHaveBeenCalled();
fireEvent.click(within(dialog).getByRole('button', { name: '保存并退出' }));
expect(onBack).toHaveBeenCalledTimes(1);
expect(
window.localStorage.getItem(
'genarrative.puzzle-runtime.exit-remodel-prompt.v1:profile-1',
),
).toBe('1');
fireEvent.click(screen.getByRole('button', { name: '返回上一页' }));
expect(screen.queryByRole('dialog')).toBeNull();
expect(onBack).toHaveBeenCalledTimes(2);
expect(onRemodelWork).not.toHaveBeenCalled();
});
test('首次退出引导的作品改造按钮进入改造流程', () => {
const onRemodelWork = vi.fn();
window.localStorage.clear();
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={{
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
profileId: 'profile-remodel',
},
}}
onBack={vi.fn()}
onRemodelWork={onRemodelWork}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '返回上一页' }));
fireEvent.click(screen.getByRole('button', { name: '作品改造' }));
expect(onRemodelWork).toHaveBeenCalledTimes(1);
expect(onRemodelWork).toHaveBeenCalledWith('profile-remodel');
expect(screen.queryByRole('dialog')).toBeNull();
});
test('顶部作者显示头像昵称,底部功能居中放大且不显示等待候选', () => {
const runWithoutNext: PuzzleRunSnapshot = {
...clearedRun,

View File

@@ -41,6 +41,7 @@ type PuzzleRuntimeShellProps = {
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onRemodelWork?: (profileId: string) => void | Promise<void>;
onSwapPieces: (payload: SwapPuzzlePiecesRequest) => void;
onDragPiece: (payload: DragPuzzlePieceRequest) => void;
onAdvanceNextLevel: (target?: PuzzleNextLevelTarget) => void;
@@ -208,6 +209,61 @@ const PUZZLE_CLEAR_DIALOG_DELAY_MS = 500;
const PUZZLE_MERGE_FLASH_DURATION_MS = 720;
const PUZZLE_HINT_DEMO_DURATION_MS = 1_250;
const PUZZLE_PIECE_PRESS_HAPTIC_PATTERN_MS = 12;
const PUZZLE_EXIT_REMODEL_PROMPT_STORAGE_PREFIX =
'genarrative.puzzle-runtime.exit-remodel-prompt.v1';
const shownExitRemodelPromptProfileIds = new Set<string>();
function buildExitRemodelPromptStorageKey(profileId: string) {
return `${PUZZLE_EXIT_REMODEL_PROMPT_STORAGE_PREFIX}:${encodeURIComponent(
profileId,
)}`;
}
function hasSeenExitRemodelPrompt(profileId: string) {
const normalizedProfileId = profileId.trim();
if (!normalizedProfileId) {
return true;
}
if (shownExitRemodelPromptProfileIds.has(normalizedProfileId)) {
if (typeof window === 'undefined') {
return true;
}
}
try {
const seen =
window.localStorage.getItem(
buildExitRemodelPromptStorageKey(normalizedProfileId),
) === '1';
if (seen) {
shownExitRemodelPromptProfileIds.add(normalizedProfileId);
}
return seen;
} catch {
return shownExitRemodelPromptProfileIds.has(normalizedProfileId);
}
}
function markExitRemodelPromptSeen(profileId: string) {
const normalizedProfileId = profileId.trim();
if (!normalizedProfileId) {
return;
}
shownExitRemodelPromptProfileIds.add(normalizedProfileId);
if (typeof window === 'undefined') {
return;
}
try {
window.localStorage.setItem(
buildExitRemodelPromptStorageKey(normalizedProfileId),
'1',
);
} catch {
// 中文注释:隐私模式下 localStorage 可能不可写,内存集合足够兜底本次挂载周期。
}
}
type PuzzlePropDialogState = {
propKind: PuzzleRuntimePropKind;
@@ -251,6 +307,7 @@ export function PuzzleRuntimeShell({
isBusy = false,
error = null,
onBack,
onRemodelWork,
onSwapPieces,
onDragPiece,
onAdvanceNextLevel,
@@ -263,6 +320,8 @@ export function PuzzleRuntimeShell({
const authUi = useAuthUi();
const [selectedPieceId, setSelectedPieceId] = useState<string | null>(null);
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
const [isExitRemodelPromptOpen, setIsExitRemodelPromptOpen] =
useState(false);
const [propDialog, setPropDialog] = useState<PuzzlePropDialogState | null>(
null,
);
@@ -621,7 +680,10 @@ export function PuzzleRuntimeShell({
}, [onTimeExpired]);
const isUiPauseActive =
isSettingsPanelOpen || Boolean(propDialog) || isOriginalOverlayVisible;
isSettingsPanelOpen ||
isExitRemodelPromptOpen ||
Boolean(propDialog) ||
isOriginalOverlayVisible;
useEffect(() => {
if (previousUiPauseActiveRef.current === isUiPauseActive) {
@@ -898,6 +960,7 @@ export function PuzzleRuntimeShell({
const authorAvatarLabel = resolveAuthorAvatarLabel(
currentLevel.authorDisplayName,
);
const exitPromptProfileId = currentLevel.profileId.trim();
const leaderboardEntries =
(currentLevel.leaderboardEntries ?? []).length > 0
? currentLevel.leaderboardEntries
@@ -909,6 +972,20 @@ export function PuzzleRuntimeShell({
const isInteractionLocked =
isBusy || runtimeStatus !== 'playing' || Boolean(propDialog);
const handleBackRequest = () => {
if (
onRemodelWork &&
exitPromptProfileId &&
!hasSeenExitRemodelPrompt(exitPromptProfileId)
) {
markExitRemodelPromptSeen(exitPromptProfileId);
setIsExitRemodelPromptOpen(true);
return;
}
onBack();
};
const openPropDialog = (propKind: PuzzleRuntimePropKind, title: string) => {
const canOpen =
propKind === 'extendTime'
@@ -1016,7 +1093,7 @@ export function PuzzleRuntimeShell({
<div className="grid grid-cols-[2.75rem_minmax(0,1fr)_2.75rem] items-start gap-2 sm:gap-3">
<button
type="button"
onClick={onBack}
onClick={handleBackRequest}
aria-label="返回上一页"
className="inline-flex h-11 w-11 items-center justify-center rounded-full bg-black/30 backdrop-blur"
>
@@ -1664,6 +1741,54 @@ export function PuzzleRuntimeShell({
</div>
) : null}
{isExitRemodelPromptOpen ? (
<div
className="absolute inset-0 z-50 flex items-center justify-center bg-slate-950/72 px-4 py-6 backdrop-blur-sm"
>
<section
role="dialog"
aria-modal="true"
aria-labelledby="puzzle-exit-remodel-title"
className="flex w-full max-w-[22rem] flex-col overflow-hidden rounded-[1.5rem] border border-white/14 bg-slate-950/94 shadow-[0_28px_90px_rgba(0,0,0,0.5)]"
onClick={(event) => event.stopPropagation()}
>
<header className="px-5 pt-6 text-center">
<h2
id="puzzle-exit-remodel-title"
className="text-2xl font-black leading-tight text-white"
>
<br />
!
</h2>
</header>
<footer className="grid gap-3 px-5 py-5">
<button
type="button"
disabled={isBusy}
onClick={() => {
setIsExitRemodelPromptOpen(false);
void onRemodelWork?.(exitPromptProfileId);
}}
className="rounded-full bg-amber-200 px-5 py-3 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-50"
>
</button>
<button
type="button"
onClick={() => {
setIsExitRemodelPromptOpen(false);
onBack();
}}
className="rounded-full border border-white/14 bg-black/24 px-5 py-3 text-sm font-black text-white transition hover:bg-white/10"
>
退
</button>
</footer>
</section>
</div>
) : null}
{runtimeStatus === 'failed' ? (
<div className="absolute inset-0 z-40 flex items-center justify-center bg-slate-950/68 px-4 py-6 backdrop-blur-sm">
<section

View File

@@ -15,6 +15,13 @@ import type {
} from '../../../packages/shared/src/contracts/match3dAgent';
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type {
PuzzleAnchorPack,
PuzzleResultDraft,
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type {
PuzzleAgentSessionSnapshot,
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
@@ -619,6 +626,41 @@ function buildMockPuzzleRun(
};
}
function buildPuzzleAnchorPack(): PuzzleAnchorPack {
return {
themePromise: {
key: 'themePromise',
label: '题材承诺',
value: '雨夜拼图',
status: 'confirmed',
},
visualSubject: {
key: 'visualSubject',
label: '画面主体',
value: '雨夜猫塔',
status: 'confirmed',
},
visualMood: {
key: 'visualMood',
label: '视觉气质',
value: '暖灯',
status: 'confirmed',
},
compositionHooks: {
key: 'compositionHooks',
label: '拼图记忆点',
value: '灯塔与猫',
status: 'confirmed',
},
tagsAndForbidden: {
key: 'tagsAndForbidden',
label: '标签与禁忌',
value: '雨夜、猫咪、塔',
status: 'confirmed',
},
};
}
function buildClearedPuzzleRun(params: {
runId: string;
entryProfileId: string;
@@ -2198,6 +2240,54 @@ test('logged out public detail gates puzzle start and remix before real actions'
expect(remixPuzzleGalleryWork).not.toHaveBeenCalled();
});
test('owned public puzzle detail edits original draft instead of remixing', async () => {
const user = userEvent.setup();
const ownedPuzzleWork = {
workId: 'puzzle-work-owned-1',
profileId: 'puzzle-profile-owned-1',
ownerUserId: mockAuthUser.id,
sourceSessionId: 'puzzle-session-1',
authorDisplayName: mockAuthUser.displayName,
levelName: '星桥机关',
summary: '旋转碎片并接通星桥机关。',
themeTags: ['机关', '星桥'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-04-25T09:00:00.000Z',
publishedAt: '2026-04-25T09:00:00.000Z',
playCount: 3,
remixCount: 0,
likeCount: 0,
publishReady: true,
} satisfies PuzzleWorkSummary;
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [ownedPuzzleWork],
});
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
item: ownedPuzzleWork,
});
render(<TestWrapper withAuth />);
await waitFor(() => {
expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0);
});
const workCards = screen.getAllByRole('button', { name: //u });
await user.click(workCards[0]!);
expect(await screen.findByText('详情')).toBeTruthy();
expect(screen.getByRole('button', { name: '作品编辑' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '作品改造' })).toBeNull();
await user.click(screen.getByRole('button', { name: '作品编辑' }));
expect(getPuzzleAgentSession).toHaveBeenCalledWith('puzzle-session-1');
expect(remixPuzzleGalleryWork).not.toHaveBeenCalled();
expect(await screen.findByText('拼图结果页')).toBeTruthy();
});
test('logged out public detail gates big fish start before local runtime', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
@@ -2496,6 +2586,13 @@ test('published puzzle detail returns to the ranking platform tab', async () =>
expect(await screen.findByTestId('puzzle-board')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回上一页' }));
await user.click(
within(
await screen.findByRole('dialog', {
name: /\s*!/u,
}),
).getByRole('button', { name: '保存并退出' }),
);
await waitFor(() => {
expect(screen.getByRole('button', { name: '启动' })).toBeTruthy();
@@ -2983,6 +3080,98 @@ test('formal puzzle next level uses backend run and leaderboard keeps frontend l
expect(screen.getByText('测试玩家')).toBeTruthy();
});
test('first puzzle runtime back click can open remix result page', async () => {
const user = userEvent.setup();
const puzzleWork: PuzzleWorkSummary = {
workId: 'puzzle-work-public-1',
profileId: 'puzzle-profile-public-1',
ownerUserId: 'user-2',
sourceSessionId: null,
authorDisplayName: '拼图作者',
levelName: '雨夜猫塔',
summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。',
themeTags: ['雨夜', '猫咪', '遗迹'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-04-25T12:10:00.000Z',
publishedAt: '2026-04-25T12:10:00.000Z',
playCount: 8,
remixCount: 0,
likeCount: 0,
publishReady: true,
};
const anchorPack = buildPuzzleAnchorPack();
const remixDraft: PuzzleResultDraft = {
workTitle: '改造后的雨夜猫塔',
workDescription: '准备改造的拼图草稿。',
levelName: '改造后的雨夜猫塔',
summary: '一只猫站在雨夜塔顶。',
themeTags: ['雨夜', '猫咪', '塔'],
forbiddenDirectives: [],
creatorIntent: null,
anchorPack,
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'idle',
levels: [],
metadata: null,
};
const remixSession: PuzzleAgentSessionSnapshot = {
sessionId: 'puzzle-session-remix-1',
currentTurn: 1,
progressPercent: 100,
stage: 'ready_to_publish',
anchorPack,
draft: remixDraft,
messages: [],
lastAssistantReply: null,
publishedProfileId: null,
suggestedActions: [],
resultPreview: null,
updatedAt: '2026-04-25T12:12:00.000Z',
};
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [puzzleWork],
});
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
item: puzzleWork,
});
vi.mocked(startPuzzleRun).mockResolvedValue({
run: buildMockPuzzleRun(puzzleWork.profileId, puzzleWork.levelName),
});
vi.mocked(remixPuzzleGalleryWork).mockResolvedValue({
session: remixSession,
});
render(<TestWrapper withAuth />);
const searchInput = await screen.findByPlaceholderText(
'搜索作品号、名称、作者、描述',
);
await user.type(searchInput, 'PZ-EPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
await user.click(await screen.findByRole('button', { name: '启动' }));
expect(await screen.findByTestId('puzzle-board')).toBeTruthy();
await user.click(await screen.findByRole('button', { name: '返回上一页' }));
const dialog = await screen.findByRole('dialog', {
name: /\s*!/u,
});
await user.click(within(dialog).getByRole('button', { name: '作品改造' }));
await waitFor(() => {
expect(remixPuzzleGalleryWork).toHaveBeenCalledWith(
'puzzle-profile-public-1',
);
});
expect(await screen.findByText('拼图结果页')).toBeTruthy();
expect(screen.getByDisplayValue('改造后的雨夜猫塔')).toBeTruthy();
});
test('public code search opens a published puzzle by PZ code', async () => {
const user = userEvent.setup();
const puzzleWork: PuzzleWorkSummary = {

View File

@@ -29,6 +29,7 @@ export type PlatformPuzzleGalleryCard = {
sourceType: 'puzzle';
workId: string;
profileId: string;
sourceSessionId?: string | null;
publicWorkCode: string;
ownerUserId: string;
authorDisplayName: string;
@@ -78,6 +79,7 @@ export type PlatformMatch3DGalleryCard = {
sourceType: 'match3d';
workId: string;
profileId: string;
sourceSessionId?: string | null;
publicWorkCode: string;
ownerUserId: string;
authorDisplayName: string;
@@ -132,6 +134,7 @@ export function mapPuzzleWorkToPlatformGalleryCard(
sourceType: 'puzzle',
workId: work.workId,
profileId: work.profileId,
sourceSessionId: work.sourceSessionId ?? null,
publicWorkCode: buildPuzzlePublicWorkCode(work.profileId),
ownerUserId: work.ownerUserId,
authorDisplayName: work.authorDisplayName,
@@ -158,6 +161,7 @@ export function mapMatch3DWorkToPlatformGalleryCard(
sourceType: 'match3d',
workId: work.workId,
profileId: work.profileId,
sourceSessionId: work.sourceSessionId ?? null,
publicWorkCode: buildMatch3DPublicWorkCode(work.profileId),
ownerUserId: work.ownerUserId,
authorDisplayName: '玩家',