feat: 任务 3.13 克隆 Modal 前端开发 #173

Merged
xiaoxia merged 2 commits from feature/task-313-clone-modal into develop 2026-07-02 17:52:28 +08:00
4 changed files with 733 additions and 5 deletions
+2
View File
@@ -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">
MP3WAVM4A 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;
}
}
+25 -5
View File
@@ -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