feat: 任务 3.13 克隆 Modal 前端开发 #173
@@ -28,6 +28,7 @@ export interface VoiceClone {
|
||||
export interface CreateVoiceCloneRequest {
|
||||
name: string;
|
||||
audio_url: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/* ── 后端 API 类型 ────────────────────────────────────── */
|
||||
@@ -160,6 +161,7 @@ export const createVoiceClone = async (
|
||||
): Promise<VoiceCloneProfile> => {
|
||||
const payload: CreateVoiceCloneRequestFull = {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
source_audio_url: data.audio_url,
|
||||
};
|
||||
const response = await apiClient.post<VoiceCloneProfile>(
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* CloneModal — 音色克隆弹窗
|
||||
* 任务 3.13:实现克隆 Modal,用户点击「克隆音色」按钮后弹出
|
||||
*
|
||||
* 功能:
|
||||
* - 步骤引导:上传音频 → 填写信息 → 提交克隆
|
||||
* - 表单字段:音频文件上传(wav/mp3/m4a,≤10MB)、音色名称(必填,2-20字符)、音色描述(可选,≤100字符)
|
||||
* - 提交后调用 POST /api/v1/voice-clones 创建克隆
|
||||
* - 创建成功后关闭 Modal,刷新配音列表
|
||||
* - 错误处理:上传失败、格式错误、大小超限等提示
|
||||
*
|
||||
* 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" | "success";
|
||||
|
||||
export interface CloneModalProps {
|
||||
/** 弹窗是否可见 */
|
||||
open: boolean;
|
||||
/** 关闭弹窗回调 */
|
||||
onClose: () => void;
|
||||
/** 克隆成功回调(返回新创建的音色) */
|
||||
onSuccess?: (voice: VoiceClone) => void;
|
||||
}
|
||||
|
||||
/* ── 常量 ───────────────────────────────────────────────── */
|
||||
|
||||
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
|
||||
|
||||
/* ── 组件 ───────────────────────────────────────────────── */
|
||||
|
||||
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);
|
||||
|
||||
/** 组件卸载时清理定时器(P2-2 修复) */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
/** 重置弹窗状态 */
|
||||
const resetState = useCallback(() => {
|
||||
setPhase("input");
|
||||
setVoiceName("");
|
||||
setVoiceDescription("");
|
||||
setSelectedFile(null);
|
||||
setDragActive(false);
|
||||
setErrorMessage("");
|
||||
}, []);
|
||||
|
||||
/** 关闭弹窗 */
|
||||
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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 表单验证 ──────────────────────────────────────── */
|
||||
|
||||
const validateForm = (): string | null => {
|
||||
const name = voiceName.trim();
|
||||
if (!name) {
|
||||
return "请输入音色名称";
|
||||
}
|
||||
if (name.length < 2 || name.length > 20) {
|
||||
return "音色名称需在 2-20 个字符之间";
|
||||
}
|
||||
if (!selectedFile) {
|
||||
return "请上传音频文件";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/* ── 提交克隆 ──────────────────────────────────────── */
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const formError = validateForm();
|
||||
if (formError) {
|
||||
setErrorMessage(formError);
|
||||
return;
|
||||
}
|
||||
|
||||
setPhase("uploading");
|
||||
setErrorMessage("");
|
||||
|
||||
try {
|
||||
// P1 修复:先上传音频文件获取真实 URL,再调用克隆 API
|
||||
const formData = new FormData();
|
||||
formData.append("file", selectedFile!);
|
||||
const uploadResult = await uploadAsset(formData);
|
||||
|
||||
const result = await createVoiceClone({
|
||||
name: voiceName.trim(),
|
||||
description: voiceDescription.trim() || undefined,
|
||||
audio_url: uploadResult.url,
|
||||
});
|
||||
|
||||
setPhase("success");
|
||||
|
||||
// 2秒后自动关闭(P2-2 修复:使用 timerRef 以便 cleanup)
|
||||
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 &&
|
||||
selectedFile !== null;
|
||||
|
||||
const isProcessing = phase === "uploading";
|
||||
|
||||
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>
|
||||
<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-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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* ── 上传中阶段 ────────────────────────────────── */}
|
||||
{phase === "uploading" && (
|
||||
<div className="xx-clonemodal-uploading">
|
||||
<div className="xx-clonemodal-uploading-spinner" />
|
||||
<p className="xx-clonemodal-uploading-text">正在克隆你的音色…</p>
|
||||
<p className="xx-clonemodal-uploading-sub">
|
||||
AI 正在分析你的声音特征,请稍候
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 成功阶段 ──────────────────────────────────── */}
|
||||
{phase === "success" && (
|
||||
<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>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CloneModal;
|
||||
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* CloneModal 样式
|
||||
* 任务 3.13:音色克隆弹窗
|
||||
*
|
||||
* V21 Design System
|
||||
* CSS 前缀:xx-clonemodal-
|
||||
*/
|
||||
|
||||
/* ── 容器 ───────────────────────────────────────────────── */
|
||||
|
||||
.xx-clonemodal-body {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* ── 步骤引导 ───────────────────────────────────────────── */
|
||||
|
||||
.xx-clonemodal-steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
margin-bottom: 24px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.xx-clonemodal-step--active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.xx-clonemodal-step-number {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--xx-color-primary, #6366f1);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xx-clonemodal-step:not(.xx-clonemodal-step--active) .xx-clonemodal-step-number {
|
||||
background: var(--xx-color-border, #e5e7eb);
|
||||
color: var(--xx-color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.xx-clonemodal-step-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--xx-color-text, #111827);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xx-clonemodal-step-connector {
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
background: var(--xx-color-border, #e5e7eb);
|
||||
margin: 0 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── 表单字段 ───────────────────────────────────────────── */
|
||||
|
||||
.xx-clonemodal-field {
|
||||
margin-bottom: 18px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xx-clonemodal-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--xx-color-text, #111827);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-required {
|
||||
color: var(--xx-color-error, #ef4444);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--xx-color-border, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--xx-color-text, #111827);
|
||||
background: var(--xx-color-bg-secondary, #f9fafb);
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.xx-clonemodal-input:focus {
|
||||
border-color: var(--xx-color-primary, #6366f1);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.xx-clonemodal-input::placeholder {
|
||||
color: var(--xx-color-text-placeholder, #9ca3af);
|
||||
}
|
||||
|
||||
.xx-clonemodal-textarea {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--xx-color-border, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--xx-color-text, #111827);
|
||||
background: var(--xx-color-bg-secondary, #f9fafb);
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.xx-clonemodal-textarea:focus {
|
||||
border-color: var(--xx-color-primary, #6366f1);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.xx-clonemodal-textarea::placeholder {
|
||||
color: var(--xx-color-text-placeholder, #9ca3af);
|
||||
}
|
||||
|
||||
.xx-clonemodal-char-count {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
font-size: 12px;
|
||||
color: var(--xx-color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
/* ── 上传区域 ───────────────────────────────────────────── */
|
||||
|
||||
.xx-clonemodal-upload-zone {
|
||||
border: 2px dashed var(--xx-color-border, #e5e7eb);
|
||||
border-radius: 12px;
|
||||
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);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── 错误提示 ───────────────────────────────────────────── */
|
||||
|
||||
.xx-clonemodal-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
color: var(--xx-color-error, #ef4444);
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-error-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ── 提示 ───────────────────────────────────────────────── */
|
||||
|
||||
.xx-clonemodal-tip {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
background: rgba(99, 102, 241, 0.06);
|
||||
font-size: 12px;
|
||||
color: var(--xx-color-text-secondary, #6b7280);
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.xx-clonemodal-tip-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ── 底部按钮 ───────────────────────────────────────────── */
|
||||
|
||||
.xx-clonemodal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
/* ── 上传中状态 ─────────────────────────────────────────── */
|
||||
|
||||
.xx-clonemodal-uploading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-clonemodal-uploading-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid var(--xx-color-border, #e5e7eb);
|
||||
border-top-color: var(--xx-color-primary, #6366f1);
|
||||
border-radius: 50%;
|
||||
animation: xx-clonemodal-spin 0.8s linear infinite;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@keyframes xx-clonemodal-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.xx-clonemodal-uploading-text {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--xx-color-text, #111827);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-uploading-sub {
|
||||
font-size: 13px;
|
||||
color: var(--xx-color-text-secondary, #6b7280);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── 成功状态 ───────────────────────────────────────────── */
|
||||
|
||||
.xx-clonemodal-success {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-clonemodal-success-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-success-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--xx-color-text, #111827);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-success-desc {
|
||||
font-size: 13px;
|
||||
color: var(--xx-color-text-secondary, #6b7280);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── 响应式 ─────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.xx-clonemodal-steps {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-step-connector {
|
||||
width: 24px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-step-label {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-step-number {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@
|
||||
* 统计:fetchVoices({ limit: 1 }) 获取 preset_count / clone_count
|
||||
*/
|
||||
import React, { useMemo, useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
SoundOutlined,
|
||||
@@ -39,6 +38,7 @@ import {
|
||||
toVoiceClone,
|
||||
type VoiceClone,
|
||||
} from "@/api/voiceClone";
|
||||
import CloneModal from "@/components/voice/CloneModal";
|
||||
import "./voices.css";
|
||||
|
||||
/* ============================================================
|
||||
@@ -510,8 +510,6 @@ const CloneCardSkeleton: React.FC = () => (
|
||||
* 主组件
|
||||
* ============================================================ */
|
||||
const VoiceLibrary: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("preset");
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [filterGender, setFilterGender] = useState<string>("all");
|
||||
@@ -524,6 +522,7 @@ const VoiceLibrary: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [detailVoice, setDetailVoice] = useState<ClonedVoiceDisplay | null>(null);
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const [cloneModalOpen, setCloneModalOpen] = useState(false);
|
||||
|
||||
const showToast = useCallback((message: string, type: Toast["type"]) => {
|
||||
const id = ++toastIdSeq;
|
||||
@@ -685,11 +684,25 @@ const VoiceLibrary: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/** 克隆音色成功回调 */
|
||||
const handleCloneSuccess = (_voice: VoiceClone) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["voice-clones"] });
|
||||
showToast("克隆已提交,正在生成中", "success");
|
||||
};
|
||||
|
||||
const pageActions = (
|
||||
<div className="xx-voices-actions">
|
||||
<Button buttonType="ghost" buttonSize="sm" icon={<UploadOutlined />}>
|
||||
上传音频
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<AudioOutlined />}
|
||||
onClick={() => setCloneModalOpen(true)}
|
||||
>
|
||||
克隆音色
|
||||
</Button>
|
||||
<Button buttonType="primary" buttonSize="sm" icon={<RobotOutlined />}>
|
||||
AI 配音
|
||||
</Button>
|
||||
@@ -839,8 +852,8 @@ const VoiceLibrary: React.FC = () => {
|
||||
<div className="xx-voices-empty">
|
||||
<div className="xx-voices-empty-icon"><UserOutlined /></div>
|
||||
<h3>暂无克隆音色</h3>
|
||||
<p>前往「我的音色」页面,上传音频素材即可克隆专属音色</p>
|
||||
<Button buttonType="primary" buttonSize="md" onClick={() => navigate("/app/my-voices")}>
|
||||
<p>上传音频素材即可克隆专属音色</p>
|
||||
<Button buttonType="primary" buttonSize="md" onClick={() => setCloneModalOpen(true)}>
|
||||
<PlusOutlined /> 去克隆音色
|
||||
</Button>
|
||||
</div>
|
||||
@@ -856,6 +869,13 @@ const VoiceLibrary: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 克隆音色弹窗 */}
|
||||
<CloneModal
|
||||
open={cloneModalOpen}
|
||||
onClose={() => setCloneModalOpen(false)}
|
||||
onSuccess={handleCloneSuccess}
|
||||
/>
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
{detailVoice && (
|
||||
<CloneDetailModal
|
||||
|
||||
Reference in New Issue
Block a user