unnamed-ui
气泡/容器

Dynamic Form

Schema-driven dynamic form with validation, multiple field types, and readonly mode

快速开始
"use client";

import {
  DynamicForm,
  type FormSchema,
} from "@/components/composed/dynamic-form/dynamic-form";

const schema: FormSchema = {
  title: "快速开始",
  // description: "展示各种字段类型的基本用法",
  fields: [
    {
      name: "name",
      label: "姓名",
      type: "input",
      required: true,
      placeholder: "请输入您的姓名",
    },
    {
      name: "category",
      label: "分类",
      type: "select",
      required: true,
      options: [
        { value: "tech", label: "技术" },
        { value: "design", label: "设计" },
        { value: "product", label: "产品" },
      ],
    },
    {
      name: "priority",
      label: "优先级",
      type: "radio",
      options: [
        { value: "low", label: "低" },
        { value: "medium", label: "中" },
        { value: "high", label: "高" },
      ],
      // orientation: "horizontal",
      defaultValue: "medium",
    },
    {
      name: "active",
      label: "启用",
      type: "switch",
      defaultValue: true,
    },
  ],
};

export function DynamicFormDemo() {
  return (
    <DynamicForm
      schema={schema}
      onFinish={(values) => {
        console.log("提交:", values);
      }}
    />
  );
}

Dynamic Form 是一个基于 Schema 配置的动态表单组件,支持多种表单控件、表单验证和只读模式,特别适用于 AI 对话场景中的用户交互表单。

概述

  • Schema 驱动:通过 JSON 配置动态生成表单
  • 类型安全:完整的 TypeScript 类型定义
  • 灵活验证:基于 Zod 的强大验证能力
  • 丰富控件:支持 8 种表单控件类型
  • 只读模式:支持表单数据的只读展示
  • 实例方法:类似 Ant Design 的 API 设计

快速开始

import { DynamicForm, type FormSchema } from "@/registry/wuhan/composed/dynamic-form";

const schema: FormSchema = {
  title: "用户信息",
  fields: [
    {
      name: "username",
      label: "用户名",
      type: "input",
      required: true,
    },
    {
      name: "email",
      label: "邮箱",
      type: "input",
    },
  ],
};

export function Example() {
  return (
    <DynamicForm
      schema={schema}
      onFinish={(values) => console.log(values)}
    />
  );
}

特性

  • 8 种表单控件:input、textarea、select、radio、checkbox、switch、slider、number
  • 自定义组件:支持使用任何具备 value 和 onChange 属性的自定义组件
  • Zod 验证集成:强大的表单验证能力
  • 只读模式:展示 label 而非 value,适合数据预览
  • 实例方法:getFieldsError、getFieldsValue、validateFields、setFields、resetFields、submit
  • 表单值监听:onValuesChange 回调实时监听表单变化
  • 自定义渲染:支持字段级别的自定义渲染函数(高级用法)
  • 响应式布局:支持 vertical、horizontal、responsive 三种布局方向
  • AI 场景友好:提供 generateJsonSchema 辅助 AI 理解表单结构

安装

pnpm dlx shadcn@latest add http://localhost:3000/r/wuhan/dynamic-form.json

代码演示

基本

基础用法,展示多种字段类型。

用户信息

请填写您的基本信息

"use client";

import {
  DynamicForm,
  type FormSchema,
} from "@/components/composed/dynamic-form/dynamic-form";

const schema: FormSchema = {
  title: "用户信息",
  description: "请填写您的基本信息",
  fields: [
    {
      name: "username",
      label: "用户名",
      type: "input",
      required: true,
      placeholder: "请输入用户名",
    },
    {
      name: "email",
      label: "邮箱",
      type: "input",
      placeholder: "example@email.com",
    },
    {
      name: "bio",
      label: "个人简介",
      type: "textarea",
      placeholder: "介绍一下自己...",
    },
    {
      name: "role",
      label: "角色",
      type: "select",
      required: true,
      options: [
        { value: "user", label: "普通用户" },
        { value: "admin", label: "管理员" },
        { value: "guest", label: "访客" },
      ],
    },
    {
      name: "notifications",
      label: "接收通知",
      type: "switch",
      defaultValue: true,
    },
  ],
};

export function DynamicFormDefault() {
  return (
    <DynamicForm
      schema={schema}
      onFinish={(values) => {
        console.log("提交的数据:", values);
      }}
    />
  );
}

表单验证

带 Zod 验证的表单,展示错误提示。

注册表单

带验证的用户注册表单

"use client";

import {
  DynamicForm,
  type FormSchema,
} from "@/components/composed/dynamic-form/dynamic-form";
import { z } from "zod";

const schema: FormSchema = {
  title: "注册表单",
  description: "带验证的用户注册表单",
  fields: [
    {
      name: "username",
      label: "用户名",
      type: "input",
      required: true,
      placeholder: "至少3个字符",
    },
    {
      name: "email",
      label: "邮箱",
      type: "input",
      required: true,
      placeholder: "有效的邮箱地址",
    },
    {
      name: "password",
      label: "密码",
      type: "input",
      required: true,
      placeholder: "至少6个字符",
    },
    {
      name: "age",
      label: "年龄",
      type: "number",
      required: true,
      min: 18,
      max: 100,
      placeholder: "必须年满18岁",
    },
    {
      name: "website",
      label: "个人网站",
      type: "input",
      placeholder: "https://example.com",
    },
    {
      name: "agree",
      label: "同意用户协议",
      type: "checkbox",
      required: true,
    },
  ],
};

const validateSchema = z.object({
  username: z.string().min(3, "用户名至少需要3个字符"),
  email: z.string().email("请输入有效的邮箱地址"),
  password: z.string().min(6, "密码至少需要6个字符"),
  age: z.number().min(18, "必须年满18岁").max(100, "年龄不能超过100岁"),
  website: z.string().url("请输入有效的URL").optional().or(z.literal("")),
  agree: z.boolean().refine((val) => val === true, {
    message: "必须同意用户协议",
  }),
});

export function DynamicFormValidation() {
  return (
    <DynamicForm
      schema={schema}
      validateSchema={validateSchema}
      onFinish={(values) => {
        console.log("验证通过,提交的数据:", values);
      }}
      onFinishFailed={(errorInfo) => {
        console.log("验证失败:", errorInfo);
      }}
    />
  );
}

设置只读状态

只读模式展示已填写的表单数据。

用户资料

已完成的用户资料信息

用户名
张三
邮箱
zhangsan@example.com
角色
管理员
订阅计划
专业版
接收通知
个人简介
热爱编程的开发者,专注于前端技术栈。
"use client";

import {
  DynamicForm,
  type FormSchema,
} from "@/components/composed/dynamic-form/dynamic-form";

const schema: FormSchema = {
  title: "用户资料",
  description: "已完成的用户资料信息",
  fields: [
    {
      name: "username",
      label: "用户名",
      type: "input",
    },
    {
      name: "email",
      label: "邮箱",
      type: "input",
    },
    {
      name: "role",
      label: "角色",
      type: "select",
      options: [
        { value: "admin", label: "管理员" },
        { value: "user", label: "普通用户" },
      ],
    },
    {
      name: "plan",
      label: "订阅计划",
      type: "radio",
      options: [
        { value: "free", label: "免费版" },
        { value: "pro", label: "专业版" },
        { value: "enterprise", label: "企业版" },
      ],
      orientation: "horizontal",
    },
    {
      name: "notifications",
      label: "接收通知",
      type: "switch",
    },
    {
      name: "bio",
      label: "个人简介",
      type: "textarea",
    },
  ],
};

const initialValues = {
  username: "张三",
  email: "zhangsan@example.com",
  role: "admin",
  plan: "pro",
  notifications: true,
  bio: "热爱编程的开发者,专注于前端技术栈。",
};

export function DynamicFormReadonly() {
  return (
    <DynamicForm
      schema={schema}
      initialValues={initialValues}
      readonly={true}
      showActions={false}
    />
  );
}

Pending 状态

Pending 状态,显示状态标签,支持表单编辑和提交。

用户信息表单待确认

请填写以下信息

"use client";

import { DynamicForm } from "@/components/composed/dynamic-form/dynamic-form";
import type { FormSchema } from "@/components/composed/dynamic-form/dynamic-form";

const schema: FormSchema = {
  title: "用户信息表单",
  description: "请填写以下信息",
  fields: [
    {
      name: "username",
      label: "用户名",
      type: "input",
      placeholder: "请输入用户名",
      required: true,
    },
    {
      name: "email",
      label: "邮箱",
      type: "input",
      placeholder: "请输入邮箱地址",
      required: true,
    },
    {
      name: "bio",
      label: "个人简介",
      type: "textarea",
      placeholder: "请输入个人简介",
    },
  ],
};

export function DynamicFormPending() {
  return (
    <DynamicForm
      schema={schema}
      status="pending"
      onFinish={(values) => console.log("表单提交:", values)}
    />
  );
}

Confirmed 状态

Confirmed 状态,显示状态标签,自动设置为只读,隐藏 Footer 按钮。

用户信息表单已确认

表单已确认,处于只读状态

用户名
张三
邮箱
zhangsan@example.com
个人简介
这是一段个人简介
"use client";

import { DynamicForm } from "@/components/composed/dynamic-form/dynamic-form";
import type { FormSchema } from "@/components/composed/dynamic-form/dynamic-form";

const schema: FormSchema = {
  title: "用户信息表单",
  description: "表单已确认,处于只读状态",
  fields: [
    {
      name: "username",
      label: "用户名",
      type: "input",
      placeholder: "请输入用户名",
      required: true,
    },
    {
      name: "email",
      label: "邮箱",
      type: "input",
      placeholder: "请输入邮箱地址",
      required: true,
    },
    {
      name: "bio",
      label: "个人简介",
      type: "textarea",
      placeholder: "请输入个人简介",
    },
  ],
};

export function DynamicFormConfirmed() {
  return (
    <DynamicForm
      schema={schema}
      status="confirmed"
      initialValues={{
        username: "张三",
        email: "zhangsan@example.com",
        bio: "这是一段个人简介",
      }}
      onFinish={(values) => console.log("表单提交:", values)}
    />
  );
}

实例方法

使用实例方法控制表单。

设置
"use client";

import { useRef, useState } from "react";
import {
  DynamicForm,
  type DynamicFormRef,
  type FormSchema,
} from "@/components/composed/dynamic-form/dynamic-form";
import { Button } from "@/components/ui/button";

const schema: FormSchema = {
  title: "设置",
  fields: [
    {
      name: "name",
      label: "名称",
      type: "input",
      required: true,
      placeholder: "请输入名称",
    },
    {
      name: "email",
      label: "邮箱",
      type: "input",
      required: true,
      placeholder: "请输入邮箱",
    },
    {
      name: "theme",
      label: "主题",
      type: "select",
      options: [
        { value: "light", label: "浅色" },
        { value: "dark", label: "深色" },
        { value: "auto", label: "自动" },
      ],
      defaultValue: "auto",
    },
  ],
};

export function DynamicFormRefMethods() {
  const formRef = useRef<DynamicFormRef>(null);
  const [output, setOutput] = useState<string>("");

  const handleGetValues = () => {
    const values = formRef.current?.getFieldsValue();
    setOutput(`当前值: ${JSON.stringify(values, null, 2)}`);
  };

  const handleGetErrors = () => {
    const errors = formRef.current?.getFieldsError();
    setOutput(`错误信息: ${JSON.stringify(errors, null, 2)}`);
  };

  const handleValidate = async () => {
    try {
      const values = await formRef.current?.validateFields();
      setOutput(`验证通过: ${JSON.stringify(values, null, 2)}`);
    } catch {
      setOutput(`验证失败`);
    }
  };

  const handleReset = () => {
    formRef.current?.resetFields();
    setOutput("表单已重置");
  };

  const handleSetValues = () => {
    formRef.current?.setFields([
      { name: "name", value: "预设名称" },
      { name: "email", value: "preset@example.com" },
      { name: "theme", value: "dark" },
    ]);
    setOutput("已设置预设值");
  };

  return (
    <div className="space-y-4">
      <DynamicForm ref={formRef} schema={schema} showActions={false} />

      <div className="flex flex-wrap gap-2">
        <Button variant="outline" size="sm" onClick={handleGetValues}>
          获取值
        </Button>
        <Button variant="outline" size="sm" onClick={handleGetErrors}>
          获取错误
        </Button>
        <Button variant="outline" size="sm" onClick={handleValidate}>
          验证
        </Button>
        <Button variant="outline" size="sm" onClick={handleSetValues}>
          设置预设值
        </Button>
        <Button variant="outline" size="sm" onClick={handleReset}>
          重置
        </Button>
      </div>

      {output && (
        <pre className="rounded-md bg-muted p-4 text-sm">
          <code>{output}</code>
        </pre>
      )}
    </div>
  );
}

AI 场景

AI 对话场景中的动态表单生成。

💡 AI 场景:用户说「帮我规划一次旅行」,AI 返回以下表单让用户填写详细信息

旅行计划

AI 为您生成的旅行计划表单

10000
"use client";

import { useState } from "react";
import {
  DynamicForm,
  type FormSchema,
} from "@/components/composed/dynamic-form/dynamic-form";
import { generateJsonSchema } from "@/components/composed/dynamic-form/dynamic-form-utils";
import { Button } from "@/components/ui/button";

// 模拟 AI 返回的表单配置
const aiGeneratedSchema: FormSchema = {
  title: "旅行计划",
  description: "AI 为您生成的旅行计划表单",
  fields: [
    {
      name: "destination",
      label: "目的地",
      type: "input",
      required: true,
      placeholder: "您想去哪里?",
    },
    {
      name: "duration",
      label: "旅行时长(天)",
      type: "number",
      required: true,
      min: 1,
      max: 30,
      defaultValue: 7,
    },
    {
      name: "budget",
      label: "预算范围",
      type: "slider",
      range: {
        min: 1000,
        max: 50000,
        step: 1000,
      },
      defaultValue: 10000,
    },
    {
      name: "travelStyle",
      label: "旅行风格",
      type: "radio",
      required: true,
      options: [
        { value: "relaxed", label: "休闲放松" },
        { value: "adventure", label: "冒险探索" },
        { value: "cultural", label: "文化体验" },
        { value: "food", label: "美食之旅" },
      ],
      // orientation: "horizontal",
    },
    {
      name: "interests",
      label: "感兴趣的活动",
      type: "checkbox",
      options: [
        { value: "hiking", label: "徒步" },
        { value: "diving", label: "潜水" },
        { value: "museum", label: "博物馆" },
        { value: "shopping", label: "购物" },
      ],
    },
    {
      name: "needGuide",
      label: "需要导游",
      type: "switch",
      defaultValue: false,
    },
    {
      name: "specialRequests",
      label: "特殊要求",
      type: "textarea",
      placeholder: "请告诉我们您的特殊需求...",
    },
  ],
};

export function DynamicFormAIScenario() {
  const [showSchema, setShowSchema] = useState(false);
  const [formData, setFormData] = useState<any>(null);

  // 生成给 AI 的 JSON Schema
  const jsonSchema = generateJsonSchema(aiGeneratedSchema.fields);

  return (
    <div className="space-y-4">
      <div className="rounded-md border p-4">
        <p className="mb-2 text-sm text-muted-foreground">
          💡 AI 场景:用户说「帮我规划一次旅行」,AI
          返回以下表单让用户填写详细信息
        </p>
        <Button
          variant="outline"
          size="sm"
          onClick={() => setShowSchema(!showSchema)}
        >
          {showSchema ? "隐藏" : "查看"} AI JSON Schema
        </Button>

        {showSchema && (
          <pre className="mt-4 overflow-auto rounded-md bg-muted p-4 text-xs">
            <code>{JSON.stringify(jsonSchema, null, 2)}</code>
          </pre>
        )}
      </div>

      <DynamicForm
        schema={aiGeneratedSchema}
        onFinish={(values) => {
          setFormData(values);
          console.log("用户提交的旅行计划:", values);
        }}
        onValuesChange={(values) => {
          console.log("表单值变化:", values);
        }}
      />

      {formData && (
        <div className="rounded-md border bg-muted/50 p-4">
          <h3 className="mb-2 font-semibold">提交的数据:</h3>
          <pre className="overflow-auto text-xs">
            <code>{JSON.stringify(formData, null, 2)}</code>
          </pre>
        </div>
      )}
    </div>
  );
}

设置额外元素

表单头部带额外信息,如状态标签。

订单信息待确认

填写订单详细信息

"use client";

import {
  DynamicForm,
  type FormSchema,
} from "@/components/composed/dynamic-form/dynamic-form";
import { StatusTag } from "@/components/composed/status-tag/status-tag";

const schema: FormSchema = {
  title: "订单信息",
  description: "填写订单详细信息",
  fields: [
    {
      name: "orderNumber",
      label: "订单编号",
      type: "input",
      placeholder: "自动生成",
      disabled: true,
      defaultValue: "ORD-2026-001",
    },
    {
      name: "customerName",
      label: "客户姓名",
      type: "input",
      required: true,
      placeholder: "请输入客户姓名",
    },
    {
      name: "product",
      label: "产品",
      type: "select",
      required: true,
      options: [
        { value: "laptop", label: "笔记本电脑" },
        { value: "phone", label: "智能手机" },
        { value: "tablet", label: "平板电脑" },
      ],
    },
    {
      name: "quantity",
      label: "数量",
      type: "number",
      defaultValue: 1,
      min: 1,
      max: 100,
    },
    {
      name: "urgent",
      label: "加急处理",
      type: "switch",
      defaultValue: false,
    },
    {
      name: "notes",
      label: "备注",
      type: "textarea",
      placeholder: "请输入备注信息",
    },
  ],
};

export function DynamicFormWithExtra() {
  return (
    <DynamicForm
      schema={schema}
      extra={<StatusTag status="pending" />}
      onFinish={(values) => {
        console.log("提交:", values);
      }}
    />
  );
}

自定义组件

通过 component 属性使用自定义表单组件。自定义组件必须具备 valueonChange 属性,这是受控组件的基本要求。

商品信息表单

展示多个自定义组件的使用

选择省份和城市(两个输入框组成的自定义组件)

¥
¥

设置商品的价格区间(包含最小值和最大值)

选择商品的主题颜色

"use client";

import * as React from "react";
import {
  DynamicForm,
  type FormSchema,
  type CustomFieldComponentProps,
} from "@/components/composed/dynamic-form/dynamic-form";
import { cn } from "@/lib/utils";

/**
 * 复杂自定义组件示例:地址选择器
 * 包含省份和城市两个输入框
 * 展示如何在一个自定义组件中管理多个输入字段
 */
interface AddressValue {
  province?: string;
  city?: string;
}

function AddressSelector({
  value,
  onChange,
  disabled,
  error,
}: CustomFieldComponentProps) {
  const addressValue = (value as AddressValue) || {};

  // 省份和城市数据
  const provinces = [
    { name: "广东省", cities: ["广州市", "深圳市", "珠海市", "东莞市"] },
    { name: "浙江省", cities: ["杭州市", "宁波市", "温州市", "绍兴市"] },
    { name: "江苏省", cities: ["南京市", "苏州市", "无锡市", "常州市"] },
    { name: "上海市", cities: ["黄浦区", "徐汇区", "长宁区", "静安区"] },
    { name: "北京市", cities: ["东城区", "西城区", "朝阳区", "海淀区"] },
  ];

  const [selectedProvince, setSelectedProvince] = React.useState<string>(
    addressValue.province || "",
  );
  const [selectedCity, setSelectedCity] = React.useState<string>(
    addressValue.city || "",
  );

  // 当省份或城市改变时,更新表单值
  React.useEffect(() => {
    onChange({
      province: selectedProvince,
      city: selectedCity,
    });
  }, [selectedProvince, selectedCity, onChange]);

  // 当省份改变时,重置城市
  const handleProvinceChange = (province: string) => {
    setSelectedProvince(province);
    setSelectedCity("");
  };

  const currentProvince = provinces.find((p) => p.name === selectedProvince);
  const cities = currentProvince ? currentProvince.cities : [];

  return (
    <div className="space-y-3">
      <div className="grid grid-cols-2 gap-3">
        {/* 省份选择 */}
        <div className="space-y-1.5">
          <label className="text-sm font-medium">省份</label>
          <select
            value={selectedProvince}
            onChange={(e) => handleProvinceChange(e.target.value)}
            disabled={disabled}
            className={cn(
              "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors",
              "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
              "disabled:cursor-not-allowed disabled:opacity-50",
              error && "border-destructive",
            )}
          >
            <option value="">请选择省份</option>
            {provinces.map((province) => (
              <option key={province.name} value={province.name}>
                {province.name}
              </option>
            ))}
          </select>
        </div>

        {/* 城市选择 */}
        <div className="space-y-1.5">
          <label className="text-sm font-medium">城市</label>
          <select
            value={selectedCity}
            onChange={(e) => setSelectedCity(e.target.value)}
            disabled={disabled || !selectedProvince}
            className={cn(
              "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors",
              "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
              "disabled:cursor-not-allowed disabled:opacity-50",
              error && "border-destructive",
            )}
          >
            <option value="">请选择城市</option>
            {cities.map((city) => (
              <option key={city} value={city}>
                {city}
              </option>
            ))}
          </select>
        </div>
      </div>

      {/* 显示当前选择 */}
      {selectedProvince && selectedCity && (
        <div className="text-sm text-muted-foreground">
          已选择:{selectedProvince} / {selectedCity}
        </div>
      )}
    </div>
  );
}

/**
 * 自定义价格范围输入组件
 * 包含最小值和最大值两个数字输入框
 */
interface PriceRangeValue {
  min?: number;
  max?: number;
}

function PriceRangeInput({
  value,
  onChange,
  disabled,
  error,
  field: _field,
}: CustomFieldComponentProps) {
  const rangeValue = (value as PriceRangeValue) || {};
  const [minValue, setMinValue] = React.useState<number | "">(
    rangeValue.min ?? "",
  );
  const [maxValue, setMaxValue] = React.useState<number | "">(
    rangeValue.max ?? "",
  );

  // 当 min 或 max 改变时,更新表单值
  React.useEffect(() => {
    onChange({
      min: minValue === "" ? undefined : minValue,
      max: maxValue === "" ? undefined : maxValue,
    });
  }, [minValue, maxValue, onChange]);

  return (
    <div className="space-y-2">
      <div className="grid grid-cols-2 gap-3">
        <div className="space-y-1.5">
          <label className="text-sm font-medium">最小值</label>
          <div className="relative">
            <span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
              ¥
            </span>
            <input
              type="number"
              value={minValue}
              onChange={(e) =>
                setMinValue(e.target.value === "" ? "" : Number(e.target.value))
              }
              disabled={disabled}
              placeholder="0"
              className={cn(
                "flex h-9 w-full rounded-md border border-input bg-transparent pl-7 pr-3 py-1 text-sm shadow-sm transition-colors",
                "placeholder:text-muted-foreground",
                "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
                "disabled:cursor-not-allowed disabled:opacity-50",
                error && "border-destructive",
              )}
            />
          </div>
        </div>

        <div className="space-y-1.5">
          <label className="text-sm font-medium">最大值</label>
          <div className="relative">
            <span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
              ¥
            </span>
            <input
              type="number"
              value={maxValue}
              onChange={(e) =>
                setMaxValue(e.target.value === "" ? "" : Number(e.target.value))
              }
              disabled={disabled}
              placeholder="不限"
              className={cn(
                "flex h-9 w-full rounded-md border border-input bg-transparent pl-7 pr-3 py-1 text-sm shadow-sm transition-colors",
                "placeholder:text-muted-foreground",
                "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
                "disabled:cursor-not-allowed disabled:opacity-50",
                error && "border-destructive",
              )}
            />
          </div>
        </div>
      </div>

      {/* 验证提示 */}
      {minValue !== "" && maxValue !== "" && minValue > maxValue && (
        <p className="text-sm text-destructive">最小值不能大于最大值</p>
      )}

      {/* 显示当前范围 */}
      {(minValue !== "" || maxValue !== "") && (
        <div className="text-sm text-muted-foreground">
          价格范围:
          {minValue !== "" ? `¥${minValue}` : "不限"} -{" "}
          {maxValue !== "" ? `¥${maxValue}` : "不限"}
        </div>
      )}
    </div>
  );
}

/**
 * 自定义颜色选择器组件
 * 必须实现 value 和 onChange 属性
 */
function ColorPicker({
  value,
  onChange,
  disabled,
  error,
}: CustomFieldComponentProps) {
  const colors = [
    { name: "红色", value: "#ef4444" },
    { name: "橙色", value: "#f97316" },
    { name: "黄色", value: "#eab308" },
    { name: "绿色", value: "#22c55e" },
    { name: "蓝色", value: "#3b82f6" },
    { name: "紫色", value: "#a855f7" },
    { name: "粉色", value: "#ec4899" },
    { name: "灰色", value: "#6b7280" },
  ];

  const currentValue = value as string | undefined;

  return (
    <div className="space-y-2">
      <div className="grid grid-cols-4 gap-2">
        {colors.map((color) => (
          <button
            key={color.value}
            type="button"
            disabled={disabled}
            onClick={() => onChange(color.value)}
            className={cn(
              "h-12 rounded-md border-2 transition-all hover:scale-105",
              "focus:outline-none focus:ring-2 focus:ring-offset-2",
              currentValue === color.value
                ? "border-primary ring-2 ring-primary ring-offset-2"
                : "border-transparent",
              disabled && "opacity-50 cursor-not-allowed",
              error && "border-destructive",
            )}
            style={{ backgroundColor: color.value }}
            title={color.name}
          />
        ))}
      </div>
      {currentValue && (
        <div className="flex items-center gap-2 text-sm text-muted-foreground">
          <div
            className="h-4 w-4 rounded border"
            style={{ backgroundColor: currentValue }}
          />
          <span>{currentValue}</span>
        </div>
      )}
    </div>
  );
}

const schema: FormSchema = {
  title: "商品信息表单",
  description: "展示多个自定义组件的使用",
  fields: [
    {
      name: "productName",
      label: "商品名称",
      type: "input",
      required: true,
      placeholder: "请输入商品名称",
    },
    {
      name: "address",
      label: "发货地址",
      type: "input", // type 可以是任意值,因为使用了 component
      required: true,
      component: AddressSelector,
      description: "选择省份和城市(两个输入框组成的自定义组件)",
      defaultValue: {},
    },
    {
      name: "priceRange",
      label: "价格范围",
      type: "input",
      component: PriceRangeInput,
      description: "设置商品的价格区间(包含最小值和最大值)",
      defaultValue: {},
    },
    {
      name: "color",
      label: "主题颜色",
      type: "input",
      component: ColorPicker,
      description: "选择商品的主题颜色",
    },
    {
      name: "category",
      label: "商品分类",
      type: "select",
      required: true,
      options: [
        { value: "electronics", label: "电子产品" },
        { value: "clothing", label: "服装" },
        { value: "food", label: "食品" },
        { value: "books", label: "图书" },
      ],
    },
    {
      name: "description",
      label: "商品描述",
      type: "textarea",
      placeholder: "请输入商品描述...",
    },
  ],
};

export function DynamicFormCustomComponent() {
  return (
    <DynamicForm
      schema={schema}
      onFinish={(values) => {
        console.log("提交的数据:", values);
        alert(JSON.stringify(values, null, 2));
      }}
    />
  );
}

自定义组件示例

以上示例展示了三个不同复杂度的自定义组件:

  1. 地址选择器(AddressSelector):包含省份和城市两个下拉框,展示如何在一个组件中管理多个输入字段
  2. 价格范围输入(PriceRangeInput):包含最小值和最大值两个数字输入框,展示如何处理复杂的数据结构
  3. 颜色选择器(ColorPicker):简单的颜色选择组件,展示基础的自定义组件实现

自定义组件要求

当使用 component 属性时,您的自定义组件需要遵循以下规范:

必需属性:

  • value: 字段的当前值,组件应根据此值显示状态
  • onChange: 值变更处理函数,当用户交互改变值时调用此函数

可选属性:

  • onBlur: 字段失焦处理函数,用于触发验证
  • error: 字段错误信息(FieldError 类型),可用于显示错误状态
  • disabled: 是否禁用组件
  • field: 完整的字段配置对象,可访问其他配置信息(如 placeholderdescription 等)

复杂组件示例:地址选择器

下面是一个完整的自定义组件示例,展示如何创建包含多个输入框的组件:

import type { CustomFieldComponentProps } from "@/registry/wuhan/composed/dynamic-form";

interface AddressValue {
  province?: string;
  city?: string;
}

function AddressSelector({ value, onChange, disabled, error }: CustomFieldComponentProps) {
  const addressValue = (value as AddressValue) || {};
  const [selectedProvince, setSelectedProvince] = useState(addressValue.province || "");
  const [selectedCity, setSelectedCity] = useState(addressValue.city || "");

  // 当省份或城市改变时,更新表单值
  useEffect(() => {
    onChange({
      province: selectedProvince,
      city: selectedCity,
    });
  }, [selectedProvince, selectedCity, onChange]);

  return (
    <div className="grid grid-cols-2 gap-3">
      <select
        value={selectedProvince}
        onChange={(e) => setSelectedProvince(e.target.value)}
        disabled={disabled}
      >
        <option value="">请选择省份</option>
        {/* ... 省份选项 */}
      </select>
      
      <select
        value={selectedCity}
        onChange={(e) => setSelectedCity(e.target.value)}
        disabled={disabled || !selectedProvince}
      >
        <option value="">请选择城市</option>
        {/* ... 城市选项 */}
      </select>
    </div>
  );
}

// 在 schema 中使用
const schema = {
  fields: [
    {
      name: "address",
      label: "地址",
      type: "input",
      component: AddressSelector,
      defaultValue: {},
    },
  ],
};

这种设计遵循 React 受控组件的标准模式,确保表单状态的一致性和可预测性。您可以创建任何符合此接口的自定义组件,如颜色选择器、标签输入、富文本编辑器等。

API

DynamicForm

主表单组件,封装了表单状态管理和验证逻辑。

Props

PropTypeDefaultDescription
schemaFormSchema-表单配置 Schema(必填)
classNamestring-自定义样式类名
styleReact.CSSProperties-内联样式
initialValuesRecord<string, any>{}表单初始值
onValuesChange(values) => void-表单值变化回调
onFinish(values) => void-表单提交成功回调
onFinishFailed(errorInfo) => void-表单提交失败回调
validateSchemaz.ZodType<any>-Zod 验证 Schema
readonlybooleanfalse是否只读模式
status"pending" | "confirmed"-状态,只支持 pending 和 confirmed,confirmed 状态下自动设置为只读并隐藏 Footer
showActionsbooleantrue是否显示操作按钮
submitTextstring"提交"提交按钮文本
resetTextstring"重置"重置按钮文本
showTitlebooleantrue是否显示表单标题
extraReact.ReactNode-表单头部额外信息(如状态标签)

Ref Methods

通过 ref 可以访问以下实例方法:

MethodTypeDescription
getFieldsError(nameList?: string[]) => FieldErrorInfo[]获取字段错误信息
getFieldsValue(nameList?: string[]) => Record<string, any>获取字段值
validateFields(nameList?: string[]) => Promise<Record<string, any>>触发表单验证
setFields(fields: Array<{name, value?, errors?}>) => void设置字段状态
resetFields(nameList?: string[]) => void重置字段到初始值
submit() => Promise<void>提交表单
getForm() => UseFormReturn<any>获取 react-hook-form 实例

Example

import { useRef } from "react";
import { DynamicForm, type DynamicFormRef } from "@/registry/wuhan/composed/dynamic-form";

function FormWithMethods() {
  const formRef = useRef<DynamicFormRef>(null);

  const handleGetValues = () => {
    const values = formRef.current?.getFieldsValue();
    console.log(values);
  };

  const handleValidate = async () => {
    try {
      const values = await formRef.current?.validateFields();
      console.log("验证通过:", values);
    } catch (error) {
      console.error("验证失败");
    }
  };

  return (
    <div>
      <DynamicForm ref={formRef} schema={schema} showActions={false} />
      <button onClick={handleGetValues}>获取值</button>
      <button onClick={handleValidate}>验证</button>
    </div>
  );
}

FormSchema

表单配置 Schema 的结构定义。

PropertyTypeDescription
titlestring表单标题
descriptionstring表单描述
fieldsFieldSchema[]字段配置列表(必填)

FieldSchema

单个字段的配置结构。

PropertyTypeDescription
namestring字段名(必填,唯一)
labelstring字段标签(必填)
typeFieldType字段类型(必填)
descriptionstring字段描述
placeholderstring占位符
defaultValueany默认值
requiredboolean是否必填
disabledboolean是否禁用
optionsFieldOption[]选项列表(select、radio 必需)
orientation'vertical' | 'horizontal' | 'responsive'布局方向
minnumber最小值(number 类型)
maxnumber最大值(number 类型)
stepnumber步进值(number 类型)
range{min, max, step?}范围配置(slider 类型)
componentReact.ComponentType<CustomFieldComponentProps>自定义组件(需要具备 value 和 onChange 属性)
render(props) => ReactNode自定义渲染函数(优先级高于 component)

FieldType

支持的字段类型。

TypeDescriptionRequired Props
input单行文本输入-
textarea多行文本输入-
select下拉选择options
radio单选按钮组options
checkbox复选框-
switch开关-
slider滑块range
number数字输入-

FieldOption

选择类字段的选项配置。

PropertyTypeDescription
valuestring | number | boolean选项值
labelstring选项显示文本
disabledboolean是否禁用该选项

CustomFieldComponentProps

自定义组件必须实现的属性接口。

PropertyTypeRequiredDescription
valueunknown字段的当前值
onChange(value: unknown) => void值变更处理函数
onBlur() => void字段失焦处理函数
errorFieldError字段错误信息
disabledboolean是否禁用
fieldFieldSchema完整的字段配置对象

简单示例(单个输入):

import type { CustomFieldComponentProps } from "@/registry/wuhan/composed/dynamic-form";

function CustomRating({ value, onChange, disabled }: CustomFieldComponentProps) {
  const rating = (value as number) || 0;
  
  return (
    <div className="flex gap-1">
      {[1, 2, 3, 4, 5].map((star) => (
        <button
          key={star}
          type="button"
          disabled={disabled}
          onClick={() => onChange(star)}
          className={star <= rating ? "text-yellow-500" : "text-gray-300"}
        >

        </button>
      ))}
    </div>
  );
}

复杂示例(多个输入):

import { useState, useEffect } from "react";
import type { CustomFieldComponentProps } from "@/registry/wuhan/composed/dynamic-form";

interface DateRangeValue {
  start?: string;
  end?: string;
}

function DateRangePicker({ value, onChange, disabled }: CustomFieldComponentProps) {
  const rangeValue = (value as DateRangeValue) || {};
  const [start, setStart] = useState(rangeValue.start || "");
  const [end, setEnd] = useState(rangeValue.end || "");

  // 当任一日期改变时,更新表单值
  useEffect(() => {
    onChange({ start, end });
  }, [start, end, onChange]);

  return (
    <div className="flex gap-2">
      <input
        type="date"
        value={start}
        onChange={(e) => setStart(e.target.value)}
        disabled={disabled}
      />
      <span>至</span>
      <input
        type="date"
        value={end}
        onChange={(e) => setEnd(e.target.value)}
        disabled={disabled}
      />
    </div>
  );
}
          className={star <= rating ? "text-yellow-500" : "text-gray-300"}
        >

        </button>
      ))}
    </div>
  );
}

工具函数

generateJsonSchema

根据字段配置生成 AI 兼容的 JSON Schema。

import { generateJsonSchema } from "@/registry/wuhan/composed/dynamic-form-utils";

const jsonSchema = generateJsonSchema(schema.fields);
// 将 jsonSchema 提供给 AI,让 AI 按照这个规范输出

extractDefaultValues

从字段配置中提取默认值。

import { extractDefaultValues } from "@/registry/wuhan/composed/dynamic-form-utils";

const defaultValues = extractDefaultValues(schema.fields);

getDisplayLabel

获取只读模式下的显示标签。

import { getDisplayLabel } from "@/registry/wuhan/composed/dynamic-form-utils";

const displayText = getDisplayLabel(value, field);
// 对于 select: 返回对应的 label
// 对于 checkbox/switch: 返回 "是" 或 "否"

使用场景

  • AI 对话:根据 AI 返回的配置动态生成表单
  • 动态配置:后台配置化的表单生成
  • 数据录入:快速构建各类数据录入表单
  • 表单预览:只读模式展示已填写的表单数据
  • 表单验证:需要复杂验证规则的表单场景

最佳实践

  1. 表单验证:使用 Zod Schema 定义验证规则,确保类型安全
  2. 初始值:合理设置 defaultValueinitialValues,避免非受控组件警告
  3. 只读模式:用于数据展示时启用 readonly,提升用户体验
  4. 实例方法:复杂交互场景下使用 ref 访问实例方法
  5. 自定义组件
    • 确保自定义组件实现 valueonChange 属性(受控组件模式)
    • 使用 TypeScript 时,导入 CustomFieldComponentProps 类型确保类型安全
    • 优先使用 component 而非 render,除非需要完全自定义布局
    • 在自定义组件中适当处理 errordisabled 状态
  6. 自定义渲染:对于需要完全控制布局的字段,使用 render 函数(优先级高于 component

注意事项

  • 确保字段的 name 在同一表单中唯一
  • 选择类字段(select、radio)必须提供 options 属性
  • 使用 validateSchema 时,字段名需要与 Schema 中定义的字段名一致
  • 只读模式下,表单不会进行验证,因此可以隐藏操作按钮
  • 对于 select 类型在只读模式下,确保 options 中包含当前值对应的选项
  • 自定义组件必须遵循受控组件模式(controlled component pattern),即通过 value 接收值,通过 onChange 通知变更

原语组件

Dynamic Form 基于以下原语组件构建:

  • DynamicFormLayoutPrimitive - 表单布局容器
  • DynamicFormHeaderPrimitive - 表单头部
  • DynamicFormTitlePrimitive - 表单标题
  • DynamicFormBodyLayout - 表单主体布局
  • DynamicFormFooterPrimitive - 表单底部操作栏

这些原语可以在 registry/wuhan/blocks/dynamic-form/dynamic-form-01.tsx 中找到。