gongxiangp2/components/admin/MaterialManagement.tsx
2026-05-07 16:37:22 +08:00

708 lines
29 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.

/**
* 素材管理组件
* 提取自 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<React.SetStateAction<Material[]>>
selectedMaterials: Set<string>
setSelectedMaterials: React.Dispatch<React.SetStateAction<Set<string>>>
showRecycleBin: boolean
setShowRecycleBin: React.Dispatch<React.SetStateAction<boolean>>
materialSearchTerm: string
setMaterialSearchTerm: React.Dispatch<React.SetStateAction<string>>
displayedMaterialsCount: number
setDisplayedMaterialsCount: React.Dispatch<React.SetStateAction<number>>
hasMoreMaterials: boolean
setHasMoreMaterials: React.Dispatch<React.SetStateAction<boolean>>
isLoadingMoreMaterials: boolean
showToast: (message: string, type?: 'success' | 'error') => void
loadMoreMaterials: () => Promise<void>
fetchData: () => Promise<void>
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<HTMLDivElement>(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<string | null>(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 (
<div className="card-apple overflow-hidden">
{/* 顶部工具栏:状态切换 + 操作 + 搜索框 */}
<div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-4 px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm sticky top-0 z-20">
{/* 左侧:Tab 切换 + 分隔线 + 常用操作 */}
<div className="flex items-center gap-4 flex-wrap">
{/* 状态切换 (Segmented Control) */}
<div className="bg-gray-100 dark:bg-gray-900/50 p-1 rounded-lg flex items-center shadow-inner border border-gray-200 dark:border-gray-700/50">
<button
onClick={() => setShowRecycleBin(false)}
className={`px-3 py-1.5 rounded-md text-xs font-bold transition-all ${
!showRecycleBin
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
全部
</button>
<button
onClick={() => setShowRecycleBin(true)}
className={`px-3 py-1.5 rounded-md text-xs font-bold transition-all flex items-center gap-1.5 ${
showRecycleBin
? 'bg-white dark:bg-gray-700 text-red-600 dark:text-red-400 shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
回收站
{showRecycleBin && totalMaterialsCount !== undefined && totalMaterialsCount > 0 && (
<span className="ml-1 px-1.5 py-0.5 bg-red-100 dark:bg-red-900/50 text-red-600 dark:text-red-400 text-[10px] font-bold rounded-full">
{totalMaterialsCount}
</span>
)}
</button>
</div>
{/* 回收站模式提示 */}
{showRecycleBin && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-xs text-red-600 dark:text-red-400">
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span className="font-medium">回收站模式</span>
<span className="hidden sm:inline">- 显示已删除素材,删除操作为永久删除</span>
{totalMaterialsCount !== undefined && (
<span className="font-bold">(共 {totalMaterialsCount} 条)</span>
)}
</div>
)}
<div className="w-px h-6 bg-gray-200 dark:bg-gray-700 hidden sm:block"></div>
{/* 批量操作按钮组 */}
<div className="flex items-center gap-2">
<button
onClick={toggleSelectAll}
disabled={isBatchProcessing || materials.length === 0}
className={`text-xs font-bold px-3 py-1.5 rounded-lg transition-colors border ${
materials.length > 0 && materials.slice(0, displayedMaterialsCount).every(m => selectedMaterials.has(m.id))
? 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 border-indigo-200 dark:border-indigo-800'
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700'
} ${isBatchProcessing || materials.length === 0 ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{materials.length > 0 && materials.slice(0, displayedMaterialsCount).every(m => selectedMaterials.has(m.id))
? '取消'
: '全选'}
</button>
{selectedMaterials.size > 0 && (
<>
<div className="hidden sm:flex items-center px-2 text-xs font-medium text-gray-500">
已选 {selectedMaterials.size}
</div>
<button
onClick={handleBatchDeleteMaterials}
disabled={isBatchProcessing}
className={`px-3 py-1.5 text-white text-xs font-bold rounded-lg transition-all flex items-center gap-1.5 shadow-sm active:scale-95 ${
isBatchProcessing
? 'bg-red-400 cursor-not-allowed'
: showRecycleBin
? 'bg-red-600 hover:bg-red-700'
: 'bg-red-500 hover:bg-red-600'
}`}
>
{isBatchProcessing && (
<svg className="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
)}
{showRecycleBin ? '彻底删除' : '删除'}
</button>
{showRecycleBin && !isBatchProcessing && (
<button
onClick={async () => {
if (!confirm(`确定要恢复选中的 ${selectedMaterials.size} 个素材吗?`)) return;
setIsBatchProcessing(true);
try {
let successCount = 0;
const ids = Array.from(selectedMaterials);
for (const id of ids) {
try {
const res = await fetch('/api/materials/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
});
if (res.ok) successCount++;
} catch (e) {
console.error(`恢复素材 ${id} 失败`, e);
}
}
setMaterials(prev => prev.filter(m => !selectedMaterials.has(m.id)));
setSelectedMaterials(new Set());
showToast(`成功恢复 ${successCount} 个素材`);
fetchData();
} catch (error) {
showToast('批量恢复部分失败', 'error');
} finally {
setIsBatchProcessing(false);
}
}}
className="px-3 py-1.5 bg-green-500 hover:bg-green-600 text-white text-xs font-bold rounded-lg transition-all shadow-sm active:scale-95"
>
恢复
</button>
)}
</>
)}
</div>
</div>
{/* 右侧:搜索框 */}
<div className="w-full md:w-80 lg:w-96 flex-shrink-0">
<SearchBar
value={materialSearchTerm}
onChange={handleSearchChange}
placeholder="搜索素材..."
isSearching={isSearching}
/>
</div>
</div>
{/* 卡片网格布局 */}
<div className="p-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 sm:gap-6">
{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 (
<div
key={material.id}
onClick={() => {
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'
}`}
>
{/* 选择指示器 (更像原生选择) */}
<div className={`absolute z-20 top-2.5 right-2.5 w-6 h-6 rounded-full flex items-center justify-center transition-all duration-200 ${
isSelected
? 'bg-blue-500 text-white shadow-md scale-100'
: 'bg-black/20 hover:bg-black/40 border border-white/30 backdrop-blur-sm'
}`}>
{isSelected && (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="3">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
{/* 预览图/视频 */}
<div className="relative w-full h-full">
{isVideo && videoUrl ? (
<video
src={videoUrl}
poster={posterUrl || undefined}
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105"
muted
loop
playsInline
preload="metadata"
/>
) : (
<img
src={material.previewUrl}
alt={material.productModel}
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105"
loading="lazy"
/>
)}
{/* 渐变遮罩 (底部信息) */}
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/80 via-black/40 to-transparent opacity-90" />
{/* 顶部左侧信息 (型号) */}
<div className="absolute top-3 left-3 right-10 overflow-hidden">
<span className="inline-block bg-black/40 backdrop-blur-md text-white/90 text-[10px] font-semibold px-2 py-0.5 rounded shadow-sm truncate max-w-full border border-white/10">
{material.productModel}
</span>
</div>
{/* 底部信息栏 */}
<div className="absolute bottom-0 left-0 right-0 p-3 transform transition-transform duration-300 group-hover:-translate-y-1">
<p className="text-xs font-bold text-white truncate mb-1 text-shadow-sm">
{material.sceneName || '无场景名称'}
</p>
<div className="flex items-center justify-between text-[10px] text-gray-300 font-medium">
<span className="truncate max-w-[60%] flex items-center gap-1">
{material.authorName}
</span>
<span className="flex-shrink-0 opacity-80 bg-white/10 px-1.5 py-0.5 rounded">
{format(new Date(material.createdAt), 'MM-dd')}
</span>
</div>
</div>
{/* 操作按钮浮层 */}
<div className="absolute inset-0 bg-black/40 backdrop-blur-[2px] opacity-0 group-hover:opacity-100 transition-all duration-300 flex flex-col items-center justify-center gap-2 p-4">
{showRecycleBin ? (
<>
<button
onClick={(e) => {
e.stopPropagation()
handleRestoreMaterial(material.id)
}}
className="w-full py-2 bg-green-500 hover:bg-green-600 text-white text-xs font-bold rounded-lg shadow-lg transform hover:scale-105 transition-all"
>
恢复
</button>
<button
onClick={(e) => {
e.stopPropagation()
handlePermanentDelete(material.id)
}}
className="w-full py-2 bg-white/20 hover:bg-red-500/80 border border-white/30 text-white text-xs font-bold rounded-lg backdrop-blur-md shadow-lg transform hover:scale-105 transition-all"
>
永久删除
</button>
</>
) : (
<>
<button
onClick={(e) => {
e.stopPropagation()
router.push(`/edit/${material.id}`)
}}
className="w-full py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-bold rounded-lg shadow-lg transform hover:scale-105 transition-all"
>
编辑
</button>
<button
onClick={(e) => {
e.stopPropagation()
handleDeleteMaterial(material.id)
}}
className="w-full py-2 bg-white/20 hover:bg-red-500/80 border border-white/30 text-white text-xs font-bold rounded-lg backdrop-blur-md shadow-lg transform hover:scale-105 transition-all"
>
移入回收站
</button>
</>
)}
</div>
</div>
</div>
)
})}
</div>
{/* 加载更多触发器 */}
{(isInSearchMode ? hasMoreSearchResults : (displayedMaterialsCount < materials.length || hasMoreMaterials)) && (
<div ref={loadMoreRef} className="py-8 text-center">
<div className="inline-flex items-center gap-2 text-gray-500 dark:text-gray-400">
{(isInSearchMode ? isLoadingMoreSearch : isLoadingMoreMaterials) ? (
<>
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>加载中...</span>
</>
) : (
<span>滚动加载更多...</span>
)}
</div>
</div>
)}
{materials.length === 0 && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
暂无素材
</div>
)}
</div>
)
}