feat: rebuild support workspace frontend
This commit is contained in:
79
frontend/src/components/app/AppCreateDialog.tsx
Normal file
79
frontend/src/components/app/AppCreateDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
93
frontend/src/components/app/AppRouteTabs.test.tsx
Normal file
93
frontend/src/components/app/AppRouteTabs.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
157
frontend/src/components/app/AppSidebar.test.tsx
Normal file
157
frontend/src/components/app/AppSidebar.test.tsx
Normal 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())
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
19
frontend/src/components/app/AppThemeToggle.test.tsx
Normal file
19
frontend/src/components/app/AppThemeToggle.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user