gongxiangp2/components/admin/MaterialManagement.tsx

660 lines
29 KiB
TypeScript
Raw Permalink 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 { 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<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({
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<HTMLDivElement>(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<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) => {
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 (
<div className="card-apple overflow-hidden" aria-busy={isBatchProcessing}>
{/* 顶部工具栏:状态切换 + 操作 + 搜索框 */}
<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)}
disabled={actionProcessing}
className={`min-h-11 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)}
disabled={actionProcessing}
className={`min-h-11 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>
<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 min-h-11 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={`min-h-11 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={handleBatchRestore}
className="min-h-11 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>
{purgeStatus}
{confirmActionDialog}
{/* 卡片网格布局 */}
<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'
}`}
>
{/* 选择指示器 (更像原生选择) */}
<button type="button" aria-label={`选择 ${material.productModel}`} aria-pressed={isSelected} disabled={isBatchProcessing}
onClick={event => { event.stopPropagation(); toggleMaterialSelection(material.id) }}
className={`absolute z-20 top-1 right-1 min-w-11 min-h-11 rounded-full flex items-center justify-center transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 ${
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>
)}
</button>
{/* 预览图/视频 */}
<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-100 md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100 transition-all duration-300 flex flex-col items-center justify-center gap-2 p-4">
{showRecycleBin ? (
<>
<button
disabled={isBatchProcessing}
onClick={(e) => {
e.stopPropagation()
handleRestoreMaterial(material.id)
}}
className="w-full min-h-11 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
disabled={isBatchProcessing}
onClick={(e) => {
e.stopPropagation()
handlePermanentDelete(material.id)
}}
className="w-full min-h-11 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
disabled={isBatchProcessing}
onClick={(e) => {
e.stopPropagation()
router.push(`/edit/${material.id}`)
}}
className="w-full min-h-11 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
disabled={isBatchProcessing}
onClick={(e) => {
e.stopPropagation()
handleDeleteMaterial(material.id)
}}
className="w-full min-h-11 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>
)}
{isLoadingMaterials && <div role="status" className="py-8 text-center text-gray-600">加载中…</div>}
{!isLoadingMaterials && materials.length === 0 && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
暂无素材
</div>
)}
</div>
)
}