From 02fba891cd853492d2ca9c7cd4a065d7c1361e72 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 30 Jul 2026 08:25:57 +0800 Subject: [PATCH] fix: add isMounted guard + use ACCEPTED_MIME constant + dynamic file extension --- .../src/components/voice/CloneModal/index.tsx | 62 +++++++++++-------- 1 file changed, 37 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/voice/CloneModal/index.tsx b/apps/web/src/components/voice/CloneModal/index.tsx index 3465a2733..c220f94fe 100644 --- a/apps/web/src/components/voice/CloneModal/index.tsx +++ b/apps/web/src/components/voice/CloneModal/index.tsx @@ -2,12 +2,21 @@ 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 } from "./constants" +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("") @@ -20,6 +29,8 @@ const CloneModal: React.FC = ({ open, onClose, onSuccess }) => const timerRef = useRef | null>(null) /** 默认音色名称计数器(组件级 ref,避免多实例串号) */ const cloneCounterRef = useRef(1) + /** 组件挂载状态标记,防止卸载后更新 state */ + const isMountedRef = useRef(true) /* ── 录音 Hook ──────────────────────────────────── */ const { @@ -61,9 +72,11 @@ const CloneModal: React.FC = ({ open, onClose, onSuccess }) => } }, [open, resetState]) - /** 组件卸载时清理定时器 */ + /** 组件挂载/卸载标记 + 清理定时器 */ useEffect(() => { + isMountedRef.current = true return () => { + isMountedRef.current = false if (timerRef.current) clearTimeout(timerRef.current) } }, []) @@ -120,29 +133,15 @@ const CloneModal: React.FC = ({ open, onClose, onSuccess }) => } } - /* ── 表单验证 ──────────────────────────────────── */ + /* ── 计算属性 ──────────────────────────────────── */ const hasAudio = selectedFile !== null || recordedBlob !== null - - const canSubmit = voiceName.trim().length >= 2 && voiceName.trim().length <= 20 && hasAudio - const isProcessing = phase === "uploading" || phase === "cloning" + const canSubmit = hasAudio && !isProcessing - /** 当前进度索引 */ - const progressIndex = (() => { - switch (phase) { - case "uploading": - return 0 - case "cloning": - return 1 - case "done": - return 2 - default: - return -1 - } - })() + const progressIndex = PROGRESS_STEPS.findIndex((s) => s.key === phase) - /* ── 提交克隆 ──────────────────────────────────── */ + /* ── 提交 ──────────────────────────────────────── */ const handleSubmit = async () => { const name = voiceName.trim() @@ -165,8 +164,11 @@ const CloneModal: React.FC = ({ open, onClose, onSuccess }) => if (selectedFile) { fileToUpload = selectedFile } else { - fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, { - type: "audio/webm", + // 使用浏览器实际生成的 MIME 类型,避免跨浏览器格式不匹配 + const mimeType = recordedBlob?.type || "audio/webm" + const ext = getExtensionFromMime(mimeType) + fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.${ext}`, { + type: mimeType, }) } @@ -174,6 +176,9 @@ const CloneModal: React.FC = ({ open, onClose, onSuccess }) => formData.append("file", fileToUpload) const uploadResult = await uploadAsset(formData) + // 组件已卸载则中止后续操作 + if (!isMountedRef.current) return + // 阶段 2:克隆 setPhase("cloning") const result = await createVoiceClone({ @@ -182,15 +187,22 @@ const CloneModal: React.FC = ({ open, onClose, onSuccess }) => audio_url: uploadResult.url, }) + // 组件已卸载则中止后续操作 + if (!isMountedRef.current) return + // 阶段 3:完成 setPhase("done") // 2秒后自动关闭 timerRef.current = setTimeout(() => { - onSuccess?.(toVoiceClone(result)) - handleClose() + if (isMountedRef.current) { + onSuccess?.(toVoiceClone(result)) + handleClose() + } }, 2000) } catch (err) { + // 组件已卸载则不更新 state + if (!isMountedRef.current) return setPhase("input") setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试") } @@ -263,7 +275,7 @@ const CloneModal: React.FC = ({ open, onClose, onSuccess }) =>