likes
comments
collection
share

Vue3 Element-Plus 一站式生成动态表单

作者站长头像
站长
· 阅读数 11

背景

经常开发管理系统的小伙伴们肯定或多或少都遇到过表单需求,对于一个系统而言,动辄就是十几,几十个表单;如果每个表单都按照传统模式编写的话,简直要把前端累死,看着一段段大同小异的代码,也是提不上一点劲,甚至看着这些它懂你,你不想懂它的代码就犯恶心。 本着偷懒的精神,我就想能否封装一个动态表单,实现思路大致就是通过JSON配置,动态生成表单页面,于是说干就干,咱玩的就是真实对吧。开撸,开撸.....

Vue3 Element-Plus 一站式生成动态表单

项目地址:github地址

数据接口设计

废话不多说,代码敬上

咋眼一看,代码有点多哈,别着急,注释已安排上。

type TreeItem = {
  value: string
  label: string
  children?: TreeItem[]
}

export type FormListItem = {
  // 栅格占据的列数
  colSpan?: number 
  // 表单元素特有的属性
  props?: {  
    placeholder?: string
    defaultValue?: unknown // 绑定的默认值
    clearable?: boolean
    disabled?: boolean | ((data: { [key: string]: any }) => boolean)
    size?: 'large' | 'default' | 'small'
    group?: unknown // 父级特有属性,针对嵌套组件 Select、Checkbox、Radio
    child?: unknown // 子级特有属性,针对嵌套组件 Select、Checkbox、Radio
    [key: string]: unknown
  } 
  // 表单元素特有的插槽
  slots?: {  
    name: string
    content: unknown
  }[] 
  // 组件类型
  typeName?: 'input' | 'select' | 'date-picker' | 'time-picker' | 'switch' | 'checkbox' | 'checkbox-group' | 'checkbox-button' | 'radio-group' | 'radio-button' | 'input-number' | 'tree-select' | 'upload' | 'slider' 
  // 表单元素特有的样式
  styles?: {
    [key: string]: number | string
  } 
  // select options 替换字段
  replaceField?: { value: string; label: string } 
  // 列表项
  options?: {
    value?: string | number | boolean | object
    label?: string | number
    disabled?: ((data: { [key: string]: any }) => boolean) | boolean
    [key: string]: unknown
  }[]
  // <el-form-item> 独有属性,同 FormItem Attributes
  formItem: Partial<FormItemProps & { class: string }>
  // 嵌套<el-form-item>
  children?: FormListItem[]
  // 树形选择器数据
  treeData?: TreeItem[] // 只针对 'tree-select'组件
  // 组件显示条件
  isShow?: ((data: { [key: string]: any }) => boolean) | boolean
}

export type FConfig = {
  form: Partial<InstanceType<typeof ElForm>> // Form Attributes 与Element属性一致
  configs: FormListItem[]  // 表单主体配置
}

常见表单需求

  • 如何控制某个组件的显示隐藏

实现思路,提供一个isShow方法,将方法绑定在对应的组件上,从而组件显示隐藏条件

isShow: (data = {}) => {
  return model.value.region == 'shanghai'
}
....
<el-form-item v-if="isShow(model)" v-bind="item.formItem">
  • 目标组件是否禁用,需要根据某个组件是否有值来判断
disabled: (data = {}) => {
    return !model.value.date1
}
....
<component :disabled="disabled(model)"></component>
  • 组件之间相互赋值,A组件的值赋值给B组件B组件的值赋值给 A组件

Vue3 Element-Plus 一站式生成动态表单

Vue3 Element-Plus 一站式生成动态表单

  • 表单验证
formItem: {
  prop: 'name',
  label: 'Activity name',
  rules: [
    {
      required: true,
      message: 'Please enter content',
      trigger: 'blur'
    }
  ]
}

组件封装

1. 输入框组件

<template>
  <el-input v-bind="attrs.props"
            ref="elInputRef"
            :style="attrs.styles">
    <template v-for="item in attrs.slots"
              #[item.name]
              :key="item.name">
      <component :is="item.content"></component>
    </template>
  </el-input>
</template>

2. 下拉选择器组件

<template>
  <el-select v-bind="attrs.props?.group"
             ref="elSelectRef"
             :style="attrs.styles">
    <el-option v-for="item in attrs.options"
               v-bind="attrs.props?.child"
               :key="item[attrs.replaceField?.value || 'value']"
               :label="item[attrs.replaceField?.label || 'label']"
               :value="item[attrs.replaceField?.value || 'value']"
               :disabled="item.disabled"></el-option>
  </el-select>
</template>

3. 日期选择器组件

<template>
  <el-date-picker v-bind="attrs.props"
                  ref="elDatePickerRef"
                  :style="attrs.styles"></el-date-picker>
</template>

封装方法都一致,还有很多组件,这里就不一个个列出来,具体大家就移步源码查看哈

项目路径 src/components/Form

组件整合

<template>
  <el-form v-bind="props.form"
           ref="formRef"
           :model="model">
    <el-row :gutter="20">
      <el-col v-for="item in props.configs"
              :key="item.formItem.prop"
              :span="item.colSpan">
        <el-form-item v-if="ifShow(item, model)"
                      v-bind="item.formItem">
          <template v-if="item.typeName == 'upload'">
            <el-upload v-bind="item.props">
              <template v-for="it in item.slots"
                        #[it.name]
                        :key="it.name">
                <component :is="it.content"></component>
              </template>
            </el-upload>
          </template>

          <template v-if="!item.children?.length">
            <component :is="components[`m-${item.typeName}`]"
                       v-bind="item"
                       v-model="model[item.formItem.prop as string]"
                       :form-data="model"
                       :disabled="ifDisabled(item, model)"></component>
          </template>

          <template v-else>
            <el-col v-for="(child, index) in item.children"
                    :key="index"
                    :span="child.colSpan">
              <el-form-item v-bind="child.formItem">
                <component :is="components[`m-${child.typeName}`]"
                           v-bind="child"
                           v-model="model[child.formItem.prop as string]"
                           :form-data="model"
                           :disabled="ifDisabled(child, model)"></component>
              </el-form-item>
            </el-col>
          </template>
        </el-form-item>
      </el-col>
    </el-row>
  </el-form>
</template>

<script setup lang="ts">
import cloneDeep from 'lodash/cloneDeep'
import { ref, onMounted, watch, computed } from 'vue'
import { getType } from '@/utils/util'
import type { ElForm, FormInstance } from 'element-plus'
import { FormListItem, FConfig } from './form'

import mInput from './components/m-input.vue'
import mSelect from './components/m-select.vue'
import mDatePicker from './components/m-date-picker.vue'
import mTimePicker from './components/m-time-picker.vue'
import mSwitch from './components/m-switch.vue'
import mCheckbox from './components/m-checkbox.vue'
import mCheckboxGroup from './components/m-checkbox-group.vue'
import mCheckboxButton from './components/m-checkbox-button.vue'
import mRadioGroup from './components/m-radio-group.vue'
import mRadioButton from './components/m-radio-button.vue'
import mInputNumber from './components/m-input-number.vue'
import mTreeSelect from './components/m-tree-select.vue'
import mSlider from './components/m-slider.vue'

type Props = FConfig & {
  data: { [key: string]: any }
}
const emits = defineEmits(['update:data'])
const props = withDefaults(defineProps<Props>(), {})
const model = ref<{ [key: string]: any }>({})
const formRef = ref<FormInstance | null>()
const components: { [key: string]: any } = {
  'm-input': mInput,
  'm-select': mSelect,
  'm-date-picker': mDatePicker,
  'm-time-picker': mTimePicker,
  'm-switch': mSwitch,
  'm-checkbox': mCheckbox,
  'm-checkbox-group': mCheckboxGroup,
  'm-checkbox-button': mCheckboxButton,
  'm-radio-group': mRadioGroup,
  'm-radio-button': mRadioButton,
  'm-input-number': mInputNumber,
  'm-tree-select': mTreeSelect,
  'm-slider': mSlider
}

const ifDisabled = computed(() => {
  return (column: FormListItem, model: { [key: string]: any }) => {
    let disabled = column.props?.disabled
    switch (getType(disabled)) {
      case 'function':
        disabled = (disabled as any)(model)
        break
      case 'undefined':
        disabled = false
    }
    return disabled
  }
})

const ifShow = (column: FormListItem, model: { [key: string]: any }) => {
  let flag = column.isShow
  switch (getType(flag)) {
    case 'function':
      flag = (flag as any)(model)
      break
    case 'undefined':
      flag = true
      break
  }
  return flag
}

// 组件重写表单重置的方法
const resetFields = () => {
  // 重置element-plus 的表单
  formRef.value?.resetFields()
}

// 表单验证
const validate = () => {
  return new Promise((resolve, reject) => {
    formRef.value?.validate((valid) => {
      if (valid) {
        resolve(true)
      } else {
        reject(false)
      }
    })
  })
}

const getFormData = () => {
  return model.value
}

watch(
  () => model.value,
  (val) => {
    emits('update:data', val)
  }
)

watch(
  () => props.data,
  (val) => {
    model.value = val
  },
  {
    immediate: true
  }
)

defineExpose({
  resetFields,
  getFormData,
  validate
})
</script>

<style scoped></style>


抽离Form公共逻辑

// hooks/useForm.ts
import { ref } from 'vue'
import type { FConfig } from '@/components/Form/form'
import { cloneDeep } from 'lodash'

type SelectOption = {
  value?: string | number | boolean | object
  label?: string | number
  disabled?: ((data: { [key: string]: any }) => boolean) | boolean
  [key: string]: unknown
}[]

type TreeItem = {
  value: string
  label: string
  children?: TreeItem[]
}

export const useForm = (formConfig: FConfig) => {
  const model = ref<{ [key: string]: any }>({})
  const config = ref<FConfig>(formConfig)

  const getFormItem = (key: string) => {
    return config['value']?.configs.find((item) => item.formItem.prop == key)
  }
  /**
   * 修改select组件options
   * @param key 对应formItem prop
   * @param options 下拉选项
   */
  const changeSelectOptions = (key: string, options: SelectOption) => {
    const formItem = getFormItem(key)
    if (formItem) {
      formItem.options = options
    }
  }

  /**
   * 修改tree-select组件treeData
   * @param key 对应formItem prop
   * @param options 下拉选项
   */
  const changeTreeSelectOptions = (key: string, options: TreeItem[]) => {
    const formItem = getFormItem(key)
    if (formItem) {
      formItem.treeData = options
    }
  }

  // 构建model绑定数据
  const initModel = () => {
    const configs = config['value']?.configs
    if (configs.length) {
      const m: { [key: string]: any } = {}
      configs.map((item) => {
        if (!item.children?.length) {
          m[item.formItem.prop as string] = item.props?.defaultValue
        } else {
          item.children.map((child) => {
            m[child.formItem.prop as string] = child.props?.defaultValue
          })
        }
      })
      model.value = cloneDeep(m)
    }
  }

  initModel()

  return { config, model, changeSelectOptions, changeTreeSelectOptions }
}

附上完整配置

// views//dynamicForm/form.tsx
import { ElIcon, ElButton } from 'element-plus'
import { Search } from '@element-plus/icons-vue'

import { useForm } from '@/hooks/useForm'

export const useFormIterate = (events?: any) => {
  const { model, ...arg } = useForm({
    form: {
      labelWidth: '140px'
    },
    configs: [
      // 输入框
      {
        colSpan: 12,
        typeName: 'input',
        props: {
          defaultValue: '',
          clearable: true,
          placeholder: 'Please enter content'
        },
        slots: [
          {
            name: 'suffix',
            content: () => (
              <ElIcon class="el-input__icon">
                <Search />
              </ElIcon>
            )
          }
        ],
        formItem: {
          prop: 'name',
          label: 'Activity name',
          rules: [
            {
              required: true,
              message: 'Please enter content',
              trigger: 'blur'
            }
          ]
        }
      },
      // 选择器
      {
        colSpan: 12,
        typeName: 'select',
        props: {
          placeholder: 'Please select content',
          defaultValue: undefined,
          group: {
            clearable: true,
            onChange: events.changeSelect
          },
          child: {}
        },
        replaceField: { value: 'key', label: 'title' },
        options: [
          { key: 'shanghai', title: 'Zone one' },
          { key: 'beijing', title: 'Zone two' }
        ],
        styles: {
          width: '100%'
        },
        formItem: {
          prop: 'region',
          label: 'Activity zone',
          rules: [
            {
              required: true,
              message: 'Please select Activity zone',
              trigger: 'change'
            }
          ]
        }
      },
      // 选择器
      {
        colSpan: 24,
        typeName: 'select',
        props: {
          disabled: () => {
            return !model.value.region
          },
          placeholder: 'Please select content',
          defaultValue: undefined,
          group: {
            clearable: true,
            onChange: events.changeSelect
          },
          child: {}
        },
        replaceField: { value: 'key', label: 'title' },
        options: [],
        styles: {
          width: '100%'
        },
        formItem: {
          prop: 'region1',
          label: 'Activity select zone',
          rules: [
            {
              required: true,
              message: 'Please select Activity zone',
              trigger: 'change'
            }
          ]
        }
      },
      {
        colSpan: 24,
        formItem: {
          required: true,
          label: 'Activity time'
        },
        children: [
          // 日期选择器
          {
            colSpan: 12,
            typeName: 'date-picker',
            props: {
              type: 'datetime',
              clearable: true,
              valueFormat: 'YYYY-MM-DD HH:mm:ss',
              placeholder: 'Pick a day'
            },
            styles: { width: '100%' },
            formItem: {
              prop: 'date1',
              rules: [
                {
                  type: 'date',
                  required: true,
                  message: 'Please pick a date',
                  trigger: 'change'
                }
              ]
            }
          },
          // 时间选择器
          {
            colSpan: 12,
            typeName: 'time-picker',
            props: {
              disabled: (data = {}) => {
                return !model.value.date1
              },
              clearable: true,
              placeholder: 'Pick a time'
            },
            styles: { width: '100%' },
            formItem: {
              prop: 'date2',
              rules: [
                {
                  type: 'date',
                  required: true,
                  message: 'Please pick a time',
                  trigger: 'change'
                }
              ]
            }
          }
        ]
      },
      // 开关
      {
        colSpan: 24,
        typeName: 'switch',
        props: {
          defaultValue: false
        },
        formItem: {
          prop: 'delivery',
          label: 'Instant delivery'
        }
      },
      // 多选框
      {
        colSpan: 12,
        typeName: 'checkbox-group',
        props: {
          group: {},
          child: {}
        },
        formItem: {
          prop: 'type',
          label: 'Activity type',
          rules: [
            {
              type: 'array',
              required: true,
              message: 'Please select at least one activity type',
              trigger: 'change'
            }
          ]
        },
        // replaceField: { value: 'value', label: 'label' },
        options: [
          { value: 'shanghai', label: 'Zone one' },
          { value: 'beijing', label: 'Zone two' }
        ]
      },
      // 多选按钮框
      {
        colSpan: 12,
        typeName: 'checkbox-button',
        props: {
          group: {},
          child: {}
        },
        formItem: {
          prop: 'button',
          label: 'Activity button',
          rules: [
            {
              type: 'array',
              required: true,
              message: 'Please select at least one activity type',
              trigger: 'change'
            }
          ]
        },
        // replaceField: { value: 'value', label: 'label' },
        options: [
          { value: 'shanghai', label: 'Zone one' },
          { value: 'beijing', label: 'Zone two' }
        ]
      },
      // 单选框
      {
        colSpan: 12,
        typeName: 'radio-group',
        props: {},
        formItem: {
          prop: 'resource',
          label: 'Resources',
          rules: [
            {
              required: true,
              message: 'Please select activity resource',
              trigger: 'change'
            }
          ]
        },
        options: [
          { value: 'shanghai', label: 'Sponsorship' },
          { value: 'beijing', label: 'Venue' }
        ]
      },
      // 单选按钮框
      {
        colSpan: 12,
        typeName: 'radio-button',
        props: {},
        formItem: {
          prop: 'resourceButton',
          label: 'Resources button',
          rules: [
            {
              required: true,
              message: 'Please select activity resource',
              trigger: 'change'
            }
          ]
        },
        options: [
          { value: 'shanghai', label: 'Sponsorship' },
          { value: 'beijing', label: 'Venue' }
        ]
      },
      // 文本域
      {
        colSpan: 24,
        typeName: 'input',
        formItem: {
          prop: 'desc',
          label: 'Activity form'
        },
        props: {
          rows: 5,
          type: 'textarea',
          clearable: true,
          placeholder: 'Please enter content'
        },
        isShow: (data = {}) => {
          return model.value.region == 'shanghai'
        }
      },
      // 文件上传
      {
        colSpan: 24,
        typeName: 'upload',
        formItem: {
          prop: 'fileName',
          label: 'Upload File',
          rules: [
            {
              required: true,
              message: 'Please select at least one activity type',
              trigger: 'change'
            }
          ]
        },
        props: {
          httpRequest: events.httpRequest
        },
        slots: [
          {
            name: 'default',
            content: () => <ElButton type="primary">上传</ElButton>
          },
          {
            name: 'tip',
            content: () => <span style="margin-left:10px">jpg/png files with a size less than 500KB</span>
          }
        ]
      },
      // 滑块
      {
        colSpan: 16,
        typeName: 'slider',
        props: {
          onChange: (val: number) => {
            model.value.number = val
          }
        },
        formItem: {
          label: 'Activity slider',
          prop: 'slider',
          rules: [
            {
              required: true,
              message: 'Please enter content',
              trigger: 'change'
            }
          ]
        }
      },
      // 数字输入框
      {
        colSpan: 8,
        typeName: 'input-number',
        formItem: {
          prop: 'number',
          label: 'Activity number'
        },
        props: {
          min: 1,
          max: 100,
          onChange: (val: number) => {
            model.value.slider = val
          }
        }
      },
      // 树形选择器
      {
        colSpan: 24,
        typeName: 'tree-select',
        formItem: {
          prop: 'tree',
          label: 'Activity tree'
        },
        styles: { width: '100%' },
        props: {
          multiple: true,
          showCheckbox: true,
          placeholder: 'Please select content'
        },
        treeData: []
      }
    ]
  })

  return { model, ...arg}
}

到这里可能会有朋友会问,为啥用的tsx后缀,而不是用js/json; 这是因为想通过组件的形式传到Slot中(m-input组件为例),从而进行展示,当然也欢迎大家在评论区提供更好的方案。

实现效果

Vue3 Element-Plus 一站式生成动态表单

Vue3 Element-Plus 一站式生成动态表单

Vue3 Element-Plus 一站式生成动态表单 详细的实现逻辑,就委屈大家移步到项目中查看了。

最后

文章暂时就写到这,如果本文对您有什么帮助,别忘了动动手指点个赞❤️。 本文如果有错误和不足之处,欢迎大家在评论区指出,多多提出您宝贵的意见!

最后分享项目地址:github地址