ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

HTML表格打造发票收据凭证:动态行与中文大写金额

HTML表格打造发票收据凭证:动态行与中文大写金额 简介面向网页前端初学者的HTML发票收据凭证界面实践项目基于原生HTML表格构建可录入项目、数量、单价的电子凭证支持填写中文数字与阿拉伯数字并通过JavaScript实时监听输入变化自动计算合计金额适合用于快速生成收据、教学演示或财务场景原型。压缩包共3个文件包含1个png效果图、1个7z压缩包和1个html页面整体仅81KB其中效果图可预览界面样式7z内为发票收据HTML表格源码html为改进版account_credential页面便于对照学习。目前已有1500余人学习下载。通过该实例可掌握table表格布局、input输入绑定、事件监听、中文数字转换及合计计算等前端交互逻辑同时可在此基础上扩展数据校验、Ajax无刷新提交等功能是练习HTML/CSS/JavaScript综合运用的轻量级素材。1. 一张能算账的HTML表格比Excel快在哪财务开票时最怕“分栏对不上”金额写错、合计算错、大写用圆珠笔描两道。这个HTML制作的电子发票收据凭证界面就是为这种场景准备的——一张基于table结构的网页表格能录入项目、数量、单价自动累加合计同时把阿拉伯数字转成中文数字直接打印出来就是一张能用的收据凭证。它不需要数据库不需要打包浏览器打开单个HTML文件就能跑。适合两类人一是前端学习者想完整走一遍HTML表格、input事件、DOM增删行的链路二是小团队内部需要快速搭个收款凭证工具不想为一张收据去装财务软件。这套实现的核心在于“按行录入、实时计算、转中文数字”下面从表格结构开始拆。2. 发票收据表格的搭建与动态行管理2.1 凭证表格的最小结构thead tbody tfootHTML里做发票收据最容易想到的就是table。thead固定表头tbody放数据行tfoot放合计。列设计成“序号、费用项目、数量、单价、金额、操作”。其中金额可以由数量乘以单价自动得出也可以让用户直接填写。实际场景里电费、网费这些项目往往一行一条数量默认1单价就是实际价格所以更常用的是允许手工改金额。选择table而不是divcss一是因为打印时边框对齐方便二是屏幕阅读器能正确读出单元格关系。表格结构如下table idinvoiceTable thead tr th序号/th th项目内容/th th数量/th th单价/th th金额/th th操作/th /tr /thead tbody idrowBody tr td classidx1/td tdinput typetext classitem placeholder如咨询服务/td tdinput typetext inputmodedecimal classqty value1/td tdinput typetext inputmodedecimal classprice value0/td td classamt0.00/td tdbutton classbtn-del typebutton删除/button/td /tr /tbody tfoot tr td colspan4合计人民币/td td idtotalAmount0.00/td tdbutton idbtnAdd typebutton添加行/button/td /tr /tfoot /table这里有几个关键选择。金额列用了td而不是input因为金额由单价计算得到避免用户两边改导致合计混乱如果希望金额可修改可以把td换成input再通过change事件重新计算。合计行的“合计人民币”跨4列右侧放计算结果添加按钮放在表格末端不用额外占一行。数量、单价输入框的type没有用number而是text inputmodedecimal这样在移动端弹出数字键盘同时可以自己接管格式校验避免number类型在浏览器里的怪异行为。下面这张表梳理各列的作用与取值方式方便后面事件绑定和计算时对照列名结构参与计算说明序号td文本否删除后重新编号项目内容text input否打印时的必填项数量text input是默认1最小0单价text input是保留两位小数金额td文本是由数量×单价得到每次计算刷新操作button否删除行第一行不真正删除2.2 用insertRow和removeChild控制增删行动态添加行不需要框架DOM原生方法够用。常见做法是cloneNode复制模板行再清空input值也可以直接用insertRow插入到tbody末尾再写innerHTML。后者字符串多但在这个规模下可读性更好const btnAdd document.getElementById(btnAdd); const rowBody document.getElementById(rowBody); btnAdd.addEventListener(click, function() { const tr rowBody.insertRow(); tr.innerHTML td classidx/td tdinput typetext classitem placeholder如办公耗材/td tdinput typetext inputmodedecimal classqty value1/td tdinput typetext inputmodedecimal classprice value0/td td classamt0.00/td tdbutton typebutton classbtn-del删除/button/td ; tr.querySelector(.item).focus(); refreshIndex(); });删除行推荐事件委托避免给每个新按钮重复绑定。事件委托的原理是让事件冒泡到tbody这一层通过e.target判断点击的按钮rowBody.addEventListener(click, function(e) { if (e.target.classList.contains(btn-del)) { const tr e.target.closest(tr); if (rowBody.rows.length 1) { rowBody.removeChild(tr); } else { // 至少保留一行防止表格变成空壳 tr.querySelector(.item).value ; tr.querySelector(.qty).value 1; tr.querySelector(.price).value 0; tr.querySelector(.amt).textContent 0.00; } refreshIndex(); calcTotal(); } });insertRow()不带参数时默认插到tbody末尾返回新建的tr。e.target.closest(tr)比parentNode.parentNode健壮得多哪怕以后在按钮和td之间再多包一层span仍然能找到行。保留最后一行的逻辑容易漏如果允许全部删除合计区会空打印出来也不像凭证所以第一行只清空不准删。删除后要刷新序号和合计这两个动作必须放在removeChild之后否则读到的是旧DOM状态。2.3 序号刷新与输入初始化序号刷新是很多初版容易略过的点删除中间一行后如果序号不重新编号第三行还显示3视觉上像缺号。用querySelectorAll收集所有序号单元格再逐个赋值function refreshIndex() { const rows rowBody.querySelectorAll(tr); rows.forEach(function(row, i) { row.querySelector(.idx).textContent i 1; }); }页面上所有输入变化都有统一入口。这里把事件绑定到rowBody上利用事件冒泡监听任何一行数量或单价的变化新增行不需要重复绑定rowBody.addEventListener(input, function(e) { if (e.target.classList.contains(qty) || e.target.classList.contains(price)) { calcLineAmount(e.target); } });行内金额计算逻辑放在calcLineAmount(input)里数量乘以单价的积保留两位小数再更新同行.amt。这一步没有把合计放进去是为了让“行计算”和“合计计算”职责分离。表格初始化放一行空数据用户打开就能直接填如果做成固定收据样式项目名称等列可以直接改成带contenteditable属性的td打印出来没有input边框不过那样取值会更麻烦需要读textContent而不是value。3. 中文数字转换与合计自动计算3.1 为什么发票上要用中文数字财务凭证里金额栏除了阿拉伯数字还要有大写中文数字目的是防止被篡改。比如“1234.56”习惯写成“壹仟贰佰叁拾肆元伍角陆分”。在做HTML收据时如果让用户手输大写容易出现“贰”和“贰”混淆或者记不住位数。更合理的做法是输入阿拉伯数字后自动生成中文大写反过来如果用户从别处粘贴了一个中文数字也需要能转换成数值参与合计。这一章实现两个函数arabicToChineseMoney(num)用于显示合计大写chineseToNumber(str)用于把用户输入的中文数字转成可计算的数字。两个方向都做覆盖“录入-计算-展示”的完整闭环。3.2 把数字转成人民币大写金额数字转中文大写的核心是“四位一节”的处理。整数部分按“拾佰仟”为一组每四位换一个“万”、“亿”单位小数部分处理“角分”。最稳妥的计算方式是先把金额乘以100变成“分”用整数运算避免浮点误差function arabicToChineseMoney(money) { const digits [零,壹,贰,叁,肆,伍,陆,柒,捌,玖]; const radices [,拾,佰,仟]; const bigRadices [,万,亿,万亿]; let num Math.round(money * 100); // 转成分处理避免浮点误差 if (num 0) return 零元整; let integerPart Math.floor(num / 100); let fractionPart num % 100; let result ; if (integerPart 0) { let zeroCount 0; for (let i 0; i bigRadices.length; i) { let section integerPart % 10000; let sectionStr ; let position 0; for (let j 0; j 4; j) { let v section % 10; if (v 0) { if (sectionStr ! ) zeroCount; } else { if (zeroCount 0 sectionStr ! ) sectionStr 零 sectionStr; sectionStr digits[v] radices[position] sectionStr; zeroCount 0; } section Math.floor(section / 10); position; } if (sectionStr ! ) { result sectionStr bigRadices[i] result; } integerPart Math.floor(integerPart / 10000); } result 元; } if (fractionPart 0) { result 整; } else { let jiao Math.floor(fractionPart / 10); let fen fractionPart % 10; if (jiao 0) { result digits[jiao] 角; } else { if (integerPart 0) result 零; } if (fen 0) result digits[fen] 分; } return result; }这段代码最容易写错的是连续零压缩。比如1005元四位一节时千位是1百位和十位都是0个位是5结果应该是“壹仟零伍元”。逻辑中的zeroCount只在已有内容时累加遇到非零数字时再检查是否需要补“零”这样“1000”不会变成“壹仟零零元”。另一个细节是“拾”前面的“壹”不能省正式凭证里“壹拾伍元”是规范写法不需要处理成口语化的“十五元”。调用示例console.log(arabicToChineseMoney(1234.56)); // 壹仟贰佰叁拾肆元伍角陆分 console.log(arabicToChineseMoney(56)); // 伍拾陆元整 console.log(arabicToChineseMoney(0.05)); // 零元零伍分3.3 把中文数字转回阿拉伯数字进行计算中文数字转数字比反过来简单但需要小心“万”、“亿”这类大单位的进位。我按以下映射处理字符值零 壹 贰 叁 肆0 1 2 3 4伍 陆 柒 捌 玖5 6 7 8 9拾 佰 仟10 100 1000万 亿10000 100000000function chineseToNumber(chineseStr) { const numMap {零:0, 壹:1, 贰:2, 叁:3, 肆:4, 伍:5, 陆:6, 柒:7, 捌:8, 玖:9}; const unitMap {拾:10, 佰:100, 仟:1000, 万:10000, 亿:100000000}; let total 0; let section 0; let number 0; for (let i 0; i chineseStr.length; i) { const ch chineseStr[i]; if (numMap[ch] ! undefined) { number numMap[ch]; } else if (unitMap[ch] ! undefined) { const unit unitMap[ch]; if (unit 10000) { section (section number) * unit; total section; section 0; number 0; } else { section (number 0 ? 1 : number) * unit; number 0; } } } return total section number; }逻辑说明number变量保存当前读到的数字遇到单位时把它和之前的数字组合成section。万和亿作为大节单位直接累加到total并重置section。例如“壹万贰仟叁佰肆拾伍”的处理过程壹存入number万使section变为10000贰和仟组合为2000加到section得到12000叁佰得到12300肆拾得到12340最后伍剩下number5返回12345。这个函数不支持“一百二”这种口语化写法但在正式单据里也不需要。使用方式是在金额input的blur事件里检查用户是否输入了中文。如果正则匹配到“壹贰叁”等字符就调chineseToNumber转换再把input.value改成阿拉伯数字重新计算itemInput.addEventListener(blur, function() { const cn this.value.trim(); if (/[壹贰叁肆伍陆柒捌玖拾佰仟万亿]/.test(cn)) { const num chineseToNumber(cn); this.value num.toFixed(2); calcLineAmount(this); } });3.4 合计实时计算与金额列更新合计计算遍历每一行把.amt的值累加保留两位小数。监听事件用input更实时中文数字转换发生在blur两个事件可以共存。计算函数如下function calcLineAmount(input) { const tr input.closest(tr); const qty parseFloat(tr.querySelector(.qty).value) || 0; const price parseFloat(tr.querySelector(.price).value) || 0; const amt qty * price; tr.querySelector(.amt).textContent amt.toFixed(2); calcTotal(); } function calcTotal() { let totalCents 0; const rows rowBody.querySelectorAll(tr); rows.forEach(function(row) { const amt parseFloat(row.querySelector(.amt).textContent) || 0; totalCents Math.round(amt * 100); // 先转成分避免浮点累计误差 }); const total totalCents / 100; document.getElementById(totalAmount).textContent total.toFixed(2); const totalCn document.getElementById(totalAmountCn); if (totalCn) { totalCn.textContent arabicToChineseMoney(total); } }注意calcTotal里用了“先乘100取整再加”的方式而不是直接累加amt。0.1*3在JavaScript里会得到0.30000000000000004显示时被toFixed(2)掩盖但累计多次后误差会被放大。转成分后累计最后再除以100输出就干净了。页面里可以放一个只读的提示区div合计金额大写span idtotalAmountCn/span/div每次calcTotal执行时这个span会同步更新成“壹仟贰佰叁拾肆元伍角陆分”打印出来就是规范凭证。至此核心功能已经闭环。4. 改进版account_credential.html事件委托与边界处理4.1 改进版到底改在哪里项目包里的“2lj改进版 account_credential.html”通常是在初版函数基础上的迭代。根据这类凭证工具的常见需求改进版会重点处理三件事一是添加行后自动聚焦到第一个输入框减少鼠标移动二是把删除操作改成防误触模式避免手滑删错行三是把所有事件统一委托到tbody保证新增行不需要重新绑定。截图“效果图.png”展示的是最终界面改进版则更关注交互细节。我的经验是最重要的改动是把事件绑定从“每个按钮/输入框单独绑定”改成“在tableBody上委托”。前面已经用了事件委托这里补充一个容易遗漏的场景——当行内金额输入框失焦时自动把用户直接填的金额同步回单价/数量防止用户只填金额不留数量导致合计逻辑错乱。rowBody.addEventListener(blur, function(e) { if (e.target.classList.contains(amountInput)) { const tr e.target.closest(tr); const val parseFloat(e.target.value); if (!isNaN(val) val 0) { // 把金额视为“数量×单价”的积默认数量为1 tr.querySelector(.qty).value 1; tr.querySelector(.price).value val.toFixed(2); calcLineAmount(tr.querySelector(.price)); } } }, true);这里用了addEventListener的第三个参数true表示在捕获阶段处理blur事件。普通blur事件不会冒泡绑定在父容器上的监听器默认收不到但捕获阶段是从父节点向目标节点传播的所以父节点能拦截到。这个细节是很多初版改来改去发现“删除行能用失焦事件不触发”的根本原因。4.2 防止使用者手滑删错行电子发票收据一旦开错打印出来作废很麻烦所以改进版里可以加一个两段式删除确认。第一次点击删除按钮时按钮文字变成“确认删”3秒内再点一次才真正删除超时自动恢复。这种方式比原生confirm温和也不打断键盘操作let pendingDelete null; let pendingDeleteTimer null; rowBody.addEventListener(click, function(e) { const btn e.target; if (btn.classList.contains(btn-del)) { const tr btn.closest(tr); if (pendingDelete tr) { rowBody.removeChild(tr); pendingDelete null; refreshIndex(); calcTotal(); } else { // 如果之前有别的行在等待确认先恢复它的按钮文字 if (pendingDelete) { pendingDelete.querySelector(.btn-del).textContent 删除; } clearTimeout(pendingDeleteTimer); btn.textContent 确认删; pendingDelete tr; pendingDeleteTimer setTimeout(function() { btn.textContent 删除; pendingDelete null; }, 3000); } } });注意当第二行点击删除时要先恢复前一行按钮的文字否则表格里会同时出现两个“确认删”。我用了pendingDelete保存当前等待确认的行元素切换时通过querySelector找回按钮。如果以后想支持键盘操作可以再绑定一个keydown事件按Esc时取消当前删除状态。4.3 小数精度与非法输入的处理自动计算合计时最坑的是精度。0.1乘3在JavaScript里会得到0.30000000000000004显示出来很难看。前面已经在calcTotal里用分累加处理这里补充输入的校验。数量、单价输入框的类型改为text后需要用正则过滤非法字符rowBody.addEventListener(input, function(e) { const input e.target; if (input.classList.contains(qty) || input.classList.contains(price)) { // 只保留数字和小数点 input.value input.value.replace(/[^\d.]/g, ); const dotIndex input.value.indexOf(.); if (dotIndex ! -1) { // 只保留第一个小数点并且小数位最多两位 input.value input.value.slice(0, dotIndex 1) input.value.slice(dotIndex 1).replace(/\./g, ).slice(0, 2); } calcLineAmount(input); } });这个过滤会把“1.2.3”变成“1.23”把“abc”变成空串。若用户输入“0.1.2”indexOf(.)找到第一个点后面部分去掉多余点后取前两位结果变成“0.12”。需要注意这个正则不支持负数但发票金额本来就不该为负数所以没问题。以下是几个常见错误和对应处理排查时可以直接对照症状原因处理新增行不响应input事件事件绑定在具体元素上没有委托改成绑定tbody用e.target判断合计显示0.30000000000000004直接累加浮点数先乘100取整再求和最后除以100blur事件不触发没有使用捕获阶段addEventListener第三个参数传true删除第一行后表格空白允许全部删除判断rows.length保留最后一行中文大写缺少“零”压缩逻辑写反只对非零数字前的零计数并补一个“零”字5. 把凭证界面推向实用验证、打印与CSV导出5.1 打印前校验必填项在打印之前要检查每一行是否项目为空、数量是否大于0、金额是否显示为NaN。用一行循环收集错误把错误行的背景色标红并返回false停止后续操作function validateBeforePrint() { const rows rowBody.querySelectorAll(tr); let valid true; rows.forEach(function(row, i) { const item row.querySelector(.item).value.trim(); const qty parseFloat(row.querySelector(.qty).value); const price parseFloat(row.querySelector(.price).value); const amt row.querySelector(.amt).textContent; const isBad !item || isNaN(qty) || qty 0 || isNaN(price) || price 0 || amt NaN; row.style.backgroundColor isBad ? #ffecec : ; if (isBad) valid false; }); return valid; }调用时先执行校验通过再触发打印失败时用alert提示错误行号不要直接打印出白板。5.2 用media print隐藏按钮和输入框边框打印凭证时要把添加/删除按钮、input边框隐藏让表格看起来像正式单据。在CSS里加打印媒体规则media print { .btn-add, .btn-del, .no-print { display: none !important; } input { border: none; background: transparent; border-bottom: 1px solid #333; outline: none; } table { border-collapse: collapse; width: 100%; } th, td { padding: 8px; border: 1px solid #333; } }打印时input只保留下边线上、左、右无边框视觉上接近手写线。如果项目名称列改成了contenteditable可以把这个规则里的input选择器加一个[contenteditable]效果一样。5.3 一键导出CSV到Excel有些场景不需要打印而是要把凭证汇总到Excel。CSV是最通用的中间格式用Blob触发下载function exportCSV() { const rows rowBody.querySelectorAll(tr); const csvLines []; csvLines.push(序号,项目,数量,单价,金额); rows.forEach(function(row, i) { const item row.querySelector(.item).value; const qty row.querySelector(.qty).value; const price row.querySelector(.price).value; const amt row.querySelector(.amt).textContent; csvLines.push(${i1},${item},${qty},${price},${amt}); }); const blob new Blob([\ufeff csvLines.join(\n)], { type: text/csv;charsetutf-8; }); const a document.createElement(a); a.href URL.createObjectURL(blob); a.download invoice.csv; a.click(); }CSV开头加\ufeffBOM是为了让Excel打开时不乱码这是Windows上中文CSV的老规矩。导出按钮放在打印按钮旁边两个动作互补。如果想彻底脱离打印对话框可以再用Puppeteer无头浏览器对file://路径截图生成PDF这样能自动生成带红色印章和固定版式的凭证文件整个HTML凭证工具就完整了。本文还有配套的精品资源点击获取
返回列表