324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
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;
|