feat(task-3.14): 一键生成对接音色克隆 — API获取克隆列表 + VoiceCloneModal集成 #150

Merged
xiaoxia merged 2 commits from feat/generate-voice-clone into develop 2026-07-01 16:36:23 +08:00
5 changed files with 1050 additions and 82 deletions
@@ -0,0 +1,441 @@
/**
* VoiceCloneModal — 音色克隆弹窗
*
* 功能:上传音频文件 / 录制音频、填写音色名称、提交克隆任务
* 进度展示:上传中 → 克隆中 → 完成(三阶段可视化)
* 对接 APIMock):POST /api/v1/voice-clones
*
* CSS 变量统一,与配音库 / 我的音色页面风格一致
* V21 Design System — 零 antd 直接导入
*/
import React, { useState, useCallback, useRef, useEffect } from "react";
import { Modal, Button } from "@/components/ui";
import { createVoiceClone } from "@/api/voiceClone";
import type { VoiceClone } from "@/api/voiceClone";
import "./voice-clone-modal.css";
/* ── 类型定义 ───────────────────────────────────────────── */
/** 弹窗阶段:input=输入 | uploading=上传中 | cloning=克隆中 | done=完成 */
type ModalPhase = "input" | "uploading" | "cloning" | "done";
export interface VoiceCloneModalProps {
/** 弹窗是否可见 */
open: boolean;
/** 关闭弹窗回调 */
onClose: () => void;
/** 克隆成功回调(返回新创建的音色) */
onSuccess?: (voice: VoiceClone) => void;
}
/* ── 进度阶段配置 ─────────────────────────────────────────── */
const PROGRESS_STEPS: { key: ModalPhase; label: string; icon: string }[] = [
{ key: "uploading", label: "上传中", icon: "📤" },
{ key: "cloning", label: "克隆中", icon: "🧬" },
{ key: "done", label: "完成", icon: "✅" },
];
/* ── 默认音色名称计数器 ─────────────────────────────────── */
let cloneCounter = 1;
const getNextDefaultName = (): string => {
const name = `我的声音 ${cloneCounter}`;
cloneCounter += 1;
return name;
};
/* ── 支持的文件扩展名 ─────────────────────────────────── */
const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a", "aac", "ogg"];
const ACCEPTED_MIME = ".mp3,.wav,.m4a,.aac,.ogg,audio/mpeg,audio/wav,audio/mp4,audio/aac,audio/ogg";
/* ── 组件 ───────────────────────────────────────────────── */
const VoiceCloneModal: React.FC<VoiceCloneModalProps> = ({
open,
onClose,
onSuccess,
}) => {
const [phase, setPhase] = useState<ModalPhase>("input");
const [voiceName, setVoiceName] = useState("");
const [isRecording, setIsRecording] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [dragActive, setDragActive] = useState(false);
const [recordTime, setRecordTime] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
/** 重置弹窗状态 */
const resetState = useCallback(() => {
setPhase("input");
setVoiceName("");
setSelectedFile(null);
setIsRecording(false);
setDragActive(false);
setRecordTime(0);
if (recordTimerRef.current) {
clearInterval(recordTimerRef.current);
recordTimerRef.current = null;
}
}, []);
/** 关闭弹窗 */
const handleClose = useCallback(() => {
resetState();
onClose();
}, [resetState, onClose]);
/** 弹窗打开时初始化默认名称 */
useEffect(() => {
if (open) {
setVoiceName(getNextDefaultName());
}
}, [open]);
/** 清理录制计时器 */
useEffect(() => {
return () => {
if (recordTimerRef.current) {
clearInterval(recordTimerRef.current);
}
};
}, []);
/* ── 文件上传 ──────────────────────────────────────── */
const handleUploadClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
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 ext = file.name.split(".").pop()?.toLowerCase();
if (ext && ACCEPTED_EXTENSIONS.includes(ext)) {
setSelectedFile(file);
setIsRecording(false);
setRecordTime(0);
}
}
};
/* ── 录制(mock) ──────────────────────────────────── */
const handleRecord = () => {
if (isRecording) {
// 停止录制
setIsRecording(false);
if (recordTimerRef.current) {
clearInterval(recordTimerRef.current);
recordTimerRef.current = null;
}
} else {
// 开始录制
setIsRecording(true);
setSelectedFile(null);
setRecordTime(0);
recordTimerRef.current = setInterval(() => {
setRecordTime((prev) => prev + 1);
}, 1000);
}
};
/** 格式化录制时间 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 handleStartClone = async () => {
const name = voiceName.trim() || getNextDefaultName();
// 阶段 1:上传中
setPhase("uploading");
await new Promise((r) => setTimeout(r, 1200));
// 阶段 2:克隆中
setPhase("cloning");
try {
const result = await createVoiceClone({
name,
audio_url: selectedFile
? `mock://${selectedFile.name}`
: "mock://recorded-audio",
});
// 阶段 3:完成
setPhase("done");
// 2秒后自动关闭
setTimeout(() => {
onSuccess?.(result);
handleClose();
}, 2000);
} catch {
setPhase("input");
}
};
/** 当前进度索引(用于进度条展示) */
const getProgressIndex = (): number => {
switch (phase) {
case "uploading":
return 0;
case "cloning":
return 1;
case "done":
return 2;
default:
return -1;
}
};
const canStart = selectedFile || isRecording;
const progressIndex = getProgressIndex();
const isProcessing = phase === "uploading" || phase === "cloning";
return (
<Modal
open={open}
onCancel={handleClose}
title="🎤 克隆新音色"
width={540}
footer={null}
destroyOnClose
maskClosable={!isProcessing}
keyboard={!isProcessing}
>
{/* ── 输入阶段 ──────────────────────────────────── */}
{phase === "input" && (
<div className="xx-vcmodal-body">
{/* 音色名称 */}
<div className="xx-vcmodal-field">
<label className="xx-vcmodal-label"></label>
<input
type="text"
className="xx-vcmodal-input"
value={voiceName}
onChange={(e) => setVoiceName(e.target.value)}
placeholder="输入音色名称"
maxLength={30}
/>
</div>
{/* 上传区域 */}
<div className="xx-vcmodal-field">
<label className="xx-vcmodal-label"></label>
<div
className={`xx-vcmodal-upload-zone${dragActive ? " xx-vcmodal-upload-zone--active" : ""}${selectedFile ? " xx-vcmodal-upload-zone--has-file" : ""}`}
onClick={handleUploadClick}
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
>
<div className="xx-vcmodal-upload-icon">
{selectedFile ? "📄" : "🎵"}
</div>
<p className="xx-vcmodal-upload-title">
{selectedFile
? selectedFile.name
: "拖拽音频文件到此处,或点击上传"}
</p>
<p className="xx-vcmodal-upload-hint">
MP3WAVM4AAACOGG
</p>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_MIME}
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
</div>
{/* 或分隔 */}
<div className="xx-vcmodal-divider">
<div className="xx-vcmodal-divider-line" />
<span className="xx-vcmodal-divider-text"></span>
<div className="xx-vcmodal-divider-line" />
</div>
{/* 录制区域 */}
<div className="xx-vcmodal-field">
<label className="xx-vcmodal-label"></label>
<div className="xx-vcmodal-record-area">
<div className="xx-vcmodal-record-info">
<p className="xx-vcmodal-record-hint">
{isRecording
? `录制中 ${formatRecordTime(recordTime)}`
: "点击按钮开始录制你的声音"}
</p>
{isRecording && (
<div className="xx-vcmodal-record-wave">
<span className="xx-vcmodal-record-wave-bar" />
<span className="xx-vcmodal-record-wave-bar" />
<span className="xx-vcmodal-record-wave-bar" />
<span className="xx-vcmodal-record-wave-bar" />
<span className="xx-vcmodal-record-wave-bar" />
</div>
)}
</div>
<button
type="button"
className={`xx-vcmodal-record-btn${isRecording ? " xx-vcmodal-record-btn--recording" : ""}`}
onClick={handleRecord}
title={isRecording ? "停止录制" : "开始录制"}
>
{isRecording ? "⏹" : "🎙️"}
</button>
</div>
</div>
{/* 提示 */}
<div className="xx-vcmodal-tip">
<span className="xx-vcmodal-tip-icon">💡</span>
<span>
10 ~ 3
</span>
</div>
{/* 底部按钮 */}
<div className="xx-vcmodal-footer">
<Button buttonType="ghost" onClick={handleClose}>
</Button>
<Button
buttonType="primary"
disabled={!canStart}
onClick={handleStartClone}
>
🎤
</Button>
</div>
</div>
)}
{/* ── 进度阶段(上传中 / 克隆中 / 完成) ─────── */}
{isProcessing && (
<div className="xx-vcmodal-progress-body">
{/* 步骤指示器 */}
<div className="xx-vcmodal-steps">
{PROGRESS_STEPS.map((step, idx) => {
const isActive = idx === progressIndex;
const isDone = idx < progressIndex;
const stepClass = [
"xx-vcmodal-step",
isActive ? "xx-vcmodal-step--active" : "",
isDone ? "xx-vcmodal-step--done" : "",
]
.filter(Boolean)
.join(" ");
return (
<React.Fragment key={step.key}>
{idx > 0 && (
<div
className={`xx-vcmodal-step-connector${isDone ? " xx-vcmodal-step-connector--done" : ""}`}
/>
)}
<div className={stepClass}>
<div className="xx-vcmodal-step-icon">
{isDone ? "✓" : step.icon}
</div>
<span className="xx-vcmodal-step-label">
{step.label}
</span>
</div>
</React.Fragment>
);
})}
</div>
{/* 当前阶段描述 */}
<div className="xx-vcmodal-progress-info">
{phase === "uploading" && (
<>
<div className="xx-vcmodal-progress-spinner" />
<p className="xx-vcmodal-progress-text"></p>
<p className="xx-vcmodal-progress-sub">
</p>
</>
)}
{phase === "cloning" && (
<>
<div className="xx-vcmodal-progress-spinner xx-vcmodal-progress-spinner--cloning" />
<p className="xx-vcmodal-progress-text">
AI
</p>
<p className="xx-vcmodal-progress-sub">
</p>
</>
)}
</div>
</div>
)}
{/* ── 完成阶段 ──────────────────────────────────── */}
{phase === "done" && (
<div className="xx-vcmodal-progress-body">
{/* 步骤指示器(全部完成) */}
<div className="xx-vcmodal-steps">
{PROGRESS_STEPS.map((step, idx) => (
<React.Fragment key={step.key}>
{idx > 0 && (
<div className="xx-vcmodal-step-connector xx-vcmodal-step-connector--done" />
)}
<div className="xx-vcmodal-step xx-vcmodal-step--done">
<div className="xx-vcmodal-step-icon"></div>
<span className="xx-vcmodal-step-label">{step.label}</span>
</div>
</React.Fragment>
))}
</div>
<div className="xx-vcmodal-success">
<div className="xx-vcmodal-success-icon">🎉</div>
<h3 className="xx-vcmodal-success-title"></h3>
<p className="xx-vcmodal-success-desc">
</p>
</div>
</div>
)}
</Modal>
);
};
export default VoiceCloneModal;
@@ -0,0 +1,471 @@
/**
* VoiceCloneModal 样式 — V21 设计系统
* CSS 变量统一,与配音库 / 我的音色页面风格一致
* 命名规范:xx-vcmodal-*
*/
@import "../../styles/global.css";
/* ── 弹窗内容区 ───────────────────────────────────────────── */
.xx-vcmodal-body {
display: flex;
flex-direction: column;
gap: var(--space-md);
padding: var(--space-sm) 0;
}
/* ── 字段容器 ─────────────────────────────────────────────── */
.xx-vcmodal-field {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.xx-vcmodal-label {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
letter-spacing: var(--letter-spacing-wide);
}
/* ── 输入框 ───────────────────────────────────────────────── */
.xx-vcmodal-input {
width: 100%;
height: 40px;
padding: 0 var(--space-md);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
font-size: var(--font-size-base);
color: var(--text-primary);
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.xx-vcmodal-input::placeholder {
color: var(--text-tertiary);
}
.xx-vcmodal-input:focus {
border-color: var(--color-primary-400);
box-shadow: 0 0 0 3px var(--color-primary-100);
}
/* ── 上传区域 ─────────────────────────────────────────────── */
.xx-vcmodal-upload-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: var(--space-lg) var(--space-md);
border: 2px dashed var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-secondary);
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease;
}
.xx-vcmodal-upload-zone:hover {
border-color: var(--color-primary-300);
background: var(--color-primary-50);
}
.xx-vcmodal-upload-zone--active {
border-color: var(--color-primary-500);
background: var(--color-primary-100);
}
.xx-vcmodal-upload-zone--has-file {
border-style: solid;
border-color: var(--color-primary-400);
background: var(--color-primary-50);
}
.xx-vcmodal-upload-icon {
font-size: 32px;
line-height: 1;
}
.xx-vcmodal-upload-title {
margin: 0;
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
color: var(--text-primary);
text-align: center;
word-break: break-all;
}
.xx-vcmodal-upload-hint {
margin: 0;
font-size: var(--font-size-sm);
color: var(--text-secondary);
text-align: center;
}
/* ── 分隔线 ───────────────────────────────────────────────── */
.xx-vcmodal-divider {
display: flex;
align-items: center;
gap: var(--space-md);
margin: var(--space-xs) 0;
}
.xx-vcmodal-divider-line {
flex: 1;
height: 1px;
background: var(--border-color);
}
.xx-vcmodal-divider-text {
font-size: var(--font-size-sm);
color: var(--text-tertiary);
font-weight: var(--font-weight-medium);
}
/* ── 录制区域 ─────────────────────────────────────────────── */
.xx-vcmodal-record-area {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
}
.xx-vcmodal-record-info {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.xx-vcmodal-record-hint {
margin: 0;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* 录制波形动画 */
.xx-vcmodal-record-wave {
display: flex;
align-items: flex-end;
gap: 3px;
height: 20px;
}
.xx-vcmodal-record-wave-bar {
width: 3px;
background: var(--color-primary-500);
border-radius: var(--radius-full);
animation: vcmodal-wave 0.8s ease-in-out infinite alternate;
}
.xx-vcmodal-record-wave-bar:nth-child(1) { height: 40%; animation-delay: 0s; }
.xx-vcmodal-record-wave-bar:nth-child(2) { height: 70%; animation-delay: 0.15s; }
.xx-vcmodal-record-wave-bar:nth-child(3) { height: 100%; animation-delay: 0.3s; }
.xx-vcmodal-record-wave-bar:nth-child(4) { height: 60%; animation-delay: 0.45s; }
.xx-vcmodal-record-wave-bar:nth-child(5) { height: 30%; animation-delay: 0.6s; }
@keyframes vcmodal-wave {
from { transform: scaleY(0.4); }
to { transform: scaleY(1); }
}
/* 录制按钮 */
.xx-vcmodal-record-btn {
width: 48px;
height: 48px;
border: none;
border-radius: var(--radius-full);
background: var(--bg-primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
font-size: 20px;
cursor: pointer;
transition: background 0.2s ease, transform 0.15s ease, box-shadow 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.xx-vcmodal-record-btn:hover {
background: var(--color-primary-50);
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.xx-vcmodal-record-btn--recording {
background: var(--error-color);
color: var(--text-inverse);
animation: vcmodal-pulse 1.2s ease-in-out infinite;
}
.xx-vcmodal-record-btn--recording:hover {
background: var(--error-hover);
}
@keyframes vcmodal-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
50% { box-shadow: 0 0 0 8px rgba(239, 68, 68, 0); }
}
/* ── 提示 ─────────────────────────────────────────────────── */
.xx-vcmodal-tip {
display: flex;
align-items: flex-start;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-xs);
background: var(--info-soft);
border: 1px solid var(--info-border);
font-size: var(--font-size-sm);
color: var(--text-secondary);
line-height: var(--line-height-base);
}
.xx-vcmodal-tip-icon {
flex-shrink: 0;
font-size: var(--font-size-base);
}
/* ── 底部按钮 ─────────────────────────────────────────────── */
.xx-vcmodal-footer {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
padding-top: var(--space-sm);
border-top: 1px solid var(--border-light);
}
/* ── 进度阶段通用 ─────────────────────────────────────────── */
.xx-vcmodal-progress-body {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-xl);
padding: var(--space-lg) 0 var(--space-md);
}
/* ── 步骤指示器 ───────────────────────────────────────────── */
.xx-vcmodal-steps {
display: flex;
align-items: center;
gap: 0;
width: 100%;
max-width: 360px;
}
.xx-vcmodal-step {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-xs);
flex: 1;
position: relative;
}
.xx-vcmodal-step-icon {
width: 40px;
height: 40px;
border-radius: var(--radius-full);
background: var(--bg-tertiary);
border: 2px solid var(--border-color);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: var(--text-tertiary);
transition: all 0.3s ease;
}
.xx-vcmodal-step--active .xx-vcmodal-step-icon {
background: var(--color-primary-50);
border-color: var(--color-primary-500);
color: var(--color-primary-600);
box-shadow: 0 0 0 4px var(--color-primary-100);
}
.xx-vcmodal-step--done .xx-vcmodal-step-icon {
background: var(--color-secondary-50);
border-color: var(--color-secondary-500);
color: var(--color-secondary-600);
}
.xx-vcmodal-step-label {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--text-tertiary);
transition: color 0.3s ease;
}
.xx-vcmodal-step--active .xx-vcmodal-step-label {
color: var(--color-primary-600);
font-weight: var(--font-weight-semibold);
}
.xx-vcmodal-step--done .xx-vcmodal-step-label {
color: var(--color-secondary-600);
}
/* 步骤连接线 */
.xx-vcmodal-step-connector {
flex: 0 0 40px;
height: 2px;
background: var(--border-color);
margin-bottom: 20px;
transition: background 0.3s ease;
}
.xx-vcmodal-step-connector--done {
background: var(--color-secondary-400);
}
/* ── 进度信息 ─────────────────────────────────────────────── */
.xx-vcmodal-progress-info {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-sm);
text-align: center;
}
/* 加载动画 */
.xx-vcmodal-progress-spinner {
width: 48px;
height: 48px;
border: 3px solid var(--color-primary-100);
border-top-color: var(--color-primary-500);
border-radius: var(--radius-full);
animation: vcmodal-spin 0.8s linear infinite;
}
.xx-vcmodal-progress-spinner--cloning {
border-color: var(--color-secondary-100);
border-top-color: var(--color-secondary-500);
}
@keyframes vcmodal-spin {
to { transform: rotate(360deg); }
}
.xx-vcmodal-progress-text {
margin: 0;
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.xx-vcmodal-progress-sub {
margin: 0;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* ── 完成阶段 ─────────────────────────────────────────────── */
.xx-vcmodal-success {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-sm);
text-align: center;
}
.xx-vcmodal-success-icon {
font-size: 48px;
line-height: 1;
animation: vcmodal-bounce 0.6s ease-out;
}
@keyframes vcmodal-bounce {
0% { transform: scale(0); opacity: 0; }
60% { transform: scale(1.2); opacity: 1; }
100% { transform: scale(1); }
}
.xx-vcmodal-success-title {
margin: 0;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
}
.xx-vcmodal-success-desc {
margin: 0;
font-size: var(--font-size-sm);
color: var(--text-secondary);
max-width: 280px;
line-height: var(--line-height-relaxed);
}
/* ── 响应式 ───────────────────────────────────────────────── */
@media (max-width: 768px) {
.xx-vcmodal-body {
gap: var(--space-sm);
}
.xx-vcmodal-upload-zone {
padding: var(--space-md) var(--space-sm);
}
.xx-vcmodal-record-area {
flex-direction: column;
align-items: stretch;
text-align: center;
}
.xx-vcmodal-record-btn {
align-self: center;
}
.xx-vcmodal-steps {
max-width: 280px;
}
.xx-vcmodal-step-connector {
flex: 0 0 24px;
}
.xx-vcmodal-footer {
flex-direction: column-reverse;
}
.xx-vcmodal-footer > * {
width: 100%;
}
}
@media (max-width: 480px) {
.xx-vcmodal-step-icon {
width: 32px;
height: 32px;
font-size: 13px;
}
.xx-vcmodal-step-connector {
flex: 0 0 16px;
margin-bottom: 16px;
}
.xx-vcmodal-progress-spinner {
width: 36px;
height: 36px;
}
.xx-vcmodal-success-icon {
font-size: 36px;
}
}
+119 -81
View File
@@ -3,7 +3,7 @@
* 功能:标题/描述输入 → 素材选择 → 配音选择 → 标题/文案 → 高级设置 → 生成 → 结果
* 使用 Task 1.5 UI 组件 + V21 CSS 变量,Mock 数据
*/
import React, { useState, useRef, useCallback } from "react";
import React, { useState, useRef, useCallback, useEffect } from "react";
import {
Typography,
Collapse,
@@ -37,6 +37,9 @@ import Tag from "@/components/ui/Tag";
import Form, { FormItem } from "@/components/ui/Form";
import type { AssetItem } from "@/api/assets";
import type { VoiceItem } from "@/api/voices";
import { getVoiceClones, formatDuration } from "@/api/voiceClone";
import type { VoiceClone } from "@/api/voiceClone";
import VoiceCloneModal from "@/components/modals/VoiceCloneModal";
import "./generate.css";
const { TextArea } = Input;
@@ -144,19 +147,6 @@ const MOCK_VOICES: VoiceItem[] = [
},
];
/* ── 克隆声音 Mock ── */
interface ClonedVoice {
id: string;
name: string;
createdAt: string;
duration: string;
}
const MOCK_CLONED_VOICES: ClonedVoice[] = [
{ id: "cv1", name: "我的声音 v1", createdAt: "2026-06-20", duration: "45s" },
{ id: "cv2", name: "我的声音 v2", createdAt: "2026-06-25", duration: "30s" },
];
/* ── 时间线 Mock ── */
interface TimelineScene {
scene: string;
@@ -277,9 +267,10 @@ const GeneratePage: React.FC = () => {
const [customVoiceText, setCustomVoiceText] = useState("");
/* ── 克隆声音 ── */
const [cloneFileList, setCloneFileList] = useState<UploadFile[]>([]);
const [clonedVoices, setClonedVoices] = useState<VoiceClone[]>([]);
const [selectedClonedVoice, setSelectedClonedVoice] = useState<string>("");
const [cloning, setCloning] = useState(false);
const [cloneModalOpen, setCloneModalOpen] = useState(false);
const [loadingClones, setLoadingClones] = useState(false);
/* ── 标题/文案 ── */
const [titleMode, setTitleMode] = useState<"ai" | "manual">("ai");
@@ -300,6 +291,32 @@ const GeneratePage: React.FC = () => {
const progressTimer = useRef<ReturnType<typeof setInterval>>(undefined);
/* ── 获取克隆音色列表 ── */
const fetchClonedVoices = useCallback(async () => {
setLoadingClones(true);
try {
const data = await getVoiceClones();
setClonedVoices(data);
} catch {
message.error("获取克隆音色失败");
} finally {
setLoadingClones(false);
}
}, []);
useEffect(() => {
fetchClonedVoices();
}, [fetchClonedVoices]);
const handleCloneSuccess = useCallback(
(voice: VoiceClone) => {
setClonedVoices((prev) => [voice, ...prev]);
setCloneModalOpen(false);
message.success("音色克隆成功!");
},
[],
);
/* ── 事件 ── */
const toggleMaterial = useCallback((id: string) => {
@@ -611,87 +628,101 @@ const GeneratePage: React.FC = () => {
AI
</Typography.Paragraph>
{/* 上传区域 */}
<Upload.Dragger
accept=".wav,.mp3,audio/*"
fileList={cloneFileList}
onChange={({ fileList: fl }) => setCloneFileList(fl)}
beforeUpload={() => false}
maxCount={1}
className="xx-clone-upload-area"
>
<div className="xx-clone-upload-icon">
<AudioOutlined />
</div>
<Typography.Paragraph className="xx-clone-upload-text">
</Typography.Paragraph>
<Typography.Paragraph className="xx-clone-upload-hint">
WAV / MP3 30
</Typography.Paragraph>
</Upload.Dragger>
{/* 开始克隆按钮 */}
<div style={{ marginTop: 16, textAlign: "center" }}>
{/* 克隆新声音按钮 */}
<div style={{ marginTop: 12, marginBottom: 16 }}>
<Button
buttonType="primary"
icon={<ThunderboltOutlined />}
loading={cloning}
onClick={() => {
if (cloneFileList.length === 0) {
message.warning("请先上传音频文件");
return;
}
setCloning(true);
setTimeout(() => {
setCloning(false);
message.success("声音克隆完成!");
}, 2000);
}}
onClick={() => setCloneModalOpen(true)}
>
</Button>
</div>
{/* 已克隆声音列表 */}
{MOCK_CLONED_VOICES.length > 0 && (
{loadingClones ? (
<Typography.Paragraph
style={{ color: "var(--text-secondary)" }}
>
</Typography.Paragraph>
) : clonedVoices.length > 0 ? (
<div className="xx-clone-voices-list">
<Typography.Paragraph className="xx-clone-voices-label">
{clonedVoices.length}
</Typography.Paragraph>
<div className="xx-voice-grid">
{MOCK_CLONED_VOICES.map((cv) => (
<div
key={cv.id}
className={`xx-voice-card ${selectedClonedVoice === cv.id ? "xx-voice-card-selected" : ""}`}
onClick={() => setSelectedClonedVoice(cv.id)}
role="button"
tabIndex={0}
aria-pressed={selectedClonedVoice === cv.id}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelectedClonedVoice(cv.id);
}
}}
>
<div className="xx-voice-header">
<div className="xx-voice-avatar">
<AudioOutlined />
</div>
<div>
<Typography.Paragraph className="xx-voice-name">
{cv.name}
</Typography.Paragraph>
<Typography.Paragraph className="xx-voice-desc">
{cv.createdAt} · {cv.duration}
</Typography.Paragraph>
{clonedVoices.map((cv) => {
const selected = selectedClonedVoice === cv.id;
const isReady = cv.status === "ready";
const isProcessing = cv.status === "processing";
return (
<div
key={cv.id}
className={`xx-voice-card ${selected ? "xx-voice-card-selected" : ""} ${!isReady ? "xx-voice-card-disabled" : ""}`}
onClick={() => {
if (isReady) setSelectedClonedVoice(cv.id);
}}
role="button"
tabIndex={isReady ? 0 : -1}
aria-pressed={selected}
aria-disabled={!isReady}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (isReady) setSelectedClonedVoice(cv.id);
}
}}
>
<div className="xx-voice-header">
<div className="xx-voice-avatar">
<AudioOutlined />
</div>
<div>
<Typography.Paragraph className="xx-voice-name">
{cv.name}
</Typography.Paragraph>
<Typography.Paragraph className="xx-voice-desc">
{formatDuration(cv.duration_seconds)}
{isProcessing && (
<Tag
variant="warning"
style={{ marginLeft: 6 }}
>
</Tag>
)}
{cv.status === "failed" && (
<Tag
variant="danger"
style={{ marginLeft: 6 }}
>
</Tag>
)}
</Typography.Paragraph>
</div>
</div>
{selected && isReady && (
<div className="xx-voice-check">
<CheckCircleFilled />
</div>
)}
</div>
</div>
))}
);
})}
</div>
</div>
) : (
<Typography.Paragraph
style={{
color: "var(--text-secondary)",
textAlign: "center",
padding: "24px 0",
}}
>
</Typography.Paragraph>
)}
{/* 提示 */}
@@ -1062,6 +1093,13 @@ const GeneratePage: React.FC = () => {
</Card>
</div>
</div>
{/* ── 音色克隆弹窗 ── */}
<VoiceCloneModal
open={cloneModalOpen}
onClose={() => setCloneModalOpen(false)}
onSuccess={handleCloneSuccess}
/>
</div>
);
};
+18
View File
@@ -209,6 +209,7 @@
}
.xx-voice-card {
position: relative;
border-radius: var(--radius-md);
border: 2px solid var(--border-color);
padding: var(--space-md);
@@ -226,6 +227,23 @@
background: var(--primary-soft) !important;
}
.xx-voice-card-disabled {
opacity: 0.5;
cursor: not-allowed;
}
.xx-voice-card-disabled:hover {
border-color: var(--border-color);
}
.xx-voice-check {
position: absolute;
top: 8px;
right: 8px;
color: var(--primary-color);
font-size: 16px;
}
.xx-voice-header {
display: flex;
align-items: center;
+1 -1
View File
@@ -147,6 +147,7 @@ export const router = createBrowserRouter([
import("@/pages/voice-clone/VoiceClone").then((m) => ({
Component: m.default,
})),
},
{
path: "my-voices",
lazy: () =>
@@ -154,7 +155,6 @@ export const router = createBrowserRouter([
Component: m.default,
})),
},
},
{
path: "accounts",
lazy: () =>