本來我只是個小前端,來了新公司前後端一起搞,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>
標籤就可以進行下載了。