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:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user