Files
meijiaka-zy/tauri-app/src/pages/ContentManagement/VoiceMaterialLibrary.tsx
T
小鱼开发 04e467e433 feat(points): 积分系统收尾 + 充值弹窗改造 + 命名统一
后端:
- 微信回调 db.commit 失败仍返回 SUCCESS,避免无限重试
- recharge() 加 order_id 幂等保护,防重复充值
- time_expire 使用北京时间(UTC+8),修复时区 bug
- 充值档位后端配置化(points-config.yaml + /recharge-options API)
- 代码审查 20 项修复(认证加固、扣费顺序、错误响应、状态同步等)

前端:
- 充值弹窗:自动轮询 + 【我已支付】手动兜底
- 二维码倒计时显示,过期后遮罩 + 刷新按钮
- 充值档位从后端动态加载
- 去掉 select/qrcode 弹窗标题,金额红色突出显示
- 全项目命名统一(视频生成/压制成片/配音合成/声音复刻等)
- Modal 关闭按钮独立于 title 显示
2026-05-09 21:29:35 +08:00

505 lines
18 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 { 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 './ContentManagement.css';
export default function VoiceMaterialLibrary() {
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [uploadName, setUploadName] = useState('');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
// 分页
const [audioPage, setAudioPage] = useState(1);
const pageSize = 20;
// 重命名状态
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 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 = 20 * 1024 * 1024; // 20MB
if (file.size > maxSize) {
resolve({ valid: false, error: `文件大小 ${(file.size / 1024 / 1024).toFixed(1)}MB,要求不超过 20MB` });
return;
}
const allowedExts = ['.mp3', '.m4a', '.wav'];
const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
if (!allowedExts.includes(ext)) {
resolve({ valid: false, error: '仅支持 MP3、M4A、WAV 格式' });
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 > 300) {
resolve({ valid: false, error: `音频时长 ${duration.toFixed(1)} 秒,要求不超过 5 分钟` });
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;}
const progress = useProgressStore.getState();
setUploadModalOpen(false);
progress.show('声音复刻');
try {
progress.update('文件校验中...');
await addVoiceMaterial(selectedFile, uploadName.trim());
progress.update('正在生成专属音色...');
progress.success('克隆成功');
} catch (err) {
progress.error(err instanceof Error ? err.message : '上传失败');
}
setUploadName('');
setSelectedFile(null);
}, [uploadName, selectedFile, addVoiceMaterial]);
// 删除处理
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 30s ~ 3min</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"
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 }}>
MP3 / M4A / WAV 10 ~ 5 20MB
</div>
</div>
)}
</div>
</div>
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
<button className="btn btn-secondary" onClick={() => setUploadModalOpen(false)}></button>
<button
className="btn btn-primary"
onClick={handleUpload}
disabled={!uploadName.trim() || !selectedFile}
>
</button>
</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.slice((audioPage - 1) * pageSize, audioPage * pageSize).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="重命名"
>
</button>
<button
className="action-icon"
onClick={() => openDeleteModal(m.id, m.name)}
title="删除"
>
</button>
</div>
</div>
</div>
))}
</div>
{voiceMaterials.length > pageSize && (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 12, padding: '8px 0' }}>
<button
className="btn btn-secondary"
style={{ padding: '4px 12px', fontSize: 'var(--font-sm)' }}
onClick={() => setAudioPage(p => Math.max(1, p - 1))}
disabled={audioPage <= 1}
>
</button>
<span style={{ fontSize: 'var(--font-sm)', color: 'var(--text-secondary)' }}>
{audioPage} / {Math.ceil(voiceMaterials.length / pageSize)}
</span>
<button
className="btn btn-secondary"
style={{ padding: '4px 12px', fontSize: 'var(--font-sm)' }}
onClick={() => setAudioPage(p => Math.min(Math.ceil(voiceMaterials.length / pageSize), p + 1))}
disabled={audioPage >= Math.ceil(voiceMaterials.length / pageSize)}
>
</button>
</div>
)}
</div>
)}
{/* 删除确认弹窗 */}
<ConfirmModal
open={deleteModalOpen}
type="danger"
title={<> <strong>{deleteTarget?.name}</strong> </>}
description="此操作不可撤销,素材将被永久删除"
confirmText="确认删除"
cancelText="取消"
confirmButtonType="danger"
onConfirm={handleConfirmDelete}
onCancel={() => { setDeleteModalOpen(false); setDeleteTarget(null); }}
/>
</div>
);
}