gongxiangp2/components/admin/UserManagement.tsx

965 lines
38 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 用户管理组件
* 提取自 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>
)
}