613c75d041
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m54s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m5s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m33s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m18s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m22s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m27s
CI/CD Pipeline / Integration Tests (push) Successful in 3m28s
CI/CD Pipeline / Unit Tests (push) Failing after 10m19s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 14m30s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 33s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 35s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m42s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m51s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
461 lines
17 KiB
TypeScript
461 lines
17 KiB
TypeScript
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, 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("")
|
|
const [voiceDescription, setVoiceDescription] = useState("")
|
|
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
|
const [dragActive, setDragActive] = useState(false)
|
|
const [errorMessage, setErrorMessage] = useState("")
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
/** 默认音色名称计数器(组件级 ref,避免多实例串号) */
|
|
const cloneCounterRef = useRef(1)
|
|
/** 组件挂载状态标记,防止卸载后更新 state */
|
|
const isMountedRef = useRef(true)
|
|
const isSubmittingRef = useRef(false)
|
|
|
|
/* ── 录音 Hook ──────────────────────────────────── */
|
|
const {
|
|
isRecording,
|
|
recordedBlob,
|
|
formattedTime,
|
|
toggleRecord,
|
|
reset: resetRecorder,
|
|
} = useAudioRecorder(setErrorMessage)
|
|
|
|
/** 生成下一个默认音色名称 */
|
|
const getNextDefaultName = useCallback((): string => {
|
|
const name = `我的声音 ${cloneCounterRef.current}`
|
|
cloneCounterRef.current += 1
|
|
return name
|
|
}, [])
|
|
|
|
/** 重置弹窗状态 */
|
|
const resetState = useCallback(() => {
|
|
setPhase("input")
|
|
setVoiceName(getNextDefaultName())
|
|
setVoiceDescription("")
|
|
setSelectedFile(null)
|
|
setDragActive(false)
|
|
if (isSubmittingRef.current) return
|
|
isSubmittingRef.current = true
|
|
setErrorMessage("")
|
|
resetRecorder()
|
|
}, [getNextDefaultName, resetRecorder])
|
|
|
|
/** 关闭弹窗 */
|
|
const handleClose = useCallback(() => {
|
|
resetState()
|
|
onClose()
|
|
}, [resetState, onClose])
|
|
|
|
/** 弹窗打开时重置状态 */
|
|
useEffect(() => {
|
|
if (open) {
|
|
resetState()
|
|
}
|
|
}, [open, resetState])
|
|
|
|
/** 组件挂载/卸载标记 + 清理定时器 */
|
|
useEffect(() => {
|
|
isMountedRef.current = true
|
|
return () => {
|
|
isMountedRef.current = false
|
|
if (timerRef.current) clearTimeout(timerRef.current)
|
|
}
|
|
}, [])
|
|
|
|
/* ── 文件上传 ──────────────────────────────────── */
|
|
|
|
const handleUploadClick = () => {
|
|
fileInputRef.current?.click()
|
|
}
|
|
|
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0]
|
|
if (file) {
|
|
const error = validateFile(file)
|
|
if (error) {
|
|
setErrorMessage(error)
|
|
setSelectedFile(null)
|
|
} else {
|
|
if (isSubmittingRef.current) return
|
|
isSubmittingRef.current = true
|
|
setErrorMessage("")
|
|
setSelectedFile(file)
|
|
resetRecorder()
|
|
}
|
|
}
|
|
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 {
|
|
if (isSubmittingRef.current) return
|
|
isSubmittingRef.current = true
|
|
setErrorMessage("")
|
|
setSelectedFile(file)
|
|
resetRecorder()
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ── 计算属性 ──────────────────────────────────── */
|
|
|
|
const hasAudio = selectedFile !== null || recordedBlob !== null
|
|
const isProcessing = phase === "uploading" || phase === "cloning"
|
|
const canSubmit = hasAudio && !isProcessing
|
|
|
|
const progressIndex = PROGRESS_STEPS.findIndex((s) => s.key === phase)
|
|
|
|
/* ── 提交 ──────────────────────────────────────── */
|
|
|
|
const handleSubmit = async () => {
|
|
const name = voiceName.trim()
|
|
if (!name || name.length < 2 || name.length > 20) {
|
|
setErrorMessage("音色名称需在 2-20 个字符之间")
|
|
return
|
|
}
|
|
if (!hasAudio) {
|
|
setErrorMessage("请上传音频文件或录制一段声音")
|
|
return
|
|
}
|
|
|
|
if (isSubmittingRef.current) return
|
|
isSubmittingRef.current = true
|
|
setErrorMessage("")
|
|
|
|
try {
|
|
// 阶段 1:上传音频
|
|
setPhase("uploading")
|
|
|
|
let fileToUpload: File
|
|
if (selectedFile) {
|
|
fileToUpload = selectedFile
|
|
} else {
|
|
// 使用浏览器实际生成的 MIME 类型,避免跨浏览器格式不匹配
|
|
const mimeType = recordedBlob?.type || "audio/webm"
|
|
const ext = getExtensionFromMime(mimeType)
|
|
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.${ext}`, {
|
|
type: mimeType,
|
|
})
|
|
}
|
|
|
|
const formData = new FormData()
|
|
formData.append("file", fileToUpload)
|
|
const uploadResult = await uploadAsset(formData)
|
|
|
|
// 组件已卸载则中止后续操作
|
|
if (!isMountedRef.current) return
|
|
|
|
// 阶段 2:克隆
|
|
setPhase("cloning")
|
|
const result = await createVoiceClone({
|
|
name,
|
|
description: voiceDescription.trim() || undefined,
|
|
audio_url: uploadResult.url,
|
|
})
|
|
|
|
// 组件已卸载则中止后续操作
|
|
if (!isMountedRef.current) return
|
|
|
|
isSubmittingRef.current = false
|
|
// 阶段 3:完成
|
|
setPhase("done")
|
|
|
|
// 2秒后自动关闭
|
|
timerRef.current = setTimeout(() => {
|
|
if (isMountedRef.current) {
|
|
onSuccess?.(toVoiceClone(result))
|
|
handleClose()
|
|
}
|
|
}, 2000)
|
|
} catch (err) {
|
|
isSubmittingRef.current = false
|
|
// 组件已卸载则不更新 state
|
|
if (!isMountedRef.current) return
|
|
setPhase("input")
|
|
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试")
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Modal
|
|
open={open}
|
|
onCancel={handleClose}
|
|
title="克隆我的音色"
|
|
width={560}
|
|
footer={null}
|
|
destroyOnClose
|
|
maskClosable={!isProcessing}
|
|
keyboard={!isProcessing}
|
|
>
|
|
{/* ── 输入阶段 ────────────────────────────────── */}
|
|
{phase === "input" && (
|
|
<div className="xx-clonemodal-body">
|
|
{/* 步骤引导 */}
|
|
<div className="xx-clonemodal-steps">
|
|
<div className="xx-clonemodal-step xx-clonemodal-step--active">
|
|
<div className="xx-clonemodal-step-number">1</div>
|
|
<span className="xx-clonemodal-step-label">上传/录制音频</span>
|
|
</div>
|
|
<div className="xx-clonemodal-step-connector" />
|
|
<div className="xx-clonemodal-step">
|
|
<div className="xx-clonemodal-step-number">2</div>
|
|
<span className="xx-clonemodal-step-label">填写信息</span>
|
|
</div>
|
|
<div className="xx-clonemodal-step-connector" />
|
|
<div className="xx-clonemodal-step">
|
|
<div className="xx-clonemodal-step-number">3</div>
|
|
<span className="xx-clonemodal-step-label">提交克隆</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 音色名称 */}
|
|
<div className="xx-clonemodal-field">
|
|
<label className="xx-clonemodal-label">
|
|
音色名称 <span className="xx-clonemodal-required">*</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
className="xx-clonemodal-input"
|
|
value={voiceName}
|
|
onChange={(e) => setVoiceName(e.target.value)}
|
|
placeholder="输入音色名称(2-20字符)"
|
|
maxLength={20}
|
|
/>
|
|
<div className="xx-clonemodal-char-count">{voiceName.length}/20</div>
|
|
</div>
|
|
|
|
{/* 上传区域 */}
|
|
<div className="xx-clonemodal-field">
|
|
<label className="xx-clonemodal-label">上传音频</label>
|
|
<div
|
|
className={`xx-clonemodal-upload-zone${dragActive ? " xx-clonemodal-upload-zone--active" : ""}${selectedFile ? " xx-clonemodal-upload-zone--has-file" : ""}`}
|
|
onClick={handleUploadClick}
|
|
onDragEnter={handleDrag}
|
|
onDragOver={handleDrag}
|
|
onDragLeave={handleDrag}
|
|
onDrop={handleDrop}
|
|
>
|
|
<div className="xx-clonemodal-upload-icon">{selectedFile ? "📄" : "🎵"}</div>
|
|
<p className="xx-clonemodal-upload-title">
|
|
{selectedFile ? selectedFile.name : "拖拽音频文件到此处,或点击上传"}
|
|
</p>
|
|
<p className="xx-clonemodal-upload-hint">支持 MP3、WAV、M4A 格式,最大 10MB</p>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept={ACCEPTED_MIME}
|
|
style={{ display: "none" }}
|
|
onChange={handleFileChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 或分隔 */}
|
|
<div className="xx-clonemodal-divider">
|
|
<div className="xx-clonemodal-divider-line" />
|
|
<span className="xx-clonemodal-divider-text">或</span>
|
|
<div className="xx-clonemodal-divider-line" />
|
|
</div>
|
|
|
|
{/* 录制区域 */}
|
|
<div className="xx-clonemodal-field">
|
|
<label className="xx-clonemodal-label">直接录制</label>
|
|
<div className="xx-clonemodal-record-area">
|
|
<div className="xx-clonemodal-record-info">
|
|
<p className="xx-clonemodal-record-hint">
|
|
{isRecording
|
|
? `录制中 ${formattedTime}`
|
|
: recordedBlob
|
|
? `已录制 ${formattedTime}`
|
|
: "点击按钮开始录制(最长 5 分钟)"}
|
|
</p>
|
|
{isRecording && (
|
|
<div className="xx-clonemodal-record-wave">
|
|
<span className="xx-clonemodal-record-wave-bar" />
|
|
<span className="xx-clonemodal-record-wave-bar" />
|
|
<span className="xx-clonemodal-record-wave-bar" />
|
|
<span className="xx-clonemodal-record-wave-bar" />
|
|
<span className="xx-clonemodal-record-wave-bar" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={`xx-clonemodal-record-btn${isRecording ? " xx-clonemodal-record-btn--recording" : ""}`}
|
|
onClick={toggleRecord}
|
|
title={isRecording ? "停止录制" : "开始录制"}
|
|
>
|
|
{isRecording ? "⏹" : "🎙️"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 音色描述 */}
|
|
<div className="xx-clonemodal-field">
|
|
<label className="xx-clonemodal-label">音色描述</label>
|
|
<textarea
|
|
className="xx-clonemodal-textarea"
|
|
value={voiceDescription}
|
|
onChange={(e) => setVoiceDescription(e.target.value)}
|
|
placeholder="可选,描述这个音色的特点(最多100字符)"
|
|
maxLength={100}
|
|
rows={3}
|
|
/>
|
|
<div className="xx-clonemodal-char-count">{voiceDescription.length}/100</div>
|
|
</div>
|
|
|
|
{/* 错误提示 */}
|
|
{errorMessage && (
|
|
<div className="xx-clonemodal-error">
|
|
<span className="xx-clonemodal-error-icon">⚠️</span>
|
|
<span>{errorMessage}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* 提示 */}
|
|
<div className="xx-clonemodal-tip">
|
|
<span className="xx-clonemodal-tip-icon">💡</span>
|
|
<span>建议上传 10 秒 ~ 3 分钟的清晰语音,环境安静、语速均匀效果最佳</span>
|
|
</div>
|
|
|
|
{/* 底部按钮 */}
|
|
<div className="xx-clonemodal-footer">
|
|
<Button buttonType="ghost" onClick={handleClose}>
|
|
取消
|
|
</Button>
|
|
<Button buttonType="primary" disabled={!canSubmit} onClick={handleSubmit}>
|
|
🎤 开始克隆
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── 进度阶段(上传中 / 克隆中) ────────────── */}
|
|
{isProcessing && (
|
|
<div className="xx-clonemodal-progress-body">
|
|
{/* 步骤指示器 */}
|
|
<div className="xx-clonemodal-steps-progress">
|
|
{PROGRESS_STEPS.map((step, idx) => {
|
|
const isActive = idx === progressIndex
|
|
const isDone = idx < progressIndex
|
|
const stepClass = [
|
|
"xx-clonemodal-step-progress",
|
|
isActive ? "xx-clonemodal-step-progress--active" : "",
|
|
isDone ? "xx-clonemodal-step-progress--done" : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
|
|
return (
|
|
<React.Fragment key={step.key}>
|
|
{idx > 0 && (
|
|
<div
|
|
className={`xx-clonemodal-step-connector${isDone ? " xx-clonemodal-step-connector--done" : ""}`}
|
|
/>
|
|
)}
|
|
<div className={stepClass}>
|
|
<div className="xx-clonemodal-step-icon">{isDone ? "✓" : step.icon}</div>
|
|
<span className="xx-clonemodal-step-label">{step.label}</span>
|
|
</div>
|
|
</React.Fragment>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* 当前阶段描述 */}
|
|
<div className="xx-clonemodal-progress-info">
|
|
{phase === "uploading" && (
|
|
<>
|
|
<div className="xx-clonemodal-progress-spinner" />
|
|
<p className="xx-clonemodal-progress-text">正在上传音频文件…</p>
|
|
<p className="xx-clonemodal-progress-sub">请稍候,正在将音频上传至服务器</p>
|
|
</>
|
|
)}
|
|
{phase === "cloning" && (
|
|
<>
|
|
<div className="xx-clonemodal-progress-spinner xx-clonemodal-progress-spinner--cloning" />
|
|
<p className="xx-clonemodal-progress-text">AI 正在克隆你的声音…</p>
|
|
<p className="xx-clonemodal-progress-sub">正在分析声音特征,生成专属音色模型</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── 完成阶段 ────────────────────────────────── */}
|
|
{phase === "done" && (
|
|
<div className="xx-clonemodal-progress-body">
|
|
{/* 步骤指示器(全部完成) */}
|
|
<div className="xx-clonemodal-steps-progress">
|
|
{PROGRESS_STEPS.map((step, idx) => (
|
|
<React.Fragment key={step.key}>
|
|
{idx > 0 && (
|
|
<div className="xx-clonemodal-step-connector xx-clonemodal-step-connector--done" />
|
|
)}
|
|
<div className="xx-clonemodal-step-progress xx-clonemodal-step-progress--done">
|
|
<div className="xx-clonemodal-step-icon">✓</div>
|
|
<span className="xx-clonemodal-step-label">{step.label}</span>
|
|
</div>
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
|
|
<div className="xx-clonemodal-success">
|
|
<div className="xx-clonemodal-success-icon">🎉</div>
|
|
<h3 className="xx-clonemodal-success-title">克隆已提交</h3>
|
|
<p className="xx-clonemodal-success-desc">
|
|
音色正在生成中,完成后将出现在「我的克隆」列表中
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
export default CloneModal
|
|
export type { CloneModalProps, ModalPhase } from "./types"
|