152 lines
5.3 KiB
TypeScript
152 lines
5.3 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
|
|
import {
|
|
LayoutDashboard,
|
|
Users,
|
|
User,
|
|
LogOut,
|
|
Moon,
|
|
Sun,
|
|
Menu,
|
|
} from 'lucide-react'
|
|
import { api, UserInfo } from '@/lib/api'
|
|
import { clearAuth, canManageUsers, roleLabel } from '@/lib/auth'
|
|
import { cn } from '@/lib/cn'
|
|
import { useTheme } from './ThemeProvider'
|
|
|
|
const BRAND = '#8B5CF6'
|
|
|
|
export function Layout() {
|
|
const { resolved, setTheme, theme } = useTheme()
|
|
const isDark = resolved === 'dark'
|
|
const navigate = useNavigate()
|
|
const [user, setUser] = useState<UserInfo | null>(null)
|
|
const [mobileOpen, setMobileOpen] = useState(false)
|
|
|
|
useEffect(() => {
|
|
const token = localStorage.getItem('access_token')
|
|
if (!token) {
|
|
setUser(null)
|
|
return
|
|
}
|
|
api.me()
|
|
.then(setUser)
|
|
.catch(() => {
|
|
clearAuth()
|
|
setUser(null)
|
|
})
|
|
}, [navigate])
|
|
|
|
const handleLogout = async () => {
|
|
try {
|
|
await api.logout()
|
|
} catch {}
|
|
clearAuth()
|
|
navigate('/login', { replace: true })
|
|
}
|
|
|
|
const navItems = [
|
|
{ to: '/', label: '看板', icon: LayoutDashboard },
|
|
...(user && canManageUsers(user.role) ? [{ to: '/users', label: '用户管理', icon: Users }] : []),
|
|
{ to: '/profile', label: '个人中心', icon: User },
|
|
]
|
|
|
|
const toggleTheme = () => {
|
|
if (theme === 'system') {
|
|
setTheme(isDark ? 'light' : 'dark')
|
|
} else {
|
|
setTheme(theme === 'dark' ? 'light' : 'dark')
|
|
}
|
|
}
|
|
|
|
const sidebar = (
|
|
<aside className="border-r border-border bg-surface flex flex-col h-full min-h-0 overflow-hidden w-56 lg:w-60">
|
|
<div className="px-5 py-5 border-b border-border shrink-0">
|
|
<div className="flex items-center gap-2.5">
|
|
<div
|
|
className="h-7 w-7 rounded-lg flex items-center justify-center text-white font-bold text-sm"
|
|
style={{ background: BRAND }}
|
|
>
|
|
A
|
|
</div>
|
|
<div className="font-mono font-bold text-[13px] tracking-[0.06em] text-foreground leading-tight">
|
|
<div>A股</div>
|
|
<div>工作台</div>
|
|
</div>
|
|
</div>
|
|
<div
|
|
className="mt-4 h-px"
|
|
style={{ background: `linear-gradient(90deg, ${BRAND}88, transparent 80%)` }}
|
|
/>
|
|
</div>
|
|
|
|
<nav className="flex-1 min-h-0 overflow-y-auto px-2 py-3 space-y-0.5">
|
|
{navItems.map(({ to, label, icon: Icon }) => (
|
|
<NavLink
|
|
key={to}
|
|
to={to}
|
|
onClick={() => setMobileOpen(false)}
|
|
className={({ isActive }) =>
|
|
cn(
|
|
'flex items-center gap-3 px-3 py-2 rounded-btn text-sm transition-colors duration-150 ease-smooth',
|
|
isActive
|
|
? 'bg-elevated text-foreground font-medium'
|
|
: 'text-foreground/80 hover:bg-elevated hover:text-foreground',
|
|
)
|
|
}
|
|
>
|
|
<Icon className="h-4 w-4 shrink-0" />
|
|
<span className="flex-1">{label}</span>
|
|
</NavLink>
|
|
))}
|
|
</nav>
|
|
|
|
<div className="border-t border-border px-3 py-3 shrink-0 space-y-2">
|
|
{user && (
|
|
<div className="px-3 py-2 rounded-btn bg-elevated/50">
|
|
<div className="text-sm font-medium text-foreground truncate">{user.username}</div>
|
|
<div className="text-[10px] text-secondary">{roleLabel(user.role)}</div>
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={toggleTheme}
|
|
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-btn text-xs text-secondary hover:bg-elevated hover:text-foreground transition-colors"
|
|
>
|
|
{isDark ? <Sun className="h-3.5 w-3.5" /> : <Moon className="h-3.5 w-3.5" />}
|
|
{isDark ? '浅色' : '深色'}
|
|
</button>
|
|
<button
|
|
onClick={handleLogout}
|
|
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-btn text-xs text-danger hover:bg-danger/10 transition-colors"
|
|
>
|
|
<LogOut className="h-3.5 w-3.5" />
|
|
退出
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
)
|
|
|
|
return (
|
|
<div className="h-screen flex flex-col lg:grid lg:grid-cols-[14rem_1fr] bg-base text-foreground overflow-hidden">
|
|
<div className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-border bg-surface shrink-0">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="h-6 w-6 rounded-md flex items-center justify-center text-white text-xs font-bold" style={{ background: BRAND }}>A</div>
|
|
<span className="text-sm font-semibold">A股工作台</span>
|
|
</div>
|
|
<button onClick={() => setMobileOpen(!mobileOpen)} className="p-2 rounded-btn hover:bg-elevated">
|
|
<Menu className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
<div className={cn('fixed inset-0 z-40 lg:static lg:block', mobileOpen ? 'block' : 'hidden')}>
|
|
<div className="absolute inset-0 bg-black/50 lg:hidden" onClick={() => setMobileOpen(false)} />
|
|
<div className="relative z-10 h-full max-w-[14rem]">{sidebar}</div>
|
|
</div>
|
|
<main className="flex-1 min-h-0 overflow-auto scrollbar-gutter-stable p-6">
|
|
<Outlet />
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|