/** * MaterialsTab 组件 * * 素材管理Tab组件 * 从主页面拆分出来,负责素材管理功能 * 包括素材列表、搜索、预览等 * 使用三层架构样式系统,支持深浅色主题 * * @created 2025-01-XX(拆分自 page.tsx) * @component */ 'use client' import { useState, useEffect, useCallback, useRef } 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, User } from './types' import LoadingState from './ui/LoadingState' const PAGE_SIZE = 40 interface MaterialsTabProps { showToast: ToastFn } export default function MaterialsTab({ showToast }: MaterialsTabProps) { const styles = useThemeStyles() const { confirm, prompt } = useDialog() const theme = useTheme() const [materials, setMaterials] = useState([]) const [loading, setLoading] = useState(true) const [editingMaterial, setEditingMaterial] = useState(null) const [materialSearchTerm, setMaterialSearchTerm] = useState('') const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('') const [selectedIds, setSelectedIds] = useState>(new Set()) const [showDeleted, setShowDeleted] = useState(false) // 是否显示回收站 const [currentPage, setCurrentPage] = useState(1) const [hasMore, setHasMore] = useState(true) const [isLoadingMore, setIsLoadingMore] = useState(false) const loadMoreRef = useRef(null) const [isUserSelectOpen, setIsUserSelectOpen] = useState(false) const [userSearchTerm, setUserSearchTerm] = useState('') const [userSearchLoading, setUserSearchLoading] = useState(false) const [userSearchResults, setUserSearchResults] = useState([]) const [selectedTargetUser, setSelectedTargetUser] = useState(null) const handleSearchUsers = useCallback( async (keyword: string) => { const trimmed = keyword.trim() setUserSearchTerm(keyword) if (!trimmed) { setUserSearchResults([]) return } setUserSearchLoading(true) try { const params = new URLSearchParams() params.set('pageSize', '20') params.set('search', trimmed) const response = await fetch(`/api/admin/users?${params.toString()}`) if (!response.ok) { showToast('搜索用户失败', 'error') return } const data = await response.json() const users = Array.isArray(data.users) ? (data.users as User[]) : [] setUserSearchResults(users) } catch (error) { console.error('Search users error:', error) showToast('搜索用户失败', 'error') } finally { setUserSearchLoading(false) } }, [showToast], ) useEffect(() => { const handler = setTimeout(() => { setDebouncedSearchTerm(materialSearchTerm.trim()) }, 300) return () => clearTimeout(handler) }, [materialSearchTerm]) const fetchMaterials = useCallback( async (page = 1, append = false) => { try { if (append) { setIsLoadingMore(true) } else { setLoading(true) setHasMore(true) } const params = new URLSearchParams() params.set('page', page.toString()) params.set('pageSize', PAGE_SIZE.toString()) if (showDeleted) { params.set('showDeleted', 'true') } if (debouncedSearchTerm) { params.set('search', debouncedSearchTerm) } const response = await fetch(`/api/materials?${params.toString()}`) if (response.ok) { const data = await response.json() const fetchedMaterials = (Array.isArray(data.materials) ? data.materials : Array.isArray(data) ? data : []) as Material[] if (append) { setMaterials((prev) => { const existingIds = new Set(prev.map((item) => item.id)) const deduped = fetchedMaterials.filter((item) => !existingIds.has(item.id)) if (deduped.length === 0) return prev return [...prev, ...deduped] }) } else { setMaterials(fetchedMaterials) } const appendedCount = fetchedMaterials.length const totalPages = typeof data?.totalPages === 'number' ? data.totalPages : undefined setCurrentPage(page) if (totalPages) { setHasMore(page < totalPages) } else { setHasMore(appendedCount === PAGE_SIZE) } return appendedCount } showToast('获取素材列表失败', 'error') } catch (error) { console.error('Error fetching materials:', error) showToast('获取素材列表失败', 'error') } finally { if (append) { setIsLoadingMore(false) } else { setLoading(false) } } setHasMore(false) return 0 }, [debouncedSearchTerm, showDeleted, showToast] ) useEffect(() => { fetchMaterials(1, false) }, [fetchMaterials]) const handleDeleteMaterial = async (id: string, permanent = false) => { const confirmed = await confirm({ title: permanent ? '确认永久删除' : '确认删除', message: permanent ? '确定要永久删除这个素材吗?此操作不可恢复!' : '确定要删除这个素材吗?素材将移至回收站,7天后自动清除。', variant: 'danger', }) if (!confirmed) return try { const url = permanent ? `/api/materials?id=${id}&permanent=true` : `/api/materials?id=${id}` const response = await fetch(url, { method: 'DELETE', }) if (response.ok) { setMaterials(prevMaterials => prevMaterials.filter(m => m.id !== id)) showToast(permanent ? '永久删除成功' : '删除成功') fetchMaterials() } else { showToast('删除失败', 'error') } } catch (error) { console.error('Error deleting material:', error) showToast('删除失败', 'error') } } const handleBulkRestore = useCallback(async () => { const ids = Array.from(selectedIds) if (!ids.length) return const confirmed = await confirm({ title: '批量恢复素材', message: `确定要恢复选中的 ${ids.length} 条素材吗?`, variant: 'default', }) if (!confirmed) return try { for (const id of ids) { // eslint-disable-next-line no-await-in-loop await fetch(`/api/materials/restore`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }), }) } setMaterials((prev) => prev.filter((material) => !selectedIds.has(material.id))) setSelectedIds(new Set()) showToast('批量恢复完成') fetchMaterials() } catch (error) { console.error('Bulk restore materials error:', error) showToast('批量恢复失败,请稍后重试', 'error') } }, [selectedIds, confirm, showToast, fetchMaterials]) const handleRestoreMaterial = async (id: string) => { try { const response = await fetch(`/api/materials/restore`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }), }) if (response.ok) { showToast('恢复成功') fetchMaterials() } else { showToast('恢复失败', 'error') } } catch (error) { console.error('Error restoring material:', error) showToast('恢复失败', 'error') } } const toggleSelectMaterial = useCallback((id: string) => { setSelectedIds((prev) => { const next = new Set(prev) if (next.has(id)) { next.delete(id) } else { next.add(id) } return next }) }, []) const clearSelection = useCallback(() => { setSelectedIds(new Set()) }, []) const selectAllVisible = useCallback((list: Material[]) => { setSelectedIds(new Set(list.map((material) => material.id))) }, []) const handleBulkDelete = useCallback(async () => { const ids = Array.from(selectedIds) if (!ids.length) return if (showDeleted) { // 回收站中:批量永久删除 const confirmed = await confirm({ title: '批量永久删除素材', message: `确定要永久删除选中的 ${ids.length} 条素材吗?此操作不可恢复!`, variant: 'danger', }) if (!confirmed) return try { for (const id of ids) { // eslint-disable-next-line no-await-in-loop await fetch(`/api/materials?id=${id}&permanent=true`, { method: 'DELETE' }) } setMaterials((prev) => prev.filter((material) => !selectedIds.has(material.id))) setSelectedIds(new Set()) showToast('批量永久删除完成') } catch (error) { console.error('Bulk permanent delete materials error:', error) showToast('批量永久删除失败,请稍后重试', 'error') } } else { // 正常列表:批量删除(软删除) const confirmed = await confirm({ title: '批量删除素材', message: `确定要删除选中的 ${ids.length} 条素材吗?素材将移至回收站,7天后自动清除。`, variant: 'danger', }) if (!confirmed) return try { for (const id of ids) { // eslint-disable-next-line no-await-in-loop await fetch(`/api/materials?id=${id}`, { method: 'DELETE' }) } setMaterials((prev) => prev.filter((material) => !selectedIds.has(material.id))) setSelectedIds(new Set()) showToast('批量删除完成') } catch (error) { console.error('Bulk delete materials error:', error) showToast('批量删除失败,请稍后重试', 'error') } } }, [selectedIds, confirm, showToast, showDeleted]) useEffect(() => { // 移除已被删除的选择项 setSelectedIds((prev) => { if (prev.size === 0) return prev const availableIds = new Set(materials.map((material) => material.id)) const next = new Set() prev.forEach((id) => { if (availableIds.has(id)) { next.add(id) } }) return next }) }, [materials]) const handleLoadMore = useCallback(async () => { if (loading || isLoadingMore || !hasMore) return const nextPage = currentPage + 1 await fetchMaterials(nextPage, true) }, [currentPage, fetchMaterials, hasMore, isLoadingMore, loading]) useEffect(() => { if (!hasMore) return const sentinel = loadMoreRef.current if (!sentinel) return const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting) { handleLoadMore() } }, { threshold: 0.2 } ) observer.observe(sentinel) return () => { observer.disconnect() } }, [handleLoadMore, hasMore]) const selectionCount = selectedIds.size const isAllVisibleSelected = materials.length > 0 && materials.every((material) => selectedIds.has(material.id)) const handleBulkTransferToTeam = useCallback(async () => { const ids = Array.from(selectedIds) if (!ids.length) return const confirmed = await confirm({ title: '移交到团队空间', message: `确定要将选中的 ${ids.length} 条素材的归属移交到团队空间吗?`, variant: 'default', }) if (!confirmed) return try { const response = await fetch('/api/admin/materials/transfer-owner', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ materialIds: ids, toTeam: true, }), }) if (!response.ok) { const data = await response.json().catch(() => ({})) showToast(data.error || '移交失败', 'error') return } showToast('已将选中素材移交到团队空间', 'success') setSelectedIds(new Set()) fetchMaterials(1, false) } catch (error) { console.error('Bulk transfer to team error:', error) showToast('移交失败,请稍后重试', 'error') } }, [selectedIds, confirm, showToast, fetchMaterials]) const handleBulkTransferToUser = useCallback(async () => { const ids = Array.from(selectedIds) if (!ids.length) return // 打开「选择用户」弹窗 setIsUserSelectOpen(true) setUserSearchTerm('') setUserSearchResults([]) setSelectedTargetUser(null) }, [selectedIds]) if (loading) { return } // 根据主题选择颜色类名 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 ( <>

素材管理

管理所有上传的素材,支持搜索、编辑和删除

{/* 搜索框 */}
setMaterialSearchTerm(e.target.value)} className={cn(styles.inputStyles.base, 'pl-11 pr-10')} /> {materialSearchTerm && ( )}
{/* 批量操作 */} {materials.length > 0 && (
已选 {selectionCount} 项
{showDeleted && ( )}
)} {/* 素材列表 */} {materials.length === 0 ? (
{debouncedSearchTerm ? '暂无匹配的素材' : '暂无素材'}
) : (
{materials.map((material) => (
{material.previewUrl ? ( {material.productModel ) : (
)}

{material.productModel}

类型: {material.materialType}
分类: {material.categoryName}
上传者: {material.user.username}
{!showDeleted ? ( <> ) : ( <> )}
))}
{(hasMore || isLoadingMore) && (
{isLoadingMore ? '加载中...' : '继续向下滚动加载更多'}
)} {!hasMore && materials.length > 0 && (
已加载全部素材
)}
)}
{/* Edit Material Modal */} {editingMaterial && ( setEditingMaterial(null)} onSuccess={async () => { setEditingMaterial(null) await fetchMaterials() showToast('修改成功') }} /> )} {/* 选择目标用户弹窗 */} {isUserSelectOpen && (
setIsUserSelectOpen(false)} />
选择目标用户
搜索并选择接收素材的用户。
handleSearchUsers(e.target.value)} placeholder="输入用户名、邮箱或部门关键字,例如:zhangsan / 设计部" className={cn( 'flex-1 rounded-lg border px-3 py-1.5 text-sm outline-none', 'placeholder:text-slate-400', theme === 'dark' ? 'bg-slate-900 border-slate-700 text-slate-100 focus:border-slate-400 focus:ring-1 focus:ring-slate-600' : 'bg-white border-slate-300 text-slate-900 focus:border-slate-500 focus:ring-1 focus:ring-slate-300', )} />

支持用户名、邮箱或部门。

{userSearchLoading ? (
搜索中...
) : userSearchResults.length === 0 ? (
{userSearchTerm.trim() ? '未找到匹配的用户,请尝试调整关键字。' : '请输入关键字开始搜索用户。'}
) : (
    {userSearchResults.map((user) => { const isSelected = selectedTargetUser?.id === user.id const label = user.username || user.email || user.id const subLabel = user.email && user.username ? user.email : user.email || user.department || '' const dept = user.department || user.managedDepartment || '' return (
  • ) })}
)}
当前已选中素材:{selectedIds.size} 条
)} ) }