vue3 + antd 封装动态表单组件(四)

传送带

vue3 + antd 封装动态表单组件(一)
vue3 + antd 封装动态表单组件(二)
vue3 + antd 封装动态表单组件(三)


前置条件

vue版本 v3.3.11
ant-design-vue版本 v4.1.1

内容梗概

这篇文章主要解决表单联动的问题。

现在模拟表单联动业务场景:

  1. <性别>为“女”时<兴趣>必填
  2. <性别>为“保密”时<生日>隐藏
  3. <性别>选项触发<兴趣>加载不同的数据
  4. <国家>为“美国”时禁用<简介>

先看效果图
vue3 + antd 封装动态表单组件(四)_第1张图片

代码区域

动态组件配置文件config.js

import { Input, Textarea, InputNumber, Select, RadioGroup, CheckboxGroup, DatePicker } from 'ant-design-vue';
// 表单域组件类型
export const componentsMap = {
    Text: Input,
    Textarea,
    Number: InputNumber,
    Select,
    Radio: RadioGroup,
    Checkbox: CheckboxGroup,
    DatePicker,
}

// 配置各组件属性默认值,相关配置项请查看ant-design官网各组件api属性配置
export const defaultComponentProps = {
    Text: {
        allowClear: true,
        bordered: true,
        disabled: false,
        showCount: true,
        maxlength: 20,
    },
    Textarea: {
        allowClear: true,
        autoSize: { minRows: 4, maxRows: 4 },
        showCount: true,
        maxlength: 200,
        style: {
            width: '100%'
        }
    },
    Select: {
        allowClear: true,
        bordered: true,
        disabled: false,
        showArrow: true,
        optionFilterProp: 'label',
        optionLabelProp: 'label',
        showSearch: true,
    },
    DatePicker: {
        allowClear: true,
        bordered: true,
        disabled: false,
        format: 'YYYY-MM-DD',
        picker: 'date',
        style: {
            width: '100%'
        }
    },
}

动态表单组件dynamic-form.vue

<template>
  <div>
    <a-form ref="formRef" :model="formModel" v-bind="$attrs">
      <a-form-item
        :name="item.field"
        :label="item.label"
        v-for="item in realFormSchema"
        :key="item.field"
        v-bind="item.formItemProps"
      >
        <!-- 表单form-item插槽, 注意优先级:组件formItemProps.slots > formItemPropsSlots-->
        <template
          v-for="slot in formItemPropsSlots"
          #[slot.name]="slotProps"
          :key="slot.key"
        >
          <template v-if="slot.field === item.field">
            <slot :name="slot.key" v-bind="slotProps"></slot>
          </template>
        </template>
        <template
          v-for="(slot, name) in item.formItemProps?.slots || {}"
          #[name]="slotProps"
          :key="`${item.field}_${name}`"
        >
          <component :is="slot" v-bind="slotProps"></component>
        </template>

        <template v-if="item.slot">
          <slot :name="item.slot" v-bind="formModel"></slot>
        </template>

        <template v-else>
          <span v-if="item.loading"
            ><LoadingOutlined style="margin-right: 4px" />数据加载中...</span
          >

          <component
            v-else
            :is="componentsMap[item.component]"
            v-bind="item.componentProps"
            v-model:value="formModel[item.field]"
          >
            <!-- 表单项组件插槽, 注意优先级:组件componentProps.slots > componentPropsSlots-->
            <template
              v-for="slot in componentPropsSlots"
              #[slot.name]="slotProps"
              :key="slot.key"
            >
              <template v-if="slot.field === item.field">
                <slot :name="slot.key" v-bind="slotProps"></slot>
              </template>
            </template>

            <template
              v-for="(slot, name) in item.componentProps?.slots || {}"
              #[name]="slotProps"
              :key="`${item.field}_componentProps_${name}`"
            >
              <!-- 这里是关键, 渲染slot -->
              <component :is="slot" v-bind="slotProps"></component>
            </template>
          </component>
        </template>
      </a-form-item>
    </a-form>
  </div>
</template>

<script setup>
import { ref, watch, onMounted, computed, useSlots, nextTick } from "vue";
import { componentsMap, defaultComponentProps } from "./config.js";
import { LoadingOutlined } from "@ant-design/icons-vue";
import dayjs from "dayjs";
import { set as setValue } from "@/common/utils";
const props = defineProps({
  // 表单项配置
  schema: {
    type: Array,
    default: () => [],
  },
  // 表单model配置,一般用于默认值、回显数据
  model: {
    type: Object,
    default: () => ({}),
  },
  // 组件属性配置
  componentProps: {
    type: Object,
    default: () => ({}),
  },
});

const slots = useSlots();

// 表单formItem slots
const formItemPropsSlots = ref([]);

// 表单项组件slots
const componentPropsSlots = ref([]);

// 用于获取componentProps、formItemProps插槽
const createPropsSlots = (type) => {
  // 对象转数组, 这里表单项slots规则为 对应的filed + '-type-' + slot名称,可自行定义规则,对应字段匹配上即可
  const slotsArr = Object.entries(slots);
  return slotsArr
    .filter((x) => x[0].indexOf(type) !== -1)
    .map((x) => {
      const slotParams = x[0].split("-");
      return {
        key: x[0],
        value: x[1],
        name: slotParams[2],
        field: slotParams[0],
      };
    });
};
const createSlots = () => {
  formItemPropsSlots.value = createPropsSlots("formItemProps");
  componentPropsSlots.value = createPropsSlots("componentProps");
};

const formRef = ref(null);

const formSchema = ref([]);
const formModel = ref({});

// 组件placeholder
const getPlaceholder = (x) => {
  let placeholder = "";
  switch (x.component) {
    case "Text":
    case "Textarea":
      placeholder = `请输入${x.label}`;
      break;
    case "RangePicker":
      placeholder = ["开始时间", "结束时间"];
      break;
    default:
      placeholder = `请选择${x.label}`;
      break;
  }
  return placeholder;
};

// 组件属性componentProps, 注意优先级:组件自己配置的componentProps > props.componentProps > config.js中的componentProps
const getComponentProps = (x) => {
  if (!x?.componentProps) x.componentProps = {};
  // 使得外层可以直接配置options
  if (x.hasOwnProperty("options") && x.options) {
    x.componentProps.options = [];
    const isFunction = typeof x.options === "function";
    const isArray = Array.isArray(x.options);
    if (isFunction || isArray) {
      // 函数时先赋值空数组
      x.componentProps.options = isFunction ? [] : x.options;
    }
  }
  // 监听onChange表单项,并触发onChange事件-一般用于表单联动
  if (x?.onChange && typeof x.onChange === "function") {
    watch(
      () => formModel.value[x.field],
      (newVal) => {
        x.onChange({
          value: newVal,
          formModel: formModel.value,
          formSchema: formSchema.value,
          formRef: { ...formRef.value, ...formRef.value?.$parent },
        });
      }
    );
  }

  return {
    placeholder: x?.componentProps?.placeholder ?? getPlaceholder(x),
    ...(defaultComponentProps[x.component] || {}), // config.js带过来的基础componentProps默认配置
    ...(props.componentProps[x.component] || {}), // props传进来的组件componentProps配置
    ...x.componentProps, // 组件自身的componentProps
  };
};

// 表单属性formItemProps
const getFormItemProps = (x) => {
  let result = { ...(x.formItemProps || {}) };
  // 使得外层可以直接配置required必填项
  if (x.hasOwnProperty("required") && x.required) {
    result.rules = [
      ...(x?.formItemProps?.rules || []),
      {
        required: true,
        message: getPlaceholder(x),
        trigger: "blur",
      },
    ];
  }
  return result;
};

// 各组件为空时的默认值
const getDefaultEmptyValue = (x) => {
  let defaultEmptyValue = "";
  switch (x.component) {
    case "Text":
    case "Textarea":
      defaultEmptyValue = "";
      break;
    case "Select":
      defaultEmptyValue = ["tag", "multiple"].includes(x?.componentProps?.mode)
        ? []
        : undefined;
    case "Cascader":
      defaultEmptyValue = x?.value?.length ? x.value : [];
    default:
      defaultEmptyValue = undefined;
      break;
  }
  return defaultEmptyValue;
};

// 格式化各组件值
const getValue = (x) => {
  let formatValue = x.value;
  if (!!x.value) {
    switch (x.component) {
      case "DatePicker":
        formatValue = dayjs(x.value, "YYYY-MM-DD");
        break;
    }
  }
  return formatValue;
};

// 隐藏属性hidden
const getHidden = (x) => (x.hasOwnProperty("hidden") ? x.hidden : false);

// 标签label
const getLabel = (x) =>
  x.formItemProps?.slots?.label ||
  formItemPropsSlots.value.find((y) => y.field === x.field)?.field
    ? undefined
    : x.label;

const getSchemaConfig = (x) => {
  return {
    ...x,
    componentProps: getComponentProps(x),
    formItemProps: getFormItemProps(x),
    value: x.value ?? getDefaultEmptyValue(x),
    label: getLabel(x),
    hidden: getHidden(x),
  };
};

// 新增schema
const addSchema = ({
  field,
  schema = { label: "label", component: "Text" },
  location = "after",
}) => {
  if (!["before", "after"].includes(location)) {
    console.warn(
      `location的值只能取 before 或 after ,默认为 before,当前取值为${location}`
    );
  }
  const index = formSchema.value.findIndex((x) => x.field === field);
  if (index !== -1) {
    //判断插入位置
    const insertIndex = location === "before" ? index : index + 1;
    schema = {
      ...getSchemaConfig(schema),
      ...schema,
    };
    formSchema.value.splice(insertIndex, 0, schema);
  }
};

// 删除schema
const deleteSchema = (field) => {
  const index = formSchema.value.findIndex((x) => x.field === field);
  if (index !== -1) {
    formSchema.value.splice(index, 1);
  }
};

const composePath = (path) => {
  const wrapProp = {
    componentProps: ["options", "disabled", "value"],
    formItemProps: ["required"],
  };
  for (const key in wrapProp) {
    if (wrapProp[key].includes(path)) {
      return `${key}.${path}`;
    }
  }
  return path;
};

// 设置schema
const setSchema = ({ field, path, value }) => {
  const formatPath = composePath(path);
  const target = formSchema.value.find((x) => x.field === field);
  // 设置required,注意移除之前设置为true的校验特性
  if (["required"].includes(path)) {
    const index = target?.formItemProps?.rules?.length
      ? target.formItemProps.rules.findIndex((y) =>
          y.hasOwnProperty("required")
        )
      : -1;
    if (index !== -1) {
      target.formItemProps.rules.splice(index, 1);
    }
    target.formItemProps = {
      ...(target.formItemProps || {}),
      rules: [
        ...(target?.formItemProps?.rules || []),
        {
          required: value,
          message: getPlaceholder(target),
          trigger: ["change", "blur"],
        },
      ],
    };
  }
  setValue(target, formatPath, value);
};

const setFormModel = () => {
  formModel.value = formSchema.value.reduce((pre, cur) => {
    if (!pre[cur.field]) {
      // 表单初始数据(默认值)
      pre[cur.field] = getValue(cur);
      return pre;
    }
  }, {});
};

const realFormSchema = computed(() =>
  formSchema.value.filter((x) => !x.hidden)
);

// 表单初始化
const initForm = () => {
  formSchema.value = props.schema.map((x) => getSchemaConfig(x));
  // model初始数据
  setFormModel();
  // options-获取异步数据
  formSchema.value.forEach(async (x) => {
    if (x.options && typeof x.options === "function") {
      x.loading = true;
      x.componentProps.options = await x.options(formModel.value);
      x.loading = false;
    }
  });
};

onMounted(() => {
  createSlots();
  initForm();
  watch(
    () => props.model,
    (newVal) => {
      // 重新赋值给formSchema
      formSchema.value.forEach((x) => {
        for (const key in newVal) {
          if (x.field === key) {
            x.value = newVal[key];
          }
        }
      });
      setFormModel();
    },
    {
      immediate: true,
      deep: true,
    }
  );
});

const hasLoadingSchema = computed(() =>
  formSchema.value.some((x) => x.loading)
);

// 表单验证
const validateFields = () => {
  if (hasLoadingSchema.value) {
    console.log("正在加载表单项数据...");
    return;
  }
  return new Promise((resolve, reject) => {
    formRef.value
      .validateFields()
      .then((formData) => {
        resolve(formData);
      })
      .catch((err) => reject(err));
  });
};

// 表单重置
const resetFields = (isInit = true) => {
  // 是否清空默认值
  if (isInit) {
    formModel.value = {};
  }
  formRef.value.resetFields();
};

// 暴露方法
defineExpose({
  validateFields,
  resetFields,
  setSchema,
  addSchema,
  deleteSchema,
});
</script>

utils.js文件

// 用于模拟接口请求
export const getRemoteData = (data = '获取数据', time = 2000) => {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log(`模拟获取接口数据`, data)
            resolve(data)
        }, time)
    })
}

// lodash "set"方法
export const set = (obj, path, value) => {
    if (Object(obj) !== obj) return obj;
    if (!Array.isArray(path)) path = path.toString().match(/[^.[\]]+/g) || [];
    path.slice(0, -1).reduce((a, c, i) =>
        Object(a[c]) === a[c]
            ? a[c]
            : a[c] = Math.abs(path[i + 1]) >> 0 === +path[i + 1]
                ? []
                : {},
        obj)[path[path.length - 1]] = value;
    return obj;
}

使用动态表单组件

<template>
  <div style="padding: 200px">
    <DynamicForm
      ref="formRef"
      :schema="schema"
      :model="model"
      :labelCol="{ span: 4 }"
      :wrapperCol="{ span: 20 }"
    >
      <template #country-formItemProps-label>
        <span style="color: green">国家span>
      template>

      
      <template #name-componentProps-addonAfter>
        <span>我是slotspan>
      template>

      <template #country-componentProps-suffixIcon>
        <span>我也是slotspan>
      template>

      <template #someComponentX="formModel">
        <div><BellFilled style="color: red" />我是特殊的某某组件div>
        <div>表单信息:{{ formModel }}div>
      template>
    DynamicForm>
    <div style="display: flex; justify-content: center">
      <a-button @click="handleReset(true)">重置(全部清空)a-button>
      <a-button style="margin-left: 50px" @click="handleReset(false)"
        >重置a-button
      >
      <a-button type="primary" style="margin-left: 50px" @click="handleSubmit"
        >提交a-button
      >
    div>
  div>
template>

<script lang="jsx" setup>
import DynamicForm from "@/components/form/dynamic-form.vue";
import { ref, reactive } from "vue";
import dayjs from "dayjs";
import { getRemoteData } from "@/common/utils";
import { UserOutlined, BellFilled } from "@ant-design/icons-vue";
const formRef = ref(null);

const schema = ref([
  {
    label: "姓名",
    field: "name",
    component: "Text",
    required: true,
    componentProps: {
      slots: {
        addonAfter: () => <UserOutlined />,
      },
    },
  },
  {
    label: '性别',
    field: "sex",
    component: "Radio",
    options: [
      { value: 1, label: "男" },
      { value: 2, label: "女" },
      { value: 3, label: "保密" },
    ],
    value: 1,
    required: true,
    formItemProps: {
      slots: {
        label: () => <div style="color: blue">性别</div>
      }
    },
    onChange: async ({formRef, value}) => {
      // <性别>为“女”时<兴趣>必填
      formRef.setSchema({
        field: 'hobby', 
        path: 'required',
        value: value === 2 
      })
      const boyList = [
        { value: 1, label: "足球" },
        { value: 2, label: "篮球" },
        { value: 3, label: "排球" },
      ]
      const girlList = [
        { value: 1, label: "音乐" },
        { value: 2, label: "美术" },
        { value: 3, label: "瑜伽" },
      ]
      const list = value === 2 ? girlList : boyList
      formRef.setSchema({
        field: 'hobby', 
        path: 'loading',
        value: true
      })
      formRef.setSchema({
        field: 'hobby', 
        path: 'options',
        value: await getRemoteData(list, 300)
      })
      formRef.setSchema({
        field: 'hobby', 
        path: 'loading',
        value: false
      })

      // <性别>为“保密”时<生日>显示
      formRef.setSchema({
        field: 'birthday', 
        path: 'hidden',
        value: value === 3 
      })
    }
  },
  {
    label: "生日",
    field: "birthday",
    component: "DatePicker",
    required: true,
  },
  {
    label: "兴趣",
    field: "hobby",
    component: "Checkbox",
    options: async () => {
      // 后台返回的数据list
      const list = [
        { value: 1, label: "足球" },
        { value: 2, label: "篮球" },
        { value: 3, label: "排球" },
      ];
      return await getRemoteData(list);
    },
  },
  {
    label: "国家",
    field: "country",
    component: "Select",
    options: [
      { value: 1, label: "中国" },
      { value: 2, label: "美国" },
      { value: 3, label: "俄罗斯" },
    ],
    onChange: ({formRef, value}) => {
      // <国家>为“美国”时禁用<简介>
      formRef.setSchema({
        field: 'desc', 
        path: 'componentProps.disabled',
        value: value === 2 
      })
    }
  },
  {
    label: "简介",
    field: "desc",
    component: "Textarea",
  },
  {
    label: "插槽组件X",
    field: "someComponentX",
    slot: "someComponentX",
    hidden: true
  },
]);
const model = reactive({ name: "百里守约", someComponentB: 'ok' });
// 提交
const handleSubmit = async () => {
  const formData = await formRef.value.validateFields();
  if (formData.birthday) {
    formData.birthday = dayjs(formData.birthday).format("YYYY-MM-DD");
  }
  console.log("提交信息:", formData);
};

// 重置
const handleReset = (isInit) => {
  formRef.value.resetFields(isInit);
};
script>

你可能感兴趣的:(vue3,ant-design,vue.js,javascript,anti-design-vue)