From aa33c2e6c817cb37e4dd2edb146a8574dff3a776 Mon Sep 17 00:00:00 2001 From: Audit Bot Date: Wed, 1 Jul 2026 15:28:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(task-3.14):=20=E4=B8=80=E9=94=AE=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=AF=B9=E6=8E=A5=E5=A3=B0=E9=9F=B3=E5=85=8B=E9=9A=86?= =?UTF-8?q?=20=E2=80=94=20getVoiceClones=20API=20+=20VoiceCloneModal=20?= =?UTF-8?q?=E9=9B=86=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GeneratePage: 替换内联克隆UI为API数据驱动的音色网格 - 集成 VoiceCloneModal 组件(四阶段进度弹窗) - 页面加载时自动 fetchClonedVoices,支持克隆成功后刷新列表 - 音色卡片区分 ready/processing/failed 状态,非 ready 禁用选择 - generate.css: 新增 disabled 卡片、选中勾选标记样式 - router/index.tsx: 修复 voice-clone 路由缺失闭合括号的语法错误 --- apps/web/src/pages/generate/GeneratePage.tsx | 200 +++++++++++-------- apps/web/src/pages/generate/generate.css | 18 ++ apps/web/src/router/index.tsx | 2 +- 3 files changed, 138 insertions(+), 82 deletions(-) diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index ef48400f4..f8028024b 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -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([]); + const [clonedVoices, setClonedVoices] = useState([]); const [selectedClonedVoice, setSelectedClonedVoice] = useState(""); - 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>(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 将克隆您的声音用于视频配音 - {/* 上传区域 */} - setCloneFileList(fl)} - beforeUpload={() => false} - maxCount={1} - className="xx-clone-upload-area" - > -
- -
- - 点击或拖拽音频文件到此区域 - - - 支持 WAV / MP3 格式,建议时长 30 秒以上 - -
- - {/* 开始克隆按钮 */} -
+ {/* 克隆新声音按钮 */} +
{/* 已克隆声音列表 */} - {MOCK_CLONED_VOICES.length > 0 && ( + {loadingClones ? ( + + 加载克隆音色中… + + ) : clonedVoices.length > 0 ? (
- 已克隆的声音 + 已克隆的声音({clonedVoices.length})
- {MOCK_CLONED_VOICES.map((cv) => ( -
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); - } - }} - > -
-
- -
-
- - {cv.name} - - - {cv.createdAt} · {cv.duration} - + {clonedVoices.map((cv) => { + const selected = selectedClonedVoice === cv.id; + const isReady = cv.status === "ready"; + const isProcessing = cv.status === "processing"; + return ( +
{ + 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); + } + }} + > +
+
+ +
+
+ + {cv.name} + + + {formatDuration(cv.duration_seconds)} + {isProcessing && ( + + 克隆中 + + )} + {cv.status === "failed" && ( + + 失败 + + )} + +
+ {selected && isReady && ( +
+ +
+ )}
-
- ))} + ); + })}
+ ) : ( + + 暂无克隆音色,点击「克隆新声音」开始 + )} {/* 提示 */} @@ -1062,6 +1093,13 @@ const GeneratePage: React.FC = () => {
+ + {/* ── 音色克隆弹窗 ── */} + setCloneModalOpen(false)} + onSuccess={handleCloneSuccess} + />
); }; diff --git a/apps/web/src/pages/generate/generate.css b/apps/web/src/pages/generate/generate.css index 54998ce48..fc8de3c06 100644 --- a/apps/web/src/pages/generate/generate.css +++ b/apps/web/src/pages/generate/generate.css @@ -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; diff --git a/apps/web/src/router/index.tsx b/apps/web/src/router/index.tsx index dbbc0aa6a..1b51d4119 100644 --- a/apps/web/src/router/index.tsx +++ b/apps/web/src/router/index.tsx @@ -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: () =>