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