前端配合Egg实现导入导出Excle文件功能

本来我只是个小前端,来了新公司前后端一起搞,node也写的美滋滋,crud非常嗨皮。结果就在上周,突然多了个导入导出功能。虽然没做过,但是想一想查一查,应该也搞的定,没想到,一搞就是一周。今天终于弄好了,所以赶紧做个笔记,完整的记录下来,为自己整理思路,希望也能帮到后来的人。

相关技术栈:

  • React
  • ant Design
  • Egg
  • MongoDB。
    使用库:
  • xlsx: 生成,解析excle文件。
  • 这里要注意,导入该库的时候的写法import * as XLSX from 'xlsx'; 这里的 'xlsx' 必须小写,不然mac中编译没问题,到测试环境的Linux服务器上就会报错了。

导入

首先,导入需要上传文件,这就涉及到egg-multipart,这是一个egg自带的内部库,我们不需要安装,直接在config文件中增加以下配置即可:

multipart: {
  fileSize: '50mb',  // 文件大小
  mode: 'file', // 文件模式
  whitelist: ['.xlsx'], // 文件类型白名单
},

然后,前端使用ant Design已经做好的上传组件Upload来上传文件:

// 点击导入按钮后执行的方法
onImport = () => {
	// 在弹框中展示警告信息及上传组件
    const modal = Modal.warning({});
	// 上传组件配置
    const uploadProps = {
      name: 'users',  // 上传文件的文件名
      action: '/server/api/users/import',  // 上传接口
      accept: '.xlsx', // 能接受的文件后缀名,不符合的在文件选择框中无法选中
      showUploadList: false, // 是否显示上传后的文件
      beforeUpload: (file: any) => { // 上传前回调,校验文件类型,大小
        const XLSX_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
        const isXLSX = file.type === XLSX_TYPE;
        if (!isXLSX) {
          message.error('请选择.xlsx文件!');
        }
        const isLt2M = file.size / 1024 / 1024 < 2;
        if (!isLt2M) {
          message.error('文件不能超过2MB!');
        }
        if (isXLSX && isLt2M) {
          modal.destroy();
        }
        return isXLSX && isLt2M;
      },
      onChange: (info: any) => { // 上传状态改变回调,根据status来写相关逻辑
        if (info.file.status === 'done') {
          const { code, data } = info.file.response;
          if (code === 0) {
            message.success(`文件 ${info.file.name} 导入成功,刷新列表!`);
            // 上传成功后的逻辑
          }
        } else if (info.file.status === 'error') {
          console.error('import fail message: ', info.file.response.msg);
          const msg = info.file.response.msg || '请检查模板及数据!';
          message.error(`文件 ${info.file.name} 导入失败,${msg}`);
        }
      },
    };

    const content = (
      <div>
        导入过程中如果出现重复数据将直接进行覆盖,请谨慎操作!
        <Upload {...uploadProps}>
          <Button type="link">
            选择文件
          </Button>
        </Upload>
      </div>
    );
    modal.update({
      title: '请谨慎操作!',
      okText: '关闭',
      okType: 'default',
      content,
    });
  };

前端就完成了,接下来是后端部分:

首先在router文件中增加上传接口,并指向对应的controller

  app.post('/api/auth/users/import', auth('auth.user.create'), 'auth.user.import'); // 导入用户

将 .xlsx 文件转为可读的js数据这个功能其他地方可能也用的到,就抽出来放到helper里

// server/app/extend/helper.ts
import * as XLSX from 'xlsx'; // 使用 xlsx包

/**
 * 获取导入的XLSX文件中的数据
 * @param {object} file 请求中的文件对象,如:ctx.request.files[0]
 * @param {string} headerKeyMap 表头-key转换对象,如 { 姓名: 'name', 邮箱 :'email' }
 * @param {string} rwoTransform 行数据转换函数,比如:将字符串 'a,b,c' 转为 ['a', 'b', 'c'];
 */
function getImportXLSXData(file: any, headerKeyMap: object, rwoTransform: any = row => row) {
  const { filepath } = file;
  const workbook = XLSX.readFile(filepath);

  // 读取内容
  let exceldata: any[] = [];
  workbook.SheetNames.forEach(sheet => {
    if (workbook.Sheets.hasOwnProperty(sheet)) {
      const data = XLSX.utils.sheet_to_json(workbook.Sheets[sheet]).map((row: any) => {
        const obj: any = {};
        Object.keys(headerKeyMap).forEach(key => {
          obj[headerKeyMap[key]] = row[key];
        });
        return rwoTransform(obj);
      });
      exceldata = [...exceldata, ...data];
    }
  });
  return exceldata;
}

然后开始写controller

// controller/auth.ts
async import(ctx) {
	// 获取文件对象
    const file = ctx.request.files[0];
    // 中文表头转换为数据的key值,所使用的的映射map
    const headerMap = {
      用户名: 'account',
      角色: 'userGroups',
      状态: 'status',
      所属单位: 'department',
      姓名: 'name',
      手机: 'mobile',
      邮箱: 'email',
    };
    try {
      // 每行数据要进行的特殊处理函数
      const rowTransform = (row: any) => ({
        ...row,
        mobile: row.mobile.toString(),
        userGroups: row.userGroups ? row.userGroups.split(/,|,/) : [],
      });
	  // 将文件解析成js数据,上边封装的可复用的解析函数
      const userData = ctx.helper.getImportXLSXData(file, headerMap, rowTransform);
      // 获取全部用户组的名称
      const allGroups = await this.ctx.service.auth.group.all();
      const allGroupNames = allGroups.list.map((i: any) => i.name);
      // 对解析出来的数据进行校验,如果校验失败,返回错误
      // 这里的校验函数 importDataValidate 就根据自己的情况写即可
      const isLegalData = this.importDataValidate(userData, allGroupNames);
      if (!isLegalData) {
        ctx.badRequest({
          data: {},
          msg: '导入文件数据校验失败: 数据不全或用户名、邮箱重复!',
        });
        return;
      }

      // 初步校验通过,导入数据库,返回结果
      const result = await ctx.service.auth.user.import(userData);
      ctx.success({
        data: result,
      });
    } catch (error) {
      console.log(error);
      ctx.badRequest({
        data: {},
        msg: error.errmsg,
      });
    }
  }

这样导入就完成了,核心代码就是上边这些。但真正用在业务中时,要考虑很多边界条件,因为上传的excle文件内容不可控,所以要在controller 和 service 中都增加很多复杂的校验逻辑,以保证将正确的数据写入数据库。另外还可以把导入失败的数据返回给前端,让前端再导出出去,方便用户修改。

导出

导出就是将数据转换成 .xlsx 文件再下载下来,分为 纯前端导出后端数据导出

纯前端导出

纯前端导出的好处是非常简单,直接使用xlsx这个库就可以做到,在前端已经有数据的情况下,可以直接写个公用的util方法,调用即可。

// utils/exportXLSX.ts

import * as XLSX from 'XLSX';

/**
 * 纯前端将数据导出成XLSX文件
 * @param {string} fileName 导出的XLSX文件名
 * @param {string} sheetName 导出文件的sheetName
 * @param {object} headers excel标题栏对象,如:{ name: '姓名', age: '年龄' },其interface要与数据对象相同
 * @param {object[]} data 要导出的数据对象数组
 */

export function exportXLSX(
  fileName: string = 'file',
  sheetName: string = 'sheet1',
  header: object,
  data: object[],
) {
  // 生成workbook
  const workbook = XLSX.utils.book_new();
  // 插入表头
  const headerData = [header, ...data];
  // 生成worksheet
  const worksheet = XLSX.utils.json_to_sheet(headerData, { skipHeader: true });
  // 组装
  XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
  // 导出,就会直接下载
  XLSX.writeFile(workbook, `${fileName}.xlsx`);
}

// 使用时直接,浏览器就会自动开始下载
exportXLSX('failedUser', 'user', header, exportData);

后端数据导出

纯前端导出固然简单,但是也有限制。比如大量的数据,不会直接全部给到前端,而是在前端分页显示的,如果我们要导出所有,数据量很大,就可以让后端来导出。

后端导出时要注意:下载文件只能使用 GET 请求,然后要模拟一个<a download="xxx"></a>标签来进行下载:

前端部分代码

onExport = async () => {
    // 根据筛选条件查询是否有符合条件的用户
    const filter = this.Table.state.filter;
    const res = await queryUser(filter);
    if (res.code === 0) {
      const { total } = res.data;
      if (total === 0) {
        message.warning('导出失败,无符合条件的用户!');
        return;
      }
    }
    // 手动拼接GET请求
    // 导出接口
    const exportAPI = '/server/api/auth/users/export';
    // 筛选条件
    let queryStr = '?';
    const filterList = Object.keys(filter).filter(key => filter[key]);
    const length = filterList.length;
    filterList.forEach((key, index) => {
      queryStr += `${key}=${filter[key]}${index === length - 1 ? '' : '&'}`;
    });
    // 伪造a标签点击
    const downloadUrl = `${exportAPI}${queryStr}`;
    const a = document.createElement('a');
    a.href = downloadUrl;
    a.download = 'users';
    a.style.display = 'none';
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
  };

后端部分代码:

先增加router:

  app.get('/api/auth/users/export', auth('auth.user.create'), 'auth.user.export'); // 导出用户

导出Excle的方法也可以复用,所以也写在helper中:

import * as XLSX from 'xlsx';

/**
 * 将数据导出成XLSX文件
 * @param {string} fileName 导出的XLSX文件名
 * @param {string} sheetName 导出文件的sheetName
 * @param {object} headers excel标题栏对象,如:{ name: '姓名', age: '年龄' },其interface要与数据对象相同
 * @param {object[]} data 要导出的数据对象数组
 */

async function exportXLSX(
  fileName: string = 'file',
  sheetName: string = 'sheet1',
  header: object,
  data: object[],
) {
  // 生成workbook
  const workbook = XLSX.utils.book_new();
  // 插入表头
  const headerData = [header, ...data];
  // 生成worksheet
  const worksheet = XLSX.utils.json_to_sheet(headerData, { skipHeader: true });
  // 组装
  XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);

  // 返回数据流
  // @ts-ignore
  this.ctx.set('Content-Type', 'application/vnd.openxmlformats');
  // @ts-ignore
  this.ctx.set(
    'Content-Disposition',
    "attachment;filename*=UTF-8' '" + encodeURIComponent(fileName) + '.xlsx',
  );
  // @ts-ignore
  this.ctx.body = await XLSX.write(workbook, {
    bookType: 'xlsx',
    type: 'buffer',
  });
}

对应的controller:

  async export(ctx) {
  	// 获取查询参数
    const query = ctx.queries;
 	// 获取数据
    const result = await ctx.service.auth.user.all(where);

    // 查询结果为0时直接返回
    if (result.total === 0) {
      ctx.success({
        data: { ...result },
      });
      return;
    }

    // 表头
    const header = {
      account: '用户名',
      userGroups: '角色',
      status: '状态',
      department: '所属单位',
      name: '姓名',
      mobile: '手机',
      email: '邮箱',
    };

    // 生成数据
    const data = result.list.map(i => {
      const item = pick(i, [
        'account',
        'userGroups',
        'status',
        'department',
        'name',
        'mobile',
        'email',
      ]);
      return {
        ...item,
        userGroups: item.userGroups.join(','),
      };
    });

    // 导出excel
    await ctx.helper.exportXLSX('users', 'users', header, data);
  }

这样返回的数据流,经过前端模拟出来的<a>标签就可以进行下载了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章