gongxiangp2/components/admin/MaterialManagement.tsx

470 lines
17 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 的素材管理功能
*/
'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>
)
}