fix(voices): 配音库四连修复——进度条隔离/TTS克隆音色/克隆弹窗素材选择 #1554
@@ -44,14 +44,19 @@ export const getVoiceCloneDetail = async (id: string): Promise<VoiceCloneProfile
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建克隆音色 */
|
||||
/** 创建克隆音色(audio_url 与 asset_id 二选一) */
|
||||
export const createVoiceClone = async (
|
||||
data: CreateVoiceCloneRequest,
|
||||
): Promise<VoiceCloneProfile> => {
|
||||
const payload: CreateVoiceCloneRequestFull = {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
source_audio_url: data.audio_url,
|
||||
}
|
||||
// 从配音素材选择克隆:直接传 asset_id,后端用素材 OSS 路径克隆
|
||||
if (data.asset_id) {
|
||||
payload.asset_id = data.asset_id
|
||||
} else {
|
||||
payload.source_audio_url = data.audio_url
|
||||
}
|
||||
const response = await apiClient.post<VoiceCloneProfile>("/voice-clones", payload)
|
||||
return response.data
|
||||
|
||||
@@ -22,10 +22,13 @@ export interface VoiceClone {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 创建克隆请求(前端简化版) */
|
||||
/** 创建克隆请求(前端简化版:audio_url 与 asset_id 二选一) */
|
||||
export interface CreateVoiceCloneRequest {
|
||||
name: string
|
||||
audio_url: string
|
||||
/** 录音/文件上传后的音频 URL(与 asset_id 二选一) */
|
||||
audio_url?: string
|
||||
/** 从配音素材选择时直接传素材 ID,后端用素材 OSS 路径克隆(与 audio_url 二选一) */
|
||||
asset_id?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
@@ -72,11 +75,13 @@ export interface VoiceCloneStatusResponse {
|
||||
retry_count: number
|
||||
}
|
||||
|
||||
/** 后端创建克隆请求(完整版) */
|
||||
/** 后端创建克隆请求(完整版:source_audio_url 与 asset_id 二选一) */
|
||||
export interface CreateVoiceCloneRequestFull {
|
||||
name: string
|
||||
description?: string
|
||||
source_audio_url: string
|
||||
source_audio_url?: string
|
||||
/** 从配音素材选择克隆时传素材 ID */
|
||||
asset_id?: string
|
||||
voice_model?: string
|
||||
language?: string
|
||||
gender?: string
|
||||
|
||||
@@ -149,53 +149,29 @@
|
||||
|
||||
/* ── 上传区域 ───────────────────────────────────────────── */
|
||||
|
||||
.xx-clonemodal-upload-zone {
|
||||
border: 2px dashed var(--xx-color-border, #e5e7eb);
|
||||
/* ── 素材选择空态 ─────────────────────────────────────────── */
|
||||
|
||||
.xx-clonemodal-asset-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border: 1px dashed var(--xx-color-border, #e5e7eb);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 28px 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: var(--xx-color-bg-secondary, #f9fafb);
|
||||
}
|
||||
|
||||
.xx-clonemodal-upload-zone:hover {
|
||||
border-color: var(--xx-color-primary, #6366f1);
|
||||
background: rgba(99, 102, 241, 0.03);
|
||||
}
|
||||
|
||||
.xx-clonemodal-upload-zone--active {
|
||||
border-color: var(--xx-color-primary, #6366f1);
|
||||
background: rgba(99, 102, 241, 0.06);
|
||||
}
|
||||
|
||||
.xx-clonemodal-upload-zone--has-file {
|
||||
border-style: solid;
|
||||
border-color: var(--xx-color-primary, #6366f1);
|
||||
background: rgba(99, 102, 241, 0.04);
|
||||
}
|
||||
|
||||
.xx-clonemodal-upload-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-upload-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--xx-color-text, #111827);
|
||||
margin: 0 0 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.xx-clonemodal-upload-hint {
|
||||
font-size: 12px;
|
||||
color: var(--xx-color-text-secondary, #6b7280);
|
||||
.xx-clonemodal-asset-empty-text {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--xx-color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
/* ── 错误提示 ───────────────────────────────────────────── */
|
||||
|
||||
/* ── 错误提示 ───────────────────────────────────────────── */
|
||||
|
||||
.xx-clonemodal-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -7,14 +7,5 @@ export const PROGRESS_STEPS: ProgressStep[] = [
|
||||
{ key: "done", label: "完成", icon: "✅" },
|
||||
]
|
||||
|
||||
/** 支持的音频扩展名 */
|
||||
export const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a", "webm"]
|
||||
|
||||
/** 文件选择器 accept 属性 */
|
||||
export const ACCEPTED_MIME = ".mp3,.wav,.m4a,.webm,audio/mpeg,audio/wav,audio/mp4,audio/webm"
|
||||
|
||||
/** 最大文件大小:10MB */
|
||||
export const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
/** 最长录制时长:5 分钟(秒) */
|
||||
export const MAX_RECORD_SECONDS = 5 * 60
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Modal, Button } from "@/components/ui"
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
||||
import { uploadAssetDirect, ensureDefaultLibrary } from "@/api/assets"
|
||||
import { uploadAssetDirect, ensureDefaultLibrary, getAssetsByKind } from "@/api/assets"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
import { PROGRESS_STEPS, ACCEPTED_MIME } from "./constants"
|
||||
import { validateFile } from "./utils"
|
||||
import { PROGRESS_STEPS } from "./constants"
|
||||
import { formatRecordTime } from "./utils"
|
||||
import { useAudioRecorder } from "./hooks/useAudioRecorder"
|
||||
import type { CloneModalProps, ModalPhase } from "./types"
|
||||
import "./clone-modal.css"
|
||||
@@ -18,15 +20,21 @@ const getExtensionFromMime = (mime: string): string => {
|
||||
return "webm"
|
||||
}
|
||||
|
||||
/** 格式化素材时长(秒 → mm:ss) */
|
||||
const formatAssetDuration = (seconds?: number): string => {
|
||||
if (!seconds || seconds <= 0) return "--:--"
|
||||
return formatRecordTime(Math.round(seconds))
|
||||
}
|
||||
|
||||
const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) => {
|
||||
const navigate = useNavigate()
|
||||
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)
|
||||
/** 从配音素材选择的素材 ID */
|
||||
const [selectedAssetId, setSelectedAssetId] = useState<string>("")
|
||||
const [errorMessage, setErrorMessage] = useState("")
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
/** 默认音色名称计数器(组件级 ref,避免多实例串号) */
|
||||
const cloneCounterRef = useRef(1)
|
||||
@@ -34,6 +42,14 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
const isMountedRef = useRef(true)
|
||||
const isSubmittingRef = useRef(false)
|
||||
|
||||
/* ── 配音素材列表(「从配音素材选择」;弹窗打开时才发请求) ────── */
|
||||
const { data: voiceAssets, isLoading: assetsLoading } = useQuery({
|
||||
queryKey: ["assets", "voice", "clone-modal"],
|
||||
queryFn: () => getAssetsByKind("voice", { limit: 100 }),
|
||||
enabled: open,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
/* ── 录音 Hook ──────────────────────────────────── */
|
||||
const {
|
||||
isRecording,
|
||||
@@ -55,8 +71,7 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
setPhase("input")
|
||||
setVoiceName(getNextDefaultName())
|
||||
setVoiceDescription("")
|
||||
setSelectedFile(null)
|
||||
setDragActive(false)
|
||||
setSelectedAssetId("")
|
||||
// 注意:resetState 不得触碰 isSubmittingRef——提交锁仅属于 handleSubmit;
|
||||
// 此前在此上锁且无复位路径,弹窗打开即死锁
|
||||
setErrorMessage("")
|
||||
@@ -85,65 +100,26 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── 文件上传 ──────────────────────────────────── */
|
||||
/* ── 素材/录音互斥:选择素材时清掉录音,开始录音时清掉素材选择 ── */
|
||||
|
||||
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 {
|
||||
// 文件选择为纯同步 state 设置,无异步竞态;防重入只属于提交动作,
|
||||
// 由 handleSubmit 的 isSubmittingRef + isProcessing 保证,此处不设锁
|
||||
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 handleSelectAsset = (assetId: string) => {
|
||||
setSelectedAssetId(assetId)
|
||||
if (assetId) {
|
||||
resetRecorder()
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// 文件选择为纯同步 state 设置,无异步竞态;防重入只属于提交动作,
|
||||
// 由 handleSubmit 的 isSubmittingRef + isProcessing 保证,此处不设锁
|
||||
setErrorMessage("")
|
||||
setSelectedFile(file)
|
||||
resetRecorder()
|
||||
}
|
||||
const handleToggleRecord = () => {
|
||||
// 开始录音会清掉已选素材;停止录音保留录音结果
|
||||
if (!isRecording) {
|
||||
setSelectedAssetId("")
|
||||
}
|
||||
toggleRecord()
|
||||
}
|
||||
|
||||
/* ── 计算属性 ──────────────────────────────────── */
|
||||
|
||||
const hasAudio = selectedFile !== null || recordedBlob !== null
|
||||
const hasAudio = selectedAssetId !== "" || recordedBlob !== null
|
||||
const isProcessing = phase === "uploading" || phase === "cloning"
|
||||
const canSubmit = hasAudio && !isProcessing
|
||||
|
||||
@@ -158,7 +134,7 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
return
|
||||
}
|
||||
if (!hasAudio) {
|
||||
setErrorMessage("请上传音频文件或录制一段声音")
|
||||
setErrorMessage("请从配音素材选择一段音频,或直接录制声音")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -167,20 +143,37 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
setErrorMessage("")
|
||||
|
||||
try {
|
||||
// 阶段 1:上传音频
|
||||
// 路径 A:从配音素材选择 → 无需上传,直接克隆
|
||||
if (selectedAssetId) {
|
||||
setPhase("cloning")
|
||||
const result = await createVoiceClone({
|
||||
name,
|
||||
description: voiceDescription.trim() || undefined,
|
||||
asset_id: selectedAssetId,
|
||||
})
|
||||
|
||||
if (!isMountedRef.current) return
|
||||
|
||||
isSubmittingRef.current = false
|
||||
setPhase("done")
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
onSuccess?.(toVoiceClone(result))
|
||||
handleClose()
|
||||
}
|
||||
}, 2000)
|
||||
return
|
||||
}
|
||||
|
||||
// 路径 B:录音 → 先上传为配音素材,再克隆
|
||||
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,
|
||||
})
|
||||
}
|
||||
// 使用浏览器实际生成的 MIME 类型,避免跨浏览器格式不匹配
|
||||
const mimeType = recordedBlob?.type || "audio/webm"
|
||||
const ext = getExtensionFromMime(mimeType)
|
||||
const fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.${ext}`, {
|
||||
type: mimeType,
|
||||
})
|
||||
|
||||
// 获取默认项目和素材库
|
||||
const project = await getOrCreateDefaultProject()
|
||||
@@ -226,6 +219,8 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
}
|
||||
}
|
||||
|
||||
const hasAssets = (voiceAssets?.length ?? 0) > 0
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
@@ -244,7 +239,7 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
<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>
|
||||
<span className="xx-clonemodal-step-label">选择/录制音频</span>
|
||||
</div>
|
||||
<div className="xx-clonemodal-step-connector" />
|
||||
<div className="xx-clonemodal-step">
|
||||
@@ -274,30 +269,42 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
<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>
|
||||
<label className="xx-clonemodal-label">从配音素材选择</label>
|
||||
{hasAssets ? (
|
||||
<select
|
||||
className="xx-clonemodal-input"
|
||||
value={selectedAssetId}
|
||||
onChange={(e) => handleSelectAsset(e.target.value)}
|
||||
disabled={assetsLoading}
|
||||
>
|
||||
<option value="">{assetsLoading ? "素材加载中…" : "请选择已上传的配音素材"}</option>
|
||||
{voiceAssets!.map((asset) => (
|
||||
<option key={asset.id} value={asset.id}>
|
||||
{asset.name}({formatAssetDuration(asset.duration)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="xx-clonemodal-asset-empty">
|
||||
<p className="xx-clonemodal-asset-empty-text">
|
||||
{assetsLoading ? "素材加载中…" : "请先在配音库上传素材"}
|
||||
</p>
|
||||
{!assetsLoading && (
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={() => {
|
||||
handleClose()
|
||||
navigate("/app/voice-materials")
|
||||
}}
|
||||
>
|
||||
去配音库上传
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 或分隔 */}
|
||||
@@ -332,7 +339,7 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
<button
|
||||
type="button"
|
||||
className={`xx-clonemodal-record-btn${isRecording ? " xx-clonemodal-record-btn--recording" : ""}`}
|
||||
onClick={toggleRecord}
|
||||
onClick={handleToggleRecord}
|
||||
title={isRecording ? "停止录制" : "开始录制"}
|
||||
>
|
||||
{isRecording ? "⏹" : "🎙️"}
|
||||
@@ -365,7 +372,7 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
{/* 提示 */}
|
||||
<div className="xx-clonemodal-tip">
|
||||
<span className="xx-clonemodal-tip-icon">💡</span>
|
||||
<span>建议上传 10 秒 ~ 3 分钟的清晰语音,环境安静、语速均匀效果最佳</span>
|
||||
<span>建议使用 10 秒 ~ 3 分钟的清晰语音,环境安静、语速均匀效果最佳</span>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
@@ -415,18 +422,18 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
{/* 当前阶段描述 */}
|
||||
<div className="xx-clonemodal-progress-info">
|
||||
{phase === "uploading" && (
|
||||
<>
|
||||
<div>
|
||||
<div className="xx-clonemodal-progress-spinner" />
|
||||
<p className="xx-clonemodal-progress-text">正在上传音频文件…</p>
|
||||
<p className="xx-clonemodal-progress-sub">请稍候,正在将音频上传至服务器</p>
|
||||
</>
|
||||
<p className="xx-clonemodal-progress-text">正在上传录音…</p>
|
||||
<p className="xx-clonemodal-progress-sub">请稍候,正在将录音上传至服务器</p>
|
||||
</div>
|
||||
)}
|
||||
{phase === "cloning" && (
|
||||
<>
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,3 @@
|
||||
import { ACCEPTED_EXTENSIONS, MAX_FILE_SIZE } from "./constants"
|
||||
|
||||
/**
|
||||
* 验证音频文件
|
||||
* @returns 错误信息,null 表示验证通过
|
||||
*/
|
||||
export 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
|
||||
}
|
||||
|
||||
/** 格式化录制时间 mm:ss */
|
||||
export const formatRecordTime = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import React from "react"
|
||||
import { Button } from "@/components/ui"
|
||||
import UploadZone from "./UploadZone"
|
||||
import RecordArea from "./RecordArea"
|
||||
import StepIndicator from "./StepIndicator"
|
||||
import { MAX_VOICE_NAME_LENGTH, MAX_VOICE_DESC_LENGTH } from "../constants/cloneModal"
|
||||
|
||||
interface InputViewProps {
|
||||
voiceName: string
|
||||
voiceDescription: string
|
||||
selectedFile: File | null
|
||||
dragActive: boolean
|
||||
isRecording: boolean
|
||||
recordTime: number
|
||||
recordedBlob: Blob | null
|
||||
errorMessage: string
|
||||
canSubmit: boolean
|
||||
onVoiceNameChange: (value: string) => void
|
||||
onVoiceDescChange: (value: string) => void
|
||||
onDragActiveChange: (active: boolean) => void
|
||||
onFileSelect: (file: File | null, error: string) => void
|
||||
onRecordToggle: () => void
|
||||
onClose: () => void
|
||||
onSubmit: () => void
|
||||
}
|
||||
|
||||
const INPUT_STEPS = ["上传/录制音频", "填写信息", "提交克隆"]
|
||||
|
||||
const InputView: React.FC<InputViewProps> = ({
|
||||
voiceName,
|
||||
voiceDescription,
|
||||
selectedFile,
|
||||
dragActive,
|
||||
isRecording,
|
||||
recordTime,
|
||||
recordedBlob,
|
||||
errorMessage,
|
||||
canSubmit,
|
||||
onVoiceNameChange,
|
||||
onVoiceDescChange,
|
||||
onDragActiveChange,
|
||||
onFileSelect,
|
||||
onRecordToggle,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-clonemodal-body">
|
||||
{/* 步骤引导 */}
|
||||
<StepIndicator currentStep={0} steps={INPUT_STEPS} />
|
||||
|
||||
{/* 音色名称 */}
|
||||
<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) => onVoiceNameChange(e.target.value)}
|
||||
placeholder="输入音色名称(2-20字符)"
|
||||
maxLength={MAX_VOICE_NAME_LENGTH}
|
||||
/>
|
||||
<div className="xx-clonemodal-char-count">
|
||||
{voiceName.length}/{MAX_VOICE_NAME_LENGTH}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 上传区域 */}
|
||||
<div className="xx-clonemodal-field">
|
||||
<label className="xx-clonemodal-label">上传音频</label>
|
||||
<UploadZone
|
||||
selectedFile={selectedFile}
|
||||
dragActive={dragActive}
|
||||
onDragActiveChange={onDragActiveChange}
|
||||
onFileSelect={onFileSelect}
|
||||
/>
|
||||
</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>
|
||||
<RecordArea
|
||||
isRecording={isRecording}
|
||||
recordTime={recordTime}
|
||||
recordedBlob={recordedBlob}
|
||||
onRecordToggle={onRecordToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 音色描述 */}
|
||||
<div className="xx-clonemodal-field">
|
||||
<label className="xx-clonemodal-label">音色描述</label>
|
||||
<textarea
|
||||
className="xx-clonemodal-textarea"
|
||||
value={voiceDescription}
|
||||
onChange={(e) => onVoiceDescChange(e.target.value)}
|
||||
placeholder="可选,描述这个音色的特点(最多100字符)"
|
||||
maxLength={MAX_VOICE_DESC_LENGTH}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="xx-clonemodal-char-count">
|
||||
{voiceDescription.length}/{MAX_VOICE_DESC_LENGTH}
|
||||
</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={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button buttonType="primary" disabled={!canSubmit} onClick={onSubmit}>
|
||||
🎤 开始克隆
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InputView
|
||||
@@ -1,92 +0,0 @@
|
||||
import React from "react"
|
||||
import { PROGRESS_STEPS } from "../constants/cloneModal"
|
||||
import type { ProgressStep } from "../types/cloneModal"
|
||||
import type { ModalPhase } from "../types/cloneModal"
|
||||
|
||||
interface ProgressViewProps {
|
||||
phase: ModalPhase
|
||||
}
|
||||
|
||||
const getProgressIndex = (phase: ModalPhase): number => {
|
||||
switch (phase) {
|
||||
case "uploading":
|
||||
return 0
|
||||
case "cloning":
|
||||
return 1
|
||||
case "done":
|
||||
return 2
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
const ProgressView: React.FC<ProgressViewProps> = ({ phase }) => {
|
||||
const progressIndex = getProgressIndex(phase)
|
||||
const isDone = phase === "done"
|
||||
|
||||
return (
|
||||
<div className="xx-clonemodal-progress-body">
|
||||
{/* 步骤指示器 */}
|
||||
<div className="xx-clonemodal-steps-progress">
|
||||
{PROGRESS_STEPS.map((step: ProgressStep, idx: number) => {
|
||||
const isActive = idx === progressIndex && !isDone
|
||||
const stepDone = idx < progressIndex || isDone
|
||||
const stepClass = [
|
||||
"xx-clonemodal-step-progress",
|
||||
isActive ? "xx-clonemodal-step-progress--active" : "",
|
||||
stepDone ? "xx-clonemodal-step-progress--done" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.key}>
|
||||
{idx > 0 && (
|
||||
<div
|
||||
className={`xx-clonemodal-step-connector${stepDone ? " xx-clonemodal-step-connector--done" : ""}`}
|
||||
/>
|
||||
)}
|
||||
<div className={stepClass}>
|
||||
<div className="xx-clonemodal-step-icon">{stepDone ? "✓" : step.icon}</div>
|
||||
<span className="xx-clonemodal-step-label">{step.label}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 完成阶段 */}
|
||||
{isDone && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 进行中阶段 */}
|
||||
{!isDone && (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProgressView
|
||||
@@ -1,49 +0,0 @@
|
||||
import React from "react"
|
||||
import { formatRecordTime } from "../utils/cloneModal"
|
||||
|
||||
interface RecordAreaProps {
|
||||
isRecording: boolean
|
||||
recordTime: number
|
||||
recordedBlob: Blob | null
|
||||
onRecordToggle: () => void
|
||||
}
|
||||
|
||||
const RecordArea: React.FC<RecordAreaProps> = ({
|
||||
isRecording,
|
||||
recordTime,
|
||||
recordedBlob,
|
||||
onRecordToggle,
|
||||
}) => {
|
||||
const getHintText = () => {
|
||||
if (isRecording) return `录制中 ${formatRecordTime(recordTime)}`
|
||||
if (recordedBlob) return `已录制 ${formatRecordTime(recordTime)}`
|
||||
return "点击按钮开始录制(最长 5 分钟)"
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-clonemodal-record-area">
|
||||
<div className="xx-clonemodal-record-info">
|
||||
<p className="xx-clonemodal-record-hint">{getHintText()}</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={onRecordToggle}
|
||||
title={isRecording ? "停止录制" : "开始录制"}
|
||||
>
|
||||
{isRecording ? "⏹" : "🎙️"}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RecordArea
|
||||
@@ -1,30 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
interface StepIndicatorProps {
|
||||
currentStep: number
|
||||
steps: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入阶段顶部的步骤引导(数字步骤)
|
||||
*/
|
||||
const StepIndicator: React.FC<StepIndicatorProps> = ({ currentStep, steps }) => {
|
||||
return (
|
||||
<div className="xx-clonemodal-steps">
|
||||
{steps.map((label, idx) => {
|
||||
const isActive = idx <= currentStep
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
{idx > 0 && <div className="xx-clonemodal-step-connector" />}
|
||||
<div className={`xx-clonemodal-step${isActive ? " xx-clonemodal-step--active" : ""}`}>
|
||||
<div className="xx-clonemodal-step-number">{idx + 1}</div>
|
||||
<span className="xx-clonemodal-step-label">{label}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StepIndicator
|
||||
@@ -1,79 +0,0 @@
|
||||
import React, { useRef } from "react"
|
||||
import { ACCEPTED_MIME } from "../constants/cloneModal"
|
||||
import { validateFile } from "../utils/cloneModal"
|
||||
|
||||
interface UploadZoneProps {
|
||||
selectedFile: File | null
|
||||
dragActive: boolean
|
||||
onDragActiveChange: (active: boolean) => void
|
||||
onFileSelect: (file: File | null, error: string) => void
|
||||
}
|
||||
|
||||
const UploadZone: React.FC<UploadZoneProps> = ({
|
||||
selectedFile,
|
||||
dragActive,
|
||||
onDragActiveChange,
|
||||
onFileSelect,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
const error = validateFile(file)
|
||||
onFileSelect(error ? null : file, error || "")
|
||||
}
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
onDragActiveChange(true)
|
||||
} else if (e.type === "dragleave") {
|
||||
onDragActiveChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onDragActiveChange(false)
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file) {
|
||||
const error = validateFile(file)
|
||||
onFileSelect(error ? null : file, error || "")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadZone
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { ProgressStep } from "../types/cloneModal"
|
||||
|
||||
/** 进度阶段配置 */
|
||||
export const PROGRESS_STEPS: ProgressStep[] = [
|
||||
{ key: "uploading", label: "上传中", icon: "📤" },
|
||||
{ key: "cloning", label: "克隆中", icon: "🧬" },
|
||||
{ key: "done", label: "完成", icon: "✅" },
|
||||
]
|
||||
|
||||
/** 支持的音频扩展名 */
|
||||
export const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a"]
|
||||
|
||||
/** input accept 属性值 */
|
||||
export const ACCEPTED_MIME = ".mp3,.wav,.m4a,audio/mpeg,audio/wav,audio/mp4"
|
||||
|
||||
/** 最大文件大小:10MB */
|
||||
export const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
/** 最长录制时长(秒):5 分钟 */
|
||||
export const MAX_RECORD_SECONDS = 5 * 60
|
||||
|
||||
/** 音色名称最小长度 */
|
||||
export const MIN_VOICE_NAME_LENGTH = 2
|
||||
|
||||
/** 音色名称最大长度 */
|
||||
export const MAX_VOICE_NAME_LENGTH = 20
|
||||
|
||||
/** 音色描述最大长度 */
|
||||
export const MAX_VOICE_DESC_LENGTH = 100
|
||||
@@ -1,119 +0,0 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import { MAX_RECORD_SECONDS } from "../constants/cloneModal"
|
||||
|
||||
interface UseAudioRecorderReturn {
|
||||
isRecording: boolean
|
||||
recordTime: number
|
||||
recordedBlob: Blob | null
|
||||
toggleRecording: () => void
|
||||
resetRecording: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 录音 Hook —— 封装 MediaRecorder 录音逻辑
|
||||
*/
|
||||
const useAudioRecorder = (): UseAudioRecorderReturn => {
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [recordTime, setRecordTime] = useState(0)
|
||||
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null)
|
||||
|
||||
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||
const audioChunksRef = useRef<Blob[]>([])
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
setIsRecording(false)
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current)
|
||||
recordTimerRef.current = null
|
||||
}
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
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)
|
||||
stream.getTracks().forEach((track) => track.stop())
|
||||
}
|
||||
|
||||
mediaRecorder.start()
|
||||
setIsRecording(true)
|
||||
setRecordTime(0)
|
||||
setRecordedBlob(null)
|
||||
|
||||
recordTimerRef.current = setInterval(() => {
|
||||
setRecordTime((prev) => {
|
||||
const next = prev + 1
|
||||
if (next >= MAX_RECORD_SECONDS) {
|
||||
setTimeout(() => {
|
||||
stopRecording()
|
||||
}, 0)
|
||||
return MAX_RECORD_SECONDS
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, 1000)
|
||||
} catch {
|
||||
// 错误由调用方通过其他机制提示
|
||||
setIsRecording(false)
|
||||
}
|
||||
}, [stopRecording])
|
||||
|
||||
const toggleRecording = useCallback(() => {
|
||||
if (isRecording) {
|
||||
stopRecording()
|
||||
} else {
|
||||
startRecording()
|
||||
}
|
||||
}, [isRecording, startRecording, stopRecording])
|
||||
|
||||
const resetRecording = useCallback(() => {
|
||||
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
|
||||
}, [])
|
||||
|
||||
// 卸载时清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (recordTimerRef.current) clearInterval(recordTimerRef.current)
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
recordTime,
|
||||
recordedBlob,
|
||||
toggleRecording,
|
||||
resetRecording,
|
||||
}
|
||||
}
|
||||
|
||||
export default useAudioRecorder
|
||||
@@ -1,134 +0,0 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import type { ModalPhase } from "../types/cloneModal"
|
||||
import { MIN_VOICE_NAME_LENGTH, MAX_VOICE_NAME_LENGTH } from "../constants/cloneModal"
|
||||
import useAudioRecorder from "./useAudioRecorder"
|
||||
|
||||
/**
|
||||
* 克隆弹窗表单状态 Hook
|
||||
* 管理表单字段、录音、文件选择、验证逻辑
|
||||
*/
|
||||
export function useCloneFormState({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
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 { isRecording, recordTime, recordedBlob, toggleRecording, resetRecording } =
|
||||
useAudioRecorder()
|
||||
|
||||
/** 默认音色名称计数器 */
|
||||
const cloneCounterRef = useRef(1)
|
||||
|
||||
const getNextDefaultName = useCallback((): string => {
|
||||
const name = `我的声音 ${cloneCounterRef.current}`
|
||||
cloneCounterRef.current += 1
|
||||
return name
|
||||
}, [])
|
||||
|
||||
const hasAudio = selectedFile !== null || recordedBlob !== null
|
||||
|
||||
const canSubmit =
|
||||
voiceName.trim().length >= MIN_VOICE_NAME_LENGTH &&
|
||||
voiceName.trim().length <= MAX_VOICE_NAME_LENGTH &&
|
||||
hasAudio
|
||||
|
||||
const isProcessing = phase === "uploading" || phase === "cloning"
|
||||
|
||||
/** 重置弹窗状态 */
|
||||
const resetState = useCallback(() => {
|
||||
setPhase("input")
|
||||
setVoiceName(getNextDefaultName())
|
||||
setVoiceDescription("")
|
||||
setSelectedFile(null)
|
||||
setDragActive(false)
|
||||
setErrorMessage("")
|
||||
resetRecording()
|
||||
}, [getNextDefaultName, resetRecording])
|
||||
|
||||
/** 关闭弹窗 */
|
||||
const handleClose = useCallback(() => {
|
||||
resetState()
|
||||
onClose()
|
||||
}, [resetState, onClose])
|
||||
|
||||
/** 弹窗打开时重置状态 */
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
resetState()
|
||||
}
|
||||
}, [open, resetState])
|
||||
|
||||
/** 选择文件(来自上传或拖拽) */
|
||||
const handleFileSelect = useCallback(
|
||||
(file: File | null, error: string) => {
|
||||
if (error) {
|
||||
setErrorMessage(error)
|
||||
setSelectedFile(null)
|
||||
} else {
|
||||
setErrorMessage("")
|
||||
setSelectedFile(file)
|
||||
// 清除录音
|
||||
resetRecording()
|
||||
}
|
||||
},
|
||||
[resetRecording],
|
||||
)
|
||||
|
||||
/** 录音切换 */
|
||||
const handleRecordToggle = useCallback(() => {
|
||||
setErrorMessage("")
|
||||
if (isRecording) {
|
||||
toggleRecording()
|
||||
} else {
|
||||
// 开始录制前清除已选文件
|
||||
setSelectedFile(null)
|
||||
toggleRecording()
|
||||
}
|
||||
}, [isRecording, toggleRecording])
|
||||
|
||||
/** 表单验证 */
|
||||
const validateForm = useCallback((): string | null => {
|
||||
const name = voiceName.trim()
|
||||
if (!name) {
|
||||
return "请输入音色名称"
|
||||
}
|
||||
if (name.length < MIN_VOICE_NAME_LENGTH || name.length > MAX_VOICE_NAME_LENGTH) {
|
||||
return `音色名称需在 ${MIN_VOICE_NAME_LENGTH}-${MAX_VOICE_NAME_LENGTH} 个字符之间`
|
||||
}
|
||||
if (!hasAudio) {
|
||||
return "请上传音频文件或录制一段声音"
|
||||
}
|
||||
return null
|
||||
}, [voiceName, hasAudio])
|
||||
|
||||
return {
|
||||
// 状态
|
||||
phase,
|
||||
setPhase,
|
||||
voiceName,
|
||||
setVoiceName,
|
||||
voiceDescription,
|
||||
setVoiceDescription,
|
||||
selectedFile,
|
||||
dragActive,
|
||||
setDragActive,
|
||||
errorMessage,
|
||||
setErrorMessage,
|
||||
// 录音
|
||||
isRecording,
|
||||
recordTime,
|
||||
recordedBlob,
|
||||
// 计算属性
|
||||
hasAudio,
|
||||
canSubmit,
|
||||
isProcessing,
|
||||
// handlers
|
||||
handleFileSelect,
|
||||
handleRecordToggle,
|
||||
handleClose,
|
||||
validateForm,
|
||||
resetState,
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { CloneModalProps } from "../types/cloneModal"
|
||||
import { useCloneFormState } from "./useCloneFormState"
|
||||
import { useCloneSubmit } from "./useCloneSubmit"
|
||||
|
||||
/**
|
||||
* 音色克隆弹窗主业务 Hook
|
||||
* 组合表单状态 + 提交流程两个子 Hook
|
||||
*/
|
||||
const useCloneModal = ({ open, onClose, onSuccess }: CloneModalProps) => {
|
||||
const formState = useCloneFormState({ open, onClose })
|
||||
|
||||
const { handleSubmit } = useCloneSubmit({
|
||||
voiceName: formState.voiceName,
|
||||
voiceDescription: formState.voiceDescription,
|
||||
selectedFile: formState.selectedFile,
|
||||
recordedBlob: formState.recordedBlob,
|
||||
setPhase: formState.setPhase,
|
||||
setErrorMessage: formState.setErrorMessage,
|
||||
validateForm: formState.validateForm,
|
||||
onSuccess,
|
||||
onClose: formState.handleClose,
|
||||
})
|
||||
|
||||
return {
|
||||
phase: formState.phase,
|
||||
voiceName: formState.voiceName,
|
||||
voiceDescription: formState.voiceDescription,
|
||||
selectedFile: formState.selectedFile,
|
||||
dragActive: formState.dragActive,
|
||||
errorMessage: formState.errorMessage,
|
||||
isRecording: formState.isRecording,
|
||||
recordTime: formState.recordTime,
|
||||
recordedBlob: formState.recordedBlob,
|
||||
canSubmit: formState.canSubmit,
|
||||
isProcessing: formState.isProcessing,
|
||||
setVoiceName: formState.setVoiceName,
|
||||
setVoiceDescription: formState.setVoiceDescription,
|
||||
setDragActive: formState.setDragActive,
|
||||
handleFileSelect: formState.handleFileSelect,
|
||||
handleRecordToggle: formState.handleRecordToggle,
|
||||
handleClose: formState.handleClose,
|
||||
handleSubmit,
|
||||
}
|
||||
}
|
||||
|
||||
export default useCloneModal
|
||||
@@ -1,108 +0,0 @@
|
||||
import { useRef, useCallback, useEffect } from "react"
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
||||
import { uploadAssetDirect, ensureDefaultLibrary } from "@/api/assets"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
|
||||
interface UseCloneSubmitOptions {
|
||||
voiceName: string
|
||||
voiceDescription: string
|
||||
selectedFile: File | null
|
||||
recordedBlob: Blob | null
|
||||
setPhase: (phase: "input" | "uploading" | "cloning" | "done") => void
|
||||
setErrorMessage: (msg: string) => void
|
||||
validateForm: () => string | null
|
||||
onSuccess?: (clone: VoiceClone) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 克隆提交流程 Hook
|
||||
* 封装上传 + 克隆 + 完成的三阶段流程
|
||||
*/
|
||||
export function useCloneSubmit({
|
||||
voiceName,
|
||||
voiceDescription,
|
||||
selectedFile,
|
||||
recordedBlob,
|
||||
setPhase,
|
||||
setErrorMessage,
|
||||
validateForm,
|
||||
onSuccess,
|
||||
onClose,
|
||||
}: UseCloneSubmitOptions) {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
/** 组件卸载时清理定时器 */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const formError = validateForm()
|
||||
if (formError) {
|
||||
setErrorMessage(formError)
|
||||
return
|
||||
}
|
||||
|
||||
setErrorMessage("")
|
||||
|
||||
try {
|
||||
// 阶段 1:上传音频
|
||||
setPhase("uploading")
|
||||
|
||||
let fileToUpload: File
|
||||
if (selectedFile) {
|
||||
fileToUpload = selectedFile
|
||||
} else {
|
||||
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
|
||||
type: "audio/webm",
|
||||
})
|
||||
}
|
||||
|
||||
// 获取默认项目和素材库
|
||||
const project = await getOrCreateDefaultProject()
|
||||
const library = await ensureDefaultLibrary({ project_id: project.id, kind: "voice" })
|
||||
|
||||
// 直传到 OSS
|
||||
const uploadResult = await uploadAssetDirect({
|
||||
file: fileToUpload,
|
||||
library_id: library.id,
|
||||
})
|
||||
|
||||
// 阶段 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))
|
||||
onClose()
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
setPhase("input")
|
||||
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试")
|
||||
}
|
||||
}, [
|
||||
validateForm,
|
||||
selectedFile,
|
||||
recordedBlob,
|
||||
voiceName,
|
||||
voiceDescription,
|
||||
setPhase,
|
||||
setErrorMessage,
|
||||
onSuccess,
|
||||
onClose,
|
||||
])
|
||||
|
||||
return { handleSubmit }
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
|
||||
/** 弹窗阶段 */
|
||||
export type ModalPhase = "input" | "uploading" | "cloning" | "done"
|
||||
|
||||
export interface CloneModalProps {
|
||||
/** 弹窗是否可见 */
|
||||
open: boolean
|
||||
/** 关闭弹窗回调 */
|
||||
onClose: () => void
|
||||
/** 克隆成功回调(返回新创建的音色) */
|
||||
onSuccess?: (voice: VoiceClone) => void
|
||||
}
|
||||
|
||||
/** 进度步骤项 */
|
||||
export interface ProgressStep {
|
||||
key: string
|
||||
label: string
|
||||
icon: string
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { ACCEPTED_EXTENSIONS, MAX_FILE_SIZE } from "../constants/cloneModal"
|
||||
|
||||
/**
|
||||
* 格式化录制时间 mm:ss
|
||||
*/
|
||||
export 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")}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证上传的音频文件
|
||||
* @returns 错误信息,null 表示验证通过
|
||||
*/
|
||||
export 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
|
||||
}
|
||||
@@ -102,6 +102,7 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
ttsAudioUrl,
|
||||
ttsError,
|
||||
presetVoices,
|
||||
clonedVoices,
|
||||
setTtsOpen,
|
||||
setTtsText,
|
||||
setTtsVoiceId,
|
||||
@@ -318,6 +319,7 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
audioUrl={ttsAudioUrl ?? ""}
|
||||
error={ttsError ?? ""}
|
||||
presetVoices={presetVoices}
|
||||
clonedVoices={clonedVoices}
|
||||
onClose={handleTtsClose}
|
||||
onTextChange={setTtsText}
|
||||
onVoiceChange={setTtsVoiceId}
|
||||
|
||||
@@ -9,6 +9,12 @@ export interface TtsPresetVoice {
|
||||
name: string
|
||||
}
|
||||
|
||||
/** 克隆音色下拉选项(仅 ready) */
|
||||
export interface TtsClonedVoice {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface TtsModalProps {
|
||||
open: boolean
|
||||
text: string
|
||||
@@ -18,6 +24,8 @@ interface TtsModalProps {
|
||||
audioUrl: string
|
||||
error: string
|
||||
presetVoices: TtsPresetVoice[]
|
||||
/** 可用克隆音色(仅 ready),为空时不显示该分组 */
|
||||
clonedVoices?: TtsClonedVoice[]
|
||||
onClose: () => void
|
||||
onTextChange: (value: string) => void
|
||||
onVoiceChange: (voiceId: string) => void
|
||||
@@ -35,6 +43,7 @@ const TtsModal: React.FC<TtsModalProps> = ({
|
||||
audioUrl,
|
||||
error,
|
||||
presetVoices,
|
||||
clonedVoices = [],
|
||||
onClose: _onClose,
|
||||
onTextChange,
|
||||
onVoiceChange,
|
||||
@@ -101,11 +110,22 @@ const TtsModal: React.FC<TtsModalProps> = ({
|
||||
}}
|
||||
>
|
||||
<option value="">默认音色</option>
|
||||
{presetVoices.map((v) => (
|
||||
<option key={v.voice_id} value={v.voice_id}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
<optgroup label="预置音色">
|
||||
{presetVoices.map((v) => (
|
||||
<option key={v.voice_id} value={v.voice_id}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
{clonedVoices.length > 0 && (
|
||||
<optgroup label="我的克隆音色">
|
||||
{clonedVoices.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -54,7 +54,8 @@ const CardPlayer: React.FC<CardPlayerProps> = ({
|
||||
document.addEventListener("mouseup", handleUp)
|
||||
}
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||
// 仅播放中的卡片显示进度,避免页面级 currentTime 联动所有卡片
|
||||
const progress = isPlaying && duration > 0 ? (currentTime / duration) * 100 : 0
|
||||
|
||||
return (
|
||||
<div className="vmat-card-player">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
|
||||
import { fetchPresetVoices, type PresetVoiceItem } from "@/api/voices"
|
||||
import { getVoiceClonesWithTotal, toVoiceClone } from "@/api/voice-clone"
|
||||
|
||||
/**
|
||||
* TTS 合成 Hook
|
||||
@@ -31,6 +32,17 @@ export function useTtsSynthesize() {
|
||||
})
|
||||
const presetVoices: PresetVoiceItem[] = presetVoicesData?.items ?? []
|
||||
|
||||
// 我的克隆音色(仅 ready 可用于合成;voice_id 直接传 profile UUID,后端解析)
|
||||
const { data: clonedData } = useQuery({
|
||||
queryKey: ["voice-clones"],
|
||||
queryFn: () => getVoiceClonesWithTotal({ limit: 50 }),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const clonedVoices = (clonedData?.items ?? [])
|
||||
.map((p) => toVoiceClone(p))
|
||||
.filter((c) => c.status === "ready")
|
||||
.map((c) => ({ id: c.id, name: c.name }))
|
||||
|
||||
/** 开始 AI 配音合成 */
|
||||
const handleTtsSynthesize = useCallback(async () => {
|
||||
if (!ttsText.trim()) {
|
||||
@@ -124,6 +136,7 @@ export function useTtsSynthesize() {
|
||||
ttsAudioUrl,
|
||||
ttsError,
|
||||
presetVoices,
|
||||
clonedVoices,
|
||||
setTtsOpen,
|
||||
setTtsText,
|
||||
setTtsVoiceId,
|
||||
|
||||
@@ -134,7 +134,13 @@ const VoiceLibrary: React.FC = () => {
|
||||
handleTtsSynthesize,
|
||||
handleTtsSave,
|
||||
handleTtsClose,
|
||||
} = useTtsSynthesize({ presetVoices, showToast })
|
||||
} = useTtsSynthesize({
|
||||
presetVoices,
|
||||
clonedVoices: clonedVoices
|
||||
.filter((v) => v.status === "ready")
|
||||
.map((v) => ({ id: v.id, name: v.name })),
|
||||
showToast,
|
||||
})
|
||||
|
||||
// ── 上传音频 ──────────────────────────────────────────
|
||||
const {
|
||||
@@ -308,6 +314,9 @@ const VoiceLibrary: React.FC = () => {
|
||||
ttsAudioUrl={ttsAudioUrl}
|
||||
ttsError={ttsError}
|
||||
presetVoices={presetVoices}
|
||||
clonedVoices={clonedVoices
|
||||
.filter((v) => v.status === "ready")
|
||||
.map((v) => ({ id: v.id, name: v.name }))}
|
||||
onTtsClose={handleTtsClose}
|
||||
onTtsTextChange={setTtsText}
|
||||
onTtsVoiceChange={setTtsVoiceId}
|
||||
|
||||
@@ -140,7 +140,9 @@ export const MaterialVoiceTab: React.FC<MaterialVoiceTabProps> = ({
|
||||
const isSelected = selectedIds.has(asset.id)
|
||||
// 播放中以 audio 真实时长为准,未播放显示卡片时长
|
||||
const effectiveDuration = isPlaying ? playDuration || cardDuration : cardDuration
|
||||
const progress = effectiveDuration > 0 ? (currentTime / effectiveDuration) * 100 : 0
|
||||
// 仅播放中的卡片显示进度,避免页面级 currentTime 联动所有卡片
|
||||
const progress =
|
||||
isPlaying && effectiveDuration > 0 ? (currentTime / effectiveDuration) * 100 : 0
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
|
||||
@@ -18,6 +18,7 @@ const TtsModal: React.FC<TtsModalProps> = ({
|
||||
ttsAudioUrl,
|
||||
ttsError,
|
||||
presetVoices,
|
||||
clonedVoices,
|
||||
onClose,
|
||||
onTextChange,
|
||||
onVoiceChange,
|
||||
@@ -36,7 +37,12 @@ const TtsModal: React.FC<TtsModalProps> = ({
|
||||
}}
|
||||
>
|
||||
<TextInputSection value={ttsText} onChange={onTextChange} />
|
||||
<VoiceSelector value={ttsVoiceId} onChange={onVoiceChange} presetVoices={presetVoices} />
|
||||
<VoiceSelector
|
||||
value={ttsVoiceId}
|
||||
onChange={onVoiceChange}
|
||||
presetVoices={presetVoices}
|
||||
clonedVoices={clonedVoices}
|
||||
/>
|
||||
<SpeedControl speed={ttsSpeed} onChange={onSpeedChange} />
|
||||
<SynthesizeButton status={ttsStatus} text={ttsText} onClick={onSynthesize} />
|
||||
{ttsError && <ErrorAlert error={ttsError} />}
|
||||
|
||||
@@ -5,6 +5,7 @@ import React from "react"
|
||||
import type { ClonedVoiceDisplay, PresetVoiceDisplay } from "../types"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { TtsStatus } from "./TtsModal"
|
||||
import type { TtsClonedVoiceOption } from "./tts-modal/VoiceSelector"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
import CloneDetailModal from "./CloneDetailModal"
|
||||
import UploadVoiceModal from "./UploadVoiceModal"
|
||||
@@ -45,6 +46,8 @@ export interface VoiceModalsProps {
|
||||
ttsAudioUrl: string | null
|
||||
ttsError: string | null
|
||||
presetVoices: PresetVoiceDisplay[]
|
||||
/** 可用克隆音色(仅 ready) */
|
||||
clonedVoices?: TtsClonedVoiceOption[]
|
||||
onTtsClose: () => void
|
||||
onTtsTextChange: (text: string) => void
|
||||
onTtsVoiceChange: (id: string) => void
|
||||
@@ -81,6 +84,7 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
|
||||
ttsAudioUrl,
|
||||
ttsError,
|
||||
presetVoices,
|
||||
clonedVoices,
|
||||
onTtsClose,
|
||||
onTtsTextChange,
|
||||
onTtsVoiceChange,
|
||||
@@ -129,6 +133,7 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
|
||||
ttsAudioUrl={ttsAudioUrl}
|
||||
ttsError={ttsError}
|
||||
presetVoices={presetVoices}
|
||||
clonedVoices={clonedVoices}
|
||||
onClose={onTtsClose}
|
||||
onTextChange={onTtsTextChange}
|
||||
onVoiceChange={onTtsVoiceChange}
|
||||
|
||||
@@ -2,14 +2,28 @@ import React from "react"
|
||||
import { type PresetVoiceDisplay } from "@/pages/voices/types"
|
||||
import { genderLabel } from "@/pages/voices/utils/format"
|
||||
|
||||
/** 克隆音色下拉选项(最小结构,新旧页面各自映射) */
|
||||
export interface TtsClonedVoiceOption {
|
||||
/** 克隆音色 profile id(合成时直接作为 voice_id 传后端解析) */
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface VoiceSelectorProps {
|
||||
value: string
|
||||
onChange: (voiceId: string) => void
|
||||
presetVoices: PresetVoiceDisplay[]
|
||||
/** 可用克隆音色(仅克隆完成/ready),为空时不显示「我的克隆音色」分组 */
|
||||
clonedVoices?: TtsClonedVoiceOption[]
|
||||
}
|
||||
|
||||
/** 音色选择下拉 */
|
||||
const VoiceSelector: React.FC<VoiceSelectorProps> = ({ value, onChange, presetVoices }) => {
|
||||
/** 音色选择下拉:预置音色 + 我的克隆音色 */
|
||||
const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
presetVoices,
|
||||
clonedVoices = [],
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
@@ -36,11 +50,22 @@ const VoiceSelector: React.FC<VoiceSelectorProps> = ({ value, onChange, presetVo
|
||||
}}
|
||||
>
|
||||
<option value="">默认音色</option>
|
||||
{presetVoices.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name} — {genderLabel(v.gender)}
|
||||
</option>
|
||||
))}
|
||||
<optgroup label="预置音色">
|
||||
{presetVoices.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name} — {genderLabel(v.gender)}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
{clonedVoices.length > 0 && (
|
||||
<optgroup label="我的克隆音色">
|
||||
{clonedVoices.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type PresetVoiceDisplay } from "@/pages/voices/types"
|
||||
import type { TtsClonedVoiceOption } from "./VoiceSelector"
|
||||
|
||||
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
|
||||
|
||||
@@ -11,6 +12,8 @@ export interface TtsModalProps {
|
||||
ttsAudioUrl: string | null
|
||||
ttsError: string | null
|
||||
presetVoices: PresetVoiceDisplay[]
|
||||
/** 可用克隆音色(仅 ready),为空时下拉不显示该分组 */
|
||||
clonedVoices?: TtsClonedVoiceOption[]
|
||||
onClose: () => void
|
||||
onTextChange: (text: string) => void
|
||||
onVoiceChange: (voiceId: string) => void
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
|
||||
import { type PresetVoiceDisplay } from "../types"
|
||||
import type { TtsClonedVoiceOption } from "../components/tts-modal/VoiceSelector"
|
||||
|
||||
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
|
||||
|
||||
@@ -12,10 +13,16 @@ export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
|
||||
*/
|
||||
interface UseTtsSynthesizeProps {
|
||||
presetVoices: PresetVoiceDisplay[]
|
||||
/** 可用克隆音色(仅 ready;合成时 voice_id 直接传克隆 profile UUID,后端解析) */
|
||||
clonedVoices?: TtsClonedVoiceOption[]
|
||||
showToast: (message: string, type: "success" | "error") => void
|
||||
}
|
||||
|
||||
export function useTtsSynthesize({ presetVoices, showToast }: UseTtsSynthesizeProps) {
|
||||
export function useTtsSynthesize({
|
||||
presetVoices,
|
||||
clonedVoices = [],
|
||||
showToast,
|
||||
}: UseTtsSynthesizeProps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [ttsOpen, setTtsOpen] = useState(false)
|
||||
@@ -131,6 +138,7 @@ export function useTtsSynthesize({ presetVoices, showToast }: UseTtsSynthesizePr
|
||||
ttsError,
|
||||
// 可选音色列表
|
||||
ttsPresetVoices: presetVoices,
|
||||
ttsClonedVoices: clonedVoices,
|
||||
// Setters
|
||||
setTtsText,
|
||||
setTtsVoiceId,
|
||||
|
||||
@@ -1,42 +1,82 @@
|
||||
import React from "react"
|
||||
// 重构:useCloneModal Hook 已拆分为 useCloneFormState + useCloneSubmit 子 Hook
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/voice-clone", () => ({
|
||||
createVoiceClone: vi.fn(),
|
||||
toVoiceClone: vi.fn(),
|
||||
toVoiceClone: vi.fn((x) => x),
|
||||
}))
|
||||
|
||||
const mockGetAssetsByKind = vi.fn()
|
||||
vi.mock("@/api/assets", () => ({
|
||||
uploadAssetDirect: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ storage_key: "test", ingest_job_id: "test", url: "http://test" }),
|
||||
ensureDefaultLibrary: vi.fn().mockResolvedValue({ id: "lib-1" }),
|
||||
getAssetsByKind: (...args: unknown[]) => mockGetAssetsByKind(...args),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/projects", () => ({
|
||||
getOrCreateDefaultProject: vi.fn().mockResolvedValue({ id: "proj-1" }),
|
||||
}))
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: ({ enabled, queryFn }: { enabled: boolean; queryFn: () => unknown }) => {
|
||||
// enabled=false 时不发请求(模拟弹窗关闭)
|
||||
if (!enabled) return { data: undefined, isLoading: false }
|
||||
return { data: mockQueryData, isLoading: mockLoading }
|
||||
},
|
||||
}))
|
||||
|
||||
let mockQueryData: unknown = undefined
|
||||
let mockLoading = false
|
||||
|
||||
vi.mock("@/components/ui", () => ({
|
||||
Modal: ({ open, children, onCancel, onOk, title }: any) =>
|
||||
Modal: ({ open, children, onCancel, title }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog", "data-title": title }, children) : null,
|
||||
Button: ({ children, onClick, disabled, buttonType }: any) =>
|
||||
React.createElement("button", { onClick, disabled, "data-type": buttonType }, children),
|
||||
}))
|
||||
|
||||
describe("CloneModal", () => {
|
||||
it("should render when closed", () => {
|
||||
it("关闭时不渲染", () => {
|
||||
mockQueryData = undefined
|
||||
const { container } = render(<CloneModal open={false} onClose={vi.fn()} />)
|
||||
expect(container).toBeTruthy()
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it("should render input phase when open", () => {
|
||||
const { container } = render(<CloneModal open={true} onClose={vi.fn()} />)
|
||||
expect(container).toBeTruthy()
|
||||
it("打开时显示「从配音素材选择」和「直接录制」,不再有文件上传入口", () => {
|
||||
mockQueryData = []
|
||||
mockLoading = false
|
||||
render(<CloneModal open={true} onClose={vi.fn()} />)
|
||||
expect(screen.getByText("从配音素材选择")).toBeTruthy()
|
||||
expect(screen.getByText("直接录制")).toBeTruthy()
|
||||
// 文件上传入口已删除
|
||||
expect(screen.queryByText(/拖拽音频文件/)).toBeNull()
|
||||
expect(screen.queryByText("上传音频")).toBeNull()
|
||||
})
|
||||
|
||||
it("should call onClose when cancel", () => {
|
||||
const onClose = vi.fn()
|
||||
render(<CloneModal open={true} onClose={onClose} />)
|
||||
// just verify render doesn't crash
|
||||
expect(onClose).toBeDefined()
|
||||
it("素材为空时提示先上传素材并给跳转入口", () => {
|
||||
mockQueryData = []
|
||||
render(<CloneModal open={true} onClose={vi.fn()} />)
|
||||
expect(screen.getByText("请先在配音库上传素材")).toBeTruthy()
|
||||
expect(screen.getByText("去配音库上传")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("有素材时下拉展示素材名和时长", () => {
|
||||
mockQueryData = [
|
||||
{ id: "a1", name: "旁白录音.m4a", duration: 65 },
|
||||
{ id: "a2", name: "访谈.mp3", duration: undefined },
|
||||
]
|
||||
render(<CloneModal open={true} onClose={vi.fn()} />)
|
||||
expect(screen.getByText("旁白录音.m4a(01:05)")).toBeTruthy()
|
||||
expect(screen.getByText("访谈.mp3(--:--)")).toBeTruthy()
|
||||
// 空态提示不出现
|
||||
expect(screen.queryByText("请先在配音库上传素材")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* Smoke test for utils
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
import "@/components/voice/CloneModal/utils"
|
||||
|
||||
describe("utils smoke", () => {
|
||||
it("should load module successfully", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user