891 lines
35 KiB
TypeScript
891 lines
35 KiB
TypeScript
/**
|
||
* 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<Material[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [editingMaterial, setEditingMaterial] = useState<Material | null>(null)
|
||
const [materialSearchTerm, setMaterialSearchTerm] = useState('')
|
||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('')
|
||
const [selectedIds, setSelectedIds] = useState<Set<string>>(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<HTMLDivElement | null>(null)
|
||
const [isUserSelectOpen, setIsUserSelectOpen] = useState(false)
|
||
const [userSearchTerm, setUserSearchTerm] = useState('')
|
||
const [userSearchLoading, setUserSearchLoading] = useState(false)
|
||
const [userSearchResults, setUserSearchResults] = useState<User[]>([])
|
||
const [selectedTargetUser, setSelectedTargetUser] = useState<User | null>(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<string>()
|
||
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 <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="flex items-center gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowDeleted(!showDeleted)}
|
||
className={cn(
|
||
'px-4 py-2 rounded-lg font-medium transition-colors',
|
||
'border border-slate-300 dark:border-slate-600',
|
||
showDeleted
|
||
? 'bg-indigo-600 text-white hover:bg-indigo-700 border-indigo-600 dark:border-indigo-600'
|
||
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700'
|
||
)}
|
||
>
|
||
{showDeleted ? '返回素材列表' : '回收站'}
|
||
</button>
|
||
<div className="relative flex-1">
|
||
<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>
|
||
</div>
|
||
|
||
{/* 批量操作 */}
|
||
{materials.length > 0 && (
|
||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||
<div className="text-sm">
|
||
已选 <span className="font-semibold">{selectionCount}</span> 项
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
isAllVisibleSelected ? clearSelection() : selectAllVisible(materials)
|
||
}
|
||
className={cn(styles.buttonStyles.secondary, styles.buttonModifiers.compactXs)}
|
||
>
|
||
{isAllVisibleSelected ? '取消全选' : '全选当前结果'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={clearSelection}
|
||
className={cn(
|
||
styles.buttonStyles.ghost,
|
||
styles.buttonModifiers.compactXs,
|
||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||
)}
|
||
disabled={!selectionCount}
|
||
>
|
||
清空选择
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleBulkTransferToTeam}
|
||
className={cn(
|
||
styles.buttonStyles.secondary,
|
||
styles.buttonModifiers.compactXs,
|
||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||
)}
|
||
disabled={!selectionCount}
|
||
>
|
||
移交到团队空间
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleBulkTransferToUser}
|
||
className={cn(
|
||
styles.buttonStyles.secondary,
|
||
styles.buttonModifiers.compactXs,
|
||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||
)}
|
||
disabled={!selectionCount}
|
||
>
|
||
移交给指定用户
|
||
</button>
|
||
{showDeleted && (
|
||
<button
|
||
type="button"
|
||
onClick={handleBulkRestore}
|
||
className={cn(
|
||
styles.buttonStyles.success,
|
||
styles.buttonModifiers.compactXs,
|
||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||
)}
|
||
disabled={!selectionCount}
|
||
>
|
||
批量恢复
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={handleBulkDelete}
|
||
className={cn(
|
||
styles.buttonStyles.danger,
|
||
styles.buttonModifiers.compactXs,
|
||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||
)}
|
||
disabled={!selectionCount}
|
||
>
|
||
{showDeleted ? '批量永久删除' : '批量删除'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 素材列表 */}
|
||
{materials.length === 0 ? (
|
||
<div className={cn('text-center py-12', textDesc)}>
|
||
{debouncedSearchTerm ? '暂无匹配的素材' : '暂无素材'}
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||
{materials.map((material) => (
|
||
<div
|
||
key={material.id}
|
||
className={cn(
|
||
styles.cardStyles.hover,
|
||
'overflow-hidden relative',
|
||
selectedIds.has(material.id) && 'ring-2 ring-offset-2 ring-indigo-500'
|
||
)}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleSelectMaterial(material.id)}
|
||
className={cn(
|
||
'absolute z-10 top-3 left-3 w-7 h-7 rounded-full border-2 flex items-center justify-center text-xs font-semibold transition-colors',
|
||
selectedIds.has(material.id)
|
||
? 'bg-indigo-600 border-indigo-600 text-white'
|
||
: 'bg-white/80 border-slate-300 text-transparent hover:text-slate-400'
|
||
)}
|
||
aria-pressed={selectedIds.has(material.id)}
|
||
aria-label={selectedIds.has(material.id) ? '取消选择素材' : '选择素材'}
|
||
>
|
||
✓
|
||
</button>
|
||
<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 mt-3">
|
||
{!showDeleted ? (
|
||
<>
|
||
<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>
|
||
</>
|
||
) : (
|
||
<>
|
||
<button
|
||
onClick={() => handleRestoreMaterial(material.id)}
|
||
className={cn(styles.buttonStyles.secondary, styles.buttonModifiers.compactXs, 'flex-1')}
|
||
>
|
||
恢复
|
||
</button>
|
||
<button
|
||
onClick={() => handleDeleteMaterial(material.id, true)}
|
||
className={cn(styles.buttonStyles.danger, styles.buttonModifiers.compactXs, 'flex-1')}
|
||
>
|
||
永久删除
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{(hasMore || isLoadingMore) && (
|
||
<div
|
||
ref={hasMore ? loadMoreRef : null}
|
||
className="flex items-center justify-center py-4 text-sm text-slate-500 dark:text-slate-400"
|
||
>
|
||
{isLoadingMore ? '加载中...' : '继续向下滚动加载更多'}
|
||
</div>
|
||
)}
|
||
{!hasMore && materials.length > 0 && (
|
||
<div className="text-center py-4 text-sm text-slate-400 dark:text-slate-500">已经到底啦~</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Edit Material Modal */}
|
||
{editingMaterial && (
|
||
<EditMaterialModal
|
||
material={editingMaterial as any}
|
||
onClose={() => setEditingMaterial(null)}
|
||
onSuccess={async () => {
|
||
setEditingMaterial(null)
|
||
await fetchMaterials()
|
||
showToast('修改成功')
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* 选择目标用户弹窗 */}
|
||
{isUserSelectOpen && (
|
||
<div className="fixed inset-0 z-40 flex items-center justify-center px-4 sm:px-6">
|
||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => setIsUserSelectOpen(false)} />
|
||
<div
|
||
className={cn(
|
||
'relative z-50 w-full max-w-xl rounded-2xl border shadow-xl',
|
||
theme === 'dark'
|
||
? 'bg-slate-900/95 border-slate-700/80 shadow-black/60'
|
||
: 'bg-white/95 border-slate-200 shadow-slate-900/5',
|
||
)}
|
||
>
|
||
<div className="px-5 pt-4 pb-3 border-b border-slate-200/70 dark:border-slate-700/80 flex items-center justify-between">
|
||
<div>
|
||
<div className="text-base font-semibold text-slate-900 dark:text-slate-50">选择目标用户</div>
|
||
<div className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||
支持按用户名、邮箱或部门关键字搜索,然后点选要接收素材的用户。
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsUserSelectOpen(false)}
|
||
className="ml-2 inline-flex h-7 w-7 items-center justify-center rounded-full text-slate-400 hover:text-slate-600 hover:bg-slate-100 dark:hover:bg-slate-800 dark:hover:text-slate-200"
|
||
>
|
||
<span className="sr-only">关闭</span>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<div className="px-5 pt-3 pb-4 space-y-3">
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1.5">
|
||
搜索用户
|
||
</label>
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="text"
|
||
value={userSearchTerm}
|
||
onChange={(e) => 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-indigo-400 focus:ring-1 focus:ring-indigo-500/70'
|
||
: 'bg-white border-slate-300 text-slate-900 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40',
|
||
)}
|
||
/>
|
||
</div>
|
||
<p className="mt-1 text-[11px] text-slate-400 dark:text-slate-500">
|
||
小提示:可以直接输入邮箱或完整用户名,结果会自动高亮匹配。
|
||
</p>
|
||
</div>
|
||
|
||
<div className="border rounded-xl max-h-72 overflow-y-auto border-slate-200 dark:border-slate-700/80">
|
||
{userSearchLoading ? (
|
||
<div className="px-4 py-6 text-sm text-slate-500 dark:text-slate-400 text-center">搜索中...</div>
|
||
) : userSearchResults.length === 0 ? (
|
||
<div className="px-4 py-6 text-sm text-slate-400 dark:text-slate-500 text-center">
|
||
{userSearchTerm.trim()
|
||
? '未找到匹配的用户,请尝试调整关键字。'
|
||
: '请输入关键字开始搜索用户。'}
|
||
</div>
|
||
) : (
|
||
<ul className="divide-y divide-slate-100 dark:divide-slate-800/80">
|
||
{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 (
|
||
<li key={user.id}>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedTargetUser(user)}
|
||
className={cn(
|
||
'w-full px-4 py-2.5 flex items-center justify-between text-left text-sm transition-colors',
|
||
isSelected
|
||
? theme === 'dark'
|
||
? 'bg-indigo-700/40 text-slate-50'
|
||
: 'bg-indigo-50 text-slate-900'
|
||
: theme === 'dark'
|
||
? 'hover:bg-slate-800/70 text-slate-100'
|
||
: 'hover:bg-slate-50 text-slate-800',
|
||
)}
|
||
>
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<span className="truncate max-w-[180px] font-medium">{label}</span>
|
||
{user.isSuperAdmin && (
|
||
<span className="inline-flex items-center rounded-full bg-amber-100 text-amber-700 px-1.5 py-0.5 text-[10px] font-medium dark:bg-amber-500/15 dark:text-amber-300">
|
||
超级管理员
|
||
</span>
|
||
)}
|
||
{user.role === 'ADMIN' && !user.isSuperAdmin && (
|
||
<span className="inline-flex items-center rounded-full bg-indigo-100 text-indigo-700 px-1.5 py-0.5 text-[10px] font-medium dark:bg-indigo-500/15 dark:text-indigo-300">
|
||
管理员
|
||
</span>
|
||
)}
|
||
</div>
|
||
{subLabel && (
|
||
<div className="mt-0.5 text-[11px] text-slate-500 dark:text-slate-400 truncate max-w-[220px]">
|
||
{subLabel}
|
||
</div>
|
||
)}
|
||
{dept && (
|
||
<div className="mt-0.5 text-[11px] text-slate-400 dark:text-slate-500 truncate max-w-[220px]">
|
||
部门:{dept}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{isSelected && (
|
||
<span className="ml-3 inline-flex h-5 w-5 items-center justify-center rounded-full bg-indigo-500 text-white text-xs">
|
||
✓
|
||
</span>
|
||
)}
|
||
</button>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="px-5 pb-4 pt-3 border-t border-slate-200/70 dark:border-slate-700/80 flex items-center justify-between">
|
||
<div className="text-[11px] text-slate-400 dark:text-slate-500">
|
||
当前已选中素材:<span className="font-semibold">{selectedIds.size}</span> 条
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setIsUserSelectOpen(false)
|
||
setSelectedTargetUser(null)
|
||
}}
|
||
className={cn(
|
||
styles.buttonStyles.secondary,
|
||
styles.buttonModifiers.compactXs,
|
||
'min-w-[80px]',
|
||
)}
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!selectedTargetUser}
|
||
onClick={async () => {
|
||
if (!selectedTargetUser) return
|
||
const ids = Array.from(selectedIds)
|
||
if (!ids.length) {
|
||
showToast('请先勾选要移交的素材', 'error')
|
||
return
|
||
}
|
||
|
||
const confirmed = await confirm({
|
||
title: '确认移交',
|
||
message: `确定要将选中的 ${ids.length} 条素材的归属移交给用户「${
|
||
selectedTargetUser.username || selectedTargetUser.email || selectedTargetUser.id
|
||
}」吗?`,
|
||
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,
|
||
targetUserId: selectedTargetUser.id,
|
||
}),
|
||
})
|
||
|
||
const data = await response.json().catch(() => ({}))
|
||
if (!response.ok) {
|
||
showToast(data.error || '移交失败', 'error')
|
||
return
|
||
}
|
||
|
||
showToast('已将选中素材移交给指定用户', 'success')
|
||
setIsUserSelectOpen(false)
|
||
setSelectedTargetUser(null)
|
||
setSelectedIds(new Set())
|
||
fetchMaterials(1, false)
|
||
} catch (error) {
|
||
console.error('Bulk transfer to user error:', error)
|
||
showToast('移交失败,请稍后重试', 'error')
|
||
}
|
||
}}
|
||
className={cn(
|
||
styles.buttonStyles.primary,
|
||
styles.buttonModifiers.compactXs,
|
||
'min-w-[100px] disabled:opacity-50 disabled:cursor-not-allowed',
|
||
)}
|
||
>
|
||
确认移交
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)
|
||
}
|
||
|