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

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

项目地址:[github地址](https://github.com/sunshine824/vue3.0-typescript-starter/tree/element)

## 数据接口设计
废话不多说,代码敬上  
> `咋眼一看,代码有点多哈,别着急,注释已安排上。 `

```js
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`方法,将方法绑定在对应的组件上,从而组件显示隐藏条件

```js
isShow: (data = {}) => {
  return model.value.region == 'shanghai'
}
....
<el-form-item v-if="isShow(model)" v-bind="item.formItem">
```
- 目标组件是否禁用,需要根据某个组件是否有值来判断

```js
disabled: (data = {}) => {
    return !model.value.date1
}
....
<component :disabled="disabled(model)"></component>
```
- 组件之间相互赋值,`A组件`的值赋值给`B组件`,`B组件`的值赋值给 `A组件`

![image.png](https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/d0ad4465c5e54481bfddabe0789df0a8~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1113&h=87&s=9599&e=png&b=f8f8f8)

![image.png](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f06c155e9f574ff3a00345cd0b1589dd~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=682&h=1090&s=100411&e=png&b=1e1e1e)  

- 表单验证

```js
formItem: {
  prop: 'name',
  label: 'Activity name',
  rules: [
    {
      required: true,
      message: 'Please enter content',
      trigger: 'blur'
    }
  ]
}
```

## 组件封装
#### 1. 输入框组件

```js
<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. 下拉选择器组件

```js
<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. 日期选择器组件

```js
<template>
  <el-date-picker v-bind="attrs.props"
                  ref="elDatePickerRef"
                  :style="attrs.styles"></el-date-picker>
</template>
```
封装方法都一致,还有很多组件,这里就不一个个列出来,具体大家就移步源码查看哈  
> 项目路径 src/components/Form

## 组件整合

```js
<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 initForm = () => {
  if (props.configs?.length) {
    let m: { [key: string]: any } = {}
    props.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({ ...props.data, ...m })
  }
}

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
}

onMounted(() => {
  initForm()
})

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

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

watch(
  () => props.configs,
  () => {
    initForm()
  },
  { deep: true }
)

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

<style scoped></style>

```

## 附上完整配置

```js
const config = ref<FConfig>({
    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,
        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: []
      }
    ]
  })
```

## 实现效果

![image.png](https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/a8e09d8a23f94af187e2f231c2d3f06e~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1362&h=618&s=71922&e=png&b=fafafa)

![image.png](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/8e57c2e19725434a9115c7b82fd122fe~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1346&h=628&s=90724&e=png&b=fafafa)

![image.png](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/448c75b2ed6c40beb8e664da7a64c942~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1353&h=668&s=79020&e=png&b=fafafa)
详细的实现逻辑,就委屈大家移步到[项目](https://github.com/sunshine824/vue3.0-typescript-starter/tree/element)中查看了。

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

最后分享项目地址:[github地址](https://github.com/sunshine824/vue3.0-typescript-starter/tree/element)

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值