概述(吐槽):記錄一個html打印合同模板的功能,技術棧有點雜,千禧年出產老系統的數據庫是sqlserver2008,原系統框架是c#,無法二開,因為原系統的合同生成功能出現bug,沒有供應商可以解決,因此只能重新開發合同生成的功能。現采用fastadmin后臺連接sqlserver數據庫(這里又有文章……)提供數據api接口,前端用的是uniapp框架做的h5項目,前端技術棧又很雜,主要用到了vue3+ts+js+uniui+z-paging,是的你沒看錯,ts和js同時用,就是這么雜……
下面重點講述html定制打印功能,通過系統自帶的打印PDF功能生成合同文件。
一、前端項目目錄結構
項目結構如圖,一個是list合同列表,detail合同詳情表,以及最最核心的print.js
二、效果頁面展示
圖1是列表頁,列表頁用了z-paging組件,自帶分頁查詢,強烈推薦~
圖2是詳情頁,豆包寫的,也還行~強烈推薦+1
打印預覽效果如上圖所示
三、代碼
print.js
import {generateStandardContract} from './template3_7_no_ad.js'/*** 使用瀏覽器原生打印功能生成PDF* @param {Object} contractData 合同數據* @param {string} templateType 模板類型* @param {string} fileName 生成的PDF文件名*/
export const printToPdf = (contractData, templateType = 'default', fileName = '合同詳情') => {console.log(contractData);// 保存當前滾動位置const scrollTop = window.scrollY;// 創建打印前的回調函數const beforePrint = () => {console.log('準備打印...');};// 創建打印后的回調函數const afterPrint = () => {window.scrollTo(0, scrollTop);console.log('打印完成');};// 檢查瀏覽器是否支持beforeprint/afterprint事件if (window.matchMedia) {const mediaQueryList = window.matchMedia('print');mediaQueryList.addEventListener('change', (mql) => {if (mql.matches) {beforePrint();} else {afterPrint();}});} else {window.onbeforeprint = beforePrint;window.onafterprint = afterPrint;}// 生成合同HTMLconst contractHtml = generateStandardContract(contractData);// 創建臨時打印窗口const printWindow = window.open('', '_blank');if (!printWindow) {console.error('無法打開新窗口,請檢查瀏覽器設置(可能被阻止)');return;}// 獲取當前頁面的樣式const styleElements = document.querySelectorAll('style, link[rel="stylesheet"]');let styleHtml = '';styleElements.forEach((element) => {if (element.tagName === 'STYLE') {styleHtml += element.outerHTML;} else if (element.tagName === 'LINK') {styleHtml += `<link rel="stylesheet" href="${element.href}">`;}});// 添加打印專用樣式 - A4布局,移除頁眉頁腳const printStyle = `<style>@media print {/* 設置A4尺寸 */@page {size: A4;margin: 1.5cm; /* 調整頁邊距 */}body {margin: 0;font-family: "SimSun", "宋體";width: 210mm; /* A4寬度 */min-height: 297mm; /* A4高度 */}/* 隱藏所有不需要打印的元素 */.no-print, .navbar, .action-buttons, .loading-mask {display: none !important;}/* 確保表格不斷頁 */table, .detail-item {page-break-inside: avoid;}/* 表格樣式 */table {border-collapse: collapse;width: 100%;}th, td {border: 1px solid #ddd;padding: 8px;}th {background-color: #f2f2f2;}/* 移除瀏覽器默認添加的頁眉頁腳 */@page {margin-top: 0;margin-bottom: 0;@top-left { content: none; }@top-center { content: none; }@top-right { content: none; }@bottom-left { content: none; }@bottom-center { content: none; }@bottom-right { content: none; }}}</style>`;// 生成頁眉HTML(含圖片)const headerHtml = `<div class="contract-header" style="text-align:center;height:75px;line-height:75px;"><img src="../../../static/header.png" alt="頁眉圖片" class="header-img" style="max-width:100%;max-height:65px;vertical-align:middle;"></div>`;// 生成頁腳HTML(不含固定定位)const footerHtml = `<div class="contract-footer" style="text-align:center;height:50px;line-height:50px;"><img src="../../../static/footer.png" alt="頁腳圖片" class="footer-img" style="max-width:100%;max-height:40px;vertical-align:middle;"></div>`;// 構建打印頁面內容printWindow.document.write(`<html><head><title>${fileName}</title>${styleHtml}${printStyle}</head><body>${headerHtml}${contractHtml}${footerHtml}<script>// 等待頁面加載完成后打印window.onload = function() {window.print();// 打印后關閉窗口(可選)// setTimeout(function() { window.close(); }, 100);};</script></body></html>`);printWindow.document.close();
};
打印模板的js
?
/*** 生成標準合同模板(條款顯示修復版)* @param {Object} contractData 合同數據* @param {boolean} [showStamp=true] 是否顯示電子章,默認為true* @returns {string} 生成的HTML內容*/
const generateStandardContract = (contractData, showStamp = true) => {// 格式化日期const formatDate = (dateStr) => {if (!dateStr) return '';return new Date(dateStr).toLocaleDateString();};// 格式化金額const formatMoney = (amount) => {if (!amount) return '0.00';return parseFloat(amount).toFixed(2);};// 提取主要數據const mainData = contractData.main || {};const detailItems = contractData.datail || [];// 過濾展位和廣告項目const boothDetails = detailItems.filter(item => item.OddTypName === '展位');const adDetails = detailItems.filter(item => item.OddTypName !== '展位');// 計算合計金額const totalAmount = detailItems.reduce((sum, item) => {return sum + parseFloat(item.OddTotalPrice || 0);}, 0);// 生成展位表格let boothTableHtml = '';if (boothDetails.length > 0) {boothTableHtml = `<h3 style="font-size:16px;margin:20px 0;padding-bottom:10px;border-bottom:1px solid #ddd;">展位信息</h3><table class="contract-table" style="width:100%;border-collapse:collapse;margin:15px 0;"><thead><tr><th style="border:1px solid #000;padding:8px 12px;text-align:center;background-color:#f0f0f0;font-weight:bold;">展館</th><th style="border:1px solid #000;padding:8px 12px;text-align:center;background-color:#f0f0f0;font-weight:bold;">展位</th><th style="border:1px solid #000;padding:8px 12px;text-align:center;background-color:#f0f0f0;font-weight:bold;">面積</th><th style="border:1px solid #000;padding:8px 12px;text-align:center;background-color:#f0f0f0;font-weight:bold;">展位費(元)</th></tr></thead><tbody>${boothDetails.map(item => {// 從OddName中提取展館和展位信息const boothInfo = item.OddName || '';const match = boothInfo.match(/展位:([\d-]+)-([\dA-Z]+)/);const srmName = match ? match[1] : '';const bthCode = match ? match[2] : '';// 從名稱中提取面積信息const areaMatch = boothInfo.match(/\((\d+\.\d+|\d+)平方米\)/);const area = areaMatch ? areaMatch[1] : '';return `<tr><td style="border:1px solid #000;padding:8px 12px;text-align:center;">${srmName}</td><td style="border:1px solid #000;padding:8px 12px;text-align:center;">${bthCode}</td><td style="border:1px solid #000;padding:8px 12px;text-align:center;">${area}平方米</td><td style="border:1px solid #000;padding:8px 12px;text-align:center;">${formatMoney(item.OddTotalPrice)}</td></tr>`;}).join('')}</tbody></table>`;}// 生成廣告表格let adTableHtml = '';if (adDetails.length > 0) {adTableHtml = `<h3 style="font-size:16px;margin:20px 0;padding-bottom:10px;border-bottom:1px solid #ddd;">廣告信息</h3><table class="contract-table" style="width:100%;border-collapse:collapse;margin:15px 0;"><thead><tr><th style="border:1px solid #000;padding:8px 12px;text-align:center;background-color:#f0f0f0;font-weight:bold;">廣告名稱</th><th style="border:1px solid #000;padding:8px 12px;text-align:center;background-color:#f0f0f0;font-weight:bold;">規格</th><th style="border:1px solid #000;padding:8px 12px;text-align:center;background-color:#f0f0f0;font-weight:bold;">單價(元)</th><th style="border:1px solid #000;padding:8px 12px;text-align:center;background-color:#f0f0f0;font-weight:bold;">金額(元)</th></tr></thead><tbody>${adDetails.map(item => {// 從名稱中提取規格信息const specMatch = item.OddName.match(/\((.*?)\)/);const scale = specMatch ? specMatch[1] : '';return `<tr><td style="border:1px solid #000;padding:8px 12px;text-align:center;">${item.OddName}</td><td style="border:1px solid #000;padding:8px 12px;text-align:center;">${scale}</td><td style="border:1px solid #000;padding:8px 12px;text-align:center;">${formatMoney(item.OddPrice)}</td><td style="border:1px solid #000;padding:8px 12px;text-align:center;">${formatMoney(item.OddTotalPrice)}</td></tr>`;}).join('')}<tr style="font-weight:bold;"><td colspan="3" style="border:1px solid #000;padding:8px 12px;text-align:right;">合計金額:</td><td style="border:1px solid #000;padding:8px 12px;text-align:center;">${formatMoney(totalAmount)}</td></tr></tbody></table>`;}// 生成甲方電子章 - 通過參數控制是否顯示const firstPageStamp = showStamp ? `<div class="stamp-container first-page-stamp" style="position:absolute;right:30%;top:30%;width:250px;height:250px;z-index:-10;opacity:0.5%;"><img src="../../../static/stamp.png" alt="電子章" class="stamp-img" style="max-width:100%;max-height:100%;opacity:0.8;"></div>` : '';const signaturePageStamp = showStamp ? `<div class="stamp-container signature-page-stamp" style="position:absolute;right:0%;top:-50px;width:250px;height:250px;z-index:-10;"><img src="../../../static/stamp.png" alt="電子章" class="stamp-img" style="max-width:100%;max-height:100%;opacity:0.9;"></div>` : '';// 生成完整合同HTMLreturn `<div class="contract-container" style="position:relative;min-height:297mm;width:210mm;margin:0 auto;font-family:'SimSun','宋體';font-size:14px;line-height:1.6;margin-bottom:10%;"><!-- 內容容器,用于打印時調整內容位置 --><div class="content-container" style="margin-bottom:10%;"><h1 class="contract-title" style="text-align:center;font-size:22px;margin:30px 0;color:#333;">參展協議書</h1><p class="contract-party" style="margin:10px 0;">甲方:廣東現代會展管理有限公司</p><p class="contract-party" style="margin:10px 0;">乙方:${mainData.OdrComName || ''}</p><p class="contract-intro" style="margin:20px 0;">乙方決定參加甲方2025年8月18日至21日舉辦的"第54屆國際名家具(東莞)展覽會暨2025東莞國際設計周",經甲乙雙方友好協商,確認如下:</p>${boothTableHtml}${adTableHtml}<p class="contract-amount" style="margin:20px 0;font-weight:bold;text-align:right;">合同總金額:${formatMoney(totalAmount)}元</p><p class="contract-handbook" style="margin:20px 0;">《參展商手冊》(附件一)中已詳細列明各有關事項,乙方必須按《參展商手冊》的有關規定進行裝修、布展和展覽等:</p>${generateContractClauses()}<div class="contract-remark" style="margin:30px 0;padding:15px;border:1px dashed #999;background-color:#f9f9f9;">${mainData.OdrRemark || ''}</div><div class="contract-signatures" style="margin-top:80px;padding-top:20px;border-top:1px dashed #999;display:flex;flex-wrap:wrap;gap:40px;position:relative;"><div class="contract-signature" style="flex:1;position:relative;min-width:200px;"><p class="signature-title" style="font-weight:bold;margin-bottom:10px;">甲方:廣東現代會展管理有限公司</p>${signaturePageStamp}<p class="signature-blank" style="margin:20px 0;min-height:40px;border-bottom:1px solid #ccc;">(簽署及蓋章)</p><p class="signature-info" style="margin-bottom:5px;">聯系地址:東莞市厚街家具大道</p><p class="signature-info" style="margin-bottom:5px;">聯系人:</p><p class="signature-info" style="margin-bottom:5px;">電話:</p><p class="signature-info" style="margin-bottom:5px;">電子郵箱:</p><p class="signature-date" style="margin-top:20px;">日期:${formatDate(mainData.OdrCreateTime)}</p></div><div class="contract-signature" style="flex:1;position:relative;min-width:200px;"><p class="signature-title" style="font-weight:bold;margin-bottom:10px;">乙方:</p><p class="signature-blank" style="margin:20px 0;min-height:40px;border-bottom:1px solid #ccc;">(簽署及蓋章)</p><p class="signature-info" style="margin-bottom:5px;">聯系地址:</p><p class="signature-info" style="margin-bottom:5px;">聯系人:${mainData.OdrHandler || ''}</p><p class="signature-info" style="margin-bottom:5px;">電話:</p><p class="signature-info" style="margin-bottom:5px;">電子郵箱:</p><p class="signature-date" style="margin-top:20px;">日期:20__年_____月_____日</p></div></div></div>${firstPageStamp}</div>`;
};/*** 生成合同條款* @returns {string} 生成的HTML內容*/
const generateContractClauses = () => {const clauses = [{number: '1、',text: '協議簽署后,在甲方不違約的情況下,如乙方要求取消展位,已交的展位費不予退還,乙方因此解除協議給甲方造成損失的,甲方有權要求乙方賠償并保留追究其法律責任的權利。'},{number: '2、',text: '乙方在確認展位后,必須按甲方發出的《繳款通知書》所規定的時間如期付清有關款項,否則,展位不予保留,已繳款項不予退還。'},{number: '3、',text: '甲方保留在特殊條件下,經與乙方協商最終調整展位的權利;甲方如不能安排展位給乙方,甲方將退回乙方已交的展位費。'},{number: '4、',text: '經甲、乙雙方確認的展位,乙方必須獨立使用。未經甲方書面同意,乙方不得私下轉讓或部分轉讓展位。若有上述情況,甲方有權解除本協議并收回展位,所交費用不予退還,且甲方不承擔展位受讓方及乙方的一切經濟損失(備注:甲方收取展臺施工平面圖、電路圖、效果圖或產品圖片等的行為并不視為甲方默認或同意乙方的轉讓行為)。若以乙方之名義為其子/母公司、總/分公司、集團公司、關聯公司等簽署本協議書的,應在簽署本協議前向甲方書面聲明,否則,視為轉讓或部分轉讓展位。'},{number: '5、',text: '乙方保證展出展品擁有自主知識產權,若有合理理由懷疑乙方展品涉嫌侵權,甲方有權以停電、暫封展位,等等方式,要求乙方完全撤出涉嫌侵權產品直至撤出展位;甲方并保留追究乙方違約責任(以甲方的實際損失或2倍合同總金額之間,以價額高者為準)及損害甲方商業信譽的權利(商業信譽損失難以確定的,甲方有權根據該事件違法情節惡劣程度、對甲方商業信譽造成負面影響的范圍及損害的程度等因素,在人民幣 20 萬元至 50 萬元的幅度來主張),甲方為此而向第三方承擔侵權等法律責任的,有權向乙方追償全部損失以及支付的一切合理費用(包括但不限于律師費、差旅費、公證費、擔保費、保全費、訴訟費、評估鑒定拍賣費、執行費等)。'},{number: '6、',text: '乙方保證展出展品符合國家質量部門相關的質量要求,否則,如有生產商、銷售商、消費者等提出存在不符合質量要求并提供初步的證據材料的,甲方有權以停電、暫封展位,等等的方式,要求乙方完全撤出涉事產品直至撤出展位,甲方并保留追究乙方違約責任及損害甲方商業信譽的權利(違約責任及商業信譽損失的計算參照第5條)。'},{number: '7、',text: '為維護展會現場秩序,保障所有參展商的共同利益,嚴禁乙方在展會現場派人員列隊巡游、派發資料或禮品、使用高音設備大聲喧嘩、舉辦各種形式的表演活動,否則甲方有權以停電、暫封展位,等等方式制止,情節嚴重的、或不聽勸阻的,甲方有權解除本協議,并對乙方限時撤場,或由甲方自行撤場,甲方無需承擔任何的違約責任。'},{number: '8、',text: '乙方需按《參展商手冊》的相關規定設計、裝修展位,于規定時間前提供展臺施工平面圖、電路圖、效果圖給甲方審核,嚴禁搭建二層,并按《參展商手冊》規定繳納相關費用。未經甲方審核通過的,乙方應在甲方指定的時間內進行整改并重新呈送甲方審核,否則甲方有權解除本協議,已繳款項不予退還。'},{number: '9、',text: '乙方最遲應在展覽會開展前48小時,到展覽會現場辦公室報到,辦理有關手續,繳納清潔押金。否則,甲方有權將其展位另行處理,已支付的展位費用不予退還。'},{number: '10、',text: '遇不可抗力因素(包括但不限于自然災害、戰爭、暴動、政府行為、疫情等),展覽會需延期舉辦時,乙方確認之展位保持有效,具體舉辦時間甲方將另行通知。甲方變更展會舉辦日期后,以書面、微信、短信、等任何一種形式通知乙方,并同時在公眾號及官網對外公告相關信息。'},{number: '11、',text: '乙方務必按照提供給組委會的產品圖片擺展,一旦發現展品不符合要求作封展位處理,乙方并需向甲方支付總展位費的兩倍金額作為違約金。'},{number: '12、',text: '乙方在布展、參展、撤展過程中發生的一切人身、財產損害以及因勞資糾紛、合同糾紛而產生的一切法律責任,均自行承擔全部賠償,甲方為此而向第三方承擔法律責任的,有權向乙方追償全部損失以及支付的一切合理費用(包括但不限于律師費、差旅費、公證費、擔保費、保全費、訴訟費、評估鑒定拍賣費、執行費等)。'},{number: '13、',text: '在甲方有合理理由懷疑撤展方并非乙方的情況下,為保障乙方的財產安全,甲方有權要求乙方提供撤展申請及相關證明文件方可撤場,否則甲方有權拒絕任何相關方的撤展。'},{number: '14、',text: '乙方存在其他違約行為,可參照上述違約條款承擔違約責任。'},{number: '15、',text: '本協議履行過程中產生爭議的,應雙方協商解決,協商不成的,提交甲方所在地人民法院解決。'},{number: '16、',text: '甲、乙雙方因履行本協議而相互發出或者提供的所有通知、文件等文書材料,均以本協議所列明的聯系地址、電話、電子郵箱通知和送達。一方遷址或者變更電話、電子郵箱的,應當書面通知對方,否則當一方發出的通知和送達被拒收、退回時,仍可視為有效送達。以當面交付文件方式送達的,交付之時視為送達;以電子郵件方式送達的,發出電子郵件時視為送達;以郵寄方式送達的,郵件交郵當日視為送達。'},{number: '17、',text: '附件或合同履行過程中雙方簽署的補充協議,與本協議具有同等法律效力,內容與本協議不一致的,除有特別約定,以最新形成之文件內容為準。'},{number: '18、',text: '本協議自雙方簽署之日起發生法律效力,本協議一式兩份,雙方各執一份。'}];return clauses.map((clause, index) => `<div class="contract-clause" style="margin:10px 0;padding-left:30px;text-indent:-30px;"><span class="clause-text" style="text-indent:0;">${clause.number}${clause.text}</span></div>`).join('');
};// 導出合同生成函數
export { generateStandardContract };
主要實現的功能點:
1、合同模板有七八個,先實現1個,就會有第二、三個,套就完了;
2、排版,這里是耗時最長的,豆包完成了80%,剩下來的只能自己手動調整;
3、關于分頁打印的問題,這里我也沒有解決,原來是想打印的每一頁都能顯示頁眉頁腳,類似word文檔的頁眉頁腳,但是由于內容是動態生成,無法預知具體打印頁數,分頁控制不好做,求解決辦法~
4、使用 CSS 的@page
規則;
5、電子簽章動態控制是否顯示,傳個參數控制就好了;
其它頁面的代碼沒啥亮點就不展示了,基本都是豆包+套娃。
四、總結
作為老系統的補充,只能做到這樣了~只要思路不滑坡,事情總有解決的辦法!