diff --git a/frontend/src/components/app/AppSidebar.test.tsx b/frontend/src/components/app/AppSidebar.test.tsx
new file mode 100644
index 0000000..b2c9c1a
--- /dev/null
+++ b/frontend/src/components/app/AppSidebar.test.tsx
@@ -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(
)
+}
+
+function MobileSidebar() {
+ const { setOpenMobile } = useSidebar()
+ React.useEffect(() => setOpenMobile(true), [setOpenMobile])
+ return
+}
+
+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(
)
+
+ const link = await screen.findByRole('link', { name: '知识检索' })
+ await user.click(link)
+ await waitFor(() => expect(container.querySelector('[data-slot="sidebar"][data-mobile="true"]')).not.toBeInTheDocument())
+ })
+})
diff --git a/frontend/src/components/app/AppSidebar.tsx b/frontend/src/components/app/AppSidebar.tsx
index 47f4a25..59d00c0 100644
--- a/frontend/src/components/app/AppSidebar.tsx
+++ b/frontend/src/components/app/AppSidebar.tsx
@@ -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
(activeGroupCode)
+
+ React.useEffect(() => {
+ setOpenGroupCode(activeGroupCode)
+ }, [activeGroupCode, currentHash])
+
return (
- <>
- {groups.map((group) => (
-
- {group.title}
-
-
- {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}/`))
+
+
+ 业务导航
+
+
+
+ {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 (
-
-
-
+
+
+
+
- {item.title}
+ {group.title}
)
- })}
-
-
-
- ))}
- >
+ }
+
+ const selectedChildCode = activeChildCode(group, currentHash)
+ const groupActive = Boolean(selectedChildCode)
+ const groupOpen = openGroupCode === group.code
+ return (
+ {
+ if (!isMobile && state === 'collapsed') {
+ setOpen(true)
+ setOpenGroupCode(group.code)
+ return
+ }
+ setOpenGroupCode(nextOpen ? group.code : null)
+ }}
+ >
+
+
+
+
+
+ {group.title}
+
+
+
+
+
+ {children.map((item) => {
+ const itemActive = selectedChildCode === item.code
+ return (
+
+
+
+
+ {item.title}
+
+
+
+ )
+ })}
+
+
+
+
+ )
+ })}
+
+
+
)
}
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 (
-
-
+
+
-
-
-
-
-
+
+
+
+
+
-
- AI 工单平台
- 企业服务治理中台
+
+ 智服台
+ AI Service Desk
@@ -179,23 +266,51 @@ export function AppSidebar() {
-
+
setOpenMobile(false)}
/>
-
-
-
-
-
服务状态
-
本地环境
+
+
+
+
+ setOpenMobile(false)}>
+
+
+ {getUserInitials(userName)}
+
+
+
+
+ {userName}
+ 个人中心 · 账号与设置
+
+
+
+
+
+
-
)
}
diff --git a/frontend/src/components/app/AppThemeToggle.test.tsx b/frontend/src/components/app/AppThemeToggle.test.tsx
new file mode 100644
index 0000000..6bbde4e
--- /dev/null
+++ b/frontend/src/components/app/AppThemeToggle.test.tsx
@@ -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(
)
+ 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()
+ })
+})
diff --git a/frontend/src/components/app/AppThemeToggle.tsx b/frontend/src/components/app/AppThemeToggle.tsx
index 0387103..1a5e428 100644
--- a/frontend/src/components/app/AppThemeToggle.tsx
+++ b/frontend/src/components/app/AppThemeToggle.tsx
@@ -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 (
-
-
- 界面主题
-
+
+ 外观模式
- {themeOptions.map((option) => {
+ {appearanceOptions.map((option) => {
const Icon = option.icon
- return (
-
-
- {option.label}
-
- )
+ return {option.label}
})}
diff --git a/frontend/src/components/app/AppTopbar.tsx b/frontend/src/components/app/AppTopbar.tsx
index d4e0d99..8e6f969 100644
--- a/frontend/src/components/app/AppTopbar.tsx
+++ b/frontend/src/components/app/AppTopbar.tsx
@@ -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 (
-