最新国产好看的视频,伊人天堂AV在线,国产Aaaaaa视频,蜜臀视频在线观看一区,人妻av色图,密臀久久久精品影片,青青视频免费观看毛片,久草在线观看视,国产三级精品色情在线

Vue前端導(dǎo)出頁(yè)面為PDF文件的最佳方案

 更新時(shí)間:2025年07月23日 10:07:34   作者:就胡編亂碼  
這篇文章主要介紹了前端導(dǎo)出PDF方案,通過(guò)html2canvas和jsPDF實(shí)現(xiàn)單頁(yè)導(dǎo)出,利用iframe分批處理列表頁(yè)數(shù)據(jù)并打包ZIP,兼顧性能與樣式還原,有效減輕服務(wù)端壓力,需要的朋友可以參考下

前言

小編最近遇到一個(gè)需求,要把前端渲染出來(lái)的頁(yè)面完整的導(dǎo)出為PDF格式,最開(kāi)始的方案是想在服務(wù)端導(dǎo)出,使用Freemarker或者Thymeleaf模板引擎,但是頁(yè)面實(shí)在是有點(diǎn)復(fù)雜,開(kāi)發(fā)起來(lái)比較費(fèi)勁,最終還是尋找前端導(dǎo)出PDF的方案。其實(shí)前端導(dǎo)出反而更好,可以減輕服務(wù)器端的壓力,導(dǎo)出來(lái)的樣式也更好看,給各位看下,筆者要導(dǎo)出的頁(yè)面,內(nèi)容還是挺多的吧。

一、導(dǎo)出工具類(lèi)

下面直接展示PDF導(dǎo)出工具類(lèi)

import html2canvas from 'html2canvas';
import { jsPDF } from 'jspdf';

export default {
	/**
	 * 將HTML元素導(dǎo)出為PDF
	 * @param element 需要導(dǎo)出的DOM元素
	 * @param fileName 導(dǎo)出的文件名
	 */
	async exportElementToPdf(element: HTMLElement, fileName: string = 'document'): Promise<void> {
		if (!element) {
			console.error('導(dǎo)出元素不能為空');
			return;
		}

		try {
			// 處理textarea元素,臨時(shí)替換為div以確保內(nèi)容完整顯示
			const textareas = Array.from(element.querySelectorAll('textarea'));
			const originalStyles: { [key: string]: string } = {};
			const replacedElements: HTMLElement[] = [];

			// 處理滾動(dòng)區(qū)域
			const scrollElements = element.querySelectorAll('[style*="overflow"],[style*="height"]');
			const originalScrollStyles: { [key: string]: string } = {};

			scrollElements.forEach((el, index) => {
				const computedStyle = window.getComputedStyle(el);
				if (computedStyle.overflow === 'auto' || computedStyle.overflow === 'scroll' ||
					computedStyle.overflowY === 'auto' || computedStyle.overflowY === 'scroll') {
					originalScrollStyles[index] = (el as HTMLElement).style.cssText;
					(el as HTMLElement).style.overflow = 'visible';
					(el as HTMLElement).style.maxHeight = 'none';
					(el as HTMLElement).style.height = 'auto';
				}
			});

			// 替換所有textarea為div,保留內(nèi)容和樣式
			textareas.forEach((textarea, index) => {
				// 保存原始樣式
				originalStyles[index] = textarea.style.cssText;

				// 創(chuàng)建替代元素
				const replacementDiv = document.createElement('div');
				replacementDiv.innerHTML = textarea.value.replace(/\n/g, '
');
				replacementDiv.style.cssText = textarea.style.cssText;
				replacementDiv.style.height = 'auto'; // 確保高度自適應(yīng)內(nèi)容
				replacementDiv.style.minHeight = window.getComputedStyle(textarea).height;
				replacementDiv.style.border = window.getComputedStyle(textarea).border;
				replacementDiv.style.padding = window.getComputedStyle(textarea).padding;
				replacementDiv.style.boxSizing = 'border-box';
				replacementDiv.style.whiteSpace = 'pre-wrap';
				replacementDiv.style.overflowY = 'visible';

				// 替換元素
				textarea.parentNode?.insertBefore(replacementDiv, textarea);
				textarea.style.display = 'none';
				replacedElements.push(replacementDiv);
			});

			// 預(yù)加載所有圖片的增強(qiáng)方法
			const preloadImages = async () => {
				// 查找所有圖片元素
				const images = Array.from(element.querySelectorAll('img'));

				// 記錄原始的src屬性
				const originalSrcs = images.map(img => img.src);

				// 確保所有圖片都完全加載
				await Promise.all(
					images.map((img, index) => {
						return new Promise<void>((resolve) => {
							// 如果圖片已經(jīng)完成加載,直接解析
							if (img.complete && img.naturalHeight !== 0) {
								resolve();
								return;
							}

							// 為每個(gè)圖片添加加載和錯(cuò)誤事件監(jiān)聽(tīng)器
							const onLoad = () => {
								img.removeEventListener('load', onLoad);
								img.removeEventListener('error', onError);
								resolve();
							};

							const onError = () => {
								console.warn(`無(wú)法加載圖片: ${img.src}`);
								img.removeEventListener('load', onLoad);
								img.removeEventListener('error', onError);

								// 嘗試重新加載圖片
								const newImg = new Image();
								newImg.crossOrigin = "Anonymous";
								newImg.onload = () => {
									img.src = originalSrcs[index]; // 恢復(fù)原始src
									resolve();
								};
								newImg.onerror = () => {
									img.src = originalSrcs[index]; // 恢復(fù)原始src
									resolve(); // 即使失敗也繼續(xù)執(zhí)行
								};

								// 強(qiáng)制重新加載
								const src = img.src;
								img.src = '';
								setTimeout(() => {
									newImg.src = src;
								}, 100);
							};

							img.addEventListener('load', onLoad);
							img.addEventListener('error', onError);

							// 如果圖片沒(méi)有src或src是數(shù)據(jù)URL,直接解析
							if (!img.src || img.src.startsWith('data:')) {
								resolve();
							}
						});
					})
				);
			};

			// 預(yù)加載所有圖片
			await preloadImages();

			// 使用html2canvas將整個(gè)元素轉(zhuǎn)為單個(gè)canvas
			const canvas = await html2canvas(element, {
				scale: 2, // 提高清晰度
				useCORS: true, // 允許加載跨域圖片
				logging: false,
				allowTaint: true, // 允許污染畫(huà)布
				backgroundColor: '#ffffff', // 設(shè)置背景色為白色
				imageTimeout: 15000, // 增加圖片加載超時(shí)時(shí)間到15秒
				onclone: (documentClone) => {
					// 在克隆的文檔中查找所有圖片
					const clonedImages = documentClone.querySelectorAll('img');

					// 確保所有圖片都設(shè)置了crossOrigin屬性
					clonedImages.forEach(img => {
						img.crossOrigin = "Anonymous";

						// 對(duì)于數(shù)據(jù)URL的圖片跳過(guò)
						if (img.src && !img.src.startsWith('data:')) {
							// 添加時(shí)間戳以避免緩存問(wèn)題
							if (img.src.indexOf('?') === -1) {
								img.src = `${img.src}?t=${new Date().getTime()}`;
							} else {
								img.src = `${img.src}&t=${new Date().getTime()}`;
							}
						}
					});

					return documentClone;
				}
			});

			// 恢復(fù)原始DOM,移除臨時(shí)添加的元素
			textareas.forEach((textarea, index) => {
				textarea.style.cssText = originalStyles[index];
				textarea.style.display = '';
				if (replacedElements[index] && replacedElements[index].parentNode) {
					replacedElements[index].parentNode.removeChild(replacedElements[index]);
				}
			});

			// 恢復(fù)滾動(dòng)區(qū)域的樣式
			scrollElements.forEach((el, index) => {
				if (originalScrollStyles[index]) {
					(el as HTMLElement).style.cssText = originalScrollStyles[index];
				}
			});

			// 創(chuàng)建PDF(使用適合內(nèi)容的尺寸)
			// 如果內(nèi)容寬高比接近A4,使用A4;否則使用自定義尺寸
			const imgWidth = 210; // A4寬度(mm)
			const imgHeight = (canvas.height * imgWidth) / canvas.width;

			// 使用一頁(yè)完整顯示內(nèi)容,不強(qiáng)制分頁(yè)
			const pdf = new jsPDF({
				orientation: imgHeight > 297 ? 'p' : 'p', // 如果內(nèi)容高度超過(guò)A4高度,使用縱向
				unit: 'mm',
				format: imgHeight > 297 ? [imgWidth, imgHeight] : 'a4' // 如果內(nèi)容高度超過(guò)A4高度,使用自定義尺寸
			});

			// 添加圖像到PDF,確保填滿頁(yè)面但保持比例
			pdf.addImage(
				canvas.toDataURL('image/jpeg', 1.0), // 使用高質(zhì)量
				'JPEG',
				0,
				0,
				imgWidth,
				imgHeight
			);

			// 保存PDF
			pdf.save(`${fileName}.pdf`);
		} catch (error) {
			console.error('導(dǎo)出PDF時(shí)發(fā)生錯(cuò)誤:', error);
		}
	}
};

這個(gè) 工具類(lèi)考慮了導(dǎo)出的html頁(yè)面中的圖片和text滾動(dòng)文本框,使得導(dǎo)出來(lái)的PDF文件能夠完整展示原HTML頁(yè)面內(nèi)容,基本能做到95%以上的還原吧,導(dǎo)出的格式是A4紙張大小,方便打印出來(lái)。

二、單頁(yè)面詳情導(dǎo)出

比如說(shuō)我現(xiàn)在有個(gè)頁(yè)面叫detail.vue,頁(yè)面模板部分如下

<template >
	<div
		class="reports-detail-page"
		v-if="reportDetail"
		ref="weekReportRef"

	>
		<img
			src="/icon/read.png"
			class="read-mark"
			:class="{
				'read-mark-mobile': mainStates.isMobile,
			}"
			alt="已審批"
			v-if="reportDetail.weekReports.status === 1"
		/>
		<el-button  class="export-pdf" type="primary" v-if="!isImporting" size="small" @click="downloadPdf">導(dǎo)出PDF</el-button>
		<week-report
			:is-plan="false"
			v-if="reportDetail.lastWeekReports"
			:week-report="reportDetail.lastWeekReports"
			:self-comments="reportDetail.weekReportsSelfCommentsList"
		/>
		<week-report
			:is-plan="true"
			:week-report="reportDetail.weekReports"
			:self-comments="reportDetail.weekReportsSelfCommentsList"
		/>
		<comment-area :is-importing="isImporting" :report-detail="reportDetail" />
	</div>
</template>

這里的關(guān)鍵屬性是ref=“weekReportRef”,其聲明定義如下:

const weekReportRef = ref<HTMLElement | null>(null);

在Vue 3中,ref是一個(gè)非常重要的響應(yīng)式API,它有兩種主要用途:

  1. 在腳本中創(chuàng)建響應(yīng)式變量:通過(guò)ref()函數(shù)創(chuàng)建一個(gè)響應(yīng)式引用
  2. 在模板中引用DOM元素或組件實(shí)例:通過(guò)在模板元素上添加ref屬性

這里主要是利用了第二點(diǎn),代表了當(dāng)前組件的渲染實(shí)例,導(dǎo)出PDF按鈕對(duì)應(yīng)的方法如下:

// 下載PDF
const downloadPdf = async () => {
	if (!weekReportRef.value) return;

	isImporting.value = true;

	// 創(chuàng)建文件名,例如:張三_2025年第28周_總結(jié)
	const fileName = `${reportDetail.value?.weekReports.userName}${weekDesc.value}周報(bào)`;

	ElLoading.service({
		lock: true,
		text: '正在導(dǎo)出PDF,請(qǐng)稍后...',
		spinner: 'el-icon-loading',
		background: 'rgba(0, 0, 0, 0.7)',
	});
	try {
		// 使用nextTick等待DOM更新完成
		await nextTick();
		await PdfExportUtils.exportElementToPdf(weekReportRef.value, fileName).then(()=>{
			isImporting.value = false;
			ElLoading.service().close();
		});
	} catch (error) {
		console.error('導(dǎo)出PDF失敗', error);
	}
};

通過(guò)以上代碼,可以看到在調(diào)用導(dǎo)出PDF時(shí),傳入了當(dāng)前的組件的實(shí)例,其中isImporting這個(gè)屬性,是筆者為了限制某些按鈕什么的控件不要在導(dǎo)出后的PDF文件中顯示而添加的臨時(shí)屬性。

三、列表頁(yè)批量壓縮導(dǎo)出

上面說(shuō)的是單頁(yè)面導(dǎo)出PDF,那如果有個(gè)列表頁(yè),需要批量選擇然后導(dǎo)出怎么辦?導(dǎo)出過(guò)程中,又沒(méi)辦法一個(gè)個(gè)點(diǎn)進(jìn)去等待數(shù)據(jù)渲染。前輩大佬早就想到了這個(gè)場(chǎng)景,我們可以利用html中的標(biāo)簽iframe,在批量選擇導(dǎo)出時(shí),為每一個(gè)列表數(shù)據(jù)臨時(shí)創(chuàng)建一個(gè)渲染后的詳情頁(yè)面數(shù)據(jù),即Dom中的Dom,然后對(duì)嵌套頁(yè)面導(dǎo)出壓縮,當(dāng)然我們用戶自己是感知不到的。比如下面的列表:

以下代碼是針對(duì)勾選數(shù)據(jù)的定義和響應(yīng)式綁定

const selectedRows = ref<WeekReportsDetail[]>([]);
// 處理表格選擇變化
const handleSelectionChange = (selection: WeekReportsDetail[]) => {
	selectedRows.value = selection;
};

批量導(dǎo)出壓縮PDF文件的代碼如下,比較復(fù)雜,僅供參考:

// 導(dǎo)出選中項(xiàng)到PDF并壓縮
const exportSelectedToPdf = async () => {
	if (selectedRows.value.length === 0) {
		ElNotification({
			title: '提示',
			message: '請(qǐng)先選擇要導(dǎo)出的周報(bào)',
			type: 'warning',
		});
		return;
	}

	// 顯示加載中提示
	const loading = ElLoading.service({
		lock: true,
		text: `正在準(zhǔn)備導(dǎo)出...`,
		spinner: 'el-icon-loading',
		background: 'rgba(0, 0, 0, 0.7)',
	});

	try {
		// 創(chuàng)建ZIP實(shí)例
		const zip = new JSZip();
		const allPdfResults: { fileName: string, pdfBlob: Blob }[] = [];

		// 定義批處理大小和函數(shù)
		const batchSize = 5; // 每批處理的數(shù)量,可以根據(jù)實(shí)際情況調(diào)整

		// 批量處理函數(shù)
		const processBatch = async (batchReports: WeekReportsDetail[]) => {
			const batchPromises = batchReports.map((report) => {
				return new Promise<{fileName: string, pdfBlob: Blob}>(async (resolve, reject) => {
					try {
						const overall = selectedRows.value.indexOf(report) + 1;
						loading.setText(`正在導(dǎo)出第 ${overall}/${selectedRows.value.length} 個(gè)周報(bào)...`);

						const iframe = document.createElement('iframe');
						iframe.style.position = 'fixed';
						iframe.style.left = '0';
						iframe.style.top = '0';
						iframe.style.width = '1024px';
						iframe.style.height = '768px';
						iframe.style.border = 'none';
						iframe.style.zIndex = '-1';
						iframe.style.opacity = '0.01'; // 幾乎不可見(jiàn)但會(huì)渲染

						// 加載詳情頁(yè)面的URL
						iframe.src = `${window.location.origin}/center/detail/${report.id}?corpId=${mainStates.corpId}&isImporting=true`;
						document.body.appendChild(iframe);

						// 使用Promise包裝iframe加載和處理
						let retryCount = 0;
						const maxRetries = 2;

						while (retryCount <= maxRetries) {
							try {
								await new Promise<void>((resolveIframe, rejectIframe) => {
									// 設(shè)置超時(shí)
									const timeoutId = setTimeout(() => {
										rejectIframe(new Error('加載超時(shí)'));
									}, 15000); // 15秒超時(shí)

									iframe.onload = async () => {
										clearTimeout(timeoutId);

										try {
											// 給頁(yè)面充分的時(shí)間加載數(shù)據(jù)和渲染
											await new Promise(r => setTimeout(r, 3000));
											const iframeDocument = iframe.contentDocument || iframe.contentWindow?.document;
											if (!iframeDocument) {
												resolveIframe();
												return;
											}
											const reportElement = iframeDocument.querySelector('.reports-detail-page');
											if (!reportElement) {
												resolveIframe();
												return;
											}

											// 處理iframe中的所有textarea和滾動(dòng)區(qū)域
											const iframeTextareas = Array.from(reportElement.querySelectorAll('textarea'));
											const replacedElements: HTMLElement[] = [];

											// 替換所有textarea為div
											iframeTextareas.forEach((textarea) => {
												const replacementDiv = document.createElement('div');
												replacementDiv.innerHTML = textarea.value.replace(/\n/g, '
');
												replacementDiv.style.cssText = textarea.style.cssText;
												replacementDiv.style.height = 'auto';
												replacementDiv.style.minHeight = window.getComputedStyle(textarea).height;
												replacementDiv.style.boxSizing = 'border-box';
												replacementDiv.style.whiteSpace = 'pre-wrap';
												replacementDiv.style.overflowY = 'visible';

												textarea.parentNode?.insertBefore(replacementDiv, textarea);
												textarea.style.display = 'none';
												replacedElements.push(replacementDiv);
											});

											// 處理滾動(dòng)區(qū)域
											const scrollElements = reportElement.querySelectorAll('[style*="overflow"],[style*="height"]');
											scrollElements.forEach((el) => {
												const computedStyle = window.getComputedStyle(el);
												if (computedStyle.overflow === 'auto' || computedStyle.overflow === 'scroll' ||
													computedStyle.overflowY === 'auto' || computedStyle.overflowY === 'scroll') {
													(el as HTMLElement).style.overflow = 'visible';
													(el as HTMLElement).style.maxHeight = 'none';
													(el as HTMLElement).style.height = 'auto';
												}
											});

											// 預(yù)加載所有圖片
											const images = Array.from(reportElement.querySelectorAll('img'));
											await Promise.all(
												images.map(img => {
													return new Promise<void>((resolveImg) => {
														if (img.complete && img.naturalHeight !== 0) {
															resolveImg();
															return;
														}

														const onLoad = () => {
															img.removeEventListener('load', onLoad);
															img.removeEventListener('error', onError);
															resolveImg();
														};

														const onError = () => {
															console.warn(`無(wú)法加載圖片: ${img.src}`);
															img.removeEventListener('load', onLoad);
															img.removeEventListener('error', onError);
															resolveImg();
														};

														img.addEventListener('load', onLoad);
														img.addEventListener('error', onError);

														// 如果圖片沒(méi)有src或src是數(shù)據(jù)URL,直接解析
														if (!img.src || img.src.startsWith('data:')) {
															resolveImg();
														} else {
															// 添加時(shí)間戳以避免緩存問(wèn)題
															const currentSrc = img.src;
															img.src = '';
															setTimeout(() => {
																if (currentSrc.indexOf('?') === -1) {
																	img.src = `${currentSrc}?t=${new Date().getTime()}`;
																} else {
																	img.src = `${currentSrc}&t=${new Date().getTime()}`;
																}
															}, 50);
														}
													});
												})
											);

											// 等待額外時(shí)間確保渲染完成
											await new Promise(r => setTimeout(r, 1000));

											// 創(chuàng)建周報(bào)文件名
											const weekDesc = DateTimeUtils.getWeekDescByYearAndWeek({
												weekIndex: report.weekIndex,
												yearIndex: report.year,
											});
											const fileName = `${report.userName}_${weekDesc}周報(bào).pdf`;

											// 使用html2canvas轉(zhuǎn)換為canvas
											const canvas = await html2canvas(reportElement as HTMLElement, {
												scale: 2,
												useCORS: true,
												logging: false,
												allowTaint: true,
												backgroundColor: '#ffffff',
												imageTimeout: 15000, // 增加超時(shí)時(shí)間
											});

											// 從canvas創(chuàng)建PDF
											const imgWidth = 210; // A4寬度(mm)
											const imgHeight = (canvas.height * imgWidth) / canvas.width;
											const pdf = new jsPDF({
												orientation: imgHeight > 297 ? 'p' : 'p',
												unit: 'mm',
												format: imgHeight > 297 ? [imgWidth, imgHeight] : 'a4',
											});

											pdf.addImage(
												canvas.toDataURL('image/jpeg', 1.0),
												'JPEG',
												0,
												0,
												imgWidth,
												imgHeight,
											);

											// 獲取PDF的Blob
											const pdfBlob = pdf.output('blob');

											// 恢復(fù)iframe中的DOM
											iframeTextareas.forEach((textarea, index) => {
												textarea.style.display = '';
												if (replacedElements[index] && replacedElements[index].parentNode) {
													replacedElements[index].parentNode.removeChild(replacedElements[index]);
												}
											});

											// 解析PDF處理結(jié)果
											resolveIframe();

											// 直接添加到ZIP
											zip.file(fileName, pdfBlob);
											resolve({ fileName, pdfBlob });

										} catch (error) {
											console.error('處理PDF時(shí)出錯(cuò):', error);
											rejectIframe(error);
										}
									};

									iframe.onerror = () => {
										clearTimeout(timeoutId);
										rejectIframe(new Error('iframe加載失敗'));
									};
								});

								// 如果成功處理了,跳出重試循環(huán)
								break;

							} catch (error) {
								retryCount++;
								console.warn(`處理PDF失敗,正在重試(${retryCount}/${maxRetries})...`, error);

								// 如果已經(jīng)達(dá)到最大重試次數(shù),則放棄這個(gè)報(bào)告
								if (retryCount > maxRetries) {
									console.error(`無(wú)法處理周報(bào) ${report.id},已達(dá)到最大重試次數(shù)`);
									// 創(chuàng)建一個(gè)空白PDF表示失敗
									const weekDesc = DateTimeUtils.getWeekDescByYearAndWeek({
										weekIndex: report.weekIndex,
										yearIndex: report.year,
									});
									const fileName = `${report.userName}_${weekDesc}周報(bào)(處理失敗).pdf`;

									// 創(chuàng)建一個(gè)簡(jiǎn)單的錯(cuò)誤PDF
									const pdf = new jsPDF();
									pdf.setFontSize(16);
									pdf.text('處理此周報(bào)時(shí)出錯(cuò)', 20, 20);
									pdf.setFontSize(12);
									pdf.text(`用戶: ${report.userName}`, 20, 40);
									pdf.text(`周報(bào)ID: ${report.id}`, 20, 50);
									pdf.text(`時(shí)間: ${weekDesc}`, 20, 60);
									pdf.text(`錯(cuò)誤信息: ${error || '未知錯(cuò)誤'}`, 20, 70);

									const errorPdfBlob = pdf.output('blob');
									zip.file(fileName, errorPdfBlob);
									resolve({ fileName, pdfBlob: errorPdfBlob });
									break;
								}

								// 等待一段時(shí)間再重試
								await new Promise(r => setTimeout(r, 2000));
							}
						}

						// 移除iframe
						if (document.body.contains(iframe)) {
							document.body.removeChild(iframe);
						}

					} catch (error) {
						console.error('PDF生成失敗:', error);
						reject(error);
					}
				});
			});

			// 處理當(dāng)前批次
			return await Promise.allSettled(batchPromises);
		};

		// 將報(bào)告分成多個(gè)批次
		const reportBatches: WeekReportsDetail[][] = [];
		for (let i = 0; i < selectedRows.value.length; i += batchSize) {
			reportBatches.push(selectedRows.value.slice(i, i + batchSize));
		}

		// 逐批處理
		for (let i = 0; i < reportBatches.length; i++) {
			loading.setText(`正在處理第 ${i+1}/${reportBatches.length} 批周報(bào)...`);
			const batchResults = await processBatch(reportBatches[i]);

			// 將結(jié)果添加到總結(jié)果中
			batchResults.forEach(result => {
				if (result.status === 'fulfilled') {
					allPdfResults.push(result.value);
				}
			});

			// 釋放一些內(nèi)存
			await new Promise(r => setTimeout(r, 500));
		}

		// 生成ZIP文件
		loading.setText('正在生成ZIP文件...');

		// 生成并下載ZIP文件
		const zipBlob = await zip.generateAsync({type: 'blob'});
		const zipUrl = URL.createObjectURL(zipBlob);
		const link = document.createElement('a');
		link.href = zipUrl;
		link.download = `周報(bào)匯總_${new Date().getTime()}.zip`;
		link.click();
		URL.revokeObjectURL(zipUrl);

		ElNotification({
			title: '導(dǎo)出成功',
			message: `已將${allPdfResults.length}個(gè)周報(bào)導(dǎo)出為ZIP壓縮文件`,
			type: 'success',
		});
	} catch (error) {
		console.error('導(dǎo)出PDF時(shí)發(fā)生錯(cuò)誤:', error);
		ElNotification({
			title: '導(dǎo)出失敗',
			message: '導(dǎo)出PDF時(shí)發(fā)生錯(cuò)誤,請(qǐng)稍后再試',
			type: 'error',
		});
	} finally {
		loading.close();
	}
};

執(zhí)行流程與關(guān)鍵步驟

  1. 前置校驗(yàn)與初始化
  • 選中項(xiàng)校驗(yàn):首先檢查 selectedRows(選中的周報(bào)數(shù)組)是否為空,若為空則通過(guò) ElNotification 顯示警告提示(“請(qǐng)先選擇要導(dǎo)出的周報(bào)”),直接終止流程。
  • 加載提示初始化:通過(guò) ElLoading.service 創(chuàng)建全屏加載提示,顯示 “正在準(zhǔn)備導(dǎo)出…”,鎖定頁(yè)面交互以避免重復(fù)操作。
  1. 批量處理機(jī)制
    為避免一次性處理過(guò)多數(shù)據(jù)導(dǎo)致瀏覽器性能問(wèn)題,采用分批處理策略:
  • 批處理配置:定義 batchSize = 5(每批處理 5 個(gè)周報(bào),可按需調(diào)整),將選中的周報(bào)數(shù)組拆分為多個(gè)批次(reportBatches)。
  • 逐批處理:通過(guò)循環(huán)逐個(gè)處理每個(gè)批次,每批處理完成后等待 500ms 釋放內(nèi)存,降低瀏覽器資源占用。
  1. 單批周報(bào)處理(核心邏輯)
    每批周報(bào)通過(guò) processBatch 函數(shù)處理,單個(gè)周報(bào)的轉(zhuǎn)換流程如下:
  • 創(chuàng)建隱藏 iframe:動(dòng)態(tài)生成一個(gè)不可見(jiàn)的 iframe(定位在頁(yè)面外,透明度 0.01),用于加載周報(bào)詳情頁(yè)(/center/detail/${report.id})。iframe 的作用是隔離詳情頁(yè)環(huán)境,避免直接操作當(dāng)前頁(yè)面 DOM 導(dǎo)致沖突。
  • iframe 加載與重試機(jī)制:
    • 為 iframe 設(shè)置 15 秒超時(shí)時(shí)間,若加載失敗則重試(最多重試 2 次),避免因網(wǎng)絡(luò)或資源加載問(wèn)題導(dǎo)致單個(gè)周報(bào)處理失敗。
    • 加載完成后等待 3 秒,確保詳情頁(yè)數(shù)據(jù)和樣式完全渲染。
  • DOM 預(yù)處理(確保 PDF 內(nèi)容完整):
    • 替換 textarea:將詳情頁(yè)中的 textarea 替換為 div(保留原樣式),因?yàn)?textarea 的滾動(dòng)特性可能導(dǎo)致內(nèi)容截?cái)?,替換后可完整顯示所有文本。
    • 處理滾動(dòng)區(qū)域:將帶有 overflow: auto/scroll 或固定高度的元素改為 overflow: visible 且 maxHeight: none,確保內(nèi)容不被容器截?cái)唷?/li>
  • 圖片預(yù)加載:遍歷詳情頁(yè)中的所有圖片,等待圖片加載完成(或超時(shí) / 錯(cuò)誤)后再繼續(xù),避免 PDF 中出現(xiàn)圖片缺失。通過(guò)添加時(shí)間戳(?t=${time})避免緩存影響。
  • 轉(zhuǎn)換為 PDF:
    • 用 html2canvas 將預(yù)處理后的詳情頁(yè)元素(.reports-detail-page)轉(zhuǎn)換為 canvas(scale: 2 提高清晰度)。
    • 用 jsPDF 將 canvas 轉(zhuǎn)為 PDF,設(shè)置 A4 尺寸(或自適應(yīng)內(nèi)容高度),輸出為 Blob 格式。
      異常處理:若多次重試后仍失敗,生成一個(gè) “錯(cuò)誤 PDF”(包含失敗原因、周報(bào) ID 等信息),避免單個(gè)失敗阻斷整個(gè)批次。
  1. 壓縮與下載
  • ZIP 打包:所有 PDF 處理完成后,通過(guò) JSZip 將所有 PDF Blob 打包為一個(gè) ZIP 文件,文件名格式為 “周報(bào)匯總_時(shí)間戳.zip”。
  • 觸發(fā)下載:將 ZIP 文件轉(zhuǎn)換為 Blob URL,通過(guò)動(dòng)態(tài)創(chuàng)建 標(biāo)簽觸發(fā)瀏覽器下載,下載完成后釋放 URL 資源。
  1. 結(jié)果反饋與資源清理
  • 成功反饋:若全部處理完成,通過(guò) ElNotification 顯示成功提示(“已將 X 個(gè)周報(bào)導(dǎo)出為 ZIP 壓縮文件”)。
  • 異常反饋:若過(guò)程中出現(xiàn)未捕獲的錯(cuò)誤,顯示錯(cuò)誤提示(“導(dǎo)出失敗,請(qǐng)稍后再試”)。
  • 資源清理:無(wú)論成功或失敗,最終通過(guò) loading.close() 關(guān)閉加載提示,釋放頁(yè)面鎖定。

核心步驟就是iframe,動(dòng)態(tài)生成一個(gè)不可見(jiàn)的 iframe(定位在頁(yè)面外,透明度 0.01),用于加載周報(bào)詳情頁(yè)(/center/detail/${report.id}),另外為什么采用批處理,不一次并發(fā)執(zhí)行呢?因?yàn)橐淮螆?zhí)行過(guò)多,渲染太多子頁(yè)面,超出瀏覽器承受范圍會(huì)報(bào)錯(cuò)。

四、總結(jié)

綜上,前端導(dǎo)出 PDF 方案通過(guò) html2canvas 與 jsPDF 組合,結(jié)合 DOM 預(yù)處理解決了復(fù)雜頁(yè)面的完整還原問(wèn)題。單頁(yè)導(dǎo)出利用 Vue 的 ref 獲取 DOM 元素直接轉(zhuǎn)換,批量導(dǎo)出則借助 iframe 隔離渲染環(huán)境并配合 JSZip 壓縮,既減輕了服務(wù)端壓力,又保證了導(dǎo)出效果。實(shí)際應(yīng)用中可根據(jù)頁(yè)面復(fù)雜度調(diào)整預(yù)處理邏輯與批處理參數(shù),平衡導(dǎo)出效率與準(zhǔn)確性。

以上就是Vue前端導(dǎo)出頁(yè)面為PDF文件的最佳方案的詳細(xì)內(nèi)容,更多關(guān)于Vue導(dǎo)出頁(yè)面為PDF的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!

相關(guān)文章

  • vue3+vite使用svg圖片方式

    vue3+vite使用svg圖片方式

    這篇文章主要介紹了vue3+vite使用svg圖片方式,具有很好的參考價(jià)值,希望對(duì)大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教
    2026-03-03
  • 使用vue3.2實(shí)現(xiàn)多頁(yè)簽導(dǎo)航

    使用vue3.2實(shí)現(xiàn)多頁(yè)簽導(dǎo)航

    這篇文章主要為大家詳細(xì)介紹了如何使用vue3.2 + elementPlus + pinia 實(shí)現(xiàn)多頁(yè)簽導(dǎo)航,文中的示例代碼講解詳細(xì),有需要的小伙伴可以參考一下
    2023-12-12
  • vue在history模式下打包部署問(wèn)題及解決方案

    vue在history模式下打包部署問(wèn)題及解決方案

    這篇文章主要介紹了vue在history模式下打包部署問(wèn)題及解決方案,具有很好的參考價(jià)值,希望對(duì)大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教
    2025-03-03
  • 詳解Vue中組件傳值的多重實(shí)現(xiàn)方式

    詳解Vue中組件傳值的多重實(shí)現(xiàn)方式

    這篇文章主要介紹了詳解Vue中組件傳值的多重實(shí)現(xiàn)方式,非常不錯(cuò),具有一定的參考借鑒價(jià)值,需要的朋友可以參考下
    2019-08-08
  • Vue3中defineProps設(shè)置默認(rèn)值的方法實(shí)現(xiàn)

    Vue3中defineProps設(shè)置默認(rèn)值的方法實(shí)現(xiàn)

    Vue3中我們經(jīng)常需要使用defineProps來(lái)定義組件的屬性,本文主要介紹了Vue3中defineProps設(shè)置默認(rèn)值的方法實(shí)現(xiàn),文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧
    2024-07-07
  • 詳解IOS微信上Vue單頁(yè)面應(yīng)用JSSDK簽名失敗解決方案

    詳解IOS微信上Vue單頁(yè)面應(yīng)用JSSDK簽名失敗解決方案

    這篇文章主要介紹了詳解IOS微信上Vue單頁(yè)面應(yīng)用JSSDK簽名失敗解決方案,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下
    2018-11-11
  • IOS上微信小程序密碼框光標(biāo)離開(kāi)提示存儲(chǔ)密碼的完美解決方案

    IOS上微信小程序密碼框光標(biāo)離開(kāi)提示存儲(chǔ)密碼的完美解決方案

    ios密碼框輸入密碼光標(biāo)離開(kāi)之后會(huì)提示存儲(chǔ)密碼的彈窗,關(guān)于這樣的問(wèn)題怎么解決呢,下面給大家分享IOS上微信小程序密碼框光標(biāo)離開(kāi)提示存儲(chǔ)密碼的完美解決方案,感興趣的朋友一起看看吧
    2024-07-07
  • 詳解用webpack2.0構(gòu)建vue2.0超詳細(xì)精簡(jiǎn)版

    詳解用webpack2.0構(gòu)建vue2.0超詳細(xì)精簡(jiǎn)版

    本篇文章主要介紹了詳解用webpack2.0構(gòu)建vue2.0超詳細(xì)精簡(jiǎn)版,小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧
    2017-04-04
  • uniapp微信小程序webview和h5數(shù)據(jù)通信代碼示例

    uniapp微信小程序webview和h5數(shù)據(jù)通信代碼示例

    這篇文章主要介紹了uniapp微信小程序webview和h5數(shù)據(jù)通信的相關(guān)資料,文章還列出了項(xiàng)目的結(jié)構(gòu),包括微信小程序和h5端的主要文件和組件,文中通過(guò)代碼介紹的非常詳細(xì),需要的朋友可以參考下
    2025-01-01
  • Vue中computed與methods的區(qū)別詳解

    Vue中computed與methods的區(qū)別詳解

    這篇文章主要介紹了Vue中computed與methods的區(qū)別詳解,小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧
    2018-03-03

最新評(píng)論

汕头市| 大荔县| 布拖县| 连江县| 桂林市| 高州市| 台东县| 襄樊市| 沅陵县| 杭锦旗| 晋中市| 新龙县| 河津市| 永胜县| 渝北区| 龙陵县| 黎川县| 江华| 镇坪县| 和林格尔县| 钟祥市| 郑州市| 屏东市| 乌海市| 汕尾市| 大洼县| 景德镇市| 宣汉县| 通化县| 阜平县| 荔波县| 榆林市| 建阳市| 麟游县| 文水县| 逊克县| 明溪县| 巨野县| 富平县| 兖州市| 贡觉县|