flnxi-Wu/app/admin/_components/MaterialsTab.tsx
2025-11-22 17:56:24 +08:00

234 lines
9.0 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.

/**
* MaterialsTab 组件
*
* 素材管理Tab组件
* 从主页面拆分出来,负责素材管理功能
* 包括素材列表、搜索、预览等
* 使用三层架构样式系统,支持深浅色主题
*
* @created 2025-01-XX(拆分自 page.tsx)
* @component
*/
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import Image from 'next/image'
import EditMaterialModal from '@/components/EditMaterialModal'
import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles'
import { useDialog } from '@/components/ui/DialogProvider'
import type { Material, ToastFn } from './types'
import LoadingState from './ui/LoadingState'
interface MaterialsTabProps {
showToast: ToastFn
}
export default function MaterialsTab({ showToast }: MaterialsTabProps) {
const styles = useThemeStyles()
const { confirm } = useDialog()
const theme = useTheme()
const [materials, setMaterials] = useState<Material[]>([])
const [loading, setLoading] = useState(true)
const [editingMaterial, setEditingMaterial] = useState<Material | null>(null)
const [materialSearchTerm, setMaterialSearchTerm] = useState('')
const fetchMaterials = useCallback(async () => {
try {
setLoading(true)
const response = await fetch('/api/materials?pageSize=1000')
if (response.ok) {
const data = await response.json()
setMaterials(data.materials || data)
} else {
showToast('获取素材列表失败', 'error')
}
} catch (error) {
console.error('Error fetching materials:', error)
showToast('获取素材列表失败', 'error')
} finally {
setLoading(false)
}
}, [showToast])
useEffect(() => {
fetchMaterials()
}, [fetchMaterials])
const handleDeleteMaterial = async (id: string) => {
const confirmed = await confirm({
title: '确认删除',
message: '确定要删除这个素材吗?',
variant: 'danger',
})
if (!confirmed) return
try {
const response = await fetch(`/api/materials?id=${id}`, {
method: 'DELETE',
})
if (response.ok) {
setMaterials(prevMaterials => prevMaterials.filter(m => m.id !== id))
showToast('删除成功')
} else {
showToast('删除失败', 'error')
}
} catch (error) {
console.error('Error deleting material:', error)
showToast('删除失败', 'error')
}
}
const filteredMaterials = useMemo(() => {
if (!materialSearchTerm.trim()) return materials
const term = materialSearchTerm.toLowerCase()
return materials.filter((material) => {
return (
material.productModel?.toLowerCase().includes(term) ||
material.brandName?.toLowerCase().includes(term) ||
material.categoryName?.toLowerCase().includes(term) ||
material.sceneName?.toLowerCase().includes(term) ||
material.materialType?.toLowerCase().includes(term) ||
material.user.username?.toLowerCase().includes(term)
)
})
}, [materials, materialSearchTerm])
if (loading) {
return <LoadingState message="加载素材列表..." />
}
// 根据主题选择颜色类名
const borderColor = theme === 'dark' ? 'border-slate-800' : 'border-slate-200'
const textTitle = theme === 'dark' ? 'text-white' : 'text-slate-900'
const textDesc = theme === 'dark' ? 'text-slate-400' : 'text-slate-500'
const textIcon = theme === 'dark' ? 'text-slate-500' : 'text-slate-400'
const textIconHover = theme === 'dark' ? 'hover:text-slate-200' : 'hover:text-slate-700'
const bgImage = theme === 'dark' ? 'bg-slate-800' : 'bg-slate-100'
const textPlaceholder = theme === 'dark' ? 'text-slate-500' : 'text-slate-400'
const textItem = theme === 'dark' ? 'text-white' : 'text-slate-900'
const textItemDesc = theme === 'dark' ? 'text-slate-400' : 'text-slate-500'
return (
<>
<div className={cn(styles.cardStyles.base)}>
<div className={cn('px-6 py-4 border-b', borderColor, 'flex items-center justify-between')}>
<div>
<h2 className={cn('text-xl font-semibold', textTitle)}>素材管理</h2>
<p className={cn('text-sm mt-1', textDesc)}>
管理所有上传的素材,支持搜索、编辑和删除
</p>
</div>
</div>
<div className="p-6">
{/* 搜索框 */}
<div className="mb-6">
<div className="relative">
<input
type="text"
placeholder="搜索素材(型号、品牌、分类、场景、类型、上传者)..."
value={materialSearchTerm}
onChange={(e) => setMaterialSearchTerm(e.target.value)}
className={cn(styles.inputStyles.base, 'pl-11 pr-10')}
/>
<svg className={cn('absolute left-4 top-3 w-5 h-5', textIcon)} 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={() => setMaterialSearchTerm('')}
className={cn(
styles.buttonStyles.ghost,
styles.buttonModifiers.icon,
'absolute right-2 top-2',
textIcon,
textIconHover,
)}
aria-label="清空搜索"
>
<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>
{/* 素材列表 */}
{filteredMaterials.length === 0 ? (
<div className={cn('text-center py-12', textDesc)}>
{materialSearchTerm ? '暂无匹配的素材' : '暂无素材'}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredMaterials.map((material) => (
<div
key={material.id}
className={cn(styles.cardStyles.hover, 'overflow-hidden')}
>
<div className={cn('aspect-square relative overflow-hidden', bgImage)}>
{material.previewUrl ? (
<Image
src={material.previewUrl}
alt={material.productModel || '素材预览图'}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, (max-width: 1280px) 33vw, 25vw"
/>
) : (
<div className={cn('w-full h-full flex items-center justify-center', textPlaceholder)}>
<svg className="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
)}
</div>
<div className="p-4">
<h3 className={cn('font-semibold truncate mb-1', textItem)}>
{material.productModel}
</h3>
<div className={cn('text-xs space-y-1 mb-3', textItemDesc)}>
<div>类型: {material.materialType}</div>
<div>分类: {material.categoryName}</div>
<div>上传者: {material.user.username}</div>
</div>
<div className="flex gap-2">
<button
onClick={() => setEditingMaterial(material)}
className={cn(styles.buttonStyles.secondary, styles.buttonModifiers.compactXs, 'flex-1')}
>
编辑
</button>
<button
onClick={() => handleDeleteMaterial(material.id)}
className={cn(styles.buttonStyles.danger, styles.buttonModifiers.compactXs, 'flex-1')}
>
删除
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Edit Material Modal */}
{editingMaterial && (
<EditMaterialModal
material={editingMaterial}
onClose={() => setEditingMaterial(null)}
onSuccess={async () => {
setEditingMaterial(null)
await fetchMaterials()
showToast('修改成功')
}}
/>
)}
</>
)
}