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; + } +}