flnxi-Wu/app/admin/_components/MaterialsTab.tsx
WuYongJie d1c6efc79d
Some checks failed
CI Check / check (push) Has been cancelled
feat: NAS 部署与 Harbor 推送流程
2025-12-03 14:39:06 +08:00

891 lines
35 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, 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>
)}
</>
)
}