提升前端开发效率:Element Plus 封装 CForm 表单组件

2,539 阅读3分钟

前言

在日常开发中,表单是几乎每个项目都会涉及的功能模块。然而,传统的表单组件写起来往往代码冗长、基础代码的重复编写枯燥无聊且容易出错。因此,我们设计并封装了一个高效灵活的表单组件——CForm,旨在简化表单的使用和管理。

提示:关于表格和搜索栏的封装看:# 提升前端开发效率:Element Plus 封装 CTable 和 CSearch 表格搜索栏组件

CForm组件

CFrom组件基于<el-form>组件进行了高度封装,通过属性和插槽机制,使表单的配置更加灵活,使用更加简便。需要支持的功能

表单数据配置:使用数据配置即可生成新增编辑表单。
表单布局自定义:可以根据配置项自定义表单的布局,支持灵活的网格布局,可以指定每个表单项在网格中占据的列数。
动态表单项渲染:根据传入的 formConfig 配置项,动态渲染表单项,支持各种类型的表单元素,包括输入框、文本域、数字输入框、开关、单选框、下拉框、日期选择器等。
表单验证:保留element-plus 的表单验证功能。提供快捷验证属性:require
表单项自定义插槽:支持插槽功能,用户可以自定义表单项的内容,在表单项后添加额外的内容或自定义表单项的样式和布局。
动态表单项显示控制:可以根据配置项中的条件函数 ifFunc 控制表单项的显示与隐藏,使表单项的显示状态可以根据表单数据的变化动态调整。

封装实现后效果

完整在线项目代码(sendbox):codesandbox.io/p/devbox/el…

1716307830215.jpg

接下来封装我们自己的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>