1、背景

vue后台管理系统,会有很多表格页面,表格上方会有一些搜索选项。表格直接使用a-table即可,而搜索栏区域每次写起来都很繁琐,且多人开发情况下每个人写的样式都不相同,布局样式无法统一。

所以要考虑对搜索栏做一个封装,统一配置引用,提升开发维护效率和界面统一。

完成后的效果大概就是长这样:在这里插入图片描述

2、分析

项目使用的是antd框架,搜索栏这种表单提交,首先要使用a-form-model组件来封装,而复杂点就是表单项可能有很多种,例如input输入框、select选择框、日期时间选择框、日期时间范围选择框、cascader级联选择框等,每一项的字段名prop、名称label、绑定的属性方法都不尽相同。所以不能通过普通的绑定个别属性的方式来处理,而slot插槽的方式也无法简化,最终决定通过传递一个配置项数组的形式来解析生成相应的结构。

3、实现

目前实现的方式由两部分组成,一部分是form表单组件,接受父组件传递的配置项数组,一部分是封装一些常用的表单项组件,通过v-if来控制,form表单组件里引入该表单项组件,循环遍历,根据传递的表单项类型来匹配显示具体的表单项。

(1)form表单组件(searchForm.vue)示例代码:

  <div class="search-form-box">
    <a-row>
      <a-form-model 
        :model="formData"
        ref="formRef"
        layout="horizontal"
        :label-col="labelCol"
        :wrapper-col="wrapperCol"
      >
        <a-col :span="6" v-for="(item, index) in formOptions" :key="newKeys[index]">
          <a-form-model-item
            :prop="item.prop"
            :label="item.label ? item.label + ':' : ''"
            :rules="item.rules"
          >
            
              <formItem v-model="formData[item.prop]" :itemOptions="item" :needParams="needParams" />
          </a-form-model-item>
        </a-col>
        <!-- 自定义插槽,可用于特殊表单块 -->
        <slot></slot>
      </a-form-model>
    </a-row>
    <a-row type="flex" justify="end">
      <!-- 提交按钮 -->
      <div class="btn-box">
        <a-button
          v-if="btnItems.includes('search')"
          type="primary"
          class="btn-search"
          @click="onSearch"
          >搜索</a-button
        >

        <a-button
          v-if="btnItems.includes('export')"
          type="primary"
          class="btn-export"
          @click="onExport"
          >导出</a-button
        >

        <a-button
          v-if="btnItems.includes('reset')"
          type="default"
          class="btn-reset"
          @click="onReset"
          >重置</a-button
        >
      </div>
    </a-row>
  </div>

(2)formItem表单项组件(formItem.vue)示例代码:

<div class="form-item">
    <a-input
      v-if="isInput"
      v-model="currentVal"
      v-bind="bindProps"
      v-on="bindEvents"
      allowClear
    />

    <a-input-number
      v-if="isInputNumber"
      v-model="currentVal"
      v-bind="bindProps"
      v-on="bindEvents"
      allowClear
    />

    <a-select
      v-if="isSelect"
      v-model="currentVal"
      v-bind="bindProps"
      v-on="bindEvents"
      allowClear
    >
      <a-select-option
        v-for="item in itemOptions.options"
        :key="item.value"
        :label="item.label"
        :value="item.value"
      ></a-select-option>
    </a-select>

    <!-- datetimerange/daterange -->
    <a-range-picker
      v-if="isDatePickerDateRange"
      v-model="currentVal"
      v-bind="bindProps"
      v-on="bindEvents"
      allowClear
      :placeholder="['开始日期', '结束日期']"
      separator="至"
      :default-time="['00:00:00', '23:59:59']"
      valueFormat="YYYY-MM-DD"
    />

    <!-- monthrange -->
    <a-date-picker
      v-if="isDatePickerMonthRange"
      v-model="currentVal"
      v-bind="bindProps"
      v-on="bindEvents"
      type="monthrange"
      allowClear
      :placeholder="['开始日期', '结束日期']"
      range-separator="至"
      valueFormat="YYYY-MM"
    ></a-date-picker>

    <!-- others -->
    <a-date-picker
      v-if="isDatePickerOthers"
      v-model="currentVal"
      v-bind="bindProps"
      v-on="bindEvents"
      allowClear
      placeholder="请选择日期"
      valueFormat="YYYY-MM-DD"
    ></a-date-picker>

    <a-cascader
      v-if="isCascader"
      v-model="currentVal"
      v-bind="bindProps"
      v-on="bindEvents"
      :options="itemOptions.options"
      allowClear
    ></a-cascader>
  </div>

4、关键点

由于antd表单组件本身有很多配置属性,不可能把所有的属性和方法都写死封装,要想无缝支持,需要用到vue的v-bind和v-on特性,vue的v-bind和v-on支持赋值为对象类型,vue会自动遍历对象里的属性依次绑定,v2.4.0+支持。

5、参数配置项解释

(1)示例:

 /**
     * 表单配置
     * 示例:
     * [{
     *   label: '用户名', // label文字
     *   prop: 'username', // 字段名
     *   type: 'input', // 指定antd组件
     *   defaultValue: '阿黄', // 字段初始值
     *   placeholder: '请输入用户名', // antd组件属性
     *   rules: [{ required: true, message: '必填项', trigger: 'blur' }], // antd组件属性
     *   events: { // antd组件方法
     *     input (val) {
     *       console.log(val)
     *     },
     *     ...... // 可添加任意antd组件支持的方法
     *   }
     *   ...... // 可添加任意antd组件支持的属性
     * }]
     */

具体扩展属性可参考antd官方文档(antd官方文档

(2)参数传递解析的流程:

  1. 首先,searchForm.vue组件里通过props接收参数:
formOptions: {
     type: Array,
     required: true,
     default () {
       return []
     }
     /**
    * 请求所需参数
    * 示例:
    * needParams: {
    *   // 参数名
    *   // 参数值
    *   token: "123"
    *  },
    */
   needParams: {
     type: Object,
     default() {
       return {};
     }
   },
},

  1. created生命周期里处理初始值:
// 添加初始值
addInitValue () {
 const obj = {}
 this.formOptions.forEach(v => {
   if (v.initValue !== undefined) {
     obj[v.prop] = v.initValue
   }
 })
 this.formData = obj
}
  1. 一部分配置项绑定在a-form-model-item上,一部分传递给formItem表单项组件再绑定:
<a-form-model 
        :model="formData"
        ref="formRef"
        layout="horizontal"
        :label-col="labelCol"
        :wrapper-col="wrapperCol"
      >
        <a-col :span="6" v-for="(item, index) in formOptions" :key="newKeys[index]">
          <a-form-model-item
            :prop="item.prop"
            :label="item.label ? item.label + ':' : ''"
            :rules="item.rules"
          >
              <formItem v-model="formData[item.prop]" :itemOptions="item" :needParams="needParams" />
          </a-form-model-item>
        </a-col>
        <!-- 自定义插槽,可用于特殊表单块 -->
        <slot></slot>
</a-form-model>
  1. formItem.vue表单项组件里props接受传参:
itemOptions: {
  type: Object,
  default () {
    return {}
  }
}
  1. computed里处理接收的参数itemOptions,生成要绑定的所有属性对象bindProps:
 // 绑定属性
    bindProps() {
      let obj = { ...this.itemOptions };
      // 移除冗余属性
      delete obj.label;
      delete obj.prop;
      delete obj.type;
      delete obj.defaultValue;
      delete obj.rules;
      delete obj.events;
      if (obj.type === "select") {
        delete obj.options;
      }
      return obj;
    },
  1. computed里生成要绑定的所有方法对象bindEvents:
 // 绑定方法
   bindEvents() {
     return this.itemOptions.events || {};
   },
  1. 处理请求所需参数
// 获取请求参数
   requestParams() {
     let params = { ...this.needParams };
     let results = {};
     if (Array.isArray(this.itemOptions.dicParamsList)) {
       this.itemOptions.dicParamsList.forEach(item => {
         if(item.value){
           results[item.key] = item.value;
         }else {
           results[item.key] = params[item.key];
         }
       });
     }
     return results;
   },
  1. 最后组件上使用
<a-input
     v-if="isInput"
     v-model="currentVal"
     v-bind="bindProps"
     v-on="bindEvents"
     allowClear
   />

(3)特殊情况的处理

由于antd的a-select里是通过a-select-option遍历实现的,而遍历数组options按antd官方不是绑定在a-select上的,所以针对a-select的配置项再加一个options属性,即select选择项的数据数组。

<a-select
      v-if="isSelect"
      v-model="currentVal"
      v-bind="bindProps"
      v-on="bindEvents"
      allowClear
    >
      <a-select-option
        v-for="item in itemOptions.options"
        :key="item.value"
        :label="item.label"
        :value="item.value"
      ></a-select-option>
    </a-select>

created中请求数据

created() {
    // select 动态添加options
    if (this.itemOptions.url) {
      axios({
        url: this.itemOptions.url,
        params: this.requestParams
      }).then(res => {
        this.itemOptions.options = res.data && res.data[this.itemOptions.propsHttpRes];
      })
    }
  },

6、按钮组

目前通过prop传入,三个按钮

// 提交按钮项,多个用逗号分隔(search搜索, export导出, reset重置)
btnItems: {
  type: String,
  default () {
    return 'search'
  }
}

7、使用方式示例

template

<searchForm
      :formOptions="formOptions"
      :needParams="needParams"
      btnItems="search, export, reset"
    />

data

formOptions: [
        {
          label: "用户名很长是是是", // label文字
          prop: "username", // 字段名
          type: "input", // 指定antd组件
          defaultValue: "阿黄", // 字段初始值
          placeholder: "请输入用户名", // antd组件属性
          rules: [{ required: true, message: "必填项", trigger: "blur" }], // antd组件属性
          events: {
            // antd组件方法
            input(val) {
              console.log(val);
            },
          },
        },
        {
          label: "年龄", // label文字
          prop: "age", // 字段名
          type: "number", // 指定antd组件
          defaultValue: 18, // 字段初始值
          placeholder: "请输入年龄", // antd组件属性
          rules: [{ required: true, message: "必填项", trigger: "blur" }], // antd组件属性
          events: {
            // antd组件方法
            input(val) {
              console.log(val);
            },
          },
        },
        {
          label: "性别", // label文字
          prop: "sex", // 字段名
          type: "select", // 指定antd组件
          defaultValue: "", // 字段初始值
          placeholder: "请选择性别", // antd组件属性
          options: [
            {
              label: "男",
              value: "1",
            },
            {
              label: "女",
              value: "2",
            },
          ],
          events: {
            // antd组件方法
            change(val) {
              console.log(val);
            },
          },
        },
        {
          label: "下拉框", // label文字
          prop: "address", // 字段名
          type: "select", // 指定antd组件
          defaultValue: "", // 字段初始值
          placeholder: "请选择性别", // antd组件属性
          options: [],
          url: 'http://rap2api.taobao.org/app/mock/270426/city',
          methods: 'get',
          dicParamsList: [
            {
              key: 'token'
            },
            {
              key: 'bussinessId',
              value: '324'
            }
          ],
          filedName: {
            label: 'name',
            value: 'id'
          },
          propsHttpRes: 'data',
          events: {
            // antd组件方法
            change(val) {
              console.log(val);
            },
          },
        },
        {
          label: "项目地址", // label文字
          prop: "project", // 字段名
          type: "cascader", // 指定antd组件
          defaultValue: [], // 字段初始值
          placeholder: "请选择性别", // antd组件属性
          options: [],
          url: 'http://rap2api.taobao.org/app/mock/270426/getCascaderList',
          methods: 'get',
          dicParamsList: [
            {
              key: 'token'
            },
            {
              key: 'bussinessId',
              value: '324'
            }
          ],
          filedName: {
            label: 'name',
            value: 'id'
          },
          propsHttpRes: 'data',
          events: {
            // antd组件方法
            change(val) {
              console.log(val);
            },
          },
        },
        {
          label: "到货日期", // label文字
          prop: "arrialDate", // 字段名
          type: "range-picker", // 指定antd组件
          defaultValue: [], // 字段初始值
          placeholder: "请选择", // antd组件属性
          events: {
            // antd组件方法
            change(val) {
              console.log(val);
            },
          },
        },
        {
          label: "发货日期", // label文字
          prop: "delieverDate", // 字段名
          type: "date-picker", // 指定antd组件
          defaultValue: '', // 字段初始值
          placeholder: "请选择", // antd组件属性
          events: {
            // antd组件方法
            change(val) {
              console.log(val);
            },
          },
        },
      ],

8、完整代码

(1)searchForm.vue

<template>
  <div class="search-form-box">
    <a-row>
      <a-form-model 
        :model="formData"
        ref="formRef"
        layout="horizontal"
        :label-col="labelCol"
        :wrapper-col="wrapperCol"
      >
        <a-col :span="6" v-for="(item, index) in formOptions" :key="newKeys[index]">
          <a-form-model-item
            :prop="item.prop"
            :label="item.label ? item.label + ':' : ''"
            :rules="item.rules"
          >
            
              <formItem v-model="formData[item.prop]" :itemOptions="item" :needParams="needParams" />
          </a-form-model-item>
        </a-col>
        <!-- 自定义插槽,可用于特殊表单块 -->
        <slot></slot>
      </a-form-model>
    </a-row>
    <a-row type="flex" justify="end">
      <!-- 提交按钮 -->
      <div class="btn-box">
        <a-button
          v-if="btnItems.includes('search')"
          type="primary"
          class="btn-search"
          @click="onSearch"
          >搜索</a-button
        >

        <a-button
          v-if="btnItems.includes('export')"
          type="primary"
          class="btn-export"
          @click="onExport"
          >导出</a-button
        >

        <a-button
          v-if="btnItems.includes('reset')"
          type="default"
          class="btn-reset"
          @click="onReset"
          >重置</a-button
        >
      </div>
    </a-row>
  </div>
</template>

<script>
import formItem from "./formItem";
import tools from '@/utils/tools'

export default {
  name: 'searchForm',
  props: {
    /**
     * 表单配置
     * 示例:
     * [{
     *   label: '用户名', // label文字
     *   prop: 'username', // 字段名
     *   type: 'input', // 指定antd组件
     *   defaultValue: '阿黄', // 字段初始值
     *   placeholder: '请输入用户名', // antd组件属性
     *   rules: [{ required: true, message: '必填项', trigger: 'blur' }], // antd组件属性
     *   events: { // antd组件方法
     *     input (val) {
     *       console.log(val)
     *     },
     *     ...... // 可添加任意antd组件支持的方法
     *   }
     *   ...... // 可添加任意antd组件支持的属性
     * }]
     */
    formOptions: {
      type: Array,
      required: true,
      default() {
        return [];
      }
    },
    /**
     * 请求所需参数
     * 示例:
     * needParams: {
     *   // 参数名
     *   // 参数值
     *   token: "123"
     *  },
     */
    needParams: {
      type: Object,
      default() {
        return {};
      }
    },
    // 提交按钮项,多个用逗号分隔(search, export, reset)
    btnItems: {
      type: String,
      default() {
        return "search";
      }
    }
  },

  data() {
    return {
      labelCol: {
          xs: { span: 24 },
          sm: { span: 9 },
          md: { span: 11 },
      },
      wrapperCol: {
          xs: { span: 24 },
          sm: { span: 15 },
          md: { span: 13 },
      },
      formData: {}
    };
  },

  computed: {
    // 生成新的key
    newKeys() {
      return this.formOptions.map(() => {
        return tools.createUniqueString()
      });
    }
  },

  created() {
    this.addDefaultValue();
  },

  methods: {
    // 校验
    onValidate(callback) {
      this.$refs.formRef.validate(valid => {
        if (valid) {
          console.log("提交成功");
          console.log(this.formData);
          callback();
        }
      });
    },
    // 搜索
    onSearch() {
      this.onValidate(() => {
        this.$emit("onSearch", this.formData);
      });
    },
    // 导出
    onExport() {
      this.onValidate(() => {
        this.$emit("onExport", this.formData);
      });
    },
    onReset() {
      this.$refs.formRef.resetFields();
    },
    // 添加初始值
    addDefaultValue() {
      const obj = {};
      this.formOptions.forEach(v => {
        if (v.defaultValue !== undefined) {
          obj[v.prop] = v.defaultValue;
        }
      });
      this.formData = obj;
    }
  },

  components: { formItem }
};
</script>

<style lang="scss">
.search-form-box {
  margin-bottom: 15px;

  .btn-box {
    padding-top: 5px;
    display: flex;

    button {
      height: 28px;
      margin: 0 5px;
    }
  }

  .ant-input-number {
    width: 100% ;
  }

  .ant-form-item {
    margin-bottom: 10px;
  }
}
</style>

(2)formItem.vue

<template>
  <div class="form-item">
    <a-input
      v-if="isInput"
      v-model="currentVal"
      v-bind="bindProps"
      v-on="bindEvents"
      allowClear
    />

    <a-input-number
      v-if="isInputNumber"
      v-model="currentVal"
      v-bind="bindProps"
      v-on="bindEvents"
      allowClear
    />

    <a-select
      v-if="isSelect"
      v-model="currentVal"
      v-bind="bindProps"
      v-on="bindEvents"
      allowClear
    >
      <a-select-option
        v-for="item in itemOptions.options"
        :key="item.value"
        :label="item.label"
        :value="item.value"
      ></a-select-option>
    </a-select>

    <!-- datetimerange/daterange -->
    <a-range-picker
      v-if="isDatePickerDateRange"
      v-model="currentVal"
      v-bind="bindProps"
      v-on="bindEvents"
      allowClear
      :placeholder="['开始日期', '结束日期']"
      separator="至"
      :default-time="['00:00:00', '23:59:59']"
      valueFormat="YYYY-MM-DD"
    />

    <!-- monthrange -->
    <a-date-picker
      v-if="isDatePickerMonthRange"
      v-model="currentVal"
      v-bind="bindProps"
      v-on="bindEvents"
      type="monthrange"
      allowClear
      :placeholder="['开始日期', '结束日期']"
      range-separator="至"
      valueFormat="YYYY-MM"
    ></a-date-picker>

    <!-- others -->
    <a-date-picker
      v-if="isDatePickerOthers"
      v-model="currentVal"
      v-bind="bindProps"
      v-on="bindEvents"
      allowClear
      placeholder="请选择日期"
      valueFormat="YYYY-MM-DD"
    ></a-date-picker>

    <a-cascader
      v-if="isCascader"
      v-model="currentVal"
      v-bind="bindProps"
      v-on="bindEvents"
      :options="itemOptions.options"
      allowClear
    ></a-cascader>
  </div>
</template>

<script>
import axios from "axios";
export default {
  inheritAttrs: false,

  props: {
    value: {},
    itemOptions: {
      type: Object,
      default() {
        return {};
      }
    },
    needParams: {
      type: Object,
      default: () => ({})
    }
  },

  computed: {
    // 双向绑定数据值
    currentVal: {
      get() {
        return this.value;
      },
      set(val) {
        this.$emit("input", val);
      }
    },
    // 绑定属性
    bindProps() {
      let obj = { ...this.itemOptions };
      // 移除冗余属性
      delete obj.label;
      delete obj.prop;
      delete obj.type;
      delete obj.defaultValue;
      delete obj.rules;
      delete obj.events;
      if (obj.type === "select") {
        delete obj.options;
      }
      return obj;
    },
    // 绑定方法
    bindEvents() {
      return this.itemOptions.events || {};
    },
    // 获取请求参数
    requestParams() {
      let params = { ...this.needParams };
      let results = {};
      if (Array.isArray(this.itemOptions.dicParamsList)) {
        this.itemOptions.dicParamsList.forEach(item => {
          if(item.value){
            results[item.key] = item.value;
          }else {
            results[item.key] = params[item.key];
          }
        });
      }
      return results;
    },
    // a-input
    isInput() {
      return this.itemOptions.type === "input";
    },
    // a-input-number
    isInputNumber() {
      return this.itemOptions.type === "number";
    },
    // a-select
    isSelect() {
      return this.itemOptions.type === "select";
    },
    // a-date-picker (type: datetimerange/daterange)
    isDatePickerDateRange() {
      return this.itemOptions.type === "range-picker";
    },
    // a-date-picker (type: monthrange)
    isDatePickerMonthRange() {
      return this.itemOptions.type === "month-range";
    },
    //  a-date-picker (type: other)
    isDatePickerOthers() {
      return this.itemOptions.type === "date-picker";
    },
    // a-cascader
    isCascader() {
      return this.itemOptions.type === "cascader";
    },
  },

  created() {
    // select 动态添加options
    if (this.itemOptions.url) {
      axios({
        url: this.itemOptions.url,
        params: this.requestParams
      }).then(res => {
        this.itemOptions.options = res.data && res.data[this.itemOptions.propsHttpRes];
      })
    }
  },

  methods: {},

  components: {},
};
</script>

<style lang="less" scoped></style>

依赖引入的一些函数方法 tools.js

/**
 * 创建唯一的字符串
 * @return {string} ojgdvbvaua40
 */
function createUniqueString() {
  const timestamp = +new Date() + "";
  const randomNum = parseInt((1 + Math.random()) * 65536) + "";
  return (+(randomNum + timestamp)).toString(32);
}

export default { 
  createUniqueString,
};
Logo

前往低代码交流专区

更多推荐