fa640ca537
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
Deploy / Staging E2E Tests (push) Failing after 116h28m49s
Deploy / Deploy Staging (push) Failing after 116h30m30s
CI/CD Pipeline / Frontend Lint (push) Failing after 116h31m4s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 116h31m4s
P3 优化(代码审计建议): 1. 录音达到 5 分钟自动停止并提示用户,避免传超大文件 2. cloneCounter 从模块级变量改为组件内 useRef,避免多实例串号 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
630 lines
22 KiB
TypeScript
630 lines
22 KiB
TypeScript
/**
|
||
* CloneModal — 音色克隆弹窗(共享组件)
|
||
*
|
||
* 功能:
|
||
* - 步骤引导:上传/录制音频 → 填写信息 → 提交克隆
|
||
* - 两种输入方式:上传文件(wav/mp3/m4a,≤10MB)或浏览器原生录音(MediaRecorder)
|
||
* - 提交:先 uploadAsset 获取真实 URL,再 createVoiceClone
|
||
* - 进度展示:上传中 → 克隆中 → 完成(三阶段可视化)
|
||
* - 错误处理:上传失败、格式错误、大小超限、录音权限拒绝等提示
|
||
*
|
||
* V21 Design System — 零 antd 直接导入
|
||
*/
|
||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||
import { Modal, Button } from "@/components/ui";
|
||
import { createVoiceClone, toVoiceClone } from "@/api/voiceClone";
|
||
import type { VoiceClone } from "@/api/voiceClone";
|
||
import { uploadAsset } from "@/api/assets";
|
||
import "./clone-modal.css";
|
||
|
||
/* ── 类型定义 ───────────────────────────────────────────── */
|
||
|
||
type ModalPhase = "input" | "uploading" | "cloning" | "done";
|
||
|
||
export interface CloneModalProps {
|
||
/** 弹窗是否可见 */
|
||
open: boolean;
|
||
/** 关闭弹窗回调 */
|
||
onClose: () => void;
|
||
/** 克隆成功回调(返回新创建的音色) */
|
||
onSuccess?: (voice: VoiceClone) => void;
|
||
}
|
||
|
||
/* ── 进度阶段配置 ─────────────────────────────────────────── */
|
||
|
||
const PROGRESS_STEPS: { key: string; label: string; icon: string }[] = [
|
||
{ key: "uploading", label: "上传中", icon: "📤" },
|
||
{ key: "cloning", label: "克隆中", icon: "🧬" },
|
||
{ key: "done", label: "完成", icon: "✅" },
|
||
];
|
||
|
||
/* ── 常量 ───────────────────────────────────────────────── */
|
||
|
||
const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a"];
|
||
const ACCEPTED_MIME = ".mp3,.wav,.m4a,audio/mpeg,audio/wav,audio/mp4";
|
||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||
|
||
/** 最长录制时长:5 分钟(秒) */
|
||
const MAX_RECORD_SECONDS = 5 * 60;
|
||
|
||
/* ── 组件 ───────────────────────────────────────────────── */
|
||
|
||
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 [isRecording, setIsRecording] = useState(false);
|
||
const [recordTime, setRecordTime] = useState(0);
|
||
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null);
|
||
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||
const audioChunksRef = useRef<Blob[]>([]);
|
||
/** 默认音色名称计数器(组件级 ref,避免多实例串号) */
|
||
const cloneCounterRef = useRef(1);
|
||
|
||
/** 生成下一个默认音色名称 */
|
||
const getNextDefaultName = useCallback((): string => {
|
||
const name = `我的声音 ${cloneCounterRef.current}`;
|
||
cloneCounterRef.current += 1;
|
||
return name;
|
||
}, []);
|
||
|
||
/** 组件卸载时清理定时器和 MediaRecorder */
|
||
useEffect(() => {
|
||
return () => {
|
||
if (timerRef.current) clearTimeout(timerRef.current);
|
||
if (recordTimerRef.current) clearInterval(recordTimerRef.current);
|
||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||
mediaRecorderRef.current.stop();
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
/** 重置弹窗状态 */
|
||
const resetState = useCallback(() => {
|
||
setPhase("input");
|
||
setVoiceName(getNextDefaultName());
|
||
setVoiceDescription("");
|
||
setSelectedFile(null);
|
||
setDragActive(false);
|
||
setErrorMessage("");
|
||
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;
|
||
}, [getNextDefaultName]);
|
||
|
||
/** 关闭弹窗 */
|
||
const handleClose = useCallback(() => {
|
||
resetState();
|
||
onClose();
|
||
}, [resetState, onClose]);
|
||
|
||
/** 弹窗打开时重置状态 */
|
||
useEffect(() => {
|
||
if (open) {
|
||
resetState();
|
||
}
|
||
}, [open, resetState]);
|
||
|
||
/* ── 文件验证 ──────────────────────────────────────── */
|
||
|
||
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;
|
||
};
|
||
|
||
/* ── 文件上传 ──────────────────────────────────────── */
|
||
|
||
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 {
|
||
setErrorMessage("");
|
||
setSelectedFile(file);
|
||
// 清除录音
|
||
setRecordedBlob(null);
|
||
setIsRecording(false);
|
||
setRecordTime(0);
|
||
}
|
||
}
|
||
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 {
|
||
setErrorMessage("");
|
||
setSelectedFile(file);
|
||
setRecordedBlob(null);
|
||
setIsRecording(false);
|
||
setRecordTime(0);
|
||
}
|
||
}
|
||
};
|
||
|
||
/* ── 录音(真实 MediaRecorder) ───────────────────── */
|
||
|
||
const handleRecord = async () => {
|
||
if (isRecording) {
|
||
// 停止录制
|
||
setIsRecording(false);
|
||
if (recordTimerRef.current) {
|
||
clearInterval(recordTimerRef.current);
|
||
recordTimerRef.current = null;
|
||
}
|
||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||
mediaRecorderRef.current.stop();
|
||
}
|
||
} else {
|
||
// 开始录制
|
||
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);
|
||
// 清除上传的文件
|
||
setSelectedFile(null);
|
||
// 停止音轨
|
||
stream.getTracks().forEach((track) => track.stop());
|
||
};
|
||
|
||
mediaRecorder.start();
|
||
setIsRecording(true);
|
||
setRecordTime(0);
|
||
setRecordedBlob(null);
|
||
setErrorMessage("");
|
||
|
||
recordTimerRef.current = setInterval(() => {
|
||
setRecordTime((prev) => {
|
||
const next = prev + 1;
|
||
if (next >= MAX_RECORD_SECONDS) {
|
||
// 达到 5 分钟上限,自动停止录制
|
||
setTimeout(() => {
|
||
setIsRecording(false);
|
||
if (recordTimerRef.current) {
|
||
clearInterval(recordTimerRef.current);
|
||
recordTimerRef.current = null;
|
||
}
|
||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||
mediaRecorderRef.current.stop();
|
||
}
|
||
setErrorMessage("已达最长录制时长(5分钟),已自动停止");
|
||
}, 0);
|
||
return MAX_RECORD_SECONDS;
|
||
}
|
||
return next;
|
||
});
|
||
}, 1000);
|
||
} catch {
|
||
setErrorMessage("无法访问麦克风,请检查浏览器权限设置");
|
||
}
|
||
}
|
||
};
|
||
|
||
/** 格式化录制时间 mm:ss */
|
||
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")}`;
|
||
};
|
||
|
||
/* ── 表单验证 ──────────────────────────────────────── */
|
||
|
||
const hasAudio = selectedFile !== null || recordedBlob !== null;
|
||
|
||
const validateForm = (): string | null => {
|
||
const name = voiceName.trim();
|
||
if (!name) {
|
||
return "请输入音色名称";
|
||
}
|
||
if (name.length < 2 || name.length > 20) {
|
||
return "音色名称需在 2-20 个字符之间";
|
||
}
|
||
if (!hasAudio) {
|
||
return "请上传音频文件或录制一段声音";
|
||
}
|
||
return null;
|
||
};
|
||
|
||
/* ── 提交克隆 ──────────────────────────────────────── */
|
||
|
||
const handleSubmit = async () => {
|
||
const formError = validateForm();
|
||
if (formError) {
|
||
setErrorMessage(formError);
|
||
return;
|
||
}
|
||
|
||
setErrorMessage("");
|
||
|
||
try {
|
||
// 阶段 1:上传音频
|
||
setPhase("uploading");
|
||
|
||
let fileToUpload: File;
|
||
if (selectedFile) {
|
||
fileToUpload = selectedFile;
|
||
} else {
|
||
// 将录音 Blob 转为 File
|
||
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
|
||
type: "audio/webm",
|
||
});
|
||
}
|
||
|
||
const formData = new FormData();
|
||
formData.append("file", fileToUpload);
|
||
const uploadResult = await uploadAsset(formData);
|
||
|
||
// 阶段 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));
|
||
handleClose();
|
||
}, 2000);
|
||
} catch (err) {
|
||
setPhase("input");
|
||
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试");
|
||
}
|
||
};
|
||
|
||
/* ── 计算属性 ──────────────────────────────────────── */
|
||
|
||
const canSubmit =
|
||
voiceName.trim().length >= 2 &&
|
||
voiceName.trim().length <= 20 &&
|
||
hasAudio;
|
||
|
||
const isProcessing = phase === "uploading" || phase === "cloning";
|
||
|
||
/** 当前进度索引 */
|
||
const getProgressIndex = (): number => {
|
||
switch (phase) {
|
||
case "uploading":
|
||
return 0;
|
||
case "cloning":
|
||
return 1;
|
||
case "done":
|
||
return 2;
|
||
default:
|
||
return -1;
|
||
}
|
||
};
|
||
|
||
const progressIndex = getProgressIndex();
|
||
|
||
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
|
||
? `录制中 ${formatRecordTime(recordTime)}`
|
||
: recordedBlob
|
||
? `已录制 ${formatRecordTime(recordTime)}`
|
||
: "点击按钮开始录制(最长 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={handleRecord}
|
||
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;
|