734d7eb377
CI/CD Pipeline / Deploy Staging (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Failing after 50h14m10s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 50h14m10s
1. 全局收敛硬编码配音/音色数据到配音素材库 (Task #170) - CloneVoiceModal: 替换硬编码音色列表为素材库 API - GeneratePage: 配音选择改为从素材库选取 - MyVoices: 统一展示素材库中的配音 2. 配音素材库增加搜索框和筛选 (Task #171) - 关键词搜索、性别筛选、风格标签筛选 - 对接后端 getAssetsByKind 筛选参数 3. 剪辑编辑器配音选择体验优化 (Task #172) - 共享 React Query 缓存 (queryKey: ['assets','voice']) - 新增试听播放/暂停按钮 - 下拉选项显示性别标签 - 加载状态 + 刷新按钮 - 空状态提示
291 lines
8.9 KiB
TypeScript
291 lines
8.9 KiB
TypeScript
/**
|
||
* CloneVoiceModal — 音色克隆弹窗
|
||
*
|
||
* 三步骤状态:input → uploading → success
|
||
* 支持上传音频文件或直接录制(mock,无真实录音)
|
||
*
|
||
* V21 Design System — 零 antd 直接导入
|
||
*/
|
||
import React, { useState, useCallback, useRef } 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-voice-modal.css";
|
||
|
||
/* ── 类型定义 ───────────────────────────────────────────── */
|
||
|
||
type ModalStep = "input" | "uploading" | "success";
|
||
|
||
export interface CloneVoiceModalProps {
|
||
/** 弹窗是否可见 */
|
||
open: boolean;
|
||
/** 关闭弹窗回调 */
|
||
onClose: () => void;
|
||
/** 克隆成功回调(返回新创建的音色) */
|
||
onSuccess?: (voice: VoiceClone) => void;
|
||
}
|
||
|
||
/* ── 默认音色名称计数器 ─────────────────────────────────── */
|
||
|
||
let cloneCounter = 1;
|
||
|
||
const getNextDefaultName = (): string => {
|
||
const name = `我的声音 ${cloneCounter}`;
|
||
cloneCounter += 1;
|
||
return name;
|
||
};
|
||
|
||
/* ── 组件 ───────────────────────────────────────────────── */
|
||
|
||
const CloneVoiceModal: React.FC<CloneVoiceModalProps> = ({
|
||
open,
|
||
onClose,
|
||
onSuccess,
|
||
}) => {
|
||
const [step, setStep] = useState<ModalStep>("input");
|
||
const [voiceName, setVoiceName] = useState("");
|
||
const [isRecording, setIsRecording] = useState(false);
|
||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||
const [dragActive, setDragActive] = useState(false);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
/** 重置弹窗状态 */
|
||
const resetState = useCallback(() => {
|
||
setStep("input");
|
||
setVoiceName("");
|
||
setSelectedFile(null);
|
||
setIsRecording(false);
|
||
setDragActive(false);
|
||
}, []);
|
||
|
||
/** 关闭弹窗 */
|
||
const handleClose = useCallback(() => {
|
||
resetState();
|
||
onClose();
|
||
}, [resetState, onClose]);
|
||
|
||
/** 上传区域点击 */
|
||
const handleUploadClick = () => {
|
||
fileInputRef.current?.click();
|
||
};
|
||
|
||
/** 文件选择 */
|
||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (file) {
|
||
setSelectedFile(file);
|
||
// 清除之前的录制状态
|
||
setIsRecording(false);
|
||
}
|
||
// 清空 input 以允许重复选择同一文件
|
||
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 === "mp3" || ext === "wav") {
|
||
setSelectedFile(file);
|
||
setIsRecording(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
/** 录制按钮(mock) */
|
||
const handleRecord = () => {
|
||
setIsRecording((prev) => !prev);
|
||
if (!isRecording) {
|
||
// 开始录制 — 清除已选文件
|
||
setSelectedFile(null);
|
||
}
|
||
};
|
||
|
||
/** 开始克隆 */
|
||
const handleStartClone = async () => {
|
||
const name = voiceName.trim() || getNextDefaultName();
|
||
setStep("uploading");
|
||
|
||
try {
|
||
// 先上传音频文件获取真实 URL
|
||
let audioUrl: string;
|
||
if (selectedFile) {
|
||
const formData = new FormData();
|
||
formData.append("file", selectedFile);
|
||
formData.append("kind", "voice");
|
||
const uploadResult = await uploadAsset(formData);
|
||
audioUrl = uploadResult.url;
|
||
} else {
|
||
// 录制功能暂未实现,提示用户上传
|
||
setStep("input");
|
||
return;
|
||
}
|
||
|
||
// 提交克隆请求
|
||
const result = await createVoiceClone({
|
||
name,
|
||
audio_url: audioUrl,
|
||
});
|
||
|
||
setStep("success");
|
||
|
||
// 2秒后自动关闭
|
||
setTimeout(() => {
|
||
onSuccess?.(toVoiceClone(result));
|
||
handleClose();
|
||
}, 2000);
|
||
} catch {
|
||
setStep("input");
|
||
}
|
||
};
|
||
|
||
/** 弹窗打开时初始化默认名称 */
|
||
const handleAfterOpenChange = (visible: boolean) => {
|
||
if (visible) {
|
||
setVoiceName(getNextDefaultName());
|
||
}
|
||
};
|
||
|
||
const canStart = selectedFile || isRecording;
|
||
|
||
return (
|
||
<Modal
|
||
open={open}
|
||
onCancel={handleClose}
|
||
title="🎤 克隆新音色"
|
||
width={520}
|
||
footer={null}
|
||
destroyOnClose
|
||
afterOpenChange={handleAfterOpenChange}
|
||
>
|
||
{/* ── 输入步骤 ──────────────────────────────────── */}
|
||
{step === "input" && (
|
||
<div className="cvm-body">
|
||
{/* 音色名称 */}
|
||
<div className="cvm-field">
|
||
<label className="cvm-label">音色名称</label>
|
||
<input
|
||
type="text"
|
||
className="cvm-input"
|
||
value={voiceName}
|
||
onChange={(e) => setVoiceName(e.target.value)}
|
||
placeholder="输入音色名称"
|
||
/>
|
||
</div>
|
||
|
||
{/* 上传区域 */}
|
||
<div className="cvm-field">
|
||
<label className="cvm-label">上传音频</label>
|
||
<div
|
||
className={`cvm-upload-zone${dragActive ? " cvm-upload-zone--active" : ""}`}
|
||
onClick={handleUploadClick}
|
||
onDragEnter={handleDrag}
|
||
onDragOver={handleDrag}
|
||
onDragLeave={handleDrag}
|
||
onDrop={handleDrop}
|
||
>
|
||
<div className="cvm-upload-icon">🎵</div>
|
||
<p className="cvm-upload-title">
|
||
{selectedFile ? selectedFile.name : "拖拽音频文件到此处"}
|
||
</p>
|
||
<p className="cvm-upload-hint">支持 MP3、WAV 格式</p>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept=".mp3,.wav,audio/mpeg,audio/wav"
|
||
style={{ display: "none" }}
|
||
onChange={handleFileChange}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 或分隔 */}
|
||
<div className="cvm-divider">
|
||
<div className="cvm-divider-line" />
|
||
<span className="cvm-divider-text">或</span>
|
||
<div className="cvm-divider-line" />
|
||
</div>
|
||
|
||
{/* 录制区域 */}
|
||
<div className="cvm-field">
|
||
<label className="cvm-label">直接录制</label>
|
||
<div className="cvm-record-area">
|
||
<p className="cvm-record-hint">
|
||
{isRecording
|
||
? "录制中…再次点击停止"
|
||
: "点击按钮开始录制你的声音"}
|
||
</p>
|
||
<button
|
||
type="button"
|
||
className={`cvm-record-btn${isRecording ? " cvm-record-btn--recording" : ""}`}
|
||
onClick={handleRecord}
|
||
>
|
||
🎙️
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 提示 */}
|
||
<div className="cvm-tip">
|
||
<span className="cvm-tip-icon">💡</span>
|
||
<span>
|
||
建议上传10秒~3分钟的清晰语音,环境安静、语速均匀效果最佳
|
||
</span>
|
||
</div>
|
||
|
||
{/* 底部按钮 */}
|
||
<div className="cvm-footer">
|
||
<Button buttonType="ghost" onClick={handleClose}>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
buttonType="primary"
|
||
disabled={!canStart}
|
||
onClick={handleStartClone}
|
||
>
|
||
🎤 开始克隆
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── 上传中步骤 ────────────────────────────────── */}
|
||
{step === "uploading" && (
|
||
<div className="cvm-uploading">
|
||
<div className="cvm-uploading-spinner" />
|
||
<p className="cvm-uploading-text">正在克隆你的音色…</p>
|
||
<p className="cvm-uploading-sub">AI 正在分析你的声音特征,请稍候</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── 成功步骤 ──────────────────────────────────── */}
|
||
{step === "success" && (
|
||
<div className="cvm-success">
|
||
<div className="cvm-success-icon">✅</div>
|
||
<h3 className="cvm-success-title">克隆已提交</h3>
|
||
<p className="cvm-success-desc">
|
||
音色正在生成中,完成后将出现在列表中
|
||
</p>
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
);
|
||
};
|
||
|
||
export default CloneVoiceModal;
|