140 lines
5.4 KiB
TypeScript
140 lines
5.4 KiB
TypeScript
import { useRef, useEffect } from 'react'
|
|
import { useNavigate, useLocation } from 'react-router-dom'
|
|
import { X, ChevronLeft, ChevronRight } from 'lucide-react'
|
|
import { useTabsStore } from '@/store/tabs'
|
|
import { useAuthStore } from '@/store/auth'
|
|
import { cn } from '@/lib/utils'
|
|
import {
|
|
ContextMenu, ContextMenuContent, ContextMenuItem,
|
|
ContextMenuSeparator, ContextMenuTrigger,
|
|
} from '@/components/ui/context-menu'
|
|
import type { SystemMenu } from '@/api/system'
|
|
|
|
/** Resolve page title from menu tree by path */
|
|
function resolveTitle(menus: SystemMenu[], path: string): string {
|
|
for (const m of menus) {
|
|
if (m.path === path) return m.title || m.name
|
|
if (m.children?.length) {
|
|
const found = resolveTitle(m.children, path)
|
|
if (found) return found
|
|
}
|
|
}
|
|
return ''
|
|
}
|
|
|
|
export default function TabBar() {
|
|
const navigate = useNavigate()
|
|
const location = useLocation()
|
|
const menus = useAuthStore(s => s.menus)
|
|
const { tabs, activeTab, addTab, removeTab, setActiveTab, closeOthers, closeAll, closeRight, closeLeft } = useTabsStore()
|
|
const scrollRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Auto-register tab on route change
|
|
useEffect(() => {
|
|
const path = location.pathname
|
|
if (path === '/login') return
|
|
const title = resolveTitle(menus || [], path)
|
|
|| path.split('/').filter(Boolean).pop()?.replace(/^\w/, c => c.toUpperCase()) || 'Page'
|
|
addTab({ path, title, closable: path !== '/dashboard' })
|
|
}, [location.pathname, menus])
|
|
|
|
const handleClick = (path: string) => {
|
|
setActiveTab(path)
|
|
navigate(path)
|
|
}
|
|
|
|
const handleClose = (e: React.MouseEvent, path: string) => {
|
|
e.stopPropagation()
|
|
const next = removeTab(path)
|
|
navigate(next)
|
|
}
|
|
|
|
const handleCloseOthers = (path: string) => {
|
|
closeOthers(path)
|
|
navigate(path)
|
|
}
|
|
|
|
const handleCloseAll = () => {
|
|
const home = closeAll()
|
|
navigate(home)
|
|
}
|
|
|
|
const scroll = (dir: 'left' | 'right') => {
|
|
scrollRef.current?.scrollBy({ left: dir === 'left' ? -200 : 200, behavior: 'smooth' })
|
|
}
|
|
|
|
const showArrows = tabs.length > 8
|
|
|
|
return (
|
|
<div className="flex items-center h-[38px] bg-white/20 dark:bg-black/10 border-b border-white/20 dark:border-white/5 px-1 gap-0.5 select-none">
|
|
{showArrows && (
|
|
<button onClick={() => scroll('left')} className="shrink-0 flex items-center justify-center w-6 h-6 rounded hover:bg-white/40 dark:hover:bg-white/10 text-muted-foreground">
|
|
<ChevronLeft className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
|
|
<div ref={scrollRef} className="flex-1 flex items-center gap-0.5 overflow-x-auto scrollbar-none">
|
|
{tabs.map(tab => (
|
|
<ContextMenu key={tab.path}>
|
|
<ContextMenuTrigger>
|
|
<button
|
|
onClick={() => handleClick(tab.path)}
|
|
className={cn(
|
|
'group relative flex items-center gap-1.5 h-[28px] px-3 rounded-md text-xs font-medium whitespace-nowrap transition-all duration-200',
|
|
activeTab === tab.path
|
|
? 'bg-white dark:bg-slate-800 text-foreground shadow-sm border border-white/60 dark:border-white/10'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-white/50 dark:hover:bg-white/5'
|
|
)}
|
|
>
|
|
{activeTab === tab.path && (
|
|
<span className="absolute bottom-0 left-1/2 -translate-x-1/2 w-4 h-[2px] bg-primary rounded-full" />
|
|
)}
|
|
<span className="max-w-[100px] truncate">{tab.title}</span>
|
|
{tab.closable && (
|
|
<span
|
|
onClick={e => handleClose(e, tab.path)}
|
|
className={cn(
|
|
'flex items-center justify-center w-4 h-4 rounded-full transition-all',
|
|
activeTab === tab.path
|
|
? 'opacity-60 hover:opacity-100 hover:bg-red-100 hover:text-red-500 dark:hover:bg-red-900/30'
|
|
: 'opacity-0 group-hover:opacity-60 hover:!opacity-100 hover:bg-red-100 hover:text-red-500 dark:hover:bg-red-900/30'
|
|
)}
|
|
>
|
|
<X className="h-2.5 w-2.5" />
|
|
</span>
|
|
)}
|
|
</button>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent className="w-44">
|
|
{tab.closable && (
|
|
<ContextMenuItem onClick={() => { const next = removeTab(tab.path); navigate(next) }}>
|
|
关闭当前
|
|
</ContextMenuItem>
|
|
)}
|
|
<ContextMenuItem onClick={() => handleCloseOthers(tab.path)}>
|
|
关闭其他
|
|
</ContextMenuItem>
|
|
<ContextMenuItem onClick={() => { closeRight(tab.path); navigate(tab.path) }}>
|
|
关闭右侧
|
|
</ContextMenuItem>
|
|
<ContextMenuItem onClick={() => { closeLeft(tab.path); navigate(tab.path) }}>
|
|
关闭左侧
|
|
</ContextMenuItem>
|
|
<ContextMenuSeparator />
|
|
<ContextMenuItem onClick={handleCloseAll}>
|
|
关闭所有
|
|
</ContextMenuItem>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
))}
|
|
</div>
|
|
|
|
{showArrows && (
|
|
<button onClick={() => scroll('right')} className="shrink-0 flex items-center justify-center w-6 h-6 rounded hover:bg-white/40 dark:hover:bg-white/10 text-muted-foreground">
|
|
<ChevronRight className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|