第三节:剖析自定义显示列、显示顺序、Excel文件流形式的导出方案
一. 自定义显示列和顺序
1. 目标与效果
(1). 可以自定义拖拽、勾选来决定哪些表格列显示,已经表格列的排列顺序。
(2). 可以实现动态增加列名或者删除某个列名
(3). 其它
2. 缓存存储的格式
key:账号_菜单名(admin_systemUser)
value:序列化后的字符串
[
{ id: '1', label: '账号', columnName: 'userAccount', isSelect: true },
{ id: '2', label: '姓名', columnName: 'userRealName', isSelect: false }
]
(1). 需要解决的第一个问题,当缓存中没有,需要请求两次接口的问题
【解决方案:弹框中的展示逻辑放到openDialog打开弹框方法中,另外closeDialog关闭弹框方法中需要对选中节点进行一下清空 】
(2). 需要解决的第二个问题,是否采用local缓存,考虑采用Session缓存,即第一次加载的时候都要请求接口,然后存到Session中,这样方便更新
【解决方案:最终决定存放到SessionStorage中,即首次打开该页面都要请求接口】
(3). 当缓存中没有,请求接口后,此时要存放到缓存中,否则刷新页面还是要继续请求接口的(只有当保存的时候,才存了缓存)
【解决方案:主页面请求接口后,如果有数据,要存放到缓存中】
(4). 前端写死的默认显示列表变了,比如新增了一个字段A,如果该用户DB中有设置数据,需要的效果是:设置页面能显示出来,放到最后的位置,且是未勾选状态
【解决方案:通过对比获取新增的字段,放到数据源的最后位置】
(5). 前端删除了1个字段,目前的效果:如果该字段没有选中, 设置页面该字段仍然存在,且是没有选中的状态;列表该字段不显示。
如果该字段已经选中,设置页面该字段仍然存在,且是选中状态;列表该字段不显示。
(想要的效果:原先即使勾选了这个字段,设置页面也不显示,列表也不直接显示列)
【解决方案:通过对比获取删除的字段,从绑定数据源中删除】
(6). 前端字段默认顺序调整了,目前的效果: DB中有数据,设置页面不变,重置后变为最新的顺序。
(想要的效果:目前效果即可)
a. 通过draggable属性给tree开启拖拽, 通过allow-drop监听,禁止成为子节点;
b. 通过@node-drop监听拖拽完后的事件,解决拖拽后节点默认取消选中的问题,改为保持原状态
c. 每次打开弹框时候,进行树的加载,获取数据的顺序为:Session缓存 → 请求接口 → 父页面传递默认显示
d. 请求接口需要两个参数,id和menuName,id从token中解析,menuName格式为: 账号_页面名称,如:admin_systemUser
e. 处理了几个特殊情况:默认字段删除,系统需要删除;增加默认字段,设置页面需要把新增的字段放到最后,且是不选中的
f. 自己做一个全选按钮,监听是否所用节点被选中,需要配合tree的@check-change 和 @node-click
g. 树的选中节点通过 监听 :default-checked-keys 来实现
h. 关闭弹框时候,需要清空绑定树的数据 和 树的选中节点数据
i. 保存逻辑:先存Session缓存, 然后提交到接口,成功后 → 通过emit调用父页面方法,emit('updateTableColumn', newColumnData); 进行数据更新。
j. 重置逻辑:这里重置后,需要点击保存才最终生效哦
下面分享setting组件的代码:
<template> <el-drawer v-model="isShow" title="自定义列显示/排序" :direction="direction" :size="mySize" @close="closeDialog"> <div class="myBtn"> <el-button size="default" type="primary" @click="save">保存</el-button> <el-button size="default" type="success" @click="reset">重置</el-button> </div> <div class="myAllChecked"> <el-checkbox v-model="isAllChecked" label="全选" @change="handleAllChecked"></el-checkbox> </div> <div class="myTree"> <el-tree ref="pTreeRef" node-key="id" show-checkbox check-on-click-node draggable @node-drop="drawEnd" @check-change="toggleAllChecked" @node-click="toggleAllChecked" :data="columnData" :allow-drop="allowDrop" :default-checked-keys="defaultCheckedKeys" /> </div> </el-drawer> </template> <script setup name="mySetting"> import { ref, nextTick, getCurrentInstance } from 'vue'; import { myAxios2, myAxios } from '/@/utils/request'; import { Session } from '/@/utils/storage'; import { ElMessage } from 'element-plus'; // 声明对外传递事件 const emit = defineEmits(['updateTableColumn']); // 接收父组件传值 const props = defineProps({ // 初始默认显示 propList: { type: Array, default() { return []; }, }, //父页面的name值 pageName: { type: String, default: '', }, }); const { proxy } = getCurrentInstance(); const store = useStore(); const isShow = ref(false); //显示or隐藏 const direction = ref('rtl'); //弹出方向 const mySize = ref(350); //drawer宽度 px const pTreeRef = ref(null); //树对象 let defaultCheckedKeys = ref([]); //默认选中的节点 let isAllChecked = ref(false); //是否全选 const columnLocalKey = `${store.state.userInfos.userInfos.userAccount}_${props.pageName}`; //表格显示列数据对应的缓存key, eg: admin_systemUser //待绑定的数据源-----保留空数组即可!! const columnData = ref([ /* { id: '0', label: '账号', columnName: 'userAccount', isSelect: true }, { id: '1', label: '姓名', columnName: 'userRealName', isSelect: true }, { id: '2', label: '性别', columnName: 'userSex', isSelect: true }, { id: '3', label: '联系方式', columnName: 'userPhone', isSelect: true }, { id: '4', label: '用户描述', columnName: 'userRemark', isSelect: true }, { id: '5', label: '创建时间', columnName: 'addTime', isSelect: true }, { id: '6', label: '操作', columnName: 'handler', isSelect: true }, */ ]); /** * 打开弹窗 */ const openDialog = async () => { isShow.value = true; nextTick(async () => { //1. 先从缓存中查找 let myColumnData = Session.get(columnLocalKey); //2. 缓存中没有, 则请求接口,获取数据 if (!myColumnData) { const { status, data } = await myAxios({ url: proxy.$url.GetUserMenuColumnsUrl, data: { menuName: props.pageName }, }); if (status == 'ok' && data) { myColumnData = data; } } //3. 接口也没有,则为第一次默认显示,需要用传递过来的数据 if (!myColumnData) { props.propList.forEach((item, index) => { // { id: '0', label: '账号', columnName: 'userAccount', isSelect: true } columnData.value.push({ id: index + '', label: item.label, columnName: item.prop, isSelect: true }); }); } else { // 最初写法 // columnData.value = [...myColumnData]; //数组浅拷贝给待绑定的数据源 (缓存或接口中的数据) // 下面是考虑新增 和 删除默认字段后的写法 /* 默认列表格式: { prop: 'userRealName', label: '姓名', align: 'center', width: '150', slotName: 'userRealName' }, 绑定数据源格式: { id: '2', label: '姓名', columnName: 'userRealName', isSelect: true }, */ //A.判断一下,默认的显示列表中是否增加了新字段,如果增加了,添加到最后边 let myColumnList = myColumnData.map(item => item.columnName); let newAddData = []; //新增的属性 格式是默认列表的格式 props.propList.forEach(item => { if (!myColumnList.includes(item.prop)) newAddData.push(item); }); //B. 判断一下,判断一下显示列表中是否删除了字段,如果删除了,需要把myColumnData中的也删除 let myPropList = props.propList.map(item => item.prop); let newDelData = []; //删除的属性 格式是默认列表的格式 myColumnData.forEach(item => { if (!myPropList.includes(item.columnName)) newDelData.push(item); }); //C.新增的字段加到最后(且是未选中), 删除的字段直接踢掉 let count = myColumnData.length; if (newAddData) { newAddData.forEach(item => myColumnData.push({ id: '' + count++, label: item.label, columnName: item.prop, isSelect: false })); } if (newDelData) { newDelData.forEach(item => { myColumnData.splice( myColumnData.findIndex(c => c.id === item.id), 1 ); //删除元素 }); } columnData.value = [...myColumnData]; //数组浅拷贝给待绑定的数据源 (处理后的数据) } defaultCheckedKeys.value = columnData.value.filter(item => item.isSelect == true).map(item => item.id); //获取默认选中的节点的id数组 defaultCheckedKeys.value?.length == props.propList?.length ? (isAllChecked.value = true) : (isAllChecked.value = false); //初始化全选按钮 }); }; /** * 关闭弹窗 */ const closeDialog = () => { isShow.value = false; defaultCheckedKeys.value = []; //需要先清空一下树的选中节点,否则下次打开取消掉的仍然选中 columnData.value = []; //清空树的数据 }; /** * 保存事件 */ const save = async () => { let allSelectNodes = pTreeRef.value.getCheckedNodes(false, true); // 获取所有的选中节点 // console.log(columnData.value);//获取拖拽后的顺序(注:绑定到数据源columnData,拖拽后数据的顺序发了变化) //组合数据 let newColumnData = []; columnData.value.forEach((item, index) => { if (!allSelectNodes.includes(item)) item.isSelect = false; else { item.isSelect = true; } newColumnData.push(item); }); //1. 保存到缓存中 Session.set(columnLocalKey, newColumnData); //2. 提交到DB const { status, msg } = await myAxios2({ url: proxy.$url.SetUserMenuColumnsUrl, data: { menuName: props.pageName, columnData: newColumnData }, }); if (status == 'ok') { ElMessage.success(msg); //3. 更新父页面表格 emit('updateTableColumn', newColumnData); } else ElMessage.error(msg); //4. 关闭弹窗 closeDialog(); }; /** * 重置事件 */ const reset = async () => { //1. 清空数据源 columnData.value = []; //2. 赋默认值 props.propList.forEach((item, index) => { // { id: '1', label: '账号', columnName: 'userAccount', isSelect: true } columnData.value.push({ id: index + '', label: item.label, columnName: item.prop, isSelect: true }); }); //3. 处理默认选中节点(全部选中) defaultCheckedKeys.value = columnData.value.map(item => item.id); //获取默认选中的节点的id数组 }; /** * 拖拽时判定目标节点能否成为拖动目标位置 * (返回 false ,拖动节点不能被拖放到目标节点) * @param {Object} draggingNode * @param {Object} dropNode * @param {String} type 'prev'、'inner' 和 'next',分别表示放置在目标节点前、插入至目标节点和放置在目标节点后 */ const allowDrop = (draggingNode, dropNode, type) => { if (type === 'inner') return false; else return true; }; /** * 拖拽完成后触发的事件 * @param {Object} dargNode 拖拽的节点 */ const drawEnd = dargNode => { // 解决拖拽后节点默认取消选中的问题,下面改为保持原状态(原先选中则拖拽后也选中,反之亦然) if (dargNode && dargNode?.checked) pTreeRef.value.setChecked(dargNode.data.id, true, true); }; /** * 全选事件 */ const handleAllChecked = () => { if (!isAllChecked.value) pTreeRef.value.setCheckedKeys([]); else { //全部选中 let allKeys = []; props.propList.forEach((item, index) => { allKeys.push(index + ''); }); pTreeRef.value.setCheckedKeys(allKeys); } }; /** * 监听tree的选项,控制全选按钮是否选中 */ const toggleAllChecked = () => { let allSelectNodes = pTreeRef.value.getCheckedNodes(false, true); // 获取所有的选中节点 allSelectNodes?.length == props.propList?.length ? (isAllChecked.value = true) : (isAllChecked.value = false); //初始化全选按钮 }; /* 对外暴露事件 */ defineExpose({ openDialog, }); </script> <style scoped lang="scss"> .myBtn { margin-top: 20px; margin-bottom: 2px; text-align: center; } .myAllChecked { padding-left: 10px; } </style>
a. 需要先初始化表格显示列和排列顺序initTableColumn →→→ 然后初始化表格数据initTableData
b. initTableColumn分两层判断:
(1). 子页面传递过来数据,从而进行主页表格的加载。
(2). 主页主动加载数据,先缓存→→后接口
c. initTableColumn处理数据:
(1). 如有数据,传递过来的数据需要和propList默认中的进行遍历比较,需要propList中有,且传递的数据isSelect=true
(2). 如无数据,直接用默认propList进行绑定即可
下面分享主页面调用代码:
<template> <div> <!-- 1. 搜索栏区域 --> <ypfSearch :activeName="activeName" v-bind="searchFormConfig" v-model="formData" @update:searchClick="searchClick" @update:resetClick="resetClick" @update:handleChange="myHandleChange" > <!-- 此处可以通过具名插槽扩展自己的内容 <template #myOwnArea> </template> --> </ypfSearch> <!-- 2. 内容区域 --> <ypfTable ref="ypfTableRef" v-bind="contentTableConfig" :tableRows="tableData.tableRows" :propList="bindPropList" :tableTotal="tableData.total" :page="tableData.param" @update:page="UpdatePage" @update:tableSelChange="tableSelChange" @update:sortChange="sortChange" > <!-- 2.1 按钮区域 --> <template #btnLeft> <el-button size="small" type="success" @click="onOpenAddDialog" v-auth="authList.add"> 新增 </el-button> <el-button size="small" type="danger" @click="deleteObjs(null)" v-auth="authList.delMany"> 删除 </el-button> </template> <template #btnRight> <el-button size="small" type="primary" round v-auth="authList.excel" @click="exportExcel"> 导出 </el-button> <el-button size="small" type="success" round v-auth="authList.arrange" @click="openSettingDialog"> 设置 </el-button> </template> <!-- 2.2 表格区域 --> <template #userSex="myInfo"> <el-tag type="success" v-if="myInfo.row1.userSex == 0">男</el-tag> <el-tag type="info" v-else>女</el-tag> </template> <template #addTime="myInfo"> {{ formatDate(new Date(myInfo.row1.addTime), 'YYYY-mm-dd') }} </template> <template #handler="myInfo"> <el-button size="small" type="text" @click="onOpenEditDialog(myInfo.row1)" v-auth="authList.edit">修改</el-button> <el-button size="small" type="text" @click="deleteObjs(myInfo.row1.id)" v-auth="authList.delOne">删除</el-button> </template> <!-- 2.3 分页区域 --> </ypfTable> <!-- 3. 弹框区域 --> <AddUer ref="addDialogRef" @updateTableInfo="initTableData" /> <EditUser ref="editDialogRef" @updateTableInfo="initTableData" /> <Setting ref="setDialogRef" @updateTableColumn="initTableColumn" :propList="propList" :pageName="pageName"></Setting> </div> </template> <script setup name="systemUser"> import { getCurrentInstance, nextTick, onMounted, reactive } from 'vue'; import { useStore } from 'vuex'; import { ElMessage } from 'element-plus'; import AddUer from '/@/views/system/user/component/addUser.vue'; import EditUser from '/@/views/system/user/component/editUser.vue'; import Setting from '/@/components/setting/index.vue'; import { myAxios } from '/@/utils/request'; import { formatDate } from '/@/utils/formatTime'; import { auth } from '/@/utils/authFunction'; import { Session } from '/@/utils/storage'; import ypfTable from '/@/components/ypfTable/index.vue'; import ypfSearch from '/@/components/ypfSearch/index.vue'; import { contentTableConfig } from './config/tableConfig'; import { searchFormConfig } from './config/searchFormConfig'; import { TrimObjStrPros } from '/@/utils/myTools'; const { proxy, type } = getCurrentInstance(); const store = useStore(); const pageName = type.name; //当前页面的name值(systemUser) const columnLocalKey = `${store.state.userInfos.userInfos.userAccount}_${pageName}`; //表格显示列数据对应的缓存key, eg: admin_systemUser // 表格对象 const ypfTableRef = ref(null); /** * 监听搜索栏折叠or展开时触发 */ const myHandleChange = () => { // 必须加个延迟,否则获取的高度是折叠前的高度 setTimeout(() => ypfTableRef.value?.calTableHeight(), 500); }; // 按钮权限对象 const authList = reactive({ search: '/system/user/search', add: '/system/user/add', edit: '/system/user/edit', delOne: '/system/user/delOne', delMany: '/system/user/delMany', excel: '/system/user/excel', arrange: '/system/user/arrange', }); // 绑定组件对象区域 const addDialogRef = ref(null); const editDialogRef = ref(null); const setDialogRef = ref(null); // 表格区域 const tableData = reactive({ tableRows: [], //表格数据源 total: 0, selectRowIds: [], //表格选中行的id数组 param: { pageNum: 1, pageSize: 10, sortName: '', //排序字段名称 sortDirection: '', //排序方式 }, }); //表格显示列和排列顺序(默认的) let propList = ref(contentTableConfig.originPropList); // 绑定用的[...propList.value] let bindPropList = ref([]); //搜索栏区域 const activeName = ref('firstCollaspe'); //对应collapse-item的name值,用于默认展开 // 优化:formData中的属性,不需要再写了,完全可以用searchFormConfig配置中的field属性即可 let formOriginData = {}; for (const item of searchFormConfig.formItems) { formOriginData[item.field] = ''; // 下面是处理默认赋值 if (item.field == 'userSex') { formOriginData[item.field] = -1; } } let formData = ref(formOriginData); /** * 初始化表格数据 */ const initTableData = async () => { if (!auth(authList.search)) { ElMessage.error('您没有查询权限'); return; } //将string类型的对象去空格 let myTrimData = TrimObjStrPros(formData.value); const { status, data } = await myAxios({ url: proxy.$url.GetUserInforByConditionUrl, data: { ...tableData.param, ...myTrimData }, }); if (status == 'ok') { tableData.tableRows = data.tableRows; tableData.total = data.total; } }; /** * 初始化表格显示列和顺序 * @param {Array} newColumnData 子页面传递过来的表格显示列和顺序 * 格式: [ { id: '1', label: '账号', columnName: 'userAccount', isSelect: true }, { id: '2', label: '姓名', columnName: 'userRealName', isSelect: false } ] */ const initTableColumn = async newColumnData => { let myColumnData = null; if (newColumnData) { //一.表示从子页面被动调用 myColumnData = newColumnData; } else { // 二. 表示主页面主动加载 //1. 先从缓存中读取 myColumnData = Session.get(columnLocalKey); //2. 缓存中没有,则请求接口 if (!myColumnData) { const { status, data } = await myAxios({ url: proxy.$url.GetUserMenuColumnsUrl, data: { menuName: pageName }, }); if (status == 'ok' && data) { myColumnData = data; Session.set(columnLocalKey, myColumnData); //存到缓存中 } } } //3. 处理数据 if (myColumnData) { //3.1 表示从缓存 或 接口 或子页面传递 中拿到了数据 let latestColumnData = []; myColumnData.forEach(item => { let newItem = propList.value.find(propItem => propItem.prop === item.columnName && item.isSelect == true); if (newItem) latestColumnData.push(newItem); }); bindPropList.value = []; //必须先清空一下,才会触发数据改变 nextTick(() => { bindPropList.value = [...latestColumnData]; }); } else { //3.2 表示缓存或者接口中都没有数据,直接用前端都默认值赋值 nextTick(() => { bindPropList.value = [...propList.value]; }); } }; /** * 搜索事件 */ const searchClick = async () => { // console.log(formData.value); await initTableData(); }; /** * 重置事件 */ const resetClick = async () => { // 清空数据 Object.keys(formData.value).forEach(key => { formData.value[key] = ''; // 下面是处理默认赋值问题 if (key == 'userSex') { formData.value[key] = -1; } }); await initTableData(); }; // 打开新增弹窗 const onOpenAddDialog = () => { addDialogRef.value.openDialog(); }; /** * 打开修改弹窗 * @param {Object} row:该行数据 */ const onOpenEditDialog = row => { editDialogRef.value.openDialog(row); }; /** * 打开设置弹框 */ const openSettingDialog = () => { setDialogRef.value.openDialog(); }; /** * 删除(单个 和 多个) * @param {String} row 单个删除时,删除的id * 对于多个删除,deleteId 为 null */ const deleteObjs = async deleteId => { let delIdsStr = deleteId ?? tableData.selectRowIds; if (delIdsStr.length === 0) { proxy.$message('请选择要删除的行'); return; } let confirmResult = await proxy.$myConfirm('您确定要删除该账户吗?'); if (confirmResult == 'confirm') { const { status, msg } = await myAxios({ url: proxy.$url.DelUserUrl, data: { delIdsStr }, }); if (status === 'ok') { ElMessage.success(msg); await initTableData(); } } }; /** * 监听表格选中行变化 * @param {Array} selections 变化后的数组(数组元素为每行的数据) */ const tableSelChange = selections => { let selectRowIds = selections.map(item => item.id); //返回一个id集合数组 tableData.selectRowIds = selectRowIds; }; /** * 监听子页面传递过来的表格排序参数 * @param {Object} pageInfo {sortName: xx, sortDirection: xx} * 解释:sortName 表格排序列名称, sortDirection 表格排序方式 */ const sortChange = async ({ sortName, sortDirection }) => { if (sortName && sortDirection) { tableData.param.sortName = sortName; tableData.param.sortDirection = sortDirection; await initTableData(); } }; /** * 监听子页面传递的表格分页参数 * @param {Object} pageInfo {pageNum: xx, pageSize: xx} */ const UpdatePage = pageInfo => { if (pageInfo) { //tableData.param 中的sortDirection和sortName被置为 "", 不影响业务 tableData.param = { ...pageInfo }; initTableData(); } }; /** * 按顺序导出显示列的excel表格 */ const exportExcel = async () => { let confirmResult = await proxy.$myConfirm('您确定要导出Excel吗?'); let myTrimData = TrimObjStrPros(formData.value); //将string类型的对象去空格 let searchStr = JSON.stringify(myTrimData); //搜索条件字符串 let pageStr = JSON.stringify(tableData.param); //分页条件字符串 //需要导出列的信息 注:只有含有excelWidth属性的列才是需要导出的列 let bindPropListStr = JSON.stringify(bindPropList.value.filter(item => item.excelWidth)); if (confirmResult == 'confirm') { // 版本1-路径的形式 // window.location.href = proxy.$url.UserDownLoadFileExcelUrl + '?auth=' + Session.get('token') + '&searchStr=' + searchStr + '&pageStr=' + pageStr + '&bindPropListStr=' + bindPropListStr; // 版本2-文件流版本 await myAxios({ url: proxy.$url.UserDownLoadFileExcelUrl2, responseType: 'blob', data: { searchStr, pageStr, bindPropListStr }, }); } }; // 页面实例挂载完成后 onMounted(async () => { // 1. 初始化表格显示列和排列顺序 await initTableColumn(); // 2. 初始化表格数据 await initTableData(); }); </script>
6. Api接口
(1). 获取表格显示的列
/// <summary>
/// 获取用户表格显示数据
/// </summary>
/// <param name="menuName">菜单名称</param>
/// <returns></returns>
[HttpPost]
public async Task<IActionResult> GetUserMenuColumns(string menuName)
{
try
{
var jwtData = JsonHelp.ToObject<JwtData>(ControllerContext.RouteData.Values["auth"].ToString());
var userId = jwtData.userId;
var myData = await _baseService.EntitiesNoTrack<T_SysUserColumns>().Where(u => u.userId == userId && u.menuName == menuName).FirstOrDefaultAsync();
if (myData != null)
{
var myColumnData = JsonHelp.ToObject<List<ColumnData>>(myData.tableFields);
return Json(new { status = "ok", msg = "获取成功", data = myColumnData });
}
return Json(new { status = "ok", msg = "没有数据" });
}
catch (Exception ex)
{
LogUtils.Error(ex); ;
return Json(new { status = "error", msg = "获取失败" });
}
}
(2). 设置表格显示的列
/// <summary>
/// 设置用户表格显示数据
/// </summary>
/// <param name="userMenuColumn">xxx</param>
/// <returns></returns>
[HttpPost]
public async Task<IActionResult> SetUserMenuColumns([FromBody] UserMenuColumn userMenuColumn)
{
try
{
var jwtData = JsonHelp.ToObject<JwtData>(ControllerContext.RouteData.Values["auth"].ToString());
var userId = jwtData.userId;
var myData = await _baseService.Entities<T_SysUserColumns>().Where(u => u.userId == userId && u.menuName == userMenuColumn.menuName).FirstOrDefaultAsync();
if (myData != null)
{
myData.tableFields = JsonHelp.ToJsonString(userMenuColumn.columnData);
myData.addTime = DateTime.Now;
}
else
{
T_SysUserColumns userColumns = new()
{
id = Guid.NewGuid().ToString("N"),
userId = userId,
menuName = userMenuColumn.menuName,
tableFields = JsonHelp.ToJsonString(userMenuColumn.columnData),
addTime = DateTime.Now,
delFlag = 0
};
await _baseService.AddAsync(userColumns);
}
await _baseService.SaveChangeAsync();
return Json(new { status = "ok", msg = "设置成功" });
}
catch (Exception ex)
{
LogUtils.Error(ex); ;
return Json(new { status = "error", msg = "设置失败" });
}
}
二. Excel文件流导出方案
1. 目标
(1). 生成Excel文件直接以文件流的形式返回前端,服务器上不存档。
(2). 导出哪些列的数据,导出来列顺序,导出列宽度,均通过前端生成,传递给接口。
(3). 其它
2. 思考
(1). 如何仅仅导出显示列的Excel,并且按照显示顺序导出?
【解决方案:前端传递所有需要导出的列bindPropListStr,接口根据列名,通过反射获取数据】
(2). 如何控制导出Excel列的宽度?
【解决方案:前端propList新增一个属性excelWidth,用来处理导出Excel的宽度。 或者直接不设置宽度,都是统一宽度,接口端直接注掉即可】
(3). 对于某些字段需要进行转换的如何处理? 比如性别,0代表男,1代表女;比如时间段转换
【解决方案:在接口中通过委托实现,在委托里if单独判断处理】
(4). 前端有些列即使显示,也不参与Excel导出,如何处理?
【解决方案:不参与导出excel的列不添加属性excelwidth,前端调用接口前,通过这个条件使用filter就过滤掉了】
(5). 接口中的方法如何抽离公共方法呢?
【解决方案:将生成Excel的业务抽离出来,每个模块特殊字段的处理不同,这里利用委托处理,将这部分业务写在对外接口中,通过委托传递到封装方法里】
(6). 如何实现服务器端不保存,直接下载呢,使用流?
参考:https://blog.csdn.net/oafzzl/article/details/108623670 (重点!!!)
【解决方案:服务端使用npoi,采用流的模式进行返回】
(7). 如何使用axios代替window.location.href ?
https://juejin.cn/post/7039914586249625631
【解决方案:axios采用responseType: 'blob'进行请求】
3. 搭建思路-前端
(1). 传递的通用参数:searchStr(搜索条件)、pageStr(字段排序)、 核心参数:bindPropListStr(显示列信息,包含顺序、字段名、excel单元格长度等)
searchStr
{"userAccount":"admin","userRealName":"ypf","userSex":0,"userPhone":"","operateDateRange":""}
pageStr
{"pageNum":1,"pageSize":10,"sortName":"","sortDirection":""}
bindPropListStr
[{"prop":"userAccount","label":"账号","align":"center","sortable":"custom","width":"150","slotName":"userAccount","excelWidth":20},
{"prop":"userRealName","label":"姓名","align":"center","width":"150","slotName":"userRealName","excelWidth":20},
{"prop":"userSex","label":"性别","align":"center","width":"100","slotName":"userSex","excelWidth":20},
{"prop":"userPhone","label":"联系方式","align":"center","width":"200","slotName":"userPhone","excelWidth":50},
{"prop":"userRemark","label":"用户描述","align":"center","slotName":"userRemark","show-overflow-tooltip":true,"excelWidth":20},
{"prop":"addTime","label":"创建时间","align":"center","sortable":"custom","slotName":"addTime","excelWidth":40}]
(2). 针对有些列,比如操作这一列,即使显示在页面上也不需要导出,针对这种列,不添加excelWidth属性,传递参数的时候,根据这一规则,使用filter过滤掉即可。
(3). 针对axios进行封装,响应中 通过response?.config?.responseType === 'blob',来判断是否是excel的文件流
(4). 然后通过decodeURI(response.headers['content-disposition'].split("filename*=UTF-8''")[1]); 获取下载文件名称
// 添加响应拦截器
myAxios.interceptors.response.use(
response => {
// 响应数据的正常处理
const res = response.data;
if (res.status && res.status == 'expired') {
}
// 情况2:文件流模式的excel导出
else if (response?.config?.responseType === 'blob') {
try {
//需要在服务端配置app.UseCors中配置WithExposedHeaders("Content-Disposition")
let fileName = decodeURI(response.headers['content-disposition'].split("filename*=UTF-8''")[1]);
if (!fileName) fileName = `${new Date().getTime()}.xls`;
let blob = new Blob([res], { type: res.type });
if (window.navigator && window.navigator.msSaveOrOpenBlob) {
window.navigator.msSaveOrOpenBlob(res, fileName);
} else {
let downloadElement = document.createElement('a');
let href = window.URL.createObjectURL(blob); //创建下载的链接
downloadElement.href = href;
downloadElement.download = fileName; //下载后文件名
document.body.appendChild(downloadElement);
downloadElement.click(); //点击下载
document.body.removeChild(downloadElement); //下载完成移除元素
window.URL.revokeObjectURL(href); //释放blob对象
}
} catch (error) {
ElMessage.error(res.msg);
}
}
},
error => {
// 异常数据的响应处理
}
);
(5). 针对axios的error进行封装,区分文件流和普通error 当token校验不过的情况
axios调用代码:
/**
* 按顺序导出显示列的excel表格
*/
const exportExcel = async () => {
let confirmResult = await proxy.$myConfirm('您确定要导出Excel吗?');
let myTrimData = TrimObjStrPros(formData.value); //将string类型的对象去空格
let searchStr = JSON.stringify(myTrimData); //搜索条件字符串
let pageStr = JSON.stringify(tableData.param); //分页条件字符串
//需要导出列的信息 注:只有含有excelWidth属性的列才是需要导出的列
let bindPropListStr = JSON.stringify(bindPropList.value.filter(item => item.excelWidth));
if (confirmResult == 'confirm') {
// 版本1-路径的形式
// window.location.href = proxy.$url.UserDownLoadFileExcelUrl + '?auth=' + Session.get('token') + '&searchStr=' + searchStr + '&pageStr=' + pageStr + '&bindPropListStr=' + bindPropListStr;
// 版本2-文件流版本
await myAxios({
url: proxy.$url.UserDownLoadFileExcelUrl2,
responseType: 'blob',
data: { searchStr, pageStr, bindPropListStr },
});
}
};
axios封装代码:
import axios from 'axios'; import { ElMessage, ElMessageBox } from 'element-plus'; import { Session } from '/@/utils/storage'; // 配置新建一个 axios 实例1 const myAxios = axios.create({ baseURL: import.meta.env.VITE_API_URL, timeout: 20000, headers: { 'X-Requested-With': 'XMLHttpRequest' }, method: 'post', //默认是post请求,可以自行覆盖 //默认是'application/json'提交,下面代码是手动转换成表单提交 transformRequest: [ function (data) { let ret = ''; for (let it in data) { ret += encodeURIComponent(it) + '=' + encodeURIComponent(data[it]) + '&'; } return ret.substring(0, ret.length - 1); }, ], }); // 添加请求拦截器 myAxios.interceptors.request.use( config => { // 在发送请求之前做些什么 token if (Session.get('token')) { config.headers.common['auth'] = `${Session.get('token')}`; } return config; }, error => { // 对请求错误, 针对页面async/await的写法,需要用try-catch包裹获取 return Promise.reject(error); } ); // 添加响应拦截器 myAxios.interceptors.response.use( response => { // 响应数据的正常处理 const res = response.data; // 情况1:`token` 过期--通过过滤器返回的 if (res.status && res.status == 'expired') { Session.clear(); ElMessageBox.alert('您的登录信息已过期,请重新登录', '提示', {}); setTimeout(() => { window.location.href = '/'; // 去登录页 }, 1500); return Promise.reject(res); } // 情况2:文件流模式的excel导出 else if (response?.config?.responseType === 'blob') { try { //需要在服务端配置app.UseCors中配置WithExposedHeaders("Content-Disposition") let fileName = decodeURI(response.headers['content-disposition'].split("filename*=UTF-8''")[1]); if (!fileName) fileName = `${new Date().getTime()}.xls`; let blob = new Blob([res], { type: res.type }); if (window.navigator && window.navigator.msSaveOrOpenBlob) { window.navigator.msSaveOrOpenBlob(res, fileName); } else { let downloadElement = document.createElement('a'); let href = window.URL.createObjectURL(blob); //创建下载的链接 downloadElement.href = href; downloadElement.download = fileName; //下载后文件名 document.body.appendChild(downloadElement); downloadElement.click(); //点击下载 document.body.removeChild(downloadElement); //下载完成移除元素 window.URL.revokeObjectURL(href); //释放blob对象 } } catch (error) { ElMessage.error(res.msg); } } // 情况3:正常请求,但是接口返回status返回error(业务上的错误) else if (res.status && res.status == 'error') { res.msg && ElMessage.error(res.msg); //当msg中有内容(除了 "" 0 NaN undefined外) return Promise.reject(res); //针对页面async/await的写法,需要用try-catch包裹获取 } // 情况4:响应数据的正常返回 else { return res; } }, error => { // 异常数据的响应处理 const res = error.response; if (res.status === 401) { // 情况1:token检验不通过(和文件流下载excel的时候,catch中的返回) if (typeof res.data === 'string') ElMessage.error(res.data); //情况2: 这里主要是处理文件流形式的excel下载token没有通过校验的情况 else { let reader = new FileReader(); reader.readAsText(res.data); reader.onload = e => { let resText = e.target.result; ElMessage.error(resText); }; } } return Promise.reject(res); } ); // ----------------------------------------------------------axios 实例2(用于处理json提交)------------------------------------------------------- // 配置新建一个 axios 实例2 const myAxios2 = axios.create({ baseURL: import.meta.env.VITE_API_URL, timeout: 50000, headers: { 'X-Requested-With': 'XMLHttpRequest' }, method: 'post', //默认是post请求,可以自行覆盖 }); // 添加请求拦截器 myAxios2.interceptors.request.use( config => { // 在发送请求之前做些什么 token if (Session.get('token')) { config.headers.common['auth'] = `${Session.get('token')}`; } return config; }, error => { // 对请求错误做些什么 return Promise.reject(error); } ); // 添加响应拦截器 myAxios2.interceptors.response.use( response => { // 响应数据的正常处理 const res = response.data; // 情况1:`token` 过期--通过过滤器返回的 if (res.status && res.status == 'expired') { Session.clear(); ElMessageBox.alert('您的登录信息已过期,请重新登录', '提示', {}); setTimeout(() => { window.location.href = '/'; // 去登录页 }, 1500); return Promise.reject(res); } // 情况2:文件流模式的excel导出 else if (response?.config?.responseType === 'blob') { try { //需要在服务端配置app.UseCors中配置WithExposedHeaders("Content-Disposition") let fileName = decodeURI(response.headers['content-disposition'].split("filename*=UTF-8''")[1]); if (!fileName) fileName = `${new Date().getTime()}.xls`; let blob = new Blob([res], { type: res.type }); if (window.navigator && window.navigator.msSaveOrOpenBlob) { window.navigator.msSaveOrOpenBlob(res, fileName); } else { let downloadElement = document.createElement('a'); let href = window.URL.createObjectURL(blob); //创建下载的链接 downloadElement.href = href; downloadElement.download = fileName; //下载后文件名 document.body.appendChild(downloadElement); downloadElement.click(); //点击下载 document.body.removeChild(downloadElement); //下载完成移除元素 window.URL.revokeObjectURL(href); //释放blob对象 } } catch (error) { ElMessage.error(res.msg); } } // 情况3:正常请求,但是接口返回status返回error(业务上的错误) else if (res.status && res.status == 'error') { res.msg && ElMessage.error(res.msg); //当msg中有内容(除了 "" 0 NaN undefined外) return Promise.reject(res); //针对页面async/await的写法,需要用try-catch包裹获取 } // 情况4:响应数据的正常返回 else { return res; } }, error => { // 异常数据的响应处理 const res = error.response; if (res.status === 401) { // 情况1:token检验不通过(和文件流下载excel的时候,catch中的返回) if (typeof res.data === 'string') ElMessage.error(res.data); //情况2: 这里主要是处理文件流形式的excel下载token没有通过校验的情况 else { let reader = new FileReader(); reader.readAsText(res.data); reader.onload = e => { let resText = e.target.result; ElMessage.error(resText); }; } } return Promise.reject(res); } ); // 导出 axios 实例 export { myAxios, myAxios2 };
4. 搭建思路-Api接口
(1). Nuget安装npoi相关的程序包。
(2). 先根据条件搜索数据 → 将数据传递到生成Excel文件方法中 → 返回文件
查看代码 /// <summary>
/// 07-导出Excel表格【NOPI-文件流版本】
/// </summary>
/// <param name="searchStr">搜索条件字符串</param>
/// <param name="pageStr">分页条件字符串</param>
/// <param name="bindPropListStr">需要导出列的信息</param>
/// <returns></returns>
[HttpPost]
public async Task<IActionResult> DownLoadFileExcel2(string searchStr, string pageStr, string bindPropListStr)
{
try
{
//一. 获取需要导出的数据
//0. 处理传递数据
var search = JsonHelp.ToObject<Search_SysUser>(searchStr);
var pc = JsonHelp.ToObject<PagingClass>(pageStr);
var myPropList = JsonHelp.ToObject<List<BindPropList>>(bindPropListStr);
//1. 数据源
var data = _baseService.EntitiesNoTrack<T_SysUser>().Where(u => u.delFlag == 0);
//2. 条件搜索
//2.1 账号
if (!string.IsNullOrEmpty(search.userAccount))
{
data = data.Where(u => u.userAccount.Contains(search.userAccount));
}
//2.2 姓名
if (!string.IsNullOrEmpty(search.userRealName))
{
data = data.Where(u => u.userRealName.Contains(search.userRealName));
}
//2.3 电话
if (!string.IsNullOrEmpty(search.userPhone))
{
data = data.Where(u => u.userPhone.Contains(search.userPhone));
}
//2.4 性别
if (search.userSex != null && search.userSex != -1)
{
data = data.Where(u => u.userSex == search.userSex);
}
//2.5 添加日期(范围)
if (search.operateDateRange?.Length == 2)
{
var startTime = Convert.ToDateTime(search.operateDateRange[0]);
var endTime = Convert.ToDateTime(search.operateDateRange[1]).AddDays(1); //代表下一天的0点
data = data.Where(u => u.addTime >= startTime && u.addTime < endTime);
}
//3. 列排序
//此处默认是根据addTime降序排列,如需修改,在此处修改
pc.sortName = string.IsNullOrEmpty(pc.sortName) ? "addTime" : pc.sortName;
pc.sortDirection = string.IsNullOrEmpty(pc.sortDirection) ? "Desc" : pc.sortDirection;
data = data.DataSorting(pc.sortName, pc.sortDirection);
//4. 获取需要导出的数据
var exportList = await data.ToListAsync();
//二. 创建Excel文件
string sheetName = "账户信息";
var fs = ExcelHelp.GetExcelWorkbookStream(sheetName, myPropList, exportList, (propItem, contentCell, myValue) =>
{
if (propItem.prop.Equals("addTime"))
{
var myValueFormat = Convert.ToDateTime(myValue).ToString("yyyy-MM-dd HH:mm:ss");
contentCell.SetCellValue(myValueFormat);
}
if (propItem.prop.Equals("userSex"))
{
var myValueFormat = Convert.ToDouble(myValue) == 0 ? "男" : "女";
contentCell.SetCellValue(myValueFormat);
}
});
//三. 返回下载内容
string fileName = $"{DateTime.Now:yyyyMMddHHmmss}_{sheetName}.xls"; //展现给用户看的文件名
return File(fs, "application/vnd.ms-excel", fileName);
}
catch (Exception ex)
{
LogUtils.Error(ex);
return new ContentResult() { StatusCode = 401, Content = "下载失败" };
}
}
(3). 抽离生产Excel方法为公共方法,利用委托将每个模块单独的格式处理也抽离的接口方法中
代码分享:
/// <summary> /// 获取包含Excel文件的Workbook对象的内存流(xls) /// </summary> /// <param name="sheetName">excel表格的sheet名称</param> /// <param name="myPropList">导出列属性(名称、排序、宽度等)</param> /// <param name="exportList">需要导出数据</param> /// <param name="specialFun">针对不同表格的特殊调用委托,参数含义分别为: /// propItem(导出列对象), contentCell(Excel的ICell单元格对象), myValue(当前单元格内容) /// </param> /// <returns></returns> public static Stream GetExcelWorkbookStream<T>(string sheetName, List<BindPropList> myPropList, List<T> exportList, Action<BindPropList, ICell, object> specialFun) where T : class { //1. 创建2007及以上版本Excel表格工作簿对象 HSSFWorkbook workbook = new(); //2. 创建一个指定名称的工作表 if (string.IsNullOrEmpty(sheetName)) { sheetName = "Sheet1"; } ISheet sheet = workbook.CreateSheet(sheetName); //3. 效果样式 // 创建单元格样式对象 ICellStyle cellStyle = workbook.CreateCellStyle(); //3.1 水平居中 cellStyle.Alignment = HorizontalAlignment.Center; //3.2 垂直居中 cellStyle.VerticalAlignment = VerticalAlignment.Center; //4. 设置表格【行高、列宽、标题】 int sheetRowIndex = 0;//记录当前操作的表格行索引 sheet.DefaultRowHeightInPoints = 15.75F; //行高 sheet.ActiveCell = CellAddress.A1; // 标题行 IRow titleRow = sheet.CreateRow(sheetRowIndex); foreach (var propItem in myPropList) { // 获取索引 int index = myPropList.IndexOf(propItem);//索引 // 设置【列宽度以适应内容】 sheet.AutoSizeColumn(index); // 设置固定列宽 sheet.SetColumnWidth(index, propItem.excelWidth * 256); // 创建并获取Cell对象 var titleCell = titleRow.CreateCell(index); // 设置标题 titleCell.SetCellValue(propItem.label); // 设置单元格样式 titleCell.CellStyle = cellStyle; } sheetRowIndex++; //5. 设置表格内容值 foreach (var exportItem in exportList) { // 内容行 IRow contentRow = sheet.CreateRow(sheetRowIndex); // 遍历字段属性 foreach (var propItem in myPropList) { // 获取索引 int propIndex = myPropList.IndexOf(propItem); // 创建并获取Cell对象 var contentCell = contentRow.CreateCell(propIndex); // 获取内容值 var myValue = exportItem.GetType().GetProperty(propItem.prop).GetValue(exportItem); // 当值不为null时进行处理 if (myValue != null) { //判断内容值类型 if (myValue is double) { // Double contentCell.SetCellValue(Convert.ToDouble(myValue)); } else if (myValue is DateTime || myValue is DateTime?) { // DateTime contentCell.SetCellValue(Convert.ToDateTime(myValue)); } else if (myValue is bool) { // Bool contentCell.SetCellValue(Convert.ToBoolean(myValue)); } else { // String contentCell.SetCellValue(Convert.ToString(myValue)); } } //特殊字段的处理(每个模块此处是不同的) specialFun(propItem, contentCell, myValue); // 设置单元格样式 contentCell.CellStyle = cellStyle; } sheetRowIndex++; } //6. 创建内存流 MemoryStream ms = new(); // 将Excel表格的Workbook对象写入内存流 workbook.Write(ms); ms.Flush(); ms.Seek(0, SeekOrigin.Begin); //7. 返回流 return ms; }
(4). 需要在服务端配置app.UseCors中配置WithExposedHeaders("Content-Disposition")
//开启跨域(要在静态文件之后)
app.UseCors(options =>
{
options.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader()
.WithExposedHeaders("Content-Disposition"); //支持文件流形式Excel的下载
});
(5). 接口自身的error在catch中需要模拟401的写法进行传递错误 return new ContentResult() { StatusCode = 401, Content = "下载失败" };
!
- 作 者 : Yaopengfei(姚鹏飞)
- 博客地址 : http://www.cnblogs.com/yaopengfei/
- 声 明1 : 如有错误,欢迎讨论,请勿谩骂^_^。
- 声 明2 : 原创博客请在转载时保留原文链接或在文章开头加上本人博客地址,否则保留追究法律责任的权利。