背景
由于公司大量中后台项目列表搜索页面均为顶部为列表筛选表单、中部为列表、底部分页模式,在开发时会出现大量重复代码的cv操作,固对element进行二次封装搜索表单组件,以提升开发效率,方便代码阅读和维护;
该篇仅用作个人工作记录使用,如有不足之处希望各位大佬轻喷,感谢!
组件封装实现
实现思路
组件主要拆分为两个部分:1、顶部筛选栏部分;2、列表 + 分页部分;
这样拆分的好处是可以覆盖大多数列表页面的业务场景;如果有比较定制化的需求,可以将列表和筛选栏组件拆开分别引入使用,减少了组件的耦合性,增强了组件的可扩展性;
组件主要接收两个参数,一个为顶部筛选栏的配置项searchConfig,用于配置顶部筛选栏的表单项类型、绑定的key、筛选项文案等;第二个参数为proTableConfig,透传给封装的pro-table组件用于配置列表和分页器;同时组件接收beforeSearch方法在每次筛选前可以对绑定的参数进行二次处理,以便于列表接口请求;
ProSearchTable搜索表格封装
<template>
<div class="search-table">
<!-- 搜索栏start -->
<pro-search
v-if="searchTempList.length"
:is-operate="false"
v-bind="proSearchConfig"
@search="handleSearch"
@clear="handleClear"
>
<pro-search-item
v-for="(searchItem, index) in searchTempList"
:key="index"
>
<template #content>
<search-form-item
v-model="query[searchItem.key]"
:search-item="searchItem"
@update="handleUpdate"
/>
</template>
</pro-search-item>
</pro-search>
<!-- 搜索栏end -->
<!-- 列表start -->
<div class="card">
<!-- 按钮插槽 -->
<div v-if="showBtnContainer" class="tw-mb-16">
<slot name="btn" />
</div>
<pro-table ref="proTableRef" v-bind="_proTableConfig" />
</div>
<!-- 列表end -->
</div>
</template>
<script setup>
import { ProTable, ProSearch, ProSearchItem } from '../../index'
import SearchFormItem from './search-form-item.vue';
import { computed, useSlots, reactive, ref } from 'vue';
// proSearch默认配置项
const defaultProSearchConfig = {
isOperate: false
};
const props = defineProps({
searchConfig: {
/**
* 搜索栏配置项
* searchTempList:{ 搜索项模板列表
* label: 筛选项名称
* key:筛选项入参字段名
* type:筛选项类型
* elementConfig:element原生的props配置,透传给element
* }
* proSearchConfig:ProSearch配置项,透传给ProSearch
*/
type: Object,
required: true
},
proTableConfig: {
/** ProTable配置项,透传给ProTable */
type: Object,
required: true
},
beforeSearch: {
/**
* 在列表接口请求前的事件,事件会返回search栏对应绑定的query,
* 可以在此方法中处理接口请求入参,改函数return的参数就是最后请求接口的参数;
*/
type: Function,
default: () => {}
}
});
const slots = useSlots();
const query = reactive({
...(props.proTableConfig.fetchParams || {})
});
const proTableRef = ref(null);
const searchTempList = computed(() => {
const list = props.searchConfig.searchTempList;
if (Array.isArray(list)) {
return list;
} else {
return [];
}
});
// 是否展示btn插槽外的盒子
const showBtnContainer = computed(() => {
const slotKeys = Object.keys(slots || []);
return slotKeys.some(key => key === 'btn');
})
// proSearch配置项
const proSearchConfig = computed(() => {
return Object.assign(defaultProSearchConfig, props.searchConfig.proSearchConfig || {});
});
const _proTableConfig = computed(() => {
const proTableConfig = props.proTableConfig;
return {
...proTableConfig,
// 如果传入beforeSearch,此处需要执行beforeSearch处理后的参数作为proTable的fetchParams
fetchParams: props.beforeSearch && proTableConfig.fetchParams ?
props.beforeSearch(deepCopy(proTableConfig.fetchParams)) :
proTableConfig.fetchParams
}
})
// 列表接口请求
const handleSearch = () => {
const payload = getPayload();
proTableRef.value.init({
param: payload,
});
}
// 获取请求参数
const getPayload = () => {
let payload = {};
for (const key in query) {
const val = query[key];
// 去掉非空参数
val !== '' &&
val !== undefined &&
val !== null &&
(payload[key] = val);
}
// 如果传入beforeSearch,此处需要执行beforeSearch处理后的参数作为接口请求入参
return props.beforeSearch && props.beforeSearch(payload) || payload;
};
// 清空筛选
const handleClear = () => {
for (const key in query) {
const fetchParams = props.proTableConfig.fetchParams;
// 此处如果有传入fetchParams,筛选之重置为fetchParams传入的默认值
const initValue = fetchParams ? fetchParams[key] : '';
query[key] = initValue || '';
}
}
// 筛选项改变回调
const handleUpdate = (val, searchInfo) => {
const key = searchInfo.key;
query[key] = val;
}
const deepCopy = v => {
if (!v) return null;
return JSON.parse(JSON.stringify(v))
};
(function init() {
if (Array.isArray(searchTempList.value)) {
// searchTempList根据初始化query
searchTempList.value.forEach(e => {
const key = e.key;
const fetchParams = props.proTableConfig.fetchParams;
// 传入的fetchParams为筛选项的初始默认值
const initValue = fetchParams ? fetchParams[key] : '';
query[key] = initValue || '';
});
}
})()
defineExpose({
getPayload,
handleSearch,
handleClear
});
</script>
<style scoped>
.card {
margin-top: 16px;
background: #fff;
padding: 16px;
border-radius: 8px;
box-sizing: border-box;
}
</style>
顶部筛选栏封装
组件中的pro-search和pro-search-item为左边筛选标签、右边表单项布局组件附带查询和筛选按钮,比较简单固不做展示,筛选栏部分筛选表单项组件(search-form-item)封装如下:
search-form-item
<template>
<el-input
v-if="type === 'input'"
v-model="bindValue"
:placeholder="placeholder"
clearable
v-bind="elementConfig"
@input="handleChange"
/>
<el-select
v-else-if="type === 'select'"
v-model="bindValue"
class="tw-w-full"
v-bind="elementConfig"
clearable
:placeholder="placeholder"
@change="handleChange"
>
<el-option
v-for="item in elementConfig.options || []"
:key="item[elementConfig.valueName || 'value']"
:label="item[elementConfig.labelName || 'label']"
:value="item[elementConfig.valueName || 'value']"
/>
</el-select>
<ProDatePicker
v-else-if="type === 'date'"
type="daterange"
:editable="false"
v-model="bindValue"
:start-placeholder="placeholder"
end-placeholder="结束时间"
value-format="YYYY-MM-DD"
format="YYYY/MM/DD"
@change="handleChange"
style="width: 100%;"
v-bind="elementConfig"
/>
<el-cascader
v-else-if="type === 'cascader'"
v-model="bindValue"
clearable
:placeholder="placeholder"
style="width: 100%;"
v-bind="elementConfig"
@change="handleChange"
/>
</template>
<script setup>
import { computed, watch, ref } from 'vue';
import { ProDatePicker } from '../../index';
const props = defineProps({
/**
* 配置项
* value:绑定值
* type:对应的表单类型
* input:输入框
* select:下拉选择框
* dater:时间段筛选框
* cascader:级联选择器
* label:placeholder类型文案
* elementConfig:element原生的props配置,透传给element
* (select比较特殊,valueName为绑定的值的key,
* labelName为绑定展示文案的key,options为下拉选先数据源)
*/
searchItem: {
type: Object,
required: true,
},
modelValue: {
type: String,
required: true,
}
});
const emits = defineEmits('update');
const bindValue = ref(props.modelValue); // 表单项绑定值
const type = computed(() => props.searchItem.type);
const placeholder = computed(() => {
const label = props.searchItem.label;
switch (type.value) {
case 'select':
case 'cascader':
return `请选择${label}`;
case 'date':
return `${label}开始时间`;
default:
return `请输入${label}`;
}
})
const elementConfig = computed(() => props.searchItem.elementConfig || {});
watch(() => props.modelValue, val => { // 保证绑定的bindValue和传入的value一致
if (props.searchItem.type === 'date') {
// 时间类型清空绑定值(bindValue)重置为空数组
!val && (bindValue.value = []);
} else {
bindValue.value = val;
}
});
const handleChange = (val) => {
val = val === null ? '' : val; // props不可以传null回来,会有vue警告,针对时间段选择器
emits('update', val, props.searchItem);
}
</script>
<style>
</style>
列表+分页组件封装
pro-table
*注意:透传属性/事件都包在了_options里面一并传给了el-table
props.options为透传给element的属性配置;
列表渲染支持插槽、render函数(jsx语法也支持)、h函数等方式进行渲染;
<template>
<div class="component-wrapper">
<el-config-provider :locale="local">
<el-table
style="width: 100%"
ref="tableRef"
:data="fetchData"
v-bind="_options"
>
<template v-for="(col, index) in columns" :key="index">
<!-- 渲染插槽 START -->
<TableColumn :col="col" @command="handleAction">
<template
v-for="(slot, index) in Object.keys($slots)"
#[slot]="scope"
>
<slot :name="slot" v-bind="scope"></slot>
</template>
</TableColumn>
<!-- 渲染插槽 END -->
</template>
<!-- 自定义空状态-->
<template #empty>
<slot name="empty">
<div style="text-align: center">
<el-image
class="empty-img"
src="https://static.wxb.com.cn/frontEnd/images/idea-middle-platform/empty.png"
></el-image>
<div class="empty-text">暂无数据</div>
</div>
</slot>
</template>
</el-table>
</el-config-provider>
<!-- 分页器 -->
<div
v-if="_options.showPagination && _paginationConfig.total > 0"
class="mt20 page-wrapper"
>
<slot name="page"></slot>
<el-config-provider :locale="local">
<el-pagination
v-bind="_paginationConfig"
@size-change="pageSizeChange"
@current-change="currentPageChange"
/>
</el-config-provider>
</div>
</div>
</template>
<script setup name="ProTable">
import { ElConfigProvider } from "element-plus";
import zhCn from "element-plus/dist/locale/zh-cn.mjs";
import { computed, ref, reactive, onMounted, watch, useAttrs } from "vue";
import TableColumn from "./table-column.vue";
const local = ref(zhCn);
const tableRef = ref(null);
const props = defineProps({
fetchApi: {
type: Function,
},
fetchParams: {
type: Object,
},
tableData: {
type: Array,
default: [],
},
columns: {
type: Array,
default: [],
required: true,
},
options: {
type: Object,
default: {},
},
selectionChange: {
type: Function,
},
noEmpty: {
type: Boolean,
default: false,
},
});
const fetchData = ref([]);
const attrs = useAttrs();
// 监听tableData数据变化
watch(
() => props.tableData,
(newValues) => {
if (props.tableData) {
fetchData.value = newValues;
}
},
{ deep: true, immediate: true }
);
// table参数
const _options = computed(() => {
const options = {
border: false, // 是否带有纵向边框
maxHeight: "calc(100vh - 195px)",
headerCellStyle: {
background: "#F8FAFB", //表头背景
color: "#2C2C34",
border: 0,
borderRadius: "4px",
height: "56px",
},
cellStyle: {
borderLeft: 0,
borderRight: 0,
borderBottom: "1px solid #F3F7F8",
height: "80px",
},
paginationConfig: {}, //分页配置
rowStyle: () => "cursor:pointer", //设置行样式
showPagination: true, // 是否显示分页
};
// 此处把其他绑定事件也透传给el-table
return Object.assign(options, props.options, attrs);
});
// 分页器参数
const config = {
total: 0, // 总条数
currentPage: 1, // 当前页
pageSize: 10, // 分页数量
pageSizes: [10, 50, 100], //每页显示个数选择器的选项设置
layout: "total, sizes, prev, pager, next,jumper", // 组件布局,子组件名用逗号分隔
};
const _paginationConfig = reactive(
Object.assign(config, _options.value.paginationConfig)
);
let searchParams = reactive({});
// 切换pageSize
const pageSizeChange = (pageSize) => {
_paginationConfig.pageSize = pageSize;
_paginationConfig.currentPage = 1;
init(searchParams, "1");
};
// 切换currenPage
const currentPageChange = (pageNum) => {
_paginationConfig.currentPage = pageNum;
init(searchParams, "1");
};
// 初始化
const init = (params = {}, type) => {
if (type !== "1") {
// 其他查询重置页码
_paginationConfig.currentPage = 1;
}
const initParams = Object.assign(
{
pageNum: _paginationConfig.currentPage,
pageSize: _paginationConfig.pageSize,
},
{ ...params }
);
searchParams = params; // 记录入参用于翻页或跳页
props.fetchApi &&
props
.fetchApi(initParams)
.then((res) => {
if (res && res.data) {
const {
data: { total: _total, list },
} = res;
fetchData.value = list || [];
_paginationConfig.total = _total || 0;
emits("on-fetch-success", res.data);
} else {
fetchData.value = [];
_paginationConfig.total = 0;
}
})
.catch((e) => {
console.log("表格fetchApi异常:", e);
});
};
onMounted(() => {
if (props.fetchApi) {
// 初始化需要除了分页相关的其它参数,需要特殊梳理
if (props.fetchParams) {
init({ param: props.fetchParams });
} else {
init();
}
}
});
defineExpose({
init,
tableRef,
fetchData,
});
</script>
table-column
<template>
<!-- 如果有配置多级表头的数据,则递归该组件 -->
<template v-if="col.children?.length">
<el-table-column :label="col.label" :width="col.width" :align="col.align">
<TableColumn v-for="item in col.children" :col="item" :key="item.prop">
<template v-for="slot in Object.keys($slots)" #[slot]="scope">
<slot :name="slot" v-bind="scope" />
</template>
</TableColumn>
<template #header="{ column, $index }">
<component
v-if="col.headerRender"
:is="col.headerRender"
:column="column"
:index="$index"
/>
<slot
v-else-if="col.headerSlot"
:name="col.headerSlot"
:column="column"
:index="$index"
></slot>
<span v-else>{{ column.label }}</span>
</template>
</el-table-column>
</template>
<!---复选框, 序号 (START)-->
<el-table-column
v-if="
col.type === 'index' || col.type === 'selection' || col.type === 'expand'
"
:index="index"
v-bind="col"
>
<!-- 当type等于expand时, 配置通过h函数渲染-->
<template #default="{ row, $index }">
<!-- render函数 (START) : 使用内置的component组件可以支持h函数渲染-->
<component
v-if="col.render"
:is="col.render"
:row="row"
:index="$index"
/>
<!-- render函数 (END) -->
<!-- 自定义slot (START) -->
<slot
v-else-if="col.slot"
:name="col.slot"
:row="row"
:index="$index"
></slot>
<!-- 自定义slot (END) -->
</template>
</el-table-column>
<!---复选框, 序号 (END)-->
<!-- 其他正常列 -->
<el-table-column v-else v-bind="col">
<!-- 自定义表头 -->
<template #header="{ column, $index }">
<!-- render渲染 -->
<component
v-if="col.headerRender"
:is="col.headerRender"
:column="column"
:index="$index"
/>
<!-- 插槽渲染 -->
<slot
v-else-if="col.headerSlot"
:name="col.headerSlot"
:column="column"
:index="$index"
></slot>
<span v-else>{{ column.label }}</span>
</template>
<template #default="{ row, $index }">
<!-- render函数 (START) 使用内置的component组件可以支持h函数渲染和txs语法-->
<component
v-else-if="col.render"
:is="col.render"
:row="row"
:index="$index"
/>
<!-- render函数 (END) -->
<!-- 自定义slot (START) -->
<slot v-else-if="col.slot" :name="col.slot" :row="row" :index="$index">
<!-- <div>插槽自定义{{row.sex}}</div> -->
</slot>
<!-- 自定义slot (END) -->
<!-- 默认渲染 (START) -->
<span v-else>{{
(row[col.prop] ?? "") === "" ? "-" : row[col.prop]
}}</span>
<!-- 默认渲染 (END) -->
</template>
</el-table-column>
</template>
<script setup>
defineProps({
col: {
type: Object,
required: true,
},
});
</script>
使用效果
效果示例代码
<template>
<div class="page-content">
<div class="examples-box">
<ProSearchTable
ref="proSearchTableRef"
:search-config="searchConfig"
:pro-table-config="proTableConfig"
:before-search="handleBeforeSrc"
>
<template #btn>
<ProButton type="primary" style="margin-bottom: 20px;">btn插槽</ProButton>
</template>
</ProSearchTable>
<div style="margin-top: 100px;">
<ProButton type="primary" @click="handleSearch">手动搜索</ProButton>
<ProButton type="primary" @click="handleClear">手动清除</ProButton>
<ProButton type="primary" @click="getPayload">获取请求参数</ProButton>
</div>
</div>
</div>
</template>
<script setup lang="jsx">
import { ProSearchTable, ProButton } from "../../../../packages";
import { ref, computed } from "vue";
// 模拟接口请求
const getTableData = (payload) => {
console.log('payload==>', payload);
return new Promise(
(resolve) => {
setTimeout(() => {
resolve({
code: 10000,
success: true,
data: {
total: 1,
list: [{
date: '2024-09-19',
name: '小明',
sex: '男',
address: '浙江杭州',
}]
}
})
}, 500);
})
}
const sexOptions = ref([]);
const areaOptions = ref([]);
const proSearchTableRef = ref(null);
const searchConfig = computed(() => ({
searchTempList: [
{ type: 'input', label: '姓名', pleaceholder: '请输入', key: 'name' },
{ type: 'date', label: '日期', key: 'dateArr', elementConfig: {
'type': 'datetimerange',
'value-format': 'YYYY-MM-DD HH:mm:ss',
'format': 'YYYY/MM/DD HH:mm:ss'
} },
{ type: 'select', label: '性别', pleaceholder: '请输入', key: 'sex',
elementConfig: {
valueName: 'itemId',
labelName: 'itemName',
options: sexOptions.value,
}
},
{ type: 'cascader', label: '地区', key: 'area',
elementConfig: {
options: areaOptions.value,
props: { value: 'code', label: 'name', children: 'child', checkStrictly: true }
},
},
],
}));
const proTableConfig = computed(() => ({
fetchApi: getTableData,
/** 此处传入的fetchParams也就是筛选栏的初始值,点击重置按钮也会重置为这里传入的值 */
fetchParams: {
initParams: '01',
name: '111',
dateArr: [ "2024-05-17 00:00:00", "2024-06-19 23:59:59"]
},
columns: [
{ prop: 'name', label: '姓名' },
{ prop: 'sex', label: '性别', minWidth: '150px' },
{ prop: 'address', label: '地址', minWidth: '180px' },
{ prop: 'date', label: '日期', minWidth: '180px', render: ({ row }) => regionRender(row) },
],
}));
const regionRender = (row) => <span style={ {color: 'red'} }>{ row.date }</span>
const handleBeforeSrc = payload => {
console.log('before-search-payload', payload);
return {
...payload,
addKey: '2'
}
}
const handleSearch = () => {
proSearchTableRef.value.handleSearch();
}
const handleClear = () => {
proSearchTableRef.value.handleClear();
}
const getPayload = () => {
const payload = proSearchTableRef.value.getPayload();
window.alert(`当前请求参数为:${JSON.stringify(payload)}`);
}
</script>
搜索表单 Attributes
ProSearchTable
属性 | 类型 | 默认值 | 描述 |
---|---|---|---|
searchConfig | Object | -- | 搜索栏的配置项,详情见下表searchConfig配置表; |
proTableConfig | Object | -- | pro-table配置项,透传给pro-table * 注意:proTableConfig传入的fetchParams也是筛选项的初始值 * |
beforeSearch | Function | -- | 在列表接口请求前的事件,事件会返回search栏对应绑定的query,可以在此方法中处理接口请求入参,改函数return的参数就是最后请求接口的参数; |
ProSearchTable事件
事件 | 说明 |
---|---|
handleSearch | 触发列表接口请求; |
handleClear | 清空筛选栏; |
getPayload | 获取当前列表请求参数; |
searchConfig配置
属性 | 类型 | 默认值 | 描述 | |
---|---|---|---|---|
searchTempList | Array | -- | 搜索栏配置项,循环渲染出每一项搜索的form;详情见下表searchTempList配置; | |
proSearchConfig | Object |
| ProSearch配置项,透传给ProSearch; |
searchTempList配置
属性 | 类型 | 默认值 | 描述 |
---|---|---|---|
type | String | -- | 对应的表单类型 * input:输入框 |
label | String | -- | 对应渲染的placeholder类型文案 |
elementConfig | Object | -- | element原生的props配置,透传给element,select比较特殊,valueName为绑定的值的key,labelName为绑定展示文案的key,options为下拉选先数据源 |