搭建用户体系
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
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-2.5 text-[10px] uppercase tracking-[0.22em] text-secondary">
|
||||
量化工具
|
||||
</div>
|
||||
<div
|
||||
className="mt-3 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user