feat: add puzzle clear template runtime

This commit is contained in:
2026-06-03 22:11:46 +08:00
parent 6e74cf5add
commit 1b5e098225
148 changed files with 19588 additions and 241 deletions

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