diff --git a/.gitignore b/.gitignore index ed8a1895..02864245 100644 --- a/.gitignore +++ b/.gitignore @@ -353,3 +353,4 @@ interested_rates.txt MaiBot.code-workspace *.lock actionlint +.sisyphus/ \ No newline at end of file diff --git a/dashboard/package.json b/dashboard/package.json index bf98b1e5..e57cd1bf 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -8,7 +8,9 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", - "format": "prettier --write \"src/**/*.{ts,tsx,css}\"" + "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", + "test": "vitest", + "test:ui": "vitest --ui" }, "dependencies": { "@codemirror/lang-javascript": "^6.2.4", @@ -75,21 +77,27 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.10.2", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.2", + "@vitest/ui": "^4.0.18", "autoprefixer": "^10.4.22", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "jsdom": "^28.1.0", "postcss": "^8.5.6", "prettier": "^3.7.4", "prettier-plugin-tailwindcss": "^0.7.2", "tailwindcss": "^3", "typescript": "~5.9.3", "typescript-eslint": "^8.49.0", - "vite": "^7.2.7" + "vite": "^7.2.7", + "vitest": "^4.0.18" } } diff --git a/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx b/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx new file mode 100644 index 00000000..cd04b78f --- /dev/null +++ b/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx @@ -0,0 +1,114 @@ +import * as React from 'react' + +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 + onChange: (field: string, value: unknown) => void + hooks?: FieldHookRegistry +} + +/** + * DynamicConfigForm - 动态配置表单组件 + * + * 根据 ConfigSchema 渲染表单字段,支持: + * 1. Hook 系统:通过 FieldHookRegistry 自定义字段渲染 + * - replace 模式:完全替换默认渲染 + * - wrapper 模式:包装默认渲染(通过 children 传递) + * 2. 嵌套 schema:递归渲染 schema.nested 中的子配置 + * 3. 默认渲染:使用 DynamicField 组件 + */ +export const DynamicConfigForm: React.FC = ({ + schema, + values, + onChange, + hooks = fieldHooks, // 默认使用全局单例 +}) => { + /** + * 渲染单个字段 + * 检查是否有注册的 Hook,根据 Hook 类型选择渲染方式 + */ + const renderField = (field: FieldSchema) => { + const fieldPath = 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 ( + onChange(field.name, v)} + /> + ) + } else { + // wrapper 模式:包装默认渲染 + return ( + onChange(field.name, v)} + > + onChange(field.name, v)} + fieldPath={fieldPath} + /> + + ) + } + } + + // 无 Hook,使用默认渲染 + return ( + onChange(field.name, v)} + fieldPath={fieldPath} + /> + ) + } + + return ( +
+ {/* 渲染顶层字段 */} + {schema.fields.map((field) => ( +
{renderField(field)}
+ ))} + + {/* 渲染嵌套 schema */} + {schema.nested && + Object.entries(schema.nested).map(([key, nestedSchema]) => ( +
+ {/* 嵌套 schema 标题 */} +
+

{nestedSchema.className}

+ {nestedSchema.classDoc && ( +

{nestedSchema.classDoc}

+ )} +
+ + {/* 递归渲染嵌套表单 */} + ) || {}} + onChange={(field, value) => onChange(`${key}.${field}`, value)} + hooks={hooks} + /> +
+ ))} +
+ ) +} diff --git a/dashboard/src/components/dynamic-form/DynamicField.tsx b/dashboard/src/components/dynamic-form/DynamicField.tsx new file mode 100644 index 00000000..de9d550c --- /dev/null +++ b/dashboard/src/components/dynamic-form/DynamicField.tsx @@ -0,0 +1,246 @@ +import * as React from "react" +import * as LucideIcons from "lucide-react" + +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Slider } from "@/components/ui/slider" +import { Switch } from "@/components/ui/switch" +import { Textarea } from "@/components/ui/textarea" +import type { FieldSchema } from "@/types/config-schema" + +export interface DynamicFieldProps { + schema: FieldSchema + value: unknown + onChange: (value: unknown) => void + // eslint-disable-next-line @typescript-eslint/no-unused-vars + fieldPath?: string // 用于 Hook 系统(未来使用) +} + +/** + * DynamicField - 根据字段类型和 x-widget 渲染对应的 shadcn/ui 组件 + * + * 渲染逻辑: + * 1. x-widget 优先:如果 schema 有 x-widget,使用对应组件 + * 2. type 回退:如果没有 x-widget,根据 type 选择默认组件 + */ +export const DynamicField: React.FC = ({ + schema, + value, + onChange, +}) => { + /** + * 渲染字段图标 + */ + const renderIcon = () => { + if (!schema['x-icon']) return null + + const IconComponent = (LucideIcons as any)[schema['x-icon']] + if (!IconComponent) return null + + return + } + + /** + * 根据 x-widget 或 type 选择并渲染对应的输入组件 + */ + const renderInputComponent = () => { + const widget = schema['x-widget'] + const type = schema.type + + // x-widget 优先 + if (widget) { + switch (widget) { + case 'slider': + return renderSlider() + case 'switch': + return renderSwitch() + case 'textarea': + return renderTextarea() + case 'select': + return renderSelect() + case 'custom': + return ( +
+ Custom field requires Hook +
+ ) + default: + // 未知的 x-widget,回退到 type + break + } + } + + // type 回退 + switch (type) { + case 'boolean': + return renderSwitch() + case 'number': + case 'integer': + return renderNumberInput() + case 'string': + return renderTextInput() + case 'select': + return renderSelect() + case 'array': + return ( +
+ Array fields not yet supported +
+ ) + case 'object': + return ( +
+ Object fields not yet supported +
+ ) + case 'textarea': + return renderTextarea() + default: + return ( +
+ Unknown field type: {type} +
+ ) + } + } + + /** + * 渲染 Switch 组件(用于 boolean 类型) + */ + const renderSwitch = () => { + const checked = Boolean(value) + return ( + onChange(checked)} + /> + ) + } + + /** + * 渲染 Slider 组件(用于 number 类型 + x-widget: slider) + */ + const renderSlider = () => { + const numValue = typeof value === 'number' ? value : (schema.default as number ?? 0) + const min = schema.minValue ?? 0 + const max = schema.maxValue ?? 100 + const step = schema.step ?? 1 + + return ( +
+ onChange(values[0])} + min={min} + max={max} + step={step} + /> +
+ {min} + {numValue} + {max} +
+
+ ) + } + + /** + * 渲染 Input[type="number"] 组件(用于 number/integer 类型) + */ + const renderNumberInput = () => { + const numValue = typeof value === 'number' ? value : (schema.default as number ?? 0) + const min = schema.minValue + const max = schema.maxValue + const step = schema.step ?? (schema.type === 'integer' ? 1 : 0.1) + + return ( + onChange(parseFloat(e.target.value) || 0)} + min={min} + max={max} + step={step} + /> + ) + } + + /** + * 渲染 Input[type="text"] 组件(用于 string 类型) + */ + const renderTextInput = () => { + const strValue = typeof value === 'string' ? value : (schema.default as string ?? '') + return ( + onChange(e.target.value)} + /> + ) + } + + /** + * 渲染 Textarea 组件(用于 textarea 类型或 x-widget: textarea) + */ + const renderTextarea = () => { + const strValue = typeof value === 'string' ? value : (schema.default as string ?? '') + return ( +