feat(task-3.14): 一键生成对接声音克隆 — getVoiceClones API + VoiceCloneModal 集成
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 188h57m24s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 188h57m29s

- GeneratePage: 替换内联克隆UI为API数据驱动的音色网格
- 集成 VoiceCloneModal 组件(四阶段进度弹窗)
- 页面加载时自动 fetchClonedVoices,支持克隆成功后刷新列表
- 音色卡片区分 ready/processing/failed 状态,非 ready 禁用选择
- generate.css: 新增 disabled 卡片、选中勾选标记样式
- router/index.tsx: 修复 voice-clone 路由缺失闭合括号的语法错误
This commit is contained in:
Audit Bot
2026-07-01 15:28:11 +08:00
parent b676f9e2c3
commit aa33c2e6c8
3 changed files with 138 additions and 82 deletions
+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: () =>