?‍? 写在开头

点赞 + 收藏 === 学会???

vue3项目实战 打印、导出PDF

一 维护模板

1 打印模板:

  <template>    <div class="print-content">      <div v-for="item in data.detailList" :key="item.id" class="label-item">        <!-- 顶部价格区域 - 最醒目 -->        <div class="price-header">          <div class="main-price">            <span class="price-value">{{ formatPrice(item.detailPrice) }}</span>            <span class="currency">¥</span>          </div>          <div v-if="item.originalPrice && item.originalPrice !== item.detailPrice" class="origin-price">            原价 ¥{{ formatPrice(item.originalPrice) }}          </div>        </div>  ​        <!-- 商品信息区域 -->        <div class="product-info">          <div class="product-name">{{ truncateText(item.skuName, 20) }}</div>          <div class="product-code">{{ item.skuCode || item.skuName.slice(-8) }}</div>        </div>  ​        <!-- 条码区域 -->        <div class="barcode-section" v-if="item.showBarcode !== false">          <img :src="item.skuCodeImg || '123456789'" alt="条码" class="barcode" v-if="item.skuCode">        </div>  ​        <!-- 底部信息区域 -->        <div class="footer-info">          <div class="info-row">            <span class="location">{{ item.location || "A1-02" }}</span>            <span class="stock">库存{{ item.stock || 36 }}</span>          </div>        </div>      </div>    </div>  </template>  ​  <script>  export default {    props: {      data: {        type: Object,        required: true      }    },    methods: {      formatPrice(price) {        return parseFloat(price || 0).toFixed(2);      },      truncateText(text, maxLength) {        if (!text) return '';        return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;      }    }  }  </script>  ​  <style scoped lang="scss">  /* 主容器 - 网格布局 */  .print-content {    display: grid;    /* 启用 CSS Grid 布局 */    grid-template-columns: repeat(auto-fill, 50mm); /* 每列宽 50mm,自动填充剩余空间 */    grid-auto-rows: 30mm; /* 每行固定高度 30mm */    background: #f5f5f5;  /* 网格背景色(浅灰色) */  ​    /* 单个标签样式 */    .label-item {      width: 50mm;      height: 30mm;      background: #ffffff;      border-radius: 2mm;      display: flex;      flex-direction: column;      position: relative;      overflow: hidden;      page-break-inside: avoid;      font-family: 'OCR','ShareTechMono', 'Condensed','Liberation Mono','Microsoft YaHei', 'SimSun', 'Arial', monospace;      box-shadow: none; /* 避免阴影被打印 */  ​      /* 价格头部区域 - 最醒目 */      .price-header {        background: linear-gradient(135deg, #2196F3 0%, #1976D2 100%);        color: white;        padding: 1mm 2mm;        text-align: center;        position: relative;  ​        .main-price {          display: flex;          align-items: baseline;          justify-content: center;          line-height: 1;  ​          .currency {            color: #000 !important;            font-weight: bold;            margin-left: 2mm;          }  ​          .price-value {            font-size: 16px;            font-weight: 900;            letter-spacing: -0.5px;            color: #000 !important;          }        }  ​        .origin-price {          font-size: 6px;          opacity: 0.8;          text-decoration: line-through;          margin-top: 0.5mm;        }  ​        /* 特殊效果 - 价格角标 */        &::after {          content: '';          position: absolute;          bottom: -1mm;          left: 50%;          transform: translateX(-50%);          width: 0;          height: 0;          border-left: 2mm solid transparent;          border-right: 2mm solid transparent;          border-top: 1mm solid #1976D2;        }      }  ​      /* 商品信息区域 */      .product-info {        padding: 1.5mm 2mm 1mm 2mm;        flex: 1;        display: flex;        flex-direction: column;        justify-content: center;  ​        .product-name {          font-size: 10px;          font-weight: 600;          color: #000 !important;          line-height: 1.2;          text-align: center;          margin-bottom: 0.5mm;          overflow: hidden;          display: -webkit-box;          --webkit-line-clamp: 2;          -webkit-box-orient: vertical;        }  ​        .product-code {          font-size: 8px;          color: #000 !important;          text-align: center;          font-family: 'Courier New', monospace;          letter-spacing: 0.3px;        }      }  ​      /* 条码区域 */      .barcode-section {        padding: 0 1mm;        text-align: center;        height: 6mm;        display: flex;        align-items: center;        justify-content: center;  ​        .barcode {          height: 5mm;          max-width: 46mm;          object-fit: contain;        }      }  ​      /* 底部信息区域 */      .footer-info {        background: #f8f9fa;        padding: 0.8mm 2mm;        border-top: 0.5px solid #e0e0e0;  ​        .info-row {          display: flex;          justify-content: space-between;          align-items: center;  ​          .location, .stock {            font-size: 5px;            color: #666;            font-weight: 500;          }  ​          .location {            background: #e3f2fd;            color: #1976d2;            padding: 0.5mm 1mm;            border-radius: 1mm;            font-weight: 600;          }  ​          .stock {            background: #f3e5f5;            color: #7b1fa2;            padding: 0.5mm 1mm;            border-radius: 1mm;            font-weight: 600;          }        }      }    }  }  ​  /* 打印优化 */  @media print {    .price-header {      /* 打印时使用模板颜色 */      -webkit-print-color-adjust: exact;      print-color-adjust: exact;    }  }  ​  </style>

2 注意说明:

  1 注意:使用原生的标签 + vue3响应式 ,不可以使用element-plus;  2 @media print{} 用来维护打印样式,最好在打印封装中统一维护,否则交叉样式会被覆盖;

二 封装获取模板

1 模板设计

  // 1 模板类型:    -- invoice-A4发票 ticket-80mm热敏小票 label-货架标签  // 2 模板写死在前端,通过更新前端维护    -- src/compoments/print/template/invoice/...    -- src/compoments/print/template/ticket/...    -- src/compoments/print/template/label/...  // 3 通过 模板类型 templateType 、模板路径 templatePath  -> 获取唯一模板    -- 前端实现模板获取 

2 封装模板获取

    // src/utils/print/templateLoader.js  import { TEMPLATE_MAP } from '@/components/Print/templates';  ​  const templateCache = new Map();  const MAX_CACHE_SIZE = 10; // 防止内存无限增长  ​  export async function loadTemplate(type, path, isFallback = false) {    console.log('loadTemplate 进行模板加载:', type, path, isFallback);    const cacheKey = `${type}/${path}`;  ​    // 检查缓存    if (templateCache.has(cacheKey)) {      return templateCache.get(cacheKey);    }  ​    try {      // 检查类型和路径是否有效      if (!TEMPLATE_MAP[type] || !TEMPLATE_MAP[type][path]) {        throw new Error(`模板 ${type}/${path} 未注册`);      }  ​      // 动态加载模块      const module = await TEMPLATE_MAP[type][path]();  ​      // 清理最久未使用的缓存      if (templateCache.size >= MAX_CACHE_SIZE) {        // Map 的 keys() 是按插入顺序的迭代器        const oldestKey = templateCache.keys().next().value;        templateCache.delete(oldestKey);      }  ​      templateCache.set(cacheKey, module.default);      return module.default;    } catch (e) {      console.error(`加载模板失败: ${type}/${path}`, e);  ​      // 回退到默认模板      if (isFallback || path === 'Default') {        throw new Error(`无法加载模板 ${type}/${path} 且默认模板也不可用`);      }  ​      return loadTemplate(type, 'Default', true);    }  }

三 生成打印数据

1 根据模板 + 打印数据 -> 生成 html(支持二维码、条形码)

    import JsBarcode from 'jsbarcode';  import { createApp, h } from 'vue';  import { isExternal } from "@/utils/validate";  import QRCode from 'qrcode';  // 1 生成条码图片  function generateBarcodeBase64(code) {    if (!code) return '';    const canvas = document.createElement('canvas');    try {      JsBarcode(canvas, code, {        format: 'CODE128',    // 条码格式 CODE128、EAN13、EAN8、UPC、CODE39、ITF、MSI...        displayValue: false,  // 是否显示条码值        width: 2,             // 条码宽度        height: 40,           // 条码高度          margin: 0,            // 条码外边距      });      return canvas.toDataURL('image/png');    } catch (err) {      console.warn('条码生成失败:', err);      return '';    }  }  ​  // 2 拼接图片路径  function getImageUrl(imgSrc) {    if (!imgSrc) {      return ''    }    try {      const src = imgSrc.split(",")[0].trim();      // 2.1 判断图片路径是否为完整路径      return isExternal(src) ? src : `${import.meta.env.VITE_APP_BASE_API}${src}`;    } catch (err) {      console.warn('图片路径拼接失败:', err);      return '';    }  }  ​  // 更安全的QR码生成  async function generateQRCode(url) {    if (!url) return '';  ​    try {      return await QRCode.toDataURL(url.toString())    } catch (err) {      console.warn('QR码生成失败:', err);      return '';    }  }  ​  /**   * 3 打印模板渲染数据    * @param {*} Component  模板组件   * @param {*} printData    打印数据     * @returns  html   */  export default async function renderTemplate(Component, printData) {    // 1. 数据验证和初始化    if (!printData || typeof printData !== 'object') {      throw new Error('Invalid data format');    }  ​    // 2. 创建安全的数据副本    const data = {      ...printData,      tenant: {        ...printData.tenant,        logo: printData?.tenant?.logo || '',        logoImage: ''      },      invoice: {        ...printData.invoice,        invoiceQr: printData?.invoice?.invoiceQr || '',        invoiceQrImage: ''      },      detailList: Array.isArray(printData.detailList) ? printData.detailList : [],      invoiceDetailList: Array.isArray(printData.invoiceDetailList) ? printData.invoiceDetailList : [],    };  ​    // 3. 异步处理二维码和条码和logo    try {      // 3.1 处理二维码      if (data.invoice.invoiceQr) {        data.invoice.invoiceQrImage = await generateQRCode(data.invoice.invoiceQr);      }      // 3.2 处理条码      if (data.detailList.length > 0) {        data.detailList = data.detailList.map(item => ({          ...item,          skuCodeImg: item.skuCode ? generateBarcodeBase64(item.skuCode) : ''        }));      }      // 3.3 处理LOGO      if (data.tenant.logo) {        data.tenant.logoImage = getImageUrl(data.tenant?.logo);      }    } catch (err) {      console.error('数据处理失败:', err);      // 即使部分数据处理失败也继续执行    }  ​  ​    // 4. 创建渲染容器    const div = document.createElement('div');    div.id = 'print-template-container';  ​    // 5. 使用Promise确保渲染完成    return new Promise((resolve) => {      const app = createApp({        render: () => h(Component, { data })      });  ​      // 6. 特殊处理:等待两个tick确保渲染完成      app.mount(div);      nextTick().then(() => {        return nextTick(); // 双重确认      }).then(() => {        const html = div.innerHTML;        app.unmount();        div.remove();        resolve(html);      }).catch(err => {        console.error('渲染失败:', err);        app.unmount();        div.remove();        resolve('<div>渲染失败</div>');      });    });  }

四 封装打印

    // src/utils/print/printHtml.js  ​  import { PrintTemplateType } from "@/views/print/printTemplate/printConstants";  /**   * 精准打印指定HTML(无浏览器默认页眉页脚)   * @param {string} html - 要打印的HTML内容   */  export function printHtml(html, { templateType = PrintTemplateType.Invoice, templateWidth = 210, templateHeight = 297 }) {  ​    // 1 根据类型调整默认参数    if (templateType === PrintTemplateType.Ticket) {      templateWidth = 80; // 热敏小票通常80mm宽      templateHeight = 0; // 高度自动    } else if (templateType === PrintTemplateType.Label) {      templateWidth = templateWidth || 50; // 标签打印机常见宽度50mm      templateHeight = templateHeight || 30; // 标签常见高度30mm    }  ​    // 1. 创建打印专用容器    const printContainer = document.createElement('div');    printContainer.id = 'print-container';    document.body.appendChild(printContainer);  ​    // 2. 注入打印控制样式(隐藏页眉页脚)    const style = document.createElement('style');    style.innerHTML = `      /* 打印页面设置 */      @page {        margin: 0;  /* 去除页边距 */        size: ${templateWidth}mm ${templateHeight === 0 ? 'auto' : `${templateHeight}mm`};  /* 自定义纸张尺寸 */      }      @media print {        body, html {          width: ${templateWidth}mm !important;          margin: 0 !important;          padding: 0 !important;          background: #fff !important;  /* 强制白色背景 */        }                /* 隐藏页面所有元素 */        body * {          visibility: hidden;         }  ​        /* 只显示打印容器内容 */        #print-container, #print-container * {          visibility: visible;          }  ​        /* 打印容器定位 */        #print-container {          position: absolute;          left: 0;          top: 0;          width: ${templateWidth}mm !important;          ${templateHeight === 0 ? 'auto' : `height: ${templateHeight}mm !important;`}          margin: 0 !important;          padding: 0 !important;          box-sizing: border-box;          page-break-after: avoid;  /* 避免分页 */          page-break-inside: avoid;        }      }  ​      /* 屏幕预览样式 */      #print-container {        width: ${templateWidth}mm;        ${templateHeight === 0 ? 'auto' : `height: ${templateHeight}mm;`}        // margin: 10px auto;        // padding: 5mm;        box-shadow: 0 0 5px rgba(0,0,0,0.2);        background: white;      }    `;    document.head.appendChild(style);  ​    // 3. 放入要打印的内容    printContainer.innerHTML = html;  ​    // 4. 触发打印    window.print();  ​    // 5. 清理(延迟确保打印完成)    setTimeout(() => {      document.body.removeChild(printContainer);      document.head.removeChild(style);    }, 1000);  }

五 封装导出PDF

    // /src/utils/print/pdfExport.js  ​  import html2canvas from 'html2canvas';  import { jsPDF } from 'jspdf';  import { PrintTemplateType } from "@/views/print/printTemplate/printConstants";  ​  // 毫米转像素的转换系数 (96dpi下)  const MM_TO_PX = 3.779527559;  ​  // 默认A4尺寸 (单位: mm)  const DEFAULT_WIDTH = 210;  const DEFAULT_HEIGHT = 297;  ​  export async function exportToPDF(html, {    filename,    templateType = PrintTemplateType.Invoice,    templateWidth = DEFAULT_WIDTH,    templateHeight = DEFAULT_HEIGHT,    allowPaging = true  }) {    // 生成文件名    const finalFilename = filename || `${templateType}_${Date.now()}.pdf`;    // 处理宽度和高度,如果为0则使用默认值    const widthMm = templateWidth === 0 ? DEFAULT_WIDTH : templateWidth;    // 分页模式使用A4高度,单页模式自动高度    const heightMm = templateHeight === 0 ? (allowPaging ? DEFAULT_HEIGHT : 'auto') : templateHeight;  ​    // 创建临时容器    const container = document.createElement('div');    container.style.position = 'absolute';    // 使容器脱离正常文档流    container.style.left = '-9999px';         // 移出可视区域,避免在页面上显示    container.style.width = `${widthMm}mm`;   // 容器宽度    container.style.height = 'auto';          // 让内容决定高度    container.style.overflow = 'visible';     // 溢出部分不被裁剪    container.innerHTML = html;               // 添加HTML内容    document.body.appendChild(container);     // 将准备好的临时容器添加到文档中  ​    try {      if (allowPaging) {        console.log('导出PDF - 分页处理模式');        const pdf = new jsPDF({          orientation: 'portrait',          unit: 'mm',          format: [widthMm, heightMm]        });  ​        // 获取所有页面或使用容器作为单页        const pageElements = container.querySelectorAll('.page');        const pages = pageElements.length > 0 ? pageElements : [container];  ​        for (let i = 0; i < pages.length; i++) {          const page = pages[i];          page.style.backgroundColor = 'white';  ​          // 计算页面高度(像素)          const pageHeightPx = page.scrollHeight;          const pageHeightMm = pageHeightPx / MM_TO_PX;  ​          const canvas = await html2canvas(page, {            scale: 2,            useCORS: true,  // 启用跨域访问            backgroundColor: '#FFFFFF',            logging: true,            width: widthMm * MM_TO_PX,  // 画布 宽度转换成像素            height: pageHeightPx,       // 画布 高度转换成像素            windowWidth: widthMm * MM_TO_PX,    // 模拟视口 宽度转换成像素            windowHeight: pageHeightPx          // 模拟视口 高度转换成像素          });  ​          const imgData = canvas.toDataURL('image/png');          const imgWidth = widthMm;          const imgHeight = (canvas.height * imgWidth) / canvas.width;  ​          if (i > 0) {            pdf.addPage([widthMm, heightMm], 'portrait');          }  ​          pdf.addImage(imgData, 'PNG', 0, 0, imgWidth, imgHeight);        }  ​        pdf.save(finalFilename);      } else {        console.log('导出PDF - 单页处理模式');        const canvas = await html2canvas(container, {          scale: 2,          useCORS: true,          backgroundColor: '#FFFFFF',          logging: true,          width: widthMm * MM_TO_PX,          height: container.scrollHeight,          windowWidth: widthMm * MM_TO_PX,          windowHeight: container.scrollHeight        });  ​        const imgData = canvas.toDataURL('image/png');        const imgWidth = widthMm;        const imgHeight = (canvas.height * imgWidth) / canvas.width;  ​        const pdf = new jsPDF({          orientation: imgWidth > imgHeight ? 'landscape' : 'portrait',          unit: 'mm',          format: [imgWidth, imgHeight]        });  ​        pdf.addImage(imgData, 'PNG', 0, 0, imgWidth, imgHeight);        pdf.save(finalFilename);      }    } catch (error) {      console.error('PDF导出失败:', error);      throw error;    } finally {      document.body.removeChild(container);    }  }

六 测试打印

1 封装打印预览界面

  方便调试模板,此处就不提供预览界面的代码里,自己手搓吧!

2 使用浏览器默认打印

  1 查看打印预览,正常打印预览与预期一样;  2 擦和看打印结果;

3 注意事项

  1 涉及的模板尺寸 与 打印纸张的尺寸 要匹配;    -- 否则预览界面异常、打印结果异常;  2 处理自动分页,页眉页脚留够空间,否则会覆盖;  3 有些打印机调试需要设置打印机的首选项,主要设置尺寸!

七 问题解决

  // 1 打印预览样式与模板不一致    -- 检查 @media print{} 这里的样式,    -- 分别检查模板 和 打印封装;      // 2 打印预览异常、打印正常    -- 问题原因:打印机纸张尺寸识别异常,即打印机当前设置的尺寸与模板尺寸不一致;    -- 解决办法:设置打印机 -> 首选项 -> 添加尺寸设置;      // 3 打印机实测:    -- 目前A4打印机、80热敏打印机、标签打印机 都有测试,没有问题!    -- 如果字体很丑,建议选择等宽字体;    -- 调节字体尺寸、颜色、尽可能美观、节省纸张!      // 4 进一步封装    -- 项目中可以进一步封装打印,向所有流程封装到一个service中,打印只需要传递 printData、templateType;    -- 可以封装批量打印;    -- 模板可以根据用户自定义配置,通过pinia维护状态;      // 5 后端来实现打印数据生成    -- 我是前端能做的尽可能不放到后端处理,减少后端请求处理压力!    

本文转载于:https://*juejin.cn**/post/7521356618174021674

如果对您有所帮助,欢迎您点个关注,我会定时更新技术文档,大家一起讨论学习,一起进步。

本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:[email protected]