ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

银行电子汇款单前端实战:从表单校验到打印导出的完整实现

银行电子汇款单前端实战:从表单校验到打印导出的完整实现

做前端这些年,被问得最多的需求之一就是“做一个银行电子汇款单页面”。听起来很简单,不就是表单加一张回执?等真正动手才发现,这里面的细节能写满好几页A4纸:字段怎么排、金额怎么转中文大写、卡号怎么分段、打印样式怎么不崩、水印怎么加。这篇文章用一个“某模拟银行电子汇款单”项目的实践,把web前端做汇款单的基础套路完整拆一遍,从需求拆解到页面排版,再到表单校验和打印导出,适合刚接触管理系统页面、或者准备做金融类业务前端的朋友参考。

1. 需求拆解:电子汇款单页面到底在解决什么问题

1.1 汇款单的前端本质:一页双向凭证

不要被“银行”两个字吓住,电子汇款单在前端的本质是一张“双向凭证”。用户填写汇款信息时,它是表单;交易完成之后,它又是一张回执。同一个版面上,前一部分是汇款人、收款人、账号、开户行、金额,后一部分是交易流水号、交易时间、手续费、交易状态。这两个部分通常在同一张容器里,并且要求“所见即所得”。

我第一次做这个需求时,只顾着把输入框画出来,结果后端同事问我要“回单展示态”的数据结构,我才意识到页面至少要有两套状态:录入态和展示态。录入态是用户填表单,展示态是用户核对信息或者下载回单。后来在实际开发中我甚至拆成三种状态:录入态、确认态、回单态。确认态是提交前让用户核对关键字段,回单态是不可编辑的最终展示。三种状态用同一套数据模型,只是交互全部禁用,样式上加一层“只读”外观。

前端如果一开始想清楚这个“一页三态”,后面会省非常多事。数据模型建议用一个统一对象来管理,不要散落在各个DOM节点上,否则切换状态时要重新取DOM值,很容易出错。我自己习惯用类似这样的结构:

const remittanceData = { payer: { name: '', accountNo: '', bankName: '' }, payee: { name: '', accountNo: '', bankName: '' }, amount: 0, currency: 'CNY', purpose: '', remark: '', serialNo: '', tradeTime: '', fee: 0, status: '' };

这个对象既是表单的初始值,也是回单展示的数据源。无论是用Vue、React,还是原生JavaScript,数据驱动的方式都成立。

1.2 字段逻辑:哪些能填,哪些只能看

汇款单的字段看着多,其实可以分成三类。第一类是用户输入字段,比如汇款人姓名、汇款账号、收款人姓名、收款账号、收款银行、金额、用途。第二类是系统回填字段,比如交易流水号、交易时间、手续费、交易状态,这类字段前端通常只做展示,点击“提交”之后由后端返回。第三类是衍生字段,最典型的就是金额中文大写、账号分段显示、用途字数统计。

这个分类直接决定了前端的校验策略。用户输入字段要做详细的校验,系统回填字段要处理加载和异常状态,衍生字段要响应式更新。之前有同事把金额中文大写放在后端生成,前端等接口返回再渲染,结果用户在填金额的瞬间看不到大写,体验就很割裂。我建议金额中文大写在前端实时算,后端在最终报文里再算一次,两边做双重保障。

另外还要特别注意“开户行”这类非结构化文本。用户可能输入“某某银行某某市某某支行营业部”,一长串字很容易把表格布局撑乱。所以在做展示区时,列宽要留足余量,并且允许自动换行,而不是text-overflow: ellipsis。银行信息的完整性比美观更重要,截断会造成用户核对困难。

1.3 容易被漏掉的需求:打印样式

这是我认为整个项目里最容易踩坑的地方。很多前端做完屏幕适配,觉得自己任务完成了,但银行类页面几乎都要打印,财务要打印入账回单,柜台要打印汇款凭证。默认的浏览器打印样式遇到这种信息密集的表格,分页会直接切断行,背景色消失,留白也乱七八糟。

所以需求拆解阶段,就要主动确认:这个页面会不会被打印?打印时是A4竖版还是其他尺寸?要不要把操作按钮隐藏?要不要把水印同时打印出来?如果这些不确定,后面返工成本很高。我一般会把打印样式当成一个独立小模块来开发,而不是在页面写完后顺手补几句CSS。这里先立一个结论:做电子汇款单,打印支持是必选项,不是加分项。

2. 页面布局与视觉基础:把汇款单做得像“单证”

2.1 用A4比例做页面骨架

银行汇款单通常是一张A4纸就能装下的内容。浏览器里做页面,我建议用一个居中的容器模拟A4纸张,宽度不要直接写死像素,而是按比例来。比如内容宽度设置在920到1000px之间,上下留出合适的白边。A4的宽高比大概是1比1.414,如果要做非常严格的纸面模拟,可以给容器设置aspect-ratio: 210 / 297,这样缩放时比例不会变形。

.slip-page { width: 960px; max-width: 100%; margin: 24px auto; padding: 40px 48px; background: #fff; aspect-ratio: 210 / 297; }

当然,aspect-ratio要配合min-height使用,因为实际内容可能超过A4高度。不要为了完全等比例而把内容压扁,原则是“容器的宽度模拟纸张,高度由内容撑开”。页面底色用浅灰色,模拟桌面,容器本身是白色,模拟一张纸,视觉效果会非常贴近真实单证。

标题区通常是“电子汇款凭证”或“汇款回单”几个字,居中,字体适当放大。这里有个细节:标题字号不要用CSS像素拍脑袋,尽量用相对单位。因为打印和屏幕显示的分辨率不同,固定字号在打印缩放时容易出现问题。给标题设置一个比较宽松的字间距,视觉上会更像银行单据。

2.2 信息栏表格:标签和内容的一一对应

汇款单主体信息我一般用table来搭,因为信息本身就是行列结构。有人喜欢用div+css网格,但考虑到打印时表格的分页行为、表头重复能力,table还是最稳妥的。关键是给table设置固定布局和合理列宽。

.slip-table { width: 100%; table-layout: fixed; border-collapse: collapse; } .slip-table td { border: 1px solid #c0c4cc; padding: 10px 12px; font-size: 14px; line-height: 1.6; vertical-align: top; } .slip-table .label { width: 120px; background: #f7f8fa; font-weight: 600; color: #333; }

table-layout: fixed是这个场景里的关键。它能让浏览器严格按指定的列宽渲染,不会因为某个单元格内容特别长就把整列撑变形。标签列给一个相对固定的宽度,内容列放剩余空间。涉及“收款人账号”“汇款人账号”这类长数字的列,可以用word-break: break-all让长数字换行,保证不溢出。

我常用的布局是一行两对信息,比如第一行左边是“汇款人”,右边是“收款人”,第二行左边是“汇款账号”,右边是“收款账号”。这种双列排版在银行回单里很常见,比较节省纸面空间。但要注意,手机端看时双列会缩得太小,建议媒体查询在窄屏下把两个单元格改成单列堆叠。

2.3 金额中文大写的实现套路

金额中文大写是汇款单页面“最有仪式感”的部分。看起来只是一个纯展示字段,但实现起来有不少边界情况。零该怎么处理,角分该怎么处理,整什么时候加,这些都要考虑。以下代码是一个基础教学版实现,方便理解核心思路,生产环境建议用后端生成的大写字符串:

function amountToChinese(value) { const numChars = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖']; const unitChars = ['', '拾', '佰', '仟']; const bigUnits = ['', '万', '亿']; const amount = Number(value); if (isNaN(amount) || amount < 0) return ''; const integerPart = Math.floor(amount); const decimalPart = Math.round((amount - integerPart) * 100); if (decimalPart >= 100) { // 四舍五入导致进位 } const intStr = String(integerPart); const groups = []; let rest = intStr; while (rest.length > 4) { groups.unshift(rest.slice(-4)); rest = rest.slice(0, -4); } groups.unshift(rest); let result = ''; let needZero = false; for (let g = 0; g < groups.length; g++) { const group = groups[g]; let groupResult = ''; let groupZero = false; for (let i = 0; i < group.length; i++) { const digit = Number(group[i]); const pos = group.length - 1 - i; if (digit === 0) { groupZero = true; } else { if ((groupZero || needZero) && groupResult) { groupResult += '零'; } groupResult += numChars[digit] + unitChars[pos]; groupZero = false; needZero = false; } } if (groupResult) { result += groupResult + bigUnits[groups.length - 1 - g]; needZero = true; } else if (result) { needZero = true; } } result = (result || '零') + '元'; if (decimalPart === 0) { result += '整'; } else { const jiao = Math.floor(decimalPart / 10); const fen = decimalPart % 10; if (jiao > 0) { result += numChars[jiao] + '角'; } else { result += '零'; } if (fen > 0) { result += numChars[fen] + '分'; } } return result; }

这段代码适合作为前端展示的辅助,但必须明确一点:银行正式凭证上的大写金额,应当以业务后端或核心系统计算为准。前端大写转换的价值在于“实时预览”,让用户在录入时就能看到中文大写是否符合预期。真正提交时,后端会独立计算并校验,前端结果不一致先提示用户确认。

3. 表单交互与校验:少让用户填错,更别让后端报错

3.1 输入格式化:卡号、金额和中文输入法

账号输入框是体验重灾区。如果用户输入16到19位银行卡号,没有分段显示,很容易看串行。基础做法是监听input事件,去掉所有非数字字符后按四位一组插入空格。但这里有个容易出错的点:中文输入法。用户如果在输入状态下直接按数字,compositionstart和compositionend事件会干扰input事件,导致光标乱跳、数字重复。

稳妥的做法是用一个isComposing标记,在compositionend之后再触发格式化。类似这样的思路:

let isComposing = false; input.addEventListener('compositionstart', () => { isComposing = true; }); input.addEventListener('compositionend', () => { isComposing = false; formatAccount(); }); input.addEventListener('input', () => { if (isComposing) return; formatAccount(); }); function formatAccount() { const raw = input.value.replace(/\D/g, '').slice(0, 19); const parts = raw.match(/.{1,4}/g) || []; input.value = parts.join(' '); }

金额输入框不能用number类型了事,因为浏览器自带的步进按钮在单据场景里很碍事,而且小数点位处理也不可控。我建议用text输入框,配合input事件过滤非法字符,只允许数字和小数点,并且最多保留两位小数。过滤时注意正则里“一次只能出现一个小数点”这类细节:

function cleanAmount(value) { let v = value.replace(/[^\d.]/g, ''); const firstDot = v.indexOf('.'); if (firstDot !== -1) { v = v.slice(0, firstDot + 1) + v.slice(firstDot + 1).replace(/\./g, ''); } const parts = v.split('.'); if (parts[1] && parts[1].length > 2) { v = parts[0] + '.' + parts[1].slice(0, 2); } return v; }

这些格式化逻辑最好放在公共工具函数里,因为回单确认页还可能复用。

3.2 校验规则:必填之外,还要做一致性校验

汇款单页面校验不止是“不能为空”。真正影响用户的是账号一致性、格式正确性、金额范围。比如用户要填两次收款账号防止输错,前端需要对比两个输入框的值并给出明确提示。这里不要用alert弹窗,应该在字段下方显示错误文本,同时通过aria-describedby关联错误信息区域,方便读屏软件把错误读出来。

<label for="payeeAccount">收款账号</label> <input id="payeeAccount" name="payeeAccount" aria-describedby="payeeAccountError" /> <p id="payeeAccountError" class="field-error" role="alert"></p>

校验时机也很重要。不要在用户刚输入第一个字符时就报“必填”,也不要等到点提交才把所有错误一次性抛出来。我采用的是“失焦校验一次,提交时全量校验”的方式。失焦校验能及时反馈,提交时再整体检查一遍,避免用户漏掉某个字段。

金额校验有几个容易忽略的边界:金额必须大于0,不能超过单笔限额,最多两位小数。如果后端返回“超限”错误,前端要能把这个错误映射到金额字段下方展示,而不是放在页面顶部。字段级错误提示比全局埋单醒目得多。

3.3 实时联动:金额大写和用途字数

当用户输入金额时,右上角或下方的中文大写金额要同步变化。这里不需要每次都立刻触发复杂的转换函数,可以加一个很轻的防抖,比如300毫秒。但如果用户使用的是Vue或React,响应式数据本身已经足够快,防抖主要是防止在composition输入过程中频繁更新带来闪烁。

let amountTimer = null; amountInput.addEventListener('input', () => { clearTimeout(amountTimer); amountTimer = setTimeout(() => { amountPreview.textContent = amountToChinese(amountInput.value); }, 300); });

用途和附言字段要做字数统计。银行单通常有长度限制,比如用途不超过20字,附言不超过50字。在页面右下角显示“10/20”这样的计数,超过限制后直接截断,而不是允许输入后再校验报错。前端主动限制输入长度,能减少很多无效提交。

4. 打印导出与页面防篡改:从“能看”到“能用”

4.1 打印样式:让浏览器输出接近纸质单据

打印样式是这个项目的重头戏。我一般单独写一个print.css,通过@media print引入。核心要做三件事:隐藏操作区、设置纸张尺寸和页边距、保证表格不在行中间分页。

@media print { body { background: #fff; margin: 0; padding: 0; } .no-print { display: none !important; } .slip-page { width: 100%; margin: 0; padding: 12mm 14mm; box-shadow: none; aspect-ratio: auto; } .slip-table { font-size: 12px; } tr { page-break-inside: avoid; } thead { display: table-header-group; } }

这里有一个关键点:屏幕显示时容器用了aspect-ratio模拟A4,但打印时如果仍然保留这个属性,浏览器可能会按比例压缩内容,所以打印样式里要把aspect-ratio: auto重新设置。页边距可以放在@page规则里:

@page { size: A4; margin: 10mm; }

如果页面内容比较长,thead会重复出现在每页顶部,这样第二页也能看到表头,不会割裂信息。tr设置page-break-inside: avoid能避免一行的内容被切到两页。

4.2 加水印:屏幕水印和打印水印分开处理

水印对汇款单来说不只是装饰,更多是“防截图乱传”的心理暗示和追溯手段。前端常见做法是生成一个重复的斜向文字水印层,可以用CSS伪元素配合position: fixed实现,也可以用canvas绘制。

我看过很多页面用background-image: url('data:image/svg+xml')来铺水印,这种方式直观,但数据量比较大。更轻盈的做法是用一个覆盖全屏的半透明div,里面放若干个span。但这些span如果直接用div写死,打印时会很难看,所以屏幕水印和打印水印要区别对待。

.watermark { position: fixed; inset: 0; z-index: 9999; pointer-events: none; opacity: 0.35; } .watermark span { position: fixed; left: 10%; top: 20%; transform: rotate(-30deg); font-size: 18px; color: #8a8a8a; white-space: nowrap; } @media print { .watermark { position: absolute; opacity: 0.25; z-index: 0; } }

打印时如果不想让水印盖住关键文字,可以降低打印水印的透明度。如果回单需要法律效力,更严谨的做法是后端在生成的PDF上加水印,前端水印只能管住浏览器页面这一层。这个边界要向产品说清楚,不要承诺“前端水印能防止一切截图”,因为浏览器页面上的任何效果都无法真正阻止有人用其他工具处理图片。

4.3 导出图片和PDF的取舍

客户经常想要一个“下载回单”按钮,前端会想到html2canvas或dom-to-image。如果需求只是“生成图片凭证”,前端可以做,但要处理三个问题:图片模糊、样式还原度、跨域资源。页面里的图片如果来自CDN,canvas导出时会因为跨域污染画布,需要开启crossOrigin。同时,截图出来的图片是位图,放大后会模糊,也不利于长期保存。

我的建议是把“下载回单”分成两条路:需要存档的,走后端生成PDF,前端拿链接下载;只是临时发群里看看的,前端html2canvas生成一张JPG或PNG。如果后端一时半会没资源做PDF,可以先用浏览器打印对话框里的“另存为PDF”,这个能力是浏览器自带的,几乎零成本。用户点“下载PDF”,本质上可以触发window.print(),然后在打印对话框里选择目标打印机为“另存为PDF”。

4.4 防篡改提示:前端能做的和不能做的

你在网上搜“汇款单防篡改”,会看到很多方案。但作为前端,我们必须清楚:页面DOM里的内容是用户和浏览器都能改的,前端加再多的哈希、暗水印,都不能真正防止一个懂技术的人修改数据。真正防篡改靠的是后端签名、交易流水号和审计日志。前端能做的只有展示层面的“防误改”——回单状态禁止编辑、背景加防伪纹理、水印带上用户标识和生成时间。

实践中我会在回单页脚下加一行小字:“本回单由系统生成,可通过凭证编号在某渠道验真。”这行字不在前端数据模型里,只是模板文本,但在用户心理和业务合规上有价值。前端在展示回单时,还要把“交易状态”字段突出显示,比如成功状态用深绿色,失败状态用红色,避免用户只看金额不看结果。

5. 常见问题与排查技巧实录

5.1 金额大写转换结果不对

这是多到可以单独写一篇排查文章的问题。最容易出错的点集中在“零”和“整”的处理。比如1001元,中文大写应该是“壹仟零壹元整”,中间的零很容易丢;比如10.05元,应该是“壹拾元零伍分”,这里“零伍分”的零也容易丢。我在开发时整理过一个自测用例表,每改一次转换函数都跑一遍:

输入金额期望输出
0零元整
1001壹仟零壹元整
10.05壹拾元零伍分
1010.10壹仟零壹拾元壹角
100000000壹亿元整

不要只测整数,角分边界一定要测。我自己写的转换函数早期就没有处理“10元整”,导致输出“壹拾元整”才补上。如果不想维护这种算法,就用“分”为单位在后端转换,前端只负责展示。正式项目里,以后端结果为准是最好的。

5.2 打印出来没有边框和背景色

浏览器打印默认不打印背景图片和背景色,这几乎是新手必踩的坑。即使你在@media print里设置了background: #f7f8fa,浏览器默认设置也可能把背景图形关掉。用户必须在打印对话框里勾选“背景图形”选项,这个前端控制不了。所以我的经验是:汇款单的关键信息不要只靠背景色区分。类型标签和字段值之间,要用边框和字体大小来区分,而不是依赖底色。这样即使背景图形没打出来,版面也依然清晰。

如果产品经理坚持要求打出来必须有底色,那就要在页面里用内联样式或者真实图片来做底色,而不是CSS background。不过这会增加实现的复杂度,一般不建议。

5.3 银行卡号输入时光标乱跳

这个问题的根源就是我前面提到的composition事件没有处理。中文输入法下输入数字,input事件会在未组词阶段触发,如果此时格式化替换了value,正在输入的拼音可能被清掉,光标就会跳到末尾。解决思路就是加isComposing标记,在组词结束前不执行格式化,组词结束后再统一处理一次。

另外,给输入框加maxlength属性时,要注意带空格的账号实际长度会超过原始号码长度。如果你设置maxlength="23",但用户输入每四位带一个空格,真实数字长度只有19位,这个要算清楚。要么在格式化时动态计算,要么干脆不用maxlength,而是用slice截断数字。

5.4 移动端打开页面错位

银行汇款单页面不只是在电脑浏览器里用,现在很多人用手机查看回单。移动端的主要问题是表格双列显示太拥挤。我建议在小于768px的视口下,把表格单元格改成块级展示,标签和值上下堆叠。打印样式和移动端样式不要混在一起,因为打印通常是桌面视口。移动端字体也不要小于12px,否则浏览器会自行缩放,导致页面布局更乱。

@media screen and (max-width: 768px) { .slip-table, .slip-table tbody, .slip-table tr, .slip-table td { display: block; width: 100%; } .slip-table .label { background: transparent; padding-bottom: 2px; font-size: 12px; color: #888; } }

如果页面涉及点击跳转,比如“查看验真详情”,移动端按钮要足够大,至少44px高,否则很容易误触。屏幕水印在移动端也要调低高度,fixed定位的水印层如果铺太多文字,会挡住阅读。

6. 实操总结与一点心得

如果让我给第一次做电子汇款单的前端一个建议,我会说:先别急着写代码,先把“录入、确认、回单”三种状态画出来,再把打印样式和屏幕样式当作两个独立页面来做。这份工作的难点不在技术本身,而在于把用户的输入、系统的回执、纸张的输出统一在一个模型里。

我记得第一次做这类页面时,卡在金额大写的“零”处理上改了三天。事后回头看,其实可以用一个很笨但很稳的办法:先把已知边界全部写成测试用例,再让转换函数跑用例,一次一次补。前端开发里很多看起来“高大上”的逻辑,最后靠的都是这种朴素方法。

还有一点是关于业务边界。金融类页面的数据准确性永远比视觉效果重要。前端可以做各种酷炫动画,但汇款单页面的核心是“准确、清晰、可核对、可打印”。因此我在页面设计里几乎不使用花哨的圆角大阴影,边框和留白才是这个场景的主角。水印、防篡改这类需求,也必须跟产品和后端确认分工,前端别大包大揽,否则只能做到“看起来安全”。

最后一招分享给正在做类似页面的朋友:开发时把浏览器窗口调成不同缩放比例,再打开打印预览看一遍。很多样式问题在预览模式下一眼就能看出来,远远好过上线后被财务同事截一张打歪的图来找你。做银行电子汇款单,能把基础打好,把细节做稳,就已经是很扎实的交付了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进