/** * 素材管理组件 * 提取自 app/admin/page.tsx 的素材管理功能 * * 搜索逻辑:复用前台 SearchBar 组件,支持 IME 中文输入 * 使用 useDeferredValue 替代手动防抖,避免闭包问题 */ 'use client' import { useRef, useEffect, useCallback, useDeferredValue, useState } from 'react' import { useRouter } from 'next/navigation' import { format } from 'date-fns' import type { Material } from '@/hooks/useAdminState' import { parseFetchError, handleError } from '@/lib/error-handler' import SearchBar from '@/components/home/SearchBar' import { isVideoMaterialType } from '@/lib/video-material-types' // 搜索分页配置 const SEARCH_PAGE_SIZE = 50 interface MaterialManagementProps { materials: Material[] setMaterials: React.Dispatch> selectedMaterials: Set setSelectedMaterials: React.Dispatch>> showRecycleBin: boolean setShowRecycleBin: React.Dispatch> materialSearchTerm: string setMaterialSearchTerm: React.Dispatch> displayedMaterialsCount: number setDisplayedMaterialsCount: React.Dispatch> hasMoreMaterials: boolean setHasMoreMaterials: React.Dispatch> isLoadingMoreMaterials: boolean showToast: (message: string, type?: 'success' | 'error') => void loadMoreMaterials: () => Promise fetchData: () => Promise materialsPageSize: number totalMaterialsCount?: number // 素材总数(用于显示) } export default function MaterialManagement({ materials, setMaterials, selectedMaterials, setSelectedMaterials, showRecycleBin, setShowRecycleBin, materialSearchTerm, setMaterialSearchTerm, displayedMaterialsCount, setDisplayedMaterialsCount, hasMoreMaterials, setHasMoreMaterials, isLoadingMoreMaterials, showToast, loadMoreMaterials, fetchData, materialsPageSize, totalMaterialsCount, }: MaterialManagementProps) { const router = useRouter() const loadMoreRef = useRef(null) // 搜索分页状态(组件内部管理) const [searchPage, setSearchPage] = useState(1) const [hasMoreSearchResults, setHasMoreSearchResults] = useState(false) const [isLoadingMoreSearch, setIsLoadingMoreSearch] = useState(false) const [totalSearchCount, setTotalSearchCount] = useState(0) // 批量操作加载状态 const [isBatchProcessing, setIsBatchProcessing] = useState(false) // 使用 useDeferredValue 替代手动防抖,与前台保持一致 const deferredSearchKeyword = useDeferredValue(materialSearchTerm) const isSearching = materialSearchTerm !== deferredSearchKeyword // 判断是否处于搜索模式 const isInSearchMode = deferredSearchKeyword.trim().length > 0 // 搜索处理函数 - 直接更新状态,搜索由 useDeferredValue 控制 const handleSearchChange = useCallback((value: string) => { setMaterialSearchTerm(value) }, [setMaterialSearchTerm]) // 实时搜索:当 deferredSearchKeyword 变化时触发搜索 // 注意:showRecycleBin 的变化由父组件 fetchData 处理,这里只处理搜索词变化 // 搜索模式使用分页加载,每页 50 条 const prevSearchKeywordRef = useRef(null) useEffect(() => { // 首次渲染时,记录初始值但不触发搜索(由 fetchData 处理) if (prevSearchKeywordRef.current === null) { prevSearchKeywordRef.current = deferredSearchKeyword return } // 只有搜索词真的变化了才触发搜索 if (deferredSearchKeyword === prevSearchKeywordRef.current) { return } prevSearchKeywordRef.current = deferredSearchKeyword // 重置搜索分页状态 setSearchPage(1) setHasMoreSearchResults(false) setTotalSearchCount(0) // 直接发送搜索请求(避免闭包问题) const performSearch = async () => { try { const hasSearch = deferredSearchKeyword.trim().length > 0 const params = new URLSearchParams() params.append('page', '1') // 搜索时使用分页(50条/页),非搜索时使用默认分页 params.append('pageSize', String(hasSearch ? SEARCH_PAGE_SIZE : materialsPageSize)) if (showRecycleBin) params.append('deleted', 'true') if (hasSearch) params.append('search', deferredSearchKeyword.trim()) const url = `/api/materials?${params.toString()}` const response = await fetch(url) if (response.ok) { const data = await response.json() const materialsData = data.materials || data const total = data.total || materialsData.length setMaterials(materialsData) setDisplayedMaterialsCount(materialsData.length) if (hasSearch) { // 搜索模式:检查是否还有更多结果 setTotalSearchCount(total) setHasMoreSearchResults(materialsData.length >= SEARCH_PAGE_SIZE) setHasMoreMaterials(false) // 搜索模式下禁用普通分页 } else { // 非搜索模式:恢复普通分页 setHasMoreMaterials(materialsData.length >= materialsPageSize) } } } catch (error) { console.error('搜索素材失败:', error) } } performSearch() // 注意:不要把 showRecycleBin 放在这个 useEffect 的依赖里,回收站切换由父组件处理 // eslint-disable-next-line react-hooks/exhaustive-deps }, [deferredSearchKeyword, materialsPageSize, setMaterials, setDisplayedMaterialsCount, setHasMoreMaterials]) // 加载更多搜索结果 const loadMoreSearchResults = useCallback(async () => { if (isLoadingMoreSearch || !hasMoreSearchResults || !isInSearchMode) return setIsLoadingMoreSearch(true) try { const nextPage = searchPage + 1 const params = new URLSearchParams() params.append('page', String(nextPage)) params.append('pageSize', String(SEARCH_PAGE_SIZE)) if (showRecycleBin) params.append('deleted', 'true') params.append('search', deferredSearchKeyword.trim()) const url = `/api/materials?${params.toString()}` const response = await fetch(url) if (response.ok) { const data = await response.json() const newMaterials = data.materials || data if (newMaterials.length > 0) { // 去重:过滤掉已经存在的素材 setMaterials(prev => { const existingIds = new Set(prev.map(m => m.id)) const uniqueNewMaterials = newMaterials.filter((m: any) => !existingIds.has(m.id)) return [...prev, ...uniqueNewMaterials] }) setDisplayedMaterialsCount(prev => prev + newMaterials.length) setSearchPage(nextPage) setHasMoreSearchResults(newMaterials.length >= SEARCH_PAGE_SIZE) } else { setHasMoreSearchResults(false) } } } catch (error) { console.error('加载更多搜索结果失败:', error) } finally { setIsLoadingMoreSearch(false) } }, [isLoadingMoreSearch, hasMoreSearchResults, isInSearchMode, searchPage, showRecycleBin, deferredSearchKeyword, setMaterials, setDisplayedMaterialsCount]) // 删除单个素材 const handleDeleteMaterial = async (id: string) => { if (!confirm('确定要删除这个素材吗?')) return try { const response = await fetch(`/api/materials?id=${id}`, { method: 'DELETE', }) if (response.ok) { setMaterials(prev => prev.filter(m => m.id !== id)) showToast('删除成功') } else { const error = await parseFetchError(response) showToast(error.message, 'error') } } catch (error) { console.error('Error deleting material:', error) const { message } = handleError(error) showToast(message, 'error') } } // 批量删除素材 const handleBatchDeleteMaterials = async () => { const selectedIds = Array.from(selectedMaterials) if (selectedIds.length === 0) { showToast('请先选择要删除的素材', 'error') return } const confirmMessage = showRecycleBin ? `确定要永久删除选中的 ${selectedIds.length} 条素材吗?此操作不可恢复!` : `确定要删除选中的 ${selectedIds.length} 条素材吗?素材将移至回收站。` if (!confirm(confirmMessage)) return setIsBatchProcessing(true) try { const response = await fetch('/api/admin/materials/batch-delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: selectedIds, permanent: showRecycleBin, }), }) if (response.ok) { const result = await response.json() setMaterials(prev => prev.filter(m => !selectedIds.includes(m.id))) setSelectedMaterials(new Set()) // 显示详细信息(如果是永久删除,显示文件清理信息) if (showRecycleBin && result.details) { showToast(`批量永久删除完成:删除 ${result.details.deletedRecords} 条记录,清理 ${result.details.filesDeleted} 个文件`) } else { showToast(showRecycleBin ? '批量永久删除完成' : '批量删除完成') } } else { const error = await parseFetchError(response) showToast(error.message, 'error') } } catch (error) { console.error('Batch delete materials error:', error) const { message } = handleError(error) showToast(message, 'error') } finally { setIsBatchProcessing(false) } } // 切换素材选择状态 const toggleMaterialSelection = (id: string) => { setSelectedMaterials(prev => { const newSet = new Set(prev) if (newSet.has(id)) { newSet.delete(id) } else { newSet.add(id) } return newSet }) } // 全选/取消全选 - 只选择当前显示的素材 const toggleSelectAll = () => { // 获取当前显示的素材列表 const displayedMaterials = materials.slice(0, displayedMaterialsCount) if (displayedMaterials.length === 0) return // 检查当前显示的素材是否全部被选中 const allDisplayedSelected = displayedMaterials.every(m => selectedMaterials.has(m.id)) if (allDisplayedSelected) { // 取消全选:清空所有选中 setSelectedMaterials(new Set()) } else { // 全选:只选择当前显示的素材 setSelectedMaterials(new Set(displayedMaterials.map(m => m.id))) } } // 恢复素材 const handleRestoreMaterial = async (id: string) => { if (!confirm('确定要恢复该素材吗?')) return try { const response = await fetch('/api/materials/restore', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) }) if (response.ok) { setMaterials(prev => prev.filter(m => m.id !== id)) showToast('恢复成功') fetchData() } else { const error = await parseFetchError(response) showToast(error.message, 'error') } } catch (error) { console.error('Restore material error:', error) const { message } = handleError(error) showToast(message, 'error') } } // 永久删除素材 const handlePermanentDelete = async (id: string) => { if (!confirm('确定要永久删除该素材吗?此操作不可恢复!')) return try { const response = await fetch(`/api/materials?id=${id}&permanent=true`, { method: 'DELETE' }) if (response.ok) { setMaterials(prev => prev.filter(m => m.id !== id)) showToast('永久删除成功') fetchData() } else { const error = await parseFetchError(response) showToast(error.message, 'error') } } catch (error) { console.error('Permanent delete error:', error) const { message } = handleError(error) showToast(message, 'error') } } // 获取素材类型角标颜色 const getBadgeColor = (type: string) => { switch (type) { case '3DMAX': case '3D场景': return 'bg-blue-600/80' case '详情页': case '详情模板': return 'bg-green-600/80' case 'AI': case '矢量文件': return 'bg-amber-600/80' case 'C4D': case 'C4D模型': return 'bg-purple-600/80' case '淘系-主图视频': case '主图视频': case '安装视频': case '淘系-逛逛视频': case '种草视频': case '抖音-千川素材': case '抖音-账号维护': return 'bg-red-600/80' default: return 'bg-black/60' } } // 监听滚动,实现懒加载(搜索模式和非搜索模式分别处理) useEffect(() => { const observer = new IntersectionObserver( (entries) => { if (!entries[0].isIntersecting) return if (isInSearchMode) { // 搜索模式:加载更多搜索结果 if (hasMoreSearchResults && !isLoadingMoreSearch) { loadMoreSearchResults() } } else { // 非搜索模式:使用父组件的加载更多 if (hasMoreMaterials && !isLoadingMoreMaterials) { loadMoreMaterials() } } }, { threshold: 0.1 } ) if (loadMoreRef.current) { observer.observe(loadMoreRef.current) } return () => observer.disconnect() }, [isInSearchMode, hasMoreSearchResults, isLoadingMoreSearch, loadMoreSearchResults, hasMoreMaterials, isLoadingMoreMaterials, loadMoreMaterials]) return (
{/* 顶部工具栏:状态切换 + 操作 + 搜索框 */}
{/* 左侧:Tab 切换 + 分隔线 + 常用操作 */}
{/* 状态切换 (Segmented Control) */}
{/* 回收站模式提示 */} {showRecycleBin && (
回收站模式 - 显示已删除素材,删除操作为永久删除 {totalMaterialsCount !== undefined && ( (共 {totalMaterialsCount} 条) )}
)}
{/* 批量操作按钮组 */}
{selectedMaterials.size > 0 && ( <>
已选 {selectedMaterials.size}
{showRecycleBin && !isBatchProcessing && ( )} )}
{/* 右侧:搜索框 */}
{/* 卡片网格布局 */}
{materials.slice(0, displayedMaterialsCount).map((material) => { const isVideo = isVideoMaterialType(material.materialType) const posterUrl = material.videoCoverUrl || material.previewUrl const videoUrl = material.videoPreviewClipUrl || material.videoPreview540Url const isSelected = selectedMaterials.has(material.id) return (
{ if (!isBatchProcessing) { toggleMaterialSelection(material.id) } }} className={`group relative aspect-[3/4] rounded-xl overflow-hidden bg-gray-100 dark:bg-gray-800 border transition-all duration-300 cursor-pointer ${ isSelected ? 'border-indigo-500 ring-2 ring-indigo-500 dark:ring-indigo-400 shadow-lg scale-[0.98]' : 'border-transparent hover:border-gray-200 dark:hover:border-gray-600 hover:shadow-lg hover:-translate-y-1' }`} > {/* 选择指示器 (更像原生选择) */}
{isSelected && ( )}
{/* 预览图/视频 */}
{isVideo && videoUrl ? (
) })}
{/* 加载更多触发器 */} {(isInSearchMode ? hasMoreSearchResults : (displayedMaterialsCount < materials.length || hasMoreMaterials)) && (
{(isInSearchMode ? isLoadingMoreSearch : isLoadingMoreMaterials) ? ( <> 加载中... ) : ( 滚动加载更多... )}
)} {materials.length === 0 && (
暂无素材
)}
) }