一、整体概述
1.1 环境
后端:Spring Boot 2.x
前端:Vue 2.x
前后端分离项目
1.2 任务要求
要求在前台能够下载word报告,报告中包含图片和表格。
在网上搜索了很多解决方案,大部分是利用freemark模板生成word,作者也试过这种方法,有几个缺点比较明显:
-
需要在word模板中写
${name}
这种变量用于freemark替换,但是在word模板转换为.xml
格式后${name}
可能不会连在一起,而是中间有xml中的代码,需要手动解决。 -
word模板转换为
.xml
后代码量很大,动辄上千行,比较容易出错,一旦出错就是文档打不开的结果。 -
如果生成的模板中带有图片,通常解决方案是在springboot后台将图片转换为base64格式然后替换掉
.xml
模板中的图片代码,但是这对图片格式以及base64图片数据中的空格等等都有很严格的要求,需要非常仔细,同时也非常麻烦。作者遇到最常见的就是图片代码被替换掉,但是图片显示不出来,需要一步一步的测试,非常麻烦,可能也与作者经验不足有关。
最后作者决定使用poi在后端生成word文档,然后前台进行下载的方法解决。
1.3 涉及到的知识点
- 动态创建form表单并且向后端传参。
- form表单如何传递对象中的数组。
- 后端接收form表单传递的对象。
- java中使用poi生成word文档。
- word文档中创建图片。
- word文档的下载。
- 该文档下载接口的权限校验。
- echarts中获取base64格式的图片。
二、数据结构
2.1 后端接收的数据结构
使用VO类接收数据
import lombok.Data;
import java.util.List;
/**
* 接受数据的Vo类
*/
@Data
public class TestVo {
/**
* key
*/
private String key;
/**
* value
*/
private String value;
/**
* chList
*/
private List<TestChildrenVo> chList;
}
import lombok.Data;
/**
* 子类型Vo
*
*/
@Data
public class TestChildrenVo {
/**
* 图片Base64
*/
private String img;
/**
* 分页大小
*/
private Integer pageSize;
/**
* 当前页
*/
private Integer pageNo;
}
2.2 前端的数据结构
let params = {
key: '111',
value: '222',
chList: [{
img: 'base64图片',
pageSize: 10,
pageNo: 1
}, {
img: 'base64图片2',
pageSize: 10,
pageNo: 2
}]
}
三、前端数据发送
首先为什么要用js创建form表单的方法请求,因为我们要下载word文件,如果使用ajax则下载下的是文件流,需要进行转换,作者曾使用ajax然后下载转blob类型下载word文件,但是这种方式下载后的word文件内容错误,无法打开,所以使用form表单post的方式下载。
然后要把数据组装为2.2.2的那种数据结构
3.1 base64图片的获取
作者生成图片使用的是echarts,具体方法为:
/**
* 生成图片
*/
getImageBase64() {
return this.$refs.myChart.getImageBase64()
},
其中myChart
为echarts的实例
3.2 组建参数
这个就是postForm
方法的第二个接收参数
let params = {
key: '111',
value: '222',
chList: [{
img: 'base64图片',
pageSize: 10,
pageNo: 1
}, {
img: 'base64图片2',
pageSize: 10,
pageNo: 2
}]
}
3.3 创建form表单
/**
* 模拟post请求
* url: 请求地址
* params:参数
*/
postForm(url, params) {
// 创建form元素
let temp_form = document.createElement('form')
// 设置form属性
temp_form.action = url
temp_form.target = '_self'
temp_form.method = 'post'
temp_form.style.display = 'none'
// 处理需要传递的参数
for (let x in params) {
if (params.hasOwnProperty(x)) {
// 判断是否为数组,动态生成form数组实例
if (x === 'chList') {
for (let i = 0; i < params[x].length; i++) {
for (let y in params[x][i]) {
let opt = document.createElement('textarea')
opt.name = 'chList[' + i + '].' + y
// 替换null和undefined为空字符串
opt.value = params[x][i][y] ? params[x][i][y] : ''
temp_form.appendChild(opt)
}
}
} else {
let opt = document.createElement('textarea')
opt.name = x
// 替换null和undefined为空字符串
opt.value = params[x] ? params[x] : ''
temp_form.appendChild(opt)
}
}
}
document.body.appendChild(temp_form)
// 提交表单
temp_form.submit()
// 移除表单
document.body.removeChild(temp_form)
},
以上代码组建的form大致为:
<form action="xxx" method="post">
<input name="key"/>
<input name="value"/>
<input name="chList[0].img"/>
<input name="chList[0].pageSize"/>
<input name="chList[0].pageNo"/>
<input name="chList[1].img"/>
<input name="chList[1].pageSize"/>
<input name="chList[1].pageNo"/>
<input name="chList[2].img"/>
<input name="chList[2].pageSize"/>
<input name="chList[2].pageNo"/>
</form>
关于form中如何提交数组的知识点:数组属性名称[索引].属性值
其中数组中的属性和TestChildrenVo
中的属性相对应
注意,如果某一个属性为undefined,后台接收到的值为字符串的undefined,所以所有的参数不能为null或者undefined,可以为空字符串。
四、后端处理
POI版本
<!--数据导出excel-->
<!-- https://mvnrepository.com/artifact/org.apache.poi/poi -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>3.17</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.poi/poi-ooxml -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>3.17</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.poi/poi-ooxml-schemas -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml-schemas</artifactId>
<version>3.17</version>
</dependency>
4.1 数据接收
TestVo
详见2.2.1数据结构
关于form提交数据的一些看法:本案例因为参数数据量较大,无法使用get请求,如果请求接口传递的参数不多并且数据量不是很大,则完全可以使用get
请求传递参数,具体方式为:
location.href = url + '?token=' + this.token + '&参数key=参数value'
其中token用于该接口的权限验证,需要后台手动验证,如果不需要验证则可以忽略。
后端数据接收方法:
/**
* word报告导出
*/
@PostMapping(value = "/export-word")
public void export(TestVo testVo,
HttpServletResponse response) throws Exception {
System.out.println(testVo.toString());
}
4.2 创建word文件
XWPFDocument doc = new XWPFDocument();// 创建Word文件
// 标题
XWPFParagraph p = doc.createParagraph();// 新建段落
p.setAlignment(ParagraphAlignment.CENTER);// 设置段落的对齐方式
XWPFRun r = p.createRun();//创建标题
r.setText("我是标题");
r.setBold(true);//设置为粗体
r.setColor("000000");//设置颜色
r.setFontSize(21); //设置字体大小
r.addCarriageReturn();//回车换行
// 段落
createParagraph(doc, "段落一");
createParagraph(doc, "段落一");
createParagraph(doc, "段落二");
List<TestChildrenVo> imgs = testVo.getChList();
for (TestChildrenVo img : imgs) {
// 插入图片
XWPFParagraph pImg = doc.createParagraph();
pImg.setAlignment(ParagraphAlignment.CENTER);
XWPFRun rImg = pImg.createRun();//创建标题
rImg.addCarriageReturn();//回车换行
String imgData = img.getImg().substring(img.getImg().indexOf(",") + 1);
// 转为二进制
byte[] bytes = new BASE64Decoder().decodeBuffer(imgData);
// 向段落中插入图片
rImg.addPicture(new ByteArrayInputStream(bytes), Document.PICTURE_TYPE_PNG, "123.png", Units.toEMU(400), Units.toEMU(180));
// 表格
XWPFTable table = doc.createTable(imgs.size() + 1, 2);
//列宽自动分割
CTTblWidth infoTableWidth = table.getCTTbl().addNewTblPr().addNewTblW();
infoTableWidth.setType(STTblWidth.DXA);
infoTableWidth.setW(BigInteger.valueOf(9072));
setTableFonts(table.getRow(0).getCell(0), "当前页");
setTableFonts(table.getRow(0).getCell(1), "分页大小");
for (int i = 1; i <= imgs.size(); i++) {
setTableFonts(table.getRow(i).getCell(0), imgs.get(i - 1).getPageNo() + "");
setTableFonts(table.getRow(i).getCell(1), imgs.get(i - 1).getPageSize() + "");
}
}
其中 String imgData = img.getImg().substring(img.getImg().indexOf(",") + 1);
这段代码为了截取base64的数据后半段,data:image/png;base64,
这些数据被截取掉,看下图:
4.3 文件下载
String fileName = "word导出测试_" + System.currentTimeMillis() + ".doc";
String fileNameURL = URLEncoder.encode(fileName, "UTF-8");
response.setCharacterEncoding("UTF-8");
response.setHeader("Content-disposition", "attachment;filename=" + fileNameURL + ";" + "filename*=utf-8''" + fileNameURL);
response.setContentType("application/octet-stream");
//刷新缓冲
response.flushBuffer();
OutputStream ouputStream = response.getOutputStream();
doc.write(ouputStream);
ouputStream.flush();
ouputStream.close();
4.4 word文件展示
4.5 代码合并
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.util.Units;
import org.apache.poi.xwpf.usermodel.*;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.*;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import sun.misc.BASE64Decoder;
import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayInputStream;
import java.io.OutputStream;
import java.math.BigInteger;
import java.net.URLEncoder;
import java.util.List;
@Slf4j
@RestController
@RequestMapping("/test")
public class WordExportTest {
/**
* word报告导出
*/
@PostMapping(value = "/export-word")
public void export(TestVo testVo,
HttpServletResponse response) throws Exception {
System.out.println(testVo.toString());
XWPFDocument doc = new XWPFDocument();// 创建Word文件
// 标题
XWPFParagraph p = doc.createParagraph();// 新建段落
p.setAlignment(ParagraphAlignment.CENTER);// 设置段落的对齐方式
XWPFRun r = p.createRun();//创建标题
r.setText("我是标题");
r.setBold(true);//设置为粗体
r.setColor("000000");//设置颜色
r.setFontSize(21); //设置字体大小
r.addCarriageReturn();//回车换行
// 段落
createParagraph(doc, "段落一");
createParagraph(doc, "段落一");
createParagraph(doc, "段落二");
List<TestChildrenVo> imgs = testVo.getChList();
for (TestChildrenVo img : imgs) {
// 插入图片
XWPFParagraph pImg = doc.createParagraph();
pImg.setAlignment(ParagraphAlignment.CENTER);
XWPFRun rImg = pImg.createRun();//创建标题
rImg.addCarriageReturn();//回车换行
String imgData = img.getImg().substring(img.getImg().indexOf(",") + 1);
byte[] bytes = new BASE64Decoder().decodeBuffer(imgData);
rImg.addPicture(new ByteArrayInputStream(bytes), Document.PICTURE_TYPE_PNG, "123.png", Units.toEMU(400), Units.toEMU(180));
// 表格
XWPFTable table = doc.createTable(imgs.size() + 1, 2);
//列宽自动分割
CTTblWidth infoTableWidth = table.getCTTbl().addNewTblPr().addNewTblW();
infoTableWidth.setType(STTblWidth.DXA);
infoTableWidth.setW(BigInteger.valueOf(9072));
setTableFonts(table.getRow(0).getCell(0), "当前页");
setTableFonts(table.getRow(0).getCell(1), "分页大小");
for (int i = 1; i <= imgs.size(); i++) {
setTableFonts(table.getRow(i).getCell(0), imgs.get(i - 1).getPageNo() + "");
setTableFonts(table.getRow(i).getCell(1), imgs.get(i - 1).getPageSize() + "");
}
}
String fileName = "word导出测试_" + System.currentTimeMillis() + ".doc";
String fileNameURL = URLEncoder.encode(fileName, "UTF-8");
response.setCharacterEncoding("UTF-8");
response.setHeader("Content-disposition", "attachment;filename=" + fileNameURL + ";" + "filename*=utf-8''" + fileNameURL);
response.setContentType("application/octet-stream");
//刷新缓冲
response.flushBuffer();
OutputStream ouputStream = response.getOutputStream();
doc.write(ouputStream);
ouputStream.flush();
ouputStream.close();
}
/**
* 创建段落
*
* @param text
*/
private void createParagraph(XWPFDocument doc, String text) {
XWPFParagraph paragraph = doc.createParagraph();// 新建段落
//paragraph.setAlignment(ParagraphAlignment.LEFT);// 设置段落的对齐方式
paragraph.setFontAlignment(1);//字体对齐方式:1左对齐 2居中3右对齐
XWPFRun run = paragraph.createRun();//创建标题
run.setText(text);
run.setColor("000000");//设置颜色
run.setFontSize(14); //设置字体大小
run.addCarriageReturn();//回车换行
}
/**
* 设置表格中字体
*
* @param cell
* @param cellText
*/
private static void setTableFonts(XWPFTableCell cell, String cellText) {
CTP ctp = CTP.Factory.newInstance();
XWPFParagraph p = new XWPFParagraph(ctp, cell);
p.setAlignment(ParagraphAlignment.CENTER);
XWPFRun run = p.createRun();
run.setFontSize(14);
run.setText(cellText);
CTRPr rpr = run.getCTR().isSetRPr() ? run.getCTR().getRPr() : run.getCTR().addNewRPr();
CTFonts fonts = rpr.isSetRFonts() ? rpr.getRFonts() : rpr.addNewRFonts();
fonts.setAscii("仿宋");
fonts.setEastAsia("仿宋");
fonts.setHAnsi("仿宋");
cell.setParagraph(p);
}
}
五、接口权限校验
SpringBoot后端权限校验使用的是jwt,需要在请求头中添加token;但是form表单无法添加请求头,则token要添加到post的参数中,然后在后端接收token进行手动校验用户是否登陆或者其他权限。如果该接口不需要进行认证,则可以直接放行。