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