返回 AiToEarn
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
138 lines Plain Text