Files
mai-bot/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx
DrSmoothl aea87e18f1 feat: enhance bot configuration with new sections and JSON field hooks
- Added support for new configuration sections: relationship, database, maisaka, mcp, and plugin_runtime.
- Introduced complex field hooks for handling JSON configurations in chat talk value rules, expression learning lists, and more.
- Updated the field hooks to include schema metadata for better UI representation.
- Refactored the bot configuration page to utilize a more dynamic approach for managing section values and state.
- Improved the configuration schema generation to ensure all top-level sections have UI metadata.
- Added tests to validate the new configuration schema and ensure proper functionality of the JSON field hooks.
2026-04-03 02:46:07 +08:00

252 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as React from 'react'
import * as LucideIcons from 'lucide-react'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import type { ConfigSchema, FieldSchema } from '@/types/config-schema'
import { fieldHooks, type FieldHookRegistry } from '@/lib/field-hooks'
import { DynamicField } from './DynamicField'
export interface DynamicConfigFormProps {
schema: ConfigSchema
values: Record<string, unknown>
onChange: (field: string, value: unknown) => void
basePath?: string
hooks?: FieldHookRegistry
/** 嵌套层级0 = tab 内容层, 1 = section 内容层, 2+ = 更深嵌套 */
level?: number
}
/**
* DynamicConfigForm - 动态配置表单组件
*
* 根据 ConfigSchema 渲染表单字段,支持:
* 1. Hook 系统:通过 FieldHookRegistry 自定义字段渲染
* - replace 模式:完全替换默认渲染
* - wrapper 模式:包装默认渲染(通过 children 传递)
* 2. 嵌套 schema递归渲染 schema.nested 中的子配置,使用 Card 容器区分层级
* 3. 默认渲染:使用 DynamicField 组件
*/
export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
schema,
values,
onChange,
basePath = '',
hooks = fieldHooks, // 默认使用全局单例
level = 0,
}) => {
const fieldMap = React.useMemo(
() => new Map(schema.fields.map((field) => [field.name, field])),
[schema.fields]
)
const buildFieldPath = (fieldName: string) => {
return basePath ? `${basePath}.${fieldName}` : fieldName
}
/**
* 渲染单个字段
* 检查是否有注册的 Hook根据 Hook 类型选择渲染方式
*/
const renderField = (field: FieldSchema) => {
const fieldPath = buildFieldPath(field.name)
// 检查是否有注册的 Hook
if (hooks.has(fieldPath)) {
const hookEntry = hooks.get(fieldPath)
if (!hookEntry) return null // Type guard理论上不会发生
const HookComponent = hookEntry.component
if (hookEntry.type === 'replace') {
// replace 模式:完全替换默认渲染
return (
<HookComponent
fieldPath={fieldPath}
value={values[field.name]}
onChange={(v) => onChange(field.name, v)}
schema={field}
/>
)
} else {
// wrapper 模式:包装默认渲染
return (
<HookComponent
fieldPath={fieldPath}
value={values[field.name]}
onChange={(v) => onChange(field.name, v)}
schema={field}
>
<DynamicField
schema={field}
value={values[field.name]}
onChange={(v) => onChange(field.name, v)}
fieldPath={fieldPath}
/>
</HookComponent>
)
}
}
// 无 Hook使用默认渲染
return (
<DynamicField
schema={field}
value={values[field.name]}
onChange={(v) => onChange(field.name, v)}
fieldPath={fieldPath}
/>
)
}
/** 渲染 section 图标 */
const renderSectionIcon = (iconName?: string) => {
if (!iconName) return null
const IconComponent = LucideIcons[iconName as keyof typeof LucideIcons] as
| React.ComponentType<{ className?: string }>
| undefined
if (!IconComponent) return null
return <IconComponent className="h-5 w-5 text-muted-foreground" />
}
// 过滤出不属于 nested 的顶层字段
const topLevelFields = schema.fields.filter(
(field) => !schema.nested?.[field.name]
)
return (
<div className="space-y-6">
{/* 渲染顶层字段 */}
{topLevelFields.length > 0 && (
<div className="space-y-1">
{topLevelFields.map((field, index) => (
<React.Fragment key={field.name}>
{index > 0 && field.type !== 'boolean' && topLevelFields[index - 1]?.type !== 'boolean' && (
<Separator className="my-1" />
)}
<div>{renderField(field)}</div>
</React.Fragment>
))}
</div>
)}
{/* 渲染嵌套 schema */}
{schema.nested &&
Object.entries(schema.nested).map(([key, nestedSchema]) => {
const nestedField = fieldMap.get(key)
const nestedFieldPath = buildFieldPath(key)
// Hook 系统处理
if (hooks.has(nestedFieldPath)) {
const hookEntry = hooks.get(nestedFieldPath)
if (!hookEntry) return null
const HookComponent = hookEntry.component
if (hookEntry.type === 'replace') {
return (
<div key={key}>
<HookComponent
fieldPath={nestedFieldPath}
value={values[key]}
onChange={(v) => onChange(key, v)}
schema={nestedField ?? nestedSchema}
/>
</div>
)
}
return (
<div key={key}>
<HookComponent
fieldPath={nestedFieldPath}
value={values[key]}
onChange={(v) => onChange(key, v)}
schema={nestedField ?? nestedSchema}
>
<DynamicConfigForm
schema={nestedSchema}
values={(values[key] as Record<string, unknown>) || {}}
onChange={(field, value) => onChange(`${key}.${field}`, value)}
basePath={nestedFieldPath}
hooks={hooks}
level={level + 1}
/>
</HookComponent>
</div>
)
}
const sectionTitle =
nestedSchema.uiLabel || nestedSchema.classDoc || nestedSchema.className
const sectionDescription =
nestedSchema.classDoc && nestedSchema.classDoc !== sectionTitle
? nestedSchema.classDoc
: undefined
// 一级嵌套:使用 Card 包裹,清晰的 section 边界
if (level === 0) {
return (
<Card key={key}>
<CardHeader className="pb-4">
<div className="flex items-center gap-2">
{renderSectionIcon(nestedSchema.uiIcon)}
<CardTitle className="text-lg">{sectionTitle}</CardTitle>
</div>
{sectionDescription && (
<CardDescription>{sectionDescription}</CardDescription>
)}
</CardHeader>
<CardContent>
<DynamicConfigForm
schema={nestedSchema}
values={(values[key] as Record<string, unknown>) || {}}
onChange={(field, value) => onChange(`${key}.${field}`, value)}
basePath={nestedFieldPath}
hooks={hooks}
level={level + 1}
/>
</CardContent>
</Card>
)
}
// 二级及更深嵌套:使用左侧指示条 + 轻量分组
return (
<div
key={key}
className="relative space-y-4 rounded-lg border-l-2 border-muted-foreground/20 pl-4 pt-1 pb-1"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
{renderSectionIcon(nestedSchema.uiIcon)}
<h4 className="text-sm font-semibold">{sectionTitle}</h4>
</div>
{sectionDescription && (
<p className="text-xs text-muted-foreground">
{sectionDescription}
</p>
)}
</div>
<DynamicConfigForm
schema={nestedSchema}
values={(values[key] as Record<string, unknown>) || {}}
onChange={(field, value) => onChange(`${key}.${field}`, value)}
basePath={nestedFieldPath}
hooks={hooks}
level={level + 1}
/>
</div>
)
})}
</div>
)
}