/** * 素材管理组件 * 提取自 app/admin/page.tsx 的素材管理功能 * * 搜索逻辑:复用前台 SearchBar 组件,支持 IME 中文输入 * 使用 useDeferredValue 替代手动防抖,避免闭包问题 */ 'use client' import { useRef, useEffect, useCallback, useDeferredValue, useState } from 'react' import { useRouter } from 'next/navigation' import { useConfirmActionDialog, type ConfirmActionOptions } from '@/components/ConfirmActionDialog' import { useMaterialPurgeJobs } from '@/components/admin/MaterialPurgeStatus' 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 { isLoadingMaterials?: boolean 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({ isLoadingMaterials = false, 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 [actionProcessing, setIsBatchProcessing] = useState(false) const isBatchProcessing = actionProcessing || isLoadingMaterials const { confirmAction, confirmActionDialog } = useConfirmActionDialog() const { track: trackPurgeJobs, status: purgeStatus } = useMaterialPurgeJobs() const actionOpen = useRef(false) const confirmMaterialAction = async (options: ConfirmActionOptions) => { if (actionOpen.current) return actionOpen.current = true try { await confirmAction({ ...options, action: async reason => { setIsBatchProcessing(true) try { await options.action?.(reason) } catch (error) { throw new Error(handleError(error).message) } finally { setIsBatchProcessing(false) } } }) } finally { actionOpen.current = false } } const removeRows = (ids: string[]) => { setMaterials(previous => previous.filter(material => !ids.includes(material.id))) setSelectedMaterials(previous => new Set(Array.from(previous).filter(id => !ids.includes(id)))) void fetchData() } const requireSuccess = async (response: Response) => { if (!response.ok) throw new Error((await parseFetchError(response)).message) return response.json() } // 使用 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) => { await confirmMaterialAction({ title: '移入回收站?', description: '素材之后可以恢复。', confirmLabel: '移入回收站', tone: 'danger', action: async () => { await requireSuccess(await fetch(`/api/materials?id=${id}`, { method: 'DELETE' })) removeRows([id]) showToast('已移入回收站') } }) } const handleBatchDeleteMaterials = async () => { const selectedIds = Array.from(selectedMaterials) if (!selectedIds.length) return const permanent = showRecycleBin await confirmMaterialAction({ title: permanent ? `彻底删除 ${selectedIds.length} 条素材?` : `将 ${selectedIds.length} 条素材移入回收站?`, description: permanent ? '删除后无法恢复,关联文件将一并清理。' : '素材之后可以恢复。', confirmLabel: permanent ? '彻底删除' : '移入回收站', loadingLabel: '正在提交…', tone: 'danger', action: async () => { const result = await requireSuccess(await fetch('/api/admin/materials/batch-delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: selectedIds, permanent }), })) if (permanent) trackPurgeJobs(result.jobs) removeRows(selectedIds) if (!permanent) showToast(`已将 ${selectedIds.length} 条素材移入回收站`) }, }) } // 切换素材选择状态 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) => { await confirmMaterialAction({ title: '恢复素材?', confirmLabel: '恢复', action: async () => { await requireSuccess(await fetch('/api/materials/restore', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }), })) removeRows([id]) showToast('已恢复素材') } }) } const handlePermanentDelete = async (id: string) => { await confirmMaterialAction({ title: '彻底删除这条素材?', description: '删除后无法恢复,关联文件将一并清理。', confirmLabel: '彻底删除', loadingLabel: '正在提交…', tone: 'danger', action: async () => { const result = await requireSuccess(await fetch(`/api/materials?id=${id}&permanent=true`, { method: 'DELETE' })) trackPurgeJobs(result.jobs) removeRows([id]) }, }) } const handleBatchRestore = async () => { let remaining = Array.from(selectedMaterials) const originalCount = remaining.length await confirmMaterialAction({ title: `恢复 ${originalCount} 条素材?`, confirmLabel: '恢复', action: async () => { const restored: string[] = [] let message = '请稍后重试' for (const id of remaining) { try { await requireSuccess(await fetch('/api/materials/restore', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }), })) restored.push(id) } catch (error) { message = handleError(error).message } } remaining = remaining.filter(id => !restored.includes(id)) removeRows(restored) if (remaining.length) throw new Error(`已恢复 ${originalCount - remaining.length} 条,${remaining.length} 条未恢复:${message}`) showToast(`已恢复 ${originalCount} 条素材`) } }) } // 获取素材类型角标颜色 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) */}
{/* 批量操作按钮组 */}
{selectedMaterials.size > 0 && ( <>
已选 {selectedMaterials.size}
{showRecycleBin && !isBatchProcessing && ( )} )}
{/* 右侧:搜索框 */}
{purgeStatus} {confirmActionDialog} {/* 卡片网格布局 */}
{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' }`} > {/* 选择指示器 (更像原生选择) */} {/* 预览图/视频 */}
{isVideo && videoUrl ? (
) })}
{/* 加载更多触发器 */} {(isInSearchMode ? hasMoreSearchResults : (displayedMaterialsCount < materials.length || hasMoreMaterials)) && (
{(isInSearchMode ? isLoadingMoreSearch : isLoadingMoreMaterials) ? ( <> 加载中... ) : ( 滚动加载更多... )}
)} {isLoadingMaterials &&
加载中…
} {!isLoadingMaterials && materials.length === 0 && (
暂无素材
)}
) }