Files
xiaoxia-saas/apps/web/src/components/voice/CloneModal.tsx
T
灵应 fa640ca537
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
Deploy / Staging E2E Tests (push) Failing after 116h28m49s
Deploy / Deploy Staging (push) Failing after 116h30m30s
CI/CD Pipeline / Frontend Lint (push) Failing after 116h31m4s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 116h31m4s
fix(voice): CloneModal 录音 5 分钟上限 + cloneCounter 改 useRef
P3 优化(代码审计建议):
1. 录音达到 5 分钟自动停止并提示用户,避免传超大文件
2. cloneCounter 从模块级变量改为组件内 useRef,避免多实例串号

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:23:26 +08:00

630 lines
22 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.
/**
* CloneModal — 音色克隆弹窗(共享组件)
*
* 功能:
* - 步骤引导:上传/录制音频 → 填写信息 → 提交克隆
* - 两种输入方式:上传文件(wav/mp3/m4a,≤10MB)或浏览器原生录音(MediaRecorder
* - 提交:先 uploadAsset 获取真实 URL,再 createVoiceClone
* - 进度展示:上传中 → 克隆中 → 完成(三阶段可视化)
* - 错误处理:上传失败、格式错误、大小超限、录音权限拒绝等提示
*
* 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 { uploadAsset } from "@/api/assets";
import "./clone-modal.css";
/* ── 类型定义 ───────────────────────────────────────────── */
type ModalPhase = "input" | "uploading" | "cloning" | "done";
export interface CloneModalProps {
/** 弹窗是否可见 */
open: boolean;
/** 关闭弹窗回调 */
onClose: () => void;
/** 克隆成功回调(返回新创建的音色) */
onSuccess?: (voice: VoiceClone) => void;
}
/* ── 进度阶段配置 ─────────────────────────────────────────── */
const PROGRESS_STEPS: { key: string; label: string; icon: string }[] = [
{ key: "uploading", label: "上传中", icon: "📤" },
{ key: "cloning", label: "克隆中", icon: "🧬" },
{ key: "done", label: "完成", icon: "✅" },
];
/* ── 常量 ───────────────────────────────────────────────── */
const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a"];
const ACCEPTED_MIME = ".mp3,.wav,.m4a,audio/mpeg,audio/wav,audio/mp4";
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
/** 最长录制时长:5 分钟(秒) */
const MAX_RECORD_SECONDS = 5 * 60;
/* ── 组件 ───────────────────────────────────────────────── */
const CloneModal: React.FC<CloneModalProps> = ({
open,
onClose,
onSuccess,
}) => {
const [phase, setPhase] = useState<ModalPhase>("input");
const [voiceName, setVoiceName] = useState("");
const [voiceDescription, setVoiceDescription] = useState("");
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [dragActive, setDragActive] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
// 录音状态
const [isRecording, setIsRecording] = useState(false);
const [recordTime, setRecordTime] = useState(0);
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
/** 默认音色名称计数器(组件级 ref,避免多实例串号) */
const cloneCounterRef = useRef(1);
/** 生成下一个默认音色名称 */
const getNextDefaultName = useCallback((): string => {
const name = `我的声音 ${cloneCounterRef.current}`;
cloneCounterRef.current += 1;
return name;
}, []);
/** 组件卸载时清理定时器和 MediaRecorder */
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
if (recordTimerRef.current) clearInterval(recordTimerRef.current);
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop();
}
};
}, []);
/** 重置弹窗状态 */
const resetState = useCallback(() => {
setPhase("input");
setVoiceName(getNextDefaultName());
setVoiceDescription("");
setSelectedFile(null);
setDragActive(false);
setErrorMessage("");
setIsRecording(false);
setRecordTime(0);
setRecordedBlob(null);
audioChunksRef.current = [];
if (recordTimerRef.current) {
clearInterval(recordTimerRef.current);
recordTimerRef.current = null;
}
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop();
}
mediaRecorderRef.current = null;
}, [getNextDefaultName]);
/** 关闭弹窗 */
const handleClose = useCallback(() => {
resetState();
onClose();
}, [resetState, onClose]);
/** 弹窗打开时重置状态 */
useEffect(() => {
if (open) {
resetState();
}
}, [open, resetState]);
/* ── 文件验证 ──────────────────────────────────────── */
const validateFile = (file: File): string | null => {
const ext = file.name.split(".").pop()?.toLowerCase();
if (!ext || !ACCEPTED_EXTENSIONS.includes(ext)) {
return "不支持的音频格式,请上传 MP3、WAV 或 M4A 文件";
}
if (file.size > MAX_FILE_SIZE) {
return "文件大小超过 10MB,请压缩后重试";
}
return null;
};
/* ── 文件上传 ──────────────────────────────────────── */
const handleUploadClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const error = validateFile(file);
if (error) {
setErrorMessage(error);
setSelectedFile(null);
} else {
setErrorMessage("");
setSelectedFile(file);
// 清除录音
setRecordedBlob(null);
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 error = validateFile(file);
if (error) {
setErrorMessage(error);
setSelectedFile(null);
} else {
setErrorMessage("");
setSelectedFile(file);
setRecordedBlob(null);
setIsRecording(false);
setRecordTime(0);
}
}
};
/* ── 录音(真实 MediaRecorder) ───────────────────── */
const handleRecord = async () => {
if (isRecording) {
// 停止录制
setIsRecording(false);
if (recordTimerRef.current) {
clearInterval(recordTimerRef.current);
recordTimerRef.current = null;
}
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop();
}
} else {
// 开始录制
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream);
mediaRecorderRef.current = mediaRecorder;
audioChunksRef.current = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = () => {
const blob = new Blob(audioChunksRef.current, { type: "audio/webm" });
setRecordedBlob(blob);
// 清除上传的文件
setSelectedFile(null);
// 停止音轨
stream.getTracks().forEach((track) => track.stop());
};
mediaRecorder.start();
setIsRecording(true);
setRecordTime(0);
setRecordedBlob(null);
setErrorMessage("");
recordTimerRef.current = setInterval(() => {
setRecordTime((prev) => {
const next = prev + 1;
if (next >= MAX_RECORD_SECONDS) {
// 达到 5 分钟上限,自动停止录制
setTimeout(() => {
setIsRecording(false);
if (recordTimerRef.current) {
clearInterval(recordTimerRef.current);
recordTimerRef.current = null;
}
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop();
}
setErrorMessage("已达最长录制时长(5分钟),已自动停止");
}, 0);
return MAX_RECORD_SECONDS;
}
return next;
});
}, 1000);
} catch {
setErrorMessage("无法访问麦克风,请检查浏览器权限设置");
}
}
};
/** 格式化录制时间 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 hasAudio = selectedFile !== null || recordedBlob !== null;
const validateForm = (): string | null => {
const name = voiceName.trim();
if (!name) {
return "请输入音色名称";
}
if (name.length < 2 || name.length > 20) {
return "音色名称需在 2-20 个字符之间";
}
if (!hasAudio) {
return "请上传音频文件或录制一段声音";
}
return null;
};
/* ── 提交克隆 ──────────────────────────────────────── */
const handleSubmit = async () => {
const formError = validateForm();
if (formError) {
setErrorMessage(formError);
return;
}
setErrorMessage("");
try {
// 阶段 1:上传音频
setPhase("uploading");
let fileToUpload: File;
if (selectedFile) {
fileToUpload = selectedFile;
} else {
// 将录音 Blob 转为 File
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
type: "audio/webm",
});
}
const formData = new FormData();
formData.append("file", fileToUpload);
const uploadResult = await uploadAsset(formData);
// 阶段 2:克隆
setPhase("cloning");
const result = await createVoiceClone({
name: voiceName.trim(),
description: voiceDescription.trim() || undefined,
audio_url: uploadResult.url,
});
// 阶段 3:完成
setPhase("done");
// 2秒后自动关闭
timerRef.current = setTimeout(() => {
onSuccess?.(toVoiceClone(result));
handleClose();
}, 2000);
} catch (err) {
setPhase("input");
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试");
}
};
/* ── 计算属性 ──────────────────────────────────────── */
const canSubmit =
voiceName.trim().length >= 2 &&
voiceName.trim().length <= 20 &&
hasAudio;
const isProcessing = phase === "uploading" || phase === "cloning";
/** 当前进度索引 */
const getProgressIndex = (): number => {
switch (phase) {
case "uploading":
return 0;
case "cloning":
return 1;
case "done":
return 2;
default:
return -1;
}
};
const progressIndex = getProgressIndex();
return (
<Modal
open={open}
onCancel={handleClose}
title="克隆我的音色"
width={560}
footer={null}
destroyOnClose
maskClosable={!isProcessing}
keyboard={!isProcessing}
>
{/* ── 输入阶段 ──────────────────────────────────── */}
{phase === "input" && (
<div className="xx-clonemodal-body">
{/* 步骤引导 */}
<div className="xx-clonemodal-steps">
<div className="xx-clonemodal-step xx-clonemodal-step--active">
<div className="xx-clonemodal-step-number">1</div>
<span className="xx-clonemodal-step-label">上传/录制音频</span>
</div>
<div className="xx-clonemodal-step-connector" />
<div className="xx-clonemodal-step">
<div className="xx-clonemodal-step-number">2</div>
<span className="xx-clonemodal-step-label">填写信息</span>
</div>
<div className="xx-clonemodal-step-connector" />
<div className="xx-clonemodal-step">
<div className="xx-clonemodal-step-number">3</div>
<span className="xx-clonemodal-step-label">提交克隆</span>
</div>
</div>
{/* 音色名称 */}
<div className="xx-clonemodal-field">
<label className="xx-clonemodal-label">
音色名称 <span className="xx-clonemodal-required">*</span>
</label>
<input
type="text"
className="xx-clonemodal-input"
value={voiceName}
onChange={(e) => setVoiceName(e.target.value)}
placeholder="输入音色名称(2-20字符)"
maxLength={20}
/>
<div className="xx-clonemodal-char-count">
{voiceName.length}/20
</div>
</div>
{/* 上传区域 */}
<div className="xx-clonemodal-field">
<label className="xx-clonemodal-label">上传音频</label>
<div
className={`xx-clonemodal-upload-zone${dragActive ? " xx-clonemodal-upload-zone--active" : ""}${selectedFile ? " xx-clonemodal-upload-zone--has-file" : ""}`}
onClick={handleUploadClick}
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
>
<div className="xx-clonemodal-upload-icon">
{selectedFile ? "📄" : "🎵"}
</div>
<p className="xx-clonemodal-upload-title">
{selectedFile
? selectedFile.name
: "拖拽音频文件到此处,或点击上传"}
</p>
<p className="xx-clonemodal-upload-hint">
支持 MP3WAVM4A 格式,最大 10MB
</p>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_MIME}
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
</div>
{/* 或分隔 */}
<div className="xx-clonemodal-divider">
<div className="xx-clonemodal-divider-line" />
<span className="xx-clonemodal-divider-text"></span>
<div className="xx-clonemodal-divider-line" />
</div>
{/* 录制区域 */}
<div className="xx-clonemodal-field">
<label className="xx-clonemodal-label">直接录制</label>
<div className="xx-clonemodal-record-area">
<div className="xx-clonemodal-record-info">
<p className="xx-clonemodal-record-hint">
{isRecording
? `录制中 ${formatRecordTime(recordTime)}`
: recordedBlob
? `已录制 ${formatRecordTime(recordTime)}`
: "点击按钮开始录制(最长 5 分钟)"}
</p>
{isRecording && (
<div className="xx-clonemodal-record-wave">
<span className="xx-clonemodal-record-wave-bar" />
<span className="xx-clonemodal-record-wave-bar" />
<span className="xx-clonemodal-record-wave-bar" />
<span className="xx-clonemodal-record-wave-bar" />
<span className="xx-clonemodal-record-wave-bar" />
</div>
)}
</div>
<button
type="button"
className={`xx-clonemodal-record-btn${isRecording ? " xx-clonemodal-record-btn--recording" : ""}`}
onClick={handleRecord}
title={isRecording ? "停止录制" : "开始录制"}
>
{isRecording ? "⏹" : "🎙️"}
</button>
</div>
</div>
{/* 音色描述 */}
<div className="xx-clonemodal-field">
<label className="xx-clonemodal-label">音色描述</label>
<textarea
className="xx-clonemodal-textarea"
value={voiceDescription}
onChange={(e) => setVoiceDescription(e.target.value)}
placeholder="可选,描述这个音色的特点(最多100字符)"
maxLength={100}
rows={3}
/>
<div className="xx-clonemodal-char-count">
{voiceDescription.length}/100
</div>
</div>
{/* 错误提示 */}
{errorMessage && (
<div className="xx-clonemodal-error">
<span className="xx-clonemodal-error-icon">⚠️</span>
<span>{errorMessage}</span>
</div>
)}
{/* 提示 */}
<div className="xx-clonemodal-tip">
<span className="xx-clonemodal-tip-icon">💡</span>
<span>
建议上传 10 ~ 3 分钟的清晰语音,环境安静、语速均匀效果最佳
</span>
</div>
{/* 底部按钮 */}
<div className="xx-clonemodal-footer">
<Button buttonType="ghost" onClick={handleClose}>
取消
</Button>
<Button
buttonType="primary"
disabled={!canSubmit}
onClick={handleSubmit}
>
🎤 开始克隆
</Button>
</div>
</div>
)}
{/* ── 进度阶段(上传中 / 克隆中) ──────────────── */}
{isProcessing && (
<div className="xx-clonemodal-progress-body">
{/* 步骤指示器 */}
<div className="xx-clonemodal-steps-progress">
{PROGRESS_STEPS.map((step, idx) => {
const isActive = idx === progressIndex;
const isDone = idx < progressIndex;
const stepClass = [
"xx-clonemodal-step-progress",
isActive ? "xx-clonemodal-step-progress--active" : "",
isDone ? "xx-clonemodal-step-progress--done" : "",
]
.filter(Boolean)
.join(" ");
return (
<React.Fragment key={step.key}>
{idx > 0 && (
<div
className={`xx-clonemodal-step-connector${isDone ? " xx-clonemodal-step-connector--done" : ""}`}
/>
)}
<div className={stepClass}>
<div className="xx-clonemodal-step-icon">
{isDone ? "✓" : step.icon}
</div>
<span className="xx-clonemodal-step-label">{step.label}</span>
</div>
</React.Fragment>
);
})}
</div>
{/* 当前阶段描述 */}
<div className="xx-clonemodal-progress-info">
{phase === "uploading" && (
<>
<div className="xx-clonemodal-progress-spinner" />
<p className="xx-clonemodal-progress-text">正在上传音频文件…</p>
<p className="xx-clonemodal-progress-sub">
请稍候,正在将音频上传至服务器
</p>
</>
)}
{phase === "cloning" && (
<>
<div className="xx-clonemodal-progress-spinner xx-clonemodal-progress-spinner--cloning" />
<p className="xx-clonemodal-progress-text">AI 正在克隆你的声音…</p>
<p className="xx-clonemodal-progress-sub">
正在分析声音特征,生成专属音色模型
</p>
</>
)}
</div>
</div>
)}
{/* ── 完成阶段 ──────────────────────────────────── */}
{phase === "done" && (
<div className="xx-clonemodal-progress-body">
{/* 步骤指示器(全部完成) */}
<div className="xx-clonemodal-steps-progress">
{PROGRESS_STEPS.map((step, idx) => (
<React.Fragment key={step.key}>
{idx > 0 && (
<div className="xx-clonemodal-step-connector xx-clonemodal-step-connector--done" />
)}
<div className="xx-clonemodal-step-progress xx-clonemodal-step-progress--done">
<div className="xx-clonemodal-step-icon"></div>
<span className="xx-clonemodal-step-label">{step.label}</span>
</div>
</React.Fragment>
))}
</div>
<div className="xx-clonemodal-success">
<div className="xx-clonemodal-success-icon">🎉</div>
<h3 className="xx-clonemodal-success-title">克隆已提交</h3>
<p className="xx-clonemodal-success-desc">
音色正在生成中,完成后将出现在「我的克隆」列表中
</p>
</div>
</div>
)}
</Modal>
);
};
export default CloneModal;