feat: add puzzle clear template runtime
This commit is contained in:
326
src/components/puzzle-clear-creation/PuzzleClearWorkspace.tsx
Normal file
326
src/components/puzzle-clear-creation/PuzzleClearWorkspace.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import { ArrowLeft, Loader2, Send } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type {
|
||||
PuzzleClearImageAsset,
|
||||
PuzzleClearSessionResponse,
|
||||
PuzzleClearWorkspaceCreateRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleClear';
|
||||
import { puzzleClearClient } from '../../services/puzzle-clear/puzzleClearClient';
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
import { CreativeImageInputPanel } from '../common/CreativeImageInputPanel';
|
||||
|
||||
type PuzzleClearWorkspaceProps = {
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onSubmitted: (
|
||||
result: PuzzleClearSessionResponse,
|
||||
payload: PuzzleClearWorkspaceCreateRequest,
|
||||
) => void;
|
||||
};
|
||||
|
||||
type PuzzleClearWorkspaceFormState = {
|
||||
workTitle: string;
|
||||
workDescription: string;
|
||||
themePrompt: string;
|
||||
boardBackgroundPrompt: string;
|
||||
boardBackgroundAsset: PuzzleClearImageAsset | null;
|
||||
boardBackgroundImageSrc: string;
|
||||
generateBoardBackground: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_FORM_STATE: PuzzleClearWorkspaceFormState = {
|
||||
workTitle: '',
|
||||
workDescription: '',
|
||||
themePrompt: '',
|
||||
boardBackgroundPrompt: '',
|
||||
boardBackgroundAsset: null,
|
||||
boardBackgroundImageSrc: '',
|
||||
generateBoardBackground: true,
|
||||
};
|
||||
|
||||
function buildLocalBoardBackgroundAsset(
|
||||
imageSrc: string,
|
||||
prompt: string,
|
||||
): PuzzleClearImageAsset {
|
||||
return {
|
||||
assetId: `local-board-background-${Date.now()}`,
|
||||
imageSrc,
|
||||
imageObjectKey: '',
|
||||
assetObjectId: '',
|
||||
generationProvider: 'local-upload',
|
||||
prompt,
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function PuzzleClearWorkspace({
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onSubmitted,
|
||||
}: PuzzleClearWorkspaceProps) {
|
||||
const [formState, setFormState] = useState(DEFAULT_FORM_STATE);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const hasBoardBackgroundInput = useMemo(
|
||||
() =>
|
||||
formState.generateBoardBackground ||
|
||||
Boolean(formState.boardBackgroundAsset || formState.boardBackgroundImageSrc),
|
||||
[
|
||||
formState.boardBackgroundAsset,
|
||||
formState.boardBackgroundImageSrc,
|
||||
formState.generateBoardBackground,
|
||||
],
|
||||
);
|
||||
|
||||
const canSubmit = useMemo(
|
||||
() =>
|
||||
Boolean(
|
||||
formState.workTitle.trim() &&
|
||||
formState.themePrompt.trim() &&
|
||||
hasBoardBackgroundInput,
|
||||
),
|
||||
[formState.themePrompt, formState.workTitle, hasBoardBackgroundInput],
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit || isSubmitting || isBusy) {
|
||||
setLocalError('请先补全输入。');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setLocalError(null);
|
||||
|
||||
try {
|
||||
const boardBackgroundAsset =
|
||||
formState.boardBackgroundAsset ??
|
||||
(formState.boardBackgroundImageSrc
|
||||
? buildLocalBoardBackgroundAsset(
|
||||
formState.boardBackgroundImageSrc,
|
||||
formState.boardBackgroundPrompt.trim() ||
|
||||
formState.themePrompt.trim(),
|
||||
)
|
||||
: null);
|
||||
const payload: PuzzleClearWorkspaceCreateRequest = {
|
||||
templateId: 'puzzle-clear',
|
||||
workTitle: formState.workTitle.trim(),
|
||||
workDescription: formState.workDescription.trim(),
|
||||
themePrompt: formState.themePrompt.trim(),
|
||||
boardBackgroundPrompt: formState.boardBackgroundPrompt.trim(),
|
||||
generateBoardBackground: formState.generateBoardBackground,
|
||||
boardBackgroundAsset,
|
||||
};
|
||||
const response = await puzzleClearClient.createSession(payload);
|
||||
onSubmitted(response, payload);
|
||||
} catch (caughtError) {
|
||||
setLocalError(
|
||||
caughtError instanceof Error ? caughtError.message : '创建草稿失败。',
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleSubmit();
|
||||
}}
|
||||
className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,0.88fr)_minmax(0,1.12fr)]">
|
||||
<section className="platform-subpanel flex min-h-0 flex-col gap-3 overflow-y-auto rounded-[1.25rem] p-4">
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
作品标题
|
||||
</span>
|
||||
<input
|
||||
value={formState.workTitle}
|
||||
maxLength={32}
|
||||
disabled={isBusy || isSubmitting}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
workTitle: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
简介
|
||||
</span>
|
||||
<textarea
|
||||
value={formState.workDescription}
|
||||
maxLength={120}
|
||||
disabled={isBusy || isSubmitting}
|
||||
rows={4}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
workDescription: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
主题词
|
||||
</span>
|
||||
<input
|
||||
value={formState.themePrompt}
|
||||
maxLength={80}
|
||||
disabled={isBusy || isSubmitting}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
themePrompt: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
|
||||
<span className="text-sm font-bold text-[var(--platform-text-strong)]">
|
||||
AI 生成底图
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formState.generateBoardBackground}
|
||||
disabled={isBusy || isSubmitting}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
generateBoardBackground: event.target.checked,
|
||||
}))
|
||||
}
|
||||
className="h-5 w-5 accent-[var(--platform-accent)]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{localError || error ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
||||
{localError ?? error}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<div className="flex min-h-[28rem] min-w-0 flex-col">
|
||||
<CreativeImageInputPanel
|
||||
disabled={isBusy || isSubmitting}
|
||||
isSubmitting={isSubmitting}
|
||||
uploadedImageSrc={formState.boardBackgroundImageSrc}
|
||||
uploadedImageAlt="场地底图"
|
||||
mainImageInputId="puzzle-clear-board-background"
|
||||
promptTextareaId="puzzle-clear-board-background-prompt"
|
||||
prompt={formState.boardBackgroundPrompt}
|
||||
promptLabel="场地底图"
|
||||
promptRows={5}
|
||||
aiRedraw={formState.generateBoardBackground}
|
||||
promptReferenceImages={[]}
|
||||
showSubmitButton={false}
|
||||
submitLabel="生成"
|
||||
submitDisabled={!canSubmit || isSubmitting || isBusy}
|
||||
labels={{
|
||||
imageField: '中央底图',
|
||||
uploadImage: '上传底图',
|
||||
replaceImage: '替换底图',
|
||||
emptyImageHint: '上传图像',
|
||||
removeImage: '移除底图',
|
||||
removeImageConfirmTitle: '移除底图',
|
||||
removeImageConfirmBody: '移除后将使用主题词生成中央场地底图。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '场地底图参考',
|
||||
closePromptReferencePreview: '关闭预览',
|
||||
}}
|
||||
onMainImageFileSelect={(file) => {
|
||||
void readPuzzleReferenceImageAsDataUrl(file)
|
||||
.then((dataUrl) => {
|
||||
setLocalError(null);
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
boardBackgroundImageSrc: dataUrl,
|
||||
boardBackgroundAsset: buildLocalBoardBackgroundAsset(
|
||||
dataUrl,
|
||||
current.boardBackgroundPrompt.trim() ||
|
||||
current.themePrompt.trim(),
|
||||
),
|
||||
generateBoardBackground: false,
|
||||
}));
|
||||
})
|
||||
.catch((caughtError) => {
|
||||
setLocalError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: '底图读取失败。',
|
||||
);
|
||||
});
|
||||
}}
|
||||
onMainImageRemove={() => {
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
boardBackgroundImageSrc: '',
|
||||
boardBackgroundAsset: null,
|
||||
}));
|
||||
}}
|
||||
onAiRedrawChange={(value) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
generateBoardBackground: value,
|
||||
}))
|
||||
}
|
||||
onPromptChange={(value) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
boardBackgroundPrompt: value,
|
||||
}))
|
||||
}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex justify-end gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] pt-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit || isSubmitting || isBusy}
|
||||
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-5 py-3 ${
|
||||
!canSubmit || isSubmitting || isBusy
|
||||
? 'cursor-not-allowed opacity-55'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
生成
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default PuzzleClearWorkspace;
|
||||
Reference in New Issue
Block a user