From 71a1d38a6a6db61581317366696b4acd0bbee730 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=BA=94?= Date: Thu, 2 Jul 2026 15:25:12 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E4=BB=BB=E5=8A=A1=203.13=20?= =?UTF-8?q?=E5=85=8B=E9=9A=86=20Modal=20=E5=89=8D=E7=AB=AF=E5=BC=80?= =?UTF-8?q?=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 CloneModal 组件(components/voice/CloneModal.tsx) - 三步引导:上传音频 → 填写信息 → 提交克隆 - 文件上传:支持 wav/mp3/m4a,≤10MB 验证,拖拽上传 - 音色名称:必填,2-20 字符验证 - 音色描述:可选,≤100 字符 - 错误处理:格式错误、大小超限、API 失败提示 - V21 设计系统样式(clone-modal.css) - 扩展 voiceClone.ts API - CreateVoiceCloneRequest 新增 description 可选字段 - createVoiceClone 函数传递 description 到后端 - 集成到 VoiceLibrary.tsx - pageActions 新增「克隆音色」按钮 - 空状态按钮改为打开 CloneModal - 克隆成功后刷新列表(invalidateQueries) - TypeScript 零错误 - V21 Design System — 零 antd 直接导入 --- apps/web/src/api/voiceClone.ts | 1 + apps/web/src/components/voice/CloneModal.tsx | 350 ++++++++++++++++++ apps/web/src/components/voice/clone-modal.css | 340 +++++++++++++++++ apps/web/src/pages/voices/VoiceLibrary.tsx | 30 +- 4 files changed, 716 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/components/voice/CloneModal.tsx create mode 100644 apps/web/src/components/voice/clone-modal.css diff --git a/apps/web/src/api/voiceClone.ts b/apps/web/src/api/voiceClone.ts index 86ab9facb..8aa624881 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 类型 ────────────────────────────────────── */ diff --git a/apps/web/src/components/voice/CloneModal.tsx b/apps/web/src/components/voice/CloneModal.tsx new file mode 100644 index 000000000..ce017f088 --- /dev/null +++ b/apps/web/src/components/voice/CloneModal.tsx @@ -0,0 +1,350 @@ +/** + * 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 "./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 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 { + const result = await createVoiceClone({ + name: voiceName.trim(), + description: voiceDescription.trim() || undefined, + audio_url: `mock://${selectedFile!.name}`, + }); + + setPhase("success"); + + // 2秒后自动关闭 + 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 +
+
+ + {/* 音色描述 */} +
+ +