import { useState, useEffect, useRef, 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'; 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 [uploadFile, setUploadFile] = useState(null); const [uploadPreview, setUploadPreview] = useState(null); const [dragOver, setDragOver] = useState(false); const [lightboxSrc, setLightboxSrc] = useState(null); const fileInputRef = useRef(null); const addFileInputRef = useRef(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 pollAssetStatus = useAssetLibraryStore((s) => s.pollAssetStatus); 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 || !uploadFile) return; if (trimmed.length > 64) { showToast('角色名称不能超过64个字符'); return; } if (trimmed.includes('&&')) { showToast('角色名称不能包含 &&'); return; } setUploading(true); const result = await createGroup(newName.trim(), uploadFile); setUploading(false); if (result) { pollAssetStatus(result.id); setNewName(''); setUploadFile(null); if (uploadPreview) URL.revokeObjectURL(uploadPreview); setUploadPreview(null); handleBackToList(); } }, [newName, uploadFile, createGroup, pollAssetStatus, uploadPreview, handleBackToList]); const handleFileSelect = useCallback((file: File) => { if (uploadPreview) URL.revokeObjectURL(uploadPreview); setUploadFile(file); setUploadPreview(URL.createObjectURL(file)); }, [uploadPreview]); 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 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); showToast('图片已上传,处理中...'); } catch { showToast('上传失败,请重试'); } }, [selectedGroup, refreshGroupDetail]); const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); setDragOver(false); const file = e.dataTransfer.files[0]; if (file && file.type.startsWith('image/')) { handleFileSelect(file); } }, [handleFileSelect]); 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.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 && ( <>
{ const file = e.target.files?.[0]; if (file) handleAddAsset(file); e.target.value = ''; }} />
{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 />
)} {groupAssets.length === 0 ? (
暂无素材图片
) : (
{groupAssets.map((asset) => (
{asset.name} setLightboxSrc(asset.url)} />
{asset.name}
{asset.status === 'active' && '可用'} {asset.status === 'processing' && '处理中'} {asset.status === 'failed' && '失败'}
))}
)} )} {/* Upload View */} {view === 'upload' && (
角色名称
setNewName(e.target.value)} />
角色图片
fileInputRef.current?.click()} onDragOver={(e) => { e.preventDefault(); setDragOver(true); }} onDragLeave={() => setDragOver(false)} onDrop={handleDrop} > {uploadPreview ? ( <> 预览
点击重新选择
) : ( <>
上传角色图片
将角色的正面图或三视图拖拽到这里,或点击选择文件
支持 JPG、PNG 格式,单张不超过 30MB
)}
⚠️ 素材上传后无法删除,请确认后再上传
{ const file = e.target.files?.[0]; if (file) handleFileSelect(file); e.target.value = ''; }} />
)}
setLightboxSrc(null)} />
); }