fix: polish platform creation flow interactions
This commit is contained in:
@@ -287,6 +287,118 @@ test('creative image input panel supports a preview-only main image mode', () =>
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('creative image input panel can preview the main image and keep upload on a corner button', () => {
|
||||
const onMainImageFileSelect = vi.fn();
|
||||
const inputClickSpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, 'click')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
render(
|
||||
<CreativeImageInputPanel
|
||||
mainImageClickMode="preview"
|
||||
uploadedImageSrc="/generated-puzzle-assets/session/level/image.png"
|
||||
uploadedImageAlt="拼图关卡图"
|
||||
mainImageInputId="level-image-upload-input"
|
||||
promptTextareaId="level-prompt-input"
|
||||
prompt="旧街灯牌下的猫。"
|
||||
promptLabel="画面描述"
|
||||
aiRedraw
|
||||
promptReferenceImages={[]}
|
||||
imageModelPicker={null}
|
||||
submitLabel="重新生成画面"
|
||||
submitDisabled={false}
|
||||
labels={{
|
||||
imageField: '画面图',
|
||||
uploadImage: '上传参考图',
|
||||
replaceImage: '更换参考图',
|
||||
emptyImageHint: '上传图片/填写画面描述',
|
||||
removeImage: '移除参考图',
|
||||
removeImageConfirmTitle: '移除参考图?',
|
||||
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '参考图预览',
|
||||
closePromptReferencePreview: '关闭参考图预览',
|
||||
previewMainImage: '查看关卡图片',
|
||||
closeMainImagePreview: '关闭关卡图片预览',
|
||||
}}
|
||||
onMainImageFileSelect={onMainImageFileSelect}
|
||||
onMainImageRemove={() => {}}
|
||||
onAiRedrawChange={() => {}}
|
||||
onPromptChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看关卡图片' }));
|
||||
expect(screen.getByRole('dialog', { name: '查看关卡图片' })).toBeTruthy();
|
||||
expect(screen.getAllByAltText('拼图关卡图').length).toBeGreaterThanOrEqual(2);
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '关闭关卡图片预览' }),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '更换参考图' }));
|
||||
expect(inputClickSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('上传参考图', { selector: 'input' }), {
|
||||
target: {
|
||||
files: [new File(['a'], 'level-reference.png', { type: 'image/png' })],
|
||||
},
|
||||
});
|
||||
expect(onMainImageFileSelect).toHaveBeenCalledWith(expect.any(File));
|
||||
} finally {
|
||||
inputClickSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('creative image input panel can hide upload and history controls independently', () => {
|
||||
render(
|
||||
<CreativeImageInputPanel
|
||||
uploadedImageSrc="/generated-puzzle-assets/session/level/image.png"
|
||||
uploadedImageAlt="拼图关卡图"
|
||||
canUploadMainImage={false}
|
||||
canUseImageHistory={false}
|
||||
mainImageInputId="level-image-upload-input"
|
||||
promptTextareaId="level-prompt-input"
|
||||
prompt="旧街灯牌下的猫。"
|
||||
promptLabel="画面描述"
|
||||
aiRedraw
|
||||
promptReferenceImages={[]}
|
||||
imageModelPicker={null}
|
||||
submitLabel="重新生成画面"
|
||||
submitDisabled={false}
|
||||
labels={{
|
||||
imageField: '画面图',
|
||||
uploadImage: '上传参考图',
|
||||
replaceImage: '更换参考图',
|
||||
emptyImageHint: '上传图片/填写画面描述',
|
||||
removeImage: '移除参考图',
|
||||
removeImageConfirmTitle: '移除参考图?',
|
||||
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '参考图预览',
|
||||
closePromptReferencePreview: '关闭参考图预览',
|
||||
previewMainImage: '查看关卡图片',
|
||||
closeMainImagePreview: '关闭关卡图片预览',
|
||||
history: '选择历史图片',
|
||||
}}
|
||||
onMainImageFileSelect={() => {}}
|
||||
onMainImageRemove={() => {}}
|
||||
onAiRedrawChange={() => {}}
|
||||
onPromptChange={() => {}}
|
||||
onHistoryClick={() => {}}
|
||||
onSubmit={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '查看关卡图片' })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '更换参考图' })).toBeNull();
|
||||
expect(
|
||||
screen.queryByLabelText('上传参考图', { selector: 'input' }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '选择历史图片' })).toBeNull();
|
||||
});
|
||||
|
||||
test('creative image input panel does not show empty upload hint over a non-removable image', () => {
|
||||
render(
|
||||
<CreativeImageInputPanel
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
@@ -28,6 +28,8 @@ export type CreativeImageInputPanelLabels = {
|
||||
promptReferenceUpload: string;
|
||||
promptReferencePreviewAlt: string;
|
||||
closePromptReferencePreview: string;
|
||||
previewMainImage?: string;
|
||||
closeMainImagePreview?: string;
|
||||
history?: string;
|
||||
};
|
||||
|
||||
@@ -37,6 +39,9 @@ export type CreativeImageInputPanelProps = {
|
||||
disabled?: boolean;
|
||||
isSubmitting?: boolean;
|
||||
mainImageMode?: 'edit' | 'preview';
|
||||
mainImageClickMode?: 'upload' | 'preview';
|
||||
canUploadMainImage?: boolean;
|
||||
canUseImageHistory?: boolean;
|
||||
canRemoveMainImage?: boolean;
|
||||
canToggleAiRedraw?: boolean;
|
||||
canUploadPromptReferences?: boolean;
|
||||
@@ -82,6 +87,9 @@ export function CreativeImageInputPanel({
|
||||
disabled = false,
|
||||
isSubmitting = false,
|
||||
mainImageMode = 'edit',
|
||||
mainImageClickMode = 'preview',
|
||||
canUploadMainImage = true,
|
||||
canUseImageHistory = true,
|
||||
canRemoveMainImage = true,
|
||||
canToggleAiRedraw = true,
|
||||
canUploadPromptReferences,
|
||||
@@ -117,8 +125,10 @@ export function CreativeImageInputPanel({
|
||||
onHistoryClick,
|
||||
onSubmit,
|
||||
}: CreativeImageInputPanelProps) {
|
||||
const mainImageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [previewReferenceImage, setPreviewReferenceImage] =
|
||||
useState<CreativeImageInputReferenceImage | null>(null);
|
||||
const [isMainImagePreviewOpen, setIsMainImagePreviewOpen] = useState(false);
|
||||
const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] =
|
||||
useState(false);
|
||||
const showPrompt = mainImageMode === 'preview' || !uploadedImageSrc || aiRedraw;
|
||||
@@ -127,10 +137,19 @@ export function CreativeImageInputPanel({
|
||||
const promptReferenceUploadDisabled =
|
||||
disabled || promptReferenceImages.length >= promptReferenceLimit;
|
||||
const canEditMainImage = mainImageMode === 'edit';
|
||||
const isMainImageUploadEnabled = canEditMainImage && canUploadMainImage;
|
||||
const shouldShowHistoryButton =
|
||||
canEditMainImage && canUseImageHistory && Boolean(onHistoryClick);
|
||||
const shouldPreviewMainImage =
|
||||
mainImageClickMode === 'preview' && Boolean(uploadedImageSrc);
|
||||
const shouldShowMainImageUploadButton =
|
||||
isMainImageUploadEnabled && shouldPreviewMainImage;
|
||||
|
||||
useEffect(() => {
|
||||
if (uploadedImageSrc) {
|
||||
setPreviewReferenceImage(null);
|
||||
} else {
|
||||
setIsMainImagePreviewOpen(false);
|
||||
}
|
||||
}, [uploadedImageSrc]);
|
||||
|
||||
@@ -187,35 +206,48 @@ export function CreativeImageInputPanel({
|
||||
</div>
|
||||
<div className={imageFrameClassName}>
|
||||
<div className={imageCardClassName}>
|
||||
{canEditMainImage ? (
|
||||
<>
|
||||
<input
|
||||
id={mainImageInputId}
|
||||
type="file"
|
||||
accept={mainImageAccept}
|
||||
disabled={disabled}
|
||||
aria-label={labels.uploadImage}
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0] ?? null;
|
||||
event.currentTarget.value = '';
|
||||
if (file) {
|
||||
onMainImageFileSelect(file);
|
||||
}
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
<label
|
||||
htmlFor={mainImageInputId}
|
||||
className={`absolute inset-0 z-0 cursor-pointer ${disabled ? 'cursor-not-allowed' : ''}`}
|
||||
title={
|
||||
uploadedImageSrc ? labels.replaceImage : labels.uploadImage
|
||||
{isMainImageUploadEnabled ? (
|
||||
<input
|
||||
ref={mainImageInputRef}
|
||||
id={mainImageInputId}
|
||||
type="file"
|
||||
accept={mainImageAccept}
|
||||
disabled={disabled}
|
||||
aria-label={labels.uploadImage}
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0] ?? null;
|
||||
event.currentTarget.value = '';
|
||||
if (file) {
|
||||
onMainImageFileSelect(file);
|
||||
}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{uploadedImageSrc ? labels.replaceImage : labels.uploadImage}
|
||||
</span>
|
||||
</label>
|
||||
</>
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
) : null}
|
||||
{shouldPreviewMainImage ? (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 z-[2] cursor-zoom-in"
|
||||
aria-label={labels.previewMainImage ?? uploadedImageAlt}
|
||||
title={labels.previewMainImage ?? uploadedImageAlt}
|
||||
onClick={() => setIsMainImagePreviewOpen(true)}
|
||||
/>
|
||||
) : isMainImageUploadEnabled ? (
|
||||
<label
|
||||
htmlFor={mainImageInputId}
|
||||
className={`absolute inset-0 z-0 cursor-pointer ${disabled ? 'cursor-not-allowed' : ''}`}
|
||||
title={
|
||||
uploadedImageSrc
|
||||
? labels.replaceImage
|
||||
: labels.uploadImage
|
||||
}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{uploadedImageSrc
|
||||
? labels.replaceImage
|
||||
: labels.uploadImage}
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
{uploadedImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
@@ -232,7 +264,19 @@ export function CreativeImageInputPanel({
|
||||
</span>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-0 z-[1] bg-[linear-gradient(180deg,rgba(255,255,255,0.12)_0%,rgba(255,255,255,0.04)_42%,rgba(255,255,255,0.18)_100%)]" />
|
||||
{canEditMainImage && onHistoryClick ? (
|
||||
{shouldShowMainImageUploadButton ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => mainImageInputRef.current?.click()}
|
||||
className="absolute bottom-3 right-3 z-10 inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/80 bg-white/94 text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[var(--platform-accent)] disabled:cursor-not-allowed disabled:opacity-55"
|
||||
aria-label={labels.replaceImage}
|
||||
title={labels.replaceImage}
|
||||
>
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
{shouldShowHistoryButton ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
@@ -284,7 +328,7 @@ export function CreativeImageInputPanel({
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
) : canEditMainImage && !uploadedImageSrc ? (
|
||||
) : isMainImageUploadEnabled && !uploadedImageSrc ? (
|
||||
<label
|
||||
htmlFor={mainImageInputId}
|
||||
className={`absolute bottom-9 left-1/2 z-10 -translate-x-1/2 whitespace-nowrap text-center text-sm font-black text-[var(--platform-text-strong)] drop-shadow-[0_1px_0_rgba(255,255,255,0.82)] transition hover:text-[var(--platform-accent)] sm:bottom-10 ${
|
||||
@@ -477,6 +521,48 @@ export function CreativeImageInputPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isMainImagePreviewOpen && uploadedImageSrc ? (
|
||||
<div
|
||||
className="platform-modal-backdrop fixed inset-0 z-[82] flex items-center justify-center px-4 py-6"
|
||||
onClick={() => setIsMainImagePreviewOpen(false)}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="creative-image-main-preview-title"
|
||||
className="platform-modal-shell platform-remap-surface w-full max-w-4xl rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-3 px-1">
|
||||
<div
|
||||
id="creative-image-main-preview-title"
|
||||
className="min-w-0 truncate text-sm font-black text-[var(--platform-text-strong)]"
|
||||
>
|
||||
{labels.previewMainImage ?? uploadedImageAlt}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
labels.closeMainImagePreview ?? labels.closePromptReferencePreview
|
||||
}
|
||||
onClick={() => setIsMainImagePreviewOpen(false)}
|
||||
className="platform-profile-icon-button flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-[82vh] overflow-hidden rounded-[1rem] bg-black/5">
|
||||
<ResolvedAssetImage
|
||||
src={uploadedImageSrc}
|
||||
refreshKey={uploadedImageRefreshKey}
|
||||
alt={uploadedImageAlt}
|
||||
className="h-full max-h-[82vh] w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isRemoveImageConfirmOpen ? (
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
||||
<div
|
||||
|
||||
@@ -726,6 +726,20 @@ function getPlatformRecommendRuntimeKind(
|
||||
return 'rpg';
|
||||
}
|
||||
|
||||
function resolveRecommendEntryShareStage(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
): PublishShareModalPayload['stage'] {
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return 'big-fish-runtime';
|
||||
}
|
||||
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
return 'puzzle-gallery-detail';
|
||||
}
|
||||
|
||||
return 'work-detail';
|
||||
}
|
||||
|
||||
function isRecommendRuntimeReadyForEntry(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
state: RecommendRuntimeState,
|
||||
@@ -1900,6 +1914,13 @@ function buildPuzzleCreationUrlState(
|
||||
};
|
||||
}
|
||||
|
||||
function pushPuzzleResultHistoryEntry(
|
||||
session: PuzzleAgentSessionSnapshot | null,
|
||||
) {
|
||||
pushAppHistoryPath('/creation/puzzle/result');
|
||||
writeCreationUrlState(buildPuzzleCreationUrlState(session));
|
||||
}
|
||||
|
||||
function buildPuzzleDraftRuntimeUrlState(
|
||||
item: PuzzleWorkSummary,
|
||||
levelId?: string | null,
|
||||
@@ -4997,6 +5018,22 @@ export function PlatformEntryFlowShellImpl({
|
||||
[],
|
||||
);
|
||||
|
||||
const openRecommendShareModal = useCallback(
|
||||
(entry: PlatformPublicGalleryCard) => {
|
||||
const publicWorkCode = resolvePlatformPublicWorkCode(entry)?.trim();
|
||||
if (!publicWorkCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
openPublishShareModal({
|
||||
title: entry.worldName,
|
||||
publicWorkCode,
|
||||
stage: resolveRecommendEntryShareStage(entry),
|
||||
});
|
||||
},
|
||||
[openPublishShareModal],
|
||||
);
|
||||
|
||||
const openRpgPublishShareModal = useCallback(
|
||||
async (profile: CustomWorldProfile | null | undefined) => {
|
||||
const profileId = profile?.id?.trim();
|
||||
@@ -6398,6 +6435,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleRun(run);
|
||||
setPuzzleRuntimeAuthMode('default');
|
||||
setPuzzleRuntimeReturnStage('puzzle-result');
|
||||
pushPuzzleResultHistoryEntry(response.session);
|
||||
openPuzzleRuntimeStage(
|
||||
setSelectionStage,
|
||||
buildPuzzleDraftRuntimeUrlState(item, null),
|
||||
@@ -6763,6 +6801,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleRun(run);
|
||||
setPuzzleRuntimeAuthMode('default');
|
||||
setPuzzleRuntimeReturnStage('puzzle-result');
|
||||
pushPuzzleResultHistoryEntry(latestSession);
|
||||
openPuzzleRuntimeStage(
|
||||
setSelectionStage,
|
||||
buildPuzzleDraftRuntimeUrlState(item, null),
|
||||
@@ -7721,6 +7760,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleRun(run);
|
||||
setPuzzleRuntimeAuthMode('default');
|
||||
setPuzzleRuntimeReturnStage('puzzle-result');
|
||||
pushPuzzleResultHistoryEntry(response.session);
|
||||
openPuzzleRuntimeStage(
|
||||
setSelectionStage,
|
||||
buildPuzzleDraftRuntimeUrlState(item, null),
|
||||
@@ -11156,6 +11196,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleRun(run);
|
||||
setPuzzleRuntimeAuthMode('default');
|
||||
setPuzzleRuntimeReturnStage('puzzle-result');
|
||||
pushPuzzleResultHistoryEntry(puzzleSession);
|
||||
openPuzzleRuntimeStage(
|
||||
setSelectionStage,
|
||||
buildPuzzleDraftRuntimeUrlState(item, options.levelId ?? null),
|
||||
@@ -11170,8 +11211,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
},
|
||||
[
|
||||
isPuzzleBusy,
|
||||
puzzleSession?.publishedProfileId,
|
||||
puzzleSession?.sessionId,
|
||||
puzzleSession,
|
||||
resolvePuzzleErrorMessage,
|
||||
setIsPuzzleBusy,
|
||||
setPuzzleError,
|
||||
@@ -16552,6 +16592,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
onLikeRecommendEntry={(entry) => {
|
||||
likePublicWork(entry);
|
||||
}}
|
||||
onShareRecommendEntry={(entry) => {
|
||||
openRecommendShareModal(entry);
|
||||
}}
|
||||
onRemixRecommendEntry={(entry) => {
|
||||
remixPublicWork(entry);
|
||||
}}
|
||||
@@ -18332,7 +18375,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
error={puzzleError}
|
||||
hideBackButton={Boolean(puzzleOnboardingDraft)}
|
||||
onBack={() => {
|
||||
setSelectionStage(puzzleRuntimeReturnStage);
|
||||
const returnStage = puzzleRuntimeReturnStage;
|
||||
setSelectionStage(returnStage);
|
||||
if (returnStage === 'puzzle-result') {
|
||||
writeCreationUrlState(
|
||||
buildPuzzleCreationUrlState(puzzleSession),
|
||||
);
|
||||
}
|
||||
}}
|
||||
onRemodelWork={
|
||||
selectedPuzzleDetail?.publicationStatus === 'published'
|
||||
|
||||
@@ -427,6 +427,19 @@ describe('PuzzleResultView', () => {
|
||||
const formalImageCard = formalImageTitle
|
||||
.closest('.creative-image-input-panel__image-field')
|
||||
?.querySelector('.puzzle-image-upload-card');
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '查看关卡图片' }),
|
||||
);
|
||||
const imagePreviewDialog = screen.getByRole('dialog', {
|
||||
name: '查看关卡图片',
|
||||
});
|
||||
expect(within(imagePreviewDialog).getByAltText('暖灯猫街')).toBeTruthy();
|
||||
fireEvent.click(
|
||||
within(imagePreviewDialog).getByRole('button', {
|
||||
name: '关闭关卡图片预览',
|
||||
}),
|
||||
);
|
||||
expect(within(dialog).getByRole('button', { name: '更换参考图' })).toBeTruthy();
|
||||
const pictureDescriptionInput = within(dialog).getByLabelText('画面描述');
|
||||
expect(levelNameInput.closest('.platform-subpanel')).toBeNull();
|
||||
expect(formalImageTitle.closest('.platform-subpanel')).toBeNull();
|
||||
|
||||
@@ -867,6 +867,8 @@ function PuzzleLevelDetailDialog({
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '参考图预览',
|
||||
closePromptReferencePreview: '关闭参考图预览',
|
||||
previewMainImage: '查看关卡图片',
|
||||
closeMainImagePreview: '关闭关卡图片预览',
|
||||
history: '选择历史图片',
|
||||
}}
|
||||
onMainImageFileSelect={(file) => {
|
||||
|
||||
@@ -6103,10 +6103,19 @@ test('puzzle draft generation auto starts trial and runtime back opens draft res
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
window.history.back();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(window.location.pathname).toBe('/creation/puzzle/result');
|
||||
});
|
||||
await user.click(await screen.findByRole('button', { name: '返回上一页' }));
|
||||
|
||||
expect(await screen.findByText('拼图结果页')).toBeTruthy();
|
||||
expect(screen.getByDisplayValue('雨夜猫街')).toBeTruthy();
|
||||
const creationParams = new URLSearchParams(window.location.search);
|
||||
expect(creationParams.get('sessionId')).toBe('puzzle-session-auto-1');
|
||||
expect(creationParams.get('profileId')).toBe('puzzle-profile-auto-1');
|
||||
});
|
||||
|
||||
test('embedded puzzle form recovers when compile request times out after backend completion', async () => {
|
||||
@@ -7309,6 +7318,47 @@ test('home recommendation starts embedded puzzle without global auth reset on lo
|
||||
});
|
||||
});
|
||||
|
||||
test('home recommendation share opens publish share modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
const publishedPuzzleWork = {
|
||||
workId: 'puzzle-work-share-1',
|
||||
profileId: 'SHARE001',
|
||||
ownerUserId: 'user-2',
|
||||
sourceSessionId: 'puzzle-session-share-1',
|
||||
authorDisplayName: '拼图作者',
|
||||
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,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
} satisfies PuzzleWorkSummary;
|
||||
|
||||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||||
items: [publishedPuzzleWork],
|
||||
});
|
||||
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
|
||||
item: publishedPuzzleWork,
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
const meta = await screen.findByLabelText('星桥分享关 作品信息');
|
||||
await user.click(within(meta).getByRole('button', { name: '分享' }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '分享给朋友' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText(/作品号:PZ-SHARE001/u)).toBeTruthy();
|
||||
expect(screen.getByText(/\/gallery\/puzzle\/detail\?work=PZ-SHARE001/u))
|
||||
.toBeTruthy();
|
||||
});
|
||||
|
||||
test('home recommendation keeps logged-in puzzle start on default auth instead of guest token', async () => {
|
||||
const publishedPuzzleWork = {
|
||||
workId: 'puzzle-work-public-2',
|
||||
|
||||
@@ -3849,6 +3849,7 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
|
||||
const onSelectNextRecommendEntry = vi.fn();
|
||||
const onSelectPreviousRecommendEntry = vi.fn();
|
||||
const onLikeRecommendEntry = vi.fn();
|
||||
const onShareRecommendEntry = vi.fn();
|
||||
const onRemixRecommendEntry = vi.fn();
|
||||
const firstEntry = {
|
||||
...puzzlePublicEntry,
|
||||
@@ -3934,6 +3935,7 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
|
||||
onSelectNextRecommendEntry={onSelectNextRecommendEntry}
|
||||
onSelectPreviousRecommendEntry={onSelectPreviousRecommendEntry}
|
||||
onLikeRecommendEntry={onLikeRecommendEntry}
|
||||
onShareRecommendEntry={onShareRecommendEntry}
|
||||
onRemixRecommendEntry={onRemixRecommendEntry}
|
||||
onOpenLibraryDetail={vi.fn()}
|
||||
onSearchPublicCode={vi.fn()}
|
||||
@@ -3952,11 +3954,6 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
|
||||
expect(screen.getAllByText('上一拼图').length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.queryByText('评论')).toBeNull();
|
||||
expect(screen.queryByLabelText(/游玩/u)).toBeNull();
|
||||
const clipboardWriteText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: clipboardWriteText },
|
||||
});
|
||||
|
||||
const meta = screen.getByLabelText('当前拼图 作品信息') as HTMLElement;
|
||||
expect(meta.closest('[data-recommend-swipe-zone="true"]')).toBeTruthy();
|
||||
@@ -3978,10 +3975,9 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
|
||||
fireEvent.click(remixButton);
|
||||
|
||||
expect(onLikeRecommendEntry).toHaveBeenCalledWith(firstEntry);
|
||||
expect(onShareRecommendEntry).toHaveBeenCalledWith(firstEntry);
|
||||
expect(onRemixRecommendEntry).toHaveBeenCalledWith(firstEntry);
|
||||
expect(clipboardWriteText).toHaveBeenCalledWith(
|
||||
expect.stringContaining('作品号:PZ-FEED1'),
|
||||
);
|
||||
expect(activeRecommendCard.getByRole('button', { name: '分享' })).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
dispatchPointerEvent(meta, 'pointerdown', { pointerId: 1, clientY: 300 });
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
Loader2,
|
||||
Palette,
|
||||
Pencil,
|
||||
Plus,
|
||||
ScanLine,
|
||||
Search,
|
||||
Settings,
|
||||
@@ -80,7 +79,6 @@ import type {
|
||||
WechatNativePayment,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import {
|
||||
getPublicAuthUserByCode,
|
||||
@@ -154,7 +152,6 @@ import {
|
||||
type PlatformPublicGalleryCard,
|
||||
type PlatformWorldCardLike,
|
||||
resolvePlatformWorkAuthorDisplayName,
|
||||
resolvePlatformPublicWorkCode,
|
||||
resolvePlatformWorldCoverImage,
|
||||
resolvePlatformWorldCoverSlides,
|
||||
resolvePlatformWorldFallbackCoverImage,
|
||||
@@ -201,6 +198,7 @@ export interface RpgEntryHomeViewProps {
|
||||
onSelectNextRecommendEntry?: (activeEntryKey?: string | null) => void;
|
||||
onSelectPreviousRecommendEntry?: (activeEntryKey?: string | null) => void;
|
||||
onLikeRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
|
||||
onShareRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
|
||||
onRemixRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
|
||||
onOpenLibraryDetail: (
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
||||
@@ -1063,7 +1061,6 @@ function RecommendSwipeCard({
|
||||
authorSummary,
|
||||
isActive,
|
||||
visual,
|
||||
shareState,
|
||||
onDragPointerDown,
|
||||
onDragPointerMove,
|
||||
onDragPointerUp,
|
||||
@@ -1077,7 +1074,6 @@ function RecommendSwipeCard({
|
||||
authorSummary?: PublicUserSummary | null;
|
||||
isActive: boolean;
|
||||
visual: ReactNode;
|
||||
shareState?: 'idle' | 'copied' | 'failed';
|
||||
onDragPointerDown?: (event: PointerEvent<HTMLElement>) => void;
|
||||
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
|
||||
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
|
||||
@@ -1101,7 +1097,6 @@ function RecommendSwipeCard({
|
||||
authorAvatarUrl={authorAvatarUrl}
|
||||
authorSummary={authorSummary}
|
||||
isActive={isActive}
|
||||
shareState={shareState}
|
||||
onDragPointerDown={onDragPointerDown}
|
||||
onDragPointerMove={onDragPointerMove}
|
||||
onDragPointerUp={onDragPointerUp}
|
||||
@@ -1123,7 +1118,6 @@ function RecommendRuntimeMeta({
|
||||
onDragPointerMove,
|
||||
onDragPointerUp,
|
||||
onDragPointerCancel,
|
||||
shareState = 'idle',
|
||||
onLike,
|
||||
onShare,
|
||||
onRemix,
|
||||
@@ -1136,7 +1130,6 @@ function RecommendRuntimeMeta({
|
||||
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
|
||||
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
|
||||
onDragPointerCancel?: (event: PointerEvent<HTMLElement>) => void;
|
||||
shareState?: 'idle' | 'copied' | 'failed';
|
||||
onLike?: () => void;
|
||||
onShare?: () => void;
|
||||
onRemix?: () => void;
|
||||
@@ -1227,13 +1220,7 @@ function RecommendRuntimeMeta({
|
||||
onShare?.();
|
||||
}}
|
||||
disabled={!isActive || !onShare}
|
||||
aria-label={
|
||||
shareState === 'copied'
|
||||
? '分享内容已复制'
|
||||
: shareState === 'failed'
|
||||
? '分享内容复制失败'
|
||||
: '分享'
|
||||
}
|
||||
aria-label="分享"
|
||||
title="分享"
|
||||
>
|
||||
<Share2 className="h-5 w-5" aria-hidden="true" />
|
||||
@@ -4139,6 +4126,7 @@ export function RpgEntryHomeView({
|
||||
onSelectNextRecommendEntry,
|
||||
onSelectPreviousRecommendEntry,
|
||||
onLikeRecommendEntry,
|
||||
onShareRecommendEntry,
|
||||
onRemixRecommendEntry,
|
||||
onOpenLibraryDetail,
|
||||
onDeleteLibraryEntry,
|
||||
@@ -4420,7 +4408,7 @@ export function RpgEntryHomeView({
|
||||
? {
|
||||
home: Sparkles,
|
||||
category: Compass,
|
||||
create: Plus,
|
||||
create: Sparkles,
|
||||
saves: Pencil,
|
||||
profile: UserRound,
|
||||
}
|
||||
@@ -5531,13 +5519,9 @@ export function RpgEntryHomeView({
|
||||
const [recommendDragOffsetY, setRecommendDragOffsetY] = useState(0);
|
||||
const [recommendDragCommitDirection, setRecommendDragCommitDirection] =
|
||||
useState<1 | -1 | null>(null);
|
||||
const [recommendShareState, setRecommendShareState] = useState<
|
||||
'idle' | 'copied' | 'failed'
|
||||
>('idle');
|
||||
const activeRecommendEntryKeyForSelection = activeRecommendEntry
|
||||
? buildPublicGalleryCardKey(activeRecommendEntry)
|
||||
: null;
|
||||
const recommendShareResetTimerRef = useRef<number | null>(null);
|
||||
const recommendCardStageRef = useRef<HTMLDivElement | null>(null);
|
||||
const recommendDragStartRef = useRef<{
|
||||
pointerId: number;
|
||||
@@ -5675,39 +5659,6 @@ export function RpgEntryHomeView({
|
||||
onSelectNextRecommendEntry,
|
||||
recommendedFeedEntries.length,
|
||||
]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (recommendShareResetTimerRef.current !== null) {
|
||||
window.clearTimeout(recommendShareResetTimerRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
useEffect(() => {
|
||||
setRecommendShareState('idle');
|
||||
}, [activeRecommendEntryKey]);
|
||||
const shareRecommendEntry = useCallback(
|
||||
(entry: PlatformPublicGalleryCard) => {
|
||||
const publicWorkCode = resolvePlatformPublicWorkCode(entry)?.trim();
|
||||
if (!publicWorkCode) {
|
||||
setRecommendShareState('failed');
|
||||
return;
|
||||
}
|
||||
|
||||
const shareText = `邀请你来玩《${entry.worldName}》\n作品号:${publicWorkCode}\n${buildPublicWorkDetailUrl(publicWorkCode)}`;
|
||||
void copyTextToClipboard(shareText).then((copied) => {
|
||||
setRecommendShareState(copied ? 'copied' : 'failed');
|
||||
if (recommendShareResetTimerRef.current !== null) {
|
||||
window.clearTimeout(recommendShareResetTimerRef.current);
|
||||
}
|
||||
recommendShareResetTimerRef.current = window.setTimeout(() => {
|
||||
recommendShareResetTimerRef.current = null;
|
||||
setRecommendShareState('idle');
|
||||
}, 1400);
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
const leadPublicEntry = featuredShelf[0] ?? generalLatestEntries[0] ?? null;
|
||||
const openLeadPublicEntry = () => {
|
||||
if (leadPublicEntry) {
|
||||
@@ -5851,9 +5802,8 @@ export function RpgEntryHomeView({
|
||||
onDragPointerMove={moveRecommendDrag}
|
||||
onDragPointerUp={endRecommendDrag}
|
||||
onDragPointerCancel={cancelRecommendDrag}
|
||||
shareState={recommendShareState}
|
||||
onLike={() => onLikeRecommendEntry?.(activeRecommendEntry)}
|
||||
onShare={() => shareRecommendEntry(activeRecommendEntry)}
|
||||
onShare={() => onShareRecommendEntry?.(activeRecommendEntry)}
|
||||
onRemix={() => onRemixRecommendEntry?.(activeRecommendEntry)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user