3789 lines
169 KiB
TypeScript
3789 lines
169 KiB
TypeScript
'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 = (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])
|
||
|
||
useEffect(() => {
|
||
if (status === 'authenticated') {
|
||
fetchData()
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [status, activeTab])
|
||
|
||
const loadUsersData = 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')
|
||
}
|
||
}
|
||
|
||
const fetchData = 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)
|
||
}
|
||
}
|
||
|
||
const fetchDepartments = 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)
|
||
}
|
||
}
|
||
|
||
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 fetchInvitations = 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')
|
||
}
|
||
}
|
||
|
||
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) {
|
||
setDepartments(prevDepts => prevDepts.filter(d => d.id !== id))
|
||
showToast('部门删除成功')
|
||
} 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 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
|
||
|
||
// 有部门管理范围(显式 managedDepartment,或者管理员自身所在部门)
|
||
if (currentDepartmentScope) {
|
||
return isDepartmentWithinScope(
|
||
currentDepartmentScope,
|
||
target.department,
|
||
departmentDescendantsMap
|
||
)
|
||
}
|
||
|
||
// 没有部门管理范围时,只能修改自己的信息
|
||
return target.id === currentUserId
|
||
},
|
||
[currentDepartmentScope, currentIsSuperAdmin, 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-50 px-6 py-3 rounded-lg shadow-lg ${
|
||
toast.type === 'success'
|
||
? 'bg-green-500 text-white'
|
||
: 'bg-red-500 text-white'
|
||
} animate-fade-in`}>
|
||
{toast.message}
|
||
</div>
|
||
)}
|
||
|
||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||
<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='直接输入选项,每行一个,例如: 选项1 选项2 选项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='直接输入选项,每行一个,例如: 选项1 选项2 选项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='直接输入选项,每行一个,例如: 选项1 选项2 选项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>
|
||
)
|
||
}
|
||
|