Some checks failed
CI Check / check (push) Has been cancelled
- 创建统一UI三层架构系统(基础层、颜色层、组合层) - 拆分Admin页面为独立Tab组件(MonitorTab、UsersTab、DepartmentsTab等) - 提取公共逻辑到Hooks(useToast、useDataFetch、useUserOperations等) - 提取工具函数(validators、formatters、api等) - 创建统一弹窗组件(Modal)和useModal Hook - 添加错误边界和统一状态组件 - 完善类型定义和可访问性支持 - 更新文档(优化记录、优化计划、更新日志)
265 lines
11 KiB
TypeScript
265 lines
11 KiB
TypeScript
'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
|
||
|