返回 AiToEarn
index.tsx
1 /**
2 * ConfigManagerDialog - 全局配置管理弹框
3 * 将后端配置对象转换为可编辑表单,并支持保存后重启与恢复检查。
4 */
5 'use client'
6
7 import type { ConfigEditorStatus, ConfigPath, ConfigPathFocusRequest, ConfigValue } from './types'
8 import { AlertCircle, Bot, Braces, CheckCircle2, FileSliders, Loader2, RefreshCw, RotateCcw, Save, Server, ShieldCheck, SlidersHorizontal } from 'lucide-react'
9 import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
10 import {
11 checkConfigEditorConfigReadyApi,
12 getConfigEditorConfigApi,
13 restartConfigEditorServiceApi,
14 saveConfigEditorConfigApi,
15 validateConfigEditorConfigApi,
16 } from '@/api/config-editor/config-editor.api'
17 import { ConfigEditorServiceTarget } from '@/api/config-editor/config-editor.types'
18 import { useTransClient } from '@/app/i18n/client'
19 import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
20 import { Badge } from '@/components/ui/badge'
21 import { Button } from '@/components/ui/button'
22 import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
23 import { Skeleton } from '@/components/ui/skeleton'
24 import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
25 import { toast } from '@/utils/ui/toast'
26 import { ConfigFormPanel } from './components/ConfigFormPanel'
27 import { ConfigJsonPanel } from './components/ConfigJsonPanel'
28 import { ConfigSectionNav } from './components/ConfigSectionNav'
29 import { useConfigSectionSpy } from './hooks/useConfigSectionSpy'
30 import { getValueAtPath, isRecord, joinPath, setValueAtPath, stableStringify } from './utils/configPath'
31 import { buildConfigSections } from './utils/configSections'
32
33 export interface ConfigManagerDialogProps {
34 open: boolean
35 onClose: () => void
36 }
37
38 type LoadingAction = 'load' | 'validate' | 'save' | 'saveRestart' | 'restart'
39 type ConfigEditMode = 'visual' | 'json'
40
41 interface ConfigManagerError {
42 title: string
43 description?: string
44 }
45
46 const healthCheckIntervalMs = 1600
47 const healthCheckMaxAttempts = 75
48 const serverRelayPath: ConfigPath = ['relay']
49 const aiRelayPath: ConfigPath = ['ai', 'relay']
50 const serverRelayDefaultConfig: Record<string, unknown> = {
51 serverUrl: '',
52 apiKey: '',
53 callbackUrl: '',
54 }
55 const aiRelayDefaultConfig: Record<string, unknown> = {
56 url: '',
57 apiKey: '',
58 timeout: 300000,
59 }
60
61 function isConfigEditorServiceTarget(value: string): value is ConfigEditorServiceTarget {
62 return value === ConfigEditorServiceTarget.Server || value === ConfigEditorServiceTarget.Ai
63 }
64
65 function getErrorMessage(error: unknown) {
66 return error instanceof Error ? error.message : String(error)
67 }
68
69 function getResponseMessage(response: { message?: string } | null | undefined) {
70 return response?.message?.trim() || ''
71 }
72
73 function formatJsonConfig(config: Record<string, unknown>) {
74 return JSON.stringify(config, null, 2)
75 }
76
77 function getRelayPath(serviceTarget: ConfigEditorServiceTarget): ConfigPath {
78 return serviceTarget === ConfigEditorServiceTarget.Ai ? aiRelayPath : serverRelayPath
79 }
80
81 function getRelayDefaultConfig(serviceTarget: ConfigEditorServiceTarget) {
82 return serviceTarget === ConfigEditorServiceTarget.Ai
83 ? { ...aiRelayDefaultConfig }
84 : { ...serverRelayDefaultConfig }
85 }
86
87 function ensureRelayConfig(config: Record<string, unknown>, serviceTarget: ConfigEditorServiceTarget) {
88 const relayPath = getRelayPath(serviceTarget)
89 if (getValueAtPath(config, relayPath) !== undefined) {
90 return { config, insertedRelayPath: null }
91 }
92
93 if (serviceTarget === ConfigEditorServiceTarget.Ai) {
94 const aiConfig = config.ai
95 if (!isRecord(aiConfig)) {
96 return { config, insertedRelayPath: null }
97 }
98
99 return {
100 config: {
101 ...config,
102 ai: {
103 ...aiConfig,
104 relay: getRelayDefaultConfig(serviceTarget),
105 },
106 },
107 insertedRelayPath: relayPath,
108 }
109 }
110
111 return {
112 config: {
113 ...config,
114 relay: getRelayDefaultConfig(serviceTarget),
115 },
116 insertedRelayPath: relayPath,
117 }
118 }
119
120 function removeValueAtPath(source: Record<string, unknown>, path: ConfigPath): Record<string, unknown> {
121 const [segment, ...remainingPath] = path
122 if (typeof segment !== 'string')
123 return source
124
125 const nextSource = { ...source }
126 if (remainingPath.length === 0) {
127 delete nextSource[segment]
128 return nextSource
129 }
130
131 const nestedValue = nextSource[segment]
132 if (!isRecord(nestedValue))
133 return nextSource
134
135 nextSource[segment] = removeValueAtPath(nestedValue, remainingPath)
136 return nextSource
137 }
138
139 function stripInsertedRelayPlaceholder(
140 config: Record<string, unknown>,
141 insertedRelayPath: ConfigPath | null,
142 serviceTarget: ConfigEditorServiceTarget,
143 ) {
144 if (!insertedRelayPath)
145 return config
146
147 const relayValue = getValueAtPath(config, insertedRelayPath)
148 if (stableStringify(relayValue) !== stableStringify(getRelayDefaultConfig(serviceTarget)))
149 return config
150
151 return removeValueAtPath(config, insertedRelayPath)
152 }
153
154 function StatusBadge({ status }: { status: ConfigEditorStatus }) {
155 const { t } = useTransClient('configManager')
156
157 if (status.service === 'restarting') {
158 return (
159 <Badge variant="outline" className="gap-1 border-warning/30 bg-warning/10 text-warning">
160 <Loader2 className="h-3 w-3 animate-spin" />
161 {t('status.restarting')}
162 </Badge>
163 )
164 }
165
166 if (status.service === 'failed') {
167 return (
168 <Badge variant="outline" className="gap-1 border-destructive/30 bg-destructive/10 text-destructive">
169 <AlertCircle className="h-3 w-3" />
170 {t('status.failed')}
171 </Badge>
172 )
173 }
174
175 if (status.service === 'running') {
176 return (
177 <Badge variant="outline" className="gap-1 border-success/30 bg-success/10 text-success">
178 <CheckCircle2 className="h-3 w-3" />
179 {t('status.running')}
180 </Badge>
181 )
182 }
183
184 return <Badge variant="secondary">{t('status.unknown')}</Badge>
185 }
186
187 function LoadingSkeleton() {
188 return (
189 <div className="flex h-full">
190 <div className="hidden w-64 shrink-0 border-r border-border p-3 md:block">
191 {Array.from({ length: 8 }).map((_, index) => <Skeleton key={index} className="mb-2 h-12" />)}
192 </div>
193 <div className="flex-1 p-4">
194 {Array.from({ length: 4 }).map((_, index) => (
195 <div key={index} className="mb-4 rounded-2xl border border-border p-4">
196 <Skeleton className="mb-4 h-5 w-40" />
197 <Skeleton className="mb-2 h-10" />
198 <Skeleton className="mb-2 h-10" />
199 <Skeleton className="h-10" />
200 </div>
201 ))}
202 </div>
203 </div>
204 )
205 }
206
207 export function ConfigManagerDialog({ open, onClose }: ConfigManagerDialogProps) {
208 const { t } = useTransClient('configManager')
209 const [config, setConfig] = useState<Record<string, unknown> | null>(null)
210 const [originalConfig, setOriginalConfig] = useState<Record<string, unknown> | null>(null)
211 const [format, setFormat] = useState<ConfigEditorStatus['format']>()
212 const [loadingAction, setLoadingAction] = useState<LoadingAction | null>(null)
213 const [error, setError] = useState<ConfigManagerError | null>(null)
214 const [successMessage, setSuccessMessage] = useState('')
215 const [healthAttempts, setHealthAttempts] = useState(0)
216 const [serviceStatus, setServiceStatus] = useState<ConfigEditorStatus['service']>('unknown')
217 const [serviceTarget, setServiceTarget] = useState(ConfigEditorServiceTarget.Server)
218 const [insertedRelayPath, setInsertedRelayPath] = useState<ConfigPath | null>(null)
219 const [editMode, setEditMode] = useState<ConfigEditMode>('visual')
220 const [jsonText, setJsonText] = useState('')
221 const [visualFocusRequest, setVisualFocusRequest] = useState<ConfigPathFocusRequest | null>(null)
222 const [jsonFocusRequest, setJsonFocusRequest] = useState<ConfigPathFocusRequest | null>(null)
223 const [highlightedVisualPathKey, setHighlightedVisualPathKey] = useState('')
224 const [highlightedJsonPathKey, setHighlightedJsonPathKey] = useState('')
225 const [jsonScrollTop, setJsonScrollTop] = useState(0)
226 const scrollContainerRef = useRef<HTMLDivElement | null>(null)
227 const visualScrollTopRef = useRef(0)
228 const focusRequestIdRef = useRef(0)
229 const visualHighlightTimerRef = useRef<number | null>(null)
230 const jsonHighlightTimerRef = useRef<number | null>(null)
231
232 const dirty = useMemo(() => {
233 if (!config || !originalConfig)
234 return false
235 if (editMode === 'json')
236 return jsonText.trim() !== formatJsonConfig(originalConfig)
237 return stableStringify(config) !== stableStringify(originalConfig)
238 }, [config, editMode, jsonText, originalConfig])
239
240 const sections = useMemo(
241 () => config ? buildConfigSections(config, originalConfig, serviceTarget, t) : [],
242 [config, originalConfig, serviceTarget, t],
243 )
244 const { activeSectionId, scrollToSection } = useConfigSectionSpy(scrollContainerRef, sections)
245 const disabled = !!loadingAction || serviceStatus === 'restarting'
246 const serverServiceDisabled = disabled || (dirty && serviceTarget !== ConfigEditorServiceTarget.Server)
247 const aiServiceDisabled = disabled || (dirty && serviceTarget !== ConfigEditorServiceTarget.Ai)
248
249 const clearVisualHighlightLater = useCallback(() => {
250 if (visualHighlightTimerRef.current !== null)
251 window.clearTimeout(visualHighlightTimerRef.current)
252 visualHighlightTimerRef.current = window.setTimeout(() => setHighlightedVisualPathKey(''), 1800)
253 }, [])
254
255 const clearJsonHighlightLater = useCallback(() => {
256 if (jsonHighlightTimerRef.current !== null)
257 window.clearTimeout(jsonHighlightTimerRef.current)
258 jsonHighlightTimerRef.current = window.setTimeout(() => setHighlightedJsonPathKey(''), 1800)
259 }, [])
260
261 useEffect(() => {
262 return () => {
263 if (visualHighlightTimerRef.current !== null)
264 window.clearTimeout(visualHighlightTimerRef.current)
265 if (jsonHighlightTimerRef.current !== null)
266 window.clearTimeout(jsonHighlightTimerRef.current)
267 }
268 }, [])
269
270 const loadConfig = useCallback(async () => {
271 setLoadingAction('load')
272 setError(null)
273 setSuccessMessage('')
274
275 try {
276 const response = await getConfigEditorConfigApi(serviceTarget, true)
277 if (!response || response.code !== 0 || !response.data?.config) {
278 throw new Error(getResponseMessage(response) || t('errors.loadFailed'))
279 }
280
281 const normalizedConfig = ensureRelayConfig(response.data.config, serviceTarget)
282 setConfig(normalizedConfig.config)
283 setOriginalConfig(normalizedConfig.config)
284 setJsonText(formatJsonConfig(normalizedConfig.config))
285 setVisualFocusRequest(null)
286 setJsonFocusRequest(null)
287 setHighlightedVisualPathKey('')
288 setHighlightedJsonPathKey('')
289 visualScrollTopRef.current = 0
290 setJsonScrollTop(0)
291 setInsertedRelayPath(normalizedConfig.insertedRelayPath)
292 setFormat(response.data.format)
293 setServiceStatus('running')
294 setHealthAttempts(0)
295 }
296 catch (loadError) {
297 setServiceStatus('failed')
298 setError({ title: t('errors.loadFailed'), description: getErrorMessage(loadError) })
299 }
300 finally {
301 setLoadingAction(null)
302 }
303 }, [serviceTarget, t])
304
305 useEffect(() => {
306 if (!open)
307 return
308 loadConfig()
309 }, [open, loadConfig])
310
311 const handleServiceTargetChange = useCallback((value: string) => {
312 if (!isConfigEditorServiceTarget(value))
313 return
314
315 setServiceTarget(value)
316 setLoadingAction('load')
317 setConfig(null)
318 setOriginalConfig(null)
319 setJsonText('')
320 setInsertedRelayPath(null)
321 setFormat(undefined)
322 setHealthAttempts(0)
323 setServiceStatus('unknown')
324 setEditMode('visual')
325 setVisualFocusRequest(null)
326 setJsonFocusRequest(null)
327 setHighlightedVisualPathKey('')
328 setHighlightedJsonPathKey('')
329 visualScrollTopRef.current = 0
330 setJsonScrollTop(0)
331 setError(null)
332 setSuccessMessage('')
333 }, [])
334
335 const handleValueChange = useCallback((path: ConfigPath, value: ConfigValue) => {
336 setConfig((current) => {
337 if (!current)
338 return current
339 const nextConfig = setValueAtPath(current, path, value)
340 setJsonText(formatJsonConfig(nextConfig))
341 return nextConfig
342 })
343 setError(null)
344 setSuccessMessage('')
345 }, [])
346
347 const parseJsonText = useCallback(() => {
348 try {
349 const parsed = JSON.parse(jsonText) as unknown
350 if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
351 setError({ title: t('errors.jsonInvalid'), description: t('errors.jsonRootObject') })
352 return null
353 }
354
355 return parsed as Record<string, unknown>
356 }
357 catch (jsonError) {
358 setError({ title: t('errors.jsonInvalid'), description: getErrorMessage(jsonError) })
359 return null
360 }
361 }, [jsonText, t])
362
363 const handleVisualScrollTopChange = useCallback((scrollTop: number) => {
364 visualScrollTopRef.current = scrollTop
365 }, [])
366
367 const rememberVisualScrollTop = useCallback(() => {
368 const currentScrollTop = scrollContainerRef.current?.scrollTop
369 if (typeof currentScrollTop === 'number')
370 visualScrollTopRef.current = currentScrollTop
371 }, [])
372
373 const handleEditModeChange = useCallback((value: string) => {
374 if (value !== 'visual' && value !== 'json')
375 return
376
377 if (value === 'json' && editMode === 'visual')
378 rememberVisualScrollTop()
379
380 if (value === 'visual' && editMode === 'json') {
381 const parsedConfig = parseJsonText()
382 if (!parsedConfig)
383 return
384 setConfig(parsedConfig)
385 setJsonText(formatJsonConfig(parsedConfig))
386 }
387
388 if (value === 'json' && config)
389 setJsonText(formatJsonConfig(config))
390
391 setVisualFocusRequest(null)
392 setJsonFocusRequest(null)
393 setHighlightedVisualPathKey('')
394 setHighlightedJsonPathKey('')
395 setError(null)
396 setSuccessMessage('')
397 setEditMode(value)
398 }, [config, editMode, parseJsonText, rememberVisualScrollTop])
399
400 const handleNavigateToJson = useCallback((path: ConfigPath) => {
401 rememberVisualScrollTop()
402
403 if (config)
404 setJsonText(formatJsonConfig(config))
405
406 focusRequestIdRef.current += 1
407 setJsonFocusRequest({ id: focusRequestIdRef.current, path: [...path] })
408 setHighlightedJsonPathKey(joinPath(path))
409 clearJsonHighlightLater()
410 setError(null)
411 setSuccessMessage('')
412 setEditMode('json')
413 }, [clearJsonHighlightLater, config, rememberVisualScrollTop])
414
415 const handleNavigateToVisual = useCallback((path: ConfigPath) => {
416 const nextConfig = editMode === 'json' ? parseJsonText() : config
417 if (!nextConfig)
418 return
419
420 if (editMode === 'json') {
421 setConfig(nextConfig)
422 setJsonText(formatJsonConfig(nextConfig))
423 }
424
425 focusRequestIdRef.current += 1
426 setVisualFocusRequest({ id: focusRequestIdRef.current, path: [...path] })
427 setHighlightedVisualPathKey(joinPath(path))
428 setJsonFocusRequest(null)
429 setHighlightedJsonPathKey('')
430 clearVisualHighlightLater()
431 setError(null)
432 setSuccessMessage('')
433 setEditMode('visual')
434 }, [clearVisualHighlightLater, config, editMode, parseJsonText])
435
436 const handleJsonTextChange = useCallback((value: string) => {
437 setJsonText(value)
438 setError(null)
439 setSuccessMessage('')
440 }, [])
441
442 const handleJsonFocusRequestHandled = useCallback((requestId: number) => {
443 setJsonFocusRequest(current => current?.id === requestId ? null : current)
444 }, [])
445
446 const handleVisualFocusRequestHandled = useCallback((requestId: number) => {
447 setVisualFocusRequest(current => current?.id === requestId ? null : current)
448 }, [])
449
450 const getEditableConfig = useCallback(() => {
451 if (editMode === 'visual')
452 return config
453
454 const parsedConfig = parseJsonText()
455 if (parsedConfig)
456 setConfig(parsedConfig)
457 return parsedConfig
458 }, [config, editMode, parseJsonText])
459
460 const validateConfig = useCallback(async (action: LoadingAction = 'validate', configOverride?: Record<string, unknown>) => {
461 const editableConfig = configOverride ?? getEditableConfig()
462 if (!editableConfig)
463 return false
464 const submittableConfig = stripInsertedRelayPlaceholder(editableConfig, insertedRelayPath, serviceTarget)
465
466 setLoadingAction(action)
467 setError(null)
468 setSuccessMessage('')
469
470 try {
471 const response = await validateConfigEditorConfigApi({ config: submittableConfig }, serviceTarget, true)
472 if (!response || response.code !== 0) {
473 throw new Error(getResponseMessage(response) || t('errors.validateFailed'))
474 }
475 if (action === 'validate') {
476 setSuccessMessage(t('messages.validateSuccess'))
477 toast.success(t('messages.validateSuccess'))
478 }
479 return true
480 }
481 catch (validateError) {
482 setError({ title: t('errors.validateFailed'), description: getErrorMessage(validateError) })
483 return false
484 }
485 finally {
486 if (action === 'validate')
487 setLoadingAction(null)
488 }
489 }, [getEditableConfig, insertedRelayPath, serviceTarget, t])
490
491 const saveConfig = useCallback(async (action: LoadingAction = 'save') => {
492 const editableConfig = getEditableConfig()
493 if (!editableConfig)
494 return false
495 const submittableConfig = stripInsertedRelayPlaceholder(editableConfig, insertedRelayPath, serviceTarget)
496
497 setLoadingAction(action)
498 setError(null)
499 setSuccessMessage('')
500
501 try {
502 const valid = await validateConfig(action, editableConfig)
503 if (!valid)
504 return false
505
506 const response = await saveConfigEditorConfigApi({ config: submittableConfig }, serviceTarget, true)
507 if (!response || response.code !== 0) {
508 throw new Error(getResponseMessage(response) || t('errors.saveFailed'))
509 }
510
511 const normalizedConfig = ensureRelayConfig(submittableConfig, serviceTarget)
512 setConfig(normalizedConfig.config)
513 setOriginalConfig(normalizedConfig.config)
514 setJsonText(formatJsonConfig(normalizedConfig.config))
515 setInsertedRelayPath(normalizedConfig.insertedRelayPath)
516 if (action === 'save') {
517 setSuccessMessage(t('messages.saveSuccess'))
518 toast.success(t('messages.saveSuccess'))
519 }
520 return true
521 }
522 catch (saveError) {
523 setError({ title: t('errors.saveFailed'), description: getErrorMessage(saveError) })
524 return false
525 }
526 finally {
527 if (action === 'save')
528 setLoadingAction(null)
529 }
530 }, [getEditableConfig, insertedRelayPath, serviceTarget, t, validateConfig])
531
532 const waitForHealth = useCallback(async () => {
533 setServiceStatus('restarting')
534 setHealthAttempts(0)
535
536 for (let attempt = 1; attempt <= healthCheckMaxAttempts; attempt += 1) {
537 setHealthAttempts(attempt)
538 await new Promise(resolve => window.setTimeout(resolve, healthCheckIntervalMs))
539 const healthy = await checkConfigEditorConfigReadyApi(serviceTarget)
540 if (healthy) {
541 setServiceStatus('running')
542 setSuccessMessage(t('messages.restartSuccess'))
543 toast.success(t('messages.restartSuccess'))
544 return true
545 }
546 }
547
548 setServiceStatus('failed')
549 setError({ title: t('errors.healthTimeout'), description: t('errors.healthTimeoutDescription') })
550 return false
551 }, [serviceTarget, t])
552
553 const restartService = useCallback(async (action: LoadingAction = 'restart') => {
554 setLoadingAction(action)
555 setError(null)
556 setSuccessMessage('')
557
558 try {
559 const response = await restartConfigEditorServiceApi(serviceTarget, true)
560 if (!response || response.code !== 0) {
561 throw new Error(getResponseMessage(response) || t('errors.restartFailed'))
562 }
563
564 await waitForHealth()
565 }
566 catch (restartError) {
567 setServiceStatus('failed')
568 setError({ title: t('errors.restartFailed'), description: getErrorMessage(restartError) })
569 }
570 finally {
571 setLoadingAction(null)
572 }
573 }, [serviceTarget, t, waitForHealth])
574
575 const handleSaveAndRestart = useCallback(async () => {
576 const saved = await saveConfig('saveRestart')
577 if (!saved) {
578 setLoadingAction(null)
579 return
580 }
581 await restartService('saveRestart')
582 }, [restartService, saveConfig])
583
584 const status: ConfigEditorStatus = {
585 service: serviceStatus,
586 format,
587 dirty,
588 }
589 const isReloading = loadingAction === 'load'
590 const isValidating = loadingAction === 'validate'
591 const isSaving = loadingAction === 'save'
592 const isRestarting = loadingAction === 'saveRestart' || loadingAction === 'restart'
593
594 return (
595 <Dialog open={open} onOpenChange={isOpen => !isOpen && onClose()}>
596 <DialogContent
597 className="h-[88vh] max-h-[760px] w-[calc(100vw-24px)] max-w-[1120px] gap-0 overflow-hidden p-0 sm:p-0"
598 aria-describedby={undefined}
599 >
600 <DialogTitle className="sr-only">{t('title')}</DialogTitle>
601 <DialogDescription className="sr-only">{t('description')}</DialogDescription>
602
603 <div className="flex h-full min-h-0 flex-col">
604 <header className="flex shrink-0 flex-col gap-3 border-b border-border px-5 py-4 md:flex-row md:items-center md:justify-between">
605 <div className="flex min-w-0 items-start gap-3">
606 <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-cyan/10 text-brand-cyan">
607 <FileSliders className="h-5 w-5" />
608 </div>
609 <div className="min-w-0">
610 <h2 className="text-lg font-semibold text-foreground">{t('title')}</h2>
611 <p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
612 </div>
613 </div>
614 <div className="flex flex-wrap items-center gap-2 pr-8">
615 <StatusBadge status={status} />
616 {dirty && <Badge variant="outline" className="border-warning/30 bg-warning/10 text-warning">{t('status.dirty')}</Badge>}
617 </div>
618 </header>
619
620 {loadingAction === 'load' && !config
621 ? <LoadingSkeleton />
622 : (
623 <div className="flex min-h-0 flex-1 flex-col">
624 <div className="flex shrink-0 flex-col gap-2 border-b border-border px-4 py-2 md:flex-row md:items-center md:justify-between">
625 <Tabs value={serviceTarget} onValueChange={handleServiceTargetChange}>
626 <TabsList className="h-9">
627 <TabsTrigger value={ConfigEditorServiceTarget.Server} disabled={serverServiceDisabled} className="gap-1.5 text-xs">
628 <Server className="h-3.5 w-3.5" />
629 {t('services.server')}
630 </TabsTrigger>
631 <TabsTrigger value={ConfigEditorServiceTarget.Ai} disabled={aiServiceDisabled} className="gap-1.5 text-xs">
632 <Bot className="h-3.5 w-3.5" />
633 {t('services.ai')}
634 </TabsTrigger>
635 </TabsList>
636 </Tabs>
637 <div className="flex items-center gap-2 self-end md:self-auto">
638 {editMode === 'json' && (
639 <span className="hidden text-xs text-muted-foreground sm:inline">{t('messages.jsonHint')}</span>
640 )}
641 <Tabs value={editMode} onValueChange={handleEditModeChange}>
642 <TabsList className="h-8 rounded-lg border border-border bg-background p-0.5 shadow-sm" aria-label={t('tabs.modeSwitch')}>
643 <TabsTrigger
644 value="visual"
645 className="h-7 w-8 rounded-md px-0 data-[state=active]:bg-muted data-[state=active]:shadow-none"
646 title={t('tabs.visual')}
647 aria-label={t('tabs.visual')}
648 >
649 <SlidersHorizontal className="h-3.5 w-3.5" />
650 <span className="sr-only">{t('tabs.visual')}</span>
651 </TabsTrigger>
652 <TabsTrigger
653 value="json"
654 className="h-7 w-8 rounded-md px-0 data-[state=active]:bg-muted data-[state=active]:shadow-none"
655 title={t('tabs.json')}
656 aria-label={t('tabs.json')}
657 >
658 <Braces className="h-3.5 w-3.5" />
659 <span className="sr-only">{t('tabs.json')}</span>
660 </TabsTrigger>
661 </TabsList>
662 </Tabs>
663 </div>
664 </div>
665
666 {editMode === 'visual'
667 ? (
668 <div className="flex min-h-0 flex-1">
669 <aside className="hidden w-60 shrink-0 border-r border-border bg-card md:block">
670 <ConfigSectionNav
671 sections={sections}
672 activeSectionId={activeSectionId}
673 disabled={disabled}
674 onSectionClick={scrollToSection}
675 />
676 </aside>
677
678 <main className="min-w-0 flex-1">
679 {config
680 ? (
681 <ConfigFormPanel
682 sections={sections}
683 config={config}
684 originalConfig={originalConfig}
685 disabled={disabled}
686 scrollContainerRef={scrollContainerRef}
687 focusRequest={visualFocusRequest}
688 highlightedPathKey={highlightedVisualPathKey}
689 initialScrollTop={visualScrollTopRef.current}
690 onFocusRequestHandled={handleVisualFocusRequestHandled}
691 onValueChange={handleValueChange}
692 onNavigateToJson={handleNavigateToJson}
693 onScrollTopChange={handleVisualScrollTopChange}
694 onSectionClick={scrollToSection}
695 />
696 )
697 : (
698 <div className="flex h-full items-center justify-center p-6">
699 <Alert variant="destructive" className="max-w-xl">
700 <AlertCircle className="h-4 w-4" />
701 <div>
702 <AlertTitle>{error?.title ?? t('errors.loadFailed')}</AlertTitle>
703 {error?.description && <AlertDescription>{error.description}</AlertDescription>}
704 </div>
705 </Alert>
706 </div>
707 )}
708 </main>
709 </div>
710 )
711 : (
712 <div className="min-h-0 flex-1 bg-background p-4">
713 <ConfigJsonPanel
714 jsonText={jsonText}
715 disabled={disabled}
716 hasConfig={!!config}
717 focusRequest={jsonFocusRequest}
718 highlightedPathKey={highlightedJsonPathKey}
719 initialScrollTop={jsonScrollTop}
720 onFocusRequestHandled={handleJsonFocusRequestHandled}
721 onJsonTextChange={handleJsonTextChange}
722 onScrollTopChange={setJsonScrollTop}
723 onNavigateToVisual={handleNavigateToVisual}
724 />
725 </div>
726 )}
727 </div>
728 )}
729
730 <footer className="flex shrink-0 flex-col gap-3 border-t border-border bg-background px-5 py-3 md:flex-row md:items-center md:justify-between">
731 <div className="min-w-0 flex-1">
732 {error && config && (
733 <div className="flex items-start gap-2 text-sm text-destructive">
734 <AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
735 <span className="line-clamp-2">
736 {error.title}
737 {error.description ? `:${error.description}` : ''}
738 </span>
739 </div>
740 )}
741 {!error && successMessage && (
742 <div className="flex items-center gap-2 text-sm text-success">
743 <CheckCircle2 className="h-4 w-4" />
744 <span>{successMessage}</span>
745 </div>
746 )}
747 {!error && !successMessage && serviceStatus === 'restarting' && (
748 <div className="flex items-center gap-2 text-sm text-muted-foreground">
749 <Loader2 className="h-4 w-4 animate-spin text-primary" />
750 <span>{t('messages.healthChecking', { current: healthAttempts, total: healthCheckMaxAttempts })}</span>
751 </div>
752 )}
753 {!error && !successMessage && serviceStatus !== 'restarting' && (
754 <div className="text-sm text-muted-foreground">
755 {dirty ? t('messages.dirtyHint') : t('messages.cleanHint')}
756 </div>
757 )}
758 </div>
759
760 <div className="flex flex-wrap justify-end gap-2">
761 <Button type="button" variant="outline" disabled={disabled} onClick={loadConfig}>
762 {isReloading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
763 {t('actions.reload')}
764 </Button>
765 <Button type="button" variant="outline" disabled={disabled || !config} onClick={() => validateConfig()}>
766 {isValidating ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldCheck className="h-4 w-4" />}
767 {t('actions.validate')}
768 </Button>
769 <Button type="button" variant="outline" disabled={disabled || !config || !dirty} onClick={() => saveConfig()}>
770 {isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
771 {t('actions.save')}
772 </Button>
773 <Button type="button" disabled={disabled || !config} onClick={dirty ? handleSaveAndRestart : () => restartService()}>
774 {isRestarting ? <Loader2 className="h-4 w-4 animate-spin" /> : <RotateCcw className="h-4 w-4" />}
775 {dirty ? t('actions.saveAndRestart') : t('actions.restart')}
776 </Button>
777 </div>
778 </footer>
779 </div>
780 </DialogContent>
781 </Dialog>
782 )
783 }
784
785 export default ConfigManagerDialog
786
786 lines Plain Text