阅读 2237

Vue 2.x折腾记 - (16) 基于Ant Design Vue 封装一个配置式的表单搜索组件

前言

这次的后台管理系统项目选型用了Vue来作为主技术栈;

因为前段时间用过React来写过项目(用了antd),感觉棒棒的。

所以这次就排除了Element UI,而采用了Ant Design Vue;

在分析整个项目原型后,发现又可以抽离类似之前的React表格搜索组件

效果图

  • 2019-04-10 14:50 : 修正了部分的初始化props及联动,新增了slot的传递

  • 2019-04-17: 我又增加了一种布局展示,内联模式,顺带修复了一些已知的问题,组件重命名为AdvancedSearch.vue

  • 2019-04-23: 新增slider组件的配置

  • 2019-04-25:若是传入的数据长度小于最大格式,默认显示为内联模式,否则为卡片模式

  • 2019-05-12: 回调支持传入自定义函数(用于返回自己组合的数据格式)

其他特性等,具体可以看下面的思维导图.

具体业务的封装中还要复杂的多,还结合了一些自定义封装组件,展示出来代码篇幅太长。

实现思路

  • 用什么来实现组件之间的通讯

昨天写第一版的时候,思维还没绕过来,用props和自定义事件($on,$emit)来实现,

实现出来的代码量贼多,因为每细化多一层组件,复杂度就越高。各种互相回调来实现。

仔细翻了下Ant Design Vue的文档,发下可以类似React的套路实现

  • 怎么来实现

要实现一个结合业务可复用的东东,首先我们必须先梳理我们要实现的功能点。

props尽量不破坏文档控件暴露的特性,而是折中去实现,拓展。

先画个思维导图梳理下功能点

遇到的问题

  • jsx来实现的问题

一开始想用jsx来实现,发现还是太天真了。各种报错,特别对Vue指令的支持一团糟

以及函数式组件的写法也是坑挺多,没办法,乖乖的回归template的写法

vue官方提供了jsx的支持,日渐完善;Github:vue/jsx

  • 控件挤成一坨的问题

这个可能是antd vue版本的样式没处理好,我仔细排查了。若没有复写他的样式,完全没法展开。

placeholder不会自动撑开,数字控件也是很小

修正前:

修正后

  • 补全当初写react版本一些欠缺考虑的东东(比如返回的查询对象上)

用法

就普通的引入,具体暴露的propschange如下

子项会覆盖全局带过来的同名特性,优先级比较高

选项 类型 解释
responsive 对象 栅栏的布局对象
size 字符串 控件规格大小(大部分都有default,small,large)
gutter 数字 控件的间距
datetimeTotimeStamp 布尔类型 若是为true,所有时间控件都会转为时间戳返回
searchDataSource 数组对象 就是需要渲染控件的数据源,具体看源码的props
@change 函数 就是查询的回调
@callbackFormat 可选函数 传递会改动回调数据,不传递则忽略
// SearchDataSource是数据源,具体可以看props的默认值
<table-search :SearchDataSource="SearchDataSource" @change="tableSearchChange" />


<table-search :SearchDataSource="SearchDataSource" @change="tableSearchChange" @callbackFormat="formatFunc">
  <a-button type="primary" @click="test">xxxx</a-button>
  <template v-slot:extra>
    <div>fasdfas</div>
  </template>
</table-search>

// 对象默认为true的,null这个特殊对象会给if直接过滤掉
methods: {
    tableSearchChange(searchParams) {
      if (searchParams) {
        // 执行查询
      } else {
        // 执行了重置,一般默认重新请求整个不带参数的列表
      }
      console.log('回调接受的表单数据: ', searchParams);
    }
}
复制代码

代码实现

AdvancedSearch.vue

<template>
  <div class="advance-search-wrapper">
    <a-form :form="form" @submit="handleSubmit">
      <template v-if="layoutMode === 'inline'">
        <a-card :bordered="bordered">
          <a-row :gutter="gutter">
            <template v-for="(item, index) in renderDataSource">
              <field-render
                :SearchGlobalOptions="SearchGlobalOptions"
                :itemOptions="item"
                :key="item.fieldName"
                v-show="index < SearchGlobalOptions.maxItem || (index >= SearchGlobalOptions.maxItem && collapsed)"
              />
            </template>
            <a-col :style="{ width: collapsed ? '100%' : 'auto' }">
              <a-tooltip placement="bottom">
                <template slot="title">
                  <span>执行查询</span>
                </template>
                <a-button type="primary" :size="SearchGlobalOptions.size" @click="handleSubmit" icon="search">
                  查询
                </a-button>
              </a-tooltip>

              <a-tooltip placement="bottom">
                <template slot="title">
                  <span>清空所有控件的值</span>
                </template>
                <a-button
                  :size="SearchGlobalOptions.size"
                  style="margin-left: 8px"
                  @click="resetSearchForm"
                  icon="border"
                >
                  重置
                </a-button>
              </a-tooltip>
              <template v-if="showCollapsedText">
                <a @click="togglecollapsed" style="margin-left: 8px">
                  <a-tooltip placement="bottom">
                    <template slot="title">
                      <span>{{ collapsed ? '点击收起部分控件' : '点击展开所有控件' }}</span>
                    </template>
                    {{ collapsed ? '收起' : '展开' }}
                    <a-icon :type="collapsed ? 'up' : 'down'" />
                  </a-tooltip>
                </a>
              </template>
              <slot name="extra" />
            </a-col>
          </a-row>
        </a-card>
      </template>
      <template v-else>
        <a-card :bordered="bordered">
          <template v-slot:title>
            <span style="text-align:left;margin:0;">
              {{ title }}
            </span>
          </template>

          <template v-slot:extra>
            <a-row type="flex" justify="start" align="middle">
              <slot>
                <a-tooltip placement="bottom">
                  <template slot="title">
                    <span>执行查询</span>
                  </template>
                  <a-button type="primary" :size="SearchGlobalOptions.size" @click="handleSubmit" icon="search">
                    查询
                  </a-button>
                </a-tooltip>

                <a-tooltip placement="bottom">
                  <template slot="title">
                    <span>清空所有控件的值</span>
                  </template>
                  <a-button
                    :size="SearchGlobalOptions.size"
                    style="margin-left: 8px"
                    @click="resetSearchForm"
                    icon="border"
                  >
                    重置
                  </a-button>
                </a-tooltip>
              </slot>
              <template v-if="showCollapsedText">
                <a @click="togglecollapsed" style="margin-left: 8px">
                  <a-tooltip placement="bottom">
                    <template slot="title">
                      <span>{{ collapsed ? '点击收起部分控件' : '点击展开所有控件' }}</span>
                    </template>
                    {{ collapsed ? '收起' : '展开' }}
                    <a-icon :type="collapsed ? 'up' : 'down'" />
                  </a-tooltip>
                </a>
              </template>
              <slot name="extra" />
            </a-row>
          </template>

          <a-row :gutter="gutter">
            <template v-for="(item, index) in renderDataSource">
              <template v-if="item.type && item.fieldName">
                <field-render
                  :SearchGlobalOptions="SearchGlobalOptions"
                  :itemOptions="item"
                  :key="item.fieldName"
                  v-show="index < SearchGlobalOptions.maxItem || (index >= SearchGlobalOptions.maxItem && collapsed)"
                />
              </template>
            </template>
          </a-row>
        </a-card>
      </template>
    </a-form>
  </div>
</template>

<script>
import FieldRender from './FieldRender';
export default {
  name: 'AdvancedSearch',
  components: {
    FieldRender
  },
  computed: {
    showCollapsedText() {
      // 显示展开搜索和收缩的判定
      return this.renderDataSource.length > this.maxItem;
    },
    SearchGlobalOptions() {
      // 全局配置
      return {
        maxItem: this.maxItem,
        size: this.size,
        immediate: this.immediate,
        responsive: this.responsive
      };
    },
    renderDataSource() {
      // 重组传入的数据,合并全局配置,子项的配置优先全局
      return this.dataSource.map(item => ({ ...this.SearchGlobalOptions, ...item }));
    },
    layoutMode() {
      // 展示模式优化
      if (this.layout) return this.layout;
      if (this.maxItem > this.dataSource.length) {
        return 'inline';
      } else {
        return 'card';
      }
    }
  },
  props: {
    layout: {
      //搜索区域的布局
      type: String,
      default: ''
    },
    bordered: {
      // 是否显示边框
      type: Boolean,
      default: false
    },
    datetimeTotimeStamp: {
      // 是否把时间控件的返回值全部转为时间戳
      type: Boolean,
      default: false
    },
    maxItem: {
      // 超过多少个折叠
      type: Number,
      default: 4
    },
    gutter: {
      // 控件的间距
      type: Number,
      default: 48
    },
    size: {
      //  控件的尺寸
      type: String,
      default: 'default'
    },
    responsive: {
      type: Object,
      default: function() {
        return {
          xxl: 6,
          xl: 8,
          md: 12,
          sm: 24
        };
      }
    },
    title: {
      type: String,
      default: '搜索条件区域'
    },
    dataSource: {
      // 数据源
      type: Array,
      default: function() {
        return [
          {
            type: 'text', // 控件类型
            labelText: '控件名称', // 控件显示的文本
            fieldName: 'formField1',
            placeholder: '文本输入区域' // 默认控件的空值文本
          },
          {
            labelText: '数字输入框',
            type: 'number',
            fieldName: 'formField2',
            placeholder: '这只是一个数字的文本输入框'
          },
          {
            labelText: '单选框',
            type: 'radio',
            fieldName: 'formField3',
            defaultValue: '0',
            options: [
              {
                label: '选项1',
                value: '0'
              },
              {
                label: '选项2',
                value: '1'
              }
            ]
          },
          {
            labelText: '日期选择',
            type: 'datetime',
            fieldName: 'formField4',
            placeholder: '选择日期'
          },
          {
            labelText: '日期范围',
            type: 'datetimeRange',
            fieldName: 'formField5',
            placeholder: ['开始日期', '选择日期']
          },
          {
            labelText: '下拉框',
            type: 'select',
            fieldName: 'formField7',
            placeholder: '下拉选择你要的',
            options: [
              {
                label: 'text1',
                value: '0'
              },
              {
                label: 'text2',
                value: '1'
              }
            ]
          },
          {
            labelText: '联动',
            type: 'cascader',
            fieldName: 'formField6',
            placeholder: '级联选择',
            options: [
              {
                value: 'zhejiang',
                label: 'Zhejiang',
                children: [
                  {
                    value: 'hangzhou',
                    label: 'Hangzhou',
                    children: [
                      {
                        value: 'xihu',
                        label: 'West Lake'
                      },
                      {
                        value: 'xiasha',
                        label: 'Xia Sha',
                        disabled: true
                      }
                    ]
                  }
                ]
              },
              {
                value: 'jiangsu',
                label: 'Jiangsu',
                children: [
                  {
                    value: 'nanjing',
                    label: 'Nanjing',
                    children: [
                      {
                        value: 'zhonghuamen',
                        label: 'Zhong Hua men'
                      }
                    ]
                  }
                ]
              }
            ]
          }
        ];
      }
    }
  },
  data() {
    return {
      // 高级搜索 展开/关闭
      collapsed: false
    };
  },
  beforeCreate() {
    this.form = this.$form.createForm(this);
  },

  methods: {
    togglecollapsed() {
      this.collapsed = !this.collapsed;
    },
    handleParams(obj) {
      // 判断必须为obj
      if (!(Object.prototype.toString.call(obj) === '[object Object]')) {
        return {};
      }
      let tempObj = {};
      for (let [key, value] of Object.entries(obj)) {
        if (Array.isArray(value) && value.length <= 0) continue;
        if (Object.prototype.toString.call(value) === '[object Function]') continue;

        if (this.datetimeTotimeStamp) {
          // 若是为true,则转为时间戳
          if (Object.prototype.toString.call(value) === '[object Object]' && value._isAMomentObject) {
            // 判断moment
            value = value.valueOf();
          }
          if (Array.isArray(value) && value[0]._isAMomentObject && value[1]._isAMomentObject) {
            // 判断moment
            value = value.map(item => item.valueOf());
          }
        }
        // 若是为字符串则清除两边空格
        if (value && typeof value === 'string') {
          value = value.trim();
        }
        tempObj[key] = value;
      }

      return tempObj;
    },
    handleSubmit(e) {
      // 触发表单提交,也就是搜索按钮
      e.preventDefault();
      this.form.validateFields((err, values) => {
        if (!err) {
          if (this.$listeners.callBackFormat && typeof this.$listeners.callBackFormat === 'function') {
            let formatData = this.$listeners.callBackFormat(values);
            this.$emit('change', formatData);
          } else {
            const queryParams = this.handleParams(values);
            this.$emit('change', queryParams);
          }
        }
      });
    },
    resetSearchForm() {
      // 重置整个查询表单
      this.form.resetFields();
      this.$emit('change', null);
    }
  }
};
</script>

<style lang="scss">
.advance-search-wrapper {
  .ant-form-item {
    display: flex;
    margin-bottom: 12px !important;
    margin-right: 0;

    .ant-form-item-control-wrapper {
      flex: 1;
      display: inline-block;
      vertical-align: middle;
    }

    > .ant-form-item-label {
      line-height: 32px;
      padding-right: 8px;
      width: auto;
    }
    .ant-form-item-control {
      height: 32px;
      line-height: 32px;
      display: flex;
      justify-content: flex-start;
      align-items: center;
      .ant-form-item-children {
        min-width: 160px;
      }
    }
  }

  .table-page-search-submitButtons {
    display: block;
    margin-bottom: 24px;
    white-space: nowrap;
  }
}
</style>


复制代码

FieldRender.vue(渲染对应控件)

<template>
  <a-col v-bind="fieldOptions.responsive" v-if="fieldOptions.fieldName && fieldOptions.type === 'text'">
    <a-form-item :label-col="labelCol" :wrapper-col="wrapperCol" :label="fieldOptions.labelText">
      <a-input
        :size="fieldOptions.size ? fieldOptions.size : 'default'"
        v-decorator="[
          fieldOptions.fieldName,
          { initialValue: fieldOptions.defaultValue ? fieldOptions.defaultValue : '' }
        ]"
        :placeholder="fieldOptions.placeholder"
      />
    </a-form-item>
  </a-col>
  <a-col v-bind="fieldOptions.responsive" v-else-if="fieldOptions.fieldName && fieldOptions.type === 'select'">
    <a-form-item :label-col="labelCol" :wrapper-col="wrapperCol" :label="fieldOptions.labelText">
      <a-select
        style="width: 100%"
        showSearch
        :filterOption="selectFilterOption"
        :size="fieldOptions.size ? fieldOptions.size : 'default'"
        allowClear
        v-decorator="[
          fieldOptions.fieldName,
          { initialValue: fieldOptions.defaultValue ? fieldOptions.defaultValue : undefined }
        ]"
        :placeholder="fieldOptions.placeholder"
      >
        <template v-for="(item, index) in fieldOptions.options">
          <a-select-option :value="item.value" :key="index">
            {{ item.label }}
          </a-select-option>
        </template>
      </a-select>
    </a-form-item>
  </a-col>
  <a-col v-else-if="fieldOptions.fieldName && fieldOptions.type === 'number'" v-bind="fieldOptions.responsive">
    <a-form-item :label-col="labelCol" :wrapper-col="wrapperCol" :label="fieldOptions.labelText">
      <a-input-number
        :size="fieldOptions.size ? fieldOptions.size : 'default'"
        :min="fieldOptions.min ? fieldOptions.min : 1"
        style="width: 100%"
        v-decorator="[
          fieldOptions.fieldName,
          { initialValue: fieldOptions.defaultValue ? fieldOptions.defaultValue : '' }
        ]"
        :placeholder="fieldOptions.placeholder"
      />
    </a-form-item>
  </a-col>
  <a-col
    v-bind="fieldOptions.responsive"
    v-else-if="fieldOptions.fieldName && fieldOptions.type === 'radio' && Array.isArray(fieldOptions.options)"
  >
    <a-form-item :label-col="labelCol" :wrapper-col="wrapperCol" :label="fieldOptions.labelText">
      <a-radio-group
        :size="fieldOptions.size ? fieldOptions.size : 'default'"
        buttonStyle="solid"
        v-decorator="[
          fieldOptions.fieldName,
          { initialValue: fieldOptions.defaultValue ? fieldOptions.defaultValue : '' }
        ]"
      >
        <template v-for="(item, index) in fieldOptions.options">
          <a-radio-button :key="index" :value="item.value">{{ item.label }} </a-radio-button>
        </template>
      </a-radio-group>
    </a-form-item>
  </a-col>
  <a-col v-bind="fieldOptions.responsive" v-else-if="fieldOptions.fieldName && fieldOptions.type === 'datetime'">
    <a-form-item :label-col="labelCol" :wrapper-col="wrapperCol" :label="fieldOptions.labelText">
      <a-date-picker
        :size="fieldOptions.size ? fieldOptions.size : 'default'"
        :placeholder="fieldOptions.placeholder"
        v-decorator="[
          fieldOptions.fieldName,
          { initialValue: fieldOptions.defaultValue ? fieldOptions.defaultValue : null }
        ]"
      />
    </a-form-item>
  </a-col>
  <a-col v-bind="fieldOptions.responsive" v-else-if="fieldOptions.fieldName && fieldOptions.type === 'datetimeRange'">
    <a-form-item :label-col="labelCol" :wrapper-col="wrapperCol" :label="fieldOptions.labelText">
      <a-range-picker
        :size="fieldOptions.size ? fieldOptions.size : 'default'"
        v-decorator="[
          fieldOptions.fieldName,
          { initialValue: fieldOptions.defaultValue ? fieldOptions.defaultValue : null }
        ]"
        :placeholder="fieldOptions.placeholder"
      />
    </a-form-item>
  </a-col>
  <a-col v-bind="fieldOptions.responsive" v-else-if="fieldOptions.fieldName && fieldOptions.type === 'cascader'">
    <a-form-item :label-col="labelCol" :wrapper-col="wrapperCol" :label="fieldOptions.labelText">
      <a-cascader
        :size="fieldOptions.size ? fieldOptions.size : 'default'"
        :options="fieldOptions.options"
        :showSearch="{ cascaderFilter }"
        v-decorator="[
          fieldOptions.fieldName,
          { initialValue: fieldOptions.defaultValue ? fieldOptions.defaultValue : [] }
        ]"
        :placeholder="fieldOptions.placeholder"
      />
    </a-form-item>
  </a-col>
  <a-col v-bind="fieldOptions.responsive" v-else-if="fieldOptions.fieldName && fieldOptions.type === 'slider'">
    <a-form-item :label-col="labelCol" :wrapper-col="wrapperCol" :label="fieldOptions.labelText">
      <a-slider
        :min="1"
        range
        :marks="fieldOptions.marks"
        :tipFormatter="e => e * (fieldOptions.baseMultiple ? fieldOptions.baseMultiple : 500)"
        v-decorator="[
          fieldOptions.fieldName,
          {
            initialValue: fieldOptions.defaultValue ? fieldOptions.defaultValue : [0, 0]
          }
        ]"
      />
    </a-form-item>
  </a-col>
</template>

<script>
export default {
  computed: {
    fieldOptions() {
      if (this.itemOptions.baseMultiple) {
        return {
          marks: {
            0: 0,
            1: this.itemOptions.baseMultiple,
            100: this.itemOptions.baseMultiple * 100
          },
          ...this.itemOptions
        };
      }
      return this.itemOptions;
    }
  },
  props: {
    itemOptions: {
      // 控件的基本参数
      type: Object,
      default: function() {
        return {
          type: 'text', // 控件类型
          defaultValue: '', // 默认值
          label: '控件名称', // 控件显示的文本
          value: '', // 控件的值
          responsive: {
            md: 8,
            sm: 24
          },
          size: '', // 控件大小
          placeholder: '' // 默认控件的空值文本
        };
      }
    }
  },
  data() {
    return {
      labelCol: { span: 6 },
      wrapperCol: { span: 18 }
    };
  },
  methods: {
    selectFilterOption(input, option) {
      // 下拉框过滤函数
      return option.componentOptions.children[0].text.toLowerCase().indexOf(input.toLowerCase()) >= 0;
    },
    cascaderFilter(inputValue, path) {
      // 级联过滤函数
      return path.some(option => option.label.toLowerCase().indexOf(inputValue.toLowerCase()) > -1);
    }
  }
};
</script>


复制代码

总结

到这类一个中规中矩的查询组件就实现了,有什么不对之处请留言,会及时修正。

还有一些功能没有拓展进去,比如任意控件触发回调,更丰富的组件支持,类似导出功能。

具体业务具体分析,有兴趣的可以自行拓展,谢谢阅读。

关注下面的标签,发现更多相似文章
评论