本来我只是个小前端,来了新公司前后端一起搞,node也写的美滋滋,crud非常嗨皮。结果就在上周,突然多了个导入导出功能。虽然没做过,但是想一想查一查,应该也搞的定,没想到,一搞就是一周。今天终于弄好了,所以赶紧做个笔记,完整的记录下来,为自己整理思路,希望也能帮到后来的人。
相关技术栈:
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 请求,然后要模拟一个标签来进行下载:
前端部分代码
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);
}
这样返回的数据流,经过前端模拟出来的标签就可以进行下载了。