import { useState, useEffect, useCallback } from 'react'; import { useAssetLibraryStore } from '../store/assetLibrary'; import { assetsApi, tosThumb } from '../lib/api'; import { showToast } from './Toast'; import { ImageLightbox } from './ImageLightbox'; import type { AssetGroup, AssetItem } from '../types'; import styles from './AssetLibraryModal.module.css'; /** Validate asset file before upload. Returns error message or null if valid. */ async function validateAssetFile(file: File): Promise { const ct = file.type || ''; if (ct.startsWith('image/')) { // Format: accept all image/* since backend checks ext if (file.size > 30 * 1024 * 1024) return '图片文件不能超过 30MB'; // Dimension check try { const dims = await new Promise<{ w: number; h: number }>((resolve, reject) => { const img = new Image(); const url = URL.createObjectURL(file); img.onload = () => { resolve({ w: img.naturalWidth, h: img.naturalHeight }); URL.revokeObjectURL(url); }; img.onerror = () => { reject(); URL.revokeObjectURL(url); }; img.src = url; }); if (dims.w <= 300 || dims.h <= 300) return `图片尺寸过小(${dims.w}×${dims.h}),宽高需在 300~6000 像素之间`; if (dims.w >= 6000 || dims.h >= 6000) return `图片尺寸过大(${dims.w}×${dims.h}),宽高需在 300~6000 像素之间`; const ratio = dims.w / dims.h; if (ratio <= 0.4 || ratio >= 2.5) return `图片比例不支持(${dims.w}×${dims.h}),宽高比需在 0.4~2.5 之间`; } catch { // Can't read dimensions (e.g. HEIC), skip — backend will validate } return null; } if (ct.startsWith('video/')) { if (ct !== 'video/mp4' && ct !== 'video/quicktime') return '仅支持 MP4 和 MOV 格式的视频'; if (file.size > 50 * 1024 * 1024) return '视频文件不能超过 50MB'; // Duration + dimension check try { const info = await new Promise<{ dur: number; w: number; h: number }>((resolve, reject) => { const vid = document.createElement('video'); const url = URL.createObjectURL(file); const timeout = setTimeout(() => { reject(); URL.revokeObjectURL(url); }, 10000); vid.addEventListener('loadedmetadata', () => { clearTimeout(timeout); resolve({ dur: vid.duration, w: vid.videoWidth, h: vid.videoHeight }); URL.revokeObjectURL(url); }); vid.addEventListener('error', () => { clearTimeout(timeout); reject(); URL.revokeObjectURL(url); }); vid.src = url; }); if (info.dur < 2 || info.dur > 15.4) return `视频时长需在 2~15 秒之间(当前 ${info.dur.toFixed(1)} 秒)`; if (info.w < 300 || info.h < 300) return `视频尺寸过小(${info.w}×${info.h}),宽高需在 300~6000 像素之间`; if (info.w > 6000 || info.h > 6000) return `视频尺寸过大(${info.w}×${info.h}),宽高需在 300~6000 像素之间`; const ratio = info.w / info.h; if (ratio < 0.4 || ratio > 2.5) return `视频比例不支持(${info.w}×${info.h}),宽高比需在 0.4~2.5 之间`; const pixels = info.w * info.h; if (pixels < 409600) return `视频像素过低(${info.w}×${info.h}=${pixels.toLocaleString()}),需在 409,600~927,408 之间`; if (pixels > 927408) return `视频像素过高(${info.w}×${info.h}=${pixels.toLocaleString()}),需在 409,600~927,408 之间`; } catch { // Can't read metadata, skip — backend will validate } return null; } if (ct.startsWith('audio/')) { if (ct !== 'audio/mpeg' && ct !== 'audio/wav') return '仅支持 MP3 和 WAV 格式的音频'; if (file.size > 15 * 1024 * 1024) return '音频文件不能超过 15MB'; // Duration check try { const dur = await new Promise((resolve, reject) => { const audio = new Audio(); const url = URL.createObjectURL(file); const timeout = setTimeout(() => { reject(); URL.revokeObjectURL(url); }, 10000); audio.addEventListener('loadedmetadata', () => { clearTimeout(timeout); resolve(audio.duration); URL.revokeObjectURL(url); }); audio.addEventListener('error', () => { clearTimeout(timeout); reject(); URL.revokeObjectURL(url); }); audio.src = url; }); if (dur < 2 || dur > 15.4) return `音频时长需在 2~15 秒之间(当前 ${dur.toFixed(1)} 秒)`; } catch { // Can't read metadata, skip } return null; } return '不支持的文件类型'; } interface Props { open: boolean; onClose: () => void; } export function AssetLibraryModal({ open, onClose }: Props) { const [view, setView] = useState<'list' | 'detail' | 'upload'>('list'); const [selectedGroup, setSelectedGroup] = useState(null); const [groupAssets, setGroupAssets] = useState([]); const [newName, setNewName] = useState(''); const [uploading, setUploading] = useState(false); const [editingName, setEditingName] = useState<{ id: number; value: string } | null>(null); const [lightboxSrc, setLightboxSrc] = useState(null); const groups = useAssetLibraryStore((s) => s.groups); const loading = useAssetLibraryStore((s) => s.loading); const total = useAssetLibraryStore((s) => s.total); const page = useAssetLibraryStore((s) => s.page); const loadGroups = useAssetLibraryStore((s) => s.loadGroups); const createGroup = useAssetLibraryStore((s) => s.createGroup); const totalPages = Math.ceil(total / 20); useEffect(() => { if (open) { loadGroups(1); setView('list'); setSelectedGroup(null); } }, [open, loadGroups]); const handleGroupClick = useCallback(async (group: AssetGroup) => { setSelectedGroup(group); try { const { data } = await assetsApi.getGroupDetail(group.id); const assets: AssetItem[] = data.assets || []; setGroupAssets(assets); // 对所有素材检查一次云端状态(处理中的更新状态,被删的清理掉) let needRefresh = false; const checks = assets.map((asset) => assetsApi.pollStatus(asset.id).then(({ data: statusData }) => { if (statusData.status !== asset.status || statusData.status as string === 'deleted') { needRefresh = true; } }).catch(() => {}) ); Promise.all(checks).then(() => { if (needRefresh) { assetsApi.getGroupDetail(group.id).then(({ data: refreshed }) => { setGroupAssets(refreshed.assets || []); }).catch(() => {}); } }); } catch { setGroupAssets([]); } setView('detail'); }, []); const handleBackToList = useCallback(() => { setView('list'); setSelectedGroup(null); setGroupAssets([]); setEditingName(null); loadGroups(page); }, [loadGroups, page]); const handleRenameGroup = useCallback(async (id: number, name: string) => { try { await assetsApi.updateGroup(id, { name }); showToast('重命名成功'); setEditingName(null); loadGroups(page); if (selectedGroup && selectedGroup.id === id) { setSelectedGroup({ ...selectedGroup, name }); } } catch { showToast('重命名失败'); } }, [loadGroups, page, selectedGroup]); const handleUploadSubmit = useCallback(async () => { const trimmed = newName.trim(); if (!trimmed) return; if (trimmed.length > 64) { showToast('角色名称不能超过64个字符'); return; } if (trimmed.includes('&&')) { showToast('角色名称不能包含 &&'); return; } setUploading(true); const result = await createGroup(trimmed, null); setUploading(false); if (result) { setNewName(''); // 创建成功后直接进入详情页 const group: AssetGroup = { id: result.id, name: trimmed, thumbnail_url: '', asset_count: 0, remote_group_id: result.remote_group_id || '', description: '', created_at: new Date().toISOString() }; setSelectedGroup(group); setGroupAssets([]); setView('detail'); loadGroups(page); } }, [newName, createGroup, loadGroups, page]); const refreshGroupDetail = useCallback(async () => { if (!selectedGroup) return; try { const { data } = await assetsApi.getGroupDetail(selectedGroup.id); setGroupAssets(data.assets || []); } catch { /* ignore */ } }, [selectedGroup]); const handleAddAsset = useCallback(async (file: File) => { if (!selectedGroup) return; const error = await validateAssetFile(file); if (error) { showToast(error); return; } const formData = new FormData(); formData.append('file', file); try { const { data } = await assetsApi.addAsset(selectedGroup.id, formData); setGroupAssets((prev) => [...prev, data]); // 轮询状态,完成后刷新详情 const pollId = data.id; const pollInterval = setInterval(async () => { try { const { data: statusData } = await assetsApi.pollStatus(pollId); if (statusData.status !== 'processing') { clearInterval(pollInterval); if (statusData.status === 'active') showToast('素材已就绪'); else if (statusData.status === 'deleted') showToast('素材在云端已被删除'); else showToast('素材处理失败'); refreshGroupDetail(); } } catch { clearInterval(pollInterval); } }, 3000); const typeLabel = file.type.startsWith('video/') ? '视频' : file.type.startsWith('audio/') ? '音频' : '图片'; showToast(`${typeLabel}已上传,处理中...`); } catch { showToast('上传失败,请重试'); } }, [selectedGroup, refreshGroupDetail]); if (!open) return null; return (
{ if (e.target === e.currentTarget) onClose(); }}>
{/* Header */}
{view !== 'list' && ( )} {view === 'list' && '人物素材库'} {view === 'detail' && (selectedGroup?.name || '角色详情')} {view === 'upload' && '上传新角色'}
{/* Body */}
{/* List View */} {view === 'list' && ( <>
{loading ? (
加载中...
) : groups.length === 0 ? (
暂无素材,点击上方按钮上传
) : (
{groups.map((group) => (
handleGroupClick(group)}> {group.asset_count === 0 ? (
暂无素材
) : ( {group.name} )}
{editingName && editingName.id === group.id ? (
e.stopPropagation()}> setEditingName({ ...editingName, value: e.target.value })} onKeyDown={(e) => { if (e.key === 'Enter') handleRenameGroup(group.id, editingName.value); if (e.key === 'Escape') setEditingName(null); }} autoFocus />
) : ( <> {group.name} )}
))}
)} {totalPages > 1 && (
{page} / {totalPages}
)} )} {/* Detail View */} {view === 'detail' && selectedGroup && ( <>
{editingName && editingName.id === selectedGroup.id && (
setEditingName({ ...editingName, value: e.target.value })} onKeyDown={(e) => { if (e.key === 'Enter') handleRenameGroup(selectedGroup.id, editingName.value); if (e.key === 'Escape') setEditingName(null); }} autoFocus />
)} {/* ── 按类型分区显示 ── */} {(['Image', 'Video', 'Audio'] as const).map((assetType) => { const typeAssets = groupAssets.filter((a) => (a.asset_type || 'Image') === assetType); const typeLabel = assetType === 'Image' ? '肖像(图片)' : assetType === 'Video' ? '视频' : '音频'; const acceptMap = { Image: 'image/*', Video: 'video/mp4,video/quicktime', Audio: 'audio/mpeg,audio/wav' }; const hintMap = { Image: '支持 JPG、PNG、WEBP、HEIC,单张不超过 30MB', Video: '支持 MP4、MOV,单个不超过 50MB', Audio: '支持 MP3、WAV,单个不超过 15MB', }; const warningMap = { Image: '⚠️ 宽高 300~6000 像素,宽高比 0.4~2.5', Video: '⚠️ 时长 2~15 秒,宽高 300~6000 像素,帧率 24~60 FPS', Audio: '⚠️ 时长 2~15 秒', }; return (
{typeLabel}
{hintMap[assetType]}
{warningMap[assetType]}
{typeAssets.map((asset) => (
{assetType === 'Video' ? ( {asset.name} ) : assetType === 'Audio' ? (
) : ( {asset.name} setLightboxSrc(asset.url)} /> )}
{asset.name}
{asset.status === 'active' && '可用'} {asset.status === 'processing' && '处理中'} {asset.status === 'failed' && '失败'}
))} {/* 拖拽上传卡片 — 和素材卡片同大小,始终在最后 */}
); })} )} {/* Upload View — only name, no file */} {view === 'upload' && (
角色名称
setNewName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleUploadSubmit(); }} autoFocus />
创建后可在详情页上传图片、视频、音频素材
)}
setLightboxSrc(null)} />
); }