效果:
一、背景
产品需求:表格支持勾选显示隐藏、拖拽排序、表头搜索。其实也就是 antdpro 的那个 proTable。
只是目前前端团队(我和另外一个小盆友)已经改用vue3,且不在使用开箱即用的方案。
作为职业ctrl+CV选手。本人肯定先百度,可惜没找到合适的。想了下,还是自己动手。
二、原理&规划
目前团队使用的ui库是,antd-vue
他的table组件使用方式:
表头数据和表格数据本身就已经抽离了,那么表头本身的展示,其实就是表头数据的处理。
我们只需要封装一层table 内部处理一次 表头,就好了。
下面是antd-vue,table基础使用代码:
<template>
<a-table :dataSource="dataSource" :columns="columns" />
</template>
<script>
export default {
setup() {
return {
dataSource: [
{
key: '1',
name: 'Mike',
age: 32,
address: '10 Downing Street',
},
...
],
columns: [
{
title: 'Name',
dataIndex: 'name',
key: 'name',
},
...
],
};
},
};
</script>
三、实操
①、表格基础继承
为了让之前的表格不需要有大改动就能顺利调整完。
为了保证表格组件使用和antdvue一致。
主要为了,懒!所以,以前的东西尽量不动它!
下面是继承核心代码:
<a-table v-bind="$attrs" >
<!-- 透传其他的 slot -->
<template v-for="(item, key, index) in $slots" :key="index" v-slot:[key]="item"> <slot :name="key" v-bind="item"></slot>
</template>
</a-table>
②、tools功能组件及产生的setting数据设计
写一个setting组件。外部传入columns,内部拖拽、勾选啥的,控制显示隐藏。
首先是setting数据格式的设计:
键值对。key为表头的唯一标识 dataIndex。value 是{ order: 顺序, show: 显示、隐藏 }
其实应该用hide比较好,不过既然都写了,就懒得改了,就这样吧
{
"createAt": {
"order": 8, // 排序
"show": false // 显示隐藏,show为undefined 也是显示
},
"name": {
"order": 4
},
...
}
下面是setting组件代码:(拖拽用的 vuedraggable)
<script setup lang="ts">
import { PropType } from 'vue';
import { TableColumnProps } from 'ant-design-vue';
import Draggable from 'vuedraggable';
const props = defineProps({
setting: {
type: Object,
default: {},
},
columns: {
type: Object as PropType<TableColumnProps[]>,// [{ valueType: 'select'| 'radio'| 'input' }]
default: [],
},
onChange: { // 修改columns
type: Function,
default: () => {},
}
});
// 勾选控制展示
const handleCheckShow = (value, record) => {
props.onChange({ ...props.setting, [record.dataIndex]: {
...props.setting[record.dataIndex],
show: value,
} });
}
// 判断显影
const getSet = (dataIndex) => {
const { show } = props.setting[dataIndex] || {};
return show === true || show === undefined;
}
// 拖拽完,更是setting的排序 order数据
const handleEnd = () => {
const settingNew = { ...props.setting };
props.columns.forEach((item: any, index) => {
settingNew[item.dataIndex] = {
...settingNew[item.dataIndex],
order: index,
}
});
props.onChange(settingNew);
}
</script>
<template>
<draggable
:list="props.columns"
@end="handleEnd"
group="people"
item-key="dataIndex">
<template #item="{element}">
<p>
<a-checkbox
:checked="getSet(element.dataIndex)"
:disabled="element.tool?.disabled"
@change="(e) => handleCheckShow(e.target.checked, element)"
>
{{element.title}}
</a-checkbox>
</p>
</template>
</draggable>
</template>
<style>
</style>
③、新的columns处理
columns其实算下来有3个:
(1)columnsInit: 初始的表头数据,前端配置的所有的项
(2)columns:前端处理过的数据。添加setting数据,根据valueType处理表头
(3)columnsEnd: a-table使用的数据,是columns根据 setting里面的show进行过滤后的数据;
逻辑核心:columns处理
流程:
(1)、监听外部的columns和 setting数据
(2)、整合一下 ——>铛!铛!铛!完成!(完美)
下面是我抽离一个的hook;
下面代码这么长的原因:我在整理 cloumns新数据时,把表头的几种过滤(输入、下拉、单选、时间范围等),也写到这儿了。(下面扩展会讲)
import { ref, computed, watch } from 'vue';
import { useRouter } from 'vue-router';
import { saveTabelStatus, getTabelStatus } from '@/utils/localStorageJson';
// setting 是否展示,字段排序
const useColumn = (props, setting: Record<string, any>) => {
const columnsInit = props.columns;
const columns = ref<any[]>([]);
const filterValueType = ref<Record<string, any>>({});
// 处理colums,主要处理 valueType问题
const handleDealColumn = (data, settingData) => {
let orderTrue = true;
let attr = (data || []).map((item) => {
// 这里是核心了,把setting 数据,根据dataIndex放到每个columns,item里面。
const { show, order } = (settingData || {})[item.dataIndex] || {};
orderTrue = orderTrue || !!order;
const obj = {
...item,
order,
show,
};
filterValueType.value[obj.dataIndex] = obj.valueType;
switch(obj.valueType) {
case 'select':
obj.filters = (obj.options || []).map((item) => ({
text: item.value,
value: item.key,
})),
obj.filterSearch = true;
break;
case 'radio':
obj.filters = (obj.options || []).map((item) => ({
text: item.value,
value: item.key,
})),
obj.filterSearch = true;
obj.filterMultiple = false;
break;
case 'input':
obj.customFilterDropdown = true;
break;
case 'dateTime':
obj.customFilterDropdown = true;
break;
}
return obj;
});
if (orderTrue) {
attr.sort((a, b) => a.order - b.order);
}
columns.value = attr;
};
handleDealColumn(columnsInit, setting.value);
watch(() => props.columns, (data) => {
handleDealColumn(data, setting);
});
watch(setting, (data) => {
handleDealColumn(columnsInit, data);
});
// 最终展示的columns
const columnsEnd = computed(() => columns.value.filter((item) => item.show !== false));
return {
columns,
columnsEnd,
filterValueType,
}
};
export {
useColumn,
}
④、a-table使用columnsEnd setting组件使用columns
<Setting
:setting="setting"
:columns="columns" :onChange="onChange"
/>
<a-table
:columns="columnsEnd"
:dataSource="dataSource"
:pagination="pagination"
:loading="loading"
@change="handleChangeTable"
v-bind="$attrs"
>
⑤、其实算下来核心已经讲完了,不过
既然都自己封装了。那么偷懒的事情怎么能只搞这么一点。
四、扩展一 :表头赛选、过滤
①、实现目标
搜索过滤、下拉过滤、时间区间等
之前使用,antdpro的时候,columns里面有个参数还是比较好用的——valueType。这次我也按照它的搞。
②、实现
首先是我们的columnsInit数据配置:
valueType的值:基本使用antdvue组件的名字。(懒得重新定义,麻烦)
多选、单选等需要提供 options 作为选项 [{key: '', value: ''}]
const columnsInit = [
{
title: '项目名称',
dataIndex: 'name',
valueType: 'input',
},
{
title: '项目状态',
dataIndex: 'status',
options: PROJECT_STATUS,
valueType: 'select',
},
{
title: '计划启动时间',
dataIndex: 'createAt',
valueType: 'dateTime',
},
...
];
valueType需要先处理成 a-table认识的样式。具体的a-table相关参数有:
1、customFilterDropdown(自定义筛选菜单,需要配合 column.customFilterDropdown 使用)
2、customFilterIcon(自定义筛选图标)
3、filters (表头的筛选菜单项)
4、filterSearch (筛选菜单项是否可搜索)
5、filterMultiple (是否多选)
3、4、5主要用于,单选、多选的情况。
其他的输入查询、时间区间查询,需要使用1、2自定节点。
下面是columnsInit数据处理dataType。
// 处理colums,主要处理 valueType问题
const handleDealColumn = (data, settingData) => {
let orderTrue = true;
let attr = (data || []).map((item) => {
const obj = {
...item,
};
filterValueType.value[obj.dataIndex] = obj.valueType;
switch(obj.valueType) {
case 'select':
obj.filters = (obj.options || []).map((item) => ({
text: item.value,
value: item.key,
})),
obj.filterSearch = true;
break;
case 'radio':
obj.filters = (obj.options || []).map((item) => ({
text: item.value,
value: item.key,
})),
obj.filterSearch = true;
obj.filterMultiple = false;
break;
case 'input':
obj.customFilterDropdown = true;
break;
case 'dateTime':
obj.customFilterDropdown = true;
break;
}
return obj;
});
下面是,a-table内,template自定义过滤组件
<script setup lang="ts">
import { reactive } from 'vue';
defineProps({
selectedKeys: {
type: Object,// [{ valueType: 'select'| 'radio'| 'input' | 'dateTime' }]
default: [],
},
setSelectedKeys: { // 修改columns
type: Function,
default: () => {},
},
confirm: { // 修改columns
type: Function,
default: () => {},
},
column: {
type: Object,
default: {},
},
clearFilters: { // 修改columns
type: Function,
default: () => {},
},
});
const state = reactive({
searchText: '',
searchedColumn: undefined,
});
// 列表筛选项输入框
const handleSearch = (selectedKeys, confirm, dataIndex) => {
confirm();
state.searchText = selectedKeys[0];
state.searchedColumn = dataIndex;
};
// 筛选项重置
const handleReset = clearFilters => {
clearFilters({ confirm: true });
state.searchText = '';
};
</script>
<template>
<div class="p-2">
<!-- 日期 -->
<a-range-picker
show-time
:placeholder="['开始时间', '结束时间']"
v-if="column.valueType === 'dateTime'"
format="YYYY-MM-DD HH:mm:ss"
:value="selectedKeys[0]"
@change="dateString => setSelectedKeys(dateString ? [dateString] : [])" />
<!-- 输入框 -->
<a-input
v-if="column.valueType === 'input'"
:placeholder="`查询${column.title}`"
:value="selectedKeys[0]"
class="w-[188px] mb-2 block"
@change="e => setSelectedKeys(e.target.value ? [e.target.value] : [])"
@pressEnter="handleSearch(selectedKeys, confirm, column.dataIndex)"/>
<div class="mt-3 flex justify-between">
<a-button
size="small"
:disabled="!selectedKeys[0]"
@click="handleReset(clearFilters)"
type="link"
>重置</a-button>
<a-button
type="primary"
size="small"
@click="handleSearch(selectedKeys, confirm, column.dataIndex)"
>确定</a-button>
</div>
</div>
</template>
<style>
</style>
五、扩展二 :内置查询
其实列表查询结构基本一致。返回结构也是一致的。
为了偷懒,为了每天不加班。“一致”也就意味着可以封装
①、入参逻辑设计
const props = defineProps({
...
services: Function, // 内部请求直接使用
onSearch: { // 外部传入时,传出去的 查询方法
type: Function,
default: () => {},
},
searchParams: Object,
});
核心2个参数。
searchParams: 内部pageSize,current字段不够,需要额外字段时,的外部传入参数。
services: 写好的,promise请求对象。
onSearch: 未传入 services情况下。内部切换页码,表头过滤时,触发外部的方法
// 内部的searchParams 包含页码和表头过滤项
const searchParams = ref<Record<string, any>>({
current: 1,
pageSize: 10,
...props.searchParams,
});
// useGetTableData是自己封装的一个请求的hook处理些自定义问题
const {
data: dataSource,
run: getTable,
pagination,
loading,
} = useGetTableData(props.services, {
manual: !props.services || !!props.searchParams,
});
// 外界有params入参时,初始请求,使用整合后的入参
if (props.services && !!props.searchParams) {
getTable({
...searchParams.value,
...props.searchParams,
});
}
// 监听searchParams 触发表格请求
watch(searchParams, (data) => {
if (props.services) {
getTable(data);
} else {
props.onSearch(data);
}
});
// 监听外部seachParams 更新自己searchParams。
watch(() => props.searchParams, (data) => {
searchParams.value = {
...searchParams.value,
...data,
}
});
最后再把更新table的方法暴露出去,方便外面手动刷新table
defineExpose({
getTable: () => getTable({ ...searchParams.value, ...props.searchParams }),
});
六、最后看下使用
<script setup lang="ts">
import { ref } from 'vue';
import { slmListProject } from '@/services/SLM';
import dayjs from 'dayjs';
import ModalVue from './Modal.vue';
import { PROJECT_STATUS_OBJ, PROJECT_STATUS, PROJECT_PRIVACY_TYPE_OBJ } from '@/contants';
const gTableRef = ref<any>(null);
const columnsInit = [
{
title: '项目名称',
dataIndex: 'name',
valueType: 'input',
},
{
title: '项目状态',
dataIndex: 'status',
options: PROJECT_STATUS,
valueType: 'select',
},
{
title: '计划启动时间',
dataIndex: 'createAt',
valueType: 'dateTime',
},
title: '操作',
dataIndex: 'oprations',
},
];
const visible = ref<boolean>(false);
const modalData = ref<Record<string, any>>();
</script>
<template>
<div>
<g-table
ref="gTableRef"
:columns="columnsInit"
:services="slmListProject"
>
<template #toolLeft>
项目列表
</template>
<template #toolRight>
<a-button type="primary" class="mb-2 mr-2" @click="() => visible = true">新建项目</a-button>
</template>
<template #bodyCell="{ column, text, record }">
<template v-if="column.dataIndex === 'oprations'">
<a-button class="mr-2" type="primary" size="small" @click="() => {
visible = true;
modalData = record;
}" disabled>详情</a-button>
</template>
</template>
</g-table>
</div>
<ModalVue
:visible="visible"
:data="modalData"
:onOk="() => gTableRef.getTable()"
:onCancel="() => {
visible= false;
modalData = undefined;
}"
/>
</template>
<style>
</style>
七、完整的g-table组件
setting组件、CustomFilterDropdown组件、useColumn在上面文章中
g-table记得注册全局(偷懒程度 + 1)
<!-- 公共table组件 -->
<script setup lang="ts">
import { ref, watch } from 'vue';
import { SettingOutlined, SearchOutlined, FilterFilled } from '@ant-design/icons-vue';
import { useColumn, useGetTableSet } from './hooks';
import useGetTableData from '@/hooks/useGetTableData';
import dayjs from 'dayjs';
import Setting from './Setting.vue';
import CustomFilterDropdown from './CustomFilterDropdown.vue';
const props = defineProps({
name: String, // 每个table的名称,可以用于setting 的key
columns: Array, // [{ valueType: 'select'| 'radio'| 'input' | 'dateTime' }]
tools: {
type: Object,
default: ['setting'],
},
services: Function, // 内部请求直接使用
onSearch: { // 外部传入时,传出去的 查询方法
type: Function,
default: () => {},
},
searchParams: Object,
});
const { setting, onChange } = useGetTableSet(props.name);
const {
columns: columnsInit,
columnsEnd,
filterValueType,
} = useColumn(props, setting);
const searchParams = ref<Record<string, any>>({
current: 1,
pageSize: 10,
...props.searchParams,
});
const handleChangeTable = (params, filters) => {
const newParams = {
...params,
...filters,
};
const {
showLessItems,
showQuickJumper,
total,
...pro
} = newParams;
for(let key in pro) {
// 单选、输入框啥的、把值处理下
if(['input', 'radio', 'dateTime'].includes(filterValueType.value[key])) {
pro[key] = pro[key] && pro[key].length > 0 ? pro[key][0] : undefined;
// 时间值,转为时间戳
if (filterValueType.value[key] === 'dateTime' && pro[key]) {
pro[key] = pro[key].map((item) => dayjs(item).unix());
}
}
}
searchParams.value = pro;
};
const {
data: dataSource,
run: getTable,
pagination,
loading,
} = useGetTableData(props.services, {
manual: !props.services || !!props.searchParams,
});
// 外界有params入参时,初始请求,使用整合后的入参
if (props.services && !!props.searchParams) {
getTable({
...searchParams.value,
...props.searchParams,
});
}
watch(searchParams, (data) => {
if (props.services) {
getTable(data);
} else {
props.onSearch(data);
}
});
watch(() => props.searchParams, (data) => {
searchParams.value = {
...searchParams.value,
...data,
}
});
defineExpose({
getTable: () => getTable({ ...searchParams.value, ...props.searchParams }),
});
</script>
<template>
<div class="p-4">
<div class="flex items-center">
<div class="flex-1">
<slot name="toolLeft"></slot>
</div>
<div v-if="!!props.tools" class="flex">
<slot name="toolRight"></slot>
<div v-for="item in props.tools" :key="item" class="flex items-center">
<template v-if="item === 'setting'">
<a-popover title="勾选展示">
<template #content>
<Setting
:setting="setting"
:columns="columnsInit"
:onChange="onChange"
/>
</template>
<SettingOutlined class="pb-4" />
</a-popover>
</template>
</div>
</div>
</div>
<!--外部传入dataSource时,会覆盖dataSource -->
<a-table
:columns="columnsEnd"
:dataSource="dataSource"
:pagination="pagination"
:loading="loading"
@change="handleChangeTable"
v-bind="$attrs"
>
<template #customFilterDropdown="{ setSelectedKeys, selectedKeys, confirm, clearFilters, column }">
<CustomFilterDropdown
:setSelectedKeys="setSelectedKeys"
:selectedKeys="selectedKeys"
:confirm="confirm"
:clearFilters="clearFilters"
:column="column"
/>
</template>
<template #customFilterIcon="{ filtered, column }">
<SearchOutlined
v-if="['dateTime', 'input'].includes(column.valueType)"
:style="{ color: filtered ? '#108ee9' : undefined }"
/>
<FilterFilled
v-else
:style="{ color: filtered ? '#108ee9' : undefined }"
/>
</template>
<!-- 透传其他的 slot -->
<template v-for="(item, key, index) in $slots" :key="index" v-slot:[key]="item">
<slot :name="key" v-bind="item"></slot>
</template>
</a-table>
</div>
</template>
<style>
</style>
哦哦想起来了,还有setting数据的存储问题。按道理:这数据应该存在后台,挂在每个用户下面。
不过目前后端没时间搞。我就先搞到localstorage里面了。
localstorage的key值,懒得写。可以传,不传就拿路由当key值。
// 获取table配置(勾选是否展示,字段排序)
// 监听路由,获取每个表格对应的key。这个key前期前端存 localstorge 后期走接口的唯一标识
const useGetTableSet = (key: string = '') => {
const { currentRoute } = useRouter();
const path = currentRoute.value.path;
const setting = ref<Record<string, any>>({}); // 数据格式 {order: '', dataIndex: '', show: false/true }
const onChange = (data:Record<string, any>) => {
saveTabelStatus(key || path, data);
setting.value = data;
}
watch(currentRoute, (data) => {
if (!key) {
setting.value = getTabelStatus(data.path);
}
});
// 初始化
setting.value = getTabelStatus(key || path);
return {
setting,
onChange,
}
}
const MY_TABLE = 'myTable';
// localstorage存储为 json时
const getLocalJson = (table: string) => {
const myTableStr = localStorage.getItem(table);
const myTable = myTableStr ? JSON.parse(myTableStr) : {};
return myTable;
};
const setLocalJson = (table: string, value?: Record<string, any>) => {
const myTable = getLocalJson(table);
localStorage.setItem(table, value ? JSON.stringify({ ...myTable, ...value }) : '');
};
// 获取 存储table 的表头等数据
const getTabelStatus = (key: string) => {
return getLocalJson(MY_TABLE)[key];
};
const saveTabelStatus = (key: string, value: any) => {
setLocalJson(MY_TABLE, { [key]: value });
};
export {
getLocalJson,
setLocalJson,
getTabelStatus,
saveTabelStatus,
};
八、总结
首先,代码里面挺水的,本来我自己用的,懒得时间细细琢磨。大家将就看看就行。
其次,还是那句话,主要提供封装业务思路:
1、不破坏原a-table的东西;
2、核心是,对columns的处理;但是setting数据单独存放,和columns分开。
3、反正自己封装,搞点扩展,valueType筛选过滤。
4、请求交互整合进去,但同时也提供组件外部自己处理方案。