返回 AiToEarn
1 /**
2 * ConfigField - 配置字段递归表单控件
3 * 将配置对象转换成设置列表,降低参数区视觉噪音。
4 */
5 'use client'
6
7 import type { ConfigFieldProps, ConfigPath, ConfigValue } from '../../types'
8 import { Braces, ChevronDown, Eye, EyeOff, Plus, Trash2 } from 'lucide-react'
9 import { useEffect, useMemo, useState } from 'react'
10 import { useTransClient } from '@/app/i18n/client'
11 import { Badge } from '@/components/ui/badge'
12 import { Button } from '@/components/ui/button'
13 import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
14 import { Input } from '@/components/ui/input'
15 import { Label } from '@/components/ui/label'
16 import { NumberInput } from '@/components/ui/number-input'
17 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
18 import { Switch } from '@/components/ui/switch'
19 import { Textarea } from '@/components/ui/textarea'
20 import { cn } from '@/utils/className'
21 import {
22 countLeafFields,
23 countModifiedLeafFields,
24 getConfigFieldLabel,
25 getLastStringSegment,
26 isConfigValueModified,
27 isSensitiveConfigPath,
28 } from '../../utils/configFieldMeta'
29 import { createEmptyValue, isRecord, joinPath } from '../../utils/configPath'
30
31 const selectOptions: Record<string, string[]> = {
32 environment: ['development', 'production'],
33 }
34
35 const compactControlClassName = 'h-7 min-h-7 w-full rounded-sm border-transparent bg-transparent px-2 py-0 text-sm shadow-none group-hover/config-item:border-input group-hover/config-item:bg-background hover:border-input hover:bg-background focus:border-ring focus:bg-background focus:ring-1 focus:ring-ring focus:ring-offset-0 focus-visible:border-ring focus-visible:bg-background focus-visible:ring-1 focus-visible:ring-ring'
36 const compactBadgeClassName = 'h-5 shrink-0 whitespace-nowrap px-1.5 py-0 text-[10px] font-normal leading-none'
37 const nodeTriggerClassName = 'group/config-item flex min-h-8 w-full cursor-pointer items-center justify-between gap-2 py-1 pr-2 text-left outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring'
38
39 function shouldUseTextarea(value: string) {
40 return value.includes('\n') || value.length > 96
41 }
42
43 function getInputId(pathKey: string) {
44 return `config-field-${pathKey.replace(/[^\w-]/g, '-')}`
45 }
46
47 function isPathPrefix(path: ConfigPath, targetPath: ConfigPath | null) {
48 if (!targetPath || path.length > targetPath.length)
49 return false
50 return path.every((segment, index) => segment === targetPath[index])
51 }
52
53 function getPathHighlightClassName(pathKey: string, highlightedPathKey: string) {
54 return pathKey === highlightedPathKey && 'animate-pulse bg-primary/10 ring-2 ring-inset ring-primary/45'
55 }
56
57 function ExpandIcon({ open }: { open: boolean }) {
58 return (
59 <ChevronDown
60 className={cn('h-3.5 w-3.5 transition-transform duration-150', !open && '-rotate-90')}
61 />
62 )
63 }
64
65 function PathJumpButton({ path, onNavigateToJson }: {
66 path: ConfigPath
67 onNavigateToJson: (path: ConfigPath) => void
68 }) {
69 const { t } = useTransClient('configManager')
70
71 return (
72 <Button
73 type="button"
74 variant="ghost"
75 size="icon"
76 className="h-5 w-5 shrink-0 cursor-pointer text-muted-foreground opacity-0 transition-opacity hover:bg-background/80 hover:text-foreground group-hover/config-item:opacity-100 focus-visible:opacity-100"
77 aria-label={t('actions.goToJsonField')}
78 onMouseDown={event => event.preventDefault()}
79 onClick={(event) => {
80 event.stopPropagation()
81 onNavigateToJson([...path])
82 }}
83 >
84 <Braces className="h-3.5 w-3.5" />
85 </Button>
86 )
87 }
88
89 function SettingLabel({
90 inputId,
91 label,
92 modified,
93 depth,
94 path,
95 onNavigateToJson,
96 }: {
97 inputId?: string
98 label: string
99 modified: boolean
100 depth: number
101 path: ConfigPath
102 onNavigateToJson: (path: ConfigPath) => void
103 }) {
104 const { t } = useTransClient('configManager')
105 const labelClassName = cn(
106 'block truncate text-foreground',
107 depth === 0 && 'text-sm font-semibold tracking-tight',
108 depth === 1 && 'text-[13px] font-semibold',
109 depth >= 2 && 'text-xs font-medium',
110 )
111 const markerClassName = cn(
112 'shrink-0',
113 depth === 0 && 'h-2 w-2 rounded-sm bg-primary',
114 depth === 1 && 'h-1.5 w-1.5 rounded-full bg-muted-foreground/70',
115 depth >= 2 && 'hidden',
116 )
117
118 return (
119 <div className="min-w-0">
120 <div className="flex min-w-0 items-center gap-1.5">
121 <span className={markerClassName} aria-hidden />
122 {inputId
123 ? (
124 <Label htmlFor={inputId} className={labelClassName}>
125 {label}
126 </Label>
127 )
128 : <div className={labelClassName}>{label}</div>}
129 {modified && (
130 <Badge variant="outline" className={cn(compactBadgeClassName, 'border-warning/30 bg-warning/10 text-warning')}>
131 {t('status.modified')}
132 </Badge>
133 )}
134 <PathJumpButton path={path} onNavigateToJson={onNavigateToJson} />
135 </div>
136 </div>
137 )
138 }
139
140 function getTreeIndentStyle(depth: number) {
141 return { paddingLeft: `${Math.min(depth, 6) * 12 + 10}px` }
142 }
143
144 function getNodeClassName(depth: number, modified: boolean) {
145 return cn(
146 'transition-colors',
147 depth === 0 && 'bg-muted/55 hover:bg-muted/75',
148 depth === 1 && 'bg-muted/20 hover:bg-muted/35',
149 depth >= 2 && 'bg-background hover:bg-muted/20',
150 modified && 'ring-1 ring-inset ring-warning/20',
151 )
152 }
153
154 function PrimitiveField({
155 path,
156 fieldKey,
157 value,
158 originalValue,
159 disabled,
160 depth = 0,
161 focusPath,
162 highlightedPathKey,
163 onValueChange,
164 onNavigateToJson,
165 }: ConfigFieldProps) {
166 const { t } = useTransClient('configManager')
167 const [showSensitiveValue, setShowSensitiveValue] = useState(false)
168 const label = getConfigFieldLabel(t, path, fieldKey)
169 const pathKey = joinPath(path)
170 const inputId = getInputId(pathKey)
171 const lastKey = getLastStringSegment(path, fieldKey)
172 const options = selectOptions[lastKey]
173 const modified = isConfigValueModified(value, originalValue)
174 const sensitive = isSensitiveConfigPath(path)
175
176 return (
177 <div
178 data-config-path-key={pathKey}
179 className={cn(
180 'group/config-item grid min-h-8 gap-2 py-0.5 pr-2 lg:grid-cols-[minmax(120px,180px)_minmax(0,1fr)] lg:items-center',
181 getNodeClassName(depth, modified),
182 getPathHighlightClassName(pathKey, highlightedPathKey),
183 )}
184 style={getTreeIndentStyle(depth)}
185 >
186 <SettingLabel
187 inputId={inputId}
188 label={label}
189 modified={modified}
190 depth={depth}
191 path={path}
192 onNavigateToJson={onNavigateToJson}
193 />
194
195 <div className="min-w-0">
196 {typeof value === 'boolean' && (
197 <div className="flex h-7 w-full items-center justify-between gap-3 rounded-sm border border-transparent bg-transparent px-2 shadow-none group-hover/config-item:border-input group-hover/config-item:bg-background hover:border-input hover:bg-background">
198 <span className="text-sm text-muted-foreground">
199 {value ? t('common.enabled') : t('common.disabled')}
200 </span>
201 <Switch
202 id={inputId}
203 checked={value}
204 disabled={disabled}
205 onCheckedChange={checked => onValueChange(path, checked)}
206 />
207 </div>
208 )}
209
210 {typeof value === 'number' && (
211 <NumberInput
212 id={inputId}
213 value={value}
214 disabled={disabled}
215 className={compactControlClassName}
216 onValueChange={nextValue => onValueChange(path, nextValue ?? 0)}
217 />
218 )}
219
220 {typeof value === 'string' && options && (
221 <Select value={value} disabled={disabled} onValueChange={nextValue => onValueChange(path, nextValue)}>
222 <SelectTrigger id={inputId} className={compactControlClassName}>
223 <SelectValue />
224 </SelectTrigger>
225 <SelectContent>
226 {options.map(option => <SelectItem key={option} value={option}>{option}</SelectItem>)}
227 </SelectContent>
228 </Select>
229 )}
230
231 {typeof value === 'string' && !options && sensitive && (
232 <div className="relative">
233 <Input
234 id={inputId}
235 value={value}
236 disabled={disabled}
237 autoComplete="off"
238 data-lpignore="true"
239 data-form-type="other"
240 className={cn(compactControlClassName, 'pr-8', !showSensitiveValue && '[-webkit-text-security:disc]')}
241 onChange={event => onValueChange(path, event.target.value)}
242 />
243 <Button
244 type="button"
245 variant="ghost"
246 size="icon"
247 disabled={disabled}
248 className="absolute right-1 top-1/2 h-5 w-5 -translate-y-1/2 cursor-pointer text-muted-foreground hover:bg-transparent hover:text-foreground"
249 aria-label={showSensitiveValue ? t('actions.hideSensitiveValue') : t('actions.showSensitiveValue')}
250 onClick={() => setShowSensitiveValue(current => !current)}
251 >
252 {showSensitiveValue ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
253 </Button>
254 </div>
255 )}
256
257 {typeof value === 'string' && !options && !sensitive && shouldUseTextarea(value) && (
258 <Textarea
259 id={inputId}
260 value={value}
261 disabled={disabled}
262 rows={3}
263 className="min-h-20 text-sm shadow-sm"
264 onChange={event => onValueChange(path, event.target.value)}
265 />
266 )}
267
268 {typeof value === 'string' && !options && !sensitive && !shouldUseTextarea(value) && (
269 <Input
270 id={inputId}
271 value={value}
272 disabled={disabled}
273 className={compactControlClassName}
274 onChange={event => onValueChange(path, event.target.value)}
275 />
276 )}
277
278 {value == null && (
279 <Input
280 id={inputId}
281 value=""
282 disabled={disabled}
283 placeholder={t('common.emptyValue')}
284 className={compactControlClassName}
285 onChange={event => onValueChange(path, event.target.value)}
286 />
287 )}
288 </div>
289 </div>
290 )
291 }
292
293 function getArrayItemDisplayValue(value: unknown) {
294 if (typeof value === 'string')
295 return value.trim()
296 if (typeof value === 'number' || typeof value === 'boolean')
297 return String(value)
298 return ''
299 }
300
301 function getArrayItemTitle(item: unknown, fallback: string) {
302 if (!isRecord(item)) {
303 const value = getArrayItemDisplayValue(item)
304 return value || fallback
305 }
306
307 const titleKeys = ['displayName', 'name', 'model', 'channel', 'id', 'key']
308 for (const key of titleKeys) {
309 const value = getArrayItemDisplayValue(item[key])
310 if (value)
311 return value
312 }
313
314 return fallback
315 }
316
317 function ArrayItemRemoveButton({ disabled, onRemove }: {
318 disabled: boolean
319 onRemove: () => void
320 }) {
321 const { t } = useTransClient('configManager')
322
323 return (
324 <Button
325 type="button"
326 variant="ghost"
327 size="icon"
328 disabled={disabled}
329 className="h-5 w-5 shrink-0 cursor-pointer text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover/config-item:opacity-100 focus-visible:opacity-100"
330 aria-label={t('actions.remove')}
331 onMouseDown={event => event.preventDefault()}
332 onClick={(event) => {
333 event.stopPropagation()
334 onRemove()
335 }}
336 >
337 <Trash2 className="h-3.5 w-3.5" />
338 </Button>
339 )
340 }
341
342 function ArrayItemAddButton({ disabled, onAdd }: {
343 disabled: boolean
344 onAdd: () => void
345 }) {
346 const { t } = useTransClient('configManager')
347
348 return (
349 <Button
350 type="button"
351 variant="ghost"
352 size="icon"
353 disabled={disabled}
354 className="h-5 w-5 shrink-0 cursor-pointer text-muted-foreground transition-colors hover:bg-background/80 hover:text-foreground"
355 aria-label={t('actions.addItem')}
356 onMouseDown={event => event.preventDefault()}
357 onClick={(event) => {
358 event.stopPropagation()
359 onAdd()
360 }}
361 >
362 <Plus className="h-3.5 w-3.5" />
363 </Button>
364 )
365 }
366
367 function PrimitiveArrayItem({
368 parentPath,
369 index,
370 value,
371 originalValue,
372 disabled,
373 depth,
374 highlightedPathKey,
375 onValueChange,
376 onNavigateToJson,
377 onRemove,
378 }: {
379 parentPath: ConfigPath
380 index: number
381 value: unknown
382 originalValue: unknown
383 disabled: boolean
384 depth: number
385 highlightedPathKey: string
386 onValueChange: (path: ConfigPath, value: ConfigValue) => void
387 onNavigateToJson: (path: ConfigPath) => void
388 onRemove: () => void
389 }) {
390 const { t } = useTransClient('configManager')
391 const itemPath = [...parentPath, index]
392 const pathKey = joinPath(itemPath)
393 const inputId = getInputId(pathKey)
394 const modified = isConfigValueModified(value, originalValue)
395
396 return (
397 <div
398 data-config-path-key={pathKey}
399 className={cn(
400 'group/config-item grid min-h-8 gap-2 border-b border-border/70 py-0.5 pr-2 last:border-b-0 lg:grid-cols-[minmax(96px,140px)_minmax(0,1fr)] lg:items-center',
401 getNodeClassName(depth, modified),
402 getPathHighlightClassName(pathKey, highlightedPathKey),
403 )}
404 style={getTreeIndentStyle(depth)}
405 >
406 <div className="flex min-w-0 items-center gap-1.5">
407 <span className="shrink-0 font-mono text-[11px] text-muted-foreground">
408 #
409 {index + 1}
410 </span>
411 {modified && (
412 <Badge variant="outline" className={cn(compactBadgeClassName, 'border-warning/30 bg-warning/10 text-warning')}>
413 {t('status.modified')}
414 </Badge>
415 )}
416 <PathJumpButton path={itemPath} onNavigateToJson={onNavigateToJson} />
417 </div>
418
419 <div className="flex min-w-0 items-center gap-1">
420 {typeof value === 'boolean' && (
421 <div className="flex h-7 min-w-0 flex-1 items-center justify-between gap-3 rounded-sm border border-transparent bg-transparent px-2 shadow-none group-hover/config-item:border-input group-hover/config-item:bg-background hover:border-input hover:bg-background">
422 <span className="text-sm text-muted-foreground">
423 {value ? t('common.enabled') : t('common.disabled')}
424 </span>
425 <Switch checked={value} disabled={disabled} onCheckedChange={checked => onValueChange(itemPath, checked)} />
426 </div>
427 )}
428
429 {typeof value === 'number' && (
430 <NumberInput
431 id={inputId}
432 value={value}
433 disabled={disabled}
434 className={compactControlClassName}
435 onValueChange={nextValue => onValueChange(itemPath, nextValue ?? 0)}
436 />
437 )}
438
439 {typeof value === 'string' && (
440 <Input
441 id={inputId}
442 value={value}
443 disabled={disabled}
444 className={compactControlClassName}
445 onChange={event => onValueChange(itemPath, event.target.value)}
446 />
447 )}
448
449 {value == null && (
450 <Input
451 id={inputId}
452 value=""
453 disabled={disabled}
454 placeholder={t('common.emptyValue')}
455 className={compactControlClassName}
456 onChange={event => onValueChange(itemPath, event.target.value)}
457 />
458 )}
459
460 <ArrayItemRemoveButton disabled={disabled} onRemove={onRemove} />
461 </div>
462 </div>
463 )
464 }
465
466 function ObjectArrayItem({
467 parentPath,
468 fieldKey,
469 index,
470 value,
471 originalValue,
472 disabled,
473 depth,
474 focusPath,
475 highlightedPathKey,
476 onValueChange,
477 onNavigateToJson,
478 onRemove,
479 }: {
480 parentPath: ConfigPath
481 fieldKey: string
482 index: number
483 value: Record<string, unknown>
484 originalValue: unknown
485 disabled: boolean
486 depth: number
487 focusPath: ConfigPath | null
488 highlightedPathKey: string
489 onValueChange: (path: ConfigPath, value: ConfigValue) => void
490 onNavigateToJson: (path: ConfigPath) => void
491 onRemove: () => void
492 }) {
493 const { t } = useTransClient('configManager')
494 const itemPath = [...parentPath, index]
495 const pathKey = joinPath(itemPath)
496 const originalRecord = isRecord(originalValue) ? originalValue : {}
497 const modifiedCount = countModifiedLeafFields(value, originalValue)
498 const modified = modifiedCount > 0
499 const leafCount = countLeafFields(value)
500 const fallbackTitle = t('common.arrayItem', { index: index + 1 })
501 const title = getArrayItemTitle(value, fallbackTitle)
502 const [open, setOpen] = useState(false)
503 const focusPathKey = focusPath ? joinPath(focusPath) : ''
504
505 useEffect(() => {
506 if (isPathPrefix(itemPath, focusPath))
507 setOpen(true)
508 }, [focusPath, focusPathKey, pathKey])
509
510 return (
511 <Collapsible
512 open={open}
513 onOpenChange={setOpen}
514 data-config-path-key={pathKey}
515 className={cn('border-b border-border/70 last:border-b-0', getPathHighlightClassName(pathKey, highlightedPathKey))}
516 >
517 <CollapsibleTrigger
518 className={cn(nodeTriggerClassName, getNodeClassName(depth, modified))}
519 style={getTreeIndentStyle(depth)}
520 >
521 <div className="flex min-w-0 flex-1 items-center gap-1.5">
522 <span className="shrink-0 font-mono text-[11px] text-muted-foreground">
523 #
524 {index + 1}
525 </span>
526 <span className="truncate text-xs font-semibold text-foreground">{title}</span>
527 {modified && (
528 <Badge variant="outline" className={cn(compactBadgeClassName, 'border-warning/30 bg-warning/10 text-warning')}>
529 {modifiedCount}
530 </Badge>
531 )}
532 <PathJumpButton path={itemPath} onNavigateToJson={onNavigateToJson} />
533 </div>
534 <div className="flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground">
535 <Badge variant="outline" className={compactBadgeClassName}>{t('panel.fieldSummary', { count: leafCount })}</Badge>
536 <ArrayItemRemoveButton disabled={disabled} onRemove={onRemove} />
537 <ExpandIcon open={open} />
538 </div>
539 </CollapsibleTrigger>
540 <CollapsibleContent className="border-t border-border/70 bg-background">
541 <div className="divide-y divide-border/70">
542 {Object.entries(value).map(([key, itemValue]) => (
543 <ConfigField
544 key={`${pathKey}.${key}`}
545 path={[...itemPath, key]}
546 fieldKey={key}
547 value={itemValue}
548 originalValue={originalRecord[key]}
549 disabled={disabled}
550 depth={depth + 1}
551 focusPath={focusPath}
552 highlightedPathKey={highlightedPathKey}
553 onValueChange={onValueChange}
554 onNavigateToJson={onNavigateToJson}
555 />
556 ))}
557 </div>
558 </CollapsibleContent>
559 </Collapsible>
560 )
561 }
562
563 function ArrayField({
564 path,
565 fieldKey,
566 value,
567 originalValue,
568 disabled,
569 depth = 0,
570 focusPath,
571 highlightedPathKey,
572 onValueChange,
573 onNavigateToJson,
574 }: ConfigFieldProps & { value: unknown[] }) {
575 const { t } = useTransClient('configManager')
576 const label = getConfigFieldLabel(t, path, fieldKey)
577 const pathKey = joinPath(path)
578 const sampleValue = value[0] ?? ''
579 const originalArray = Array.isArray(originalValue) ? originalValue : []
580 const modifiedCount = countModifiedLeafFields(value, originalValue)
581 const modified = modifiedCount > 0
582 const leafCount = countLeafFields(value)
583 const [open, setOpen] = useState(depth < 2)
584 const focusPathKey = focusPath ? joinPath(focusPath) : ''
585
586 useEffect(() => {
587 if (isPathPrefix(path, focusPath))
588 setOpen(true)
589 }, [focusPath, focusPathKey, pathKey])
590
591 return (
592 <Collapsible
593 open={open}
594 onOpenChange={setOpen}
595 data-config-path-key={pathKey}
596 className={cn('group/config-node', getNodeClassName(depth, modified), getPathHighlightClassName(pathKey, highlightedPathKey))}
597 >
598 <CollapsibleTrigger
599 className={nodeTriggerClassName}
600 style={getTreeIndentStyle(depth)}
601 >
602 <SettingLabel label={label} modified={modified} depth={depth} path={path} onNavigateToJson={onNavigateToJson} />
603 <div className="flex shrink-0 items-center gap-1.5 text-[11px] text-muted-foreground">
604 <Badge variant="secondary" className={compactBadgeClassName}>{t('common.itemCount', { count: value.length })}</Badge>
605 <Badge variant="outline" className={compactBadgeClassName}>{t('panel.fieldSummary', { count: leafCount })}</Badge>
606 <ArrayItemAddButton
607 disabled={disabled}
608 onAdd={() => onValueChange(path, [...value, createEmptyValue(sampleValue)])}
609 />
610 <ExpandIcon open={open} />
611 </div>
612 </CollapsibleTrigger>
613 <CollapsibleContent className="border-t border-border/70 bg-muted/10">
614 {value.length === 0 && (
615 <div className="m-2 rounded-md border border-dashed border-border bg-background px-3 py-3 text-center text-sm text-muted-foreground">
616 {t('common.emptyArray')}
617 </div>
618 )}
619 {value.map((item, index) => {
620 const itemPathKey = `${pathKey}-${index}`
621 const removeItem = () => onValueChange(path, value.filter((_, itemIndex) => itemIndex !== index))
622
623 if (isRecord(item)) {
624 return (
625 <ObjectArrayItem
626 key={itemPathKey}
627 parentPath={path}
628 fieldKey={fieldKey}
629 index={index}
630 value={item}
631 originalValue={originalArray[index]}
632 disabled={disabled}
633 depth={depth + 1}
634 focusPath={focusPath}
635 highlightedPathKey={highlightedPathKey}
636 onValueChange={onValueChange}
637 onNavigateToJson={onNavigateToJson}
638 onRemove={removeItem}
639 />
640 )
641 }
642
643 if (!Array.isArray(item)) {
644 return (
645 <PrimitiveArrayItem
646 key={itemPathKey}
647 parentPath={path}
648 index={index}
649 value={item}
650 originalValue={originalArray[index]}
651 disabled={disabled}
652 depth={depth + 1}
653 highlightedPathKey={highlightedPathKey}
654 onValueChange={onValueChange}
655 onNavigateToJson={onNavigateToJson}
656 onRemove={removeItem}
657 />
658 )
659 }
660
661 return (
662 <ConfigField
663 key={itemPathKey}
664 path={[...path, index]}
665 fieldKey={`${fieldKey}.${index}`}
666 value={item}
667 originalValue={originalArray[index]}
668 disabled={disabled}
669 depth={depth + 1}
670 focusPath={focusPath}
671 highlightedPathKey={highlightedPathKey}
672 onValueChange={onValueChange}
673 onNavigateToJson={onNavigateToJson}
674 />
675 )
676 })}
677 </CollapsibleContent>
678 </Collapsible>
679 )
680 }
681
682 function ObjectField({
683 path,
684 fieldKey,
685 value,
686 originalValue,
687 disabled,
688 depth = 0,
689 focusPath,
690 highlightedPathKey,
691 onValueChange,
692 onNavigateToJson,
693 }: ConfigFieldProps & { value: Record<string, unknown> }) {
694 const { t } = useTransClient('configManager')
695 const label = getConfigFieldLabel(t, path, fieldKey)
696 const entries = useMemo(() => Object.entries(value), [value])
697 const originalRecord = isRecord(originalValue) ? originalValue : {}
698 const pathKey = joinPath(path)
699 const modifiedCount = countModifiedLeafFields(value, originalValue)
700 const modified = modifiedCount > 0
701 const leafCount = countLeafFields(value)
702 const [open, setOpen] = useState(depth < 2)
703 const focusPathKey = focusPath ? joinPath(focusPath) : ''
704
705 useEffect(() => {
706 if (isPathPrefix(path, focusPath))
707 setOpen(true)
708 }, [focusPath, focusPathKey, pathKey])
709
710 return (
711 <Collapsible
712 open={open}
713 onOpenChange={setOpen}
714 data-config-path-key={pathKey}
715 className={cn('group/config-node', getNodeClassName(depth, modified), getPathHighlightClassName(pathKey, highlightedPathKey))}
716 >
717 <CollapsibleTrigger
718 className={nodeTriggerClassName}
719 style={getTreeIndentStyle(depth)}
720 >
721 <SettingLabel label={label} modified={modified} depth={depth} path={path} onNavigateToJson={onNavigateToJson} />
722 <div className="flex shrink-0 items-center gap-1.5 text-[11px] text-muted-foreground">
723 <Badge variant="secondary" className={compactBadgeClassName}>{t('panel.fieldSummary', { count: leafCount })}</Badge>
724 {modifiedCount > 0 && <Badge variant="outline" className={cn(compactBadgeClassName, 'border-warning/30 bg-warning/10 text-warning')}>{modifiedCount}</Badge>}
725 <ExpandIcon open={open} />
726 </div>
727 </CollapsibleTrigger>
728 <CollapsibleContent className="border-t border-border/70">
729 {entries.length === 0 && (
730 <div className="m-2 rounded-md border border-dashed border-border bg-background px-3 py-3 text-center text-sm text-muted-foreground">
731 {t('common.emptyObject')}
732 </div>
733 )}
734 <div className="divide-y divide-border/70">
735 {entries.map(([key, itemValue]) => (
736 <ConfigField
737 key={`${pathKey}.${key}`}
738 path={[...path, key]}
739 fieldKey={key}
740 value={itemValue}
741 originalValue={originalRecord[key]}
742 disabled={disabled}
743 depth={depth + 1}
744 focusPath={focusPath}
745 highlightedPathKey={highlightedPathKey}
746 onValueChange={onValueChange}
747 onNavigateToJson={onNavigateToJson}
748 />
749 ))}
750 </div>
751 </CollapsibleContent>
752 </Collapsible>
753 )
754 }
755
756 export function ConfigField(props: ConfigFieldProps) {
757 const { value } = props
758
759 if (Array.isArray(value))
760 return <ArrayField {...props} value={value} />
761
762 if (isRecord(value))
763 return <ObjectField {...props} value={value} />
764
765 return <PrimitiveField {...props} />
766 }
767
767 lines Plain Text