Update Match3D/image-generation docs & code

Adds/updates documentation, assets and implementation for Match3D and puzzle image generation workflows. Key changes: decision logs and pitfalls updated to prefer VectorEngine Gemini for Match3D material sheets and to require edits (multipart) for 1:1 container reference images; guidance added for when to use APIMart vs VectorEngine. .env.example clarified APIMart/Responses config. Many new public assets and PPT visuals added. Code changes across frontend and backend: updated shared contracts, server-rs match3d/puzzle/image-generation handlers, VectorEngine/OpenAI image generation clients, and multiple React components/tests to handle UI/background/container image signing, edits workflow, and puzzle UI background resolution. Added src/services/puzzle-runtime/puzzleUiBackgroundSource.ts and related test updates. Includes notes about multipart HTTP/1.1 requirement and test/verification commands in docs.
This commit is contained in:
2026-05-14 20:34:45 +08:00
parent d33c937ebc
commit 548db78ca7
103 changed files with 6687 additions and 3270 deletions

View File

@@ -1,7 +1,7 @@
import type { ReactNode } from 'react';
import { ResolvedAssetImage } from './ResolvedAssetImage';
import type { CustomWorldCoverRenderMode } from '../services/customWorldCover';
import { ResolvedAssetImage } from './ResolvedAssetImage';
const COVER_PORTRAIT_CLASS_NAMES = [
'h-[54%] w-[24%] translate-y-[8%]',
@@ -11,6 +11,7 @@ const COVER_PORTRAIT_CLASS_NAMES = [
type CustomWorldCoverArtworkProps = {
imageSrc?: string | null;
fallbackImageSrc?: string | null;
title: string;
fallbackLabel: string;
renderMode?: CustomWorldCoverRenderMode;
@@ -21,6 +22,7 @@ type CustomWorldCoverArtworkProps = {
export function CustomWorldCoverArtwork({
imageSrc,
fallbackImageSrc,
title,
fallbackLabel,
renderMode = 'image',
@@ -31,24 +33,24 @@ export function CustomWorldCoverArtwork({
const coverCharacterImageSrcs = characterImageSrcs.slice(0, 3);
return (
<div
className={`relative overflow-hidden bg-[radial-gradient(circle_at_top,rgba(255,244,214,0.3),transparent_38%),linear-gradient(180deg,rgba(34,40,55,0.92),rgba(10,12,18,0.96))] ${className}`}
>
{imageSrc ? (
<div className={`custom-world-cover-artwork relative overflow-hidden ${className}`}>
{imageSrc || fallbackImageSrc ? (
<ResolvedAssetImage
src={imageSrc}
fallbackSrc={fallbackImageSrc}
alt={title}
loading="lazy"
className="absolute inset-0 h-full w-full object-cover"
/>
) : null}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.04),rgba(8,10,14,0.26)_46%,rgba(8,10,14,0.82)_100%)]" />
{!imageSrc ? (
{!imageSrc && !fallbackImageSrc ? (
<div className="absolute inset-0 flex items-center justify-center px-4 text-center text-sm font-semibold tracking-[0.18em] text-zinc-300">
{fallbackLabel}
</div>
) : null}
{renderMode === 'scene_with_roles' && coverCharacterImageSrcs.length > 0 ? (
{renderMode === 'scene_with_roles' &&
coverCharacterImageSrcs.length > 0 ? (
<>
<div className="absolute inset-x-0 bottom-0 h-[42%] bg-[linear-gradient(180deg,rgba(8,10,14,0)_0%,rgba(8,10,14,0.88)_100%)]" />
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-center gap-2 px-3 pb-2 sm:pb-3">

View File

@@ -1,4 +1,4 @@
import type { ImgHTMLAttributes } from 'react';
import { type ImgHTMLAttributes, useEffect, useState } from 'react';
import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
@@ -16,16 +16,42 @@ export function ResolvedAssetImage({
fallbackSrc,
alt,
refreshKey,
onError,
...rest
}: ResolvedAssetImageProps) {
const { resolvedUrl } = useResolvedAssetReadUrl(src, {
refreshKey,
});
const finalSrc = resolvedUrl || fallbackSrc?.trim() || '';
const normalizedFallbackSrc = fallbackSrc?.trim() ?? '';
const [useFallbackSrc, setUseFallbackSrc] = useState(false);
const finalSrc =
useFallbackSrc && normalizedFallbackSrc
? normalizedFallbackSrc
: resolvedUrl || normalizedFallbackSrc;
useEffect(() => {
setUseFallbackSrc(false);
}, [normalizedFallbackSrc, resolvedUrl]);
if (!finalSrc) {
return null;
}
return <img {...rest} src={finalSrc} alt={alt} />;
return (
<img
{...rest}
src={finalSrc}
alt={alt}
onError={(event) => {
if (
normalizedFallbackSrc &&
!useFallbackSrc &&
finalSrc !== normalizedFallbackSrc
) {
setUseFallbackSrc(true);
}
onError?.(event);
}}
/>
);
}

View File

@@ -6,8 +6,8 @@ import { afterEach, expect, test, vi } from 'vitest';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes';
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes';
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
const noopCreateType = () => {};
@@ -61,10 +61,10 @@ const testEntryConfig = {
id: 'visual-novel',
title: '视觉小说',
subtitle: '分支叙事体验',
badge: '可创建',
badge: '敬请期待',
imageSrc: '/creation-type-references/visual-novel.webp',
visible: true,
open: true,
visible: false,
open: false,
sortOrder: 60,
updatedAtMicros: 1,
},
@@ -443,7 +443,28 @@ test('creation hub shows RPG public work code from published library entry', ()
expect(screen.queryByText('CW-00000001')).toBeNull();
});
test('creation hub shows delete action for persisted rpg drafts', () => {
test('creation hub hides persisted draft delete action behind swipe underlay', () => {
const { container } = render(
<CustomWorldCreationHub
items={[{ ...baseDraftItem, profileId: 'profile-1' }]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onDeletePublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>,
);
expect(container.querySelector('.creation-work-card__swipe-underlay')).toBeTruthy();
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
});
test('creation hub reveals persisted draft delete action from keyboard', async () => {
const user = userEvent.setup();
render(
<CustomWorldCreationHub
items={[{ ...baseDraftItem, profileId: 'profile-1' }]}
@@ -459,10 +480,13 @@ test('creation hub shows delete action for persisted rpg drafts', () => {
/>,
);
screen.getByRole('button', { name: //u }).focus();
await user.keyboard('{ArrowLeft}');
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
});
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();
@@ -502,6 +526,12 @@ test('creation hub published work delete action is available beside share withou
/>,
);
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
expect(screen.queryByRole('button', { name: '分享' })).toBeNull();
screen.getByRole('button', { name: //u }).focus();
await user.keyboard('{ArrowLeft}');
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
expect(screen.getByRole('button', { name: '分享' })).toBeTruthy();
@@ -548,7 +578,7 @@ test('creation hub opens persisted rpg drafts by card click', async () => {
expect(openedItems).toEqual([persistedDraft]);
});
test('creation hub published share button copies share text without opening the card', async () => {
test('creation hub published swipe share button copies share text without opening the card', async () => {
const user = userEvent.setup();
const writeText = vi.fn(async () => undefined);
const onOpenPuzzleDetail = vi.fn();
@@ -591,6 +621,8 @@ test('creation hub published share button copies share text without opening the
/>,
);
screen.getByRole('button', { name: //u }).focus();
await user.keyboard('{ArrowLeft}');
await user.click(screen.getByRole('button', { name: '分享' }));
expect(writeText).toHaveBeenCalledWith(

View File

@@ -56,10 +56,10 @@ const testEntryConfig = {
id: 'visual-novel',
title: '视觉小说',
subtitle: '分支叙事体验',
badge: '可创建',
badge: '敬请期待',
imageSrc: '/creation-type-references/visual-novel.webp',
visible: true,
open: true,
visible: false,
open: false,
sortOrder: 60,
updatedAtMicros: 1,
},
@@ -217,9 +217,11 @@ test('creation hub marks generating and newly completed drafts', () => {
expect(html).toContain('生成中');
expect(html).toContain('aria-label="新生成完成"');
expect(html).toContain('生成中...');
expect(html).toContain('creation-work-card__spinner');
});
test('creation hub published work spans full mobile row', () => {
test('creation hub published work uses unified list card layout', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
items={[]}
@@ -253,9 +255,10 @@ test('creation hub published work spans full mobile row', () => {
/>,
);
expect(html).toContain('grid-cols-2');
expect(html).toContain('col-span-2 sm:col-span-1');
expect(html).not.toContain('grid-cols-1 gap-3 md:grid-cols-2');
expect(html).toContain('creation-work-list');
expect(html).toContain('platform-category-game-item');
expect(html).toContain('creation-work-card__side-cover');
expect(html).not.toContain('col-span-2 sm:col-span-1');
});
test('creation hub draft cards use cover background and hide updated time', () => {
@@ -318,9 +321,109 @@ test('creation hub draft cards use cover background and hide updated time', () =
expect(html).toContain(
'class="absolute inset-0 h-full w-full object-cover" src="/covers/new-draft.webp"',
);
expect(html).toContain('creation-work-card__side-cover');
expect(html).toContain('src="/covers/new-draft.webp"');
expect(html).toContain(
'--creation-work-card-cover-fallback:url(/creation-type-references/puzzle.webp)',
);
expect(html).not.toContain('1778457601.234567Z');
expect(html).not.toContain('2026-05-07');
expect(html).not.toContain('更新于');
expect(html).not.toContain('最后修改');
});
test('creation hub draft cards fall back to creation type cover when cover is missing', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
mode="works-only"
items={[]}
puzzleItems={[
{
workId: 'puzzle:no-cover-draft',
profileId: 'puzzle-profile-no-cover',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
workTitle: '缺少封面的拼图草稿',
workDescription: '没有生成封面时也需要保留图像背景。',
levelName: '缺少封面的拼图草稿',
summary: '没有生成封面时也需要保留图像背景。',
themeTags: [],
coverImageSrc: null,
publicationStatus: 'draft',
updatedAt: '2026-05-07T00:00:00.000Z',
publishedAt: null,
publishReady: false,
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
onOpenPuzzleDetail={() => {}}
/>,
);
expect(html).toContain('缺少封面的拼图草稿');
expect(html).toContain(
'class="absolute inset-0 h-full w-full object-cover" src="/creation-type-references/puzzle.webp"',
);
expect(html).toContain(
'--creation-work-card-cover-fallback:url(/creation-type-references/puzzle.webp)',
);
expect(html).not.toContain('>封面</div>');
});
test('creation hub published card keeps publish info without fixed action text', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
mode="works-only"
items={[]}
puzzleItems={[
{
workId: 'puzzle:published-card',
profileId: 'puzzle-profile-published',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
workTitle: '统一卡片作品',
workDescription: '作品卡仍要保留原有的积分与统计信息。',
levelName: '统一卡片作品',
summary: '作品卡仍要保留原有的积分与统计信息。',
themeTags: ['潮雾'],
coverImageSrc: '/covers/unified-card.webp',
publicationStatus: 'published',
updatedAt: '2026-05-07T00:00:00.000Z',
publishedAt: '2026-05-07T00:00:00.000Z',
playCount: 88,
remixCount: 9,
likeCount: 6,
publishReady: true,
pointIncentiveTotalPoints: 4,
pointIncentiveClaimablePoints: 1,
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
onOpenPuzzleDetail={() => {}}
onClaimPuzzlePointIncentive={() => {}}
/>,
);
expect(html).toContain('积分激励');
expect(html).toContain('待领取');
expect(html).toContain('游玩');
expect(html).toContain('改造');
expect(html).toContain('点赞');
expect(html).toContain('creation-work-card__side-cover');
expect(html).not.toContain('creation-work-card__action');
expect(html).not.toContain('>查看详情<');
});

View File

@@ -27,9 +27,8 @@ import {
CustomWorldWorkTabs,
} from './CustomWorldWorkTabs';
// 中文注释:草稿在手机端保持双列,已发布卡片由卡片自身跨两列展示公开指标。
const WORK_GRID_CLASS =
'grid grid-cols-2 gap-2.5 sm:gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4';
'creation-work-list grid min-w-0 gap-3 sm:gap-3.5 xl:gap-4';
const WORK_METRIC_CACHE_KEY = 'genarrative.creationHub.publishedMetrics.v1';
type WorkMetricSnapshot = Record<
@@ -268,38 +267,6 @@ export function CustomWorldCreationHub({
[activeFilter, shelfItems],
);
function handleOpenShelfItem(item: CreationWorkShelfItem) {
switch (item.source.kind) {
case 'puzzle':
onOpenPuzzleDetail?.(item.source.item);
return;
case 'baby-object-match':
onOpenBabyObjectMatchDetail?.(item.source.item);
return;
case 'visual-novel':
onOpenVisualNovelDetail?.(item.source.item);
return;
case 'big-fish':
onOpenBigFishDetail?.(item.source.item);
return;
case 'match3d':
onOpenMatch3DDetail?.(item.source.item);
return;
case 'square-hole':
onOpenSquareHoleDetail?.(item.source.item);
return;
case 'rpg':
if (item.status === 'draft') {
onOpenDraft(item.source.item);
return;
}
if (item.source.item.profileId) {
onEnterPublished(item.source.item.profileId);
}
}
}
function buildDeleteAction(item: CreationWorkShelfItem) {
if (!item.canDelete) {
return null;

View File

@@ -1,5 +1,13 @@
import { Share2, Trash2 } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
type CSSProperties,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { copyTextToClipboard } from '../../services/clipboard';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
@@ -10,6 +18,7 @@ import {
import {
type CreationWorkShelfBadgeTone,
type CreationWorkShelfItem,
type CreationWorkShelfKind,
type CreationWorkShelfMetric,
type CreationWorkShelfMetricId,
formatCreationMetricCount,
@@ -33,7 +42,20 @@ const BADGE_TONE_CLASS: Record<CreationWorkShelfBadgeTone, string> = {
};
const METRIC_ANIMATION_DURATION_MS = 820;
const SWIPE_ACTION_WIDTH_PX = 76;
const SWIPE_REVEAL_THRESHOLD_PX = 42;
const SWIPE_DIRECTION_LOCK_PX = 8;
const EMPTY_PUBLISHED_METRICS: CreationWorkShelfMetric[] = [];
const CREATION_WORK_KIND_FALLBACK_COVER: Record<CreationWorkShelfKind, string> =
{
rpg: '/creation-type-references/rpg.webp',
'big-fish': '/creation-type-references/big-fish.webp',
match3d: '/creation-type-references/match3d.webp',
'square-hole': '/creation-type-references/square-hole.webp',
puzzle: '/creation-type-references/puzzle.webp',
'baby-object-match': '/creation-type-references/creative-agent.webp',
'visual-novel': '/creation-type-references/visual-novel.webp',
};
function easeOutCubic(progress: number) {
return 1 - (1 - progress) ** 3;
@@ -68,6 +90,10 @@ function shouldAnimatePublishedMetrics() {
return !window.navigator.userAgent.toLowerCase().includes('jsdom');
}
function clampSwipeOffset(value: number, revealWidth: number) {
return Math.min(0, Math.max(-revealWidth, value));
}
function usePublishedMetricAnimation(
metrics: CreationWorkShelfMetric[],
previousMetricValues?: Partial<Record<CreationWorkShelfMetricId, number>>,
@@ -199,16 +225,82 @@ export function CustomWorldWorkCard({
'idle',
);
const shareResetTimerRef = useRef<number | null>(null);
const suppressOpenResetTimerRef = useRef<number | null>(null);
const suppressOpenRef = useRef(false);
const swipeGestureRef = useRef<{
pointerId: number;
startX: number;
startY: number;
startOffset: number;
isDragging: boolean;
} | null>(null);
const lastSwipeOffsetRef = useRef(0);
const [isSwipeDragging, setIsSwipeDragging] = useState(false);
const [isSwipeActionRevealed, setIsSwipeActionRevealed] = useState(false);
const [swipeOffset, setSwipeOffset] = useState(0);
const isPublished = item.status === 'published';
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 =
Boolean(onClaimPointIncentive) &&
(item.pointIncentive?.claimablePoints ?? 0) > 0;
const displayTitle = formatPlatformWorkDisplayName(item.title);
const fallbackCoverImageSrc = CREATION_WORK_KIND_FALLBACK_COVER[item.kind];
const { cardRef, deltas, displayValues, showGrowth } =
usePublishedMetricAnimation(
isPublished ? item.metrics : EMPTY_PUBLISHED_METRICS,
previousMetricValues,
);
const surfaceOffset = 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 = {
'--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 copyShareText = () => {
const publicWorkCode = item.publicWorkCode?.trim();
const sharePath = item.sharePath?.trim();
@@ -237,210 +329,399 @@ export function CustomWorldWorkCard({
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 updateSwipeGesture = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
const gesture = swipeGestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) {
return;
}
const deltaX = event.clientX - gesture.startX;
const deltaY = event.clientY - gesture.startY;
if (!gesture.isDragging) {
if (
Math.abs(deltaX) < SWIPE_DIRECTION_LOCK_PX &&
Math.abs(deltaY) < SWIPE_DIRECTION_LOCK_PX
) {
return;
}
if (Math.abs(deltaY) > Math.abs(deltaX)) {
swipeGestureRef.current = null;
return;
}
gesture.isDragging = true;
setIsSwipeDragging(true);
}
// 中文注释:横向手势只移动卡片表层,删除动作保持在底层,避免列表滚动时误触。
event.preventDefault();
suppressOpenRef.current = true;
const nextOffset = clampSwipeOffset(
gesture.startOffset + deltaX,
swipeRevealWidth,
);
lastSwipeOffsetRef.current = nextOffset;
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;
setIsSwipeDragging(false);
if (!gesture.isDragging) {
return;
}
const shouldReveal =
lastSwipeOffsetRef.current <=
-Math.min(SWIPE_REVEAL_THRESHOLD_PX, swipeRevealWidth * 0.45);
if (shouldReveal) {
revealSwipeActions();
} else {
closeSwipeActions();
}
suppressOpenRef.current = true;
scheduleOpenSuppressReset();
};
const cancelSwipeGesture = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
const gesture = swipeGestureRef.current;
if (gesture?.pointerId === event.pointerId) {
event.currentTarget.releasePointerCapture?.(event.pointerId);
}
swipeGestureRef.current = null;
setIsSwipeDragging(false);
if (isSwipeActionRevealed) {
revealSwipeActions();
} else {
closeSwipeActions();
}
};
const handleCardOpen = () => {
if (isSwipeActionRevealed) {
closeSwipeActions();
return;
}
onOpen();
};
const handleCardKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowLeft' && swipeRevealWidth > 0) {
event.preventDefault();
revealSwipeActions();
return;
}
if (event.key === 'Escape' && isSwipeActionRevealed) {
event.preventDefault();
closeSwipeActions();
return;
}
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
handleCardOpen();
};
return (
<div
ref={cardRef}
role="button"
tabIndex={0}
aria-label={`${item.openActionLabel}${item.title}`}
onClick={onOpen}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
onOpen();
}}
className={`creation-work-card platform-surface platform-interactive-card relative min-h-[9.5rem] cursor-pointer overflow-hidden px-2.5 py-2.5 text-left sm:min-h-[12.5rem] sm:px-4 sm:py-4 xl:min-h-[11.25rem] xl:px-4 xl:py-3.5 ${isPublished ? 'col-span-2 sm:col-span-1' : ''}`}
style={swipeShellStyle}
className={`creation-work-card-shell ${
isSwipeDragging || isSwipeActionRevealed
? 'creation-work-card-shell--actions-visible'
: ''
}`}
>
<CustomWorldCoverArtwork
imageSrc={item.coverImageSrc}
title={item.title}
fallbackLabel="封面"
renderMode={item.coverRenderMode}
characterImageSrcs={item.coverCharacterImageSrcs}
className="platform-cover-artwork absolute inset-0"
/>
<div className="creation-work-card__overlay absolute inset-0" />
{item.hasUnreadUpdate ? (
<span
aria-label="新生成完成"
className="pointer-events-none absolute right-2 top-2 z-30 h-2.5 w-2.5 rounded-full bg-red-500 shadow-[0_0_0_3px_rgba(255,255,255,0.26),0_0_14px_rgba(239,68,68,0.75)]"
/>
) : null}
<div className="pointer-events-none relative z-20 flex min-h-[8rem] flex-col sm:min-h-[10.5rem] xl:min-h-[9.75rem]">
<div className="pointer-events-auto absolute right-0 top-0 z-30 flex items-center gap-1">
{onDelete ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
disabled={deleteBusy}
aria-label={deleteBusy ? '删除中' : '删除'}
title={deleteBusy ? '删除中' : '删除作品'}
className="grid h-7 w-7 place-items-center rounded-full bg-black/22 text-white/78 transition hover:bg-red-500/22 hover:text-white disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:w-8"
>
{deleteBusy ? (
<span className="text-xs leading-none"></span>
) : (
<Trash2 aria-hidden="true" className="h-3.5 w-3.5" />
)}
</button>
) : null}
{isPublished ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
copyShareText();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
disabled={!item.canShare || !item.sharePath}
title={
!item.canShare || !item.sharePath
? '暂不可分享'
: shareState === 'copied'
{swipeActionCount > 0 ? (
<div
aria-hidden={!isSwipeActionRevealed}
className="creation-work-card__swipe-underlay"
>
<div className="creation-work-card__swipe-actions">
{canUseShareAction ? (
<button
type="button"
tabIndex={isSwipeActionRevealed ? 0 : -1}
onClick={(event) => {
event.stopPropagation();
suppressOpenRef.current = false;
copyShareText();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
title={
shareState === 'copied'
? '已复制'
: shareState === 'failed'
? '复制失败'
: '分享作品'
}
aria-label={
!item.canShare || !item.sharePath
? '暂不可分享'
: shareState === 'copied'
}
aria-label={
shareState === 'copied'
? '分享内容已复制'
: shareState === 'failed'
? '分享内容复制失败'
: '分享'
}
className="inline-flex h-7 min-w-7 items-center justify-center gap-1 whitespace-nowrap rounded-full bg-black/22 px-1.5 text-white/78 transition hover:bg-white/18 hover:text-white disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:min-w-8"
}
className="creation-work-card__swipe-button creation-work-card__swipe-button--share"
>
{shareState === 'idle' ? (
<Share2 aria-hidden="true" className="h-4 w-4" />
) : (
<span className="text-[10px] font-semibold leading-none">
{shareState === 'copied' ? '已复制' : '复制失败'}
</span>
)}
</button>
) : null}
{onDelete ? (
<button
type="button"
tabIndex={isSwipeActionRevealed ? 0 : -1}
onClick={(event) => {
event.stopPropagation();
suppressOpenRef.current = false;
closeSwipeActions();
onDelete();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
disabled={deleteBusy}
aria-label={deleteBusy ? '删除中' : '删除'}
title={deleteBusy ? '删除中' : '删除作品'}
className="creation-work-card__swipe-button creation-work-card__swipe-button--danger"
>
{deleteBusy ? (
<span className="text-xs leading-none">...</span>
) : (
<Trash2 aria-hidden="true" className="h-4 w-4" />
)}
</button>
) : null}
</div>
</div>
) : null}
<div
role="button"
tabIndex={0}
aria-label={`${item.openActionLabel}${item.title}${item.isGenerating ? ',生成中' : ''}`}
onClick={(event) => {
if (suppressOpenRef.current) {
event.preventDefault();
suppressOpenRef.current = false;
return;
}
handleCardOpen();
}}
onKeyDown={handleCardKeyDown}
onPointerDown={beginSwipeGesture}
onPointerMove={updateSwipeGesture}
onPointerUp={endSwipeGesture}
onPointerCancel={cancelSwipeGesture}
style={swipeSurfaceStyle}
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
}`}
>
{shareState === 'idle' ? (
<Share2 aria-hidden="true" className="h-3.5 w-3.5" />
) : (
<span className="text-[10px] font-semibold leading-none">
{shareState === 'copied' ? '已复制' : '复制失败'}
{item.isGenerating
? '生成中'
: item.status === 'published'
? '已发布'
: '草稿'}
</span>
</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>
)}
</button>
))}
</div>
<div className="creation-work-card__summary platform-category-game-item__summary">
{item.summary}
</div>
{isPublished ? (
<div className="creation-work-card__published-info">
{item.pointIncentive ? (
<div className="creation-work-card-incentive">
<div
aria-label={`积分激励总数 ${formatCreationPointIncentiveTotal(item.pointIncentive.totalPoints)} 泥点`}
className="creation-work-card-incentive__metric"
>
<span className="creation-work-card-incentive__label">
</span>
<span className="creation-work-card-incentive__value">
{formatCreationPointIncentiveTotal(
item.pointIncentive.totalPoints,
)}
</span>
</div>
<div
aria-label={`待领取积分 ${item.pointIncentive.claimablePoints} 泥点`}
className="creation-work-card-incentive__metric"
>
<span className="creation-work-card-incentive__label">
</span>
<span className="creation-work-card-incentive__value">
{formatCreationMetricCount(
item.pointIncentive.claimablePoints,
)}
</span>
</div>
<button
type="button"
disabled={!canClaimPointIncentive || pointIncentiveBusy}
onClick={(event) => {
event.stopPropagation();
onClaimPointIncentive?.();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
className="creation-work-card-incentive__button"
>
{pointIncentiveBusy ? '领取中' : '领取积分'}
</button>
</div>
) : null}
<div className="creation-work-card__metrics">
{item.metrics.map((metric) => (
<div
key={`${item.id}-${metric.id}`}
aria-label={`${metric.label} ${displayValues[metric.id] ?? metric.value}${metric.unit}`}
className={`creation-work-card-stat creation-work-card-stat--${metric.tone}`}
>
<span className="creation-work-card-stat__label">
{metric.label}
</span>
<span className="creation-work-card-stat__value">
<span className="creation-work-card-stat__number">
{formatCreationMetricCount(
displayValues[metric.id] ?? metric.value,
)}
</span>
<span className="creation-work-card-stat__unit">
{metric.unit}
</span>
</span>
{showGrowth && deltas[metric.id] > 0 ? (
<span className="creation-work-card-stat__growth">
<span aria-hidden="true"></span>
{formatCreationMetricCount(deltas[metric.id])}
</span>
) : null}
</div>
))}
</div>
</div>
) : null}
</div>
<div className="flex items-start justify-between gap-2 pr-12 sm:gap-3 sm:pr-14">
<div className="flex max-h-[3rem] min-w-0 flex-wrap gap-1 overflow-hidden sm:max-h-none sm:gap-2">
{item.isGenerating ? (
<span className="platform-pill platform-pill--cool max-w-full truncate px-2 py-0.5 text-[9px] sm:px-3 sm:py-1 sm:text-[10px]">
</span>
) : null}
{item.badges.map((badge) => (
<span
key={`${item.id}-${badge.id}`}
className={`platform-pill ${BADGE_TONE_CLASS[badge.tone]} max-w-full truncate px-2 py-0.5 text-[9px] sm:px-3 sm:py-1 sm:text-[10px]`}
>
{formatPlatformWorkDisplayTag(badge.label)}
</span>
))}
</div>
<div
className="creation-work-card__side-cover"
style={sideCoverStyle}
aria-hidden="true"
>
<CustomWorldCoverArtwork
imageSrc={item.coverImageSrc}
fallbackImageSrc={fallbackCoverImageSrc}
title={item.title}
fallbackLabel="封面"
renderMode={item.coverRenderMode}
characterImageSrcs={item.coverCharacterImageSrcs}
className="absolute inset-0"
/>
</div>
{item.hasUnreadUpdate ? (
<span
aria-label="新生成完成"
className="creation-work-card__unread-dot"
/>
) : null}
<div className="mt-3 min-h-0 sm:mt-4 xl:mt-3">
<div className="line-clamp-1 break-words text-base font-black leading-tight text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.52)] sm:text-2xl xl:text-xl">
{displayTitle}
</div>
<div className="mt-2 line-clamp-2 break-words text-[11px] leading-4 text-white/84 [text-shadow:0_1px_8px_rgba(0,0,0,0.5)] sm:mt-3 sm:text-sm sm:leading-6 xl:mt-2">
{item.summary}
</div>
</div>
{isPublished ? (
<div className="mt-auto space-y-2 pt-3 sm:pt-4 xl:pt-3">
{item.pointIncentive ? (
<div className="creation-work-card-incentive">
<div
aria-label={`积分激励总数 ${formatCreationPointIncentiveTotal(item.pointIncentive.totalPoints)} 泥点`}
className="creation-work-card-incentive__metric"
>
<span className="creation-work-card-incentive__label">
</span>
<span className="creation-work-card-incentive__value">
{formatCreationPointIncentiveTotal(
item.pointIncentive.totalPoints,
)}
</span>
</div>
<div
aria-label={`待领取积分 ${item.pointIncentive.claimablePoints} 泥点`}
className="creation-work-card-incentive__metric"
>
<span className="creation-work-card-incentive__label">
</span>
<span className="creation-work-card-incentive__value">
{formatCreationMetricCount(
item.pointIncentive.claimablePoints,
)}
</span>
</div>
<button
type="button"
disabled={!canClaimPointIncentive || pointIncentiveBusy}
onClick={(event) => {
event.stopPropagation();
onClaimPointIncentive?.();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
className="pointer-events-auto creation-work-card-incentive__button"
>
{pointIncentiveBusy ? '领取中' : '领取积分'}
</button>
</div>
) : null}
<div className="grid grid-cols-3 gap-1.5 sm:gap-2">
{item.metrics.map((metric) => (
<div
key={`${item.id}-${metric.id}`}
aria-label={`${metric.label} ${displayValues[metric.id] ?? metric.value}${metric.unit}`}
className={`creation-work-card-stat creation-work-card-stat--${metric.tone}`}
>
<span className="creation-work-card-stat__label">
{metric.label}
</span>
<span className="creation-work-card-stat__value">
<span className="creation-work-card-stat__number">
{formatCreationMetricCount(
displayValues[metric.id] ?? metric.value,
)}
</span>
<span className="creation-work-card-stat__unit">
{metric.unit}
</span>
</span>
{showGrowth && deltas[metric.id] > 0 ? (
<span className="creation-work-card-stat__growth">
<span aria-hidden="true"></span>
{formatCreationMetricCount(deltas[metric.id])}
</span>
) : null}
</div>
))}
</div>
{item.isGenerating ? (
<div className="creation-work-card__generating-mask" aria-hidden="true">
<span className="creation-work-card__spinner" />
<span>...</span>
</div>
) : null}
</div>

View File

@@ -290,6 +290,148 @@ test('buildCreationWorkShelfItems falls back to available gameplay images as cov
);
});
test('buildCreationWorkShelfItems uses generated object keys as cover sources', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [
{
workId: 'puzzle:level-object-key',
profileId: 'puzzle-profile-level-object-key',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
levelName: '关卡对象拼图',
summary: '作品摘要带关卡图对象路径时用关卡图做卡片背景。',
themeTags: [],
coverImageSrc: null,
publicationStatus: 'draft',
updatedAt: '2026-05-08T00:00:00.000Z',
publishedAt: null,
publishReady: false,
levels: [
{
levelId: 'level-1',
levelName: '第一关',
pictureDescription: '港口雨夜。',
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '',
assetId: 'asset-1',
prompt: '港口雨夜',
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-1',
coverImageSrc:
'generated-puzzle-assets/session/profile/level-cover.png',
coverAssetId: null,
generationStatus: 'ready',
},
],
},
],
match3dItems: [
{
workId: 'match3d:object-key-cover',
profileId: 'match3d-profile-object-key-cover',
ownerUserId: 'user-1',
gameName: '对象路径抓鹅',
themeText: '糖果厨房',
summary: '背景图或物品图只有 object key 时也应展示。',
tags: [],
coverImageSrc: null,
clearCount: 18,
difficulty: 1,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-07T00:00:00.000Z',
publishReady: false,
generatedBackgroundAsset: {
prompt: '糖果厨房背景',
imageObjectKey:
'generated-match3d-assets/session/profile/background/image.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/background/container.png',
status: 'ready',
},
generatedItemAssets: [
{
itemId: 'item-1',
itemName: '糖果',
imageObjectKey:
'generated-match3d-assets/session/profile/items/item-1/image.png',
imageViews: [
{
viewId: 'view-1',
viewIndex: 1,
imageObjectKey:
'generated-match3d-assets/session/profile/items/item-1/views/view-1.png',
},
],
status: 'image_ready',
},
],
},
],
});
expect(items.find((item) => item.kind === 'puzzle')?.coverImageSrc).toBe(
'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',
);
});
test('buildCreationWorkShelfItems falls back to match3d item object key without background', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [],
match3dItems: [
{
workId: 'match3d:item-object-key-cover',
profileId: 'match3d-profile-item-object-key-cover',
ownerUserId: 'user-1',
gameName: '物品对象路径抓鹅',
themeText: '糖果厨房',
summary: '背景图缺失时用物品视角图对象路径。',
tags: [],
coverImageSrc: null,
clearCount: 18,
difficulty: 1,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-07T00:00:00.000Z',
publishReady: false,
generatedItemAssets: [
{
itemId: 'item-1',
itemName: '糖果',
imageObjectKey:
'generated-match3d-assets/session/profile/items/item-1/image.png',
imageViews: [
{
viewId: 'view-1',
viewIndex: 1,
imageObjectKey:
'generated-match3d-assets/session/profile/items/item-1/views/view-1.png',
},
],
status: 'image_ready',
},
],
},
],
});
expect(items.find((item) => item.kind === 'match3d')?.coverImageSrc).toBe(
'generated-match3d-assets/session/profile/items/item-1/views/view-1.png',
);
});
test('getCreationWorkShelfItemTime parses backend seconds.microsZ values', () => {
expect(getCreationWorkShelfItemTime('1778457601.234567Z')).toBe(
1778457601234.567,

View File

@@ -621,6 +621,11 @@ function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) {
}
for (const level of item.levels ?? []) {
const levelCoverImageSrc = normalizeCoverImageSrc(level.coverImageSrc);
if (levelCoverImageSrc) {
return levelCoverImageSrc;
}
const selectedCandidateImageSrc =
level.selectedCandidateId && level.candidates.length > 0
? normalizeCoverImageSrc(
@@ -632,13 +637,10 @@ function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) {
const fallbackCandidateImageSrc = normalizeCoverImageSrc(
level.candidates[level.candidates.length - 1]?.imageSrc,
);
const levelCoverImageSrc =
selectedCandidateImageSrc ||
normalizeCoverImageSrc(level.coverImageSrc) ||
fallbackCandidateImageSrc;
const candidateImageSrc = selectedCandidateImageSrc || fallbackCandidateImageSrc;
if (levelCoverImageSrc) {
return levelCoverImageSrc;
if (candidateImageSrc) {
return candidateImageSrc;
}
}
@@ -653,18 +655,27 @@ function resolveMatch3DWorkCoverImageSrc(item: Match3DWorkSummary) {
const backgroundImageSrc =
normalizeCoverImageSrc(item.backgroundImageSrc) ||
normalizeCoverImageSrc(item.backgroundImageObjectKey) ||
normalizeCoverImageSrc(item.generatedBackgroundAsset?.imageSrc) ||
normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageSrc);
normalizeCoverImageSrc(item.generatedBackgroundAsset?.imageObjectKey) ||
normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageSrc) ||
normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageObjectKey);
if (backgroundImageSrc) {
return backgroundImageSrc;
}
for (const asset of item.generatedItemAssets ?? []) {
const imageViewSrc = normalizeCoverImageSrc(
asset.imageViews?.find((view) => normalizeCoverImageSrc(view.imageSrc))
?.imageSrc,
const imageView = asset.imageViews?.find(
(view) =>
normalizeCoverImageSrc(view.imageSrc) ||
normalizeCoverImageSrc(view.imageObjectKey),
);
const itemImageSrc = normalizeCoverImageSrc(asset.imageSrc);
const imageViewSrc =
normalizeCoverImageSrc(imageView?.imageSrc) ||
normalizeCoverImageSrc(imageView?.imageObjectKey);
const itemImageSrc =
normalizeCoverImageSrc(asset.imageSrc) ||
normalizeCoverImageSrc(asset.imageObjectKey);
if (imageViewSrc || itemImageSrc) {
return imageViewSrc || itemImageSrc;
}

View File

@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen, within } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
@@ -57,6 +57,14 @@ const baseSession: Match3DAgentSessionSnapshot = {
updatedAt: '2026-05-10T10:00:00.000Z',
};
function confirmMatch3DPointCost() {
const confirmDialog = screen.getByRole('dialog', {
name: '确认消耗泥点',
});
expect(within(confirmDialog).getByText('消耗 10 泥点')).toBeTruthy();
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确定' }));
}
test('match3d workspace submits derived entry form payload instead of agent chat', () => {
const onCreateFromForm = vi.fn();
const onExecuteAction = vi.fn();
@@ -90,6 +98,9 @@ test('match3d workspace submits derived entry form payload instead of agent chat
fireEvent.click(screen.getByRole('button', { name: '进阶' }));
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
expect(onCreateFromForm).not.toHaveBeenCalled();
confirmMatch3DPointCost();
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '赛博水果摊题材消除16次难度6',
themeText: '赛博水果摊',
@@ -128,6 +139,7 @@ test('match3d workspace supports custom 2d asset style prompt', () => {
});
fireEvent.click(screen.getByRole('button', { name: '应用' }));
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
confirmMatch3DPointCost();
expect(onCreateFromForm).toHaveBeenCalledWith(
expect.objectContaining({
@@ -159,6 +171,7 @@ test('match3d workspace submits strict pixel-retro style prompt', () => {
});
fireEvent.click(screen.getByRole('button', { name: '像素复古' }));
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
confirmMatch3DPointCost();
expect(onCreateFromForm).toHaveBeenCalledWith(
expect.objectContaining({
@@ -190,6 +203,7 @@ test('match3d workspace keeps click sound generation disabled from entry form',
target: { value: '海岛甜品' },
});
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
confirmMatch3DPointCost();
expect(onCreateFromForm).toHaveBeenCalledWith(
expect.objectContaining({
@@ -224,6 +238,7 @@ test('match3d workspace falls back to compile action when restored from the lega
).toBe('true');
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
confirmMatch3DPointCost();
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'match3d_compile_draft',

View File

@@ -52,7 +52,7 @@ const MATCH3D_ASSET_STYLE_OPTIONS = [
label: '扁平图标',
imageSrc: '/match3d-style-references/flat-icon.png',
prompt:
'干净扁平的2D游戏道具图标风格正面视角色块清楚边缘硬朗。',
'干净扁平的 2D 游戏道具图标风格,正面视角,色块清楚,边缘硬朗,高可读性,适合移动端休闲游戏素材。',
},
{
id: 'cel-cartoon',
@@ -63,10 +63,10 @@ const MATCH3D_ASSET_STYLE_OPTIONS = [
},
{
id: 'pixel-retro',
label: '像素',
label: '像素复古',
imageSrc: '/match3d-style-references/pixel-retro.png',
prompt:
'像素2D游戏道具sprite风格',
'64x64 复古像素 2D 游戏道具 sprite 风格,限制调色板,硬像素边缘,清晰正面剪影,禁止抗锯齿,禁止柔光渐变,透明背景。',
},
{
id: 'watercolor',
@@ -206,6 +206,7 @@ export function Match3DAgentWorkspace({
resolveInitialFormState(session, initialFormPayload),
);
const [isCustomStylePanelOpen, setIsCustomStylePanelOpen] = useState(false);
const [isPointCostConfirmOpen, setIsPointCostConfirmOpen] = useState(false);
const [draftCustomStylePrompt, setDraftCustomStylePrompt] = useState('');
const appliedInitialFormKeyRef = useRef<string | null>(null);
@@ -219,6 +220,7 @@ export function Match3DAgentWorkspace({
appliedInitialFormKeyRef.current = nextInitialFormKey;
setFormState(resolveInitialFormState(session, initialFormPayload));
setIsCustomStylePanelOpen(false);
setIsPointCostConfirmOpen(false);
setDraftCustomStylePrompt('');
}, [initialFormPayload, session]);
@@ -283,12 +285,22 @@ export function Match3DAgentWorkspace({
return;
}
setIsPointCostConfirmOpen(true);
};
const executeSubmitForm = () => {
if (!canSubmit) {
return;
}
if (onCreateFromForm) {
setIsPointCostConfirmOpen(false);
onCreateFromForm(formPayload);
return;
}
if (session) {
setIsPointCostConfirmOpen(false);
onExecuteAction({
action: 'match3d_compile_draft',
generateClickSound: false,
@@ -539,6 +551,44 @@ export function Match3DAgentWorkspace({
</div>
</div>
) : null}
{isPointCostConfirmOpen ? (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div
role="dialog"
aria-modal="true"
aria-labelledby="match3d-point-cost-confirm-title"
className="platform-modal-shell platform-remap-surface w-full max-w-xs rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
>
<div
id="match3d-point-cost-confirm-title"
className="text-base font-black text-[var(--platform-text-strong)]"
>
</div>
<div className="mt-2 text-sm font-semibold leading-6 text-[var(--platform-text-base)]">
10
</div>
<div className="mt-5 grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setIsPointCostConfirmOpen(false)}
className="platform-button platform-button--secondary justify-center"
>
</button>
<button
type="button"
disabled={!canSubmit}
onClick={executeSubmitForm}
className={`platform-button platform-button--primary justify-center ${!canSubmit ? 'cursor-not-allowed opacity-55' : ''}`}
>
</button>
</div>
</div>
</div>
) : null}
</div>
);
}

View File

@@ -3,9 +3,7 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { AudioGenerationTaskResponse } from '../../../packages/shared/src/contracts/creationAudio';
import type { Match3DWorkProfile } from '../../../packages/shared/src/contracts/match3dWorks';
import * as creationAudioService from '../../services/creation-audio';
import * as match3dWorksService from '../../services/match3d-works';
import { clearMatch3DGeneratedModelBytesCache } from '../../services/match3dGeneratedModelCache';
import { Match3DResultView } from './Match3DResultView';
@@ -32,6 +30,7 @@ vi.mock('../../services/assetReadUrlService', () => ({
vi.mock('../../services/match3d-works', () => ({
generateMatch3DBackgroundImage: vi.fn(),
generateMatch3DContainerImage: vi.fn(),
generateMatch3DCoverImage: vi.fn(),
generateMatch3DItemAssets: vi.fn(),
generateMatch3DWorkTags: vi.fn(),
@@ -40,16 +39,6 @@ vi.mock('../../services/match3d-works', () => ({
updateMatch3DWork: vi.fn(),
}));
vi.mock('../../services/creation-audio', () => ({
createBackgroundMusicTask: vi.fn(),
createSoundEffectTask: vi.fn(),
publishBackgroundMusicAsset: vi.fn(),
publishSoundEffectAsset: vi.fn(),
waitForGeneratedAudioAsset: vi.fn((taskId: string, publish: () => unknown) =>
publish(),
),
}));
afterEach(() => {
clearMatch3DGeneratedModelBytesCache();
vi.clearAllMocks();
@@ -66,6 +55,28 @@ function createDeferred<T>() {
return { promise, reject, resolve };
}
function stubMatch3DCoverUpload(dataUrl: string) {
class MockFileReader {
result: string | ArrayBuffer | null = dataUrl;
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
readAsDataURL() {
this.onload?.();
}
}
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
}
function confirmPointCost() {
const dialogs = screen.getAllByRole('dialog', { name: '确认消耗泥点' });
const dialog = dialogs[dialogs.length - 1]!;
fireEvent.click(
dialog.querySelector('button:last-of-type') as HTMLButtonElement,
);
}
function createProfile(
overrides: Partial<Match3DWorkProfile> = {},
): Match3DWorkProfile {
@@ -177,13 +188,14 @@ describe('Match3DResultView', () => {
});
});
test('面图独立面板支持引用物品素材后 AI 重绘', async () => {
test('面图独立面板支持引用物品素材作为多参考图生成', async () => {
const profile = createProfile({
generatedItemAssets: [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: '/generated-match3d-assets/session/profile/items/i1/image.png',
imageSrc:
'/generated-match3d-assets/session/profile/items/i1/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/i1/image.png',
modelSrc: null,
@@ -208,7 +220,7 @@ describe('Match3DResultView', () => {
'/generated-match3d-assets/session/profile/cover/task/cover.png',
coverImageObjectKey:
'generated-match3d-assets/session/profile/cover/task/cover.png',
prompt: '草莓抓大鹅面图',
prompt: '草莓抓大鹅面图',
});
render(
@@ -220,25 +232,84 @@ describe('Match3DResultView', () => {
/>,
);
fireEvent.click(screen.getByRole('button', { name: '面图' }));
expect(screen.getByRole('dialog', { name: '面图' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '面图' }));
expect(screen.getByRole('dialog', { name: '面图' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '引用草莓' }));
fireEvent.change(screen.getByLabelText('碰面图提示词'), {
target: { value: '草莓抓大鹅面图' },
fireEvent.change(screen.getByLabelText('封面描述'), {
target: { value: '草莓抓大鹅面图' },
});
fireEvent.click(screen.getByRole('button', { name: '生成面图' }));
fireEvent.click(screen.getByRole('button', { name: '生成面图' }));
await waitFor(() => {
expect(match3dWorksService.generateMatch3DCoverImage).toHaveBeenCalledWith(
profile.profileId,
{
prompt: '草莓抓大鹅面图',
referenceImageSrc:
'generated-match3d-assets/session/profile/items/i1/image.png',
},
);
expect(
match3dWorksService.generateMatch3DCoverImage,
).toHaveBeenCalledWith(profile.profileId, {
prompt: '草莓抓大鹅面图',
referenceImageSrcs: [
'generated-match3d-assets/session/profile/items/i1/image.png',
],
uploadedImageSrc: null,
});
expect(onSaved).toHaveBeenCalledWith(nextProfile);
expect(screen.queryByRole('dialog', { name: '面图' })).toBeNull();
expect(screen.queryByRole('dialog', { name: '面图' })).toBeNull();
});
});
test('封面图上传后对齐拼图入口显示 AI 重绘开关和删除按钮', async () => {
const uploadedDataUrl = 'data:image/png;base64,match3d-cover-uploaded';
stubMatch3DCoverUpload(uploadedDataUrl);
const profile = createProfile();
const nextProfile = createProfile({
coverImageSrc:
'/generated-match3d-assets/session/profile/cover/task/cover.png',
});
vi.mocked(match3dWorksService.generateMatch3DCoverImage).mockResolvedValue({
item: nextProfile,
coverImageSrc:
'/generated-match3d-assets/session/profile/cover/task/cover.png',
coverImageObjectKey:
'generated-match3d-assets/session/profile/cover/task/cover.png',
prompt: '保留构图,改成节日果园',
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '封面图' }));
fireEvent.change(
screen.getByLabelText('上传封面图', { selector: 'input' }),
{
target: {
files: [new File(['x'], 'cover.png', { type: 'image/png' })],
},
},
);
await waitFor(() => {
expect(screen.getByRole('switch', { name: 'AI重绘' })).toBeTruthy();
expect(screen.getByRole('button', { name: '移除封面图' })).toBeTruthy();
expect(screen.getByLabelText('AI重绘要求')).toBeTruthy();
});
expect(screen.queryByText('参考图')).toBeNull();
fireEvent.change(screen.getByLabelText('AI重绘要求'), {
target: { value: '保留构图,改成节日果园' },
});
fireEvent.click(screen.getByRole('button', { name: '生成封面图' }));
await waitFor(() => {
expect(
match3dWorksService.generateMatch3DCoverImage,
).toHaveBeenCalledWith(profile.profileId, {
prompt: '保留构图,改成节日果园',
uploadedImageSrc: uploadedDataUrl,
referenceImageSrcs: [],
});
});
});
@@ -495,7 +566,7 @@ describe('Match3DResultView', () => {
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
expect(screen.getByRole('button', { name: '物品' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'UI' })).toBeTruthy();
expect(screen.getByRole('button', { name: '背景音乐' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '背景音乐' })).toBeNull();
fireEvent.click(
screen.getByRole('button', { name: '打开水果核心物件物品素材' }),
@@ -503,8 +574,8 @@ describe('Match3DResultView', () => {
expect(screen.getByRole('dialog', { name: /水果核心物件/u })).toBeTruthy();
expect(screen.getByText('素材名称')).toBeTruthy();
expect(screen.getByText('暂无音效')).toBeTruthy();
expect(screen.getByLabelText('生成点击音效10泥点')).toBeTruthy();
expect(screen.queryByText('暂无音效')).toBeNull();
expect(screen.queryByLabelText('生成点击音效10泥点')).toBeNull();
expect(screen.queryByRole('button', { name: '重新生成' })).toBeNull();
expect(screen.queryByText('用途')).toBeNull();
});
@@ -515,7 +586,8 @@ describe('Match3DResultView', () => {
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: '/generated-match3d-assets/session/profile/items/i1/image.png',
imageSrc:
'/generated-match3d-assets/session/profile/items/i1/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/i1/image.png',
modelSrc: null,
@@ -529,7 +601,8 @@ describe('Match3DResultView', () => {
{
itemId: 'match3d-item-2',
itemName: '苹果',
imageSrc: '/generated-match3d-assets/session/profile/items/i2/image.png',
imageSrc:
'/generated-match3d-assets/session/profile/items/i2/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/i2/image.png',
modelSrc: null,
@@ -591,7 +664,8 @@ describe('Match3DResultView', () => {
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: '/generated-match3d-assets/session/profile/items/i1/image.png',
imageSrc:
'/generated-match3d-assets/session/profile/items/i1/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/i1/image.png',
modelSrc:
@@ -638,14 +712,18 @@ describe('Match3DResultView', () => {
fireEvent.change(screen.getByLabelText('物品名称 4'), {
target: { value: '苹果' },
});
expect(screen.getByRole('button', { name: /生成物品素材 · 2泥点/u })).toBeTruthy();
expect(
screen.getByRole('button', { name: /生成物品素材 · 2泥点/u }),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /生成物品素材/u }));
confirmPointCost();
await waitFor(() => {
expect(match3dWorksService.generateMatch3DItemAssets).toHaveBeenCalledWith(
profile.profileId,
{ itemNames: ['草莓', '苹果', '蓝莓'] },
);
expect(
match3dWorksService.generateMatch3DItemAssets,
).toHaveBeenCalledWith(profile.profileId, {
itemNames: ['草莓', '苹果', '蓝莓'],
});
expect(onSaved).toHaveBeenCalledWith(
expect.objectContaining({ generatedItemAssets }),
);
@@ -661,7 +739,9 @@ describe('Match3DResultView', () => {
test('批量新增面板关闭后素材列表继续显示生成进度', async () => {
const deferred = createDeferred<{
item: Match3DWorkProfile;
generatedItemAssets: NonNullable<Match3DWorkProfile['generatedItemAssets']>;
generatedItemAssets: NonNullable<
Match3DWorkProfile['generatedItemAssets']
>;
}>();
vi.mocked(match3dWorksService.generateMatch3DItemAssets).mockReturnValue(
deferred.promise,
@@ -681,6 +761,7 @@ describe('Match3DResultView', () => {
target: { value: '草莓' },
});
fireEvent.click(screen.getByRole('button', { name: /生成物品素材/u }));
confirmPointCost();
await waitFor(() => {
expect(screen.getAllByText('生成中').length).toBeGreaterThan(0);
@@ -705,6 +786,133 @@ describe('Match3DResultView', () => {
});
});
test('批量重新生成会收集已有物品名称并按替换模式调用素材生成接口', async () => {
const generatedItemAssets = [
{ ...createReadyGeneratedItemAsset(1), itemName: '草莓' },
{ ...createReadyGeneratedItemAsset(2), itemName: '苹果' },
];
const regeneratedAssets = [
{
...generatedItemAssets[0]!,
imageSrc:
'/generated-match3d-assets/session/profile/items/item-1/new-image.png',
},
generatedItemAssets[1]!,
];
const profile = createProfile({ generatedItemAssets });
const onSaved = vi.fn();
vi.mocked(match3dWorksService.generateMatch3DItemAssets).mockResolvedValue({
item: createProfile({ generatedItemAssets: regeneratedAssets }),
generatedItemAssets: regeneratedAssets,
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onSaved={onSaved}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: '批量重新生成' }));
expect(
screen.getByRole('dialog', { name: '批量重新生成物品' }),
).toBeTruthy();
expect(screen.getByLabelText('重新生成物品名称 1')).toHaveProperty(
'value',
'草莓',
);
expect(screen.getByLabelText('重新生成物品名称 2')).toHaveProperty(
'value',
'苹果',
);
fireEvent.change(screen.getByLabelText('重新生成物品名称 2'), {
target: { value: '' },
});
fireEvent.click(screen.getByRole('button', { name: /重新生成物品素材/u }));
confirmPointCost();
await waitFor(() => {
expect(
match3dWorksService.generateMatch3DItemAssets,
).toHaveBeenCalledWith(profile.profileId, {
itemNames: ['草莓'],
mode: 'replace',
});
expect(onSaved).toHaveBeenCalledWith(
expect.objectContaining({ generatedItemAssets: regeneratedAssets }),
);
expect(
screen.getAllByText('已重新生成 1 种物品素材').length,
).toBeGreaterThan(0);
});
expect(
screen.getByRole('button', { name: '打开草莓物品素材' }),
).toBeTruthy();
});
test('批量重新生成只提交能匹配到的已有物品名称', async () => {
const generatedItemAssets = [
{ ...createReadyGeneratedItemAsset(1), itemName: '草莓' },
{ ...createReadyGeneratedItemAsset(2), itemName: '苹果' },
{ ...createReadyGeneratedItemAsset(3), itemName: '梨子' },
{ ...createReadyGeneratedItemAsset(4), itemName: '香蕉' },
{ ...createReadyGeneratedItemAsset(5), itemName: '葡萄' },
{ ...createReadyGeneratedItemAsset(6), itemName: '橙子' },
];
const profile = createProfile({ generatedItemAssets });
vi.mocked(match3dWorksService.generateMatch3DItemAssets).mockResolvedValue({
item: profile,
generatedItemAssets,
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: '批量重新生成' }));
fireEvent.change(screen.getByLabelText('重新生成物品名称 1'), {
target: { value: '草莓' },
});
fireEvent.change(screen.getByLabelText('重新生成物品名称 2'), {
target: { value: '不存在' },
});
fireEvent.change(screen.getByLabelText('重新生成物品名称 3'), {
target: { value: '梨子' },
});
fireEvent.change(screen.getByLabelText('重新生成物品名称 4'), {
target: { value: '' },
});
fireEvent.change(screen.getByLabelText('重新生成物品名称 5'), {
target: { value: '' },
});
fireEvent.change(screen.getByLabelText('重新生成物品名称 6'), {
target: { value: '' },
});
expect(
screen.getByRole('button', { name: /重新生成物品素材 · 2泥点/u }),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /重新生成物品素材/u }));
confirmPointCost();
await waitFor(() => {
expect(
match3dWorksService.generateMatch3DItemAssets,
).toHaveBeenCalledWith(profile.profileId, {
itemNames: ['草莓', '梨子'],
mode: 'replace',
});
});
});
test('难度配置对齐入口页并派生消除次数与物品数量', async () => {
const onSaved = vi.fn();
vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({
@@ -722,9 +930,7 @@ describe('Match3DResultView', () => {
fireEvent.click(screen.getByRole('button', { name: '难度配置' }));
expect(
screen.getByRole('button', { name: '轻松 8次 · 3种' }),
).toBeTruthy();
expect(screen.getByRole('button', { name: '轻松 8次 · 3种' })).toBeTruthy();
const difficultySlider = screen.getByRole('slider', { name: '难度' });
expect((difficultySlider as HTMLInputElement).value).toBe('1');
expect(
@@ -806,16 +1012,16 @@ describe('Match3DResultView', () => {
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(
screen.getByRole('button', { name: '打开物品1物品素材' }),
);
fireEvent.click(screen.getByRole('button', { name: '打开物品1物品素材' }));
expect(screen.getByDisplayValue('物品1')).toBeTruthy();
expect(
[...document.querySelectorAll('img')].some((image) =>
image
.getAttribute('src')
?.includes('generated-match3d-assets/session/profile/items/item-1/views/view-01.png'),
?.includes(
'generated-match3d-assets/session/profile/items/item-1/views/view-01.png',
),
),
).toBe(true);
});
@@ -843,12 +1049,10 @@ describe('Match3DResultView', () => {
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(
screen.getByRole('button', { name: '打开物品1物品素材' }),
);
fireEvent.click(screen.getByRole('button', { name: '打开物品1物品素材' }));
const imageSources = [...document.querySelectorAll('img')].map((image) =>
image.getAttribute('src') ?? '',
const imageSources = [...document.querySelectorAll('img')].map(
(image) => image.getAttribute('src') ?? '',
);
expect(
imageSources.some((source) => source.includes('legacy-primary.png')),
@@ -858,7 +1062,7 @@ describe('Match3DResultView', () => {
).toBe(true);
});
test('物品详情五视角预览使用 1:1 五格布局', () => {
test('物品详情五视角预览使用上方焦点区和底部缩略图栏', () => {
render(
<Match3DResultView
profile={createProfile({
@@ -871,14 +1075,21 @@ describe('Match3DResultView', () => {
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(
screen.getByRole('button', { name: '打开物品1物品素材' }),
);
fireEvent.click(screen.getByRole('button', { name: '打开物品1物品素材' }));
const preview = screen.getByLabelText('物品1五视角预览');
expect(preview.className).toContain('aspect-square');
expect(preview.className).toContain('grid-cols-[repeat(5,minmax(0,1fr))]');
expect(preview.querySelectorAll('img')).toHaveLength(5);
const stage = screen.getByTestId('match3d-item-preview-stage');
const focusFrame = screen.getByTestId('match3d-item-preview-focus-frame');
const thumbnails = screen.getByTestId('match3d-item-preview-thumbnails');
expect(stage.className).toContain('aspect-square');
expect(focusFrame.className).toContain('inset-[7%]');
expect(thumbnails.style.gridAutoColumns).toBe('calc((100% - 1.5rem) / 4)');
expect(preview.querySelectorAll('img')).toHaveLength(10);
expect(
screen
.getByRole('button', { name: '切换物品1视角3' })
.getAttribute('aria-pressed'),
).toBe('true');
});
test('草稿阶段仅有切割图片时展示 2D 素材', () => {
@@ -910,9 +1121,7 @@ describe('Match3DResultView', () => {
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(
screen.getByRole('button', { name: '打开草莓物品素材' }),
);
fireEvent.click(screen.getByRole('button', { name: '打开草莓物品素材' }));
expect(screen.getByDisplayValue('草莓')).toBeTruthy();
expect(
@@ -1109,8 +1318,12 @@ describe('Match3DResultView', () => {
fireEvent.change(screen.getByLabelText('UI背景图画面描述提示词'), {
target: { value: '新背景提示词' },
});
expect(screen.getByRole('button', { name: /重新生成 · 2泥点/u })).toBeTruthy();
expect(
screen.getByRole('button', { name: /重新生成 · 2泥点/u }),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /重新生成/u }));
expect(screen.getByRole('dialog', { name: '确认消耗泥点' })).toBeTruthy();
confirmPointCost();
await waitFor(() => {
expect(
@@ -1137,6 +1350,171 @@ describe('Match3DResultView', () => {
});
});
test('素材配置 UI 子 Tab 重新生成后显示90秒倒计时进度', async () => {
const deferred =
createDeferred<
Awaited<
ReturnType<typeof match3dWorksService.generateMatch3DBackgroundImage>
>
>();
vi.mocked(
match3dWorksService.generateMatch3DBackgroundImage,
).mockReturnValue(deferred.promise);
render(
<Match3DResultView
profile={createProfile({
generatedBackgroundAsset: {
prompt: '旧背景提示词',
imageSrc:
'/generated-match3d-assets/session/profile/background/old/background.png',
imageObjectKey:
'generated-match3d-assets/session/profile/background/old/background.png',
containerPrompt: '旧容器提示词',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/old/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/old/container.png',
status: 'image_ready',
error: null,
},
})}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: 'UI' }));
fireEvent.click(screen.getByRole('button', { name: /重新生成/u }));
confirmPointCost();
await waitFor(() => {
expect(
screen.getByRole('progressbar', { name: 'UI背景图生成进度' }),
).toBeTruthy();
expect(screen.getByText('预计剩余 90 秒')).toBeTruthy();
});
});
test('素材配置容器形象子 Tab 单独调用容器图生成接口并刷新素材', async () => {
const profile = createProfile({
generatedBackgroundAsset: {
prompt: '旧背景提示词',
imageSrc:
'/generated-match3d-assets/session/profile/background/old/background.png',
imageObjectKey:
'generated-match3d-assets/session/profile/background/old/background.png',
containerPrompt: '旧容器提示词',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/old/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/old/container.png',
status: 'image_ready',
error: null,
},
generatedItemAssets: [
{
...createReadyGeneratedItemAsset(1),
backgroundAsset: {
prompt: '旧背景提示词',
imageSrc:
'/generated-match3d-assets/session/profile/background/old/background.png',
imageObjectKey:
'generated-match3d-assets/session/profile/background/old/background.png',
containerPrompt: '旧容器提示词',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/old/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/old/container.png',
status: 'image_ready',
error: null,
},
},
],
});
const nextBackgroundAsset = {
prompt: '旧背景提示词',
imageSrc:
'/generated-match3d-assets/session/profile/background/old/background.png',
imageObjectKey:
'generated-match3d-assets/session/profile/background/old/background.png',
containerPrompt: '新容器提示词',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/new/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/new/container.png',
status: 'image_ready',
error: null,
};
const nextProfile = createProfile({
...profile,
generatedBackgroundAsset: nextBackgroundAsset,
generatedItemAssets: [
{
...profile.generatedItemAssets![0]!,
backgroundAsset: nextBackgroundAsset,
},
],
});
const onSaved = vi.fn();
vi.mocked(
match3dWorksService.generateMatch3DContainerImage,
).mockResolvedValue({
item: nextProfile,
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/new/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/new/container.png',
generatedBackgroundAsset: nextBackgroundAsset,
prompt: '新容器提示词',
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onSaved={onSaved}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: '容器形象' }));
fireEvent.change(screen.getByLabelText('容器形象画面描述提示词'), {
target: { value: '新容器提示词' },
});
fireEvent.click(screen.getByRole('button', { name: /重新生成/u }));
confirmPointCost();
await waitFor(() => {
expect(
match3dWorksService.generateMatch3DContainerImage,
).toHaveBeenCalledWith(profile.profileId, {
prompt: '新容器提示词',
});
expect(
match3dWorksService.generateMatch3DBackgroundImage,
).not.toHaveBeenCalled();
expect(onSaved).toHaveBeenCalledWith(
expect.objectContaining({
generatedItemAssets: [
expect.objectContaining({
itemId: 'match3d-item-1',
backgroundAsset: expect.objectContaining({
prompt: '旧背景提示词',
imageSrc:
'/generated-match3d-assets/session/profile/background/old/background.png',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/new/container.png',
}),
}),
],
}),
);
});
});
test('历史草稿同时带旧 draft 和 profile 素材时以 profile 多视角素材补齐试玩资产', async () => {
const draftAsset = {
...createReadyGeneratedItemAsset(1),
@@ -1178,9 +1556,7 @@ describe('Match3DResultView', () => {
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(
screen.getByRole('button', { name: '打开草莓物品素材' }),
);
fireEvent.click(screen.getByRole('button', { name: '打开草莓物品素材' }));
expect(screen.getByDisplayValue('草莓')).toBeTruthy();
expect(
@@ -1211,8 +1587,7 @@ describe('Match3DResultView', () => {
});
});
test('物品音效提示词可编辑并用于生成音效', async () => {
const createTaskDeferred = createDeferred<AudioGenerationTaskResponse>();
test('物品详情隐藏点击音效生成入口', () => {
const profile = createProfile({
generatedItemAssets: [
{
@@ -1233,23 +1608,6 @@ describe('Match3DResultView', () => {
},
],
});
vi.mocked(creationAudioService.createSoundEffectTask).mockReturnValue(
createTaskDeferred.promise,
);
vi.mocked(creationAudioService.publishSoundEffectAsset).mockResolvedValue({
kind: 'sound_effect',
taskId: 'sound-task-1',
provider: 'vector-engine-vidu',
status: 'completed',
assetObjectId: 'asset-sound-1',
assetKind: 'match3d_click_sound',
audioSrc: '/generated-match3d-assets/audio/click.wav',
});
vi.mocked(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).mockResolvedValue({
item: createProfile({ generatedItemAssets: profile.generatedItemAssets }),
});
render(
<Match3DResultView
@@ -1260,53 +1618,17 @@ describe('Match3DResultView', () => {
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(
screen.getByRole('button', { name: '打开草莓物品素材' }),
);
fireEvent.change(screen.getByLabelText('草莓点击音效提示词'), {
target: { value: '草莓泡泡破裂音效' },
});
fireEvent.click(screen.getByRole('button', { name: /生成点击音效/u }));
fireEvent.click(screen.getByRole('button', { name: '打开草莓物品素材' }));
await waitFor(() => {
expect(creationAudioService.createSoundEffectTask).toHaveBeenCalledWith({
prompt: '草莓泡泡破裂音效',
duration: 3,
});
expect(screen.getByLabelText('音效生成中')).toBeTruthy();
});
createTaskDeferred.resolve({
kind: 'sound_effect',
taskId: 'sound-task-1',
provider: 'vector-engine-vidu',
status: 'submitted',
});
await waitFor(() => {
expect(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).toHaveBeenCalledWith(
'match3d-profile-1',
expect.objectContaining({
generatedItemAssets: [
expect.objectContaining({
soundPrompt: '草莓泡泡破裂音效',
clickSound: expect.objectContaining({
audioSrc: '/generated-match3d-assets/audio/click.wav',
}),
}),
],
}),
);
expect(screen.getByLabelText('草莓点击音效').getAttribute('src')).toBe(
'https://signed.example.com/generated-match3d-assets/audio/click.wav',
);
});
expect(screen.getByRole('dialog', { name: /草莓/u })).toBeTruthy();
expect(screen.queryByLabelText('草莓点击音效提示词')).toBeNull();
expect(screen.queryByRole('button', { name: /生成点击音效/u })).toBeNull();
expect(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).not.toHaveBeenCalled();
});
test('背景音乐子 Tab 使用草稿生成的背景音乐参数并显示进度', async () => {
const createTaskDeferred = createDeferred<AudioGenerationTaskResponse>();
test('素材配置隐藏背景音乐子 Tab', () => {
const profile = createProfile({
generatedItemAssets: [
{
@@ -1330,25 +1652,6 @@ describe('Match3DResultView', () => {
},
],
});
vi.mocked(creationAudioService.createBackgroundMusicTask).mockReturnValue(
createTaskDeferred.promise,
);
vi.mocked(
creationAudioService.publishBackgroundMusicAsset,
).mockResolvedValue({
kind: 'background_music',
taskId: 'music-task-1',
provider: 'vector-engine-suno',
status: 'completed',
assetObjectId: 'asset-music-1',
assetKind: 'match3d_background_music',
audioSrc: '/generated-match3d-assets/audio/music.wav',
});
vi.mocked(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).mockResolvedValue({
item: createProfile({ generatedItemAssets: profile.generatedItemAssets }),
});
render(
<Match3DResultView
@@ -1359,60 +1662,14 @@ describe('Match3DResultView', () => {
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: '背景音乐' }));
expect(screen.getByLabelText('抓大鹅背景音乐曲名')).toHaveProperty(
'value',
'果园轻舞',
);
expect(screen.getByLabelText('抓大鹅背景音乐风格')).toHaveProperty(
'value',
'轻快, 休闲',
);
expect(screen.queryByRole('button', { name: '背景音乐' })).toBeNull();
expect(screen.queryByLabelText('抓大鹅背景音乐曲名')).toBeNull();
expect(screen.queryByLabelText('抓大鹅背景音乐风格')).toBeNull();
expect(screen.queryByLabelText('抓大鹅背景音乐提示词')).toBeNull();
expect(screen.getByRole('button', { name: /生成音乐 · 5泥点/u })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /生成音乐/u }));
await waitFor(() => {
expect(
creationAudioService.createBackgroundMusicTask,
).toHaveBeenCalledWith({
prompt: '',
title: '果园轻舞',
tags: '轻快, 休闲',
});
expect(screen.getByLabelText('音乐生成中')).toBeTruthy();
});
createTaskDeferred.resolve({
kind: 'background_music',
taskId: 'music-task-1',
provider: 'vector-engine-suno',
status: 'submitted',
});
await waitFor(() => {
expect(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).toHaveBeenCalledWith(
'match3d-profile-1',
expect.objectContaining({
generatedItemAssets: [
expect.objectContaining({
backgroundMusicTitle: '果园轻舞',
backgroundMusicStyle: '轻快, 休闲',
backgroundMusicPrompt: '',
backgroundMusic: expect.objectContaining({
audioSrc: '/generated-match3d-assets/audio/music.wav',
}),
}),
],
}),
);
expect(screen.getByLabelText('抓大鹅背景音乐').getAttribute('src')).toBe(
'https://signed.example.com/generated-match3d-assets/audio/music.wav',
);
});
expect(screen.queryByRole('button', { name: /生成音乐/u })).toBeNull();
expect(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).not.toHaveBeenCalled();
});
test('背景音乐在非首个素材时仍显示并进入试玩 profile', async () => {
@@ -1455,14 +1712,7 @@ describe('Match3DResultView', () => {
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: '背景音乐' }));
await waitFor(() => {
expect(screen.getByLabelText('抓大鹅背景音乐').getAttribute('src')).toBe(
'https://signed.example.com/generated-match3d-assets/audio/floating-song.mp3',
);
});
expect(screen.queryByText('暂无音乐')).toBeNull();
expect(screen.queryByRole('button', { name: '背景音乐' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
@@ -1473,8 +1723,7 @@ describe('Match3DResultView', () => {
expect.objectContaining({
itemId: 'match3d-item-1',
backgroundMusic: expect.objectContaining({
audioSrc:
'/generated-match3d-assets/audio/floating-song.mp3',
audioSrc: '/generated-match3d-assets/audio/floating-song.mp3',
}),
}),
]),

File diff suppressed because it is too large Load Diff

View File

@@ -474,6 +474,114 @@ test('运行态会换签并渲染抓大鹅中心容器 UI 图', async () => {
screen.getByTestId('match3d-container-image').getAttribute('src'),
).toBe('https://oss.example.com/match3d-container.png');
});
fireEvent.load(screen.getByTestId('match3d-container-image'));
expect(screen.getByTestId('match3d-board').className).toContain(
'bg-transparent',
);
expect(screen.getByTestId('match3d-board').className).not.toContain(
'rounded-full',
);
});
test('容器图换签失败时保留默认圆形容器兜底', async () => {
const run = startLocalMatch3DRun(3);
const generatedItemAssets: Match3DGeneratedItemAsset[] = [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
imageViews: [],
status: 'image_ready',
modelSrc: null,
modelObjectKey: null,
backgroundAsset: {
prompt: '果园纯背景',
imageSrc: null,
imageObjectKey: null,
containerPrompt: '果园浅盘容器',
containerImageSrc: null,
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/failing-task/container.png',
status: 'image_ready',
error: null,
},
},
];
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('read-url failed'));
renderRuntime(run, generatedItemAssets);
await waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalled();
});
expect(screen.queryByTestId('match3d-container-image')).toBeNull();
expect(screen.getByTestId('match3d-board').className).toContain(
'rounded-full',
);
expect(screen.getByTestId('match3d-board').className).not.toContain(
'bg-transparent',
);
});
test('运行态会从顶层 UI 资产加载背景和容器图', async () => {
const run = startLocalMatch3DRun(3);
vi.spyOn(globalThis, 'fetch').mockImplementation((input) => {
const url = String(input);
const signedUrl = url.includes('ui-container')
? 'https://oss.example.com/match3d-container.png'
: 'https://oss.example.com/match3d-background.png';
return Promise.resolve(
new Response(
JSON.stringify({
read: {
signedUrl,
expiresAt: new Date(Date.now() + 60_000).toISOString(),
},
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
),
);
});
render(
<Match3DRuntimeShell
run={run}
generatedItemAssets={[]}
generatedBackgroundAsset={{
prompt: '果园纯背景',
imageSrc: null,
imageObjectKey:
'generated-match3d-assets/session/profile/background/task/background.png',
containerPrompt: '果园浅盘容器',
containerImageSrc: null,
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/task/container.png',
status: 'image_ready',
error: null,
}}
onBack={vi.fn()}
onRestart={vi.fn()}
onOptimisticRunChange={vi.fn()}
onClickItem={vi.fn()}
/>,
);
await waitFor(() => {
expect(
screen.getByTestId('match3d-background-image').getAttribute('src'),
).toBe('https://oss.example.com/match3d-background.png');
expect(
screen.getByTestId('match3d-container-image').getAttribute('src'),
).toBe('https://oss.example.com/match3d-container.png');
});
fireEvent.load(screen.getByTestId('match3d-container-image'));
expect(screen.getByTestId('match3d-board').className).toContain(
'bg-transparent',
);
});
test('运行态从任意素材读取作品级背景音乐并换签播放', async () => {

View File

@@ -22,7 +22,10 @@ import type {
Match3DRunSnapshot,
Match3DTraySlot,
} from '../../../packages/shared/src/contracts/match3dRuntime';
import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks';
import type {
Match3DGeneratedBackgroundAsset,
Match3DGeneratedItemAsset,
} from '../../../packages/shared/src/contracts/match3dWorks';
import {
isGeneratedLegacyPath,
resolveAssetReadUrl,
@@ -58,6 +61,7 @@ import {
type Match3DRuntimeShellProps = {
run: Match3DRunSnapshot | null;
generatedItemAssets?: Match3DGeneratedItemAsset[];
generatedBackgroundAsset?: Match3DGeneratedBackgroundAsset | null;
backgroundImageSrc?: string | null;
isBusy?: boolean;
error?: string | null;
@@ -459,6 +463,7 @@ function Match3DSettlement({
export function Match3DRuntimeShell({
run,
generatedItemAssets = [],
generatedBackgroundAsset = null,
backgroundImageSrc = null,
isBusy = false,
error = null,
@@ -564,6 +569,8 @@ export function Match3DRuntimeShell({
const backgroundAssetSrc =
backgroundImageSrc?.trim() ||
generatedBackgroundAsset?.imageSrc?.trim() ||
generatedBackgroundAsset?.imageObjectKey?.trim() ||
runtimeGeneratedItemAssets
.map(
(asset) =>
@@ -574,6 +581,8 @@ export function Match3DRuntimeShell({
.find(Boolean) ||
'';
const containerAssetSrc =
generatedBackgroundAsset?.containerImageSrc?.trim() ||
generatedBackgroundAsset?.containerImageObjectKey?.trim() ||
runtimeGeneratedItemAssets
.map(
(asset) =>
@@ -606,6 +615,10 @@ export function Match3DRuntimeShell({
?.backgroundMusic?.audioSrc ?? null;
const [resolvedBackgroundMusicSrc, setResolvedBackgroundMusicSrc] = useState('');
const [resolvedContainerImageSrc, setResolvedContainerImageSrc] = useState('');
const [isContainerImageLoaded, setIsContainerImageLoaded] = useState(false);
const hasRenderedContainerAsset = Boolean(
resolvedContainerImageSrc && isContainerImageLoaded,
);
const clickSoundByTypeId = useMemo(() => {
if (!run) {
return new Map<string, string>();
@@ -715,11 +728,14 @@ export function Match3DRuntimeShell({
useEffect(() => {
if (!containerAssetSrc) {
setResolvedContainerImageSrc('');
setIsContainerImageLoaded(false);
return undefined;
}
let cancelled = false;
const controller = new AbortController();
setResolvedContainerImageSrc('');
setIsContainerImageLoaded(false);
void resolveAssetReadUrl(containerAssetSrc, {
signal: controller.signal,
expireSeconds: 300,
@@ -727,11 +743,13 @@ export function Match3DRuntimeShell({
.then((resolvedSrc) => {
if (!cancelled) {
setResolvedContainerImageSrc(resolvedSrc);
setIsContainerImageLoaded(false);
}
})
.catch(() => {
if (!cancelled) {
setResolvedContainerImageSrc('');
setIsContainerImageLoaded(false);
}
});
@@ -875,6 +893,7 @@ export function Match3DRuntimeShell({
src={resolvedBackgroundImageSrc}
alt=""
aria-hidden="true"
data-testid="match3d-background-image"
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
/>
) : null}
@@ -921,7 +940,11 @@ export function Match3DRuntimeShell({
<section className="relative mt-3 flex flex-1 min-h-0 items-center justify-center">
<div
ref={stageRef}
className="relative aspect-square max-w-full overflow-hidden rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)]"
className={`relative aspect-square max-w-full ${
hasRenderedContainerAsset
? 'overflow-visible bg-transparent'
: 'overflow-hidden rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)]'
}`}
style={{
width: 'min(92vw, 58dvh, 100%)',
}}
@@ -933,8 +956,15 @@ export function Match3DRuntimeShell({
src={resolvedContainerImageSrc}
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-[-4%] z-0 h-[108%] w-[108%] object-contain"
className={`pointer-events-none absolute inset-[-8%] z-0 h-[116%] w-[116%] object-contain drop-shadow-[0_22px_42px_rgba(15,23,42,0.28)] ${
isContainerImageLoaded ? 'opacity-100' : 'opacity-0'
}`}
data-testid="match3d-container-image"
onLoad={() => setIsContainerImageLoaded(true)}
onError={() => {
setIsContainerImageLoaded(false);
setResolvedContainerImageSrc('');
}}
/>
) : (
<div className="pointer-events-none absolute inset-[7%] z-0 rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />

View File

@@ -642,6 +642,7 @@ function mapPublicWorkDetailToMatch3DWork(
backgroundImageSrc: entry.backgroundImageSrc ?? null,
backgroundImageObjectKey: entry.backgroundImageObjectKey ?? null,
generatedBackgroundAsset:
entry.generatedBackgroundAsset ??
entry.generatedItemAssets
?.map((asset) => asset.backgroundAsset ?? null)
.find(Boolean) ?? null,
@@ -744,6 +745,32 @@ function resolveMatch3DRuntimeGeneratedItemAssets(
: normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
}
function resolveMatch3DRuntimeGeneratedBackgroundAsset(
run: Match3DRunSnapshot | null,
profile: Match3DWorkProfile | null,
publicWorkDetail: PlatformPublicGalleryCard | null,
) {
const runProfileId = run?.profileId?.trim() ?? '';
const profileBackground = profile?.generatedBackgroundAsset ?? null;
const publicBackground =
publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail)
? (publicWorkDetail.generatedBackgroundAsset ?? null)
: null;
if (runProfileId && profile?.profileId === runProfileId) {
return profileBackground ?? publicBackground;
}
if (
runProfileId &&
publicWorkDetail &&
isMatch3DGalleryEntry(publicWorkDetail) &&
publicWorkDetail.profileId === runProfileId
) {
return publicBackground ?? profileBackground;
}
return profileBackground ?? publicBackground;
}
function resolveActiveMatch3DRuntimeProfile(
run: Match3DRunSnapshot | null,
runtimeProfile: Match3DWorkProfile | null,
@@ -8911,6 +8938,11 @@ export function PlatformEntryFlowShellImpl({
activeMatch3DRuntimeProfile,
activeEntry,
)}
generatedBackgroundAsset={resolveMatch3DRuntimeGeneratedBackgroundAsset(
match3dRun,
activeMatch3DRuntimeProfile,
activeEntry,
)}
backgroundImageSrc={resolveMatch3DRuntimeBackgroundImageSrc(
match3dRun,
activeMatch3DRuntimeProfile,
@@ -10985,6 +11017,11 @@ export function PlatformEntryFlowShellImpl({
activeMatch3DRuntimeProfile,
selectedPublicWorkDetail,
)}
generatedBackgroundAsset={resolveMatch3DRuntimeGeneratedBackgroundAsset(
match3dRun,
activeMatch3DRuntimeProfile,
selectedPublicWorkDetail,
)}
backgroundImageSrc={resolveMatch3DRuntimeBackgroundImageSrc(
match3dRun,
activeMatch3DRuntimeProfile,

View File

@@ -150,6 +150,14 @@ function stubCanvas(dataUrl: string, drawImage = vi.fn()) {
return drawImage;
}
function confirmPuzzlePointCost() {
const confirmDialog = screen.getByRole('dialog', {
name: '确认消耗泥点',
});
expect(within(confirmDialog).getByText('消耗 2 泥点')).toBeTruthy();
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确定' }));
}
test('puzzle workspace submits the work form instead of agent chat', () => {
const onCreateFromForm = vi.fn();
@@ -174,6 +182,9 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
});
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
expect(onCreateFromForm).not.toHaveBeenCalled();
confirmPuzzlePointCost();
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '一只猫在雨夜灯牌下回头。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
@@ -243,6 +254,7 @@ test('puzzle workspace keeps the reference image upload as a primary panel', ()
target: { value: '一只猫在阳光窗台上看着毛线球。' },
});
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
confirmPuzzlePointCost();
expect(onCreateFromForm).toHaveBeenCalledWith(
expect.objectContaining({
pictureDescription: '一只猫在阳光窗台上看着毛线球。',
@@ -303,6 +315,7 @@ test('puzzle workspace selects a history image from the upload card', async () =
target: { value: '保留历史图里的主体,改成晴天花园。' },
});
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
confirmPuzzlePointCost();
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '保留历史图里的主体,改成晴天花园。',
@@ -356,6 +369,7 @@ test('puzzle workspace falls back to compile action for restored sessions', () =
);
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
confirmPuzzlePointCost();
expect(onCreateFromForm).not.toHaveBeenCalled();
expect(onExecuteAction).toHaveBeenCalledWith({
@@ -389,6 +403,7 @@ test('puzzle workspace switches the image model from the description box', () =>
expect(screen.queryByRole('menuitemradio', { name: '原模型' })).toBeNull();
fireEvent.click(screen.getByRole('menuitemradio', { name: 'nanobanana2' }));
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
confirmPuzzlePointCost();
expect(onCreateFromForm).toHaveBeenCalledWith(
expect.objectContaining({
@@ -538,6 +553,7 @@ test('puzzle workspace submits uploaded reference image when AI redraw is on', a
target: { value: '保留上传画面的主体和构图,改成雨夜灯街。' },
});
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
confirmPuzzlePointCost();
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '保留上传画面的主体和构图,改成雨夜灯街。',

View File

@@ -162,6 +162,7 @@ export function PuzzleAgentWorkspace({
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] =
useState(false);
const [isPointCostConfirmOpen, setIsPointCostConfirmOpen] = useState(false);
const previousSessionIdRef = useRef<string | null>(
session?.sessionId ?? null,
);
@@ -192,6 +193,7 @@ export function PuzzleAgentWorkspace({
setCropState(null);
setIsHistoryPickerOpen(false);
setIsRemoveImageConfirmOpen(false);
setIsPointCostConfirmOpen(false);
}, [initialFormPayload, session]);
const pictureDescription = formState.pictureDescription.trim();
@@ -359,6 +361,19 @@ export function PuzzleAgentWorkspace({
return;
}
if (formState.aiRedraw) {
setIsPointCostConfirmOpen(true);
return;
}
executeSubmitForm();
};
const executeSubmitForm = () => {
if (!canSubmit) {
return;
}
const payloadPictureDescription = formState.aiRedraw
? pictureDescription
: pictureDescription || formState.referenceImageLabel || '上传拼图图片';
@@ -371,10 +386,12 @@ export function PuzzleAgentWorkspace({
};
if (!session && onCreateFromForm) {
setIsPointCostConfirmOpen(false);
onCreateFromForm(payload);
return;
}
setIsPointCostConfirmOpen(false);
onExecuteAction({
action: 'compile_puzzle_draft',
promptText: payloadPictureDescription,
@@ -697,6 +714,43 @@ export function PuzzleAgentWorkspace({
</div>
</div>
) : null}
{isPointCostConfirmOpen ? (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div
role="dialog"
aria-modal="true"
aria-labelledby="puzzle-point-cost-confirm-title"
className="platform-modal-shell platform-remap-surface w-full max-w-xs rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
>
<div
id="puzzle-point-cost-confirm-title"
className="text-base font-black text-[var(--platform-text-strong)]"
>
</div>
<div className="mt-2 text-sm font-semibold leading-6 text-[var(--platform-text-base)]">
2
</div>
<div className="mt-5 grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setIsPointCostConfirmOpen(false)}
className="platform-button platform-button--secondary justify-center"
>
</button>
<button
type="button"
disabled={!canSubmit}
onClick={executeSubmitForm}
className={`platform-button platform-button--primary justify-center ${!canSubmit ? 'cursor-not-allowed opacity-55' : ''}`}
>
</button>
</div>
</div>
</div>
) : null}
</div>
);
}

View File

@@ -542,7 +542,7 @@ describe('PuzzleResultView', () => {
const publishDialog = screen.getByRole('dialog', { name: '发布拼图作品' });
expect(within(publishDialog).getByText('还有关卡画面正在生成。')).toBeTruthy();
expect(
within(publishDialog).getByRole('button', { name: '发布到广场' }),
within(publishDialog).getByRole('button', { name: /发布到广场/u }),
).toHaveProperty('disabled', true);
});
@@ -561,7 +561,7 @@ describe('PuzzleResultView', () => {
fireEvent.click(
within(screen.getByRole('dialog', { name: '发布拼图作品' })).getByRole(
'button',
{ name: '发布到广场' },
{ name: /发布到广场/u },
),
);
@@ -598,7 +598,7 @@ describe('PuzzleResultView', () => {
fireEvent.click(screen.getByRole('button', { name: /发布/u }));
const dialog = screen.getByRole('dialog', { name: '发布拼图作品' });
fireEvent.click(
within(dialog).getByRole('button', { name: '发布到广场' }),
within(dialog).getByRole('button', { name: /发布到广场/u }),
);
rerender(
@@ -715,6 +715,56 @@ describe('PuzzleResultView', () => {
expect(within(preview).getByLabelText('拼图区边界')).toBeTruthy();
});
test('UI背景只有 objectKey 时草稿页仍显示生成图', () => {
const base = createSession();
const level = base.draft!.levels![0]!;
render(
<PuzzleResultView
session={createSession({
draft: {
...base.draft!,
levels: [
{
...level,
uiBackgroundPrompt: '雨夜猫街竖屏拼图UI背景',
uiBackgroundImageSrc: null,
uiBackgroundImageObjectKey:
'generated-puzzle-assets/session/ui/background-object-key.png',
},
],
},
})}
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
expect(screen.getByAltText('拼图UI背景图').getAttribute('src')).toBe(
'/generated-puzzle-assets/session/ui/background-object-key.png',
);
expect(screen.getByRole('button', { name: /重新生成/u })).toBeTruthy();
});
test('does not display local fallback as saved UI background prompt', () => {
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
expect(screen.getByLabelText('拼图UI背景提示词')).toHaveProperty(
'value',
'',
);
});
test('generates UI background with edited prompt and current levels snapshot', () => {
const onExecuteAction = vi.fn();
@@ -732,6 +782,11 @@ describe('PuzzleResultView', () => {
});
expect(screen.getByRole('button', { name: /生成UI背景 · 2泥点/u })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
const confirmDialog = screen.getByRole('dialog', {
name: '确认消耗泥点',
});
expect(within(confirmDialog).getByText('消耗 2 泥点')).toBeTruthy();
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确定' }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_puzzle_ui_background',
@@ -752,7 +807,7 @@ describe('PuzzleResultView', () => {
]);
});
test('素材配置背景音乐试听使用签名地址', () => {
test('素材配置隐藏背景音乐入口', () => {
const base = createSession();
const level = base.draft!.levels![0]!;
@@ -784,15 +839,12 @@ describe('PuzzleResultView', () => {
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: '背景音乐' }));
expect(screen.getByRole('button', { name: /重新生成音乐 · 5泥点/u })).toBeTruthy();
expect(screen.getByLabelText('拼图背景音乐').getAttribute('src')).toBe(
'https://signed.example.com/generated-puzzle-assets/session/audio/music.mp3',
);
expect(screen.queryByRole('button', { name: '背景音乐' })).toBeNull();
expect(screen.queryByRole('button', { name: /重新生成音乐/u })).toBeNull();
expect(screen.queryByLabelText('拼图背景音乐')).toBeNull();
});
test('生成完成回包合并音乐和UI背景后试玩使用最新资源', () => {
test('生成完成回包合并历史音乐和UI背景后试玩使用最新资源', () => {
const onStartTestRun = vi.fn();
const base = createSession();
const localLevel = {
@@ -857,10 +909,7 @@ describe('PuzzleResultView', () => {
expect(screen.getByAltText('拼图UI背景图').getAttribute('src')).toBe(
'/generated-puzzle-assets/session/ui/fruit-background.png',
);
fireEvent.click(screen.getByRole('button', { name: '背景音乐' }));
expect(screen.getByLabelText('拼图背景音乐').getAttribute('src')).toBe(
'https://signed.example.com/generated-puzzle-assets/session/audio/fruit.mp3',
);
expect(screen.queryByRole('button', { name: '背景音乐' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '试玩' }));

View File

@@ -7,7 +7,6 @@ import {
LayoutTemplate,
Loader2,
MessageSquareText,
Music,
Play,
Plus,
Sparkles,
@@ -18,7 +17,6 @@ import {
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { CreationAudioAsset } from '../../../packages/shared/src/contracts/creationAudio';
import type { CreativeDraftEditResult } from '../../../packages/shared/src/contracts/creativeAgent';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
@@ -26,14 +24,9 @@ import type {
PuzzleResultDraft,
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import {
createBackgroundMusicTask,
publishBackgroundMusicAsset,
waitForGeneratedAudioAsset,
} from '../../services/creation-audio';
import { updatePuzzleWork } from '../../services/puzzle-works';
import { resolvePuzzleUiBackgroundSource } from '../../services/puzzle-runtime/puzzleUiBackgroundSource';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { useAuthUi } from '../auth/AuthUiContext';
import PuzzleHistoryAssetPickerDialog from '../puzzle-agent/PuzzleHistoryAssetPickerDialog';
import {
@@ -63,7 +56,7 @@ type PuzzleResultViewProps = {
type PuzzleAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
type PuzzleResultTab = 'levels' | 'work' | 'assets';
type PuzzleAssetConfigTabId = 'ui' | 'music';
type PuzzleAssetConfigTabId = 'ui';
type DraftEditState = {
workTitle: string;
@@ -76,10 +69,8 @@ const PUZZLE_MIN_THEME_TAG_COUNT = 3;
const PUZZLE_MAX_THEME_TAG_COUNT = 6;
const PUZZLE_AUTOSAVE_DEBOUNCE_MS = 600;
const PUZZLE_IMAGE_GENERATION_POINT_COST = 2;
const PUZZLE_BACKGROUND_MUSIC_POINT_COST = 5;
const PUZZLE_PUBLISH_POINT_COST = 1;
const PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS = 90;
const PUZZLE_BACKGROUND_MUSIC_ASSET_KIND = 'puzzle_background_music';
const PUZZLE_BACKGROUND_MUSIC_SLOT = 'background_music';
const PUZZLE_UI_BACKGROUND_REFERENCE_SRC =
'/ui-previews/puzzle-image-compact-ui-2026-05-08.png';
@@ -94,7 +85,6 @@ const PUZZLE_ASSET_CONFIG_TABS: Array<{
label: string;
}> = [
{ id: 'ui', label: 'UI' },
{ id: 'music', label: '背景音乐' },
];
type PuzzleLevelGenerationRuntime = {
@@ -1099,8 +1089,13 @@ function PuzzlePublishDialog({
{actionError}
</div>
) : publishReady ? (
<div className="platform-banner platform-banner--success text-sm leading-6">
<div className="space-y-2">
<div className="platform-banner platform-banner--success text-sm leading-6">
</div>
<div className="platform-banner platform-banner--warning text-sm font-semibold leading-6">
{PUZZLE_PUBLISH_POINT_COST}
</div>
</div>
) : (
<div className="space-y-2">
@@ -1151,7 +1146,9 @@ function PuzzlePublishDialog({
disabled={!publishReady || isBusy}
className={`platform-button platform-button--primary ${!publishReady || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isBusy ? '发布中...' : '发布到广场'}
{isBusy
? '发布中...'
: `发布到广场 · ${PUZZLE_PUBLISH_POINT_COST}泥点`}
</button>
</div>
</div>
@@ -1426,11 +1423,13 @@ function PuzzleUiAssetsTab({
editState,
firstLevel,
);
const prompt = firstLevel?.uiBackgroundPrompt ?? defaultPrompt;
const prompt = firstLevel?.uiBackgroundPrompt ?? '';
const normalizedPrompt = prompt.trim() || defaultPrompt.trim();
const backgroundPreviewSrc =
firstLevel?.uiBackgroundImageSrc?.trim() || PUZZLE_UI_BACKGROUND_REFERENCE_SRC;
resolvePuzzleUiBackgroundSource(firstLevel) || PUZZLE_UI_BACKGROUND_REFERENCE_SRC;
const hasGeneratedUiBackground = Boolean(resolvePuzzleUiBackgroundSource(firstLevel));
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [isCostConfirmOpen, setIsCostConfirmOpen] = useState(false);
const updateFirstLevel = (nextLevel: PuzzleDraftLevel) => {
onChange({
@@ -1495,11 +1494,7 @@ function PuzzleUiAssetsTab({
if (!firstLevel || !normalizedPrompt) {
return;
}
updateFirstLevel({
...firstLevel,
uiBackgroundPrompt: normalizedPrompt,
});
onGenerate(normalizedPrompt);
setIsCostConfirmOpen(true);
}}
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 ${!firstLevel || !normalizedPrompt || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
>
@@ -1508,7 +1503,7 @@ function PuzzleUiAssetsTab({
) : (
<Wand2 className="h-4 w-4" />
)}
{firstLevel?.uiBackgroundImageSrc ? '重新生成' : '生成UI背景'} · {PUZZLE_IMAGE_GENERATION_POINT_COST}
{hasGeneratedUiBackground ? '重新生成' : '生成UI背景'} · {PUZZLE_IMAGE_GENERATION_POINT_COST}
</button>
</div>
</div>
@@ -1524,6 +1519,53 @@ function PuzzleUiAssetsTab({
onClose={() => setIsPreviewOpen(false)}
/>
) : null}
{isCostConfirmOpen ? (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div
role="dialog"
aria-modal="true"
aria-labelledby="puzzle-ui-point-cost-confirm-title"
className="platform-modal-shell platform-remap-surface w-full max-w-xs rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
>
<div
id="puzzle-ui-point-cost-confirm-title"
className="text-base font-black text-[var(--platform-text-strong)]"
>
</div>
<div className="mt-2 text-sm font-semibold leading-6 text-[var(--platform-text-base)]">
{PUZZLE_IMAGE_GENERATION_POINT_COST}
</div>
<div className="mt-5 grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setIsCostConfirmOpen(false)}
className="platform-button platform-button--secondary justify-center"
>
</button>
<button
type="button"
disabled={!firstLevel || !normalizedPrompt || isBusy}
onClick={() => {
if (!firstLevel || !normalizedPrompt) {
return;
}
updateFirstLevel({
...firstLevel,
uiBackgroundPrompt: normalizedPrompt,
});
setIsCostConfirmOpen(false);
onGenerate(normalizedPrompt);
}}
className={`platform-button platform-button--primary justify-center ${!firstLevel || !normalizedPrompt || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
>
</button>
</div>
</div>
</div>
) : null}
</div>
);
}
@@ -1648,187 +1690,11 @@ function PuzzleUiRuntimePreviewPanel({
);
}
function PuzzleMusicTab({
editState,
profileId,
sessionId,
isBusy,
onChange,
}: {
editState: DraftEditState;
profileId: string | null;
sessionId: string;
isBusy: boolean;
onChange: (nextState: DraftEditState) => void;
}) {
const currentMusic = editState.levels[0]?.backgroundMusic ?? null;
const [title, setTitle] = useState(() =>
(
currentMusic?.title?.trim() ||
editState.levels[0]?.levelName.trim() ||
editState.workTitle.trim() ||
'拼图'
).slice(0, 40),
);
const [tags, setTags] = useState('轻快, 游戏, 循环, instrumental');
const [statusText, setStatusText] = useState<string | null>(null);
const [errorText, setErrorText] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const { resolvedUrl: resolvedMusicSrc } = useResolvedAssetReadUrl(
currentMusic?.audioSrc,
{ expireSeconds: 300 },
);
const canGenerate = title.trim().length > 0;
const writeMusic = (music: CreationAudioAsset) => {
const firstLevel = editState.levels[0];
if (!firstLevel) {
return;
}
onChange({
...editState,
levels: [
{ ...firstLevel, backgroundMusic: music },
...editState.levels.slice(1),
],
});
};
const generateMusic = async () => {
if (!canGenerate || isGenerating || !editState.levels[0]) {
return;
}
setIsGenerating(true);
setStatusText('生成中');
setErrorText(null);
try {
const task = await createBackgroundMusicTask({
prompt: '',
title: title.trim(),
tags: tags.trim() || null,
});
const asset = await waitForGeneratedAudioAsset(task.taskId, () =>
publishBackgroundMusicAsset(task.taskId, {
entityKind: 'puzzle_work',
entityId: profileId ?? sessionId,
slot: PUZZLE_BACKGROUND_MUSIC_SLOT,
assetKind: PUZZLE_BACKGROUND_MUSIC_ASSET_KIND,
profileId,
storagePrefix: 'puzzle_assets',
}),
);
if (!asset.audioSrc) {
throw new Error('音频生成完成但缺少播放地址。');
}
writeMusic({
taskId: asset.taskId,
provider: asset.provider,
assetObjectId: asset.assetObjectId ?? null,
assetKind: asset.assetKind ?? PUZZLE_BACKGROUND_MUSIC_ASSET_KIND,
audioSrc: asset.audioSrc,
prompt: '',
title: title.trim(),
updatedAt: new Date().toISOString(),
});
setStatusText('已生成');
} catch (caughtError) {
setErrorText(
caughtError instanceof Error ? caughtError.message : '背景音乐生成失败。',
);
setStatusText(null);
} finally {
setIsGenerating(false);
}
};
return (
<div className="space-y-3">
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
{statusText ? (
<span className="platform-pill platform-pill--cool px-3 py-1 text-[11px]">
{statusText}
</span>
) : null}
</div>
{currentMusic?.audioSrc && resolvedMusicSrc ? (
<audio
className="mt-3 w-full"
controls
src={resolvedMusicSrc}
aria-label="拼图背景音乐"
/>
) : currentMusic?.audioSrc ? (
<div className="mt-3 rounded-[0.9rem] border border-[var(--platform-subpanel-border)] bg-white/62 px-3 py-3 text-sm font-semibold text-[var(--platform-text-soft)]">
</div>
) : (
<div className="mt-3 flex h-12 items-center gap-2 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/62 px-3 text-sm font-semibold text-[var(--platform-text-soft)]">
<Music className="h-4 w-4" />
</div>
)}
</section>
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={title}
disabled={isBusy || isGenerating}
onChange={(event) => setTitle(event.target.value)}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
aria-label="背景音乐曲名"
/>
</label>
<label className="mt-3 block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={tags}
disabled={isBusy || isGenerating}
onChange={(event) => setTags(event.target.value)}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
aria-label="背景音乐风格"
/>
</label>
<button
type="button"
disabled={!canGenerate || isBusy || isGenerating}
onClick={() => void generateMusic()}
className={`platform-button platform-button--primary mt-3 min-h-11 w-full justify-center gap-2 ${!canGenerate || isBusy || isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isGenerating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Music className="h-4 w-4" />
)}
{currentMusic ? '重新生成音乐' : '生成音乐'} · {PUZZLE_BACKGROUND_MUSIC_POINT_COST}
</button>
</section>
{errorText ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{errorText}
</div>
) : null}
</div>
);
}
function PuzzleAssetConfigTab({
activeAssetConfigTab,
editState,
imageRefreshKey,
isBusy,
profileId,
sessionId,
onAssetConfigTabChange,
onChange,
onGenerateUiBackground,
@@ -1837,8 +1703,6 @@ function PuzzleAssetConfigTab({
editState: DraftEditState;
imageRefreshKey: string;
isBusy: boolean;
profileId: string | null;
sessionId: string;
onAssetConfigTabChange: (tab: PuzzleAssetConfigTabId) => void;
onChange: (nextState: DraftEditState) => void;
onGenerateUiBackground: (prompt: string) => void;
@@ -1858,15 +1722,6 @@ function PuzzleAssetConfigTab({
onGenerate={onGenerateUiBackground}
/>
) : null}
{activeAssetConfigTab === 'music' ? (
<PuzzleMusicTab
editState={editState}
profileId={profileId}
sessionId={sessionId}
isBusy={isBusy}
onChange={onChange}
/>
) : null}
</div>
);
}
@@ -2300,8 +2155,6 @@ export function PuzzleResultView({
editState={editState}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
profileId={profileId ?? null}
sessionId={session.sessionId}
onAssetConfigTabChange={setActiveAssetConfigTab}
onChange={setEditState}
onGenerateUiBackground={(prompt) => {

View File

@@ -1,7 +1,7 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { beforeEach, expect, test, vi } from 'vitest';
import { expect, test, vi } from 'vitest';
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import { AuthUiContext } from '../auth/AuthUiContext';
@@ -33,41 +33,6 @@ vi.mock('../ResolvedAssetImage', () => ({
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
const mocapMock = vi.hoisted(() => ({
state: 'grab',
x: 0.42,
y: 0.58,
}));
const debugModeMock = vi.hoisted(() => ({
enabled: true,
}));
vi.mock('../../config/debugMode', () => ({
IS_DEBUG_MODE: debugModeMock.enabled,
isDebugMode: () => debugModeMock.enabled,
}));
vi.mock('../../services/useMocapInput', () => ({
useMocapInput: () => ({
status: 'connected',
latestCommand: {
actions: [mocapMock.state],
primaryHand: {x: mocapMock.x, y: mocapMock.y, state: mocapMock.state, source: 'palm_center'},
parseWarnings: [],
},
rawPacketPreview: {text: '{"hands":[{"state":"grab"}]}', receivedAtMs: 1},
error: null,
}),
}));
beforeEach(() => {
debugModeMock.enabled = true;
mocapMock.state = 'grab';
mocapMock.x = 0.42;
mocapMock.y = 0.58;
});
function createAuthValue() {
return {
user: null,
@@ -181,42 +146,7 @@ const clearedRun: PuzzleRunSnapshot = {
},
};
test('调试模式下拼图界面折叠展示 mocap 连接状态,展开后显示最近动作调试信息', () => {
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={{
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'playing',
},
}}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
/>,
);
const debugPanel = screen.getByTestId('puzzle-mocap-debug');
expect(within(debugPanel).getByText('mocap: connected')).toBeTruthy();
const toggleButton = within(debugPanel).getByRole('button', {
name: 'mocap: connected',
});
expect(toggleButton.getAttribute('aria-expanded')).toBe('false');
expect(within(debugPanel).queryByText('动作: grab')).toBeNull();
fireEvent.click(toggleButton);
expect(toggleButton.getAttribute('aria-expanded')).toBe('true');
expect(within(debugPanel).getByText('动作: grab')).toBeTruthy();
expect(within(debugPanel).getByText('手势: grab @ 0.42, 0.58')).toBeTruthy();
expect(within(debugPanel).getByText('解析: 无')).toBeTruthy();
expect(within(debugPanel).getByText(/原始:/)).toBeTruthy();
});
test('非调试模式下拼图界面不渲染 mocap 调试面板', () => {
debugModeMock.enabled = false;
test('拼图界面不调用 mocap也不渲染 mocap 光标或调试面板', () => {
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={{
@@ -234,44 +164,10 @@ test('非调试模式下拼图界面不渲染 mocap 调试面板', () => {
);
expect(screen.queryByTestId('puzzle-mocap-debug')).toBeNull();
expect(screen.queryByTestId('puzzle-mocap-cursor')).toBeNull();
});
test('拼图界面在 mocap open_palm 时显示体感光标', () => {
mocapMock.state = 'open_palm';
mocapMock.x = 0.42;
mocapMock.y = 0.58;
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={{
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'playing',
startedAtMs: Date.now(),
remainingMs: 300_000,
timeLimitMs: 300_000,
},
}}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
/>,
);
const cursor = screen.getByTestId('puzzle-mocap-cursor');
expect(cursor).toBeTruthy();
expect(Number.parseFloat(cursor.style.left)).toBeCloseTo(42);
expect(Number.parseFloat(cursor.style.top)).toBeCloseTo(58);
mocapMock.state = 'grab';
mocapMock.x = 0.42;
mocapMock.y = 0.58;
});
test('抓握时会触发拖拽提交并在松开时落子', () => {
mocapMock.state = 'grab';
mocapMock.x = 0.34;
mocapMock.y = 0.34;
test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
const onDragPiece = vi.fn();
const playingRun: PuzzleRunSnapshot = {
...clearedRun,
@@ -358,12 +254,9 @@ test('抓握时会触发拖拽提交并在松开时落子', () => {
);
});
test('mocap 抓握合并大块时按大块锚点提交拖拽', () => {
test('指针拖拽合并大块时按大块锚点提交拖拽', () => {
const originalRequestAnimationFrame = window.requestAnimationFrame;
const originalCancelAnimationFrame = window.cancelAnimationFrame;
mocapMock.state = 'open_palm';
mocapMock.x = 0.2;
mocapMock.y = 0.2;
const onDragPiece = vi.fn();
const mergedRun: PuzzleRunSnapshot = {
...clearedRun,
@@ -405,7 +298,7 @@ test('mocap 抓握合并大块时按大块锚点提交拖拽', () => {
value: vi.fn(),
});
const { container, rerender, unmount } = renderPuzzleRuntime(
const { container, unmount } = renderPuzzleRuntime(
<PuzzleRuntimeShell
run={mergedRun}
onBack={vi.fn()}
@@ -432,48 +325,34 @@ test('mocap 抓握合并大块时按大块锚点提交拖拽', () => {
height: 300,
toJSON: () => ({}),
}) as DOMRect;
const mergedPiece = container.querySelector(
'[data-merged-piece-outline="true"]',
) as HTMLElement | null;
if (!mergedPiece) {
throw new Error('缺少测试合并拼图片');
}
mocapMock.state = 'grab';
mocapMock.x = 0.2;
mocapMock.y = 0.2;
rerender(
<AuthUiContext.Provider value={createAuthValue()}>
<PuzzleRuntimeShell
run={mergedRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={onDragPiece}
onAdvanceNextLevel={vi.fn()}
/>
</AuthUiContext.Provider>,
);
mocapMock.x = 0.7;
mocapMock.y = 0.7;
rerender(
<AuthUiContext.Provider value={createAuthValue()}>
<PuzzleRuntimeShell
run={mergedRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={onDragPiece}
onAdvanceNextLevel={vi.fn()}
/>
</AuthUiContext.Provider>,
);
mocapMock.state = 'open_palm';
rerender(
<AuthUiContext.Provider value={createAuthValue()}>
<PuzzleRuntimeShell
run={mergedRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={onDragPiece}
onAdvanceNextLevel={vi.fn()}
/>
</AuthUiContext.Provider>,
);
act(() => {
dispatchPointerEvent(mergedPiece, 'pointerdown', {
pointerId: 12,
clientX: 60,
clientY: 60,
});
});
act(() => {
dispatchPointerEvent(mergedPiece, 'pointermove', {
pointerId: 12,
clientX: 210,
clientY: 210,
});
});
act(() => {
dispatchPointerEvent(mergedPiece, 'pointerup', {
pointerId: 12,
clientX: 210,
clientY: 210,
});
});
expect(onDragPiece).toHaveBeenCalledTimes(1);
expect(onDragPiece).toHaveBeenCalledWith({
@@ -491,9 +370,6 @@ test('mocap 抓握合并大块时按大块锚点提交拖拽', () => {
configurable: true,
value: originalCancelAnimationFrame,
});
mocapMock.state = 'grab';
mocapMock.x = 0.42;
mocapMock.y = 0.58;
});
test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
@@ -661,6 +537,37 @@ test('运行态优先把关卡 UI 背景渲染为舞台背景', () => {
expect(backgroundImage).toBeTruthy();
});
test('运行态在只有 UI 背景 objectKey 时仍渲染生成背景', () => {
const runWithUiBackground: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'playing',
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
uiBackgroundImageSrc: null,
uiBackgroundImageObjectKey:
'generated-puzzle-assets/session/ui/background-object-key.png',
remainingMs: 300_000,
timeLimitMs: 300_000,
},
};
const { container } = renderPuzzleRuntime(
<PuzzleRuntimeShell
run={runWithUiBackground}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
/>,
);
const backgroundImage = container.querySelector(
'img[src="/generated-puzzle-assets/session/ui/background-object-key.png"]',
);
expect(backgroundImage).toBeTruthy();
});
test('关闭通关弹窗后保留底部下一关入口', () => {
vi.useFakeTimers();
const onAdvanceNextLevel = vi.fn();
@@ -1046,9 +953,6 @@ test('移动端点击拼图片时立即触发一次震动反馈', () => {
const originalRequestAnimationFrame = window.requestAnimationFrame;
const originalCancelAnimationFrame = window.cancelAnimationFrame;
const vibrate = vi.fn();
mocapMock.state = 'open_palm';
mocapMock.x = 0.42;
mocapMock.y = 0.58;
const playingRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {

View File

@@ -1,8 +1,6 @@
import {
ArrowLeft,
ArrowRight,
ChevronDown,
ChevronUp,
Clock,
Eye,
Lightbulb,
@@ -24,12 +22,11 @@ import type {
PuzzleRuntimePropKind,
SwapPuzzlePiecesRequest,
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import { isDebugMode } from '../../config/debugMode';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { resolvePuzzleUiBackgroundSource } from '../../services/puzzle-runtime/puzzleUiBackgroundSource';
import {
createRuntimeDragInputController,
createRuntimeInputPointFromClient,
createRuntimeInputPointFromNormalized,
readRuntimeInputElementBounds,
resolveRuntimeInputGridCell,
type RuntimeDragInputSession,
@@ -42,7 +39,6 @@ import {
playRuntimeLevelClearSound,
resolveRuntimeCountdownSecondBucket,
} from '../../services/runtimeAudioFeedback';
import { useMocapInput } from '../../services/useMocapInput';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
import { useAuthUi } from '../auth/AuthUiContext';
import { PixelIcon } from '../PixelIcon';
@@ -230,8 +226,6 @@ const PUZZLE_HINT_DEMO_DURATION_MS = 1_250;
const PUZZLE_PIECE_PRESS_HAPTIC_PATTERN_MS = 12;
const PUZZLE_EXIT_REMODEL_PROMPT_STORAGE_PREFIX =
'genarrative.puzzle-runtime.exit-remodel-prompt.v1';
const PUZZLE_MOCAP_DRAG_INPUT_ID = 'mocap:primary-hand';
const PUZZLE_MOCAP_CURSOR_FRAME_MS = 1000 / 60;
const shownExitRemodelPromptProfileIds = new Set<string>();
@@ -305,16 +299,6 @@ type PuzzleHintDemoState = {
offsetYPercent: number;
};
type PuzzleMocapCursorState = {
x: number;
y: number;
state: string;
};
type PuzzleMocapCursorSample = PuzzleMocapCursorState & {
receivedAtMs: number;
};
type PuzzleRuntimeDragTargetState = {
pieceId: string;
groupId: string | null;
@@ -376,7 +360,6 @@ export function PuzzleRuntimeShell({
const [isFreezeEffectVisible, setIsFreezeEffectVisible] = useState(false);
const [isPropConfirming, setIsPropConfirming] = useState(false);
const [propConfirmError, setPropConfirmError] = useState<string | null>(null);
const [isMocapDebugExpanded, setIsMocapDebugExpanded] = useState(false);
const [hintDemo, setHintDemo] = useState<PuzzleHintDemoState | null>(null);
const [mergeFlash, setMergeFlash] = useState<PuzzleMergeFlashState | null>(
null,
@@ -414,17 +397,6 @@ export function PuzzleRuntimeShell({
pieceId: string;
groupId: string | null;
} | null>(null);
const [mocapCursor, setMocapCursor] = useState<PuzzleMocapCursorState | null>(
null,
);
const mocapCursorPreviousSampleRef = useRef<PuzzleMocapCursorSample | null>(
null,
);
const mocapCursorTargetSampleRef = useRef<PuzzleMocapCursorSample | null>(null);
const mocapCursorIntervalRef = useRef<number | null>(null);
const updateMocapCursorSampleRef = useRef<(
nextSample: PuzzleMocapCursorSample,
) => void>(() => {});
const runtimeDragInputControllerRef = useRef(
createRuntimeDragInputController<string>(),
);
@@ -470,7 +442,7 @@ export function PuzzleRuntimeShell({
currentLevel?.coverImageSrc ?? null,
);
const { resolvedUrl: resolvedUiBackgroundImage } = useResolvedAssetReadUrl(
currentLevel?.uiBackgroundImageSrc ?? null,
resolvePuzzleUiBackgroundSource(currentLevel) ?? null,
);
const tryPlayBackgroundMusic = useCallback(() => {
const audio = backgroundAudioRef.current;
@@ -483,26 +455,6 @@ export function PuzzleRuntimeShell({
audio.volume = Math.max(0, Math.min(1, musicVolume));
void audio.play().catch(() => {});
}, [musicVolume, resolvedBackgroundMusicSrc, runtimeStatus]);
const mocapInput = useMocapInput({enabled: runtimeStatus === 'playing'});
const primaryMocapHand = mocapInput.latestCommand?.primaryHand;
const primaryMocapHandState = primaryMocapHand?.state;
const primaryMocapHandX = primaryMocapHand?.x;
const primaryMocapHandY = primaryMocapHand?.y;
const mocapActionsLabel =
mocapInput.latestCommand?.actions.length
? mocapInput.latestCommand.actions.join(', ')
: '无';
const mocapHandLabel =
primaryMocapHandState &&
typeof primaryMocapHandX === 'number' &&
typeof primaryMocapHandY === 'number'
? `${primaryMocapHandState} @ ${primaryMocapHandX.toFixed(2)}, ${primaryMocapHandY.toFixed(2)}`
: '无';
const mocapParseWarningLabel = mocapInput.latestCommand?.parseWarnings?.length
? mocapInput.latestCommand.parseWarnings.join('')
: '无';
const mocapRawPacketLabel = mocapInput.rawPacketPreview?.text ?? '未收到';
const shouldShowMocapDebugPanel = isDebugMode();
useEffect(() => {
currentLevelRef.current = currentLevel;
@@ -1018,31 +970,6 @@ export function PuzzleRuntimeShell({
readRuntimeInputElementBounds(boardRef.current),
);
const resolveBoardInputPointFromNormalized = (
normalizedX: number,
normalizedY: number,
) =>
createRuntimeInputPointFromNormalized(
normalizedX,
normalizedY,
readRuntimeInputElementBounds(boardRef.current),
);
const resetMocapCursorInterpolation = () => {
mocapCursorPreviousSampleRef.current = null;
mocapCursorTargetSampleRef.current = null;
setMocapCursor(null);
};
updateMocapCursorSampleRef.current = (nextSample: PuzzleMocapCursorSample) => {
const previousTarget = mocapCursorTargetSampleRef.current;
mocapCursorPreviousSampleRef.current = previousTarget ?? nextSample;
mocapCursorTargetSampleRef.current = nextSample;
if (!previousTarget) {
setMocapCursor(nextSample);
}
};
const syncRuntimeDragFromController = (
session: RuntimeDragInputSession<string> | null,
) => {
@@ -1103,136 +1030,6 @@ export function PuzzleRuntimeShell({
},
});
useEffect(() => {
const activeSession = runtimeDragInputControllerRef.current.getSession();
if (!board || runtimeStatus !== 'playing' || isInteractionLocked) {
runtimeDragInputControllerRef.current.cancel();
resetMocapCursorInterpolation();
return;
}
if (
!primaryMocapHandState ||
typeof primaryMocapHandX !== 'number' ||
typeof primaryMocapHandY !== 'number'
) {
runtimeDragInputControllerRef.current.cancel(PUZZLE_MOCAP_DRAG_INPUT_ID);
resetMocapCursorInterpolation();
return;
}
const nextSample = {
x: primaryMocapHandX,
y: primaryMocapHandY,
state: primaryMocapHandState,
receivedAtMs: performance.now(),
};
updateMocapCursorSampleRef.current(nextSample);
const handPoint = resolveBoardInputPointFromNormalized(nextSample.x, nextSample.y);
if (primaryMocapHandState === 'grab') {
if (activeSession?.inputId !== PUZZLE_MOCAP_DRAG_INPUT_ID) {
const sourceCell = resolveRuntimeInputGridCell(handPoint, board);
const sourcePiece = sourceCell
? pieceByCell.get(`${sourceCell.row}:${sourceCell.col}`) ?? null
: null;
if (!sourcePiece) {
runtimeDragInputControllerRef.current.cancel(
PUZZLE_MOCAP_DRAG_INPUT_ID,
);
return;
}
runtimeDragInputControllerRef.current.press({
targetId: sourcePiece.pieceId,
inputId: PUZZLE_MOCAP_DRAG_INPUT_ID,
deviceKind: 'mocap',
point: handPoint,
});
return;
}
runtimeDragInputControllerRef.current.move({
inputId: PUZZLE_MOCAP_DRAG_INPUT_ID,
point: handPoint,
forceDragging: true,
});
return;
}
if (activeSession?.inputId === PUZZLE_MOCAP_DRAG_INPUT_ID) {
runtimeDragInputControllerRef.current.release({
inputId: PUZZLE_MOCAP_DRAG_INPUT_ID,
point: handPoint,
forceDrop: activeSession.deviceKind === 'mocap',
});
}
}, [
board,
isInteractionLocked,
pieceByCell,
primaryMocapHandState,
primaryMocapHandX,
primaryMocapHandY,
runtimeStatus,
]);
useEffect(() => {
if (!board || runtimeStatus !== 'playing') {
if (mocapCursorIntervalRef.current !== null) {
window.clearInterval(mocapCursorIntervalRef.current);
mocapCursorIntervalRef.current = null;
}
return;
}
const tickMocapCursor = () => {
const targetSample = mocapCursorTargetSampleRef.current;
if (!targetSample) {
return;
}
const previousSample = mocapCursorPreviousSampleRef.current ?? targetSample;
const durationMs = Math.max(
PUZZLE_MOCAP_CURSOR_FRAME_MS,
targetSample.receivedAtMs - previousSample.receivedAtMs,
);
const progress = targetSample.receivedAtMs === previousSample.receivedAtMs
? 1
: Math.min(
1,
Math.max(0, (performance.now() - targetSample.receivedAtMs) / durationMs),
);
const nextCursor = {
x: previousSample.x + (targetSample.x - previousSample.x) * progress,
y: previousSample.y + (targetSample.y - previousSample.y) * progress,
state: targetSample.state,
};
const nextPoint = resolveBoardInputPointFromNormalized(
nextCursor.x,
nextCursor.y,
);
setMocapCursor(nextCursor);
const activeSession = runtimeDragInputControllerRef.current.getSession();
if (activeSession?.inputId === PUZZLE_MOCAP_DRAG_INPUT_ID) {
runtimeDragInputControllerRef.current.move({
inputId: PUZZLE_MOCAP_DRAG_INPUT_ID,
point: nextPoint,
forceDragging: true,
});
}
};
tickMocapCursor();
mocapCursorIntervalRef.current = window.setInterval(
tickMocapCursor,
PUZZLE_MOCAP_CURSOR_FRAME_MS,
);
return () => {
if (mocapCursorIntervalRef.current !== null) {
window.clearInterval(mocapCursorIntervalRef.current);
mocapCursorIntervalRef.current = null;
}
};
}, [board, runtimeStatus]);
if (!run || !currentLevel || !board) {
return (
<div
@@ -1810,21 +1607,6 @@ export function PuzzleRuntimeShell({
/>
</div>
) : null}
{mocapCursor ? (
<div
data-testid="puzzle-mocap-cursor"
className={`pointer-events-none absolute z-[70] flex h-8 w-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border-2 ${
mocapCursor.state === 'grab'
? 'border-amber-200 bg-amber-400/90 text-amber-950'
: 'border-cyan-200 bg-cyan-300/90 text-cyan-950'
} shadow-[0_10px_24px_rgba(15,23,42,0.25)]`}
style={{left: `${mocapCursor.x * 100}%`, top: `${mocapCursor.y * 100}%`}}
>
<span className="text-[10px] font-black leading-none">
{mocapCursor.state === 'grab' ? '抓' : '手'}
</span>
</div>
) : null}
{mergeFlash ? (
<div
key={mergeFlash.key}
@@ -1852,45 +1634,6 @@ export function PuzzleRuntimeShell({
</div>
) : null}
{shouldShowMocapDebugPanel ? (
<section
data-testid="puzzle-mocap-debug"
className="w-[min(92vw,34rem)] overflow-hidden rounded-[0.9rem] border border-white/20 bg-slate-950/70 font-mono text-[10px] leading-4 text-white shadow-[0_12px_32px_rgba(15,23,42,0.25)] backdrop-blur"
>
<button
type="button"
aria-expanded={isMocapDebugExpanded}
aria-controls="puzzle-mocap-debug-content"
onClick={() => {
setIsMocapDebugExpanded((current) => !current);
}}
className="flex min-h-9 w-full items-center justify-between gap-3 px-3 py-2 text-left transition hover:bg-white/10"
>
<span className="min-w-0 truncate">
mocap: {mocapInput.status}
</span>
{isMocapDebugExpanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
) : (
<ChevronUp className="h-3.5 w-3.5 shrink-0" />
)}
</button>
{isMocapDebugExpanded ? (
<div
id="puzzle-mocap-debug-content"
className="border-t border-white/10 px-3 pb-2 pt-2"
>
<div>: {mocapActionsLabel}</div>
<div>: {mocapHandLabel}</div>
<div>: {mocapParseWarningLabel}</div>
<div className="max-h-20 overflow-auto break-all text-white/75">
: {mocapRawPacketLabel}
</div>
{mocapInput.error ? <div>: {mocapInput.error}</div> : null}
</div>
) : null}
</section>
) : null}
{canShowNextAction ? (
<button
type="button"

View File

@@ -18,7 +18,9 @@ export function RpgEntryBrandLogo({
aria-hidden={decorative || undefined}
aria-label={decorative ? undefined : '陶泥儿 GENARRATIVE'}
>
<span className="platform-brand-logo__title"></span>
<span className="platform-brand-logo__title">
<span className="platform-brand-logo__title-suffix"></span>
</span>
<span className="platform-brand-logo__subtitle">GENARRATIVE</span>
</span>
);

View File

@@ -94,6 +94,7 @@ import {
} from '../../services/puzzle-runtime';
import {
dragLocalPuzzlePiece,
startLocalPuzzleRun,
swapLocalPuzzlePieces,
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
import {
@@ -289,10 +290,10 @@ const testCreationEntryConfig = {
id: 'visual-novel',
title: '视觉小说',
subtitle: '分支叙事体验',
badge: '可创建',
badge: '敬请期待',
imageSrc: '/creation-type-references/visual-novel.webp',
visible: true,
open: true,
visible: false,
open: false,
sortOrder: 60,
updatedAtMicros: 1,
},
@@ -542,6 +543,10 @@ vi.mock('../../services/puzzle-runtime/puzzleLocalRuntime', async () => {
return {
...actual,
dragLocalPuzzlePiece: vi.fn(actual.dragLocalPuzzlePiece),
startLocalPuzzleRun: vi.fn(
(...args: Parameters<typeof actual.startLocalPuzzleRun>) =>
actual.startLocalPuzzleRun(...args),
),
swapLocalPuzzlePieces: vi.fn(actual.swapLocalPuzzlePieces),
};
});
@@ -810,10 +815,12 @@ vi.mock('../match3d-runtime/Match3DRuntimeShell', () => ({
Match3DRuntimeShell: ({
run,
generatedItemAssets = [],
generatedBackgroundAsset = null,
onBack,
}: {
run: Match3DRunSnapshot | null;
generatedItemAssets?: Match3DWorkSummary['generatedItemAssets'];
generatedBackgroundAsset?: Match3DWorkSummary['generatedBackgroundAsset'];
onBack: () => void;
}) => (
<div className="match3d-runtime-shell-mock">
@@ -873,6 +880,22 @@ vi.mock('../match3d-runtime/Match3DRuntimeShell', () => ({
).length
}
</div>
<div data-testid="match3d-runtime-top-level-background-count">
{
generatedBackgroundAsset?.imageSrc?.trim() ||
generatedBackgroundAsset?.imageObjectKey?.trim()
? 1
: 0
}
</div>
<div data-testid="match3d-runtime-top-level-container-ui-count">
{
generatedBackgroundAsset?.containerImageSrc?.trim() ||
generatedBackgroundAsset?.containerImageObjectKey?.trim()
? 1
: 0
}
</div>
<button type="button" onClick={onBack}>
</button>
@@ -2672,6 +2695,30 @@ beforeEach(() => {
vi.mocked(usePuzzleRuntimeProp).mockImplementation(async (runId) => ({
run: buildMockPuzzleRun(`${runId}-profile`, '后端同步关卡'),
}));
vi.mocked(startLocalPuzzleRun).mockImplementation((item, levelId) => {
const runId = `local-puzzle-run-${item.profileId}`;
const firstLevel = item.levels?.[0] ?? null;
return {
...buildMockPuzzleRun(item.profileId, firstLevel?.levelName ?? item.levelName),
runId,
entryProfileId: item.profileId,
currentLevel: {
...buildMockPuzzleRun(item.profileId, firstLevel?.levelName ?? item.levelName)
.currentLevel!,
runId,
levelId: levelId ?? firstLevel?.levelId ?? null,
coverImageSrc: firstLevel?.coverImageSrc ?? item.coverImageSrc,
uiBackgroundImageSrc:
firstLevel?.uiBackgroundImageSrc ??
(firstLevel?.uiBackgroundImageObjectKey
? `/${firstLevel.uiBackgroundImageObjectKey.replace(/^\/+/u, '')}`
: null),
uiBackgroundImageObjectKey:
firstLevel?.uiBackgroundImageObjectKey ?? null,
backgroundMusic: firstLevel?.backgroundMusic ?? null,
},
};
});
vi.mocked(submitPuzzleLeaderboard).mockImplementation(
async (runId, payload) => ({
run: {
@@ -2795,9 +2842,6 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
expect(
screen.getByRole('tab', { name: '拼图' }).querySelector('img')?.src,
).toContain('/creation-type-references/puzzle.webp');
expect(
screen.getByRole('tab', { name: '视觉小说' }).querySelector('img')?.src,
).toContain('/creation-type-references/visual-novel.webp');
expect(
screen.getByRole('tab', { name: 'AIRP' }).querySelector('img')?.src,
).toContain('/creation-type-references/airp.webp');
@@ -2814,6 +2858,7 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
expect(screen.queryByPlaceholderText('问一问陶泥儿')).toBeNull();
expect(screen.queryByRole('button', { name: /角色扮演/u })).toBeNull();
expect(screen.queryByRole('tab', { name: /方洞挑战/u })).toBeNull();
expect(screen.queryByRole('tab', { name: '视觉小说' })).toBeNull();
expect(screen.getByRole('tab', { name: /抓大鹅/u })).toBeTruthy();
expect(createRpgCreationSession).not.toHaveBeenCalled();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
@@ -3875,27 +3920,46 @@ test('puzzle draft generation auto starts trial and runtime back opens draft res
await openCreateTemplateHub(user);
await user.click(screen.getByRole('button', { name: '生成草稿' }));
expect(await screen.findByText('雨夜猫街')).toBeTruthy();
expect(updatePuzzleWork).toHaveBeenCalledWith(
'puzzle-profile-auto-1',
expect.objectContaining({
levelName: '雨夜猫街',
coverImageSrc: '/puzzle/auto-candidate.png',
levels: [
expect.objectContaining({
uiBackgroundImageSrc:
'/generated-puzzle-assets/puzzle-session-auto-1/ui/background.png',
backgroundMusic: expect.objectContaining({
audioSrc:
'/generated-puzzle-assets/puzzle-session-auto-1/audio/background.mp3',
await waitFor(() => {
expect(updatePuzzleWork).toHaveBeenCalledWith(
'puzzle-profile-auto-1',
expect.objectContaining({
levelName: '雨夜猫街',
coverImageSrc: '/puzzle/auto-candidate.png',
levels: [
expect.objectContaining({
uiBackgroundImageSrc:
'/generated-puzzle-assets/puzzle-session-auto-1/ui/background.png',
backgroundMusic: expect.objectContaining({
audioSrc:
'/generated-puzzle-assets/puzzle-session-auto-1/audio/background.mp3',
}),
}),
}),
],
],
}),
);
});
await waitFor(() => {
expect(startLocalPuzzleRun).toHaveBeenCalledTimes(1);
});
const runtimeWork = vi.mocked(startLocalPuzzleRun).mock.calls[0]?.[0];
expect(runtimeWork?.levels?.[0]).toEqual(
expect.objectContaining({
uiBackgroundImageSrc:
'/generated-puzzle-assets/puzzle-session-auto-1/ui/background.png',
uiBackgroundImageObjectKey:
'generated-puzzle-assets/puzzle-session-auto-1/ui/background.png',
}),
);
const runtimeSnapshot = vi.mocked(startLocalPuzzleRun).mock.results[0]?.value;
expect(runtimeSnapshot?.currentLevel?.uiBackgroundImageSrc).toBe(
'/generated-puzzle-assets/puzzle-session-auto-1/ui/background.png',
);
expect(screen.queryByText('拼图结果页')).toBeNull();
await user.click(screen.getByRole('button', { name: '返回上一页' }));
await user.click(
await screen.findByRole('button', { name: '返回上一页' }),
);
expect(await screen.findByText('拼图结果页')).toBeTruthy();
expect(screen.getByDisplayValue('雨夜猫街')).toBeTruthy();
@@ -4950,6 +5014,80 @@ test('home recommendation Match3D runtime keeps image, music and UI assets witho
);
});
test('home recommendation Match3D runtime passes top-level UI background assets', async () => {
const match3dCard: Match3DWorkSummary = {
workId: 'match3d-work-card-top-level-ui',
profileId: 'match3d-profile-card-top-level-ui',
ownerUserId: 'user-2',
sourceSessionId: 'match3d-session-card-top-level-ui',
gameName: '果园抓大鹅',
themeText: '果园',
summary: '消除果园素材。',
tags: ['果园', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 3,
difficulty: 5,
publicationStatus: 'published',
playCount: 3,
updatedAt: '2026-04-25T10:30:00.000Z',
publishedAt: '2026-04-25T10:30:00.000Z',
publishReady: true,
backgroundImageObjectKey:
'generated-match3d-assets/session/profile/background/background.png',
generatedBackgroundAsset: {
prompt: '果园竖屏纯背景',
imageSrc: null,
imageObjectKey:
'generated-match3d-assets/session/profile/background/background.png',
containerPrompt: '果园浅盘容器',
containerImageSrc: null,
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/container.png',
status: 'image_ready',
error: null,
},
generatedItemAssets: [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc:
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
},
],
};
vi.mocked(listMatch3DGallery).mockResolvedValue({
items: [match3dCard],
});
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
run: buildMockMatch3DRun(match3dCard.profileId),
});
render(<TestWrapper withAuth />);
await waitFor(() => {
expect(
screen.getByTestId('match3d-runtime-top-level-background-count'),
).toHaveProperty('textContent', '1');
});
expect(
screen.getByTestId('match3d-runtime-top-level-container-ui-count'),
).toHaveProperty('textContent', '1');
expect(getMatch3DWorkDetail).not.toHaveBeenCalledWith(
'match3d-profile-card-top-level-ui',
);
});
test('home recommendation Match3D runtime reloads detail when card only has UI assets', async () => {
const match3dCard: Match3DWorkSummary = {
workId: 'match3d-work-card-ui-only',

View File

@@ -2,6 +2,7 @@ import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import { BABY_OBJECT_MATCH_EDUTAINMENT_TAG } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type {
Match3DGeneratedBackgroundAsset,
Match3DGeneratedItemAsset,
Match3DWorkSummary,
} from '../../../packages/shared/src/contracts/match3dWorks';
@@ -117,6 +118,7 @@ export type PlatformMatch3DGalleryCard = {
backgroundPrompt?: string | null;
backgroundImageSrc?: string | null;
backgroundImageObjectKey?: string | null;
generatedBackgroundAsset?: Match3DGeneratedBackgroundAsset | null;
generatedItemAssets?: Match3DGeneratedItemAsset[];
};
@@ -298,6 +300,7 @@ export function mapMatch3DWorkToPlatformGalleryCard(
backgroundPrompt: work.backgroundPrompt ?? null,
backgroundImageSrc: work.backgroundImageSrc ?? null,
backgroundImageObjectKey: work.backgroundImageObjectKey ?? null,
generatedBackgroundAsset: work.generatedBackgroundAsset ?? null,
generatedItemAssets: work.generatedItemAssets ?? [],
};
}