当前位置: 首页 > news >正文

记录---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.jsimport { 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

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

http://www.hskmm.com/?act=detail&tid=9771

相关文章:

  • 09
  • node.js安装(绿色版)
  • 08
  • selenium完整版一览 - 教程
  • 创龙 瑞芯微 RK3588 国产2.4GHz八核 工业开发板—开发环境搭建(二) - 创龙科技
  • ctfshow web55
  • ctfshow web58
  • ctfshow web57
  • 01
  • test 1
  • 关于如何计算空间
  • ECT-OS-JiuHuaShan框架实现的元推理,是新质生产力的绝对标杆
  • 线性调频信号(LFM)在雷达中的时域及频域MATLAB编程
  • Ubuntu 18.04 LTS 安装 6.10.10 内核 - 教程
  • 国标GB28181视频平台EasyGBS核心功能解密:如何实现海量设备的录像精准检索与高效回放?
  • 最大流判定+拆点
  • C++ 左值、右值、左值引用、右值引用
  • 基数排序模板(Radix Sort)
  • [项目开发经验分享]基于强类型事件的类型参数传递问题 —— 在 .NET Winform项目中如何设计泛型事件总线以实现UI与核心层的解耦
  • python3安装pip3
  • 堆基础知识
  • RUST 实现 Future trait
  • 行程长度编码
  • mysql 虚拟列,可以简化 SQL 逻辑、提升查询效率
  • Flash Attention算法动画
  • PointNetwork-求解TSP-05 - jack
  • 多站点的TSP问题求解-06 - jack
  • Windows 11如何进入安全模式
  • C# CAN通信上位机系统设计与实现
  • 进程池VS线程池