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,79 @@
import type * as React from 'react'
import { CircleAlert, LoaderCircle, Plus } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { cn } from '@/lib/utils'
type AppCreateDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: string
submitLabel: string
onSubmit: React.FormEventHandler<HTMLFormElement>
children: React.ReactNode
pending?: boolean
submitDisabled?: boolean
error?: string
className?: string
bodyClassName?: string
}
export function AppCreateDialog({
open,
onOpenChange,
title,
description,
submitLabel,
onSubmit,
children,
pending = false,
submitDisabled = false,
error,
className,
bodyClassName
}: AppCreateDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={cn('overflow-hidden p-0 sm:max-w-lg', className)}>
<DialogHeader className="border-b border-border/70 bg-gradient-to-r from-primary/[0.08] via-card to-secondary/55 px-6 py-5 pr-12">
<div className="mb-1 flex size-10 items-center justify-center rounded-2xl bg-primary/10 text-primary shadow-sm ring-1 ring-primary/10">
<Plus className="size-5" aria-hidden="true" />
</div>
<DialogTitle className="text-lg">{title}</DialogTitle>
<DialogDescription className="leading-relaxed">{description}</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit}>
<div className={cn('scrollbar-panel max-h-[65vh] overflow-y-auto px-6 py-5', bodyClassName)}>
{children}
{error ? (
<Alert variant="destructive" className="mt-4">
<CircleAlert />
<AlertTitle>创建未完成</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
</div>
<DialogFooter className="m-0 rounded-none border-t border-border/70 bg-muted/35 px-6 py-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
取消
</Button>
<Button type="submit" disabled={pending || submitDisabled}>
{pending ? <LoaderCircle data-icon="inline-start" className="animate-spin" /> : <Plus data-icon="inline-start" />}
{pending ? '正在创建…' : submitLabel}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,34 +0,0 @@
import { Badge } from '@/components/ui/badge'
import { Card, CardAction, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import type { DashboardMetric } from '@/features/prototype/types'
type AppMetricCardProps = {
label: string
value: string | number
change: string
tone?: DashboardMetric['tone']
}
const variants = {
default: 'secondary',
success: 'success',
warning: 'warning',
danger: 'danger',
ai: 'ai'
} as const
export function AppMetricCard({ label, value, change, tone = 'default' }: AppMetricCardProps) {
return (
<Card>
<CardHeader>
<CardTitle>{label}</CardTitle>
<CardAction>
<Badge variant={variants[tone]}>{change}</Badge>
</CardAction>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold text-foreground">{value}</p>
</CardContent>
</Card>
)
}

View File

@@ -4,20 +4,36 @@ import { cn } from '@/lib/utils'
type AppPageProps = {
children: React.ReactNode
header?: React.ReactNode
className?: string
contentClassName?: string
maxWidth?: '6xl' | '7xl'
maxWidth?: '6xl' | '7xl' | 'contained' | 'fluid'
}
const maxWidthClass: Record<NonNullable<AppPageProps['maxWidth']>, string> = {
'6xl': 'max-w-6xl',
'7xl': 'max-w-7xl'
'7xl': 'max-w-7xl',
contained: 'max-w-5xl',
fluid: 'max-w-none'
}
export function AppPage({ children, className, contentClassName, maxWidth = '7xl' }: AppPageProps) {
export function AppPage({ children, header, className, contentClassName, maxWidth = 'fluid' }: AppPageProps) {
return (
<main className={cn('min-h-screen bg-background px-4 py-5 text-foreground sm:px-6 lg:px-8 lg:py-7', className)}>
<div className={cn('mx-auto flex flex-col gap-5', maxWidthClass[maxWidth], contentClassName)}>
<main className={cn('min-h-full bg-background text-foreground', className)}>
{header ? (
<div
data-slot="app-page-header-surface"
className="border-b bg-card"
>
<div className={cn('mx-auto px-4 py-3.5 sm:px-5 lg:px-6 lg:py-4', maxWidthClass[maxWidth])}>
{header}
</div>
</div>
) : null}
<div
data-slot="app-page-content"
className={cn('mx-auto flex flex-col gap-5 px-4 py-4 sm:px-5 lg:px-6 lg:py-5', maxWidthClass[maxWidth], contentClassName)}
>
{children}
</div>
</main>

View File

@@ -1,24 +1,73 @@
import type * as React from 'react'
import * as React from 'react'
import { ArrowLeft } from 'lucide-react'
import { useCurrentRoute } from '@/app/route-meta'
import { Button } from '@/components/ui/button'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from '@/components/ui/breadcrumb'
type AppPageHeaderProps = {
title: string
description?: string
breadcrumbLabel?: string
meta?: React.ReactNode
actions?: React.ReactNode
}
export function AppPageHeader({ title, description, meta, actions }: AppPageHeaderProps) {
export function AppPageHeader({ title, description, breadcrumbLabel, meta, actions }: AppPageHeaderProps) {
const route = useCurrentRoute()
const showBreadcrumbs = route.meta.breadcrumbs.length > 0
const parentBreadcrumb = route.meta.breadcrumbs[route.meta.breadcrumbs.length - 1]
return (
<header data-slot="app-page-header" className="border-b border-border/80 pb-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<header data-slot="app-page-header" className="flex min-h-14 flex-col justify-center gap-2.5">
{showBreadcrumbs ? (
<div className="flex min-w-0 items-center gap-2">
{route.meta.backPath ? (
<Button asChild variant="ghost" size="sm">
<a href={`#${route.meta.backPath}`} aria-label={`返回${parentBreadcrumb?.label ?? '上一页'}`}>
<ArrowLeft data-icon="inline-start" />
返回
</a>
</Button>
) : null}
<Breadcrumb className="min-w-0">
<BreadcrumbList className="flex-nowrap text-xs">
{route.meta.breadcrumbs.map((item, index) => (
<React.Fragment key={`${item.label}-${item.path ?? index}`}>
<BreadcrumbItem className="min-w-0">
{item.path ? (
<BreadcrumbLink className="truncate" href={`#${item.path}`}>{item.label}</BreadcrumbLink>
) : (
<span className="truncate">{item.label}</span>
)}
</BreadcrumbItem>
<BreadcrumbSeparator />
</React.Fragment>
))}
<BreadcrumbItem className="min-w-0">
<BreadcrumbPage className="truncate font-medium">{breadcrumbLabel ?? title}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
) : null}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-normal text-foreground">{title}</h1>
<h1 className="text-xl font-semibold tracking-[-0.025em] text-foreground lg:text-2xl">{title}</h1>
{meta}
</div>
{description ? <p className="mt-1 text-sm text-muted-foreground">{description}</p> : null}
</div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">
{actions}
</div> : null}
</div>
</header>
)

View File

@@ -1,14 +0,0 @@
import { Badge } from '@/components/ui/badge'
import type { McpTool } from '@/features/prototype/types'
const riskConfig = {
low: { label: '低风险', variant: 'success' },
medium: { label: '中风险', variant: 'warning' },
high: { label: '高风险', variant: 'danger' }
} as const
export function AppRiskBadge({ risk }: { risk: McpTool['risk_level'] }) {
const config = riskConfig[risk]
return <Badge variant={config.variant}>{config.label}</Badge>
}

View File

@@ -0,0 +1,93 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AppRouteTabs } from '@/components/app/AppRouteTabs'
function navigate(path: string) {
act(() => {
window.location.hash = path
window.dispatchEvent(new HashChangeEvent('hashchange'))
})
}
beforeEach(() => {
window.sessionStorage.clear()
window.location.hash = '#/tickets/42'
})
afterEach(() => cleanup())
describe('已打开页面标签', () => {
it('只显示实际打开过的页面,并允许关闭非当前标签', async () => {
const user = userEvent.setup()
render(<AppRouteTabs />)
await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(1))
expect(screen.getByRole('tab', { name: '工单详情' })).toHaveAttribute('data-state', 'active')
navigate('/knowledge')
await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(2))
expect(screen.getByRole('tab', { name: '知识库' })).toHaveAttribute('data-state', 'active')
await user.click(screen.getByRole('button', { name: '关闭工单详情(/tickets/42)' }))
expect(screen.queryByRole('tab', { name: '工单详情' })).not.toBeInTheDocument()
expect(window.location.hash).toBe('#/knowledge')
})
it('关闭当前标签后切换到相邻页面,并在关闭最后一个标签时回到工作台', async () => {
const user = userEvent.setup()
render(<AppRouteTabs />)
navigate('/knowledge')
await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(2))
await user.click(screen.getByRole('button', { name: '关闭知识库(/knowledge)' }))
expect(window.location.hash).toBe('#/tickets/42')
navigate('/tickets/42')
await waitFor(() => expect(screen.getByRole('tab', { name: '工单详情' })).toHaveAttribute('data-state', 'active'))
await user.click(screen.getByRole('button', { name: '关闭工单详情(/tickets/42)' }))
expect(window.location.hash).toBe('#/')
navigate('/')
await waitFor(() => expect(screen.getByRole('tab', { name: '工作台' })).toHaveAttribute('data-state', 'active'))
})
it('刷新式重新挂载后保留本次会话已打开的标签', async () => {
const { unmount } = render(<AppRouteTabs />)
navigate('/knowledge')
await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(2))
unmount()
render(<AppRouteTabs />)
expect(screen.getByRole('tab', { name: '工单详情' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: '知识库' })).toBeInTheDocument()
})
it('标签溢出时隐藏原生滚动条,并提供左右切换和全部页面菜单', async () => {
const user = userEvent.setup()
const { container } = render(<AppRouteTabs />)
navigate('/knowledge')
await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(2))
const scrollArea = container.querySelector('[data-slot="app-route-tabs-scroll"]') as HTMLDivElement
const scrollBy = vi.fn()
Object.defineProperties(scrollArea, {
clientWidth: { configurable: true, value: 320 },
scrollWidth: { configurable: true, value: 720 },
scrollLeft: { configurable: true, value: 0, writable: true },
scrollBy: { configurable: true, value: scrollBy }
})
fireEvent.scroll(scrollArea)
const nextButton = await screen.findByRole('button', { name: '向右查看已打开页面' })
expect(scrollArea).toHaveClass('scrollbar-hidden', 'scroll-fade-x')
expect(screen.getByRole('button', { name: '向左查看已打开页面' })).toBeDisabled()
await user.click(nextButton)
expect(scrollBy).toHaveBeenCalledWith(expect.objectContaining({ behavior: 'smooth' }))
await user.click(screen.getByRole('button', { name: '查看全部已打开页面,共 2 个' }))
expect(screen.getByRole('menuitem', { name: /工单详情/ })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: /知识库.*当前/ })).toHaveAttribute('aria-current', 'page')
})
})

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

View File

@@ -1,5 +1,6 @@
import type * as React from 'react'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
type AppSectionProps = {
@@ -7,9 +8,29 @@ type AppSectionProps = {
description?: string
action?: React.ReactNode
children: React.ReactNode
footer?: React.ReactNode
variant?: 'plain' | 'card'
}
export function AppSection({ title, description, action, children }: AppSectionProps) {
export function AppSection({ title, description, action, children, footer, variant = 'plain' }: AppSectionProps) {
if (variant === 'card') {
return (
<section data-slot="app-section">
<Card className="gap-0 py-0">
<CardHeader className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 border-b bg-muted/30 py-3.5">
<div className="grid gap-1">
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
{description ? <CardDescription className="text-xs leading-5">{description}</CardDescription> : null}
</div>
{action ? <div className="self-start">{action}</div> : null}
</CardHeader>
<CardContent className="p-4">{children}</CardContent>
{footer ? <CardFooter className="justify-between gap-3 bg-muted/45 px-4 py-3">{footer}</CardFooter> : null}
</Card>
</section>
)
}
return (
<section data-slot="app-section" className="grid gap-3">
<div className="flex min-h-8 items-start justify-between gap-3">

View File

@@ -0,0 +1,157 @@
import * as React from 'react'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AppSidebar } from '@/components/app/AppSidebar'
import { SidebarProvider, useSidebar } from '@/components/ui/sidebar'
import type { AuthMenu } from '@/features/auth/types'
const logout = vi.fn()
vi.mock('@/features/auth/AuthProvider', () => ({
useAuth: () => ({
logout,
user: {
username: 'admin',
display_name: '平台管理员',
menus: [
menu(1, 'workbench', '工作台', '#/', 'home'),
menu(2, 'business', '工单', '', 'ticket', [
menu(21, 'business.tickets', '工单中心', '#/tickets', 'ticket'),
menu(22, 'business.ticket_create', '创建工单', '#/tickets/new', 'briefcase')
]),
menu(3, 'knowledge', '知识与方案', '', 'book_open', [
menu(31, 'platform.knowledge', '知识库', '#/knowledge', 'database'),
menu(32, 'knowledge.search', '知识检索', '#/knowledge/search', 'search')
]),
menu(4, 'empty', '空分组', '', 'settings'),
{ ...menu(5, 'hidden', '隐藏分组', '', 'settings', [menu(51, 'hidden.child', '隐藏页面', '#/hidden', 'settings')]), is_visible: false }
]
}
})
}))
function menu(id: number, code: string, title: string, path: string, icon: string, children: AuthMenu[] = []): AuthMenu {
return {
id,
code,
title,
path,
icon,
permission_code: '',
is_visible: true,
is_enabled: true,
children
}
}
function renderSidebar(defaultOpen = true) {
return render(<SidebarProvider defaultOpen={defaultOpen}><AppSidebar /></SidebarProvider>)
}
function MobileSidebar() {
const { setOpenMobile } = useSidebar()
React.useEffect(() => setOpenMobile(true), [setOpenMobile])
return <AppSidebar />
}
beforeEach(() => {
vi.stubGlobal('ResizeObserver', class {
observe() {}
unobserve() {}
disconnect() {}
})
window.location.hash = '#/knowledge/search'
window.innerWidth = 1280
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query.includes('max-width') ? window.innerWidth < 1024 : false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn()
}))
})
afterEach(() => cleanup())
describe('两级侧栏导航', () => {
it('工作台直接打开,当前页面所属一级分类自动展开并精确高亮二级页面', async () => {
renderSidebar()
expect(screen.getByRole('link', { name: '工作台' })).toHaveAttribute('href', '#/')
expect(screen.queryByText('运营总览')).not.toBeInTheDocument()
expect(screen.queryByText('空分组')).not.toBeInTheDocument()
expect(screen.queryByText('隐藏分组')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '知识与方案' })).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByRole('link', { name: '知识检索' })).toHaveAttribute('data-active', 'true')
expect(screen.getByRole('link', { name: '知识库' })).toHaveAttribute('data-active', 'false')
})
it('只允许一个一级分类展开,并允许手动收起当前分类', async () => {
const user = userEvent.setup()
renderSidebar()
await user.click(screen.getByRole('button', { name: '工单' }))
expect(screen.getByRole('button', { name: '工单' })).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByRole('button', { name: '知识与方案' })).toHaveAttribute('aria-expanded', 'false')
expect(screen.getByRole('link', { name: '工单中心' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '工单' }))
expect(screen.getByRole('button', { name: '工单' })).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByRole('link', { name: '工单中心' })).not.toBeInTheDocument()
})
it('图标侧栏点击一级分类后展开完整侧栏并显示对应二级菜单', async () => {
const user = userEvent.setup()
const { container } = renderSidebar(false)
expect(container.querySelector('[data-slot="sidebar"]')).toHaveAttribute('data-state', 'collapsed')
const knowledgeButton = screen.getByRole('button', { name: '知识与方案' })
expect(knowledgeButton).toHaveClass('group-data-[collapsible=icon]:mx-auto')
expect(knowledgeButton).toHaveClass('group-data-[collapsible=icon]:!p-0', 'group-data-[collapsible=icon]:justify-center')
expect(knowledgeButton).not.toHaveClass('group-data-[collapsible=icon]:!p-2')
expect(knowledgeButton.closest('[data-sidebar="menu-item"]')).toHaveClass(
'group-data-[collapsible=icon]:w-full',
'group-data-[collapsible=icon]:justify-center'
)
await user.click(knowledgeButton)
await waitFor(() => expect(container.querySelector('[data-slot="sidebar"]')).toHaveAttribute('data-state', 'expanded'))
expect(screen.getByRole('link', { name: '知识检索' })).toBeInTheDocument()
})
it('桌面端始终提供明确的导航栏收起和展开按钮', async () => {
const user = userEvent.setup()
const { container } = renderSidebar()
await user.click(screen.getByRole('button', { name: '收起导航栏' }))
await waitFor(() => expect(container.querySelector('[data-slot="sidebar"]')).toHaveAttribute('data-state', 'collapsed'))
await user.click(screen.getByRole('button', { name: '展开导航栏' }))
await waitFor(() => expect(container.querySelector('[data-slot="sidebar"]')).toHaveAttribute('data-state', 'expanded'))
})
it('用户卡片在展开和图标侧栏中都能打开个人中心', async () => {
const { container } = renderSidebar()
const profileLink = screen.getByRole('link', { name: '打开个人中心' })
expect(profileLink).toHaveAttribute('href', '#/profile')
expect(screen.getByText('个人中心 · 账号与设置')).toBeInTheDocument()
await userEvent.setup().click(screen.getByRole('button', { name: '收起导航栏' }))
await waitFor(() => expect(container.querySelector('[data-slot="sidebar"]')).toHaveAttribute('data-state', 'collapsed'))
expect(screen.getByRole('link', { name: '打开个人中心' })).toBeInTheDocument()
})
it('移动端点击二级页面后关闭侧栏', async () => {
const user = userEvent.setup()
window.innerWidth = 390
const { container } = render(<SidebarProvider><MobileSidebar /></SidebarProvider>)
const link = await screen.findByRole('link', { name: '知识检索' })
await user.click(link)
await waitFor(() => expect(container.querySelector('[data-slot="sidebar"][data-mobile="true"]')).not.toBeInTheDocument())
})
})

View File

@@ -3,10 +3,12 @@ import {
Bot,
BookOpen,
BriefcaseBusiness,
ChevronRight,
ClipboardList,
Database,
Home,
Library,
LogOut,
Menu,
ScrollText,
Search,
@@ -19,7 +21,10 @@ import {
Workflow
} from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { AppThemeToggle } from '@/components/app/AppThemeToggle'
import { Avatar, AvatarBadge, AvatarFallback } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import {
Sidebar,
SidebarContent,
@@ -31,12 +36,16 @@ import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarSeparator,
SidebarTrigger,
useSidebar
} from '@/components/ui/sidebar'
import { useAuth } from '@/features/auth/AuthProvider'
import type { AuthMenu } from '@/features/auth/types'
import { cn } from '@/lib/utils'
const iconMap = {
audit: ScrollText,
@@ -76,39 +85,22 @@ function visibleChildren(menu: AuthMenu) {
return menu.children.filter((item) => item.is_visible && item.is_enabled)
}
const knowledgeNavGroup: AuthMenu = {
id: -100,
code: 'knowledge_builtin',
title: '知识库',
path: '#/knowledge',
icon: 'book_open',
permission_code: 'knowledge.view',
is_visible: true,
is_enabled: true,
children: [
{
id: -101,
code: 'knowledge_bases',
title: '知识库',
path: '#/knowledge',
icon: 'book_open',
permission_code: 'knowledge.view',
is_visible: true,
is_enabled: true,
children: []
},
{
id: -102,
code: 'knowledge_search',
title: '知识检索',
path: '#/knowledge/search',
icon: 'search',
permission_code: 'knowledge.view',
is_visible: true,
is_enabled: true,
children: []
}
]
function routeMatches(menuPath: string, currentHash: string) {
if (!menuPath) return false
const route = currentHash.replace(/^#/, '')
const menuRoute = menuPath.replace(/^#/, '')
return route === menuRoute || (menuRoute !== '/' && route.startsWith(`${menuRoute}/`))
}
function activeChildCode(menu: AuthMenu, currentHash: string) {
return visibleChildren(menu)
.filter((item) => routeMatches(item.path, currentHash))
.sort((left, right) => right.path.length - left.path.length)[0]?.code
}
function getUserInitials(name: string) {
const normalizedName = name.trim()
return normalizedName ? normalizedName.slice(0, 2).toUpperCase() : 'AI'
}
type SidebarNavContentProps = {
@@ -118,60 +110,155 @@ type SidebarNavContentProps = {
}
function SidebarNavContent({ groups, currentHash, onNavigate }: SidebarNavContentProps) {
const { isMobile, setOpen, state } = useSidebar()
const activeGroupCode = groups.find((group) => {
const children = visibleChildren(group)
return children.length > 0 && Boolean(activeChildCode(group, currentHash))
})?.code ?? null
const [openGroupCode, setOpenGroupCode] = React.useState<string | null>(activeGroupCode)
React.useEffect(() => {
setOpenGroupCode(activeGroupCode)
}, [activeGroupCode, currentHash])
return (
<>
{groups.map((group) => (
<SidebarGroup key={group.code}>
<SidebarGroupLabel>{group.title}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{visibleChildren(group).map((item) => {
const Icon = getIcon(item.icon)
const route = currentHash.replace(/^#/, '')
const itemRoute = item.path.replace(/^#/, '')
const active = route === itemRoute || (itemRoute !== '/' && route.startsWith(`${itemRoute}/`))
<SidebarGroup className="px-2 py-3">
<SidebarGroupLabel className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-[0.16em] text-sidebar-foreground/45">
业务导航
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="gap-1.5">
{groups.map((group) => {
const children = visibleChildren(group)
const Icon = getIcon(group.icon)
if (group.path && children.length === 0) {
const groupActive = routeMatches(group.path, currentHash)
return (
<SidebarMenuItem key={item.code}>
<SidebarMenuButton asChild isActive={active} size="lg" tooltip={item.title}>
<a href={item.path} onClick={onNavigate}>
<SidebarMenuItem key={group.code} className="group-data-[collapsible=icon]:flex group-data-[collapsible=icon]:w-full group-data-[collapsible=icon]:justify-center">
<SidebarMenuButton
asChild
isActive={groupActive}
size="lg"
tooltip={group.title}
className="relative h-10 rounded-lg data-[active=true]:font-semibold group-data-[collapsible=icon]:mx-auto"
>
<a href={group.path} onClick={onNavigate}>
<span
aria-hidden="true"
className={cn(
'absolute -left-2 h-5 w-0.5 rounded-r-full bg-sidebar-primary transition-opacity group-data-[collapsible=icon]:hidden',
groupActive ? 'opacity-100' : 'opacity-0'
)}
/>
<Icon />
<span>{item.title}</span>
<span className="max-w-40 overflow-hidden whitespace-nowrap transition-[max-width,opacity,transform] duration-200 ease-out motion-reduce:transition-none group-data-[collapsible=icon]:max-w-0 group-data-[collapsible=icon]:-translate-x-1 group-data-[collapsible=icon]:opacity-0">{group.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</>
}
const selectedChildCode = activeChildCode(group, currentHash)
const groupActive = Boolean(selectedChildCode)
const groupOpen = openGroupCode === group.code
return (
<Collapsible
key={group.code}
asChild
open={groupOpen}
onOpenChange={(nextOpen) => {
if (!isMobile && state === 'collapsed') {
setOpen(true)
setOpenGroupCode(group.code)
return
}
setOpenGroupCode(nextOpen ? group.code : null)
}}
>
<SidebarMenuItem className="group/collapsible group-data-[collapsible=icon]:flex group-data-[collapsible=icon]:w-full group-data-[collapsible=icon]:justify-center">
<CollapsibleTrigger asChild>
<SidebarMenuButton
isActive={groupActive}
size="lg"
tooltip={group.title}
aria-label={group.title}
className="relative h-10 rounded-lg data-[active=true]:font-semibold group-data-[collapsible=icon]:mx-auto"
>
<span
aria-hidden="true"
className={cn(
'absolute -left-2 h-5 w-0.5 rounded-r-full bg-sidebar-primary transition-opacity group-data-[collapsible=icon]:hidden',
groupActive ? 'opacity-100' : 'opacity-0'
)}
/>
<Icon />
<span className="max-w-40 overflow-hidden whitespace-nowrap transition-[max-width,opacity,transform] duration-200 ease-out motion-reduce:transition-none group-data-[collapsible=icon]:max-w-0 group-data-[collapsible=icon]:-translate-x-1 group-data-[collapsible=icon]:opacity-0">{group.title}</span>
<ChevronRight className="ml-auto size-3.5 text-sidebar-foreground/45 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90 group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub className="my-1 gap-0.5 border-sidebar-border py-1">
{children.map((item) => {
const itemActive = selectedChildCode === item.code
return (
<SidebarMenuSubItem key={item.code}>
<SidebarMenuSubButton
asChild
isActive={itemActive}
className="relative h-8 rounded-md pl-6 data-[active=true]:font-medium"
>
<a href={item.path} onClick={onNavigate}>
<span
aria-hidden="true"
className={cn(
'absolute left-2 size-1.5 rounded-full border transition-colors',
itemActive
? 'border-sidebar-primary bg-sidebar-primary'
: 'border-sidebar-border bg-sidebar'
)}
/>
<span>{item.title}</span>
</a>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
)
})}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
}
export function AppSidebar() {
const { user } = useAuth()
const { setOpenMobile } = useSidebar()
const { logout, user } = useAuth()
const { setOpenMobile, state } = useSidebar()
const currentHash = useCurrentHash()
const menuGroups = (user?.menus ?? []).filter((menu) => menu.is_visible && menu.is_enabled)
const hasKnowledgeMenu = menuGroups.some(
(group) => group.code.startsWith('knowledge') || visibleChildren(group).some((item) => item.path.startsWith('#/knowledge'))
)
const groups = hasKnowledgeMenu ? menuGroups : [...menuGroups, knowledgeNavGroup]
const groups = (user?.menus ?? []).filter((menu) => {
if (!menu.is_visible || !menu.is_enabled) return false
return Boolean(menu.path) || visibleChildren(menu).length > 0
})
const userName = user?.display_name || user?.username || '当前用户'
return (
<Sidebar collapsible="icon">
<SidebarHeader className="px-3 py-3">
<Sidebar collapsible="icon" className="border-sidebar-border">
<SidebarHeader className="px-3 pb-3 pt-4 group-data-[collapsible=icon]:px-2">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild size="lg" tooltip="AI 工单平台">
<a href="#/">
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground">
<Workflow />
<SidebarMenuItem className="group-data-[collapsible=icon]:flex group-data-[collapsible=icon]:w-full group-data-[collapsible=icon]:justify-center">
<SidebarMenuButton asChild size="lg" tooltip="智服台" className="h-12 rounded-xl group-data-[collapsible=icon]:mx-auto">
<a href="#/" aria-label="返回工作台">
<span className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-sidebar-primary text-sidebar-primary-foreground shadow-sm shadow-primary/20 transition-[width,height,border-radius] [transition-duration:240ms] [transition-timing-function:cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none group-data-[collapsible=icon]:size-8 group-data-[collapsible=icon]:rounded-lg">
<Workflow className="size-5" />
</span>
<span className="grid min-w-0 flex-1 text-left leading-tight">
<span className="truncate font-semibold">AI 工单平台</span>
<span className="truncate text-xs text-sidebar-foreground/60">企业服务治理中台</span>
<span className="grid max-w-40 min-w-0 flex-1 gap-0.5 overflow-hidden text-left leading-tight transition-[max-width,opacity,transform] duration-200 ease-out motion-reduce:transition-none group-data-[collapsible=icon]:max-w-0 group-data-[collapsible=icon]:flex-none group-data-[collapsible=icon]:-translate-x-1 group-data-[collapsible=icon]:opacity-0">
<span className="truncate text-[15px] font-semibold tracking-tight">智服台</span>
<span className="truncate text-[10px] font-medium uppercase tracking-[0.12em] text-sidebar-foreground/45">AI Service Desk</span>
</span>
</a>
</SidebarMenuButton>
@@ -179,23 +266,51 @@ export function AppSidebar() {
</SidebarMenu>
</SidebarHeader>
<SidebarSeparator />
<SidebarContent>
<SidebarContent className="scrollbar-panel">
<SidebarNavContent
groups={groups}
currentHash={currentHash}
onNavigate={() => setOpenMobile(false)}
/>
</SidebarContent>
<SidebarFooter>
<div className="flex min-h-10 items-center gap-2 px-2 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0">
<Bot className="size-4 shrink-0 text-sidebar-foreground/60" />
<div className="flex min-w-0 flex-1 items-center justify-between gap-2 group-data-[collapsible=icon]:hidden">
<span className="truncate text-xs text-sidebar-foreground/60">服务状态</span>
<Badge variant="outline">本地环境</Badge>
<SidebarFooter className="border-t border-sidebar-border p-2.5 group-data-[collapsible=icon]:p-2">
<SidebarMenu>
<SidebarMenuItem className="group-data-[collapsible=icon]:flex group-data-[collapsible=icon]:justify-center">
<SidebarMenuButton
asChild
size="lg"
tooltip="个人中心"
isActive={routeMatches('/profile', currentHash)}
className="h-auto min-h-12 rounded-xl border border-sidebar-border bg-sidebar-accent/35 p-2 group-data-[collapsible=icon]:size-10 group-data-[collapsible=icon]:border-0 group-data-[collapsible=icon]:bg-transparent group-data-[collapsible=icon]:p-0"
>
<a href="#/profile" aria-label="打开个人中心" onClick={() => setOpenMobile(false)}>
<Avatar>
<AvatarFallback className="bg-sidebar-primary/10 font-semibold text-sidebar-primary">
{getUserInitials(userName)}
</AvatarFallback>
<AvatarBadge className="bg-success" aria-label="在线" />
</Avatar>
<span className="min-w-0 flex-1 group-data-[collapsible=icon]:hidden">
<span className="block truncate text-xs font-semibold">{userName}</span>
<span className="mt-0.5 block truncate text-[10px] text-sidebar-foreground/55">个人中心 · 账号与设置</span>
</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<div className="flex items-center justify-between gap-2 px-1 group-data-[collapsible=icon]:flex-col group-data-[collapsible=icon]:px-0">
<span className="text-[10px] font-medium text-sidebar-foreground/45 group-data-[collapsible=icon]:hidden">显示与账户</span>
<div className="flex items-center gap-1 group-data-[collapsible=icon]:flex-col">
<SidebarTrigger
className="hidden min-[1024px]:inline-flex"
aria-label={state === 'expanded' ? '收起导航栏' : '展开导航栏'}
title={state === 'expanded' ? '收起导航栏' : '展开导航栏'}
/>
<AppThemeToggle />
<Button variant="ghost" size="icon" aria-label="退出登录" onClick={() => void logout()}><LogOut /></Button>
</div>
</div>
</SidebarFooter>
<SidebarRail />
</Sidebar>
)
}

View File

@@ -0,0 +1,19 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { AppThemeProvider } from './AppThemeProvider'
import { AppThemeToggle } from './AppThemeToggle'
describe('明暗模式', () => {
it('只提供亮色、暗色和跟随系统', async () => {
const user = userEvent.setup()
render(<AppThemeProvider><AppThemeToggle /></AppThemeProvider>)
await user.click(screen.getByRole('button', { name: /外观模式/ }))
expect(screen.getByRole('menuitemradio', { name: '亮色' })).toBeInTheDocument()
expect(screen.getByRole('menuitemradio', { name: '暗色' })).toBeInTheDocument()
expect(screen.getByRole('menuitemradio', { name: '跟随系统' })).toBeInTheDocument()
expect(screen.queryByText('温润服务')).not.toBeInTheDocument()
expect(screen.queryByText('精密企业')).not.toBeInTheDocument()
})
})

View File

@@ -9,11 +9,10 @@ import {
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
const themeOptions = [
const appearanceOptions = [
{ value: 'light', label: '亮色', icon: Sun },
{ value: 'dark', label: '暗色', icon: Moon },
{ value: 'system', label: '跟随系统', icon: Laptop }
@@ -21,29 +20,23 @@ const themeOptions = [
export function AppThemeToggle() {
const { theme = 'system', setTheme } = useTheme()
const activeTheme = themeOptions.find((option) => option.value === theme) ?? themeOptions[2]
const ActiveIcon = activeTheme.icon
const active = appearanceOptions.find((option) => option.value === theme) ?? appearanceOptions[2]
const ActiveIcon = active.icon
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={`主题:${activeTheme.label}`}>
<ActiveIcon data-icon="inline-start" />
<Button variant="ghost" size="icon" aria-label={`外观模式:${active.label}`}>
<ActiveIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuLabel>界面主题</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuLabel>外观模式</DropdownMenuLabel>
<DropdownMenuGroup>
<DropdownMenuRadioGroup value={theme} onValueChange={setTheme}>
{themeOptions.map((option) => {
{appearanceOptions.map((option) => {
const Icon = option.icon
return (
<DropdownMenuRadioItem key={option.value} value={option.value}>
<Icon data-icon="inline-start" />
{option.label}
</DropdownMenuRadioItem>
)
return <DropdownMenuRadioItem key={option.value} value={option.value}><Icon data-icon="inline-start" />{option.label}</DropdownMenuRadioItem>
})}
</DropdownMenuRadioGroup>
</DropdownMenuGroup>

View File

@@ -1,92 +1,23 @@
import * as React from 'react'
import { ChevronDown, ChevronRight, LogOut, UserRound } from 'lucide-react'
import { Bot } from 'lucide-react'
import { matchRoute, useCurrentRoute } from '@/app/route-meta'
import { AppThemeToggle } from '@/components/app/AppThemeToggle'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Separator } from '@/components/ui/separator'
import { SidebarTrigger } from '@/components/ui/sidebar'
import { useAuth } from '@/features/auth/AuthProvider'
const routeTitles: Array<{ pattern: RegExp; title: string }> = [
{ pattern: /^\/$/, title: '工作台' },
{ pattern: /^\/tickets$/, title: '工单中心' },
{ pattern: /^\/tickets\/new$/, title: '创建工单' },
{ pattern: /^\/tickets\/\d+$/, title: '工单详情' },
{ pattern: /^\/solutions$/, title: '解决方案库' },
{ pattern: /^\/solutions\/new/, title: '新建解决方案' },
{ pattern: /^\/knowledge$/, title: '知识库' },
{ pattern: /^\/knowledge\/search$/, title: '知识检索' },
{ pattern: /^\/knowledge\/items\/new$/, title: '新建知识条目' },
{ pattern: /^\/system\/users$/, title: '用户管理' },
{ pattern: /^\/system\/user-groups$/, title: '用户组管理' },
{ pattern: /^\/system\/roles$/, title: '角色管理' },
{ pattern: /^\/system\/menus$/, title: '菜单权限' },
{ pattern: /^\/audit$/, title: '审计日志' },
{ pattern: /^\/design-system$/, title: '设计系统' }
]
import { useAgentWorkspace } from '@/features/agent-workspace/AgentWorkspaceProvider'
export function getCurrentPageTitle(hash: string) {
const route = hash.replace(/^#/, '') || '/'
return routeTitles.find((item) => item.pattern.test(route))?.title ?? '当前工作区'
return matchRoute(route).meta.section
}
export function AppTopbar() {
const { logout, user } = useAuth()
const [pageTitle, setPageTitle] = React.useState(() => getCurrentPageTitle(window.location.hash))
React.useEffect(() => {
const handleHashChange = () => setPageTitle(getCurrentPageTitle(window.location.hash))
window.addEventListener('hashchange', handleHashChange)
return () => window.removeEventListener('hashchange', handleHashChange)
}, [])
const { meta } = useCurrentRoute()
const { setSheetOpen } = useAgentWorkspace()
return (
<header data-slot="app-topbar" className="sticky top-0 z-20 flex h-14 items-center justify-between border-b border-border/80 bg-background/95 px-4 backdrop-blur sm:px-6 lg:px-8">
<div className="flex min-w-0 items-center gap-2 text-sm">
<SidebarTrigger className="-ml-1" aria-label="切换侧边栏" />
<Separator orientation="vertical" className="mx-1 h-4" />
<span className="hidden font-medium text-foreground sm:inline">企业服务治理中台</span>
<ChevronRight className="hidden size-4 text-muted-foreground sm:block" />
<span className="truncate text-muted-foreground">{pageTitle}</span>
</div>
<div className="flex items-center gap-1">
<AppThemeToggle />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="gap-2 px-2 sm:px-3">
<span className="flex size-7 items-center justify-center rounded-md bg-muted text-foreground">
<UserRound />
</span>
<span className="hidden max-w-32 truncate sm:inline">{user?.display_name || user?.username || '当前用户'}</span>
<ChevronDown data-icon="inline-end" className="hidden text-muted-foreground sm:block" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
<span className="grid gap-0.5">
<span className="truncate text-sm text-foreground">{user?.display_name || user?.username || '当前用户'}</span>
<span className="truncate text-xs font-normal text-muted-foreground">{user?.username || '未设置账号'}</span>
</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive" onSelect={() => void logout()}>
<LogOut data-icon="inline-start" />
退出登录
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<header data-slot="app-topbar" className="flex h-12 shrink-0 items-center justify-between border-b bg-card px-3 min-[1024px]:hidden">
<div className="flex min-w-0 items-center gap-2"><SidebarTrigger aria-label="打开导航" /><span className="truncate text-sm font-semibold">{meta.section}</span></div>
<div className="flex items-center gap-1"><Button variant="ghost" size="icon" aria-label="打开 Agent 演示助手" onClick={() => setSheetOpen(true)}><Bot /></Button><AppThemeToggle /></div>
</header>
)
}

View File

@@ -1,10 +1,13 @@
import * as React from 'react'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AppShell } from '@/app/layout/AppShell'
import { AppPage } from '@/components/app/AppPage'
import { AppPageHeader } from '@/components/app/AppPageHeader'
import { AppSection } from '@/components/app/AppSection'
import { AGENT_DOCKED_OPEN_STORAGE_KEY } from '@/features/agent-workspace/AgentWorkspaceProvider'
const logout = vi.fn()
@@ -44,6 +47,12 @@ vi.mock('@/features/auth/AuthProvider', () => ({
}))
beforeEach(() => {
vi.stubGlobal('ResizeObserver', class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
})
window.sessionStorage.clear()
window.location.hash = '#/tickets'
window.innerWidth = 1280
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
@@ -58,17 +67,21 @@ beforeEach(() => {
}))
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
describe('企业后台 UI 底座', () => {
it('使用 shadcn 侧栏骨架并显示当前中文页面', () => {
const { container } = render(
<AppShell>
<div>业务内容</div>
</AppShell>
)
const { container } = render(<AppShell><div>业务内容</div></AppShell>)
expect(container.querySelector('[data-slot="sidebar-wrapper"]')).toBeInTheDocument()
expect(container.querySelector('[data-slot="sidebar-trigger"]')).toHaveAccessibleName('切换侧边栏')
expect(container.querySelector('[data-slot="app-topbar"]')).toHaveTextContent('工单中心')
expect(within(container.querySelector('[data-slot="app-topbar"]') as HTMLElement).getByRole('button', { name: '打开导航' })).toBeInTheDocument()
expect(container.querySelector('[data-slot="app-topbar"]')).toHaveTextContent('工单')
expect(container.querySelector('[data-slot="app-global-header"]')).toHaveClass('shrink-0', 'border-b')
expect(container.querySelector('[data-slot="app-shell-scroll"]')).toHaveClass('overflow-y-auto', 'scrollbar-page')
expect(within(container.querySelector('[data-slot="app-global-header"]') as HTMLElement).getAllByRole('tab')).toHaveLength(1)
expect(screen.getByRole('link', { name: '工单中心' })).toHaveAttribute('href', '#/tickets')
})
@@ -90,4 +103,114 @@ describe('企业后台 UI 底座', () => {
expect(section?.tagName).toBe('SECTION')
expect(section?.querySelector('[data-slot="card"]')).not.toBeInTheDocument()
})
it('1920px 下允许通过明确按钮持续切换侧栏状态,不渲染误导性的缩放条', async () => {
const user = userEvent.setup()
window.innerWidth = 1920
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query === '(min-width: 1536px)',
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn()
}))
const { container } = render(<AppShell><div>业务内容</div></AppShell>)
const sidebar = container.querySelector('[data-slot="sidebar"]')
await waitFor(() => expect(sidebar).toHaveAttribute('data-state', 'expanded'))
await user.click(screen.getByRole('button', { name: '收起导航栏' }))
await waitFor(() => expect(sidebar).toHaveAttribute('data-state', 'collapsed'))
expect(within(container).queryByRole('button', { name: '切换侧边栏' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '展开导航栏' }))
await waitFor(() => expect(sidebar).toHaveAttribute('data-state', 'expanded'))
})
it('桌面 Agent 助手可关闭,并可从全局悬浮入口重新打开', async () => {
const user = userEvent.setup()
window.innerWidth = 1440
window.sessionStorage.setItem(AGENT_DOCKED_OPEN_STORAGE_KEY, 'false')
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query === '(min-width: 1280px)',
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn()
}))
const { container } = render(
<AppShell>
<AppPage header={<AppPageHeader title="工单中心" />}>
<p>业务内容</p>
</AppPage>
</AppShell>
)
expect(container.querySelector('[data-slot="app-agent-dock"]')).not.toBeInTheDocument()
expect(container.querySelector('[data-slot="agent-workspace-launcher"]')).toHaveClass('fixed', 'rounded-full')
await user.click(screen.getByRole('button', { name: '打开 Agent 助手' }))
await waitFor(() => expect(container.querySelector('[data-slot="app-agent-dock"]')).toBeInTheDocument())
expect(window.sessionStorage.getItem(AGENT_DOCKED_OPEN_STORAGE_KEY)).toBe('true')
expect(screen.queryByRole('button', { name: '打开 Agent 助手' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '关闭 Agent 助手' }))
await waitFor(() => expect(container.querySelector('[data-slot="app-agent-dock"]')).not.toBeInTheDocument())
expect(window.sessionStorage.getItem(AGENT_DOCKED_OPEN_STORAGE_KEY)).toBe('false')
expect(screen.getByRole('button', { name: '打开 Agent 助手' })).toBeInTheDocument()
})
it('治理页面同样显示全局 Agent 入口,并通过 Sheet 打开', async () => {
const user = userEvent.setup()
window.location.hash = '#/system/users'
const { container } = render(<AppShell><div>用户管理内容</div></AppShell>)
await user.click(screen.getByRole('button', { name: '打开 Agent 助手' }))
expect(await screen.findByRole('dialog')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Agent 助手' })).toBeInTheDocument()
expect(container.querySelector('[data-slot="app-agent-dock"]')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '打开 Agent 助手' })).not.toBeInTheDocument()
})
it('页面上下文栏独立于正文,且不与固定全站页眉重复粘附', () => {
window.location.hash = '#/tickets/new'
const { container } = render(
<AppPage
header={<AppPageHeader title="创建工单" description="记录问题上下文" />}
>
<p>表单内容</p>
</AppPage>
)
const surface = container.querySelector('[data-slot="app-page-header-surface"]')
const content = container.querySelector('[data-slot="app-page-content"]')
expect(surface).toHaveClass('border-b', 'bg-card')
expect(surface).not.toHaveClass('min-[1024px]:sticky')
expect(surface).toHaveTextContent('工单中心')
expect(surface).toHaveTextContent('创建工单')
expect(screen.queryByRole('link', { name: '返回工单中心' })).not.toBeInTheDocument()
expect(within(surface as HTMLElement).getByRole('link', { name: '工单中心' })).toHaveAttribute('href', '#/tickets')
expect(content).toHaveTextContent('表单内容')
expect(surface?.contains(content)).toBe(false)
})
it('详情页允许使用实体编号覆盖当前面包屑节点', () => {
window.location.hash = '#/tickets/42'
render(
<AppPage header={<AppPageHeader title="VPN 连接失败" breadcrumbLabel="INC-20260714-0042" />}>
<p>详情内容</p>
</AppPage>
)
expect(screen.getByRole('link', { name: 'INC-20260714-0042' })).toHaveAttribute('aria-current', 'page')
expect(screen.getByRole('heading', { name: 'VPN 连接失败' })).toBeInTheDocument()
})
})

View File

@@ -1,53 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { AppApprovalPanel } from './AppApprovalPanel'
import { AppMetricCard } from './AppMetricCard'
import { AppPrototypeNotice } from './AppPrototypeNotice'
import { AppReferenceCard } from './AppReferenceCard'
import { AppRiskBadge } from './AppRiskBadge'
import { AppTraceTimeline } from './AppTraceTimeline'
describe('Stage A 共享组件', () => {
it('明确标识演示数据', () => {
render(<AppPrototypeNotice />)
expect(screen.getByText('当前页面使用演示数据')).toBeInTheDocument()
})
it('展示指标、风险、引用和执行步骤', () => {
render(
<>
<AppMetricCard label="待处理工单" value="18" change="较昨日增加 3 单" tone="warning" />
<AppRiskBadge risk="high" />
<AppReferenceCard
title="排查步骤"
sourceType="knowledge_item"
content="检查任务状态"
score={0.93}
sourceLabel="客户支持知识库"
/>
<AppTraceTimeline
items={[
{
id: 1,
step_name: '检索知识',
step_type: 'retrieval',
status: 'success',
input_summary: '问题',
output_summary: '命中 2 条',
latency_ms: 180
}
]}
/>
<AppApprovalPanel title="等待人工采纳" description="确认后才能写入业务数据。" state="waiting" />
</>
)
expect(screen.getByText('待处理工单')).toBeInTheDocument()
expect(screen.getByText('高风险')).toBeInTheDocument()
expect(screen.getByText('匹配度 93%')).toBeInTheDocument()
expect(screen.getByText('检索知识')).toBeInTheDocument()
expect(screen.getByText('等待人工采纳')).toBeInTheDocument()
})
})