首页 / 资讯中心 / 文章详情

Electron+Vue 静默打印58mm热敏小票全链路拆解

Electron+Vue 静默打印58mm热敏小票全链路拆解 ★ FEATURED ARTICLE
简介面向 Electron 桌面端开发者的打印小票功能示例工程基于 electron 与 Vue CLI 3 搭建聚焦本地打印机设置与静默打印流程的落地实现。项目以 JavaScript 为主包内共 25 个文件含 14 个 js 业务与主进程脚本、3 个 vue 组件、2 个 json 配置、2 个 html 打印页面及图标等静态资源压缩包约 117KB体量轻便、便于快速运行与二次改造。已有 3665 人学习下载。工程把打印逻辑拆分为主进程与渲染层渲染层由 Vue 组件触发打印主进程负责调用系统打印能力并通过 electron-store 持久化用户选择的打印机名称未设置时弹出配置窗口设置完成后直接静默输出形成完整的交互闭环。目录按 src/components、main-process、assets、config 与 public 分层主进程 background.js、打印页 print.html、入口 main.js 等职责明确适合需要理解 Electron 主渲染通信、打印参数配置与本地存储方案的开发者参考也可作为收银、票据类桌面应用的起步脚手架。1. 门店收银台前的 3 秒钟藏着一整条 Electron 打印链路收银员扫完最后一件商品按下“结算”旁边那台 58mm 热敏小票机“咔”一声开始出纸。整个过程没有弹窗也没有让用户选择打印机连强烈的加载动画都没出现。对顾客来说这只是 3 秒钟的事对写代码的人来说这 3 秒钟里发生了一次跨进程通信从 Vue 渲染进程里组装订单数据把数据送到 preload 暴露出来的桥接方法再由主进程接收接着主进程开一个隐藏窗口、加载小票模板、填充数据、调用webContents.print最后把结果回传给渲染进程。这个 electron vue-cli3 的小票打印 demo做的就是这条完整链路。它适合两类人一类是正在做零售、餐饮、门店收银类桌面应用需要对接 58mm 或 80mm 热敏小票机的开发者另一类是已经能用 Electron 做点东西但每次碰静默打印、页面尺寸、打包后资源丢失就出问题的同学。下面按我的拆解顺序先把工程骨架讲清楚再逐段看打印代码最后落到怎么换成自己的小票模板。2. 工程骨架拆解主进程、渲染进程与打印页的三方分工2.1 目录结构一份典型的 electron vue-cli3 工程这个 demo 的基本形态是 vue-cli3 项目加vue-cli-plugin-electron-builder。之所以不是直接手撸 webpack 双入口是因为 vue-cli3 的插件体系已经把主进程打包、开发环境热更新、构建产物目录这些事处理掉了你只要遵守它的约定目录剩下的精力可以全放在业务上。常见的目录形态大致如下electron-vue-print/ ├── public/ │ ├── index.html # 主界面模板vue-cli 默认入口 │ └── print.html # 小票页模板打印专用独立于 SPA ├── src/ │ ├── background.js # 主进程入口electron-builder 插件约定 │ ├── preload/ │ │ └── index.js # 预加载脚本向渲染进程暴露受控 API │ └── renderer/ │ ├── main.js # 渲染进程入口 │ ├── App.vue │ ├── router.js │ ├── store.js │ └── views/ │ ├── Cashier.vue # 收银台主界面 │ └── Setting.vue # 打印机选择、测试打印 ├── vue.config.js ├── package.json └── babel.config.js这里面最关键的一点是public/print.html这个独立页面。很多新手会把小票模板做成一个 Vue 路由页面然后在主界面里“跳到那个路由再打印”。这种做法在开发环境里看起来没问题一旦到了生产环境路由切换、页面卸载、组件销毁时机全都会干扰打印结果。独立 HTML 页面的好处很直接它不参与 SPA 路由不带任何业务逻辑主进程什么时候需要就什么时候把它加载到一个隐藏窗口里打完了直接destroy()干净利落。src/background.js是主进程唯一的入口所有跟操作系统打交道的事情都往这里塞创建主窗口、注册 IPC 处理器、枚举打印机、执行打印。src/preload/index.js是主进程和渲染进程之间的唯一通道渲染进程能拿到哪些能力完全由它决定。2.2 两种构建产物怎么被 Electron 加载这一点是理解整个项目运转的基础。vue-cli3 在这里其实产出了两套东西主进程代码src/background.js及其依赖由 electron-builder 插件用 webpack 单独打一份 CommonJS 产物输出到dist_electron/目录package.json的main字段指向它。渲染进程代码src/renderer/走标准的 vue-cli SPA 构建流程输出到dist/或者插件指定的目录。开发阶段npm run electron:serve会先起一个 vue-cli 的开发服务器再把process.env.WEBPACK_DEV_SERVER_URL注入主进程环境变量。主进程创建窗口时判断这个变量存不存在存在就loadURL(devServerUrl)不存在就loadFile()加载本地打包好的 HTML。打印页的加载逻辑用的是同一套判断这是新手最容易漏掉的地方。开发时你能打开小票窗口、打包后就白屏八成就是因为打印页的路径只按开发环境写死了// 判断当前是开发环境还是打包后环境决定加载 URL 还是本地文件 const isDev !!process.env.WEBPACK_DEV_SERVER_URL function resolvePrintPage() { if (isDev) { // 开发环境走 dev server改模板能热更新 return ${process.env.WEBPACK_DEV_SERVER_URL}print.html } // 生产环境__dirname 指向 dist_electronprint.html 在 public 里被打到了同级目录 return file://${path.join(__dirname, print.html)} }这段代码的逻辑很直白但有几个参数值得停下来看一眼。process.env.WEBPACK_DEV_SERVER_URL是插件在开发模式下自动注入的形如http://localhost:8080/注意末尾带斜杠拼接print.html时不要再补一个。__dirname在主进程打包产物里指向dist_electron而public/目录下的静态文件会被复制到构建产物的根级所以print.html和background.js的产物在同一层path.join(__dirname, print.html)才能命中。如果你在vue.config.js里改过outputDir或者publicPath这个相对位置会变打包后必白屏。file://前缀也不能省。win.loadFile()是更稳妥的写法它会自动处理协议和路径转义但很多 demo 用的是loadURL加字符串拼接遇到中文路径或者空格就会翻车这个坑在后面的排查章节还会再提一次。2.3 进程分工谁负责拼数据谁负责碰打印机Electron 里最容易写乱的地方就是职责边界不清。这个 demo 的分工算是比较标准值得抄渲染进程Cashier.vue这些只干三件事拿到订单数据、按业务规则算好小票上要显示的字段、把结构化的 payload 交给桥接 API。它不require(electron)不碰BrowserWindow也完全不知道打印机叫什么名字。preload 脚本只负责“暴露能力”。它用contextBridge把几个方法挂到window上每个方法对应一个ipcRenderer.invoke通道参数原样传、结果原样回不写业务逻辑。这一层的作用是隔离渲染进程就算被注入恶意脚本也调不到ipcRenderer.send这种危险接口。主进程负责所有脏活累活创建隐藏窗口、加载小票模板、注入数据、等渲染完成、调print、回收窗口、把成败结果返回。它不关心订单里买了什么只认 payload 里的字段。这种切法有一个隐性好处打印逻辑可以单独测试。你在主进程里写一个printTest(deviceName)直接传一份假订单进去就能验证整条链路不需要真的走一遍收银流程。后面讲二次开发时这个能力会频繁用到。顺带说一句contextIsolation这个配置。Electron 12 之后默认开启渲染进程拿不到 Node 的原生requirepreload 里也不能再用window.xxx ...的方式直接挂方法了必须走contextBridge.exposeInMainWorld。如果你拿到的老项目在 preload 里直接写window.printAPI {...}升级 Electron 后必然报“找不到 printAPI”这一点在排查章节里会展开。3. 打印链路核心代码从 Vue 点击到 webContents.print3.1 渲染进程组装 payload 并发出打印请求收银台上的“结算并打印”按钮背后做的事情比想象中要多。订单里通常有商品明细、优惠、合计、支付方式、门店信息、流水号、时间戳小票上不可能全塞得按模板挑字段。我一般会在Cashier.vue里单独抽一个buildTicketPayload函数把业务对象转成打印模板能直接渲染的扁平结构这样以后换模板只改这一个函数。// src/renderer/views/Cashier.vue import { ticketApi } from /api/ticket async function handleCheckout() { // 1. 业务侧先落实订单拿到订单号和流水号 const order await ticketApi.submit(currentCart) // 2. 转成小票模板能直接吃的结构字段名和模板一一对应 const payload buildTicketPayload(order) // 3. 走 preload 暴露的桥接方法触发主进程打印 const res await window.printAPI.printTicket(payload) if (res.ok) { // 打印成功才清空购物车避免打失败丢单 clearCart() } else { // 失败时保留现场让收银员可以重打 showPrintError(res.message) } } function buildTicketPayload(order) { return { shopName: order.shopName, ticketNo: order.serialNo, // 小票流水号重打时用得上 createdAt: formatTime(order.paidAt), items: order.items.map(i ({ name: i.title, qty: i.count, price: (i.unitPrice / 100).toFixed(2), amount: (i.totalPrice / 100).toFixed(2) })), total: (order.payAmount / 100).toFixed(2), payType: order.payChannel, // 打印机名字从设置页读用户选过一次就存本地 deviceName: window.printAPI.getSavedPrinterName() } }这段代码里有几个点值得说。第一金额在数据库里一般存整数分小票上要显示成两位小数的元转换必须放在渲染进程做不要让主进程去猜单位。第二deviceName从本地配置读出来一起传过去主进程不需要知道配置存在哪。第三也是最重要的一点——打印成功才清空购物车。我见过不止一个项目点了结算先清空购物车再调打印结果打印机缺纸、驱动报错收银员当场傻眼只能靠回忆重录一遍订单。window.printAPI这个对象在渲染进程里是只读的它由 preload 通过contextBridge注入页面里的 JS 改不了它的方法指向。这也是为什么渲染层可以放心大胆地把打印入口暴露给业务代码。3.2 preload IPC把数据安全地送进主进程preload 这一层代码量很少但设计得好不好直接影响后面好不好维护。我的习惯是按能力分组暴露而不是一个大杂烩对象// src/preload/index.js const { contextBridge, ipcRenderer } require(electron) contextBridge.exposeInMainWorld(printAPI, { // 打印一张小票返回 { ok, message } printTicket: (payload) ipcRenderer.invoke(print:ticket, payload), // 枚举系统里的打印机设置页用 listPrinters: () ipcRenderer.invoke(print:list), // 打一张自检页验证驱动和纸张是否正常 printTest: (deviceName) ipcRenderer.invoke(print:test, deviceName), // 本地保存打印机名字纯本地读取快 getSavedPrinterName: () localStorage.getItem(printer.deviceName) || , savePrinterName: (name) localStorage.setItem(printer.deviceName, name) })ipcRenderer.invoke和ipcMain.handle是配对使用的走的是 Promise 语义天然支持 async/await比老的sendon加回调事件要清爽得多。参数通过结构化克隆传递普通对象、数组、字符串都没问题但函数、类实例、undefined之外的特殊对象传不过去payload 一定要保证是纯数据。localStorage在 preload 里能用因为 preload 有渲染进程的上下文。打印机名字这种一两条的配置放localStorage比走 IPC 存到主进程再读一次要快得多设置页打开就能立刻显示。如果你的配置项变多了比如纸张宽度、打印份数、页脚文案我一般会换成一个 JSON 串一次存下来读的时候JSON.parse一次搞定。这里有个细节contextBridge暴露的对象在渲染进程里是深冻结的你没法在页面上给window.printAPI加新方法也没法改已有方法的实现。这既是安全边界也提醒你——要在 preload 里把需要的 API 一次想全别等到业务写到一半再回来加。3.3 主进程隐藏窗口 数据注入 调用 print主进程是整个链路里代码量最大的部分。核心思路是每次打印都新建一个不可见的BrowserWindow加载print.html等页面渲染完成后注入数据再调webContents.print最后销毁窗口。这套流程的好处是每次打印都是一个干净的上下文不存在上一次的数据残留。// src/background.js const { app, BrowserWindow, ipcMain } require(electron) const path require(node:path) // 打印任务串行队列避免并发建窗口导致订单串号 let printChain Promise.resolve() ipcMain.handle(print:ticket, (event, payload) { // 把任务挂到链尾失败也继续往下走不能因为一单失败卡死整个队列 printChain printChain.then(() doPrint(payload), () doPrint(payload)) return printChain }) async function doPrint(payload) { const win new BrowserWindow({ show: false, // 绝对不可见 width: 400, height: 900, webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: false } }) try { // 1. 加载小票模板页 await win.loadURL(resolvePrintPage()) // 2. 把数据塞进页面模板里的 render 函数负责生成 DOM await win.webContents.executeJavaScript( window.renderTicket(${JSON.stringify(payload)}) ) // 3. 给二维码、图片留一点绘制时间这个延时是血泪经验 await sleep(150) // 4. 调系统打印silent 为 true 时不弹系统对话框 return await new Promise((resolve) { win.webContents.print({ silent: true, printBackground: true, // 虚线、底色必须开否则打不出来 deviceName: payload.deviceName, // 为空则用系统默认打印机 margins: { marginType: none }, // 小票纸没有边距概念 pageSize: { width: 58000, height: 297000 } // 单位是微米 }, (success, failureReason) { resolve({ ok: success, message: failureReason || }) }) }) } catch (err) { return { ok: false, message: err.message } } finally { // 5. 无论成败都销毁窗口否则隐藏窗口会越积越多 if (!win.isDestroyed()) win.destroy() } } const sleep (ms) new Promise((r) setTimeout(r, ms))这段代码值得逐块拆开看。show: false是静默打印的前提窗口一旦可见用户就会看到闪烁的空白页。webPreferences里nodeIntegration: false加contextIsolation: true是安全基线打印页里不需要 Node 能力就别开。executeJavaScript传的是一个字符串payload 用JSON.stringify序列化后拼进去。这里要小心 payload 里如果含有反引号、/script之类的字符会破坏字符串结构稳妥的做法是在模板页里把renderTicket挂到window上然后用win.webContents.send配合ipcRenderer.once传数据。demo 里为了代码短用了字符串拼接生产环境我一般会换成 IPC 事件。sleep(150)这个延时看起来土但很有必要。如果小票模板里有 canvas 画的二维码、或者异步加载的 logo 图executeJavaScript返回时 DOM 已经有了但位图可能还没画完这时候直接 print打出来的二维码就是空白或者缺角。150ms 是个经验值机器慢的话可以拉到 300ms更靠谱的做法是在模板页里用Promise等图片onload之后再resolve。pageSize的单位是微米不是毫米也不是像素。58mm 宽的纸写5800080mm 写80000。高度给一个大值297000对应 297mm让内容自己撑开这样一张小票内容再长也不会被截断。这个参数写错的后果很直观打出来是 A4 大小内容缩在纸的左上角一小块。3.4 print 参数逐项拆解与失败排查顺序webContents.print的参数不多但每一个都能单独制造一个线上问题。我整理成下面这张表改代码时对着看参数常用值作用写错的后果silenttrue跳过系统打印对话框每次打印弹窗收银流程被打断printBackgroundtrue打印背景色和虚线边框分隔线、底纹全部消失deviceName打印机全名指定目标打印机为空走默认打印机名字错了直接失败margins{ marginType: none }去掉页边距内容整体偏移右侧被裁掉pageSize{ width: 58000, height: 297000 }纸张尺寸单位微米按 A4 排版内容缩成一角copies1或业务值打印份数多联单场景会少打排查顺序我是这么走的先看print回调里的failureReason字符串它一般会告诉你“printer not found”还是“no default printer”。如果回调说成功但纸没出来先确认deviceName拿到的是不是系统里的全名——Windows 上打印机名字经常带“(副本 1)”这种后缀getPrintersAsync()返回什么就用什么别自己拼。再确认纸张尺寸在驱动里是不是也设成了 58mm有的驱动会把应用的pageSize覆盖掉这时候要在系统打印机首选项里改。4. 小票模板与打印样式58mm 热敏纸的排版规则4.1 模板结构一个独立 HTML 对应一张小票public/print.html是整个打印链路里唯一跟“长什么样”有关的地方。它不引入 Vue、不引入任何 UI 库就是一段纯 HTML 加一段内联脚本。原因很简单打印页加载得越快越好每多一个外部依赖就多一次网络请求或文件读取打印延迟直接上去。!-- public/print.html -- !DOCTYPE html html langzh-CN head meta charsetUTF-8 style /* 关键告诉打印引擎这张纸的物理尺寸 */ page { size: 58mm auto; margin: 0; } html, body { margin: 0; padding: 0; width: 58mm; /* 热敏机字库有限优先用系统里一定有的字体 */ font-family: Microsoft YaHei, PingFang SC, sans-serif; color: #000; } .ticket { padding: 2mm 3mm; font-size: 12px; line-height: 1.5; } .ticket__shop { text-align: center; font-size: 15px; font-weight: 700; } .ticket__meta { text-align: center; font-size: 11px; margin: 1mm 0 2mm; } .row { display: flex; justify-content: space-between; } .row--item { margin-top: 1mm; } .divider { border-top: 1px dashed #000; margin: 2mm 0; } .total { font-size: 14px; font-weight: 700; } .footer { text-align: center; font-size: 11px; margin-top: 3mm; } /* 打印时强制保留背景和虚线否则会被浏览器优化掉 */ media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } } /style /head body div classticket idticket/div script // 主进程通过 executeJavaScript 调用这个函数注入数据 window.renderTicket function (data) { const el document.getElementById(ticket) el.innerHTML div classticket__shop${data.shopName}/div div classticket__meta单号${data.ticketNo}br${data.createdAt}/div div classdivider/div ${data.items.map(it div classrow row--item span${it.name}/span span${it.qty} x ${it.price}/span /div div classrowspan/spanspan${it.amount}/span/div ).join()} div classdivider/div div classrow totalspan合计/spanspan${data.total}/span/div div classrowspan支付方式/spanspan${data.payType}/span/div div classdivider/div div classfooter谢谢惠顾欢迎再次光临/div } /script /body /html这段模板里有两个点决定成败。page { size: 58mm auto; margin: 0; }是跟打印引擎沟通纸张尺寸的声明它必须和主进程里pageSize的微米值对得上一边写 58mm 一边写 80000 就会打架。auto让高度随内容增长小票打多少行都不会被硬截断。-webkit-print-color-adjust: exact是另一个容易被忽略的声明。Chromium 默认在打印时会“优化”掉背景色和粗边框虚线分隔线经常直接消失。加上这一行并且主进程里printBackground: true虚线才能稳定打出来。模板里用innerHTML拼字符串是为了让 demo 代码短。真实项目里如果商品名可能带或者就必须做转义否则一段商品名就能破坏整个小票结构。我一般的做法是写一个escapeHtml函数把 五个字符替换掉调用成本很低但能省掉一次线上事故。4.2 字号、宽度与分页热敏纸上的排版约束58mm 热敏纸的有效打印宽度大约 48mm两边各留 5mm 左右的非打印区。这个约束决定了几件事一行最多放 32 个半角字符或 16 个全角汉字商品名超过这个宽度必须截断或者换行价格列要右对齐字号不能太小否则热敏头打出来糊成一片。我的经验值是这样正文字号 12px 到 13px店铺名 15px 到 16px合计行 14px 加粗页脚 11px。小于 11px 的字在 203dpi 的热敏头上会明显发虚尤其是笔画多的汉字。行高给 1.5 倍左右行与行之间留一点呼吸不然打出来像一坨墨。80mm 的机器宽度翻倍排版逻辑一样把page改成size: 80mm auto、width: 80mm主进程pageSize.width改成80000其余样式按比例放大一到两档就行。别想着用媒体查询在同一个模板里同时适配两种宽度主进程是知道当前用哪台打印机的直接把宽度作为参数注入模板更省事。分页这件事在小票场景里基本不用管因为height给的是auto内容多长纸就出多长。但有一个例外如果你的小票内容特别长比如一单几十件商品超过驱动单页最大长度限制时会被强制切断切口可能落在某一行商品中间。稳妥的做法是当商品行数超过阈值时主动在小票里插入一个分页标记分成两张打。这个阈值因驱动而异得在自己目标机器上实测。4.3 二维码和条形码的清晰度控制小票上经常要打订单二维码或者付款码。两种做法一种是引入qrcode这类库在模板页里用 canvas 画出来然后toDataURL()塞进img另一种是后端直接返回图片 URL模板页加载。前者不依赖网络更稳我一般选前者。清晰度的关键在于绘制尺寸。canvas 的 CSS 尺寸和实际像素尺寸是两回事如果只设 CSS 宽高canvas 默认 300x150放大后就是一片糊。正确做法是绘制时把 canvas 的实际宽高设成目标尺寸的 2 到 3 倍再用 CSS 缩回来// 在小票模板页里生成二维码2 倍尺寸保证打印清晰 function drawQr(text, targetMm) { const dpr 3 // 58mm 纸按 8 dot/mm 粗略估算再乘缩放系数 const px Math.round(targetMm * 8 * dpr) const canvas document.createElement(canvas) canvas.width px canvas.height px canvas.style.width targetMm mm canvas.style.height targetMm mm QRCode.toCanvas(canvas, text, { margin: 1, width: px }) return canvas.toDataURL(image/png) }参数里targetMm是小票上二维码的物理边长一般 20mm 到 25mm 之间太小扫不出来太大浪费纸。dpr 3是我试出来的经验值2 倍有时边缘会毛3 倍肉眼看着就干净了文件体积也没大到影响打印速度。margin: 1是二维码的静区别设成 0热敏纸上墨点会往外洇静区不够就扫不出来。生成完之后把 dataURL 放进img的src还要给图片留出加载时间。这就是前面主进程里sleep(150)存在的原因——executeJavaScript返回时img的src已经设好了但位图可能还没解码完成这时候 print 打出来就是空白框。5. 避坑与排查静默打印、页面尺寸与打包后资源5.1 现象打出来是 A4 大小内容缩在左上角这是出现频率最高的一个问题。打印机能正常工作纸也出来了但内容排版完全不对本该铺满 58mm 宽的小票实际只占了纸张左上角一小块剩下的全是空白。原因基本锁定在pageSize上。Chromium 的打印接口如果不显式指定纸张尺寸就会用系统的默认纸张桌面系统默认就是 A4。而小票机的驱动虽然物理上只认 58mm但接收到的排版指令是按 A4 算的于是内容按 A4 的宽度排版实际打到 58mm 的纸上就缩成一角。解决办法是两处必须同时改主进程print参数里pageSize给{ width: 58000, height: 297000 }单位微米模板页style里加page { size: 58mm auto; margin: 0; }。只改一处有时也能生效但另一处会在某些驱动上把结果覆盖回去两台一起改最稳。5.2 现象silent 设了 true还是弹系统打印框silent: true写了代码也确实是这么传的但点打印还是弹出系统的打印对话框收银员得手动点一下“打印”。这种情况先检查deviceName。silent模式要求必须能确定目标打印机如果deviceName是空字符串Electron 会退回到默认打印机如果系统里压根没设默认打印机或者deviceName跟系统里的名字对不上比如少了“(副本 1)”后缀打印就会失败并且可能弹框。排查方法是在设置页里调一次listPrinters把getPrintersAsync()返回的name原样打出来跟你在配置里存的对比一下。还有一种情况是驱动本身不支持静默。少数老驱动在收到打印任务时会强制弹窗确认这个 Electron 层解决不了只能换驱动或者换打印方案。// 枚举系统打印机把 name 原样显示给用户选 ipcMain.handle(print:list, async () { const win BrowserWindow.getAllWindows()[0] const printers await win.webContents.getPrintersAsync() return printers.map(p ({ name: p.name, // 这个名字就是 print 的 deviceName displayName: p.displayName, isDefault: p.isDefault })) })这段代码返回的name是系统内部标识displayName是给人看的。存配置要存name因为print的deviceName认的是它。5.3 现象开发环境能打打包后白屏或找不到模板npm run electron:serve一切正常打完包安装上点打印要么卡住不动要么直接报错说页面加载失败。打开开发者工具一看打印窗口里是空白页。这就是 2.2 节提过的路径问题。开发环境下打印页通过 dev server 的 HTTP 地址加载打包后WEBPACK_DEV_SERVER_URL不存在代码走file://分支如果路径拼错了就加载不到。常见错误有三种用__dirname拼的时候多套了一层../publicPath配成了相对路径导致资源 404print.html没被复制到构建产物目录。排查顺序先在打包产物的dist_electron目录里ls一下确认print.html真的在里面然后把主进程里最终拼出来的 URLconsole.log出来在浏览器地址栏里粘贴试试能不能打开最后检查electron-builder的files配置确认public下的静态文件没有被排除规则滤掉。loadFile比手拼file://字符串更省心它会自动处理路径分隔符和转义中文路径也不会出问题。如果非要手拼至少用pathToFileURL转一下。5.4 现象中文变宋体、字变细或者干脆乱码开发机上一切正常换到门店那台机器上中文全变成了宋体笔画细得几乎看不见有的字还变成了方块。这是字体回退的问题。模板里font-family写了一个目标机器上没有的字体Chromium 就回退到默认字体Windows 上通常是宋体热敏打印下宋体的细横线基本打不出来。还有一种更糟的情况是meta charset没写或者是错的中文字节被按 Latin-1 解码直接显示成乱码。解决起来分两步。第一步head里必须有meta charsetUTF-8并且 HTML 文件本身也确实以 UTF-8 保存不能是 GBK。第二步font-family写成字体栈把系统常驻字体放前面Microsoft YaHei, PingFang SC, Noto Sans SC, sans-serif这样 Windows 上命中雅黑macOS 上命中苹方Linux 上命中 Noto不会掉到宋体。如果目标机器上什么中文字体都没有那就得把字体文件随应用一起打包用font-face引进来。代价是打印页加载要多读一个文件字体文件动辄几 MB会影响首张票的打印速度能靠系统字体解决就别走这条路。5.5 现象连续打多单时串号、丢单或者程序卡死单个订单打印完全正常一到高峰期连续打十几单就出现小票内容错乱——第二张票上印着第一单的商品或者中间某单干脆没打出来严重时整个应用无响应。原因是并发创建隐藏窗口。收银员手快两次点击间隔可能只有几百毫秒第一张票的窗口还没销毁第二个打印任务就开了一个新窗口两个窗口同时调print系统打印队列会乱序数据注入也可能串到错误的窗口上。窗口越开越多内存和句柄耗尽应用就卡死了。解决办法就是 3.3 节里那个printChain。所有打印任务挂到一条 Promise 链上串行执行前一个打完再开下一个窗口。链上任务失败也要继续往下走用.then(onFulfilled, onRejected)两个分支都指向同一个任务函数避免一单失败把后面全卡住。另外别忘了finally里销毁窗口。我见过只在成功回调里destroy()的写法一旦打印失败隐藏窗口就永远留在那儿打十次失败就积十个窗口。6. 二次开发换成自己的小票模板外加两个提效改造拿到这份工程之后最常做的改造就是换成自家的小票样式。步骤其实就三步但每一步都有容易漏的地方。第一步改模板。public/print.html里的renderTicket函数整体替换成你的结构字段名跟着 payload 一起改。改完之后别急着跑完整收银流程先在设置页挂一个“打印测试页”按钮传一份写死的假数据进去验证模板渲染和打印参数。这个按钮在调试期能省掉大量来回操作。第二步改主进程参数。纸张宽度、pageSize、margins三处要同步改58mm 和 80mm 切换时尤其容易只改一处。我的建议是把宽度抽成一个常量文件主进程和模板页各自读一份改的时候一眼能看到所有关联点。第三步改 payload。buildTicketPayload是业务对象到打印数据的唯一转换点加字段、改格式、算优惠明细都在这一个函数里做。别在主进程里做业务计算主进程只负责搬运。除了换模板还有两个改造点性价比很高我自己每个项目都会加改造点改动位置解决什么问题打印失败自动进重打队列doPrint返回值 渲染进程 store缺纸、卡纸后不用手抄订单打印前静默预览截图webContents.capturePage上线前批量核对模板效果打印机自检页设置页新增按钮 print:test通道换机器时快速验证驱动和纸张小票流水号落库payload 增加ticketNo并回写重打时不会打出重复单号重打队列这个改造值得多说一句。做法是在渲染进程维护一个failedTickets数组printTicket返回ok: false时把 payload 压进去收银界面顶部挂一个红色角标显示待重打数量点开可以逐条重试或者一键全打。这个功能上线之后门店因为打印机抖动导致的补录工作量基本归零。预览截图则是用capturePage在打印前抓一张图存到本地跑一遍回归测试用例把几十张小票截图摊开看比一张一张真打出来核对要快得多也省纸。有个教训我印象很深。早期做这类项目时我把打印逻辑直接写在Cashier.vue的事件处理里也没做串行队列。上线第一周就遇到高峰期连续打单串号门店只能先把订单记在纸上事后人工补录。从那以后我每次接打印需求都强制先把队列、失败重试、自检页这三件事做掉再写业务界面。打印这东西平时不显眼真出问题就是收银台前排长队后悔药没地方买。希望这些拆解能帮你少绕几个弯把这份 demo 跑起来之后先从模板和参数改起剩下的慢慢加。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站