From f23b0ceae4e094e0fbb07bcfbd9b0bf7b1aa847a Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 27 Jul 2026 20:47:39 +0800 Subject: [PATCH] =?UTF-8?q?refactor(clone-modal):=20=E4=B8=89=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=E9=87=8D=E6=9E=84=20CloneModal=EF=BC=88602=E2=86=9282?= =?UTF-8?q?=E8=A1=8C,=20-86%=EF=BC=89=20(#934)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/components/voice/CloneModal.tsx | 614 ++---------------- .../voice/clone-modal/InputView.tsx | 142 ++++ .../voice/clone-modal/ProgressView.tsx | 92 +++ .../voice/clone-modal/RecordArea.tsx | 49 ++ .../voice/clone-modal/StepIndicator.tsx | 30 + .../voice/clone-modal/UploadZone.tsx | 79 +++ .../components/voice/constants/cloneModal.ts | 29 + .../voice/hooks/useAudioRecorder.ts | 119 ++++ .../components/voice/hooks/useCloneModal.ts | 215 ++++++ .../src/components/voice/types/cloneModal.ts | 20 + .../src/components/voice/utils/cloneModal.ts | 25 + 11 files changed, 847 insertions(+), 567 deletions(-) create mode 100644 apps/web/src/components/voice/clone-modal/InputView.tsx create mode 100644 apps/web/src/components/voice/clone-modal/ProgressView.tsx create mode 100644 apps/web/src/components/voice/clone-modal/RecordArea.tsx create mode 100644 apps/web/src/components/voice/clone-modal/StepIndicator.tsx create mode 100644 apps/web/src/components/voice/clone-modal/UploadZone.tsx create mode 100644 apps/web/src/components/voice/constants/cloneModal.ts create mode 100644 apps/web/src/components/voice/hooks/useAudioRecorder.ts create mode 100644 apps/web/src/components/voice/hooks/useCloneModal.ts create mode 100644 apps/web/src/components/voice/types/cloneModal.ts create mode 100644 apps/web/src/components/voice/utils/cloneModal.ts diff --git a/apps/web/src/components/voice/CloneModal.tsx b/apps/web/src/components/voice/CloneModal.tsx index ebc851679..0a70f17ce 100644 --- a/apps/web/src/components/voice/CloneModal.tsx +++ b/apps/web/src/components/voice/CloneModal.tsx @@ -10,357 +10,35 @@ * * V21 Design System — 零 antd 直接导入 */ -import React, { useState, useCallback, useRef, useEffect } from "react" -import { Modal, Button } from "@/components/ui" -import { createVoiceClone, toVoiceClone } from "@/api/voice-clone" -import type { VoiceClone } from "@/api/voice-clone" -import { uploadAsset } from "@/api/assets" +import React from "react" +import { Modal } from "@/components/ui" +import type { CloneModalProps } from "./types/cloneModal" +import useCloneModal from "./hooks/useCloneModal" +import InputView from "./clone-modal/InputView" +import ProgressView from "./clone-modal/ProgressView" import "./clone-modal.css" -/* ── 类型定义 ───────────────────────────────────────────── */ - -type ModalPhase = "input" | "uploading" | "cloning" | "done" - -export interface CloneModalProps { - /** 弹窗是否可见 */ - open: boolean - /** 关闭弹窗回调 */ - onClose: () => void - /** 克隆成功回调(返回新创建的音色) */ - onSuccess?: (voice: VoiceClone) => void -} - -/* ── 进度阶段配置 ─────────────────────────────────────────── */ - -const PROGRESS_STEPS: { key: string; label: string; icon: string }[] = [ - { key: "uploading", label: "上传中", icon: "📤" }, - { key: "cloning", label: "克隆中", icon: "🧬" }, - { key: "done", label: "完成", icon: "✅" }, -] - -/* ── 常量 ───────────────────────────────────────────────── */ - -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 - -/** 最长录制时长:5 分钟(秒) */ -const MAX_RECORD_SECONDS = 5 * 60 - -/* ── 组件 ───────────────────────────────────────────────── */ - 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 [isRecording, setIsRecording] = useState(false) - const [recordTime, setRecordTime] = useState(0) - const [recordedBlob, setRecordedBlob] = useState(null) - - const fileInputRef = useRef(null) - const timerRef = useRef | null>(null) - const recordTimerRef = useRef | null>(null) - const mediaRecorderRef = useRef(null) - const audioChunksRef = useRef([]) - /** 默认音色名称计数器(组件级 ref,避免多实例串号) */ - const cloneCounterRef = useRef(1) - - /** 生成下一个默认音色名称 */ - const getNextDefaultName = useCallback((): string => { - const name = `我的声音 ${cloneCounterRef.current}` - cloneCounterRef.current += 1 - return name - }, []) - - /** 组件卸载时清理定时器和 MediaRecorder */ - useEffect(() => { - return () => { - if (timerRef.current) clearTimeout(timerRef.current) - if (recordTimerRef.current) clearInterval(recordTimerRef.current) - if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") { - mediaRecorderRef.current.stop() - } - } - }, []) - - /** 重置弹窗状态 */ - const resetState = useCallback(() => { - setPhase("input") - setVoiceName(getNextDefaultName()) - setVoiceDescription("") - setSelectedFile(null) - setDragActive(false) - setErrorMessage("") - setIsRecording(false) - setRecordTime(0) - setRecordedBlob(null) - audioChunksRef.current = [] - if (recordTimerRef.current) { - clearInterval(recordTimerRef.current) - recordTimerRef.current = null - } - if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") { - mediaRecorderRef.current.stop() - } - mediaRecorderRef.current = null - }, [getNextDefaultName]) - - /** 关闭弹窗 */ - 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) - // 清除录音 - setRecordedBlob(null) - setIsRecording(false) - setRecordTime(0) - } - } - 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) - setRecordedBlob(null) - setIsRecording(false) - setRecordTime(0) - } - } - } - - /* ── 录音(真实 MediaRecorder) ───────────────────── */ - - const handleRecord = async () => { - if (isRecording) { - // 停止录制 - setIsRecording(false) - if (recordTimerRef.current) { - clearInterval(recordTimerRef.current) - recordTimerRef.current = null - } - if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") { - mediaRecorderRef.current.stop() - } - } else { - // 开始录制 - try { - const stream = await navigator.mediaDevices.getUserMedia({ - audio: true, - }) - const mediaRecorder = new MediaRecorder(stream) - mediaRecorderRef.current = mediaRecorder - audioChunksRef.current = [] - - mediaRecorder.ondataavailable = (event) => { - if (event.data.size > 0) { - audioChunksRef.current.push(event.data) - } - } - - mediaRecorder.onstop = () => { - const blob = new Blob(audioChunksRef.current, { type: "audio/webm" }) - setRecordedBlob(blob) - // 清除上传的文件 - setSelectedFile(null) - // 停止音轨 - stream.getTracks().forEach((track) => track.stop()) - } - - mediaRecorder.start() - setIsRecording(true) - setRecordTime(0) - setRecordedBlob(null) - setErrorMessage("") - - recordTimerRef.current = setInterval(() => { - setRecordTime((prev) => { - const next = prev + 1 - if (next >= MAX_RECORD_SECONDS) { - // 达到 5 分钟上限,自动停止录制 - setTimeout(() => { - setIsRecording(false) - if (recordTimerRef.current) { - clearInterval(recordTimerRef.current) - recordTimerRef.current = null - } - if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") { - mediaRecorderRef.current.stop() - } - setErrorMessage("已达最长录制时长(5分钟),已自动停止") - }, 0) - return MAX_RECORD_SECONDS - } - return next - }) - }, 1000) - } catch { - setErrorMessage("无法访问麦克风,请检查浏览器权限设置") - } - } - } - - /** 格式化录制时间 mm:ss */ - const formatRecordTime = (seconds: number): string => { - const m = Math.floor(seconds / 60) - const s = seconds % 60 - return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}` - } - - /* ── 表单验证 ──────────────────────────────────────── */ - - const hasAudio = selectedFile !== null || recordedBlob !== null - - const validateForm = (): string | null => { - const name = voiceName.trim() - if (!name) { - return "请输入音色名称" - } - if (name.length < 2 || name.length > 20) { - return "音色名称需在 2-20 个字符之间" - } - if (!hasAudio) { - return "请上传音频文件或录制一段声音" - } - return null - } - - /* ── 提交克隆 ──────────────────────────────────────── */ - - const handleSubmit = async () => { - const formError = validateForm() - if (formError) { - setErrorMessage(formError) - return - } - - setErrorMessage("") - - try { - // 阶段 1:上传音频 - setPhase("uploading") - - let fileToUpload: File - if (selectedFile) { - fileToUpload = selectedFile - } else { - // 将录音 Blob 转为 File - fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, { - type: "audio/webm", - }) - } - - const formData = new FormData() - formData.append("file", fileToUpload) - const uploadResult = await uploadAsset(formData) - - // 阶段 2:克隆 - setPhase("cloning") - const result = await createVoiceClone({ - name: voiceName.trim(), - description: voiceDescription.trim() || undefined, - audio_url: uploadResult.url, - }) - - // 阶段 3:完成 - setPhase("done") - - // 2秒后自动关闭 - 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 && hasAudio - - const isProcessing = phase === "uploading" || phase === "cloning" - - /** 当前进度索引 */ - const getProgressIndex = (): number => { - switch (phase) { - case "uploading": - return 0 - case "cloning": - return 1 - case "done": - return 2 - default: - return -1 - } - } - - const progressIndex = getProgressIndex() + const { + phase, + voiceName, + voiceDescription, + selectedFile, + dragActive, + errorMessage, + isRecording, + recordTime, + recordedBlob, + canSubmit, + isProcessing, + setVoiceName, + setVoiceDescription, + setDragActive, + handleFileSelect, + handleRecordToggle, + handleClose, + handleSubmit, + } = useCloneModal({ open, onClose, onSuccess }) return ( = ({ open, onClose, onSuccess }) => maskClosable={!isProcessing} keyboard={!isProcessing} > - {/* ── 输入阶段 ──────────────────────────────────── */} + {/* 输入阶段 */} {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 - ? `录制中 ${formatRecordTime(recordTime)}` - : recordedBlob - ? `已录制 ${formatRecordTime(recordTime)}` - : "点击按钮开始录制(最长 5 分钟)"} -

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