前言
这次的后台管理系统项目选型用了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">xxxxa-button>
<template v-slot:extra>
<div>fasdfasdiv>
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>
复制代码
总结
到这类一个中规中矩的查询组件就实现了,有什么不对之处请留言,会及时修正。
还有一些功能没有拓展进去,比如任意控件触发回调,更丰富的组件支持,类似导出功能。
具体业务具体分析,有兴趣的可以自行拓展,谢谢阅读。