记录—vue3项目实战 打印、导出PDF


🧑‍ 写在开头

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

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

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