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

This commit is contained in:
2026-07-30 08:25:57 +08:00
parent 6cce7e9e08
commit 02fba891cd
@@ -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}
/>