前端配合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>標籤就可以進行下載了。

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