feat: rebuild support workspace frontend
This commit is contained in:
250
frontend/src/components/app/AppRouteTabs.tsx
Normal file
250
frontend/src/components/app/AppRouteTabs.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import * as React from 'react'
|
||||
import { Check, ChevronLeft, ChevronRight, Ellipsis, X } from 'lucide-react'
|
||||
|
||||
import { useCurrentRoute, type RouteNavigationTab } from '@/app/route-meta'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-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
|
||||
|
||||
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 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 next = openedTabs.filter((tab) => tab.path !== path)
|
||||
saveOpenedTabs(next)
|
||||
setOpenedTabs(next)
|
||||
|
||||
if (path !== route) return
|
||||
const fallback = next[Math.max(0, closedIndex - 1)] ?? next[0]
|
||||
window.location.hash = fallback?.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) => (
|
||||
<div
|
||||
key={tab.path}
|
||||
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>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{scrollState.overflow ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="向右查看已打开页面"
|
||||
disabled={!scrollState.canScrollRight}
|
||||
onClick={() => scrollTabs(1)}
|
||||
>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label={`查看全部已打开页面,共 ${openedTabs.length} 个`}>
|
||||
<Ellipsis />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="scrollbar-panel max-h-80 w-64">
|
||||
<DropdownMenuLabel>已打开页面 · {openedTabs.length}</DropdownMenuLabel>
|
||||
<DropdownMenuGroup>
|
||||
{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>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
) : null}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user