vue3高级表单组件

1,472 阅读6分钟

DynamicForm 动态表单组件

DynamicForm 是一个基于 ant-design-vue 的动态表单组件,用于快速构建和管理复杂的表单界面。它通过配置化的方式来生成表单,大大减少了表单开发的重复工作。

特性

  • 🚀 配置驱动:通过简单的 JSON 配置即可生成完整表单
  • 🎨 支持多种表单项:内置支持 Input、Select、Radio、Checkbox、DatePicker 等多种表单控件
  • 🔄 表单联动:支持表单项之间的联动显示和校验
  • 📦 灵活扩展:支持自定义表单项和插槽
  • 🎯 校验集成:内置表单验证功能,支持自定义校验规则
  • 💫 动态渲染:支持动态更新表单配置和选项

适用场景

  • 需要快速构建表单的场景
  • 表单结构经常变动的场景
  • 需要处理复杂表单联动的场景
  • 需要统一表单交互和样式的场景

Props

参数说明类型必填
value表单数据Record<string, any>
config表单配置DynamicFormConfig
formProps表单配置FormProps

config

参数说明类型必填
formItems表单项配置FormItemConfig[]
formProps表单配置FormProps

FormItems 配置说明

formItems 是一个数组,每个元素都是一个表单项配置对象,具有以下属性:

基础属性

  • key: string - 表单项的唯一标识,也是表单数据的字段名
  • label: string | VNode - 表单项标签文本,如需要自定义label,可以使用VNode,示例:h( Tooltip, { title: '爱好', }, () => [h(QuestionCircleOutlined), ' 爱好'] )
  • type: string - 表单项类型,支持以下类型:
    • 'input' - 输入框
    • 'select' - 下拉选择框
    • 'radio' - 单选框组
    • 'checkbox' - 复选框组
    • 'datePicker' - 日期选择器
    • 'timePicker' - 时间选择器
    • 'inputNumber' - 数字输入框
    • 'switch' - 开关
    • 'slot' - 自定义插槽

可选属性

  • tips: string | TipsConfig - 表单项后提示,区别于 formItemProps.tooltip(在label后)
  • rules: RuleObject[] - 校验规则数组,遵循 ant-design-vue Form 的规则配置
  • visible: boolean | ((formData: IFormData) => boolean) - 控制表单项是否显示
  • options: { label: string; value: any; }[] - 用于 select/radio/checkbox 的选项列表
  • props: object - 传递给表单控件的属性
  • formItemProps: object - 传递给 Form.Item 的属性

示例配置

const formItems = [
  {
    key: 'username',
    label: '用户名',
    type: 'input',
    rules: [
      { required: true, message: '请输入用户名' }
    ],
    props: {
      placeholder: '请输入用户名'
    }
  },
  {
    key: 'gender',
    label: '性别',
    type: 'radio',
    options: [
      { label: '男', value: 'male' },
      { label: '女', value: 'female' }
    ]
  },
  {
    key: 'birthday',
    label: '生日',
    type: 'datePicker',
    props: {
      format: 'YYYY-MM-DD'
    }
  },
  {
    key: 'customField',
    label: '自定义字段',
    type: 'slot',
    visible: (formData) => formData.gender === 'male'
  }
]

特殊说明

  1. 当 type 为 'slot' 时,需要在模板中提供对应的具名插槽
  2. visible 函数可以根据表单数据动态控制表单项的显示/隐藏
  3. datePicker 类型会自动添加一些默认配置,可通过 props 覆盖
  4. select/radio/checkbox 类型必须提供 options 配置

Events

事件名说明回调参数
update:modelValue表单数据更新时触发(value: Record<string, any>) => void

表单方法

方法名说明回调参数
validate校验表单(nameList?: string[]) => Promise
validateFields校验表单(nameList?: string[]) => Promise
clearValidate清除校验(nameList?: string[]) => void
resetFields重置表单() => void
scrollToField滚动到指定表单项(name: string) => void

使用方法

<template>
  <DynamicForm
    :value="formData"
    :config="formConfig"
  />
</template>

<script setup lang="ts">
import { ref } from 'vue'
import type { DynamicFormConfig } from '@/components/DynamicForm/types';

const formData = ref({})

const formConfig: DynamicFormConfig = {
  formItems: [
    {
      key: 'name',
      label: '姓名',
      type: 'input',
      rules: [{ required: true, message: '请输入姓名' }]
    },
    {
      key: 'gender',
      label: '性别',
      type: 'radio',
      options: [
        { label: '男', value: 'male' },
        { label: '女', value: 'female' }
      ]
    }
  ],
  formProps: {
    layout: 'horizontal',
  }
}
</script>

动态设置下拉选项

<template>
  <DynamicForm :config="formConfig" :value="formData" ref="dynamicFormRef">
</template>

<script setup lang="ts">
import { ref } from 'vue';
import type { DynamicFormConfig } from '@/components/DynamicForm/types';

const formData = ref({});
export const hobbyOptions = ref<OptionItem[]>([]); // 动态设置下拉选项,适用后端接口返回数据的情况
const formConfig: DynamicFormConfig = {
  formItems: [
    {
      key: 'hobby',
      label: '爱好',
      type: 'select',
      options: hobbyOptions,
    },
  ],
};
const setHobbyOptions = () => {
  hobbyOptions.value = [
    { label: '篮球', value: 'basketball' },
    { label: '足球', value: 'football' },
    { label: '羽毛球', value: 'badminton' },
  ];
};
</script>

动态设置表单项是否显示

通过表单项的visible属性控制表单项的显示隐藏,提供2种方式,visible可以是一个布尔值,也可以是一个函数,函数返回布尔值

<template>
  <DynamicForm :config="formConfig" :value="formData" ref="dynamicFormRef">
</template>

<script setup lang="ts">
import { ref } from 'vue';
import type { DynamicFormConfig } from '@/components/DynamicForm/types';

const formData = ref({});
const hobbyVisible = ref(false) //通过非表单项值控制显示隐藏,手动设置hobbyVisible的值即可
const formConfig: DynamicFormConfig = {
  formItems: [
    {
      key: 'age',
      label: '年龄',
      type: 'select',
      options: [
        {
          label: '10',
          value: 10,
        },
        {
          label: '11',
          value: 11,
        },
      ],
    },
    {
      key: 'name',
      label: '姓名',
      type: 'input',
      visible: (data) => data.age === 10, //通过表单项控制
    },
    {
      ke y: 'hobby',
      label: '爱好',
      type: 'input',
      visible: ()=>{
        return hobbyVisible.value
      },
    },
  ],
};
</script>

插槽

通过插槽可以自定义表单项,插槽的值是一个函数,函数返回一个VNode,插槽的值可以是一个VNode,也可以是一个函数,函数返回一个VNode

<template>
  <DynamicForm :config="formConfig" :value="formData" ref="dynamicFormRef">
    <template #rate>
        <Button type="primary">插槽</Button>
    </template>
  </DynamicForm>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import type { FormChangeValue } from '@/components/DynamicForm/types';

const formData = ref({});
const formConfig: DynamicFormConfig = {
  formItems: [
    {
      key: 'rate',
      label: '插槽',
      type: 'slot',
    },
  ],
};
</script>

一行多个表单项的情况

这个例子演示了一个表单中包含多个表单控件的情况。

<template>
  <DynamicForm :config="formConfig" :value="formData" ref="dynamicFormRef">
    <template #space>
      <a-space>
        <a-form-item name="area" :rules="[{ required: true, message: '请输入area' }]">
          <a-input v-model:value="formData.area" placeholder="请输入area" />
        </a-form-item>
        <a-form-item name="price" :rules="[{ required: true, message: '请输入price' }]">
          <a-input v-model:value="formData.price" />
        </a-form-item>
      </a-space>
    </template>
  </DynamicForm>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import type { FormChangeValue } from '@/components/DynamicForm/types';
const formData = ref<IFormData & { rate: number; area: string; price: string }>({
  name: '张三',
  rate: 3,
  area: '',
  price: '456',
});
const formConfig: DynamicFormConfig = {
  formItems: [
    {
      key: 'space',
      label: '一行多个',
      type: 'slot',
    },
  ],
};
</script>

表单项后展示tips

  • 直接显示文本
const formConfig: DynamicFormConfig = {
  formItems: [
    {
      key: 'area',
      label: 'area',
      type: 'input',
      tips: '请输入area', // 表单项后展示tips
    },
  ],
};
  • 显示图标,hover展示提示
const formConfig: DynamicFormConfig = {
  formItems: [
    {
      key: 'area',
      label: 'area',
      type: 'input',
      tips:  {
        withIcon: true,
        text: '请输入area',
      }, 
    },
  ],
};

表单onChange

表单会emit出来onChange事件,可以监听这个事件,来获取表单数据的变化

<template>
  <DynamicForm :config="formConfig" :value="formData" ref="dynamicFormRef" onChange='onChangeForm' />
</template>

<script setup lang="ts">
import { ref } from 'vue';
import type { DynamicFormConfig, FormChangeValue } from '@/components/DynamicForm/types';
const onChangeForm = ({key,value,formValues}: FormChangeValue) => {
  console.log('----val----36', key, value,formValues);
};
</script>

tips

  • 如果不想显示label,可以设置 formItemProps.noStyle 为true
const formConfig: DynamicFormConfig = {
 formItems: [
   {
     key: 'rate',
     label: '插槽',
     type: 'slot',
     formItemProps:{
       noStyle:true //隐藏label
     }
   },
 ],
};

源码:

<script lang="ts" setup>
import { ref, computed, unref, reactive, toRaw, defineEmits } from 'vue';
import {
  Input,
  Select,
  RadioGroup,
  CheckboxGroup,
  DatePicker,
  TimePicker,
  InputNumber,
  Switch,
  Cascader,
  Form,
  TreeSelect,
  Tooltip,
} from 'ant-design-vue';
import type { DynamicFormConfig, FormItemConfig, IFormData, FormChangeValue } from './types';
import type { FormInstance } from 'ant-design-vue';
const emit = defineEmits<{
  change: [value: FormChangeValue];
}>();

const formRef = ref<FormInstance>();

const props = defineProps<{
  config: DynamicFormConfig;
  value: IFormData;
}>();

const modelValue = reactive<IFormData>(toRaw(props.value));

// 渲染表单项
const renderFormItem = (item: FormItemConfig) => {
  // 根据type渲染不同的组件
  const component = {
    input: Input,
    select: Select,
    radio: RadioGroup,
    checkbox: CheckboxGroup,
    datePicker: DatePicker,
    timePicker: TimePicker,
    inputNumber: InputNumber,
    switch: Switch,
    cascader: Cascader,
    treeSelect: TreeSelect,
  };
  // 添加事件
  item.props = {
    ...item.props,
    onChange: () => {
      emit('change', { key: item.key, value: modelValue[item.key], formValues: toRaw(modelValue) });
    },
  };

  if (['input', 'inputNumber'].includes(item.type)) {
    item.props = {
      placeholder: '请输入',
      ...item.props,
    };
  }

  //  设置options
  if (['checkbox', 'select', 'radio', 'cascader'].includes(item.type)) {
    item.props = {
      placeholder: '请选择',
      ...item.props,
      options: unref(item.options) || [],
    };
  }
  if (item.type === 'treeSelect') {
    item.props = {
      placeholder: '请选择',
      ...item.props,
      treeData: unref(item.options) || [],
    };
  }

  // 对于 DatePicker,我们可以添加一些默认配置
  if (item.type === 'datePicker') {
    item.props = {
      allowClear: true,
      format: 'YYYY-MM-DD',
      valueFormat: 'YYYY-MM-DD',
      ...item.props,
    };
  }

  return component[item.type as keyof typeof component];
};

//  校验规则
const rulesRef = computed(() => {
  const rules: Record<string, NonNullable<FormItemConfig['rules']>> = {};
  props.config.formItems.forEach((item) => {
    if (item.rules && Array.isArray(item.rules) && item.rules.length > 0) {
      rules[item.key] = item.rules;
    }
  });
  return rules;
});
// 展示的表单项
const visibleItems = computed(() => {
  return props.config.formItems.filter((item) => {
    if (item.visible === false) return false;
    if (typeof item.visible === 'function') {
      return (item.visible as (data: IFormData) => boolean)(modelValue);
    }
    return true;
  });
});

// 定义表单方法
const formMethods = {
  validate: () => formRef.value?.validate(),
  validateFields: (nameList?: string[]) => formRef.value?.validateFields(nameList),
  clearValidate: (nameList?: string[]) => formRef.value?.clearValidate(nameList),
  resetFields: () => formRef.value?.resetFields(),
  scrollToField: (name: string) => formRef.value?.scrollToField(name),
};

// 校验表单
defineExpose(formMethods);
</script>

<template>
  <div class="dynamic-form">
    <Form v-bind="props.config.formProps" ref="formRef" :model="modelValue" :rules="rulesRef">
      <Form.Item
        v-for="item in visibleItems"
        :key="item.key"
        :label="item.label"
        :name="item.key"
        :rules="item.rules"
        v-bind="item.formItemProps"
      >
        <template v-if="item.type === 'slot'">
          <slot :name="item.key" :data="modelValue" :item="item" />
        </template>
        <div v-else class="form-item-content">
          <component
            class="__component"
            :is="renderFormItem(item)"
            v-model:value="modelValue[item.key]"
            v-bind="{ ...item.props }"
          />
          <div class="__right">
            <span v-if="item.tips && typeof item.tips === 'string'" class="tips">{{ item.tips }}</span>
            <Tooltip
              class="tips"
              v-else-if="item.tips && typeof item.tips === 'object' && item.tips.withIcon"
              :title="item.tips.text"
            >
              <QuestionCircleOutlined />
            </Tooltip>
          </div>
        </div>
      </Form.Item>
    </Form>
  </div>
</template>

<style scoped lang="scss">
.dynamic-form {
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  .form-item-content {
    width: 100%;
    display: flex;
    align-items: center;
    gap: 10px;
    .__left {
      max-width: 600px;
    }
    .__component {
      width: 80%;
      min-width: 200px;
    }
    .__right {
      flex-shrink: 0;
      flex: 1;
      .tips {
        color: #999;
      }
    }
  }
}
</style>