vue3实现包含表格的Word文件导出
近期遇到一个要求,需要在网页上导出Word文档,文档中有表格,也有普通的数据,查阅了很多资料,总算比较完美的解决了,记录一下
先上一下最终效果
vue3项目根据Word模板导出Word文件
当然,个人的项目要比这个演示的视频复杂多了,需要配合后端完成
实现过程主要参考了这篇文章
一、第三方库的安装
要实现Word的导出功能,需要额外安装以下第三方包:
"dependencies": { "angular-expressions": "^1.2.1", "docx-preview": "^0.3.2", "docxtemplater": "^3.49.1", "docxtemplater-image-module-free": "^1.1.1", "file-saver": "^2.0.5", "lodash": "^4.17.21", "pizzip": "^3.1.7", },
版本安装最新的就行了,这是我自己的目前最新的版本(2024年8月)
二、Word模板的创建
必须要有一个Word模板,根据自己的需求创建,我的模板如下:
这个模板中有常规变量、图片变量和表格变量,表格变量需要循环表格数据获取
1、普通变量
普通变量直接用{变量名}
的形式放在模板中就行了,注意这里的变量名必须与前端vue组件中的变量名保持一致
2、图片变量
图片变量用{%变量名}
表示,就是普通变量前加%符号
3、表格变量
通常情况下,表格都是多行的,也就是说需要循环遍历前端的表格数据,表格数据的处理比较复杂,处理步骤如下:
- 需要循环的表格数据用
{#变量名}
开始,用{/变量名}
结束循环,也就是{#tableData}和{/tableData} - 列变量用
{变量名}
表示,也就是{order}、{col1}、{col}和{col3}、{col5}
看下我前端的tableData变量:
const tableData = ref([ { order: 0, col1: "合计", col: 6266, col3: 23, col5: 2 }, { order: 1, col1: "徐州", col: 706, col3: 1, col5: 0 }, { order: 2, col1: "苏州", col: 668, col3: 2, col5: 0 }, { order: 3, col1: "盐城", col: 624, col3: 2, col5: 0 }, { order: 4, col1: "南通", col: 518, col3: 0, col5: 0 }, { order: 5, col1: "连云港", col: 498, col3: 3, col5: 0 }, { order: 6, col1: "淮安", col: 490, col3: 3, col5: 1 }, { order: 7, col1: "常州", col: 458, col3: 1, col5: 0 }, { order: 8, col1: "泰州", col: 454, col3: 1, col5: 0 }, { order: 9, col1: "无锡", col: 433, col3: 2, col5: 0 }, { order: 10, col1: "南京", col: 400, col3: 2, col5: 1 }, { order: 11, col1: "扬州", col: 383, col3: 3, col5: 0 }, { order: 12, col1: "宿迁", col: 363, col3: 1, col5: 0 }, { order: 13, col1: "镇江", col: 271, col3: 2, col5: 0 }, ]);
在看下最后生成的表格:
创建好Word模板后,放在静态文件夹中就行了
三、编写导出Word的工具函数
在utils文件夹中创建exportFile.js文件,编写以下代码:
// 引入基本模块 import Docxtemplater from "docxtemplater"; import PizZip from "pizzip"; import PizZipUtils from "pizzip/utils/index.js"; import { saveAs } from "file-saver"; // 图片模块 import ImageModule from "docxtemplater-image-module-free"; // 解析语法模块 import expressions from "angular-expressions"; import assign from "lodash/assign"; // 文档预览模块 import { renderAsync } from "docx-preview"; expressions.filters.lower = function (input) { if (!input) return input; return input.toLowerCase(); }; function angularParser(tag) { tag = tag .replace(/^\.$/, "this") .replace(/('|')/g, "'") .replace(/("|")/g, '"'); const expr = expressions.compile(tag); return { get: function (scope, context) { let obj = {}; const scopeList = context.scopeList; const num = context.num; for (let i = 0, len = num + 1; i < len; i++) { obj = assign(obj, scopeList[i]); } return expr(scope, obj); }, }; } // 加载文件 function loadFile(url, callback) { PizZipUtils.getBinaryContent(url, callback); } // 配置空值替换函数 作为配置参数可配置在setOptions中 function nullGetter(part, scopeManager) { if (!part.module) { return "-null-"; } if (part.module === "rawxml") { return ""; } return "--"; } /** * 预览word,支持图片 * @param {Object} tempDocxPath 模板文件路径 * @param {Object} wordData 导出数据 * @param {Object} fileName 导出文件名 * @param {Arrsy} imgSize 自定义图片尺寸 */ export const getWordImage = (tempDocxPath, wordData, imgSize, file) => { // 本地word.docx文件需要放在public目录下 loadFile(tempDocxPath, (error, content) => { if (error) { throw error; } // 图片配置 const imageOpts = { getImage: function (tagValue, tagName) { return new Promise(function (resolve, reject) { PizZipUtils.getBinaryContent(tagValue, function (error, content) { if (error) { return reject(error); } return resolve(content); }); }); }, getSize: function (img, tagValue, tagName) { const size = imgSize[tagName] ? imgSize[tagName] : [150, 150]; return size; }, }; let imageModule = new ImageModule(imageOpts); const zip = new PizZip(content); // 实例化有两种方式 这里是链式 const doc = new Docxtemplater() .loadZip(zip) .setOptions({ // delimiters: { start: "[[", end: "]]" }, paragraphLoop: true, linebreaks: true, nullGetter: nullGetter, parser: angularParser, }) .attachModule(imageModule) .compile(); doc.renderAsync(wordData).then(() => { const out = doc.getZip().generate({ type: "blob", mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", }); renderAsync(out, file); }); }); }; /** * 导出word,不支持图片 * @param {Object} tempDocxPath 模板文件路径 * @param {Object} wordData 导出数据 * @param {Object} fileName 导出文件名 */ export const exportWord = (tempDocxPath, wordData, fileName) => { // 本地word.docx文件需要放在public目录下 loadFile(tempDocxPath, (error, content) => { if (error) { throw error; } const zip = new PizZip(content); // 没有配置解析语法,深层次对象语法(obj.xx.xx)不可识别 const doc = new Docxtemplater(zip, { paragraphLoop: true, linebreaks: true, }); doc.render(wordData); const out = doc.getZip().generate({ type: "blob", mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", }); // Output the document using Data-URI saveAs(out, `${fileName}.docx`); }); } /** * 导出word,支持图片 * @param {Object} tempDocxPath 模板文件路径 * @param {Object} wordData 导出数据 * @param {Object} fileName 导出文件名 * @param {Arrsy} imgSize 自定义图片尺寸 */ export const exportWordImage = (tempDocxPath, wordData, fileName, imgSize) => { // 本地word.docx文件需要放在public目录下 loadFile(tempDocxPath, (error, content) => { if (error) { throw error; } // 图片配置 const imageOpts = { getImage: function (tagValue, tagName) { return new Promise(function (resolve, reject) { PizZipUtils.getBinaryContent(tagValue, function (error, content) { if (error) { return reject(error); } return resolve(content); }); }); }, getSize: function (img, tagValue, tagName) { const size = imgSize[tagName] ? imgSize[tagName] : [150, 150] return size; }, }; let imageModule = new ImageModule(imageOpts); const zip = new PizZip(content); // 实例化有两种方式 这里是链式 const doc = new Docxtemplater() .loadZip(zip) .setOptions({ // delimiters: { start: "[[", end: "]]" }, paragraphLoop: true, linebreaks: true, nullGetter: nullGetter, parser: angularParser, }) .attachModule(imageModule) .compile(); doc.renderAsync(wordData).then(function () { const out = doc.getZip().generate({ type: "blob", mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", }); saveAs(out, `${fileName}.docx`); }); }); }
这里有Word的预览以及Word的导出处理函数,我直接参考的他人的,没做修改,可以自己根据需求进行修改,后面有时间我再慢慢理解
四、前端页面预览和导出
先上代码,我这里只写了个演示,所有没有用路由什么的,就是直接放在App.vue根组件中
<script setup> import { exportWordImage, getWordImage } from "@/utils/exportFile"; import { ref } from "vue"; const dialogVisible = ref(false); const startSchemeTemplate = ref({ name: "启动方案名称", time: "2023-12-12", scope: `1.XXXX所有一、二次设备 2.XXXX主变、XXXX主变(XX管辖)`, projectAdjuster: `1.XXXX,XXXX主变冲击五次、核相。 2.XXXXX设备冲击一次,XXXXXXX二次定相。 3.XXXXXX,XXXX差动保护带负荷试验。(XX管辖) 4.XXXXXX备自投实跳试验。`, condition: `1.XXX启动范围内的所有一、二次设备施工结束,验收合格,监控信息与相应调控人员核对完备,设备可以带电,站内一次设备相位正确。 2.XXX待用XXXXXX、待用XXXXXX、待用XXXXXX、待用XXXXXX、待用XXXXXX、待用XXXXXX、待用XXXXXX、银标XXXXXX、银阳XXXXXX、银区XXXXXX、待用XXXXXX、待用XXXXXX、待用XXXXXX、XXXXXX开关保护按定值单整定并投入。 3.启动范围内所有设备均为冷备用状态。`, stepAdjuster: `1.XXXXXX冲击一次、定相。 2.XXXXXX一次设备冲击(见附图2)`, imgPath: "https://docxtemplater.com/puffin.png", tableData: [] }); const imgSize = ref({ imgPath: [150, 150], imgPath1: [550, 250], }); const tableData = ref([ { order: 0, col1: "合计", col: 6266, col3: 23, col5: 2 }, { order: 1, col1: "徐州", col: 706, col3: 1, col5: 0 }, { order: 2, col1: "苏州", col: 668, col3: 2, col5: 0 }, { order: 3, col1: "盐城", col: 624, col3: 2, col5: 0 }, { order: 4, col1: "南通", col: 518, col3: 0, col5: 0 }, { order: 5, col1: "连云港", col: 498, col3: 3, col5: 0 }, { order: 6, col1: "淮安", col: 490, col3: 3, col5: 1 }, { order: 7, col1: "常州", col: 458, col3: 1, col5: 0 }, { order: 8, col1: "泰州", col: 454, col3: 1, col5: 0 }, { order: 9, col1: "无锡", col: 433, col3: 2, col5: 0 }, { order: 10, col1: "南京", col: 400, col3: 2, col5: 1 }, { order: 11, col1: "扬州", col: 383, col3: 3, col5: 0 }, { order: 12, col1: "宿迁", col: 363, col3: 1, col5: 0 }, { order: 13, col1: "镇江", col: 271, col3: 2, col5: 0 }, ]); const htmlTitle = ref("启动方案"); const downLoad = () => { exportWordImage( "../template.docx", startSchemeTemplate.value, htmlTitle.value, imgSize.value ); }; const goPreview = () => { dialogVisible.value = true; }; const file = ref(null); const handleOpened = () => { startSchemeTemplate.value.tableData = tableData.value getWordImage( "../template.docx", startSchemeTemplate.value, imgSize.value, file.value ); }; </script> <template> <div style="height: 90%; background: #fff; padding: 24px"> <div style="margin-bottom: 17px; text-align: left"> <el-button type="primary" @click="downLoad"> 下载启动方案 </el-button> <el-button type="primary" @click="goPreview"> 预览启动方案 </el-button> </div> <el-divider /> <div style="margin-top: 24px"> <!--搜索区域--> <el-form :model="startSchemeTemplate" label-width="110px"> <el-row :gutter="24"> <el-col :span="12"> <el-form-item label="启动方案名称:"> <el-input v-model="startSchemeTemplate.name" placeholder="请输入" /> </el-form-item> </el-col> <el-col :span="12"> <el-form-item label="预定启动时间:"> <el-date-picker v-model="startSchemeTemplate.time" type="date" placeholder="请选择" /> </el-form-item> </el-col> </el-row> <el-row :gutter="24" style="height: 280px"> <el-col :span="12"> <el-form-item label="启动范围:"> <el-input v-model="startSchemeTemplate.scope" placeholder="请输入" type="textarea" :autosize="{ minRows: 13.5, maxRows: 14 }" /> </el-form-item> </el-col> <el-col :span="12"> <el-form-item label="调试项目:"> <el-input v-model="startSchemeTemplate.projectAdjuster" placeholder="请输入" type="textarea" :autosize="{ minRows: 13.5, maxRows: 14 }" /> </el-form-item> </el-col> </el-row> <el-row :gutter="24" style="height: 280px"> <el-col :span="12"> <el-form-item label="启动条件:"> <el-input v-model="startSchemeTemplate.condition" placeholder="请输入" type="textarea" :autosize="{ minRows: 13.5, maxRows: 14 }" /> </el-form-item> </el-col> <el-col :span="12"> <el-form-item label="调试步骤:"> <el-input v-model="startSchemeTemplate.stepAdjuster" placeholder="请输入" type="textarea" :autosize="{ minRows: 13.5, maxRows: 14 }" /> </el-form-item> </el-col> </el-row> </el-form> </div> </div> <el-dialog v-model="dialogVisible" @opened="handleOpened" title="流程图" width="1200px" top="5vh" > <div class="docWrap"> <div ref="file"></div> </div> </el-dialog> </template> <style scoped> .btn { float: left; margin: 0 0 24px; } .docWrap { height: 700px; overflow: auto; clear: both; } </style>
变量全部放在startSchemeTemplate这个响应式变量中,根据前端的表单(或者输入)来更改模板中的数据,把模板和模板中需要的数据对应起来看就更直观了
const startSchemeTemplate = ref({ name: "启动方案名称", time: "2023-12-12", scope: `1.XXXX所有一、二次设备 2.XXXX主变、XXXX主变(XX管辖)`, projectAdjuster: `1.XXXX,XXXX主变冲击五次、核相。 2.XXXXX设备冲击一次,XXXXXXX二次定相。 3.XXXXXX,XXXX差动保护带负荷试验。(XX管辖) 4.XXXXXX备自投实跳试验。`, condition: `1.XXX启动范围内的所有一、二次设备施工结束,验收合格,监控信息与相应调控人员核对完备,设备可以带电,站内一次设备相位正确。 2.XXX待用XXXXXX、待用XXXXXX、待用XXXXXX、待用XXXXXX、待用XXXXXX、待用XXXXXX、待用XXXXXX、银标XXXXXX、银阳XXXXXX、银区XXXXXX、待用XXXXXX、待用XXXXXX、待用XXXXXX、XXXXXX开关保护按定值单整定并投入。 3.启动范围内所有设备均为冷备用状态。`, stepAdjuster: `1.XXXXXX冲击一次、定相。 2.XXXXXX一次设备冲击(见附图2)`, imgPath: "https://docxtemplater.com/puffin.png", tableData: [] });
一言蔽之,把Word模板中需要动态变化的数据放在响应式数据中(这里是startSchemeTemplate),然后根据响应式数据填充模板
预览和导出功能,调用utils/exportFile.js中对应的方法就可以了
五、代码仓库
我已经把这个程序的所有代码和模板文件都传到了代码仓库,有需要的可以自行下载理解