1081 lines
42 KiB
TypeScript
1081 lines
42 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState, useCallback } from 'react'
|
|
import { useSession } from 'next-auth/react'
|
|
import { useRouter } from 'next/navigation'
|
|
import Header from '@/components/Header'
|
|
import EditMaterialModal from '@/components/EditMaterialModal'
|
|
import { format } from 'date-fns'
|
|
|
|
interface User {
|
|
id: string
|
|
email: string
|
|
username: string
|
|
role: string
|
|
isActive: boolean
|
|
canDownload: boolean
|
|
createdAt: string
|
|
_count: {
|
|
materials: number
|
|
}
|
|
}
|
|
|
|
interface Material {
|
|
id: string
|
|
previewUrl: string
|
|
productModel: string
|
|
categoryName: string
|
|
sceneName: string
|
|
renderer: string
|
|
authorName: string
|
|
createdAt: string
|
|
user: {
|
|
username: string
|
|
}
|
|
}
|
|
|
|
interface Category {
|
|
id: string
|
|
name: string
|
|
count: number
|
|
}
|
|
|
|
interface Scene {
|
|
id: string
|
|
name: string
|
|
count: number
|
|
}
|
|
|
|
export default function AdminPage() {
|
|
const { data: session, status } = useSession()
|
|
const router = useRouter()
|
|
const [activeTab, setActiveTab] = useState<'materials' | 'users' | 'categories'>('materials')
|
|
const [materials, setMaterials] = useState<Material[]>([])
|
|
const [users, setUsers] = useState<User[]>([])
|
|
const [categories, setCategories] = useState<Category[]>([])
|
|
const [scenes, setScenes] = useState<Scene[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [editingMaterial, setEditingMaterial] = useState<Material | null>(null)
|
|
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
|
|
const [editingScene, setEditingScene] = useState<Scene | null>(null)
|
|
const [showAddCategoryModal, setShowAddCategoryModal] = useState(false)
|
|
const [showAddSceneModal, setShowAddSceneModal] = useState(false)
|
|
const [newCategoryName, setNewCategoryName] = useState('')
|
|
const [newSceneName, setNewSceneName] = useState('')
|
|
const [toast, setToast] = useState<{message: string, type: 'success' | 'error'} | null>(null)
|
|
|
|
// Toast自动隐藏
|
|
useEffect(() => {
|
|
if (toast) {
|
|
const timer = setTimeout(() => setToast(null), 3000)
|
|
return () => clearTimeout(timer)
|
|
}
|
|
}, [toast])
|
|
|
|
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
|
|
setToast({ message, type })
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (status === 'unauthenticated') {
|
|
router.push('/login')
|
|
return
|
|
}
|
|
|
|
if (status === 'authenticated' && (session.user as any)?.role !== 'ADMIN') {
|
|
router.push('/')
|
|
return
|
|
}
|
|
}, [status, session, router])
|
|
|
|
useEffect(() => {
|
|
if (status === 'authenticated') {
|
|
fetchData()
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [status, activeTab])
|
|
|
|
const fetchData = async () => {
|
|
setLoading(true)
|
|
try {
|
|
if (activeTab === 'materials') {
|
|
const response = await fetch('/api/materials?pageSize=1000')
|
|
if (response.ok) {
|
|
const data = await response.json()
|
|
// 处理新的分页API响应格式
|
|
setMaterials(data.materials || data)
|
|
}
|
|
} else if (activeTab === 'users') {
|
|
const response = await fetch('/api/admin/users')
|
|
if (response.ok) {
|
|
const data = await response.json()
|
|
setUsers(data)
|
|
}
|
|
} else if (activeTab === 'categories') {
|
|
const [categoriesRes, scenesRes] = await Promise.all([
|
|
fetch('/api/categories'),
|
|
fetch('/api/scenes')
|
|
])
|
|
if (categoriesRes.ok) {
|
|
const data = await categoriesRes.json()
|
|
setCategories(data)
|
|
}
|
|
if (scenesRes.ok) {
|
|
const data = await scenesRes.json()
|
|
setScenes(data)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching data:', error)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleDeleteMaterial = async (id: string) => {
|
|
if (!confirm('确定要删除这个素材吗?')) 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 handleDeleteUser = async (id: string) => {
|
|
if (!confirm('确定要删除这个用户吗?该用户的所有素材也会被删除。')) return
|
|
|
|
try {
|
|
const response = await fetch(`/api/admin/users?id=${id}`, {
|
|
method: 'DELETE',
|
|
})
|
|
|
|
if (response.ok) {
|
|
// 直接从本地状态移除
|
|
setUsers(prevUsers => prevUsers.filter(u => u.id !== id))
|
|
showToast('删除成功')
|
|
} else {
|
|
const data = await response.json()
|
|
showToast(data.error || '删除失败', 'error')
|
|
}
|
|
} catch (error) {
|
|
console.error('Error deleting user:', error)
|
|
showToast('删除失败', 'error')
|
|
}
|
|
}
|
|
|
|
const handleToggleDownload = async (userId: string, currentCanDownload: boolean) => {
|
|
const action = currentCanDownload ? '关闭' : '开启'
|
|
if (!confirm(`确定要${action}该用户的下载权限吗?`)) return
|
|
|
|
try {
|
|
const response = await fetch('/api/admin/users/toggle-download', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ userId }),
|
|
})
|
|
|
|
if (response.ok) {
|
|
// 直接更新本地状态,避免重新加载整个列表
|
|
setUsers(prevUsers =>
|
|
prevUsers.map(user =>
|
|
user.id === userId
|
|
? { ...user, canDownload: !currentCanDownload }
|
|
: user
|
|
)
|
|
)
|
|
showToast(`下载权限已${action}`)
|
|
} else {
|
|
const data = await response.json()
|
|
showToast(data.error || '操作失败', 'error')
|
|
}
|
|
} catch (error) {
|
|
console.error('Error toggling download permission:', error)
|
|
showToast('操作失败', 'error')
|
|
}
|
|
}
|
|
|
|
const handleToggleActive = async (userId: string, currentStatus: boolean) => {
|
|
const action = currentStatus ? '禁用' : '激活'
|
|
if (!confirm(`确定要${action}这个用户吗?`)) return
|
|
|
|
try {
|
|
const response = await fetch('/api/admin/users/toggle-active', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
userId,
|
|
isActive: !currentStatus,
|
|
}),
|
|
})
|
|
|
|
if (response.ok) {
|
|
// 直接更新本地状态
|
|
setUsers(prevUsers =>
|
|
prevUsers.map(user =>
|
|
user.id === userId
|
|
? { ...user, isActive: !currentStatus }
|
|
: user
|
|
)
|
|
)
|
|
showToast(`${action}成功`)
|
|
} else {
|
|
const data = await response.json()
|
|
showToast(data.error || `${action}失败`, 'error')
|
|
}
|
|
} catch (error) {
|
|
console.error('Toggle active error:', error)
|
|
showToast(`${action}失败`, 'error')
|
|
}
|
|
}
|
|
|
|
const handleUpdateCategory = async (id: string, newName: string) => {
|
|
if (!newName.trim()) {
|
|
showToast('分类名称不能为空', 'error')
|
|
return
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/categories', {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ id, name: newName.trim() }),
|
|
})
|
|
|
|
if (response.ok) {
|
|
// 直接更新本地状态
|
|
setCategories(prevCategories =>
|
|
prevCategories.map(cat =>
|
|
cat.id === id ? { ...cat, name: newName.trim() } : cat
|
|
)
|
|
)
|
|
setEditingCategory(null)
|
|
showToast('修改成功')
|
|
} else {
|
|
const data = await response.json()
|
|
showToast(data.error || '修改失败', 'error')
|
|
}
|
|
} catch (error) {
|
|
console.error('Error updating category:', error)
|
|
showToast('修改失败', 'error')
|
|
}
|
|
}
|
|
|
|
const handleUpdateScene = async (id: string, newName: string) => {
|
|
if (!newName.trim()) {
|
|
showToast('场景名称不能为空', 'error')
|
|
return
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/scenes', {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ id, name: newName.trim() }),
|
|
})
|
|
|
|
if (response.ok) {
|
|
// 直接更新本地状态
|
|
setScenes(prevScenes =>
|
|
prevScenes.map(scene =>
|
|
scene.id === id ? { ...scene, name: newName.trim() } : scene
|
|
)
|
|
)
|
|
setEditingScene(null)
|
|
showToast('修改成功')
|
|
} else {
|
|
const data = await response.json()
|
|
showToast(data.error || '修改失败', 'error')
|
|
}
|
|
} catch (error) {
|
|
console.error('Error updating scene:', error)
|
|
showToast('修改失败', 'error')
|
|
}
|
|
}
|
|
|
|
const handleDeleteCategory = async (id: string) => {
|
|
if (!confirm('确定要删除这个使用店铺分类吗?')) return
|
|
|
|
try {
|
|
const response = await fetch(`/api/categories?id=${id}`, {
|
|
method: 'DELETE',
|
|
})
|
|
|
|
if (response.ok) {
|
|
// 直接从本地状态移除
|
|
setCategories(prevCategories => prevCategories.filter(cat => cat.id !== id))
|
|
showToast('删除成功')
|
|
} else {
|
|
const data = await response.json()
|
|
showToast(data.error || '删除失败', 'error')
|
|
}
|
|
} catch (error) {
|
|
console.error('Error deleting category:', error)
|
|
showToast('删除失败', 'error')
|
|
}
|
|
}
|
|
|
|
const handleDeleteScene = async (id: string) => {
|
|
if (!confirm('确定要删除这个场景类别吗?')) return
|
|
|
|
try {
|
|
const response = await fetch(`/api/scenes?id=${id}`, {
|
|
method: 'DELETE',
|
|
})
|
|
|
|
if (response.ok) {
|
|
// 直接从本地状态移除
|
|
setScenes(prevScenes => prevScenes.filter(scene => scene.id !== id))
|
|
showToast('删除成功')
|
|
} else {
|
|
const data = await response.json()
|
|
showToast(data.error || '删除失败', 'error')
|
|
}
|
|
} catch (error) {
|
|
console.error('Error deleting scene:', error)
|
|
showToast('删除失败', 'error')
|
|
}
|
|
}
|
|
|
|
const handleSetRole = async (userId: string, currentRole: string) => {
|
|
const newRole = currentRole === 'ADMIN' ? 'USER' : 'ADMIN'
|
|
const action = newRole === 'ADMIN' ? '设置为管理员' : '取消管理员权限'
|
|
|
|
if (!confirm(`确定要${action}吗?`)) return
|
|
|
|
try {
|
|
const response = await fetch('/api/admin/users/set-role', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
userId,
|
|
role: newRole,
|
|
}),
|
|
})
|
|
|
|
if (response.ok) {
|
|
// 直接更新本地状态
|
|
setUsers(prevUsers =>
|
|
prevUsers.map(user =>
|
|
user.id === userId
|
|
? { ...user, role: newRole }
|
|
: user
|
|
)
|
|
)
|
|
showToast(`${action}成功`)
|
|
} else {
|
|
const data = await response.json()
|
|
showToast(data.error || `${action}失败`, 'error')
|
|
}
|
|
} catch (error) {
|
|
console.error('Set role error:', error)
|
|
showToast(`${action}失败`, 'error')
|
|
}
|
|
}
|
|
|
|
const handleAddCategory = async () => {
|
|
if (!newCategoryName.trim()) {
|
|
showToast('分类名称不能为空', 'error')
|
|
return
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/categories', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ name: newCategoryName.trim() }),
|
|
})
|
|
|
|
if (response.ok) {
|
|
const data = await response.json()
|
|
// 添加到本地状态
|
|
setCategories(prevCategories => [...prevCategories, { id: data.id, name: newCategoryName.trim(), count: 0 }])
|
|
setShowAddCategoryModal(false)
|
|
setNewCategoryName('')
|
|
showToast('添加成功')
|
|
} else {
|
|
const data = await response.json()
|
|
showToast(data.error || '添加失败', 'error')
|
|
}
|
|
} catch (error) {
|
|
console.error('Error adding category:', error)
|
|
showToast('添加失败', 'error')
|
|
}
|
|
}
|
|
|
|
const handleAddScene = async () => {
|
|
if (!newSceneName.trim()) {
|
|
showToast('场景名称不能为空', 'error')
|
|
return
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/scenes', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ name: newSceneName.trim() }),
|
|
})
|
|
|
|
if (response.ok) {
|
|
const data = await response.json()
|
|
// 添加到本地状态
|
|
setScenes(prevScenes => [...prevScenes, { id: data.id, name: newSceneName.trim(), count: 0 }])
|
|
setShowAddSceneModal(false)
|
|
setNewSceneName('')
|
|
showToast('添加成功')
|
|
} else {
|
|
const data = await response.json()
|
|
showToast(data.error || '添加失败', 'error')
|
|
}
|
|
} catch (error) {
|
|
console.error('Error adding scene:', error)
|
|
showToast('添加失败', 'error')
|
|
}
|
|
}
|
|
|
|
if (status === 'loading' || loading) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center">
|
|
<div className="text-xl text-gray-600">加载中...</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!session || (session.user as any)?.role !== 'ADMIN') {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50">
|
|
<Header session={session} />
|
|
|
|
{/* Toast通知 */}
|
|
{toast && (
|
|
<div className={`fixed top-20 right-4 z-50 px-6 py-3 rounded-lg shadow-lg ${
|
|
toast.type === 'success'
|
|
? 'bg-green-500 text-white'
|
|
: 'bg-red-500 text-white'
|
|
} animate-fade-in`}>
|
|
{toast.message}
|
|
</div>
|
|
)}
|
|
|
|
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
|
<h1 className="text-3xl font-bold text-gray-900 mb-8">系统设置</h1>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex space-x-4 border-b border-gray-200 mb-8">
|
|
<button
|
|
type="button"
|
|
onClick={() => setActiveTab('materials')}
|
|
className={`pb-4 px-4 font-medium transition-colors ${
|
|
activeTab === 'materials'
|
|
? 'text-gray-900 border-b-2 border-gray-900'
|
|
: 'text-gray-600 hover:text-gray-900'
|
|
}`}
|
|
>
|
|
素材管理
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setActiveTab('users')}
|
|
className={`pb-4 px-4 font-medium transition-colors ${
|
|
activeTab === 'users'
|
|
? 'text-gray-900 border-b-2 border-gray-900'
|
|
: 'text-gray-600 hover:text-gray-900'
|
|
}`}
|
|
>
|
|
用户管理
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setActiveTab('categories')}
|
|
className={`pb-4 px-4 font-medium transition-colors ${
|
|
activeTab === 'categories'
|
|
? 'text-gray-900 border-b-2 border-gray-900'
|
|
: 'text-gray-600 hover:text-gray-900'
|
|
}`}
|
|
>
|
|
分类管理
|
|
</button>
|
|
</div>
|
|
|
|
{/* Materials Table */}
|
|
{activeTab === 'materials' && (
|
|
<div className="card-apple overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50 border-b border-gray-200">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
预览图
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
商品型号
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
场景名称
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
作者
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
上传时间
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
操作
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{materials.map((material) => (
|
|
<tr key={material.id}>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<img
|
|
src={material.previewUrl}
|
|
alt={material.sceneName}
|
|
className="w-16 h-16 object-cover rounded-lg"
|
|
/>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm font-medium text-gray-900">
|
|
{material.productModel}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm text-gray-900">
|
|
{material.sceneName}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm text-gray-900">
|
|
{material.authorName}
|
|
</div>
|
|
<div className="text-xs text-gray-500">
|
|
@{material.user.username}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
{format(new Date(material.createdAt), 'yyyy-MM-dd HH:mm')}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
|
<div className="flex items-center space-x-3">
|
|
<button
|
|
onClick={() => setEditingMaterial(material)}
|
|
className="text-gray-700 hover:text-gray-900 font-medium"
|
|
>
|
|
编辑
|
|
</button>
|
|
<button
|
|
onClick={() => handleDeleteMaterial(material.id)}
|
|
className="text-gray-700 hover:text-gray-900 font-medium"
|
|
>
|
|
删除
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{materials.length === 0 && (
|
|
<div className="text-center py-8 text-gray-500">
|
|
暂无素材
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Users Table */}
|
|
{activeTab === 'users' && (
|
|
<div className="card-apple overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50 border-b border-gray-200">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
用户名
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
邮箱
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
角色
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
状态
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
下载权限
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
素材数量
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
注册时间
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
操作
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{users.map((user) => (
|
|
<tr key={user.id}>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm font-medium text-gray-900">
|
|
{user.username}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm text-gray-900">{user.email}</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<span
|
|
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
|
user.role === 'ADMIN'
|
|
? 'bg-purple-100 text-purple-800'
|
|
: 'bg-green-100 text-green-800'
|
|
}`}
|
|
>
|
|
{user.role === 'ADMIN' ? '管理员' : '用户'}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<span
|
|
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
|
user.isActive
|
|
? 'bg-green-100 text-green-800'
|
|
: 'bg-red-100 text-red-800'
|
|
}`}
|
|
>
|
|
{user.isActive ? '已激活' : '未激活'}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<span
|
|
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
|
user.canDownload
|
|
? 'bg-blue-100 text-blue-800'
|
|
: 'bg-gray-100 text-gray-800'
|
|
}`}
|
|
>
|
|
{user.canDownload ? '允许下载' : '禁止下载'}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
{user._count.materials}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
{format(new Date(user.createdAt), 'yyyy-MM-dd')}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
|
<div className="flex items-center space-x-3">
|
|
{user.email !== session?.user?.email && (
|
|
<>
|
|
<button
|
|
onClick={() => handleSetRole(user.id, user.role)}
|
|
className={`font-medium ${
|
|
user.role === 'ADMIN'
|
|
? 'text-orange-600 hover:text-orange-800 dark:text-orange-400'
|
|
: 'text-purple-600 hover:text-purple-800 dark:text-purple-400'
|
|
}`}
|
|
>
|
|
{user.role === 'ADMIN' ? '取消管理员' : '设为管理员'}
|
|
</button>
|
|
<button
|
|
onClick={() => handleToggleActive(user.id, user.isActive)}
|
|
className="text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100 font-medium"
|
|
>
|
|
{user.isActive ? '禁用' : '激活'}
|
|
</button>
|
|
{user.role !== 'ADMIN' && (
|
|
<button
|
|
onClick={() => handleToggleDownload(user.id, user.canDownload)}
|
|
className={`font-medium ${
|
|
user.canDownload
|
|
? 'text-yellow-600 hover:text-yellow-800 dark:text-yellow-400'
|
|
: 'text-blue-600 hover:text-blue-800 dark:text-blue-400'
|
|
}`}
|
|
>
|
|
{user.canDownload ? '关闭下载' : '开启下载'}
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => handleDeleteUser(user.id)}
|
|
className="text-red-600 hover:text-red-800 dark:text-red-400 font-medium"
|
|
>
|
|
删除
|
|
</button>
|
|
</>
|
|
)}
|
|
{user.email === session?.user?.email && (
|
|
<span className="text-xs text-gray-500 dark:text-gray-400 italic">
|
|
当前账号
|
|
</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{users.length === 0 && (
|
|
<div className="text-center py-8 text-gray-500">
|
|
暂无用户
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Categories Management */}
|
|
{activeTab === 'categories' && (
|
|
<div className="space-y-8">
|
|
{/* 使用店铺分类 */}
|
|
<div className="card-apple">
|
|
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">使用店铺分类</h2>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">管理所有使用店铺分类名称</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowAddCategoryModal(true)}
|
|
className="px-4 py-2 bg-gray-900 dark:bg-gray-700 text-white text-sm rounded-lg hover:bg-gray-800 dark:hover:bg-gray-600 transition-colors flex items-center gap-2"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
新增店铺
|
|
</button>
|
|
</div>
|
|
<div className="p-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{categories.map((category) => (
|
|
<div
|
|
key={category.id}
|
|
className="border border-gray-200 dark:border-gray-600 rounded-lg p-4 hover:shadow-md transition-shadow"
|
|
>
|
|
{editingCategory?.id === category.id ? (
|
|
<div className="space-y-3">
|
|
<input
|
|
type="text"
|
|
defaultValue={category.name}
|
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-gray-400 outline-none"
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
handleUpdateCategory(category.id, e.currentTarget.value)
|
|
} else if (e.key === 'Escape') {
|
|
setEditingCategory(null)
|
|
}
|
|
}}
|
|
autoFocus
|
|
/>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={(e) => {
|
|
const input = e.currentTarget.parentElement?.previousElementSibling as HTMLInputElement
|
|
handleUpdateCategory(category.id, input.value)
|
|
}}
|
|
className="flex-1 px-3 py-1.5 bg-gray-900 text-white text-sm rounded-lg hover:bg-gray-800 transition-colors"
|
|
>
|
|
保存
|
|
</button>
|
|
<button
|
|
onClick={() => setEditingCategory(null)}
|
|
className="flex-1 px-3 py-1.5 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors"
|
|
>
|
|
取消
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="font-medium text-gray-900 dark:text-white">
|
|
{category.name}
|
|
</h3>
|
|
<span className="text-xs text-gray-500 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded-full">
|
|
{category.count} 个素材
|
|
</span>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => setEditingCategory(category)}
|
|
className="flex-1 px-3 py-1.5 text-gray-700 dark:text-gray-300 text-sm border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
|
>
|
|
编辑
|
|
</button>
|
|
<button
|
|
onClick={() => handleDeleteCategory(category.id)}
|
|
className="flex-1 px-3 py-1.5 text-red-600 dark:text-red-400 text-sm border border-red-300 dark:border-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
|
>
|
|
删除
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
{categories.length === 0 && (
|
|
<div className="text-center py-8 text-gray-500">
|
|
暂无使用店铺分类
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 场景类别 */}
|
|
<div className="card-apple">
|
|
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">场景类别</h2>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">管理所有场景类别名称</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowAddSceneModal(true)}
|
|
className="px-4 py-2 bg-gray-900 dark:bg-gray-700 text-white text-sm rounded-lg hover:bg-gray-800 dark:hover:bg-gray-600 transition-colors flex items-center gap-2"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
新增场景
|
|
</button>
|
|
</div>
|
|
<div className="p-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{scenes.map((scene) => (
|
|
<div
|
|
key={scene.id}
|
|
className="border border-gray-200 dark:border-gray-600 rounded-lg p-4 hover:shadow-md transition-shadow"
|
|
>
|
|
{editingScene?.id === scene.id ? (
|
|
<div className="space-y-3">
|
|
<input
|
|
type="text"
|
|
defaultValue={scene.name}
|
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-gray-400 outline-none"
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
handleUpdateScene(scene.id, e.currentTarget.value)
|
|
} else if (e.key === 'Escape') {
|
|
setEditingScene(null)
|
|
}
|
|
}}
|
|
autoFocus
|
|
/>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={(e) => {
|
|
const input = e.currentTarget.parentElement?.previousElementSibling as HTMLInputElement
|
|
handleUpdateScene(scene.id, input.value)
|
|
}}
|
|
className="flex-1 px-3 py-1.5 bg-gray-900 text-white text-sm rounded-lg hover:bg-gray-800 transition-colors"
|
|
>
|
|
保存
|
|
</button>
|
|
<button
|
|
onClick={() => setEditingScene(null)}
|
|
className="flex-1 px-3 py-1.5 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors"
|
|
>
|
|
取消
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="font-medium text-gray-900 dark:text-white">
|
|
{scene.name}
|
|
</h3>
|
|
<span className="text-xs text-gray-500 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded-full">
|
|
{scene.count} 个素材
|
|
</span>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => setEditingScene(scene)}
|
|
className="flex-1 px-3 py-1.5 text-gray-700 dark:text-gray-300 text-sm border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
|
>
|
|
编辑
|
|
</button>
|
|
<button
|
|
onClick={() => handleDeleteScene(scene.id)}
|
|
className="flex-1 px-3 py-1.5 text-red-600 dark:text-red-400 text-sm border border-red-300 dark:border-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
|
>
|
|
删除
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
{scenes.length === 0 && (
|
|
<div className="text-center py-8 text-gray-500">
|
|
暂无场景类别
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</main>
|
|
|
|
{/* Edit Material Modal */}
|
|
{editingMaterial && (
|
|
<EditMaterialModal
|
|
material={editingMaterial}
|
|
onClose={() => setEditingMaterial(null)}
|
|
onSuccess={async () => {
|
|
setEditingMaterial(null)
|
|
// 只重新获取素材列表,不刷新其他数据
|
|
if (activeTab === 'materials') {
|
|
try {
|
|
const response = await fetch('/api/materials?pageSize=1000')
|
|
if (response.ok) {
|
|
const data = await response.json()
|
|
setMaterials(data.materials || data)
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching materials:', error)
|
|
}
|
|
}
|
|
showToast('修改成功')
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Add Category Modal */}
|
|
{showAddCategoryModal && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md p-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
|
新增使用店铺分类
|
|
</h3>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
分类名称
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={newCategoryName}
|
|
onChange={(e) => setNewCategoryName(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
handleAddCategory()
|
|
} else if (e.key === 'Escape') {
|
|
setShowAddCategoryModal(false)
|
|
setNewCategoryName('')
|
|
}
|
|
}}
|
|
placeholder="请输入店铺名称"
|
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg focus:ring-2 focus:ring-gray-400 outline-none"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<div className="flex gap-3 justify-end">
|
|
<button
|
|
onClick={() => {
|
|
setShowAddCategoryModal(false)
|
|
setNewCategoryName('')
|
|
}}
|
|
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
|
>
|
|
取消
|
|
</button>
|
|
<button
|
|
onClick={handleAddCategory}
|
|
className="px-4 py-2 bg-gray-900 dark:bg-gray-700 text-white rounded-lg hover:bg-gray-800 dark:hover:bg-gray-600 transition-colors"
|
|
>
|
|
确定添加
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Add Scene Modal */}
|
|
{showAddSceneModal && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md p-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
|
新增场景类别
|
|
</h3>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
类别名称
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={newSceneName}
|
|
onChange={(e) => setNewSceneName(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
handleAddScene()
|
|
} else if (e.key === 'Escape') {
|
|
setShowAddSceneModal(false)
|
|
setNewSceneName('')
|
|
}
|
|
}}
|
|
placeholder="请输入场景类别名称"
|
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg focus:ring-2 focus:ring-gray-400 outline-none"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<div className="flex gap-3 justify-end">
|
|
<button
|
|
onClick={() => {
|
|
setShowAddSceneModal(false)
|
|
setNewSceneName('')
|
|
}}
|
|
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
|
>
|
|
取消
|
|
</button>
|
|
<button
|
|
onClick={handleAddScene}
|
|
className="px-4 py-2 bg-gray-900 dark:bg-gray-700 text-white rounded-lg hover:bg-gray-800 dark:hover:bg-gray-600 transition-colors"
|
|
>
|
|
确定添加
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|