flnxi-Wu/app/admin/_components/UsersTab.tsx
2025-11-22 17:56:24 +08:00

557 lines
19 KiB
TypeScript
Raw 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.

/**
* 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}
/>
</>
)
}