B端开发化神期--这个Vue3动态表单设计是不是你的Crush?

1.前言

能来到这里证明你我有缘,既然如此,道友我想送你一场造化!

上一篇我们分享了ElTable组件的二次开发封装,没看过的道友可以点击此处前往查看。今天我们分享一下基于Vue3+Elemet-Plus的动态表单的封装与设计。话不多说,我们先上图,展示下最终实现的效果。

2.效果演示

单纯看上面的效果展示可能会觉得这也没啥,谁都能做。那么让我们来看看上面效果实现的完整代码实现,相信眼前的道友,你会感兴趣(目前还有点没处理,表单元素改变不该触发其他字段的校验,体验不好)。

js 复制代码
<template>
  <div style="width: 50%">
    <p>动态表单虚拟场景演示</p>
    <FormRender ref="formInstance" v-model="formData" :formItems="formItems"></FormRender>
    <el-button @click="submit">提交信息</el-button>
    <el-button @click="dataEcho">模拟数据回显</el-button>
    <el-button @click="formInstance.resetFields()">重置</el-button>
  </div>
</template>
<script setup lang="ts">
import { ref } from "vue";

const formData = ref(null);

// 模拟异步请求
const requestData = (dataList, responseTime = 100) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(dataList);
    }, responseTime);
  });
};

const platforms = [
  { name: "淘宝", code: "taobao" },
  { name: "抖音", code: "douyin" },
];
const shops = [
  { id: 1, name: "淘宝旗舰店1", code: "tb1", platformCode: "taobao" },
  { id: 2, name: "淘宝旗舰店2", code: "tb2", platformCode: "taobao" },
  { id: 3, name: "淘宝旗舰店3", code: "tb3", platformCode: "taobao" },
  { id: 4, name: "抖音旗舰店1", code: "dy1", platformCode: "douyin" },
  { id: 5, name: "抖音旗舰店2", code: "dy2", platformCode: "douyin" },
];
const payTypeEnums: any = {
  1: "微信",
  2: "支付宝",
  3: "银行卡",
};

const formItems = [
  {
    label: "平台",
    field: "platformCode",
    extendField: { platformName: "name" },
    required: true,
    uiType: "selector",
    props: {
      valueKey: "code",
      options: () => requestData(platforms),
    },
  },
  {
    label: "店铺",
    field: "shopCode",
    required: true,
    uiType: "selector",
    props: {
      multiple: true,
      valueKey: "code",
      filter: (formData: any) => ({ ...formData }),
      options: (filter) => {
        const platformCodes = Array.isArray(filter.platformCode)
          ? filter.platformCode
          : [filter.platformCode];
        return requestData(shops, 500).then((list: any[]) => {
          return list.filter((item: any) => platformCodes.includes(item.platformCode));
        });
      },
    },
  },
  {
    label: "支付方式",
    field: "payType",
    extendField: {
      payTypeObj: {
        payName: "name",
        payCode: "code",
      },
    },
    required: true,
    uiType: "checkbox",
    props: {
      valueKey: "code",
      options: payTypeEnums,
    },
  },
  {
    label: "截止时间",
    field: ["startDate", "endDate"],
    uiType: "dateTimePicker",
    props: { type: "daterange" },
  },

  {
    label: "appKey",
    field: "ext.appKey",
    required: true,
    uiType: "input",
    visiable: {
      platformCode: {
        equals: "douyin",
      },
    },
  },
  {
    label: "appSecret",
    field: "ext.appSecret",
    required: true,
    uiType: "input",
    visiable: (formData) => formData.platformCode == "douyin",
  },
];

const formInstance: any = ref({});
const submit = () => {
  formInstance.value.submit((form) => {
    console.log("submit", form);
  });
};

const dataEcho = () => {
  formData.value = {
    platformCode: "douyin",
    platformName: "抖音",
    shopCode: "dy1",
    payType: ["1"],
    startDate: "2023-12-13",
    endDate: "2024-01-19",
    ext: {
      appKey: "12334",
      appSecret: "234325435",
    },
  };
};
</script>

可能以前你看到过很多json配置的表单渲染,但是看到这里,不知道有没有惊艳到眼前的你。如果觉得不够好,希望不吝赐教,可以提出你的建议;或者说有更好的实现方案,我们也可以私下交流,共同进步。

3.设计

三军未动,粮草先行;代码未动,需求设计先行。在动手敲代码之前,我们得先想好设计目标,我们要实现哪些功能,这些功能必须得解决我们开发中的痛点。

功能设计

以JSON配置方式实现表单动态渲染,根据开发实践中提炼设计实现以下功能。

功能设计图

渲染协议

在渲染协议设计上,设计目标是以最少的配置项实现需求中的所有功能,这样可以减少开发者的心智负担,不必记太多配置字段。根据上图功能设计,最终设计出渲染协议如下:

js 复制代码
interface FormItem {
  label: string;//对应formItem的label
  //对应formItem的prop,支持字符串例如: 
  field: string|string[];
  //针对选择器,如果想获取选项除field字段外的其他字段,可以在此声明
  extendField?: object; 
  initValue?: any;
  required?: boolean;
  rule?: any[];
  visiable?: any; //支持函数,对象,布尔值
  disabled?: any; //支持函数,对象,布尔值
  uiType: string | component;
  props?: any; //支持uiType对应组件的所有属性
  events?: any; //支持uiType对应组件的所有属性
}

在协议设计中,我们的field字段支持场景主要有三种:
1、单一对应字段 :'platformCode'、
2、嵌套字段: 'platform.code'
3、双字段(用于例如数值区间,日期范围):'startDate,endDate'、'date.start,date.end'
以上三种设计主要是为了减少对输出的提交数据二次加工操作,上述配置在提交的表单中将会输出数据结构如下:
{
  platformCode:'',
  platform:{
   code:''
  },
  startDate:'',
  endDate:'',
  date:{
    start:'',
    end:''
  }
}
开发中我们可以根据需要配置对应的字段

此外相信眼尖的你也看到了,上面还有一个extendField的配置项,
该字段的设计主要用于(select,radio,checkbox)等场景取值。
假设我们的选项数据如下
 [
     {name: "微信",code: "wx"},
     {name: "支付宝",code: "alipay"}
 ]
 
需求一:
多选,选中数据时同时取name,code并以{options:[{payName:'',payCode:''}]}数据结构传输给后端,
这时候我们配置如下
 {
    label: "支付方式",
    field: "payTypeCode",
    extendField: {
      options: {         
        payName: "name",  
        payCode: "code",      
      },
    },
    uiType: "checkbox",
     props:{
      valueKey:'code'
    }
 }
 需求2:
 单选,选中数据时同时取named的值name并以{payTypeCode:'',payTypeName:''}数据结构传输给后端,
 这时候我们配置如下
 {
    label: "支付方式",
    field: "payTypeCode",
    extendField: {
      payTypeName: "name",
    },
    uiType: "checkbox",
    props:{
      valueKey:'code'
    }
 }

4.方案实现原理以及的代码展示

在性能上市面上的动态表单引擎大多是基于依赖收集来实现的,这样做可以实现细粒度的精准更新。但是本方案不走与别人相同的路(哈哈)采用的是基于Vue3的computed做实时全表单项扫描更新(某个表单项值变动则重新计算并更新所有子项 )。这种方案的优点是实现相对简单,缺点是在大表单(表单项超过50+)场景下性能会有所折损。在实际测试中表单项在100+会有明显联动卡顿现象,50+的时候变现还是很流畅,无明显卡顿现象,可能也是基于Vue3优秀的性能表现。但是大多数场景是不需要大表单的,即使需要大表单我们也可以通过设计拆成多个子表单来实现,这也是一种思路。

代码结构

核心代码

share/index.ts

js 复制代码
function collectVisibleFormItems(formItems: FormItem[], formData: any) {
  const formDataValue = { ...formData };
  return formItems
    .map((formItem: RenderFormItem) => {
      const field = formItem.field.toString() || "";
      const value = formDataValue[field];
      const newFormItem: RenderFormItem = {
        ...formItem,
        field,
        props: { ...(formItem.props || {}) },
        events: { ...(formItem.events || {}) },
      };

      if (isFunction(formItem.visiable)) {
        newFormItem.visiable = formItem.visiable(formData);
      } else if (!isEmpty(formItem.visiable) && isObject(formItem.visiable)) {
        newFormItem.visiable = fieldRulePaser(formItem.visiable, formDataValue);
      } else {
        newFormItem.visiable =
          formItem.visiable === undefined ? true : formItem.visiable;
      }

      if (formItem.uiType == "select" && formItem.props.disabled) {
        newFormItem.props.disabled = formItem.props.disabled;
      } else if (isFunction(formItem.disabled)) {
        newFormItem.props.disabled = formItem.disabled(formDataValue);
      } else if (isObject(formItem.disabled) && !isEmpty(formItem.visiable)) {
        newFormItem.props.disabled = fieldRulePaser(
          formItem.disabled,
          formDataValue
        );
      } else {
        newFormItem.props.disabled = !!formItem.disabled;
      }

      //联动过滤场景
      if (isFunction(formItem?.props?.filter)) {
        newFormItem.props.filter = formItem.props.filter(formDataValue);
      } else if (isObject(formItem?.props?.filter)) {
        newFormItem.props.filter = formItem?.props?.filter;
      }
      // change事件代理
      ["change", "onChange", "on-change"].forEach((eventName) => {
        newFormItem.events[eventName] = (options: any) =>
          onChangeProxy(options, formData, formItem);
      });

      return newFormItem;
    })
    .filter((formItem) => formItem.visiable);
}

function collectFieldsWillOutput(
  visiableFormItems: FormItem[],
  primaryKey = "id"
) {
  return visiableFormItems.reduce(
    (fields, formItem) => {
      fields.push(formItem.field?.toString());
      if (Array.isArray(formItem.extendField)) {
        fields = [...fields, ...formItem.extendField];
      } else if (isObject(formItem.extendField)) {
        fields = [...fields, ...Object.keys(formItem.extendField)];
      }
      return fields;
    },
    [primaryKey]
  );
}

function collectFormRules(formItems: FormItem[]) {
  return formItems.reduce((rules, formItem) => {
    if ((formItem.rule || []).length > 0) {
      rules[formItem.field] = formItem.rule;
    }
    if (formItem.required) {
      rules[formItem.field] = [
        {
          required: true,
          message: `${formItem.label}不能为空!`,
          trigger: "blur",
        },
      ];
    }
    return rules;
  }, {});
}

function useForm(FormItems: RenderFormItem[] | SearchFormItem[]) {
  const form = reactive<FormData>({
    data: {},
    rules: {},
    items: [],
    itemCacheByField: {},
    fieldsWillOutput: [],
  });

  const formItems = computed(() => FormItems.map(mergePorps));

  form.rules = computed(() => collectFormRules(form.items));

  form.items = computed(() => {
    // console.time("formRenderItemsUpdate");
    const visibleFormItems = collectVisibleFormItems(
      formItems.value,
      form.data
    );
    form.fieldsWillOutput = collectFieldsWillOutput(visibleFormItems);
    // console.timeEnd("formRenderItemsUpdate");
    return visibleFormItems;
  });

  form.itemCacheByField = form.items.reduce((cache, formItem) => {
    cache[formItem.field] = formItem;
    return cache;
  }, {});

  return form;
}

//表单渲染
export function useRenderForm(props: {
  modelValue?: any;
  formItems?: RenderFormItem[];
}) {
  const form = useForm(props.formItems);

  const cancelWatch = watch(
    () => props.modelValue,
    (dataValue) => {
      if (!isObject(dataValue) || isEmpty(dataValue)) {
        return;
      }
      // nextTick(() => cancelWatch());
      setInputFormValue(form.data, props.formItems, props.modelValue || {});
    },
    { immediate: true }
  );

  return form;
}

//列表搜索条件表单
export function useFilterForm(filterItems: SearchFormItem[]) {
  const form = useForm(filterItems);
  // 初始化数据查询数据
  form.data = form.items.reduce((data, formItem) => {
    const field = formItem.field;
    const hasInitValue = formItem.initValue !== undefined;
    if (hasInitValue) {
      data[field] = formItem.initValue;
    }
    if (!hasInitValue) {
      data[field] = field.includes(",") ? [] : null;
    }
    return data;
  }, {});
  return form;
}

formValueParser.ts

js 复制代码
import { get, set } from "lodash";
import { isEmpty, isObject } from "../../utils/valueTypeCheck";
const isMultipleField = (field: string) => field.includes(",");
const isDeepObjField = (field: string) => field.includes(".");
const getDeepObjValue = (sourceData: any, field: string) => {
  let value = get(sourceData, field);
  if (!value) {
    const targetKey = field.split(".").reverse()[0];
    value = get(sourceData, targetKey);
  }
  return value;
};
export function setInputFormValue(
  result: any,
  formItems: FormItem[],
  inputFormData: any = {}
) {
  formItems.forEach((formItem: FormItem) => {
    const field = formItem.field.toString();
    const value = get(inputFormData, field);

    const extendField = formItem.extendField;

    if (isObject(extendField) && !isEmpty(inputFormData)) {
      Object.keys(extendField).forEach((field) => {
        const fieldValue = get(inputFormData, field);
        fieldValue && set(result, field, fieldValue);
      });
    }

    if (value) {
      result[field] = value;
      return;
    }

    if (isMultipleField(field)) {
      const values = field.split(",").map((key) => {
        let value = get(inputFormData, key);
        if (!value && isDeepObjField(key)) {
          value = getDeepObjValue(inputFormData, key);
        }
        return value;
      });
      result[field] = values;
      return;
    }

    if (isDeepObjField(field) && !isMultipleField(field)) {
      result[field] = getDeepObjValue(inputFormData, field);
      return;
    }
  });
}

function setOutputFormValue(result: any = {}, field: string, value: any) {
  if (!field.includes(",")) {
    set(result, field, value);
    return;
  }
  const [startField, endField] = field.split(",");

  if (isObject(value) && "min" in value && "max" in value) {
    const { min, max } = value;
    set(result, startField, min || 0);
    set(result, endField, max || 0);
    return;
  }
  if (Array.isArray(value)) {
    const [startValue, endValue] = value;
    set(result, startField, startValue);
    set(result, endField, endValue);
    return;
  }
}

// 格式化输出
export function formatFormRenderOuputValue(formData, fieldsWillOutput) {
  let result: any = {};
  console.log("formData", formData);
  Object.keys(formData)
    .filter((field) => fieldsWillOutput.includes(field))
    .forEach((field: string) => {
      const formItemValue = formData[field];
      setOutputFormValue(result, field, formItemValue);
    });
  return result;
}

export function formatSearchFormOuputValue(
  formData: any,
  fieldsWillOutput
): any {
  const result: any = {};
  Object.keys(formData)
    .filter((field) => fieldsWillOutput.includes(field))
    .forEach((field: string) => {
      const formItemValue = formData[field];
      formItemValue && setOutputFormValue(result, field, formItemValue);
    });
  return Object.keys(result).reduce((res, key) => {
    if (!isEmpty(result[key])) {
      res[key] = result[key];
    }
    return res;
  });
}

formRuleParser.ts

js 复制代码
import { isEmpty, isString } from "../../utils/valueTypeCheck";

export interface RuleType {
  equals?: string;
  not?: string;
  in?: string;
  notIn?: string;
  includes?: string | string[];
  excludes?: string | string[];
  empty?: boolean;
  lt?: number;
  lte?: number;
  gt?: number;
  gte?: number;
}

const ruleValueCompareFunc = {
  equals: (formItemValue, ruleValue) => formItemValue === ruleValue,
  not: (formItemValue, ruleValue) => formItemValue !== ruleValue,
  in: (formItemValue, ruleValue) =>
    !isEmpty(ruleValue) && ruleValue.includes(formItemValue),
  notIn: (formItemValue, ruleValue) =>
    !isEmpty(ruleValue) && !ruleValue.includes(formItemValue),
  includes: (formItemValue, ruleValue) =>
    !isEmpty(formItemValue) && formItemValue.includes(ruleValue),
  excludes: (formItemValue, ruleValue) =>
    formItemValue && !formItemValue.includes(ruleValue),
  empty: (formItemValue, ruleValue) =>
    ruleValue ? isEmpty(formItemValue) : !isEmpty(formItemValue),
  notEmpty: (formItemValue, ruleValue) =>
    ruleValue ? !isEmpty(formItemValue) : isEmpty(formItemValue),
  lt: (formItemValue, ruleValue) =>
    !isEmpty(ruleValue) && formItemValue < ruleValue,
  lte: (formItemValue, ruleValue) =>
    !isEmpty(ruleValue) &&
    (formItemValue < ruleValue || formItemValue == ruleValue),
  gt: (formItemValue, ruleValue) =>
    !isEmpty(ruleValue) && formItemValue > ruleValue,
  gte: (formItemValue, ruleValue) =>
    !isEmpty(ruleValue) &&
    (formItemValue > ruleValue || formItemValue == ruleValue),
};

const ruleValueCompareFuncNames: string[] = Object.keys(ruleValueCompareFunc);

const parseFormItemKey = (formItemKey) => {
  const isOr = formItemKey.startsWith("$");
  const key = isOr ? formItemKey.substring(1) : formItemKey;
  return {
    key,
    condition: isOr ? "||" : "&&",
  };
};

const parseRule = (rule: any) => {
  if (isString(rule)) {
    return rule;
  }
  let valueStr = "";
  Object.keys(rule).forEach((ruleName, index) => {
    let { key, condition } = parseFormItemKey(ruleName);
    condition = index == 0 ? "" : condition;
    if (!ruleValueCompareFuncNames.includes(key)) {
      return;
    }
    const formItemValue = rule.formItemValue;
    const ruleValue = rule[ruleName];

    valueStr += `${condition}${ruleValueCompareFunc[key](
      formItemValue,
      ruleValue
    )}`;
  });
  return valueStr ? `(${valueStr})` : "";
};

const parseFields = (fieldRules: any = {}, formData: any = {}) => {
  const rules = [];
  Object.keys(fieldRules).forEach((formItemKey, index) => {
    const { key, condition } = parseFormItemKey(formItemKey);
    const formItemValue = formData[key];
    const fieldRule = fieldRules[formItemKey];
    fieldRule["formItemValue"] = formItemValue;
    if (index == 0) {
      rules.push(fieldRule);
      return;
    }
    rules.push(condition);
    rules.push(fieldRule);
  });
  return rules;
};

export const fieldRulePaser = (fieldRules, formData) => {
  const rules = parseFields(fieldRules, formData).map((fieldRule) =>
    isString(fieldRule) ? fieldRule : parseRule(fieldRule)
  );
  return eval(
    rules.reduce((initValue, itemValue) => (initValue += itemValue), "")
  );
};

eventProxy.ts (extendField实现核心)

js 复制代码
import {
  isObject,
  isArray,
  isString,
  isFunction,
  isEmpty,
} from "@/components/utils/valueTypeCheck";

function loop() {}

function getOptionValue(options, isMultiple) {
  return isMultiple ? options : options[0];
}

export function onChangeProxy(options: any, formData: any, formItem: FormItem) {
  let { extendField, events } = formItem;

  // 参数归一化
  events = events || {};
  const changeEvent = events.change || events.onChange || events["on-change"];
  const dispatchChangeEvent = isFunction(changeEvent) ? changeEvent : loop;

  const isMultiple = Array.isArray(options);

  //选中数据归一化为数组
  let selections: any[] = isMultiple ? options : [options].filter(Boolean);

  if (isEmpty(extendField) || !isObject(extendField)) {
    dispatchChangeEvent(getOptionValue(selections, isMultiple), formData);
    return;
  }

  /******************存在extendField情况处理*************************/
  const result: any = {};
  for (let key in extendField) {
    let values = null;
    let extendFieldValue = extendField[key];
    if (isObject(extendFieldValue)) {
      const sourceKeys = Object.values(extendFieldValue);
      const targetKeys = Object.keys(extendFieldValue);
      values = selections.map((item: any) => {
        return sourceKeys.reduce((newItem: any, sourceKey: string, index) => {
          const targetKey = targetKeys[index];
          newItem[targetKey] = item[sourceKey];
          return newItem;
        }, {});
      });
    } else if (isArray(extendFieldValue)) {
      values = selections.map((item: any) => {
        return extendFieldValue.reduce(
          (newItem: any, sourceKey: string, index) => {
            newItem[sourceKey] = item[sourceKey];
            return newItem;
          },
          {}
        );
      });
    } else if (isString(extendFieldValue)) {
      values = selections.map((item) => item[extendFieldValue]);
    }

    result[key] = isMultiple ? values : values[0];
  }

  Object.assign(formData, result);
  dispatchChangeEvent(getOptionValue(selections, isMultiple), formData);
}

RormRender.vue

js 复制代码
<template>
  <el-form @submit.prevent="() => { }" ref="formInstance" v-bind="Object.assign({ labelWidth: 80 }, $attrs)"
    :model="form.data" :rules="form.rules">
    <template v-for="(item, index) in form.items" :key="item.field || index">
      <FormItemRender :item="item" v-model="form.data[item.field]"></FormItemRender>
    </template>
  </el-form>
</template>

<script setup lang="ts">
import FormItemRender from "./FormItemRender.vue";
import { useRenderForm, formatFormRenderOuputValue } from "./shared";
import { ElForm } from "element-plus";

console.time("FormRenderInitSpendTime");

export interface Props {
  modelValue?: any;
  formItemSpan?: number;
  formItems: RenderFormItem[] | any[];
}

const props = withDefaults(defineProps<Props>(), {
  modelValue: () => ({}),
  formItems: () => [],
  formItemSpan: 24,
});

let form = useRenderForm(props);

const formInstance = ref<typeof ElForm>(null);

let getFormData = async () => {
  if (!formInstance.value) return;
  const isValid = await formInstance.value.validate();
  return isValid ? formatFormRenderOuputValue(form.data, form.fieldsWillOutput) : null;
};

const emit = defineEmits(["update:modelValue"]);

const updateModelValue = (value: any) => {
  emit("update:modelValue", value);
};

const submit = async (callback: Function) => {
  if (callback && typeof callback !== "function") {
    console.error(`submit 回调函数必须是方法`);
    return;
  }
  const formData = await getFormData();
  callback(formData);
  updateModelValue(formData);
  return formData;
};

const resetFields = () => {
  formInstance.value.resetFields();
};

defineExpose({
  submit,
  resetFields,
  getFormData,
});
</script>

FormItemRender.vue

js 复制代码
<template>
  <el-form-item ref="formItemInstance" v-bind="props.item" :prop="props.item.field">
    <el-input
      v-if="uiType == 'input'"
      v-bind="props.item.props"
      v-on="props.item.events"
      v-model="formItemValue"
    >
    </el-input>
    <BasicSelector
      v-else-if="uiType == 'selector'"
      :validate-event="false"
      v-bind="props.item.props"
      v-on="props.item.events"
      v-model="formItemValue"
    >
    </BasicSelector>
    <DateTimePickerPlus
      v-else-if="uiType == 'datetimepicker'"
      v-bind="props.item.props"
      v-on="props.item.events"
      v-model="formItemValue"
    >
    </DateTimePickerPlus>
    <RichTextEditor
      v-else-if="uiType == 'richtexteditor'"
      v-bind="props.item.props"
      v-on="props.item.events"
      v-model="formItemValue"
    >
    </RichTextEditor>

    <RadioPlus
      v-else-if="uiType == 'radio'"
      v-bind="props.item.props"
      v-on="props.item.events"
      v-model="formItemValue"
    >
    </RadioPlus>

    <CheckBoxPlus
      v-else-if="uiType == 'checkbox'"
      v-bind="props.item.props"
      v-on="props.item.events"
      v-model="formItemValue"
    >
    </CheckBoxPlus>

    <component
      v-else
      :is="uiType"
      v-bind="props.item.props"
      v-on="props.item.events"
      v-model="formItemValue"
    >
    </component>
  </el-form-item>
</template>
<script setup lang="ts">
import BasicSelector from "../Selectors/BasicSelector.vue";
import DateTimePickerPlus from "../Selectors/DateTimeSelector/DateTimeSelector.vue";
import RichTextEditor from "../RichTextEditor/RichTextEditor.vue";
import RadioPlus from "../Selectors/Radio.vue";
import CheckBoxPlus from "../Selectors/Checkbox.vue";
interface Props {
  item: RenderFormItem;
  modelValue: any;
}

const formItemInstance = ref<any>();

const props = defineProps<Props>();

const uiType = computed(() =>
  // props.item.uiType
  typeof props.item.uiType == "string"
    ? props.item.uiType.toLocaleLowerCase()
    : props.item.uiType
);

const emit = defineEmits(["update:modelValue"]);

const formItemValue = computed({
  get() {
    return props.modelValue;
  },
  set(val) {
    emit("update:modelValue", val);
  },
});
</script>

SearchForm.vue

js 复制代码
<style lang="scss">
.search-form {
  --el-date-editor-width: 100%;
  height: unset;
  transition: all 0.3s linear;
  will-change: height;
  text-align: left;

  .el-form-item {
    position: relative;
    width: 98%;
  }

  .el-form-item__content-right {
    .el-form-item__content {
      justify-content: flex-end;
    }
  }

  .el-form-item__label {
    position: absolute;
    left: 0px;
    top: -6px;
    z-index: 10;
    width: fit-content;
    height: 12px;
    line-height: 12px !important;
    padding: 0 6px;
    margin: 0;
    color: #999;
    background: #fff;
    transform: scale(0.75);
  }

  .el-input,
  .el-input__inner {
    width: 100% !important;
    // text-align: center;
    color: #2d4457;
  }

  .el-button {
    padding: 6px 12px;
  }

  .el-button+.el-button {
    margin-left: 4px;
  }

  section[uitype="dateTimePicker"] {
    text-align: left !important;
  }
}
</style>

<template>
  <el-form ref="formInstance" class="search-form" inline :model="form.data" :rules="form.rules"
    @keyup.native.enter.pre="dispatchSearch(form.data)">
    <el-row>
      <el-col v-for="item in visibleFormItems" :key="item.field" :span="item.span || LayoutConfig.defaultItemSpan">
        <el-form-item :label="`${item.label}`" v-bind="item" :prop="item.field">
          <FormItemRender :item="item" v-model="form.data[item.field]"></FormItemRender>
        </el-form-item>
      </el-col>
      <el-col :span="LayoutConfig.actionSpan" :offset="fullCosapse ? 18 : 0">
        <el-form-item :class="{ 'el-form-item__content-right': fullCosapse }">
          <el-button type="primary" @click="dispatchSearch(form.data)">查询</el-button>
          <el-button @click="resetForm()">清空</el-button>
          <el-button type="default" text v-show="LayoutConfig.hasMore" @click="collapse = !collapse">
            {{ !collapse ? "展开" : "收起" }}
            <el-icon style="vertical-align: middle">
              <ArrowDown v-show="!collapse"></ArrowDown>
              <ArrowUp v-show="collapse"></ArrowUp>
            </el-icon>
          </el-button>
        </el-form-item>
      </el-col>
    </el-row>
  </el-form>
</template>

<script setup lang="ts">
import { ArrowDown, ArrowUp } from "@element-plus/icons-vue";
import { useFilterForm, formatSearchFormOuputValue } from "./shared";
import FormItemRender from "./FormItemRender.vue";

export interface Props {
  filterItems: SearchFormItem[];
}

const props = withDefaults(defineProps<Props>(), {
  filterItems: () => [],
});

const form = useFilterForm(props.filterItems);

/*****触发查询逻辑******/
const emit = defineEmits(["search", "update:height"]);

const formInstance = ref(null);

const dispatchSearch = (formData) => {
  formInstance.value.validate((isValid) => {
    if (!isValid) return;
    emit("search", formatSearchFormOuputValue(formData, form.fieldsWillOutput));
  });
};

const resetForm = () => {
  // Object.keys(form.data).forEach(
  //   (key) => (form.data[key] = Array.isArray(form.data[key]) ? [] : null)
  // );
  formInstance.value.resetFields();
};

onMounted(() => dispatchSearch(form.data));

/**操作按钮动态布局控制**/
const LayoutConfig = computed(() => {
  // 24列栅格布局
  const totalSpanEveryRow = 24;
  // 每行放置Item个数
  const formItemNumOfEveryRow = 4;
  //操作栏占用
  const actionSpan = 6;

  const defaultItemSpan = totalSpanEveryRow / formItemNumOfEveryRow;

  const formLength = props.filterItems.length;

  return {
    formItemNumOfEveryRow,
    hasMore: formLength > formItemNumOfEveryRow - 1,
    defaultItemSpan,
    actionSpan,
  };
});

const visibleFormItems = ref<FormItem[]>([]);

const collapse = ref<boolean>(false);

const fullCosapse = ref<boolean>(false);

const filterVisibleFormItems = (isCollapse: boolean) => {
  const totalFormItemNum = props.filterItems.length;
  const formItemNumOfEveryRow = LayoutConfig.value.formItemNumOfEveryRow;
  // 元素是否刚好占满一行
  const isFull = totalFormItemNum % formItemNumOfEveryRow == 0;

  visibleFormItems.value = form.items.filter((formItem, index) => {
    if (index + 1 < formItemNumOfEveryRow || isCollapse) {
      return formItem;
    }
  });

  fullCosapse.value = isFull && isCollapse;
};

/***更新控件高度***/
const updateHeight = () => {
  nextTick(() => emit("update:height", formInstance.value.$el.clientHeight));
};

const stopWatch = watch(
  collapse,
  (isCollapse: boolean) => {
    updateHeight();
    filterVisibleFormItems(isCollapse);
  },
  { immediate: true }
);

onUnmounted(() => stopWatch());
</script>

至此本方案设计以及代码分享至此结束。

尚未解决问题:

1、动态全局更新会触发必填控件校验,影响体验

2、部分控件的slot支持(已有方案,待实现后确认可行再补充该文分享)

如有更好的解决方案,望不吝赐教~

看到这里的道友,你已经沾染了因果,唯有点赞方能破除~~~

下集预告:设计一个好用的弹窗表单

相关推荐
雷特IT17 分钟前
Uncaught TypeError: 0 is not a function的解决方法
前端·javascript
长路 ㅤ   40 分钟前
vite学习教程02、vite+vue2配置环境变量
前端·vite·环境变量·跨环境配置
亚里士多没有德7751 小时前
强制删除了windows自带的edge浏览器,重装不了怎么办【已解决】
前端·edge
micro2010141 小时前
Microsoft Edge 离线安装包制作或获取方法和下载地址分享
前端·edge
.生产的驴1 小时前
Electron Vue框架环境搭建 Vue3环境搭建
java·前端·vue.js·spring boot·后端·electron·ecmascript
awonw1 小时前
[前端][easyui]easyui select 默认值
前端·javascript·easyui
老齐谈电商1 小时前
Electron桌面应用打包现有的vue项目
javascript·vue.js·electron
LIURUOYU4213081 小时前
vue.js组建开发
vue.js
九圣残炎1 小时前
【Vue】vue-admin-template项目搭建
前端·vue.js·arcgis
《源码好优多》2 小时前
基于SpringBoot+Vue+Uniapp的植物园管理小程序系统(2024最新,源码+文档+远程部署+讲解视频等)
vue.js·spring boot·uni-app