Files
ai-ticket-platform/frontend/src/components/app/AppRouteTabs.tsx

305 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}