feat: add wooden fish play template

This commit is contained in:
2026-05-21 23:34:07 +08:00
parent ef09a23c35
commit 5b0f9f3763
121 changed files with 11580 additions and 159 deletions

View File

@@ -0,0 +1,25 @@
/* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react';
import { expect, test } from 'vitest';
import { WoodenFishWorkspace } from './WoodenFishWorkspace';
test('功德有什么默认只显示基础词条,不显示运行态 +1 后缀', () => {
render(
<WoodenFishWorkspace
onBack={() => {}}
onSubmitted={() => {}}
/>,
);
const sectionTitle = screen.getByText('功德有什么');
const section = sectionTitle.closest('section');
expect(section).not.toBeNull();
expect(within(section as HTMLElement).getByDisplayValue('幸运')).toBeTruthy();
expect(within(section as HTMLElement).getByDisplayValue('健康')).toBeTruthy();
expect(within(section as HTMLElement).getByDisplayValue('财富')).toBeTruthy();
expect(within(section as HTMLElement).queryByDisplayValue('幸运+1')).toBeNull();
expect(within(section as HTMLElement).queryByDisplayValue('功德+1')).toBeNull();
});

View File

@@ -0,0 +1,534 @@
import {
ArrowLeft,
Loader2,
Mic,
Pause,
Send,
Upload,
} from 'lucide-react';
import { useMemo, useRef, 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 } from '../../services/wooden-fish/woodenFishDefaults';
import { CreativeImageInputPanel } from '../common/CreativeImageInputPanel';
type WoodenFishWorkspaceProps = {
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onSubmitted: (
result: WoodenFishSessionResponse,
payload: WoodenFishWorkspaceCreateRequest,
) => void;
};
type WoodenFishWorkspaceFormState = {
workTitle: string;
workDescription: string;
themeTags: string;
hitObjectPrompt: string;
hitObjectReferenceImageSrc: string;
hitSoundPrompt: string;
hitSoundAsset: WoodenFishAudioAsset | null;
floatingWords: string[];
};
const DEFAULT_FLOATING_WORDS = [
'幸运',
'健康',
'财富',
'姻缘',
'幸福',
'事业',
'成功',
'功德',
];
const DEFAULT_FORM_STATE: WoodenFishWorkspaceFormState = {
workTitle: '今日敲木鱼',
workDescription: '',
themeTags: '敲木鱼 解压',
hitObjectPrompt: WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
hitObjectReferenceImageSrc: '',
hitSoundPrompt: '清脆短促的木鱼敲击声',
hitSoundAsset: null,
floatingWords: DEFAULT_FLOATING_WORDS,
};
function splitTags(value: string) {
return value
.split(/[,\s]+/u)
.map((item) => item.trim())
.filter(Boolean)
.slice(0, 6);
}
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;
}
function readAudioFileAsAsset(file: File, source: 'uploaded' | 'recorded') {
return new Promise<WoodenFishAudioAsset>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('音频读取失败,请重试。'));
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error('音频读取失败,请重试。'));
return;
}
resolve({
assetId: `local-${source}-${Date.now()}`,
audioSrc: reader.result,
audioObjectKey: '',
assetObjectId: '',
source,
prompt: file.name,
durationMs: null,
});
};
reader.readAsDataURL(file);
});
}
function WoodenFishAudioInputPanel({
disabled,
prompt,
asset,
onPromptChange,
onAssetChange,
onError,
}: {
disabled: boolean;
prompt: string;
asset: WoodenFishAudioAsset | null;
onPromptChange: (value: string) => void;
onAssetChange: (asset: WoodenFishAudioAsset | null) => void;
onError: (message: string | null) => void;
}) {
const [isRecording, setIsRecording] = useState(false);
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<BlobPart[]>([]);
const startRecording = async () => {
if (disabled || isRecording) {
return;
}
try {
if (
typeof navigator === 'undefined' ||
!navigator.mediaDevices?.getUserMedia ||
typeof MediaRecorder === 'undefined'
) {
throw new Error('当前浏览器不支持录音。');
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
chunksRef.current = [];
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
recorder.onstop = () => {
const blob = new Blob(chunksRef.current, {
type: recorder.mimeType || 'audio/webm',
});
stream.getTracks().forEach((track) => track.stop());
const file = new File([blob], `wooden-fish-hit-${Date.now()}.webm`, {
type: blob.type,
});
void readAudioFileAsAsset(file, 'recorded')
.then(onAssetChange)
.catch((caughtError) => {
onError(
caughtError instanceof Error
? caughtError.message
: '录音保存失败。',
);
});
};
recorderRef.current = recorder;
recorder.start();
setIsRecording(true);
onError(null);
} catch (caughtError) {
onError(
caughtError instanceof Error ? caughtError.message : '录音启动失败。',
);
}
};
const stopRecording = () => {
recorderRef.current?.stop();
recorderRef.current = null;
setIsRecording(false);
};
return (
<section className="platform-subpanel rounded-[1.25rem] p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-black text-[var(--platform-text-strong)]">
</div>
{asset ? (
<button
type="button"
onClick={() => onAssetChange(null)}
disabled={disabled}
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-xs"
>
</button>
) : null}
</div>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<textarea
value={prompt}
disabled={disabled || Boolean(asset)}
onChange={(event) => onPromptChange(event.target.value)}
rows={2}
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>
<div className="mt-3 flex flex-wrap items-center gap-2">
<label
className={`platform-button platform-button--secondary min-h-10 cursor-pointer gap-2 px-3 py-2 text-sm ${
disabled ? 'pointer-events-none opacity-55' : ''
}`}
>
<Upload className="h-4 w-4" />
<input
type="file"
accept="audio/*"
disabled={disabled}
className="sr-only"
onChange={(event) => {
const file = event.currentTarget.files?.[0] ?? null;
event.currentTarget.value = '';
if (!file) {
return;
}
void readAudioFileAsAsset(file, 'uploaded')
.then((nextAsset) => {
onError(null);
onAssetChange(nextAsset);
})
.catch((caughtError) => {
onError(
caughtError instanceof Error
? caughtError.message
: '音频读取失败。',
);
});
}}
/>
</label>
<button
type="button"
disabled={disabled}
onClick={() => {
if (isRecording) {
stopRecording();
return;
}
void startRecording();
}}
className="platform-button platform-button--ghost min-h-10 gap-2 px-3 py-2 text-sm"
>
{isRecording ? (
<Pause className="h-4 w-4" />
) : (
<Mic className="h-4 w-4" />
)}
{isRecording ? '停止' : '录音'}
</button>
{asset?.audioSrc ? (
<audio controls src={asset.audioSrc} className="h-10 max-w-full" />
) : (
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
{asset ? '音效已选择' : '可生成、上传或录制'}
</div>
)}
</div>
</section>
);
}
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 = Boolean(
formState.workTitle.trim() &&
formState.hitObjectPrompt.trim() &&
normalizedFloatingWords.length > 0,
);
const handleSubmit = async () => {
if (!canSubmit || isSubmitting || isBusy) {
setLocalError('请先补全输入。');
return;
}
setIsSubmitting(true);
setLocalError(null);
try {
const payload: WoodenFishWorkspaceCreateRequest = {
templateId: 'wooden-fish',
workTitle: formState.workTitle.trim(),
workDescription: formState.workDescription.trim(),
themeTags: splitTags(formState.themeTags),
hitObjectPrompt: formState.hitObjectPrompt.trim(),
hitObjectReferenceImageSrc:
formState.hitObjectReferenceImageSrc.trim() || null,
hitSoundPrompt: formState.hitSoundAsset
? null
: formState.hitSoundPrompt.trim() || null,
hitSoundAsset: formState.hitSoundAsset,
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 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,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={[]}
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 min-h-0 flex-col gap-3 overflow-y-auto pr-0 lg:pr-1">
<section className="platform-subpanel 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}
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="mt-3 block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<textarea
value={formState.workDescription}
onChange={(event) =>
setFormState((current) => ({
...current,
workDescription: event.target.value,
}))
}
rows={2}
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="mt-3 block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={formState.themeTags}
onChange={(event) =>
setFormState((current) => ({
...current,
themeTags: 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>
</section>
<WoodenFishAudioInputPanel
disabled={isBusy || isSubmitting}
prompt={formState.hitSoundPrompt}
asset={formState.hitSoundAsset}
onPromptChange={(value) =>
setFormState((current) => ({
...current,
hitSoundPrompt: value,
}))
}
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) => (
<input
key={index}
value={word}
maxLength={16}
disabled={isBusy || isSubmitting}
onChange={(event) => {
const nextWords = [...formState.floatingWords];
nextWords[index] = event.target.value;
setFormState((current) => ({
...current,
floatingWords: nextWords.slice(0, 8),
}));
}}
className="w-full rounded-[0.9rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-2.5 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
))}
</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;