Files
xiaoxia-saas/apps/web/src/components/modals/VoiceCloneModal.tsx
T
灵应 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
feat: 任务3.11 配音库页面升级 — 对接后端真实API
- 重写 voices.ts:新增统一音色 API(fetchVoices/fetchPresetVoices),保留旧接口向后兼容
- 重写 voiceClone.ts:对接后端音色克隆 API,导出 toVoiceClone 类型转换
- 升级 VoiceLibrary.tsx:删除所有 Mock 数据,useQuery 对接 preset/clone/unified 三个接口
- 修复 CloneVoiceModal/VoiceCloneModal:toVoiceClone 包装 createVoiceClone 返回值
- 修复 VoiceClone.tsx:useQuery queryFn 箭头函数包装
2026-07-02 11:40:33 +08:00

442 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* VoiceCloneModal — 音色克隆弹窗
*
* 功能:上传音频文件 / 录制音频、填写音色名称、提交克隆任务
* 进度展示:上传中 → 克隆中 → 完成(三阶段可视化)
* 对接 APIMock):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">
支持 MP3WAVM4AAACOGG 格式
</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;