| 1 | /** |
| 2 | * ConfigFormPanel - 配置表单滚动区域 |
| 3 | * 每个分组作为锚点节点供左侧目录联动。 |
| 4 | */ |
| 5 | 'use client' |
| 6 | |
| 7 | import type { ConfigFormPanelProps } from '../../types' |
| 8 | import { useEffect, useLayoutEffect, useRef } from 'react' |
| 9 | import { useTransClient } from '@/app/i18n/client' |
| 10 | import { Badge } from '@/components/ui/badge' |
| 11 | import { joinPath } from '../../utils/configPath' |
| 12 | import { getConfigSectionValue } from '../../utils/configSections' |
| 13 | import { ConfigField } from '../ConfigField' |
| 14 | |
| 15 | export function ConfigFormPanel({ |
| 16 | sections, |
| 17 | config, |
| 18 | originalConfig, |
| 19 | disabled, |
| 20 | scrollContainerRef, |
| 21 | focusRequest, |
| 22 | highlightedPathKey, |
| 23 | initialScrollTop, |
| 24 | onFocusRequestHandled, |
| 25 | onValueChange, |
| 26 | onNavigateToJson, |
| 27 | onScrollTopChange, |
| 28 | }: ConfigFormPanelProps) { |
| 29 | const { t } = useTransClient('configManager') |
| 30 | const shouldRestoreInitialScrollRef = useRef(!focusRequest) |
| 31 | const didRestoreInitialScrollRef = useRef(false) |
| 32 | |
| 33 | useLayoutEffect(() => { |
| 34 | if (!shouldRestoreInitialScrollRef.current || didRestoreInitialScrollRef.current) |
| 35 | return |
| 36 | |
| 37 | const container = scrollContainerRef.current |
| 38 | if (!container) |
| 39 | return |
| 40 | |
| 41 | container.scrollTop = initialScrollTop |
| 42 | didRestoreInitialScrollRef.current = true |
| 43 | }, [initialScrollTop, scrollContainerRef]) |
| 44 | |
| 45 | useEffect(() => { |
| 46 | if (!focusRequest) |
| 47 | return |
| 48 | |
| 49 | let firstFrame = 0 |
| 50 | let secondFrame = 0 |
| 51 | const targetPathKey = joinPath(focusRequest.path) |
| 52 | |
| 53 | firstFrame = window.requestAnimationFrame(() => { |
| 54 | secondFrame = window.requestAnimationFrame(() => { |
| 55 | const container = scrollContainerRef.current |
| 56 | if (!container) |
| 57 | return |
| 58 | |
| 59 | const target = Array.from(container.querySelectorAll<HTMLElement>('[data-config-path-key]')) |
| 60 | .find(element => element.dataset.configPathKey === targetPathKey) |
| 61 | |
| 62 | if (!target) |
| 63 | return |
| 64 | |
| 65 | const containerRect = container.getBoundingClientRect() |
| 66 | const targetRect = target.getBoundingClientRect() |
| 67 | container.scrollTop = Math.max(0, targetRect.top - containerRect.top + container.scrollTop - 72) |
| 68 | onFocusRequestHandled(focusRequest.id) |
| 69 | }) |
| 70 | }) |
| 71 | |
| 72 | return () => { |
| 73 | window.cancelAnimationFrame(firstFrame) |
| 74 | window.cancelAnimationFrame(secondFrame) |
| 75 | } |
| 76 | }, [focusRequest, onFocusRequestHandled, scrollContainerRef]) |
| 77 | |
| 78 | return ( |
| 79 | <div |
| 80 | ref={(node) => { |
| 81 | scrollContainerRef.current = node |
| 82 | }} |
| 83 | className="h-full overflow-y-auto bg-background" |
| 84 | onScroll={(event) => { |
| 85 | onScrollTopChange(event.currentTarget.scrollTop) |
| 86 | }} |
| 87 | > |
| 88 | <div className="space-y-3 px-3 py-3 pb-6"> |
| 89 | {sections.map(section => ( |
| 90 | <section |
| 91 | key={section.id} |
| 92 | data-config-section-id={section.id} |
| 93 | className="scroll-mt-3" |
| 94 | > |
| 95 | <div className="px-3 pb-2 pt-3"> |
| 96 | <div className="flex min-w-0 items-center gap-2"> |
| 97 | <h3 className="shrink-0 text-sm font-semibold text-foreground">{section.label}</h3> |
| 98 | {section.notRecommended && ( |
| 99 | <Badge variant="outline" className="h-5 shrink-0 border-warning/30 bg-warning/10 px-1.5 py-0 text-[10px] font-normal leading-none text-warning"> |
| 100 | {t('status.notRecommended')} |
| 101 | </Badge> |
| 102 | )} |
| 103 | <div className="h-px min-w-0 flex-1 bg-border" aria-hidden /> |
| 104 | </div> |
| 105 | {section.description && ( |
| 106 | <p className="mt-1 line-clamp-2 text-xs leading-4 text-muted-foreground"> |
| 107 | {section.description} |
| 108 | </p> |
| 109 | )} |
| 110 | </div> |
| 111 | <div className="mx-3 mb-3 overflow-hidden rounded-md border border-border bg-background divide-y divide-border/70"> |
| 112 | {section.paths.map((path) => { |
| 113 | const value = getConfigSectionValue(config, path) |
| 114 | const originalValue = originalConfig ? getConfigSectionValue(originalConfig, path) : undefined |
| 115 | const fieldKey = String(path[path.length - 1] ?? section.id) |
| 116 | return ( |
| 117 | <ConfigField |
| 118 | key={path.join('.')} |
| 119 | path={path} |
| 120 | fieldKey={fieldKey} |
| 121 | value={value} |
| 122 | originalValue={originalValue} |
| 123 | disabled={disabled} |
| 124 | focusPath={focusRequest?.path ?? null} |
| 125 | highlightedPathKey={highlightedPathKey} |
| 126 | onValueChange={onValueChange} |
| 127 | onNavigateToJson={onNavigateToJson} |
| 128 | /> |
| 129 | ) |
| 130 | })} |
| 131 | </div> |
| 132 | </section> |
| 133 | ))} |
| 134 | </div> |
| 135 | </div> |
| 136 | ) |
| 137 | } |
| 138 |