flnxi-Wu/app/admin/page.tsx
WuYongJie 5f2ba48532
Some checks failed
CI Check / check (push) Has been cancelled
优化模板管理器拖拽交互体验
- 移除高亮卡片效果,避免与虚位卡片视觉重复
- 松开鼠标后立即更新布局,不再等待动画完成
- 优化拖拽动画效果,使用弹性缓动函数
- 更新 CHANGELOG.md 记录本次优化
2025-11-20 11:24:25 +08:00

3802 lines
170 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

'use client'
import { useEffect, useState, useCallback, useMemo } 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'
import TemplateManager from './_components/TemplateManager'
import {
buildDepartmentDescendants,
getDepartmentCollator,
isDepartmentWithinScope,
} from '@/lib/departments'
interface User {
id: string
email: string
username: string
department: string
parentId: string | null
role: string
isActive: boolean
canDownload: boolean
managedDepartment?: string
isSuperAdmin?: boolean
createdAt: string
_count: {
materials: number
}
}
interface Material {
id: string
previewUrl: string
productModel: string
brandName?: string | null
seriesName?: string | null
skuCode?: string | null
materialTexture?: string | null
categoryName: string
sceneName: string
materialType: string
renderer: string
renderTime?: string | null
imageResolution?: string | null
colorMode?: string | null
authorName: string
createdAt: string
user: {
username: string
}
}
interface Category {
id: string
name: string
count: number
options?: string | null
}
interface Scene {
id: string
name: string
count: number
options?: string | null
}
interface Platform {
id: string
name: string
count: number
options?: string | null
}
export default function AdminPage() {
const { data: session, status } = useSession()
const sessionUser = session?.user as any
const router = useRouter()
const [activeTab, setActiveTab] = useState<'materials' | 'users' | 'categories' | 'templates' | 'departments' | 'invitations' | 'monitor'>('monitor')
// 监控仪表板状态
const [dashboardData, setDashboardData] = useState<any>(null)
const [dashboardLoading, setDashboardLoading] = useState(false)
const [dashboardError, setDashboardError] = useState<string | null>(null)
const [materials, setMaterials] = useState<Material[]>([])
const [users, setUsers] = useState<User[]>([])
const [categories, setCategories] = useState<Category[]>([])
const [scenes, setScenes] = useState<Scene[]>([])
const [platforms, setPlatforms] = useState<Platform[]>([])
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 [editingPlatform, setEditingPlatform] = useState<Platform | null>(null)
const [showAddCategoryModal, setShowAddCategoryModal] = useState(false)
const [showAddSceneModal, setShowAddSceneModal] = useState(false)
const [showAddPlatformModal, setShowAddPlatformModal] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
const [newSceneName, setNewSceneName] = useState('')
const [newPlatformName, setNewPlatformName] = useState('')
const [toast, setToast] = useState<{message: string, type: 'success' | 'error'} | null>(null)
// 编辑用户(整合所有操作选项)
const [editingUser, setEditingUser] = useState<User | null>(null)
const [departments, setDepartments] = useState<Array<{id: string, name: string}>>([])
const [selectedDepartment, setSelectedDepartment] = useState('')
const [selectedParentId, setSelectedParentId] = useState('')
const [editingUsername, setEditingUsername] = useState('')
// 部门管理
const [addingDepartment, setAddingDepartment] = useState(false)
const [renamingDepartmentId, setRenamingDepartmentId] = useState<string | null>(null)
const [renamingDepartmentName, setRenamingDepartmentName] = useState('')
// 部门搜索(架构管理)
const [departmentSearchTerm, setDepartmentSearchTerm] = useState('')
// 用户搜索
const [userSearchTerm, setUserSearchTerm] = useState('')
// 素材搜索
const [materialSearchTerm, setMaterialSearchTerm] = useState('')
// 部门折叠状态
const [collapsedNodeIds, setCollapsedNodeIds] = useState<Set<string>>(new Set())
const [collapsedDepartments, setCollapsedDepartments] = useState<Set<string>>(new Set())
const departmentCollator = useMemo(() => getDepartmentCollator(), [])
// 邀请管理
interface Invitation {
id: string
code: string
createdBy: string
usedBy: string | null
usedAt: string | null
expiresAt: string | null
maxUses: number
currentUses: number
isActive: boolean
createdAt: string
inviteUrl?: string
}
const [invitations, setInvitations] = useState<Invitation[]>([])
const [showCreateInvitationModal, setShowCreateInvitationModal] = useState(false)
const [newInvitationMaxUses, setNewInvitationMaxUses] = useState(1)
const [newInvitationExpiresInDays, setNewInvitationExpiresInDays] = useState<number | null>(null)
const userLookup = useMemo(() => {
const map = new Map<string, User>()
users.forEach((user) => map.set(user.id, user))
return map
}, [users])
// Toast自动隐藏
useEffect(() => {
if (toast) {
const timer = setTimeout(() => setToast(null), 3000)
return () => clearTimeout(timer)
}
}, [toast])
const showToast = useCallback((message: string, type: 'success' | 'error' = 'success') => {
setToast({ message, type })
}, [])
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/login')
return
}
const isSuperAdmin = (session?.user as any)?.isSuperAdmin
if (status === 'authenticated' && (session.user as any)?.role !== 'ADMIN' && !isSuperAdmin) {
router.push('/')
return
}
}, [status, session, router])
const loadUsersData = useCallback(async () => {
try {
// 加载所有用户,避免分页导致当前登录用户不在列表中
const response = await fetch('/api/admin/users?pageSize=1000')
if (response.ok) {
const data = await response.json()
setUsers(data.users || [])
} else {
const errorData = await response.json().catch(() => ({}))
console.error('Error fetching users:', errorData)
showToast(errorData.error || '获取用户列表失败', 'error')
}
} catch (error) {
console.error('Error fetching users:', error)
showToast('获取用户列表失败', 'error')
}
}, [showToast])
const fetchDepartments = useCallback(async () => {
try {
const response = await fetch('/api/departments')
if (response.ok) {
const data = await response.json()
const sorted = Array.isArray(data)
? [...data].sort((a, b) => departmentCollator.compare(a.name, b.name))
: data
setDepartments(sorted)
}
} catch (error) {
console.error('Error fetching departments:', error)
}
}, [departmentCollator])
const fetchInvitations = useCallback(async () => {
try {
const response = await fetch('/api/invitations')
if (response.ok) {
const data = await response.json()
// API返回格式: { invitations: [...], pagination: {...} }
setInvitations(data.invitations || [])
} else {
const errorData = await response.json().catch(() => ({}))
console.error('Error fetching invitations:', errorData)
showToast(errorData.error || '获取邀请链接失败', 'error')
}
} catch (error) {
console.error('Error fetching invitations:', error)
showToast('获取邀请链接失败,请稍后重试', 'error')
}
}, [showToast])
const fetchData = useCallback(async () => {
if (activeTab === 'templates') {
setLoading(false)
return
}
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') {
await loadUsersData()
// 加载部门列表
fetchDepartments()
} else if (activeTab === 'monitor') {
setDashboardLoading(true)
setDashboardError(null)
try {
const response = await fetch('/api/admin/system-status', {
credentials: 'include'
})
if (response.ok) {
const data = await response.json()
setDashboardData(data)
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
} catch (err) {
console.error('Failed to fetch dashboard data:', err)
setDashboardError(err instanceof Error ? err.message : '获取数据失败')
} finally {
setDashboardLoading(false)
setLoading(false)
}
return
} else if (activeTab === 'categories') {
const [categoriesRes, scenesRes, platformsRes] = await Promise.all([
fetch('/api/categories'),
fetch('/api/scenes'),
fetch('/api/platforms')
])
if (categoriesRes.ok) {
const data = await categoriesRes.json()
setCategories(data)
}
if (scenesRes.ok) {
const data = await scenesRes.json()
setScenes(data)
}
if (platformsRes.ok) {
const data = await platformsRes.json()
setPlatforms(data)
}
} else if (activeTab === 'departments') {
await fetchDepartments()
} else if (activeTab === 'invitations') {
fetchInvitations()
}
} catch (error) {
console.error('Error fetching data:', error)
} finally {
setLoading(false)
}
}, [activeTab, fetchDepartments, fetchInvitations, loadUsersData])
useEffect(() => {
if (status === 'authenticated') {
fetchData()
}
}, [status, fetchData])
const handleStartRenameDepartment = (deptId: string, currentName: string) => {
setRenamingDepartmentId(deptId)
setRenamingDepartmentName(currentName)
}
const handleRenameDepartment = async () => {
if (!renamingDepartmentId) return
const trimmed = renamingDepartmentName.trim()
if (!trimmed) {
showToast('部门名称不能为空', 'error')
return
}
try {
const response = await fetch('/api/departments', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ id: renamingDepartmentId, name: trimmed }),
})
if (response.ok) {
await fetchDepartments()
showToast('部门重命名成功')
setRenamingDepartmentId(null)
setRenamingDepartmentName('')
} else {
const data = await response.json().catch(() => ({ error: '重命名失败' }))
showToast(data.error || '重命名失败', 'error')
}
} catch (error) {
console.error('Error renaming department:', error)
showToast('重命名失败', 'error')
}
}
const handlePrepareAddSubDepartment = async (parentName: string) => {
const input = window.prompt(
`为「${parentName}」添加子分支\n\n建议直接输入子分支名,例如:短视频、直播组等`,
`${parentName}-`
)
if (!input) return
const name = input.trim()
if (!name) {
showToast('子分支名称不能为空', 'error')
return
}
const finalName = name.startsWith(parentName) ? name : `${parentName}-${name}`
await createDepartment(finalName)
}
const handleCreateInvitation = async () => {
try {
const response = await fetch('/api/invitations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
maxUses: newInvitationMaxUses,
expiresInDays: newInvitationExpiresInDays,
}),
})
if (response.ok) {
const data = await response.json()
await fetchInvitations()
setShowCreateInvitationModal(false)
setNewInvitationMaxUses(1)
setNewInvitationExpiresInDays(null)
showToast(`邀请链接创建成功: ${data.inviteUrl}`)
} else {
const error = await response.json()
showToast(error.error || '创建失败', 'error')
}
} catch (error) {
console.error('Create invitation error:', error)
showToast('创建失败,请稍后重试', 'error')
}
}
const handleToggleInvitationActive = async (id: string, isActive: boolean) => {
try {
const response = await fetch(`/api/invitations/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ isActive: !isActive }),
})
if (response.ok) {
await fetchInvitations()
showToast(isActive ? '邀请链接已禁用' : '邀请链接已激活')
} else {
const error = await response.json()
showToast(error.error || '操作失败', 'error')
}
} catch (error) {
console.error('Toggle invitation active error:', error)
showToast('操作失败,请稍后重试', 'error')
}
}
const handleDeleteInvitation = async (id: string) => {
if (!confirm('确定要删除这个邀请链接吗?')) return
try {
const response = await fetch(`/api/invitations/${id}`, {
method: 'DELETE',
})
if (response.ok) {
await fetchInvitations()
showToast('邀请链接已删除')
} else {
const error = await response.json()
showToast(error.error || '删除失败', 'error')
}
} catch (error) {
console.error('Delete invitation error:', error)
showToast('删除失败,请稍后重试', 'error')
}
}
const handleEditUser = (user: User) => {
setEditingUser(user)
setSelectedDepartment(user.department || '')
// 不再设置 selectedParentId,因为直属上级是固定的(部门管理员)
setEditingUsername(user.username)
}
const [showPasswordModal, setShowPasswordModal] = useState(false)
const [transferPassword, setTransferPassword] = useState('')
const handleTransferSuperAdmin = async (targetEmail: string, password: string) => {
if (!targetEmail) {
showToast('请提供目标用户邮箱', 'error')
return
}
if (!password) {
showToast('请输入密码', 'error')
return
}
try {
const res = await fetch('/api/admin/transfer-superadmin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetEmail, password }),
})
const data = await res.json()
if (res.ok) {
showToast('最高权限已转移')
setEditingUser(null)
setSelectedDepartment('')
setSelectedParentId('')
setEditingUsername('')
setShowPasswordModal(false)
setTransferPassword('')
// 刷新用户列表
fetchData()
} else {
showToast(data.error || '操作失败', 'error')
}
} catch (err) {
console.error('transfer super admin error:', err)
showToast('操作失败,请稍后重试', 'error')
}
}
const handleUpdateUser = async () => {
if (!editingUser) return
const usernameChanged = editingUsername !== editingUser.username
const departmentChanged = selectedDepartment !== (editingUser.department || '')
if (!usernameChanged && !departmentChanged) {
showToast('未做任何修改', 'error')
return
}
try {
let materialsUpdated = false
let parentIdToSet: string | null = null
// 如果部门改变,自动设置直属上级为该部门的管理员
if (departmentChanged && selectedDepartment) {
const managers = findDepartmentManagers(selectedDepartment) || []
if (managers.length > 0) {
// 选择第一个不是当前用户的管理员
const firstManager = managers.find((manager) => manager.id !== editingUser.id) || managers[0]
parentIdToSet = firstManager.id
}
}
if (usernameChanged || departmentChanged) {
const response = await fetch('/api/admin/users/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: editingUser.id,
username: editingUsername,
department: selectedDepartment,
}),
})
const data = await response.json()
if (!response.ok) {
showToast(data.error || '更新失败', 'error')
return
}
materialsUpdated = Boolean(data.materialsUpdated)
// 如果部门改变且需要设置直属上级
if (departmentChanged && parentIdToSet) {
const hierarchyResponse = await fetch('/api/admin/users/update-hierarchy', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: editingUser.id,
parentId: parentIdToSet,
}),
})
if (!hierarchyResponse.ok) {
const hierarchyData = await hierarchyResponse.json()
showToast(hierarchyData.error || '调整上下级失败', 'error')
return
}
}
}
await fetchData()
setEditingUser(null)
setSelectedDepartment('')
setSelectedParentId('')
setEditingUsername('')
showToast(materialsUpdated ? '用户信息已更新,素材信息已同步' : '用户信息已更新')
} catch (error) {
console.error('Update user error:', error)
showToast('更新失败,请稍后重试', 'error')
}
}
const createDepartment = async (name: string) => {
const trimmed = name.trim()
if (!trimmed) {
showToast('部门名称不能为空', 'error')
return
}
setAddingDepartment(true)
try {
const response = await fetch('/api/departments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: trimmed }),
})
if (response.ok) {
await fetchDepartments()
showToast('部门添加成功')
} else {
const error = await response.json()
showToast(error.error || '添加失败', 'error')
}
} catch (error) {
console.error('Add department error:', error)
showToast('添加失败,请稍后重试', 'error')
} finally {
setAddingDepartment(false)
}
}
const handleDeleteDepartment = async (id: string, name: string) => {
if (!confirm(`确定要删除部门"${name}"吗?`)) return
try {
const response = await fetch(`/api/departments?id=${id}`, {
method: 'DELETE',
})
if (response.ok) {
const result = await response.json().catch(() => ({}))
await Promise.all([fetchDepartments(), loadUsersData()])
showToast(result.message || '部门删除成功')
} else {
const error = await response.json()
showToast(error.error || '删除失败', 'error')
}
} catch (error) {
console.error('Delete department error:', error)
showToast('删除失败,请稍后重试', 'error')
}
}
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 ? '关闭' : '开启'
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 ? '禁用' : '激活'
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, options?: string | null) => {
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(), options }),
})
if (response.ok) {
setEditingCategory(null)
showToast('修改成功')
// 重新获取数据以确保数据同步
await fetchData()
} 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, options?: string | null) => {
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(), options }),
})
if (response.ok) {
setEditingScene(null)
showToast('修改成功')
// 重新获取数据以确保数据同步
await fetchData()
} 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) {
showToast('删除成功')
// 重新获取数据以确保数据同步
await fetchData()
} 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) {
showToast('删除成功')
// 重新获取数据以确保数据同步
await fetchData()
} 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' ? '设置为管理员' : '取消管理员权限'
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) {
// 刷新数据,用户树会同步更新最新的管理员信息
await fetchData()
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) {
setShowAddCategoryModal(false)
setNewCategoryName('')
showToast('添加成功')
// 重新获取数据以确保数据同步
await fetchData()
} 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) {
setShowAddSceneModal(false)
setNewSceneName('')
showToast('添加成功')
// 重新获取数据以确保数据同步
await fetchData()
} else {
const data = await response.json()
showToast(data.error || '添加失败', 'error')
}
} catch (error) {
console.error('Error adding scene:', error)
showToast('添加失败', 'error')
}
}
// 平台分类管理函数
const handleAddPlatform = async () => {
if (!newPlatformName.trim()) {
showToast('平台名称不能为空', 'error')
return
}
try {
const response = await fetch('/api/platforms', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: newPlatformName.trim() }),
})
if (response.ok) {
setShowAddPlatformModal(false)
setNewPlatformName('')
showToast('添加成功')
// 重新获取数据以确保数据同步
await fetchData()
} else {
const data = await response.json()
showToast(data.error || '添加失败', 'error')
}
} catch (error) {
console.error('Error adding platform:', error)
showToast('添加失败', 'error')
}
}
const handleUpdatePlatform = async (id: string, newName: string, options?: string | null) => {
if (!newName.trim()) {
showToast('平台名称不能为空', 'error')
return
}
try {
const response = await fetch('/api/platforms', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ id, name: newName.trim(), options }),
})
if (response.ok) {
setEditingPlatform(null)
showToast('修改成功')
// 重新获取数据以确保数据同步
await fetchData()
} else {
const data = await response.json()
showToast(data.error || '修改失败', 'error')
}
} catch (error) {
console.error('Error updating platform:', error)
showToast('修改失败', 'error')
}
}
const handleDeletePlatform = async (id: string) => {
if (!confirm('确定要删除这个平台分类吗?')) return
try {
const response = await fetch(`/api/platforms?id=${id}`, {
method: 'DELETE',
})
if (response.ok) {
showToast('删除成功')
// 重新获取数据以确保数据同步
await fetchData()
} else {
const data = await response.json()
showToast(data.error || '删除失败', 'error')
}
} catch (error) {
console.error('Error deleting platform:', error)
showToast('删除失败', 'error')
}
}
// 设置部门管理员
const handleSetDepartmentManager = async (userId: string, departmentName: string) => {
try {
const response = await fetch('/api/admin/users/set-department-manager', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ userId, departmentName }),
})
if (response.ok) {
const data = await response.json()
await fetchData()
setEditingUser((prev) => {
if (prev && prev.id === userId) {
return {
...prev,
managedDepartment: departmentName,
department: departmentName,
role: 'ADMIN',
}
}
return prev
})
showToast('部门管理员设置成功')
} else {
const data = await response.json()
showToast(data.error || '设置失败', 'error')
}
} catch (error) {
console.error('Set department manager error:', error)
showToast('设置失败', 'error')
}
}
// 切换折叠状态
const toggleNodeCollapse = (nodeId: string) => {
setCollapsedNodeIds((prev) => {
const next = new Set(prev)
if (next.has(nodeId)) {
next.delete(nodeId)
} else {
next.add(nodeId)
}
return next
})
}
type UserTreeNode = {
user: User
children: UserTreeNode[]
}
type DepartmentNode = {
id: string
name: string
fullName: string
children: DepartmentNode[]
}
type DepartmentUserGrouping = {
managers: User[]
regulars: User[]
}
// 用户层级信息,用于处理上下级/权限等逻辑
const { treeRoots, childLookup, superAdmin } = useMemo(() => {
if (!users || users.length === 0) {
return { treeRoots: [], childLookup: new Map<string, string[]>(), superAdmin: null as User | null }
}
const detectedSuperAdmin = users.find((u) => u.isSuperAdmin) || null
const childMap = new Map<string, string[]>()
users.forEach((user) => {
if (user && user.id) {
childMap.set(user.id, [])
}
})
const roots: string[] = []
users.forEach((user) => {
if (!user || !user.id) return
const parentId = user.parentId
if (parentId && childMap.has(parentId)) {
childMap.get(parentId)!.push(user.id)
} else {
roots.push(user.id)
}
})
const compareUsers = (a: User, b: User) => {
if (a.isSuperAdmin && !b.isSuperAdmin) return -1
if (!a.isSuperAdmin && b.isSuperAdmin) return 1
if (a.role === 'ADMIN' && b.role !== 'ADMIN') return -1
if (a.role !== 'ADMIN' && b.role === 'ADMIN') return 1
const labelA = (a.username || a.email || '').toLowerCase()
const labelB = (b.username || b.email || '').toLowerCase()
return labelA.localeCompare(labelB, 'zh-CN')
}
const buildNodes = (ids: string[]): UserTreeNode[] =>
ids
.map((id) => {
const user = userLookup.get(id)
if (!user) return null
return {
user,
children: buildNodes(childMap.get(id) || []),
}
})
.filter((node): node is UserTreeNode => Boolean(node))
.sort((a, b) => compareUsers(a.user, b.user))
return {
treeRoots: buildNodes(roots),
childLookup: childMap,
superAdmin: detectedSuperAdmin
}
}, [users, userLookup])
const collectDescendants = useCallback(
(startId: string) => {
const result = new Set<string>()
const queue = [...(childLookup.get(startId) || [])]
while (queue.length) {
const id = queue.shift()!
if (result.has(id)) continue
result.add(id)
const children = childLookup.get(id) || []
queue.push(...children)
}
return result
},
[childLookup]
)
const findDepartmentManagers = useCallback(
(departmentName: string | null | undefined) => {
if (!departmentName) return null
const result = users.filter(
(candidate) => candidate.managedDepartment && candidate.managedDepartment === departmentName
)
return result.length > 0 ? result : null
},
[users]
)
const currentDepartmentManagers = useMemo(() => {
if (!selectedDepartment) return []
return findDepartmentManagers(selectedDepartment) || []
}, [selectedDepartment, findDepartmentManagers])
const editingUserIsDeptManager = useMemo(() => {
if (!editingUser) return false
return currentDepartmentManagers.some((manager) => manager.id === editingUser.id)
}, [currentDepartmentManagers, editingUser])
const handleRemoveDepartmentManager = async (userId: string) => {
try {
const response = await fetch(`/api/admin/users/set-department-manager?userId=${userId}`, {
method: 'DELETE',
})
if (response.ok) {
await fetchData()
setEditingUser((prev) => {
if (prev && prev.id === userId) {
return {
...prev,
managedDepartment: undefined,
}
}
return prev
})
showToast('已取消部门管理员')
} else {
const data = await response.json()
showToast(data.error || '操作失败', 'error')
}
} catch (error) {
console.error('Remove department manager error:', error)
showToast('操作失败', 'error')
}
}
const treeToRender = useMemo(() => {
const term = userSearchTerm.trim().toLowerCase()
if (!term) return treeRoots
const matchUser = (user: User) => {
const username = user.username?.toLowerCase() || ''
const email = user.email?.toLowerCase() || ''
const department = user.department?.toLowerCase() || ''
return username.includes(term) || email.includes(term) || department.includes(term)
}
const filterNodes = (nodes: UserTreeNode[]): UserTreeNode[] => {
const result: UserTreeNode[] = []
nodes.forEach((node) => {
const filteredChildren = filterNodes(node.children)
if (matchUser(node.user) || filteredChildren.length > 0) {
result.push({
user: node.user,
children: filteredChildren,
})
}
})
return result
}
return filterNodes(treeRoots)
}, [treeRoots, userSearchTerm])
const allParentOptions = useMemo(() => {
const options: Array<{ id: string; label: string }> = []
const traverse = (nodes: UserTreeNode[], depth: number) => {
nodes.forEach((node) => {
const indent = depth > 0 ? `${' '.repeat(depth)}- ` : ''
const label = node.user.username || node.user.email || '未命名'
options.push({ id: node.user.id, label: `${indent}${label}` })
traverse(node.children, depth + 1)
})
}
traverse(treeRoots, 0)
return options
}, [treeRoots])
const currentUserId = (session?.user as any)?.id as string | undefined
const currentUserRecord = currentUserId ? userLookup.get(currentUserId) : undefined
const currentIsSuperAdmin = Boolean(
currentUserRecord?.isSuperAdmin ?? (session?.user as any)?.isSuperAdmin
)
const currentRole = (session?.user as any)?.role as 'ADMIN' | 'USER' | undefined
const currentIsAdminRole = currentIsSuperAdmin || currentRole === 'ADMIN' || currentUserRecord?.role === 'ADMIN'
const currentManagedDepartment = (currentUserRecord?.managedDepartment ??
(session?.user as any)?.managedDepartment) as string | undefined
const currentDepartmentScope =
currentManagedDepartment || (currentUserRecord?.department || '').trim() || undefined
const departmentDescendantsMap = useMemo(
() => buildDepartmentDescendants(departments),
[departments]
)
const departmentUsersMap = useMemo(() => {
const map = new Map<string, DepartmentUserGrouping>()
const ensureEntry = (name: string) => {
if (!map.has(name)) {
map.set(name, { managers: [], regulars: [] })
}
return map.get(name)!
}
const sortUsers = (a: User, b: User) => {
// 最高权限账户始终排在最前面
if (a.isSuperAdmin && !b.isSuperAdmin) return -1
if (!a.isSuperAdmin && b.isSuperAdmin) return 1
const labelA = (a.username || a.email || '').toLowerCase()
const labelB = (b.username || b.email || '').toLowerCase()
return labelA.localeCompare(labelB, 'zh-CN')
}
users.forEach((user) => {
// 最高权限账户可以设置在任何分支内,但依旧拥有最高权限
const deptName = (user.department && user.department.trim()) || '未分配部门'
const entry = ensureEntry(deptName)
if (user.isSuperAdmin) {
// 最高权限账户显示在管理员列表中
entry.managers.push(user)
} else if (user.managedDepartment || user.role === 'ADMIN') {
entry.managers.push(user)
} else {
entry.regulars.push(user)
}
})
departments?.forEach((dept) => {
ensureEntry(dept.name)
})
ensureEntry('未分配部门')
map.forEach((entry) => {
entry.managers.sort(sortUsers)
entry.regulars.sort(sortUsers)
})
return map
}, [users, departments])
// 计算每个部门的素材数量
const departmentMaterialCounts = useMemo(() => {
const counts = new Map<string, number>()
users.forEach((user) => {
const deptName = (user.department && user.department.trim()) || '未分配部门'
const current = counts.get(deptName) || 0
counts.set(deptName, current + (user._count?.materials || 0))
})
return counts
}, [users])
const canManageUser = useCallback(
(target: User) => {
if (!currentUserId) return false
if (currentIsSuperAdmin) return true
if (target.isSuperAdmin) return false
const targetDept = (target.department || '').trim()
if (!targetDept && currentIsAdminRole) {
return true
}
// 有部门管理范围(显式 managedDepartment,或者管理员自身所在部门)
if (currentDepartmentScope) {
return isDepartmentWithinScope(
currentDepartmentScope,
target.department,
departmentDescendantsMap
)
}
// 没有部门管理范围时,只能修改自己的信息
return target.id === currentUserId
},
[
currentDepartmentScope,
currentIsSuperAdmin,
currentIsAdminRole,
currentUserId,
departmentDescendantsMap,
]
)
const editingUserDescendants = useMemo(() => {
if (!editingUser) return new Set<string>()
return collectDescendants(editingUser.id)
}, [editingUser, collectDescendants])
const parentSelectOptions = useMemo(() => {
const excluded = new Set(editingUserDescendants)
if (editingUser) {
excluded.add(editingUser.id)
}
return allParentOptions.filter((option) => {
if (excluded.has(option.id)) return false
const candidate = users.find((user) => user.id === option.id)
if (!candidate) return false
if (editingUser && option.id === (editingUser.parentId ?? '')) return true
return canManageUser(candidate)
})
}, [allParentOptions, canManageUser, editingUser, editingUserDescendants, users])
// 新的用户树形结构渲染:最高权限 -> 分支 -> 管理员 -> 其他人员
const toggleDepartmentCollapse = useCallback((deptName: string) => {
setCollapsedDepartments(prev => {
const next = new Set(prev)
if (next.has(deptName)) {
next.delete(deptName)
} else {
next.add(deptName)
}
return next
})
}, [])
const renderOrganizedUserTree = (): JSX.Element[] => {
const renderActionButtons = (user: User, options?: { showRoleToggle?: boolean }) => {
const allowed = canManageUser(user)
const disabledClass = allowed ? '' : 'opacity-40 cursor-not-allowed pointer-events-none'
const tooltip = allowed ? undefined : '无权限修改其他分支成员'
// 对于当前登录用户无权管理的成员,直接不展示操作按钮,只给出一行提示
if (!allowed && user.id !== currentUserId) {
return (
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] text-gray-500 dark:text-gray-400">无权限修改</span>
</div>
)
}
const actionButton = (
label: string,
className: string,
onClick: () => void,
visible: boolean = true
) => {
if (!visible) return null
return (
<button
type="button"
disabled={!allowed}
title={tooltip}
className={`${className} ${disabledClass}`}
onClick={() => {
if (!allowed) return
onClick()
}}
>
{label}
</button>
)
}
return (
<div className="flex flex-wrap items-center gap-2">
{actionButton(
'编辑',
'px-3 py-1 text-xs text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors',
() => handleEditUser(user)
)}
{actionButton(
user.isActive ? '禁用' : '激活',
'px-3 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors',
() => handleToggleActive(user.id, user.isActive)
)}
{actionButton(
user.canDownload ? '禁止下载' : '允许下载',
'px-3 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors',
() => handleToggleDownload(user.id, user.canDownload)
)}
{actionButton(
user.role === 'ADMIN' ? '取消管理员' : '设为管理员',
'px-3 py-1 text-xs bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded hover:bg-purple-200 dark:hover:bg-purple-800 transition-colors',
() => handleSetRole(user.id, user.role),
options?.showRoleToggle !== false && !user.isSuperAdmin
)}
</div>
)
}
const sections: JSX.Element[] = []
const visitedDepartments = new Set<string>()
const renderStatusBadges = (user: User) => (
<>
<span
className={`px-2 py-0.5 text-xs rounded-full ${
user.isActive
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'
}`}
>
{user.isActive ? '已激活' : '未激活'}
</span>
<span
className={`px-2 py-0.5 text-xs rounded-full ${
user.canDownload
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
{user.canDownload ? '可下载' : '禁止下载'}
</span>
</>
)
const renderUserRow = (user: User, isManager = false) => (
<div
key={user.id}
className="group flex items-start gap-3 px-4 py-2.5 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-all duration-300 ease-out rounded-lg border border-gray-200 dark:border-gray-700 hover:shadow-md hover:scale-[1.01] cursor-pointer"
>
<div className="flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span
className={`transition-all duration-200 ${
isManager || user.isSuperAdmin
? 'text-sm font-semibold text-indigo-900 dark:text-indigo-100 group-hover:text-indigo-700 dark:group-hover:text-indigo-200'
: 'text-sm font-medium text-gray-900 dark:text-white group-hover:text-gray-700 dark:group-hover:text-gray-200'
}`}
>
{user.username}
</span>
{user.isSuperAdmin && (
<span className="px-2 py-0.5 bg-gradient-to-r from-purple-600 to-pink-600 text-white text-xs font-semibold rounded-full shadow-sm hover:shadow-md transition-all duration-200 hover:scale-105">最高权限</span>
)}
{isManager && !user.isSuperAdmin && (
<span className="px-2 py-0.5 bg-gradient-to-r from-purple-500 to-indigo-600 text-white text-xs font-semibold rounded-full shadow-sm hover:shadow-md transition-all duration-200 hover:scale-105">管理员</span>
)}
{renderStatusBadges(user)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">素材: {user._count.materials}</div>
</div>
{renderActionButtons(user)}
</div>
)
const renderDepartmentUsers = (entry?: DepartmentUserGrouping) => {
const managerCount = entry?.managers.length ?? 0
const regularCount = entry?.regulars.length ?? 0
const total = managerCount + regularCount
if (total === 0) {
return (
<div className="px-4 py-2 rounded-lg border border-dashed border-gray-300 dark:border-gray-600 text-xs text-gray-500 dark:text-gray-400 bg-white/70 dark:bg-gray-800/40">
暂无成员
</div>
)
}
return (
<div className="space-y-3">
{managerCount > 0 && (
<div className="space-y-1">
<div className="text-xs font-semibold text-indigo-600 dark:text-indigo-300">管理员</div>
<div className="space-y-1">
{entry!.managers.map((user) => renderUserRow(user, true))}
</div>
</div>
)}
{regularCount > 0 && (
<div className="space-y-1">
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400">成员</div>
<div className="space-y-1">
{entry!.regulars.map((user) => renderUserRow(user))}
</div>
</div>
)}
</div>
)
}
const renderDepartmentBranch = (node: DepartmentNode, depth = 0): JSX.Element | null => {
visitedDepartments.add(node.fullName)
const entry = departmentUsersMap.get(node.fullName)
const memberCount = (entry?.managers.length ?? 0) + (entry?.regulars.length ?? 0)
const materialCount = departmentMaterialCounts.get(node.fullName) || 0
const managers = entry?.managers || []
const managerNames = managers.map(m => m.username).filter(Boolean)
const childBranches = node.children
.map((child) => renderDepartmentBranch(child, depth + 1))
.filter((child): child is JSX.Element => Boolean(child))
const isCollapsed = collapsedDepartments.has(node.fullName)
const depthLevel = Math.min(depth, 2)
const depthContainerClasses =
depthLevel === 0
? 'bg-indigo-50/90 dark:bg-indigo-900/50 border-indigo-400 dark:border-indigo-400'
: depthLevel === 1
? 'bg-indigo-50/70 dark:bg-indigo-900/30 border-indigo-300 dark:border-indigo-500/80'
: 'bg-indigo-50/50 dark:bg-indigo-900/20 border-indigo-200 dark:border-indigo-700/80'
const canToggle = memberCount > 0 || childBranches.length > 0
return (
<div key={node.fullName} className="space-y-2">
<div
className={`group flex items-center justify-between px-4 py-2.5 border-l-[3px] rounded-xl shadow-[0_1px_3px_rgba(15,23,42,0.06)] dark:shadow-[0_1px_3px_rgba(15,23,42,0.55)] transition-all duration-300 ease-out hover:shadow-md hover:scale-[1.01] cursor-pointer ${depthContainerClasses}`}
onClick={() => {
if (!canToggle) return
toggleDepartmentCollapse(node.fullName)
}}
style={{ marginLeft: depth * 12 }}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
{canToggle ? (
<button
className="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded hover:bg-indigo-200/80 dark:hover:bg-indigo-700/80 transition-all duration-200 hover:scale-110"
title={isCollapsed ? '展开' : '折叠'}
onClick={(e) => {
e.stopPropagation()
toggleDepartmentCollapse(node.fullName)
}}
>
<svg
className={`w-4 h-4 transform transition-transform duration-300 ease-out text-indigo-600 dark:text-indigo-300 ${
isCollapsed ? '' : 'rotate-90'
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
) : (
<span className="flex-shrink-0 w-6 h-6" />
)}
<div className="flex flex-col min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-gray-900 dark:text-white">{node.fullName}</span>
{managerNames.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs text-gray-500 dark:text-gray-400">管理员:</span>
{managerNames.map((name, idx) => (
<span
key={idx}
className="px-2 py-0.5 bg-gradient-to-r from-purple-500 to-indigo-600 text-white text-xs font-semibold rounded-full shadow-sm hover:shadow-md transition-all duration-200 hover:scale-105"
>
{name}
</span>
))}
</div>
)}
</div>
<div className="flex items-center gap-3 mt-0.5">
<span className="text-xs text-gray-500 dark:text-gray-400">成员:{memberCount}</span>
<span className="text-xs text-gray-500 dark:text-gray-400">素材:{materialCount}</span>
</div>
</div>
</div>
</div>
{!isCollapsed && (
<div className="ml-6 space-y-2 animate-in fade-in slide-in-from-top-2 duration-300">
{renderDepartmentUsers(entry)}
{childBranches.length > 0 && childBranches}
</div>
)}
</div>
)
}
// 不再单独显示最高权限账户,它会在所属分支中显示
const rootCollapsed = collapsedDepartments.has('ROOT')
const departmentBranches = departmentTree
.map((node) => renderDepartmentBranch(node))
.filter((node): node is JSX.Element => Boolean(node))
sections.push(
<div key="user-tree-root" className="space-y-2">
<div className="group flex items-center justify-between px-4 py-2.5 bg-gradient-to-r from-indigo-50/90 to-purple-50/90 dark:from-indigo-900/50 dark:to-purple-900/50 border-l-4 border-indigo-500 dark:border-indigo-400 rounded-xl shadow-sm hover:shadow-md transition-all duration-300 ease-out hover:scale-[1.01]">
<div className="flex items-center gap-3">
<button
className="flex-shrink-0 w-7 h-7 flex items-center justify-center rounded-xl hover:bg-indigo-200/80 dark:hover:bg-indigo-700/80 transition-all duration-200 hover:scale-110 active:scale-95"
title={rootCollapsed ? '展开' : '折叠'}
onClick={() => toggleDepartmentCollapse('ROOT')}
>
<svg
className={`w-5 h-5 transform transition-transform duration-200 text-indigo-600 dark:text-indigo-300 ${
rootCollapsed ? '' : 'rotate-90'
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
<div className="flex flex-col">
<span className="text-base font-semibold text-gray-900 dark:text-white">主公司</span>
<span className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">一级分支:{departmentTree.length}</span>
</div>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400">成员总数:{users.length}</span>
</div>
{!rootCollapsed && (
<div className="mt-2 space-y-2 ml-6 animate-in fade-in slide-in-from-top-2 duration-300">
{departmentBranches.length > 0 ? (
departmentBranches
) : (
<div className="text-xs text-gray-500 dark:text-gray-400 px-4 py-2">
暂无分支,请先在架构管理中创建分支
</div>
)}
</div>
)}
</div>
)
const orphanNames = Array.from(departmentUsersMap.keys()).filter(
(name) => name !== '未分配部门' && !visitedDepartments.has(name)
)
orphanNames.forEach((name) => {
const entry = departmentUsersMap.get(name)
const total = (entry?.managers.length ?? 0) + (entry?.regulars.length ?? 0)
if (!total) return
sections.push(
<div key={`orphan-${name}`} className="space-y-2">
<div className="px-4 py-2 bg-amber-50 dark:bg-amber-900/30 border-l-4 border-amber-400 dark:border-amber-500 rounded-lg">
<div className="text-sm font-semibold text-amber-800 dark:text-amber-200">{name}(未在架构中)</div>
</div>
<div className="ml-6">{renderDepartmentUsers(entry)}</div>
</div>
)
})
const unassignedEntry = departmentUsersMap.get('未分配部门')
const unassignedCount = (unassignedEntry?.managers.length ?? 0) + (unassignedEntry?.regulars.length ?? 0)
if (unassignedCount > 0) {
sections.push(
<div key="unassigned" className="space-y-2">
<div className="px-4 py-2 bg-gray-100 dark:bg-gray-800 border-l-4 border-gray-400 dark:border-gray-600 rounded-lg">
<div className="text-sm font-semibold text-gray-800 dark:text-gray-100">未分配部门</div>
</div>
<div className="ml-6">{renderDepartmentUsers(unassignedEntry)}</div>
</div>
)
}
if (sections.length === 0) {
sections.push(
<div key="empty-users" className="text-center py-12 text-gray-500 dark:text-gray-400">
暂无用户
</div>
)
}
return sections
}
// 架构管理:根据部门名称构建树形结构(使用“-”表示层级)
const departmentTree = useMemo<DepartmentNode[]>(() => {
if (!departments || departments.length === 0) return []
const nodeMap = new Map<string, DepartmentNode>()
// 先为每个真实部门创建节点
departments.forEach((dept) => {
nodeMap.set(dept.name, {
id: dept.id,
name: dept.name,
fullName: dept.name,
children: [],
})
})
const roots: DepartmentNode[] = []
departments.forEach((dept) => {
const node = nodeMap.get(dept.name)!
const lastDashIndex = dept.name.lastIndexOf('-')
if (lastDashIndex === -1) {
// 没有上级,作为一级分支挂在“主公司”下面
roots.push(node)
} else {
const parentName = dept.name.substring(0, lastDashIndex)
const parentNode = nodeMap.get(parentName)
if (parentNode) {
parentNode.children.push(node)
} else {
// 找不到父级,退化为一级分支
roots.push(node)
}
}
})
const sortNodes = (nodes: DepartmentNode[]) => {
nodes.sort((a, b) => departmentCollator.compare(a.fullName, b.fullName))
nodes.forEach((n) => sortNodes(n.children))
}
sortNodes(roots)
return roots
}, [departments, departmentCollator])
const filteredDepartmentTree = useMemo<DepartmentNode[]>(() => {
const term = departmentSearchTerm.trim().toLowerCase()
if (!term) return departmentTree
const filterNodes = (nodes: DepartmentNode[]): DepartmentNode[] => {
const result: DepartmentNode[] = []
nodes.forEach((node) => {
const selfMatch =
node.name.toLowerCase().includes(term) || node.fullName.toLowerCase().includes(term)
const filteredChildren = filterNodes(node.children)
if (selfMatch || filteredChildren.length > 0) {
result.push({
...node,
children: filteredChildren,
})
}
})
return result
}
return filterNodes(departmentTree)
}, [departmentTree, departmentSearchTerm])
// 保留原有的 renderUserNodes 用于搜索功能
const renderUserNodes = (nodes: UserTreeNode[], depth = 0): JSX.Element[] => {
return nodes.map((node) => {
const { user } = node
const hasChildren = node.children.length > 0
const collapsed = userSearchTerm.trim()
? false
: collapsedNodeIds.has(user.id)
let roleBadge: JSX.Element | null = null
if (user.isSuperAdmin) {
roleBadge = (
<span className="px-2 py-0.5 bg-purple-600 text-white text-xs rounded-full">最高权限</span>
)
} else if (user.role === 'ADMIN') {
roleBadge = (
<span className="px-2 py-0.5 bg-purple-600 text-white text-xs rounded-full">管理员</span>
)
}
const statusBadges = (
<>
<span
className={`px-2 py-0.5 text-xs rounded-full ${
user.isActive
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'
}`}
>
{user.isActive ? '已激活' : '未激活'}
</span>
<span
className={`px-2 py-0.5 text-xs rounded-full ${
user.canDownload
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
{user.canDownload ? '可下载' : '禁止下载'}
</span>
</>
)
const rowClassName = [
'flex items-start gap-3 px-4 py-3 transition-colors',
'bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700/50',
].join(' ')
return (
<div key={user.id} className="first:border-t-0">
<div
className={rowClassName}
style={{ paddingLeft: `${depth * 24 + 16}px` }}
>
{hasChildren ? (
<button
onClick={() => toggleNodeCollapse(user.id)}
className="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
title={collapsed ? '展开' : '折叠'}
>
<svg
className={`w-4 h-4 transform transition-transform ${collapsed ? '' : 'rotate-90'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
) : (
<span className="flex-shrink-0 w-6 h-6" />
)}
<div className="flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-gray-900 dark:text-white">{user.username}</span>
{roleBadge}
{statusBadges}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
素材: {user._count.materials}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
disabled={!canManageUser(user)}
title={!canManageUser(user) ? '无权限修改其他分支成员' : undefined}
onClick={() => {
if (!canManageUser(user)) return
handleEditUser(user)
}}
className={`px-3 py-1 text-xs text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors ${
canManageUser(user) ? '' : 'opacity-40 cursor-not-allowed pointer-events-none'
}`}
>
编辑
</button>
<button
type="button"
disabled={!canManageUser(user)}
title={!canManageUser(user) ? '无权限修改其他分支成员' : undefined}
onClick={() => {
if (!canManageUser(user)) return
handleToggleActive(user.id, user.isActive)
}}
className={`px-3 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors ${
canManageUser(user) ? '' : 'opacity-40 cursor-not-allowed pointer-events-none'
}`}
>
{user.isActive ? '禁用' : '激活'}
</button>
<button
type="button"
disabled={!canManageUser(user)}
title={!canManageUser(user) ? '无权限修改其他分支成员' : undefined}
onClick={() => {
if (!canManageUser(user)) return
handleToggleDownload(user.id, user.canDownload)
}}
className={`px-3 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors ${
canManageUser(user) ? '' : 'opacity-40 cursor-not-allowed pointer-events-none'
}`}
>
{user.canDownload ? '禁止下载' : '允许下载'}
</button>
{!user.isSuperAdmin && (
<button
type="button"
disabled={!canManageUser(user)}
title={!canManageUser(user) ? '无权限修改其他分支成员' : undefined}
onClick={() => {
if (!canManageUser(user)) return
handleSetRole(user.id, user.role)
}}
className={`px-3 py-1 text-xs bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded hover:bg-purple-200 dark:hover:bg-purple-800 transition-colors ${
canManageUser(user) ? '' : 'opacity-40 cursor-not-allowed pointer-events-none'
}`}
>
{user.role === 'ADMIN' ? '取消管理员' : '设为管理员'}
</button>
)}
{!canManageUser(user) && (
<span className="text-[11px] text-gray-500 dark:text-gray-400">无权限修改</span>
)}
</div>
</div>
{hasChildren && !collapsed && (
<div>{renderUserNodes(node.children, depth + 1)}</div>
)}
</div>
)
})
}
if (status === 'loading' || loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-xl text-gray-600 dark:text-gray-400">加载中...</div>
</div>
)
}
const organizedTree = renderOrganizedUserTree()
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header session={session} />
{/* Toast通知 */}
{toast && (
<div className={`fixed top-6 left-1/2 -translate-x-1/2 z-[100] 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">
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-4">
<button
onClick={() => router.push('/')}
className="px-4 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-white border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg transition-all flex items-center gap-2 shadow-sm hover:shadow-md font-medium"
title="返回主页"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
返回主页
</button>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">系统管理</h1>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => router.push('/admin/settings')}
className="btn-primary flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
外观设置
</button>
</div>
</div>
{/* Tabs - Apple风格动画 */}
<div className="flex space-x-1 border-b border-gray-200 dark:border-gray-700 mb-8 overflow-x-auto">
{/* 监控仪表板 - 只有管理员及以上权限才能看到 */}
{(session?.user as any)?.role === 'ADMIN' && (
<button
type="button"
onClick={() => setActiveTab('monitor')}
className={`relative pb-4 px-5 font-medium transition-all duration-300 ease-out ${
activeTab === 'monitor'
? 'text-gray-900 dark:text-white'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<span className="relative z-10">监控仪表板</span>
{activeTab === 'monitor' && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-gray-900 dark:bg-white rounded-full animate-in slide-in-from-left duration-300" />
)}
</button>
)}
<button
type="button"
onClick={() => setActiveTab('users')}
className={`relative pb-4 px-5 font-medium transition-all duration-300 ease-out ${
activeTab === 'users'
? 'text-gray-900 dark:text-white'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<span className="relative z-10">用户管理</span>
{activeTab === 'users' && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-gray-900 dark:bg-white rounded-full animate-in slide-in-from-left duration-300" />
)}
</button>
<button
type="button"
onClick={() => setActiveTab('departments')}
className={`relative pb-4 px-5 font-medium transition-all duration-300 ease-out ${
activeTab === 'departments'
? 'text-gray-900 dark:text-white'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<span className="relative z-10">架构管理</span>
{activeTab === 'departments' && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-gray-900 dark:bg-white rounded-full animate-in slide-in-from-left duration-300" />
)}
</button>
<button
type="button"
onClick={() => setActiveTab('templates')}
className={`relative pb-4 px-5 font-medium transition-all duration-300 ease-out ${
activeTab === 'templates'
? 'text-gray-900 dark:text-white'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<span className="relative z-10">模板管理</span>
{activeTab === 'templates' && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-gray-900 dark:bg-white rounded-full animate-in slide-in-from-left duration-300" />
)}
</button>
<button
type="button"
onClick={() => setActiveTab('categories')}
className={`relative pb-4 px-5 font-medium transition-all duration-300 ease-out ${
activeTab === 'categories'
? 'text-gray-900 dark:text-white'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<span className="relative z-10">分类管理</span>
{activeTab === 'categories' && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-gray-900 dark:bg-white rounded-full animate-in slide-in-from-left duration-300" />
)}
</button>
{/* 邀请管理 - 只有管理员及以上权限才能看到 */}
{(session?.user as any)?.role === 'ADMIN' && (
<button
type="button"
onClick={() => setActiveTab('invitations')}
className={`relative pb-4 px-5 font-medium transition-all duration-300 ease-out ${
activeTab === 'invitations'
? 'text-gray-900 dark:text-white'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<span className="relative z-10">邀请管理</span>
{activeTab === 'invitations' && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-gray-900 dark:bg-white rounded-full animate-in slide-in-from-left duration-300" />
)}
</button>
)}
<button
type="button"
onClick={() => setActiveTab('materials')}
className={`relative pb-4 px-5 font-medium transition-all duration-300 ease-out ${
activeTab === 'materials'
? 'text-gray-900 dark:text-white'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<span className="relative z-10">素材管理</span>
{activeTab === 'materials' && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-gray-900 dark:bg-white rounded-full animate-in slide-in-from-left duration-300" />
)}
</button>
</div>
{/* Template Management */}
{activeTab === 'templates' && (
<TemplateManager showToast={showToast} />
)}
{/* 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" data-category-editor>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">
分类名称 <span className="text-red-500">*</span>
</label>
<input
type="text"
defaultValue={category.name}
name="category-name"
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
onKeyDown={(e) => {
if (e.key === 'Enter') {
const editor = e.currentTarget.closest('[data-category-editor]') as HTMLElement | null
const optionsInput = editor?.querySelector<HTMLTextAreaElement>('textarea[name="category-options"]')
handleUpdateCategory(category.id, e.currentTarget.value, optionsInput?.value || null)
} else if (e.key === 'Escape') {
setEditingCategory(null)
}
}}
autoFocus
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">
下拉选项 <span className="text-gray-500 font-normal">(可选)</span>
</label>
<textarea
rows={4}
defaultValue={category.options ? (() => {
try {
const parsed = JSON.parse(category.options)
return Array.isArray(parsed) ? parsed.join('\n') : category.options
} catch {
return category.options
}
})() : ''}
name="category-options"
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200 resize-none font-mono"
placeholder='直接输入选项,每行一个,例如:&#10;选项1&#10;选项2&#10;选项3'
/>
<div className="mt-2 p-2.5 bg-blue-50/50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p className="text-[10px] font-semibold text-blue-700 dark:text-blue-300 mb-1.5">填写说明:</p>
<div className="text-[10px] text-blue-600 dark:text-blue-400">
<p className="mb-1.5">每行输入一个选项,例如:</p>
<div className="ml-3 font-mono bg-white dark:bg-gray-800 px-2 py-1 rounded border border-blue-200 dark:border-blue-700">
选项1<br/>
选项2<br/>
选项3
</div>
</div>
</div>
</div>
<div className="flex gap-2">
<button
onClick={(e) => {
const editor = e.currentTarget.closest('[data-category-editor]') as HTMLElement | null
const nameInput = editor?.querySelector<HTMLInputElement>('input[name="category-name"]')
const optionsInput = editor?.querySelector<HTMLTextAreaElement>('textarea[name="category-options"]')
handleUpdateCategory(category.id, nameInput?.value || '', optionsInput?.value || null)
}}
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 dark:border-gray-600 text-gray-700 dark:text-gray-300 text-sm rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 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 dark:text-gray-400">
暂无使用店铺分类
</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" data-scene-editor>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">
分类名称 <span className="text-red-500">*</span>
</label>
<input
type="text"
defaultValue={scene.name}
name="scene-name"
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
onKeyDown={(e) => {
if (e.key === 'Enter') {
const editor = e.currentTarget.closest('[data-scene-editor]') as HTMLElement | null
const optionsInput = editor?.querySelector<HTMLTextAreaElement>('textarea[name="scene-options"]')
handleUpdateScene(scene.id, e.currentTarget.value, optionsInput?.value || null)
} else if (e.key === 'Escape') {
setEditingScene(null)
}
}}
autoFocus
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">
下拉选项 <span className="text-gray-500 font-normal">(可选)</span>
</label>
<textarea
rows={4}
defaultValue={scene.options ? (() => {
try {
const parsed = JSON.parse(scene.options)
return Array.isArray(parsed) ? parsed.join('\n') : scene.options
} catch {
return scene.options
}
})() : ''}
name="scene-options"
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200 resize-none font-mono"
placeholder='直接输入选项,每行一个,例如:&#10;选项1&#10;选项2&#10;选项3'
/>
<div className="mt-2 p-2.5 bg-blue-50/50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p className="text-[10px] font-semibold text-blue-700 dark:text-blue-300 mb-1.5">填写说明:</p>
<div className="text-[10px] text-blue-600 dark:text-blue-400">
<p className="mb-1.5">每行输入一个选项,例如:</p>
<div className="ml-3 font-mono bg-white dark:bg-gray-800 px-2 py-1 rounded border border-blue-200 dark:border-blue-700">
选项1<br/>
选项2<br/>
选项3
</div>
</div>
</div>
</div>
<div className="flex gap-2">
<button
onClick={(e) => {
const editor = e.currentTarget.closest('[data-scene-editor]') as HTMLElement | null
const nameInput = editor?.querySelector<HTMLInputElement>('input[name="scene-name"]')
const optionsInput = editor?.querySelector<HTMLTextAreaElement>('textarea[name="scene-options"]')
handleUpdateScene(scene.id, nameInput?.value || '', optionsInput?.value || null)
}}
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 dark:border-gray-600 text-gray-700 dark:text-gray-300 text-sm rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 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 dark:text-gray-400">
暂无场景类别
</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={() => setShowAddPlatformModal(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">
{platforms.map((platform) => (
<div
key={platform.id}
className="border border-gray-200 dark:border-gray-600 rounded-lg p-4 hover:shadow-md transition-shadow"
>
{editingPlatform?.id === platform.id ? (
<div className="space-y-3" data-platform-editor>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">
分类名称 <span className="text-red-500">*</span>
</label>
<input
type="text"
defaultValue={platform.name}
name="platform-name"
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
onKeyDown={(e) => {
if (e.key === 'Enter') {
const editor = e.currentTarget.closest('[data-platform-editor]') as HTMLElement | null
const optionsInput = editor?.querySelector<HTMLTextAreaElement>('textarea[name="platform-options"]')
handleUpdatePlatform(platform.id, e.currentTarget.value, optionsInput?.value || null)
} else if (e.key === 'Escape') {
setEditingPlatform(null)
}
}}
autoFocus
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">
下拉选项 <span className="text-gray-500 font-normal">(可选)</span>
</label>
<textarea
rows={4}
defaultValue={platform.options ? (() => {
try {
const parsed = JSON.parse(platform.options)
return Array.isArray(parsed) ? parsed.join('\n') : platform.options
} catch {
return platform.options
}
})() : ''}
name="platform-options"
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200 resize-none font-mono"
placeholder='直接输入选项,每行一个,例如:&#10;选项1&#10;选项2&#10;选项3'
/>
<div className="mt-2 p-2.5 bg-blue-50/50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p className="text-[10px] font-semibold text-blue-700 dark:text-blue-300 mb-1.5">填写说明:</p>
<div className="text-[10px] text-blue-600 dark:text-blue-400">
<p className="mb-1.5">每行输入一个选项,例如:</p>
<div className="ml-3 font-mono bg-white dark:bg-gray-800 px-2 py-1 rounded border border-blue-200 dark:border-blue-700">
选项1<br/>
选项2<br/>
选项3
</div>
</div>
</div>
</div>
<div className="flex gap-2">
<button
onClick={(e) => {
const editor = e.currentTarget.closest('[data-platform-editor]') as HTMLElement | null
const nameInput = editor?.querySelector<HTMLInputElement>('input[name="platform-name"]')
const optionsInput = editor?.querySelector<HTMLTextAreaElement>('textarea[name="platform-options"]')
handleUpdatePlatform(platform.id, nameInput?.value || '', optionsInput?.value || null)
}}
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={() => setEditingPlatform(null)}
className="flex-1 px-3 py-1.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 text-sm rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 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">
{platform.name}
</h3>
<span className="text-xs text-gray-500 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded-full">
{platform.count} 个素材
</span>
</div>
<div className="flex gap-2">
<button
onClick={() => setEditingPlatform(platform)}
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={() => handleDeletePlatform(platform.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>
{platforms.length === 0 && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
暂无平台分类
</div>
)}
</div>
</div>
</div>
)}
{/* Departments Tab */}
{activeTab === 'departments' && (
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-lg overflow-hidden border border-gray-200/50 dark:border-gray-800/50">
<div className="flex items-center justify-between p-6 border-b border-gray-200/60 dark:border-gray-800/60">
<div>
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white tracking-tight">架构管理</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1.5">管理组织架构的分支结构</p>
</div>
</div>
<div className="p-6 space-y-6">
{/* 搜索分支 */}
<div className="flex gap-3">
<div className="relative flex-1">
<input
type="text"
value={departmentSearchTerm}
onChange={(e) => setDepartmentSearchTerm(e.target.value)}
placeholder="搜索分支名称(支持模糊匹配,例如:一部、短视频、技术部等)"
className="w-full px-4 py-2.5 pl-10 rounded-xl border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
/>
<svg className="absolute left-3 top-3 w-5 h-5 text-gray-400" 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>
{departmentSearchTerm && (
<button
onClick={() => setDepartmentSearchTerm('')}
className="absolute right-3 top-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<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>
{departmentSearchTerm && (
<button
onClick={() => setDepartmentSearchTerm('')}
className="px-4 py-2.5 text-sm bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-xl hover:bg-gray-200 dark:hover:bg-gray-700 transition-all duration-200 ease-out hover:scale-105 active:scale-95 font-medium"
>
清空
</button>
)}
</div>
<div className="mt-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">组织结构视图</h3>
<span className="text-xs text-gray-500 dark:text-gray-400">
以「主公司」为根的树形结构,仅展示分支(不含人员),支持折叠与搜索
</span>
</div>
<div className="mt-4 space-y-2">
<div className="space-y-2">
{/* 根节点:主公司(即使没有分支也要展示,方便首个部门的添加) */}
<div className="group flex items-center justify-between px-4 py-2.5 bg-gradient-to-r from-indigo-50/90 to-purple-50/90 dark:from-indigo-900/50 dark:to-purple-900/50 border-l-4 border-indigo-500 dark:border-indigo-400 rounded-xl shadow-sm hover:shadow-md transition-all duration-300 ease-out hover:scale-[1.01]">
<div className="flex items-center gap-3">
<button
className="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded hover:bg-indigo-200/80 dark:hover:bg-indigo-700/80 transition-all duration-200 hover:scale-110"
title={collapsedDepartments.has('ROOT') ? '展开' : '折叠'}
onClick={() => toggleDepartmentCollapse('ROOT')}
>
<svg
className={`w-4 h-4 transform transition-transform duration-300 ease-out text-indigo-600 dark:text-indigo-300 ${
collapsedDepartments.has('ROOT') ? '' : 'rotate-90'
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
<div className="flex flex-col">
<span className="text-sm font-semibold text-gray-900 dark:text-white">
主公司
</span>
<span className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
一级分支:{filteredDepartmentTree.length}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={async () => {
const input = window.prompt(
'为「主公司」添加一级分支,例如:一部、设计部、技术部等',
''
)
if (!input) return
const name = input.trim()
if (!name) {
showToast('分支名称不能为空', 'error')
return
}
await createDepartment(name)
}}
disabled={addingDepartment}
className="px-4 py-2 text-sm bg-indigo-600 dark:bg-indigo-700 text-white rounded-xl hover:bg-indigo-700 dark:hover:bg-indigo-600 transition-all duration-200 ease-out hover:scale-105 active:scale-95 shadow-sm hover:shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{addingDepartment ? '添加中...' : '添加子分支'}
</button>
</div>
</div>
{/* 主公司下的树形分支 */}
{!collapsedDepartments.has('ROOT') && (
<div className="mt-2 space-y-2 ml-6 animate-in fade-in slide-in-from-top-2 duration-300">
{departments.length === 0 ? (
<div className="text-xs text-gray-500 dark:text-gray-400 px-4 py-2">
当前还没有任何分支,请使用上方按钮为「主公司」添加第一个一级分支
</div>
) : filteredDepartmentTree.length === 0 ? (
<div className="text-xs text-gray-500 dark:text-gray-400 px-4 py-2">
未找到匹配的分支,请调整搜索关键词或清空搜索
</div>
) : (
filteredDepartmentTree.map((node) => {
const renderDeptNode = (
deptNode: DepartmentNode,
depth = 0
): JSX.Element => {
const isCollapsed = collapsedDepartments.has(deptNode.fullName)
const hasChildren = deptNode.children.length > 0
const level = Math.min(depth, 2)
const depthContainerClasses =
level === 0
? 'bg-indigo-50/90 dark:bg-indigo-900/50 border-indigo-400 dark:border-indigo-400'
: level === 1
? 'bg-indigo-50/70 dark:bg-indigo-900/30 border-indigo-300 dark:border-indigo-500/80'
: 'bg-indigo-50/50 dark:bg-indigo-900/20 border-indigo-200 dark:border-indigo-700/80'
const depthNameClasses =
level === 0
? 'text-sm font-semibold text-gray-900 dark:text-white'
: level === 1
? 'text-sm font-semibold text-gray-900 dark:text-white'
: 'text-sm font-semibold text-gray-900 dark:text-white'
const depthActionGap = level === 0 ? 'gap-4' : 'gap-3'
return (
<div key={deptNode.fullName} className="space-y-1.5">
<div
className={`group flex items-center justify-between px-5 py-3 border-l-[3px] rounded-xl shadow-[0_1px_3px_rgba(15,23,42,0.06)] dark:shadow-[0_1px_3px_rgba(15,23,42,0.55)] transition-all duration-300 ease-out hover:shadow-md hover:scale-[1.01] ${depthContainerClasses}`}
style={{ marginLeft: depth * 12 }}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
{hasChildren ? (
<button
className="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded hover:bg-indigo-200/80 dark:hover:bg-indigo-700/80 transition-all duration-200 hover:scale-110"
title={isCollapsed ? '展开' : '折叠'}
onClick={(e) => {
e.stopPropagation()
toggleDepartmentCollapse(deptNode.fullName)
}}
>
<svg
className={`w-4 h-4 transform transition-transform duration-300 ease-out text-indigo-600 dark:text-indigo-300 ${
isCollapsed ? '' : 'rotate-90'
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
) : (
<span className="flex-shrink-0 w-6 h-6" />
)}
{renamingDepartmentId === deptNode.id ? (
<input
value={renamingDepartmentName}
onChange={(e) => setRenamingDepartmentName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleRenameDepartment()
} else if (e.key === 'Escape') {
setRenamingDepartmentId(null)
setRenamingDepartmentName('')
}
}}
className="flex-1 px-3 py-1.5 rounded-xl border border-indigo-300 dark:border-indigo-600 bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-300/70 dark:focus:ring-indigo-500/80 transition-all duration-200"
autoFocus
/>
) : (
<span className={`${depthNameClasses} truncate`}>{deptNode.fullName}</span>
)}
</div>
<div className="flex items-center gap-2">
{renamingDepartmentId === deptNode.id ? (
<>
<button
onClick={handleRenameDepartment}
className="px-3 py-1.5 text-sm rounded-lg bg-green-600 text-white hover:bg-green-700 transition-all duration-200 ease-out hover:scale-105 active:scale-95 shadow-sm hover:shadow-md font-medium"
>
保存
</button>
<button
onClick={() => {
setRenamingDepartmentId(null)
setRenamingDepartmentName('')
}}
className="px-3 py-1.5 text-sm rounded-lg bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 transition-all duration-200 ease-out hover:scale-105 active:scale-95 font-medium"
>
取消
</button>
</>
) : (
<>
<button
onClick={(e) => {
e.stopPropagation()
handlePrepareAddSubDepartment(deptNode.fullName)
}}
className="px-3 py-1.5 text-xs rounded-lg bg-indigo-600 dark:bg-indigo-700 text-white border border-indigo-600 dark:border-indigo-700 hover:bg-indigo-700 dark:hover:bg-indigo-600 transition-all duration-200 ease-out hover:scale-105 active:scale-95 shadow-sm hover:shadow-md font-medium"
>
添加子分支
</button>
<button
onClick={(e) => {
e.stopPropagation()
handleStartRenameDepartment(deptNode.id, deptNode.fullName)
}}
className="px-3 py-1.5 text-xs rounded-lg bg-white/90 dark:bg-gray-800 text-indigo-700 dark:text-indigo-200 border border-indigo-200 dark:border-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-800/70 transition-all duration-200 ease-out hover:scale-105 active:scale-95 font-medium"
>
重命名
</button>
<button
onClick={(e) => {
e.stopPropagation()
handleDeleteDepartment(deptNode.id, deptNode.fullName)
}}
className="px-3 py-1.5 text-xs rounded-lg bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-300 border border-red-200 dark:border-red-700 hover:bg-red-100 dark:hover:bg-red-900/50 transition-all duration-200 ease-out hover:scale-105 active:scale-95 font-medium"
>
删除
</button>
</>
)}
</div>
</div>
{!isCollapsed && deptNode.children.length > 0 && (
<div className="ml-6 space-y-2 animate-in fade-in slide-in-from-top-2 duration-300">
{deptNode.children.map((child) => renderDeptNode(child, depth + 1))}
</div>
)}
</div>
)
}
return renderDeptNode(node)
})
)}
</div>
)}
</div>
</div>
</div>
</div>
</div>
)}
{/* Invitations Management */}
{activeTab === 'invitations' && (
<div className="card-apple overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">邀请管理</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
创建和管理邀请链接,用户可以通过邀请链接注册账号
</p>
</div>
<button
onClick={() => setShowCreateInvitationModal(true)}
className="btn-primary flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
创建邀请链接
</button>
</div>
</div>
<div className="p-6">
{invitations.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
暂无邀请链接,点击上方按钮创建
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">邀请码</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">邀请链接</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">使用次数</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">过期时间</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">状态</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">创建时间</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{invitations.map((invitation) => {
const isExpired = invitation.expiresAt && new Date(invitation.expiresAt) < new Date()
const isUsedUp = (invitation.currentUses || 0) >= (invitation.maxUses || 1)
const isValid = (invitation.isActive !== false) && !isExpired && !isUsedUp
return (
<tr key={invitation.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
<td className="px-4 py-3 text-sm font-mono text-gray-900 dark:text-white">
{invitation.code}
</td>
<td className="px-4 py-3 text-sm">
{invitation.inviteUrl ? (
<div className="flex items-center gap-2">
<input
type="text"
readOnly
value={invitation.inviteUrl}
className="flex-1 px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-white"
/>
<button
onClick={() => {
const text = invitation.inviteUrl!
const copyFallback = () => {
try {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
const ok = document.execCommand('copy')
document.body.removeChild(textarea)
showToast(ok ? '链接已复制到剪贴板' : '复制失败,请手动复制', ok ? 'success' as const : 'error' as const)
} catch {
showToast('复制失败,请手动复制', 'error')
}
}
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text)
.then(() => showToast('链接已复制到剪贴板'))
.catch(copyFallback)
} else {
copyFallback()
}
}}
className="px-2 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
复制
</button>
</div>
) : (
<span className="text-gray-400">-</span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white">
{invitation.currentUses} / {invitation.maxUses}
</td>
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white">
{invitation.expiresAt
? new Date(invitation.expiresAt).toLocaleString('zh-CN')
: '永不过期'}
</td>
<td className="px-4 py-3 text-sm">
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
isValid
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
}`}
>
{!invitation.isActive
? '已禁用'
: isExpired
? '已过期'
: isUsedUp
? '已用完'
: '有效'}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{new Date(invitation.createdAt).toLocaleString('zh-CN')}
</td>
<td className="px-4 py-3 text-sm">
<div className="flex items-center gap-2">
<button
onClick={() => handleToggleInvitationActive(invitation.id, invitation.isActive)}
className={`px-2 py-1 text-xs rounded transition-colors ${
invitation.isActive
? 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-800'
: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-800'
}`}
>
{invitation.isActive ? '禁用' : '启用'}
</button>
<button
onClick={() => handleDeleteInvitation(invitation.id)}
className="px-2 py-1 text-xs bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 rounded hover:bg-red-200 dark:hover:bg-red-800 transition-colors"
>
删除
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
{activeTab === 'users' && (
<div className="card-apple overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">用户管理</h2>
<div className="relative">
<input
type="text"
placeholder="搜索用户名、邮箱或部门..."
value={userSearchTerm}
onChange={(e) => setUserSearchTerm(e.target.value)}
className="w-full px-4 py-2 pl-10 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
/>
<svg className="absolute left-3 top-2.5 w-5 h-5 text-gray-400" 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>
{userSearchTerm && (
<button
onClick={() => setUserSearchTerm('')}
className="absolute right-3 top-2.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<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 className="overflow-hidden p-4">
{userSearchTerm.trim() ? (
// 搜索模式:使用原有的树形结构
treeToRender.length > 0 ? (
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{renderUserNodes(treeToRender)}
</div>
) : (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">暂无匹配的用户</div>
)
) : (
// 正常模式:使用新的组织结构
organizedTree.length > 0 ? (
<div className="space-y-2">
{renderOrganizedUserTree()}
</div>
) : (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">暂无用户</div>
)
)}
</div>
</div>
)}
{/* 监控仪表板 */}
{activeTab === 'monitor' && (
<div className="space-y-8">
{dashboardLoading ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">加载系统监控数据...</p>
</div>
</div>
) : dashboardError ? (
<div className="text-center py-12">
<div className="bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-600 text-red-700 dark:text-red-300 px-4 py-3 rounded">
<strong className="font-bold">错误:</strong>
<span className="block sm:inline">{dashboardError}</span>
</div>
<button
onClick={() => {
setActiveTab('monitor')
fetchData()
}}
className="mt-4 bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
重试
</button>
</div>
) : dashboardData ? (
<>
{/* 系统概览卡片 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center">
<span className="text-white font-medium">👥</span>
</div>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">总用户数</dt>
<dd className="text-lg font-medium text-gray-900 dark:text-white">{dashboardData.overview.totalUsers}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-green-500 rounded-full flex items-center justify-center">
<span className="text-white font-medium">⚡</span>
</div>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">活跃用户</dt>
<dd className="text-lg font-medium text-gray-900 dark:text-white">{dashboardData.overview.activeUsers}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-purple-500 rounded-full flex items-center justify-center">
<span className="text-white font-medium">📦</span>
</div>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">总素材数</dt>
<dd className="text-lg font-medium text-gray-900 dark:text-white">{dashboardData.overview.totalMaterials}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-yellow-500 rounded-full flex items-center justify-center">
<span className="text-white font-medium">🆕</span>
</div>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">本月新用户</dt>
<dd className="text-lg font-medium text-gray-900 dark:text-white">{dashboardData.overview.newUsersThisMonth}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-indigo-500 rounded-full flex items-center justify-center">
<span className="text-white font-medium">📈</span>
</div>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">本月新素材</dt>
<dd className="text-lg font-medium text-gray-900 dark:text-white">{dashboardData.overview.newMaterialsThisMonth}</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
{/* 素材上传趋势(按月统计) */}
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">📊 素材上传趋势(按月统计,最近12个月)</h3>
<div className="h-64 overflow-y-auto">
<div className="space-y-2">
{dashboardData.trends.materialUploads.map((item: any, index: number) => {
const maxCount = Math.max(...dashboardData.trends.materialUploads.map((d: any) => d.count), 1)
return (
<div key={index} className="flex justify-between items-center py-2 border-b border-gray-100 dark:border-gray-700">
<span className="text-sm text-gray-600 dark:text-gray-400">{item.date}</span>
<div className="flex items-center">
<div className="w-32 bg-gray-200 dark:bg-gray-700 rounded-full h-2 mr-3">
<div
className="bg-purple-600 h-2 rounded-full"
style={{ width: `${Math.min(100, (item.count / maxCount) * 100)}%` }}
></div>
</div>
<span className="text-sm font-medium text-gray-900 dark:text-white">{item.count}</span>
</div>
</div>
)
})}
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* 部门分布 */}
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">🏢 部门分布</h3>
<div className="space-y-3">
{dashboardData.distribution.departments.map((item: any, index: number) => (
<div key={index} className="flex justify-between items-center">
<span className="text-sm text-gray-600 dark:text-gray-400">{item.department || '未设置'}</span>
<span className="text-sm font-medium text-gray-900 dark:text-white">{item.count}</span>
</div>
))}
</div>
</div>
{/* 权限分布 */}
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">🛡️ 权限分布</h3>
<div className="space-y-3">
{dashboardData.distribution.roles.map((item: any, index: number) => (
<div key={index} className="flex justify-between items-center">
<span className="text-sm text-gray-600 dark:text-gray-400">
{item.role === 'ADMIN' && item.isSuperAdmin ? '超级管理员' :
item.role === 'ADMIN' ? '管理员' : '普通用户'}
</span>
<span className="text-sm font-medium text-gray-900 dark:text-white">{item.count}</span>
</div>
))}
</div>
</div>
{/* 系统信息 */}
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">⚙️ 系统信息</h3>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600 dark:text-gray-400">注册功能</span>
{currentIsSuperAdmin ? (
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={dashboardData.systemInfo.registrationEnabled}
onChange={async (e) => {
try {
const response = await fetch('/api/system-settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enableRegistration: e.target.checked
})
})
if (response.ok) {
setDashboardData((prev: any) => ({
...prev,
systemInfo: {
...prev.systemInfo,
registrationEnabled: e.target.checked
}
}))
showToast('设置已更新')
} else {
showToast('更新失败', 'error')
}
} catch (error) {
console.error('Update registration setting error:', error)
showToast('更新失败', 'error')
}
}}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
) : (
<span className={`text-sm font-medium ${dashboardData.systemInfo.registrationEnabled ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
{dashboardData.systemInfo.registrationEnabled ? '开启' : '关闭'}
</span>
)}
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600 dark:text-gray-400">用户激活</span>
{currentIsSuperAdmin ? (
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={dashboardData.systemInfo.userActivationRequired}
onChange={async (e) => {
try {
const response = await fetch('/api/system-settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enableUserActivation: e.target.checked
})
})
if (response.ok) {
setDashboardData((prev: any) => ({
...prev,
systemInfo: {
...prev.systemInfo,
userActivationRequired: e.target.checked
}
}))
showToast('设置已更新')
} else {
showToast('更新失败', 'error')
}
} catch (error) {
console.error('Update activation setting error:', error)
showToast('更新失败', 'error')
}
}}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
) : (
<span className={`text-sm font-medium ${dashboardData.systemInfo.userActivationRequired ? 'text-yellow-600 dark:text-yellow-400' : 'text-green-600 dark:text-green-400'}`}>
{dashboardData.systemInfo.userActivationRequired ? '需要' : '关闭'}
</span>
)}
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600 dark:text-gray-400">数据库状态</span>
<span className={`text-sm font-medium ${dashboardData.status.database === 'healthy' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
{dashboardData.status.database === 'healthy' ? '正常' : '异常'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600 dark:text-gray-400">运行时间</span>
<span className="text-sm font-medium text-gray-900 dark:text-white">
{Math.floor(dashboardData.status.uptime / 3600)}小时
</span>
</div>
</div>
</div>
</div>
{/* 最近活动 */}
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">🕒 最近活动</h3>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
素材标题
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
上传者
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
上传时间
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{dashboardData.recentActivity.map((activity: any) => (
<tr key={activity.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
{activity.title}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{activity.uploadedBy.username}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{new Date(activity.createdAt).toLocaleString('zh-CN')}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
) : (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">暂无数据</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>
)}
{/* 新增平台分类模态框 */}
{showAddPlatformModal && (
<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={newPlatformName}
onChange={(e) => setNewPlatformName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleAddPlatform()
} else if (e.key === 'Escape') {
setShowAddPlatformModal(false)
setNewPlatformName('')
}
}}
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={() => {
setShowAddPlatformModal(false)
setNewPlatformName('')
}}
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={handleAddPlatform}
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>
)}
{/* 编辑用户模态框(整合所有操作选项) */}
{editingUser && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
编辑用户
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{editingUser.email}
</p>
</div>
<div className="p-6 space-y-4">
{/* 用户信息显示 */}
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
邮箱
</label>
<p className="text-sm text-gray-900 dark:text-white">{editingUser.email}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
注册时间
</label>
<p className="text-sm text-gray-900 dark:text-white">
{format(new Date(editingUser.createdAt), 'yyyy-MM-dd HH:mm:ss')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
直属上级
</label>
<p className="text-sm text-gray-900 dark:text-white">
{editingUser.department ? (() => {
const managers = findDepartmentManagers(editingUser.department) || []
return managers.length > 0 ? managers.map(m => m.username).join('、') : '无'
})() : '无'}
</p>
</div>
</div>
{/* 用户名编辑 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
用户名
</label>
<input
type="text"
value={editingUsername}
onChange={(e) => setEditingUsername(e.target.value)}
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-blue-500 outline-none"
placeholder="请输入用户名"
/>
</div>
{/* 部门选择 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
选择部门
</label>
<select
value={selectedDepartment}
onChange={(e) => {
const value = e.target.value
setSelectedDepartment(value)
}}
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-blue-500 outline-none"
>
<option value="">-- 未分配部门 --</option>
{departments.map((dept) => (
<option key={dept.id} value={dept.name}>
{dept.name}
</option>
))}
</select>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
更改部门后,该用户的直属上级将自动设置为该部门的管理员。
</p>
</div>
{/* 角色设置 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
角色
</label>
<div className="flex gap-2">
<button
onClick={async () => {
await handleSetRole(editingUser.id, editingUser.role)
setEditingUser(null)
setSelectedDepartment('')
setSelectedParentId('')
setEditingUsername('')
}}
className={`flex-1 px-4 py-2 rounded-lg transition-colors ${
editingUser.role === 'ADMIN'
? 'bg-purple-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
{editingUser.role === 'ADMIN' ? '管理员' : '设为管理员'}
</button>
</div>
</div>
{/* 状态操作 */}
<div className="grid grid-cols-2 gap-3">
<button
onClick={async () => {
await handleToggleActive(editingUser.id, editingUser.isActive)
setEditingUser(null)
setSelectedDepartment('')
setSelectedParentId('')
setEditingUsername('')
}}
className={`px-4 py-2 rounded-lg transition-colors ${
editingUser.isActive
? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-900/50'
: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-900/50'
}`}
>
{editingUser.isActive ? '禁用用户' : '激活用户'}
</button>
<button
onClick={async () => {
await handleToggleDownload(editingUser.id, editingUser.canDownload)
setEditingUser(null)
setSelectedDepartment('')
setSelectedParentId('')
setEditingUsername('')
}}
className={`px-4 py-2 rounded-lg transition-colors ${
editingUser.canDownload
? 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 hover:bg-yellow-200 dark:hover:bg-yellow-900/50'
: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 hover:bg-blue-200 dark:hover:bg-blue-900/50'
}`}
>
{editingUser.canDownload ? '禁止下载' : '允许下载'}
</button>
</div>
{/* 转移最高权限(仅最高权限可见,且不能转移给自己) */}
{sessionUser?.isSuperAdmin && editingUser.email !== sessionUser?.email && (
<div className="pt-2 border-t border-gray-200 dark:border-gray-700">
<button
onClick={() => {
setShowPasswordModal(true)
}}
className="w-full px-3 py-1.5 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex items-center justify-center gap-1.5"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
转移最高权限
</button>
</div>
)}
{/* 删除用户 */}
<div className="pt-2 border-t border-gray-200 dark:border-gray-700">
<button
onClick={() => {
if (confirm('确定要删除这个用户吗?该用户的所有素材也会被删除。')) {
handleDeleteUser(editingUser.id)
setEditingUser(null)
setSelectedDepartment('')
setSelectedParentId('')
setEditingUsername('')
}
}}
className="w-full px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
删除用户
</button>
</div>
{departments.length === 0 && (
<div className="text-sm text-yellow-600 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3">
⚠️ 暂无部门,请先在系统设置中添加部门
</div>
)}
</div>
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex gap-3 justify-end">
<button
onClick={() => {
setEditingUser(null)
setSelectedDepartment('')
setSelectedParentId('')
setEditingUsername('')
}}
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={handleUpdateUser}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
保存
</button>
</div>
</div>
</div>
)}
{/* 创建邀请链接模态框 */}
{showCreateInvitationModal && (
<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="number"
min="1"
value={newInvitationMaxUses}
onChange={(e) => setNewInvitationMaxUses(parseInt(e.target.value) || 1)}
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"
placeholder="1"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
邀请链接最多可以被使用多少次
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
过期天数(可选)
</label>
<input
type="number"
min="1"
value={newInvitationExpiresInDays || ''}
onChange={(e) => setNewInvitationExpiresInDays(e.target.value ? parseInt(e.target.value) : null)}
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"
placeholder="留空表示永不过期"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
邀请链接在多少天后过期,留空表示永不过期
</p>
</div>
<div className="flex gap-3 justify-end">
<button
onClick={() => {
setShowCreateInvitationModal(false)
setNewInvitationMaxUses(1)
setNewInvitationExpiresInDays(null)
}}
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={handleCreateInvitation}
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>
)}
{/* 转移最高权限密码验证模态框 */}
{showPasswordModal && editingUser && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
转移最高权限
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
请输入您的密码以确认此操作
</p>
</div>
<div className="p-6 space-y-4">
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3">
<p className="text-sm text-yellow-800 dark:text-yellow-200">
<strong>警告:</strong>将最高权限转移给 <strong>{editingUser.username}</strong> ({editingUser.email}) 后,当前账号将失去最高权限。
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
请输入您的密码
</label>
<input
type="password"
value={transferPassword}
onChange={(e) => setTransferPassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleTransferSuperAdmin(editingUser.email, transferPassword)
}
}}
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-purple-500 outline-none"
placeholder="请输入密码"
autoFocus
/>
</div>
</div>
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex gap-3 justify-end">
<button
onClick={() => {
setShowPasswordModal(false)
setTransferPassword('')
}}
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={() => {
if (!transferPassword) {
showToast('请输入密码', 'error')
return
}
handleTransferSuperAdmin(editingUser.email, transferPassword)
}}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
确认转移
</button>
</div>
</div>
</div>
)}
</div>
)
}