feat(task-3.14): 一键生成对接声音克隆 — getVoiceClones API + VoiceCloneModal 集成
- GeneratePage: 替换内联克隆UI为API数据驱动的音色网格 - 集成 VoiceCloneModal 组件(四阶段进度弹窗) - 页面加载时自动 fetchClonedVoices,支持克隆成功后刷新列表 - 音色卡片区分 ready/processing/failed 状态,非 ready 禁用选择 - generate.css: 新增 disabled 卡片、选中勾选标记样式 - router/index.tsx: 修复 voice-clone 路由缺失闭合括号的语法错误
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
Reference in New Issue
Block a user