記錄---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
如果對您有所幫助,歡迎您點個關注,我會定時更新技術文檔,大家一起討論學習,一起進步。


浙公網安備 33010602011771號