965 lines
38 KiB
TypeScript
965 lines
38 KiB
TypeScript
/**
|
||
* 用户管理组件
|
||
* 提取自 app/admin/page.tsx 的用户管理功能
|
||
*/
|
||
|
||
'use client'
|
||
|
||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||
import { format } from 'date-fns'
|
||
import type { User, Department } from '@/hooks/useAdminState'
|
||
import DepartmentTreeSelect from '@/components/DepartmentTreeSelect'
|
||
import { buildDepartmentDescendants } from '@/lib/departments'
|
||
import { parseFetchError, handleError } from '@/lib/error-handler'
|
||
|
||
// 用户分组类型(参考 flnxi_sucai)
|
||
interface DepartmentUserGrouping {
|
||
managers: User[]
|
||
regulars: User[]
|
||
}
|
||
|
||
interface UserManagementProps {
|
||
users: User[]
|
||
setUsers: React.Dispatch<React.SetStateAction<User[]>>
|
||
departments: Department[]
|
||
userSearchTerm: string
|
||
setUserSearchTerm: React.Dispatch<React.SetStateAction<string>>
|
||
collapsedDepartments: Set<string>
|
||
collapsedAdmins: Set<string>
|
||
toggleDepartmentCollapse: (dept: string) => void
|
||
toggleAdminCollapse: (adminId: string) => void
|
||
showToast: (message: string, type?: 'success' | 'error') => void
|
||
fetchData: () => Promise<void>
|
||
currentUserEmail?: string
|
||
}
|
||
|
||
export default function UserManagement({
|
||
users,
|
||
setUsers,
|
||
departments,
|
||
userSearchTerm,
|
||
setUserSearchTerm,
|
||
collapsedDepartments,
|
||
collapsedAdmins,
|
||
toggleDepartmentCollapse,
|
||
toggleAdminCollapse,
|
||
showToast,
|
||
fetchData,
|
||
currentUserEmail,
|
||
}: UserManagementProps) {
|
||
// 编辑用户部门
|
||
const [editingUser, setEditingUser] = useState<User | null>(null)
|
||
const [selectedDepartment, setSelectedDepartment] = useState('')
|
||
|
||
// 组织用户树形结构数据(根据部门层级结构,与部门管理逻辑统一)
|
||
const getUserTreeData = useCallback(() => {
|
||
// 构建部门ID到部门的映射
|
||
const deptMap = new Map<string, Department>()
|
||
departments.forEach(dept => {
|
||
deptMap.set(dept.id, dept)
|
||
})
|
||
|
||
// 构建部门名称到部门的映射(用于匹配用户的department字段)
|
||
const deptNameMap = new Map<string, Department>()
|
||
departments.forEach(dept => {
|
||
deptNameMap.set(dept.name, dept)
|
||
})
|
||
|
||
// 构建用户到部门的映射(通过department或departmentId)
|
||
const getUserDepartment = (user: User): Department | null => {
|
||
const userAny = user as any
|
||
// 优先使用departmentRef.id(新字段,来自API的include)
|
||
if (userAny.departmentRef?.id) {
|
||
const dept = deptMap.get(userAny.departmentRef.id)
|
||
if (dept) return dept
|
||
}
|
||
// 其次使用departmentId(新字段)
|
||
if (userAny.departmentId) {
|
||
const dept = deptMap.get(userAny.departmentId)
|
||
if (dept) return dept
|
||
}
|
||
// 回退到department名称(旧字段)
|
||
// 注意:department 可能是空字符串 '',需要检查是否为 truthy
|
||
if (user.department && user.department.trim() !== '') {
|
||
const dept = deptNameMap.get(user.department)
|
||
if (dept) return dept
|
||
}
|
||
return null
|
||
}
|
||
|
||
// 获取部门的所有用户(包括管理员和普通用户)- 只包括直接属于该部门的用户
|
||
const getUsersByDepartment = (dept: Department): User[] => {
|
||
return users.filter(u => {
|
||
const userDept = getUserDepartment(u)
|
||
return userDept && userDept.id === dept.id
|
||
})
|
||
}
|
||
|
||
// 获取部门的管理员(通过managerId匹配)
|
||
const getDepartmentManager = (dept: Department): User | null => {
|
||
if (!dept.managerId) return null
|
||
return users.find(u => u.id === dept.managerId) || null
|
||
}
|
||
|
||
// 构建部门用户分组映射(参考 flnxi_sucai 的 departmentUsersMap 逻辑)
|
||
const departmentUsersMap = (() => {
|
||
const map = new Map<string, DepartmentUserGrouping>()
|
||
|
||
// 确保每个部门都有条目
|
||
const ensureEntry = (deptName: string): DepartmentUserGrouping => {
|
||
if (!map.has(deptName)) {
|
||
map.set(deptName, { managers: [], regulars: [] })
|
||
}
|
||
return map.get(deptName)!
|
||
}
|
||
|
||
// 用户排序函数
|
||
const sortUsers = (a: User, b: User): number => {
|
||
const userAnyA = a as any
|
||
const userAnyB = b as any
|
||
// 超级管理员优先
|
||
if (userAnyA.isSuperAdmin && !userAnyB.isSuperAdmin) return -1
|
||
if (!userAnyA.isSuperAdmin && userAnyB.isSuperAdmin) return 1
|
||
// 管理员角色优先
|
||
if ((userAnyA.role === 'SYSTEM_ADMIN' || userAnyA.role === 'DEPT_ADMIN' || userAnyA.role === 'ADMIN') &&
|
||
(userAnyB.role !== 'SYSTEM_ADMIN' && userAnyB.role !== 'DEPT_ADMIN' && userAnyB.role !== 'ADMIN')) return -1
|
||
if ((userAnyA.role !== 'SYSTEM_ADMIN' && userAnyA.role !== 'DEPT_ADMIN' && userAnyA.role !== 'ADMIN') &&
|
||
(userAnyB.role === 'SYSTEM_ADMIN' || userAnyB.role === 'DEPT_ADMIN' || userAnyB.role === 'ADMIN')) 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 userDept = getUserDepartment(user)
|
||
const deptName = userDept ? userDept.name : '未分配部门'
|
||
const entry = ensureEntry(deptName)
|
||
|
||
const userAny = user as any
|
||
// 判断是否为管理员:超级管理员、部门管理员(通过managerId匹配)、管理员角色
|
||
const isManager = userAny.isSuperAdmin ||
|
||
userDept?.managerId === user.id ||
|
||
userAny.role === 'SYSTEM_ADMIN' ||
|
||
userAny.role === 'DEPT_ADMIN' ||
|
||
userAny.role === 'ADMIN'
|
||
|
||
if (isManager) {
|
||
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
|
||
})()
|
||
|
||
// 构建部门树形结构(与部门管理逻辑统一:找出所有顶级部门,然后使用childrenMap)
|
||
const departmentTree = (() => {
|
||
// 找出所有顶级部门(没有parentId或parentId不在列表中)
|
||
const topLevelDepts = departments.filter(dept => !dept.parentId || !departments.find(d => d.id === dept.parentId))
|
||
|
||
// 构建子部门映射
|
||
const childrenMap = new Map<string, Department[]>()
|
||
departments.forEach(dept => {
|
||
if (dept.parentId) {
|
||
if (!childrenMap.has(dept.parentId)) {
|
||
childrenMap.set(dept.parentId, [])
|
||
}
|
||
childrenMap.get(dept.parentId)!.push(dept)
|
||
}
|
||
})
|
||
|
||
// 排序子部门
|
||
childrenMap.forEach((children) => {
|
||
children.sort((a, b) => {
|
||
const sortA = a.sortOrder ?? 0
|
||
const sortB = b.sortOrder ?? 0
|
||
if (sortA !== sortB) return sortA - sortB
|
||
return a.name.localeCompare(b.name)
|
||
})
|
||
})
|
||
|
||
return { topLevelDepts, childrenMap }
|
||
})()
|
||
|
||
// 递归获取部门及其所有子部门的用户总数(用于显示)
|
||
const getTotalUsersCountIncludingChildren = (deptId: string): number => {
|
||
let count = getUsersByDepartment({ id: deptId } as Department).length
|
||
const children = departmentTree.childrenMap.get(deptId) || []
|
||
children.forEach(child => {
|
||
count += getTotalUsersCountIncludingChildren(child.id)
|
||
})
|
||
return count
|
||
}
|
||
|
||
// 获取未分配部门的用户(包括department为空字符串、null、undefined或找不到匹配部门的情况)
|
||
const usersWithoutDepartment = users.filter(u => {
|
||
const userDept = getUserDepartment(u)
|
||
// 如果找不到匹配的部门,则认为是未分配部门的用户
|
||
if (!userDept) return true
|
||
return false
|
||
})
|
||
|
||
return {
|
||
departmentTree,
|
||
getUsersByDepartment,
|
||
getTotalUsersCountIncludingChildren,
|
||
getDepartmentManager,
|
||
getUserDepartment,
|
||
usersWithoutDepartment,
|
||
departmentUsersMap,
|
||
}
|
||
}, [users, departments])
|
||
|
||
// 编辑用户部门
|
||
const handleEditUserDepartment = (user: User) => {
|
||
setEditingUser(user)
|
||
// 获取用户的部门名称(API接受名称)
|
||
setSelectedDepartment(user.department || '')
|
||
}
|
||
|
||
// 更新用户部门
|
||
const handleUpdateUserDepartment = async () => {
|
||
if (!editingUser) return
|
||
|
||
try {
|
||
const response = await fetch('/api/admin/users/update-department', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
userId: editingUser.id,
|
||
department: selectedDepartment,
|
||
}),
|
||
})
|
||
|
||
if (response.ok) {
|
||
// 重新获取数据以同步更新
|
||
await fetchData()
|
||
setEditingUser(null)
|
||
setSelectedDepartment('')
|
||
showToast('部门更新成功')
|
||
} else {
|
||
const parsedError = await parseFetchError(response)
|
||
showToast(parsedError.message, 'error')
|
||
}
|
||
} catch (error) {
|
||
console.error('Update department error:', error)
|
||
const { message } = handleError(error)
|
||
showToast(message, '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.map(user => (
|
||
user.id === id
|
||
? { ...user, status: 'archived', isActive: false, archivedAt: new Date().toISOString() }
|
||
: user
|
||
)))
|
||
showToast('用户已归档')
|
||
} else {
|
||
const parsedError = await parseFetchError(response)
|
||
showToast(parsedError.message, 'error')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error deleting user:', error)
|
||
const { message } = handleError(error)
|
||
showToast(message, 'error')
|
||
}
|
||
}
|
||
|
||
// 切换下载权限
|
||
const handleToggleDownload = async (userId: string, currentCanDownload: boolean) => {
|
||
const action = currentCanDownload ? '关闭' : '开启'
|
||
if (!confirm(`确定要${action}该用户的下载权限吗?`)) return
|
||
|
||
try {
|
||
const response = await fetch('/api/admin/users/toggle-download', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ userId }),
|
||
})
|
||
|
||
if (response.ok) {
|
||
setUsers(prevUsers =>
|
||
prevUsers.map(user =>
|
||
user.id === userId
|
||
? { ...user, canDownload: !currentCanDownload }
|
||
: user
|
||
)
|
||
)
|
||
showToast(`下载权限已${action}`)
|
||
} else {
|
||
const data = await response.json()
|
||
showToast(data.error || '操作失败', 'error')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error toggling download permission:', error)
|
||
showToast('操作失败', 'error')
|
||
}
|
||
}
|
||
|
||
// 切换激活状态
|
||
const handleToggleActive = async (userId: string, currentStatus: boolean) => {
|
||
const action = currentStatus ? '禁用' : '激活'
|
||
if (!confirm(`确定要${action}这个用户吗?`)) return
|
||
|
||
try {
|
||
const response = await fetch('/api/admin/users/toggle-active', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
userId,
|
||
isActive: !currentStatus,
|
||
}),
|
||
})
|
||
|
||
if (response.ok) {
|
||
setUsers(prevUsers =>
|
||
prevUsers.map(user =>
|
||
user.id === userId
|
||
? { ...user, isActive: !currentStatus }
|
||
: user
|
||
)
|
||
)
|
||
showToast(`${action}成功`)
|
||
} else {
|
||
const data = await response.json()
|
||
showToast(data.error || `${action}失败`, 'error')
|
||
}
|
||
} catch (error) {
|
||
console.error('Toggle active error:', error)
|
||
showToast(`${action}失败`, 'error')
|
||
}
|
||
}
|
||
|
||
// 角色选项配置(阶段四:支持所有角色)
|
||
const roleOptions = [
|
||
{ value: 'USER', label: '普通用户', description: '基础权限,可上传和查看素材' },
|
||
{ value: 'ADMIN', label: '管理员', description: '系统管理员(向后兼容,建议使用系统管理员)' },
|
||
{ value: 'DEPT_ADMIN', label: '部门管理员', description: '管理指定部门的用户和素材' },
|
||
{ value: 'SYSTEM_ADMIN', label: '系统管理员', description: '拥有系统级管理权限' },
|
||
]
|
||
|
||
// 获取角色标签
|
||
const getRoleLabel = (role: string) => {
|
||
const option = roleOptions.find(r => r.value === role)
|
||
return option?.label || role
|
||
}
|
||
|
||
// 设置角色(阶段四:支持所有角色)
|
||
const handleSetRole = async (userId: string, newRole: string) => {
|
||
if (!confirm(`确定要将用户角色设置为"${getRoleLabel(newRole)}"吗?`)) return
|
||
|
||
try {
|
||
const response = await fetch('/api/admin/users/set-role', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
userId,
|
||
role: newRole,
|
||
}),
|
||
})
|
||
|
||
if (response.ok) {
|
||
setUsers(prevUsers =>
|
||
prevUsers.map(user =>
|
||
user.id === userId
|
||
? { ...user, role: newRole }
|
||
: user
|
||
)
|
||
)
|
||
showToast(`角色已设置为"${getRoleLabel(newRole)}"`)
|
||
} else {
|
||
const data = await response.json()
|
||
showToast(data.error || '设置角色失败', 'error')
|
||
}
|
||
} catch (error) {
|
||
console.error('Set role error:', error)
|
||
showToast('设置角色失败', 'error')
|
||
}
|
||
}
|
||
|
||
const treeData = getUserTreeData()
|
||
const filteredUsers = useMemo(() => {
|
||
if (!userSearchTerm) return users
|
||
const searchLower = userSearchTerm.toLowerCase()
|
||
return users.filter(user =>
|
||
user.username.toLowerCase().includes(searchLower) ||
|
||
(user.department && user.department.toLowerCase().includes(searchLower)) ||
|
||
((user as any).realName && (user as any).realName.toLowerCase().includes(searchLower)) ||
|
||
(user.email && user.email.toLowerCase().includes(searchLower))
|
||
)
|
||
}, [users, userSearchTerm])
|
||
|
||
// 当有搜索条件时,自动展开包含匹配用户的部门
|
||
useEffect(() => {
|
||
if (!userSearchTerm) return
|
||
|
||
// 找出包含匹配用户的部门ID
|
||
const departmentsWithMatches = new Set<string>()
|
||
|
||
filteredUsers.forEach(user => {
|
||
const userDept = treeData.getUserDepartment(user)
|
||
if (userDept) {
|
||
// 添加该部门及其所有父部门
|
||
let currentDept: Department | null = userDept
|
||
while (currentDept) {
|
||
departmentsWithMatches.add(currentDept.id)
|
||
// 查找父部门
|
||
currentDept = currentDept.parentId
|
||
? departments.find(d => d.id === currentDept!.parentId) || null
|
||
: null
|
||
}
|
||
}
|
||
})
|
||
|
||
// 自动展开包含匹配用户的部门
|
||
departmentsWithMatches.forEach(deptId => {
|
||
if (collapsedDepartments.has(deptId)) {
|
||
toggleDepartmentCollapse(deptId)
|
||
}
|
||
})
|
||
|
||
// 如果有未分配部门的匹配用户,展开未分配部门
|
||
const hasUnassignedMatches = treeData.usersWithoutDepartment.some(u =>
|
||
filteredUsers.some(fu => fu.id === u.id)
|
||
)
|
||
if (hasUnassignedMatches && collapsedDepartments.has('__UNASSIGNED__')) {
|
||
toggleDepartmentCollapse('__UNASSIGNED__')
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [userSearchTerm, filteredUsers])
|
||
|
||
// 检查部门或其子部门是否有匹配的用户(用于搜索时过滤部门)
|
||
const hasMatchingUsersInDepartment = useCallback((dept: Department): boolean => {
|
||
if (!userSearchTerm) return true // 没有搜索条件时,显示所有部门
|
||
|
||
// 检查该部门是否有匹配的用户
|
||
const userGrouping = treeData.departmentUsersMap.get(dept.name) || { managers: [], regulars: [] }
|
||
const allDeptUsers = [...userGrouping.managers, ...userGrouping.regulars]
|
||
const hasMatchingUsers = allDeptUsers.some(u => filteredUsers.some(fu => fu.id === u.id))
|
||
|
||
if (hasMatchingUsers) return true
|
||
|
||
// 检查子部门是否有匹配的用户(递归)
|
||
const children = treeData.departmentTree.childrenMap.get(dept.id) || []
|
||
for (const childDept of children) {
|
||
if (hasMatchingUsersInDepartment(childDept)) {
|
||
return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
}, [userSearchTerm, filteredUsers, treeData])
|
||
|
||
// 递归渲染部门树(使用用户分组逻辑,参考 flnxi_sucai)
|
||
const renderDepartmentNode = (dept: Department, level: number = 0): React.ReactNode => {
|
||
// 如果有搜索条件,只显示包含匹配用户的部门
|
||
if (userSearchTerm && !hasMatchingUsersInDepartment(dept)) {
|
||
return null
|
||
}
|
||
|
||
const children = treeData.departmentTree.childrenMap.get(dept.id) || []
|
||
const hasChildren = children.length > 0
|
||
|
||
// 使用分组后的用户数据
|
||
const userGrouping = treeData.departmentUsersMap.get(dept.name) || { managers: [], regulars: [] }
|
||
const managers = userGrouping.managers
|
||
const regulars = userGrouping.regulars
|
||
|
||
// 过滤用户(如果有搜索条件)
|
||
const filteredManagers = !userSearchTerm
|
||
? managers
|
||
: managers.filter(u => filteredUsers.some(fu => fu.id === u.id))
|
||
const filteredRegulars = !userSearchTerm
|
||
? regulars
|
||
: regulars.filter(u => filteredUsers.some(fu => fu.id === u.id))
|
||
|
||
// 获取包括子部门在内的总用户数
|
||
const totalUsersCount = treeData.getTotalUsersCountIncludingChildren(dept.id)
|
||
|
||
// 统计未激活人数(统计该部门的所有用户,包括管理员和普通成员)
|
||
const allDeptUsers = [...managers, ...regulars]
|
||
const inactiveCount = allDeptUsers.filter(u => {
|
||
const userAny = u as any
|
||
const isActive = userAny.isActive !== false && userAny.status !== 'inactive' && userAny.status !== 'disabled'
|
||
return !isActive
|
||
}).length
|
||
|
||
// 检查是否有用户(包括子部门的用户)或有子部门,如果有就可以展开
|
||
const hasUsers = filteredManagers.length > 0 || filteredRegulars.length > 0
|
||
const hasUsersInChildren = hasChildren && totalUsersCount > allDeptUsers.length
|
||
const canExpand = hasUsers || hasUsersInChildren || hasChildren
|
||
const isCollapsed = collapsedDepartments.has(dept.id)
|
||
const indent = level * 24
|
||
const paddingLeftStyle = { paddingLeft: `${indent + 16}px` }
|
||
|
||
// 渲染用户组(管理员或普通成员)
|
||
const renderUserGroup = (userList: User[], isManagerGroup: boolean, hasManagers: boolean) => {
|
||
if (userList.length === 0) return null
|
||
|
||
return (
|
||
<div className="space-y-2">
|
||
{isManagerGroup && (
|
||
<div className="text-xs font-semibold text-purple-600 dark:text-purple-300 px-4 py-1">
|
||
管理员
|
||
</div>
|
||
)}
|
||
{!isManagerGroup && userList.length > 0 && hasManagers && (
|
||
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 px-4 py-1">
|
||
成员
|
||
</div>
|
||
)}
|
||
<div className="space-y-1">
|
||
{userList.map((user) => (
|
||
<div key={user.id} style={{ paddingLeft: `${(level + 1) * 24 + 16}px` }}>
|
||
<UserRow
|
||
user={user}
|
||
indentLevel={0}
|
||
onEditDepartment={handleEditUserDepartment}
|
||
onSetRole={handleSetRole}
|
||
onToggleActive={handleToggleActive}
|
||
onToggleDownload={handleToggleDownload}
|
||
onDelete={handleDeleteUser}
|
||
roleOptions={roleOptions}
|
||
getRoleLabel={getRoleLabel}
|
||
isManager={isManagerGroup}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div key={dept.id}>
|
||
<div
|
||
className={`border border-gray-200 dark:border-gray-600 rounded-lg overflow-hidden ${level > 0 ? 'mt-2' : ''}`}
|
||
>
|
||
{/* 部门标题行 */}
|
||
<div
|
||
className={`bg-blue-50 dark:bg-blue-900/20 p-4 ${canExpand ? 'cursor-pointer' : ''} hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors`}
|
||
style={paddingLeftStyle}
|
||
onClick={() => canExpand && toggleDepartmentCollapse(dept.id)}
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
{canExpand && (
|
||
<div
|
||
className="flex-shrink-0 w-6 h-6 flex items-center justify-center"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<svg className={`w-4 h-4 transform transition-transform ${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>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||
</svg>
|
||
<span className="font-medium text-gray-900 dark:text-white">{dept.name}</span>
|
||
{dept.code && (
|
||
<span className="text-xs text-gray-500 dark:text-gray-400">({dept.code})</span>
|
||
)}
|
||
{managers.length > 0 && (
|
||
<div className="flex items-center gap-1.5 flex-wrap">
|
||
<span className="text-xs text-gray-500 dark:text-gray-400">管理员:</span>
|
||
{managers.slice(0, 3).map((mgr) => (
|
||
<span
|
||
key={mgr.id}
|
||
className="px-2 py-0.5 bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 text-xs rounded-full"
|
||
>
|
||
{mgr.username}
|
||
</span>
|
||
))}
|
||
{managers.length > 3 && (
|
||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||
等{managers.length}人
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||
({totalUsersCount} 人
|
||
{hasChildren && totalUsersCount > allDeptUsers.length && (
|
||
<span className="text-blue-600 dark:text-blue-400">,含子部门</span>
|
||
)}
|
||
{inactiveCount > 0 && (
|
||
<span className="text-red-600 dark:text-red-400">,{inactiveCount} 人未激活</span>
|
||
)}
|
||
)
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 部门用户列表(使用分组显示) */}
|
||
{!isCollapsed && (
|
||
<div className="space-y-3 py-2">
|
||
{/* 显示"暂无成员"提示 */}
|
||
{filteredManagers.length === 0 && filteredRegulars.length === 0 && (
|
||
<div className="px-4 py-2 border-dashed border-2 border-gray-200 dark:border-gray-700 rounded-lg text-xs text-gray-500 dark:text-gray-400 mx-4">
|
||
暂无成员
|
||
</div>
|
||
)}
|
||
|
||
{/* 管理员组 */}
|
||
{renderUserGroup(filteredManagers, true, filteredManagers.length > 0)}
|
||
|
||
{/* 普通成员组 */}
|
||
{renderUserGroup(filteredRegulars, false, filteredManagers.length > 0)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 递归渲染子部门 */}
|
||
{!isCollapsed && hasChildren && (
|
||
<div className="ml-4">
|
||
{children.map(child => renderDepartmentNode(child, level + 1))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="card-apple overflow-hidden">
|
||
{/* 用户搜索框 */}
|
||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||
<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>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 树形结构显示 */}
|
||
<div className="p-6">
|
||
<div className="space-y-2">
|
||
{/* 根据部门层级结构显示(与部门管理逻辑统一) */}
|
||
{treeData.departmentTree.topLevelDepts.map(dept => renderDepartmentNode(dept, 0))}
|
||
|
||
{/* 未分配部门的用户 */}
|
||
{treeData.usersWithoutDepartment.length > 0 && (() => {
|
||
// 如果有搜索条件,只显示匹配的用户
|
||
const unassignedUsers = !userSearchTerm
|
||
? treeData.usersWithoutDepartment
|
||
: treeData.usersWithoutDepartment.filter(u => filteredUsers.some(fu => fu.id === u.id))
|
||
|
||
// 如果有搜索条件但没有匹配的用户,不显示未分配部门
|
||
if (userSearchTerm && unassignedUsers.length === 0) {
|
||
return null
|
||
}
|
||
|
||
const isCollapsed = collapsedDepartments.has('__UNASSIGNED__')
|
||
|
||
// 统计未激活人数
|
||
const inactiveCount = treeData.usersWithoutDepartment.filter(u => {
|
||
const userAny = u as any
|
||
const isActive = userAny.isActive !== false && userAny.status !== 'inactive' && userAny.status !== 'disabled'
|
||
return !isActive
|
||
}).length
|
||
|
||
return (
|
||
<div className="border border-gray-200 dark:border-gray-600 rounded-lg overflow-hidden">
|
||
<div
|
||
className="bg-gray-50 dark:bg-gray-900/20 p-4 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||
onClick={() => unassignedUsers.length > 0 && toggleDepartmentCollapse('__UNASSIGNED__')}
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
{unassignedUsers.length > 0 && (
|
||
<div
|
||
className="flex-shrink-0 w-6 h-6 flex items-center justify-center"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<svg className={`w-4 h-4 transform transition-transform ${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>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-2">
|
||
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||
</svg>
|
||
<span className="font-medium text-gray-900 dark:text-white">未分配部门</span>
|
||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||
({treeData.usersWithoutDepartment.length} 人
|
||
{inactiveCount > 0 && (
|
||
<span className="text-red-600 dark:text-red-400">,{inactiveCount} 人未激活</span>
|
||
)}
|
||
)
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{!isCollapsed && unassignedUsers.map((user) => (
|
||
<div key={user.id} style={{ paddingLeft: '40px' }}>
|
||
<UserRow
|
||
user={user}
|
||
indentLevel={0}
|
||
onEditDepartment={handleEditUserDepartment}
|
||
onSetRole={handleSetRole}
|
||
onToggleActive={handleToggleActive}
|
||
onToggleDownload={handleToggleDownload}
|
||
onDelete={handleDeleteUser}
|
||
roleOptions={roleOptions}
|
||
getRoleLabel={getRoleLabel}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
})()}
|
||
|
||
{users.length === 0 && (
|
||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||
暂无用户
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 编辑部门弹窗 */}
|
||
{editingUser && (
|
||
<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 p-6 w-96 max-w-[90vw]">
|
||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||
编辑用户部门
|
||
</h3>
|
||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||
用户: {editingUser.username}
|
||
</p>
|
||
<div className="mb-4">
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
部门
|
||
</label>
|
||
<DepartmentTreeSelect
|
||
departments={departments}
|
||
value={selectedDepartment}
|
||
onChange={(deptId) => {
|
||
// 将部门ID转换为部门名称(API接受名称)
|
||
const dept = departments.find(d => d.id === deptId || d.name === deptId)
|
||
setSelectedDepartment(dept ? dept.name : '')
|
||
}}
|
||
placeholder="请选择部门"
|
||
allowEmpty={true}
|
||
emptyLabel="未分配"
|
||
className="w-full"
|
||
/>
|
||
</div>
|
||
<div className="flex justify-end gap-3">
|
||
<button
|
||
onClick={() => {
|
||
setEditingUser(null)
|
||
setSelectedDepartment('')
|
||
}}
|
||
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
onClick={handleUpdateUserDepartment}
|
||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||
>
|
||
保存
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 用户行组件
|
||
interface UserRowProps {
|
||
user: User
|
||
indentLevel: number
|
||
onEditDepartment: (user: User) => void
|
||
onSetRole: (userId: string, newRole: string) => void
|
||
onToggleActive: (userId: string, currentStatus: boolean) => void
|
||
onToggleDownload: (userId: string, currentCanDownload: boolean) => void
|
||
onDelete: (userId: string) => void
|
||
roleOptions: Array<{ value: string; label: string; description: string }>
|
||
getRoleLabel: (role: string) => string
|
||
isManager?: boolean
|
||
}
|
||
|
||
function UserRow({
|
||
user,
|
||
indentLevel,
|
||
onEditDepartment,
|
||
onSetRole,
|
||
onToggleActive,
|
||
onToggleDownload,
|
||
onDelete,
|
||
roleOptions,
|
||
getRoleLabel,
|
||
isManager = false,
|
||
}: UserRowProps) {
|
||
const [showRoleMenu, setShowRoleMenu] = useState(false)
|
||
|
||
// 获取角色标签颜色
|
||
const getRoleColor = (role: string) => {
|
||
switch (role) {
|
||
case 'SYSTEM_ADMIN':
|
||
return 'bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200'
|
||
case 'ADMIN':
|
||
return 'bg-indigo-100 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200'
|
||
case 'DEPT_ADMIN':
|
||
return 'bg-cyan-100 dark:bg-cyan-900 text-cyan-800 dark:text-cyan-200'
|
||
case 'USER':
|
||
default:
|
||
return 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300'
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className={`bg-white dark:bg-gray-800 p-4 border-t border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors ${isManager ? 'bg-purple-50/50 dark:bg-purple-900/10' : ''}`}>
|
||
<div className="flex items-center gap-3">
|
||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||
</svg>
|
||
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm font-medium text-gray-900 dark:text-white">{user.username}</span>
|
||
{/* 管理员标识 */}
|
||
{isManager && (
|
||
<span className="px-2 py-0.5 bg-purple-600 text-white text-xs rounded-full">部门管理员</span>
|
||
)}
|
||
{/* 角色标签(阶段四新增) */}
|
||
<span className={`px-2 py-0.5 text-xs rounded-full ${getRoleColor(user.role)}`}>
|
||
{getRoleLabel(user.role)}
|
||
</span>
|
||
<span className={`px-2 py-0.5 text-xs rounded-full ${
|
||
user.isActive
|
||
? 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'
|
||
: 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200'
|
||
}`}>
|
||
{user.isActive ? '已激活' : '未激活'}
|
||
</span>
|
||
<span className={`px-2 py-0.5 text-xs rounded-full ${
|
||
user.canDownload
|
||
? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200'
|
||
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300'
|
||
}`}>
|
||
{user.canDownload ? '可下载' : '禁止下载'}
|
||
</span>
|
||
</div>
|
||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||
素材: {user._count.materials} | 注册: {format(new Date(user.createdAt), 'yyyy-MM-dd')}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 relative">
|
||
<button
|
||
onClick={() => onEditDepartment(user)}
|
||
className="px-2 py-1 text-xs text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors"
|
||
>
|
||
编辑
|
||
</button>
|
||
{/* 角色选择下拉菜单(阶段四更新) */}
|
||
<div className="relative">
|
||
<button
|
||
onClick={() => setShowRoleMenu(!showRoleMenu)}
|
||
className="px-2 py-1 text-xs text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/30 rounded transition-colors"
|
||
>
|
||
设置角色
|
||
</button>
|
||
{showRoleMenu && (
|
||
<>
|
||
<div
|
||
className="fixed inset-0 z-10"
|
||
onClick={() => setShowRoleMenu(false)}
|
||
/>
|
||
<div className="absolute right-0 mt-1 w-56 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-20">
|
||
<div className="py-1">
|
||
{roleOptions.map((option) => (
|
||
<button
|
||
key={option.value}
|
||
onClick={() => {
|
||
if (user.role !== option.value) {
|
||
onSetRole(user.id, option.value)
|
||
}
|
||
setShowRoleMenu(false)
|
||
}}
|
||
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${
|
||
user.role === option.value
|
||
? 'bg-purple-50 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400'
|
||
: 'text-gray-700 dark:text-gray-300'
|
||
}`}
|
||
>
|
||
<div className="font-medium">{option.label}</div>
|
||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||
{option.description}
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
<button
|
||
onClick={() => onToggleActive(user.id, user.isActive)}
|
||
className="px-2 py-1 text-xs text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
|
||
>
|
||
{user.isActive ? '禁用' : '激活'}
|
||
</button>
|
||
<button
|
||
onClick={() => onToggleDownload(user.id, user.canDownload)}
|
||
className="px-2 py-1 text-xs text-yellow-600 dark:text-yellow-400 hover:bg-yellow-50 dark:hover:bg-yellow-900/30 rounded transition-colors"
|
||
>
|
||
{user.canDownload ? '禁止下载' : '允许下载'}
|
||
</button>
|
||
<button
|
||
onClick={() => onDelete(user.id)}
|
||
className="px-2 py-1 text-xs text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|