2025新版

接口定义

@PostMapping("/exportWelfareCertificate")
@Operation(summary = "导出福利报销凭证")
public void exportWelfareCertificate(@RequestBody OasCostSharingCertificateExportVo reqParam, HttpServletResponse response) {
List<OasCostSharingCredentialExcelVo> excelDataList = costSharingService.getWelfareCertificateData(reqParam);
String url = null;
String fileName = "福利费凭证_" + DateUtil.format(new Date(),"YYYYMMdd_HHmmss") + ".xlsx";
String objectKey = "oas/costSharing/welfare/" + fileName;
try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
InputStream templateStream = new ByteArrayInputStream(
HttpUtil.downloadBytes(CostSharingConstant.certificateTemplateUrl))) {
// 先写内存
EasyExcel.write(bos).withTemplate(templateStream).sheet().doFill(excelDataList);
byte[] bytes = bos.toByteArray();

// 这里为了记录日志先上传到minio里了
url = MinioUtil.upload(new ByteArrayInputStream(bytes), objectKey);

String encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replaceAll("\\+", "%20");
// 回写响应
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader("Content-Disposition",
"attachment; filename=\"" + encodedFileName + "\"; " +
"filename*=UTF-8''" + encodedFileName);
response.setContentLength(bytes.length);
try (OutputStream out = response.getOutputStream()) {
out.write(bytes);
}
} catch (Exception e) {
log.error("oas:费用分摊,导出福利凭证失败", e);
} finally {
try {
Map<String, String> params = Map.of(
"billType", reqParam.getBillType(),
"selectedBill", reqParam.getSelectedBill(),
"fsDeptInfoId", reqParam.getFsDeptInfoId()
);
costSharingLogService.insertLog(CostSharingConstant.MODULE_TYPE.WELFARE, CostSharingConstant.EVENT_TYPE.EXPORT, fileName, url, JSON.toJSONString(params));
} catch (Exception logEx) {
log.warn("记录导出日志失败", logEx);
}
}
}

api定义与使用

export const exportWelfareCertificateApi = (params: {
dataList;
totalAmountExcludingTax;
totalTax;
totalAmount;
billDate;
billType;
selectedBill;
fsDeptInfoId;
}) => {
return defHttp.post(
{
url: Api.exportWelfareCertificate,
params,
responseType: 'blob', // 非常重要
timeout: 1000000,
},
{ isTransformResponse: false, isReturnNativeResponse: true }
);
};


let response = await exportWelfareCertificateApi({
dataList: allLeafHasPersonNumNodeArr,
totalAmountExcludingTax: costSharingItem.value.totalAmountExcludingTax,
totalTax: costSharingItem.value.totalTax,
totalAmount: costSharingItem.value.totalAmount,
billDate: costSharingItem.value.selectedInvoice[0].billDate,
billType: billType.value,
selectedBill: JSON.stringify(
costSharingItem.value.selectedInvoice.map((e) => ({
deptId: e.deptId,
deptName: e.deptName,
billDate: e.billDate,
djbh: e.djbh,
pkJkbx: e.pkJkbx,
djrq: e.djrq,
zy: e.zy,
total: e.total,
}))
),
fsDeptInfoId: costSharingItem.value.fsDeptInfoId,
});
const blob = new Blob([response.data], { type: response.data.type || 'application/octet-stream' });
const cd = response.headers?.['content-disposition'];
downloadBlobSmart(blob, '福利费凭证.xlsx', cd);

工具类

// 追加到文件末尾
declare global {
interface Navigator {
msSaveOrOpenBlob?: (blob: Blob, defaultName?: string) => boolean;
msSaveBlob?: (blob: Blob, defaultName?: string) => boolean;
}
}

function parseDispositionFileName(header?: string | null): string | null {
if (!header) return null;

// RFC5987: filename*=UTF-8''...
const utf8Match = header.match(/filename\*\s*=\s*([^']*)''([^;]+)/i);
if (utf8Match && utf8Match[2]) {
try {
return decodeURIComponent(utf8Match[2]);
} catch {
// ignore decode error and fallback to standard
}
}

// filename="..."
const quoted = header.match(/filename\s*=\s*"([^"]+)"/i);
if (quoted && quoted[1]) {
return quoted[1];
}

// filename=...
const unquoted = header.match(/filename\s*=\s*([^;]+)/i);
if (unquoted && unquoted[1]) {
return unquoted[1].trim();
}

return null;
}

function sanitizeAndTruncateFileName(inputName: string): string {
const illegalRe = /[<>:"/\\|?*\x00-\x1F]/g;
const windowsTrailingRe = /[. ]+$/;
let name = inputName.replace(illegalRe, '_').replace(windowsTrailingRe, '_');

// 保留扩展名,整体长度不超过 200
if (name.length > 200) {
const lastDot = name.lastIndexOf('.');
if (lastDot > 0 && lastDot >= name.length - 15) {
const ext = name.slice(lastDot);
const baseMax = 200 - ext.length;
name = name.slice(0, Math.max(1, baseMax)) + ext;
} else {
name = name.slice(0, 200);
}
}
return name || 'download';
}

function isIOSLike(): boolean {
const ua = navigator.userAgent;
const iOS = /iPad|iPhone|iPod/i.test(ua);
const iPadOS = navigator.platform === 'MacIntel' && (navigator as any).maxTouchPoints > 1;
return iOS || iPadOS;
}

/**
* 更稳健的 Blob 下载方法(支持 Content-Disposition 文件名、iOS/IE 兼容)
*/
export function downloadBlobSmart(blob: Blob, defaultFileName: string, contentDisposition?: string | null): void {
const parsedName = parseDispositionFileName(contentDisposition);
const fileName = sanitizeAndTruncateFileName(parsedName || defaultFileName || 'download');

// IE / 旧 Edge
if (typeof navigator.msSaveOrOpenBlob === 'function') {
navigator.msSaveOrOpenBlob(blob, fileName);
return;
}
if (typeof navigator.msSaveBlob === 'function') {
navigator.msSaveBlob(blob, fileName);
return;
}

const a = document.createElement('a');
const supportsDownload = 'download' in a;
const iOS = isIOSLike();

// 标准路径(非 iOS 且支持 download)
if (!iOS && supportsDownload) {
const url = URL.createObjectURL(blob);
a.style.display = 'none';
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
return;
}

// 兜底(iOS/Safari 等):用 DataURL 打开,由用户保存
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
const win = window.open();
if (win) {
win.document.write('<iframe src="' + dataUrl + '" style="display:none;"></iframe>');
} else {
window.location.href = dataUrl;
}
};
reader.readAsDataURL(blob);
}

返回整体文件流,通过浏览器转成二进制触发下载

该方式和传统的方式不同。

传统是读取服务器返回的流,这里是服务器流都已经整体返回了,然后才通过 js 转成文件触发下载

该方式只适合下载小文件(一般是小于 10M),如果文件过大,会导致浏览器占用内存过大,页面崩溃

以下载 excel 为例子

前台代码

/**
* 下载数据导入模板
*/
export function downloadImportTemplate(url, fileName) {
return request({
type: 'get',
url,
responseType: 'blob'
}).then((res) => {
const url = window.URL.createObjectURL(new Blob([res]))
const link = document.createElement('a')
link.style.display = 'none'
link.href = url
link.setAttribute('download', fileName + '.xlsx')
document.body.appendChild(link)
link.click()
})
}

// 其他地方只需引入该方法,调用传入参数即可
downloadImportTemplate('/downloadTemplate', '工程车辆导入模板')
@RequestMapping("/downloadTemplate")
public void downLoadEngineeringVehiclesImportTemplateFile(HttpServletResponse response) {
downLoadTemplate("工程车导入模板", response);
}


public void downLoadTemplate(String name, HttpServletResponse response) {
String path = "template/" + name + ".xlsx";
// 创建缓冲区
byte[] buffer = new byte[1024];// 缓冲区大小1k
int len = 0;
// 重点就是获取输入流和输出流,还有设置请求头
try (InputStream in = this.getClass().getClassLoader().getResourceAsStream(path);
OutputStream out = response.getOutputStream()) {
// 设置头部信息
response.setHeader(
"Content-disposition",
"attachment;filename="
+ new String((name + ".xlsx").getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1));

//循环将输入流中的内容读取到缓冲区当中
while ((len = in.read(buffer)) > 0) {
//输出缓冲区的内容到浏览器,实现文件下载
out.write(buffer, 0, len);
}
} catch (Exception e) {
e.printStackTrace();
}
}