数据接口设计
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 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 组件为例),从而进行展示,当然也欢迎大家在评论区提供更好的方案。