Increase VectorEngine timeouts and add image UI

Add VectorEngine image generation config and raise request timeouts (env + scripts) from 180000 to 1000000ms. Introduce a reusable CreativeImageInputPanel component with tests and wire up mobile keyboard-focus helpers; update generation views and related tests (CustomWorldGenerationView, BarkBattle editor, Match3D, Puzzle flows). Improve API error handling / VectorEngine request guidance (packages/shared http.ts and docs), and apply multiple backend/frontend fixes for puzzle/match3d/prompt handling. Also include extensive docs and decision-log updates describing UI/UX decisions and verification steps.
This commit is contained in:
2026-05-15 02:40:59 +08:00
parent 4642855fd0
commit 74fd9a33ac
87 changed files with 5508 additions and 1261 deletions

View File

@@ -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(
<CustomWorldCreationHub
items={[{ ...baseDraftItem, profileId: 'profile-1' }]}
loading={false}
error={null}
onRetry={() => {}}
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(
<CustomWorldCreationHub
items={[{ ...baseDraftItem, profileId: 'profile-1' }]}
loading={false}
error={null}
onRetry={() => {}}
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();
});

View File

@@ -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<HTMLDivElement>,
) => {
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<HTMLDivElement>,
) => {
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<typeof swipeGestureRef.current>,
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<HTMLDivElement>,
) => {
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<HTMLDivElement>,
) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
const gesture = swipeGestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) {
return;
}
updateSwipeOffset(
gesture,
event.clientX,
event.clientY,
() => event.preventDefault(),
);
};
const endSwipeGesture = (event: ReactPointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>,
) => {
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<HTMLDivElement>,
) => {
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<HTMLDivElement>) => {
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' : ''}`}
>
<div className="creation-work-card__body platform-category-game-item__body">
<div className="creation-work-card__title-row platform-category-game-item__title-row">
<span className="creation-work-card__title platform-category-game-item__title">
{displayTitle}
</span>
<span
className={`creation-work-card__status-pill creation-work-card__status-pill--${
item.isGenerating ? 'generating' : item.status
}`}
>
{item.isGenerating
? '生成中'
: item.status === 'published'
? '已发布'
: '草稿'}
</span>
<div className="creation-work-card__title-lockup">
<span
aria-label={
item.isGenerating
? '生成中'
: item.status === 'published'
? '已发布'
: '草稿'
}
className={`creation-work-card__state-mark creation-work-card__state-mark--${
item.isGenerating ? 'generating' : item.status
}`}
>
{item.isGenerating ? (
<Loader2 aria-hidden="true" className="h-3.5 w-3.5" />
) : item.status === 'published' ? (
<BadgeCheck aria-hidden="true" className="h-3.5 w-3.5" />
) : (
<Clock3 aria-hidden="true" className="h-3.5 w-3.5" />
)}
</span>
<span className="creation-work-card__title platform-category-game-item__title">
{displayTitle}
</span>
</div>
</div>
<div className="creation-work-card__meta platform-category-game-item__meta">
{item.badges
.slice(1)
.map((badge) => (
<span
key={`${item.id}-${badge.id}`}
className={`creation-work-card__badge platform-pill ${BADGE_TONE_CLASS[badge.tone]}`}
>
{formatPlatformWorkDisplayTag(badge.label)}
</span>
))}
{item.badges.slice(1).map((badge) => (
<span
key={`${item.id}-${badge.id}`}
className={`creation-work-card__badge platform-pill ${BADGE_TONE_CLASS[badge.tone]}`}
>
{formatPlatformWorkDisplayTag(badge.label)}
</span>
))}
</div>
<div className="creation-work-card__summary platform-category-game-item__summary">
@@ -698,18 +809,20 @@ export function CustomWorldWorkCard({
<div
className="creation-work-card__side-cover"
style={sideCoverStyle}
style={coverFadeStyle}
aria-hidden="true"
>
<CustomWorldCoverArtwork
imageSrc={item.coverImageSrc}
fallbackImageSrc={fallbackCoverImageSrc}
title={item.title}
fallbackLabel="封面"
renderMode={item.coverRenderMode}
characterImageSrcs={item.coverCharacterImageSrcs}
className="absolute inset-0"
/>
<div className="creation-work-card__side-cover-inner">
<CustomWorldCoverArtwork
imageSrc={item.coverImageSrc}
fallbackImageSrc={fallbackCoverImageSrc}
title={item.title}
fallbackLabel="封面"
renderMode={item.coverRenderMode}
characterImageSrcs={item.coverCharacterImageSrcs}
className="absolute inset-0"
/>
</div>
</div>
{item.hasUnreadUpdate ? (
<span
@@ -719,7 +832,10 @@ export function CustomWorldWorkCard({
) : null}
{item.isGenerating ? (
<div className="creation-work-card__generating-mask" aria-hidden="true">
<div
className="creation-work-card__generating-mask"
aria-hidden="true"
>
<span className="creation-work-card__spinner" />
<span>...</span>
</div>

View File

@@ -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,

View File

@@ -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) {