Merge origin/master into hermes/hermes-4fd30995

This commit is contained in:
2026-05-15 03:41:50 +08:00
348 changed files with 31117 additions and 4591 deletions
+9 -7
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">
@@ -0,0 +1,129 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { describe, expect, test } from 'vitest';
import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime';
import { CustomWorldGenerationView } from './CustomWorldGenerationView';
function createProgress(
overrides: Partial<CustomWorldGenerationProgress> = {},
): CustomWorldGenerationProgress {
return {
phaseId: 'draft_foundation',
phaseLabel: '整理草稿',
phaseDetail: '正在整理当前生成步骤。',
batchLabel: '第 2 批',
overallProgress: 42,
completedWeight: 21,
totalWeight: 50,
elapsedMs: 125_000,
estimatedRemainingMs: 75_000,
activeStepIndex: 1,
steps: [
{
id: 'step-1',
label: '收集设定',
detail: '整理初始输入。',
completed: 1,
total: 1,
status: 'completed',
},
{
id: 'step-2',
label: '编译草稿',
detail: '生成首版结构。',
completed: 2,
total: 4,
status: 'active',
},
{
id: 'step-3',
label: '写回结果',
detail: '同步结果页。',
completed: 0,
total: 4,
status: 'pending',
},
],
...overrides,
};
}
describe('CustomWorldGenerationView', () => {
test.each(['拼图草稿生成进度', '抓大鹅草稿生成进度'])(
'hides batch module and keeps wait/timer in one row for %s',
(progressTitle) => {
render(
<CustomWorldGenerationView
settingText="竖屏生成题材"
progress={createProgress()}
isGenerating
error={null}
onBack={() => {}}
onEditSetting={() => {}}
onRetry={() => {}}
settingDescription={null}
settingActionLabel={null}
progressTitle={progressTitle}
/>,
);
expect(screen.queryByText('当前批次')).toBeNull();
expect(screen.getByText('预计等待')).toBeTruthy();
expect(screen.getByText('计时')).toBeTruthy();
const statsNode = screen
.getByText('预计等待')
.closest('.custom-world-generation-stats');
expect(statsNode?.className).toContain(
'custom-world-generation-stats--two-column',
);
expect(statsNode?.getAttribute('style')).toContain(
'grid-template-columns: repeat(2, minmax(0, 1fr))',
);
const stepNodes = [
screen.getByText('收集设定'),
screen.getByText('编译草稿'),
screen.getByText('写回结果'),
].map((node) => node.closest('.custom-world-generation-step'));
expect(stepNodes.every(Boolean)).toBe(true);
expect(stepNodes[0]?.getAttribute('style')).toContain(
'--generation-step-delay: 0ms',
);
expect(stepNodes[1]?.getAttribute('style')).toContain(
'--generation-step-delay: 90ms',
);
expect(stepNodes[2]?.getAttribute('style')).toContain(
'--generation-step-delay: 180ms',
);
},
);
test('keeps batch module for other generation pages', () => {
render(
<CustomWorldGenerationView
settingText="大鱼吃小鱼题材"
progress={createProgress()}
isGenerating
error={null}
onBack={() => {}}
onEditSetting={() => {}}
onRetry={() => {}}
settingDescription={null}
settingActionLabel={null}
progressTitle="大鱼吃小鱼草稿生成进度"
/>,
);
expect(screen.getByText('当前批次')).toBeTruthy();
expect(
screen
.getByText('预计等待')
.closest('.custom-world-generation-stats')
?.className,
).not.toContain('custom-world-generation-stats--two-column');
});
});
+96 -15
View File
@@ -1,4 +1,6 @@
import { motion } from 'motion/react';
import type { CSSProperties } from 'react';
import { useEffect, useState } from 'react';
import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime';
import type { CustomWorldStructuredAnchorEntry } from '../services/customWorldAgentGenerationProgress';
@@ -24,6 +26,7 @@ interface CustomWorldGenerationViewProps {
pausedBadgeLabel?: string;
idleBadgeLabel?: string;
structuredEmptyText?: string;
hideBatchModule?: boolean;
}
function formatDuration(ms: number) {
@@ -86,6 +89,49 @@ function buildFallbackRenderKey(
return normalizedValue ? normalizedValue : fallback;
}
function useIsMobileGenerationLayout() {
const [isMobile, setIsMobile] = useState(() => {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return false;
}
return window.matchMedia('(max-width: 639px)').matches;
});
useEffect(() => {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return undefined;
}
const mediaQuery = window.matchMedia('(max-width: 639px)');
const syncMobileLayout = () => {
setIsMobile(mediaQuery.matches);
};
syncMobileLayout();
if (typeof mediaQuery.addEventListener === 'function') {
mediaQuery.addEventListener('change', syncMobileLayout);
return () => {
mediaQuery.removeEventListener('change', syncMobileLayout);
};
}
mediaQuery.addListener(syncMobileLayout);
return () => {
mediaQuery.removeListener(syncMobileLayout);
};
}, []);
return isMobile;
}
export function CustomWorldGenerationView({
settingText,
anchorEntries = [],
@@ -107,7 +153,9 @@ export function CustomWorldGenerationView({
pausedBadgeLabel = '生成已暂停',
idleBadgeLabel = '等待操作',
structuredEmptyText = '正在整理当前设定结构,请稍后。',
hideBatchModule = false,
}: CustomWorldGenerationViewProps) {
const isMobileGenerationLayout = useIsMobileGenerationLayout();
const progressValue = getProgressPercentage(progress);
const steps = progress?.steps ?? [];
const hasStructuredAnchors = anchorEntries.length > 0;
@@ -116,6 +164,10 @@ export function CustomWorldGenerationView({
const normalizedSettingDescription = settingDescription?.trim() ?? '';
const hasSettingActionLabel = normalizedSettingActionLabel.length > 0;
const hasSettingDescription = normalizedSettingDescription.length > 0;
const shouldHideBatchModule =
hideBatchModule ||
progressTitle === '拼图草稿生成进度' ||
progressTitle === '抓大鹅草稿生成进度';
const estimatedWaitText =
progress?.estimatedRemainingMs != null
? `预计还需 ${formatDuration(progress.estimatedRemainingMs)}`
@@ -179,28 +231,41 @@ export function CustomWorldGenerationView({
/>
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-3 xl:gap-3">
<div className="platform-subpanel rounded-2xl px-4 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
当前批次
<div
className={`custom-world-generation-stats mt-4 grid gap-2 xl:gap-3 ${
shouldHideBatchModule
? 'custom-world-generation-stats--two-column grid-cols-2'
: 'sm:grid-cols-3'
}`}
style={
shouldHideBatchModule
? { gridTemplateColumns: 'repeat(2, minmax(0, 1fr))' }
: undefined
}
>
{shouldHideBatchModule ? null : (
<div className="platform-subpanel min-w-0 rounded-2xl px-4 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
当前批次
</div>
<div className="mt-1 text-sm font-semibold text-white">
{progress?.batchLabel ?? '准备中'}
</div>
</div>
<div className="mt-1 text-sm font-semibold text-white">
{progress?.batchLabel ?? '准备中'}
</div>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-3">
)}
<div className="platform-subpanel min-w-0 rounded-2xl px-3 py-3 sm:px-4">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
预计等待
</div>
<div className="mt-1 text-sm font-semibold text-white">
<div className="mt-1 break-keep text-xs font-semibold text-white sm:text-sm">
{estimatedWaitText}
</div>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-3">
<div className="platform-subpanel min-w-0 rounded-2xl px-3 py-3 sm:px-4">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
计时
</div>
<div className="mt-1 text-sm font-semibold text-white">
<div className="mt-1 break-keep text-xs font-semibold text-white sm:text-sm">
{elapsedText}
</div>
</div>
@@ -211,7 +276,7 @@ export function CustomWorldGenerationView({
const stepProgress = getStepProgressPercentage(step);
return (
<div
<motion.div
key={buildFallbackRenderKey(
step.id,
`progress-step-${index}`,
@@ -222,7 +287,23 @@ export function CustomWorldGenerationView({
: step.status === 'active'
? 'border-sky-300/22 bg-sky-500/10'
: 'platform-subpanel'
}`}
} custom-world-generation-step`}
initial={
isMobileGenerationLayout
? { opacity: 0, x: '-110vw' }
: false
}
animate={{ opacity: 1, x: 0 }}
transition={{
duration: 0.34,
ease: 'easeOut',
delay: isMobileGenerationLayout ? index * 0.09 : 0,
}}
style={
{
'--generation-step-delay': `${index * 90}ms`,
} as CSSProperties
}
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 text-sm font-semibold text-white">
@@ -248,7 +329,7 @@ export function CustomWorldGenerationView({
<div className="mt-2 text-xs leading-6 text-zinc-400">
{step.detail}
</div>
</div>
</motion.div>
);
})}
</div>
+29 -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);
}}
/>
);
}
@@ -0,0 +1,68 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { BarkBattleConfigEditor } from './BarkBattleConfigEditor';
describe('BarkBattleConfigEditor', () => {
it('allows creators to edit lightweight config and publish a Bark Battle work', async () => {
const onPublish = vi.fn();
render(<BarkBattleConfigEditor isBusy={false} onPublish={onPublish} />);
expect(screen.getByRole('heading', { name: '汪汪声浪大作战' })).toBeTruthy();
expect(screen.getByText('轻配置')).toBeTruthy();
expect((screen.getByLabelText('作品标题') as HTMLInputElement).value).toBe('我的声浪竞技场');
expect((screen.getByLabelText('难度预设') as HTMLSelectElement).value).toBe('normal');
expect((screen.getByLabelText('开启排行榜') as HTMLInputElement).checked).toBe(true);
await userEvent.clear(screen.getByLabelText('作品标题'));
await userEvent.type(screen.getByLabelText('作品标题'), '周末狗狗杯');
await userEvent.selectOptions(screen.getByLabelText('主题背景'), 'neon-park');
await userEvent.selectOptions(screen.getByLabelText('玩家狗狗'), 'shiba');
await userEvent.selectOptions(screen.getByLabelText('对手狗狗'), 'husky');
await userEvent.selectOptions(screen.getByLabelText('难度预设'), 'hard');
await userEvent.click(screen.getByLabelText('开启排行榜'));
await userEvent.click(screen.getByRole('button', { name: '发布并试玩' }));
expect(onPublish).toHaveBeenCalledWith({
title: '周末狗狗杯',
description: '',
themePreset: 'neon-park',
playerDogSkinPreset: 'shiba',
opponentDogSkinPreset: 'husky',
difficultyPreset: 'hard',
leaderboardEnabled: false,
});
});
it('requires a non-empty title before publishing', async () => {
const onPublish = vi.fn();
render(<BarkBattleConfigEditor isBusy={false} onPublish={onPublish} />);
await userEvent.clear(screen.getByLabelText('作品标题'));
await userEvent.click(screen.getByRole('button', { name: '发布并试玩' }));
expect(onPublish).not.toHaveBeenCalled();
expect(screen.getByText('请先填写作品标题')).toBeTruthy();
});
it('can render as an embedded creation form without a local page header', () => {
const onPublish = vi.fn();
render(
<BarkBattleConfigEditor
error="发布失败"
isBusy={false}
onPublish={onPublish}
showBackButton={false}
title={null}
/>,
);
expect(screen.queryByRole('heading', { name: '汪汪声浪大作战' })).toBeNull();
expect(screen.queryByRole('button', { name: '返回' })).toBeNull();
expect(screen.getByLabelText('汪汪声浪轻配置编辑器')).toBeTruthy();
expect(screen.getByText('发布失败')).toBeTruthy();
});
});
@@ -0,0 +1,284 @@
import { ArrowLeft, Loader2, Trophy, WandSparkles } from 'lucide-react';
import { useMemo, useState } from 'react';
import type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle';
import type { BarkBattleDifficultyPreset } from '../../../packages/shared/src/contracts/barkBattle';
import { BarkBattlePreviewCard } from './BarkBattlePreviewCard';
export type BarkBattleConfigEditorProps = {
isBusy?: boolean;
error?: string | null;
onPublish: (payload: BarkBattleConfigEditorPayload) => void | Promise<void>;
onBack?: () => void;
showBackButton?: boolean;
title?: string | null;
};
const THEME_OPTIONS = [
{ value: 'sunny-yard', label: '阳光院子' },
{ value: 'neon-park', label: '霓虹公园' },
{ value: 'moonlight-rooftop', label: '月光天台' },
];
const DOG_SKIN_OPTIONS = [
{ value: 'corgi', label: '柯基' },
{ value: 'shiba', label: '柴犬' },
{ value: 'husky', label: '哈士奇' },
];
const DIFFICULTY_OPTIONS: Array<{ value: BarkBattleDifficultyPreset; label: string }> = [
{ value: 'easy', label: '轻松' },
{ value: 'normal', label: '标准' },
{ value: 'hard', label: '硬核' },
];
export function BarkBattleConfigEditor({
isBusy = false,
error: externalError = null,
onPublish,
onBack,
showBackButton = true,
title: headingTitle = '汪汪声浪大作战',
}: BarkBattleConfigEditorProps) {
const [title, setTitle] = useState('我的声浪竞技场');
const [description, setDescription] = useState('');
const [themePreset, setThemePreset] = useState('sunny-yard');
const [playerDogSkinPreset, setPlayerDogSkinPreset] = useState('corgi');
const [opponentDogSkinPreset, setOpponentDogSkinPreset] = useState('husky');
const [difficultyPreset, setDifficultyPreset] = useState<BarkBattleDifficultyPreset>('normal');
const [leaderboardEnabled, setLeaderboardEnabled] = useState(true);
const [localError, setLocalError] = useState<string | null>(null);
const payload = useMemo<BarkBattleConfigEditorPayload>(
() => ({
title: title.trim(),
description: description.trim(),
themePreset,
playerDogSkinPreset,
opponentDogSkinPreset,
difficultyPreset,
leaderboardEnabled,
}),
[
title,
description,
themePreset,
playerDogSkinPreset,
opponentDogSkinPreset,
difficultyPreset,
leaderboardEnabled,
],
);
const handlePublish = () => {
if (!payload.title) {
setLocalError('请先填写作品标题');
return;
}
setLocalError(null);
void onPublish(payload);
};
const visibleError = localError ?? externalError;
return (
<section
className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden"
aria-label="汪汪声浪轻配置编辑器"
>
{showBackButton && onBack ? (
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 sm:mb-4">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
>
<span className="inline-flex items-center gap-1.5">
<ArrowLeft className="h-3.5 w-3.5" />
返回
</span>
</button>
</div>
) : null}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{headingTitle ? (
<div className="mb-3 shrink-0 sm:mb-5">
<div className="flex flex-wrap items-center gap-2">
<h1 className="m-0 text-3xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
{headingTitle}
</h1>
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
轻配置
</span>
</div>
</div>
) : null}
<div
className={`grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] gap-3 lg:grid-cols-[minmax(0,1.12fr)_minmax(17rem,0.88fr)] lg:grid-rows-1 ${isBusy ? 'opacity-55' : ''}`}
>
<div className="flex min-h-0 flex-col gap-3 overflow-y-auto pr-0 lg:pr-1">
<label className="block shrink-0">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
作品标题
</span>
<input
value={title}
disabled={isBusy}
onChange={(event) => setTitle(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-base font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
maxLength={40}
aria-label="作品标题"
/>
</label>
<label className="block shrink-0">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
简介
</span>
<textarea
value={description}
disabled={isBusy}
onChange={(event) => setDescription(event.target.value)}
className="h-[5.5rem] min-h-[5.5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
maxLength={160}
placeholder=""
aria-label="简介"
/>
</label>
<div className="grid shrink-0 gap-2.5 sm:grid-cols-2">
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
主题背景
</span>
<select
value={themePreset}
disabled={isBusy}
onChange={(event) => setThemePreset(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
aria-label="主题背景"
>
{THEME_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
难度预设
</span>
<select
value={difficultyPreset}
disabled={isBusy}
onChange={(event) =>
setDifficultyPreset(
event.target.value as BarkBattleDifficultyPreset,
)
}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
aria-label="难度预设"
>
{DIFFICULTY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
玩家狗狗
</span>
<select
value={playerDogSkinPreset}
disabled={isBusy}
onChange={(event) =>
setPlayerDogSkinPreset(event.target.value)
}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
aria-label="玩家狗狗"
>
{DOG_SKIN_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
对手狗狗
</span>
<select
value={opponentDogSkinPreset}
disabled={isBusy}
onChange={(event) =>
setOpponentDogSkinPreset(event.target.value)
}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
aria-label="对手狗狗"
>
{DOG_SKIN_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
<label className="flex shrink-0 items-center justify-between gap-3 rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-4 py-3 text-sm font-black text-[var(--platform-text-strong)] shadow-[inset_0_1px_0_rgba(255,255,255,0.76)]">
<span className="inline-flex items-center gap-2">
<Trophy className="h-4 w-4 text-amber-500" />
开启排行榜
</span>
<input
aria-label="开启排行榜"
type="checkbox"
checked={leaderboardEnabled}
disabled={isBusy}
onChange={(event) =>
setLeaderboardEnabled(event.target.checked)
}
className="h-5 w-5 accent-[#ff4f6a]"
/>
</label>
{visibleError ? (
<div className="platform-banner platform-banner--danger shrink-0 rounded-2xl text-sm leading-6">
{visibleError}
</div>
) : null}
</div>
<BarkBattlePreviewCard config={payload} />
</div>
</div>
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
<button
type="button"
disabled={isBusy}
onClick={handlePublish}
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
>
<span className="inline-flex flex-wrap items-center justify-center gap-1.5 sm:gap-2">
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<WandSparkles className="h-4 w-4" />
)}
<span>{isBusy ? '发布中' : '发布并试玩'}</span>
</span>
</button>
</div>
</section>
);
}
@@ -0,0 +1,75 @@
import type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle';
type BarkBattlePreviewCardProps = {
config: BarkBattleConfigEditorPayload;
};
const THEME_LABELS: Record<string, string> = {
'sunny-yard': '阳光院子',
'neon-park': '霓虹公园',
'moonlight-rooftop': '月光天台',
};
const DOG_LABELS: Record<string, string> = {
corgi: '柯基',
shiba: '柴犬',
husky: '哈士奇',
};
const DIFFICULTY_LABELS = {
easy: '轻松',
normal: '标准',
hard: '硬核',
};
export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) {
return (
<aside
className="platform-subpanel flex min-h-0 flex-col overflow-hidden rounded-[1.2rem] p-3 sm:p-4"
aria-label="作品预览卡片"
>
<div className="flex min-h-0 flex-1 flex-col rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/76 p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.78)] sm:p-4">
<div
className="mb-4 flex min-h-[8.5rem] items-center justify-center rounded-[1rem] bg-[linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,236,241,0.9)_46%,rgba(224,247,250,0.82))] text-5xl shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] sm:min-h-[10rem]"
aria-hidden="true"
>
<span>汪 VS 嗷</span>
</div>
<h2 className="text-lg font-black leading-tight text-[var(--platform-text-strong)]">
{config.title || '未命名声浪竞技场'}
</h2>
<p className="mt-2 min-h-[2.625rem] text-sm font-semibold leading-6 text-[var(--platform-text-muted)]">
{config.description || '30 秒声浪拔河,喊出你的能量优势。'}
</p>
<dl className="mt-4 grid gap-2 text-sm">
<div className="flex justify-between gap-3 rounded-[0.85rem] bg-white/74 px-3 py-2">
<dt className="text-[var(--platform-text-muted)]">主题</dt>
<dd className="font-black text-[var(--platform-text-strong)]">
{THEME_LABELS[config.themePreset] ?? config.themePreset}
</dd>
</div>
<div className="flex justify-between gap-3 rounded-[0.85rem] bg-white/74 px-3 py-2">
<dt className="text-[var(--platform-text-muted)]">阵容</dt>
<dd className="font-black text-[var(--platform-text-strong)]">
{DOG_LABELS[config.playerDogSkinPreset] ?? config.playerDogSkinPreset}
{' vs '}
{DOG_LABELS[config.opponentDogSkinPreset] ?? config.opponentDogSkinPreset}
</dd>
</div>
<div className="flex justify-between gap-3 rounded-[0.85rem] bg-white/74 px-3 py-2">
<dt className="text-[var(--platform-text-muted)]">难度</dt>
<dd className="font-black text-[var(--platform-text-strong)]">
{DIFFICULTY_LABELS[config.difficultyPreset]}
</dd>
</div>
<div className="flex justify-between gap-3 rounded-[0.85rem] bg-white/74 px-3 py-2">
<dt className="text-[var(--platform-text-muted)]">排行榜</dt>
<dd className="font-black text-[var(--platform-text-strong)]">
{config.leaderboardEnabled ? '开启' : '关闭'}
</dd>
</div>
</dl>
</div>
</aside>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -60,14 +60,25 @@ describe('childMotionWarmupModel', () => {
{
type: 'left-hand',
path: [
{ x: 0.3, y: 0.4 },
{ x: 0.34, y: 0.32 },
{ x: 0.3, y: 0.4, armAngleDeg: 12, armReach: 0.2 },
{ x: 0.34, y: 0.32, armAngleDeg: 44, armReach: 0.28 },
],
},
);
const withRightHand = applyChildMotionWarmupCompletion(
'wave_right_hand',
withLeftHand,
{
type: 'right-hand',
path: [
{ x: 0.7, y: 0.42, armAngleDeg: 10, armReach: 0.22 },
{ x: 0.82, y: 0.3, armAngleDeg: 46, armReach: 0.31 },
],
},
);
const completed = applyChildMotionWarmupCompletion(
'jump_once',
withLeftHand,
withRightHand,
{
type: 'jump',
jumpSpace: 0.14,
@@ -77,6 +88,16 @@ describe('childMotionWarmupModel', () => {
expect(completed.leftBoundary).toBeCloseTo(0.16);
expect(completed.rightBoundary).toBeCloseTo(0.16);
expect(completed.leftHandPath).toHaveLength(2);
expect(completed.leftHandSpace).toEqual({
minX: 0.3,
maxX: 0.34,
minY: 0.32,
maxY: 0.4,
minAngleDeg: 12,
maxAngleDeg: 44,
maxReach: 0.28,
});
expect(completed.rightHandSpace?.maxReach).toBe(0.31);
expect(completed.jumpSpace).toBe(0.14);
});
});
@@ -32,6 +32,20 @@ export type ChildMotionWarmupStep = {
export type ChildMotionPoint = {
x: number;
y: number;
isRaised?: boolean;
isArmExtended?: boolean;
armAngleDeg?: number;
armReach?: number;
};
export type ChildMotionHandSpace = {
minX: number;
maxX: number;
minY: number;
maxY: number;
minAngleDeg: number | null;
maxAngleDeg: number | null;
maxReach: number | null;
};
export type ChildMotionWarmupCalibration = {
@@ -39,6 +53,8 @@ export type ChildMotionWarmupCalibration = {
rightBoundary: number | null;
leftHandPath: ChildMotionPoint[];
rightHandPath: ChildMotionPoint[];
leftHandSpace: ChildMotionHandSpace | null;
rightHandSpace: ChildMotionHandSpace | null;
jumpSpace: number | null;
};
@@ -206,10 +222,39 @@ export function createEmptyChildMotionCalibration(): ChildMotionWarmupCalibratio
rightBoundary: null,
leftHandPath: [],
rightHandPath: [],
leftHandSpace: null,
rightHandSpace: null,
jumpSpace: null,
};
}
function resolveChildMotionHandSpace(
path: ChildMotionPoint[],
): ChildMotionHandSpace | null {
if (path.length === 0) {
return null;
}
const xValues = path.map((point) => point.x);
const yValues = path.map((point) => point.y);
const angleValues = path
.map((point) => point.armAngleDeg)
.filter((angle): angle is number => typeof angle === 'number');
const reachValues = path
.map((point) => point.armReach)
.filter((reach): reach is number => typeof reach === 'number');
return {
minX: Math.min(...xValues),
maxX: Math.max(...xValues),
minY: Math.min(...yValues),
maxY: Math.max(...yValues),
minAngleDeg: angleValues.length > 0 ? Math.min(...angleValues) : null,
maxAngleDeg: angleValues.length > 0 ? Math.max(...angleValues) : null,
maxReach: reachValues.length > 0 ? Math.max(...reachValues) : null,
};
}
export function applyChildMotionWarmupCompletion(
stepId: ChildMotionWarmupStepId,
calibration: ChildMotionWarmupCalibration,
@@ -233,6 +278,7 @@ export function applyChildMotionWarmupCompletion(
return {
...calibration,
leftHandPath: completion.path,
leftHandSpace: resolveChildMotionHandSpace(completion.path),
};
}
@@ -240,6 +286,7 @@ export function applyChildMotionWarmupCompletion(
return {
...calibration,
rightHandPath: completion.path,
rightHandSpace: resolveChildMotionHandSpace(completion.path),
};
}
@@ -0,0 +1,145 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { CreativeImageInputPanel } from './CreativeImageInputPanel';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
test('creative image input panel handles reference uploads and preview', () => {
const onPromptReferenceFilesSelect = vi.fn();
const onPromptReferenceRemove = vi.fn();
const onSubmit = vi.fn();
render(
<CreativeImageInputPanel
uploadedImageSrc=""
uploadedImageAlt="拼图图片"
mainImageInputId="image-upload-input"
promptTextareaId="image-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[
{
id: 'ref-1',
label: '参考图 1',
imageSrc: 'data:image/png;base64,ref-1',
},
]}
imageModelPicker={<div />}
submitLabel="生成"
submitDisabled={false}
labels={{
imageField: '拼图画面',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onPromptReferenceFilesSelect={onPromptReferenceFilesSelect}
onPromptReferenceRemove={onPromptReferenceRemove}
onSubmit={onSubmit}
/>,
);
const promptReferenceInput = screen.getByLabelText('上传参考图', {
selector: 'input',
});
expect((promptReferenceInput as HTMLInputElement).multiple).toBe(true);
fireEvent.change(promptReferenceInput, {
target: {
files: [
new File(['a'], 'ref-1.png', { type: 'image/png' }),
new File(['b'], 'ref-2.png', { type: 'image/png' }),
],
},
});
expect(onPromptReferenceFilesSelect).toHaveBeenCalledWith(
expect.arrayContaining([
expect.any(File),
expect.any(File),
]),
);
fireEvent.click(
screen.getByRole('button', { name: '预览参考图 参考图 1' }),
);
expect(
screen.getByRole('dialog', { name: '参考图 1' }),
).toBeTruthy();
expect(screen.getByAltText('参考图预览')).toHaveProperty(
'src',
expect.stringContaining('ref-1'),
);
fireEvent.click(screen.getByRole('button', { name: '关闭参考图预览' }));
fireEvent.click(screen.getByRole('button', { name: '移除参考图 参考图 1' }));
expect(onPromptReferenceRemove).toHaveBeenCalledWith('ref-1');
fireEvent.click(screen.getByRole('button', { name: '生成' }));
expect(onSubmit).toHaveBeenCalledTimes(1);
});
test('creative image input panel confirms before removing uploaded image', () => {
const onMainImageRemove = vi.fn();
render(
<CreativeImageInputPanel
uploadedImageSrc="data:image/png;base64,main"
uploadedImageAlt="拼图图片"
mainImageInputId="image-upload-input"
promptTextareaId="image-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面AI重绘要求(提示词)"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={<div />}
submitLabel="生成"
submitDisabled={false}
labels={{
imageField: '拼图画面',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={onMainImageRemove}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onSubmit={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '移除拼图图片' }));
const dialog = screen.getByRole('dialog', { name: '移除拼图图片?' });
expect(within(dialog).getByText('移除后需要重新上传图片。')).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: '移除' }));
expect(onMainImageRemove).toHaveBeenCalledTimes(1);
});
@@ -0,0 +1,463 @@
import {
History,
ImagePlus,
Loader2,
Sparkles,
Trash2,
X,
} from 'lucide-react';
import { type ReactNode, useEffect, useState } from 'react';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
export type CreativeImageInputReferenceImage = {
id: string;
label: string;
imageSrc: string;
};
export type CreativeImageInputPanelLabels = {
imageField: string;
uploadImage: string;
replaceImage: string;
emptyImageHint: string;
removeImage: string;
removeImageConfirmTitle: string;
removeImageConfirmBody: string;
promptReferenceUpload: string;
promptReferencePreviewAlt: string;
closePromptReferencePreview: string;
history?: string;
};
export type CreativeImageInputPanelProps = {
className?: string;
disabled?: boolean;
isSubmitting?: boolean;
uploadedImageSrc: string;
uploadedImageAlt: string;
mainImageInputId: string;
mainImageAccept?: string;
promptTextareaId: string;
prompt: string;
promptLabel: string;
promptRows?: number;
aiRedraw: boolean;
promptReferenceImages: CreativeImageInputReferenceImage[];
promptReferenceLimit?: number;
imageModelPicker?: ReactNode;
error?: string | null;
inputError?: string | null;
submitLabel: string;
submitCostLabel?: string | null;
submitDisabled: boolean;
labels: CreativeImageInputPanelLabels;
onMainImageFileSelect: (file: File) => void;
onMainImageRemove: () => void;
onAiRedrawChange: (enabled: boolean) => void;
onPromptChange: (value: string) => void;
onPromptReferenceFilesSelect?: (files: File[]) => void;
onPromptReferenceRemove?: (referenceId: string) => void;
onHistoryClick?: () => void;
onSubmit: () => void;
};
const DEFAULT_IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp';
const DEFAULT_PROMPT_REFERENCE_LIMIT = 5;
export function CreativeImageInputPanel({
className = '',
disabled = false,
isSubmitting = false,
uploadedImageSrc,
uploadedImageAlt,
mainImageInputId,
mainImageAccept = DEFAULT_IMAGE_ACCEPT,
promptTextareaId,
prompt,
promptLabel,
promptRows = 2,
aiRedraw,
promptReferenceImages,
promptReferenceLimit = DEFAULT_PROMPT_REFERENCE_LIMIT,
imageModelPicker = null,
error = null,
inputError = null,
submitLabel,
submitCostLabel = null,
submitDisabled,
labels,
onMainImageFileSelect,
onMainImageRemove,
onAiRedrawChange,
onPromptChange,
onPromptReferenceFilesSelect,
onPromptReferenceRemove,
onHistoryClick,
onSubmit,
}: CreativeImageInputPanelProps) {
const [previewReferenceImage, setPreviewReferenceImage] =
useState<CreativeImageInputReferenceImage | null>(null);
const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] =
useState(false);
const showPrompt = !uploadedImageSrc || aiRedraw;
const promptReferenceUploadDisabled =
disabled || promptReferenceImages.length >= promptReferenceLimit;
useEffect(() => {
if (uploadedImageSrc) {
setPreviewReferenceImage(null);
}
}, [uploadedImageSrc]);
useEffect(() => {
if (
previewReferenceImage &&
!promptReferenceImages.some(
(reference) => reference.id === previewReferenceImage.id,
)
) {
setPreviewReferenceImage(null);
}
}, [previewReferenceImage, promptReferenceImages]);
return (
<div
className={`creative-image-input-panel flex min-h-0 flex-1 flex-col ${className}`}
>
<div className="creative-image-input-panel__body puzzle-creation-form-body flex min-h-0 flex-1 flex-col overflow-hidden pr-0 lg:overflow-y-auto lg:pr-1">
<section className="creative-image-input-panel__section puzzle-creation-form-section flex min-h-0 flex-1 flex-col overflow-hidden lg:overflow-visible">
<div
className={`creative-image-input-panel__grid puzzle-creation-form-grid min-h-0 flex-1 gap-2.5 sm:gap-4 ${
showPrompt
? 'flex flex-col lg:grid lg:grid-cols-[minmax(15rem,0.9fr)_minmax(0,1.15fr)]'
: 'flex flex-col lg:grid lg:grid-cols-1'
}`}
>
<div
className={`creative-image-input-panel__image-field puzzle-image-field flex min-h-0 min-w-0 flex-1 flex-col ${
disabled ? 'opacity-55' : ''
}`}
>
<div className="mb-2 shrink-0 text-sm font-black text-[var(--platform-text-strong)]">
{labels.imageField}
</div>
<div className="creative-image-input-panel__image-frame puzzle-image-card-frame flex min-h-0 flex-1 items-center justify-center">
<div className="creative-image-input-panel__image-card puzzle-image-upload-card relative aspect-square h-full max-h-full max-w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)] transition lg:h-auto lg:w-full">
<input
id={mainImageInputId}
type="file"
accept={mainImageAccept}
disabled={disabled}
aria-label={labels.uploadImage}
onChange={(event) => {
const file = event.currentTarget.files?.[0] ?? null;
event.currentTarget.value = '';
if (file) {
onMainImageFileSelect(file);
}
}}
className="sr-only"
/>
<label
htmlFor={mainImageInputId}
className={`absolute inset-0 z-0 cursor-pointer ${disabled ? 'cursor-not-allowed' : ''}`}
title={uploadedImageSrc ? labels.replaceImage : labels.uploadImage}
>
<span className="sr-only">
{uploadedImageSrc ? labels.replaceImage : labels.uploadImage}
</span>
</label>
{uploadedImageSrc ? (
<ResolvedAssetImage
src={uploadedImageSrc}
alt={uploadedImageAlt}
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
/>
) : (
<span className="pointer-events-none flex h-full items-center justify-center bg-[radial-gradient(circle_at_50%_28%,rgba(255,255,255,0.9),transparent_38%),linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,241,229,0.86))]">
<span className="flex h-14 w-14 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/92 text-[var(--platform-text-strong)] shadow-sm sm:h-20 sm:w-20">
<ImagePlus className="h-6 w-6 sm:h-8 sm:w-8" />
</span>
</span>
)}
<div className="pointer-events-none absolute inset-0 z-[1] bg-[linear-gradient(180deg,rgba(255,255,255,0.12)_0%,rgba(255,255,255,0.04)_42%,rgba(255,255,255,0.18)_100%)]" />
{onHistoryClick ? (
<button
type="button"
disabled={disabled}
onClick={onHistoryClick}
className={`absolute right-3 top-3 z-10 inline-flex items-center gap-1.5 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-[11px] font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] ${
disabled ? 'cursor-not-allowed opacity-55' : ''
}`}
aria-label={labels.history ?? '选择历史图片'}
title={labels.history ?? '选择历史图片'}
>
<History className="h-3.5 w-3.5" />
<span>历史</span>
</button>
) : null}
{uploadedImageSrc ? (
<label className="absolute bottom-3 left-3 z-10 inline-flex cursor-pointer items-center gap-2 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-xs font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur">
<span>AI重绘</span>
<input
role="switch"
type="checkbox"
checked={aiRedraw}
disabled={disabled}
onChange={(event) => onAiRedrawChange(event.target.checked)}
className="sr-only"
aria-label="AI重绘"
/>
<span
aria-hidden="true"
className={`relative h-5 w-9 rounded-full transition ${
aiRedraw ? 'bg-[#ff4056]' : 'bg-zinc-300'
}`}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition ${
aiRedraw ? 'left-[1.125rem]' : 'left-0.5'
}`}
/>
</span>
</label>
) : null}
{uploadedImageSrc ? (
<button
type="button"
disabled={disabled}
onClick={() => setIsRemoveImageConfirmOpen(true)}
className="absolute left-3 top-3 z-10 inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/80 bg-white/94 text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] disabled:cursor-not-allowed disabled:opacity-55"
aria-label={labels.removeImage}
title={labels.removeImage}
>
<Trash2 className="h-4 w-4" />
</button>
) : (
<label
htmlFor={mainImageInputId}
className={`absolute bottom-9 left-1/2 z-10 -translate-x-1/2 whitespace-nowrap text-center text-sm font-black text-[var(--platform-text-strong)] drop-shadow-[0_1px_0_rgba(255,255,255,0.82)] transition hover:text-[#ff4056] sm:bottom-10 ${
disabled
? 'cursor-not-allowed opacity-55'
: 'cursor-pointer'
}`}
>
{labels.emptyImageHint}
</label>
)}
</div>
</div>
</div>
{showPrompt ? (
<div className="block shrink-0 lg:min-h-0">
<label
htmlFor={promptTextareaId}
className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]"
>
{promptLabel}
</label>
<div className="relative">
<textarea
id={promptTextareaId}
value={prompt}
disabled={disabled}
rows={promptRows}
placeholder=""
onChange={(event) => onPromptChange(event.target.value)}
className="h-[6rem] min-h-[6rem] w-full resize-none rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-14 text-base leading-6 text-[var(--platform-text-strong)] outline-none placeholder:text-zinc-400 sm:h-[7.5rem] sm:min-h-[7.5rem] lg:h-[9.25rem] lg:min-h-[9.25rem]"
aria-label={promptLabel}
/>
{imageModelPicker}
{!uploadedImageSrc && onPromptReferenceFilesSelect ? (
<label
className={`absolute bottom-3 right-3 z-10 inline-flex h-8 w-8 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/96 text-[var(--platform-text-strong)] shadow-sm transition hover:bg-[var(--platform-subpanel-fill)] hover:text-[#ff4056] ${
promptReferenceUploadDisabled
? 'cursor-not-allowed opacity-55'
: 'cursor-pointer'
}`}
aria-label={labels.promptReferenceUpload}
title={labels.promptReferenceUpload}
>
<ImagePlus className="h-4 w-4" />
<input
type="file"
accept={mainImageAccept}
multiple
aria-label={labels.promptReferenceUpload}
disabled={promptReferenceUploadDisabled}
onChange={(event) => {
const files = Array.from(event.currentTarget.files ?? []);
event.currentTarget.value = '';
if (files.length > 0) {
onPromptReferenceFilesSelect(files);
}
}}
className="sr-only"
/>
</label>
) : null}
</div>
{!uploadedImageSrc && promptReferenceImages.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{promptReferenceImages.map((reference) => (
<div
key={reference.id}
className="relative h-12 w-12 overflow-hidden rounded-[0.75rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-sm"
>
<button
type="button"
disabled={disabled}
onClick={() => setPreviewReferenceImage(reference)}
className="block h-full w-full"
aria-label={`预览参考图 ${reference.label}`}
title={reference.label}
>
<ResolvedAssetImage
src={reference.imageSrc}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
</button>
{onPromptReferenceRemove ? (
<button
type="button"
disabled={disabled}
onClick={() => onPromptReferenceRemove(reference.id)}
className="absolute right-0.5 top-0.5 inline-flex h-5 w-5 items-center justify-center rounded-full bg-white/94 text-[var(--platform-text-strong)] shadow-sm transition hover:text-[#ff4056] disabled:opacity-55"
aria-label={`移除参考图 ${reference.label}`}
title="移除参考图"
>
<X className="h-3 w-3" />
</button>
) : null}
</div>
))}
</div>
) : null}
</div>
) : null}
</div>
<div className="mt-2 shrink-0 space-y-3">
{inputError ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{inputError}
</div>
) : null}
{error ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{error}
</div>
) : null}
</div>
</section>
</div>
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
<button
type="button"
disabled={disabled || submitDisabled}
onClick={onSubmit}
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${
submitDisabled ? 'cursor-not-allowed opacity-55' : ''
}`}
>
<span className="inline-flex flex-wrap items-center justify-center gap-1.5 sm:gap-2">
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<Sparkles className="h-4 w-4" />
<span>{submitLabel}</span>
{submitCostLabel ? (
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
{submitCostLabel}
</span>
) : null}
</span>
</button>
</div>
{previewReferenceImage ? (
<div
className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6"
onClick={() => setPreviewReferenceImage(null)}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="creative-image-reference-preview-title"
className="platform-modal-shell platform-remap-surface w-full max-w-2xl rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
onClick={(event) => event.stopPropagation()}
>
<div className="mb-3 flex items-center justify-between gap-3 px-1">
<div
id="creative-image-reference-preview-title"
className="min-w-0 truncate text-sm font-black text-[var(--platform-text-strong)]"
>
{previewReferenceImage.label}
</div>
<button
type="button"
aria-label={labels.closePromptReferencePreview}
onClick={() => setPreviewReferenceImage(null)}
className="platform-profile-icon-button flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="max-h-[72vh] overflow-hidden rounded-[1rem] bg-black/5">
<ResolvedAssetImage
src={previewReferenceImage.imageSrc}
alt={labels.promptReferencePreviewAlt}
className="h-full max-h-[72vh] w-full object-contain"
/>
</div>
</div>
</div>
) : null}
{isRemoveImageConfirmOpen ? (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div
role="dialog"
aria-modal="true"
aria-labelledby="creative-image-remove-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="creative-image-remove-confirm-title"
className="text-base font-black text-[var(--platform-text-strong)]"
>
{labels.removeImageConfirmTitle}
</div>
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-base)]">
{labels.removeImageConfirmBody}
</div>
<div className="mt-5 grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setIsRemoveImageConfirmOpen(false)}
className="platform-button platform-button--secondary justify-center"
>
取消
</button>
<button
type="button"
onClick={() => {
onMainImageRemove();
setIsRemoveImageConfirmOpen(false);
}}
className="platform-button platform-button--primary justify-center"
>
移除
</button>
</div>
</div>
</div>
) : null}
</div>
);
}
export default CreativeImageInputPanel;
@@ -1,13 +1,14 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
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 +62,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,
},
@@ -82,7 +83,9 @@ const testEntryConfig = {
],
} satisfies CreationEntryConfig;
const testCreationTypes = derivePlatformCreationTypes(testEntryConfig.creationTypes);
const testCreationTypes = derivePlatformCreationTypes(
testEntryConfig.creationTypes,
);
const originalClipboard = navigator.clipboard;
@@ -189,6 +192,40 @@ const hiddenSquareHoleItem: SquareHoleWorkSummary = {
sourceSessionId: 'square-hole-session-hidden',
};
const babyObjectMatchDraftItem: BabyObjectMatchDraft = {
draftId: 'baby-object-draft-delete',
profileId: 'baby-object-profile-delete',
templateId: 'baby-object-match',
templateName: '宝贝识物',
workTitle: '宝贝识物删除测试',
workDescription: '苹果和香蕉识物分类',
itemNames: ['苹果', '香蕉'],
itemAssets: [
{
itemId: 'baby-object-item-1',
itemName: '苹果',
imageSrc: '/apple.png',
assetObjectId: null,
generationProvider: 'placeholder',
prompt: '苹果',
},
{
itemId: 'baby-object-item-2',
itemName: '香蕉',
imageSrc: '/banana.png',
assetObjectId: null,
generationProvider: 'placeholder',
prompt: '香蕉',
},
],
visualPackage: null,
themeTags: ['寓教于乐'],
publicationStatus: 'draft',
createdAt: new Date('2026-05-11T10:00:00.000Z').toISOString(),
updatedAt: new Date('2026-05-11T10:00:00.000Z').toISOString(),
publishedAt: null,
};
test('creation hub reflects updated draft title summary and counts after rerender', async () => {
const user = userEvent.setup();
const onCreateType = vi.fn();
@@ -280,7 +317,9 @@ test('creation hub hides square hole works when the creation type is hidden', ()
);
expect(screen.queryByText('隐藏方洞挑战')).toBeNull();
expect(screen.queryByText('入口隐藏后,这条作品不应出现在创作页作品架。')).toBeNull();
expect(
screen.queryByText('入口隐藏后,这条作品不应出现在创作页作品架。'),
).toBeNull();
});
test('creation hub mixes puzzle works into the same grid and uses puzzle tag to distinguish', () => {
@@ -443,7 +482,61 @@ 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 left swipe', () => {
const { container } = render(
<CustomWorldCreationHub
items={[{ ...baseDraftItem, profileId: 'profile-1' }]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onDeletePublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>,
);
const card = screen.getByRole('button', { name: /继续完善《潮雾列岛》/u });
fireEvent.touchStart(card, {
touches: [{ clientX: 180, clientY: 20 }],
});
fireEvent.touchMove(card, {
touches: [{ clientX: 92, clientY: 22 }],
});
fireEvent.touchEnd(card);
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
expect(
container.querySelector('.creation-work-card-shell--actions-visible'),
).toBeTruthy();
});
test('creation hub reveals persisted draft delete action from keyboard', async () => {
const user = userEvent.setup();
render(
<CustomWorldCreationHub
items={[{ ...baseDraftItem, profileId: 'profile-1' }]}
@@ -459,10 +552,47 @@ 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();
expect(screen.queryByRole('button', { name: '分享' })).toBeNull();
});
test('creation hub published work delete action is available beside share without opening card', async () => {
test('creation hub shows delete action for baby object match drafts', async () => {
const user = userEvent.setup();
const onDeleteBabyObjectMatch = vi.fn();
const onOpenBabyObjectMatchDetail = vi.fn();
render(
<CustomWorldCreationHub
items={[]}
babyObjectMatchItems={[babyObjectMatchDraftItem]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onOpenBabyObjectMatchDetail={onOpenBabyObjectMatchDetail}
onDeleteBabyObjectMatch={onDeleteBabyObjectMatch}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>,
);
screen.getByRole('button', { name: /继续创作《宝贝识物删除测试》/u }).focus();
await user.keyboard('{ArrowLeft}');
await user.click(screen.getByRole('button', { name: '删除' }));
expect(onDeleteBabyObjectMatch).toHaveBeenCalledWith(
babyObjectMatchDraftItem,
);
expect(onOpenBabyObjectMatchDetail).not.toHaveBeenCalled();
});
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 +632,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 +684,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 +727,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(
@@ -607,3 +745,35 @@ test('creation hub published share button copies share text without opening the
await screen.findByRole('button', { name: '分享内容已复制' }),
).toBeTruthy();
});
test('creation hub left swipe draft reveals delete without opening card', () => {
const onDeletePublished = vi.fn();
const onOpenDraft = vi.fn();
render(
<CustomWorldCreationHub
items={[{ ...baseDraftItem, profileId: 'profile-1' }]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={onOpenDraft}
onEnterPublished={() => {}}
onDeletePublished={onDeletePublished}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>,
);
const card = screen.getByRole('button', { name: /继续完善《潮雾列岛》/u });
fireEvent.touchStart(card, {
touches: [{ clientX: 180, clientY: 20 }],
});
fireEvent.touchMove(card, {
touches: [{ clientX: 88, clientY: 22 }],
});
fireEvent.touchEnd(card);
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
expect(onOpenDraft).not.toHaveBeenCalled();
});
@@ -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('>查看详情<');
});
@@ -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<
@@ -68,6 +67,7 @@ type CustomWorldCreationHubProps = {
claimingPuzzleProfileId?: string | null;
babyObjectMatchItems?: BabyObjectMatchDraft[];
onOpenBabyObjectMatchDetail?: ((item: BabyObjectMatchDraft) => void) | null;
onDeleteBabyObjectMatch?: ((item: BabyObjectMatchDraft) => void) | null;
visualNovelItems?: VisualNovelWorkSummary[];
onOpenVisualNovelDetail?: ((item: VisualNovelWorkSummary) => void) | null;
onDeleteVisualNovel?: ((item: VisualNovelWorkSummary) => void) | null;
@@ -172,6 +172,7 @@ export function CustomWorldCreationHub({
claimingPuzzleProfileId = null,
babyObjectMatchItems = [],
onOpenBabyObjectMatchDetail = null,
onDeleteBabyObjectMatch = null,
visualNovelItems = [],
onOpenVisualNovelDetail = null,
onDeleteVisualNovel = null,
@@ -202,6 +203,7 @@ export function CustomWorldCreationHub({
canDeleteSquareHole:
isSquareHoleCreationVisible && Boolean(onDeleteSquareHole),
canDeletePuzzle: Boolean(onDeletePuzzle),
canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch),
canDeleteVisualNovel: Boolean(onDeleteVisualNovel),
onOpenRpgDraft: onOpenDraft,
onEnterRpgPublished: onEnterPublished,
@@ -216,6 +218,7 @@ export function CustomWorldCreationHub({
onDeletePuzzle: onDeletePuzzle ?? undefined,
onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined,
onOpenBabyObjectMatchDetail: onOpenBabyObjectMatchDetail ?? undefined,
onDeleteBabyObjectMatch: onDeleteBabyObjectMatch ?? undefined,
onOpenVisualNovelDetail: onOpenVisualNovelDetail ?? undefined,
onDeleteVisualNovel: onDeleteVisualNovel ?? undefined,
getItemState: getWorkState,
@@ -231,6 +234,7 @@ export function CustomWorldCreationHub({
onDeleteSquareHole,
onDeletePublished,
onDeletePuzzle,
onDeleteBabyObjectMatch,
onDeleteVisualNovel,
onClaimPuzzlePointIncentive,
onOpenBigFishDetail,
@@ -269,6 +273,7 @@ export function CustomWorldCreationHub({
);
function handleOpenShelfItem(item: CreationWorkShelfItem) {
onOpenShelfItem?.(item);
switch (item.source.kind) {
case 'puzzle':
onOpenPuzzleDetail?.(item.source.item);
@@ -379,8 +384,7 @@ export function CustomWorldCreationHub({
metricSnapshot[buildWorkMetricCacheItemKey(item)]
}
onOpen={() => {
onOpenShelfItem?.(item);
item.actions.open();
handleOpenShelfItem(item);
}}
onDelete={buildDeleteAction(item)}
deleteBusy={deletingWorkId === item.id}
File diff suppressed because it is too large Load Diff
@@ -87,6 +87,8 @@ test('buildCreationWorkShelfItems attaches open and delete actions through shelf
});
test('buildCreationWorkShelfItems maps baby object match local drafts', () => {
const onOpenBabyObjectMatchDetail = vi.fn();
const onDeleteBabyObjectMatch = vi.fn();
const baseDraft: BabyObjectMatchDraft = {
draftId: 'baby-object-draft-1',
profileId: 'baby-object-profile-12345678',
@@ -113,6 +115,7 @@ test('buildCreationWorkShelfItems maps baby object match local drafts', () => {
prompt: '香蕉',
},
],
visualPackage: null,
themeTags: ['寓教于乐'],
publicationStatus: 'draft',
createdAt: '2026-05-11T00:00:00.000Z',
@@ -135,14 +138,23 @@ test('buildCreationWorkShelfItems maps baby object match local drafts', () => {
updatedAt: '2026-05-11T01:00:00.000Z',
},
],
canDeleteBabyObjectMatch: true,
onOpenBabyObjectMatchDetail,
onDeleteBabyObjectMatch,
});
items[1]?.actions.open();
items[1]?.actions.delete?.();
expect(items[0]?.kind).toBe('baby-object-match');
expect(items[0]?.status).toBe('published');
expect(items[0]?.publicWorkCode).toBe('BO-87654321');
expect(items[0]?.sharePath).toContain('/works/detail?work=BO-87654321');
expect(items[1]?.status).toBe('draft');
expect(items[1]?.publicWorkCode).toBeNull();
expect(items[1]?.canDelete).toBe(true);
expect(onOpenBabyObjectMatchDetail).toHaveBeenCalledWith(baseDraft);
expect(onDeleteBabyObjectMatch).toHaveBeenCalledWith(baseDraft);
});
test('buildCreationWorkShelfItems sorts works by latest updatedAt across timestamp formats', () => {
@@ -290,6 +302,324 @@ 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/container.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('buildCreationWorkShelfItems ignores puzzle theme reference cover and uses first level image', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [
{
workId: 'puzzle:theme-reference-cover',
profileId: 'puzzle-profile-theme-reference-cover',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
levelName: '主题兜底拼图',
summary: '摘要里的封面是玩法参考图时,用第一关画面兜底。',
themeTags: [],
coverImageSrc: '/creation-type-references/puzzle.webp',
publicationStatus: 'draft',
updatedAt: '2026-05-08T00:00:00.000Z',
publishedAt: null,
publishReady: false,
levels: [
{
levelId: 'level-1',
levelName: '第一关',
pictureDescription: '第一关画面。',
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '/puzzle-first-level-candidate.png',
assetId: 'asset-1',
prompt: '第一关画面',
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-1',
coverImageSrc: '/puzzle-first-level-cover.png',
coverAssetId: 'asset-1',
generationStatus: 'ready',
},
],
},
],
});
expect(items.find((item) => item.kind === 'puzzle')?.coverImageSrc).toBe(
'/puzzle-first-level-cover.png',
);
});
test('buildCreationWorkShelfItems ignores match3d theme reference cover and uses container image', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [],
match3dItems: [
{
workId: 'match3d:theme-reference-cover',
profileId: 'match3d-profile-theme-reference-cover',
ownerUserId: 'user-1',
gameName: '主题兜底抓鹅',
themeText: '糖果厨房',
summary: '摘要里的封面是玩法参考图时,用UI背景图兜底。',
tags: [],
coverImageSrc: '/creation-type-references/match3d.webp',
clearCount: 18,
difficulty: 1,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-07T00:00:00.000Z',
publishReady: false,
generatedBackgroundAsset: {
prompt: '糖果厨房竖屏UI背景',
imageSrc: '/match3d-ui-background.png',
containerImageSrc: '/match3d-container.png',
status: 'image_ready',
},
generatedItemAssets: [
{
itemId: 'item-1',
itemName: '糖果',
imageSrc: '/match3d-item.png',
status: 'image_ready',
},
],
},
],
});
expect(items.find((item) => item.kind === 'match3d')?.coverImageSrc).toBe(
'/match3d-container.png',
);
});
test('buildCreationWorkShelfItems uses match3d container asset before background and item image', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [],
match3dItems: [
{
workId: 'match3d:item-background-asset-cover',
profileId: 'match3d-profile-item-background-asset-cover',
ownerUserId: 'user-1',
gameName: '背景资产抓鹅',
themeText: '糖果厨房',
summary: '顶层背景缺失时,从素材携带的UI背景兜底。',
tags: [],
coverImageSrc: '/creation-type-references/match3d.webp',
clearCount: 18,
difficulty: 1,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-07T00:00:00.000Z',
publishReady: false,
generatedItemAssets: [
{
itemId: 'item-1',
itemName: '糖果',
imageSrc: '/match3d-item.png',
backgroundAsset: {
prompt: '糖果厨房竖屏UI背景',
imageObjectKey:
'generated-match3d-assets/session/profile/background/image.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/container.png',
status: 'image_ready',
},
status: 'image_ready',
},
],
},
],
});
expect(items.find((item) => item.kind === 'match3d')?.coverImageSrc).toBe(
'generated-match3d-assets/session/profile/ui-container/container.png',
);
});
test('buildCreationWorkShelfItems uses match3d transparent container reference as last fallback', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [],
match3dItems: [
{
workId: 'match3d:container-reference-fallback',
profileId: 'match3d-profile-container-reference-fallback',
ownerUserId: 'user-1',
sourceSessionId: 'session-1',
gameName: '水果抓大鹅',
themeText: '水果',
summary: '',
tags: [],
coverImageSrc: null,
referenceImageSrc: null,
backgroundPrompt: '',
backgroundImageSrc: null,
backgroundImageObjectKey: null,
generatedBackgroundAsset: null,
generatedItemAssets: [],
clearCount: 3,
difficulty: 2,
publicationStatus: 'draft',
publishReady: false,
playCount: 0,
updatedAt: '2026-05-01T00:00:00.000Z',
publishedAt: null,
},
],
});
expect(items.find((item) => item.kind === 'match3d')?.coverImageSrc).toBe(
'/match3d-background-references/pot-fused-reference.png',
);
});
test('getCreationWorkShelfItemTime parses backend seconds.microsZ values', () => {
expect(getCreationWorkShelfItemTime('1778457601.234567Z')).toBe(
1778457601234.567,
@@ -17,6 +17,9 @@ import {
} from '../../services/publicWorkCode';
import type { CustomWorldProfile } from '../../types';
const MATCH3D_CONTAINER_REFERENCE_COVER_SRC =
'/match3d-background-references/pot-fused-reference.png';
export type CreationWorkShelfKind =
| 'rpg'
| 'big-fish'
@@ -130,6 +133,7 @@ export function buildCreationWorkShelfItems(params: {
canDeleteMatch3D?: boolean;
canDeleteSquareHole?: boolean;
canDeletePuzzle?: boolean;
canDeleteBabyObjectMatch?: boolean;
canDeleteVisualNovel?: boolean;
onOpenRpgDraft?: (item: CustomWorldWorkSummary) => void;
onEnterRpgPublished?: (profileId: string) => void;
@@ -144,6 +148,7 @@ export function buildCreationWorkShelfItems(params: {
onDeletePuzzle?: (item: PuzzleWorkSummary) => void;
onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void;
onOpenBabyObjectMatchDetail?: (item: BabyObjectMatchDraft) => void;
onDeleteBabyObjectMatch?: (item: BabyObjectMatchDraft) => void;
onOpenVisualNovelDetail?: (item: VisualNovelWorkSummary) => void;
onDeleteVisualNovel?: (item: VisualNovelWorkSummary) => void;
getItemState?: (
@@ -164,6 +169,7 @@ export function buildCreationWorkShelfItems(params: {
canDeleteMatch3D = false,
canDeleteSquareHole = false,
canDeletePuzzle = false,
canDeleteBabyObjectMatch = false,
canDeleteVisualNovel = false,
onOpenRpgDraft,
onEnterRpgPublished,
@@ -178,6 +184,7 @@ export function buildCreationWorkShelfItems(params: {
onDeletePuzzle,
onClaimPuzzlePointIncentive,
onOpenBabyObjectMatchDetail,
onDeleteBabyObjectMatch,
onOpenVisualNovelDetail,
onDeleteVisualNovel,
getItemState,
@@ -217,8 +224,9 @@ export function buildCreationWorkShelfItems(params: {
}),
),
...babyObjectMatchItems.map((item) =>
mapBabyObjectMatchDraftToShelfItem(item, {
mapBabyObjectMatchDraftToShelfItem(item, canDeleteBabyObjectMatch, {
onOpen: onOpenBabyObjectMatchDetail,
onDelete: onDeleteBabyObjectMatch,
}),
),
...visualNovelItems.map((item) =>
@@ -467,6 +475,7 @@ function mapPuzzleWorkToShelfItem(
function mapBabyObjectMatchDraftToShelfItem(
item: BabyObjectMatchDraft,
canDelete: boolean,
adapter: WorkShelfAdapter<BabyObjectMatchDraft>,
): CreationWorkShelfItem {
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
@@ -495,7 +504,7 @@ function mapBabyObjectMatchDraftToShelfItem(
? buildPublicWorkStagePath('work-detail', publicWorkCode)
: null,
openActionLabel: status === 'published' ? '查看详情' : '继续创作',
canDelete: false,
canDelete,
canShare: status === 'published' && Boolean(publicWorkCode),
badges: [
buildStatusBadge(status),
@@ -614,31 +623,54 @@ function normalizeCoverImageSrc(value?: string | null) {
return value?.trim() || null;
}
function isCreationTypeReferenceCoverImageSrc(value?: string | null) {
const normalizedValue = normalizeCoverImageSrc(value);
if (!normalizedValue) {
return false;
}
// 中文注释:玩法参考图只做草稿页兜底,不应覆盖作品已经生成出来的真实关卡图或运行态背景图。
return /^\/?creation-type-references\/[^/?#]+(?:[?#].*)?$/u.test(
normalizedValue,
);
}
function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) {
const directCoverImageSrc = normalizeCoverImageSrc(item.coverImageSrc);
if (directCoverImageSrc) {
if (
directCoverImageSrc &&
!isCreationTypeReferenceCoverImageSrc(directCoverImageSrc)
) {
return directCoverImageSrc;
}
for (const level of item.levels ?? []) {
const levelCoverImageSrc = normalizeCoverImageSrc(level.coverImageSrc);
if (
levelCoverImageSrc &&
!isCreationTypeReferenceCoverImageSrc(levelCoverImageSrc)
) {
return levelCoverImageSrc;
}
const selectedCandidateImageSrc =
level.selectedCandidateId && level.candidates.length > 0
? normalizeCoverImageSrc(
level.candidates.find(
(candidate) => candidate.candidateId === level.selectedCandidateId,
)?.imageSrc,
)
)
: null;
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 &&
!isCreationTypeReferenceCoverImageSrc(candidateImageSrc)
) {
return candidateImageSrc;
}
}
@@ -647,30 +679,67 @@ function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) {
function resolveMatch3DWorkCoverImageSrc(item: Match3DWorkSummary) {
const directCoverImageSrc = normalizeCoverImageSrc(item.coverImageSrc);
if (directCoverImageSrc) {
if (
directCoverImageSrc &&
!isCreationTypeReferenceCoverImageSrc(directCoverImageSrc)
) {
return directCoverImageSrc;
}
const topLevelContainerImageSrc =
normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageSrc) ||
normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageObjectKey);
if (topLevelContainerImageSrc) {
return topLevelContainerImageSrc;
}
for (const asset of item.generatedItemAssets ?? []) {
const assetContainerImageSrc =
normalizeCoverImageSrc(asset.backgroundAsset?.containerImageSrc) ||
normalizeCoverImageSrc(asset.backgroundAsset?.containerImageObjectKey);
if (assetContainerImageSrc) {
return assetContainerImageSrc;
}
}
const backgroundImageSrc =
normalizeCoverImageSrc(item.backgroundImageSrc) ||
normalizeCoverImageSrc(item.backgroundImageObjectKey) ||
normalizeCoverImageSrc(item.generatedBackgroundAsset?.imageSrc) ||
normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageSrc);
normalizeCoverImageSrc(item.generatedBackgroundAsset?.imageObjectKey);
if (backgroundImageSrc) {
return backgroundImageSrc;
}
for (const asset of item.generatedItemAssets ?? []) {
const imageViewSrc = normalizeCoverImageSrc(
asset.imageViews?.find((view) => normalizeCoverImageSrc(view.imageSrc))
?.imageSrc,
const assetBackgroundImageSrc =
normalizeCoverImageSrc(asset.backgroundAsset?.imageSrc) ||
normalizeCoverImageSrc(asset.backgroundAsset?.imageObjectKey);
if (assetBackgroundImageSrc) {
return assetBackgroundImageSrc;
}
const imageView = asset.imageViews?.find(
(view) =>
normalizeCoverImageSrc(view.imageSrc) ||
normalizeCoverImageSrc(view.imageObjectKey),
);
const itemImageSrc = normalizeCoverImageSrc(asset.imageSrc);
if (imageViewSrc || itemImageSrc) {
return imageViewSrc || itemImageSrc;
const imageViewSrc =
normalizeCoverImageSrc(imageView?.imageSrc) ||
normalizeCoverImageSrc(imageView?.imageObjectKey);
const itemImageSrc =
normalizeCoverImageSrc(asset.imageSrc) ||
normalizeCoverImageSrc(asset.imageObjectKey);
const preferredImageSrc = imageViewSrc || itemImageSrc;
if (
preferredImageSrc &&
!isCreationTypeReferenceCoverImageSrc(preferredImageSrc)
) {
return preferredImageSrc;
}
}
return null;
return MATCH3D_CONTAINER_REFERENCE_COVER_SRC;
}
function resolveSquareHoleWorkCoverImageSrc(item: SquareHoleWorkSummary) {
@@ -51,6 +51,7 @@ function createDraft(overrides: Partial<BabyObjectMatchDraft> = {}) {
prompt: '香蕉',
},
],
visualPackage: null,
themeTags: ['宝贝识物'],
publicationStatus: 'draft',
createdAt: '2026-05-11T00:00:00.000Z',
@@ -62,13 +63,81 @@ function createDraft(overrides: Partial<BabyObjectMatchDraft> = {}) {
return draft;
}
function createGeneratedDraft() {
return createDraft({
itemAssets: [
{
itemId: 'baby-object-item-1',
itemName: '苹果',
imageSrc: 'data:image/png;base64,a',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: '苹果',
},
{
itemId: 'baby-object-item-2',
itemName: '香蕉',
imageSrc: 'data:image/png;base64,b',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: '香蕉',
},
],
visualPackage: {
themePrompt: '果园主题',
assets: [
{
assetId: 'baby-object-visual-background',
assetKind: 'background',
imageSrc: 'data:image/png;base64,background',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'background',
},
{
assetId: 'baby-object-visual-ui-frame',
assetKind: 'ui-frame',
imageSrc: 'data:image/png;base64,ui',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'ui',
},
{
assetId: 'baby-object-visual-gift-box',
assetKind: 'gift-box',
imageSrc: 'data:image/png;base64,gift',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'gift',
},
{
assetId: 'baby-object-visual-basket',
assetKind: 'basket',
imageSrc: 'data:image/png;base64,basket',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'basket',
},
{
assetId: 'baby-object-visual-smoke-puff',
assetKind: 'smoke-puff',
imageSrc: 'data:image/png;base64,smoke',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'smoke',
},
],
},
});
}
test('baby object result publishes with exact edutainment tag', async () => {
const user = userEvent.setup();
const onPublish = vi.fn();
render(
<BabyObjectMatchResultView
draft={createDraft()}
draft={createGeneratedDraft()}
onBack={() => {}}
onPublish={onPublish}
/>,
@@ -90,7 +159,7 @@ test('baby object result exposes save and test run actions', async () => {
render(
<BabyObjectMatchResultView
draft={createDraft()}
draft={createGeneratedDraft()}
onBack={() => {}}
onSaveDraft={onSaveDraft}
onStartTestRun={onStartTestRun}
@@ -103,3 +172,38 @@ test('baby object result exposes save and test run actions', async () => {
expect(onSaveDraft).toHaveBeenCalledTimes(1);
expect(onStartTestRun).toHaveBeenCalledTimes(1);
});
test('baby object result blocks placeholder assets and exposes regeneration', async () => {
const user = userEvent.setup();
const onPublish = vi.fn();
const onStartTestRun = vi.fn();
const onRegenerateAssets = vi.fn();
render(
<BabyObjectMatchResultView
draft={createDraft()}
onBack={() => {}}
onPublish={onPublish}
onStartTestRun={onStartTestRun}
onRegenerateAssets={onRegenerateAssets}
/>,
);
expect(
screen.getByText('当前作品仍是占位资源,请重新生成 image-2 资源后再试玩或发布。'),
).toBeTruthy();
expect(
(screen.getByRole('button', { name: '试玩' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '发布' }) as HTMLButtonElement)
.disabled,
).toBe(true);
await user.click(screen.getByRole('button', { name: '重新生成资源' }));
expect(onRegenerateAssets).toHaveBeenCalledTimes(1);
expect(onPublish).not.toHaveBeenCalled();
expect(onStartTestRun).not.toHaveBeenCalled();
});
@@ -1,4 +1,12 @@
import { ArrowLeft, CheckCircle2, Loader2, Play, Save, Tag } from 'lucide-react';
import {
ArrowLeft,
CheckCircle2,
Loader2,
Play,
RefreshCw,
Save,
Tag,
} from 'lucide-react';
import { useMemo } from 'react';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
@@ -17,6 +25,7 @@ type BabyObjectMatchResultViewProps = {
onSaveDraft?: (draft: BabyObjectMatchDraft) => void;
onPublish?: (draft: BabyObjectMatchDraft) => void;
onStartTestRun?: (draft: BabyObjectMatchDraft) => void;
onRegenerateAssets?: (draft: BabyObjectMatchDraft) => void;
};
function normalizeDraftForAction(draft: BabyObjectMatchDraft) {
@@ -27,6 +36,14 @@ function normalizeDraftForAction(draft: BabyObjectMatchDraft) {
};
}
const REQUIRED_VISUAL_ASSET_KINDS = [
'background',
'ui-frame',
'gift-box',
'basket',
'smoke-puff',
] as const;
export function BabyObjectMatchResultView({
draft,
isBusy = false,
@@ -35,12 +52,29 @@ export function BabyObjectMatchResultView({
onSaveDraft,
onPublish,
onStartTestRun,
onRegenerateAssets,
}: BabyObjectMatchResultViewProps) {
const normalizedDraft = useMemo(() => normalizeDraftForAction(draft), [draft]);
const hasGeneratedAssets =
normalizedDraft.itemAssets.every(
(asset) =>
asset.generationProvider === 'vector-engine-gpt-image-2' &&
asset.imageSrc.startsWith('data:image/png;base64,'),
) &&
Boolean(normalizedDraft.visualPackage) &&
REQUIRED_VISUAL_ASSET_KINDS.every((kind) =>
normalizedDraft.visualPackage!.assets.some(
(asset) =>
asset.assetKind === kind &&
asset.generationProvider === 'vector-engine-gpt-image-2' &&
asset.imageSrc.startsWith('data:image/png;base64,'),
),
);
const publishReady =
normalizedDraft.itemNames.every((itemName) => itemName.trim()) &&
normalizedDraft.itemAssets.every((asset) => asset.imageSrc.trim()) &&
hasBabyObjectMatchRequiredTag(normalizedDraft.themeTags);
hasBabyObjectMatchRequiredTag(normalizedDraft.themeTags) &&
hasGeneratedAssets;
const isPublished = normalizedDraft.publicationStatus === 'published';
return (
@@ -123,9 +157,15 @@ export function BabyObjectMatchResultView({
{error}
</div>
) : null}
{!hasGeneratedAssets ? (
<div className="platform-banner mt-3 rounded-2xl text-sm leading-6">
当前作品仍是占位资源,请重新生成 image-2 资源后再试玩或发布。
</div>
) : null}
</div>
<div className="mt-3 grid shrink-0 gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:grid-cols-3">
<div className="mt-3 grid shrink-0 gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:grid-cols-4">
<button
type="button"
disabled={isBusy || !onSaveDraft}
@@ -137,7 +177,20 @@ export function BabyObjectMatchResultView({
</button>
<button
type="button"
disabled={isBusy || !onStartTestRun}
disabled={isBusy || !onRegenerateAssets}
onClick={() => onRegenerateAssets?.(normalizedDraft)}
className="platform-button platform-button--secondary justify-center disabled:cursor-not-allowed disabled:opacity-55"
>
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
重新生成资源
</button>
<button
type="button"
disabled={isBusy || !hasGeneratedAssets || !onStartTestRun}
onClick={() => onStartTestRun?.(normalizedDraft)}
className="platform-button platform-button--secondary justify-center disabled:cursor-not-allowed disabled:opacity-55"
>
@@ -0,0 +1,246 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { BabyLoveDrawingRuntimeShell } from './BabyLoveDrawingRuntimeShell';
const saveBabyLoveDrawingMock = vi.fn();
const createBabyLoveDrawingMagicImageMock = vi.fn();
const mocapMock = vi.hoisted(() => ({
command: null as null | {
actions: string[];
leftHand?: {
x: number;
y: number;
state: 'open_palm' | 'grab' | 'unknown';
side: 'left';
} | null;
rightHand?: {
x: number;
y: number;
state: 'open_palm' | 'grab' | 'unknown';
side: 'right';
} | null;
},
}));
vi.mock('../../services/useMocapInput', () => ({
useMocapInput: () => ({
status: 'idle',
latestCommand: mocapMock.command,
rawPacketPreview: null,
error: null,
}),
}));
vi.mock('../../services/edutainment-baby-drawing', () => ({
createBabyLoveDrawingMagicImage: (...args: unknown[]) =>
createBabyLoveDrawingMagicImageMock(...args),
saveBabyLoveDrawing: (...args: unknown[]) => saveBabyLoveDrawingMock(...args),
}));
function installCanvasMock() {
const context = {
save: vi.fn(),
restore: vi.fn(),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fillRect: vi.fn(),
drawImage: vi.fn(),
set fillStyle(_value: string) {},
set strokeStyle(_value: string) {},
set lineWidth(_value: number) {},
set lineCap(_value: CanvasLineCap) {},
set lineJoin(_value: CanvasLineJoin) {},
set globalCompositeOperation(_value: GlobalCompositeOperation) {},
} as unknown as CanvasRenderingContext2D;
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context);
vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue(
'data:image/png;base64,original',
);
}
beforeEach(() => {
installCanvasMock();
mocapMock.command = null;
saveBabyLoveDrawingMock.mockReturnValue({
record: {
drawingId: 'baby-love-drawing-local-1',
templateId: 'baby-love-drawing',
templateName: '宝贝爱画',
originalImageSrc: 'data:image/png;base64,original',
magicImageSrc: null,
strokeTrace: [],
saveMode: 'original-only',
themeTags: ['寓教于乐', '宝贝爱画'],
createdAt: '2026-05-13T08:00:00.000Z',
updatedAt: '2026-05-13T08:00:00.000Z',
},
});
createBabyLoveDrawingMagicImageMock.mockResolvedValue({
magicImageSrc: 'data:image/png;base64,magic',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '绘本风格',
});
});
afterEach(() => {
vi.restoreAllMocks();
});
test('renders drawing board, seven colors and tool buttons', () => {
render(<BabyLoveDrawingRuntimeShell />);
expect(screen.getByTestId('baby-love-drawing-runtime')).toBeTruthy();
expect(screen.getByLabelText('画板')).toBeTruthy();
expect(screen.getByLabelText('红')).toBeTruthy();
expect(screen.getByLabelText('紫')).toBeTruthy();
expect(screen.getAllByRole('button')).toHaveLength(11);
expect(screen.getByLabelText('画笔')).toBeTruthy();
expect(screen.getByLabelText('橡皮')).toBeTruthy();
});
test('finish then save stores original drawing in local demo service', () => {
render(<BabyLoveDrawingRuntimeShell />);
fireEvent.click(screen.getByRole('button', { name: '完成' }));
fireEvent.click(screen.getByRole('button', { name: '保存' }));
expect(saveBabyLoveDrawingMock).toHaveBeenCalledWith(
expect.objectContaining({
originalImageSrc: 'data:image/png;base64,original',
magicImageSrc: null,
}),
);
expect(screen.getByText('已保存')).toBeTruthy();
});
test('back button calls onBack callback', () => {
const onBack = vi.fn();
render(<BabyLoveDrawingRuntimeShell onBack={onBack} />);
fireEvent.click(screen.getByLabelText('返回'));
expect(onBack).toHaveBeenCalledTimes(1);
});
test('mocap camera-left hand drives the player right hand brush cursor', () => {
mocapMock.command = {
actions: [],
leftHand: { x: 0.72, y: 0.34, state: 'open_palm', side: 'left' },
rightHand: null,
};
const { container, rerender } = render(<BabyLoveDrawingRuntimeShell />);
const cursor = container.querySelector(
'.baby-love-drawing-runtime__cursor',
) as HTMLElement;
expect(cursor.style.left).toBe('72%');
expect(cursor.style.top).toBe('34%');
mocapMock.command = {
actions: [],
leftHand: null,
rightHand: { x: 0.18, y: 0.82, state: 'grab', side: 'right' },
};
rerender(<BabyLoveDrawingRuntimeShell />);
expect(cursor.style.left).toBe('72%');
expect(cursor.style.top).toBe('34%');
});
test('mocap camera-right hand renders the player left hand color indicator', () => {
mocapMock.command = {
actions: [],
leftHand: null,
rightHand: { x: 0.16, y: 0.42, state: 'open_palm', side: 'right' },
};
const { container } = render(<BabyLoveDrawingRuntimeShell />);
const indicator = container.querySelector(
'.baby-love-drawing-runtime__left-hand-indicator',
) as HTMLElement;
expect(indicator).toBeTruthy();
expect(indicator.style.left).toBe('16%');
expect(indicator.style.top).toBe('42%');
});
test('left hand indicator stays visible through brief mocap hand loss', () => {
vi.useFakeTimers();
mocapMock.command = {
actions: [],
leftHand: null,
rightHand: { x: 0.16, y: 0.42, state: 'open_palm', side: 'right' },
};
const { container, rerender } = render(<BabyLoveDrawingRuntimeShell />);
vi.advanceTimersByTime(120);
mocapMock.command = {
actions: [],
leftHand: null,
rightHand: null,
};
rerender(<BabyLoveDrawingRuntimeShell />);
const indicator = container.querySelector(
'.baby-love-drawing-runtime__left-hand-indicator',
) as HTMLElement;
expect(indicator).toBeTruthy();
expect(indicator.style.left).toBe('16%');
expect(indicator.style.top).toBe('42%');
});
test('player left hand never takes over the right hand brush cursor', () => {
mocapMock.command = {
actions: [],
leftHand: { x: 0.68, y: 0.32, state: 'open_palm', side: 'left' },
rightHand: { x: 0.18, y: 0.78, state: 'open_palm', side: 'right' },
};
const { container, rerender } = render(<BabyLoveDrawingRuntimeShell />);
const cursor = container.querySelector(
'.baby-love-drawing-runtime__cursor',
) as HTMLElement;
expect(cursor.style.left).toBe('68%');
expect(cursor.style.top).toBe('32%');
mocapMock.command = {
actions: [],
leftHand: null,
rightHand: { x: 0.7, y: 0.3, state: 'grab', side: 'right' },
};
rerender(<BabyLoveDrawingRuntimeShell />);
expect(cursor.style.left).toBe('68%');
expect(cursor.style.top).toBe('32%');
});
test('large camera-left jump is rejected to prevent left hand stealing brush', () => {
mocapMock.command = {
actions: [],
leftHand: { x: 0.72, y: 0.34, state: 'open_palm', side: 'left' },
rightHand: null,
};
const { container, rerender } = render(<BabyLoveDrawingRuntimeShell />);
const cursor = container.querySelector(
'.baby-love-drawing-runtime__cursor',
) as HTMLElement;
expect(cursor.style.left).toBe('72%');
expect(cursor.style.top).toBe('34%');
mocapMock.command = {
actions: [],
leftHand: { x: 0.16, y: 0.82, state: 'grab', side: 'left' },
rightHand: null,
};
rerender(<BabyLoveDrawingRuntimeShell />);
expect(cursor.style.left).toBe('72%');
expect(cursor.style.top).toBe('34%');
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More