Vue 折騰記 - (16) 基於Ant Design Vue 封裝一個配置式的表單搜尋元件

CRPER發表於2019-04-10

前言

這次的後臺管理系統專案選型用了Vue來作為主技術棧;

因為前段時間用過React來寫過專案(用了antd),感覺棒棒的。

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

在分析整個專案原型後,發現又可以抽離類似之前的React表格搜尋元件

React 折騰記 - (6) 基於React 16.x+ Antd 3.封裝的一個宣告式的查詢元件(實用強大)


效果圖

Vue 折騰記 - (16) 基於Ant Design Vue 封裝一個配置式的表單搜尋元件

  • 2019-04-10 14:50 : 修正了部分的初始化props及聯動,新增了slot的傳遞

Vue 折騰記 - (16) 基於Ant Design Vue 封裝一個配置式的表單搜尋元件

  • 2019-04-17: 我又增加了一種佈局展示,內聯模式,順帶修復了一些已知的問題,元件重新命名為AdvancedSearch.vue

Vue 折騰記 - (16) 基於Ant Design Vue 封裝一個配置式的表單搜尋元件

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

  • 2019-04-25:若是傳入的資料長度小於最大格式,預設顯示為內聯模式,否則為卡片模式

其他特性等,具體可以看下面的思維導圖.


實現思路

  • 用什麼來實現元件之間的通訊

昨天寫第一版的時候,思維還沒繞過來,用props和自定義事件($on,$emit)來實現,

實現出來的程式碼量賊多,因為每細化多一層元件,複雜度就越高。各種互相回撥來實現。

仔細翻了下Ant Design Vue的文件,發下可以類似React的套路實現

  • 怎麼來實現

要實現一個結合業務可複用的東東,首先我們必須先梳理我們要實現的功能點。

props儘量不破壞文件控制元件暴露的特性,而是折中去實現,擴充。

先畫個思維導圖梳理下功能點

Vue 折騰記 - (16) 基於Ant Design Vue 封裝一個配置式的表單搜尋元件


遇到的問題

  • jsx來實現的問題

一開始想用jsx來實現,發現還是太天真了。各種報錯,特別對Vue指令的支援一團糟

以及函式式元件的寫法也是坑挺多,沒辦法,乖乖的迴歸template的寫法

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

  • 控制元件擠成一坨的問題

這個可能是antd vue版本的樣式沒處理好,我仔細排查了。若沒有複寫他的樣式,完全沒法展開。

placeholder不會自動撐開,數字控制元件也是很小

修正前:

Vue 折騰記 - (16) 基於Ant Design Vue 封裝一個配置式的表單搜尋元件

修正後

Vue 折騰記 - (16) 基於Ant Design Vue 封裝一個配置式的表單搜尋元件

  • 補全當初寫react版本一些欠缺考慮的東東(比如返回的查詢物件上)

用法

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

子項會覆蓋全域性帶過來的同名特性,優先順序比較高

選項 型別 解釋
responsive 物件 柵欄的佈局物件
size 字串 控制元件規格大小(大部分都有default,small,large)
gutter 數字 控制元件的間距
datetimeTotimeStamp 布林型別 若是為true,所有時間控制元件都會轉為時間戳返回
searchDataSource 陣列物件 就是需要渲染控制元件的資料來源,具體看原始碼的props
@change 函式 就是查詢的回撥
// SearchDataSource是資料來源,具體可以看props的預設值
<table-search :SearchDataSource="SearchDataSource" @change="tableSearchChange" />


<table-search :SearchDataSource="SearchDataSource" @change="tableSearchChange">
  <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="index"
                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>

              <slot name="extra" />
              <template v-if="renderDataSource.length > SearchGlobalOptions.maxItem">
                <a-divider type="vertical" />
                <a @click="togglecollapsed" style="margin-left: 8px">
                  {{ collapsed ? '收起' : '展開' }}
                  <a-icon :type="collapsed ? 'up' : 'down'" />
                </a>
              </template>
            </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>
              <slot name="extra" />
              <template v-if="renderDataSource.length > SearchGlobalOptions.maxItem">
                <a-divider type="vertical" />
                <a @click="togglecollapsed" style="margin-left: 8px">
                  {{ collapsed ? '收起' : '展開' }}
                  <a-icon :type="collapsed ? 'up' : 'down'" />
                </a>
              </template>
            </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: {
    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) {
          console.log('處理前的表單資料', values);
          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>


複製程式碼

總結

到這類一箇中規中矩的查詢元件就實現了,有什麼不對之處請留言,會及時修正。

還有一些功能沒有擴充進去,比如任意控制元件觸發回撥。更豐富的元件支援,類似匯出功能

具體業務具體分析,有興趣的可以自行擴充,謝謝閱讀、

相關文章