feat(ui): collapsible sidebar and move logout into top user menu
Build and Deploy / build-and-push (push) Successful in 53s
Build and Deploy / build-and-push (push) Successful in 53s
This commit is contained in:
+192
-80
@@ -1,8 +1,10 @@
|
|||||||
import { Outlet, useLocation, useNavigate } from '@tanstack/react-router'
|
import { Outlet, useLocation, useNavigate } from '@tanstack/react-router'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Bell,
|
Bell,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
FileCode,
|
FileCode,
|
||||||
Globe,
|
Globe,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
@@ -12,17 +14,20 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
Moon,
|
Moon,
|
||||||
Network,
|
Network,
|
||||||
|
PanelLeftClose,
|
||||||
|
PanelLeftOpen,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Server,
|
Server,
|
||||||
Settings,
|
Settings,
|
||||||
Sun,
|
Sun,
|
||||||
Terminal,
|
Terminal,
|
||||||
|
UserRound,
|
||||||
Variable,
|
Variable,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useTheme } from '@/context/ThemeContext'
|
import { useTheme } from '@/context/ThemeContext'
|
||||||
import { api } from '@/api'
|
import { api } from '@/api'
|
||||||
import { usePublicSite, useSentence } from '@/api/hooks'
|
import { usePublicSite, useSentence, useUser } from '@/api/hooks'
|
||||||
import { NodeSwitcher } from '@/components/business/NodeSwitcher'
|
import { NodeSwitcher } from '@/components/business/NodeSwitcher'
|
||||||
import { SystemNotice } from '@/components/business/SystemNotice'
|
import { SystemNotice } from '@/components/business/SystemNotice'
|
||||||
import { ToastHost } from '@/components/business/ToastHost'
|
import { ToastHost } from '@/components/business/ToastHost'
|
||||||
@@ -46,6 +51,8 @@ const NAV_ITEMS = [
|
|||||||
{ path: '/settings', icon: Settings, label: '系统设置', exact: true },
|
{ path: '/settings', icon: Settings, label: '系统设置', exact: true },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
const COLLAPSE_KEY = 'taskpool.sidebar.collapsed'
|
||||||
|
|
||||||
function isActive(pathname: string, path: string, exact: boolean) {
|
function isActive(pathname: string, path: string, exact: boolean) {
|
||||||
if (exact) return pathname === path
|
if (exact) return pathname === path
|
||||||
return pathname === path || pathname.startsWith(path + '/')
|
return pathname === path || pathname.startsWith(path + '/')
|
||||||
@@ -57,11 +64,23 @@ export default function Layout() {
|
|||||||
const { theme, toggleTheme } = useTheme()
|
const { theme, toggleTheme } = useTheme()
|
||||||
const { data: site } = usePublicSite()
|
const { data: site } = usePublicSite()
|
||||||
const { data: sentenceData } = useSentence()
|
const { data: sentenceData } = useSentence()
|
||||||
|
const { data: user } = useUser()
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
const [isMobile, setIsMobile] = useState(false)
|
const [isMobile, setIsMobile] = useState(false)
|
||||||
|
const [collapsed, setCollapsed] = useState(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(COLLAPSE_KEY) === '1'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||||
|
const userMenuRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const title = site?.title || '任务池'
|
const title = site?.title || '任务池'
|
||||||
const sentence = sentenceData?.sentence || '欢迎使用任务池'
|
const sentence = sentenceData?.sentence || '欢迎使用任务池'
|
||||||
|
const username = user?.username || '管理员'
|
||||||
|
const sidebarCollapsed = !isMobile && collapsed
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = title
|
document.title = title
|
||||||
@@ -86,12 +105,32 @@ export default function Layout() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMenuOpen(false)
|
setMenuOpen(false)
|
||||||
|
setUserMenuOpen(false)
|
||||||
}, [location.pathname])
|
}, [location.pathname])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
eventBus.init()
|
eventBus.init()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(COLLAPSE_KEY, collapsed ? '1' : '0')
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [collapsed])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onDocClick(e: MouseEvent) {
|
||||||
|
if (!userMenuRef.current) return
|
||||||
|
if (!userMenuRef.current.contains(e.target as Node)) {
|
||||||
|
setUserMenuOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDocClick)
|
||||||
|
return () => document.removeEventListener('mousedown', onDocClick)
|
||||||
|
}, [])
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
try {
|
try {
|
||||||
await api.auth.logout()
|
await api.auth.logout()
|
||||||
@@ -104,103 +143,176 @@ export default function Layout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen w-full bg-[var(--bg-primary)] text-[var(--text-primary)]">
|
<div className="flex min-h-screen w-full bg-[var(--bg-primary)] text-[var(--text-primary)]">
|
||||||
{isMobile && menuOpen && (
|
{isMobile && menuOpen && (
|
||||||
<div className="fixed inset-0 z-40 bg-black/50 backdrop-blur-[1px]" onClick={() => setMenuOpen(false)} />
|
<div className="fixed inset-0 z-40 bg-black/50 backdrop-blur-[1px]" onClick={() => setMenuOpen(false)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-y-0 left-0 z-50 flex shrink-0 flex-col border-r border-[var(--border)] bg-[var(--bg-secondary)] transition-[width,transform] duration-200 ease-out lg:static lg:translate-x-0',
|
||||||
|
sidebarCollapsed ? 'w-[68px]' : 'w-[200px]',
|
||||||
|
isMobile && !menuOpen ? '-translate-x-full' : 'translate-x-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed inset-y-0 left-0 z-50 flex w-[200px] shrink-0 flex-col border-r border-[var(--border)] bg-[var(--bg-secondary)] transition-transform duration-200 lg:static lg:translate-x-0',
|
'relative flex h-14 items-center border-b border-[var(--border)]',
|
||||||
isMobile && !menuOpen ? '-translate-x-full' : 'translate-x-0',
|
sidebarCollapsed ? 'justify-center px-2' : 'gap-2.5 px-4',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="relative flex h-14 items-center gap-2.5 border-b border-[var(--border)] px-4">
|
<div className="ms-on-accent flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md bg-[var(--bg-accent)] text-[13px] font-bold text-[var(--text-on-accent)]">
|
||||||
<div className="ms-on-accent flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md bg-[var(--bg-accent)] text-[13px] font-bold text-[var(--text-on-accent)]">
|
T
|
||||||
T
|
</div>
|
||||||
</div>
|
{!sidebarCollapsed && (
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate text-[13px] font-semibold tracking-tight">{title}</div>
|
<div className="truncate text-[13px] font-semibold tracking-tight">{title}</div>
|
||||||
<div className="truncate text-[10px] text-[var(--text-muted)]">TaskPool</div>
|
<div className="truncate text-[10px] text-[var(--text-muted)]">TaskPool</div>
|
||||||
</div>
|
</div>
|
||||||
{isMobile && (
|
)}
|
||||||
|
{isMobile && (
|
||||||
|
<button
|
||||||
|
className="rounded-md p-1.5 text-[var(--text-muted)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)]"
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
>
|
||||||
|
<X size={16} strokeWidth={1.5} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className={cn('flex flex-1 flex-col gap-0.5 overflow-y-auto py-3', sidebarCollapsed ? 'px-1.5' : 'px-2.5')}>
|
||||||
|
{NAV_ITEMS.map(({ path, icon: Icon, label, exact }) => {
|
||||||
|
const active = isActive(location.pathname, path, exact)
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
className="rounded-md p-1.5 text-[var(--text-muted)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)]"
|
key={path}
|
||||||
onClick={() => setMenuOpen(false)}
|
title={sidebarCollapsed ? label : undefined}
|
||||||
|
onClick={() => navigate({ to: path })}
|
||||||
|
className={cn(
|
||||||
|
'group relative flex h-9 w-full items-center rounded-md text-[13px] transition-[background,color,border-color] duration-150',
|
||||||
|
sidebarCollapsed ? 'justify-center px-0' : 'gap-2.5 px-2.5',
|
||||||
|
active
|
||||||
|
? 'border border-[var(--border-hover)] bg-[var(--bg-tertiary)] font-medium text-[var(--text-primary)]'
|
||||||
|
: 'border border-transparent text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<X size={16} strokeWidth={1.5} />
|
<Icon
|
||||||
|
size={15}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
className={cn(active ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)] group-hover:text-[var(--text-primary)]')}
|
||||||
|
/>
|
||||||
|
{!sidebarCollapsed && (
|
||||||
|
<>
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
{active ? <span className="ml-auto h-1.5 w-1.5 rounded-full bg-[var(--bg-accent)]" /> : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{sidebarCollapsed && active ? (
|
||||||
|
<span className="absolute right-1 top-1/2 h-1.5 w-1.5 -translate-y-1/2 rounded-full bg-[var(--bg-accent)]" />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* 桌面端:侧栏底部放一个次要折叠入口,主入口在顶栏 */}
|
||||||
|
{!isMobile && (
|
||||||
|
<div className="border-t border-[var(--border)] p-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed((v) => !v)}
|
||||||
|
title={collapsed ? '展开侧边栏' : '收起侧边栏'}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full items-center rounded-md text-[12px] text-[var(--text-muted)] transition-colors hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]',
|
||||||
|
sidebarCollapsed ? 'justify-center' : 'gap-2 px-2.5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{collapsed ? <ChevronRight size={15} strokeWidth={1.5} /> : <ChevronLeft size={15} strokeWidth={1.5} />}
|
||||||
|
{!sidebarCollapsed && <span>收起导航</span>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<header className="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--bg-secondary)_92%,transparent)] px-4 backdrop-blur-md">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
|
{isMobile ? (
|
||||||
|
<button
|
||||||
|
className="rounded-md p-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]"
|
||||||
|
onClick={() => setMenuOpen(true)}
|
||||||
|
title="打开菜单"
|
||||||
|
>
|
||||||
|
<Menu size={18} strokeWidth={1.5} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="rounded-md p-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)]"
|
||||||
|
onClick={() => setCollapsed((v) => !v)}
|
||||||
|
title={collapsed ? '展开侧边栏' : '收起侧边栏'}
|
||||||
|
>
|
||||||
|
{collapsed ? <PanelLeftOpen size={17} strokeWidth={1.5} /> : <PanelLeftClose size={17} strokeWidth={1.5} />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<p className="truncate text-[13px] text-[var(--text-muted)]" title={sentence}>
|
||||||
|
{sentence}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto px-2.5 py-3">
|
<div className="flex items-center gap-1.5">
|
||||||
{NAV_ITEMS.map(({ path, icon: Icon, label, exact }) => {
|
<NodeSwitcher />
|
||||||
const active = isActive(location.pathname, path, exact)
|
<SystemNotice />
|
||||||
return (
|
<Button
|
||||||
<button
|
variant="secondary"
|
||||||
key={path}
|
size="icon"
|
||||||
onClick={() => navigate({ to: path })}
|
onClick={toggleTheme}
|
||||||
className={cn(
|
title={theme === 'light' ? '深色模式' : '浅色模式'}
|
||||||
'group flex h-9 w-full items-center gap-2.5 rounded-md px-2.5 text-[13px] transition-[background,color,border-color] duration-150',
|
className="h-8 w-8"
|
||||||
active
|
>
|
||||||
? 'border border-[var(--border-hover)] bg-[var(--bg-tertiary)] font-medium text-[var(--text-primary)]'
|
{theme === 'light' ? <Moon size={15} strokeWidth={1.5} /> : <Sun size={15} strokeWidth={1.5} />}
|
||||||
: 'border border-transparent text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
size={15}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
className={cn(active ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)] group-hover:text-[var(--text-primary)]')}
|
|
||||||
/>
|
|
||||||
<span className="truncate">{label}</span>
|
|
||||||
{active ? <span className="ml-auto h-1.5 w-1.5 rounded-full bg-[var(--bg-accent)]" /> : null}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="space-y-1 border-t border-[var(--border)] p-2.5">
|
|
||||||
<Button variant="ghost" className="h-9 w-full justify-start gap-2.5 px-2.5 text-[13px]" onClick={logout}>
|
|
||||||
<LogOut size={15} strokeWidth={1.5} />
|
|
||||||
退出登录
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main className="flex min-w-0 flex-1 flex-col">
|
{/* 用户菜单:退出登录放这里,比左下角更干净 */}
|
||||||
<header className="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--bg-secondary)_92%,transparent)] px-4 backdrop-blur-md">
|
<div className="relative" ref={userMenuRef}>
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
<button
|
||||||
{isMobile && (
|
onClick={() => setUserMenuOpen((v) => !v)}
|
||||||
<button
|
className={cn(
|
||||||
className="rounded-md p-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]"
|
'flex h-8 items-center gap-1.5 rounded-md border border-[var(--border)] bg-[var(--bg-primary)] px-2 text-[12px] text-[var(--text-secondary)] transition-colors hover:border-[var(--border-hover)] hover:text-[var(--text-primary)]',
|
||||||
onClick={() => setMenuOpen(true)}
|
userMenuOpen && 'border-[var(--border-hover)] text-[var(--text-primary)]',
|
||||||
>
|
)}
|
||||||
<Menu size={18} strokeWidth={1.5} />
|
title="账户"
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<p className="truncate text-[13px] text-[var(--text-muted)]" title={sentence}>
|
|
||||||
{sentence}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<NodeSwitcher />
|
|
||||||
<SystemNotice />
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="icon"
|
|
||||||
onClick={toggleTheme}
|
|
||||||
title={theme === 'light' ? '深色模式' : '浅色模式'}
|
|
||||||
className="h-8 w-8"
|
|
||||||
>
|
>
|
||||||
{theme === 'light' ? <Moon size={15} strokeWidth={1.5} /> : <Sun size={15} strokeWidth={1.5} />}
|
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-[var(--bg-tertiary)] text-[var(--text-muted)]">
|
||||||
</Button>
|
<UserRound size={12} strokeWidth={1.75} />
|
||||||
</div>
|
</span>
|
||||||
</header>
|
<span className="hidden max-w-[96px] truncate sm:inline">{username}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="ms-page flex-1 overflow-y-auto p-4 lg:p-6">
|
{userMenuOpen && (
|
||||||
<Outlet />
|
<div className="absolute right-0 top-[calc(100%+6px)] z-50 w-44 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--bg-secondary)] shadow-[0_12px_40px_rgba(0,0,0,0.18)]">
|
||||||
|
<div className="border-b border-[var(--border)] px-3 py-2.5">
|
||||||
|
<div className="truncate text-[12px] font-medium text-[var(--text-primary)]">{username}</div>
|
||||||
|
<div className="truncate text-[11px] text-[var(--text-muted)]">已登录</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setUserMenuOpen(false)
|
||||||
|
void logout()
|
||||||
|
}}
|
||||||
|
className="flex h-10 w-full items-center gap-2 px-3 text-[13px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--bg-hover)] hover:text-[var(--danger)]"
|
||||||
|
>
|
||||||
|
<LogOut size={14} strokeWidth={1.5} />
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</header>
|
||||||
<ToastHost />
|
|
||||||
|
<div className="ms-page flex-1 overflow-y-auto p-4 lg:p-6">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<ToastHost />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user