import React, { useState, useCallback, useRef, useEffect } from "react" import { Modal, Button } from "@/components/ui" import { createVoiceClone, toVoiceClone } from "@/api/voice-clone" import { uploadAsset } from "@/api/assets" import { PROGRESS_STEPS, ACCEPTED_MIME } from "./constants" import { validateFile } from "./utils" import { useAudioRecorder } from "./hooks/useAudioRecorder" import type { CloneModalProps, ModalPhase } from "./types" import "./clone-modal.css" /** 根据 MIME 类型推断文件扩展名 */ const getExtensionFromMime = (mime: string): string => { if (mime.includes("webm")) return "webm" if (mime.includes("mp4") || mime.includes("m4a")) return "m4a" if (mime.includes("ogg")) return "ogg" if (mime.includes("wav")) return "wav" return "webm" } const CloneModal: React.FC = ({ open, onClose, onSuccess }) => { const [phase, setPhase] = useState("input") const [voiceName, setVoiceName] = useState("") const [voiceDescription, setVoiceDescription] = useState("") const [selectedFile, setSelectedFile] = useState(null) const [dragActive, setDragActive] = useState(false) const [errorMessage, setErrorMessage] = useState("") const fileInputRef = useRef(null) const timerRef = useRef | null>(null) /** 默认音色名称计数器(组件级 ref,避免多实例串号) */ const cloneCounterRef = useRef(1) /** 组件挂载状态标记,防止卸载后更新 state */ const isMountedRef = useRef(true) const isSubmittingRef = useRef(false) /* ── 录音 Hook ──────────────────────────────────── */ const { isRecording, recordedBlob, formattedTime, toggleRecord, reset: resetRecorder, } = useAudioRecorder(setErrorMessage) /** 生成下一个默认音色名称 */ const getNextDefaultName = useCallback((): string => { const name = `我的声音 ${cloneCounterRef.current}` cloneCounterRef.current += 1 return name }, []) /** 重置弹窗状态 */ const resetState = useCallback(() => { setPhase("input") setVoiceName(getNextDefaultName()) setVoiceDescription("") setSelectedFile(null) setDragActive(false) if (isSubmittingRef.current) return isSubmittingRef.current = true setErrorMessage("") resetRecorder() }, [getNextDefaultName, resetRecorder]) /** 关闭弹窗 */ const handleClose = useCallback(() => { resetState() onClose() }, [resetState, onClose]) /** 弹窗打开时重置状态 */ useEffect(() => { if (open) { resetState() } }, [open, resetState]) /** 组件挂载/卸载标记 + 清理定时器 */ useEffect(() => { isMountedRef.current = true return () => { isMountedRef.current = false if (timerRef.current) clearTimeout(timerRef.current) } }, []) /* ── 文件上传 ──────────────────────────────────── */ const handleUploadClick = () => { fileInputRef.current?.click() } const handleFileChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (file) { const error = validateFile(file) if (error) { setErrorMessage(error) setSelectedFile(null) } else { if (isSubmittingRef.current) return isSubmittingRef.current = true setErrorMessage("") setSelectedFile(file) resetRecorder() } } e.target.value = "" } /* ── 拖拽 ──────────────────────────────────────── */ const handleDrag = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() if (e.type === "dragenter" || e.type === "dragover") { setDragActive(true) } else if (e.type === "dragleave") { setDragActive(false) } } const handleDrop = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() setDragActive(false) const file = e.dataTransfer.files?.[0] if (file) { const error = validateFile(file) if (error) { setErrorMessage(error) setSelectedFile(null) } else { if (isSubmittingRef.current) return isSubmittingRef.current = true setErrorMessage("") setSelectedFile(file) resetRecorder() } } } /* ── 计算属性 ──────────────────────────────────── */ const hasAudio = selectedFile !== null || recordedBlob !== null const isProcessing = phase === "uploading" || phase === "cloning" const canSubmit = hasAudio && !isProcessing const progressIndex = PROGRESS_STEPS.findIndex((s) => s.key === phase) /* ── 提交 ──────────────────────────────────────── */ const handleSubmit = async () => { const name = voiceName.trim() if (!name || name.length < 2 || name.length > 20) { setErrorMessage("音色名称需在 2-20 个字符之间") return } if (!hasAudio) { setErrorMessage("请上传音频文件或录制一段声音") return } if (isSubmittingRef.current) return isSubmittingRef.current = true setErrorMessage("") try { // 阶段 1:上传音频 setPhase("uploading") let fileToUpload: File if (selectedFile) { fileToUpload = selectedFile } else { // 使用浏览器实际生成的 MIME 类型,避免跨浏览器格式不匹配 const mimeType = recordedBlob?.type || "audio/webm" const ext = getExtensionFromMime(mimeType) fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.${ext}`, { type: mimeType, }) } const formData = new FormData() formData.append("file", fileToUpload) const uploadResult = await uploadAsset(formData) // 组件已卸载则中止后续操作 if (!isMountedRef.current) return // 阶段 2:克隆 setPhase("cloning") const result = await createVoiceClone({ name, description: voiceDescription.trim() || undefined, audio_url: uploadResult.url, }) // 组件已卸载则中止后续操作 if (!isMountedRef.current) return isSubmittingRef.current = false // 阶段 3:完成 setPhase("done") // 2秒后自动关闭 timerRef.current = setTimeout(() => { if (isMountedRef.current) { onSuccess?.(toVoiceClone(result)) handleClose() } }, 2000) } catch (err) { isSubmittingRef.current = false // 组件已卸载则不更新 state if (!isMountedRef.current) return setPhase("input") setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试") } } return ( {/* ── 输入阶段 ────────────────────────────────── */} {phase === "input" && (
{/* 步骤引导 */}
1
上传/录制音频
2
填写信息
3
提交克隆
{/* 音色名称 */}
setVoiceName(e.target.value)} placeholder="输入音色名称(2-20字符)" maxLength={20} />
{voiceName.length}/20
{/* 上传区域 */}
{selectedFile ? "📄" : "🎵"}

{selectedFile ? selectedFile.name : "拖拽音频文件到此处,或点击上传"}

支持 MP3、WAV、M4A 格式,最大 10MB

{/* 或分隔 */}
或
{/* 录制区域 */}

{isRecording ? `录制中 ${formattedTime}` : recordedBlob ? `已录制 ${formattedTime}` : "点击按钮开始录制(最长 5 分钟)"}

{isRecording && (
)}
{/* 音色描述 */}