收口统一创作流程一期
This commit is contained in:
@@ -101,6 +101,97 @@ test('creative image input panel handles reference uploads and preview', () => {
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('creative image input panel can opt out of filling the parent height', () => {
|
||||
const { container } = render(
|
||||
<CreativeImageInputPanel
|
||||
fillHeight={false}
|
||||
uploadedImageSrc=""
|
||||
uploadedImageAlt="拼图图片"
|
||||
mainImageInputId="image-upload-input"
|
||||
promptTextareaId="image-prompt-input"
|
||||
prompt="旧街灯牌下的猫。"
|
||||
promptLabel="画面描述"
|
||||
aiRedraw
|
||||
promptReferenceImages={[]}
|
||||
imageModelPicker={null}
|
||||
submitLabel="生成"
|
||||
submitDisabled={false}
|
||||
labels={{
|
||||
imageField: '拼图画面',
|
||||
uploadImage: '上传拼图图片',
|
||||
replaceImage: '更换拼图图片',
|
||||
emptyImageHint: '上传图片/填写画面描述',
|
||||
removeImage: '移除拼图图片',
|
||||
removeImageConfirmTitle: '移除拼图图片?',
|
||||
removeImageConfirmBody: '移除后需要重新上传图片。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '参考图预览',
|
||||
closePromptReferencePreview: '关闭参考图预览',
|
||||
}}
|
||||
onMainImageFileSelect={() => {}}
|
||||
onMainImageRemove={() => {}}
|
||||
onAiRedrawChange={() => {}}
|
||||
onPromptChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const panel = container.querySelector('.creative-image-input-panel');
|
||||
const body = container.querySelector('.creative-image-input-panel__body');
|
||||
const section = container.querySelector('.creative-image-input-panel__section');
|
||||
expect(panel?.className).toContain('flex-none');
|
||||
expect(panel?.className).not.toContain('flex-1');
|
||||
expect(body?.className).toContain('flex-none');
|
||||
expect(body?.className).not.toContain('overflow-y-auto');
|
||||
expect(section?.className).toContain('flex-none');
|
||||
expect(section?.className).not.toContain('overflow-hidden');
|
||||
});
|
||||
|
||||
test('creative image input panel fills the parent height by default', () => {
|
||||
const { container } = render(
|
||||
<CreativeImageInputPanel
|
||||
uploadedImageSrc=""
|
||||
uploadedImageAlt="拼图图片"
|
||||
mainImageInputId="image-upload-input"
|
||||
promptTextareaId="image-prompt-input"
|
||||
prompt="旧街灯牌下的猫。"
|
||||
promptLabel="画面描述"
|
||||
aiRedraw
|
||||
promptReferenceImages={[]}
|
||||
imageModelPicker={null}
|
||||
submitLabel="生成"
|
||||
submitDisabled={false}
|
||||
labels={{
|
||||
imageField: '拼图画面',
|
||||
uploadImage: '上传拼图图片',
|
||||
replaceImage: '更换拼图图片',
|
||||
emptyImageHint: '上传图片/填写画面描述',
|
||||
removeImage: '移除拼图图片',
|
||||
removeImageConfirmTitle: '移除拼图图片?',
|
||||
removeImageConfirmBody: '移除后需要重新上传图片。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '参考图预览',
|
||||
closePromptReferencePreview: '关闭参考图预览',
|
||||
}}
|
||||
onMainImageFileSelect={() => {}}
|
||||
onMainImageRemove={() => {}}
|
||||
onAiRedrawChange={() => {}}
|
||||
onPromptChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const panel = container.querySelector('.creative-image-input-panel');
|
||||
const body = container.querySelector('.creative-image-input-panel__body');
|
||||
const section = container.querySelector('.creative-image-input-panel__section');
|
||||
expect(panel?.className).toContain('flex-1');
|
||||
expect(panel?.className).not.toContain('flex-none');
|
||||
expect(body?.className).toContain('flex-1');
|
||||
expect(body?.className).toContain('overflow-y-auto');
|
||||
expect(section?.className).toContain('flex-1');
|
||||
expect(section?.className).toContain('overflow-hidden');
|
||||
});
|
||||
|
||||
test('creative image input panel confirms before removing uploaded image', () => {
|
||||
const onMainImageRemove = vi.fn();
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export type CreativeImageInputPanelLabels = {
|
||||
|
||||
export type CreativeImageInputPanelProps = {
|
||||
className?: string;
|
||||
fillHeight?: boolean;
|
||||
disabled?: boolean;
|
||||
isSubmitting?: boolean;
|
||||
mainImageMode?: 'edit' | 'preview';
|
||||
@@ -77,6 +78,7 @@ const DEFAULT_PROMPT_REFERENCE_LIMIT = 5;
|
||||
|
||||
export function CreativeImageInputPanel({
|
||||
className = '',
|
||||
fillHeight = true,
|
||||
disabled = false,
|
||||
isSubmitting = false,
|
||||
mainImageMode = 'edit',
|
||||
@@ -143,29 +145,48 @@ export function CreativeImageInputPanel({
|
||||
}
|
||||
}, [previewReferenceImage, promptReferenceImages]);
|
||||
|
||||
const bodyClassName = fillHeight
|
||||
? 'creative-image-input-panel__body puzzle-creation-form-body flex min-h-0 flex-1 flex-col overflow-hidden pr-0 lg:overflow-y-auto lg:pr-1'
|
||||
: 'creative-image-input-panel__body puzzle-creation-form-body flex flex-none flex-col overflow-visible pr-0 lg:pr-1';
|
||||
const sectionClassName = fillHeight
|
||||
? 'creative-image-input-panel__section puzzle-creation-form-section flex min-h-0 flex-1 flex-col overflow-hidden lg:overflow-visible'
|
||||
: 'creative-image-input-panel__section puzzle-creation-form-section flex flex-none flex-col overflow-visible';
|
||||
const gridSizeClassName = fillHeight ? 'min-h-0 flex-1' : 'flex-none';
|
||||
const imageFieldClassName = fillHeight
|
||||
? 'creative-image-input-panel__image-field puzzle-image-field flex min-h-0 min-w-0 flex-1 flex-col'
|
||||
: 'creative-image-input-panel__image-field puzzle-image-field flex min-w-0 flex-none flex-col';
|
||||
const imageFrameClassName = fillHeight
|
||||
? 'creative-image-input-panel__image-frame puzzle-image-card-frame flex min-h-0 flex-1 items-center justify-center'
|
||||
: 'creative-image-input-panel__image-frame puzzle-image-card-frame flex flex-none items-center justify-center';
|
||||
const imageCardClassName = fillHeight
|
||||
? 'creative-image-input-panel__image-card puzzle-image-upload-card relative aspect-square h-full min-h-[14rem] max-h-full max-w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)] transition sm:min-h-[18rem] lg:h-auto lg:w-full'
|
||||
: 'creative-image-input-panel__image-card puzzle-image-upload-card relative aspect-square w-full min-h-[14rem] max-w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)] transition sm:min-h-[18rem]';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`creative-image-input-panel flex min-h-0 flex-1 flex-col ${className}`}
|
||||
className={`creative-image-input-panel flex min-h-0 flex-col ${
|
||||
fillHeight ? 'flex-1' : 'flex-none'
|
||||
} ${className}`}
|
||||
>
|
||||
<div className="creative-image-input-panel__body puzzle-creation-form-body flex min-h-0 flex-1 flex-col overflow-hidden pr-0 lg:overflow-y-auto lg:pr-1">
|
||||
<section className="creative-image-input-panel__section puzzle-creation-form-section flex min-h-0 flex-1 flex-col overflow-hidden lg:overflow-visible">
|
||||
<div className={bodyClassName}>
|
||||
<section className={sectionClassName}>
|
||||
<div
|
||||
className={`creative-image-input-panel__grid puzzle-creation-form-grid min-h-0 flex-1 gap-2.5 sm:gap-4 ${
|
||||
className={`creative-image-input-panel__grid puzzle-creation-form-grid ${gridSizeClassName} gap-2.5 sm:gap-4 ${
|
||||
showPrompt
|
||||
? 'flex flex-col lg:grid lg:grid-cols-[minmax(15rem,0.9fr)_minmax(0,1.15fr)]'
|
||||
: 'flex flex-col lg:grid lg:grid-cols-1'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`creative-image-input-panel__image-field puzzle-image-field flex min-h-0 min-w-0 flex-1 flex-col ${
|
||||
className={`${imageFieldClassName} ${
|
||||
disabled ? 'opacity-55' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="mb-2 shrink-0 text-sm font-black text-[var(--platform-text-strong)]">
|
||||
{labels.imageField}
|
||||
</div>
|
||||
<div className="creative-image-input-panel__image-frame puzzle-image-card-frame flex min-h-0 flex-1 items-center justify-center">
|
||||
<div className="creative-image-input-panel__image-card puzzle-image-upload-card relative aspect-square h-full min-h-[14rem] max-h-full max-w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)] transition sm:min-h-[18rem] lg:h-auto lg:w-full">
|
||||
<div className={imageFrameClassName}>
|
||||
<div className={imageCardClassName}>
|
||||
{canEditMainImage ? (
|
||||
<>
|
||||
<input
|
||||
|
||||
@@ -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;
|
||||
@@ -2856,10 +2856,10 @@ const CustomWorldGenerationView = lazy(async () => {
|
||||
};
|
||||
});
|
||||
|
||||
const UnifiedCreationPage = lazy(async () => {
|
||||
const module = await import('../unified-creation/UnifiedCreationPage');
|
||||
const UnifiedCreationWorkspace = lazy(async () => {
|
||||
const module = await import('../unified-creation/UnifiedCreationWorkspace');
|
||||
return {
|
||||
default: module.UnifiedCreationPage,
|
||||
default: module.UnifiedCreationWorkspace,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -2907,13 +2907,6 @@ const BigFishRuntimeShell = lazy(async () => {
|
||||
};
|
||||
});
|
||||
|
||||
const Match3DAgentWorkspace = lazy(async () => {
|
||||
const module = await import('../match3d-creation/Match3DAgentWorkspace');
|
||||
return {
|
||||
default: module.Match3DAgentWorkspace,
|
||||
};
|
||||
});
|
||||
|
||||
const Match3DResultView = lazy(async () => {
|
||||
const module = await import('../match3d-result/Match3DResultView');
|
||||
return {
|
||||
@@ -2951,13 +2944,6 @@ const SquareHoleRuntimeShell = lazy(async () => {
|
||||
};
|
||||
});
|
||||
|
||||
const JumpHopWorkspace = lazy(async () => {
|
||||
const module = await import('../jump-hop-creation/JumpHopWorkspace');
|
||||
return {
|
||||
default: module.JumpHopWorkspace,
|
||||
};
|
||||
});
|
||||
|
||||
const JumpHopResultView = lazy(async () => {
|
||||
const module = await import('../jump-hop-result/JumpHopResultView');
|
||||
return {
|
||||
@@ -2972,13 +2958,6 @@ const JumpHopRuntimeShell = lazy(async () => {
|
||||
};
|
||||
});
|
||||
|
||||
const WoodenFishWorkspace = lazy(async () => {
|
||||
const module = await import('../wooden-fish-creation/WoodenFishWorkspace');
|
||||
return {
|
||||
default: module.WoodenFishWorkspace,
|
||||
};
|
||||
});
|
||||
|
||||
const WoodenFishResultView = lazy(async () => {
|
||||
const module = await import('../wooden-fish-result/WoodenFishResultView');
|
||||
return {
|
||||
@@ -3032,13 +3011,6 @@ const CustomWorldCreationHub = lazy(async () => {
|
||||
};
|
||||
});
|
||||
|
||||
const PuzzleAgentWorkspace = lazy(async () => {
|
||||
const module = await import('../puzzle-agent/PuzzleAgentWorkspace');
|
||||
return {
|
||||
default: module.PuzzleAgentWorkspace,
|
||||
};
|
||||
});
|
||||
|
||||
const CreativeAgentWorkspace = lazy(async () => {
|
||||
const module = await import('../creative-agent/CreativeAgentWorkspace');
|
||||
return {
|
||||
@@ -15732,37 +15704,33 @@ export function PlatformEntryFlowShellImpl({
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col overflow-y-auto overflow-x-hidden"
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<LazyPanelFallback label="正在加载抓大鹅共创工作区..." />
|
||||
}
|
||||
>
|
||||
<UnifiedCreationPage
|
||||
<UnifiedCreationWorkspace
|
||||
playId="match3d"
|
||||
spec={getUnifiedCreationSpec(
|
||||
'match3d',
|
||||
unifiedCreationConfigById.get('match3d'),
|
||||
)}
|
||||
>
|
||||
<Match3DAgentWorkspace
|
||||
session={match3dSession}
|
||||
isBusy={isStreamingMatch3DReply}
|
||||
error={match3dError}
|
||||
onBack={leaveMatch3DFlow}
|
||||
onExecuteAction={(payload) => {
|
||||
void executeMatch3DAction(payload);
|
||||
}}
|
||||
initialFormPayload={match3dFormDraftPayload}
|
||||
title={null}
|
||||
unifiedChrome
|
||||
onCreateFromForm={(payload) => {
|
||||
runProtectedAction(() => {
|
||||
void createMatch3DDraftFromForm(payload);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</UnifiedCreationPage>
|
||||
session={match3dSession}
|
||||
isBusy={isStreamingMatch3DReply}
|
||||
error={match3dError}
|
||||
onBack={leaveMatch3DFlow}
|
||||
onExecuteAction={(payload) => {
|
||||
void executeMatch3DAction(payload);
|
||||
}}
|
||||
initialFormPayload={match3dFormDraftPayload}
|
||||
onCreateFromForm={(payload) => {
|
||||
runProtectedAction(() => {
|
||||
void createMatch3DDraftFromForm(payload);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -16398,7 +16366,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载跳一跳创作..." />}
|
||||
>
|
||||
<JumpHopWorkspace
|
||||
<UnifiedCreationWorkspace
|
||||
playId="jump-hop"
|
||||
spec={getUnifiedCreationSpec(
|
||||
'jump-hop',
|
||||
unifiedCreationConfigById.get('jump-hop'),
|
||||
)}
|
||||
isBusy={isJumpHopBusy}
|
||||
error={jumpHopError}
|
||||
onBack={leaveJumpHopFlow}
|
||||
@@ -16421,7 +16394,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载跳一跳生成面板..." />}
|
||||
>
|
||||
<CustomWorldGenerationView
|
||||
<UnifiedGenerationPage
|
||||
playId="jump-hop"
|
||||
settingText={
|
||||
jumpHopSession?.draft?.workTitle?.trim() ||
|
||||
jumpHopSession?.draft?.workDescription?.trim() ||
|
||||
@@ -16441,16 +16415,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
setSelectionStage('jump-hop-workspace');
|
||||
}}
|
||||
onRetry={retryJumpHopDraftGeneration}
|
||||
onInterrupt={undefined}
|
||||
backLabel="返回创作中心"
|
||||
settingActionLabel={null}
|
||||
retryLabel="重新生成草稿"
|
||||
settingTitle="当前跳一跳信息"
|
||||
settingDescription={null}
|
||||
progressTitle="跳一跳草稿生成进度"
|
||||
activeBadgeLabel="素材生成中"
|
||||
pausedBadgeLabel="素材生成已暂停"
|
||||
idleBadgeLabel="等待返回工作区"
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
@@ -16540,26 +16504,24 @@ export function PlatformEntryFlowShellImpl({
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col overflow-y-auto overflow-x-hidden"
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载敲木鱼创作..." />}
|
||||
>
|
||||
<UnifiedCreationPage
|
||||
<UnifiedCreationWorkspace
|
||||
playId="wooden-fish"
|
||||
spec={getUnifiedCreationSpec(
|
||||
'wooden-fish',
|
||||
unifiedCreationConfigById.get('wooden-fish'),
|
||||
)}
|
||||
>
|
||||
<WoodenFishWorkspace
|
||||
isBusy={isWoodenFishBusy}
|
||||
error={woodenFishError}
|
||||
onBack={leaveWoodenFishFlow}
|
||||
onSubmitted={(result, payload) => {
|
||||
void compileWoodenFishSession(result, payload);
|
||||
}}
|
||||
/>
|
||||
</UnifiedCreationPage>
|
||||
isBusy={isWoodenFishBusy}
|
||||
error={woodenFishError}
|
||||
onBack={leaveWoodenFishFlow}
|
||||
onSubmitted={(result, payload) => {
|
||||
void compileWoodenFishSession(result, payload);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -16695,39 +16657,35 @@ export function PlatformEntryFlowShellImpl({
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col overflow-y-auto overflow-x-hidden"
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载拼图创作..." />}
|
||||
>
|
||||
<UnifiedCreationPage
|
||||
<UnifiedCreationWorkspace
|
||||
playId="puzzle"
|
||||
spec={getUnifiedCreationSpec(
|
||||
'puzzle',
|
||||
unifiedCreationConfigById.get('puzzle'),
|
||||
)}
|
||||
>
|
||||
<PuzzleAgentWorkspace
|
||||
session={puzzleSession}
|
||||
isBusy={isStreamingPuzzleReply}
|
||||
error={puzzleError}
|
||||
onBack={leavePuzzleFlow}
|
||||
onSubmitMessage={(payload) => {
|
||||
void submitPuzzleMessage(payload);
|
||||
}}
|
||||
onExecuteAction={(payload) => {
|
||||
executePuzzleWorkspaceAction(payload);
|
||||
}}
|
||||
initialFormPayload={puzzleFormDraftPayload}
|
||||
title={null}
|
||||
unifiedChrome
|
||||
onCreateFromForm={(payload) => {
|
||||
void createPuzzleDraftFromForm(payload);
|
||||
}}
|
||||
onAutoSaveForm={(payload) => {
|
||||
void savePuzzleFormDraft(payload);
|
||||
}}
|
||||
/>
|
||||
</UnifiedCreationPage>
|
||||
session={puzzleSession}
|
||||
isBusy={isStreamingPuzzleReply}
|
||||
error={puzzleError}
|
||||
onBack={leavePuzzleFlow}
|
||||
onSubmitMessage={(payload) => {
|
||||
void submitPuzzleMessage(payload);
|
||||
}}
|
||||
onExecuteAction={(payload) => {
|
||||
executePuzzleWorkspaceAction(payload);
|
||||
}}
|
||||
initialFormPayload={puzzleFormDraftPayload}
|
||||
onCreateFromForm={(payload) => {
|
||||
void createPuzzleDraftFromForm(payload);
|
||||
}}
|
||||
onAutoSaveForm={(payload) => {
|
||||
void savePuzzleFormDraft(payload);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
export type PuzzleCreationTemplate = {
|
||||
id: string;
|
||||
title: string;
|
||||
imageSrc: string;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
// 中文注释:模板只服务入口快速填词,正式作品信息仍在结果页补全。
|
||||
export const PUZZLE_CREATION_TEMPLATES: PuzzleCreationTemplate[] = [
|
||||
{
|
||||
id: 'couple-memory',
|
||||
title: '情侣合照拼图',
|
||||
imageSrc: '/puzzle-creation-templates/couple-memory.webp',
|
||||
prompt:
|
||||
'温暖自然光下的一对情侣纪念合照,城市咖啡馆窗边,桌面有花束和两杯热饮,人物神情自然,画面主体清晰,前中后景层次明确。',
|
||||
},
|
||||
{
|
||||
id: 'family-keepsake',
|
||||
title: '家庭纪念拼图',
|
||||
imageSrc: '/puzzle-creation-templates/family-keepsake.webp',
|
||||
prompt:
|
||||
'三代家人在客厅沙发前的家庭纪念合照,柔和午后阳光,孩子抱着生日蛋糕,长辈微笑,画面温暖完整,细节丰富但不杂乱。',
|
||||
},
|
||||
{
|
||||
id: 'friends-party',
|
||||
title: '朋友聚会拼图',
|
||||
imageSrc: '/puzzle-creation-templates/friends-party.webp',
|
||||
prompt:
|
||||
'朋友们在露台夜晚聚会,彩灯、桌上零食和举杯瞬间,人物分布有层次,中央焦点清楚,氛围轻松热闹。',
|
||||
},
|
||||
{
|
||||
id: 'festival-card',
|
||||
title: '节日贺卡拼图',
|
||||
imageSrc: '/puzzle-creation-templates/festival-card.webp',
|
||||
prompt:
|
||||
'节日餐桌与礼物布置,暖色灯光、彩带、蜡烛和窗外烟花,画面像无字贺卡,主体集中,边角细节可辨。',
|
||||
},
|
||||
{
|
||||
id: 'knowledge-summary',
|
||||
title: '知识总结拼图',
|
||||
imageSrc: '/puzzle-creation-templates/knowledge-summary.webp',
|
||||
prompt:
|
||||
'一张无文字的知识学习主题插画,书桌上有打开的笔记本、便签、咖啡、台灯和思维导图式图形元素,构图整洁,重点明确。',
|
||||
},
|
||||
{
|
||||
id: 'product-detail',
|
||||
title: '商品细节拼图',
|
||||
imageSrc: '/puzzle-creation-templates/product-detail.webp',
|
||||
prompt:
|
||||
'精致商品静物展示,一只高质感香水瓶放在丝绸与花瓣之间,玻璃反光清晰,包装和材质细节丰富,背景干净。',
|
||||
},
|
||||
{
|
||||
id: 'healing-landscape',
|
||||
title: '治愈风景拼图',
|
||||
imageSrc: '/puzzle-creation-templates/healing-landscape.webp',
|
||||
prompt:
|
||||
'治愈风景插画,清晨湖边、薄雾、远山、木栈道和一盏小灯,色彩柔和。',
|
||||
},
|
||||
{
|
||||
id: 'cute-pet',
|
||||
title: '宠物可爱拼图',
|
||||
imageSrc: '/puzzle-creation-templates/cute-pet.webp',
|
||||
prompt:
|
||||
'一只可爱的橘猫趴在阳光窗台上,旁边有绿植、毛线球和小毯子,猫的表情清楚,画面温柔干净。',
|
||||
},
|
||||
{
|
||||
id: 'hot-topic-poster',
|
||||
title: '热点海报拼图',
|
||||
imageSrc: '/puzzle-creation-templates/hot-topic-poster.webp',
|
||||
prompt:
|
||||
'电影感热点海报风插画,雨夜街头、霓虹反光、奔跑的人影和远处光束,强烈视觉焦点,画面无文字。',
|
||||
},
|
||||
{
|
||||
id: 'event-invitation',
|
||||
title: '活动邀请拼图',
|
||||
imageSrc: '/puzzle-creation-templates/event-invitation.webp',
|
||||
prompt:
|
||||
'活动邀请主题插画,展厅入口、花艺装置、签到台和柔和灯带,人群剪影自然分布,画面高级干净,无文字。',
|
||||
},
|
||||
{
|
||||
id: 'daily-challenge',
|
||||
title: '每日挑战拼图',
|
||||
imageSrc: '/puzzle-creation-templates/daily-challenge.webp',
|
||||
prompt:
|
||||
'每日挑战主题插画,清爽桌面上摆放相机、明信片、计时器和小奖章,色彩明亮,构图有趣,细节可拆解。',
|
||||
},
|
||||
{
|
||||
id: 'children-learning',
|
||||
title: '儿童认知拼图',
|
||||
imageSrc: '/puzzle-creation-templates/children-learning.webp',
|
||||
prompt:
|
||||
'儿童认知学习插画,木质桌面上有积木、彩色形状、动物玩偶和小书本,色彩明快,元素边界清晰,无文字。',
|
||||
},
|
||||
];
|
||||
@@ -24,12 +24,12 @@ import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { CreativeImageInputPanel } from '../common/CreativeImageInputPanel';
|
||||
import PuzzleHistoryAssetPickerDialog from '../puzzle-agent/PuzzleHistoryAssetPickerDialog';
|
||||
import PuzzleHistoryAssetPickerDialog from '../unified-creation/shared/PuzzleHistoryAssetPickerDialog';
|
||||
import {
|
||||
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
type PuzzleImageModelId,
|
||||
} from '../puzzle-agent/puzzleImageModelOptions';
|
||||
import { PuzzleImageModelPicker } from '../puzzle-agent/PuzzleImageModelPicker';
|
||||
} from '../unified-creation/shared/puzzleImageModelOptions';
|
||||
import { PuzzleImageModelPicker } from '../unified-creation/shared/PuzzleImageModelPicker';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type PuzzleResultViewProps = {
|
||||
|
||||
@@ -803,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,
|
||||
@@ -1007,8 +1007,8 @@ vi.mock('../match3d-result/Match3DResultView', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../match3d-creation/Match3DAgentWorkspace', () => ({
|
||||
Match3DAgentWorkspace: ({
|
||||
vi.mock('../unified-creation/workspaces/Match3DCreationWorkspace', () => ({
|
||||
Match3DCreationWorkspace: ({
|
||||
session,
|
||||
isBusy,
|
||||
error,
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
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')}>
|
||||
<UnifiedCreationPage
|
||||
spec={getUnifiedCreationSpec('wooden-fish')}
|
||||
onBack={onBack}
|
||||
>
|
||||
<div>敲木鱼工作台</div>
|
||||
</UnifiedCreationPage>,
|
||||
);
|
||||
@@ -41,6 +46,8 @@ describe('UnifiedCreationPage', () => {
|
||||
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(
|
||||
@@ -48,7 +55,19 @@ describe('UnifiedCreationPage', () => {
|
||||
.getByText('敲木鱼工作台')
|
||||
.closest('.unified-creation-page__content')
|
||||
?.className,
|
||||
).not.toContain('overflow-y-auto');
|
||||
expect(root?.className).not.toContain('overflow-hidden');
|
||||
).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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type { UnifiedCreationSpec } from './unifiedCreationSpecs';
|
||||
@@ -5,15 +6,19 @@ 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 w-full max-w-5xl flex-col px-3 pt-2 sm:px-4 sm:pt-3"
|
||||
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}
|
||||
@@ -21,10 +26,22 @@ export function UnifiedCreationPage({
|
||||
data-result-stage={spec.resultStage}
|
||||
>
|
||||
<header className="unified-creation-page__header shrink-0 pb-3">
|
||||
<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 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"
|
||||
@@ -32,6 +49,11 @@ export function UnifiedCreationPage({
|
||||
{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>
|
||||
@@ -49,7 +71,7 @@ export function UnifiedCreationPage({
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="unified-creation-page__content pb-3 sm:pb-4">
|
||||
<div className="unified-creation-page__content flex min-h-max flex-col pb-3 sm:pb-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { UnifiedCreationWorkspace } from './UnifiedCreationWorkspace';
|
||||
import { getUnifiedCreationSpec } from './unifiedCreationSpecs';
|
||||
|
||||
vi.mock('./workspaces/PuzzleCreationWorkspace', () => ({
|
||||
PuzzleCreationWorkspace: ({
|
||||
unifiedChrome,
|
||||
}: {
|
||||
unifiedChrome?: boolean;
|
||||
}) => (
|
||||
<div
|
||||
className="puzzle-agent-workspace"
|
||||
data-unified-chrome={unifiedChrome ? 'true' : 'false'}
|
||||
>
|
||||
拼图工作台
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./workspaces/Match3DCreationWorkspace', () => ({
|
||||
Match3DCreationWorkspace: ({
|
||||
unifiedChrome,
|
||||
}: {
|
||||
unifiedChrome?: boolean;
|
||||
}) => (
|
||||
<div
|
||||
className="match3d-agent-workspace"
|
||||
data-unified-chrome={unifiedChrome ? 'true' : 'false'}
|
||||
>
|
||||
抓大鹅工作台
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./workspaces/JumpHopCreationWorkspace', () => ({
|
||||
JumpHopCreationWorkspace: ({
|
||||
unifiedChrome,
|
||||
}: {
|
||||
unifiedChrome?: boolean;
|
||||
}) => (
|
||||
<div
|
||||
className="jump-hop-workspace"
|
||||
data-unified-chrome={unifiedChrome ? 'true' : 'false'}
|
||||
>
|
||||
跳一跳工作台
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./workspaces/WoodenFishCreationWorkspace', () => ({
|
||||
WoodenFishCreationWorkspace: ({
|
||||
unifiedChrome,
|
||||
}: {
|
||||
unifiedChrome?: boolean;
|
||||
}) => (
|
||||
<div
|
||||
className="wooden-fish-workspace"
|
||||
data-unified-chrome={unifiedChrome ? 'true' : 'false'}
|
||||
>
|
||||
敲木鱼工作台
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('UnifiedCreationWorkspace', () => {
|
||||
test('统一承载四条首批创作入口', () => {
|
||||
const onBack = vi.fn();
|
||||
|
||||
const puzzleResult = render(
|
||||
<UnifiedCreationWorkspace
|
||||
playId="puzzle"
|
||||
spec={getUnifiedCreationSpec('puzzle')}
|
||||
session={null}
|
||||
isBusy={false}
|
||||
error={null}
|
||||
onBack={onBack}
|
||||
onSubmitMessage={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onCreateFromForm={() => {}}
|
||||
onAutoSaveForm={() => {}}
|
||||
initialFormPayload={null}
|
||||
/>,
|
||||
);
|
||||
const puzzleWorkspace = screen
|
||||
.getByText('拼图工作台')
|
||||
.closest('[data-unified-chrome]');
|
||||
const puzzlePage = screen
|
||||
.getByText('拼图工作台')
|
||||
.closest('.unified-creation-page');
|
||||
expect(puzzleWorkspace?.getAttribute('data-unified-chrome')).toBe('true');
|
||||
expect(puzzlePage?.getAttribute('data-play-id')).toBe('puzzle');
|
||||
expect(screen.getByRole('button', { name: '返回' })).toBeTruthy();
|
||||
puzzleResult.unmount();
|
||||
|
||||
const match3dResult = render(
|
||||
<UnifiedCreationWorkspace
|
||||
playId="match3d"
|
||||
spec={getUnifiedCreationSpec('match3d')}
|
||||
session={null}
|
||||
isBusy={false}
|
||||
error={null}
|
||||
onBack={onBack}
|
||||
onExecuteAction={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onCreateFromForm={() => {}}
|
||||
initialFormPayload={null}
|
||||
/>,
|
||||
);
|
||||
const match3dWorkspace = screen
|
||||
.getByText('抓大鹅工作台')
|
||||
.closest('[data-unified-chrome]');
|
||||
const match3dPage = screen
|
||||
.getByText('抓大鹅工作台')
|
||||
.closest('.unified-creation-page');
|
||||
expect(match3dWorkspace?.getAttribute('data-unified-chrome')).toBe('true');
|
||||
expect(match3dPage?.getAttribute('data-play-id')).toBe('match3d');
|
||||
match3dResult.unmount();
|
||||
|
||||
const jumpHopResult = render(
|
||||
<UnifiedCreationWorkspace
|
||||
playId="jump-hop"
|
||||
spec={getUnifiedCreationSpec('jump-hop')}
|
||||
isBusy={false}
|
||||
error={null}
|
||||
onBack={onBack}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
);
|
||||
const jumpHopWorkspace = screen
|
||||
.getByText('跳一跳工作台')
|
||||
.closest('[data-unified-chrome]');
|
||||
const jumpHopPage = screen
|
||||
.getByText('跳一跳工作台')
|
||||
.closest('.unified-creation-page');
|
||||
expect(jumpHopWorkspace?.getAttribute('data-unified-chrome')).toBe('true');
|
||||
expect(jumpHopPage?.getAttribute('data-play-id')).toBe('jump-hop');
|
||||
jumpHopResult.unmount();
|
||||
|
||||
const woodenFishResult = render(
|
||||
<UnifiedCreationWorkspace
|
||||
playId="wooden-fish"
|
||||
spec={getUnifiedCreationSpec('wooden-fish')}
|
||||
isBusy={false}
|
||||
error={null}
|
||||
onBack={onBack}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
);
|
||||
const woodenFishWorkspace = screen
|
||||
.getByText('敲木鱼工作台')
|
||||
.closest('[data-unified-chrome]');
|
||||
const woodenFishPage = screen
|
||||
.getByText('敲木鱼工作台')
|
||||
.closest('.unified-creation-page');
|
||||
expect(woodenFishWorkspace?.getAttribute('data-unified-chrome')).toBe(
|
||||
'true',
|
||||
);
|
||||
expect(woodenFishPage?.getAttribute('data-play-id')).toBe('wooden-fish');
|
||||
woodenFishResult.unmount();
|
||||
});
|
||||
|
||||
test('统一页头返回按钮会透传给当前玩法壳层', async () => {
|
||||
const onBack = vi.fn();
|
||||
|
||||
render(
|
||||
<UnifiedCreationWorkspace
|
||||
playId="jump-hop"
|
||||
spec={getUnifiedCreationSpec('jump-hop')}
|
||||
isBusy={false}
|
||||
error={null}
|
||||
onBack={onBack}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
screen.getByRole('button', { name: '返回' }).click();
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryAllByRole('button', { name: '返回' })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
125
src/components/unified-creation/UnifiedCreationWorkspace.tsx
Normal file
125
src/components/unified-creation/UnifiedCreationWorkspace.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
import { UnifiedCreationPage } from './UnifiedCreationPage';
|
||||
import type { UnifiedCreationSpec } from './unifiedCreationSpecs';
|
||||
import { Match3DCreationWorkspace } from './workspaces/Match3DCreationWorkspace';
|
||||
import { PuzzleCreationWorkspace } from './workspaces/PuzzleCreationWorkspace';
|
||||
import { JumpHopCreationWorkspace } from './workspaces/JumpHopCreationWorkspace';
|
||||
import { WoodenFishCreationWorkspace } from './workspaces/WoodenFishCreationWorkspace';
|
||||
|
||||
type PuzzleCreationWorkspaceProps = ComponentProps<
|
||||
typeof PuzzleCreationWorkspace
|
||||
>;
|
||||
type Match3DCreationWorkspaceProps = ComponentProps<
|
||||
typeof Match3DCreationWorkspace
|
||||
>;
|
||||
type JumpHopCreationWorkspaceProps = ComponentProps<
|
||||
typeof JumpHopCreationWorkspace
|
||||
>;
|
||||
type WoodenFishCreationWorkspaceProps = ComponentProps<
|
||||
typeof WoodenFishCreationWorkspace
|
||||
>;
|
||||
|
||||
type UnifiedCreationWorkspaceBaseProps = {
|
||||
spec: UnifiedCreationSpec;
|
||||
};
|
||||
|
||||
type PuzzleUnifiedCreationWorkspaceProps =
|
||||
UnifiedCreationWorkspaceBaseProps & {
|
||||
playId: 'puzzle';
|
||||
} & PuzzleCreationWorkspaceProps;
|
||||
|
||||
type Match3DUnifiedCreationWorkspaceProps =
|
||||
UnifiedCreationWorkspaceBaseProps & {
|
||||
playId: 'match3d';
|
||||
} & Match3DCreationWorkspaceProps;
|
||||
|
||||
type JumpHopUnifiedCreationWorkspaceProps =
|
||||
UnifiedCreationWorkspaceBaseProps & {
|
||||
playId: 'jump-hop';
|
||||
} & JumpHopCreationWorkspaceProps;
|
||||
|
||||
type WoodenFishUnifiedCreationWorkspaceProps =
|
||||
UnifiedCreationWorkspaceBaseProps & {
|
||||
playId: 'wooden-fish';
|
||||
} & WoodenFishCreationWorkspaceProps;
|
||||
|
||||
export type UnifiedCreationWorkspaceProps =
|
||||
| PuzzleUnifiedCreationWorkspaceProps
|
||||
| Match3DUnifiedCreationWorkspaceProps
|
||||
| JumpHopUnifiedCreationWorkspaceProps
|
||||
| WoodenFishUnifiedCreationWorkspaceProps;
|
||||
|
||||
export function UnifiedCreationWorkspace(props: UnifiedCreationWorkspaceProps) {
|
||||
switch (props.playId) {
|
||||
case 'puzzle':
|
||||
return (
|
||||
<UnifiedCreationPage spec={props.spec} onBack={props.onBack}>
|
||||
<PuzzleCreationWorkspace
|
||||
session={props.session}
|
||||
isBusy={props.isBusy}
|
||||
error={props.error}
|
||||
onBack={props.onBack}
|
||||
onSubmitMessage={props.onSubmitMessage}
|
||||
onExecuteAction={props.onExecuteAction}
|
||||
onCreateFromForm={props.onCreateFromForm}
|
||||
onAutoSaveForm={props.onAutoSaveForm}
|
||||
initialFormPayload={props.initialFormPayload}
|
||||
showBackButton={false}
|
||||
title={null}
|
||||
unifiedChrome
|
||||
/>
|
||||
</UnifiedCreationPage>
|
||||
);
|
||||
case 'match3d':
|
||||
return (
|
||||
<UnifiedCreationPage spec={props.spec} onBack={props.onBack}>
|
||||
<Match3DCreationWorkspace
|
||||
session={props.session}
|
||||
isBusy={props.isBusy}
|
||||
error={props.error}
|
||||
onBack={props.onBack}
|
||||
onExecuteAction={props.onExecuteAction}
|
||||
onCreateFromForm={props.onCreateFromForm}
|
||||
onSubmitMessage={props.onSubmitMessage}
|
||||
initialFormPayload={props.initialFormPayload}
|
||||
showBackButton={false}
|
||||
title={null}
|
||||
unifiedChrome
|
||||
/>
|
||||
</UnifiedCreationPage>
|
||||
);
|
||||
case 'jump-hop':
|
||||
return (
|
||||
<UnifiedCreationPage spec={props.spec} onBack={props.onBack}>
|
||||
<JumpHopCreationWorkspace
|
||||
isBusy={props.isBusy}
|
||||
error={props.error}
|
||||
onBack={props.onBack}
|
||||
onSubmitted={props.onSubmitted}
|
||||
showBackButton={false}
|
||||
unifiedChrome
|
||||
/>
|
||||
</UnifiedCreationPage>
|
||||
);
|
||||
case 'wooden-fish':
|
||||
return (
|
||||
<UnifiedCreationPage spec={props.spec} onBack={props.onBack}>
|
||||
<WoodenFishCreationWorkspace
|
||||
isBusy={props.isBusy}
|
||||
error={props.error}
|
||||
onBack={props.onBack}
|
||||
onSubmitted={props.onSubmitted}
|
||||
showBackButton={false}
|
||||
unifiedChrome
|
||||
/>
|
||||
</UnifiedCreationPage>
|
||||
);
|
||||
default: {
|
||||
const exhaustiveCheck: never = props;
|
||||
return exhaustiveCheck;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default UnifiedCreationWorkspace;
|
||||
@@ -50,4 +50,22 @@ describe('UnifiedGenerationPage', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
} from './unifiedCreationSpecs';
|
||||
|
||||
describe('unified creation specs', () => {
|
||||
test('一期只接拼图、抓大鹅和敲木鱼', () => {
|
||||
test('统一壳当前覆盖拼图、抓大鹅、跳一跳和敲木鱼', () => {
|
||||
expect(listUnifiedCreationSpecs().map((spec) => spec.playId).sort()).toEqual(
|
||||
['match3d', 'puzzle', 'wooden-fish'],
|
||||
['jump-hop', 'match3d', 'puzzle', 'wooden-fish'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('unified creation specs', () => {
|
||||
expect([...fieldKinds].sort()).toEqual(['audio', 'image', 'select', 'text']);
|
||||
});
|
||||
|
||||
test('三条链路都映射到统一创作、生成、结果阶段', () => {
|
||||
test('四条链路都映射到统一创作、生成、结果阶段', () => {
|
||||
expect(getUnifiedCreationSpec('puzzle')).toMatchObject({
|
||||
workspaceStage: 'puzzle-agent-workspace',
|
||||
generationStage: 'puzzle-generating',
|
||||
@@ -33,6 +33,11 @@ describe('unified creation specs', () => {
|
||||
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',
|
||||
|
||||
@@ -58,6 +58,63 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
|
||||
},
|
||||
],
|
||||
},
|
||||
'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: '想做个什么玩法?',
|
||||
|
||||
@@ -13,6 +13,12 @@ const UNIFIED_GENERATION_COPY = {
|
||||
progressTitle: '抓大鹅草稿生成进度',
|
||||
activeBadgeLabel: '素材生成中',
|
||||
},
|
||||
'jump-hop': {
|
||||
retryLabel: '重新生成草稿',
|
||||
settingTitle: '当前跳一跳信息',
|
||||
progressTitle: '跳一跳草稿生成进度',
|
||||
activeBadgeLabel: '素材生成中',
|
||||
},
|
||||
'wooden-fish': {
|
||||
retryLabel: '重新生成草稿',
|
||||
settingTitle: '当前敲木鱼信息',
|
||||
|
||||
@@ -4,11 +4,11 @@ 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 { JumpHopWorkspace } from './JumpHopWorkspace';
|
||||
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', () => ({
|
||||
vi.mock('../../../services/jump-hop/jumpHopClient', () => ({
|
||||
jumpHopClient: {
|
||||
createSession: vi.fn(),
|
||||
},
|
||||
@@ -40,7 +40,7 @@ test('jump hop workspace submits structured payload after required fields are fi
|
||||
mockCreateSession.mockResolvedValue(sessionResponse);
|
||||
|
||||
render(
|
||||
<JumpHopWorkspace onBack={() => {}} onSubmitted={onSubmitted} />,
|
||||
<JumpHopCreationWorkspace onBack={() => {}} onSubmitted={onSubmitted} />,
|
||||
);
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: '生成' });
|
||||
@@ -84,9 +84,26 @@ test('jump hop workspace calls back when return button is clicked', async () =>
|
||||
const user = userEvent.setup();
|
||||
const onBack = vi.fn();
|
||||
|
||||
render(<JumpHopWorkspace onBack={onBack} onSubmitted={() => {}} />);
|
||||
render(<JumpHopCreationWorkspace onBack={onBack} onSubmitted={() => {}} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('jump hop workspace can defer visible chrome to the unified creation page', () => {
|
||||
const { container } = render(
|
||||
<JumpHopCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
showBackButton={false}
|
||||
unifiedChrome
|
||||
/>,
|
||||
);
|
||||
|
||||
const workspace = container.querySelector('.jump-hop-workspace');
|
||||
expect(workspace?.getAttribute('data-unified-chrome')).toBe('true');
|
||||
expect(workspace?.className).toContain('max-w-none');
|
||||
expect(workspace?.className).not.toContain('platform-remap-surface');
|
||||
expect(screen.queryByRole('button', { name: '返回' })).toBeNull();
|
||||
});
|
||||
@@ -6,10 +6,10 @@ import type {
|
||||
JumpHopSessionResponse,
|
||||
JumpHopStylePreset,
|
||||
JumpHopWorkspaceCreateRequest,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import { jumpHopClient } from '../../services/jump-hop/jumpHopClient';
|
||||
} from '../../../../packages/shared/src/contracts/jumpHop';
|
||||
import { jumpHopClient } from '../../../services/jump-hop/jumpHopClient';
|
||||
|
||||
type JumpHopWorkspaceProps = {
|
||||
type JumpHopCreationWorkspaceProps = {
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
@@ -17,6 +17,8 @@ type JumpHopWorkspaceProps = {
|
||||
result: JumpHopSessionResponse,
|
||||
payload: JumpHopWorkspaceCreateRequest,
|
||||
) => void;
|
||||
showBackButton?: boolean;
|
||||
unifiedChrome?: boolean;
|
||||
};
|
||||
|
||||
type JumpHopWorkspaceFormState = {
|
||||
@@ -41,12 +43,14 @@ const DEFAULT_FORM_STATE: JumpHopWorkspaceFormState = {
|
||||
endMoodPrompt: '',
|
||||
};
|
||||
|
||||
export function JumpHopWorkspace({
|
||||
export function JumpHopCreationWorkspace({
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onSubmitted,
|
||||
}: JumpHopWorkspaceProps) {
|
||||
showBackButton = true,
|
||||
unifiedChrome = false,
|
||||
}: JumpHopCreationWorkspaceProps) {
|
||||
const [formState, setFormState] = useState(DEFAULT_FORM_STATE);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -99,17 +103,26 @@ export function JumpHopWorkspace({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
unifiedChrome
|
||||
? 'jump-hop-workspace mx-auto flex min-h-0 w-full max-w-none flex-col overflow-visible'
|
||||
: 'jump-hop-workspace platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4'
|
||||
}
|
||||
data-unified-chrome={unifiedChrome ? 'true' : 'false'}
|
||||
>
|
||||
{showBackButton ? (
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block sm:col-span-2">
|
||||
@@ -275,4 +288,4 @@ export function JumpHopWorkspace({
|
||||
);
|
||||
}
|
||||
|
||||
export default JumpHopWorkspace;
|
||||
export default JumpHopCreationWorkspace;
|
||||
@@ -3,8 +3,8 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import { Match3DAgentWorkspace } from './Match3DAgentWorkspace';
|
||||
import type { Match3DAgentSessionSnapshot } from '../../../../packages/shared/src/contracts/match3dAgent';
|
||||
import { Match3DCreationWorkspace } from './Match3DCreationWorkspace';
|
||||
|
||||
const baseSession: Match3DAgentSessionSnapshot = {
|
||||
sessionId: 'match3d-session-1',
|
||||
@@ -70,7 +70,7 @@ test('match3d workspace submits derived entry form payload instead of agent chat
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<Match3DAgentWorkspace
|
||||
<Match3DCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
@@ -114,7 +114,7 @@ test('match3d workspace submits derived entry form payload instead of agent chat
|
||||
|
||||
test('match3d workspace can defer visible chrome to the unified creation page', () => {
|
||||
const { container } = render(
|
||||
<Match3DAgentWorkspace
|
||||
<Match3DCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
@@ -138,7 +138,7 @@ test('match3d workspace omits legacy asset style fields from entry payload', ()
|
||||
const onCreateFromForm = vi.fn();
|
||||
|
||||
render(
|
||||
<Match3DAgentWorkspace
|
||||
<Match3DCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
@@ -162,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={() => {}}
|
||||
@@ -188,7 +188,7 @@ test('match3d workspace falls back to compile action when restored from the lega
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<Match3DAgentWorkspace
|
||||
<Match3DCreationWorkspace
|
||||
session={baseSession}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
@@ -6,9 +6,9 @@ import type {
|
||||
ExecuteMatch3DActionRequest,
|
||||
Match3DAgentSessionSnapshot,
|
||||
SendMatch3DMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
} from '../../../../packages/shared/src/contracts/match3dAgent';
|
||||
|
||||
type Match3DAgentWorkspaceProps = {
|
||||
type Match3DCreationWorkspaceProps = {
|
||||
session: Match3DAgentSessionSnapshot | null;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
@@ -104,10 +104,9 @@ function resolveInitialFormState(
|
||||
}
|
||||
|
||||
/**
|
||||
* 抓大鹅创作入口已从固定 Agent 追问改成表单式。
|
||||
* 组件名保留为 Match3DAgentWorkspace,兼容现有路由、草稿恢复和父层分流。
|
||||
* 统一创作目录内的抓大鹅工作台实现。
|
||||
*/
|
||||
export function Match3DAgentWorkspace({
|
||||
export function Match3DCreationWorkspace({
|
||||
session,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
@@ -118,7 +117,7 @@ export function Match3DAgentWorkspace({
|
||||
showBackButton = true,
|
||||
title = '想做个什么玩法?',
|
||||
unifiedChrome = false,
|
||||
}: Match3DAgentWorkspaceProps) {
|
||||
}: Match3DCreationWorkspaceProps) {
|
||||
const [formState, setFormState] = useState<Match3DFormState>(() =>
|
||||
resolveInitialFormState(session, initialFormPayload),
|
||||
);
|
||||
@@ -368,4 +367,4 @@ export function Match3DAgentWorkspace({
|
||||
);
|
||||
}
|
||||
|
||||
export default Match3DAgentWorkspace;
|
||||
export default Match3DCreationWorkspace;
|
||||
@@ -10,11 +10,11 @@ import {
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient';
|
||||
import { PuzzleAgentWorkspace } from './PuzzleAgentWorkspace';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { puzzleAssetClient } from '../../../services/puzzle-works/puzzleAssetClient';
|
||||
import { PuzzleCreationWorkspace } from './PuzzleCreationWorkspace';
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
vi.mock('../../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: ({
|
||||
src,
|
||||
alt,
|
||||
@@ -26,7 +26,7 @@ vi.mock('../ResolvedAssetImage', () => ({
|
||||
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({
|
||||
vi.mock('../../../services/puzzle-works/puzzleAssetClient', () => ({
|
||||
puzzleAssetClient: {
|
||||
listHistoryAssets: vi.fn(),
|
||||
uploadReferenceImage: vi.fn(),
|
||||
@@ -177,7 +177,7 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
<PuzzleCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
@@ -217,7 +217,7 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
|
||||
|
||||
test('puzzle workspace can defer visible chrome to the unified creation page', () => {
|
||||
const { container } = render(
|
||||
<PuzzleAgentWorkspace
|
||||
<PuzzleCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
@@ -229,11 +229,14 @@ test('puzzle workspace can defer visible chrome to the unified creation page', (
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -241,7 +244,7 @@ test('puzzle workspace can defer visible chrome to the unified creation page', (
|
||||
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={() => {}}
|
||||
@@ -320,7 +323,7 @@ test('puzzle workspace selects a history image from the upload card', async () =
|
||||
]);
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
<PuzzleCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
@@ -377,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={() => {}}
|
||||
@@ -407,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={() => {}}
|
||||
@@ -438,7 +441,7 @@ test('puzzle workspace switches image mode without exposing model names', () =>
|
||||
const onCreateFromForm = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
<PuzzleCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
@@ -502,7 +505,7 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
|
||||
};
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
<PuzzleCreationWorkspace
|
||||
session={formDraftSession}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
@@ -541,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={() => {}}
|
||||
@@ -600,7 +603,7 @@ test('puzzle workspace submits history image when AI redraw is off', async () =>
|
||||
]);
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
<PuzzleCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
@@ -645,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={() => {}}
|
||||
@@ -728,7 +731,7 @@ test('puzzle workspace uploads prompt references as asset object ids', async ()
|
||||
});
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
<PuzzleCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
@@ -803,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={() => {}}
|
||||
@@ -870,7 +873,7 @@ test('puzzle workspace shows AI redraw switch only after upload', async () => {
|
||||
stubReferenceImageUpload(uploadedDataUrl);
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
<PuzzleCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
@@ -907,7 +910,7 @@ test('puzzle workspace confirms before removing uploaded image', async () => {
|
||||
stubReferenceImageUpload(uploadedDataUrl);
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
<PuzzleCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
@@ -949,7 +952,7 @@ test('puzzle workspace opens crop tool for non-square uploads', async () => {
|
||||
const drawImage = stubCanvas(croppedDataUrl);
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
<PuzzleCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
@@ -6,38 +6,38 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
|
||||
import type { PuzzleAgentActionRequest } from '../../../../packages/shared/src/contracts/puzzleAgentActions';
|
||||
import type {
|
||||
CreatePuzzleAgentSessionRequest,
|
||||
PuzzleAgentSessionSnapshot,
|
||||
SendPuzzleAgentMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works/puzzleHistoryAsset';
|
||||
} from '../../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { getPuzzleHistoryAssetReferenceLabel } from '../../../services/puzzle-works/puzzleHistoryAsset';
|
||||
import {
|
||||
cropPuzzleReferenceImageDataUrl,
|
||||
isPuzzleReferenceImageSquare,
|
||||
readPuzzleReferenceImageAsDataUrl,
|
||||
readPuzzleReferenceImageForUpload,
|
||||
} from '../../services/puzzleReferenceImage';
|
||||
} from '../../../services/puzzleReferenceImage';
|
||||
import {
|
||||
CreativeImageInputPanel,
|
||||
type CreativeImageInputReferenceImage,
|
||||
} from '../common/CreativeImageInputPanel';
|
||||
} from '../../common/CreativeImageInputPanel';
|
||||
import {
|
||||
buildCenteredSquareImageCropRect,
|
||||
clampSquareImageCropRect,
|
||||
SquareImageCropModal,
|
||||
type SquareImageCropRect,
|
||||
} from '../common/SquareImageCropModal';
|
||||
import PuzzleHistoryAssetPickerDialog from './PuzzleHistoryAssetPickerDialog';
|
||||
} from '../../common/SquareImageCropModal';
|
||||
import PuzzleHistoryAssetPickerDialog from '../shared/PuzzleHistoryAssetPickerDialog';
|
||||
import {
|
||||
normalizePuzzleImageModel,
|
||||
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
type PuzzleImageModelId,
|
||||
} from './puzzleImageModelOptions';
|
||||
import { PuzzleImageModelPicker } from './PuzzleImageModelPicker';
|
||||
} from '../shared/puzzleImageModelOptions';
|
||||
import { PuzzleImageModelPicker } from '../shared/PuzzleImageModelPicker';
|
||||
|
||||
type PuzzleAgentWorkspaceProps = {
|
||||
type PuzzleCreationWorkspaceProps = {
|
||||
session: PuzzleAgentSessionSnapshot | null;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
@@ -234,9 +234,9 @@ function addPuzzlePromptReferenceImage(
|
||||
|
||||
/**
|
||||
* 拼图创作入口已从 Agent 对话改为填表式。
|
||||
* 组件名保留为 PuzzleAgentWorkspace 以兼容现有路由与草稿恢复入口。
|
||||
* 统一创作目录内的拼图工作台实现。
|
||||
*/
|
||||
export function PuzzleAgentWorkspace({
|
||||
export function PuzzleCreationWorkspace({
|
||||
session,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
@@ -248,7 +248,7 @@ export function PuzzleAgentWorkspace({
|
||||
showBackButton = true,
|
||||
title = '想做个什么玩法?',
|
||||
unifiedChrome = false,
|
||||
}: PuzzleAgentWorkspaceProps) {
|
||||
}: PuzzleCreationWorkspaceProps) {
|
||||
const [formState, setFormState] = useState<PuzzleFormState>(() =>
|
||||
resolveInitialFormState(session, initialFormPayload),
|
||||
);
|
||||
@@ -633,6 +633,7 @@ export function PuzzleAgentWorkspace({
|
||||
|
||||
<CreativeImageInputPanel
|
||||
className={unifiedChrome ? 'min-h-0 flex-none' : ''}
|
||||
fillHeight={!unifiedChrome}
|
||||
disabled={isBusy}
|
||||
isSubmitting={isBusy}
|
||||
uploadedImageSrc={formState.referenceImageSrc}
|
||||
@@ -782,4 +783,4 @@ export function PuzzleAgentWorkspace({
|
||||
);
|
||||
}
|
||||
|
||||
export default PuzzleAgentWorkspace;
|
||||
export default PuzzleCreationWorkspace;
|
||||
@@ -3,11 +3,11 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { woodenFishClient } from '../../services/wooden-fish/woodenFishClient';
|
||||
import { WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT } from '../../services/wooden-fish/woodenFishDefaults';
|
||||
import { WoodenFishWorkspace } from './WoodenFishWorkspace';
|
||||
import { woodenFishClient } from '../../../services/wooden-fish/woodenFishClient';
|
||||
import { WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT } from '../../../services/wooden-fish/woodenFishDefaults';
|
||||
import { WoodenFishCreationWorkspace } from './WoodenFishCreationWorkspace';
|
||||
|
||||
vi.mock('../../services/wooden-fish/woodenFishClient', () => ({
|
||||
vi.mock('../../../services/wooden-fish/woodenFishClient', () => ({
|
||||
woodenFishClient: {
|
||||
createSession: vi.fn(),
|
||||
},
|
||||
@@ -31,7 +31,7 @@ test('敲什么输入栏初始置空但提交时仍使用默认生成提示词',
|
||||
const onSubmitted = vi.fn();
|
||||
|
||||
render(
|
||||
<WoodenFishWorkspace
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={onSubmitted}
|
||||
/>,
|
||||
@@ -48,7 +48,7 @@ test('敲什么输入栏初始置空但提交时仍使用默认生成提示词',
|
||||
|
||||
test('功德有什么默认只显示基础词条,不显示运行态 +1 后缀', () => {
|
||||
render(
|
||||
<WoodenFishWorkspace
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
@@ -72,7 +72,7 @@ test('功德有什么默认只显示基础词条,不显示运行态 +1 后缀'
|
||||
|
||||
test('功德有什么支持通过加号新增词条并移除新增格子', () => {
|
||||
render(
|
||||
<WoodenFishWorkspace
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
@@ -92,7 +92,7 @@ test('功德有什么支持通过加号新增词条并移除新增格子', () =>
|
||||
|
||||
test('敲击音效临时关闭提示词生成入口,仅保留上传和录音', () => {
|
||||
render(
|
||||
<WoodenFishWorkspace
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
@@ -109,7 +109,7 @@ test('敲击音效临时关闭提示词生成入口,仅保留上传和录音',
|
||||
|
||||
test('敲击音效和功德词条不放进独立滚动窗', () => {
|
||||
const { container } = render(
|
||||
<WoodenFishWorkspace
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
@@ -130,7 +130,7 @@ test('敲击音效和功德词条不放进独立滚动窗', () => {
|
||||
|
||||
test('工作台只保留一个生成按钮', () => {
|
||||
render(
|
||||
<WoodenFishWorkspace
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
@@ -138,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');
|
||||
});
|
||||
@@ -11,17 +11,17 @@ 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 { CreativeAudioInputPanel } from '../common/CreativeAudioInputPanel';
|
||||
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;
|
||||
@@ -29,6 +29,8 @@ type WoodenFishWorkspaceProps = {
|
||||
result: WoodenFishSessionResponse,
|
||||
payload: WoodenFishWorkspaceCreateRequest,
|
||||
) => void;
|
||||
showBackButton?: boolean;
|
||||
unifiedChrome?: boolean;
|
||||
};
|
||||
|
||||
type WoodenFishWorkspaceFormState = {
|
||||
@@ -66,12 +68,14 @@ function normalizeFloatingWords(words: string[]) {
|
||||
return normalized.length > 0 ? normalized : [...DEFAULT_FLOATING_WORDS];
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -155,21 +159,31 @@ export function WoodenFishWorkspace({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="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">
|
||||
<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 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}
|
||||
@@ -320,4 +334,4 @@ export function WoodenFishWorkspace({
|
||||
);
|
||||
}
|
||||
|
||||
export default WoodenFishWorkspace;
|
||||
export default WoodenFishCreationWorkspace;
|
||||
@@ -24,17 +24,23 @@ export type UnifiedCreationField = {
|
||||
};
|
||||
|
||||
export type UnifiedCreationSpec = {
|
||||
playId: 'puzzle' | 'match3d' | 'wooden-fish';
|
||||
playId: 'puzzle' | 'match3d' | 'jump-hop' | 'wooden-fish';
|
||||
title: string;
|
||||
workspaceStage:
|
||||
| 'puzzle-agent-workspace'
|
||||
| 'match3d-agent-workspace'
|
||||
| 'jump-hop-workspace'
|
||||
| 'wooden-fish-workspace';
|
||||
generationStage:
|
||||
| 'puzzle-generating'
|
||||
| 'match3d-generating'
|
||||
| 'jump-hop-generating'
|
||||
| 'wooden-fish-generating';
|
||||
resultStage: 'puzzle-result' | 'match3d-result' | 'wooden-fish-result';
|
||||
resultStage:
|
||||
| 'puzzle-result'
|
||||
| 'match3d-result'
|
||||
| 'jump-hop-result'
|
||||
| 'wooden-fish-result';
|
||||
fields: UnifiedCreationField[];
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user