You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
145 lines
5.5 KiB
145 lines
5.5 KiB
import { NavLink } from 'react-router-dom'
|
|
import {
|
|
LayoutDashboard, Users, LogOut, Settings, FolderOpen,
|
|
Shield, Menu as MenuIcon, Building2, User, ChevronDown,
|
|
Cpu, Key, MessageSquare, BarChart3, Wallet,
|
|
} from 'lucide-react'
|
|
import type { LucideIcon } from 'lucide-react'
|
|
import { useAuth } from '@/contexts/AuthContext'
|
|
import { useState, useRef, useEffect } from 'react'
|
|
import type { MenuItem } from '@/types'
|
|
|
|
const iconMap: Record<string, LucideIcon> = {
|
|
User, LayoutDashboard, Users, FolderOpen, Shield,
|
|
Menu: MenuIcon, Building2, Settings,
|
|
Cpu, Key, MessageSquare, BarChart3, Wallet,
|
|
}
|
|
|
|
function getIcon(iconName: string): LucideIcon {
|
|
return iconMap[iconName] || LayoutDashboard
|
|
}
|
|
|
|
export function Sidebar() {
|
|
const { user, logout, userMenus, currentOrg, userOrgs, switchOrg } = useAuth()
|
|
const [orgDropdownOpen, setOrgDropdownOpen] = useState(false)
|
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
function handleClickOutside(e: MouseEvent) {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
|
setOrgDropdownOpen(false)
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClickOutside)
|
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
}, [])
|
|
|
|
const handleSwitchOrg = async (orgId: number) => {
|
|
await switchOrg(orgId)
|
|
setOrgDropdownOpen(false)
|
|
}
|
|
|
|
const menuItems: MenuItem[] = userMenus.length > 0 ? userMenus : []
|
|
|
|
return (
|
|
<aside className="w-64 h-screen bg-sidebar-bg backdrop-blur-xl border-r border-border flex flex-col fixed left-0 top-0 z-40">
|
|
{/* Logo */}
|
|
<div className="h-16 flex items-center px-6 border-b border-border shrink-0">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-sky-500 to-blue-600 flex items-center justify-center glow-primary">
|
|
<span className="text-lg font-bold text-white font-display">B</span>
|
|
</div>
|
|
<div>
|
|
<h1 className="text-lg font-bold text-foreground font-display leading-tight">BASE</h1>
|
|
<p className="text-xs text-text-muted font-body">管理面板</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Org Switcher */}
|
|
{userOrgs.length > 0 && (
|
|
<div className="px-4 pt-4" ref={dropdownRef}>
|
|
<button
|
|
onClick={() => setOrgDropdownOpen(!orgDropdownOpen)}
|
|
className="w-full flex items-center justify-between px-3 py-2 rounded-lg bg-muted border border-border-secondary text-sm text-text-secondary hover:bg-surface-hover transition-colors"
|
|
>
|
|
<span className="truncate">{currentOrg?.name || '选择机构'}</span>
|
|
<ChevronDown className={cn('h-4 w-4 transition-transform', orgDropdownOpen && 'rotate-180')} />
|
|
</button>
|
|
{orgDropdownOpen && (
|
|
<div className="mt-1 py-1 rounded-lg bg-card border border-border-secondary shadow-xl">
|
|
{userOrgs.map((org) => (
|
|
<button
|
|
key={org.orgId}
|
|
onClick={() => handleSwitchOrg(org.orgId)}
|
|
className={cn(
|
|
'w-full text-left px-3 py-2 text-sm transition-colors',
|
|
currentOrg?.id === org.orgId
|
|
? 'text-sky-400 bg-sky-500/10'
|
|
: 'text-text-secondary hover:text-foreground hover:bg-surface-hover'
|
|
)}
|
|
>
|
|
<div className="truncate">{org.orgName}</div>
|
|
<div className="text-xs text-text-muted">{org.roleName}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Navigation */}
|
|
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
|
|
{menuItems.map((item) => {
|
|
const Icon = getIcon(item.icon)
|
|
return (
|
|
<NavLink
|
|
key={item.path}
|
|
to={item.path}
|
|
className={({ isActive }) =>
|
|
cn(
|
|
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200',
|
|
'font-body text-sm font-medium',
|
|
isActive
|
|
? 'bg-gradient-to-r from-sky-500/20 to-blue-600/20 text-sky-400 border border-sky-500/30'
|
|
: 'text-text-secondary hover:bg-surface-hover hover:text-foreground'
|
|
)
|
|
}
|
|
>
|
|
<Icon className="h-5 w-5" />
|
|
{item.name}
|
|
</NavLink>
|
|
)
|
|
})}
|
|
</nav>
|
|
|
|
{/* User Info */}
|
|
<div className="h-14 flex items-center gap-3 px-5 border-t border-border shrink-0">
|
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-amber-500 to-orange-600 flex items-center justify-center">
|
|
<span className="text-sm font-bold text-foreground font-display">
|
|
{user?.username?.[0]?.toUpperCase() || 'U'}
|
|
</span>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-foreground font-body truncate">
|
|
{user?.username || 'User'}
|
|
</p>
|
|
<p className="text-xs text-text-muted font-body truncate">
|
|
{user?.role || ''}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={logout}
|
|
className="p-1.5 text-text-muted hover:text-red-400 transition-colors"
|
|
title="退出登录"
|
|
>
|
|
<LogOut className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
)
|
|
}
|
|
|
|
function cn(...classes: (string | undefined | false | null)[]) {
|
|
return classes.filter(Boolean).join(' ')
|
|
}
|
|
|