Files
meijiaka-zy/tauri-app/src/pages/ContentManagement/VoiceMaterialLibrary.tsx
T
2026-06-08 13:30:02 +08:00

562 lines
20 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.
/**
* 素材库页面
* ==========
*
* 管理音频素材(音色克隆)。
* 上传 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>
);
}