收口统一创作流程一期

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

@@ -0,0 +1,337 @@
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 WoodenFishCreationWorkspaceProps = {
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onSubmitted: (
result: WoodenFishSessionResponse,
payload: WoodenFishWorkspaceCreateRequest,
) => void;
showBackButton?: boolean;
unifiedChrome?: boolean;
};
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 WoodenFishCreationWorkspace({
isBusy = false,
error = null,
onBack,
onSubmitted,
showBackButton = true,
unifiedChrome = false,
}: WoodenFishCreationWorkspaceProps) {
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={
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}
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 WoodenFishCreationWorkspace;