From b676f9e2c32859a8c8f839ca479514f8c6a553b6 Mon Sep 17 00:00:00 2001 From: Audit Bot Date: Wed, 1 Jul 2026 14:57:28 +0800 Subject: [PATCH 1/2] feat: add VoiceCloneModal component with 3-stage progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VoiceCloneModal.tsx: upload audio / record audio, fill voice name, submit clone task, 3-stage progress display (上传中 → 克隆中 → 完成) - voice-clone-modal.css: CSS variables from global.css, xx-vcmodal-* naming convention, responsive breakpoints at 768/480px - Uses Modal/Button from @/components/ui (V21 Design System) - Calls createVoiceClone API (POST /api/v1/voice-clones mock) - Props: open, onClose, onSuccess — integrates with MyVoices page Task 3.13 — 音色克隆弹窗组件 --- .../src/components/modals/VoiceCloneModal.tsx | 441 ++++++++++++++++ .../components/modals/voice-clone-modal.css | 471 ++++++++++++++++++ 2 files changed, 912 insertions(+) create mode 100644 apps/web/src/components/modals/VoiceCloneModal.tsx create mode 100644 apps/web/src/components/modals/voice-clone-modal.css diff --git a/apps/web/src/components/modals/VoiceCloneModal.tsx b/apps/web/src/components/modals/VoiceCloneModal.tsx new file mode 100644 index 000000000..0d8e4525d --- /dev/null +++ b/apps/web/src/components/modals/VoiceCloneModal.tsx @@ -0,0 +1,441 @@ +/** + * VoiceCloneModal — 音色克隆弹窗 + * + * 功能:上传音频文件 / 录制音频、填写音色名称、提交克隆任务 + * 进度展示:上传中 → 克隆中 → 完成(三阶段可视化) + * 对接 API(Mock):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 = ({ + open, + onClose, + onSuccess, +}) => { + const [phase, setPhase] = useState("input"); + const [voiceName, setVoiceName] = useState(""); + const [isRecording, setIsRecording] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const [dragActive, setDragActive] = useState(false); + const [recordTime, setRecordTime] = useState(0); + const fileInputRef = useRef(null); + const recordTimerRef = useRef | 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) => { + 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 ( + + {/* ── 输入阶段 ──────────────────────────────────── */} + {phase === "input" && ( +
+ {/* 音色名称 */} +
+ + setVoiceName(e.target.value)} + placeholder="输入音色名称" + maxLength={30} + /> +
+ + {/* 上传区域 */} +
+ +
+
+ {selectedFile ? "📄" : "🎵"} +
+

+ {selectedFile + ? selectedFile.name + : "拖拽音频文件到此处,或点击上传"} +

+

+ 支持 MP3、WAV、M4A、AAC、OGG 格式 +

+ +
+
+ + {/* 或分隔 */} +
+
+ +
+
+ + {/* 录制区域 */} +
+ +
+
+

+ {isRecording + ? `录制中 ${formatRecordTime(recordTime)}` + : "点击按钮开始录制你的声音"} +

+ {isRecording && ( +
+ + + + + +
+ )} +
+ +
+
+ + {/* 提示 */} +
+ 💡 + + 建议上传 10 秒 ~ 3 分钟的清晰语音,环境安静、语速均匀效果最佳 + +
+ + {/* 底部按钮 */} +
+ + +
+
+ )} + + {/* ── 进度阶段(上传中 / 克隆中 / 完成) ─────── */} + {isProcessing && ( +
+ {/* 步骤指示器 */} +
+ {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 ( + + {idx > 0 && ( +
+ )} +
+
+ {isDone ? "✓" : step.icon} +
+ + {step.label} + +
+ + ); + })} +
+ + {/* 当前阶段描述 */} +
+ {phase === "uploading" && ( + <> +
+

正在上传音频文件…

+

+ 请稍候,正在将音频上传至服务器 +

+ + )} + {phase === "cloning" && ( + <> +
+

+ AI 正在克隆你的声音… +

+

+ 正在分析声音特征,生成专属音色模型 +

+ + )} +
+
+ )} + + {/* ── 完成阶段 ──────────────────────────────────── */} + {phase === "done" && ( +
+ {/* 步骤指示器(全部完成) */} +
+ {PROGRESS_STEPS.map((step, idx) => ( + + {idx > 0 && ( +
+ )} +
+
+ {step.label} +
+ + ))} +
+ +
+
🎉
+

克隆已提交

+

+ 音色正在生成中,完成后将出现在「我的音色库」列表中 +

+
+
+ )} + + ); +}; + +export default VoiceCloneModal; diff --git a/apps/web/src/components/modals/voice-clone-modal.css b/apps/web/src/components/modals/voice-clone-modal.css new file mode 100644 index 000000000..2761bced6 --- /dev/null +++ b/apps/web/src/components/modals/voice-clone-modal.css @@ -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; + } +} -- 2.54.0 From aa33c2e6c817cb37e4dd2edb146a8574dff3a776 Mon Sep 17 00:00:00 2001 From: Audit Bot Date: Wed, 1 Jul 2026 15:28:11 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(task-3.14):=20=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=AF=B9=E6=8E=A5=E5=A3=B0=E9=9F=B3=E5=85=8B?= =?UTF-8?q?=E9=9A=86=20=E2=80=94=20getVoiceClones=20API=20+=20VoiceCloneMo?= =?UTF-8?q?dal=20=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: () => -- 2.54.0