305 lines
12 KiB
TypeScript
305 lines
12 KiB
TypeScript
import * as React from 'react'
|
||
import { Check, ChevronLeft, ChevronRight, Ellipsis, Layers2, PanelLeftClose, PanelRightClose, Trash2, X } from 'lucide-react'
|
||
|
||
import { useCurrentRoute, type RouteNavigationTab } from '@/app/route-meta'
|
||
import { Button } from '@/components/ui/button'
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuGroup,
|
||
DropdownMenuItem,
|
||
DropdownMenuLabel,
|
||
DropdownMenuSeparator,
|
||
DropdownMenuShortcut,
|
||
DropdownMenuTrigger
|
||
} from '@/components/ui/dropdown-menu'
|
||
import {
|
||
ContextMenu,
|
||
ContextMenuContent,
|
||
ContextMenuGroup,
|
||
ContextMenuItem,
|
||
ContextMenuSeparator,
|
||
ContextMenuTrigger
|
||
} from '@/components/ui/context-menu'
|
||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||
|
||
const STORAGE_KEY = 'ai-ticket-platform.opened-route-tabs.v2'
|
||
const MAX_OPEN_TABS = 12
|
||
const SCROLL_EPSILON = 2
|
||
const WORKBENCH_TAB: RouteNavigationTab = { label: '工作台', path: '/' }
|
||
|
||
type CloseScope = 'left' | 'right' | 'others' | 'all'
|
||
|
||
type TabScrollState = {
|
||
overflow: boolean
|
||
canScrollLeft: boolean
|
||
canScrollRight: boolean
|
||
}
|
||
|
||
function readOpenedTabs(): RouteNavigationTab[] {
|
||
try {
|
||
const stored: unknown = JSON.parse(window.sessionStorage.getItem(STORAGE_KEY) ?? '[]')
|
||
if (!Array.isArray(stored)) return []
|
||
return stored.filter((item): item is RouteNavigationTab => {
|
||
if (!item || typeof item !== 'object') return false
|
||
const tab = item as Partial<RouteNavigationTab>
|
||
return typeof tab.label === 'string' && typeof tab.path === 'string' && tab.path.startsWith('/')
|
||
}).slice(-MAX_OPEN_TABS)
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
function saveOpenedTabs(tabs: RouteNavigationTab[]) {
|
||
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(tabs))
|
||
}
|
||
|
||
function addOpenedTab(tabs: RouteNavigationTab[], tab: RouteNavigationTab) {
|
||
const existing = tabs.findIndex((item) => item.path === tab.path)
|
||
if (existing >= 0) {
|
||
return tabs.map((item, index) => index === existing ? { ...item, label: tab.label } : item)
|
||
}
|
||
return [...tabs, tab].slice(-MAX_OPEN_TABS)
|
||
}
|
||
|
||
export function AppRouteTabs() {
|
||
const { route, meta } = useCurrentRoute()
|
||
const [openedTabs, setOpenedTabs] = React.useState(readOpenedTabs)
|
||
const scrollAreaRef = React.useRef<HTMLDivElement>(null)
|
||
const tabRefs = React.useRef(new Map<string, HTMLDivElement>())
|
||
const [scrollState, setScrollState] = React.useState<TabScrollState>({
|
||
overflow: false,
|
||
canScrollLeft: false,
|
||
canScrollRight: false
|
||
})
|
||
const activeTabIndex = openedTabs.findIndex((tab) => tab.path === route)
|
||
|
||
const syncScrollState = React.useCallback(() => {
|
||
const element = scrollAreaRef.current
|
||
if (!element) return
|
||
const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth)
|
||
setScrollState({
|
||
overflow: maxScrollLeft > SCROLL_EPSILON,
|
||
canScrollLeft: element.scrollLeft > SCROLL_EPSILON,
|
||
canScrollRight: element.scrollLeft < maxScrollLeft - SCROLL_EPSILON
|
||
})
|
||
}, [])
|
||
|
||
React.useEffect(() => {
|
||
setOpenedTabs((current) => {
|
||
const next = addOpenedTab(current, { label: meta.title, path: route })
|
||
saveOpenedTabs(next)
|
||
return next
|
||
})
|
||
}, [meta.title, route])
|
||
|
||
React.useLayoutEffect(() => {
|
||
const element = scrollAreaRef.current
|
||
if (!element) return
|
||
|
||
syncScrollState()
|
||
element.addEventListener('scroll', syncScrollState, { passive: true })
|
||
window.addEventListener('resize', syncScrollState)
|
||
const resizeObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(syncScrollState)
|
||
resizeObserver?.observe(element)
|
||
|
||
return () => {
|
||
element.removeEventListener('scroll', syncScrollState)
|
||
window.removeEventListener('resize', syncScrollState)
|
||
resizeObserver?.disconnect()
|
||
}
|
||
}, [openedTabs.length, syncScrollState])
|
||
|
||
React.useLayoutEffect(() => {
|
||
const frame = window.requestAnimationFrame(() => {
|
||
const element = scrollAreaRef.current
|
||
const activeTab = tabRefs.current.get(route)
|
||
if (!element || !activeTab) return
|
||
|
||
const tabStart = activeTab.offsetLeft
|
||
const tabEnd = tabStart + activeTab.offsetWidth
|
||
const viewportStart = element.scrollLeft
|
||
const viewportEnd = viewportStart + element.clientWidth
|
||
let nextScrollLeft = viewportStart
|
||
|
||
if (tabStart < viewportStart) nextScrollLeft = Math.max(0, tabStart - 16)
|
||
if (tabEnd > viewportEnd) nextScrollLeft = tabEnd - element.clientWidth + 16
|
||
|
||
if (nextScrollLeft !== viewportStart) {
|
||
if (typeof element.scrollTo === 'function') element.scrollTo({ left: nextScrollLeft, behavior: 'smooth' })
|
||
else element.scrollLeft = nextScrollLeft
|
||
}
|
||
syncScrollState()
|
||
})
|
||
|
||
return () => window.cancelAnimationFrame(frame)
|
||
}, [openedTabs, route, scrollState.overflow, syncScrollState])
|
||
|
||
const closeTab = (path: string) => {
|
||
const closedIndex = openedTabs.findIndex((tab) => tab.path === path)
|
||
const remaining = openedTabs.filter((tab) => tab.path !== path)
|
||
const next = remaining.length ? remaining : [WORKBENCH_TAB]
|
||
saveOpenedTabs(next)
|
||
setOpenedTabs(next)
|
||
|
||
if (path !== route) return
|
||
const fallback = remaining[Math.max(0, closedIndex - 1)] ?? remaining[0] ?? WORKBENCH_TAB
|
||
window.location.hash = fallback.path
|
||
}
|
||
|
||
const closeTabs = (anchorPath: string, scope: CloseScope) => {
|
||
const anchorIndex = openedTabs.findIndex((tab) => tab.path === anchorPath)
|
||
if (anchorIndex < 0 && scope !== 'all') return
|
||
|
||
let remaining: RouteNavigationTab[]
|
||
if (scope === 'left') remaining = openedTabs.slice(anchorIndex)
|
||
else if (scope === 'right') remaining = openedTabs.slice(0, anchorIndex + 1)
|
||
else if (scope === 'others') remaining = [openedTabs[anchorIndex]]
|
||
else remaining = []
|
||
|
||
const next = remaining.length ? remaining : [WORKBENCH_TAB]
|
||
saveOpenedTabs(next)
|
||
setOpenedTabs(next)
|
||
|
||
if (next.some((tab) => tab.path === route)) return
|
||
const anchor = next.find((tab) => tab.path === anchorPath)
|
||
window.location.hash = anchor?.path ?? WORKBENCH_TAB.path
|
||
}
|
||
|
||
const scrollTabs = (direction: -1 | 1) => {
|
||
const element = scrollAreaRef.current
|
||
if (!element) return
|
||
const distance = Math.max(240, element.clientWidth * 0.65) * direction
|
||
if (typeof element.scrollBy === 'function') element.scrollBy({ left: distance, behavior: 'smooth' })
|
||
else element.scrollLeft += distance
|
||
}
|
||
|
||
return (
|
||
<nav data-slot="app-route-tabs" aria-label="已打开页面" className="flex min-w-0 items-center gap-0.5">
|
||
{scrollState.overflow ? (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
aria-label="向左查看已打开页面"
|
||
disabled={!scrollState.canScrollLeft}
|
||
onClick={() => scrollTabs(-1)}
|
||
>
|
||
<ChevronLeft />
|
||
</Button>
|
||
) : null}
|
||
<Tabs
|
||
className="min-w-0 flex-1 gap-0"
|
||
value={route}
|
||
onValueChange={(path) => {
|
||
if (path !== route) window.location.hash = path
|
||
}}
|
||
>
|
||
<TabsList
|
||
ref={scrollAreaRef}
|
||
variant="line"
|
||
data-slot="app-route-tabs-scroll"
|
||
data-can-scroll-left={scrollState.canScrollLeft}
|
||
data-can-scroll-right={scrollState.canScrollRight}
|
||
className="scrollbar-hidden scroll-fade-x h-10 w-full max-w-none justify-start gap-1 overflow-x-auto overflow-y-hidden px-0"
|
||
onWheel={(event) => {
|
||
if (!scrollState.overflow || Math.abs(event.deltaX) >= Math.abs(event.deltaY)) return
|
||
event.preventDefault()
|
||
event.currentTarget.scrollLeft += event.deltaY
|
||
}}
|
||
>
|
||
{openedTabs.map((tab, tabIndex) => (
|
||
<ContextMenu key={tab.path}>
|
||
<ContextMenuTrigger asChild>
|
||
<div
|
||
ref={(node) => {
|
||
if (node) tabRefs.current.set(tab.path, node)
|
||
else tabRefs.current.delete(tab.path)
|
||
}}
|
||
className="group/tab relative flex flex-none items-center"
|
||
>
|
||
<TabsTrigger value={tab.path} className="max-w-52 flex-none px-3.5 pr-8">
|
||
<span className="truncate">{tab.label}</span>
|
||
</TabsTrigger>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-xs"
|
||
className="absolute right-1 opacity-60 group-hover/tab:opacity-100 focus-visible:opacity-100"
|
||
aria-label={`关闭${tab.label}(${tab.path})`}
|
||
title={`关闭${tab.label}`}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
onClick={(event) => {
|
||
event.stopPropagation()
|
||
closeTab(tab.path)
|
||
}}
|
||
>
|
||
<X />
|
||
</Button>
|
||
</div>
|
||
</ContextMenuTrigger>
|
||
<ContextMenuContent className="w-44">
|
||
<ContextMenuGroup>
|
||
<ContextMenuItem onSelect={() => closeTab(tab.path)}><X />关闭当前标签</ContextMenuItem>
|
||
<ContextMenuItem disabled={tabIndex === 0} onSelect={() => closeTabs(tab.path, 'left')}><PanelLeftClose />关闭左侧标签</ContextMenuItem>
|
||
<ContextMenuItem disabled={tabIndex === openedTabs.length - 1} onSelect={() => closeTabs(tab.path, 'right')}><PanelRightClose />关闭右侧标签</ContextMenuItem>
|
||
<ContextMenuItem disabled={openedTabs.length <= 1} onSelect={() => closeTabs(tab.path, 'others')}><Layers2 />关闭其他标签</ContextMenuItem>
|
||
</ContextMenuGroup>
|
||
<ContextMenuSeparator />
|
||
<ContextMenuGroup>
|
||
<ContextMenuItem onSelect={() => closeTabs(tab.path, 'all')}><Trash2 />关闭全部标签</ContextMenuItem>
|
||
</ContextMenuGroup>
|
||
</ContextMenuContent>
|
||
</ContextMenu>
|
||
))}
|
||
</TabsList>
|
||
</Tabs>
|
||
{scrollState.overflow ? (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
aria-label="向右查看已打开页面"
|
||
disabled={!scrollState.canScrollRight}
|
||
onClick={() => scrollTabs(1)}
|
||
>
|
||
<ChevronRight />
|
||
</Button>
|
||
) : null}
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button type="button" variant="ghost" size="icon-sm" aria-label={`查看全部已打开页面,共 ${openedTabs.length} 个`} title="管理已打开页面">
|
||
<Ellipsis />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="scrollbar-panel max-h-80 w-64">
|
||
<DropdownMenuGroup>
|
||
<DropdownMenuLabel>已打开页面 · {openedTabs.length}</DropdownMenuLabel>
|
||
{openedTabs.map((tab) => (
|
||
<DropdownMenuItem
|
||
key={tab.path}
|
||
aria-current={tab.path === route ? 'page' : undefined}
|
||
onSelect={() => {
|
||
if (tab.path !== route) window.location.hash = tab.path
|
||
}}
|
||
>
|
||
{tab.path === route ? <Check /> : <span aria-hidden="true" className="size-4" />}
|
||
<span className="truncate">{tab.label}</span>
|
||
{tab.path === route ? <DropdownMenuShortcut>当前</DropdownMenuShortcut> : null}
|
||
</DropdownMenuItem>
|
||
))}
|
||
</DropdownMenuGroup>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuGroup>
|
||
<DropdownMenuLabel>批量关闭</DropdownMenuLabel>
|
||
<DropdownMenuItem disabled={activeTabIndex <= 0} onSelect={() => closeTabs(route, 'left')}><PanelLeftClose />关闭左侧标签</DropdownMenuItem>
|
||
<DropdownMenuItem disabled={activeTabIndex < 0 || activeTabIndex === openedTabs.length - 1} onSelect={() => closeTabs(route, 'right')}><PanelRightClose />关闭右侧标签</DropdownMenuItem>
|
||
<DropdownMenuItem disabled={openedTabs.length <= 1} onSelect={() => closeTabs(route, 'others')}><Layers2 />关闭其他标签</DropdownMenuItem>
|
||
<DropdownMenuItem onSelect={() => closeTabs(route, 'all')}><Trash2 />关闭全部标签</DropdownMenuItem>
|
||
</DropdownMenuGroup>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</nav>
|
||
)
|
||
}
|