第三节:剖析自定义显示列、显示顺序、Excel文件流形式的导出方案

一. 自定义显示列和顺序

1.  目标与效果

  (1). 可以自定义拖拽、勾选来决定哪些表格列显示,已经表格列的排列顺序。

  (2). 可以实现动态增加列名或者删除某个列名

  (3). 其它

 

2. 缓存存储的格式

         key:账号_菜单名(admin_systemUser)

         value:序列化后的字符串

              [

                  { id: '1', label: '账号', columnName: 'userAccount', isSelect: true },

                  { id: '2', label: '姓名', columnName: 'userRealName', isSelect: false }

            ]

 

3. 思考

   (1). 需要解决的第一个问题,当缓存中没有,需要请求两次接口的问题

      【解决方案:弹框中的展示逻辑放到openDialog打开弹框方法中,另外closeDialog关闭弹框方法中需要对选中节点进行一下清空 】

   (2). 需要解决的第二个问题,是否采用local缓存,考虑采用Session缓存,即第一次加载的时候都要请求接口,然后存到Session中,这样方便更新

      【解决方案:最终决定存放到SessionStorage中,即首次打开该页面都要请求接口】

  (3). 当缓存中没有,请求接口后,此时要存放到缓存中,否则刷新页面还是要继续请求接口的(只有当保存的时候,才存了缓存)

      【解决方案:主页面请求接口后,如果有数据,要存放到缓存中】

   (4). 前端写死的默认显示列表变了,比如新增了一个字段A,如果该用户DB中有设置数据,需要的效果是:设置页面能显示出来,放到最后的位置,且是未勾选状态

      【解决方案:通过对比获取新增的字段,放到数据源的最后位置】

   (5). 前端删除了1个字段,目前的效果:如果该字段没有选中,  设置页面该字段仍然存在,且是没有选中的状态;列表该字段不显示。

                                                              如果该字段已经选中,设置页面该字段仍然存在,且是选中状态;列表该字段不显示。

      (想要的效果:原先即使勾选了这个字段,设置页面也不显示,列表也不直接显示列)

      【解决方案:通过对比获取删除的字段,从绑定数据源中删除】

   (6). 前端字段默认顺序调整了,目前的效果: DB中有数据,设置页面不变,重置后变为最新的顺序。

      (想要的效果:目前效果即可)

 

4. 搭建思路-  设置页面封装

    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>
View Code
 
5. 主页面调用

    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>
View Code

 

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 };
View Code

 

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;
    }
View Code

 

(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 : 原创博客请在转载时保留原文链接或在文章开头加上本人博客地址,否则保留追究法律责任的权利。
 
posted @ 2022-12-27 08:40  Yaopengfei  阅读(270)  评论(1编辑  收藏  举报