ProTable 是基于 Vue3.2 + Element-Plus 二次封装 el-table,首先我们在封装 ProTable 组件的时候,在不影响 el-table 原有的属性、事件、方法的前提下,然后在其基础上做二次封装。

ProTable 组件目前使用属性透传进行重构,支持 el-table && el-table-column 所有属性、事件、方法的调用。ProTable 组件上的绑定的所有属性和事件都会通过 v-bind="$attrs" 透传到 el-table 上。ProTable 组件内部暴露了 el-table DOM,可通过 proTable.value.element.方法名 调用其方法。

思路

把一个表格页面所有重复的功能 (表格多选、查询、重置、刷新、分页、数据操作二次确认、文件下载、文件上传) 都封装成 Hooks 函数钩子或组件,然后在 ProTable 组件中使用这些函数钩子或组件。在页面中使用的时,只需传给 ProTable 当前表格数据的请求 API、表格配置项 columns 就行了,数据传输都使用 作用域插槽 或 tsx 语法从 ProTable 传递给父组件就能在页面上获取到了。

一、ProTable 功能

1、表格内容自适应屏幕宽高,溢出内容表格内部滚动(flex 布局)
2、表格搜索、重置、分页查询 Hooks 封装 (页面使用不会存在任何搜索、重置、分页查询逻辑)
3、表格数据操作 Hooks 封装 (单条数据删除、批量删除、重置密码、状态切换等操作)
4、表格数据多选 Hooks 封装 (支持现跨页勾选数据)
5、表格数据导入组件、导出 Hooks 封装
6、表格搜索区域使用 Grid 布局重构,支持自定义响应式配置
7、表格分页组件封装(Pagination)
8、表格数据刷新、列显隐、列排序、搜索区域显隐设置
9、表格数据打印功能(可勾选行数据、隐藏列打印)
10、表格配置支持多级 prop(示例 ==> prop: user.detail.name)
11、单元格内容格式化、tag 标签显示(有字典 enum 会根据字典 enum 自动格式化)
12、支持多级表头、表头内容自定义渲染(支持作用域插槽、tsx 语法、h 函数)
13、支持单元格内容自定义渲染(支持作用域插槽、tsx 语法、h 函数)
14、配合 TreeFilter、SelectFilter 组件使用更佳

二、ProTable 需求分析

ProTable 主要分为五个模块:

1、表格搜索区

搜索区域的字段都是存在于表格当中的,并且每个页面的搜索、重置方法都是一样的逻辑,只是不同的查询参数而已。我们完全可以在传表格配置项 columns 时,直接指定某个 column 的 search 配置,就能把该项变为搜索项,然后使用 el 字段可以指定搜索框的类型,最后把表格的搜索方法都封装成 Hooks 钩子函数。页面上完全就不会存在任何搜索、重置逻辑了。

通过 component :is 动态组件 && v-bind 属性透传实现,将用户传递的参数全部透传到组件上,所以可以直接根据 element 官方文档在 props 中传递参数了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
<template>
<component
:is="column.search?.render ?? `el-${column.search?.el}`"
v-bind="{ ...handleSearchProps, ...placeholder, searchParam, clearable }"
v-model.trim="searchParam[column.search?.key ?? handleProp(column.prop!)]"
:data="column.search?.el === 'tree-select' ? columnEnum : []"
:options="['cascader', 'select-v2'].includes(column.search?.el!) ? columnEnum : []"
>
<template #default="{ data }" v-if="column.search?.el === 'cascader'">
<span>{{ data[fieldNames.label] }}</span>
</template>
<template v-if="column.search?.el === 'select'">
<component
:is="`el-option`"
v-for="(col, index) in columnEnum"
:key="index"
:label="col[fieldNames.label]"
:value="col[fieldNames.value]"
></component>
</template>
<slot v-else></slot>
</component>
</template>

<script setup lang="ts" name="SearchFormItem">
import { computed, inject, ref } from "vue";
import { handleProp } from "@/utils";
import { ColumnProps } from "@/components/ProTable/interface";

interface SearchFormItem {
column: ColumnProps;
searchParam: { [key: string]: any };
}
const props = defineProps<SearchFormItem>();

// 判断 fieldNames 设置 label && value && children 的 key 值
const fieldNames = computed(() => {
return {
label: props.column.fieldNames?.label ?? "label",
value: props.column.fieldNames?.value ?? "value",
children: props.column.fieldNames?.children ?? "children",
};
});

// 接收 enumMap (el 为 select-v2 需单独处理 enumData)
const enumMap = inject("enumMap", ref(new Map()));
const columnEnum = computed(() => {
let enumData = enumMap.value.get(props.column.prop);
if (!enumData) return [];
if (props.column.search?.el === "select-v2" && props.column.fieldNames) {
enumData = enumData.map((item: { [key: string]: any }) => {
return {
...item,
label: item[fieldNames.value.label],
value: item[fieldNames.value.value],
};
});
}
return enumData;
});

// 处理透传的 searchProps (el 为 tree-select、cascader 的时候需要给下默认 label && value && children)
const handleSearchProps = computed(() => {
const label = fieldNames.value.label;
const value = fieldNames.value.value;
const children = fieldNames.value.children;
const searchEl = props.column.search?.el;
let searchProps = props.column.search?.props ?? {};
if (searchEl === "tree-select") {
searchProps = {
...searchProps,
props: { ...searchProps.props, label, children },
nodeKey: value,
};
}
if (searchEl === "cascader") {
searchProps = {
...searchProps,
props: { ...searchProps.props, label, value, children },
};
}
return searchProps;
});

// 处理默认 placeholder
const placeholder = computed(() => {
const search = props.column.search;
if (
["datetimerange", "daterange", "monthrange"].includes(
search?.props?.type
) ||
search?.props?.isRange
) {
return {
rangeSeparator: "至",
startPlaceholder: "开始时间",
endPlaceholder: "结束时间",
};
}
const placeholder =
search?.props?.placeholder ??
(search?.el?.includes("input") ? "请输入" : "请选择");
return { placeholder };
});

// 是否有清除按钮 (当搜索项有默认值时,清除按钮不显示)
const clearable = computed(() => {
const search = props.column.search;
return (
search?.props?.clearable ??
(search?.defaultValue == null || search?.defaultValue == undefined)
);
});
</script>

表格搜索项可以使用 tsx 组件自定义渲染

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<script setup lang="tsx">
const columns: ColumnProps[] = [
{
prop: "user.detail.age",
label: "年龄",
search: {
// 自定义 search 组件
render: ({ searchParam }) => {
return (
<div class="flx-center">
<el-input
vModel_trim={searchParam.minAge}
placeholder="最小年龄"
style={{ width: "50%" }}
/>
<span class="mr10 ml10">-</span>
<el-input
vModel_trim={searchParam.maxAge}
placeholder="最大年龄"
style={{ width: "50%" }}
/>
</div>
);
},
},
},
];
</script>

2、表格数据操作按钮

表格数据操作按钮基本上每个页面都会不一样,所以我们直接使用 作用域插槽 来完成每个页面的数据操作按钮区域,作用域插槽 可以将表格多选数据信息从 ProTable 的 Hooks 多选钩子函数中传到页面上使用。
scope 数据中包含:selectedList(当前选择的数据)、selectedListIds(当前选择的数据 id)、isSelected(当前是否选中的数据)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<!-- ProTable 中 tableHeader 插槽 -->
<slot
name="tableHeader"
:selectList="selectedList"
:selectedListIds="selectedListIds"
:isSelected="isSelected"
></slot>

<!-- 页面使用 -->
<template #tableHeader="scope">
<el-button type="primary" :icon="CirclePlus" @click="openDrawer('新增')"
>新增用户</el-button
>
<el-button type="primary" :icon="Upload" plain @click="batchAdd"
>批量添加用户</el-button
>
<el-button type="primary" :icon="Download" plain @click="downloadFile"
>导出用户数据</el-button
>
<el-button
type="danger"
:icon="Delete"
plain
@click="batchDelete(scope.selectedListIds)"
:disabled="!scope.isSelected"
>批量删除用户</el-button
>
</template>

3、表格功能按钮

表格功能按钮包括:表格数据刷新(一直会携带当前查询和分页条件)、表格数据打印、表格列设置(列显隐、列排序)、表格搜索区域显隐(方便展示更多的数据信息)。 可通过 toolButton 属性控制这块区域的显隐。
表格打印基于 PrintJs 实现,因 PrintJs 不支持多级表头打印,所以当页面存在多级表头时,只会打印最后一级表头。表格打印功能可根据显示的列和勾选的数据动态打印,默认打印当前显示的所有数据。

4、表格主体内容展示区域

这里是最重要的数据展示区域,表头和单元格内容可以自定义渲染,表头支持 headerRender 方法(避免与 el-table-column 上的属性重名导致报错)、作用域插槽(column.prop + ‘Header’)两种方式自定义,单元格内容支持 render 方法和作用域插槽(column 上的 prop 属性)两种方式自定义。

作用域插槽:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- 使用作用域插槽自定义单元格内容 username -->
<template #username="scope">
{{ scope.row.username }}
</template>

<!-- 使用作用域插槽自定义表头内容 username -->
<template #usernameHeader="scope">
<el-button
type="primary"
@click="ElMessage.success('我是通过作用域插槽渲染的表头')"
>
{{ scope.column.label }}
</el-button>
</template>

tsx 语法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<script setup lang="tsx">
const columns: ColumnProps[] = [
{
prop: "username",
label: "用户姓名",
// 使用 headerRender 自定义表头
headerRender: scope => {
return (
<el-button
type="primary"
onClick={() => {
ElMessage.success("我是通过 tsx 语法渲染的表头");
}}
>
{scope.column.label}
</el-button>
);
}
},
{
prop: "status",
label: "用户状态",
// 使用 render 自定义表格内容
render: scope => {
return (
<el-switch
model-value={scope.row.status}
active-text={scope.row.status ? "启用" : "禁用"}
active-value={1}
inactive-value={0}
onClick={() => changeStatus(scope.row)}
/>
)
);
}
},
];
</script>

如果你想使用 el-table 的任何属性、事件,目前通过属性透传都能支持。

1
2
3
4
5
6
7
8
9
10
11
12
<template>
<el-table ref="tableRef" v-bind="$attrs"> </el-table>
</template>

<script setup lang="ts" name="ProTable">
import { ref } from "vue";
import { ElTable } from "element-plus";

const tableRef = ref<InstanceType<typeof ElTable>>();

defineExpose({ element: tableRef });
</script>

5、表格分页

表格分页的封装可以使 ProTable 页面不存在分页逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<template>
<!-- 分页组件 -->
<el-pagination
:background="true"
:current-page="pageable.pageNum"
:page-size="pageable.pageSize"
:page-sizes="[10, 25, 50, 100]"
:total="pageable.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
></el-pagination>
</template>

<script setup lang="ts" name="Pagination">
interface Pageable {
pageNum: number;
pageSize: number;
total: number;
}

interface PaginationProps {
pageable: Pageable;
handleSizeChange: (size: number) => void;
handleCurrentChange: (currentPage: number) => void;
}

defineProps<PaginationProps>();
</script>

三、Hooks 函数

1、useTable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
import { Table } from "./interface";
import { reactive, computed, toRefs } from "vue";

/**
* @description table 页面操作方法封装
* @param {Function} api 获取表格数据 api 方法 (必传)
* @param {Object} initParam 获取数据初始化参数 (非必传,默认为{})
* @param {Boolean} isPageable 是否有分页 (非必传,默认为true)
* @param {Function} dataCallBack 对后台返回的数据进行处理的方法 (非必传)
* */
export const useTable = (
api?: (params: any) => Promise<any>,
initParam: object = {},
isPageable: boolean = true,
dataCallBack?: (data: any) => any,
requestError?: (error: any) => void
) => {
const state = reactive<Table.TableStateProps>({
// 表格数据
tableData: [],
// 分页数据
pageable: {
// 当前页数
pageNum: 1,
// 每页显示条数
pageSize: 10,
// 总条数
total: 0,
},
// 查询参数(只包括查询)
searchParam: {},
// 初始化默认的查询参数
searchInitParam: {},
// 总参数(包含分页和查询参数)
totalParam: {},
});

/**
* @description 分页查询参数(只包括分页和表格字段排序,其他排序方式可自行配置)
* */
const pageParam = computed({
get: () => {
return {
pageNum: state.pageable.pageNum,
pageSize: state.pageable.pageSize,
};
},
set: (newVal: any) => {
console.log("我是分页更新之后的值", newVal);
},
});

/**
* @description 获取表格数据
* @return void
* */
const getTableList = async () => {
if (!api) return;
try {
// 先把初始化参数和分页参数放到总参数里面
Object.assign(
state.totalParam,
initParam,
isPageable ? pageParam.value : {}
);
let { data } = await api({
...state.searchInitParam,
...state.totalParam,
});
dataCallBack && (data = dataCallBack(data));
state.tableData = isPageable ? data.list : data;
// 解构后台返回的分页数据 (如果有分页更新分页信息)
const { pageNum, pageSize, total } = data;
isPageable && updatePageable({ pageNum, pageSize, total });
} catch (error) {
requestError && requestError(error);
}
};

/**
* @description 更新查询参数
* @return void
* */
const updatedTotalParam = () => {
state.totalParam = {};
// 处理查询参数,可以给查询参数加自定义前缀操作
let nowSearchParam: { [key: string]: any } = {};
// 防止手动清空输入框携带参数(这里可以自定义查询参数前缀)
for (let key in state.searchParam) {
// * 某些情况下参数为 false/0 也应该携带参数
if (
state.searchParam[key] ||
state.searchParam[key] === false ||
state.searchParam[key] === 0
) {
nowSearchParam[key] = state.searchParam[key];
}
}
Object.assign(
state.totalParam,
nowSearchParam,
isPageable ? pageParam.value : {}
);
};

/**
* @description 更新分页信息
* @param {Object} resPageable 后台返回的分页数据
* @return void
* */
const updatePageable = (resPageable: Table.Pageable) => {
Object.assign(state.pageable, resPageable);
};

/**
* @description 表格数据查询
* @return void
* */
const search = () => {
state.pageable.pageNum = 1;
updatedTotalParam();
getTableList();
};

/**
* @description 表格数据重置
* @return void
* */
const reset = () => {
state.pageable.pageNum = 1;
state.searchParam = {};
// 重置搜索表单的时,如果有默认搜索参数,则重置默认的搜索参数
Object.keys(state.searchInitParam).forEach((key) => {
state.searchParam[key] = state.searchInitParam[key];
});
updatedTotalParam();
getTableList();
};

/**
* @description 每页条数改变
* @param {Number} val 当前条数
* @return void
* */
const handleSizeChange = (val: number) => {
state.pageable.pageNum = 1;
state.pageable.pageSize = val;
getTableList();
};

/**
* @description 当前页改变
* @param {Number} val 当前页
* @return void
* */
const handleCurrentChange = (val: number) => {
state.pageable.pageNum = val;
getTableList();
};

return {
...toRefs(state),
getTableList,
search,
reset,
handleSizeChange,
handleCurrentChange,
updatedTotalParam,
};
};

2、useSelection

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { ref, computed } from "vue";

/**
* @description 表格多选数据操作
* @param {String} rowKey 当表格可以多选时,所指定的 id
* */
export const useSelection = (rowKey: string = "id") => {
const isSelected = ref<boolean>(false);
const selectedList = ref<{ [key: string]: any }[]>([]);

// 当前选中的所有 ids 数组
const selectedListIds = computed((): string[] => {
let ids: string[] = [];
selectedList.value.forEach((item) => ids.push(item[rowKey]));
return ids;
});

/**
* @description 多选操作
* @param {Array} rowArr 当前选择的所有数据
* @return void
*/
const selectionChange = (rowArr: { [key: string]: any }[]) => {
rowArr.length ? (isSelected.value = true) : (isSelected.value = false);
selectedList.value = rowArr;
};

return {
isSelected,
selectedList,
selectedListIds,
selectionChange,
};
};

3、useDownload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import { ElNotification } from "element-plus";

/**
* @description 接收数据流生成 blob,创建链接,下载文件
* @param {Function} api 导出表格的api方法 (必传)
* @param {String} tempName 导出的文件名 (必传)
* @param {Object} params 导出的参数 (默认{})
* @param {Boolean} isNotify 是否有导出消息提示 (默认为 true)
* @param {String} fileType 导出的文件格式 (默认为.xlsx)
* */
export const useDownload = async (
api: (param: any) => Promise<any>,
tempName: string,
params: any = {},
isNotify: boolean = true,
fileType: string = ".xlsx"
) => {
if (isNotify) {
ElNotification({
title: "温馨提示",
message: "如果数据庞大会导致下载缓慢哦,请您耐心等待!",
type: "info",
duration: 3000,
});
}
try {
const res = await api(params);
const blob = new Blob([res]);
// 兼容 edge 不支持 createObjectURL 方法
if ("msSaveOrOpenBlob" in navigator)
return window.navigator.msSaveOrOpenBlob(blob, tempName + fileType);
const blobUrl = window.URL.createObjectURL(blob);
const exportFile = document.createElement("a");
exportFile.style.display = "none";
exportFile.download = `${tempName}${fileType}`;
exportFile.href = blobUrl;
document.body.appendChild(exportFile);
exportFile.click();
// 去除下载对 url 的影响
document.body.removeChild(exportFile);
window.URL.revokeObjectURL(blobUrl);
} catch (error) {
console.log(error);
}
};

4、useHandledata

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { ElMessageBox, ElMessage } from "element-plus";
import { HandleData } from "./interface";

/**
* @description 操作单条数据信息 (二次确认【删除、禁用、启用、重置密码】)
* @param {Function} api 操作数据接口的api方法 (必传)
* @param {Object} params 携带的操作数据参数 {id,params} (必传)
* @param {String} message 提示信息 (必传)
* @param {String} confirmType icon类型 (不必传,默认为 warning)
* @returns {Promise}
*/
export const useHandleData = (
api: (params: any) => Promise<any>,
params: any = {},
message: string,
confirmType: HandleData.MessageType = "warning"
) => {
return new Promise((resolve, reject) => {
ElMessageBox.confirm(`是否${message}?`, "温馨提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: confirmType,
draggable: true,
}).then(async () => {
const res = await api(params);
if (!res) return reject(false);
ElMessage({
type: "success",
message: `${message}成功!`,
});
resolve(true);
});
});
};

四、ProTable 组件部分代码

ProTable.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
<template>
<!-- 查询表单 card -->
<SearchForm
:search="search"
:reset="reset"
:columns="searchColumns"
:search-param="searchParam"
:search-col="searchCol"
v-show="isShowSearch"
/>

<!-- 表格内容 card -->
<div class="card table-main">
<!-- 表格头部 操作按钮 -->
<div class="table-header">
<div class="header-button-lf">
<slot
name="tableHeader"
:selectedListIds="selectedListIds"
:selectedList="selectedList"
:isSelected="isSelected"
/>
</div>
<div class="header-button-ri" v-if="toolButton">
<slot name="toolButton">
<el-button :icon="Refresh" circle @click="getTableList" />
<el-button
:icon="Printer"
circle
v-if="columns.length"
@click="print"
/>
<el-button
:icon="Operation"
circle
v-if="columns.length"
@click="openColSetting"
/>
<el-button
:icon="Search"
circle
v-if="searchColumns.length"
@click="isShowSearch = !isShowSearch"
/>
</slot>
</div>
</div>
<!-- 表格主体 -->
<el-table
ref="tableRef"
v-bind="$attrs"
:data="data ?? tableData"
:border="border"
:row-key="rowKey"
@selection-change="selectionChange"
>
<!-- 默认插槽 -->
<slot></slot>
<template v-for="item in tableColumns" :key="item">
<!-- selection || index || expand -->
<el-table-column
v-bind="item"
:align="item.align ?? 'center'"
:reserve-selection="item.type == 'selection'"
v-if="
item.type && ['selection', 'index', 'expand'].includes(item.type)
"
>
<template #default="scope" v-if="item.type == 'expand'">
<component :is="item.render" v-bind="scope" v-if="item.render">
</component>
<slot :name="item.type" v-bind="scope" v-else></slot>
</template>
</el-table-column>
<!-- other -->
<TableColumn
v-if="!item.type && item.prop && item.isShow"
:column="item"
>
<template v-for="slot in Object.keys($slots)" #[slot]="scope">
<slot :name="slot" v-bind="scope"></slot>
</template>
</TableColumn>
</template>
<!-- 插入表格最后一行之后的插槽 -->
<template #append>
<slot name="append"> </slot>
</template>
<!-- 无数据 -->
<template #empty>
<div class="table-empty">
<slot name="empty">
<img src="@/assets/images/notData.png" alt="notData" />
<div>暂无数据</div>
</slot>
</div>
</template>
</el-table>
<!-- 分页组件 -->
<slot name="pagination">
<Pagination
v-if="pagination"
:pageable="pageable"
:handle-size-change="handleSizeChange"
:handle-current-change="handleCurrentChange"
/>
</slot>
</div>
<!-- 列设置 -->
<ColSetting v-if="toolButton" ref="colRef" v-model:col-setting="colSetting" />
</template>

<script setup lang="ts" name="ProTable">
import { ref, watch, computed, provide, onMounted } from "vue";
import { ElTable } from "element-plus";
import { useTable } from "@/hooks/useTable";
import { useSelection } from "@/hooks/useSelection";
import { BreakPoint } from "@/components/Grid/interface";
import { ColumnProps } from "@/components/ProTable/interface";
import { Refresh, Printer, Operation, Search } from "@element-plus/icons-vue";
import {
filterEnum,
formatValue,
handleProp,
handleRowAccordingToProp,
} from "@/utils";
import SearchForm from "@/components/SearchForm/index.vue";
import Pagination from "./components/Pagination.vue";
import ColSetting from "./components/ColSetting.vue";
import TableColumn from "./components/TableColumn.vue";
import printJS from "print-js";

export interface ProTableProps {
columns: ColumnProps[]; // 列配置项 ==> 必传
data?: any[]; // 静态 table data 数据,若存在则不会使用 requestApi 返回的 data ==> 非必传
requestApi?: (params: any) => Promise<any>; // 请求表格数据的 api ==> 非必传
requestAuto?: boolean; // 是否自动执行请求 api ==> 非必传(默认为true)
requestError?: (params: any) => void; // 表格 api 请求错误监听 ==> 非必传
dataCallback?: (data: any) => any; // 返回数据的回调函数,可以对数据进行处理 ==> 非必传
title?: string; // 表格标题,目前只在打印的时候用到 ==> 非必传
pagination?: boolean; // 是否需要分页组件 ==> 非必传(默认为true)
initParam?: any; // 初始化请求参数 ==> 非必传(默认为{})
border?: boolean; // 是否带有纵向边框 ==> 非必传(默认为true)
toolButton?: boolean; // 是否显示表格功能按钮 ==> 非必传(默认为true)
rowKey?: string; // 行数据的 Key,用来优化 Table 的渲染,当表格数据多选时,所指定的 id ==> 非必传(默认为 id)
searchCol?: number | Record<BreakPoint, number>; // 表格搜索项 每列占比配置 ==> 非必传 { xs: 1, sm: 2, md: 2, lg: 3, xl: 4 }
}

// 接受父组件参数,配置默认值
const props = withDefaults(defineProps<ProTableProps>(), {
columns: () => [],
requestAuto: true,
pagination: true,
initParam: {},
border: true,
toolButton: true,
rowKey: "id",
searchCol: () => ({ xs: 1, sm: 2, md: 2, lg: 3, xl: 4 }),
});

// 是否显示搜索模块
const isShowSearch = ref(true);

// 表格 DOM 元素
const tableRef = ref<InstanceType<typeof ElTable>>();

// 表格多选 Hooks
const { selectionChange, selectedList, selectedListIds, isSelected } =
useSelection(props.rowKey);

// 表格操作 Hooks
const {
tableData,
pageable,
searchParam,
searchInitParam,
getTableList,
search,
reset,
handleSizeChange,
handleCurrentChange,
} = useTable(
props.requestApi,
props.initParam,
props.pagination,
props.dataCallback,
props.requestError
);

// 清空选中数据列表
const clearSelection = () => tableRef.value!.clearSelection();

// 初始化请求
onMounted(() => props.requestAuto && getTableList());

// 监听页面 initParam 改化,重新获取表格数据
watch(() => props.initParam, getTableList, { deep: true });

// 接收 columns 并设置为响应式
const tableColumns = ref<ColumnProps[]>(props.columns);

// 定义 enumMap 存储 enum 值(避免异步请求无法格式化单元格内容 || 无法填充搜索下拉选择)
const enumMap = ref(new Map<string, { [key: string]: any }[]>());
provide("enumMap", enumMap);
const setEnumMap = async (col: ColumnProps) => {
if (!col.enum) return;
// 如果当前 enum 为后台数据需要请求数据,则调用该请求接口,并存储到 enumMap
if (typeof col.enum !== "function")
return enumMap.value.set(col.prop!, col.enum!);
const { data } = await col.enum();
enumMap.value.set(col.prop!, data);
};

// 扁平化 columns
const flatColumnsFunc = (
columns: ColumnProps[],
flatArr: ColumnProps[] = []
) => {
columns.forEach(async (col) => {
if (col._children?.length) flatArr.push(...flatColumnsFunc(col._children));
flatArr.push(col);

// 给每一项 column 添加 isShow && isFilterEnum 默认属性
col.isShow = col.isShow ?? true;
col.isFilterEnum = col.isFilterEnum ?? true;

// 设置 enumMap
setEnumMap(col);
});
return flatArr.filter((item) => !item._children?.length);
};

// flatColumns
const flatColumns = ref<ColumnProps[]>();
flatColumns.value = flatColumnsFunc(tableColumns.value);

// 过滤需要搜索的配置项
const searchColumns = flatColumns.value.filter(
(item) => item.search?.el || item.search?.render
);

// 设置搜索表单排序默认值 && 设置搜索表单项的默认值
searchColumns.forEach((column, index) => {
column.search!.order = column.search!.order ?? index + 2;
if (
column.search?.defaultValue !== undefined &&
column.search?.defaultValue !== null
) {
searchInitParam.value[column.search.key ?? handleProp(column.prop!)] =
column.search?.defaultValue;
searchParam.value[column.search.key ?? handleProp(column.prop!)] =
column.search?.defaultValue;
}
});

// 排序搜索表单项
searchColumns.sort((a, b) => a.search!.order! - b.search!.order!);

// 列设置 ==> 过滤掉不需要设置的列
const colRef = ref();
const colSetting = tableColumns.value!.filter(
(item) =>
!["selection", "index", "expand"].includes(item.type!) &&
item.prop !== "operation" &&
item.isShow
);
const openColSetting = () => colRef.value.openColSetting();

// 🙅‍♀️ 不需要打印可以把以下方法删除,打印功能目前存在很多 bug
// 处理打印数据(把后台返回的值根据 enum 做转换)
const printData = computed(() => {
const handleData = props.data ?? tableData.value;
const printDataList = JSON.parse(
JSON.stringify(selectedList.value.length ? selectedList.value : handleData)
);
// 找出需要转换数据的列(有 enum || 多级 prop && 需要根据 enum 格式化)
const needTransformCol = flatColumns.value!.filter(
(item) =>
(item.enum || (item.prop && item.prop.split(".").length > 1)) &&
item.isFilterEnum
);
needTransformCol.forEach((colItem) => {
printDataList.forEach((tableItem: { [key: string]: any }) => {
tableItem[handleProp(colItem.prop!)] =
colItem.prop!.split(".").length > 1 && !colItem.enum
? formatValue(handleRowAccordingToProp(tableItem, colItem.prop!))
: filterEnum(
handleRowAccordingToProp(tableItem, colItem.prop!),
enumMap.value.get(colItem.prop!),
colItem.fieldNames
);
for (const key in tableItem) {
if (tableItem[key] === null)
tableItem[key] = formatValue(tableItem[key]);
}
});
});
return printDataList;
});

// 打印表格数据(💥 多级表头数据打印时,只能扁平化成一维数组,printJs 不支持多级表头打印)
const print = () => {
const header = `<div style="text-align: center"><h2>${props.title}</h2></div>`;
const gridHeaderStyle =
"border: 1px solid #ebeef5;height: 45px;color: #232425;text-align: center;background-color: #fafafa;";
const gridStyle =
"border: 1px solid #ebeef5;height: 40px;color: #494b4e;text-align: center";
printJS({
printable: printData.value,
header: props.title && header,
properties: flatColumns
.value!.filter(
(item) =>
!["selection", "index", "expand"].includes(item.type!) &&
item.isShow &&
item.prop !== "operation"
)
.map((item: ColumnProps) => ({
field: handleProp(item.prop!),
displayName: item.label,
})),
type: "json",
gridHeaderStyle,
gridStyle,
});
};

// 暴露给父组件的参数和方法(外部需要什么,都可以从这里暴露出去)
defineExpose({
element: tableRef,
tableData,
pageable,
searchParam,
searchInitParam,
getTableList,
search,
reset,
handleSizeChange,
handleCurrentChange,
clearSelection,
enumMap,
isSelected,
selectedList,
selectedListIds,
});
</script>

TableColumn.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
<template>
<RenderTableColumn v-bind="column" />
</template>

<script setup lang="tsx" name="TableColumn">
import { inject, ref, useSlots } from "vue";
import {
ColumnProps,
RenderScope,
HeaderRenderScope,
} from "@/components/ProTable/interface";
import {
filterEnum,
formatValue,
handleProp,
handleRowAccordingToProp,
} from "@/utils";

defineProps<{ column: ColumnProps }>();

const slots = useSlots();

const enumMap = inject("enumMap", ref(new Map()));

// 渲染表格数据
const renderCellData = (item: ColumnProps, scope: RenderScope<any>) => {
return enumMap.value.get(item.prop) && item.isFilterEnum
? filterEnum(
handleRowAccordingToProp(scope.row, item.prop!),
enumMap.value.get(item.prop)!,
item.fieldNames
)
: formatValue(handleRowAccordingToProp(scope.row, item.prop!));
};

// 获取 tag 类型
const getTagType = (item: ColumnProps, scope: RenderScope<any>) => {
return filterEnum(
handleRowAccordingToProp(scope.row, item.prop!),
enumMap.value.get(item.prop),
item.fieldNames,
"tag"
);
};

const RenderTableColumn = (item: ColumnProps) => {
return (
<>
{item.isShow && (
<el-table-column
{...item}
align={item.align ?? "center"}
showOverflowTooltip={
item.showOverflowTooltip ?? item.prop !== "operation"
}
>
{{
default: (scope: RenderScope<any>) => {
if (item._children)
return item._children.map((child) => RenderTableColumn(child));
if (item.render) return item.render(scope);
if (slots[handleProp(item.prop!)])
return slots[handleProp(item.prop!)]!(scope);
if (item.tag)
return (
<el-tag type={getTagType(item, scope)}>
{renderCellData(item, scope)}
</el-tag>
);
return renderCellData(item, scope);
},
header: (scope: HeaderRenderScope<any>) => {
if (item.headerRender) return item.headerRender(scope);
if (slots[`${handleProp(item.prop!)}Header`])
return slots[`${handleProp(item.prop!)}Header`]!(scope);
return item.label;
},
}}
</el-table-column>
)}
</>
);
};
</script>

使用 ProTable 组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
<template>
<div class="table-box">
<ProTable
ref="proTable"
title="用户列表"
:columns="columns"
:request-api="getTableList"
:init-param="initParam"
:data-callback="dataCallback"
>
<!-- 表格 header 按钮 -->
<template #tableHeader="scope">
<el-button
type="primary"
:icon="CirclePlus"
@click="openDrawer('新增')"
v-auth="'add'"
>新增用户</el-button
>
<el-button
type="primary"
:icon="Upload"
plain
@click="batchAdd"
v-auth="'batchAdd'"
>批量添加用户</el-button
>
<el-button
type="primary"
:icon="Download"
plain
@click="downloadFile"
v-auth="'export'"
>导出用户数据</el-button
>
<el-button type="primary" plain @click="toDetail"
>To 子集详情页面</el-button
>
<el-button
type="danger"
:icon="Delete"
plain
@click="batchDelete(scope.selectedListIds)"
:disabled="!scope.isSelected"
>
批量删除用户
</el-button>
</template>
<!-- Expand -->
<template #expand="scope">
{{ scope.row }}
</template>
<!-- usernameHeader -->
<template #usernameHeader="scope">
<el-button
type="primary"
@click="ElMessage.success('我是通过作用域插槽渲染的表头')"
>
{{ scope.column.label }}
</el-button>
</template>
<!-- createTime -->
<template #createTime="scope">
<el-button
type="primary"
link
@click="ElMessage.success('我是通过作用域插槽渲染的内容')"
>
{{ scope.row.createTime }}
</el-button>
</template>
<!-- 表格操作 -->
<template #operation="scope">
<el-button
type="primary"
link
:icon="View"
@click="openDrawer('查看', scope.row)"
>查看</el-button
>
<el-button
type="primary"
link
:icon="EditPen"
@click="openDrawer('编辑', scope.row)"
>编辑</el-button
>
<el-button
type="primary"
link
:icon="Refresh"
@click="resetPass(scope.row)"
>重置密码</el-button
>
<el-button
type="primary"
link
:icon="Delete"
@click="deleteAccount(scope.row)"
>删除</el-button
>
</template>
</ProTable>
<UserDrawer ref="drawerRef" />
<ImportExcel ref="dialogRef" />
</div>
</template>

<script setup lang="tsx" name="useProTable">
import { ref, reactive } from "vue";
import { useRouter } from "vue-router";
import { User } from "@/api/interface";
import { useHandleData } from "@/hooks/useHandleData";
import { useDownload } from "@/hooks/useDownload";
import { useAuthButtons } from "@/hooks/useAuthButtons";
import { ElMessage, ElMessageBox } from "element-plus";
import ProTable from "@/components/ProTable/index.vue";
import ImportExcel from "@/components/ImportExcel/index.vue";
import UserDrawer from "@/views/proTable/components/UserDrawer.vue";
import {
ProTableInstance,
ColumnProps,
HeaderRenderScope,
} from "@/components/ProTable/interface";
import {
CirclePlus,
Delete,
EditPen,
Download,
Upload,
View,
Refresh,
} from "@element-plus/icons-vue";
import {
getUserList,
deleteUser,
editUser,
addUser,
changeUserStatus,
resetUserPassWord,
exportUserInfo,
BatchAddUser,
getUserStatus,
getUserGender,
} from "@/api/modules/user";

const router = useRouter();

// 跳转详情页
const toDetail = () => {
router.push(
`/proTable/useProTable/detail/${Math.random().toFixed(
3
)}?params=detail-page`
);
};

// 获取 ProTable 元素,调用其获取刷新数据方法(还能获取到当前查询参数,方便导出携带参数)
const proTable = ref<ProTableInstance>();

// 如果表格需要初始化请求参数,直接定义传给 ProTable(之后每次请求都会自动带上该参数,此参数更改之后也会一直带上,改变此参数会自动刷新表格数据)
const initParam = reactive({ type: 1 });

// dataCallback 是对于返回的表格数据做处理,如果你后台返回的数据不是 list && total && pageNum && pageSize 这些字段,那么你可以在这里进行处理成这些字段
// 或者直接去 hooks/useTable.ts 文件中把字段改为你后端对应的就行
const dataCallback = (data: any) => {
return {
list: data.list,
total: data.total,
pageNum: data.pageNum,
pageSize: data.pageSize,
};
};

// 如果你想在请求之前对当前请求参数做一些操作,可以自定义如下函数:params 为当前所有的请求参数(包括分页),最后返回请求列表接口
// 默认不做操作就直接在 ProTable 组件上绑定 :requestApi="getUserList"
const getTableList = (params: any) => {
let newParams = JSON.parse(JSON.stringify(params));
newParams.createTime && (newParams.startTime = newParams.createTime[0]);
newParams.createTime && (newParams.endTime = newParams.createTime[1]);
delete newParams.createTime;
return getUserList(newParams);
};

// 页面按钮权限(按钮权限既可以使用 hooks,也可以直接使用 v-auth 指令,指令适合直接绑定在按钮上,hooks 适合根据按钮权限显示不同的内容)
const { BUTTONS } = useAuthButtons();

// 自定义渲染表头(使用tsx语法)
const headerRender = (scope: HeaderRenderScope<User.ResUserList>) => {
return (
<el-button
type="primary"
onClick={() => ElMessage.success("我是通过 tsx 语法渲染的表头")}
>
{scope.column.label}
</el-button>
);
};

// 表格配置项
const columns: ColumnProps<User.ResUserList>[] = [
{ type: "selection", fixed: "left", width: 80 },
{ type: "index", label: "#", width: 80 },
{ type: "expand", label: "Expand", width: 100 },
{
prop: "username",
label: "用户姓名",
search: { el: "input" },
render: (scope) => {
return (
<el-button
type="primary"
link
onClick={() => ElMessage.success("我是通过 tsx 语法渲染的内容")}
>
{scope.row.username}
</el-button>
);
},
},
{
prop: "gender",
label: "性别",
// 字典数据
// enum: genderType,
// 字典请求不带参数
enum: getUserGender,
// 字典请求携带参数
// enum: () => getUserGender({ id: 1 }),
search: { el: "select", props: { filterable: true } },
fieldNames: { label: "genderLabel", value: "genderValue" },
},
{
// 多级 prop
prop: "user.detail.age",
label: "年龄",
search: {
// 自定义 search 显示内容
render: ({ searchParam }) => {
return (
<div class="flx-center">
<el-input vModel_trim={searchParam.minAge} placeholder="最小年龄" />
<span class="mr10 ml10">-</span>
<el-input vModel_trim={searchParam.maxAge} placeholder="最大年龄" />
</div>
);
},
},
},
{ prop: "idCard", label: "身份证号", search: { el: "input" } },
{ prop: "email", label: "邮箱" },
{ prop: "address", label: "居住地址" },
{
prop: "status",
label: "用户状态",
enum: getUserStatus,
search: { el: "tree-select", props: { filterable: true } },
fieldNames: { label: "userLabel", value: "userStatus" },
render: (scope) => {
return (
<>
{BUTTONS.value.status ? (
<el-switch
model-value={scope.row.status}
active-text={scope.row.status ? "启用" : "禁用"}
active-value={1}
inactive-value={0}
onClick={() => changeStatus(scope.row)}
/>
) : (
<el-tag type={scope.row.status ? "success" : "danger"}>
{scope.row.status ? "启用" : "禁用"}
</el-tag>
)}
</>
);
},
},
{
prop: "createTime",
label: "创建时间",
headerRender,
width: 180,
search: {
el: "date-picker",
span: 2,
props: { type: "datetimerange", valueFormat: "YYYY-MM-DD HH:mm:ss" },
defaultValue: ["2022-11-12 11:35:00", "2022-12-12 11:35:00"],
},
},
{ prop: "operation", label: "操作", fixed: "right", width: 330 },
];

// 删除用户信息
const deleteAccount = async (params: User.ResUserList) => {
await useHandleData(
deleteUser,
{ id: [params.id] },
`删除【${params.username}】用户`
);
proTable.value?.getTableList();
};

// 批量删除用户信息
const batchDelete = async (id: string[]) => {
await useHandleData(deleteUser, { id }, "删除所选用户信息");
proTable.value?.clearSelection();
proTable.value?.getTableList();
};

// 重置用户密码
const resetPass = async (params: User.ResUserList) => {
await useHandleData(
resetUserPassWord,
{ id: params.id },
`重置【${params.username}】用户密码`
);
proTable.value?.getTableList();
};

// 切换用户状态
const changeStatus = async (row: User.ResUserList) => {
await useHandleData(
changeUserStatus,
{ id: row.id, status: row.status == 1 ? 0 : 1 },
`切换【${row.username}】用户状态`
);
proTable.value?.getTableList();
};

// 导出用户列表
const downloadFile = async () => {
ElMessageBox.confirm("确认导出用户数据?", "温馨提示", {
type: "warning",
}).then(() =>
useDownload(exportUserInfo, "用户列表", proTable.value?.searchParam)
);
};

// 批量添加用户
const dialogRef = ref<InstanceType<typeof ImportExcel> | null>(null);
const batchAdd = () => {
const params = {
title: "用户",
tempApi: exportUserInfo,
importApi: BatchAddUser,
getTableList: proTable.value?.getTableList,
};
dialogRef.value?.acceptParams(params);
};

// 打开 drawer(新增、查看、编辑)
const drawerRef = ref<InstanceType<typeof UserDrawer> | null>(null);
const openDrawer = (title: string, row: Partial<User.ResUserList> = {}) => {
const params = {
title,
isView: title === "查看",
row: { ...row },
api: title === "新增" ? addUser : title === "编辑" ? editUser : undefined,
getTableList: proTable.value?.getTableList,
};
drawerRef.value?.acceptParams(params);
};
</script>