☰
Vue项目接入LDP本地打印服务:门店小票打印完整实践
2026/10/17 13:19:39 网站建设 项目流程

做vue项目时接到一个很小的需求:门店小票打印。后台管理系统、收银机、订单列表都在Vue里跑得好好的,唯一的硬骨头就是点一下“打印小票”,要让顾客拿到一张排版像样、能扫出支付记录的热敏小票。一开始我以为浏览器里window.print()就能糊弄过去,真做起来才发现完全不是那么回事,最后老老实实接了LDP这类本地打印服务,才把这条链路彻底打通。

借这个机会,我把整个接入过程、踩过的坑、模板设计的细节都梳理出来。文章适合正在做Vue项目、需要对接小票打印机的前端开发者,也包括刚接手这类收银改造、还不太清楚小票打印技术原理的朋友。你如果能完整看完,从零接入到真机调优应该不会有太大的坎。

1. 小票打印为什么绕不开LDP这类打印服务

1.1 浏览器直接打印到底差在哪里

先说个很反直觉的事实:小票打印机本质上是一个非常“专一”的设备,它和普通A4激光打印机完全是两种生物。普通打印机走的是系统打印驱动,浏览器还能通过打印接口把网页内容交给驱动;但小票机走的是串口、USB虚拟串口或者网口,接收的指令往往是厂家自定义的指令集,比如常见的ESC/POS指令。浏览器并不能直接把HTML翻译成这些指令。

如果硬要用window.print()打小票,你会遇到至少三座大山:

  • 纸张尺寸不可控。浏览器打印默认是A4、Letter这类常规纸张,虽然可以在CSS里写@page设置自定义尺寸,但不同内核、不同版本的内核解析结果都不一样,你在Chrome里调好了,到了某国产浏览器就全乱了。
  • 页边距、页眉页脚总有惊喜。用户打印时随便勾选个“页眉页脚”,小票顶端就会出现一排URL,门店票面全是乱码一样的内容。
  • 预览弹窗打断收银节奏。客人排队等着呢,收银员每打一张票都得在浏览器预览窗口点一下确认,体验非常糟糕。

所以做小票打印,前端真正需要的不是“打印网页”,而是“把数据交给能直接控制打印机的程序”。这正是LDP这类打印服务的价值所在。

1.2 LDP打印服务的工作链路和Vue的关系

LDP是业界这类本地打印中间件的统称,页面通过一个全局API把排版指令发给本机常驻的打印服务程序,打印服务再把指令转换成小票机能听懂的信号。整个链路大概是:

Vue组件生成打印数据 -> 调用LDP客户端提供的JS接口 -> 本地LDP打印程序接收指令 -> 指令发送到对应端口的小票机 -> 出票

这里有个需要先想明白的点:Vue只是“组织数据的人”,真正干活的是本地打印服务。页面和后端接口怎么设计、订单数据长什么样,打印服务完全不关心,它只关心你有没有把一张票的文本、条码、线条坐标都排好交给它。所以在整个项目里,Vue侧要做的就是三件事:拼数据、排模板、调API。

而LDP这类中间件之所以在收银场景里被广泛使用,除了能直接控制打印机,还因为它在Windows系统上非常稳定,能把任务牢牢排在本地队列里,支持切纸、钱箱、不开预览直接打印等操作。这些能力,浏览器打印API一个都不具备。

1.3 适用场景判断:不是所有打印需求都要上LDP

小票打印用LDP几乎是标配,但我也遇到过同事拿着这套方案去打印普通A4报表的,那就是过度设计了。简单做个场景区分:

需求特征适合方案
80mm/58mm热敏小票,需要精确控制纸张宽度、布局LDP这类本地打印服务
需要控制钱箱、切纸、二维码、条码LDP这类本地打印服务
内网后台偶尔打印A4订单表格,用户会用浏览器预览普通浏览器打印就够
手机端/平板端页面打印小票都不合适,见第5章移动端替代方案
大量打印任务,需要静默出票不打扰收银员必须用本地打印服务

判断标准其实很简单:先看纸宽,再看是否允许弹预览,最后看要不要控制钱箱。只要有一条对得上,就应该走LDP方案,而不是在浏览器打印样式里死磕。

2. Vue项目接入LDP打印服务的完整落地流程

2.1 本地打印服务的安装与JS文件的引入

先说前置条件。收银电脑上必须先装好LDP对应的打印服务程序,这个服务安装完会在系统里注册一个本地进程,负责和打印机通信。网页里那套JS API,本质上只是个“传话的”,真正执行打印的是这个本机程序。

所以第一步是:去打印服务官方渠道下载对应操作系统的安装包,装到收银电脑上。装上之后,任务管理器里能看到对应的进程在跑,这一步很关键,服务没起来后面全都白搭。

第二步是把打印服务配套的JS文件放到项目里。常见做法是把LodopFuncs.js(这里我统一用LDP指代,实际文件名以你下载的安装包为准)放到public/static/下,然后在index.html里用普通script标签引入。为什么放public而不放src里通过import引入?因为这个JS文件会在全局挂载一个打印对象,它不遵循模块化导出规则,用Vite或webpack去打包反而容易出兼容问题。直接用普通标签引入最省事。

2.2 打印前置检查:把“服务没装”变成可提示的友好信息

页面加载后不能直接调打印API,否则会得到一个很粗暴的报错。常规做法是在打印动作触发前,先检测本地打印服务是否存在。这个检测本身并不复杂,伪代码思路如下:

// 检测LDP打印服务是否可用 function checkLdpReady() { // 不同版本的打印服务暴露的全局对象名不同,这里统一抽象 if (!window.LDP) { return { ready: false, reason: 'not_installed' }; } // 部分场景是服务装了但进程没起来 try { const ldp = window.LDP; const printerCount = ldp.GET_PRINTER_COUNT(); if (printerCount <= 0) { return { ready: false, reason: 'no_printer' }; } } catch (e) { return { ready: false, reason: 'service_error' }; } return { ready: true, reason: '' }; }

我建议把这个检测封装成一个带弹窗提示的函数。用户没装服务时,提示他去下载安装并重启页面;装了服务但没接打印机,提示他检查线缆和驱动;服务进程崩了,提示重启服务。不要直接抛一堆底层错误字符串给门店收银员,他们看不懂“CLODOP”报错是什么,只会觉得你的系统坏了。

2.3 封装一个可复用的打印服务模块

接入阶段最容易犯的错,是每个页面都直接去调全局API,导致打印逻辑散落各处。我会把打印相关操作全部收敛到一个printerService.js里,这样后续换打印服务商、调整打印参数,都只需要改这一个文件。

// src/services/printerService.js let ldpInstance = null; // 获取LDP打印对象,重复调用只初始化一次 function getLDP() { if (ldpInstance) return ldpInstance; if (!window.LDP) { throw new Error('LDP打印服务不可用,请先安装并启动打印服务'); } ldpInstance = window.LDP; return ldpInstance; } // 初始化打印参数:纸张宽度、方向等 function initPrinter(paperWidth) { const ldp = getLDP(); // 这里的几个参数按实际API调整,下面数字只是示意 ldp.SET_PRINT_PAGESIZE(1, paperWidth, 0, 0); ldp.SET_PRINTER_INDEX_A(0); // 选择默认小票机 } // 新增一行文本 function addText(content, options = {}) { const ldp = getLDP(); const { x = 0, y = 0, height = 20, font = '宋体', size = 3, bold = false, align = 'left' } = options; // 该API的本意是:增加一个文本打印项到当前任务 ldp.ADD_PRINT_TEXT(x, y, 600, height, content); // 再对样式做细粒度设置,具体项名以实际文档为准 } // 增加分隔线,这是小票里最常用的装饰元素之一 function addLine() { const ldp = getLDP(); ldp.ADD_PRINT_LINE(0, 0, 600, 0, 1); } // 执行打印,可选是否预览 function print(preview = false) { const ldp = getLDP(); if (preview) { ldp.PREVIEW(); } else { ldp.PRINT(); } } export default { getLDP, initPrinter, addText, addLine, print, };

封装的好处是,页面里永远不出现打印API的铁锈味,业务代码只关心“我要打印什么内容”。这套抽象不一定完全符合你手里的控件命名,但思路是一致的:对外提供语义化方法,对内收敛控件的脏活累活。

2.4 从按钮点击到小票出来的完整代码链路

封装完服务层之后,页面调用就非常清爽了。以某个订单详情页为例:

// 订单详情页某个组件内 import printerService from '@/services/printerService'; function handlePrintTicket(order) { // 1. 检测环境 const check = checkLdpReady(); if (!check.ready) { showInstallTip(check.reason); return; } // 2. 初始化打印参数(80mm纸为例) printerService.initPrinter(800); // 这里的单位通常是一个固定值,具体按控件文档 // 3. 设置任务起始位置 const ldp = printerService.getLDP(); ldp.SET_PRINT_STYLE(0, 'FONTNAME', '黑体'); // 4. 往模板里添加内容,这里只是示意 printerService.addText('XX连锁便利(万达店)', { align: 'center', bold: true, height: 30 }); printerService.addLine(); printerService.addText('订单号:' + order.no, {}); printerService.addText('时间:' + order.createdAt, {}); printerService.addLine(); order.items.forEach(item => { printerService.addText(`${item.name} x${item.count} ${item.price}`, {}); }); printerService.addLine(); printerService.addText('合计:¥' + order.totalAmount, { align: 'right', bold: true }); printerService.addText('优惠:-¥' + order.discount, { align: 'right' }); printerService.addText('实收:¥' + order.paidAmount, { align: 'right', bold: true }); printerService.addLine(); // 5. 增加二维码信息,订单核销用 ldp.ADD_PRINT_QRCODE(300, 100, 0, 0, order.qrCodeContent); // 6. 执行打印,不弹预览,直接出票 printerService.print(false); }

这里有一个很容易忽略的点:打印任务一旦开始,调用的文字、线条、二维码都是按顺序叠加在一个“任务纸面”上的。LDP这类API不是流式排版,而是绝对定位。也就是说,每一行文本往哪里放,你得自己控制y坐标的累加,控件不会帮你“换行”。模板写得糙,小票就会字叠字。这一点在下一章专门展开。

3. 小票模板设计:哪些参数决定一张票是否专业

3.1 动手写模板前的三条硬约束

我把这几年做小票模板的经验浓缩成三条铁律,写代码前先想清楚这三件事,后面能省掉大量调试点。

第一,先确认打印机纸宽。市面上最常见的是80mm和58mm两种热敏纸。这里要注意,纸宽不等于可打印宽度。80mm热敏纸的实际可打印宽度一般在72mm左右,两侧各有4mm左右的边距;58mm纸的可打印宽度大概在48mm到56mm之间。排版时如果把打印区域设置成800这类整数,很可能超出实际纸面,右边的内容会被切掉。

第二,字体选择要克制。小票机上的字体渲染能力和普通电脑不一样,很多花哨字体根本没安装,你指定了也是白搭,控件会自动回退到默认字体。我一般只用宋体、黑体、Arial,中文宋体、数字Arial,看起来最干净。字号也不要肆意扩大,80mm纸一行能放得下20个汉字已经是极限了,再大就是换行灾难。

第三,高度要自己累加。把打印任务想象成在一个画布上画格子,每个格子(文本行)多高、离左边多远、离顶边多远,全部由你指定。一行30像素高,就把下一行的y坐标加上30。这个累加过程很容易出错,我的习惯是用一个变量let y = 0;然后逐行累加,每行后面写注释,方便回头调。

3.2 常见小票模板的结构拆解

一张标准的小票,从上到下通常是店名、订单信息、商品明细、金额汇总、二维码/条码、存根提示。这里我给一个可以直接参考的模板生成函数:

function buildTicketTemplate(order) { const ldp = printerService.getLDP(); let y = 0; const lineHeight = 28; // 店名,居中 ldp.ADD_PRINT_TEXT(60, y, 600, 40, order.shopName); ldp.SET_PRINT_STYLE(0, 'FONTSIZE', 4); ldp.SET_PRINT_STYLE(0, 'BOLD', 1); y += 40; // 分割线 ldp.ADD_PRINT_LINE(20, y, 580, y, 1); y += 20; // 订单信息,左对齐 ldp.ADD_PRINT_TEXT(20, y, 600, lineHeight, `单号:${order.no}`); y += lineHeight; ldp.ADD_PRINT_TEXT(20, y, 600, lineHeight, `时间:${order.time}`); y += lineHeight; // 商品明细区域 order.items.forEach(item => { const line = `${item.name}`; ldp.ADD_PRINT_TEXT(20, y, 300, lineHeight, line); ldp.ADD_PRINT_TEXT(340, y, 260, lineHeight, `${item.count} × ${item.price}`); y += lineHeight; }); // 金额区 ldp.ADD_PRINT_LINE(20, y, 580, y, 1); y += 20; ldp.ADD_PRINT_TEXT(20, y, 400, lineHeight, '合计'); ldp.ADD_PRINT_TEXT(360, y, 240, lineHeight, `¥${order.total}`); y += lineHeight; ldp.ADD_PRINT_TEXT(20, y, 400, lineHeight, '实收'); ldp.ADD_PRINT_TEXT(360, y, 240, lineHeight, `¥${order.paid}`); y += lineHeight; // 二维码,位置可以固定在页面下方 ldp.ADD_PRINT_QRCODE(200, 400, 0, 1, order.qrCode); return y; }

这样写出来的模板非常直观,每一行的坐标都是可预期的,后面哪一行间距不对,直接改y的累加值就行。不要小看这个土办法,它比花里胡哨的HTML模板模式稳定得多。

3.3 条码、二维码、剪切线和钱箱的控制

小票里最常见的三样附加要素分别是条码、二维码、切纸指令。条码用于商品编码或订单追踪,一般建议用CODE128,因为它支持字母数字混合;二维码用于支付凭证或到店核销,内容一般是订单号或者带签名信息的URL。

剪线和钱箱是很多人第一次接手时会忽略的。剪线的意思是打印完内容后,小票机自动切纸,操作上只是打印一个特殊控制指令,不需要额外排模板。钱箱控制则是一条专用指令,在收银场景里如果每笔订单完成时希望钱箱自动弹开,就在打印任务最后加上这个控制字符。很多小票机把这两个能力和打印机型号绑定,驱动层不开启的话,指令发了也没用,需要先在打印机配套工具里确认支持。

3.4 模板只负责渲染,数据计算别混进来

一个让我印象深刻的教训:早期版本我把金额、找零、折扣这些计算逻辑写在了打印模板函数里,结果某天后端改了优惠币规则,前端模板里的旧公式没同步,小票上的金额和系统订单页金额对不上,被顾客当场发现。从那以后我定了规矩:打印模板函数只接收一个已经计算完成的纯数据结构,不做任何业务计算。

const receiptData = { shopName: order.shopName, no: order.no, time: order.time, items: order.items.map(it => ({ name: it.name, count: it.count, price: formatMoney(it.payPrice), // 格式化也放外面 })), total: formatMoney(order.total), paid: formatMoney(order.paid), qrCode: order.qrCode, }; // 模板函数只负责把receiptData摆到纸面上

这样设计有个好处:想给模板加个“门店地址”字段,直接在数据组装处加一个属性就行,完全不碰模板布局逻辑。后续如果接外卖平台、对接第三方订单,数据结构和模板都能复用。

4. 真机调试阶段的常见坑与完整排查链路

4.1 “没有可用的打印服务”提示到底从哪来

先看最常见的报错:点击打印,页面提示类似“打印服务不存在或服务异常”。我按下面的链路排查,基本三分钟内能定位。

第一步,确认服务是否真的装了、启动着。打开任务管理器进程列表,搜打印服务相关进程。没有这个进程,说明安装失败或者被安全软件误杀了,重装并放行即可。第二步,确认网页是否加载了配套JS。打开开发者工具,在Console输入window.LDP,如果输出undefined,说明JS文件没挂上。第三步,确认端口和打印机驱动。打开打印服务自带的配置界面,点“测试打印”,如果能打出一张测试票,说明服务和打印机是通的,问题一定在网页侧。

我把这个排查顺序做成了一张简单的决策表,新人照着查就行:

报错现象排查步骤大概率原因
网页上提示服务不可用查进程、查window.LDP服务没启动 / JS没引入
服务正常但打印按钮无响应看打印服务窗口有没有报错任务打印任务被卡住,重启服务
任务入队但没有纸出来打印服务里点“测试打印”打印机连接、驱动或纸张问题,与前端无关
有纸但内容和模板不符预览模式对比坐标越界或字体问题

这个排查链路稳定复现过很多次,先解决环境问题再谈模板问题,能少走很多弯路。

4.2 模板在预览里正常,真机却缺内容或串行

这是最让人头疼的坑:预览模式一切正常,和真的打印出来对不上。

我复现过几次之后归纳出几个常见原因。第一,坐标越界。预览时窗口会自动缩放,界面上看起来内容在纸内,真机按原始像素输出时,内容超出了纸宽,直接被打印机截掉。第二,字体差异。预览用的是电脑上安装的完整字体,真机打印服务进程可能拿不到某些字体,于是回退到默认字体,行高变了,后面的内容位置全部错位。第三,字符编码。商品名里有空格、特殊符号时,模板里没做encode处理,打印服务收到的内容被截断,后半行消失。

排查方法也很笨但有效:把打印模式切成“文本模式”而不是“HTML模式”。文本模式只保留最基础的坐标+文字,能最大限度还原真机行为。如果文本模式正常、HTML模式不正常,就说明问题出在CSS兼容性上——这是使用HTML模板打印的典型陷阱,排版一时爽,真机火葬场。所以我推荐优先用基础API一行一行排文本,别把一整个HTML块丢进去。

4.3 连续打印任务串单、顺序错乱

门店场景很常见:顾客A刚付款,打印任务还没出完,顾客B又付款了。页面连续触发两次打印,第二次的任务可能会排在第一次前面,或者两张票内容贴在一起。

这个问题的根源在于:Vue里的打印调用是异步的,但打印服务处理任务是单队列的。你两次调用虽然按顺序发出,但如果第一次调用的回调还没执行完,第二次调用又开始了,任务状态就会混。

解决办法有两个方向,我通常在项目里同时做:

  • 数据上给每次打印分配唯一任务号,模板里打上单据号,哪怕顺序乱了也能人工识别;
  • 代码上做打印队列,保证同一时间只有一个打印任务在飞。

队列实现并不复杂,核心就是一个Promise链:

let printQueue = Promise.resolve(); function enqueuePrint(task) { printQueue = printQueue.then(() => task()).catch(e => { console.error('打印任务失败', e); }); return printQueue; } // 调用侧统一走队列 enqueuePrint(() => handlePrintTicket(orderA)); enqueuePrint(() => handlePrintTicket(orderB));

这个队列保证任务一个接一个执行,前一个出现异常也不会阻塞后面的。我见过很多团队忽略这一步,直到门店高峰期出现连续丢票才开始排查,其实在接入初期就加上队列,成本很低。

4.4 回调不执行的真正原因:作用域和闭包陷阱

打印服务API里通常有回调函数,用来告诉你“任务已完成”或“任务失败”。很多朋友在Vue里直接写箭头函数,结果发现回调完全不触发。

我踩过一次后仔细看了源码才发现,回调函数里如果使用了外层的this,而这个this已经被Vue组件的响应式代理污染了,打印服务内部拿到的是一个Proxy对象,调用回调时按它自己的规则去做参数拼接,结果整个函数没被正确注册。解决办法很简单:回调函数写成普通函数,或者完全不依赖this,只依赖传入的参数。

// 不推荐的写法:箭头函数内部依赖组件this ldp.SET_PRINT_CALLBACK((result) => { this.printStatus = result; // this指向不可靠 }); // 推荐写法:用普通函数+外部变量传递状态 let currentPrintStatus = ''; ldp.SET_PRINT_CALLBACK(function (result) { currentPrintStatus = result; });

如果你必须更新组件状态,可以把这个状态用Vuex或事件总线通知组件,不要在回调里直接操作组件实例。这个问题的本质是打印服务API不是为响应式框架设计的,它的回调是纯原生的,你得顺着它的规则来。

4.5 关于授权和打印测试页,给新手的三个建议

第一个建议:商业使用之前确认授权。这类打印中间件通常有免费版和商业授权版,免费版会在打印内容里附加提示信息,或者限制打印任务数量,你在门店项目里直接用,很容易在高峰时段触发限制,到时候一张票都打不出来。正规做法是项目上线前就申请商业授权,这个费用通常不高,比临时救火划算得多。

第二个建议:永远在真机上保存一张“标准测试票”。我每次调完模板,都会打印一张包含边框、条码、二维码、长字符串、特殊字符的测试票,存档在项目文档里。后面同事改了什么参数,拿这张票一对比就知道哪里坏了。

第三个建议:把打印服务的日志级别打开。遇到疑难杂症不要瞎猜,日志里会明确记录每次任务的状态、错误码、打印机返回信息,很多时候答案就在最后几行日志里。

5. 再进一步:多联打印、静默模式与移动端替代

5.1 多联打印的两种实现思路

餐饮行业常要“一单一联”或者“一单两联”,后厨一张、前台一张。LDP方案里做多联,通常有两种做法。

做法一:在一张任务页里,把同样内容垂直复制一份,中间留出撕纸间距。这个适合产物其实是同一联、只是打印出来给不同人看的情况,比如前台联和后厨联内容完全一样。

做法二:调用控件的份数设置能力,打印份数设为2,打印机自动出两张内容相同的票。这个适合真正的“联单”,热敏纸两层复写纸的产品会自动带出第二联。需要注意:不是所有小票机都支持“份数”控制,有些型号需要驱动侧开启,而且份数和“任务重复执行”是两回事——份数是打印服务在底层控制的,如果打印服务不支持,就不要轻易用循环调用的方式模拟,否则很容易重蹈4.3节的串单问题。

5.2 静默打印:把预览模式限制在调试环境

正式上线后,收银员不应该看到预览弹窗。开发调试时又确实需要预览来检查排版。我的做法是把预览开关做成一个环境变量控制:

const isDebugMode = process.env.NODE_ENV === 'development'; function printTicket() { // 开发环境预览,生产环境直接打印 printerService.print(isDebugMode); }

这样开发时每次打印都能看到效果,上线后自动静默出票。这里有个细节:生产环境如果发现某台电脑打印异常,千万别让收银员去改代码。我会在旁边放一个维护脚本,临时把环境变量切回预览模式,远程指导时能直观看到问题。

5.3 移动端不支持的现实与替代方案

LDP这套方案几乎只能在桌面浏览器里用。移动端、平板端的浏览器拿不到本地打印服务,这条路根本走不通。如果你的场景是“店员拿iPad开单、旁边放着一台蓝牙小票机”,那就需要重新评估架构了。

常见的移动端替代方案有三类:

  • 厂家云打印。打印机厂商提供云服务,前端直接把票据数据推到云端,再由绑定的小票机实时打印。优点是部署方便、门店不需要固定电脑,缺点是有网络延迟、依赖第三方服务的稳定性。
  • 蓝牙直连方案。通过系统原生能力或小程序能力连接蓝牙打印机,前端在H5里搞不定,需要套一层小程序或原生壳。
  • 农夫式方案:桌面端照常用LDP,移动端只负责“下单成功”,出票交给桌面端监听。这在门店同时有收银电脑和店员移动设备时很实用,也最容易实现。

选型建议:如果项目一开始就确定移动端是主要场景,就别再用桌面打印服务的老思路,直接走云打印或蓝牙;如果只是偶尔用手机补打,桌面端监听方案性价比最高。

5.4 最后的实用建议

做小票打印这件事,技术难度不高,但坑全在细节里。我个人现在的习惯是:拿到项目第一件事先问清楚打印机型号、纸宽、要不要钱箱、要不要联动后厨,先和门店把硬件约束对齐,再回工位写模板。等模板调完,先把测试票打印出来贴在设备旁边,确认无误后再推进上线。

模板参数这种细活,宁愿多花半小时调间距,也别让门店的一线收银员天天对着歪七扭八的小票抱怨。毕竟,小票是顾客对门店的第一张“纸质名片”,同样也是这套系统在真实世界里留下的唯一痕迹。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询