返回 AiToEarn
NavSection.tsx
1 /**
2 * NavSection - 侧边栏主导航区域
3 */
4
5 'use client'
6
7 import type { NavItemData, NavSectionProps, SidebarCommonProps } from '../types'
8 import { FileText } from 'lucide-react'
9 import Link from 'next/link'
10 import { useTransClient } from '@/app/i18n/client'
11 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
12 import { useGetClientLng } from '@/hooks/useSystem'
13 import { cn } from '@/utils/className'
14
15 /** 单个导航项 */
16 interface NavItemProps extends SidebarCommonProps {
17 item: NavItemData
18 currentRoute: string
19 level?: number
20 }
21
22 interface NavItemActiveState {
23 isSelfActive: boolean
24 isDescendantActive: boolean
25 isActive: boolean
26 }
27
28 function isItemActive(item: NavItemData, currentRoute: string): boolean {
29 if (item.path === currentRoute) {
30 return true
31 }
32
33 return item.children?.some(child => isItemActive(child, currentRoute)) ?? false
34 }
35
36 function getItemActiveState(item: NavItemData, currentRoute: string): NavItemActiveState {
37 const isSelfActive = item.path === currentRoute
38 const isDescendantActive = item.children?.some(child => isItemActive(child, currentRoute)) ?? false
39
40 return {
41 isSelfActive,
42 isDescendantActive,
43 isActive: isSelfActive || isDescendantActive,
44 }
45 }
46
47 function NavItem({ item, currentRoute, collapsed, level = 0 }: NavItemProps) {
48 const { t } = useTransClient('route')
49 const lng = useGetClientLng()
50 const { isActive, isDescendantActive, isSelfActive } = getItemActiveState(item, currentRoute)
51 const hasChildren = Boolean(item.children?.length)
52 const fullPath = item.path
53 ? item.path.startsWith('/')
54 ? `/${lng}${item.path}`
55 : `/${lng}/${item.path}`
56 : undefined
57 const showStrongActive = isSelfActive || (collapsed && isActive)
58 const showBranchActive = !showStrongActive && !collapsed && level === 0 && hasChildren && isDescendantActive
59
60 const contentNode = (
61 <div
62 className={cn(
63 'relative flex w-full min-w-0 items-center text-sm font-medium transition-[background-color,color,box-shadow] duration-200',
64 level === 0 ? 'rounded-xl' : 'rounded-lg',
65 showStrongActive
66 ? 'bg-brand-cyan/10 text-brand-cyan ring-1 ring-inset ring-brand-cyan/25'
67 : 'text-muted-foreground hover:bg-brand-cyan/10 hover:text-brand-cyan',
68 showBranchActive && 'text-brand-cyan',
69 collapsed
70 ? 'justify-center px-2 py-2.5'
71 : level === 0
72 ? 'gap-3 px-3 py-2.5'
73 : 'gap-2.5 px-3 py-2 text-[13px]',
74 )}
75 >
76 {showBranchActive && (
77 <span className="absolute left-1.5 top-1/2 h-5 w-1 -translate-y-1/2 rounded-full bg-brand-cyan" />
78 )}
79 <span
80 className={cn(
81 'flex shrink-0 items-center justify-center',
82 showStrongActive && 'text-brand-cyan',
83 showBranchActive && 'text-brand-cyan',
84 !showStrongActive && !showBranchActive && level > 0 && 'opacity-80',
85 )}
86 >
87 {level === 0 ? (
88 item.icon || <FileText size={20} />
89 ) : (
90 <span className="size-1.5 rounded-full bg-current/60" />
91 )}
92 </span>
93 {!collapsed && (
94 <span className="overflow-hidden text-ellipsis whitespace-nowrap">
95 {t(item.translationKey)}
96 </span>
97 )}
98 </div>
99 )
100
101 const content = fullPath ? (
102 <Link href={fullPath} className="block w-full" data-testid={`sidebar-nav-item-${item.translationKey}`}>
103 {contentNode}
104 </Link>
105 ) : (
106 <div className="w-full" data-testid={`sidebar-nav-item-${item.translationKey}`}>{contentNode}</div>
107 )
108
109 const renderedContent = collapsed ? (
110 <TooltipProvider>
111 <Tooltip>
112 <TooltipTrigger asChild>{content}</TooltipTrigger>
113 <TooltipContent side="right">
114 <p>{t(item.translationKey)}</p>
115 </TooltipContent>
116 </Tooltip>
117 </TooltipProvider>
118 ) : (
119 content
120 )
121
122 if (!hasChildren || collapsed) {
123 return renderedContent
124 }
125
126 return (
127 <div className="space-y-1">
128 {renderedContent}
129 <div className="ml-4 mt-1 space-y-1 border-l border-sidebar-border/50 pl-3">
130 {item.children?.map(child => (
131 <NavItem
132 key={child.path || child.translationKey}
133 item={child}
134 currentRoute={currentRoute}
135 collapsed={collapsed}
136 level={level + 1}
137 />
138 ))}
139 </div>
140 </div>
141 )
142 }
143
144 export function NavSection({ items, currentRoute, collapsed }: NavSectionProps) {
145 // 旧 "More" 分组内的项改为直接展示在主菜单底部
146 const trailingKeys = ['tasksHistory', 'header.materialLibrary']
147 const mainItems = items.filter(item => !trailingKeys.includes(item.translationKey))
148 const trailingItems = trailingKeys
149 .map(key => items.find(i => i.translationKey === key))
150 .filter((i): i is NavItemData => i !== undefined)
151 const navItems = [...mainItems, ...trailingItems]
152
153 return (
154 <nav className="flex flex-col gap-1 pr-1" data-testid="sidebar-nav">
155 {navItems.map(item => (
156 <NavItem
157 key={item.path || item.translationKey}
158 item={item}
159 currentRoute={currentRoute}
160 collapsed={collapsed}
161 />
162 ))}
163 </nav>
164 )
165 }
166
166 lines Plain Text