云山雾竹 發表於 2025-9-19 17:09:00

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

<h1 data-id="heading-0">🧑‍💻 写在开头</h1>
<p>点赞 + 收藏 === 学会🤣🤣🤣</p>
<h3 data-id="heading-1">一 维护模板</h3>
<h4 data-id="heading-2">1 打印模板:</h4>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">&lt;template&gt;
   &nbsp;&lt;div class="print-content"&gt;
   &nbsp; &nbsp;&lt;div v-for="item in data.detailList" :key="item.id" class="label-item"&gt;
   &nbsp; &nbsp; &nbsp;&lt;!-- 顶部价格区域 - 最醒目 --&gt;
   &nbsp; &nbsp; &nbsp;&lt;div class="price-header"&gt;
   &nbsp; &nbsp; &nbsp; &nbsp;&lt;div class="main-price"&gt;
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&lt;span class="price-value"&gt;{{ formatPrice(item.detailPrice) }}&lt;/span&gt;
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&lt;span class="currency"&gt;¥&lt;/span&gt;
   &nbsp; &nbsp; &nbsp; &nbsp;&lt;/div&gt;
   &nbsp; &nbsp; &nbsp; &nbsp;&lt;div v-if="item.originalPrice &amp;&amp; item.originalPrice !== item.detailPrice" class="origin-price"&gt;
   &nbsp; &nbsp; &nbsp; &nbsp;原价 ¥{{ formatPrice(item.originalPrice) }}
   &nbsp; &nbsp; &nbsp; &nbsp;&lt;/div&gt;
   &nbsp; &nbsp; &nbsp;&lt;/div&gt;

   &nbsp; &nbsp; &nbsp;&lt;!-- 商品信息区域 --&gt;
   &nbsp; &nbsp; &nbsp;&lt;div class="product-info"&gt;
   &nbsp; &nbsp; &nbsp; &nbsp;&lt;div class="product-name"&gt;{{ truncateText(item.skuName, 20) }}&lt;/div&gt;
   &nbsp; &nbsp; &nbsp; &nbsp;&lt;div class="product-code"&gt;{{ item.skuCode || item.skuName.slice(-8) }}&lt;/div&gt;
   &nbsp; &nbsp; &nbsp;&lt;/div&gt;

   &nbsp; &nbsp; &nbsp;&lt;!-- 条码区域 --&gt;
   &nbsp; &nbsp; &nbsp;&lt;div class="barcode-section" v-if="item.showBarcode !== false"&gt;
   &nbsp; &nbsp; &nbsp; &nbsp;&lt;img :src="item.skuCodeImg || '123456789'" alt="条码" class="barcode" v-if="item.skuCode"&gt;
   &nbsp; &nbsp; &nbsp;&lt;/div&gt;

   &nbsp; &nbsp; &nbsp;&lt;!-- 底部信息区域 --&gt;
   &nbsp; &nbsp; &nbsp;&lt;div class="footer-info"&gt;
   &nbsp; &nbsp; &nbsp; &nbsp;&lt;div class="info-row"&gt;
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&lt;span class="location"&gt;{{ item.location || "A1-02" }}&lt;/span&gt;
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&lt;span class="stock"&gt;库存{{ item.stock || 36 }}&lt;/span&gt;
   &nbsp; &nbsp; &nbsp; &nbsp;&lt;/div&gt;
   &nbsp; &nbsp; &nbsp;&lt;/div&gt;
   &nbsp; &nbsp;&lt;/div&gt;
   &nbsp;&lt;/div&gt;
&lt;/template&gt;

&lt;script&gt;
export default {
   &nbsp;props: {
   &nbsp; &nbsp;data: {
   &nbsp; &nbsp; &nbsp;type: Object,
   &nbsp; &nbsp; &nbsp;required: true
   &nbsp;}
    },
   &nbsp;methods: {
   &nbsp; &nbsp;formatPrice(price) {
   &nbsp; &nbsp; &nbsp;return parseFloat(price || 0).toFixed(2);
   &nbsp;},
   &nbsp; &nbsp;truncateText(text, maxLength) {
   &nbsp; &nbsp; &nbsp;if (!text) return '';
   &nbsp; &nbsp; &nbsp;return text.length &gt; maxLength ? text.substring(0, maxLength) + '...' : text;
   &nbsp;}
    }
}
&lt;/script&gt;

&lt;style scoped lang="scss"&gt;
/* 主容器 - 网格布局 */
.print-content {
   &nbsp;display: grid; &nbsp; &nbsp;/* 启用 CSS Grid 布局 */
   &nbsp;grid-template-columns: repeat(auto-fill, 50mm); /* 每列宽 50mm,自动填充剩余空间 */
   &nbsp;grid-auto-rows: 30mm; /* 每行固定高度 30mm */
   &nbsp;background: #f5f5f5; &nbsp;/* 网格背景色(浅灰色) */

   &nbsp;/* 单个标签样式 */
   &nbsp;.label-item {
   &nbsp; &nbsp;width: 50mm;
   &nbsp; &nbsp;height: 30mm;
   &nbsp; &nbsp;background: #ffffff;
   &nbsp; &nbsp;border-radius: 2mm;
   &nbsp; &nbsp;display: flex;
   &nbsp; &nbsp;flex-direction: column;
   &nbsp; &nbsp;position: relative;
   &nbsp; &nbsp;overflow: hidden;
   &nbsp; &nbsp;page-break-inside: avoid;
   &nbsp; &nbsp;font-family: 'OCR','ShareTechMono', 'Condensed','Liberation Mono','Microsoft YaHei', 'SimSun', 'Arial', monospace;
   &nbsp; &nbsp;box-shadow: none; /* 避免阴影被打印 */

   &nbsp; &nbsp;/* 价格头部区域 - 最醒目 */
   &nbsp; &nbsp;.price-header {
   &nbsp; &nbsp; &nbsp;background: linear-gradient(135deg, #2196F3 0%, #1976D2 100%);
   &nbsp; &nbsp; &nbsp;color: white;
   &nbsp; &nbsp; &nbsp;padding: 1mm 2mm;
   &nbsp; &nbsp; &nbsp;text-align: center;
   &nbsp; &nbsp; &nbsp;position: relative;

   &nbsp; &nbsp; &nbsp;.main-price {
   &nbsp; &nbsp; &nbsp; &nbsp;display: flex;
   &nbsp; &nbsp; &nbsp; &nbsp;align-items: baseline;
   &nbsp; &nbsp; &nbsp; &nbsp;justify-content: center;
   &nbsp; &nbsp; &nbsp; &nbsp;line-height: 1;

   &nbsp; &nbsp; &nbsp; &nbsp;.currency {
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;color: #000 !important;
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;font-weight: bold;
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;margin-left: 2mm;
   &nbsp; &nbsp; &nbsp;}

   &nbsp; &nbsp; &nbsp; &nbsp;.price-value {
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;font-size: 16px;
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;font-weight: 900;
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;letter-spacing: -0.5px;
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;color: #000 !important;
   &nbsp; &nbsp; &nbsp;}
   &nbsp; &nbsp;}

   &nbsp; &nbsp; &nbsp;.origin-price {
   &nbsp; &nbsp; &nbsp; &nbsp;font-size: 6px;
   &nbsp; &nbsp; &nbsp; &nbsp;opacity: 0.8;
   &nbsp; &nbsp; &nbsp; &nbsp;text-decoration: line-through;
   &nbsp; &nbsp; &nbsp; &nbsp;margin-top: 0.5mm;
   &nbsp; &nbsp;}

   &nbsp; &nbsp; &nbsp;/* 特殊效果 - 价格角标 */
   &nbsp; &nbsp;&amp;::after {
   &nbsp; &nbsp; &nbsp; &nbsp;content: '';
   &nbsp; &nbsp; &nbsp; &nbsp;position: absolute;
   &nbsp; &nbsp; &nbsp; &nbsp;bottom: -1mm;
   &nbsp; &nbsp; &nbsp; &nbsp;left: 50%;
   &nbsp; &nbsp; &nbsp; &nbsp;transform: translateX(-50%);
   &nbsp; &nbsp; &nbsp; &nbsp;width: 0;
   &nbsp; &nbsp; &nbsp; &nbsp;height: 0;
   &nbsp; &nbsp; &nbsp; &nbsp;border-left: 2mm solid transparent;
   &nbsp; &nbsp; &nbsp; &nbsp;border-right: 2mm solid transparent;
   &nbsp; &nbsp; &nbsp; &nbsp;border-top: 1mm solid #1976D2;
   &nbsp; &nbsp;}
   &nbsp;}

   &nbsp; &nbsp;/* 商品信息区域 */
   &nbsp; &nbsp;.product-info {
   &nbsp; &nbsp; &nbsp;padding: 1.5mm 2mm 1mm 2mm;
   &nbsp; &nbsp; &nbsp;flex: 1;
   &nbsp; &nbsp; &nbsp;display: flex;
   &nbsp; &nbsp; &nbsp;flex-direction: column;
   &nbsp; &nbsp; &nbsp;justify-content: center;

   &nbsp; &nbsp; &nbsp;.product-name {
   &nbsp; &nbsp; &nbsp; &nbsp;font-size: 10px;
   &nbsp; &nbsp; &nbsp; &nbsp;font-weight: 600;
   &nbsp; &nbsp; &nbsp; &nbsp;color: #000 !important;
   &nbsp; &nbsp; &nbsp; &nbsp;line-height: 1.2;
   &nbsp; &nbsp; &nbsp; &nbsp;text-align: center;
   &nbsp; &nbsp; &nbsp; &nbsp;margin-bottom: 0.5mm;
   &nbsp; &nbsp; &nbsp; &nbsp;overflow: hidden;
   &nbsp; &nbsp; &nbsp; &nbsp;display: -webkit-box;
   &nbsp; &nbsp; &nbsp; &nbsp;--webkit-line-clamp: 2;
   &nbsp; &nbsp; &nbsp; &nbsp;-webkit-box-orient: vertical;
   &nbsp; &nbsp;}

   &nbsp; &nbsp; &nbsp;.product-code {
   &nbsp; &nbsp; &nbsp; &nbsp;font-size: 8px;
   &nbsp; &nbsp; &nbsp; &nbsp;color: #000 !important;
   &nbsp; &nbsp; &nbsp; &nbsp;text-align: center;
   &nbsp; &nbsp; &nbsp; &nbsp;font-family: 'Courier New', monospace;
   &nbsp; &nbsp; &nbsp; &nbsp;letter-spacing: 0.3px;
   &nbsp; &nbsp;}
   &nbsp;}

   &nbsp; &nbsp;/* 条码区域 */
   &nbsp; &nbsp;.barcode-section {
   &nbsp; &nbsp; &nbsp;padding: 0 1mm;
   &nbsp; &nbsp; &nbsp;text-align: center;
   &nbsp; &nbsp; &nbsp;height: 6mm;
   &nbsp; &nbsp; &nbsp;display: flex;
   &nbsp; &nbsp; &nbsp;align-items: center;
   &nbsp; &nbsp; &nbsp;justify-content: center;

   &nbsp; &nbsp; &nbsp;.barcode {
   &nbsp; &nbsp; &nbsp; &nbsp;height: 5mm;
   &nbsp; &nbsp; &nbsp; &nbsp;max-width: 46mm;
   &nbsp; &nbsp; &nbsp; &nbsp;object-fit: contain;
   &nbsp; &nbsp;}
   &nbsp;}

   &nbsp; &nbsp;/* 底部信息区域 */
   &nbsp; &nbsp;.footer-info {
   &nbsp; &nbsp; &nbsp;background: #f8f9fa;
   &nbsp; &nbsp; &nbsp;padding: 0.8mm 2mm;
   &nbsp; &nbsp; &nbsp;border-top: 0.5px solid #e0e0e0;

   &nbsp; &nbsp; &nbsp;.info-row {
   &nbsp; &nbsp; &nbsp; &nbsp;display: flex;
   &nbsp; &nbsp; &nbsp; &nbsp;justify-content: space-between;
   &nbsp; &nbsp; &nbsp; &nbsp;align-items: center;

   &nbsp; &nbsp; &nbsp; &nbsp;.location, .stock {
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;font-size: 5px;
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;color: #666;
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;font-weight: 500;
   &nbsp; &nbsp; &nbsp;}

   &nbsp; &nbsp; &nbsp; &nbsp;.location {
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;background: #e3f2fd;
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;color: #1976d2;
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;padding: 0.5mm 1mm;
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;border-radius: 1mm;
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;font-weight: 600;
   &nbsp; &nbsp; &nbsp;}

   &nbsp; &nbsp; &nbsp; &nbsp;.stock {
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;background: #f3e5f5;
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;color: #7b1fa2;
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;padding: 0.5mm 1mm;
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;border-radius: 1mm;
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;font-weight: 600;
   &nbsp; &nbsp; &nbsp;}
   &nbsp; &nbsp;}
   &nbsp;}
    }
}

/* 打印优化 */
@media print {
   &nbsp;.price-header {
   &nbsp; &nbsp;/* 打印时使用模板颜色 */
   &nbsp; &nbsp;-webkit-print-color-adjust: exact;
   &nbsp; &nbsp;print-color-adjust: exact;
    }
}

&lt;/style&gt;</pre>
</div>
<h4 data-id="heading-3">2 注意说明:</h4>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">1 注意:使用原生的标签 + vue3响应式 ,不可以使用element-plus;
2 @media print{} 用来维护打印样式,最好在打印封装中统一维护,否则交叉样式会被覆盖;</pre>
</div>
<h3 data-id="heading-4">二 封装获取模板</h3>
<h4 data-id="heading-5">1 模板设计</h4>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">
// 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-&gt; 获取唯一模板
   &nbsp;-- 前端实现模板获取 </pre>
</div>
<h4 data-id="heading-6">2 封装模板获取</h4>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">
// 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) {
   &nbsp;console.log('loadTemplate 进行模板加载:', type, path, isFallback);
   &nbsp;const cacheKey = `${type}/${path}`;

   &nbsp;// 检查缓存
   &nbsp;if (templateCache.has(cacheKey)) {
   &nbsp; &nbsp;return templateCache.get(cacheKey);
    }

   &nbsp;try {
   &nbsp; &nbsp;// 检查类型和路径是否有效
   &nbsp; &nbsp;if (!TEMPLATE_MAP || !TEMPLATE_MAP) {
   &nbsp; &nbsp; &nbsp;throw new Error(`模板 ${type}/${path} 未注册`);
   &nbsp;}

   &nbsp; &nbsp;// 动态加载模块
   &nbsp; &nbsp;const module = await TEMPLATE_MAP();

   &nbsp; &nbsp;// 清理最久未使用的缓存
   &nbsp; &nbsp;if (templateCache.size &gt;= MAX_CACHE_SIZE) {
   &nbsp; &nbsp; &nbsp;// Map 的 keys() 是按插入顺序的迭代器
   &nbsp; &nbsp; &nbsp;const oldestKey = templateCache.keys().next().value;
   &nbsp; &nbsp; &nbsp;templateCache.delete(oldestKey);
   &nbsp;}

   &nbsp; &nbsp;templateCache.set(cacheKey, module.default);
   &nbsp; &nbsp;return module.default;
    } catch (e) {
   &nbsp; &nbsp;console.error(`加载模板失败: ${type}/${path}`, e);

   &nbsp; &nbsp;// 回退到默认模板
   &nbsp; &nbsp;if (isFallback || path === 'Default') {
   &nbsp; &nbsp; &nbsp;throw new Error(`无法加载模板 ${type}/${path} 且默认模板也不可用`);
   &nbsp;}

   &nbsp; &nbsp;return loadTemplate(type, 'Default', true);
    }
}</pre>
</div>
<h3 data-id="heading-7">三 生成打印数据</h3>
<h4 data-id="heading-8">1 根据模板 + 打印数据 -&gt; 生成 html(支持二维码、条形码)</h4>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">
import JsBarcode from 'jsbarcode';
import { createApp, h } from 'vue';
import { isExternal } from "@/utils/validate";
import QRCode from 'qrcode';
// 1 生成条码图片
function generateBarcodeBase64(code) {
   &nbsp;if (!code) return '';
   &nbsp;const canvas = document.createElement('canvas');
   &nbsp;try {
   &nbsp; &nbsp;JsBarcode(canvas, code, {
   &nbsp; &nbsp; &nbsp;format: 'CODE128', &nbsp; &nbsp;// 条码格式 CODE128、EAN13、EAN8、UPC、CODE39、ITF、MSI...
   &nbsp; &nbsp; &nbsp;displayValue: false, &nbsp;// 是否显示条码值
   &nbsp; &nbsp; &nbsp;width: 2, &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; // 条码宽度
   &nbsp; &nbsp; &nbsp;height: 40, &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; // 条码高度 &nbsp;
   &nbsp; &nbsp; &nbsp;margin: 0, &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// 条码外边距
   &nbsp;});
   &nbsp; &nbsp;return canvas.toDataURL('image/png');
    } catch (err) {
   &nbsp; &nbsp;console.warn('条码生成失败:', err);
   &nbsp; &nbsp;return '';
    }
}

// 2 拼接图片路径
function getImageUrl(imgSrc) {
   &nbsp;if (!imgSrc) {
   &nbsp; &nbsp;return ''
    }
   &nbsp;try {
   &nbsp; &nbsp;const src = imgSrc.split(",").trim();
   &nbsp; &nbsp;// 2.1 判断图片路径是否为完整路径
   &nbsp; &nbsp;return isExternal(src) ? src : `${import.meta.env.VITE_APP_BASE_API}${src}`;
   &nbsp;} catch (err) {
   &nbsp; &nbsp;console.warn('图片路径拼接失败:', err);
   &nbsp; &nbsp;return '';
   &nbsp;}
}

// 更安全的QR码生成
async function generateQRCode(url) {
   &nbsp;if (!url) return '';

   &nbsp;try {
   &nbsp; &nbsp;return await QRCode.toDataURL(url.toString())
   &nbsp;} catch (err) {
   &nbsp; &nbsp;console.warn('QR码生成失败:', err);
   &nbsp; &nbsp;return '';
   &nbsp;}
}

/**
   * 3 打印模板渲染数据
   * @param {*} Component模板组件
   * @param {*} printData &nbsp;打印数据 &nbsp;
   * @returnshtml
   */
export default async function renderTemplate(Component, printData) {
   &nbsp;// 1. 数据验证和初始化
   &nbsp;if (!printData || typeof printData !== 'object') {
   &nbsp; &nbsp;throw new Error('Invalid data format');
   &nbsp;}

   &nbsp;// 2. 创建安全的数据副本
   &nbsp;const data = {
   &nbsp; &nbsp;...printData,
   &nbsp; &nbsp;tenant: {
   &nbsp; &nbsp; &nbsp;...printData.tenant,
   &nbsp; &nbsp; &nbsp;logo: printData?.tenant?.logo || '',
   &nbsp; &nbsp; &nbsp;logoImage: ''
   &nbsp; &nbsp;},
   &nbsp; &nbsp;invoice: {
   &nbsp; &nbsp; &nbsp;...printData.invoice,
   &nbsp; &nbsp; &nbsp;invoiceQr: printData?.invoice?.invoiceQr || '',
   &nbsp; &nbsp; &nbsp;invoiceQrImage: ''
   &nbsp; &nbsp;},
   &nbsp; &nbsp;detailList: Array.isArray(printData.detailList) ? printData.detailList : [],
   &nbsp; &nbsp;invoiceDetailList: Array.isArray(printData.invoiceDetailList) ? printData.invoiceDetailList : [],
   &nbsp;};

   &nbsp;// 3. 异步处理二维码和条码和logo
   &nbsp;try {
   &nbsp; &nbsp;// 3.1 处理二维码
   &nbsp; &nbsp;if (data.invoice.invoiceQr) {
   &nbsp; &nbsp; &nbsp;data.invoice.invoiceQrImage = await generateQRCode(data.invoice.invoiceQr);
   &nbsp; &nbsp;}
   &nbsp; &nbsp;// 3.2 处理条码
   &nbsp; &nbsp;if (data.detailList.length &gt; 0) {
   &nbsp; &nbsp; &nbsp;data.detailList = data.detailList.map(item =&gt; ({
   &nbsp; &nbsp; &nbsp; &nbsp;...item,
   &nbsp; &nbsp; &nbsp; &nbsp;skuCodeImg: item.skuCode ? generateBarcodeBase64(item.skuCode) : ''
   &nbsp; &nbsp; &nbsp;}));
   &nbsp; &nbsp;}
   &nbsp; &nbsp;// 3.3 处理LOGO
   &nbsp; &nbsp;if (data.tenant.logo) {
   &nbsp; &nbsp; &nbsp;data.tenant.logoImage = getImageUrl(data.tenant?.logo);
   &nbsp; &nbsp;}
   &nbsp;} catch (err) {
   &nbsp; &nbsp;console.error('数据处理失败:', err);
   &nbsp; &nbsp;// 即使部分数据处理失败也继续执行
   &nbsp;}


   &nbsp;// 4. 创建渲染容器
   &nbsp;const div = document.createElement('div');
   &nbsp;div.id = 'print-template-container';

   &nbsp;// 5. 使用Promise确保渲染完成
   &nbsp;return new Promise((resolve) =&gt; {
   &nbsp; &nbsp;const app = createApp({
   &nbsp; &nbsp; &nbsp;render: () =&gt; h(Component, { data })
   &nbsp; &nbsp;});

   &nbsp; &nbsp;// 6. 特殊处理:等待两个tick确保渲染完成
   &nbsp; &nbsp;app.mount(div);
   &nbsp; &nbsp;nextTick().then(() =&gt; {
   &nbsp; &nbsp; &nbsp;return nextTick(); // 双重确认
   &nbsp; &nbsp;}).then(() =&gt; {
   &nbsp; &nbsp; &nbsp;const html = div.innerHTML;
   &nbsp; &nbsp; &nbsp;app.unmount();
   &nbsp; &nbsp; &nbsp;div.remove();
   &nbsp; &nbsp; &nbsp;resolve(html);
   &nbsp; &nbsp;}).catch(err =&gt; {
   &nbsp; &nbsp; &nbsp;console.error('渲染失败:', err);
   &nbsp; &nbsp; &nbsp;app.unmount();
   &nbsp; &nbsp; &nbsp;div.remove();
   &nbsp; &nbsp; &nbsp;resolve('&lt;div&gt;渲染失败&lt;/div&gt;');
   &nbsp; &nbsp;});
   &nbsp;});
}
​</pre>
</div>
<h3 data-id="heading-9">四 封装打印</h3>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">
// 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 }) {

   &nbsp;// 1 根据类型调整默认参数
   &nbsp;if (templateType === PrintTemplateType.Ticket) {
   &nbsp; &nbsp;templateWidth = 80; // 热敏小票通常80mm宽
   &nbsp; &nbsp;templateHeight = 0; // 高度自动
    } else if (templateType === PrintTemplateType.Label) {
   &nbsp; &nbsp;templateWidth = templateWidth || 50; // 标签打印机常见宽度50mm
   &nbsp; &nbsp;templateHeight = templateHeight || 30; // 标签常见高度30mm
    }

   &nbsp;// 1. 创建打印专用容器
   &nbsp;const printContainer = document.createElement('div');
   &nbsp;printContainer.id = 'print-container';
   &nbsp;document.body.appendChild(printContainer);

   &nbsp;// 2. 注入打印控制样式(隐藏页眉页脚)
   &nbsp;const style = document.createElement('style');
   &nbsp;style.innerHTML = `
   &nbsp; &nbsp;/* 打印页面设置 */
   &nbsp; &nbsp;@page {
   &nbsp; &nbsp; &nbsp;margin: 0;/* 去除页边距 */
   &nbsp; &nbsp; &nbsp;size: ${templateWidth}mm ${templateHeight === 0 ? 'auto' : `${templateHeight}mm`};/* 自定义纸张尺寸 */
   &nbsp; &nbsp;}
   &nbsp; &nbsp;@media print {
   &nbsp; &nbsp; &nbsp;body, html {
   &nbsp; &nbsp; &nbsp; &nbsp;width: ${templateWidth}mm !important;
   &nbsp; &nbsp; &nbsp; &nbsp;margin: 0 !important;
   &nbsp; &nbsp; &nbsp; &nbsp;padding: 0 !important;
   &nbsp; &nbsp; &nbsp; &nbsp;background: #fff !important;/* 强制白色背景 */
   &nbsp; &nbsp; &nbsp;}
   &nbsp; &nbsp; &nbsp;
   &nbsp; &nbsp; &nbsp;/* 隐藏页面所有元素 */
   &nbsp; &nbsp; &nbsp;body * {
   &nbsp; &nbsp; &nbsp; &nbsp;visibility: hidden;
   &nbsp; &nbsp; &nbsp;}

   &nbsp; &nbsp; &nbsp;/* 只显示打印容器内容 */
   &nbsp; &nbsp; &nbsp;#print-container, #print-container * {
   &nbsp; &nbsp; &nbsp; &nbsp;visibility: visible; &nbsp;
   &nbsp; &nbsp; &nbsp;}

   &nbsp; &nbsp; &nbsp;/* 打印容器定位 */
   &nbsp; &nbsp; &nbsp;#print-container {
   &nbsp; &nbsp; &nbsp; &nbsp;position: absolute;
   &nbsp; &nbsp; &nbsp; &nbsp;left: 0;
   &nbsp; &nbsp; &nbsp; &nbsp;top: 0;
   &nbsp; &nbsp; &nbsp; &nbsp;width: ${templateWidth}mm !important;
   &nbsp; &nbsp; &nbsp; &nbsp;${templateHeight === 0 ? 'auto' : `height: ${templateHeight}mm !important;`}
   &nbsp; &nbsp; &nbsp; &nbsp;margin: 0 !important;
   &nbsp; &nbsp; &nbsp; &nbsp;padding: 0 !important;
   &nbsp; &nbsp; &nbsp; &nbsp;box-sizing: border-box;
   &nbsp; &nbsp; &nbsp; &nbsp;page-break-after: avoid;/* 避免分页 */
   &nbsp; &nbsp; &nbsp; &nbsp;page-break-inside: avoid;
   &nbsp; &nbsp; &nbsp;}
   &nbsp; &nbsp;}

   &nbsp; &nbsp;/* 屏幕预览样式 */
   &nbsp; &nbsp;#print-container {
   &nbsp; &nbsp; &nbsp;width: ${templateWidth}mm;
   &nbsp; &nbsp; &nbsp;${templateHeight === 0 ? 'auto' : `height: ${templateHeight}mm;`}
   &nbsp; &nbsp; &nbsp;// margin: 10px auto;
   &nbsp; &nbsp; &nbsp;// padding: 5mm;
   &nbsp; &nbsp; &nbsp;box-shadow: 0 0 5px rgba(0,0,0,0.2);
   &nbsp; &nbsp; &nbsp;background: white;
   &nbsp; &nbsp;}
   &nbsp;`;
   &nbsp;document.head.appendChild(style);

   &nbsp;// 3. 放入要打印的内容
   &nbsp;printContainer.innerHTML = html;

   &nbsp;// 4. 触发打印
   &nbsp;window.print();

   &nbsp;// 5. 清理(延迟确保打印完成)
   &nbsp;setTimeout(() =&gt; {
   &nbsp; &nbsp;document.body.removeChild(printContainer);
   &nbsp; &nbsp;document.head.removeChild(style);
    }, 1000);
}</pre>
</div>
<h3 data-id="heading-10">五 封装导出PDF</h3>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">
// /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, {
   &nbsp;filename,
   &nbsp;templateType = PrintTemplateType.Invoice,
   &nbsp;templateWidth = DEFAULT_WIDTH,
   &nbsp;templateHeight = DEFAULT_HEIGHT,
   &nbsp;allowPaging = true
}) {
   &nbsp;// 生成文件名
   &nbsp;const finalFilename = filename || `${templateType}_${Date.now()}.pdf`;
   &nbsp;// 处理宽度和高度,如果为0则使用默认值
   &nbsp;const widthMm = templateWidth === 0 ? DEFAULT_WIDTH : templateWidth;
   &nbsp;// 分页模式使用A4高度,单页模式自动高度
   &nbsp;const heightMm = templateHeight === 0 ? (allowPaging ? DEFAULT_HEIGHT : 'auto') : templateHeight;

   &nbsp;// 创建临时容器
   &nbsp;const container = document.createElement('div');
   &nbsp;container.style.position = 'absolute'; &nbsp; &nbsp;// 使容器脱离正常文档流
   &nbsp;container.style.left = '-9999px'; &nbsp; &nbsp; &nbsp; &nbsp; // 移出可视区域,避免在页面上显示
   &nbsp;container.style.width = `${widthMm}mm`; &nbsp; // 容器宽度
   &nbsp;container.style.height = 'auto'; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// 让内容决定高度
   &nbsp;container.style.overflow = 'visible'; &nbsp; &nbsp; // 溢出部分不被裁剪
   &nbsp;container.innerHTML = html; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; // 添加HTML内容
   &nbsp;document.body.appendChild(container); &nbsp; &nbsp; // 将准备好的临时容器添加到文档中

   &nbsp;try {
   &nbsp; &nbsp;if (allowPaging) {
   &nbsp; &nbsp; &nbsp;console.log('导出PDF - 分页处理模式');
   &nbsp; &nbsp; &nbsp;const pdf = new jsPDF({
   &nbsp; &nbsp; &nbsp; &nbsp;orientation: 'portrait',
   &nbsp; &nbsp; &nbsp; &nbsp;unit: 'mm',
   &nbsp; &nbsp; &nbsp; &nbsp;format:
   &nbsp; &nbsp;});

   &nbsp; &nbsp; &nbsp;// 获取所有页面或使用容器作为单页
   &nbsp; &nbsp; &nbsp;const pageElements = container.querySelectorAll('.page');
   &nbsp; &nbsp; &nbsp;const pages = pageElements.length &gt; 0 ? pageElements : ;

   &nbsp; &nbsp; &nbsp;for (let i = 0; i &lt; pages.length; i++) {
   &nbsp; &nbsp; &nbsp; &nbsp;const page = pages;
   &nbsp; &nbsp; &nbsp; &nbsp;page.style.backgroundColor = 'white';

   &nbsp; &nbsp; &nbsp; &nbsp;// 计算页面高度(像素)
   &nbsp; &nbsp; &nbsp; &nbsp;const pageHeightPx = page.scrollHeight;
   &nbsp; &nbsp; &nbsp; &nbsp;const pageHeightMm = pageHeightPx / MM_TO_PX;

   &nbsp; &nbsp; &nbsp; &nbsp;const canvas = await html2canvas(page, {
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;scale: 2,
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;useCORS: true, &nbsp;// 启用跨域访问
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;backgroundColor: '#FFFFFF',
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;logging: true,
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;width: widthMm * MM_TO_PX, &nbsp;// 画布 宽度转换成像素
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;height: pageHeightPx, &nbsp; &nbsp; &nbsp; // 画布 高度转换成像素
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;windowWidth: widthMm * MM_TO_PX, &nbsp; &nbsp;// 模拟视口 宽度转换成像素
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;windowHeight: pageHeightPx &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// 模拟视口 高度转换成像素
   &nbsp; &nbsp; &nbsp;});

   &nbsp; &nbsp; &nbsp; &nbsp;const imgData = canvas.toDataURL('image/png');
   &nbsp; &nbsp; &nbsp; &nbsp;const imgWidth = widthMm;
   &nbsp; &nbsp; &nbsp; &nbsp;const imgHeight = (canvas.height * imgWidth) / canvas.width;

   &nbsp; &nbsp; &nbsp; &nbsp;if (i &gt; 0) {
   &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;pdf.addPage(, 'portrait');
   &nbsp; &nbsp; &nbsp;}

   &nbsp; &nbsp; &nbsp; &nbsp;pdf.addImage(imgData, 'PNG', 0, 0, imgWidth, imgHeight);
   &nbsp; &nbsp;}

   &nbsp; &nbsp; &nbsp;pdf.save(finalFilename);
   &nbsp;} else {
   &nbsp; &nbsp; &nbsp;console.log('导出PDF - 单页处理模式');
   &nbsp; &nbsp; &nbsp;const canvas = await html2canvas(container, {
   &nbsp; &nbsp; &nbsp; &nbsp;scale: 2,
   &nbsp; &nbsp; &nbsp; &nbsp;useCORS: true,
   &nbsp; &nbsp; &nbsp; &nbsp;backgroundColor: '#FFFFFF',
   &nbsp; &nbsp; &nbsp; &nbsp;logging: true,
   &nbsp; &nbsp; &nbsp; &nbsp;width: widthMm * MM_TO_PX,
   &nbsp; &nbsp; &nbsp; &nbsp;height: container.scrollHeight,
   &nbsp; &nbsp; &nbsp; &nbsp;windowWidth: widthMm * MM_TO_PX,
   &nbsp; &nbsp; &nbsp; &nbsp;windowHeight: container.scrollHeight
   &nbsp; &nbsp;});

   &nbsp; &nbsp; &nbsp;const imgData = canvas.toDataURL('image/png');
   &nbsp; &nbsp; &nbsp;const imgWidth = widthMm;
   &nbsp; &nbsp; &nbsp;const imgHeight = (canvas.height * imgWidth) / canvas.width;

   &nbsp; &nbsp; &nbsp;const pdf = new jsPDF({
   &nbsp; &nbsp; &nbsp; &nbsp;orientation: imgWidth &gt; imgHeight ? 'landscape' : 'portrait',
   &nbsp; &nbsp; &nbsp; &nbsp;unit: 'mm',
   &nbsp; &nbsp; &nbsp; &nbsp;format:
   &nbsp; &nbsp;});

   &nbsp; &nbsp; &nbsp;pdf.addImage(imgData, 'PNG', 0, 0, imgWidth, imgHeight);
   &nbsp; &nbsp; &nbsp;pdf.save(finalFilename);
   &nbsp;}
    } catch (error) {
   &nbsp; &nbsp;console.error('PDF导出失败:', error);
   &nbsp; &nbsp;throw error;
    } finally {
   &nbsp; &nbsp;document.body.removeChild(container);
    }
}</pre>
</div>
<h3 data-id="heading-11">六 测试打印</h3>
<h4 data-id="heading-12">1 封装打印预览界面</h4>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">方便调试模板,此处就不提供预览界面的代码里,自己手搓吧!</pre>
</div>
<h4 data-id="heading-13">2 使用浏览器默认打印</h4>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">1 查看打印预览,正常打印预览与预期一样;
2 擦和看打印结果;</pre>
</div>
<h4 data-id="heading-14">3 注意事项</h4>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">1 涉及的模板尺寸 与 打印纸张的尺寸 要匹配;
    -- 否则预览界面异常、打印结果异常;
2 处理自动分页,页眉页脚留够空间,否则会覆盖;
3 有些打印机调试需要设置打印机的首选项,主要设置尺寸!</pre>
</div>
<h3 data-id="heading-15">七 问题解决</h3>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">// 1 打印预览样式与模板不一致
    -- 检查 @media print{} 这里的样式,
    -- 分别检查模板 和 打印封装;
   
// 2 打印预览异常、打印正常
    -- 问题原因:打印机纸张尺寸识别异常,即打印机当前设置的尺寸与模板尺寸不一致;
    -- 解决办法:设置打印机 -&gt; 首选项 -&gt; 添加尺寸设置;
   
// 3 打印机实测:
    -- 目前A4打印机、80热敏打印机、标签打印机 都有测试,没有问题!
    -- 如果字体很丑,建议选择等宽字体;
    -- 调节字体尺寸、颜色、尽可能美观、节省纸张!
   
// 4 进一步封装
    -- 项目中可以进一步封装打印,向所有流程封装到一个service中,打印只需要传递 printData、templateType;
    -- 可以封装批量打印;
    -- 模板可以根据用户自定义配置,通过pinia维护状态;
   
// 5 后端来实现打印数据生成
    -- 我是前端能做的尽可能不放到后端处理,减少后端请求处理压力!
    </pre>
</div>
<div>
<h2>本文转载于:https://juejin.cn/post/7521356618174021674</h2>
</div>
<h3 id="tid-D8HBxE">如果对您有所帮助,欢迎您点个关注,我会定时更新技术文档,大家一起讨论学习,一起进步。</h3>
<p><em><img src="https://img2024.cnblogs.com/blog/2149129/202501/2149129-20250122165814748-630765389.png" alt="" loading="lazy"></em></p><br><br>
来源:https://www.cnblogs.com/smileZAZ/p/19101283
頁: [1]
查看完整版本: 记录---vue3项目实战 打印、导出PDF