| 1 | /** |
| 2 | * ConfigSectionNav - 配置分组目录 |
| 3 | * 支持当前滚动分组高亮与点击锚点滚动。 |
| 4 | */ |
| 5 | 'use client' |
| 6 | |
| 7 | import type { ConfigSectionView } from '../../types' |
| 8 | import { useEffect, useRef } from 'react' |
| 9 | import { useTransClient } from '@/app/i18n/client' |
| 10 | import { cn } from '@/utils/className' |
| 11 | |
| 12 | interface ConfigSectionNavProps { |
| 13 | sections: ConfigSectionView[] |
| 14 | activeSectionId: string |
| 15 | disabled?: boolean |
| 16 | onSectionClick: (sectionId: string) => void |
| 17 | } |
| 18 | |
| 19 | export function ConfigSectionNav({ sections, activeSectionId, disabled, onSectionClick }: ConfigSectionNavProps) { |
| 20 | const { t } = useTransClient('configManager') |
| 21 | const navRef = useRef<HTMLElement | null>(null) |
| 22 | |
| 23 | useEffect(() => { |
| 24 | const nav = navRef.current |
| 25 | if (!nav || !activeSectionId) |
| 26 | return |
| 27 | |
| 28 | const activeItem = Array.from(nav.querySelectorAll<HTMLElement>('[data-config-section-nav-id]')) |
| 29 | .find(item => item.dataset.configSectionNavId === activeSectionId) |
| 30 | |
| 31 | if (!activeItem) |
| 32 | return |
| 33 | |
| 34 | nav.scrollTo({ |
| 35 | top: Math.max(0, activeItem.offsetTop - nav.clientHeight / 2 + activeItem.clientHeight / 2), |
| 36 | behavior: 'smooth', |
| 37 | }) |
| 38 | }, [activeSectionId, sections]) |
| 39 | |
| 40 | return ( |
| 41 | <nav ref={navRef} className="flex h-full flex-col gap-1 overflow-y-auto p-3 pb-[45%]" aria-label="Configuration sections"> |
| 42 | {sections.map((section) => { |
| 43 | const active = section.id === activeSectionId |
| 44 | return ( |
| 45 | <button |
| 46 | key={section.id} |
| 47 | type="button" |
| 48 | data-config-section-nav-id={section.id} |
| 49 | aria-current={active ? 'true' : undefined} |
| 50 | disabled={disabled} |
| 51 | onClick={() => onSectionClick(section.id)} |
| 52 | className={cn( |
| 53 | 'group relative flex cursor-pointer flex-col rounded-lg px-3 py-2 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60', |
| 54 | active |
| 55 | ? 'bg-brand-cyan/10 text-foreground' |
| 56 | : 'text-muted-foreground hover:bg-accent hover:text-foreground', |
| 57 | )} |
| 58 | > |
| 59 | {active && <span className="absolute left-0 top-2 h-[calc(100%-16px)] w-0.5 rounded-full bg-gradient-back" />} |
| 60 | <span className="flex items-center justify-between gap-3 text-sm font-medium"> |
| 61 | <span>{section.label}</span> |
| 62 | <span className="flex shrink-0 items-center gap-1"> |
| 63 | {section.notRecommended && ( |
| 64 | <span className="rounded-full bg-warning/10 px-1.5 py-0.5 text-[10px] text-warning ring-1 ring-warning/30"> |
| 65 | {t('status.notRecommended')} |
| 66 | </span> |
| 67 | )} |
| 68 | {section.modifiedFieldCount > 0 && ( |
| 69 | <span className="rounded-full bg-warning/10 px-1.5 py-0.5 text-[10px] text-warning ring-1 ring-warning/30"> |
| 70 | {section.modifiedFieldCount} |
| 71 | </span> |
| 72 | )} |
| 73 | <span className="rounded-full bg-background px-1.5 py-0.5 text-[10px] text-muted-foreground ring-1 ring-border"> |
| 74 | {section.fieldCount} |
| 75 | </span> |
| 76 | </span> |
| 77 | </span> |
| 78 | {section.description && ( |
| 79 | <span className="mt-1 line-clamp-2 text-xs leading-4 text-muted-foreground"> |
| 80 | {section.description} |
| 81 | </span> |
| 82 | )} |
| 83 | </button> |
| 84 | ) |
| 85 | })} |
| 86 | </nav> |
| 87 | ) |
| 88 | } |
| 89 |