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

发布时间 2023-11-18 13:19:30作者: 柯基与佩奇

数据接口设计

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组件

image.png

image.png

  • 表单验证
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 组件为例),从而进行展示,当然也欢迎大家在评论区提供更好的方案。

实现效果

image.png

image.png