470 lines
17 KiB
TypeScript
470 lines
17 KiB
TypeScript
/**
|
||
* 素材管理组件
|
||
* 提取自 app/admin/page.tsx 的素材管理功能
|
||
*/
|
||
|
||
'use client'
|
||
|
||
import { useRef, useEffect, useCallback } 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'
|
||
|
||
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
|
||
hasMoreMaterials: boolean
|
||
isLoadingMoreMaterials: boolean
|
||
showToast: (message: string, type?: 'success' | 'error') => void
|
||
searchMaterials: (searchTerm: string) => Promise<void>
|
||
loadMoreMaterials: () => Promise<void>
|
||
fetchData: () => Promise<void>
|
||
}
|
||
|
||
export default function MaterialManagement({
|
||
materials,
|
||
setMaterials,
|
||
selectedMaterials,
|
||
setSelectedMaterials,
|
||
showRecycleBin,
|
||
setShowRecycleBin,
|
||
materialSearchTerm,
|
||
setMaterialSearchTerm,
|
||
displayedMaterialsCount,
|
||
hasMoreMaterials,
|
||
isLoadingMoreMaterials,
|
||
showToast,
|
||
searchMaterials,
|
||
loadMoreMaterials,
|
||
fetchData,
|
||
}: MaterialManagementProps) {
|
||
const router = useRouter()
|
||
const loadMoreRef = useRef<HTMLDivElement>(null)
|
||
const materialSearchDebounceRef = useRef<NodeJS.Timeout | null>(null)
|
||
|
||
// 素材搜索处理函数(局部刷新,使用防抖)
|
||
const handleMaterialSearchChange = useCallback((value: string) => {
|
||
setMaterialSearchTerm(value)
|
||
|
||
if (materialSearchDebounceRef.current) {
|
||
clearTimeout(materialSearchDebounceRef.current)
|
||
}
|
||
|
||
if (!value.trim()) {
|
||
searchMaterials('')
|
||
return
|
||
}
|
||
|
||
materialSearchDebounceRef.current = setTimeout(() => {
|
||
searchMaterials(value)
|
||
}, 500)
|
||
}, [searchMaterials, setMaterialSearchTerm])
|
||
|
||
// 删除单个素材
|
||
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
|
||
|
||
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) {
|
||
setMaterials(prev => prev.filter(m => !selectedIds.includes(m.id)))
|
||
setSelectedMaterials(new Set())
|
||
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')
|
||
}
|
||
}
|
||
|
||
// 切换素材选择状态
|
||
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 = () => {
|
||
if (materials.length > 0 && materials.every(m => selectedMaterials.has(m.id))) {
|
||
setSelectedMaterials(new Set())
|
||
} else {
|
||
setSelectedMaterials(new Set(materials.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 '种草视频':
|
||
return 'bg-red-600/80'
|
||
default:
|
||
return 'bg-black/60'
|
||
}
|
||
}
|
||
|
||
// 监听滚动,实现懒加载
|
||
useEffect(() => {
|
||
const observer = new IntersectionObserver(
|
||
(entries) => {
|
||
if (entries[0].isIntersecting && hasMoreMaterials && !isLoadingMoreMaterials) {
|
||
loadMoreMaterials()
|
||
}
|
||
},
|
||
{ threshold: 0.1 }
|
||
)
|
||
|
||
if (loadMoreRef.current) {
|
||
observer.observe(loadMoreRef.current)
|
||
}
|
||
|
||
return () => observer.disconnect()
|
||
}, [hasMoreMaterials, isLoadingMoreMaterials, loadMoreMaterials])
|
||
|
||
return (
|
||
<div className="card-apple overflow-hidden">
|
||
{/* 素材搜索框和批量操作 */}
|
||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 space-y-4">
|
||
{/* 搜索框 */}
|
||
<div className="relative">
|
||
<input
|
||
type="text"
|
||
placeholder="搜索素材:SPU型号、店铺、场景、作者..."
|
||
value={materialSearchTerm}
|
||
onChange={(e) => handleMaterialSearchChange(e.target.value)}
|
||
className="w-full pl-10 pr-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all"
|
||
/>
|
||
<svg
|
||
className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||
</svg>
|
||
{materialSearchTerm && (
|
||
<button
|
||
onClick={() => handleMaterialSearchChange('')}
|
||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||
>
|
||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* 批量操作工具栏 */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-4">
|
||
<button
|
||
onClick={toggleSelectAll}
|
||
className="text-sm text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||
>
|
||
{materials.length > 0 && materials.every(m => selectedMaterials.has(m.id))
|
||
? '取消全选'
|
||
: '全选当前结果'}
|
||
</button>
|
||
{selectedMaterials.size > 0 && (
|
||
<button
|
||
onClick={handleBatchDeleteMaterials}
|
||
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm rounded-lg transition-colors"
|
||
>
|
||
{showRecycleBin ? '批量永久删除' : '批量删除'}
|
||
</button>
|
||
)}
|
||
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||
<input
|
||
type="checkbox"
|
||
className="rounded"
|
||
checked={showRecycleBin}
|
||
onChange={(e) => setShowRecycleBin(e.target.checked)}
|
||
/>
|
||
查看回收站
|
||
</label>
|
||
</div>
|
||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||
共 {materials.length} 个{showRecycleBin ? '(回收站)' : ''}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 卡片网格布局 */}
|
||
<div className="p-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||
{materials.slice(0, displayedMaterialsCount).map((material) => {
|
||
const isVideo = material.materialType === '主图视频' ||
|
||
material.materialType === '安装视频' ||
|
||
material.materialType === '种草视频'
|
||
const posterUrl = material.videoCoverUrl || material.previewUrl
|
||
const videoUrl = material.videoPreviewClipUrl || material.videoPreview540Url
|
||
|
||
return (
|
||
<div
|
||
key={material.id}
|
||
className="relative aspect-[3/4] rounded-2xl overflow-hidden bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow hover:shadow-lg transition-shadow"
|
||
>
|
||
{/* 选择框 */}
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
toggleMaterialSelection(material.id)
|
||
}}
|
||
className={`absolute z-10 top-2 left-2 w-7 h-7 rounded-full border-2 flex items-center justify-center text-xs font-semibold transition-colors ${
|
||
selectedMaterials.has(material.id)
|
||
? 'bg-indigo-600 border-indigo-600 text-white'
|
||
: 'bg-white/90 border-gray-300 text-transparent hover:text-gray-400'
|
||
}`}
|
||
aria-pressed={selectedMaterials.has(material.id)}
|
||
aria-label={selectedMaterials.has(material.id) ? '取消选择素材' : '选择素材'}
|
||
>
|
||
✓
|
||
</button>
|
||
|
||
{/* 预览图/视频 */}
|
||
<div className="relative w-full h-full bg-gray-200 dark:bg-gray-700">
|
||
{isVideo && videoUrl ? (
|
||
<video
|
||
src={videoUrl}
|
||
poster={posterUrl || undefined}
|
||
className="w-full h-full object-cover"
|
||
muted
|
||
loop
|
||
playsInline
|
||
preload="metadata"
|
||
/>
|
||
) : (
|
||
<img
|
||
src={material.previewUrl}
|
||
alt={material.productModel}
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
)}
|
||
|
||
{/* 商品型号角标 */}
|
||
<div className="absolute top-2 left-10 bg-black/70 text-white px-2 py-1 rounded text-xs font-medium">
|
||
{material.productModel}
|
||
</div>
|
||
|
||
{/* 素材类型角标 */}
|
||
{material.materialType && (
|
||
<div className={`absolute top-2 right-2 text-white px-2 py-1 rounded text-xs font-medium ${getBadgeColor(material.materialType)}`}>
|
||
{material.materialType}
|
||
</div>
|
||
)}
|
||
|
||
{/* 底部信息栏 */}
|
||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3">
|
||
<p className="text-sm font-medium text-white truncate mb-1">
|
||
{material.sceneName}
|
||
</p>
|
||
<div className="flex items-center justify-between text-xs text-white/80">
|
||
<span className="truncate">{material.authorName}</span>
|
||
<span className="ml-2 flex-shrink-0">
|
||
{format(new Date(material.createdAt), 'MM-dd')}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 操作按钮(悬停显示) */}
|
||
<div className="absolute inset-0 bg-black/60 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
|
||
{showRecycleBin ? (
|
||
<>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
handleRestoreMaterial(material.id)
|
||
}}
|
||
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg transition-colors"
|
||
>
|
||
恢复
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
handlePermanentDelete(material.id)
|
||
}}
|
||
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm rounded-lg transition-colors"
|
||
>
|
||
永久删除
|
||
</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
router.push(`/edit/${material.id}`)
|
||
}}
|
||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors"
|
||
>
|
||
编辑
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
handleDeleteMaterial(material.id)
|
||
}}
|
||
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm rounded-lg transition-colors"
|
||
>
|
||
删除
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* 加载更多触发器 */}
|
||
{(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">
|
||
{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>
|
||
)
|
||
}
|