b0aee55ff5
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 169h9m48s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 169h9m52s
Deploy / Deploy Staging (push) Failing after 169h11m45s
CI/CD Pipeline / Frontend Lint (push) Failing after 169h12m18s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 169h12m24s
- 重写 voices.ts:新增统一音色 API(fetchVoices/fetchPresetVoices),保留旧接口向后兼容 - 重写 voiceClone.ts:对接后端音色克隆 API,导出 toVoiceClone 类型转换 - 升级 VoiceLibrary.tsx:删除所有 Mock 数据,useQuery 对接 preset/clone/unified 三个接口 - 修复 CloneVoiceModal/VoiceCloneModal:toVoiceClone 包装 createVoiceClone 返回值 - 修复 VoiceClone.tsx:useQuery queryFn 箭头函数包装
442 lines
15 KiB
TypeScript
442 lines
15 KiB
TypeScript
/**
|
||
* 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, toVoiceClone } 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<VoiceCloneModalProps> = ({
|
||
open,
|
||
onClose,
|
||
onSuccess,
|
||
}) => {
|
||
const [phase, setPhase] = useState<ModalPhase>("input");
|
||
const [voiceName, setVoiceName] = useState("");
|
||
const [isRecording, setIsRecording] = useState(false);
|
||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||
const [dragActive, setDragActive] = useState(false);
|
||
const [recordTime, setRecordTime] = useState(0);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const recordTimerRef = useRef<ReturnType<typeof setInterval> | 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<HTMLInputElement>) => {
|
||
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?.(toVoiceClone(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 (
|
||
<Modal
|
||
open={open}
|
||
onCancel={handleClose}
|
||
title="🎤 克隆新音色"
|
||
width={540}
|
||
footer={null}
|
||
destroyOnClose
|
||
maskClosable={!isProcessing}
|
||
keyboard={!isProcessing}
|
||
>
|
||
{/* ── 输入阶段 ──────────────────────────────────── */}
|
||
{phase === "input" && (
|
||
<div className="xx-vcmodal-body">
|
||
{/* 音色名称 */}
|
||
<div className="xx-vcmodal-field">
|
||
<label className="xx-vcmodal-label">音色名称</label>
|
||
<input
|
||
type="text"
|
||
className="xx-vcmodal-input"
|
||
value={voiceName}
|
||
onChange={(e) => setVoiceName(e.target.value)}
|
||
placeholder="输入音色名称"
|
||
maxLength={30}
|
||
/>
|
||
</div>
|
||
|
||
{/* 上传区域 */}
|
||
<div className="xx-vcmodal-field">
|
||
<label className="xx-vcmodal-label">上传音频</label>
|
||
<div
|
||
className={`xx-vcmodal-upload-zone${dragActive ? " xx-vcmodal-upload-zone--active" : ""}${selectedFile ? " xx-vcmodal-upload-zone--has-file" : ""}`}
|
||
onClick={handleUploadClick}
|
||
onDragEnter={handleDrag}
|
||
onDragOver={handleDrag}
|
||
onDragLeave={handleDrag}
|
||
onDrop={handleDrop}
|
||
>
|
||
<div className="xx-vcmodal-upload-icon">
|
||
{selectedFile ? "📄" : "🎵"}
|
||
</div>
|
||
<p className="xx-vcmodal-upload-title">
|
||
{selectedFile
|
||
? selectedFile.name
|
||
: "拖拽音频文件到此处,或点击上传"}
|
||
</p>
|
||
<p className="xx-vcmodal-upload-hint">
|
||
支持 MP3、WAV、M4A、AAC、OGG 格式
|
||
</p>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept={ACCEPTED_MIME}
|
||
style={{ display: "none" }}
|
||
onChange={handleFileChange}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 或分隔 */}
|
||
<div className="xx-vcmodal-divider">
|
||
<div className="xx-vcmodal-divider-line" />
|
||
<span className="xx-vcmodal-divider-text">或</span>
|
||
<div className="xx-vcmodal-divider-line" />
|
||
</div>
|
||
|
||
{/* 录制区域 */}
|
||
<div className="xx-vcmodal-field">
|
||
<label className="xx-vcmodal-label">直接录制</label>
|
||
<div className="xx-vcmodal-record-area">
|
||
<div className="xx-vcmodal-record-info">
|
||
<p className="xx-vcmodal-record-hint">
|
||
{isRecording
|
||
? `录制中 ${formatRecordTime(recordTime)}`
|
||
: "点击按钮开始录制你的声音"}
|
||
</p>
|
||
{isRecording && (
|
||
<div className="xx-vcmodal-record-wave">
|
||
<span className="xx-vcmodal-record-wave-bar" />
|
||
<span className="xx-vcmodal-record-wave-bar" />
|
||
<span className="xx-vcmodal-record-wave-bar" />
|
||
<span className="xx-vcmodal-record-wave-bar" />
|
||
<span className="xx-vcmodal-record-wave-bar" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={`xx-vcmodal-record-btn${isRecording ? " xx-vcmodal-record-btn--recording" : ""}`}
|
||
onClick={handleRecord}
|
||
title={isRecording ? "停止录制" : "开始录制"}
|
||
>
|
||
{isRecording ? "⏹" : "🎙️"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 提示 */}
|
||
<div className="xx-vcmodal-tip">
|
||
<span className="xx-vcmodal-tip-icon">💡</span>
|
||
<span>
|
||
建议上传 10 秒 ~ 3 分钟的清晰语音,环境安静、语速均匀效果最佳
|
||
</span>
|
||
</div>
|
||
|
||
{/* 底部按钮 */}
|
||
<div className="xx-vcmodal-footer">
|
||
<Button buttonType="ghost" onClick={handleClose}>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
buttonType="primary"
|
||
disabled={!canStart}
|
||
onClick={handleStartClone}
|
||
>
|
||
🎤 开始克隆
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── 进度阶段(上传中 / 克隆中 / 完成) ─────── */}
|
||
{isProcessing && (
|
||
<div className="xx-vcmodal-progress-body">
|
||
{/* 步骤指示器 */}
|
||
<div className="xx-vcmodal-steps">
|
||
{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 (
|
||
<React.Fragment key={step.key}>
|
||
{idx > 0 && (
|
||
<div
|
||
className={`xx-vcmodal-step-connector${isDone ? " xx-vcmodal-step-connector--done" : ""}`}
|
||
/>
|
||
)}
|
||
<div className={stepClass}>
|
||
<div className="xx-vcmodal-step-icon">
|
||
{isDone ? "✓" : step.icon}
|
||
</div>
|
||
<span className="xx-vcmodal-step-label">
|
||
{step.label}
|
||
</span>
|
||
</div>
|
||
</React.Fragment>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* 当前阶段描述 */}
|
||
<div className="xx-vcmodal-progress-info">
|
||
{phase === "uploading" && (
|
||
<>
|
||
<div className="xx-vcmodal-progress-spinner" />
|
||
<p className="xx-vcmodal-progress-text">正在上传音频文件…</p>
|
||
<p className="xx-vcmodal-progress-sub">
|
||
请稍候,正在将音频上传至服务器
|
||
</p>
|
||
</>
|
||
)}
|
||
{phase === "cloning" && (
|
||
<>
|
||
<div className="xx-vcmodal-progress-spinner xx-vcmodal-progress-spinner--cloning" />
|
||
<p className="xx-vcmodal-progress-text">
|
||
AI 正在克隆你的声音…
|
||
</p>
|
||
<p className="xx-vcmodal-progress-sub">
|
||
正在分析声音特征,生成专属音色模型
|
||
</p>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── 完成阶段 ──────────────────────────────────── */}
|
||
{phase === "done" && (
|
||
<div className="xx-vcmodal-progress-body">
|
||
{/* 步骤指示器(全部完成) */}
|
||
<div className="xx-vcmodal-steps">
|
||
{PROGRESS_STEPS.map((step, idx) => (
|
||
<React.Fragment key={step.key}>
|
||
{idx > 0 && (
|
||
<div className="xx-vcmodal-step-connector xx-vcmodal-step-connector--done" />
|
||
)}
|
||
<div className="xx-vcmodal-step xx-vcmodal-step--done">
|
||
<div className="xx-vcmodal-step-icon">✓</div>
|
||
<span className="xx-vcmodal-step-label">{step.label}</span>
|
||
</div>
|
||
</React.Fragment>
|
||
))}
|
||
</div>
|
||
|
||
<div className="xx-vcmodal-success">
|
||
<div className="xx-vcmodal-success-icon">🎉</div>
|
||
<h3 className="xx-vcmodal-success-title">克隆已提交</h3>
|
||
<p className="xx-vcmodal-success-desc">
|
||
音色正在生成中,完成后将出现在「我的音色库」列表中
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
);
|
||
};
|
||
|
||
export default VoiceCloneModal;
|