import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { parse as parseToml } from 'smol-toml' import { AlertDescription, Alert } from '@/components/ui/alert' import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' import { ScrollArea } from '@/components/ui/scroll-area' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { CodeEditor } from '@/components/CodeEditor' import { DynamicConfigForm } from '@/components/dynamic-form' import { RestartOverlay } from '@/components/restart-overlay' import { useToast } from '@/hooks/use-toast' import { getBotConfig, getBotConfigRaw, getBotConfigSchema, updateBotConfig, updateBotConfigRaw } from '@/lib/config-api' import { fieldHooks } from '@/lib/field-hooks' import { RestartProvider, useRestart } from '@/lib/restart-context' import { cn } from '@/lib/utils' import { ChevronDown, ChevronUp, Code2, Info, Layout, Power, RefreshCw, Save } from 'lucide-react' import type { ConfigSchema } from '@/types/config-schema' import { BotPlatformsHook, ChatPromptsHook, ChatTalkValueRulesHook, ExpressionGroupsHook, ExpressionLearningListHook, KeywordRulesHook, MCPRootItemsHook, MCPServersHook, RegexRulesHook, useAutoSave, useConfigAutoSave, } from './bot/hooks' type ConfigSectionData = Record // ==================== 常量定义 ==================== /** Toast 显示前的延迟时间 (毫秒) */ const TOAST_DISPLAY_DELAY = 500 /** Tab 标签页的首选排列顺序 (host field name) */ const TAB_ORDER = [ 'bot', 'chat', 'expression', 'a_memorix', 'visual', 'message_receive', 'emoji', 'voice', 'response_post_process', 'webui', 'plugin_runtime', 'log', ] /** 默认展示的主配置栏目 */ const DEFAULT_VISIBLE_TAB_IDS = new Set([ 'bot', 'chat', 'expression', 'a_memorix', ]) // ==================== Tab 分组类型与构建 ==================== interface TabGroup { id: string label: string icon: string sections: string[] } /** * 从 schema 的 nested 字段解析出 tab 分组信息。 * - 有 uiLabel 且无 uiParent → 独立 tab * - 有 uiParent → 递归找到最终 host,并归入对应 tab */ function buildTabGroupsFromSchema(schema: ConfigSchema): TabGroup[] { const nested = schema.nested || {} const nestedEntries = Object.entries(nested) const hosts = new Map() const resolveHostId = (fieldName: string, visited: Set = new Set()): string | null => { if (visited.has(fieldName)) { return null } const fieldSchema = nested[fieldName] if (!fieldSchema) { return null } if (!fieldSchema.uiParent) { return fieldSchema.uiLabel && fieldSchema.uiIcon ? fieldName : null } visited.add(fieldName) return resolveHostId(fieldSchema.uiParent, visited) } for (const [fieldName, fieldSchema] of nestedEntries) { if (fieldSchema.uiLabel && fieldSchema.uiIcon && !fieldSchema.uiParent) { hosts.set(fieldName, { id: fieldName, label: fieldSchema.uiLabel, icon: fieldSchema.uiIcon || '', sections: [fieldName], }) } } for (const [fieldName] of nestedEntries) { const hostId = resolveHostId(fieldName) if (!hostId || hostId === fieldName) { continue } const parent = hosts.get(hostId) if (parent && !parent.sections.includes(fieldName)) { parent.sections.push(fieldName) } } // 按 TAB_ORDER 排序;未列入的 tab 追加到末尾 return Array.from(hosts.values()).sort((a, b) => { const ai = TAB_ORDER.indexOf(a.id) const bi = TAB_ORDER.indexOf(b.id) return (ai === -1 ? Infinity : ai) - (bi === -1 ? Infinity : bi) }) } // 主导出组件:包装 RestartProvider export function BotConfigPage() { return ( ) } // 内部实现组件 function BotConfigPageContent() { const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [autoSaving, setAutoSaving] = useState(false) const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) const [editMode, setEditMode] = useState<'visual' | 'source'>('visual') const [sourceCode, setSourceCode] = useState('') const [hasTomlError, setHasTomlError] = useState(false) const [tomlErrorMessage, setTomlErrorMessage] = useState('') const [restartNoticeVisible, setRestartNoticeVisible] = useState( () => localStorage.getItem('bot-config-restart-notice-dismissed') !== 'true' ) const { toast } = useToast() const { triggerRestart, isRestarting } = useRestart() // 配置状态 const [botConfig, setBotConfig] = useState(null) const [personalityConfig, setPersonalityConfig] = useState(null) const [chatConfig, setChatConfig] = useState(null) const [expressionConfig, setExpressionConfig] = useState(null) const [emojiConfig, setEmojiConfig] = useState(null) const [memoryConfig, setMemoryConfig] = useState(null) const [visualConfig, setVisualConfig] = useState(null) const [voiceConfig, setVoiceConfig] = useState(null) const [messageReceiveConfig, setMessageReceiveConfig] = useState(null) const [keywordReactionConfig, setKeywordReactionConfig] = useState(null) const [responsePostProcessConfig, setResponsePostProcessConfig] = useState(null) const [chineseTypoConfig, setChineseTypoConfig] = useState(null) const [responseSplitterConfig, setResponseSplitterConfig] = useState(null) const [logConfig, setLogConfig] = useState(null) const [debugConfig, setDebugConfig] = useState(null) const [maimMessageConfig, setMaimMessageConfig] = useState(null) const [telemetryConfig, setTelemetryConfig] = useState(null) const [webuiConfig, setWebuiConfig] = useState(null) const [databaseConfig, setDatabaseConfig] = useState(null) const [mcpConfig, setMcpConfig] = useState(null) const [pluginRuntimeConfig, setPluginRuntimeConfig] = useState(null) const [aMemorixConfig, setAMemorixConfig] = useState(null) // Schema 状态(用于动态 tab 分组) const [configSchema, setConfigSchema] = useState(null) // 用于标记初始加载和配置缓存 const initialLoadRef = useRef(true) const configRef = useRef>({}) // ==================== 辅助函数 ==================== /** * 翻译 TOML 错误信息为中文 */ const translateTomlError = (errorMessage: string): string => { // 分行处理,保留多行格式 const lines = errorMessage.split('\n') // 翻译第一行(主要错误信息) let firstLine = lines[0] // 移除 "Error: " 前缀(如果有) firstLine = firstLine.replace(/^Error:\s*/, '') // 常见 TOML 错误模式匹配和翻译 const translations: Array<[RegExp, string | ((match: RegExpMatchArray) => string)]> = [ // Invalid TOML document 系列 [/Invalid TOML document: unrecognized escape sequence/, 'TOML 文档错误:无法识别的转义序列(提示:在双引号字符串中使用 \\\\ 转义反斜杠,或使用单引号字符串)'], [/Invalid TOML document: only letter, numbers, dashes and underscores are allowed in keys/, 'TOML 文档错误:键名只能包含字母、数字、短横线和下划线'], [/Invalid TOML document: (.+)/, 'TOML 文档错误:$1'], // 位置错误系列 [/Unexpected character.*at line (\d+), column (\d+)/, '第 $1 行第 $2 列:意外的字符'], [/Expected.*at line (\d+), column (\d+)/, '第 $1 行第 $2 列:缺少必要的字符'], [/Invalid.*at line (\d+), column (\d+)/, '第 $1 行第 $2 列:无效的语法'], [/Unterminated string at line (\d+)/, '第 $1 行:字符串未正常结束(缺少引号)'], [/Duplicate key.*at line (\d+)/, '第 $1 行:重复的键名'], [/Invalid escape sequence at line (\d+)/, '第 $1 行:无效的转义序列(提示:在双引号字符串中使用 \\\\ 转义反斜杠)'], [/Expected.*but got.*at line (\d+)/, '第 $1 行:类型不匹配'], [/line (\d+), column (\d+)/, '第 $1 行第 $2 列'], // 通用错误系列 [/Unexpected end of input/, '意外的文件结束(可能缺少闭合符号)'], [/Unexpected token/, '意外的标记'], [/Invalid number/, '无效的数字'], [/Invalid date/, '无效的日期格式'], [/Invalid boolean/, '无效的布尔值(应为 true 或 false)'], [/Unexpected character/, '意外的字符'], [/unrecognized escape sequence/, '无法识别的转义序列'], ] // 尝试翻译第一行 for (const [pattern, replacement] of translations) { if (pattern.test(firstLine)) { firstLine = firstLine.replace(pattern, replacement as string) break } } // 重组多行错误信息 if (lines.length > 1) { lines[0] = firstLine return lines.join('\n') } return firstLine } /** * 解析并设置所有配置状态 * 抽取自 loadConfig 和 handleModeChange 中的重复逻辑 */ const parseAndSetConfig = useCallback((config: Record) => { configRef.current = config setBotConfig((config.bot ?? {}) as ConfigSectionData) setPersonalityConfig((config.personality ?? {}) as ConfigSectionData) setChatConfig((config.chat ?? {}) as ConfigSectionData) setExpressionConfig((config.expression ?? {}) as ConfigSectionData) setEmojiConfig((config.emoji ?? {}) as ConfigSectionData) setMemoryConfig((config.memory ?? {}) as ConfigSectionData) setVisualConfig((config.visual ?? {}) as ConfigSectionData) setVoiceConfig((config.voice ?? {}) as ConfigSectionData) setMessageReceiveConfig((config.message_receive ?? {}) as ConfigSectionData) setKeywordReactionConfig((config.keyword_reaction ?? {}) as ConfigSectionData) setResponsePostProcessConfig((config.response_post_process ?? {}) as ConfigSectionData) setChineseTypoConfig((config.chinese_typo ?? {}) as ConfigSectionData) setResponseSplitterConfig((config.response_splitter ?? {}) as ConfigSectionData) setLogConfig((config.log ?? {}) as ConfigSectionData) setDebugConfig((config.debug ?? {}) as ConfigSectionData) setMaimMessageConfig((config.maim_message ?? {}) as ConfigSectionData) setTelemetryConfig((config.telemetry ?? {}) as ConfigSectionData) setWebuiConfig((config.webui ?? {}) as ConfigSectionData) setDatabaseConfig((config.database ?? {}) as ConfigSectionData) setMcpConfig((config.mcp ?? {}) as ConfigSectionData) setPluginRuntimeConfig((config.plugin_runtime ?? {}) as ConfigSectionData) setAMemorixConfig((config.a_memorix ?? {}) as ConfigSectionData) }, []) /** * 构建完整的配置对象用于保存 * 抽取自 saveConfig 和 handleSaveAndRestart 中的重复逻辑 */ const buildFullConfig = useCallback(() => { return { ...configRef.current, bot: botConfig, personality: personalityConfig, chat: chatConfig, expression: expressionConfig, emoji: emojiConfig, memory: memoryConfig, visual: visualConfig, voice: voiceConfig, message_receive: messageReceiveConfig, keyword_reaction: keywordReactionConfig, response_post_process: responsePostProcessConfig, chinese_typo: chineseTypoConfig, response_splitter: responseSplitterConfig, log: logConfig, debug: debugConfig, maim_message: maimMessageConfig, telemetry: telemetryConfig, webui: webuiConfig, database: databaseConfig, mcp: mcpConfig, plugin_runtime: pluginRuntimeConfig, a_memorix: aMemorixConfig, } }, [ botConfig, personalityConfig, chatConfig, expressionConfig, emojiConfig, memoryConfig, visualConfig, voiceConfig, messageReceiveConfig, keywordReactionConfig, responsePostProcessConfig, chineseTypoConfig, responseSplitterConfig, logConfig, debugConfig, maimMessageConfig, telemetryConfig, webuiConfig, databaseConfig, mcpConfig, pluginRuntimeConfig, aMemorixConfig, ]) // 加载源代码 const loadSourceCode = useCallback(async () => { try { const result = await getBotConfigRaw() if (!result.success) { toast({ variant: 'destructive', title: '加载失败', description: result.error, }) return } const raw = (result.data as unknown as Record).content as string // 将 TOML 基本字符串中的转义序列转换为实际字符以便在编辑器中正确显示 // 使用正则表达式只处理双引号字符串内的转义序列,不影响单引号字符串 const unescaped = raw.replace(/"([^"]*)"/g, (_match, content) => { const decoded = content .replace(/\\n/g, '\n') // 换行符 .replace(/\\t/g, '\t') // 制表符 .replace(/\\r/g, '\r') // 回车符 .replace(/\\"/g, '"') // 双引号 .replace(/\\\\/g, '\\') // 反斜杠(必须放在最后) return `"${decoded}"` }) setSourceCode(unescaped) setHasTomlError(false) } catch (error) { toast({ variant: 'destructive', title: '加载失败', description: error instanceof Error ? error.message : '加载源代码失败', }) } }, [toast]) // 加载配置 const loadConfig = useCallback(async () => { try { setLoading(true) const [result, schemaResult] = await Promise.all([getBotConfig(), getBotConfigSchema()]) if (!result.success) { toast({ title: '加载失败', description: result.error, variant: 'destructive', }) setLoading(false) return } parseAndSetConfig((result.data as Record).config as Record) if (schemaResult.success && schemaResult.data) { setConfigSchema((schemaResult.data as unknown as Record).schema as ConfigSchema) } setHasUnsavedChanges(false) initialLoadRef.current = false // 同时加载源代码 await loadSourceCode() } catch (error) { console.error('加载配置失败:', error) toast({ title: '加载失败', description: '无法加载配置文件', variant: 'destructive', }) } finally { setLoading(false) } }, [toast, loadSourceCode, parseAndSetConfig]) useEffect(() => { loadConfig() }, [loadConfig]) useEffect(() => { const hookEntries = [ ['bot.platforms', BotPlatformsHook], ['chat.chat_prompts', ChatPromptsHook], ['chat.talk_value_rules', ChatTalkValueRulesHook], ['expression.expression_groups', ExpressionGroupsHook], ['expression.learning_list', ExpressionLearningListHook], ['keyword_reaction.keyword_rules', KeywordRulesHook], ['keyword_reaction.regex_rules', RegexRulesHook], ['mcp.client.roots.items', MCPRootItemsHook], ['mcp.servers', MCPServersHook], ] as const for (const [fieldPath, hookComponent] of hookEntries) { fieldHooks.register(fieldPath, hookComponent, 'replace') } return () => { for (const [fieldPath] of hookEntries) { fieldHooks.unregister(fieldPath) } } }, []) // 使用模块化的 useAutoSave hook const { triggerAutoSave, cancelPendingAutoSave } = useAutoSave( initialLoadRef.current, setAutoSaving, setHasUnsavedChanges ) // 使用 useConfigAutoSave hook 简化配置变化监听 // 注意: useConfigAutoSave 是一个 hook,不能在条件语句或循环中调用 // 因此我们仍然需要逐个调用,但代码更简洁 useConfigAutoSave(botConfig, 'bot', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(personalityConfig, 'personality', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(chatConfig, 'chat', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(expressionConfig, 'expression', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(emojiConfig, 'emoji', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(memoryConfig, 'memory', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(visualConfig, 'visual', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(voiceConfig, 'voice', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(messageReceiveConfig, 'message_receive', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(keywordReactionConfig, 'keyword_reaction', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(responsePostProcessConfig, 'response_post_process', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(chineseTypoConfig, 'chinese_typo', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(responseSplitterConfig, 'response_splitter', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(logConfig, 'log', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(debugConfig, 'debug', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(maimMessageConfig, 'maim_message', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(telemetryConfig, 'telemetry', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(webuiConfig, 'webui', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(databaseConfig, 'database', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(mcpConfig, 'mcp', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(pluginRuntimeConfig, 'plugin_runtime', initialLoadRef.current, triggerAutoSave) useConfigAutoSave(aMemorixConfig, 'a_memorix', initialLoadRef.current, triggerAutoSave) // 保存源代码 const saveSourceCode = async () => { try { setSaving(true) // 编辑器展示时会把 basic string 内的 \n 展开成真实换行;保存前先转回 TOML 转义序列。 const escapedSourceCode = sourceCode.replace(/"([^"]*)"/g, (_match, content) => { const encoded = content .replace(/\\/g, '\\\\') // 反斜杠必须先转义,避免 \s 等序列被 TOML 当作非法转义 .replace(/"/g, '\\"') .replace(/\n/g, '\\n') .replace(/\t/g, '\\t') .replace(/\r/g, '\\r') return `"${encoded}"` }) // 前端验证 TOML 格式 try { parseToml(escapedSourceCode) } catch (error) { const errorMsg = error instanceof Error ? error.message : 'TOML 格式错误' const translatedMsg = translateTomlError(errorMsg) setHasTomlError(true) setTomlErrorMessage(translatedMsg) toast({ variant: 'destructive', title: 'TOML 格式错误', description: translatedMsg, }) setSaving(false) return } const result = await updateBotConfigRaw(escapedSourceCode) if (!result.success) { setHasTomlError(true) const errorMsg = result.error setTomlErrorMessage(errorMsg) toast({ variant: 'destructive', title: '保存失败', description: errorMsg, }) return } setHasUnsavedChanges(false) setHasTomlError(false) setTomlErrorMessage('') toast({ title: '保存成功', description: '配置已保存', }) // 重新加载可视化配置 await loadConfig() } catch (error) { setHasTomlError(true) const errorMsg = error instanceof Error ? error.message : '保存配置失败' setTomlErrorMessage(errorMsg) toast({ variant: 'destructive', title: '保存失败', description: errorMsg, }) } finally { setSaving(false) } } // 处理模式切换 const handleModeChange = async (mode: 'visual' | 'source') => { if (hasUnsavedChanges) { toast({ variant: 'destructive', title: '切换失败', description: '请先保存当前更改', }) return } setEditMode(mode) if (mode === 'source') { await loadSourceCode() } else { // 切换回可视化时,直接重新加载配置但不显示全局 loading try { const result = await getBotConfig() if (!result.success) { toast({ title: '加载失败', description: result.error, variant: 'destructive', }) return } parseAndSetConfig((result.data as Record).config as Record) setHasUnsavedChanges(false) } catch (error) { console.error('加载配置失败:', error) toast({ title: '加载失败', description: '无法加载配置文件', variant: 'destructive', }) } } } // 手动保存 const saveConfig = async () => { try { setSaving(true) // 取消待处理的自动保存 cancelPendingAutoSave() const result = await updateBotConfig(buildFullConfig()) if (!result.success) { toast({ title: '保存失败', description: result.error, variant: 'destructive', }) setSaving(false) return } setHasUnsavedChanges(false) toast({ title: '保存成功', description: '麦麦主程序配置已保存', }) } catch (error) { console.error('保存配置失败:', error) toast({ title: '保存失败', description: (error as Error).message, variant: 'destructive', }) } finally { setSaving(false) } } // 重启麦麦 const handleRestart = async () => { await triggerRestart() } const dismissRestartNotice = () => { localStorage.setItem('bot-config-restart-notice-dismissed', 'true') setRestartNoticeVisible(false) } const handleReloadFromFile = async () => { cancelPendingAutoSave() await loadConfig() setHasUnsavedChanges(false) toast({ title: '已刷新', description: '已从 bot_config.toml 重新读取配置', }) } // 保存并重启 const handleSaveAndRestart = async () => { try { setSaving(true) // 取消待处理的自动保存 cancelPendingAutoSave() const result = await updateBotConfig(buildFullConfig()) if (!result.success) { toast({ title: '保存失败', description: result.error, variant: 'destructive', }) setSaving(false) return } setHasUnsavedChanges(false) toast({ title: '保存成功', description: '配置已保存,即将重启麦麦...', }) // 等待一下让用户看到保存成功的提示 await new Promise(resolve => setTimeout(resolve, TOAST_DISPLAY_DELAY)) await handleRestart() } catch (error) { console.error('保存失败:', error) toast({ title: '保存失败', description: (error as Error).message, variant: 'destructive', }) } finally { setSaving(false) } } // 根据 schema 构建 tab 分组 const tabGroups = useMemo(() => { if (!configSchema) return [] return buildTabGroupsFromSchema(configSchema) }, [configSchema]) const sectionValues = useMemo>( () => ({ bot: botConfig, personality: personalityConfig, chat: chatConfig, expression: expressionConfig, emoji: emojiConfig, memory: memoryConfig, visual: visualConfig, voice: voiceConfig, message_receive: messageReceiveConfig, keyword_reaction: keywordReactionConfig, response_post_process: responsePostProcessConfig, chinese_typo: chineseTypoConfig, response_splitter: responseSplitterConfig, log: logConfig, debug: debugConfig, maim_message: maimMessageConfig, telemetry: telemetryConfig, webui: webuiConfig, database: databaseConfig, mcp: mcpConfig, plugin_runtime: pluginRuntimeConfig, a_memorix: aMemorixConfig, }), [ botConfig, personalityConfig, chatConfig, expressionConfig, emojiConfig, memoryConfig, visualConfig, voiceConfig, messageReceiveConfig, keywordReactionConfig, responsePostProcessConfig, chineseTypoConfig, responseSplitterConfig, logConfig, debugConfig, maimMessageConfig, telemetryConfig, webuiConfig, databaseConfig, mcpConfig, pluginRuntimeConfig, aMemorixConfig, ] ) const setSectionValue = useCallback((sectionName: string, value: ConfigSectionData) => { const sectionSetterMap: Record void> = { bot: setBotConfig, personality: setPersonalityConfig, chat: setChatConfig, expression: setExpressionConfig, emoji: setEmojiConfig, memory: setMemoryConfig, visual: setVisualConfig, voice: setVoiceConfig, message_receive: setMessageReceiveConfig, keyword_reaction: setKeywordReactionConfig, response_post_process: setResponsePostProcessConfig, chinese_typo: setChineseTypoConfig, response_splitter: setResponseSplitterConfig, log: setLogConfig, debug: setDebugConfig, maim_message: setMaimMessageConfig, telemetry: setTelemetryConfig, webui: setWebuiConfig, database: setDatabaseConfig, mcp: setMcpConfig, plugin_runtime: setPluginRuntimeConfig, a_memorix: setAMemorixConfig, } sectionSetterMap[sectionName]?.(value) }, []) if (loading) { return (

加载中...

) } return (
{/* 页面标题 */}

麦麦主程序配置

管理麦麦的核心功能和行为设置

{/* 按钮组 - 桌面端靠右 */}
handleModeChange(v as 'visual' | 'source')} className="w-full min-w-[13rem] sm:w-[14rem]" > 可视化 源代码 确认重启麦麦?

{hasUnsavedChanges ? '当前有未保存的配置更改。点击确认将先保存配置,然后重启麦麦使新配置生效。重启过程中麦麦将暂时离线。' : '即将重启麦麦主程序。重启过程中麦麦将暂时离线,配置将在重启后生效。' }

取消 {hasUnsavedChanges ? '保存并重启' : '确认重启'}
{/* 重启提示 */} {restartNoticeVisible && ( 配置更新后需要重启麦麦才能生效。你可以点击右上角的"保存并重启"按钮一键完成保存和重启。 )} {/* 源代码模式 */} {editMode === 'source' && (
源代码模式(高级功能):直接编辑 TOML 配置文件。此功能仅适用于熟悉 TOML 语法的高级用户。保存时会在前端验证格式,只有格式完全正确才能保存。 {hasTomlError && tomlErrorMessage && (
⚠️ TOML 格式错误:
                      {tomlErrorMessage}
                    
)}
{ setSourceCode(value) setHasUnsavedChanges(true) // 清除之前的错误状态 if (hasTomlError) { setHasTomlError(false) setTomlErrorMessage('') } }} language="toml" height="calc(100vh - 280px)" minHeight="500px" placeholder="TOML 配置内容" />
)} {/* 可视化模式 */} {editMode === 'visual' && ( )} {/* 重启遮罩层 */}
) } // ==================== 动态 Tab 渲染组件 ==================== function updateNestedValue( target: ConfigSectionData | null | undefined, pathSegments: string[], value: unknown ): ConfigSectionData { const currentTarget = target && typeof target === 'object' && !Array.isArray(target) ? target : {} const [currentPath, ...restPath] = pathSegments if (!currentPath) { return currentTarget } if (restPath.length === 0) { return { ...currentTarget, [currentPath]: value, } } return { ...currentTarget, [currentPath]: updateNestedValue(currentTarget[currentPath] as ConfigSectionData | undefined, restPath, value), } } interface DynamicConfigTabsProps { configSchema: ConfigSchema | null tabGroups: TabGroup[] sectionValues: Record setSectionValue: (sectionName: string, value: ConfigSectionData) => void setHasUnsavedChanges: (v: boolean) => void } function DynamicConfigTabs(props: DynamicConfigTabsProps) { const { configSchema, sectionValues, setHasUnsavedChanges, setSectionValue, tabGroups } = props const [expanded, setExpanded] = useState(false) const [activeTab, setActiveTab] = useState(tabGroups[0]?.id ?? '') useEffect(() => { if (!tabGroups.some((tab) => tab.id === activeTab)) { setActiveTab(tabGroups[0]?.id ?? '') } }, [activeTab, tabGroups]) if (tabGroups.length === 0 || !configSchema?.nested) { return null } const visibleTabGroups = expanded ? tabGroups : tabGroups.filter((tab) => DEFAULT_VISIBLE_TAB_IDS.has(tab.id)) const hasCollapsibleTabs = tabGroups.some((tab) => !DEFAULT_VISIBLE_TAB_IDS.has(tab.id)) const firstExpandedTabId = visibleTabGroups.find( (tab) => !DEFAULT_VISIBLE_TAB_IDS.has(tab.id) )?.id const toggleExpanded = () => { setExpanded((current) => { if (current && !DEFAULT_VISIBLE_TAB_IDS.has(activeTab)) { const firstDefaultTab = tabGroups.find((tab) => DEFAULT_VISIBLE_TAB_IDS.has(tab.id)) setActiveTab(firstDefaultTab?.id ?? tabGroups[0]?.id ?? '') } return !current }) } const renderTabContent = (tab: TabGroup) => { const tabNestedEntries = tab.sections .map((sectionName) => [sectionName, configSchema.nested?.[sectionName]] as const) .filter((entry): entry is readonly [string, ConfigSchema] => Boolean(entry[1])) if (tabNestedEntries.length === 0) { return null } const values = Object.fromEntries( tabNestedEntries.map(([sectionName]) => [sectionName, sectionValues[sectionName] ?? {}]) ) const tabSchema: ConfigSchema = { className: tab.id, classDoc: tab.label, fields: [], nested: Object.fromEntries(tabNestedEntries), } return ( { const [sectionName, ...restPath] = fieldPath.split('.') if (!sectionName) { return } const currentSectionValue = sectionValues[sectionName] ?? {} const nextSectionValue = restPath.length === 0 ? (value as ConfigSectionData) : updateNestedValue(currentSectionValue, restPath, value) setSectionValue(sectionName, nextSectionValue) setHasUnsavedChanges(true) }} hooks={fieldHooks} /> ) } return ( {visibleTabGroups.map((tab) => { const isExpandedOnlyTab = !DEFAULT_VISIBLE_TAB_IDS.has(tab.id) return ( {tab.id === firstExpandedTabId && ( )} {tab.label} ) })} {hasCollapsibleTabs && ( )} {tabGroups.map((tab) => ( {renderTabContent(tab)} ))} ) }