557 lines
19 KiB
TypeScript
557 lines
19 KiB
TypeScript
/**
|
||
* UsersTab 组件
|
||
*
|
||
* 用户管理Tab组件
|
||
* 从主页面拆分出来,负责用户管理功能
|
||
* 包括用户列表、搜索、编辑、删除、权限管理等
|
||
* 使用三层架构样式系统,支持深浅色主题
|
||
*
|
||
* @created 2025-01-XX(拆分自 page.tsx)
|
||
* @component
|
||
*/
|
||
|
||
'use client'
|
||
|
||
import { useCallback, useMemo, useState } from 'react'
|
||
import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles'
|
||
import { useDialog } from '@/components/ui/DialogProvider'
|
||
import type { Department, DepartmentNode, DepartmentUserGrouping, User, UserTreeNode } from './types'
|
||
import { buildDepartmentTree } from '../_utils/departmentTree'
|
||
import DepartmentHierarchy from './users/DepartmentHierarchy'
|
||
import UserTree from './users/UserTree'
|
||
import { useModalState } from '../_hooks/useModalState'
|
||
import UserEditModal from './users/UserEditModal'
|
||
import TransferSuperAdminModal from './users/TransferSuperAdminModal'
|
||
|
||
interface UsersTabProps {
|
||
users: User[]
|
||
departments: Department[]
|
||
currentUserId?: string
|
||
currentUserEmail?: string
|
||
currentIsSuperAdmin?: boolean
|
||
canManageUser: (user: User) => boolean
|
||
onToggleActive: (userId: string, current: boolean) => Promise<void> | void
|
||
onToggleDownload: (userId: string, current: boolean) => Promise<void> | void
|
||
onSetRole: (userId: string, currentRole: User['role']) => Promise<void> | void
|
||
onUpdateUser: (user: User, payload: { username: string; department: string }) => Promise<boolean> | boolean
|
||
onDeleteUser: (userId: string) => Promise<boolean> | boolean
|
||
onTransferSuperAdmin: (targetEmail: string, password: string) => Promise<boolean> | boolean
|
||
}
|
||
|
||
export default function UsersTab({
|
||
users,
|
||
departments,
|
||
currentUserId,
|
||
currentUserEmail,
|
||
currentIsSuperAdmin,
|
||
canManageUser,
|
||
onToggleActive,
|
||
onToggleDownload,
|
||
onSetRole,
|
||
onUpdateUser,
|
||
onDeleteUser,
|
||
onTransferSuperAdmin,
|
||
}: UsersTabProps) {
|
||
const styles = useThemeStyles()
|
||
const theme = useTheme()
|
||
const [searchTerm, setSearchTerm] = useState('')
|
||
const [collapsedUserNodeIds, setCollapsedUserNodeIds] = useState<Set<string>>(new Set())
|
||
const [collapsedDepartments, setCollapsedDepartments] = useState<Set<string>>(new Set())
|
||
const [editingUsername, setEditingUsername] = useState('')
|
||
const [selectedDepartment, setSelectedDepartment] = useState('')
|
||
const [transferPassword, setTransferPassword] = useState('')
|
||
const editingUserModal = useModalState<User>()
|
||
const passwordModal = useModalState()
|
||
const editingUser = editingUserModal.data
|
||
|
||
// 根据主题选择颜色类名
|
||
const badgeActive = theme === 'dark' ? 'bg-green-900/30 text-green-300' : 'bg-green-100 text-green-700'
|
||
const badgeInactive = theme === 'dark' ? 'bg-rose-900/30 text-rose-300' : 'bg-rose-100 text-rose-700'
|
||
const badgeRole = theme === 'dark' ? 'bg-blue-900/30 text-blue-300' : 'bg-blue-100 text-blue-700'
|
||
const badgeNoRole = theme === 'dark' ? 'bg-slate-800 text-slate-200' : 'bg-slate-200 text-slate-700'
|
||
const textDesc = theme === 'dark' ? 'text-slate-400' : 'text-slate-500'
|
||
const textSuperAdmin = theme === 'dark' ? 'text-indigo-100 group-hover:text-indigo-200' : 'text-indigo-900 group-hover:text-indigo-700'
|
||
const textNormal = theme === 'dark' ? 'text-white group-hover:text-slate-200' : 'text-slate-900 group-hover:text-slate-700'
|
||
|
||
const userLookup = useMemo(() => {
|
||
const map = new Map<string, User>()
|
||
users.forEach((user) => map.set(user.id, user))
|
||
return map
|
||
}, [users])
|
||
|
||
const { treeRoots } = useMemo(() => {
|
||
if (!users.length) {
|
||
return { treeRoots: [] as UserTreeNode[] }
|
||
}
|
||
|
||
const childMap = new Map<string, string[]>()
|
||
users.forEach((user) => {
|
||
if (user?.id) {
|
||
childMap.set(user.id, [])
|
||
}
|
||
})
|
||
|
||
const rootIds: string[] = []
|
||
users.forEach((user) => {
|
||
if (!user?.id) return
|
||
const parentId = user.parentId
|
||
if (parentId && childMap.has(parentId)) {
|
||
childMap.get(parentId)!.push(user.id)
|
||
} else {
|
||
rootIds.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(rootIds) }
|
||
}, [userLookup, users])
|
||
|
||
const toggleUserNodeCollapse = useCallback((nodeId: string) => {
|
||
setCollapsedUserNodeIds((prev) => {
|
||
const next = new Set(prev)
|
||
if (next.has(nodeId)) {
|
||
next.delete(nodeId)
|
||
} else {
|
||
next.add(nodeId)
|
||
}
|
||
return next
|
||
})
|
||
}, [])
|
||
|
||
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 handleEditUser = useCallback(
|
||
(user: User) => {
|
||
editingUserModal.open(user)
|
||
setSelectedDepartment(user.department || '')
|
||
setEditingUsername(user.username || user.email || '')
|
||
},
|
||
[editingUserModal],
|
||
)
|
||
|
||
const handleCloseTransferModal = useCallback(() => {
|
||
passwordModal.close()
|
||
setTransferPassword('')
|
||
}, [passwordModal])
|
||
|
||
const handleCloseEditModal = useCallback(() => {
|
||
editingUserModal.close()
|
||
setSelectedDepartment('')
|
||
setEditingUsername('')
|
||
handleCloseTransferModal()
|
||
}, [editingUserModal, handleCloseTransferModal])
|
||
|
||
const handleSaveUser = useCallback(async () => {
|
||
if (!editingUser) return
|
||
const success = await onUpdateUser(editingUser, {
|
||
username: editingUsername,
|
||
department: selectedDepartment,
|
||
})
|
||
if (success) {
|
||
handleCloseEditModal()
|
||
}
|
||
}, [editingUser, editingUsername, onUpdateUser, selectedDepartment, handleCloseEditModal])
|
||
|
||
const handleModalRole = useCallback(async () => {
|
||
if (!editingUser) return
|
||
await onSetRole(editingUser.id, editingUser.role)
|
||
handleCloseEditModal()
|
||
}, [editingUser, onSetRole, handleCloseEditModal])
|
||
|
||
const handleModalActive = useCallback(async () => {
|
||
if (!editingUser) return
|
||
await onToggleActive(editingUser.id, editingUser.isActive)
|
||
handleCloseEditModal()
|
||
}, [editingUser, onToggleActive, handleCloseEditModal])
|
||
|
||
const handleModalDownload = useCallback(async () => {
|
||
if (!editingUser) return
|
||
await onToggleDownload(editingUser.id, editingUser.canDownload)
|
||
handleCloseEditModal()
|
||
}, [editingUser, onToggleDownload, handleCloseEditModal])
|
||
|
||
const { confirm } = useDialog()
|
||
const handleDeleteUserInternal = useCallback(async () => {
|
||
if (!editingUser) return
|
||
const confirmed = await confirm({
|
||
title: '确认删除',
|
||
message: '确定要删除这个用户吗?该用户的所有素材也会被删除。',
|
||
variant: 'danger',
|
||
})
|
||
if (!confirmed) return
|
||
const success = await onDeleteUser(editingUser.id)
|
||
if (success) {
|
||
handleCloseEditModal()
|
||
}
|
||
}, [editingUser, onDeleteUser, handleCloseEditModal, confirm])
|
||
|
||
const showTransferButton = useMemo(
|
||
() => Boolean(currentIsSuperAdmin && editingUser && editingUser.email !== currentUserEmail),
|
||
[currentIsSuperAdmin, editingUser, currentUserEmail]
|
||
)
|
||
|
||
const handleOpenTransferModal = useCallback(() => {
|
||
if (!editingUser || !showTransferButton) return
|
||
setTransferPassword('')
|
||
passwordModal.open()
|
||
}, [editingUser, passwordModal, showTransferButton])
|
||
|
||
const handleConfirmTransfer = useCallback(async () => {
|
||
if (!editingUser) return
|
||
const success = await onTransferSuperAdmin(editingUser.email, transferPassword)
|
||
if (success) {
|
||
handleCloseTransferModal()
|
||
handleCloseEditModal()
|
||
}
|
||
}, [editingUser, onTransferSuperAdmin, transferPassword, handleCloseTransferModal, handleCloseEditModal])
|
||
|
||
const treeToRender = useMemo(() => {
|
||
const term = searchTerm.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[] =>
|
||
nodes
|
||
.map((node) => {
|
||
const filteredChildren = filterNodes(node.children)
|
||
return matchUser(node.user) || filteredChildren.length
|
||
? { user: node.user, children: filteredChildren }
|
||
: null
|
||
})
|
||
.filter((node): node is UserTreeNode => Boolean(node))
|
||
|
||
return filterNodes(treeRoots)
|
||
}, [treeRoots, searchTerm])
|
||
|
||
const departmentTree = useMemo(
|
||
() => buildDepartmentTree(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 || 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 hasDepartments = departments.length > 0
|
||
|
||
const editingUserDepartment = editingUser?.department ?? ''
|
||
|
||
const managerNames = useMemo(() => {
|
||
if (!editingUserDepartment) return []
|
||
return users
|
||
.filter((candidate) => candidate.managedDepartment === editingUserDepartment)
|
||
.map((manager) => manager.username)
|
||
.filter(Boolean) as string[]
|
||
}, [editingUserDepartment, users])
|
||
|
||
const renderStatusBadges = (user: User) => (
|
||
<>
|
||
<span
|
||
className={cn(
|
||
'px-2 py-0.5 text-xs rounded-full',
|
||
user.isActive
|
||
? badgeActive
|
||
: badgeInactive,
|
||
)}
|
||
>
|
||
{user.isActive ? '已激活' : '未激活'}
|
||
</span>
|
||
<span
|
||
className={cn(
|
||
'px-2 py-0.5 text-xs rounded-full',
|
||
user.canDownload
|
||
? badgeRole
|
||
: badgeNoRole,
|
||
)}
|
||
>
|
||
{user.canDownload ? '可下载' : '禁止下载'}
|
||
</span>
|
||
</>
|
||
)
|
||
|
||
const renderActionButtons = useCallback((user: User, options?: { showRoleToggle?: boolean }) => {
|
||
const allowed = canManageUser(user)
|
||
if (!allowed && user.id !== currentUserId) {
|
||
return <span className={cn('text-[11px]', textDesc)}>无权限修改</span>
|
||
}
|
||
|
||
const actionButton = (
|
||
label: string,
|
||
className: string,
|
||
onClick: () => void,
|
||
visible: boolean = true,
|
||
) => {
|
||
if (!visible) return null
|
||
return (
|
||
<button
|
||
type="button"
|
||
disabled={!allowed}
|
||
className={cn(
|
||
className,
|
||
styles.buttonModifiers.compactXs,
|
||
!allowed && 'opacity-40 cursor-not-allowed pointer-events-none',
|
||
)}
|
||
onClick={() => {
|
||
if (!allowed) return
|
||
onClick()
|
||
}}
|
||
>
|
||
{label}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{actionButton(
|
||
'编辑',
|
||
styles.buttonStyles.secondary,
|
||
() => handleEditUser(user),
|
||
)}
|
||
{actionButton(
|
||
user.isActive ? '禁用' : '激活',
|
||
user.isActive ? styles.buttonStyles.danger : styles.buttonStyles.success,
|
||
() => onToggleActive(user.id, user.isActive),
|
||
)}
|
||
{actionButton(
|
||
user.canDownload ? '禁止下载' : '允许下载',
|
||
styles.buttonStyles.secondary,
|
||
() => onToggleDownload(user.id, user.canDownload),
|
||
)}
|
||
{actionButton(
|
||
user.role === 'ADMIN' ? '取消管理员' : '设为管理员',
|
||
styles.buttonStyles.rainbow,
|
||
() => onSetRole(user.id, user.role),
|
||
options?.showRoleToggle !== false && !user.isSuperAdmin,
|
||
)}
|
||
</div>
|
||
)
|
||
}, [canManageUser, currentUserId, handleEditUser, onToggleActive, onToggleDownload, onSetRole])
|
||
|
||
const renderUserRow = useCallback(
|
||
(user: User, isManager = false) => (
|
||
<div key={user.id} className={cn(styles.cardStyles.hover, 'group flex items-start gap-3 cursor-pointer')}>
|
||
<div className="flex-1 space-y-1">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span
|
||
className={cn(
|
||
'transition-all duration-200 text-sm',
|
||
isManager || user.isSuperAdmin
|
||
? cn('font-semibold', textSuperAdmin)
|
||
: cn('font-medium', textNormal),
|
||
)}
|
||
>
|
||
{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={cn('text-xs', textDesc)}>素材:{user._count.materials}</div>
|
||
</div>
|
||
{renderActionButtons(user)}
|
||
</div>
|
||
),
|
||
[renderActionButtons],
|
||
)
|
||
|
||
const showSearchResults = Boolean(searchTerm.trim())
|
||
|
||
return (
|
||
<>
|
||
<div className={cn(styles.spacingStyles.section)}>
|
||
<section
|
||
className={cn(
|
||
styles.cardStyles.large,
|
||
`bg-gradient-to-br ${theme === 'dark' ? 'from-slate-900 via-slate-800 to-indigo-900' : 'from-slate-50 via-blue-50 to-indigo-50'}`,
|
||
)}
|
||
>
|
||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||
<div className="space-y-1.5">
|
||
<p className="text-sm font-semibold tracking-wide text-slate-900 dark:text-white/90">成员目录</p>
|
||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||
结合组织树与搜索框,可按姓名、邮箱或部门快速定位管理员与成员。
|
||
</p>
|
||
</div>
|
||
<div className="relative w-full md:w-[320px] lg:w-[360px]">
|
||
<input
|
||
type="text"
|
||
placeholder="搜索用户名、邮箱或部门..."
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
className={cn(styles.inputStyles.base, 'pl-11 pr-10')}
|
||
/>
|
||
<svg className={cn('absolute left-4 top-3 w-5 h-5', theme === 'dark' ? 'text-slate-500' : 'text-slate-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>
|
||
{searchTerm && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setSearchTerm('')}
|
||
className={cn(
|
||
styles.buttonStyles.ghost,
|
||
styles.buttonModifiers.icon,
|
||
'absolute right-2 top-2',
|
||
theme === 'dark' ? 'text-slate-400 hover:text-slate-200' : 'text-slate-500 hover:text-slate-700',
|
||
)}
|
||
aria-label="清空成员搜索"
|
||
>
|
||
<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>
|
||
</section>
|
||
|
||
<section className={cn(styles.cardStyles.base, 'overflow-hidden')}>
|
||
<div className="overflow-hidden p-4">
|
||
{showSearchResults ? (
|
||
treeToRender.length > 0 ? (
|
||
<UserTree
|
||
nodes={treeToRender}
|
||
collapsedNodeIds={collapsedUserNodeIds}
|
||
onToggleUserNode={toggleUserNodeCollapse}
|
||
searchTerm={searchTerm}
|
||
renderStatusBadges={renderStatusBadges}
|
||
renderActionButtons={renderActionButtons}
|
||
/>
|
||
) : (
|
||
<div className={cn('text-center py-12', textDesc)}>暂无匹配的用户</div>
|
||
)
|
||
) : (
|
||
<DepartmentHierarchy
|
||
departmentTree={departmentTree}
|
||
departmentUsersMap={departmentUsersMap}
|
||
departmentMaterialCounts={departmentMaterialCounts}
|
||
collapsedDepartments={collapsedDepartments}
|
||
onToggleDepartmentCollapse={toggleDepartmentCollapse}
|
||
renderUserRow={renderUserRow}
|
||
totalUsers={users.length}
|
||
/>
|
||
)}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
<UserEditModal
|
||
isOpen={editingUserModal.isOpen}
|
||
user={editingUser}
|
||
departments={departments}
|
||
editingUsername={editingUsername}
|
||
selectedDepartment={selectedDepartment}
|
||
managerNames={managerNames}
|
||
showTransferButton={showTransferButton}
|
||
onChangeUsername={setEditingUsername}
|
||
onChangeDepartment={setSelectedDepartment}
|
||
onClose={handleCloseEditModal}
|
||
onSave={handleSaveUser}
|
||
onToggleRole={handleModalRole}
|
||
onToggleActive={handleModalActive}
|
||
onToggleDownload={handleModalDownload}
|
||
onDelete={handleDeleteUserInternal}
|
||
onOpenTransferModal={handleOpenTransferModal}
|
||
hasDepartments={hasDepartments}
|
||
/>
|
||
<TransferSuperAdminModal
|
||
isOpen={passwordModal.isOpen}
|
||
targetUser={editingUser}
|
||
password={transferPassword}
|
||
onClose={handleCloseTransferModal}
|
||
onChangePassword={setTransferPassword}
|
||
onConfirm={handleConfirmTransfer}
|
||
/>
|
||
</>
|
||
)
|
||
}
|
||
|