前言
这次的后台管理系统项目选型用了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
版本一些欠缺考虑的东东(比如返回的查询对象上)
用法
就普通的引入,具体暴露的props
和change
如下
子项会覆盖全局带过来的同名特性,优先级比较高
选项 | 类型 | 解释 |
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>