562 lines
20 KiB
TypeScript
562 lines
20 KiB
TypeScript
/**
|
||
* 素材库页面
|
||
* ==========
|
||
*
|
||
* 管理音频素材(音色克隆)。
|
||
* 上传 mp3/wav → 七牛云 → Kling 音色克隆 → voices.json
|
||
*/
|
||
|
||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||
import { useVoiceStore } from '../../store/voiceStore';
|
||
import { usePointStore } from '../../store';
|
||
import { toast } from '../../store/uiStore';
|
||
import { useProgressStore } from '../../store/progressStore';
|
||
import * as voiceApi from '../../api/modules/voice';
|
||
import Modal from '../../components/Modal/Modal';
|
||
import ConfirmModal from '../../components/Modal/ConfirmModal';
|
||
import RechargeModal from '../../components/RechargeModal/RechargeModal';
|
||
import './ContentManagement.css';
|
||
|
||
export default function VoiceMaterialLibrary() {
|
||
const [uploadModalOpen, setUploadModalOpen] = useState(false);
|
||
const [uploadName, setUploadName] = useState('');
|
||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||
|
||
// 重命名状态
|
||
const [editingId, setEditingId] = useState<string | null>(null);
|
||
const [editingName, setEditingName] = useState('');
|
||
|
||
// 删除确认状态
|
||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
|
||
|
||
// 积分不足弹窗
|
||
const [showPointsModal, setShowPointsModal] = useState(false);
|
||
const showRechargeModal = usePointStore((state) => state.showRechargeModal);
|
||
const setShowRechargeModal = usePointStore((state) => state.setShowRechargeModal);
|
||
const fetchBalance = usePointStore((state) => state.fetchBalance);
|
||
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const pollingIds = useRef<Set<string>>(new Set());
|
||
|
||
// 音频播放状态(全局单例)
|
||
const [playingId, setPlayingId] = useState<string | null>(null);
|
||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||
|
||
// 清理音频(组件卸载或切换素材时)
|
||
const stopAudio = useCallback(() => {
|
||
if (audioRef.current) {
|
||
audioRef.current.pause();
|
||
audioRef.current.onended = null;
|
||
audioRef.current = null;
|
||
}
|
||
}, []);
|
||
|
||
// 切换播放/暂停
|
||
const togglePlay = useCallback((id: string, url: string) => {
|
||
// 情况1:点击当前正在播放的素材 -> 暂停
|
||
if (playingId === id) {
|
||
stopAudio();
|
||
setPlayingId(null);
|
||
return;
|
||
}
|
||
|
||
// 情况2:播放新素材 -> 先停止旧的
|
||
stopAudio();
|
||
setPlayingId(null);
|
||
|
||
const audio = new Audio(url);
|
||
audioRef.current = audio;
|
||
|
||
// 播放自然结束
|
||
audio.onended = () => {
|
||
setPlayingId(prev => (prev === id ? null : prev));
|
||
if (audioRef.current === audio) {audioRef.current = null;}
|
||
};
|
||
|
||
// 立即更新 UI,不给用户"闪烁"的感觉
|
||
setPlayingId(id);
|
||
|
||
// 开始播放(失败时回退)
|
||
audio.play().catch(() => {
|
||
setPlayingId(prev => (prev === id ? null : prev));
|
||
if (audioRef.current === audio) {audioRef.current = null;}
|
||
});
|
||
}, [playingId, stopAudio]);
|
||
|
||
// 组件卸载时清理
|
||
useEffect(() => () => stopAudio(), [stopAudio]);
|
||
|
||
const {
|
||
voiceMaterials,
|
||
isLoadingMaterials,
|
||
loadVoiceMaterials,
|
||
addVoiceMaterial,
|
||
renameVoiceMaterial,
|
||
deleteVoiceMaterial,
|
||
updateVoiceMaterialStatus,
|
||
} = useVoiceStore();
|
||
|
||
// 加载数据
|
||
useEffect(() => {
|
||
loadVoiceMaterials();
|
||
}, [loadVoiceMaterials]);
|
||
|
||
// 轮询 pending/processing 状态的音频素材
|
||
useEffect(() => {
|
||
const pending = voiceMaterials.filter(m => m.status === 'pending' || m.status === 'processing');
|
||
const intervals: ReturnType<typeof setInterval>[] = [];
|
||
|
||
for (const item of pending) {
|
||
if (pollingIds.current.has(item.id)) {continue;}
|
||
pollingIds.current.add(item.id);
|
||
|
||
const interval = setInterval(async () => {
|
||
try {
|
||
const result = await voiceApi.queryCloneTask(item.id);
|
||
if (result.status === 'succeeded') {
|
||
updateVoiceMaterialStatus(item.id, 'ready', result.voiceId, result.trialUrl);
|
||
clearInterval(interval);
|
||
pollingIds.current.delete(item.id);
|
||
} else if (result.status === 'failed') {
|
||
updateVoiceMaterialStatus(item.id, 'failed');
|
||
clearInterval(interval);
|
||
pollingIds.current.delete(item.id);
|
||
}
|
||
} catch (err) {
|
||
console.error('[VoiceMaterialLibrary] 轮询克隆状态失败:', err);
|
||
}
|
||
}, 5000);
|
||
|
||
intervals.push(interval);
|
||
|
||
// 10 分钟后自动停止轮询
|
||
setTimeout(() => {
|
||
clearInterval(interval);
|
||
pollingIds.current.delete(item.id);
|
||
}, 600000);
|
||
}
|
||
|
||
return () => intervals.forEach(clearInterval);
|
||
}, [voiceMaterials, updateVoiceMaterialStatus]);
|
||
|
||
// 音频文件验证
|
||
const validateAudioFile = (file: File): Promise<{ valid: boolean; error?: string }> => {
|
||
return new Promise(resolve => {
|
||
const maxSize = 50 * 1024 * 1024; // 50MB
|
||
if (file.size > maxSize) {
|
||
resolve({ valid: false, error: `文件大小 ${(file.size / 1024 / 1024).toFixed(1)}MB,要求不超过 20MB` });
|
||
return;
|
||
}
|
||
|
||
const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
|
||
|
||
if (ext === '.mp4') {
|
||
// MP4 文件:检查时长(10 秒 ~ 2 分钟)
|
||
const video = document.createElement('video');
|
||
video.preload = 'metadata';
|
||
|
||
video.onloadedmetadata = () => {
|
||
const duration = video.duration;
|
||
URL.revokeObjectURL(video.src);
|
||
if (duration < 10) {
|
||
resolve({ valid: false, error: `视频时长 ${duration.toFixed(1)} 秒,要求至少 10 秒` });
|
||
return;
|
||
}
|
||
if (duration > 120) {
|
||
resolve({ valid: false, error: `视频时长 ${duration.toFixed(1)} 秒,要求不超过 2 分钟` });
|
||
return;
|
||
}
|
||
resolve({ valid: true });
|
||
};
|
||
|
||
video.onerror = () => {
|
||
URL.revokeObjectURL(video.src);
|
||
resolve({ valid: false, error: '无法读取视频文件' });
|
||
};
|
||
|
||
setTimeout(() => {
|
||
URL.revokeObjectURL(video.src);
|
||
resolve({ valid: false, error: '读取视频超时' });
|
||
}, 8000);
|
||
|
||
video.src = URL.createObjectURL(file);
|
||
return;
|
||
}
|
||
|
||
const allowedAudioExts = ['.mp3', '.m4a', '.wav'];
|
||
if (!allowedAudioExts.includes(ext)) {
|
||
resolve({ valid: false, error: '仅支持 MP3、M4A、WAV、MP4 格式' });
|
||
return;
|
||
}
|
||
|
||
const audio = document.createElement('audio');
|
||
audio.preload = 'metadata';
|
||
|
||
audio.onloadedmetadata = () => {
|
||
const duration = audio.duration;
|
||
URL.revokeObjectURL(audio.src);
|
||
if (duration < 10) {
|
||
resolve({ valid: false, error: `音频时长 ${duration.toFixed(1)} 秒,要求至少 10 秒` });
|
||
return;
|
||
}
|
||
if (duration > 120) {
|
||
resolve({ valid: false, error: `音频时长 ${duration.toFixed(1)} 秒,要求不超过 2 分钟` });
|
||
return;
|
||
}
|
||
resolve({ valid: true });
|
||
};
|
||
|
||
audio.onerror = () => {
|
||
URL.revokeObjectURL(audio.src);
|
||
resolve({ valid: false, error: '无法读取音频文件' });
|
||
};
|
||
|
||
setTimeout(() => {
|
||
URL.revokeObjectURL(audio.src);
|
||
resolve({ valid: false, error: '读取音频超时' });
|
||
}, 8000);
|
||
|
||
audio.src = URL.createObjectURL(file);
|
||
});
|
||
};
|
||
|
||
// 文件选择
|
||
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) {return;}
|
||
|
||
const validation = await validateAudioFile(file);
|
||
if (!validation.valid) {
|
||
toast.error(validation.error || '文件验证失败');
|
||
e.target.value = '';
|
||
return;
|
||
}
|
||
|
||
setSelectedFile(file);
|
||
}, []);
|
||
|
||
// 上传处理
|
||
const handleUpload = useCallback(async () => {
|
||
if (!uploadName.trim() || !selectedFile) {return;}
|
||
|
||
// 前置积分检查:声音复刻
|
||
await fetchBalance();
|
||
const voiceClonePoints = usePointStore.getState().getRule('voice_clone')?.points || 200;
|
||
const balance = usePointStore.getState().balance;
|
||
if (balance < voiceClonePoints) {
|
||
setShowPointsModal(true);
|
||
setUploadModalOpen(false);
|
||
return;
|
||
}
|
||
|
||
const progress = useProgressStore.getState();
|
||
setUploadModalOpen(false);
|
||
|
||
progress.show('声音复刻');
|
||
try {
|
||
progress.update('文件校验中...');
|
||
await addVoiceMaterial(selectedFile, uploadName.trim());
|
||
progress.update('正在生成专属音色...');
|
||
progress.success('复刻成功', 200);
|
||
} catch (err) {
|
||
console.error('[VoiceMaterialLibrary] handleUpload catch:', err, '类型:', typeof err, '是Error?', err instanceof Error);
|
||
const msg = err instanceof Error ? err.message : '上传失败';
|
||
if (msg.includes('402') || msg.includes('积分不足')) {
|
||
setShowPointsModal(true);
|
||
progress.hide();
|
||
return;
|
||
}
|
||
progress.error(msg);
|
||
}
|
||
|
||
setUploadName('');
|
||
setSelectedFile(null);
|
||
}, [uploadName, selectedFile, addVoiceMaterial, fetchBalance]);
|
||
|
||
// 删除处理
|
||
const openDeleteModal = (id: string, name: string) => {
|
||
setDeleteTarget({ id, name });
|
||
setDeleteModalOpen(true);
|
||
};
|
||
|
||
const handleConfirmDelete = useCallback(async () => {
|
||
if (!deleteTarget) {return;}
|
||
try {
|
||
await deleteVoiceMaterial(deleteTarget.id);
|
||
toast.success('已删除');
|
||
} catch {
|
||
toast.error('删除失败');
|
||
} finally {
|
||
setDeleteModalOpen(false);
|
||
setDeleteTarget(null);
|
||
}
|
||
}, [deleteTarget, deleteVoiceMaterial]);
|
||
|
||
const statusLabel = (status: string) => {
|
||
switch (status) {
|
||
case 'ready': return '可用';
|
||
case 'pending': return '等待中';
|
||
case 'processing': return '克隆中...';
|
||
case 'failed': return '失败';
|
||
default: return status;
|
||
}
|
||
};
|
||
|
||
const startRename = (id: string, currentName: string) => {
|
||
setEditingId(id);
|
||
setEditingName(currentName);
|
||
};
|
||
|
||
const cancelRename = () => {
|
||
setEditingId(null);
|
||
setEditingName('');
|
||
};
|
||
|
||
const confirmRename = useCallback(async () => {
|
||
if (!editingId || !editingName.trim()) {
|
||
cancelRename();
|
||
return;
|
||
}
|
||
try {
|
||
await renameVoiceMaterial(editingId, editingName.trim());
|
||
setEditingId(null);
|
||
setEditingName('');
|
||
} catch {
|
||
toast.error('重命名失败');
|
||
}
|
||
}, [editingId, editingName, renameVoiceMaterial]);
|
||
|
||
return (
|
||
<div className="content-page">
|
||
{/* 页面标题和上传区域 */}
|
||
<div className="voice-clone-wrapper">
|
||
<div className="voice-clone-title-group">
|
||
<h2>声音复刻</h2>
|
||
<p className="voice-clone-desc">上传人声音频,AI 自动学习声音特征,生成专属音色</p>
|
||
</div>
|
||
|
||
{/* 上传引导卡片 */}
|
||
<div
|
||
className="voice-upload-card"
|
||
onClick={() => setUploadModalOpen(true)}
|
||
>
|
||
<div className="voice-upload-icon">
|
||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" />
|
||
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||
<line x1="12" y1="19" x2="12" y2="22" />
|
||
<line x1="8" y1="22" x2="16" y2="22" />
|
||
</svg>
|
||
</div>
|
||
<div className="voice-upload-text">
|
||
<span className="voice-upload-title">上传声音样本</span>
|
||
<span className="voice-upload-hint">MP3 / M4A / WAV / MP4,建议时长 10 秒 ~ 2 分钟</span>
|
||
</div>
|
||
<div className="voice-upload-arrow">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<line x1="12" y1="19" x2="12" y2="5" />
|
||
<polyline points="5 12 12 5 19 12" />
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 上传弹窗 */}
|
||
<Modal
|
||
open={uploadModalOpen}
|
||
onClose={() => setUploadModalOpen(false)}
|
||
title=""
|
||
width="480px"
|
||
>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||
<div>
|
||
<label style={{ fontSize: 'var(--font-sm)', fontWeight: 500, marginBottom: 8, display: 'block' }}>
|
||
音色名称
|
||
</label>
|
||
<input
|
||
type="text"
|
||
className="input"
|
||
placeholder="例如:我的声音"
|
||
value={uploadName}
|
||
onChange={e => setUploadName(e.target.value)}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label style={{ fontSize: 'var(--font-sm)', fontWeight: 500, marginBottom: 8, display: 'block' }}>
|
||
选择文件
|
||
</label>
|
||
<div
|
||
style={{
|
||
border: '2px dashed var(--border-color)',
|
||
borderRadius: 'var(--radius-md)',
|
||
padding: 'var(--spacing-xl)',
|
||
textAlign: 'center',
|
||
cursor: 'pointer',
|
||
transition: 'all var(--transition-fast)',
|
||
}}
|
||
onClick={() => fileInputRef.current?.click()}
|
||
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--primary)'; }}
|
||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-color)'; }}
|
||
>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept=".mp3,.m4a,.wav,.mp4"
|
||
onChange={handleFileSelect}
|
||
style={{ display: 'none' }}
|
||
/>
|
||
{selectedFile ? (
|
||
<div>
|
||
<div style={{ fontWeight: 500, fontSize: 'var(--font-sm)' }}>{selectedFile.name}</div>
|
||
<div style={{ fontSize: 'var(--font-xs)', color: 'var(--text-secondary)', marginTop: 4 }}>
|
||
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div style={{ color: 'var(--text-secondary)' }}>
|
||
<div style={{ fontSize: 'var(--font-sm)' }}>点击选择文件</div>
|
||
<div style={{ fontSize: 'var(--font-xs)', marginTop: 6, lineHeight: 1.6 }}>
|
||
<div>支持 MP3 / M4A / WAV / MP4</div>
|
||
<div>人声干净无杂音,时长 10 秒 ~ 2 分钟,不超过 50MB</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||
<span style={{ fontSize: 'var(--font-xs)', color: 'var(--text-tertiary)' }}>
|
||
每次复刻消耗 <strong style={{ color: 'var(--primary)' }}>200</strong> 积分
|
||
</span>
|
||
<div style={{ display: 'flex', gap: 12 }}>
|
||
<button className="btn btn-secondary" onClick={() => setUploadModalOpen(false)}>取消</button>
|
||
<button
|
||
className="btn btn-primary"
|
||
onClick={handleUpload}
|
||
disabled={!uploadName.trim() || !selectedFile}
|
||
>
|
||
开始复刻
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
|
||
{/* 音频列表 */}
|
||
{isLoadingMaterials ? (
|
||
<p style={{ color: 'var(--text-secondary)' }}>加载中...</p>
|
||
) : voiceMaterials.length === 0 ? (
|
||
<div className="empty-state-page">
|
||
<div className="empty-state-icon">
|
||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
|
||
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||
<line x1="12" y1="19" x2="12" y2="23" />
|
||
<line x1="8" y1="23" x2="16" y2="23" />
|
||
</svg>
|
||
</div>
|
||
<p className="empty-state-title">暂无克隆音色</p>
|
||
<p className="empty-state-desc">上传一段你的人声音频,<br />AI 将学习你的声音特征</p>
|
||
</div>
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, flex: 1, overflow: 'auto' }}>
|
||
<div className="voice-list" style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 12, alignContent: 'start', alignItems: 'start' }}>
|
||
{voiceMaterials.map(m => (
|
||
<div key={m.id} className="voice-row" style={{ cursor: 'default' }}>
|
||
<div className="voice-row-main">
|
||
<div className="voice-row-info">
|
||
{editingId === m.id ? (
|
||
<input
|
||
type="text"
|
||
className="input"
|
||
value={editingName}
|
||
onChange={e => setEditingName(e.target.value)}
|
||
onKeyDown={e => {
|
||
if (e.key === 'Enter') {confirmRename();}
|
||
if (e.key === 'Escape') {cancelRename();}
|
||
}}
|
||
onBlur={confirmRename}
|
||
autoFocus
|
||
style={{ width: '100%', height: 28, padding: '2px 8px', fontSize: 'var(--font-sm)' }}
|
||
/>
|
||
) : (
|
||
<div className="voice-row-name">
|
||
{m.name}
|
||
<span className="voice-row-desc-inline">{statusLabel(m.status)}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="voice-row-actions">
|
||
{m.sourceUrl && (
|
||
<button
|
||
className="preview-icon"
|
||
onClick={e => {
|
||
e.stopPropagation();
|
||
togglePlay(m.id, m.sourceUrl);
|
||
}}
|
||
title={playingId === m.id ? '暂停' : '播放'}
|
||
>
|
||
{playingId === m.id ? '⏸' : '▶'}
|
||
</button>
|
||
)}
|
||
<button
|
||
className="action-icon"
|
||
onClick={() => startRename(m.id, m.name)}
|
||
title="重命名"
|
||
>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" /></svg>
|
||
</button>
|
||
<button
|
||
className="action-icon"
|
||
onClick={() => openDeleteModal(m.id, m.name)}
|
||
title="删除"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
</div>
|
||
)}
|
||
|
||
{/* 删除确认弹窗 */}
|
||
<ConfirmModal
|
||
open={deleteModalOpen}
|
||
type="danger"
|
||
title={<>确认删除素材 <strong>「{deleteTarget?.name}」</strong> 吗?</>}
|
||
description="此操作不可撤销,素材将被永久删除"
|
||
confirmText="确认删除"
|
||
cancelText="取消"
|
||
confirmButtonType="danger"
|
||
onConfirm={handleConfirmDelete}
|
||
onCancel={() => { setDeleteModalOpen(false); setDeleteTarget(null); }}
|
||
/>
|
||
|
||
{/* 积分不足弹窗 */}
|
||
<ConfirmModal
|
||
open={showPointsModal}
|
||
type="warning"
|
||
title="积分不足"
|
||
description="请先充值积分后再进行尝试"
|
||
confirmText="立即充值"
|
||
cancelText="稍后再说"
|
||
confirmButtonType="danger"
|
||
onConfirm={() => { setShowPointsModal(false); setShowRechargeModal(true); }}
|
||
onCancel={() => setShowPointsModal(false)}
|
||
onClose={() => setShowPointsModal(false)}
|
||
/>
|
||
<RechargeModal
|
||
open={showRechargeModal}
|
||
onClose={() => setShowRechargeModal(false)}
|
||
onRechargeSuccess={() => { fetchBalance(); setShowPointsModal(false); }}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|