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:
kdletters
2026-06-01 15:22:58 +08:00
86 changed files with 4944 additions and 967 deletions

View File

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

View File

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

View File

@@ -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: '汪汪声浪素材生成进度' })

View 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;

View File

@@ -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();

View File

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

View 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);
});

View 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);
});

View File

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

View File

@@ -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:
'儿童认知学习插画,木质桌面上有积木、彩色形状、动物玩偶和小书本,色彩明快,元素边界清晰,无文字。',
},
];

View File

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

View File

@@ -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();

View File

@@ -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`}>

View File

@@ -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);
}
}

View 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);
});

View 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');
});
});

View 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;

View File

@@ -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);
});
});

View 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;

View File

@@ -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();
});
});

View 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;

View File

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

View 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',
});
});
});

View 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);
}

View 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];
}

View File

@@ -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();
});

View File

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

View File

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

View File

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

View File

@@ -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={() => {}}

View File

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

View File

@@ -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');
});

View File

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

View File

@@ -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);
});