feat: rebuild support workspace frontend

This commit is contained in:
hailin
2026-07-16 17:20:45 +08:00
parent fcb8121f0a
commit e90648e129
95 changed files with 4353 additions and 2165 deletions

View 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>
)
}