概述(吐槽):记录一个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;
}
// 生成合同HTML
const 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>
` : '';
// 生成完整合同HTML
return `
<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、电子签章动态控制是否显示,传个参数控制就好了;
其它页面的代码没啥亮点就不展示了,基本都是豆包+套娃。
四、总结
作为老系统的补充,只能做到这样了~只要思路不滑坡,事情总有解决的办法!