Files
stock/frontend/src/components/Layout.tsx
T

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