Merge remote-tracking branch 'origin/codex/unified-creation-flow-phase1'
# Conflicts: # server-rs/crates/api-server/src/wooden_fish.rs
This commit is contained in:
@@ -142,12 +142,17 @@ describe('CustomWorldGenerationView', () => {
|
||||
screen
|
||||
.getByRole('progressbar', { name: progressTitle })
|
||||
.className,
|
||||
).toContain('w-[400px]');
|
||||
).toContain('w-[min(400px,calc(100%_-_0.75rem))]');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('progressbar', { name: progressTitle })
|
||||
.className,
|
||||
).toContain('h-[400px]');
|
||||
).toContain('max-w-full');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('progressbar', { name: progressTitle })
|
||||
.className,
|
||||
).toContain('aspect-square');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('progressbar', { name: progressTitle })
|
||||
|
||||
@@ -133,44 +133,14 @@ export function GenerationProgressHero({
|
||||
const ringFillDasharray = `${ringMetrics.progressLength.toFixed(2)} ${ringMetrics.circumference.toFixed(2)}`;
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto flex w-full max-w-[60rem] flex-col items-center px-1 pb-1 pt-1 sm:pt-4">
|
||||
<div className="relative mx-auto flex w-full min-w-0 max-w-[60rem] flex-col items-center px-1 pb-1 pt-1 sm:pt-4">
|
||||
<div className="sr-only">
|
||||
{title}
|
||||
{phaseLabel ? ` ${phaseLabel}` : ''}
|
||||
</div>
|
||||
<div className="relative w-full max-w-[56rem] sm:max-w-[60rem]">
|
||||
<div className="relative w-full min-w-0 max-w-[56rem] sm:max-w-[60rem]">
|
||||
<div
|
||||
className="absolute left-0 top-1/2 z-20 w-[min(6.8rem,28vw)] -translate-y-1/2 rounded-[1.1rem] border border-white/58 bg-white/58 px-2.5 py-2 text-center shadow-[0_14px_30px_rgba(112,57,30,0.10)] backdrop-blur-md sm:w-[8rem] sm:px-3 sm:py-2.5"
|
||||
data-testid="generation-hero-wait-card"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1.5 text-[#2a1c14]">
|
||||
<Hourglass className="h-3.5 w-3.5 shrink-0" strokeWidth={2.2} />
|
||||
<div className="text-[9px] font-black tracking-[0.1em] text-[#7e421f] sm:text-[10px]">
|
||||
预计等待
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 break-keep text-[12px] font-black leading-tight text-[#161211] sm:text-[13px]">
|
||||
{estimatedWaitText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute right-0 top-1/2 z-20 w-[min(6.8rem,28vw)] -translate-y-1/2 rounded-[1.1rem] border border-white/58 bg-white/58 px-2.5 py-2 text-center shadow-[0_14px_30px_rgba(112,57,30,0.10)] backdrop-blur-md sm:w-[8rem] sm:px-3 sm:py-2.5"
|
||||
data-testid="generation-hero-elapsed-card"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1.5 text-[#2a1c14]">
|
||||
<div className="text-[9px] font-black tracking-[0.1em] text-[#7e421f] sm:text-[10px]">
|
||||
已耗时
|
||||
</div>
|
||||
<Clock3 className="h-3.5 w-3.5 shrink-0" strokeWidth={2.2} />
|
||||
</div>
|
||||
<div className="mt-1.5 break-keep text-[12px] font-black leading-tight text-[#161211] sm:text-[13px]">
|
||||
{elapsedText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative mx-auto h-[400px] w-[400px] shrink-0 overflow-visible rounded-full"
|
||||
className="relative mx-auto aspect-square w-[min(400px,calc(100%_-_0.75rem))] max-w-full shrink-0 overflow-visible rounded-full"
|
||||
role="progressbar"
|
||||
aria-label={title}
|
||||
aria-valuemin={0}
|
||||
@@ -244,6 +214,38 @@ export function GenerationProgressHero({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-20 mt-[-0.3rem] grid w-full grid-cols-2 gap-2 px-0.5 sm:absolute sm:inset-0 sm:mt-0 sm:block sm:px-0">
|
||||
<div
|
||||
className="w-full rounded-[1.1rem] border border-white/58 bg-white/58 px-2.5 py-2 text-center shadow-[0_14px_30px_rgba(112,57,30,0.10)] backdrop-blur-md sm:absolute sm:left-0 sm:top-1/2 sm:w-[8rem] sm:-translate-y-1/2 sm:px-3 sm:py-2.5"
|
||||
data-testid="generation-hero-wait-card"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1.5 text-[#2a1c14]">
|
||||
<Hourglass className="h-3.5 w-3.5 shrink-0" strokeWidth={2.2} />
|
||||
<div className="text-[9px] font-black tracking-[0.1em] text-[#7e421f] sm:text-[10px]">
|
||||
预计等待
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 break-keep text-[12px] font-black leading-tight text-[#161211] sm:text-[13px]">
|
||||
{estimatedWaitText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="w-full rounded-[1.1rem] border border-white/58 bg-white/58 px-2.5 py-2 text-center shadow-[0_14px_30px_rgba(112,57,30,0.10)] backdrop-blur-md sm:absolute sm:right-0 sm:top-1/2 sm:w-[8rem] sm:-translate-y-1/2 sm:px-3 sm:py-2.5"
|
||||
data-testid="generation-hero-elapsed-card"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1.5 text-[#2a1c14]">
|
||||
<div className="text-[9px] font-black tracking-[0.1em] text-[#7e421f] sm:text-[10px]">
|
||||
已耗时
|
||||
</div>
|
||||
<Clock3 className="h-3.5 w-3.5 shrink-0" strokeWidth={2.2} />
|
||||
</div>
|
||||
<div className="mt-1.5 break-keep text-[12px] font-black leading-tight text-[#161211] sm:text-[13px]">
|
||||
{elapsedText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -130,12 +130,17 @@ describe('BarkBattleGeneratingView', () => {
|
||||
screen
|
||||
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
||||
.className,
|
||||
).toContain('w-[400px]');
|
||||
).toContain('w-[min(400px,calc(100%_-_0.75rem))]');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
||||
.className,
|
||||
).toContain('h-[400px]');
|
||||
).toContain('max-w-full');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
||||
.className,
|
||||
).toContain('aspect-square');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
||||
|
||||
206
src/components/common/CreativeAudioInputPanel.tsx
Normal file
206
src/components/common/CreativeAudioInputPanel.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { Mic, Pause, Upload } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
export type CreativeAudioAsset = {
|
||||
assetId: string;
|
||||
audioSrc: string;
|
||||
audioObjectKey: string;
|
||||
assetObjectId: string;
|
||||
source: string;
|
||||
prompt?: string | null;
|
||||
durationMs?: number | null;
|
||||
};
|
||||
|
||||
type CreativeAudioInputPanelProps<TAsset extends CreativeAudioAsset> = {
|
||||
disabled?: boolean;
|
||||
title: string;
|
||||
defaultLabel: string;
|
||||
asset: TAsset | null;
|
||||
buildRecordedFileName: () => string;
|
||||
onAssetChange: (asset: TAsset | null) => void;
|
||||
onError: (message: string | null) => void;
|
||||
readFileAsAsset?: (
|
||||
file: File,
|
||||
source: 'uploaded' | 'recorded',
|
||||
) => Promise<TAsset>;
|
||||
};
|
||||
|
||||
export function readCreativeAudioFileAsAsset<TAsset extends CreativeAudioAsset>(
|
||||
file: File,
|
||||
source: 'uploaded' | 'recorded',
|
||||
) {
|
||||
return new Promise<TAsset>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(new Error('音频读取失败,请重试。'));
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result !== 'string') {
|
||||
reject(new Error('音频读取失败,请重试。'));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
assetId: `local-${source}-${Date.now()}`,
|
||||
audioSrc: reader.result,
|
||||
audioObjectKey: '',
|
||||
assetObjectId: '',
|
||||
source,
|
||||
prompt: file.name,
|
||||
durationMs: null,
|
||||
} as TAsset);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
|
||||
disabled = false,
|
||||
title,
|
||||
defaultLabel,
|
||||
asset,
|
||||
buildRecordedFileName,
|
||||
onAssetChange,
|
||||
onError,
|
||||
readFileAsAsset = readCreativeAudioFileAsAsset,
|
||||
}: CreativeAudioInputPanelProps<TAsset>) {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<BlobPart[]>([]);
|
||||
|
||||
const startRecording = async () => {
|
||||
if (disabled || isRecording) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
typeof navigator === 'undefined' ||
|
||||
!navigator.mediaDevices?.getUserMedia ||
|
||||
typeof MediaRecorder === 'undefined'
|
||||
) {
|
||||
throw new Error('当前浏览器不支持录音。');
|
||||
}
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const recorder = new MediaRecorder(stream);
|
||||
chunksRef.current = [];
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
const blob = new Blob(chunksRef.current, {
|
||||
type: recorder.mimeType || 'audio/webm',
|
||||
});
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
const file = new File([blob], buildRecordedFileName(), {
|
||||
type: blob.type,
|
||||
});
|
||||
void readFileAsAsset(file, 'recorded')
|
||||
.then(onAssetChange)
|
||||
.catch((caughtError) => {
|
||||
onError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: '录音保存失败。',
|
||||
);
|
||||
});
|
||||
};
|
||||
recorderRef.current = recorder;
|
||||
recorder.start();
|
||||
setIsRecording(true);
|
||||
onError(null);
|
||||
} catch (caughtError) {
|
||||
onError(
|
||||
caughtError instanceof Error ? caughtError.message : '录音启动失败。',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
recorderRef.current?.stop();
|
||||
recorderRef.current = null;
|
||||
setIsRecording(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="platform-subpanel rounded-[1.25rem] p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-black text-[var(--platform-text-strong)]">
|
||||
{title}
|
||||
</div>
|
||||
{asset ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAssetChange(null)}
|
||||
disabled={disabled}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-xs"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<label
|
||||
className={`platform-button platform-button--secondary min-h-10 cursor-pointer gap-2 px-3 py-2 text-sm ${
|
||||
disabled ? 'pointer-events-none opacity-55' : ''
|
||||
}`}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
上传
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
disabled={disabled}
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0] ?? null;
|
||||
event.currentTarget.value = '';
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
void readFileAsAsset(file, 'uploaded')
|
||||
.then((nextAsset) => {
|
||||
onError(null);
|
||||
onAssetChange(nextAsset);
|
||||
})
|
||||
.catch((caughtError) => {
|
||||
onError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: '音频读取失败。',
|
||||
);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (isRecording) {
|
||||
stopRecording();
|
||||
return;
|
||||
}
|
||||
void startRecording();
|
||||
}}
|
||||
className="platform-button platform-button--ghost min-h-10 gap-2 px-3 py-2 text-sm"
|
||||
>
|
||||
{isRecording ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Mic className="h-4 w-4" />
|
||||
)}
|
||||
{isRecording ? '停止' : '录音'}
|
||||
</button>
|
||||
{asset?.audioSrc ? (
|
||||
<audio controls src={asset.audioSrc} className="h-10 max-w-full" />
|
||||
) : (
|
||||
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
|
||||
{asset ? '音效已选择' : defaultLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreativeAudioInputPanel;
|
||||
@@ -101,6 +101,97 @@ test('creative image input panel handles reference uploads and preview', () => {
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('creative image input panel can opt out of filling the parent height', () => {
|
||||
const { container } = render(
|
||||
<CreativeImageInputPanel
|
||||
fillHeight={false}
|
||||
uploadedImageSrc=""
|
||||
uploadedImageAlt="拼图图片"
|
||||
mainImageInputId="image-upload-input"
|
||||
promptTextareaId="image-prompt-input"
|
||||
prompt="旧街灯牌下的猫。"
|
||||
promptLabel="画面描述"
|
||||
aiRedraw
|
||||
promptReferenceImages={[]}
|
||||
imageModelPicker={null}
|
||||
submitLabel="生成"
|
||||
submitDisabled={false}
|
||||
labels={{
|
||||
imageField: '拼图画面',
|
||||
uploadImage: '上传拼图图片',
|
||||
replaceImage: '更换拼图图片',
|
||||
emptyImageHint: '上传图片/填写画面描述',
|
||||
removeImage: '移除拼图图片',
|
||||
removeImageConfirmTitle: '移除拼图图片?',
|
||||
removeImageConfirmBody: '移除后需要重新上传图片。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '参考图预览',
|
||||
closePromptReferencePreview: '关闭参考图预览',
|
||||
}}
|
||||
onMainImageFileSelect={() => {}}
|
||||
onMainImageRemove={() => {}}
|
||||
onAiRedrawChange={() => {}}
|
||||
onPromptChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const panel = container.querySelector('.creative-image-input-panel');
|
||||
const body = container.querySelector('.creative-image-input-panel__body');
|
||||
const section = container.querySelector('.creative-image-input-panel__section');
|
||||
expect(panel?.className).toContain('flex-none');
|
||||
expect(panel?.className).not.toContain('flex-1');
|
||||
expect(body?.className).toContain('flex-none');
|
||||
expect(body?.className).not.toContain('overflow-y-auto');
|
||||
expect(section?.className).toContain('flex-none');
|
||||
expect(section?.className).not.toContain('overflow-hidden');
|
||||
});
|
||||
|
||||
test('creative image input panel fills the parent height by default', () => {
|
||||
const { container } = render(
|
||||
<CreativeImageInputPanel
|
||||
uploadedImageSrc=""
|
||||
uploadedImageAlt="拼图图片"
|
||||
mainImageInputId="image-upload-input"
|
||||
promptTextareaId="image-prompt-input"
|
||||
prompt="旧街灯牌下的猫。"
|
||||
promptLabel="画面描述"
|
||||
aiRedraw
|
||||
promptReferenceImages={[]}
|
||||
imageModelPicker={null}
|
||||
submitLabel="生成"
|
||||
submitDisabled={false}
|
||||
labels={{
|
||||
imageField: '拼图画面',
|
||||
uploadImage: '上传拼图图片',
|
||||
replaceImage: '更换拼图图片',
|
||||
emptyImageHint: '上传图片/填写画面描述',
|
||||
removeImage: '移除拼图图片',
|
||||
removeImageConfirmTitle: '移除拼图图片?',
|
||||
removeImageConfirmBody: '移除后需要重新上传图片。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '参考图预览',
|
||||
closePromptReferencePreview: '关闭参考图预览',
|
||||
}}
|
||||
onMainImageFileSelect={() => {}}
|
||||
onMainImageRemove={() => {}}
|
||||
onAiRedrawChange={() => {}}
|
||||
onPromptChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const panel = container.querySelector('.creative-image-input-panel');
|
||||
const body = container.querySelector('.creative-image-input-panel__body');
|
||||
const section = container.querySelector('.creative-image-input-panel__section');
|
||||
expect(panel?.className).toContain('flex-1');
|
||||
expect(panel?.className).not.toContain('flex-none');
|
||||
expect(body?.className).toContain('flex-1');
|
||||
expect(body?.className).toContain('overflow-y-auto');
|
||||
expect(section?.className).toContain('flex-1');
|
||||
expect(section?.className).toContain('overflow-hidden');
|
||||
});
|
||||
|
||||
test('creative image input panel confirms before removing uploaded image', () => {
|
||||
const onMainImageRemove = vi.fn();
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export type CreativeImageInputPanelLabels = {
|
||||
|
||||
export type CreativeImageInputPanelProps = {
|
||||
className?: string;
|
||||
fillHeight?: boolean;
|
||||
disabled?: boolean;
|
||||
isSubmitting?: boolean;
|
||||
mainImageMode?: 'edit' | 'preview';
|
||||
@@ -77,6 +78,7 @@ const DEFAULT_PROMPT_REFERENCE_LIMIT = 5;
|
||||
|
||||
export function CreativeImageInputPanel({
|
||||
className = '',
|
||||
fillHeight = true,
|
||||
disabled = false,
|
||||
isSubmitting = false,
|
||||
mainImageMode = 'edit',
|
||||
@@ -143,29 +145,48 @@ export function CreativeImageInputPanel({
|
||||
}
|
||||
}, [previewReferenceImage, promptReferenceImages]);
|
||||
|
||||
const bodyClassName = fillHeight
|
||||
? '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'
|
||||
: 'creative-image-input-panel__body puzzle-creation-form-body flex flex-none flex-col overflow-visible pr-0 lg:pr-1';
|
||||
const sectionClassName = fillHeight
|
||||
? 'creative-image-input-panel__section puzzle-creation-form-section flex min-h-0 flex-1 flex-col overflow-hidden lg:overflow-visible'
|
||||
: 'creative-image-input-panel__section puzzle-creation-form-section flex flex-none flex-col overflow-visible';
|
||||
const gridSizeClassName = fillHeight ? 'min-h-0 flex-1' : 'flex-none';
|
||||
const imageFieldClassName = fillHeight
|
||||
? 'creative-image-input-panel__image-field puzzle-image-field flex min-h-0 min-w-0 flex-1 flex-col'
|
||||
: 'creative-image-input-panel__image-field puzzle-image-field flex min-w-0 flex-none flex-col';
|
||||
const imageFrameClassName = fillHeight
|
||||
? 'creative-image-input-panel__image-frame puzzle-image-card-frame flex min-h-0 flex-1 items-center justify-center'
|
||||
: 'creative-image-input-panel__image-frame puzzle-image-card-frame flex flex-none items-center justify-center';
|
||||
const imageCardClassName = fillHeight
|
||||
? 'creative-image-input-panel__image-card puzzle-image-upload-card relative aspect-square h-full min-h-[14rem] 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 sm:min-h-[18rem] lg:h-auto lg:w-full'
|
||||
: 'creative-image-input-panel__image-card puzzle-image-upload-card relative aspect-square w-full min-h-[14rem] 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 sm:min-h-[18rem]';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`creative-image-input-panel flex min-h-0 flex-1 flex-col ${className}`}
|
||||
className={`creative-image-input-panel flex min-h-0 flex-col ${
|
||||
fillHeight ? 'flex-1' : 'flex-none'
|
||||
} ${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={bodyClassName}>
|
||||
<section className={sectionClassName}>
|
||||
<div
|
||||
className={`creative-image-input-panel__grid puzzle-creation-form-grid min-h-0 flex-1 gap-2.5 sm:gap-4 ${
|
||||
className={`creative-image-input-panel__grid puzzle-creation-form-grid ${gridSizeClassName} 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 ${
|
||||
className={`${imageFieldClassName} ${
|
||||
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 min-h-[14rem] 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 sm:min-h-[18rem] lg:h-auto lg:w-full">
|
||||
<div className={imageFrameClassName}>
|
||||
<div className={imageCardClassName}>
|
||||
{canEditMainImage ? (
|
||||
<>
|
||||
<input
|
||||
|
||||
144
src/components/jump-hop-result/JumpHopResultView.test.tsx
Normal file
144
src/components/jump-hop-result/JumpHopResultView.test.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { JumpHopDraftResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import { JumpHopResultView } from './JumpHopResultView';
|
||||
|
||||
const draft: JumpHopDraftResponse = {
|
||||
templateId: 'jump-hop',
|
||||
templateName: '跳一跳',
|
||||
profileId: 'profile-1',
|
||||
workTitle: '云端跳台',
|
||||
workDescription: '一路跳到星星。',
|
||||
themeTags: ['云朵', '星空'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'paper-toy',
|
||||
characterPrompt: '纸片小兔',
|
||||
tilePrompt: '柔软云朵平台',
|
||||
endMoodPrompt: '星光门',
|
||||
characterAsset: {
|
||||
assetId: 'character-1',
|
||||
imageSrc: 'data:image/png;base64,character',
|
||||
imageObjectKey: 'jump-hop/character.png',
|
||||
assetObjectId: 'asset-character',
|
||||
generationProvider: 'vector-engine-gpt-image-2',
|
||||
prompt: '角色图',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
tileAtlasAsset: {
|
||||
assetId: 'tiles-1',
|
||||
imageSrc: 'data:image/png;base64,tiles',
|
||||
imageObjectKey: 'jump-hop/tiles.png',
|
||||
assetObjectId: 'asset-tiles',
|
||||
generationProvider: 'vector-engine-gpt-image-2',
|
||||
prompt: '地块图',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
tileAssets: [
|
||||
{
|
||||
tileType: 'start',
|
||||
imageSrc: 'data:image/png;base64,tile-start',
|
||||
imageObjectKey: 'jump-hop/tile-start.png',
|
||||
assetObjectId: 'asset-tile-start',
|
||||
sourceAtlasCell: 'A1',
|
||||
visualWidth: 128,
|
||||
visualHeight: 96,
|
||||
topSurfaceRadius: 24,
|
||||
landingRadius: 28,
|
||||
},
|
||||
{
|
||||
tileType: 'finish',
|
||||
imageSrc: 'data:image/png;base64,tile-finish',
|
||||
imageObjectKey: 'jump-hop/tile-finish.png',
|
||||
assetObjectId: 'asset-tile-finish',
|
||||
sourceAtlasCell: 'A2',
|
||||
visualWidth: 128,
|
||||
visualHeight: 96,
|
||||
topSurfaceRadius: 24,
|
||||
landingRadius: 28,
|
||||
},
|
||||
],
|
||||
path: {
|
||||
seed: 'jump-hop-seed',
|
||||
difficulty: 'standard',
|
||||
platforms: [
|
||||
{
|
||||
platformId: 'platform-1',
|
||||
tileType: 'start',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 48,
|
||||
height: 36,
|
||||
landingRadius: 22,
|
||||
perfectRadius: 12,
|
||||
scoreValue: 1,
|
||||
},
|
||||
{
|
||||
platformId: 'platform-2',
|
||||
tileType: 'finish',
|
||||
x: 16,
|
||||
y: 18,
|
||||
width: 60,
|
||||
height: 42,
|
||||
landingRadius: 22,
|
||||
perfectRadius: 12,
|
||||
scoreValue: 2,
|
||||
},
|
||||
],
|
||||
finishIndex: 1,
|
||||
cameraPreset: 'default',
|
||||
scoring: {
|
||||
chargeToDistanceRatio: 1.2,
|
||||
maxChargeMs: 1800,
|
||||
hitBonus: 20,
|
||||
perfectBonus: 50,
|
||||
},
|
||||
},
|
||||
coverComposite: 'data:image/png;base64,cover',
|
||||
generationStatus: 'ready',
|
||||
};
|
||||
|
||||
test('jump hop result view exposes test run and publish actions', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onBack = vi.fn();
|
||||
const onEdit = vi.fn();
|
||||
const onStartTestRun = vi.fn();
|
||||
const onPublish = vi.fn();
|
||||
const onRegenerateCharacter = vi.fn();
|
||||
const onRegenerateTiles = vi.fn();
|
||||
|
||||
render(
|
||||
<JumpHopResultView
|
||||
profile={draft}
|
||||
onBack={onBack}
|
||||
onEdit={onEdit}
|
||||
onStartTestRun={onStartTestRun}
|
||||
onPublish={onPublish}
|
||||
onRegenerateCharacter={onRegenerateCharacter}
|
||||
onRegenerateTiles={onRegenerateTiles}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('云端跳台')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '试玩' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '发布' })).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '试玩' }));
|
||||
await user.click(screen.getByRole('button', { name: '发布' }));
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
await user.click(screen.getByRole('button', { name: '返回编辑' }));
|
||||
await user.click(screen.getByRole('button', { name: '角色' }));
|
||||
await user.click(screen.getByRole('button', { name: '地块' }));
|
||||
|
||||
expect(onStartTestRun).toHaveBeenCalledTimes(1);
|
||||
expect(onPublish).toHaveBeenCalledTimes(1);
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
expect(onEdit).toHaveBeenCalledTimes(1);
|
||||
expect(onRegenerateCharacter).toHaveBeenCalledTimes(1);
|
||||
expect(onRegenerateTiles).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
212
src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx
Normal file
212
src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
JumpHopRuntimeRunSnapshotResponse,
|
||||
JumpHopWorkProfileResponse,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import { JumpHopRuntimeShell } from './JumpHopRuntimeShell';
|
||||
|
||||
const profile: JumpHopWorkProfileResponse = {
|
||||
summary: {
|
||||
runtimeKind: 'jump-hop',
|
||||
workId: 'work-1',
|
||||
profileId: 'profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'session-1',
|
||||
workTitle: '云端跳台',
|
||||
workDescription: '一路跳到星星。',
|
||||
themeTags: ['云朵'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'paper-toy',
|
||||
coverImageSrc: 'data:image/png;base64,cover',
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-30T10:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: true,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
draft: {
|
||||
templateId: 'jump-hop',
|
||||
templateName: '跳一跳',
|
||||
profileId: 'profile-1',
|
||||
workTitle: '云端跳台',
|
||||
workDescription: '一路跳到星星。',
|
||||
themeTags: ['云朵'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'paper-toy',
|
||||
characterPrompt: '纸片小兔',
|
||||
tilePrompt: '云朵平台',
|
||||
endMoodPrompt: '星光门',
|
||||
characterAsset: {
|
||||
assetId: 'character-1',
|
||||
imageSrc: 'data:image/png;base64,character',
|
||||
imageObjectKey: 'jump-hop/character.png',
|
||||
assetObjectId: 'asset-character',
|
||||
generationProvider: 'vector-engine-gpt-image-2',
|
||||
prompt: '角色图',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
tileAtlasAsset: {
|
||||
assetId: 'tiles-1',
|
||||
imageSrc: 'data:image/png;base64,tiles',
|
||||
imageObjectKey: 'jump-hop/tiles.png',
|
||||
assetObjectId: 'asset-tiles',
|
||||
generationProvider: 'vector-engine-gpt-image-2',
|
||||
prompt: '地块图',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
tileAssets: [
|
||||
{
|
||||
tileType: 'start',
|
||||
imageSrc: 'data:image/png;base64,tile-start',
|
||||
imageObjectKey: 'jump-hop/tile-start.png',
|
||||
assetObjectId: 'asset-tile-start',
|
||||
sourceAtlasCell: 'A1',
|
||||
visualWidth: 128,
|
||||
visualHeight: 96,
|
||||
topSurfaceRadius: 24,
|
||||
landingRadius: 28,
|
||||
},
|
||||
],
|
||||
path: {
|
||||
seed: 'jump-hop-seed',
|
||||
difficulty: 'standard',
|
||||
platforms: [
|
||||
{
|
||||
platformId: 'platform-1',
|
||||
tileType: 'start',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 48,
|
||||
height: 36,
|
||||
landingRadius: 22,
|
||||
perfectRadius: 12,
|
||||
scoreValue: 1,
|
||||
},
|
||||
],
|
||||
finishIndex: 0,
|
||||
cameraPreset: 'default',
|
||||
scoring: {
|
||||
chargeToDistanceRatio: 1.2,
|
||||
maxChargeMs: 1800,
|
||||
hitBonus: 20,
|
||||
perfectBonus: 50,
|
||||
},
|
||||
},
|
||||
coverComposite: 'data:image/png;base64,cover',
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
path: {
|
||||
seed: 'jump-hop-seed',
|
||||
difficulty: 'standard',
|
||||
platforms: [
|
||||
{
|
||||
platformId: 'platform-1',
|
||||
tileType: 'start',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 48,
|
||||
height: 36,
|
||||
landingRadius: 22,
|
||||
perfectRadius: 12,
|
||||
scoreValue: 1,
|
||||
},
|
||||
],
|
||||
finishIndex: 0,
|
||||
cameraPreset: 'default',
|
||||
scoring: {
|
||||
chargeToDistanceRatio: 1.2,
|
||||
maxChargeMs: 1800,
|
||||
hitBonus: 20,
|
||||
perfectBonus: 50,
|
||||
},
|
||||
},
|
||||
characterAsset: {
|
||||
assetId: 'character-1',
|
||||
imageSrc: 'data:image/png;base64,character',
|
||||
imageObjectKey: 'jump-hop/character.png',
|
||||
assetObjectId: 'asset-character',
|
||||
generationProvider: 'vector-engine-gpt-image-2',
|
||||
prompt: '角色图',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
tileAtlasAsset: {
|
||||
assetId: 'tiles-1',
|
||||
imageSrc: 'data:image/png;base64,tiles',
|
||||
imageObjectKey: 'jump-hop/tiles.png',
|
||||
assetObjectId: 'asset-tiles',
|
||||
generationProvider: 'vector-engine-gpt-image-2',
|
||||
prompt: '地块图',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
tileAssets: [
|
||||
{
|
||||
tileType: 'start',
|
||||
imageSrc: 'data:image/png;base64,tile-start',
|
||||
imageObjectKey: 'jump-hop/tile-start.png',
|
||||
assetObjectId: 'asset-tile-start',
|
||||
sourceAtlasCell: 'A1',
|
||||
visualWidth: 128,
|
||||
visualHeight: 96,
|
||||
topSurfaceRadius: 24,
|
||||
landingRadius: 28,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const run: JumpHopRuntimeRunSnapshotResponse = {
|
||||
runId: 'run-1',
|
||||
profileId: 'profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'playing',
|
||||
currentPlatformIndex: 0,
|
||||
score: 0,
|
||||
combo: 0,
|
||||
path: profile.path,
|
||||
lastJump: null,
|
||||
startedAtMs: 1000,
|
||||
finishedAtMs: null,
|
||||
};
|
||||
|
||||
test('jump hop runtime shell supports jump, restart and exit actions', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onJump = vi.fn().mockResolvedValue(undefined);
|
||||
const onRestart = vi.fn();
|
||||
const onExit = vi.fn();
|
||||
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={profile}
|
||||
run={run}
|
||||
onJump={onJump}
|
||||
onRestart={onRestart}
|
||||
onExit={onExit}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.pointer([
|
||||
{ target: screen.getByRole('button', { name: '起跳' }), keys: '[MouseLeft>]' },
|
||||
]);
|
||||
await user.pointer([
|
||||
{ target: screen.getByRole('button', { name: '起跳' }), keys: '[/MouseLeft]' },
|
||||
]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onJump).toHaveBeenCalledWith({ chargeMs: expect.any(Number) });
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '重开' }));
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
|
||||
expect(onRestart).toHaveBeenCalledTimes(1);
|
||||
expect(onExit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
import { ArrowLeft, Edit3, Sparkles } from 'lucide-react';
|
||||
|
||||
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
|
||||
type Match3DDraftReadyViewProps = {
|
||||
session: Match3DAgentSessionSnapshot;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
export function Match3DDraftReadyView({
|
||||
session,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
}: Match3DDraftReadyViewProps) {
|
||||
const draft = session.draft;
|
||||
const title = draft?.gameName || '抓大鹅草稿';
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-4xl flex-col">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<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>
|
||||
|
||||
<section className="platform-subpanel min-h-0 flex-1 overflow-y-auto rounded-[1.5rem] p-4 sm:p-5">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
|
||||
<div className="grid aspect-square w-full max-w-[10rem] shrink-0 place-items-center rounded-[1.2rem] bg-[radial-gradient(circle_at_30%_25%,rgba(190,242,100,0.36),transparent_34%),linear-gradient(135deg,rgba(16,185,129,0.18),rgba(251,146,60,0.16))] text-emerald-800">
|
||||
<Sparkles className="h-10 w-10" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-2xl font-black leading-tight text-[var(--platform-text-strong)] sm:text-3xl">
|
||||
{title}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-base)]">
|
||||
{draft?.summaryText ?? session.lastAssistantReply ?? ''}
|
||||
</div>
|
||||
|
||||
{draft ? (
|
||||
<div className="mt-5 grid gap-2 sm:grid-cols-3">
|
||||
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
|
||||
题材
|
||||
</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{draft.themeText}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
|
||||
物品
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{draft.totalItemCount ?? draft.clearCount * 3} 件
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
|
||||
难度
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{draft.difficulty}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-4 rounded-[1.25rem] text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-4 flex justify-end pb-[max(0.25rem,env(safe-area-inset-bottom))]">
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="platform-button platform-button--primary cursor-not-allowed opacity-55"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Edit3 className="h-4 w-4" />
|
||||
继续编辑
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Match3DDraftReadyView;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,94 +0,0 @@
|
||||
export type PuzzleCreationTemplate = {
|
||||
id: string;
|
||||
title: string;
|
||||
imageSrc: string;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
// 中文注释:模板只服务入口快速填词,正式作品信息仍在结果页补全。
|
||||
export const PUZZLE_CREATION_TEMPLATES: PuzzleCreationTemplate[] = [
|
||||
{
|
||||
id: 'couple-memory',
|
||||
title: '情侣合照拼图',
|
||||
imageSrc: '/puzzle-creation-templates/couple-memory.webp',
|
||||
prompt:
|
||||
'温暖自然光下的一对情侣纪念合照,城市咖啡馆窗边,桌面有花束和两杯热饮,人物神情自然,画面主体清晰,前中后景层次明确。',
|
||||
},
|
||||
{
|
||||
id: 'family-keepsake',
|
||||
title: '家庭纪念拼图',
|
||||
imageSrc: '/puzzle-creation-templates/family-keepsake.webp',
|
||||
prompt:
|
||||
'三代家人在客厅沙发前的家庭纪念合照,柔和午后阳光,孩子抱着生日蛋糕,长辈微笑,画面温暖完整,细节丰富但不杂乱。',
|
||||
},
|
||||
{
|
||||
id: 'friends-party',
|
||||
title: '朋友聚会拼图',
|
||||
imageSrc: '/puzzle-creation-templates/friends-party.webp',
|
||||
prompt:
|
||||
'朋友们在露台夜晚聚会,彩灯、桌上零食和举杯瞬间,人物分布有层次,中央焦点清楚,氛围轻松热闹。',
|
||||
},
|
||||
{
|
||||
id: 'festival-card',
|
||||
title: '节日贺卡拼图',
|
||||
imageSrc: '/puzzle-creation-templates/festival-card.webp',
|
||||
prompt:
|
||||
'节日餐桌与礼物布置,暖色灯光、彩带、蜡烛和窗外烟花,画面像无字贺卡,主体集中,边角细节可辨。',
|
||||
},
|
||||
{
|
||||
id: 'knowledge-summary',
|
||||
title: '知识总结拼图',
|
||||
imageSrc: '/puzzle-creation-templates/knowledge-summary.webp',
|
||||
prompt:
|
||||
'一张无文字的知识学习主题插画,书桌上有打开的笔记本、便签、咖啡、台灯和思维导图式图形元素,构图整洁,重点明确。',
|
||||
},
|
||||
{
|
||||
id: 'product-detail',
|
||||
title: '商品细节拼图',
|
||||
imageSrc: '/puzzle-creation-templates/product-detail.webp',
|
||||
prompt:
|
||||
'精致商品静物展示,一只高质感香水瓶放在丝绸与花瓣之间,玻璃反光清晰,包装和材质细节丰富,背景干净。',
|
||||
},
|
||||
{
|
||||
id: 'healing-landscape',
|
||||
title: '治愈风景拼图',
|
||||
imageSrc: '/puzzle-creation-templates/healing-landscape.webp',
|
||||
prompt:
|
||||
'治愈风景插画,清晨湖边、薄雾、远山、木栈道和一盏小灯,色彩柔和。',
|
||||
},
|
||||
{
|
||||
id: 'cute-pet',
|
||||
title: '宠物可爱拼图',
|
||||
imageSrc: '/puzzle-creation-templates/cute-pet.webp',
|
||||
prompt:
|
||||
'一只可爱的橘猫趴在阳光窗台上,旁边有绿植、毛线球和小毯子,猫的表情清楚,画面温柔干净。',
|
||||
},
|
||||
{
|
||||
id: 'hot-topic-poster',
|
||||
title: '热点海报拼图',
|
||||
imageSrc: '/puzzle-creation-templates/hot-topic-poster.webp',
|
||||
prompt:
|
||||
'电影感热点海报风插画,雨夜街头、霓虹反光、奔跑的人影和远处光束,强烈视觉焦点,画面无文字。',
|
||||
},
|
||||
{
|
||||
id: 'event-invitation',
|
||||
title: '活动邀请拼图',
|
||||
imageSrc: '/puzzle-creation-templates/event-invitation.webp',
|
||||
prompt:
|
||||
'活动邀请主题插画,展厅入口、花艺装置、签到台和柔和灯带,人群剪影自然分布,画面高级干净,无文字。',
|
||||
},
|
||||
{
|
||||
id: 'daily-challenge',
|
||||
title: '每日挑战拼图',
|
||||
imageSrc: '/puzzle-creation-templates/daily-challenge.webp',
|
||||
prompt:
|
||||
'每日挑战主题插画,清爽桌面上摆放相机、明信片、计时器和小奖章,色彩明亮,构图有趣,细节可拆解。',
|
||||
},
|
||||
{
|
||||
id: 'children-learning',
|
||||
title: '儿童认知拼图',
|
||||
imageSrc: '/puzzle-creation-templates/children-learning.webp',
|
||||
prompt:
|
||||
'儿童认知学习插画,木质桌面上有积木、彩色形状、动物玩偶和小书本,色彩明快,元素边界清晰,无文字。',
|
||||
},
|
||||
];
|
||||
@@ -24,12 +24,12 @@ import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { CreativeImageInputPanel } from '../common/CreativeImageInputPanel';
|
||||
import PuzzleHistoryAssetPickerDialog from '../puzzle-agent/PuzzleHistoryAssetPickerDialog';
|
||||
import PuzzleHistoryAssetPickerDialog from '../unified-creation/shared/PuzzleHistoryAssetPickerDialog';
|
||||
import {
|
||||
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
type PuzzleImageModelId,
|
||||
} from '../puzzle-agent/puzzleImageModelOptions';
|
||||
import { PuzzleImageModelPicker } from '../puzzle-agent/PuzzleImageModelPicker';
|
||||
} from '../unified-creation/shared/puzzleImageModelOptions';
|
||||
import { PuzzleImageModelPicker } from '../unified-creation/shared/PuzzleImageModelPicker';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type PuzzleResultViewProps = {
|
||||
|
||||
@@ -17,6 +17,10 @@ import type {
|
||||
BabyObjectMatchDraft,
|
||||
CreateBabyObjectMatchDraftRequest,
|
||||
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type {
|
||||
JumpHopWorkDetailResponse,
|
||||
JumpHopWorkProfileResponse,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
@@ -82,6 +86,7 @@ import {
|
||||
regenerateBabyObjectMatchDraftAssets,
|
||||
saveBabyObjectMatchDraft,
|
||||
} from '../../services/edutainment-baby-object';
|
||||
import { jumpHopClient } from '../../services/jump-hop/jumpHopClient';
|
||||
import { match3dCreationClient } from '../../services/match3d-creation';
|
||||
import {
|
||||
createServerMatch3DRuntimeAdapter,
|
||||
@@ -625,6 +630,22 @@ vi.mock('../../services/edutainment-baby-object', () => ({
|
||||
saveBabyObjectMatchDraft: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/jump-hop/jumpHopClient', () => ({
|
||||
jumpHopClient: {
|
||||
createSession: vi.fn(),
|
||||
executeAction: vi.fn(),
|
||||
getGalleryDetail: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
getWorkDetail: vi.fn(),
|
||||
listGallery: vi.fn(),
|
||||
listWorks: vi.fn(),
|
||||
publishWork: vi.fn(),
|
||||
restartRun: vi.fn(),
|
||||
startRun: vi.fn(),
|
||||
submitJump: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../services/match3d-creation', () => ({
|
||||
match3dCreationClient: {
|
||||
createSession: vi.fn(),
|
||||
@@ -782,8 +803,8 @@ vi.mock('../../services/puzzle-agent', () => ({
|
||||
streamPuzzleAgentMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../puzzle-agent/PuzzleAgentWorkspace', () => ({
|
||||
PuzzleAgentWorkspace: ({
|
||||
vi.mock('../unified-creation/workspaces/PuzzleCreationWorkspace', () => ({
|
||||
PuzzleCreationWorkspace: ({
|
||||
session,
|
||||
isBusy,
|
||||
error,
|
||||
@@ -986,8 +1007,8 @@ vi.mock('../match3d-result/Match3DResultView', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../match3d-creation/Match3DAgentWorkspace', () => ({
|
||||
Match3DAgentWorkspace: ({
|
||||
vi.mock('../unified-creation/workspaces/Match3DCreationWorkspace', () => ({
|
||||
Match3DCreationWorkspace: ({
|
||||
session,
|
||||
isBusy,
|
||||
error,
|
||||
@@ -1465,6 +1486,139 @@ function buildMockBabyObjectMatchDraft(
|
||||
};
|
||||
}
|
||||
|
||||
function buildMockJumpHopWork(
|
||||
overrides: Partial<JumpHopWorkProfileResponse> = {},
|
||||
): JumpHopWorkProfileResponse {
|
||||
const profileId = overrides.summary?.profileId ?? 'jump-hop-profile-1';
|
||||
const path = overrides.path ?? {
|
||||
seed: 'jump-hop-seed',
|
||||
difficulty: 'standard' as const,
|
||||
platforms: [
|
||||
{
|
||||
platformId: 'platform-start',
|
||||
tileType: 'start' as const,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 48,
|
||||
height: 36,
|
||||
landingRadius: 22,
|
||||
perfectRadius: 12,
|
||||
scoreValue: 1,
|
||||
},
|
||||
{
|
||||
platformId: 'platform-finish',
|
||||
tileType: 'finish' as const,
|
||||
x: 16,
|
||||
y: 18,
|
||||
width: 60,
|
||||
height: 42,
|
||||
landingRadius: 22,
|
||||
perfectRadius: 12,
|
||||
scoreValue: 2,
|
||||
},
|
||||
],
|
||||
finishIndex: 1,
|
||||
cameraPreset: 'default',
|
||||
scoring: {
|
||||
chargeToDistanceRatio: 1.2,
|
||||
maxChargeMs: 1800,
|
||||
hitBonus: 20,
|
||||
perfectBonus: 50,
|
||||
},
|
||||
};
|
||||
const characterAsset = overrides.characterAsset ?? {
|
||||
assetId: 'jump-hop-character-1',
|
||||
imageSrc: 'data:image/png;base64,character',
|
||||
imageObjectKey: 'jump-hop/character.png',
|
||||
assetObjectId: 'asset-jump-hop-character',
|
||||
generationProvider: 'vector-engine-gpt-image-2',
|
||||
prompt: '纸片小兔',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
};
|
||||
const tileAtlasAsset = overrides.tileAtlasAsset ?? {
|
||||
assetId: 'jump-hop-tiles-1',
|
||||
imageSrc: 'data:image/png;base64,tiles',
|
||||
imageObjectKey: 'jump-hop/tiles.png',
|
||||
assetObjectId: 'asset-jump-hop-tiles',
|
||||
generationProvider: 'vector-engine-gpt-image-2',
|
||||
prompt: '柔软云朵平台',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
};
|
||||
const tileAssets = overrides.tileAssets ?? [
|
||||
{
|
||||
tileType: 'start' as const,
|
||||
imageSrc: 'data:image/png;base64,tile-start',
|
||||
imageObjectKey: 'jump-hop/tile-start.png',
|
||||
assetObjectId: 'asset-jump-hop-tile-start',
|
||||
sourceAtlasCell: 'A1',
|
||||
visualWidth: 128,
|
||||
visualHeight: 96,
|
||||
topSurfaceRadius: 24,
|
||||
landingRadius: 28,
|
||||
},
|
||||
{
|
||||
tileType: 'finish' as const,
|
||||
imageSrc: 'data:image/png;base64,tile-finish',
|
||||
imageObjectKey: 'jump-hop/tile-finish.png',
|
||||
assetObjectId: 'asset-jump-hop-tile-finish',
|
||||
sourceAtlasCell: 'A2',
|
||||
visualWidth: 128,
|
||||
visualHeight: 96,
|
||||
topSurfaceRadius: 24,
|
||||
landingRadius: 28,
|
||||
},
|
||||
];
|
||||
const draft = overrides.draft ?? {
|
||||
templateId: 'jump-hop',
|
||||
templateName: '跳一跳',
|
||||
profileId,
|
||||
workTitle: '云端跳台',
|
||||
workDescription: '一路跳到星星。',
|
||||
themeTags: ['云朵', '星空'],
|
||||
difficulty: 'standard' as const,
|
||||
stylePreset: 'paper-toy' as const,
|
||||
characterPrompt: '纸片小兔',
|
||||
tilePrompt: '柔软云朵平台',
|
||||
endMoodPrompt: '星光门',
|
||||
characterAsset,
|
||||
tileAtlasAsset,
|
||||
tileAssets,
|
||||
path,
|
||||
coverComposite: 'data:image/png;base64,cover',
|
||||
generationStatus: 'ready' as const,
|
||||
};
|
||||
|
||||
return {
|
||||
summary: {
|
||||
runtimeKind: 'jump-hop',
|
||||
workId: 'jump-hop-work-1',
|
||||
profileId,
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'jump-hop-session-1',
|
||||
workTitle: draft.workTitle,
|
||||
workDescription: draft.workDescription,
|
||||
themeTags: draft.themeTags,
|
||||
difficulty: draft.difficulty,
|
||||
stylePreset: draft.stylePreset,
|
||||
coverImageSrc: draft.coverComposite,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-30T10:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: true,
|
||||
generationStatus: 'ready',
|
||||
...overrides.summary,
|
||||
},
|
||||
draft,
|
||||
path,
|
||||
characterAsset,
|
||||
tileAtlasAsset,
|
||||
tileAssets,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMockBarkBattleWork(
|
||||
overrides: Partial<BarkBattleWorkSummary> = {},
|
||||
): BarkBattleWorkSummary {
|
||||
@@ -2520,6 +2674,18 @@ beforeEach(() => {
|
||||
vi.mocked(listVisualNovelWorks).mockResolvedValue({ works: [] });
|
||||
vi.mocked(listLocalBabyObjectMatchDrafts).mockResolvedValue([]);
|
||||
vi.mocked(deleteLocalBabyObjectMatchDraft).mockResolvedValue([]);
|
||||
vi.mocked(jumpHopClient.listGallery).mockResolvedValue({
|
||||
items: [],
|
||||
hasMore: false,
|
||||
nextCursor: null,
|
||||
});
|
||||
vi.mocked(jumpHopClient.listWorks).mockResolvedValue({ items: [] });
|
||||
vi.mocked(jumpHopClient.getSession).mockRejectedValue(
|
||||
new Error('未找到跳一跳会话'),
|
||||
);
|
||||
vi.mocked(jumpHopClient.getWorkDetail).mockRejectedValue(
|
||||
new Error('未找到跳一跳作品'),
|
||||
);
|
||||
vi.mocked(saveBabyObjectMatchDraft).mockImplementation(async (payload) => ({
|
||||
draft: payload.draft,
|
||||
}));
|
||||
@@ -7215,6 +7381,58 @@ test('refreshing RPG agent path restores stored agent workspace pointer', async
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('direct jump hop result route shows recovery panel when no draft pointer exists', async () => {
|
||||
window.history.replaceState(null, '', '/creation/jump-hop/result');
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
expect(await screen.findByText('跳一跳草稿未恢复')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '返回创作' })).toBeTruthy();
|
||||
expect(jumpHopClient.getWorkDetail).not.toHaveBeenCalled();
|
||||
expect(jumpHopClient.getSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('direct jump hop result route restores work detail by profile id', async () => {
|
||||
const work = buildMockJumpHopWork({
|
||||
summary: {
|
||||
runtimeKind: 'jump-hop',
|
||||
workId: 'jump-hop-work-restore-1',
|
||||
profileId: 'jump-hop-profile-restore-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: null,
|
||||
workTitle: '恢复后的云端跳台',
|
||||
workDescription: '从 profileId 回读完整跳一跳结果。',
|
||||
themeTags: ['云朵'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'paper-toy',
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-30T10:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: true,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
});
|
||||
vi.mocked(jumpHopClient.getWorkDetail).mockResolvedValueOnce({
|
||||
item: work,
|
||||
} satisfies JumpHopWorkDetailResponse);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/creation/jump-hop/result?profileId=jump-hop-profile-restore-1',
|
||||
);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
expect(await screen.findByText('恢复后的云端跳台')).toBeTruthy();
|
||||
expect(screen.queryByText('跳一跳草稿未恢复')).toBeNull();
|
||||
expect(jumpHopClient.getWorkDetail).toHaveBeenCalledWith(
|
||||
'jump-hop-profile-restore-1',
|
||||
);
|
||||
expect(jumpHopClient.getSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('embedded puzzle form maps raw bearer token errors to user-facing auth copy', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
|
||||
@@ -6104,6 +6104,11 @@ export function RpgEntryHomeView({
|
||||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||||
<section>
|
||||
<SectionHeader title="我的创作" detail="草稿与已发布" />
|
||||
{platformError ? (
|
||||
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
|
||||
{platformError}
|
||||
</div>
|
||||
) : null}
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取你的作品..." />
|
||||
) : myEntries.length > 0 ? (
|
||||
@@ -6140,7 +6145,16 @@ export function RpgEntryHomeView({
|
||||
const createContent: ReactNode =
|
||||
createTabContent ?? fallbackCreateStartContent;
|
||||
|
||||
const savesContent: ReactNode = draftTabContent ?? fallbackDraftContent;
|
||||
const savesContent: ReactNode = (
|
||||
<>
|
||||
{platformError ? (
|
||||
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
|
||||
{platformError}
|
||||
</div>
|
||||
) : null}
|
||||
{draftTabContent ?? fallbackDraftContent}
|
||||
</>
|
||||
);
|
||||
|
||||
const profileContent: ReactNode = (
|
||||
<div className={`${MOBILE_PROFILE_PAGE_STAGE_CLASS} platform-profile-page`}>
|
||||
|
||||
@@ -870,6 +870,10 @@ export function resolvePlatformWorkAuthorDisplayName(
|
||||
const displayName = authorSummary?.displayName?.trim();
|
||||
const publicUserCode = authorSummary?.publicUserCode?.trim();
|
||||
|
||||
if (displayName && publicUserCode) {
|
||||
return `${displayName} · ${publicUserCode}`;
|
||||
}
|
||||
|
||||
return displayName || publicUserCode || entry.authorDisplayName.trim() || '玩家';
|
||||
}
|
||||
|
||||
@@ -1079,4 +1083,4 @@ function buildBarkBattleThemeTags(work: BarkBattleWorkSummary) {
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
}
|
||||
}
|
||||
|
||||
102
src/components/square-hole-result/SquareHoleResultView.test.tsx
Normal file
102
src/components/square-hole-result/SquareHoleResultView.test.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { SquareHoleWorkProfile } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import { SquareHoleResultView } from './SquareHoleResultView';
|
||||
|
||||
vi.mock('../../services/square-hole-works', () => ({
|
||||
publishSquareHoleWork: vi.fn(),
|
||||
regenerateSquareHoleWorkImage: vi.fn(),
|
||||
squareHoleAssetClient: {
|
||||
listHistoryAssets: vi.fn(),
|
||||
},
|
||||
updateSquareHoleWork: vi.fn(),
|
||||
}));
|
||||
|
||||
function createProfile(): SquareHoleWorkProfile {
|
||||
return {
|
||||
profileId: 'profile-1',
|
||||
workId: 'work-1',
|
||||
ownerUserId: 'user-1',
|
||||
gameName: '方洞挑战',
|
||||
themeText: '几何反差',
|
||||
twistRule: '形状要投进对应洞口',
|
||||
summary: '把所有形状投入正确洞口。',
|
||||
tags: ['方洞', '反差'],
|
||||
coverImageSrc: 'data:image/png;base64,cover',
|
||||
backgroundPrompt: '几何场景',
|
||||
backgroundImageSrc: 'data:image/png;base64,background',
|
||||
shapeOptions: [
|
||||
{
|
||||
optionId: 'shape-1',
|
||||
shapeKind: 'square',
|
||||
label: '方块',
|
||||
targetHoleId: 'hole-1',
|
||||
imagePrompt: '方块贴图',
|
||||
imageSrc: 'data:image/png;base64,shape-1',
|
||||
},
|
||||
],
|
||||
holeOptions: [
|
||||
{
|
||||
holeId: 'hole-1',
|
||||
holeKind: 'hole-1',
|
||||
label: '洞口 1',
|
||||
imagePrompt: '洞口 1 贴图',
|
||||
imageSrc: 'data:image/png;base64,hole-1',
|
||||
},
|
||||
],
|
||||
shapeCount: 6,
|
||||
difficulty: 2,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-30T10:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: true,
|
||||
};
|
||||
}
|
||||
|
||||
test('square hole result view exposes test run and publish actions', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onBack = vi.fn();
|
||||
const onStartTestRun = vi.fn();
|
||||
const onPublished = vi.fn();
|
||||
const { publishSquareHoleWork, updateSquareHoleWork } = await import(
|
||||
'../../services/square-hole-works'
|
||||
);
|
||||
const mockUpdateSquareHoleWork = vi.mocked(updateSquareHoleWork);
|
||||
const mockPublishSquareHoleWork = vi.mocked(publishSquareHoleWork);
|
||||
const nextProfile = createProfile();
|
||||
mockUpdateSquareHoleWork.mockResolvedValue({
|
||||
item: nextProfile,
|
||||
} as Awaited<ReturnType<typeof updateSquareHoleWork>>);
|
||||
mockPublishSquareHoleWork.mockResolvedValue({
|
||||
item: nextProfile,
|
||||
} as Awaited<ReturnType<typeof publishSquareHoleWork>>);
|
||||
|
||||
render(
|
||||
<SquareHoleResultView
|
||||
profile={createProfile()}
|
||||
onBack={onBack}
|
||||
onStartTestRun={onStartTestRun}
|
||||
onPublished={onPublished}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '试玩' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '发布' })).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '试玩' }));
|
||||
await user.click(screen.getByRole('button', { name: '发布' }));
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onStartTestRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(onPublished).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
73
src/components/unified-creation/UnifiedCreationPage.test.tsx
Normal file
73
src/components/unified-creation/UnifiedCreationPage.test.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { UnifiedCreationPage } from './UnifiedCreationPage';
|
||||
import { getUnifiedCreationSpec } from './unifiedCreationSpecs';
|
||||
|
||||
describe('UnifiedCreationPage', () => {
|
||||
test('按后端字段 spec 暴露统一创作页字段契约', () => {
|
||||
const onBack = vi.fn();
|
||||
|
||||
render(
|
||||
<UnifiedCreationPage
|
||||
spec={getUnifiedCreationSpec('wooden-fish')}
|
||||
onBack={onBack}
|
||||
>
|
||||
<div>敲木鱼工作台</div>
|
||||
</UnifiedCreationPage>,
|
||||
);
|
||||
|
||||
const root = screen
|
||||
.getByText('敲木鱼工作台')
|
||||
.closest('.unified-creation-page');
|
||||
expect(root?.getAttribute('data-play-id')).toBe('wooden-fish');
|
||||
expect(root?.getAttribute('data-field-kinds')).toBe(
|
||||
'text,image,audio,text',
|
||||
);
|
||||
expect(root?.getAttribute('data-workspace-stage')).toBe(
|
||||
'wooden-fish-workspace',
|
||||
);
|
||||
expect(root?.getAttribute('data-generation-stage')).toBe(
|
||||
'wooden-fish-generating',
|
||||
);
|
||||
expect(root?.getAttribute('data-result-stage')).toBe('wooden-fish-result');
|
||||
|
||||
const fields = screen.getAllByTestId('unified-creation-field');
|
||||
expect(fields.map((field) => field.getAttribute('data-field-id'))).toEqual([
|
||||
'hitObjectPrompt',
|
||||
'hitObjectReferenceImage',
|
||||
'hitSoundAsset',
|
||||
'floatingWords',
|
||||
]);
|
||||
expect(fields[2]?.getAttribute('data-field-kind')).toBe('audio');
|
||||
expect(fields[3]?.getAttribute('data-required')).toBe('true');
|
||||
expect(screen.getByTestId('unified-creation-play-badge').textContent).toBe(
|
||||
'wooden-fish',
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '返回' }));
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByLabelText('创作字段')).toBeNull();
|
||||
expect(screen.queryByTestId('unified-creation-visible-field')).toBeNull();
|
||||
expect(
|
||||
screen
|
||||
.getByText('敲木鱼工作台')
|
||||
.closest('.unified-creation-page__content')
|
||||
?.className,
|
||||
).toContain('flex');
|
||||
expect(
|
||||
screen
|
||||
.getByText('敲木鱼工作台')
|
||||
.closest('.unified-creation-page__content')
|
||||
?.className,
|
||||
).toContain('min-h-max');
|
||||
expect(
|
||||
screen
|
||||
.getByText('敲木鱼工作台')
|
||||
.closest('.unified-creation-page__content')
|
||||
?.className,
|
||||
).not.toContain('min-h-0');
|
||||
expect(root?.className).toContain('overflow-y-auto');
|
||||
});
|
||||
});
|
||||
81
src/components/unified-creation/UnifiedCreationPage.tsx
Normal file
81
src/components/unified-creation/UnifiedCreationPage.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type { UnifiedCreationSpec } from './unifiedCreationSpecs';
|
||||
|
||||
type UnifiedCreationPageProps = {
|
||||
spec: UnifiedCreationSpec;
|
||||
children: ReactNode;
|
||||
onBack?: () => void;
|
||||
isBackDisabled?: boolean;
|
||||
};
|
||||
|
||||
export function UnifiedCreationPage({
|
||||
spec,
|
||||
children,
|
||||
onBack,
|
||||
isBackDisabled = false,
|
||||
}: UnifiedCreationPageProps) {
|
||||
return (
|
||||
<div
|
||||
className="unified-creation-page platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-y-auto overflow-x-hidden px-3 pt-2 sm:px-4 sm:pt-3"
|
||||
data-play-id={spec.playId}
|
||||
data-field-kinds={spec.fields.map((field) => field.kind).join(',')}
|
||||
data-workspace-stage={spec.workspaceStage}
|
||||
data-generation-stage={spec.generationStage}
|
||||
data-result-stage={spec.resultStage}
|
||||
>
|
||||
<header className="unified-creation-page__header shrink-0 pb-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
{onBack ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={isBackDisabled}
|
||||
className={`platform-button platform-button--ghost min-h-0 shrink-0 px-3 py-1.5 text-[11px] ${
|
||||
isBackDisabled ? 'cursor-not-allowed opacity-45' : ''
|
||||
}`}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回
|
||||
</button>
|
||||
) : (
|
||||
<span aria-hidden="true" className="min-h-8 w-0 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className="unified-creation-page__play-badge shrink-0 rounded-full border border-[var(--platform-subpanel-border)] bg-white/80 px-3 py-1 text-[11px] font-black text-[var(--platform-text-soft)]"
|
||||
data-testid="unified-creation-play-badge"
|
||||
>
|
||||
{spec.playId}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h1 className="m-0 min-w-0 truncate text-[1.35rem] font-black leading-tight tracking-normal text-[var(--platform-text-strong)] sm:text-[1.65rem]">
|
||||
{spec.title}
|
||||
</h1>
|
||||
</div>
|
||||
</header>
|
||||
<div className="sr-only" data-testid="unified-creation-spec">
|
||||
<h1>{spec.title}</h1>
|
||||
<ul>
|
||||
{spec.fields.map((field) => (
|
||||
<li
|
||||
key={field.id}
|
||||
data-testid="unified-creation-field"
|
||||
data-field-id={field.id}
|
||||
data-field-kind={field.kind}
|
||||
data-required={field.required ? 'true' : 'false'}
|
||||
>
|
||||
{field.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="unified-creation-page__content flex min-h-max flex-col pb-3 sm:pb-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UnifiedCreationPage;
|
||||
@@ -0,0 +1,184 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { UnifiedCreationWorkspace } from './UnifiedCreationWorkspace';
|
||||
import { getUnifiedCreationSpec } from './unifiedCreationSpecs';
|
||||
|
||||
vi.mock('./workspaces/PuzzleCreationWorkspace', () => ({
|
||||
PuzzleCreationWorkspace: ({
|
||||
unifiedChrome,
|
||||
}: {
|
||||
unifiedChrome?: boolean;
|
||||
}) => (
|
||||
<div
|
||||
className="puzzle-agent-workspace"
|
||||
data-unified-chrome={unifiedChrome ? 'true' : 'false'}
|
||||
>
|
||||
拼图工作台
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./workspaces/Match3DCreationWorkspace', () => ({
|
||||
Match3DCreationWorkspace: ({
|
||||
unifiedChrome,
|
||||
}: {
|
||||
unifiedChrome?: boolean;
|
||||
}) => (
|
||||
<div
|
||||
className="match3d-agent-workspace"
|
||||
data-unified-chrome={unifiedChrome ? 'true' : 'false'}
|
||||
>
|
||||
抓大鹅工作台
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./workspaces/JumpHopCreationWorkspace', () => ({
|
||||
JumpHopCreationWorkspace: ({
|
||||
unifiedChrome,
|
||||
}: {
|
||||
unifiedChrome?: boolean;
|
||||
}) => (
|
||||
<div
|
||||
className="jump-hop-workspace"
|
||||
data-unified-chrome={unifiedChrome ? 'true' : 'false'}
|
||||
>
|
||||
跳一跳工作台
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./workspaces/WoodenFishCreationWorkspace', () => ({
|
||||
WoodenFishCreationWorkspace: ({
|
||||
unifiedChrome,
|
||||
}: {
|
||||
unifiedChrome?: boolean;
|
||||
}) => (
|
||||
<div
|
||||
className="wooden-fish-workspace"
|
||||
data-unified-chrome={unifiedChrome ? 'true' : 'false'}
|
||||
>
|
||||
敲木鱼工作台
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('UnifiedCreationWorkspace', () => {
|
||||
test('统一承载四条首批创作入口', () => {
|
||||
const onBack = vi.fn();
|
||||
|
||||
const puzzleResult = render(
|
||||
<UnifiedCreationWorkspace
|
||||
playId="puzzle"
|
||||
spec={getUnifiedCreationSpec('puzzle')}
|
||||
session={null}
|
||||
isBusy={false}
|
||||
error={null}
|
||||
onBack={onBack}
|
||||
onSubmitMessage={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onCreateFromForm={() => {}}
|
||||
onAutoSaveForm={() => {}}
|
||||
initialFormPayload={null}
|
||||
/>,
|
||||
);
|
||||
const puzzleWorkspace = screen
|
||||
.getByText('拼图工作台')
|
||||
.closest('[data-unified-chrome]');
|
||||
const puzzlePage = screen
|
||||
.getByText('拼图工作台')
|
||||
.closest('.unified-creation-page');
|
||||
expect(puzzleWorkspace?.getAttribute('data-unified-chrome')).toBe('true');
|
||||
expect(puzzlePage?.getAttribute('data-play-id')).toBe('puzzle');
|
||||
expect(screen.getByRole('button', { name: '返回' })).toBeTruthy();
|
||||
puzzleResult.unmount();
|
||||
|
||||
const match3dResult = render(
|
||||
<UnifiedCreationWorkspace
|
||||
playId="match3d"
|
||||
spec={getUnifiedCreationSpec('match3d')}
|
||||
session={null}
|
||||
isBusy={false}
|
||||
error={null}
|
||||
onBack={onBack}
|
||||
onExecuteAction={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onCreateFromForm={() => {}}
|
||||
initialFormPayload={null}
|
||||
/>,
|
||||
);
|
||||
const match3dWorkspace = screen
|
||||
.getByText('抓大鹅工作台')
|
||||
.closest('[data-unified-chrome]');
|
||||
const match3dPage = screen
|
||||
.getByText('抓大鹅工作台')
|
||||
.closest('.unified-creation-page');
|
||||
expect(match3dWorkspace?.getAttribute('data-unified-chrome')).toBe('true');
|
||||
expect(match3dPage?.getAttribute('data-play-id')).toBe('match3d');
|
||||
match3dResult.unmount();
|
||||
|
||||
const jumpHopResult = render(
|
||||
<UnifiedCreationWorkspace
|
||||
playId="jump-hop"
|
||||
spec={getUnifiedCreationSpec('jump-hop')}
|
||||
isBusy={false}
|
||||
error={null}
|
||||
onBack={onBack}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
);
|
||||
const jumpHopWorkspace = screen
|
||||
.getByText('跳一跳工作台')
|
||||
.closest('[data-unified-chrome]');
|
||||
const jumpHopPage = screen
|
||||
.getByText('跳一跳工作台')
|
||||
.closest('.unified-creation-page');
|
||||
expect(jumpHopWorkspace?.getAttribute('data-unified-chrome')).toBe('true');
|
||||
expect(jumpHopPage?.getAttribute('data-play-id')).toBe('jump-hop');
|
||||
jumpHopResult.unmount();
|
||||
|
||||
const woodenFishResult = render(
|
||||
<UnifiedCreationWorkspace
|
||||
playId="wooden-fish"
|
||||
spec={getUnifiedCreationSpec('wooden-fish')}
|
||||
isBusy={false}
|
||||
error={null}
|
||||
onBack={onBack}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
);
|
||||
const woodenFishWorkspace = screen
|
||||
.getByText('敲木鱼工作台')
|
||||
.closest('[data-unified-chrome]');
|
||||
const woodenFishPage = screen
|
||||
.getByText('敲木鱼工作台')
|
||||
.closest('.unified-creation-page');
|
||||
expect(woodenFishWorkspace?.getAttribute('data-unified-chrome')).toBe(
|
||||
'true',
|
||||
);
|
||||
expect(woodenFishPage?.getAttribute('data-play-id')).toBe('wooden-fish');
|
||||
woodenFishResult.unmount();
|
||||
});
|
||||
|
||||
test('统一页头返回按钮会透传给当前玩法壳层', async () => {
|
||||
const onBack = vi.fn();
|
||||
|
||||
render(
|
||||
<UnifiedCreationWorkspace
|
||||
playId="jump-hop"
|
||||
spec={getUnifiedCreationSpec('jump-hop')}
|
||||
isBusy={false}
|
||||
error={null}
|
||||
onBack={onBack}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
screen.getByRole('button', { name: '返回' }).click();
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryAllByRole('button', { name: '返回' })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
125
src/components/unified-creation/UnifiedCreationWorkspace.tsx
Normal file
125
src/components/unified-creation/UnifiedCreationWorkspace.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
import { UnifiedCreationPage } from './UnifiedCreationPage';
|
||||
import type { UnifiedCreationSpec } from './unifiedCreationSpecs';
|
||||
import { Match3DCreationWorkspace } from './workspaces/Match3DCreationWorkspace';
|
||||
import { PuzzleCreationWorkspace } from './workspaces/PuzzleCreationWorkspace';
|
||||
import { JumpHopCreationWorkspace } from './workspaces/JumpHopCreationWorkspace';
|
||||
import { WoodenFishCreationWorkspace } from './workspaces/WoodenFishCreationWorkspace';
|
||||
|
||||
type PuzzleCreationWorkspaceProps = ComponentProps<
|
||||
typeof PuzzleCreationWorkspace
|
||||
>;
|
||||
type Match3DCreationWorkspaceProps = ComponentProps<
|
||||
typeof Match3DCreationWorkspace
|
||||
>;
|
||||
type JumpHopCreationWorkspaceProps = ComponentProps<
|
||||
typeof JumpHopCreationWorkspace
|
||||
>;
|
||||
type WoodenFishCreationWorkspaceProps = ComponentProps<
|
||||
typeof WoodenFishCreationWorkspace
|
||||
>;
|
||||
|
||||
type UnifiedCreationWorkspaceBaseProps = {
|
||||
spec: UnifiedCreationSpec;
|
||||
};
|
||||
|
||||
type PuzzleUnifiedCreationWorkspaceProps =
|
||||
UnifiedCreationWorkspaceBaseProps & {
|
||||
playId: 'puzzle';
|
||||
} & PuzzleCreationWorkspaceProps;
|
||||
|
||||
type Match3DUnifiedCreationWorkspaceProps =
|
||||
UnifiedCreationWorkspaceBaseProps & {
|
||||
playId: 'match3d';
|
||||
} & Match3DCreationWorkspaceProps;
|
||||
|
||||
type JumpHopUnifiedCreationWorkspaceProps =
|
||||
UnifiedCreationWorkspaceBaseProps & {
|
||||
playId: 'jump-hop';
|
||||
} & JumpHopCreationWorkspaceProps;
|
||||
|
||||
type WoodenFishUnifiedCreationWorkspaceProps =
|
||||
UnifiedCreationWorkspaceBaseProps & {
|
||||
playId: 'wooden-fish';
|
||||
} & WoodenFishCreationWorkspaceProps;
|
||||
|
||||
export type UnifiedCreationWorkspaceProps =
|
||||
| PuzzleUnifiedCreationWorkspaceProps
|
||||
| Match3DUnifiedCreationWorkspaceProps
|
||||
| JumpHopUnifiedCreationWorkspaceProps
|
||||
| WoodenFishUnifiedCreationWorkspaceProps;
|
||||
|
||||
export function UnifiedCreationWorkspace(props: UnifiedCreationWorkspaceProps) {
|
||||
switch (props.playId) {
|
||||
case 'puzzle':
|
||||
return (
|
||||
<UnifiedCreationPage spec={props.spec} onBack={props.onBack}>
|
||||
<PuzzleCreationWorkspace
|
||||
session={props.session}
|
||||
isBusy={props.isBusy}
|
||||
error={props.error}
|
||||
onBack={props.onBack}
|
||||
onSubmitMessage={props.onSubmitMessage}
|
||||
onExecuteAction={props.onExecuteAction}
|
||||
onCreateFromForm={props.onCreateFromForm}
|
||||
onAutoSaveForm={props.onAutoSaveForm}
|
||||
initialFormPayload={props.initialFormPayload}
|
||||
showBackButton={false}
|
||||
title={null}
|
||||
unifiedChrome
|
||||
/>
|
||||
</UnifiedCreationPage>
|
||||
);
|
||||
case 'match3d':
|
||||
return (
|
||||
<UnifiedCreationPage spec={props.spec} onBack={props.onBack}>
|
||||
<Match3DCreationWorkspace
|
||||
session={props.session}
|
||||
isBusy={props.isBusy}
|
||||
error={props.error}
|
||||
onBack={props.onBack}
|
||||
onExecuteAction={props.onExecuteAction}
|
||||
onCreateFromForm={props.onCreateFromForm}
|
||||
onSubmitMessage={props.onSubmitMessage}
|
||||
initialFormPayload={props.initialFormPayload}
|
||||
showBackButton={false}
|
||||
title={null}
|
||||
unifiedChrome
|
||||
/>
|
||||
</UnifiedCreationPage>
|
||||
);
|
||||
case 'jump-hop':
|
||||
return (
|
||||
<UnifiedCreationPage spec={props.spec} onBack={props.onBack}>
|
||||
<JumpHopCreationWorkspace
|
||||
isBusy={props.isBusy}
|
||||
error={props.error}
|
||||
onBack={props.onBack}
|
||||
onSubmitted={props.onSubmitted}
|
||||
showBackButton={false}
|
||||
unifiedChrome
|
||||
/>
|
||||
</UnifiedCreationPage>
|
||||
);
|
||||
case 'wooden-fish':
|
||||
return (
|
||||
<UnifiedCreationPage spec={props.spec} onBack={props.onBack}>
|
||||
<WoodenFishCreationWorkspace
|
||||
isBusy={props.isBusy}
|
||||
error={props.error}
|
||||
onBack={props.onBack}
|
||||
onSubmitted={props.onSubmitted}
|
||||
showBackButton={false}
|
||||
unifiedChrome
|
||||
/>
|
||||
</UnifiedCreationPage>
|
||||
);
|
||||
default: {
|
||||
const exhaustiveCheck: never = props;
|
||||
return exhaustiveCheck;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default UnifiedCreationWorkspace;
|
||||
@@ -0,0 +1,71 @@
|
||||
/* @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 { UnifiedGenerationPage } from './UnifiedGenerationPage';
|
||||
|
||||
function createProgress(): CustomWorldGenerationProgress {
|
||||
return {
|
||||
phaseId: 'puzzle-cover-image',
|
||||
phaseLabel: '生成拼图首图',
|
||||
phaseDetail: '正在生成图片。',
|
||||
batchLabel: '生成拼图首图',
|
||||
overallProgress: 36,
|
||||
completedWeight: 36,
|
||||
totalWeight: 100,
|
||||
elapsedMs: 12_000,
|
||||
estimatedRemainingMs: 30_000,
|
||||
activeStepIndex: 0,
|
||||
steps: [
|
||||
{
|
||||
id: 'puzzle-cover-image',
|
||||
label: '生成拼图首图',
|
||||
detail: '正在生成图片。',
|
||||
completed: 0.36,
|
||||
total: 1,
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('UnifiedGenerationPage', () => {
|
||||
test('按玩法下发统一生成页文案并透传进度', () => {
|
||||
render(
|
||||
<UnifiedGenerationPage
|
||||
playId="puzzle"
|
||||
settingText="一只发光的纸船"
|
||||
progress={createProgress()}
|
||||
isGenerating
|
||||
onBack={() => {}}
|
||||
onEditSetting={() => {}}
|
||||
onRetry={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(document.body.textContent).toContain('拼图图片生成进度');
|
||||
expect(screen.getByText('图片生成中')).toBeTruthy();
|
||||
expect(screen.getAllByText('生成拼图首图').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('当前拼图信息')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('jump-hop generation page uses unified copy', () => {
|
||||
render(
|
||||
<UnifiedGenerationPage
|
||||
playId="jump-hop"
|
||||
settingText="云端糖果塔"
|
||||
progress={createProgress()}
|
||||
isGenerating
|
||||
onBack={() => {}}
|
||||
onEditSetting={() => {}}
|
||||
onRetry={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(document.body.textContent).toContain('跳一跳草稿生成进度');
|
||||
expect(screen.getByText('素材生成中')).toBeTruthy();
|
||||
expect(screen.getByText('当前跳一跳信息')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
59
src/components/unified-creation/UnifiedGenerationPage.tsx
Normal file
59
src/components/unified-creation/UnifiedGenerationPage.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { CustomWorldGenerationProgress } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { CustomWorldStructuredAnchorEntry } from '../../services/customWorldAgentGenerationProgress';
|
||||
import { CustomWorldGenerationView } from '../CustomWorldGenerationView';
|
||||
import type { UnifiedCreationPlayId } from './unifiedCreationSpecs';
|
||||
import { getUnifiedGenerationCopy } from './unifiedGenerationCopy';
|
||||
|
||||
type UnifiedGenerationPageProps = {
|
||||
playId: UnifiedCreationPlayId;
|
||||
settingText: string;
|
||||
anchorEntries?: CustomWorldStructuredAnchorEntry[];
|
||||
progress: CustomWorldGenerationProgress | null;
|
||||
isGenerating: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onEditSetting: () => void;
|
||||
onRetry: () => void;
|
||||
hideBatchModule?: boolean;
|
||||
};
|
||||
|
||||
export function UnifiedGenerationPage({
|
||||
playId,
|
||||
settingText,
|
||||
anchorEntries = [],
|
||||
progress,
|
||||
isGenerating,
|
||||
error = null,
|
||||
onBack,
|
||||
onEditSetting,
|
||||
onRetry,
|
||||
hideBatchModule = false,
|
||||
}: UnifiedGenerationPageProps) {
|
||||
const copy = getUnifiedGenerationCopy(playId);
|
||||
|
||||
return (
|
||||
<CustomWorldGenerationView
|
||||
settingText={settingText}
|
||||
anchorEntries={anchorEntries}
|
||||
progress={progress}
|
||||
isGenerating={isGenerating}
|
||||
error={error}
|
||||
onBack={onBack}
|
||||
onEditSetting={onEditSetting}
|
||||
onRetry={onRetry}
|
||||
onInterrupt={undefined}
|
||||
backLabel="返回创作中心"
|
||||
settingActionLabel={null}
|
||||
retryLabel={copy.retryLabel}
|
||||
settingTitle={copy.settingTitle}
|
||||
settingDescription={null}
|
||||
progressTitle={copy.progressTitle}
|
||||
activeBadgeLabel={copy.activeBadgeLabel}
|
||||
pausedBadgeLabel="素材生成已暂停"
|
||||
idleBadgeLabel="等待返回工作区"
|
||||
hideBatchModule={hideBatchModule}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default UnifiedGenerationPage;
|
||||
@@ -5,13 +5,13 @@ import { createPortal } from 'react-dom';
|
||||
import {
|
||||
puzzleAssetClient,
|
||||
type PuzzleHistoryAsset,
|
||||
} from '../../services/puzzle-works/puzzleAssetClient';
|
||||
} from '../../../services/puzzle-works/puzzleAssetClient';
|
||||
import {
|
||||
formatPuzzleHistoryAssetCreatedAt,
|
||||
getPuzzleHistoryAssetDisplayName,
|
||||
} from '../../services/puzzle-works/puzzleHistoryAsset';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
} from '../../../services/puzzle-works/puzzleHistoryAsset';
|
||||
import { useAuthUi } from '../../auth/AuthUiContext';
|
||||
import { ResolvedAssetImage } from '../../ResolvedAssetImage';
|
||||
|
||||
type PuzzleHistoryAssetPickerDialogProps = {
|
||||
isBusy: boolean;
|
||||
47
src/components/unified-creation/unifiedCreationSpecs.test.ts
Normal file
47
src/components/unified-creation/unifiedCreationSpecs.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
getUnifiedCreationSpec,
|
||||
listUnifiedCreationSpecs,
|
||||
} from './unifiedCreationSpecs';
|
||||
|
||||
describe('unified creation specs', () => {
|
||||
test('统一壳当前覆盖拼图、抓大鹅、跳一跳和敲木鱼', () => {
|
||||
expect(listUnifiedCreationSpecs().map((spec) => spec.playId).sort()).toEqual(
|
||||
['jump-hop', 'match3d', 'puzzle', 'wooden-fish'],
|
||||
);
|
||||
});
|
||||
|
||||
test('字段模型只包含首期公共能力', () => {
|
||||
const fieldKinds = new Set(
|
||||
listUnifiedCreationSpecs().flatMap((spec) =>
|
||||
spec.fields.map((field) => field.kind),
|
||||
),
|
||||
);
|
||||
|
||||
expect([...fieldKinds].sort()).toEqual(['audio', 'image', 'select', 'text']);
|
||||
});
|
||||
|
||||
test('四条链路都映射到统一创作、生成、结果阶段', () => {
|
||||
expect(getUnifiedCreationSpec('puzzle')).toMatchObject({
|
||||
workspaceStage: 'puzzle-agent-workspace',
|
||||
generationStage: 'puzzle-generating',
|
||||
resultStage: 'puzzle-result',
|
||||
});
|
||||
expect(getUnifiedCreationSpec('match3d')).toMatchObject({
|
||||
workspaceStage: 'match3d-agent-workspace',
|
||||
generationStage: 'match3d-generating',
|
||||
resultStage: 'match3d-result',
|
||||
});
|
||||
expect(getUnifiedCreationSpec('jump-hop')).toMatchObject({
|
||||
workspaceStage: 'jump-hop-workspace',
|
||||
generationStage: 'jump-hop-generating',
|
||||
resultStage: 'jump-hop-result',
|
||||
});
|
||||
expect(getUnifiedCreationSpec('wooden-fish')).toMatchObject({
|
||||
workspaceStage: 'wooden-fish-workspace',
|
||||
generationStage: 'wooden-fish-generating',
|
||||
resultStage: 'wooden-fish-result',
|
||||
});
|
||||
});
|
||||
});
|
||||
164
src/components/unified-creation/unifiedCreationSpecs.ts
Normal file
164
src/components/unified-creation/unifiedCreationSpecs.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import type {
|
||||
CreationEntryTypeConfig,
|
||||
UnifiedCreationSpec,
|
||||
} from '../../services/creationEntryConfigService';
|
||||
|
||||
export type UnifiedCreationPlayId = UnifiedCreationSpec['playId'];
|
||||
export type { UnifiedCreationSpec };
|
||||
|
||||
const FALLBACK_UNIFIED_CREATION_SPECS: Record<
|
||||
UnifiedCreationPlayId,
|
||||
UnifiedCreationSpec
|
||||
> = {
|
||||
puzzle: {
|
||||
playId: 'puzzle',
|
||||
title: '想做个什么玩法?',
|
||||
workspaceStage: 'puzzle-agent-workspace',
|
||||
generationStage: 'puzzle-generating',
|
||||
resultStage: 'puzzle-result',
|
||||
fields: [
|
||||
{
|
||||
id: 'pictureDescription',
|
||||
kind: 'text',
|
||||
label: '画面描述',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'referenceImage',
|
||||
kind: 'image',
|
||||
label: '拼图画面',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'promptReferenceImages',
|
||||
kind: 'image',
|
||||
label: '参考图',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
match3d: {
|
||||
playId: 'match3d',
|
||||
title: '想做个什么玩法?',
|
||||
workspaceStage: 'match3d-agent-workspace',
|
||||
generationStage: 'match3d-generating',
|
||||
resultStage: 'match3d-result',
|
||||
fields: [
|
||||
{
|
||||
id: 'themeText',
|
||||
kind: 'text',
|
||||
label: '题材',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'difficulty',
|
||||
kind: 'select',
|
||||
label: '难度',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
'jump-hop': {
|
||||
playId: 'jump-hop',
|
||||
title: '想做个什么玩法?',
|
||||
workspaceStage: 'jump-hop-workspace',
|
||||
generationStage: 'jump-hop-generating',
|
||||
resultStage: 'jump-hop-result',
|
||||
fields: [
|
||||
{
|
||||
id: 'workTitle',
|
||||
kind: 'text',
|
||||
label: '作品标题',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'workDescription',
|
||||
kind: 'text',
|
||||
label: '作品简介',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'themeTags',
|
||||
kind: 'text',
|
||||
label: '主题标签',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'difficulty',
|
||||
kind: 'select',
|
||||
label: '难度',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'stylePreset',
|
||||
kind: 'select',
|
||||
label: '风格',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'characterPrompt',
|
||||
kind: 'text',
|
||||
label: '角色提示词',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'tilePrompt',
|
||||
kind: 'text',
|
||||
label: '地块提示词',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'endMoodPrompt',
|
||||
kind: 'text',
|
||||
label: '终点氛围',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
'wooden-fish': {
|
||||
playId: 'wooden-fish',
|
||||
title: '想做个什么玩法?',
|
||||
workspaceStage: 'wooden-fish-workspace',
|
||||
generationStage: 'wooden-fish-generating',
|
||||
resultStage: 'wooden-fish-result',
|
||||
fields: [
|
||||
{
|
||||
id: 'hitObjectPrompt',
|
||||
kind: 'text',
|
||||
label: '敲什么',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'hitObjectReferenceImage',
|
||||
kind: 'image',
|
||||
label: '参考图',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'hitSoundAsset',
|
||||
kind: 'audio',
|
||||
label: '敲击音效',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'floatingWords',
|
||||
kind: 'text',
|
||||
label: '功德有什么',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function getUnifiedCreationSpec(
|
||||
playId: UnifiedCreationPlayId,
|
||||
configType?: CreationEntryTypeConfig | null,
|
||||
) {
|
||||
return (
|
||||
configType?.unifiedCreationSpec ?? FALLBACK_UNIFIED_CREATION_SPECS[playId]
|
||||
);
|
||||
}
|
||||
|
||||
export function listUnifiedCreationSpecs() {
|
||||
return Object.values(FALLBACK_UNIFIED_CREATION_SPECS);
|
||||
}
|
||||
40
src/components/unified-creation/unifiedGenerationCopy.ts
Normal file
40
src/components/unified-creation/unifiedGenerationCopy.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { UnifiedCreationPlayId } from './unifiedCreationSpecs';
|
||||
|
||||
const UNIFIED_GENERATION_COPY = {
|
||||
puzzle: {
|
||||
retryLabel: '重新生成图片',
|
||||
settingTitle: '当前拼图信息',
|
||||
progressTitle: '拼图图片生成进度',
|
||||
activeBadgeLabel: '图片生成中',
|
||||
},
|
||||
match3d: {
|
||||
retryLabel: '重新生成草稿',
|
||||
settingTitle: '当前抓大鹅信息',
|
||||
progressTitle: '抓大鹅草稿生成进度',
|
||||
activeBadgeLabel: '素材生成中',
|
||||
},
|
||||
'jump-hop': {
|
||||
retryLabel: '重新生成草稿',
|
||||
settingTitle: '当前跳一跳信息',
|
||||
progressTitle: '跳一跳草稿生成进度',
|
||||
activeBadgeLabel: '素材生成中',
|
||||
},
|
||||
'wooden-fish': {
|
||||
retryLabel: '重新生成草稿',
|
||||
settingTitle: '当前敲木鱼信息',
|
||||
progressTitle: '敲木鱼草稿生成进度',
|
||||
activeBadgeLabel: '素材生成中',
|
||||
},
|
||||
} as const satisfies Record<
|
||||
UnifiedCreationPlayId,
|
||||
{
|
||||
retryLabel: string;
|
||||
settingTitle: string;
|
||||
progressTitle: string;
|
||||
activeBadgeLabel: string;
|
||||
}
|
||||
>;
|
||||
|
||||
export function getUnifiedGenerationCopy(playId: UnifiedCreationPlayId) {
|
||||
return UNIFIED_GENERATION_COPY[playId];
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { JumpHopSessionResponse } from '../../../../packages/shared/src/contracts/jumpHop';
|
||||
import { jumpHopClient } from '../../../services/jump-hop/jumpHopClient';
|
||||
import { JumpHopCreationWorkspace } from './JumpHopCreationWorkspace';
|
||||
|
||||
vi.mock('../../../services/jump-hop/jumpHopClient', () => ({
|
||||
jumpHopClient: {
|
||||
createSession: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockCreateSession = vi.mocked(jumpHopClient.createSession);
|
||||
|
||||
beforeEach(() => {
|
||||
mockCreateSession.mockReset();
|
||||
});
|
||||
|
||||
function createSessionResponse(): JumpHopSessionResponse {
|
||||
return {
|
||||
session: {
|
||||
sessionId: 'jump-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'draft',
|
||||
draft: null,
|
||||
createdAt: '2026-05-30T10:00:00.000Z',
|
||||
updatedAt: '2026-05-30T10:00:00.000Z',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('jump hop workspace submits structured payload after required fields are filled', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmitted = vi.fn();
|
||||
const sessionResponse = createSessionResponse();
|
||||
mockCreateSession.mockResolvedValue(sessionResponse);
|
||||
|
||||
render(
|
||||
<JumpHopCreationWorkspace onBack={() => {}} onSubmitted={onSubmitted} />,
|
||||
);
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: '生成' });
|
||||
expect(submitButton).toHaveProperty('disabled', true);
|
||||
|
||||
await user.type(screen.getByLabelText('作品标题'), '云朵跳台');
|
||||
await user.type(screen.getByLabelText('作品简介'), '在云端一路跳到星星。');
|
||||
await user.type(screen.getByLabelText('主题标签'), '云朵 星星');
|
||||
await user.selectOptions(screen.getByLabelText('难度'), 'standard');
|
||||
await user.selectOptions(screen.getByLabelText('风格'), 'paper-toy');
|
||||
await user.type(screen.getByLabelText('角色提示词'), '一只纸片小兔');
|
||||
await user.type(screen.getByLabelText('地块提示词'), '柔软云朵平台');
|
||||
await user.type(screen.getByLabelText('终点氛围'), '星光门');
|
||||
|
||||
expect(submitButton).toHaveProperty('disabled', false);
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateSession).toHaveBeenCalledWith({
|
||||
templateId: 'jump-hop',
|
||||
workTitle: '云朵跳台',
|
||||
workDescription: '在云端一路跳到星星。',
|
||||
themeTags: ['云朵', '星星'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'paper-toy',
|
||||
characterPrompt: '一只纸片小兔',
|
||||
tilePrompt: '柔软云朵平台',
|
||||
endMoodPrompt: '星光门',
|
||||
});
|
||||
});
|
||||
expect(onSubmitted).toHaveBeenCalledWith(
|
||||
sessionResponse,
|
||||
expect.objectContaining({
|
||||
templateId: 'jump-hop',
|
||||
workTitle: '云朵跳台',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('jump hop workspace calls back when return button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onBack = vi.fn();
|
||||
|
||||
render(<JumpHopCreationWorkspace onBack={onBack} onSubmitted={() => {}} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('jump hop workspace can defer visible chrome to the unified creation page', () => {
|
||||
const { container } = render(
|
||||
<JumpHopCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
showBackButton={false}
|
||||
unifiedChrome
|
||||
/>,
|
||||
);
|
||||
|
||||
const workspace = container.querySelector('.jump-hop-workspace');
|
||||
expect(workspace?.getAttribute('data-unified-chrome')).toBe('true');
|
||||
expect(workspace?.className).toContain('max-w-none');
|
||||
expect(workspace?.className).not.toContain('platform-remap-surface');
|
||||
expect(screen.queryByRole('button', { name: '返回' })).toBeNull();
|
||||
});
|
||||
@@ -6,10 +6,10 @@ import type {
|
||||
JumpHopSessionResponse,
|
||||
JumpHopStylePreset,
|
||||
JumpHopWorkspaceCreateRequest,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import { jumpHopClient } from '../../services/jump-hop/jumpHopClient';
|
||||
} from '../../../../packages/shared/src/contracts/jumpHop';
|
||||
import { jumpHopClient } from '../../../services/jump-hop/jumpHopClient';
|
||||
|
||||
type JumpHopWorkspaceProps = {
|
||||
type JumpHopCreationWorkspaceProps = {
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
@@ -17,6 +17,8 @@ type JumpHopWorkspaceProps = {
|
||||
result: JumpHopSessionResponse,
|
||||
payload: JumpHopWorkspaceCreateRequest,
|
||||
) => void;
|
||||
showBackButton?: boolean;
|
||||
unifiedChrome?: boolean;
|
||||
};
|
||||
|
||||
type JumpHopWorkspaceFormState = {
|
||||
@@ -41,12 +43,14 @@ const DEFAULT_FORM_STATE: JumpHopWorkspaceFormState = {
|
||||
endMoodPrompt: '',
|
||||
};
|
||||
|
||||
export function JumpHopWorkspace({
|
||||
export function JumpHopCreationWorkspace({
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onSubmitted,
|
||||
}: JumpHopWorkspaceProps) {
|
||||
showBackButton = true,
|
||||
unifiedChrome = false,
|
||||
}: JumpHopCreationWorkspaceProps) {
|
||||
const [formState, setFormState] = useState(DEFAULT_FORM_STATE);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -99,17 +103,26 @@ export function JumpHopWorkspace({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
unifiedChrome
|
||||
? 'jump-hop-workspace mx-auto flex min-h-0 w-full max-w-none flex-col overflow-visible'
|
||||
: 'jump-hop-workspace platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4'
|
||||
}
|
||||
data-unified-chrome={unifiedChrome ? 'true' : 'false'}
|
||||
>
|
||||
{showBackButton ? (
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block sm:col-span-2">
|
||||
@@ -275,4 +288,4 @@ export function JumpHopWorkspace({
|
||||
);
|
||||
}
|
||||
|
||||
export default JumpHopWorkspace;
|
||||
export default JumpHopCreationWorkspace;
|
||||
@@ -3,8 +3,8 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import { Match3DAgentWorkspace } from './Match3DAgentWorkspace';
|
||||
import type { Match3DAgentSessionSnapshot } from '../../../../packages/shared/src/contracts/match3dAgent';
|
||||
import { Match3DCreationWorkspace } from './Match3DCreationWorkspace';
|
||||
|
||||
const baseSession: Match3DAgentSessionSnapshot = {
|
||||
sessionId: 'match3d-session-1',
|
||||
@@ -70,7 +70,7 @@ test('match3d workspace submits derived entry form payload instead of agent chat
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<Match3DAgentWorkspace
|
||||
<Match3DCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
@@ -112,11 +112,33 @@ test('match3d workspace submits derived entry form payload instead of agent chat
|
||||
expect(onExecuteAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('match3d workspace can defer visible chrome to the unified creation page', () => {
|
||||
const { container } = render(
|
||||
<Match3DCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onCreateFromForm={() => {}}
|
||||
title={null}
|
||||
unifiedChrome
|
||||
/>,
|
||||
);
|
||||
|
||||
const workspace = container.querySelector('.match3d-agent-workspace');
|
||||
expect(workspace?.getAttribute('data-unified-chrome')).toBe('true');
|
||||
expect(workspace?.className).toContain('max-w-none');
|
||||
expect(workspace?.className).not.toContain('h-full');
|
||||
expect(workspace?.className).not.toContain('overflow-hidden');
|
||||
expect(workspace?.className).not.toContain('platform-remap-surface');
|
||||
expect(screen.queryByRole('heading', { name: '想做个什么玩法?' })).toBeNull();
|
||||
expect(screen.getByLabelText('想做一个什么题材的抓大鹅?')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('match3d workspace omits legacy asset style fields from entry payload', () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
|
||||
render(
|
||||
<Match3DAgentWorkspace
|
||||
<Match3DCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
@@ -140,7 +162,7 @@ test('match3d workspace keeps click sound generation disabled from entry form',
|
||||
const onCreateFromForm = vi.fn();
|
||||
|
||||
render(
|
||||
<Match3DAgentWorkspace
|
||||
<Match3DCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
@@ -166,7 +188,7 @@ test('match3d workspace falls back to compile action when restored from the lega
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<Match3DAgentWorkspace
|
||||
<Match3DCreationWorkspace
|
||||
session={baseSession}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
@@ -6,9 +6,9 @@ import type {
|
||||
ExecuteMatch3DActionRequest,
|
||||
Match3DAgentSessionSnapshot,
|
||||
SendMatch3DMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
} from '../../../../packages/shared/src/contracts/match3dAgent';
|
||||
|
||||
type Match3DAgentWorkspaceProps = {
|
||||
type Match3DCreationWorkspaceProps = {
|
||||
session: Match3DAgentSessionSnapshot | null;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
@@ -19,6 +19,7 @@ type Match3DAgentWorkspaceProps = {
|
||||
initialFormPayload?: CreateMatch3DSessionRequest | null;
|
||||
showBackButton?: boolean;
|
||||
title?: string | null;
|
||||
unifiedChrome?: boolean;
|
||||
};
|
||||
|
||||
type Match3DFormState = {
|
||||
@@ -103,10 +104,9 @@ function resolveInitialFormState(
|
||||
}
|
||||
|
||||
/**
|
||||
* 抓大鹅创作入口已从固定 Agent 追问改成表单式。
|
||||
* 组件名保留为 Match3DAgentWorkspace,兼容现有路由、草稿恢复和父层分流。
|
||||
* 统一创作目录内的抓大鹅工作台实现。
|
||||
*/
|
||||
export function Match3DAgentWorkspace({
|
||||
export function Match3DCreationWorkspace({
|
||||
session,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
@@ -116,7 +116,8 @@ export function Match3DAgentWorkspace({
|
||||
initialFormPayload = null,
|
||||
showBackButton = true,
|
||||
title = '想做个什么玩法?',
|
||||
}: Match3DAgentWorkspaceProps) {
|
||||
unifiedChrome = false,
|
||||
}: Match3DCreationWorkspaceProps) {
|
||||
const [formState, setFormState] = useState<Match3DFormState>(() =>
|
||||
resolveInitialFormState(session, initialFormPayload),
|
||||
);
|
||||
@@ -183,7 +184,14 @@ export function Match3DAgentWorkspace({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden">
|
||||
<div
|
||||
className={
|
||||
unifiedChrome
|
||||
? 'match3d-agent-workspace mx-auto flex min-h-0 w-full max-w-none flex-col overflow-visible'
|
||||
: 'match3d-agent-workspace platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden'
|
||||
}
|
||||
data-unified-chrome={unifiedChrome ? 'true' : 'false'}
|
||||
>
|
||||
{showBackButton ? (
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 sm:mb-4">
|
||||
<button
|
||||
@@ -197,8 +205,14 @@ export function Match3DAgentWorkspace({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden pr-0">
|
||||
{title ? (
|
||||
<div
|
||||
className={
|
||||
unifiedChrome
|
||||
? 'flex flex-col pr-0'
|
||||
: 'flex min-h-0 flex-1 flex-col overflow-hidden pr-0'
|
||||
}
|
||||
>
|
||||
{title && !unifiedChrome ? (
|
||||
<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">
|
||||
@@ -211,9 +225,19 @@ export function Match3DAgentWorkspace({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<section
|
||||
className={
|
||||
unifiedChrome
|
||||
? 'flex flex-col'
|
||||
: 'flex min-h-0 flex-1 flex-col overflow-hidden'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] gap-3 lg:grid-cols-[minmax(0,1.1fr)_minmax(16rem,0.9fr)] lg:grid-rows-1 ${isBusy ? 'opacity-55' : ''}`}
|
||||
className={`grid gap-3 lg:grid-cols-[minmax(0,1.1fr)_minmax(16rem,0.9fr)] ${
|
||||
unifiedChrome
|
||||
? ''
|
||||
: 'min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] lg:grid-rows-1'
|
||||
} ${isBusy ? 'opacity-55' : ''}`}
|
||||
>
|
||||
<label className="block min-h-0">
|
||||
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
|
||||
@@ -343,4 +367,4 @@ export function Match3DAgentWorkspace({
|
||||
);
|
||||
}
|
||||
|
||||
export default Match3DAgentWorkspace;
|
||||
export default Match3DCreationWorkspace;
|
||||
@@ -10,11 +10,11 @@ import {
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient';
|
||||
import { PuzzleAgentWorkspace } from './PuzzleAgentWorkspace';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { puzzleAssetClient } from '../../../services/puzzle-works/puzzleAssetClient';
|
||||
import { PuzzleCreationWorkspace } from './PuzzleCreationWorkspace';
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
vi.mock('../../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: ({
|
||||
src,
|
||||
alt,
|
||||
@@ -26,7 +26,7 @@ vi.mock('../ResolvedAssetImage', () => ({
|
||||
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({
|
||||
vi.mock('../../../services/puzzle-works/puzzleAssetClient', () => ({
|
||||
puzzleAssetClient: {
|
||||
listHistoryAssets: vi.fn(),
|
||||
uploadReferenceImage: vi.fn(),
|
||||
@@ -177,7 +177,7 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
<PuzzleCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
@@ -215,10 +215,36 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
|
||||
expect(screen.queryByText('旧会话消息不再渲染为聊天入口。')).toBeNull();
|
||||
});
|
||||
|
||||
test('puzzle workspace can defer visible chrome to the unified creation page', () => {
|
||||
const { container } = render(
|
||||
<PuzzleCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onCreateFromForm={() => {}}
|
||||
unifiedChrome
|
||||
title={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const workspace = container.querySelector('.puzzle-agent-workspace');
|
||||
const imagePanel = container.querySelector('.creative-image-input-panel');
|
||||
expect(workspace?.getAttribute('data-unified-chrome')).toBe('true');
|
||||
expect(workspace?.className).toContain('max-w-none');
|
||||
expect(workspace?.className).not.toContain('h-full');
|
||||
expect(workspace?.className).not.toContain('overflow-hidden');
|
||||
expect(workspace?.className).not.toContain('platform-remap-surface');
|
||||
expect(imagePanel?.className).toContain('flex-none');
|
||||
expect(imagePanel?.className).not.toContain('flex-1');
|
||||
expect(screen.queryByRole('heading', { name: '想做个什么玩法?' })).toBeNull();
|
||||
expect(screen.getByLabelText('画面描述')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('puzzle workspace keeps the reference image upload as a primary panel', () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
const { container } = render(
|
||||
<PuzzleAgentWorkspace
|
||||
<PuzzleCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
@@ -297,7 +323,7 @@ test('puzzle workspace selects a history image from the upload card', async () =
|
||||
]);
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
<PuzzleCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
@@ -354,7 +380,7 @@ test('puzzle workspace selects a history image from the upload card', async () =
|
||||
test('puzzle upload card stays light in light theme', () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
const { container } = render(
|
||||
<PuzzleAgentWorkspace
|
||||
<PuzzleCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
@@ -384,7 +410,7 @@ test('puzzle workspace falls back to compile action for restored sessions', () =
|
||||
const onCreateFromForm = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
<PuzzleCreationWorkspace
|
||||
session={baseSession}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
@@ -415,7 +441,7 @@ test('puzzle workspace switches image mode without exposing model names', () =>
|
||||
const onCreateFromForm = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
<PuzzleCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
@@ -479,7 +505,7 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
|
||||
};
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
<PuzzleCreationWorkspace
|
||||
session={formDraftSession}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
@@ -518,7 +544,7 @@ test('puzzle workspace hides prompt and cost when AI redraw is off', async () =>
|
||||
stubReferenceImageUpload(uploadedDataUrl);
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
<PuzzleCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
@@ -577,7 +603,7 @@ test('puzzle workspace submits history image when AI redraw is off', async () =>
|
||||
]);
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
<PuzzleCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
@@ -622,7 +648,7 @@ test('puzzle workspace submits uploaded reference image as data URL when AI redr
|
||||
stubReferenceImageUpload(uploadedDataUrl);
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
<PuzzleCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
@@ -705,7 +731,7 @@ test('puzzle workspace uploads prompt references as asset object ids', async ()
|
||||
});
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
<PuzzleCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
@@ -780,7 +806,7 @@ test('puzzle workspace uploads prompt reference images from the description box'
|
||||
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
<PuzzleCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
@@ -847,7 +873,7 @@ test('puzzle workspace shows AI redraw switch only after upload', async () => {
|
||||
stubReferenceImageUpload(uploadedDataUrl);
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
<PuzzleCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
@@ -884,7 +910,7 @@ test('puzzle workspace confirms before removing uploaded image', async () => {
|
||||
stubReferenceImageUpload(uploadedDataUrl);
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
<PuzzleCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
@@ -926,7 +952,7 @@ test('puzzle workspace opens crop tool for non-square uploads', async () => {
|
||||
const drawImage = stubCanvas(croppedDataUrl);
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
<PuzzleCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
@@ -6,38 +6,38 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
|
||||
import type { PuzzleAgentActionRequest } from '../../../../packages/shared/src/contracts/puzzleAgentActions';
|
||||
import type {
|
||||
CreatePuzzleAgentSessionRequest,
|
||||
PuzzleAgentSessionSnapshot,
|
||||
SendPuzzleAgentMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works/puzzleHistoryAsset';
|
||||
} from '../../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { getPuzzleHistoryAssetReferenceLabel } from '../../../services/puzzle-works/puzzleHistoryAsset';
|
||||
import {
|
||||
cropPuzzleReferenceImageDataUrl,
|
||||
isPuzzleReferenceImageSquare,
|
||||
readPuzzleReferenceImageAsDataUrl,
|
||||
readPuzzleReferenceImageForUpload,
|
||||
} from '../../services/puzzleReferenceImage';
|
||||
} from '../../../services/puzzleReferenceImage';
|
||||
import {
|
||||
CreativeImageInputPanel,
|
||||
type CreativeImageInputReferenceImage,
|
||||
} from '../common/CreativeImageInputPanel';
|
||||
} from '../../common/CreativeImageInputPanel';
|
||||
import {
|
||||
buildCenteredSquareImageCropRect,
|
||||
clampSquareImageCropRect,
|
||||
SquareImageCropModal,
|
||||
type SquareImageCropRect,
|
||||
} from '../common/SquareImageCropModal';
|
||||
import PuzzleHistoryAssetPickerDialog from './PuzzleHistoryAssetPickerDialog';
|
||||
} from '../../common/SquareImageCropModal';
|
||||
import PuzzleHistoryAssetPickerDialog from '../shared/PuzzleHistoryAssetPickerDialog';
|
||||
import {
|
||||
normalizePuzzleImageModel,
|
||||
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
type PuzzleImageModelId,
|
||||
} from './puzzleImageModelOptions';
|
||||
import { PuzzleImageModelPicker } from './PuzzleImageModelPicker';
|
||||
} from '../shared/puzzleImageModelOptions';
|
||||
import { PuzzleImageModelPicker } from '../shared/PuzzleImageModelPicker';
|
||||
|
||||
type PuzzleAgentWorkspaceProps = {
|
||||
type PuzzleCreationWorkspaceProps = {
|
||||
session: PuzzleAgentSessionSnapshot | null;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
@@ -49,6 +49,7 @@ type PuzzleAgentWorkspaceProps = {
|
||||
initialFormPayload?: CreatePuzzleAgentSessionRequest | null;
|
||||
showBackButton?: boolean;
|
||||
title?: string | null;
|
||||
unifiedChrome?: boolean;
|
||||
};
|
||||
|
||||
type PuzzleFormState = {
|
||||
@@ -233,9 +234,9 @@ function addPuzzlePromptReferenceImage(
|
||||
|
||||
/**
|
||||
* 拼图创作入口已从 Agent 对话改为填表式。
|
||||
* 组件名保留为 PuzzleAgentWorkspace 以兼容现有路由与草稿恢复入口。
|
||||
* 统一创作目录内的拼图工作台实现。
|
||||
*/
|
||||
export function PuzzleAgentWorkspace({
|
||||
export function PuzzleCreationWorkspace({
|
||||
session,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
@@ -246,7 +247,8 @@ export function PuzzleAgentWorkspace({
|
||||
initialFormPayload = null,
|
||||
showBackButton = true,
|
||||
title = '想做个什么玩法?',
|
||||
}: PuzzleAgentWorkspaceProps) {
|
||||
unifiedChrome = false,
|
||||
}: PuzzleCreationWorkspaceProps) {
|
||||
const [formState, setFormState] = useState<PuzzleFormState>(() =>
|
||||
resolveInitialFormState(session, initialFormPayload),
|
||||
);
|
||||
@@ -592,7 +594,14 @@ export function PuzzleAgentWorkspace({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface puzzle-agent-workspace mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden">
|
||||
<div
|
||||
className={
|
||||
unifiedChrome
|
||||
? 'puzzle-agent-workspace mx-auto flex min-h-0 w-full max-w-none flex-col overflow-visible'
|
||||
: 'puzzle-agent-workspace platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden'
|
||||
}
|
||||
data-unified-chrome={unifiedChrome ? 'true' : 'false'}
|
||||
>
|
||||
{showBackButton ? (
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 sm:mb-4">
|
||||
<button
|
||||
@@ -609,7 +618,7 @@ export function PuzzleAgentWorkspace({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{title ? (
|
||||
{title && !unifiedChrome ? (
|
||||
<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">
|
||||
@@ -623,6 +632,8 @@ export function PuzzleAgentWorkspace({
|
||||
) : null}
|
||||
|
||||
<CreativeImageInputPanel
|
||||
className={unifiedChrome ? 'min-h-0 flex-none' : ''}
|
||||
fillHeight={!unifiedChrome}
|
||||
disabled={isBusy}
|
||||
isSubmitting={isBusy}
|
||||
uploadedImageSrc={formState.referenceImageSrc}
|
||||
@@ -772,4 +783,4 @@ export function PuzzleAgentWorkspace({
|
||||
);
|
||||
}
|
||||
|
||||
export default PuzzleAgentWorkspace;
|
||||
export default PuzzleCreationWorkspace;
|
||||
@@ -3,11 +3,11 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { woodenFishClient } from '../../services/wooden-fish/woodenFishClient';
|
||||
import { WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT } from '../../services/wooden-fish/woodenFishDefaults';
|
||||
import { WoodenFishWorkspace } from './WoodenFishWorkspace';
|
||||
import { woodenFishClient } from '../../../services/wooden-fish/woodenFishClient';
|
||||
import { WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT } from '../../../services/wooden-fish/woodenFishDefaults';
|
||||
import { WoodenFishCreationWorkspace } from './WoodenFishCreationWorkspace';
|
||||
|
||||
vi.mock('../../services/wooden-fish/woodenFishClient', () => ({
|
||||
vi.mock('../../../services/wooden-fish/woodenFishClient', () => ({
|
||||
woodenFishClient: {
|
||||
createSession: vi.fn(),
|
||||
},
|
||||
@@ -31,7 +31,7 @@ test('敲什么输入栏初始置空但提交时仍使用默认生成提示词',
|
||||
const onSubmitted = vi.fn();
|
||||
|
||||
render(
|
||||
<WoodenFishWorkspace
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={onSubmitted}
|
||||
/>,
|
||||
@@ -48,7 +48,7 @@ test('敲什么输入栏初始置空但提交时仍使用默认生成提示词',
|
||||
|
||||
test('功德有什么默认只显示基础词条,不显示运行态 +1 后缀', () => {
|
||||
render(
|
||||
<WoodenFishWorkspace
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
@@ -72,7 +72,7 @@ test('功德有什么默认只显示基础词条,不显示运行态 +1 后缀'
|
||||
|
||||
test('功德有什么支持通过加号新增词条并移除新增格子', () => {
|
||||
render(
|
||||
<WoodenFishWorkspace
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
@@ -92,7 +92,7 @@ test('功德有什么支持通过加号新增词条并移除新增格子', () =>
|
||||
|
||||
test('敲击音效临时关闭提示词生成入口,仅保留上传和录音', () => {
|
||||
render(
|
||||
<WoodenFishWorkspace
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
@@ -107,9 +107,30 @@ test('敲击音效临时关闭提示词生成入口,仅保留上传和录音',
|
||||
expect(within(section as HTMLElement).getByText('录音')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('敲击音效和功德词条不放进独立滚动窗', () => {
|
||||
const { container } = render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const audioSection = screen.getByText('敲击音效').closest('section');
|
||||
const floatingWordsSection = screen.getByText('功德有什么').closest('section');
|
||||
const sidePanel = audioSection?.parentElement;
|
||||
|
||||
expect(audioSection).not.toBeNull();
|
||||
expect(floatingWordsSection).not.toBeNull();
|
||||
expect(sidePanel).toBe(floatingWordsSection?.parentElement);
|
||||
expect(sidePanel?.className).not.toContain('overflow-y-auto');
|
||||
expect(sidePanel?.className).not.toContain('min-h-0');
|
||||
expect(container.querySelector('.overflow-y-auto')).toBeNull();
|
||||
expect(container.firstElementChild?.className).not.toContain('h-full');
|
||||
});
|
||||
|
||||
test('工作台只保留一个生成按钮', () => {
|
||||
render(
|
||||
<WoodenFishWorkspace
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
@@ -117,3 +138,35 @@ test('工作台只保留一个生成按钮', () => {
|
||||
|
||||
expect(screen.getAllByRole('button', { name: '生成' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('敲木鱼工作台可以交给统一创作页承载可见外壳', () => {
|
||||
const { container } = render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
showBackButton={false}
|
||||
unifiedChrome
|
||||
/>,
|
||||
);
|
||||
|
||||
const workspace = container.querySelector('.wooden-fish-workspace');
|
||||
expect(workspace?.getAttribute('data-unified-chrome')).toBe('true');
|
||||
expect(workspace?.className).toContain('max-w-none');
|
||||
expect(workspace?.className).not.toContain('platform-remap-surface');
|
||||
expect(screen.queryByRole('button', { name: '返回' })).toBeNull();
|
||||
});
|
||||
|
||||
test('敲木鱼工作台在统一壳下不强行填满左侧图片面板高度', () => {
|
||||
const { container } = render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
showBackButton={false}
|
||||
unifiedChrome
|
||||
/>,
|
||||
);
|
||||
|
||||
const imagePanel = container.querySelector('.creative-image-input-panel');
|
||||
expect(imagePanel?.className).toContain('flex-none');
|
||||
expect(imagePanel?.className).not.toContain('flex-1');
|
||||
});
|
||||
@@ -1,29 +1,27 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Mic,
|
||||
Pause,
|
||||
Plus,
|
||||
Send,
|
||||
X,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type {
|
||||
WoodenFishAudioAsset,
|
||||
WoodenFishSessionResponse,
|
||||
WoodenFishWorkspaceCreateRequest,
|
||||
} from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
import { woodenFishClient } from '../../services/wooden-fish/woodenFishClient';
|
||||
} from '../../../../packages/shared/src/contracts/woodenFish';
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../../services/puzzleReferenceImage';
|
||||
import { woodenFishClient } from '../../../services/wooden-fish/woodenFishClient';
|
||||
import {
|
||||
WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
|
||||
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
|
||||
} from '../../services/wooden-fish/woodenFishDefaults';
|
||||
import { CreativeImageInputPanel } from '../common/CreativeImageInputPanel';
|
||||
} from '../../../services/wooden-fish/woodenFishDefaults';
|
||||
import { CreativeAudioInputPanel } from '../../common/CreativeAudioInputPanel';
|
||||
import { CreativeImageInputPanel } from '../../common/CreativeImageInputPanel';
|
||||
|
||||
type WoodenFishWorkspaceProps = {
|
||||
type WoodenFishCreationWorkspaceProps = {
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
@@ -31,6 +29,8 @@ type WoodenFishWorkspaceProps = {
|
||||
result: WoodenFishSessionResponse,
|
||||
payload: WoodenFishWorkspaceCreateRequest,
|
||||
) => void;
|
||||
showBackButton?: boolean;
|
||||
unifiedChrome?: boolean;
|
||||
};
|
||||
|
||||
type WoodenFishWorkspaceFormState = {
|
||||
@@ -68,188 +68,14 @@ function normalizeFloatingWords(words: string[]) {
|
||||
return normalized.length > 0 ? normalized : [...DEFAULT_FLOATING_WORDS];
|
||||
}
|
||||
|
||||
function readAudioFileAsAsset(file: File, source: 'uploaded' | 'recorded') {
|
||||
return new Promise<WoodenFishAudioAsset>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(new Error('音频读取失败,请重试。'));
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result !== 'string') {
|
||||
reject(new Error('音频读取失败,请重试。'));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
assetId: `local-${source}-${Date.now()}`,
|
||||
audioSrc: reader.result,
|
||||
audioObjectKey: '',
|
||||
assetObjectId: '',
|
||||
source,
|
||||
prompt: file.name,
|
||||
durationMs: null,
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function WoodenFishAudioInputPanel({
|
||||
disabled,
|
||||
asset,
|
||||
onAssetChange,
|
||||
onError,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
asset: WoodenFishAudioAsset | null;
|
||||
onAssetChange: (asset: WoodenFishAudioAsset | null) => void;
|
||||
onError: (message: string | null) => void;
|
||||
}) {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<BlobPart[]>([]);
|
||||
|
||||
const startRecording = async () => {
|
||||
if (disabled || isRecording) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
typeof navigator === 'undefined' ||
|
||||
!navigator.mediaDevices?.getUserMedia ||
|
||||
typeof MediaRecorder === 'undefined'
|
||||
) {
|
||||
throw new Error('当前浏览器不支持录音。');
|
||||
}
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const recorder = new MediaRecorder(stream);
|
||||
chunksRef.current = [];
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
const blob = new Blob(chunksRef.current, {
|
||||
type: recorder.mimeType || 'audio/webm',
|
||||
});
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
const file = new File([blob], `wooden-fish-hit-${Date.now()}.webm`, {
|
||||
type: blob.type,
|
||||
});
|
||||
void readAudioFileAsAsset(file, 'recorded')
|
||||
.then(onAssetChange)
|
||||
.catch((caughtError) => {
|
||||
onError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: '录音保存失败。',
|
||||
);
|
||||
});
|
||||
};
|
||||
recorderRef.current = recorder;
|
||||
recorder.start();
|
||||
setIsRecording(true);
|
||||
onError(null);
|
||||
} catch (caughtError) {
|
||||
onError(
|
||||
caughtError instanceof Error ? caughtError.message : '录音启动失败。',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
recorderRef.current?.stop();
|
||||
recorderRef.current = null;
|
||||
setIsRecording(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="platform-subpanel rounded-[1.25rem] p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-black text-[var(--platform-text-strong)]">
|
||||
敲击音效
|
||||
</div>
|
||||
{asset ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAssetChange(null)}
|
||||
disabled={disabled}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-xs"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<label
|
||||
className={`platform-button platform-button--secondary min-h-10 cursor-pointer gap-2 px-3 py-2 text-sm ${
|
||||
disabled ? 'pointer-events-none opacity-55' : ''
|
||||
}`}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
上传
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
disabled={disabled}
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0] ?? null;
|
||||
event.currentTarget.value = '';
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
void readAudioFileAsAsset(file, 'uploaded')
|
||||
.then((nextAsset) => {
|
||||
onError(null);
|
||||
onAssetChange(nextAsset);
|
||||
})
|
||||
.catch((caughtError) => {
|
||||
onError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: '音频读取失败。',
|
||||
);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (isRecording) {
|
||||
stopRecording();
|
||||
return;
|
||||
}
|
||||
void startRecording();
|
||||
}}
|
||||
className="platform-button platform-button--ghost min-h-10 gap-2 px-3 py-2 text-sm"
|
||||
>
|
||||
{isRecording ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Mic className="h-4 w-4" />
|
||||
)}
|
||||
{isRecording ? '停止' : '录音'}
|
||||
</button>
|
||||
{asset?.audioSrc ? (
|
||||
<audio controls src={asset.audioSrc} className="h-10 max-w-full" />
|
||||
) : (
|
||||
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
|
||||
{asset ? '音效已选择' : '默认木鱼音'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function WoodenFishWorkspace({
|
||||
export function WoodenFishCreationWorkspace({
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onSubmitted,
|
||||
}: WoodenFishWorkspaceProps) {
|
||||
showBackButton = true,
|
||||
unifiedChrome = false,
|
||||
}: WoodenFishCreationWorkspaceProps) {
|
||||
const [formState, setFormState] = useState(DEFAULT_FORM_STATE);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -333,21 +159,31 @@ export function WoodenFishWorkspace({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
unifiedChrome
|
||||
? 'wooden-fish-workspace mx-auto flex min-h-0 w-full max-w-none flex-col overflow-visible'
|
||||
: 'wooden-fish-workspace platform-remap-surface mx-auto flex w-full max-w-5xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4'
|
||||
}
|
||||
data-unified-chrome={unifiedChrome ? 'true' : 'false'}
|
||||
>
|
||||
{showBackButton ? (
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,1.12fr)_minmax(19rem,0.88fr)]">
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.12fr)_minmax(19rem,0.88fr)]">
|
||||
<div className="flex min-h-[26rem] min-w-0 flex-col">
|
||||
<CreativeImageInputPanel
|
||||
fillHeight={!unifiedChrome}
|
||||
disabled={isBusy || isSubmitting}
|
||||
isSubmitting={isSubmitting}
|
||||
uploadedImageSrc={formState.hitObjectReferenceImageSrc}
|
||||
@@ -409,10 +245,13 @@ export function WoodenFishWorkspace({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-col gap-3 overflow-y-auto pr-0 lg:pr-1">
|
||||
<WoodenFishAudioInputPanel
|
||||
<div className="flex flex-col gap-3 pr-0 lg:pr-1">
|
||||
<CreativeAudioInputPanel<WoodenFishAudioAsset>
|
||||
disabled={isBusy || isSubmitting}
|
||||
title="敲击音效"
|
||||
defaultLabel="默认木鱼音"
|
||||
asset={formState.hitSoundAsset}
|
||||
buildRecordedFileName={() => `wooden-fish-hit-${Date.now()}.webm`}
|
||||
onAssetChange={(asset) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
@@ -426,7 +265,7 @@ export function WoodenFishWorkspace({
|
||||
<div className="mb-3 text-sm font-black text-[var(--platform-text-strong)]">
|
||||
功德有什么
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{formState.floatingWords.map((word, index) => (
|
||||
<div key={index} className="relative">
|
||||
<input
|
||||
@@ -495,4 +334,4 @@ export function WoodenFishWorkspace({
|
||||
);
|
||||
}
|
||||
|
||||
export default WoodenFishWorkspace;
|
||||
export default WoodenFishCreationWorkspace;
|
||||
@@ -118,6 +118,11 @@ test('visual novel generation helpers build process page data', () => {
|
||||
expect(buildVisualNovelEntryGenerationAnchorEntries(payload)).toEqual([
|
||||
{ id: 'visual-novel-idea', label: '一句话', value: '雨夜书店' },
|
||||
{ id: 'visual-novel-style', label: '视觉画风', value: '水彩绘本' },
|
||||
{
|
||||
id: 'visual-novel-target',
|
||||
label: '生成目标',
|
||||
value: '可编辑并可试玩的视觉小说草稿',
|
||||
},
|
||||
]);
|
||||
|
||||
const progress = buildVisualNovelEntryGenerationProgress(
|
||||
@@ -126,7 +131,8 @@ test('visual novel generation helpers build process page data', () => {
|
||||
8_000,
|
||||
);
|
||||
|
||||
expect(progress.phaseId).toBe('generating');
|
||||
expect(progress.phaseId).toBe('visual-novel-world');
|
||||
expect(progress.overallProgress).toBeGreaterThan(0);
|
||||
expect(progress.phaseLabel).toBe('扩展世界观');
|
||||
expect(progress.steps.some((step) => step.status === 'active')).toBe(true);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user