Files
Genarrative/src/components/wooden-fish-creation/WoodenFishWorkspace.tsx

324 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ArrowLeft,
Loader2,
Plus,
Send,
X,
} from 'lucide-react';
import { useMemo, useState } from 'react';
import type {
WoodenFishAudioAsset,
WoodenFishSessionResponse,
WoodenFishWorkspaceCreateRequest,
} from '../../../packages/shared/src/contracts/woodenFish';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { woodenFishClient } from '../../services/wooden-fish/woodenFishClient';
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';
type WoodenFishWorkspaceProps = {
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onSubmitted: (
result: WoodenFishSessionResponse,
payload: WoodenFishWorkspaceCreateRequest,
) => void;
};
type WoodenFishWorkspaceFormState = {
hitObjectPrompt: string;
hitObjectReferenceImageSrc: string;
hitSoundAsset: WoodenFishAudioAsset | null;
floatingWords: string[];
};
const DEFAULT_THEME_TAGS = ['敲木鱼', '解压'];
const DEFAULT_FLOATING_WORDS = ['幸运'];
const MAX_FLOATING_WORD_COUNT = 8;
const DEFAULT_FORM_STATE: WoodenFishWorkspaceFormState = {
hitObjectPrompt: '',
hitObjectReferenceImageSrc: '',
hitSoundAsset: null,
floatingWords: DEFAULT_FLOATING_WORDS,
};
function normalizeFloatingWords(words: string[]) {
const seen = new Set<string>();
const normalized: string[] = [];
for (const word of words) {
const trimmed = word.trim().replace(/[+]\s*1$/u, '').trim();
if (!trimmed || seen.has(trimmed)) {
continue;
}
seen.add(trimmed);
normalized.push(trimmed);
if (normalized.length >= 8) {
break;
}
}
return normalized.length > 0 ? normalized : [...DEFAULT_FLOATING_WORDS];
}
export function WoodenFishWorkspace({
isBusy = false,
error = null,
onBack,
onSubmitted,
}: WoodenFishWorkspaceProps) {
const [formState, setFormState] = useState(DEFAULT_FORM_STATE);
const [localError, setLocalError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [aiRedraw, setAiRedraw] = useState(true);
const normalizedFloatingWords = useMemo(
() => normalizeFloatingWords(formState.floatingWords),
[formState.floatingWords],
);
const canSubmit = normalizedFloatingWords.length > 0;
const updateFloatingWord = (index: number, value: string) => {
setFormState((current) => {
const nextWords = [...current.floatingWords];
nextWords[index] = value;
return {
...current,
floatingWords: nextWords.slice(0, MAX_FLOATING_WORD_COUNT),
};
});
};
const addFloatingWord = () => {
setFormState((current) => {
if (current.floatingWords.length >= MAX_FLOATING_WORD_COUNT) {
return current;
}
return {
...current,
floatingWords: [...current.floatingWords, ''],
};
});
};
const removeFloatingWord = (index: number) => {
if (index <= 0) {
return;
}
setFormState((current) => ({
...current,
floatingWords: current.floatingWords.filter(
(_word, currentIndex) => currentIndex !== index,
),
}));
};
const handleSubmit = async () => {
if (!canSubmit || isSubmitting || isBusy) {
setLocalError('请先补全输入。');
return;
}
setIsSubmitting(true);
setLocalError(null);
try {
const payload: WoodenFishWorkspaceCreateRequest = {
templateId: 'wooden-fish',
workTitle: '',
workDescription:
formState.hitObjectPrompt.trim() || WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
themeTags: DEFAULT_THEME_TAGS,
hitObjectPrompt:
formState.hitObjectPrompt.trim() || WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
hitObjectReferenceImageSrc:
formState.hitObjectReferenceImageSrc.trim() || null,
hitSoundPrompt: null,
hitSoundAsset:
formState.hitSoundAsset ?? WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
floatingWords: normalizedFloatingWords,
};
const response = await woodenFishClient.createSession(payload);
onSubmitted(response, payload);
} catch (caughtError) {
setLocalError(
caughtError instanceof Error ? caughtError.message : '创建草稿失败。',
);
} finally {
setIsSubmitting(false);
}
};
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="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
disabled={isBusy || isSubmitting}
isSubmitting={isSubmitting}
uploadedImageSrc={formState.hitObjectReferenceImageSrc}
uploadedImageAlt="敲击物参考图"
mainImageInputId="wooden-fish-hit-object-reference"
promptTextareaId="wooden-fish-hit-object-prompt"
prompt={formState.hitObjectPrompt}
promptLabel="敲什么"
promptRows={4}
aiRedraw={aiRedraw}
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,
hitObjectReferenceImageSrc: dataUrl,
}));
setAiRedraw(true);
})
.catch((caughtError) => {
setLocalError(
caughtError instanceof Error
? caughtError.message
: '参考图读取失败。',
);
});
}}
onMainImageRemove={() => {
setFormState((current) => ({
...current,
hitObjectReferenceImageSrc: '',
}));
}}
onAiRedrawChange={setAiRedraw}
onPromptChange={(value) =>
setFormState((current) => ({
...current,
hitObjectPrompt: value,
}))
}
onSubmit={handleSubmit}
/>
</div>
<div className="flex flex-col gap-3 pr-0 lg:pr-1">
<CreativeAudioInputPanel<WoodenFishAudioAsset>
disabled={isBusy || isSubmitting}
title="敲击音效"
defaultLabel="默认木鱼音"
asset={formState.hitSoundAsset}
buildRecordedFileName={() => `wooden-fish-hit-${Date.now()}.webm`}
onAssetChange={(asset) =>
setFormState((current) => ({
...current,
hitSoundAsset: asset,
}))
}
onError={setLocalError}
/>
<section className="platform-subpanel rounded-[1.25rem] p-4">
<div className="mb-3 text-sm font-black text-[var(--platform-text-strong)]">
</div>
<div className="grid gap-2 sm:grid-cols-2">
{formState.floatingWords.map((word, index) => (
<div key={index} className="relative">
<input
value={word}
maxLength={16}
disabled={isBusy || isSubmitting}
aria-label={`功德词条 ${index + 1}`}
onChange={(event) =>
updateFloatingWord(index, event.target.value)
}
className="h-12 w-full rounded-[0.95rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 pr-10 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
{index > 0 ? (
<button
type="button"
disabled={isBusy || isSubmitting}
onClick={() => removeFloatingWord(index)}
className="absolute right-2 top-1/2 inline-flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-full bg-white/92 text-[var(--platform-text-soft)] shadow-sm transition hover:text-[var(--platform-accent)] disabled:opacity-45"
aria-label={`删除功德词条 ${index + 1}`}
title="删除词条"
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
))}
{formState.floatingWords.length < MAX_FLOATING_WORD_COUNT ? (
<button
type="button"
disabled={isBusy || isSubmitting}
onClick={addFloatingWord}
className="grid h-12 place-items-center rounded-[0.95rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/55 text-[var(--platform-text-soft)] transition hover:border-[var(--platform-accent)] hover:bg-white/78 hover:text-[var(--platform-accent)] disabled:opacity-45"
aria-label="新增功德词条"
title="新增词条"
>
<Plus className="h-5 w-5" />
</button>
) : null}
</div>
</section>
</div>
</div>
{localError || error ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{localError ?? error}
</div>
) : null}
<div className="mt-3 flex justify-end gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))]">
<button
type="button"
onClick={handleSubmit}
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>
</div>
);
}
export default WoodenFishWorkspace;