前言
在日常开发中,表单是几乎每个项目都会涉及的功能模块。然而,传统的表单组件写起来往往代码冗长、基础代码的重复编写枯燥无聊且容易出错。因此,我们设计并封装了一个高效灵活的表单组件——CForm,旨在简化表单的使用和管理。
提示:关于表格和搜索栏的封装看:# 提升前端开发效率:Element Plus 封装 CTable 和 CSearch 表格搜索栏组件
CForm组件
CFrom组件基于<el-form>组件进行了高度封装,通过属性和插槽机制,使表单的配置更加灵活,使用更加简便。需要支持的功能:
表单数据配置:使用数据配置即可生成新增、编辑表单。
表单布局自定义:可以根据配置项自定义表单的布局,支持灵活的网格布局,可以指定每个表单项在网格中占据的列数。
动态表单项渲染:根据传入的 formConfig 配置项,动态渲染表单项,支持各种类型的表单元素,包括输入框、文本域、数字输入框、开关、单选框、下拉框、日期选择器等。
表单验证:保留element-plus 的表单验证功能。提供快捷验证属性:require。
表单项自定义插槽:支持插槽功能,用户可以自定义表单项的内容,在表单项后添加额外的内容或自定义表单项的样式和布局。
动态表单项显示控制:可以根据配置项中的条件函数 ifFunc 控制表单项的显示与隐藏,使表单项的显示状态可以根据表单数据的变化动态调整。
封装实现后效果
完整在线项目代码(sendbox):codesandbox.io/p/devbox/el…
接下来封装我们自己的CForm
封装后的表单用法
<template>
<Cform ref="formRef" :formConfig="formConfig">
<template #pay_amount="{ formVal }">
<div class="pay_amount">
自定义整行:<el-input v-model="formVal.pay_amount" />
</div>
</template>
<template #custom="{ formVal }">
<div class="custom">
<el-input v-model="formVal.custom" />
</div>
</template>
<template #name_suffix="{ formVal }">
<div>输入框本身的插槽~</div>
</template>
<template #date__suffix="{ formVal }">
<div>表单项后缀插槽~</div>
</template>
</Cform>
</template>
<script setup lang="ts">
import { ref } from "vue";
import Cform from "@/components/common/CForm.vue";
import { CFormConfigItem, CFormConfigItemType } from "@/types/cform";
import { ElMessage } from "element-plus";
const formRef = ref();
// 表单重置
const reset = ()=>{
formRef.value.reset();
}
// 根据配置项读取表单数据
const formConfig = ref<CFormConfigItem[]>([
{
label: "隐藏Id",
key: "order_id",
value: "",
type: CFormConfigItemType.Hidden,
},
{
label: "基础信息标题",
value: "",
type: CFormConfigItemType.Title,
},
{
label: "名称",
key: "name",
value: "",
type: CFormConfigItemType.Input,
required: true,
},
{
label: "数字",
key: "number",
value: 0,
type: CFormConfigItemType.Number,
},
{
label: "选项",
key: "option",
value: "",
type: CFormConfigItemType.Select,
options: [
{ label: "选项1", value: 1 },
{ label: "选项2", value: 2 },
],
required: true,
},
{
label: "日期",
key: "date",
value: "",
type: CFormConfigItemType.DatePicker,
required: true,
},
{
label: "开关",
key: "switch",
value: true,
type: CFormConfigItemType.Switch,
required: true,
},
{
label: "单选",
key: "sex",
value: 1,
type: CFormConfigItemType.RadioGroup,
options: [
{ label: "男", value: 1 },
{ label: "女", value: 2 },
],
required: true,
},
{
label: "自定义整行",
key: "pay_amount",
value: "",
type: CFormConfigItemType.Custom_FormItem,
},
{
label: "自定义表单值",
key: "custom",
value: "",
type: CFormConfigItemType.Custom,
},
]);
</script>
<style lang="scss" scoped></style>
Cform表单代码实现
<template>
<!-- 表单组件 -->
<el-form
ref="formRef"
:label-width="130"
:model="formVal"
label-align="right"
label-placement="left"
:rules="readonly || disabled ? null : rules"
:disabled="disabled"
:show-feedback="!readonly && !disabled"
:validate-on-rule-change="false"
>
<div :style="gridStyle()">
<!-- 前缀插槽 -->
<slot name="prefix"></slot>
<template
v-for="item in props.formConfig.filter((x: any) => x.type != 'hidden')"
:key="item.key"
>
<div
:style="gridItemStyle(item)"
v-if="!item.ifFunc || item.ifFunc(formVal)"
:span="item.type == 'title' ? props.cols : item.span || 1"
>
<template v-if="item.type == 'title'">
<!-- 标题 -->
<div class="title" :span="props.cols">{{ item.label }}</div>
</template>
<template v-else-if="item.type == 'custom_item'">
<!-- 自定义项插槽 -->
<slot
:name="item.key"
v-bind="{ rowConfig: item, formVal: formVal }"
/>
</template>
<el-form-item
v-else
:label="`${item.label}:`"
:prop="item.key"
:label-width="item.labelWdith"
>
<template v-if="keySlots[item.key]">
<!-- 自定义插槽 -->
<slot
:name="keySlots[item.key]"
v-bind="{ rowConfig: item, formVal: formVal }"
/>
</template>
<template v-else-if="item.readonly">
<!-- 只读 -->
<span>{{ formVal[item.key] || "--" }}</span>
</template>
<template v-else>
<!-- 表单项 -->
<template v-if="['input', 'textarea'].includes(item.type as any)">
<!-- 输入框/文本域 -->
<el-input
:type="item.type"
v-model="formVal[item.key]"
clearable
:placeholder="getTip(item)"
v-bind="item.attrs"
>
<template v-for="slotName in mapSlots[item.key]" #[slotName]>
<slot :name="`${item.key}_${slotName}`" />
</template>
</el-input>
</template>
<template v-else-if="item.type == 'text'">
<!-- 文本 -->
<div>
{{ formVal[item.key] }}
</div>
</template>
<template v-else-if="item.type == 'number'">
<!-- 数字输入框 -->
<el-input-number
v-model="formVal[item.key]"
clearable
:placeholder="getTip(item)"
v-bind="item.attrs"
>
<template v-for="slotName in mapSlots[item.key]" #[slotName]>
<slot :name="`${item.key}_${slotName}`" />
</template>
</el-input-number>
</template>
<template v-else-if="item.type == 'switch'">
<!-- 开关 -->
<el-switch v-model="formVal[item.key]" v-bind="item.attrs" />
</template>
<template v-else-if="item.type == 'radio_group'">
<!-- 单选框组 -->
<el-radio-group v-model="formVal[item.key]" v-bind="item.attrs">
<el-radio
v-for="option in item.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</el-radio>
</el-radio-group>
</template>
<template v-else-if="item.type == 'select'">
<!-- 下拉框 -->
<el-select
v-model="formVal[item.key]"
:options="item.options"
clearable
:placeholder="getTip(item)"
v-bind="item.attrs"
>
<el-option
v-for="option in item.options"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</template>
<template v-else-if="item.type == 'image'">
<!-- 图片上传组件 -->
<!-- <ImageUpload v-model="formVal[item.key]" v-bind="item.attrs" /> -->
</template>
<template v-else-if="item.type == 'file'">
<!-- 文件上传组件 -->
<!-- <FileUpload v-model="formVal[item.key]" v-bind="item.attrs" /> -->
</template>
<template v-else-if="item.type == 'datePicker'">
<!-- 日期选择器 -->
<el-date-picker
v-model="formVal[item.key]"
:placeholder="getTip(item)"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
v-bind="item.attrs"
/>
</template>
</template>
<!-- 后缀插槽 -->
<slot
:name="`${item.key}__suffix`"
v-bind="{ rowConfig: item, formVal: formVal }"
/>
</el-form-item>
</div>
</template>
<!-- 后缀插槽 -->
<slot name="suffix"></slot>
</div>
</el-form>
</template>
<script setup lang="ts">
import { ref, watch, useSlots, nextTick } from "vue";
// TODO:封装图片、文件上传组件。
// import ImageUpload from "@/components/ImageUpload/index.vue";
// import FileUpload from "@/components/Common/FileUpload.vue";
import { CFormConfigItem } from "@/types/components/cform";
const formRef = ref();
const emit = defineEmits(["refresh"]);
const props = withDefaults(
defineProps<{
formConfig: CFormConfigItem[];
readonly: boolean;
disabled: boolean;
cols: number;
formAttrs: any;
}>(),
{
formConfig: () => [] as any[],
readonly: false,
disabled: false,
cols: 1,
formAttrs: {},
},
);
const slots = useSlots();
const mapSlots: any = {};
const keySlots: any = {};
// 表单数据
const formVal = ref<any>({});
const rules = ref<any>({});
let lastData: any = null
const initData = () => {
// 根据配置项读取表单数据
props.formConfig.forEach((x: any) => {
if (x.type === "title") return;
if (x.type == "select" && x.attrs?.multiple && !x.value) {
formVal.value[x.key] = [];
} else {
formVal.value[x.key] = x.value;
}
if (x.rule) rules.value[x.key] = x.rule;
else if (x.required) {
// 组合不同类型值的校验规则
let msg = `请输入${x.label}`;
let type = "string";
let trigger = ["blur"];
if (["select", "radio_group", "datePicker"].includes(x.type)) {
msg = `请选择${x.label}`;
trigger.push("change");
}
if (["image", "file"].includes(x.type)) {
msg = `请上传${x.label}`;
}
if (["select", "radio_group", "number", "switch"].includes(x.type)) {
type = x.type == "select" && x.attrs?.multiple ? "array" : "number";
} else if (Array.isArray(x.value)) {
type = "array";
} else if (x.type == "datePicker") {
type = "date";
}
rules.value[x.key] = [
{ required: true, message: msg, trigger: trigger, type: type },
];
}
for (const key in slots) {
let searchKey = x.key + "_";
if (key.startsWith(searchKey)) {
let slotName = key.replace(searchKey, "");
mapSlots[x.key] = mapSlots[slotName] || [];
mapSlots[x.key].push(slotName);
} else if (x.key == key && x.type == "custom") {
keySlots[x.key] = key;
}
}
});
lastData = { ...formVal.value }
};
// 配置修改时,重新读取表单数据
watch(
() => props.formConfig,
() => {
initData();
},
{ immediate: true, deep: true },
);
// 对比上次的数据是否有变化。防止传入相同formConfig,但是重置了表单再次打开无数据。
watch(
() => formVal,
(newVal) => {
if (Object.keys(newVal).length > 0 && lastData && Object.keys(lastData).length === 0) {
initData()
}
},
{ deep: true }
)
// 验证表单
const validate = (cb: any) => {
formRef.value.validate(cb);
};
// 重置数据
const reset = () => {
formVal.value = {};
formRef.value?.resetFields();
};
// 获取提示语
const getTip = (item: any) => {
if (item.tip) {
return item.tip;
} else if (["select", "radio_group"].includes(item.type)) {
return `请选择${item.label}`;
}
return `请输入${item.label}`;
};
// 网格布局
const gridStyle = () => {
return {
width: "100%",
display: "grid",
gridTemplateColumns: `repeat(${props.cols}, minmax(0px, 1fr))`,
gap: "0px 12px",
};
};
const gridItemStyle = (item: any) => {
return {
gridColumn: `span ${item.span || 1} / span 1`,
};
};
// 获取表单数据
const getFormValue = () => ({ ...formVal.value });
defineExpose({ validate, getFormValue, reset });
</script>
<style lang="scss" scoped>
.title {
font-size: 15px;
font-weight: bold;
color: #333;
margin-bottom: 10px;
}
</style>