背景
经常开发管理系统的小伙伴们肯定或多或少都遇到过表单需求,对于一个系统而言,动辄就是十几,几十个表单;如果每个表单都按照传统模式编写的话,简直要把前端累死,看着一段段大同小异的代码,也是提不上一点劲,甚至看着这些它懂你,你不想懂它
的代码就犯恶心。
本着偷懒的精神,我就想能否封装一个动态表单,实现思路大致就是通过JSON配置,动态生成表单页面
,于是说干就干,咱玩的就是真实对吧。开撸,开撸…
项目地址: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组件
- 表单验证
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 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>
附上完整配置
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: []
}
]
})
实现效果
详细的实现逻辑,就委屈大家移步到项目中查看了。
最后
文章暂时就写到这,如果本文对您有什么帮助,别忘了动动手指点个赞❤️。
本文如果有错误和不足之处,欢迎大家在评论区指出,多多提出您宝贵的意见!
最后分享项目地址:github地址