news 2026/10/20 11:57:01

发票样板H5开发实战:版式拆解、数据填充与打印调优

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
发票样板H5开发实战:版式拆解、数据填充与打印调优

“发票样板 H5”这几个字放在一起,第一反应就是要在浏览器里做出一张像模像样的发票样式页面,支持填数据、改金额、打印出来当样品。我做这个需求时,业务部门拿到的还是一张静态图片,改个公司名称就得找设计重新修图,改多了双方都累。所以我当时决定直接上 H5:把版式固定好、数据做成可配置、打印样式一次性调干净。这篇文章把我从拆版式到写完代码、调完打印的几个关键步骤,以及中间翻过的车,完整整理一遍,给你做同类需求时省点试错时间。

1. 发票样板 H5 并不只是“画一张发票”

1.1 一张样板页的业务价值

发票样板页面最常见的应用场景有三类。第一类是给客户演示开票结果,客户不想等真实开票流程,直接看一张“样板发票”就能确认字段和布局是否满足需求;第二类是财务系统的前端原型,需要先用一张假票据验证界面,开发人员才有依据去对接正式接口;第三类是自动化测试的夹具页面,用固定数据渲染一张票据样张,用来验证打印、导出等功能。

这三个场景都有一个共同要求:页面必须支持动态填入数据、快速刷新展示、可打印输出。如果只输出一张图片,后面两类场景基本没法落地。H5 用 HTML 写版式、用 JavaScript 控制数据,天然能满足这些要求。我做项目时经常提醒自己,样板页面的目标是“以假乱真”,这个“假”是指数据是模拟的,但版式和打印效果必须接近真实票据,否则业务方看不出问题,后续返工成本反而更高。

1.2 为什么不能直接用图片或 PDF

很多人第一反应是用一张发票截图,简单直接。但图片是位图,字段稍微变一下就得重新截图,改起来非常痛苦;如果要在手机 H5 里展示,图片体积还会拖慢加载速度,放大后边角发虚。PDF 适合定稿存档,但要在页面里做动态填数和在线预览,交互成本很高,通常还得引入额外的查看器控件,体验不一定好。

H5 的优势在于“版式可复用、数据可分离”。CSS 负责把发票长什么样描述清楚,JavaScript 负责把数据填进对应位置,同一套代码既能嵌入业务系统,也能单独分享链接。业务方只需要打开页面,左侧输入框改几个字段,右侧预览区马上就能刷新成一张新发票。这种交互方式图片和 PDF 都做不到。

1.3 这个页面到底适合谁使用

使用这类样板页面的主要是三拨人。前端开发拿它做模板和适配;产品经理拿它快速确认发票字段和布局;测试或运营人员拿它准备演示数据和截图。三类人关注点不太一样,但都希望样板页面能改、能打、能看。我建议动手前先确认谁是这个页面的最终使用者,因为交互深度完全不同。

只给内部预览用,做一个简单的填充表单就够了。对外演示,可能要加二维码和防伪信息。如果要打印出来盖章,就得做到每一毫米的位置不偏。不要一开始就把需求想成“画一张好看的图”,要想清“哪些字段可变、哪些结构固定、输出形态是什么”,否则很容易做出一堆表面功夫,实际没法用。

2. 动手前先拆版式和数据模型,再写代码

2.1 发票版式:先定义纸张和坐标

拿到需求后别急着写 HTML,第一件事是搞清楚发票的物理尺寸和分区。拿最常用的电子发票样张来说,常见尺寸是 210mm 宽、148mm 高,也有企业会自定义尺寸。客户说“要跟真票一样”,听起来抽象,核心其实就是三个尺寸:纸张宽高、四周边距、表格列宽。我习惯先列一个区块坐标表,把一整个票据拆成几个大区域:

区块主要内容位置说明
票头区发票名称、发票代码、发票号码顶部居中或左右分布
信息区购买方信息、销售方信息票头下方左右各半
明细区项目名称、数量、单价、金额、税额页面中部表格
价税合计区小写合计金额、大写合计金额明细区下方
签章区收款人、复核、开票人、备注页面底部

有了这张坐标表,CSS 里的盒子布局才不容易跑偏。很多人一上来就忙着调颜色、加阴影,结果打印时不是高了就是宽了。发票样板的还原目标很明确:先像一张票,再谈好不好看。

2.2 数据字段:与真实票据字段对齐

发票样板的核心是数据字段,不是样式。如果字段名和真实票据对不上,后面接后端接口时又得重改。我把数据对象设计成和业务字段尽量一一对应,例如:

const invoiceData = { title: '电子发票(普通发票)样张', invoiceCode: '031002300211', invoiceNumber: '12345678', issueDate: '2024-05-20', checkCode: '31140988712045632109', buyer: { name: '示例购买方公司', taxId: '91440101MA5XXXXXX0', bank: '某某银行某某支行', account: '6809 8899 1020 3040 5060' }, seller: { name: '示例销售方公司', taxId: '91440101MA5XXXXXX1', bank: '某某银行某某支行', account: '6809 8899 1020 3040 5061' }, items: [ { name: '技术服务费', spec: '', unit: '项', quantity: 1, price: 1000, amount: 1000, taxRate: 6, tax: 60 } ], totalAmount: 1000, totalTax: 60, total: 1060, totalUpper: '壹仟零陆拾元整', remarks: '备注信息' };

字段名用驼峰命名方便前端处理,后端如果返回的是下划线命名,在接口层做一次映射即可。不要直接在模板里写死字段,更不要在页面里到处硬编码“购买方”“销售方”这类文案,因为不同发票类型对同一字段的称呼可能略有差异。

2.3 “版式层”和“数据层”分离的好处

我最初写样板时偷懒,把数据直接写死在 HTML 里,结果改一处就得全局替换,非常痛苦。后来改成用><div class="invoice-wrap"> <div class="invoice"> <div class="invoice-header"> <h1 class="invoice-title">电子发票(普通发票)样张</h1> <div class="invoice-no"> <span>发票代码:<em>* { box-sizing: border-box; } body { margin: 0; padding: 16px; background: #f5f6f8; font-family: SimSun, 'Songti SC', 'Noto Serif CJK SC', Serif; } .invoice-wrap { display: flex; justify-content: center; } .invoice { width: 210mm; min-height: 148mm; background: #fff; padding: 8mm 12mm; border: 1px solid #ddd; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); font-size: 14px; color: #222; } .invoice-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #444; padding-bottom: 4mm; } .invoice-title { font-size: 24px; font-weight: 700; margin: 0; } .invoice-table { width: 100%; border-collapse: collapse; table-layout: fixed; margin: 6mm 0; } .invoice-table th, .invoice-table td { border: 1px solid #444; padding: 3mm 2mm; text-align: center; font-size: 13px; }

为什么用box-sizing: border-box?因为发票宽度定义成 210mm,如果再用默认的content-box加 padding,总宽度会超过 210mm,打印时必然溢出。屏幕预览如果觉得版面太小,可以给.invoice加transform: scale(),但打印样式里必须移除,否则打印结果会莫名其妙地被缩小或位移。

3.3 JavaScript 动态填充数据的写法

填充数据的关键是遍历所有[data-field]元素,按字段路径找到对应值并写入。我封装了一个通用的fillInvoice方法:

function getValueByPath(obj, path) { return path.split('.').reduce((acc, key) => acc && acc[key], obj); } function escapeHtml(str) { return String(str) .replaceAll('&', '&amp;') .replaceAll('<', '&lt;') .replaceAll('>', '&gt;') .replaceAll('"', '&quot;'); } function fillInvoice(data) { document.querySelectorAll('[data-field]').forEach(el => { const path = el.dataset.field; const value = getValueByPath(data, path); if (value !== undefined && value !== null) { el.textContent = escapeHtml(value); } }); renderItems(data.items || []); } function renderItems(items) { const tbody = document.querySelector('#invoiceItems'); tbody.innerHTML = items.map(item => ` <tr> <td>${escapeHtml(item.name)}</td> <td>${escapeHtml(item.spec || '')}</td> <td>${escapeHtml(item.unit || '')}</td> <td>${formatNumber(item.quantity)}</td> <td>${formatMoney(item.price)}</td> <td>${formatMoney(item.amount)}</td> <td>${escapeHtml(item.taxRate)}%</td> <td>${formatMoney(item.tax)}</td> </tr> `).join(''); }

这里有两个重点。第一,为什么用textContent而不是直接赋值innerHTML:因为发票里的公司名称、备注字段可能包含特殊符号,直接插入 HTML 会引发样式错乱甚至脚本注入。第二,表格行可以拼接字符串,但每个字段都要经过escapeHtml转义。执行fillInvoice(invoiceData)后,整个版面就会自动更新。

3.4 金额格式化与中文大写转换

金额显示基本都会涉及千分位和两位小数。用toLocaleString做格式化比较稳妥:

function formatMoney(value) { const num = Number(value) || 0; return num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } function formatNumber(value) { const num = Number(value) || 0; return num.toLocaleString('zh-CN', { maximumFractionDigits: 4 }); }

中文大写转换是发票样板绕不开的环节。最稳妥的方案是引入一个经过大量测试的开源库,但如果项目不允许加额外依赖,可以自己写一个精简版本。核心逻辑是把整数部分按“千、百、十、个”分组,再插入“万、亿”等单位,小数部分单独处理“角、分”。

function convertCurrencyToChinese(amount) { const digits = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖']; const radices = ['', '拾', '佰', '仟']; const bigRadices = ['', '万', '亿']; const money = Number(amount); if (isNaN(money) || money < 0) return '金额错误'; if (money === 0) return '零元整'; const integer = Math.floor(money); const decimal = Math.round((money - integer) * 100); let intStr = String(integer); let result = ''; let zeroCount = 0; for (let i = 0; i < intStr.length; i++) { const digit = Number(intStr[i]); const unitIndex = (intStr.length - i - 1) % 4; const groupIndex = Math.floor((intStr.length - i - 1) / 4); if (digit === 0) { zeroCount++; if (unitIndex === 0 && groupIndex > 0 && zeroCount < 4) { if (result && !result.endsWith('零')) { result += '零'; } result += bigRadices[groupIndex]; zeroCount = 0; } } else { if (zeroCount > 0) { result += '零'; } zeroCount = 0; result += digits[digit] + radices[unitIndex]; if (unitIndex === 0 && groupIndex > 0) { result += bigRadices[groupIndex]; } } } if (decimal === 0) { return result + '元整'; } const jiao = Math.floor(decimal / 10); const fen = decimal % 10; let decimalStr = jiao > 0 ? digits[jiao] + '角' : '零'; if (fen > 0) { decimalStr += digits[fen] + '分'; } return result + '元' + decimalStr; }

这个函数只适合常规金额,边界情况建议补测试用例,例如“100000000.03”“100200”这类容易在“零”上出错的值。实际项目里,如果后端接口已经返回了大写金额字符串,前端直接展示即可,不需要自己再转换一遍,减少出错概率。

3.5 打印前你需要处理的三件事

第一次让发票样板开放打印功能时,我遇到过不少问题,现在总结了三个必做动作。

第一,把非打印区域分清楚。页面上的编辑按钮、输入表单、调试面板都要加一个类,打印时统一隐藏:

<button class="btn no-print" onclick="window.print()">打印</button>
@media print { .no-print { display: none !important; } }

第二,设置打印页边距。直接在 CSS 里声明@page,可以控制纸张大小和边距,但不同浏览器的兼容性有些差异,最好同时把对话里的边距选项设置为“无”或“默认”。

@page { size: 210mm 148mm; margin: 0; }

第三,保留颜色和背景。发票通常有红色印章、灰色背景区块,浏览器为了让打印省墨,默认可能去掉背景色。需要加:

.invoice { -webkit-print-color-adjust: exact; print-color-adjust: exact; }

打印按钮建议放在预览区外面,不要遮住票据。点击打印前再执行一次fillInvoice(currentData),防止用户改了表单但没有触发预览刷新,最后打印出来是老数据。

4. 常见问题:我踩过的几个坑

4.1 打印偏移:@page 和边距的关系

最常遇到的问题是打印出来的发票整体偏右或偏下,有时还多出一页空白。原因大多不是代码没写对,而是浏览器打印设定了额外的页边距。Chrome 打印对话框里有一个“边距”选项,默认选择是“默认”,这时即使 CSS 写了@page { margin: 0 },页面也未必完全贴合纸张边缘。

我现在的排查顺序是:先看预览里是不是有页面缩放残留,再看打印对话框的边距设置,最后检查.invoice是否在@media print里去掉了transform: scale()。开发阶段建议每隔一段时间就跑一次打印预览,不要等所有样式写完再调,越到后期越难定位是哪个样式影响了偏移。

提示:打印预览是你最诚实的验收标准。屏幕里再好看,打印输出不对就是不对。这个道理我在连续踩了两天坑之后才彻底记住。

4.2 表格断裂和字体串位

发票明细行一多,表格在分页时很容易被切断,上半页下半行、下半页上半行,看着非常不舒服。解决办法是给表格行设置分页保护:

.invoice-table tr { page-break-inside: avoid; } .invoice-table thead { display: table-header-group; }

display: table-header-group的作用是当表格跨页时,每页都会重复表头,这样打印出来的多页票据更清晰。

字体串位是另一个容易被忽略的问题。发票通常用宋体或黑体,但 Windows 和 macOS 对中文字体的名称处理不一样。Windows 里SimSun是宋体,macOS 没有这个名字;反过来,Songti SC在 Windows 里又不存在。我一般写一组回退字体:

.invoice { font-family: SimSun, 'Songti SC', 'Noto Serif CJK SC', Serif; }

这样不同系统至少能呈现相近的中文衬线风格。嵌入字体虽然最保险,但会让 H5 页面体积明显变大,对样板页不太划算。

4.3 长文本破坏版式

公司名称、地址、备注字段偶尔超长,尤其是“开户银行及账号”这一行,稍微一长就把整行撑破。处理方案分两步。第一步,明细表格使用table-layout: fixed,固定每一列的宽度比例,让文本在单元格内部折行而不是撑开表格:

.invoice-table { table-layout: fixed; } .invoice-table td { word-break: break-all; overflow-wrap: anywhere; }

第二步,对有最长字符限制的字段,在前端先做一次截断或省略。比如纳税人识别号经常有固定位数,如果数据超过位数,多半是测试数据写错了,打印出来反而暴露问题。

空单元格也要保留高度。像我这种“规格型号”和“单位”经常为空的情况,不要在 JS 里直接把整行删除或把字段替换成空字符串而且不加占位,否则行高会塌掉,整张发票的留白比例完全变形。

4.4 一些“发票校验”相关的显示细节

这里不讨论真实发票的税务规则,只说前端显示上的几个细节。第一,校验码字段大多数时候是一组长数字,打印出来要有适当的字符间距,不能挤成一团。第二,密码区不是普通文本,在样张里可以用***或空格占位,但不要随便放一串看起来像真实密码的数字。第三,金额为 0 时要显示“0.00”,不能出现“¥.00”这样的半截格式。

税率也有例外情况。有的票据税率不是数字,而是“不征税”“免税”这类文本,如果前端一刀切把它转成数字再格式化,就会直接显示NaN%或者0%,明显不对。处理方式是数据结构里加一个taxType字段,或者让税率字段直接支持字符串,渲染时先判断类型再选择格式化逻辑。

5. 再把视野打开一点:发票样板的后续扩展

5.1 从写死的样板到可视化模板编辑器

样板页面做到一定程度,一定会遇到新的需求:把某一列的宽度调一下,把公司名称的字体加大,把备注区放到左下角而不是右下角。如果每次都是开发改 CSS,效率很低。后续可以做一个可视化模板编辑器,让业务人员自己配置发票的区块位置、字段绑定、字体大小和边框样式。

本质上还是“版式和数据分离”这套思路。用一个 schema 描述每个区块的坐标、数据字段、样式属性,系统读取 schema 之后动态渲染 H5 预览。做编辑器时要注意,编辑态和打印预览态必须分开。编辑态需要拖拽辅助线和可点击的控件,打印预览态则应屏蔽所有编辑辅助元素,只保留纯票据版面。

5.2 对接后端开票数据的正确姿势

样板页面最终大多要接入开票接口,但这不等于前端可以自己做“发票生成”。正确做法是:前端只负责把后端返回的发票数据渲染到页面上,开票动作和票据有效性全部交给后端或第三方服务处理。前端不要自己去计算税额,不要自己拼发票号码,更不要用 H5 页面去生成真实票据。

真实开票涉及很多外部规范,不是一个网页能搞定的。尤其是对外演示时,页面要明确标注“样板”或“样例”,避免有人截图后误当成正式发票使用。这个点比技术本身重要,我甚至建议在页面的固定角落加一个浅色水印,比如“仅供演示”,既不影响版式,又能避免后续扯皮。

5.3 二维码、查验区和其他组件

电子发票右下角通常有二维码和查验提示。在样板页面里,二维码一般只是一个<img>标签,图片地址由后端生成后返回,前端不需要自己实现二维码逻辑。

<div class="invoice-qr"> <img>
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 11:55:59

CP2102 Linux驱动安装与配置:Ubuntu下串口调试避坑指南

简介&#xff1a;这份资源是面向Linux平台、尤其是Ubuntu 11.04&#xff08;内核2.6.38&#xff09;用户的CP2102 USB转UART桥接芯片驱动程序&#xff0c;适合嵌入式开发者、硬件调试人员及物联网爱好者解决USB与串口通信的兼容性问题。压缩包内共4个文件&#xff0c;以C源码为…

作者头像 李华
网站建设 2026/10/20 11:55:54

MFC图片缩放实战:从GDI到GDI+与双线性插值算法详解

简介&#xff1a;这份资源面向具备一定C基础的MFC初学者与桌面应用开发者&#xff0c;聚焦在MFC框架下实现图片放大缩小的完整工程示例&#xff0c;帮助解决CStatic控件自定义绘图、位图缩放算法选择与用户交互控制等常见问题。压缩包共43个文件&#xff0c;约4.27MB&#xff0…

作者头像 李华
网站建设 2026/10/20 11:55:17

用CEL输入接管API数据采集:从脚本到配置的Elastic Agent集成实践

前阵子有个同事问我&#xff1a;一个Elastic Agent集成里&#xff0c;最让人头疼的部分是哪个&#xff1f;我几乎没犹豫就回答——不是Agent本身&#xff0c;也不是日志文件的采集&#xff0c;而是那些为了对接HTTP API而写的自定义脚本。日志类采集只要定义好消息模式&#xf…

作者头像 李华
网站建设 2026/10/20 11:55:17

软件测试工程师必学Linux技能:日志排查与系统定位实战

记得刚带测试团队那会儿&#xff0c;每次面试新人我都会问一句&#xff1a;“在Linux服务器上看日志&#xff0c;你一般怎么操作&#xff1f;”不少候选人简历里写着“熟悉Linux”&#xff0c;真到现场&#xff0c;要么盯着屏幕半天不知道从哪个文件看起&#xff0c;要么只会ta…

作者头像 李华
网站建设 2026/10/20 11:55:17

多级注意力机制并行预测模型:从原理到即跑即用实战

有个朋友拿着一份现成的预测模型代码来找我&#xff0c;说训练了一百轮&#xff0c;验证集指标始终上不去。我远程扫了一眼&#xff0c;问题很快就定位到了&#xff1a;模型里只有一层注意力&#xff0c;直接堆在输入最上面。这件事也促使我把手头的项目重新整理成今天要讲的这…

作者头像 李华
网站建设 2026/10/20 11:55:03

AutoDL+4090搭建Qwen2.5-7B LoRA微调环境全攻略

简介&#xff1a;基于Qwen2.5-7B-Instruct的大模型微调实战指南&#xff0c;是一份面向具备一定机器学习基础、希望快速上手大模型微调的研发人员与开发者的实操文档。文档以AutoDL 4090云GPU为运行环境&#xff0c;完整覆盖了账号与实例创建、镜像选择、SSH与VSCode远程连接、…

作者头像 李华