diff --git a/apps/web/src/api/voiceClone.ts b/apps/web/src/api/voiceClone.ts index 86ab9facb..1dc475d81 100644 --- a/apps/web/src/api/voiceClone.ts +++ b/apps/web/src/api/voiceClone.ts @@ -28,6 +28,7 @@ export interface VoiceClone { export interface CreateVoiceCloneRequest { name: string; audio_url: string; + description?: string; } /* ── 后端 API 类型 ────────────────────────────────────── */ @@ -160,6 +161,7 @@ export const createVoiceClone = async ( ): Promise => { const payload: CreateVoiceCloneRequestFull = { name: data.name, + description: data.description, source_audio_url: data.audio_url, }; const response = await apiClient.post( diff --git a/apps/web/src/components/voice/CloneModal.tsx b/apps/web/src/components/voice/CloneModal.tsx new file mode 100644 index 000000000..479c8a75b --- /dev/null +++ b/apps/web/src/components/voice/CloneModal.tsx @@ -0,0 +1,366 @@ +/** + * CloneModal — 音色克隆弹窗 + * 任务 3.13:实现克隆 Modal,用户点击「克隆音色」按钮后弹出 + * + * 功能: + * - 步骤引导:上传音频 → 填写信息 → 提交克隆 + * - 表单字段:音频文件上传(wav/mp3/m4a,≤10MB)、音色名称(必填,2-20字符)、音色描述(可选,≤100字符) + * - 提交后调用 POST /api/v1/voice-clones 创建克隆 + * - 创建成功后关闭 Modal,刷新配音列表 + * - 错误处理:上传失败、格式错误、大小超限等提示 + * + * V21 Design System — 零 antd 直接导入 + */ +import React, { useState, useCallback, useRef, useEffect } from "react"; +import { Modal, Button } from "@/components/ui"; +import { createVoiceClone, toVoiceClone } from "@/api/voiceClone"; +import type { VoiceClone } from "@/api/voiceClone"; +import { uploadAsset } from "@/api/assets"; +import "./clone-modal.css"; + +/* ── 类型定义 ───────────────────────────────────────────── */ + +type ModalPhase = "input" | "uploading" | "success"; + +export interface CloneModalProps { + /** 弹窗是否可见 */ + open: boolean; + /** 关闭弹窗回调 */ + onClose: () => void; + /** 克隆成功回调(返回新创建的音色) */ + onSuccess?: (voice: VoiceClone) => void; +} + +/* ── 常量 ───────────────────────────────────────────────── */ + +const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a"]; +const ACCEPTED_MIME = ".mp3,.wav,.m4a,audio/mpeg,audio/wav,audio/mp4"; +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + +/* ── 组件 ───────────────────────────────────────────────── */ + +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); + + /** 组件卸载时清理定时器(P2-2 修复) */ + useEffect(() => { + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }; + }, []); + + /** 重置弹窗状态 */ + const resetState = useCallback(() => { + setPhase("input"); + setVoiceName(""); + setVoiceDescription(""); + setSelectedFile(null); + setDragActive(false); + setErrorMessage(""); + }, []); + + /** 关闭弹窗 */ + const handleClose = useCallback(() => { + resetState(); + onClose(); + }, [resetState, onClose]); + + /** 弹窗打开时重置状态 */ + useEffect(() => { + if (open) { + resetState(); + } + }, [open, resetState]); + + /* ── 文件验证 ──────────────────────────────────────── */ + + const validateFile = (file: File): string | null => { + const ext = file.name.split(".").pop()?.toLowerCase(); + if (!ext || !ACCEPTED_EXTENSIONS.includes(ext)) { + return "不支持的音频格式,请上传 MP3、WAV 或 M4A 文件"; + } + if (file.size > MAX_FILE_SIZE) { + return "文件大小超过 10MB,请压缩后重试"; + } + return null; + }; + + /* ── 文件上传 ──────────────────────────────────────── */ + + 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 { + setErrorMessage(""); + setSelectedFile(file); + } + } + 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 { + setErrorMessage(""); + setSelectedFile(file); + } + } + }; + + /* ── 表单验证 ──────────────────────────────────────── */ + + const validateForm = (): string | null => { + const name = voiceName.trim(); + if (!name) { + return "请输入音色名称"; + } + if (name.length < 2 || name.length > 20) { + return "音色名称需在 2-20 个字符之间"; + } + if (!selectedFile) { + return "请上传音频文件"; + } + return null; + }; + + /* ── 提交克隆 ──────────────────────────────────────── */ + + const handleSubmit = async () => { + const formError = validateForm(); + if (formError) { + setErrorMessage(formError); + return; + } + + setPhase("uploading"); + setErrorMessage(""); + + try { + // P1 修复:先上传音频文件获取真实 URL,再调用克隆 API + const formData = new FormData(); + formData.append("file", selectedFile!); + const uploadResult = await uploadAsset(formData); + + const result = await createVoiceClone({ + name: voiceName.trim(), + description: voiceDescription.trim() || undefined, + audio_url: uploadResult.url, + }); + + setPhase("success"); + + // 2秒后自动关闭(P2-2 修复:使用 timerRef 以便 cleanup) + timerRef.current = setTimeout(() => { + onSuccess?.(toVoiceClone(result)); + handleClose(); + }, 2000); + } catch (err) { + setPhase("input"); + setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试"); + } + }; + + /* ── 计算属性 ──────────────────────────────────────── */ + + const canSubmit = + voiceName.trim().length >= 2 && + voiceName.trim().length <= 20 && + selectedFile !== null; + + const isProcessing = phase === "uploading"; + + return ( + + {/* ── 输入阶段 ──────────────────────────────────── */} + {phase === "input" && ( +
+ {/* 步骤引导 */} +
+
+
1
+ 上传音频 +
+
+
+
2
+ 填写信息 +
+
+
+
3
+ 提交克隆 +
+
+ + {/* 上传区域 */} +
+ +
+
+ {selectedFile ? "📄" : "🎵"} +
+

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

+

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

+ +
+
+ + {/* 音色名称 */} +
+ + setVoiceName(e.target.value)} + placeholder="输入音色名称(2-20字符)" + maxLength={20} + /> +
+ {voiceName.length}/20 +
+
+ + {/* 音色描述 */} +
+ +