-
难度
-
{DIFFICULTY_LABELS[config.difficultyPreset]}
+
+
难度
+
+ {DIFFICULTY_LABELS[config.difficultyPreset]}
+
-
-
排行榜
-
{config.leaderboardEnabled ? 'å¼€å¯' : 'å…³é—'}
+
+
排行榜
+
+ {config.leaderboardEnabled ? 'å¼€å¯' : 'å…³é—'}
+
diff --git a/src/components/common/CreativeImageInputPanel.test.tsx b/src/components/common/CreativeImageInputPanel.test.tsx
new file mode 100644
index 00000000..11c175e1
--- /dev/null
+++ b/src/components/common/CreativeImageInputPanel.test.tsx
@@ -0,0 +1,145 @@
+/* @vitest-environment jsdom */
+
+import { fireEvent, render, screen, within } from '@testing-library/react';
+import { expect, test, vi } from 'vitest';
+
+import { CreativeImageInputPanel } from './CreativeImageInputPanel';
+
+vi.mock('../ResolvedAssetImage', () => ({
+ ResolvedAssetImage: ({
+ src,
+ alt,
+ className,
+ }: {
+ src?: string | null;
+ alt?: string;
+ className?: string;
+ }) => (src ?

: null),
+}));
+
+test('creative image input panel handles reference uploads and preview', () => {
+ const onPromptReferenceFilesSelect = vi.fn();
+ const onPromptReferenceRemove = vi.fn();
+ const onSubmit = vi.fn();
+
+ render(
+
}
+ submitLabel="生æˆ"
+ submitDisabled={false}
+ labels={{
+ imageField: '拼图画é¢',
+ uploadImage: 'ä¸Šä¼ æ‹¼å›¾å›¾ç‰‡',
+ replaceImage: 'æ›´æ¢æ‹¼å›¾å›¾ç‰‡',
+ emptyImageHint: 'ä¸Šä¼ å›¾ç‰‡/å¡«å†™ç”»é¢æè¿°',
+ removeImage: '移除拼图图片',
+ removeImageConfirmTitle: '移除拼图图片?',
+ removeImageConfirmBody: '移除åŽéœ€è¦é‡æ–°ä¸Šä¼ 图片。',
+ promptReferenceUpload: 'ä¸Šä¼ å‚考图',
+ promptReferencePreviewAlt: 'å‚考图预览',
+ closePromptReferencePreview: 'å…³é—å‚考图预览',
+ }}
+ onMainImageFileSelect={() => {}}
+ onMainImageRemove={() => {}}
+ onAiRedrawChange={() => {}}
+ onPromptChange={() => {}}
+ onPromptReferenceFilesSelect={onPromptReferenceFilesSelect}
+ onPromptReferenceRemove={onPromptReferenceRemove}
+ onSubmit={onSubmit}
+ />,
+ );
+
+ const promptReferenceInput = screen.getByLabelText('ä¸Šä¼ å‚考图', {
+ selector: 'input',
+ });
+ expect((promptReferenceInput as HTMLInputElement).multiple).toBe(true);
+
+ fireEvent.change(promptReferenceInput, {
+ target: {
+ files: [
+ new File(['a'], 'ref-1.png', { type: 'image/png' }),
+ new File(['b'], 'ref-2.png', { type: 'image/png' }),
+ ],
+ },
+ });
+ expect(onPromptReferenceFilesSelect).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ expect.any(File),
+ expect.any(File),
+ ]),
+ );
+
+ fireEvent.click(
+ screen.getByRole('button', { name: '预览å‚考图 å‚考图 1' }),
+ );
+ expect(
+ screen.getByRole('dialog', { name: 'å‚考图 1' }),
+ ).toBeTruthy();
+ expect(screen.getByAltText('å‚考图预览')).toHaveProperty(
+ 'src',
+ expect.stringContaining('ref-1'),
+ );
+ fireEvent.click(screen.getByRole('button', { name: 'å…³é—å‚考图预览' }));
+ fireEvent.click(screen.getByRole('button', { name: '移除å‚考图 å‚考图 1' }));
+ expect(onPromptReferenceRemove).toHaveBeenCalledWith('ref-1');
+
+ fireEvent.click(screen.getByRole('button', { name: '生æˆ' }));
+ expect(onSubmit).toHaveBeenCalledTimes(1);
+});
+
+test('creative image input panel confirms before removing uploaded image', () => {
+ const onMainImageRemove = vi.fn();
+
+ render(
+
}
+ submitLabel="生æˆ"
+ submitDisabled={false}
+ labels={{
+ imageField: '拼图画é¢',
+ uploadImage: 'ä¸Šä¼ æ‹¼å›¾å›¾ç‰‡',
+ replaceImage: 'æ›´æ¢æ‹¼å›¾å›¾ç‰‡',
+ emptyImageHint: 'ä¸Šä¼ å›¾ç‰‡/å¡«å†™ç”»é¢æè¿°',
+ removeImage: '移除拼图图片',
+ removeImageConfirmTitle: '移除拼图图片?',
+ removeImageConfirmBody: '移除åŽéœ€è¦é‡æ–°ä¸Šä¼ 图片。',
+ promptReferenceUpload: 'ä¸Šä¼ å‚考图',
+ promptReferencePreviewAlt: 'å‚考图预览',
+ closePromptReferencePreview: 'å…³é—å‚考图预览',
+ }}
+ onMainImageFileSelect={() => {}}
+ onMainImageRemove={onMainImageRemove}
+ onAiRedrawChange={() => {}}
+ onPromptChange={() => {}}
+ onSubmit={() => {}}
+ />,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: '移除拼图图片' }));
+ const dialog = screen.getByRole('dialog', { name: '移除拼图图片?' });
+ expect(within(dialog).getByText('移除åŽéœ€è¦é‡æ–°ä¸Šä¼ 图片。')).toBeTruthy();
+ fireEvent.click(within(dialog).getByRole('button', { name: '移除' }));
+ expect(onMainImageRemove).toHaveBeenCalledTimes(1);
+});
diff --git a/src/components/common/CreativeImageInputPanel.tsx b/src/components/common/CreativeImageInputPanel.tsx
new file mode 100644
index 00000000..90a7a431
--- /dev/null
+++ b/src/components/common/CreativeImageInputPanel.tsx
@@ -0,0 +1,463 @@
+import {
+ History,
+ ImagePlus,
+ Loader2,
+ Sparkles,
+ Trash2,
+ X,
+} from 'lucide-react';
+import { type ReactNode, useEffect, useState } from 'react';
+
+import { ResolvedAssetImage } from '../ResolvedAssetImage';
+
+export type CreativeImageInputReferenceImage = {
+ id: string;
+ label: string;
+ imageSrc: string;
+};
+
+export type CreativeImageInputPanelLabels = {
+ imageField: string;
+ uploadImage: string;
+ replaceImage: string;
+ emptyImageHint: string;
+ removeImage: string;
+ removeImageConfirmTitle: string;
+ removeImageConfirmBody: string;
+ promptReferenceUpload: string;
+ promptReferencePreviewAlt: string;
+ closePromptReferencePreview: string;
+ history?: string;
+};
+
+export type CreativeImageInputPanelProps = {
+ className?: string;
+ disabled?: boolean;
+ isSubmitting?: boolean;
+ uploadedImageSrc: string;
+ uploadedImageAlt: string;
+ mainImageInputId: string;
+ mainImageAccept?: string;
+ promptTextareaId: string;
+ prompt: string;
+ promptLabel: string;
+ promptRows?: number;
+ aiRedraw: boolean;
+ promptReferenceImages: CreativeImageInputReferenceImage[];
+ promptReferenceLimit?: number;
+ imageModelPicker?: ReactNode;
+ error?: string | null;
+ inputError?: string | null;
+ submitLabel: string;
+ submitCostLabel?: string | null;
+ submitDisabled: boolean;
+ labels: CreativeImageInputPanelLabels;
+ onMainImageFileSelect: (file: File) => void;
+ onMainImageRemove: () => void;
+ onAiRedrawChange: (enabled: boolean) => void;
+ onPromptChange: (value: string) => void;
+ onPromptReferenceFilesSelect?: (files: File[]) => void;
+ onPromptReferenceRemove?: (referenceId: string) => void;
+ onHistoryClick?: () => void;
+ onSubmit: () => void;
+};
+
+const DEFAULT_IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp';
+const DEFAULT_PROMPT_REFERENCE_LIMIT = 5;
+
+export function CreativeImageInputPanel({
+ className = '',
+ disabled = false,
+ isSubmitting = false,
+ uploadedImageSrc,
+ uploadedImageAlt,
+ mainImageInputId,
+ mainImageAccept = DEFAULT_IMAGE_ACCEPT,
+ promptTextareaId,
+ prompt,
+ promptLabel,
+ promptRows = 2,
+ aiRedraw,
+ promptReferenceImages,
+ promptReferenceLimit = DEFAULT_PROMPT_REFERENCE_LIMIT,
+ imageModelPicker = null,
+ error = null,
+ inputError = null,
+ submitLabel,
+ submitCostLabel = null,
+ submitDisabled,
+ labels,
+ onMainImageFileSelect,
+ onMainImageRemove,
+ onAiRedrawChange,
+ onPromptChange,
+ onPromptReferenceFilesSelect,
+ onPromptReferenceRemove,
+ onHistoryClick,
+ onSubmit,
+}: CreativeImageInputPanelProps) {
+ const [previewReferenceImage, setPreviewReferenceImage] =
+ useState
(null);
+ const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] =
+ useState(false);
+ const showPrompt = !uploadedImageSrc || aiRedraw;
+ const promptReferenceUploadDisabled =
+ disabled || promptReferenceImages.length >= promptReferenceLimit;
+
+ useEffect(() => {
+ if (uploadedImageSrc) {
+ setPreviewReferenceImage(null);
+ }
+ }, [uploadedImageSrc]);
+
+ useEffect(() => {
+ if (
+ previewReferenceImage &&
+ !promptReferenceImages.some(
+ (reference) => reference.id === previewReferenceImage.id,
+ )
+ ) {
+ setPreviewReferenceImage(null);
+ }
+ }, [previewReferenceImage, promptReferenceImages]);
+
+ return (
+
+
+
+
+
+
+ {labels.imageField}
+
+
+
+
{
+ const file = event.currentTarget.files?.[0] ?? null;
+ event.currentTarget.value = '';
+ if (file) {
+ onMainImageFileSelect(file);
+ }
+ }}
+ className="sr-only"
+ />
+
+ {uploadedImageSrc ? (
+
+ ) : (
+
+
+
+
+
+ )}
+
+ {onHistoryClick ? (
+
+ ) : null}
+ {uploadedImageSrc ? (
+
+ ) : null}
+ {uploadedImageSrc ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {showPrompt ? (
+
+
+
+
+ {!uploadedImageSrc && promptReferenceImages.length > 0 ? (
+
+ {promptReferenceImages.map((reference) => (
+
+
+ {onPromptReferenceRemove ? (
+
+ ) : null}
+
+ ))}
+
+ ) : null}
+
+ ) : null}
+
+
+
+ {inputError ? (
+
+ {inputError}
+
+ ) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+
+
+
+
+
+ {previewReferenceImage ? (
+
setPreviewReferenceImage(null)}
+ >
+
event.stopPropagation()}
+ >
+
+
+ {previewReferenceImage.label}
+
+
+
+
+
+
+
+
+ ) : null}
+
+ {isRemoveImageConfirmOpen ? (
+
+
+
+ {labels.removeImageConfirmTitle}
+
+
+ {labels.removeImageConfirmBody}
+
+
+
+
+
+
+
+ ) : null}
+
+ );
+}
+
+export default CreativeImageInputPanel;
diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx
index c3062fc4..59a8f390 100644
--- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx
+++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx
@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
-import { render, screen } from '@testing-library/react';
+import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
@@ -83,7 +83,9 @@ const testEntryConfig = {
],
} satisfies CreationEntryConfig;
-const testCreationTypes = derivePlatformCreationTypes(testEntryConfig.creationTypes);
+const testCreationTypes = derivePlatformCreationTypes(
+ testEntryConfig.creationTypes,
+);
const originalClipboard = navigator.clipboard;
@@ -315,7 +317,9 @@ test('creation hub hides square hole works when the creation type is hidden', ()
);
expect(screen.queryByText('éšè—方洞挑战')).toBeNull();
- expect(screen.queryByText('å…¥å£éšè—åŽï¼Œè¿™æ¡ä½œå“ä¸åº”å‡ºçŽ°åœ¨åˆ›ä½œé¡µä½œå“æž¶ã€‚')).toBeNull();
+ expect(
+ screen.queryByText('å…¥å£éšè—åŽï¼Œè¿™æ¡ä½œå“ä¸åº”å‡ºçŽ°åœ¨åˆ›ä½œé¡µä½œå“æž¶ã€‚'),
+ ).toBeNull();
});
test('creation hub mixes puzzle works into the same grid and uses puzzle tag to distinguish', () => {
@@ -494,10 +498,43 @@ test('creation hub hides persisted draft delete action behind swipe underlay', (
/>,
);
- expect(container.querySelector('.creation-work-card__swipe-underlay')).toBeTruthy();
+ expect(
+ container.querySelector('.creation-work-card__swipe-underlay'),
+ ).toBeTruthy();
expect(screen.queryByRole('button', { name: 'åˆ é™¤' })).toBeNull();
});
+test('creation hub reveals persisted draft delete action from left swipe', () => {
+ const { container } = render(
+ {}}
+ onCreateType={noopCreateType}
+ onOpenDraft={() => {}}
+ onEnterPublished={() => {}}
+ onDeletePublished={() => {}}
+ entryConfig={testEntryConfig}
+ creationTypes={testCreationTypes}
+ />,
+ );
+
+ const card = screen.getByRole('button', { name: /ç»§ç»å®Œå–„《潮雾列岛》/u });
+ fireEvent.touchStart(card, {
+ touches: [{ clientX: 180, clientY: 20 }],
+ });
+ fireEvent.touchMove(card, {
+ touches: [{ clientX: 92, clientY: 22 }],
+ });
+ fireEvent.touchEnd(card);
+
+ expect(screen.getByRole('button', { name: 'åˆ é™¤' })).toBeTruthy();
+ expect(
+ container.querySelector('.creation-work-card-shell--actions-visible'),
+ ).toBeTruthy();
+});
+
test('creation hub reveals persisted draft delete action from keyboard', async () => {
const user = userEvent.setup();
render(
@@ -519,6 +556,7 @@ test('creation hub reveals persisted draft delete action from keyboard', async (
await user.keyboard('{ArrowLeft}');
expect(screen.getByRole('button', { name: 'åˆ é™¤' })).toBeTruthy();
+ expect(screen.queryByRole('button', { name: '分享' })).toBeNull();
});
test('creation hub shows delete action for baby object match drafts', async () => {
@@ -548,11 +586,13 @@ test('creation hub shows delete action for baby object match drafts', async () =
await user.click(screen.getByRole('button', { name: 'åˆ é™¤' }));
- expect(onDeleteBabyObjectMatch).toHaveBeenCalledWith(babyObjectMatchDraftItem);
+ expect(onDeleteBabyObjectMatch).toHaveBeenCalledWith(
+ babyObjectMatchDraftItem,
+ );
expect(onOpenBabyObjectMatchDetail).not.toHaveBeenCalled();
});
-test('creation hub published work delete action is available beside share without opening card', async () => {
+test('creation hub published work delete action is revealed without opening card', async () => {
const user = userEvent.setup();
const onDeletePuzzle = vi.fn();
const onOpenPuzzleDetail = vi.fn();
@@ -705,3 +745,35 @@ test('creation hub published swipe share button copies share text without openin
await screen.findByRole('button', { name: '分享内容已å¤åˆ¶' }),
).toBeTruthy();
});
+
+test('creation hub left swipe draft reveals delete without opening card', () => {
+ const onDeletePublished = vi.fn();
+ const onOpenDraft = vi.fn();
+
+ render(
+ {}}
+ onCreateType={noopCreateType}
+ onOpenDraft={onOpenDraft}
+ onEnterPublished={() => {}}
+ onDeletePublished={onDeletePublished}
+ entryConfig={testEntryConfig}
+ creationTypes={testCreationTypes}
+ />,
+ );
+
+ const card = screen.getByRole('button', { name: /ç»§ç»å®Œå–„《潮雾列岛》/u });
+ fireEvent.touchStart(card, {
+ touches: [{ clientX: 180, clientY: 20 }],
+ });
+ fireEvent.touchMove(card, {
+ touches: [{ clientX: 88, clientY: 22 }],
+ });
+ fireEvent.touchEnd(card);
+
+ expect(screen.getByRole('button', { name: 'åˆ é™¤' })).toBeTruthy();
+ expect(onOpenDraft).not.toHaveBeenCalled();
+});
diff --git a/src/components/custom-world-home/CustomWorldWorkCard.tsx b/src/components/custom-world-home/CustomWorldWorkCard.tsx
index c11ee7b3..d940663d 100644
--- a/src/components/custom-world-home/CustomWorldWorkCard.tsx
+++ b/src/components/custom-world-home/CustomWorldWorkCard.tsx
@@ -1,8 +1,15 @@
-import { Share2, Trash2 } from 'lucide-react';
+import {
+ BadgeCheck,
+ Clock3,
+ Loader2,
+ Share2,
+ Trash2,
+} from 'lucide-react';
import {
type CSSProperties,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
+ type TouchEvent as ReactTouchEvent,
useEffect,
useMemo,
useRef,
@@ -239,7 +246,8 @@ export function CustomWorldWorkCard({
const [isSwipeActionRevealed, setIsSwipeActionRevealed] = useState(false);
const [swipeOffset, setSwipeOffset] = useState(0);
const isPublished = item.status === 'published';
- const canUseShareAction = isPublished && item.canShare && Boolean(item.sharePath);
+ const canUseShareAction =
+ isPublished && item.canShare && Boolean(item.sharePath);
const swipeActionCount = (canUseShareAction ? 1 : 0) + (onDelete ? 1 : 0);
const swipeRevealWidth = swipeActionCount * SWIPE_ACTION_WIDTH_PX;
const canClaimPointIncentive =
@@ -252,54 +260,28 @@ export function CustomWorldWorkCard({
isPublished ? item.metrics : EMPTY_PUBLISHED_METRICS,
previousMetricValues,
);
- const surfaceOffset = isSwipeDragging
+ const coverFadeStyle = {
+ WebkitMaskImage:
+ 'linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.04) 18%, rgba(0, 0, 0, 0.18) 42%, rgba(0, 0, 0, 0.48) 70%, rgba(0, 0, 0, 0.72) 100%)',
+ maskImage:
+ 'linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.04) 18%, rgba(0, 0, 0, 0.18) 42%, rgba(0, 0, 0, 0.48) 70%, rgba(0, 0, 0, 0.72) 100%)',
+ } as CSSProperties;
+ const currentSwipeOffset = isSwipeDragging
? swipeOffset
: isSwipeActionRevealed
? -swipeRevealWidth
: 0;
- const swipeActionOpacity =
- swipeRevealWidth > 0 ? Math.min(1, Math.abs(surfaceOffset) / swipeRevealWidth) : 0;
- const swipeSurfaceStyle = {
- '--creation-work-card-swipe-offset': `${surfaceOffset}px`,
- } as CSSProperties;
- const swipeShellStyle = {
- '--creation-work-card-action-opacity': `${swipeActionOpacity}`,
- } as CSSProperties;
- const sideCoverStyle = {
+ const cardSurfaceStyle = {
+ '--creation-work-card-swipe-offset': `${currentSwipeOffset}px`,
'--creation-work-card-cover-fallback': `url(${fallbackCoverImageSrc})`,
} as CSSProperties;
-
- const closeSwipeActions = () => {
- setIsSwipeActionRevealed(false);
- setSwipeOffset(0);
- lastSwipeOffsetRef.current = 0;
- };
-
- const revealSwipeActions = () => {
- if (swipeRevealWidth <= 0) {
- closeSwipeActions();
- return;
- }
-
- setIsSwipeActionRevealed(true);
- setSwipeOffset(-swipeRevealWidth);
- lastSwipeOffsetRef.current = -swipeRevealWidth;
- };
-
- const scheduleOpenSuppressReset = () => {
- if (typeof window === 'undefined') {
- return;
- }
-
- if (suppressOpenResetTimerRef.current !== null) {
- window.clearTimeout(suppressOpenResetTimerRef.current);
- }
-
- suppressOpenResetTimerRef.current = window.setTimeout(() => {
- suppressOpenResetTimerRef.current = null;
- suppressOpenRef.current = false;
- }, 260);
- };
+ const swipeShellStyle = {
+ '--creation-work-card-action-opacity': `${
+ swipeRevealWidth > 0
+ ? Math.min(1, Math.abs(currentSwipeOffset) / swipeRevealWidth)
+ : 0
+ }`,
+ } as CSSProperties;
const copyShareText = () => {
const publicWorkCode = item.publicWorkCode?.trim();
@@ -324,57 +306,42 @@ export function CustomWorldWorkCard({
}, 1400);
});
};
- useEffect(
- () => () => {
+ useEffect(() => {
+ return () => {
if (shareResetTimerRef.current !== null) {
window.clearTimeout(shareResetTimerRef.current);
}
if (suppressOpenResetTimerRef.current !== null) {
window.clearTimeout(suppressOpenResetTimerRef.current);
}
- },
- [],
- );
-
- useEffect(() => {
- if (swipeActionCount > 0) {
- return;
- }
-
- closeSwipeActions();
- }, [swipeActionCount]);
-
- const beginSwipeGesture = (
- event: ReactPointerEvent,
- ) => {
- if (swipeRevealWidth <= 0) {
- return;
- }
-
- if (event.pointerType === 'mouse' && event.button !== 0) {
- return;
- }
-
- swipeGestureRef.current = {
- pointerId: event.pointerId,
- startX: event.clientX,
- startY: event.clientY,
- startOffset: isSwipeActionRevealed ? -swipeRevealWidth : 0,
- isDragging: false,
};
- event.currentTarget.setPointerCapture?.(event.pointerId);
+ }, []);
+
+ const closeSwipeActions = () => {
+ setIsSwipeActionRevealed(false);
+ setSwipeOffset(0);
+ lastSwipeOffsetRef.current = 0;
};
- const updateSwipeGesture = (
- event: ReactPointerEvent,
- ) => {
- const gesture = swipeGestureRef.current;
- if (!gesture || gesture.pointerId !== event.pointerId) {
+ const revealSwipeActions = () => {
+ if (swipeRevealWidth <= 0) {
+ closeSwipeActions();
return;
}
- const deltaX = event.clientX - gesture.startX;
- const deltaY = event.clientY - gesture.startY;
+ setIsSwipeActionRevealed(true);
+ setSwipeOffset(-swipeRevealWidth);
+ lastSwipeOffsetRef.current = -swipeRevealWidth;
+ };
+
+ const updateSwipeOffset = (
+ gesture: NonNullable,
+ clientX: number,
+ clientY: number,
+ preventDefault: () => void,
+ ) => {
+ const deltaX = clientX - gesture.startX;
+ const deltaY = clientY - gesture.startY;
if (!gesture.isDragging) {
if (
Math.abs(deltaX) < SWIPE_DIRECTION_LOCK_PX &&
@@ -393,7 +360,7 @@ export function CustomWorldWorkCard({
}
// 䏿–‡æ³¨é‡Šï¼šæ¨ªå‘手势åªç§»åЍå¡ç‰‡è¡¨å±‚ï¼Œåˆ é™¤åŠ¨ä½œä¿æŒåœ¨åº•层,é¿å…列表滚动时误触。
- event.preventDefault();
+ preventDefault();
suppressOpenRef.current = true;
const nextOffset = clampSwipeOffset(
gesture.startOffset + deltaX,
@@ -403,19 +370,9 @@ export function CustomWorldWorkCard({
setSwipeOffset(nextOffset);
};
- const endSwipeGesture = (
- event: ReactPointerEvent,
- ) => {
- const gesture = swipeGestureRef.current;
- if (!gesture || gesture.pointerId !== event.pointerId) {
- return;
- }
-
- event.currentTarget.releasePointerCapture?.(event.pointerId);
- swipeGestureRef.current = null;
+ const finishSwipeGesture = (wasDragging: boolean) => {
setIsSwipeDragging(false);
-
- if (!gesture.isDragging) {
+ if (!wasDragging) {
return;
}
@@ -431,9 +388,74 @@ export function CustomWorldWorkCard({
scheduleOpenSuppressReset();
};
- const cancelSwipeGesture = (
- event: ReactPointerEvent,
- ) => {
+ const scheduleOpenSuppressReset = () => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ if (suppressOpenResetTimerRef.current !== null) {
+ window.clearTimeout(suppressOpenResetTimerRef.current);
+ }
+
+ suppressOpenResetTimerRef.current = window.setTimeout(() => {
+ suppressOpenResetTimerRef.current = null;
+ suppressOpenRef.current = false;
+ }, 260);
+ };
+
+ useEffect(() => {
+ if (swipeActionCount > 0) {
+ return;
+ }
+
+ closeSwipeActions();
+ }, [swipeActionCount]);
+
+ const beginSwipeGesture = (event: ReactPointerEvent) => {
+ if (swipeRevealWidth <= 0) {
+ return;
+ }
+
+ if (event.pointerType === 'mouse' && event.button !== 0) {
+ return;
+ }
+
+ swipeGestureRef.current = {
+ pointerId: event.pointerId,
+ startX: event.clientX,
+ startY: event.clientY,
+ startOffset: isSwipeActionRevealed ? -swipeRevealWidth : 0,
+ isDragging: false,
+ };
+ event.currentTarget.setPointerCapture?.(event.pointerId);
+ };
+
+ const updateSwipeGesture = (event: ReactPointerEvent) => {
+ const gesture = swipeGestureRef.current;
+ if (!gesture || gesture.pointerId !== event.pointerId) {
+ return;
+ }
+
+ updateSwipeOffset(
+ gesture,
+ event.clientX,
+ event.clientY,
+ () => event.preventDefault(),
+ );
+ };
+
+ const endSwipeGesture = (event: ReactPointerEvent) => {
+ const gesture = swipeGestureRef.current;
+ if (!gesture || gesture.pointerId !== event.pointerId) {
+ return;
+ }
+
+ event.currentTarget.releasePointerCapture?.(event.pointerId);
+ swipeGestureRef.current = null;
+ finishSwipeGesture(gesture.isDragging);
+ };
+
+ const cancelSwipeGesture = (event: ReactPointerEvent) => {
const gesture = swipeGestureRef.current;
if (gesture?.pointerId === event.pointerId) {
event.currentTarget.releasePointerCapture?.(event.pointerId);
@@ -448,6 +470,69 @@ export function CustomWorldWorkCard({
}
};
+ const beginTouchSwipeGesture = (
+ event: ReactTouchEvent,
+ ) => {
+ if (swipeRevealWidth <= 0) {
+ return;
+ }
+
+ const touch = event.touches[0];
+ if (!touch) {
+ return;
+ }
+
+ swipeGestureRef.current = {
+ pointerId: -1,
+ startX: touch.clientX,
+ startY: touch.clientY,
+ startOffset: isSwipeActionRevealed ? -swipeRevealWidth : 0,
+ isDragging: false,
+ };
+ };
+
+ const updateTouchSwipeGesture = (
+ event: ReactTouchEvent,
+ ) => {
+ const gesture = swipeGestureRef.current;
+ const touch = event.touches[0];
+ if (!gesture || gesture.pointerId !== -1 || !touch) {
+ return;
+ }
+
+ updateSwipeOffset(
+ gesture,
+ touch.clientX,
+ touch.clientY,
+ () => event.preventDefault(),
+ );
+ };
+
+ const endTouchSwipeGesture = () => {
+ const gesture = swipeGestureRef.current;
+ if (!gesture || gesture.pointerId !== -1) {
+ return;
+ }
+
+ swipeGestureRef.current = null;
+ finishSwipeGesture(gesture.isDragging);
+ };
+
+ const cancelTouchSwipeGesture = () => {
+ const gesture = swipeGestureRef.current;
+ if (!gesture || gesture.pointerId !== -1) {
+ return;
+ }
+
+ swipeGestureRef.current = null;
+ setIsSwipeDragging(false);
+ if (isSwipeActionRevealed) {
+ revealSwipeActions();
+ } else {
+ closeSwipeActions();
+ }
+ };
+
const handleCardOpen = () => {
if (isSwipeActionRevealed) {
closeSwipeActions();
@@ -458,7 +543,12 @@ export function CustomWorldWorkCard({
};
const handleCardKeyDown = (event: ReactKeyboardEvent) => {
- if (event.key === 'ArrowLeft' && swipeRevealWidth > 0) {
+ if (
+ (event.key === 'ArrowLeft' ||
+ event.key === 'ContextMenu' ||
+ (event.shiftKey && event.key === 'F10')) &&
+ swipeRevealWidth > 0
+ ) {
event.preventDefault();
revealSwipeActions();
return;
@@ -578,38 +668,59 @@ export function CustomWorldWorkCard({
onPointerMove={updateSwipeGesture}
onPointerUp={endSwipeGesture}
onPointerCancel={cancelSwipeGesture}
- style={swipeSurfaceStyle}
+ onTouchStart={beginTouchSwipeGesture}
+ onTouchMove={updateTouchSwipeGesture}
+ onTouchEnd={endTouchSwipeGesture}
+ onTouchCancel={cancelTouchSwipeGesture}
+ onContextMenu={(event) => {
+ if (swipeRevealWidth <= 0) {
+ return;
+ }
+
+ event.preventDefault();
+ revealSwipeActions();
+ }}
+ style={cardSurfaceStyle}
className={`creation-work-card platform-category-game-item platform-interactive-card cursor-pointer overflow-hidden text-left ${isPublished ? 'creation-work-card--published' : 'creation-work-card--draft'} ${item.isGenerating ? 'creation-work-card--generating' : ''} ${isSwipeDragging ? 'creation-work-card--swiping' : ''}`}
>
-
- {displayTitle}
-
-
- {item.isGenerating
- ? '生æˆä¸'
- : item.status === 'published'
- ? 'å·²å‘布'
- : 'è‰ç¨¿'}
-
+
+
+ {item.isGenerating ? (
+
+ ) : item.status === 'published' ? (
+
+ ) : (
+
+ )}
+
+
+ {displayTitle}
+
+
- {item.badges
- .slice(1)
- .map((badge) => (
-
- {formatPlatformWorkDisplayTag(badge.label)}
-
- ))}
+ {item.badges.slice(1).map((badge) => (
+
+ {formatPlatformWorkDisplayTag(badge.label)}
+
+ ))}
@@ -698,18 +809,20 @@ export function CustomWorldWorkCard({
{item.hasUnreadUpdate ? (
+
生æˆä¸...
diff --git a/src/components/custom-world-home/creationWorkShelf.test.ts b/src/components/custom-world-home/creationWorkShelf.test.ts
index c6549bb2..300961d3 100644
--- a/src/components/custom-world-home/creationWorkShelf.test.ts
+++ b/src/components/custom-world-home/creationWorkShelf.test.ts
@@ -393,7 +393,7 @@ test('buildCreationWorkShelfItems uses generated object keys as cover sources',
'generated-puzzle-assets/session/profile/level-cover.png',
);
expect(items.find((item) => item.kind === 'match3d')?.coverImageSrc).toBe(
- 'generated-match3d-assets/session/profile/background/image.png',
+ 'generated-match3d-assets/session/profile/background/container.png',
);
});
@@ -444,6 +444,182 @@ test('buildCreationWorkShelfItems falls back to match3d item object key without
);
});
+test('buildCreationWorkShelfItems ignores puzzle theme reference cover and uses first level image', () => {
+ const items = buildCreationWorkShelfItems({
+ rpgItems: [],
+ bigFishItems: [],
+ puzzleItems: [
+ {
+ workId: 'puzzle:theme-reference-cover',
+ profileId: 'puzzle-profile-theme-reference-cover',
+ ownerUserId: 'user-1',
+ authorDisplayName: '测试作者',
+ levelName: '主题兜底拼图',
+ summary: '摘è¦é‡Œçš„å°é¢æ˜¯çŽ©æ³•å‚考图时,用第一关画é¢å…œåº•。',
+ themeTags: [],
+ coverImageSrc: '/creation-type-references/puzzle.webp',
+ publicationStatus: 'draft',
+ updatedAt: '2026-05-08T00:00:00.000Z',
+ publishedAt: null,
+ publishReady: false,
+ levels: [
+ {
+ levelId: 'level-1',
+ levelName: '第一关',
+ pictureDescription: '第一关画é¢ã€‚',
+ candidates: [
+ {
+ candidateId: 'candidate-1',
+ imageSrc: '/puzzle-first-level-candidate.png',
+ assetId: 'asset-1',
+ prompt: '第一关画é¢',
+ sourceType: 'generated',
+ selected: true,
+ },
+ ],
+ selectedCandidateId: 'candidate-1',
+ coverImageSrc: '/puzzle-first-level-cover.png',
+ coverAssetId: 'asset-1',
+ generationStatus: 'ready',
+ },
+ ],
+ },
+ ],
+ });
+
+ expect(items.find((item) => item.kind === 'puzzle')?.coverImageSrc).toBe(
+ '/puzzle-first-level-cover.png',
+ );
+});
+
+test('buildCreationWorkShelfItems ignores match3d theme reference cover and uses container image', () => {
+ const items = buildCreationWorkShelfItems({
+ rpgItems: [],
+ bigFishItems: [],
+ puzzleItems: [],
+ match3dItems: [
+ {
+ workId: 'match3d:theme-reference-cover',
+ profileId: 'match3d-profile-theme-reference-cover',
+ ownerUserId: 'user-1',
+ gameName: '主题兜底抓鹅',
+ themeText: '糖果厨房',
+ summary: '摘è¦é‡Œçš„å°é¢æ˜¯çŽ©æ³•å‚考图时,用UI背景图兜底。',
+ tags: [],
+ coverImageSrc: '/creation-type-references/match3d.webp',
+ clearCount: 18,
+ difficulty: 1,
+ publicationStatus: 'draft',
+ playCount: 0,
+ updatedAt: '2026-05-07T00:00:00.000Z',
+ publishReady: false,
+ generatedBackgroundAsset: {
+ prompt: '糖果厨房竖å±UI背景',
+ imageSrc: '/match3d-ui-background.png',
+ containerImageSrc: '/match3d-container.png',
+ status: 'image_ready',
+ },
+ generatedItemAssets: [
+ {
+ itemId: 'item-1',
+ itemName: '糖果',
+ imageSrc: '/match3d-item.png',
+ status: 'image_ready',
+ },
+ ],
+ },
+ ],
+ });
+
+ expect(items.find((item) => item.kind === 'match3d')?.coverImageSrc).toBe(
+ '/match3d-container.png',
+ );
+});
+
+test('buildCreationWorkShelfItems uses match3d container asset before background and item image', () => {
+ const items = buildCreationWorkShelfItems({
+ rpgItems: [],
+ bigFishItems: [],
+ puzzleItems: [],
+ match3dItems: [
+ {
+ workId: 'match3d:item-background-asset-cover',
+ profileId: 'match3d-profile-item-background-asset-cover',
+ ownerUserId: 'user-1',
+ gameName: '背景资产抓鹅',
+ themeText: '糖果厨房',
+ summary: 'é¡¶å±‚èƒŒæ™¯ç¼ºå¤±æ—¶ï¼Œä»Žç´ ææºå¸¦çš„UI背景兜底。',
+ tags: [],
+ coverImageSrc: '/creation-type-references/match3d.webp',
+ clearCount: 18,
+ difficulty: 1,
+ publicationStatus: 'draft',
+ playCount: 0,
+ updatedAt: '2026-05-07T00:00:00.000Z',
+ publishReady: false,
+ generatedItemAssets: [
+ {
+ itemId: 'item-1',
+ itemName: '糖果',
+ imageSrc: '/match3d-item.png',
+ backgroundAsset: {
+ prompt: '糖果厨房竖å±UI背景',
+ imageObjectKey:
+ 'generated-match3d-assets/session/profile/background/image.png',
+ containerImageObjectKey:
+ 'generated-match3d-assets/session/profile/ui-container/container.png',
+ status: 'image_ready',
+ },
+ status: 'image_ready',
+ },
+ ],
+ },
+ ],
+ });
+
+ expect(items.find((item) => item.kind === 'match3d')?.coverImageSrc).toBe(
+ 'generated-match3d-assets/session/profile/ui-container/container.png',
+ );
+});
+
+test('buildCreationWorkShelfItems uses match3d transparent container reference as last fallback', () => {
+ const items = buildCreationWorkShelfItems({
+ rpgItems: [],
+ bigFishItems: [],
+ puzzleItems: [],
+ match3dItems: [
+ {
+ workId: 'match3d:container-reference-fallback',
+ profileId: 'match3d-profile-container-reference-fallback',
+ ownerUserId: 'user-1',
+ sourceSessionId: 'session-1',
+ gameName: '水果抓大鹅',
+ themeText: '水果',
+ summary: '',
+ tags: [],
+ coverImageSrc: null,
+ referenceImageSrc: null,
+ backgroundPrompt: '',
+ backgroundImageSrc: null,
+ backgroundImageObjectKey: null,
+ generatedBackgroundAsset: null,
+ generatedItemAssets: [],
+ clearCount: 3,
+ difficulty: 2,
+ publicationStatus: 'draft',
+ publishReady: false,
+ playCount: 0,
+ updatedAt: '2026-05-01T00:00:00.000Z',
+ publishedAt: null,
+ },
+ ],
+ });
+
+ expect(items.find((item) => item.kind === 'match3d')?.coverImageSrc).toBe(
+ '/match3d-background-references/pot-fused-reference.png',
+ );
+});
+
test('getCreationWorkShelfItemTime parses backend seconds.microsZ values', () => {
expect(getCreationWorkShelfItemTime('1778457601.234567Z')).toBe(
1778457601234.567,
diff --git a/src/components/custom-world-home/creationWorkShelf.ts b/src/components/custom-world-home/creationWorkShelf.ts
index 663a3c89..e97c6551 100644
--- a/src/components/custom-world-home/creationWorkShelf.ts
+++ b/src/components/custom-world-home/creationWorkShelf.ts
@@ -17,6 +17,9 @@ import {
} from '../../services/publicWorkCode';
import type { CustomWorldProfile } from '../../types';
+const MATCH3D_CONTAINER_REFERENCE_COVER_SRC =
+ '/match3d-background-references/pot-fused-reference.png';
+
export type CreationWorkShelfKind =
| 'rpg'
| 'big-fish'
@@ -620,15 +623,33 @@ function normalizeCoverImageSrc(value?: string | null) {
return value?.trim() || null;
}
+function isCreationTypeReferenceCoverImageSrc(value?: string | null) {
+ const normalizedValue = normalizeCoverImageSrc(value);
+ if (!normalizedValue) {
+ return false;
+ }
+
+ // 䏿–‡æ³¨é‡Šï¼šçŽ©æ³•å‚考图åªåšè‰ç¨¿é¡µå…œåº•,ä¸åº”覆盖作å“å·²ç»ç”Ÿæˆå‡ºæ¥çš„真实关å¡å›¾æˆ–è¿è¡Œæ€èƒŒæ™¯å›¾ã€‚
+ return /^\/?creation-type-references\/[^/?#]+(?:[?#].*)?$/u.test(
+ normalizedValue,
+ );
+}
+
function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) {
const directCoverImageSrc = normalizeCoverImageSrc(item.coverImageSrc);
- if (directCoverImageSrc) {
+ if (
+ directCoverImageSrc &&
+ !isCreationTypeReferenceCoverImageSrc(directCoverImageSrc)
+ ) {
return directCoverImageSrc;
}
for (const level of item.levels ?? []) {
const levelCoverImageSrc = normalizeCoverImageSrc(level.coverImageSrc);
- if (levelCoverImageSrc) {
+ if (
+ levelCoverImageSrc &&
+ !isCreationTypeReferenceCoverImageSrc(levelCoverImageSrc)
+ ) {
return levelCoverImageSrc;
}
@@ -638,14 +659,17 @@ function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) {
level.candidates.find(
(candidate) => candidate.candidateId === level.selectedCandidateId,
)?.imageSrc,
- )
+ )
: null;
const fallbackCandidateImageSrc = normalizeCoverImageSrc(
level.candidates[level.candidates.length - 1]?.imageSrc,
);
const candidateImageSrc = selectedCandidateImageSrc || fallbackCandidateImageSrc;
- if (candidateImageSrc) {
+ if (
+ candidateImageSrc &&
+ !isCreationTypeReferenceCoverImageSrc(candidateImageSrc)
+ ) {
return candidateImageSrc;
}
}
@@ -655,22 +679,46 @@ function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) {
function resolveMatch3DWorkCoverImageSrc(item: Match3DWorkSummary) {
const directCoverImageSrc = normalizeCoverImageSrc(item.coverImageSrc);
- if (directCoverImageSrc) {
+ if (
+ directCoverImageSrc &&
+ !isCreationTypeReferenceCoverImageSrc(directCoverImageSrc)
+ ) {
return directCoverImageSrc;
}
+ const topLevelContainerImageSrc =
+ normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageSrc) ||
+ normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageObjectKey);
+ if (topLevelContainerImageSrc) {
+ return topLevelContainerImageSrc;
+ }
+
+ for (const asset of item.generatedItemAssets ?? []) {
+ const assetContainerImageSrc =
+ normalizeCoverImageSrc(asset.backgroundAsset?.containerImageSrc) ||
+ normalizeCoverImageSrc(asset.backgroundAsset?.containerImageObjectKey);
+ if (assetContainerImageSrc) {
+ return assetContainerImageSrc;
+ }
+ }
+
const backgroundImageSrc =
normalizeCoverImageSrc(item.backgroundImageSrc) ||
normalizeCoverImageSrc(item.backgroundImageObjectKey) ||
normalizeCoverImageSrc(item.generatedBackgroundAsset?.imageSrc) ||
- normalizeCoverImageSrc(item.generatedBackgroundAsset?.imageObjectKey) ||
- normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageSrc) ||
- normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageObjectKey);
+ normalizeCoverImageSrc(item.generatedBackgroundAsset?.imageObjectKey);
if (backgroundImageSrc) {
return backgroundImageSrc;
}
for (const asset of item.generatedItemAssets ?? []) {
+ const assetBackgroundImageSrc =
+ normalizeCoverImageSrc(asset.backgroundAsset?.imageSrc) ||
+ normalizeCoverImageSrc(asset.backgroundAsset?.imageObjectKey);
+ if (assetBackgroundImageSrc) {
+ return assetBackgroundImageSrc;
+ }
+
const imageView = asset.imageViews?.find(
(view) =>
normalizeCoverImageSrc(view.imageSrc) ||
@@ -682,12 +730,16 @@ function resolveMatch3DWorkCoverImageSrc(item: Match3DWorkSummary) {
const itemImageSrc =
normalizeCoverImageSrc(asset.imageSrc) ||
normalizeCoverImageSrc(asset.imageObjectKey);
- if (imageViewSrc || itemImageSrc) {
- return imageViewSrc || itemImageSrc;
+ const preferredImageSrc = imageViewSrc || itemImageSrc;
+ if (
+ preferredImageSrc &&
+ !isCreationTypeReferenceCoverImageSrc(preferredImageSrc)
+ ) {
+ return preferredImageSrc;
}
}
- return null;
+ return MATCH3D_CONTAINER_REFERENCE_COVER_SRC;
}
function resolveSquareHoleWorkCoverImageSrc(item: SquareHoleWorkSummary) {
diff --git a/src/components/match3d-result/Match3DResultView.test.tsx b/src/components/match3d-result/Match3DResultView.test.tsx
index f8c13c05..28e29f4b 100644
--- a/src/components/match3d-result/Match3DResultView.test.tsx
+++ b/src/components/match3d-result/Match3DResultView.test.tsx
@@ -1,6 +1,12 @@
// @vitest-environment jsdom
-import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import {
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ within,
+} from '@testing-library/react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { Match3DWorkProfile } from '../../../packages/shared/src/contracts/match3dWorks';
@@ -147,6 +153,7 @@ describe('Match3DResultView', () => {
expect(screen.getByText('ä½œå“æ ‡ç¾')).toBeTruthy();
expect(screen.getByText('水果')).toBeTruthy();
expect(screen.getByText('抓大鹅')).toBeTruthy();
+ expect(screen.queryByRole('button', { name: 'å°é¢å›¾' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
@@ -188,7 +195,7 @@ describe('Match3DResultView', () => {
});
});
- test('å°é¢å›¾ç‹¬ç«‹é¢æ¿æ”¯æŒå¼•用物å“ç´ æä½œä¸ºå¤šå‚考图生æˆ', async () => {
+ test('å‘å¸ƒé¢æ¿å†…支æŒå¼•用物å“ç´ æä½œä¸ºå¤šå‚考图生æˆå°é¢', async () => {
const profile = createProfile({
generatedItemAssets: [
{
@@ -232,13 +239,19 @@ describe('Match3DResultView', () => {
/>,
);
- fireEvent.click(screen.getByRole('button', { name: 'å°é¢å›¾' }));
- expect(screen.getByRole('dialog', { name: 'å°é¢å›¾' })).toBeTruthy();
- fireEvent.click(screen.getByRole('button', { name: '引用è‰èŽ“' }));
- fireEvent.change(screen.getByLabelText('å°é¢æè¿°'), {
+ fireEvent.click(screen.getByRole('button', { name: 'å‘布' }));
+ const publishDialog = screen.getByRole('dialog', {
+ name: 'å‘布抓大鹅作å“',
+ });
+ fireEvent.click(
+ within(publishDialog).getByRole('button', { name: '引用è‰èŽ“' }),
+ );
+ fireEvent.change(within(publishDialog).getByLabelText('å°é¢æè¿°'), {
target: { value: 'è‰èŽ“æŠ“å¤§é¹…å°é¢å›¾' },
});
- fireEvent.click(screen.getByRole('button', { name: '生æˆå°é¢å›¾' }));
+ fireEvent.click(
+ within(publishDialog).getByRole('button', { name: '生æˆå°é¢å›¾' }),
+ );
await waitFor(() => {
expect(
@@ -251,7 +264,74 @@ describe('Match3DResultView', () => {
uploadedImageSrc: null,
});
expect(onSaved).toHaveBeenCalledWith(nextProfile);
- expect(screen.queryByRole('dialog', { name: 'å°é¢å›¾' })).toBeNull();
+ expect(
+ screen.getByRole('dialog', { name: 'å‘布抓大鹅作å“' }),
+ ).toBeTruthy();
+ });
+ });
+
+ test('生æˆå°é¢å›¾åªæ›´æ–°å°é¢å—段,ä¸ç”¨æ—§å›žåŒ…覆盖当å‰ç‰©å“ç´ æå’Œé…ç½®', async () => {
+ const generatedItemAssets = [createReadyGeneratedItemAsset(1)];
+ const profile = createProfile({
+ clearCount: 12,
+ difficulty: 4,
+ generatedItemAssets,
+ });
+ const staleResponseProfile = createProfile({
+ ...profile,
+ coverImageSrc:
+ '/generated-match3d-assets/session/profile/cover/task/cover.png',
+ clearCount: 8,
+ difficulty: 2,
+ generatedItemAssets: [],
+ });
+ const onSaved = vi.fn();
+ vi.mocked(match3dWorksService.generateMatch3DCoverImage).mockResolvedValue({
+ item: staleResponseProfile,
+ coverImageSrc:
+ '/generated-match3d-assets/session/profile/cover/task/cover.png',
+ coverImageObjectKey:
+ 'generated-match3d-assets/session/profile/cover/task/cover.png',
+ prompt: 'æ°´æžœå°é¢å›¾',
+ });
+
+ render(
+ {}}
+ onSaved={onSaved}
+ onStartTestRun={() => {}}
+ />,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'å‘布' }));
+ const publishDialog = screen.getByRole('dialog', {
+ name: 'å‘布抓大鹅作å“',
+ });
+ fireEvent.change(within(publishDialog).getByLabelText('å°é¢æè¿°'), {
+ target: { value: 'æ°´æžœå°é¢å›¾' },
+ });
+ fireEvent.click(
+ within(publishDialog).getByRole('button', { name: '生æˆå°é¢å›¾' }),
+ );
+
+ await waitFor(() => {
+ expect(onSaved).toHaveBeenCalledWith(
+ expect.objectContaining({
+ coverImageSrc:
+ '/generated-match3d-assets/session/profile/cover/task/cover.png',
+ clearCount: 12,
+ difficulty: 4,
+ generatedItemAssets: expect.arrayContaining([
+ expect.objectContaining({
+ itemId: 'match3d-item-1',
+ imageViews: expect.arrayContaining([
+ expect.objectContaining({ viewId: 'view-01' }),
+ ]),
+ }),
+ ]),
+ }),
+ );
});
});
@@ -280,9 +360,14 @@ describe('Match3DResultView', () => {
/>,
);
- fireEvent.click(screen.getByRole('button', { name: 'å°é¢å›¾' }));
+ fireEvent.click(screen.getByRole('button', { name: 'å‘布' }));
+ const publishDialog = screen.getByRole('dialog', {
+ name: 'å‘布抓大鹅作å“',
+ });
fireEvent.change(
- screen.getByLabelText('ä¸Šä¼ å°é¢å›¾', { selector: 'input' }),
+ within(publishDialog).getByLabelText('ä¸Šä¼ å°é¢å›¾', {
+ selector: 'input',
+ }),
{
target: {
files: [new File(['x'], 'cover.png', { type: 'image/png' })],
@@ -291,16 +376,22 @@ describe('Match3DResultView', () => {
);
await waitFor(() => {
- expect(screen.getByRole('switch', { name: 'AIé‡ç»˜' })).toBeTruthy();
- expect(screen.getByRole('button', { name: '移除å°é¢å›¾' })).toBeTruthy();
- expect(screen.getByLabelText('AIé‡ç»˜è¦æ±‚')).toBeTruthy();
+ expect(
+ within(publishDialog).getByRole('switch', { name: 'AIé‡ç»˜' }),
+ ).toBeTruthy();
+ expect(
+ within(publishDialog).getByRole('button', { name: '移除å°é¢å›¾' }),
+ ).toBeTruthy();
+ expect(within(publishDialog).getByLabelText('AIé‡ç»˜è¦æ±‚')).toBeTruthy();
});
- expect(screen.queryByText('å‚考图')).toBeNull();
+ expect(within(publishDialog).queryByText('å‚考图')).toBeNull();
- fireEvent.change(screen.getByLabelText('AIé‡ç»˜è¦æ±‚'), {
+ fireEvent.change(within(publishDialog).getByLabelText('AIé‡ç»˜è¦æ±‚'), {
target: { value: 'ä¿ç•™æž„图,改æˆèŠ‚æ—¥æžœå›' },
});
- fireEvent.click(screen.getByRole('button', { name: '生æˆå°é¢å›¾' }));
+ fireEvent.click(
+ within(publishDialog).getByRole('button', { name: '生æˆå°é¢å›¾' }),
+ );
await waitFor(() => {
expect(
@@ -418,9 +509,23 @@ describe('Match3DResultView', () => {
);
const publishButton = screen.getByRole('button', { name: 'å‘布' });
- expect(publishButton).toHaveProperty('disabled', true);
+ expect(publishButton).toHaveProperty('disabled', false);
fireEvent.click(publishButton);
+ const publishDialog = screen.getByRole('dialog', {
+ name: 'å‘布抓大鹅作å“',
+ });
+ expect(within(publishDialog).getByText('å°é¢å›¾ä¸èƒ½ä¸ºç©ºã€‚')).toBeTruthy();
+ expect(
+ within(publishDialog).getByText('æ ‡ç¾æ•°é‡éœ€è¦åœ¨ 3 到 6 个之间。'),
+ ).toBeTruthy();
+ expect(
+ within(publishDialog).getByRole('button', { name: 'å‘布到广场' }),
+ ).toHaveProperty('disabled', true);
+ fireEvent.click(
+ within(publishDialog).getByRole('button', { name: 'å‘布到广场' }),
+ );
+ expect(within(publishDialog).getByText('å°é¢å›¾ä¸èƒ½ä¸ºç©ºã€‚')).toBeTruthy();
expect(match3dWorksService.publishMatch3DWork).not.toHaveBeenCalled();
});
@@ -449,9 +554,21 @@ describe('Match3DResultView', () => {
);
const publishButton = screen.getByRole('button', { name: 'å‘布' });
- expect(publishButton).toHaveProperty('disabled', true);
+ expect(publishButton).toHaveProperty('disabled', false);
fireEvent.click(publishButton);
+ const publishDialog = screen.getByRole('dialog', {
+ name: 'å‘布抓大鹅作å“',
+ });
+ expect(
+ within(publishDialog).getByText(
+ '当å‰éš¾åº¦éœ€è¦ 3 ç§ç‰©å“ï¼Œå·²ç”Ÿæˆ 2 ç§ï¼Œè¯·å…ˆåœ¨ç´ æé…ç½®ä¸è¡¥é½ã€‚',
+ ),
+ ).toBeTruthy();
+ expect(
+ within(publishDialog).getByRole('button', { name: 'å‘布到广场' }),
+ ).toHaveProperty('disabled', true);
expect(match3dWorksService.publishMatch3DWork).not.toHaveBeenCalled();
+ fireEvent.click(within(publishDialog).getByRole('button', { name: 'å–æ¶ˆ' }));
fireEvent.click(screen.getByRole('button', { name: '难度é…ç½®' }));
expect(screen.getByText('已生æˆç‰©å“ç§ç±»')).toBeTruthy();
expect(screen.getAllByText('2 ç§').length).toBeGreaterThan(0);
@@ -505,6 +622,11 @@ describe('Match3DResultView', () => {
);
fireEvent.click(screen.getByRole('button', { name: 'å‘布' }));
+ fireEvent.click(
+ within(
+ screen.getByRole('dialog', { name: 'å‘布抓大鹅作å“' }),
+ ).getByRole('button', { name: 'å‘布到广场' }),
+ );
await waitFor(() => {
expect(
@@ -1062,7 +1184,7 @@ describe('Match3DResultView', () => {
).toBe(true);
});
- test('物å“详情五视角预览使用上方焦点区和底部缩略图æ ', () => {
+ test('物å“详情五视角预览使用上方大图和底部缩略图æ ', () => {
render(
{
const preview = screen.getByLabelText('物å“1五视角预览');
const stage = screen.getByTestId('match3d-item-preview-stage');
- const focusFrame = screen.getByTestId('match3d-item-preview-focus-frame');
+ const focusImage = screen.getByTestId('match3d-item-preview-focus-image');
const thumbnails = screen.getByTestId('match3d-item-preview-thumbnails');
expect(stage.className).toContain('aspect-square');
- expect(focusFrame.className).toContain('inset-[7%]');
+ expect(stage.className).toContain('max-w-[22rem]');
+ expect(focusImage.className).toContain('place-items-center');
+ expect(focusImage.querySelector('img')?.className).toContain('p-3');
expect(thumbnails.style.gridAutoColumns).toBe('calc((100% - 1.5rem) / 4)');
- expect(preview.querySelectorAll('img')).toHaveLength(10);
+ expect(preview.querySelectorAll('img')).toHaveLength(6);
expect(
screen
.getByRole('button', { name: '切æ¢ç‰©å“1视角3' })
.getAttribute('aria-pressed'),
).toBe('true');
+ expect(
+ screen.queryByTestId('match3d-item-preview-focus-frame'),
+ ).toBeNull();
+
+ fireEvent.click(screen.getByRole('button', { name: '切æ¢ç‰©å“1视角5' }));
+ expect(
+ screen
+ .getByTestId('match3d-item-preview-focus-image')
+ .getAttribute('data-preview-src'),
+ ).toContain('views/view-05.png');
});
test('è‰ç¨¿é˜¶æ®µä»…有切割图片时展示 2D ç´ æ', () => {
@@ -1216,6 +1350,11 @@ describe('Match3DResultView', () => {
fireEvent.click(screen.getByRole('button', { name: '预览UI页é¢' }));
expect(screen.getByRole('dialog', { name: 'UI预览' })).toBeTruthy();
expect(screen.getByText('1:30')).toBeTruthy();
+ expect(
+ document.querySelector(
+ 'img[src="/match3d-background-references/pot-fused-reference.png"]',
+ ),
+ ).toBeTruthy();
});
test('ç´ æé…ç½® UI å Tab 修改æç¤ºè¯åŽè°ƒç”¨èƒŒæ™¯å›¾ç”ŸæˆæŽ¥å£å¹¶åˆ·æ–°ç´ æ', async () => {
diff --git a/src/components/match3d-result/Match3DResultView.tsx b/src/components/match3d-result/Match3DResultView.tsx
index da51491c..5d36acec 100644
--- a/src/components/match3d-result/Match3DResultView.tsx
+++ b/src/components/match3d-result/Match3DResultView.tsx
@@ -317,9 +317,15 @@ function resolveMatch3DBackgroundPrompt(
}
function resolveMatch3DContainerPreviewSource(
+ profile: Match3DWorkProfile,
+ draft: Match3DResultDraft | null,
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
return (
+ draft?.generatedBackgroundAsset?.containerImageSrc?.trim() ||
+ draft?.generatedBackgroundAsset?.containerImageObjectKey?.trim() ||
+ profile.generatedBackgroundAsset?.containerImageSrc?.trim() ||
+ profile.generatedBackgroundAsset?.containerImageObjectKey?.trim() ||
generatedItemAssets
.map(
(asset) =>
@@ -1154,6 +1160,32 @@ function buildPlayableProfile(
);
}
+function buildCoverImageUpdatedProfile(
+ profile: Match3DWorkProfile,
+ editState: Match3DResultEditState,
+ responseItem: Match3DWorkProfile,
+ coverImageSrc: string,
+ generatedItemAssets: readonly Match3DGeneratedItemAsset[] = [],
+) {
+ // 䏿–‡æ³¨é‡Šï¼šå°é¢ç”Ÿæˆåªå…许更新å°é¢å—段;接å£å›žåŒ…如果æ¥è‡ªæ—§å¿«ç…§ï¼Œä¸èƒ½è¦†ç›–当å‰ç‰©å“ç´ ææˆ–难度é…置。
+ const visibleProfile = buildPlayableProfile(
+ profile,
+ {
+ ...editState,
+ coverImageSrc,
+ },
+ generatedItemAssets.length > 0
+ ? generatedItemAssets
+ : (profile.generatedItemAssets ?? []),
+ );
+
+ return {
+ ...visibleProfile,
+ coverImageSrc,
+ updatedAt: responseItem.updatedAt || visibleProfile.updatedAt,
+ };
+}
+
function resolveMatch3DResultGeneratedItemAssets(
profile: Match3DWorkProfile,
draft: Match3DResultDraft | null,
@@ -1298,13 +1330,11 @@ function Match3DWorkInfoTab({
editState,
isBusy,
onChange,
- onOpenCoverPanel,
onGenerateTags,
}: {
editState: Match3DResultEditState;
isBusy: boolean;
onChange: (nextState: Match3DResultEditState) => void;
- onOpenCoverPanel: () => void;
onGenerateTags: () => void;
}) {
const [isAddingTag, setIsAddingTag] = useState(false);
@@ -1324,38 +1354,6 @@ function Match3DWorkInfoTab({
return (
-
-
-
-
-