fix: add isMounted guard + use ACCEPTED_MIME constant + dynamic file extension
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 39s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m42s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m48s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m21s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m48s
AI Code Review / AI Code Review (pull_request) Failing after 2m37s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m23s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m41s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m21s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1m12s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 3m32s
CI/CD Pipeline / CI Gate (pull_request) Failing after 19s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 39s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m42s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m48s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m21s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m48s
AI Code Review / AI Code Review (pull_request) Failing after 2m37s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m23s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m41s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m21s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1m12s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 3m32s
CI/CD Pipeline / CI Gate (pull_request) Failing after 19s
This commit is contained in:
@@ -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<CloneModalProps> = ({ open, onClose, onSuccess }) => {
|
||||
const [phase, setPhase] = useState<ModalPhase>("input")
|
||||
const [voiceName, setVoiceName] = useState("")
|
||||
@@ -20,6 +29,8 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
/** 默认音色名称计数器(组件级 ref,避免多实例串号) */
|
||||
const cloneCounterRef = useRef(1)
|
||||
/** 组件挂载状态标记,防止卸载后更新 state */
|
||||
const isMountedRef = useRef(true)
|
||||
|
||||
/* ── 录音 Hook ──────────────────────────────────── */
|
||||
const {
|
||||
@@ -61,9 +72,11 @@ const CloneModal: React.FC<CloneModalProps> = ({ 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<CloneModalProps> = ({ 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<CloneModalProps> = ({ 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<CloneModalProps> = ({ 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<CloneModalProps> = ({ 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<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={".mp3,.wav,.m4a,audio/mpeg,audio/wav,audio/mp4"}
|
||||
accept={ACCEPTED_MIME}
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user