flnxi-Wu/app/admin/_components/users/DepartmentHierarchy.tsx
WuYongJie e4dcb6260a
Some checks failed
CI Check / check (push) Has been cancelled
feat: Admin页面重构 & UI三层架构系统
- 创建统一UI三层架构系统(基础层、颜色层、组合层)
- 拆分Admin页面为独立Tab组件(MonitorTab、UsersTab、DepartmentsTab等)
- 提取公共逻辑到Hooks(useToast、useDataFetch、useUserOperations等)
- 提取工具函数(validators、formatters、api等)
- 创建统一弹窗组件(Modal)和useModal Hook
- 添加错误边界和统一状态组件
- 完善类型定义和可访问性支持
- 更新文档(优化记录、优化计划、更新日志)
2025-11-22 15:17:10 +08:00

265 lines
11 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.

'use client'
import { memo, useMemo } from 'react'
import type { DepartmentNode, DepartmentUserGrouping, User } from '../types'
import { cardStyles, cn } from '@/lib/ui-styles'
interface DepartmentHierarchyProps {
departmentTree: DepartmentNode[]
departmentUsersMap: Map<string, DepartmentUserGrouping>
departmentMaterialCounts: Map<string, number>
collapsedDepartments: Set<string>
onToggleDepartmentCollapse: (name: string) => void
renderUserRow: (user: User, isManager?: boolean) => JSX.Element
totalUsers: number
}
function DepartmentHierarchyComponent({
departmentTree,
departmentUsersMap,
departmentMaterialCounts,
collapsedDepartments,
onToggleDepartmentCollapse,
renderUserRow,
totalUsers,
}: DepartmentHierarchyProps) {
const sections = useMemo(() => {
const visitedDepartments = new Set<string>()
const renderDepartmentUsers = (entry?: DepartmentUserGrouping) => {
const managerCount = entry?.managers.length ?? 0
const regularCount = entry?.regulars.length ?? 0
const total = managerCount + regularCount
if (!total) {
return (
<div
className={cn(
cardStyles.flat,
'px-4 py-2 border-dashed border-2 text-xs text-slate-500 dark:text-slate-400',
)}
>
暂无成员
</div>
)
}
return (
<div className="space-y-3">
{managerCount > 0 && (
<div className="space-y-1">
<div className="text-xs font-semibold text-indigo-600 dark:text-indigo-300">管理员</div>
<div className="space-y-1">{entry!.managers.map((user) => renderUserRow(user, true))}</div>
</div>
)}
{regularCount > 0 && (
<div className="space-y-1">
<div className="text-xs font-semibold text-slate-500 dark:text-slate-400">成员</div>
<div className="space-y-1">{entry!.regulars.map((user) => renderUserRow(user))}</div>
</div>
)}
</div>
)
}
const renderDepartmentBranch = (node: DepartmentNode, depth = 0): JSX.Element | null => {
visitedDepartments.add(node.fullName)
const entry = departmentUsersMap.get(node.fullName)
const memberCount = (entry?.managers.length ?? 0) + (entry?.regulars.length ?? 0)
const materialCount = departmentMaterialCounts.get(node.fullName) || 0
const managerNames = (entry?.managers || []).map((manager) => manager.username).filter(Boolean)
const children = node.children
.map((child) => renderDepartmentBranch(child, depth + 1))
.filter(Boolean) as JSX.Element[]
const isCollapsed = collapsedDepartments.has(node.fullName)
const canToggle = memberCount > 0 || children.length > 0
const depthLevel = Math.min(depth, 2)
const depthContainerClasses =
depthLevel === 0
? 'bg-indigo-50/90 dark:bg-indigo-900/50 border-indigo-400 dark:border-indigo-400'
: depthLevel === 1
? 'bg-indigo-50/70 dark:bg-indigo-900/30 border-indigo-300 dark:border-indigo-500/80'
: 'bg-indigo-50/50 dark:bg-indigo-900/20 border-indigo-200 dark:border-indigo-700/80'
return (
<div key={node.fullName} className="space-y-2">
<div
className={cn(
'group flex items-center justify-between px-4 py-2.5 border-l-[3px] rounded-xl shadow-[0_1px_3px_rgba(15,23,42,0.06)] dark:shadow-[0_1px_3px_rgba(15,23,42,0.55)] transition-all duration-300 ease-out hover:shadow-md hover:scale-[1.01]',
depthContainerClasses,
)}
onClick={() => {
if (!canToggle) return
onToggleDepartmentCollapse(node.fullName)
}}
style={{ marginLeft: depth * 12 }}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
{canToggle ? (
<button
type="button"
className="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded hover:bg-indigo-200/80 dark:hover:bg-indigo-700/80 transition-all duration-200 hover:scale-110"
title={isCollapsed ? '展开' : '折叠'}
onClick={(e) => {
e.stopPropagation()
onToggleDepartmentCollapse(node.fullName)
}}
>
<svg
className={cn(
'w-4 h-4 transform transition-transform duration-300 ease-out text-indigo-600 dark:text-indigo-300',
!isCollapsed && 'rotate-90',
)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
) : (
<span className="flex-shrink-0 w-6 h-6" />
)}
<div className="flex flex-col min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-slate-900 dark:text-white">{node.fullName}</span>
{!!managerNames.length && (
<div className="flex items-center gap-1.5 flex-wrap text-xs text-slate-500 dark:text-slate-400">
<span>管理员:</span>
{managerNames.map((name) => (
<span
key={name}
className="px-2 py-0.5 bg-gradient-to-r from-purple-500 to-indigo-600 text-white text-xs font-semibold rounded-full shadow-sm hover:shadow-md transition-all duration-200 hover:scale-105"
>
{name}
</span>
))}
</div>
)}
</div>
<div className="flex items-center gap-3 mt-0.5 text-xs text-slate-500 dark:text-slate-400">
<span>成员:{memberCount}</span>
<span>素材:{materialCount}</span>
</div>
</div>
</div>
</div>
{!isCollapsed && (
<div className="ml-6 space-y-2 animate-in fade-in slide-in-from-top-2 duration-300">
{renderDepartmentUsers(entry)}
{children}
</div>
)}
</div>
)
}
const sections: JSX.Element[] = []
const rootCollapsed = collapsedDepartments.has('ROOT')
const departmentBranches = departmentTree
.map((node) => {
visitedDepartments.add(node.fullName)
return renderDepartmentBranch(node)
})
.filter(Boolean) as JSX.Element[]
sections.push(
<div key="user-tree-root" className="space-y-2">
<div className="group flex items-center justify-between px-4 py-2.5 bg-gradient-to-r from-indigo-50/90 to-purple-50/90 dark:from-indigo-900/50 dark:to-purple-900/50 border-l-4 border-indigo-500 dark:border-indigo-400 rounded-xl shadow-sm hover:shadow-md transition-all duration-300 ease-out hover:scale-[1.01]">
<div className="flex items-center gap-3">
<button
type="button"
className="flex-shrink-0 w-7 h-7 flex items-center justify-center rounded-xl hover:bg-indigo-200/80 dark:hover:bg-indigo-700/80 transition-all duration-200 hover:scale-110 active:scale-95"
title={rootCollapsed ? '展开' : '折叠'}
onClick={() => onToggleDepartmentCollapse('ROOT')}
>
<svg
className={cn(
'w-5 h-5 transform transition-transform duration-200 text-indigo-600 dark:text-indigo-300',
!rootCollapsed && 'rotate-90',
)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
<div className="flex flex-col">
<span className="text-base font-semibold text-slate-900 dark:text-white">主公司</span>
<span className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">一级分支:{departmentTree.length}</span>
</div>
</div>
<span className="text-xs text-slate-500 dark:text-slate-400">成员总数:{totalUsers}</span>
</div>
{!rootCollapsed && (
<div className="mt-2 space-y-2 ml-6 animate-in fade-in slide-in-from-top-2 duration-300">
{departmentBranches.length ? (
departmentBranches
) : (
<div className="text-xs text-slate-500 dark:text-slate-400 px-4 py-2">暂无分支,请先在架构管理中创建分支</div>
)}
</div>
)}
</div>,
)
const orphanNames = Array.from(departmentUsersMap.keys()).filter(
(name) => name !== '未分配部门' && !visitedDepartments.has(name),
)
orphanNames.forEach((name) => {
const entry = departmentUsersMap.get(name)
const total = (entry?.managers.length ?? 0) + (entry?.regulars.length ?? 0)
if (!total) return
sections.push(
<div key={`orphan-${name}`} className="space-y-2">
<div className="px-4 py-2 bg-amber-50 dark:bg-amber-900/30 border-l-4 border-amber-400 dark:border-amber-500 rounded-lg">
<div className="text-sm font-semibold text-amber-800 dark:text-amber-200">{name}(未在架构中)</div>
</div>
<div className="ml-6">{renderDepartmentUsers(entry)}</div>
</div>,
)
})
const unassignedEntry = departmentUsersMap.get('未分配部门')
const unassignedCount = (unassignedEntry?.managers.length ?? 0) + (unassignedEntry?.regulars.length ?? 0)
if (unassignedCount > 0) {
sections.push(
<div key="unassigned" className="space-y-2">
<div className="px-4 py-2 bg-slate-100 dark:bg-slate-800 border-l-4 border-slate-400 dark:border-slate-600 rounded-lg">
<div className="text-sm font-semibold text-slate-800 dark:text-slate-100">未分配部门</div>
</div>
<div className="ml-6">{renderDepartmentUsers(unassignedEntry)}</div>
</div>,
)
}
if (!sections.length) {
sections.push(
<div key="empty-users" className="text-center py-12 text-slate-500 dark:text-slate-400">
暂无用户
</div>,
)
}
return sections
}, [
collapsedDepartments,
departmentMaterialCounts,
departmentTree,
departmentUsersMap,
onToggleDepartmentCollapse,
renderUserRow,
totalUsers,
])
return <div className="space-y-2">{sections}</div>
}
export const DepartmentHierarchy = memo(DepartmentHierarchyComponent)
export default DepartmentHierarchy