收口统一创作流程一期

This commit is contained in:
2026-05-31 14:46:32 +00:00
parent 724d8be405
commit 23dec91bd6
36 changed files with 919 additions and 469 deletions

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

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

View File

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

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

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

View File

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

View File

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

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

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

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

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

View File

@@ -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: '想做个什么玩法?',

View File

@@ -13,6 +13,12 @@ const UNIFIED_GENERATION_COPY = {
progressTitle: '抓大鹅草稿生成进度',
activeBadgeLabel: '素材生成中',
},
'jump-hop': {
retryLabel: '重新生成草稿',
settingTitle: '当前跳一跳信息',
progressTitle: '跳一跳草稿生成进度',
activeBadgeLabel: '素材生成中',
},
'wooden-fish': {
retryLabel: '重新生成草稿',
settingTitle: '当前敲木鱼信息',

View File

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

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

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

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

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

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={() => {}}
/>,
@@ -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');
});

View File

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

View File

@@ -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[];
};