660 lines
29 KiB
TypeScript
660 lines
29 KiB
TypeScript
/**
|
||
* 素材管理组件
|
||
* 提取自 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>
|
||
)
|
||
}
|