vue3实现包含表格的Word文件导出

vue3实现包含表格的Word文件导出

近期遇到一个要求,需要在网页上导出Word文档,文档中有表格,也有普通的数据,查阅了很多资料,总算比较完美的解决了,记录一下

先上一下最终效果

演示视频

vue3项目根据Word模板导出Word文件

当然,个人的项目要比这个演示的视频复杂多了,需要配合后端完成

实现过程主要参考了这篇文章

一、第三方库的安装

要实现Word的导出功能,需要额外安装以下第三方包:

json 复制代码
"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变量:

js 复制代码
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文件,编写以下代码:

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根组件中

javascript 复制代码
<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这个响应式变量中,根据前端的表单(或者输入)来更改模板中的数据,把模板和模板中需要的数据对应起来看就更直观了

js 复制代码
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中对应的方法就可以了

五、代码仓库

我已经把这个程序的所有代码和模板文件都传到了代码仓库,有需要的可以自行下载理解

相关推荐
Lysun0011 小时前
[less] Operation on an invalid type
前端·vue·less·sass·scss
程序视点8 小时前
【Vue3新工具】Pinia.js:提升开发效率,更轻量、更高效的状态管理方案!
前端·javascript·vue.js·typescript·vue·ecmascript
刚刚好ā9 小时前
js作用域超全介绍--全局作用域、局部作用、块级作用域
前端·javascript·vue.js·vue
ZwaterZ12 小时前
vue el-table表格点击某行触发事件&&操作栏点击和row-click冲突问题
前端·vue.js·elementui·c#·vue
万里沧海寄云帆13 小时前
Word 插入分节符页码更新问题
windows·microsoft·word
ZwaterZ15 小时前
el-table-column自动生成序号&&在序号前插入图标
前端·javascript·c#·vue
木子七17 小时前
vue2-vuex
前端·vue
小小黑00718 小时前
uniapp+vue3+ts H5端使用Quill富文本插件以及解决上传图片反显的问题
uni-app·vue
Ztiddler21 小时前
【npm设置代理-解决npm网络连接error network失败问题】
前端·后端·npm·node.js·vue
zls3653651 天前
.NET高效下载word文件
开发语言·c#·word·.net