Electron + Vue 热敏小票静默打印:58mm模板与队列排错
简介:electron-vue-print-demo 是一个基于 Electron 与 Vue CLI3 的桌面端小票打印示例项目,主要面向需要实现本地打印机设置、静默打印和打印流程控制的前端开发者。项目以 JavaScript 编写,围绕 electron-store 保存打印机名称、判断是否已配置、未配置时弹出设置窗口、确认后执行打印等环节,给出可参考的业务闭环,适合作为收银、订单、票据类桌面应用的起步模板。
压缩包共 25 个文件,约 117KB,以 14 个 JavaScript 文件、3 个 Vue 组件、2 个 JSON 配置和 2 个 HTML 页面为主,另含说明文档与图标资源,源码目录覆盖主进程、渲染组件、打印页面及构建配置,结构紧凑,便于按模块阅读和二次调整。目前已有 3665 人学习。
读者可以从中了解 Electron 主进程与 Vue 渲染层的协作方式、electron-store 的本地持久化用法,以及打印参数和静默打印的调用思路;项目提供打印页与设置弹窗的对应实现,可作为验证打印环境和拆解桌面端打印方案的参考资料。
1. 收银台前的那声「咔」:electron + vue 做打印小票到底卡在哪
收银台前排着队,收银员按下结账,热敏打印机「咔」一声吐出一张 58mm 宽的小票。这个动作背后,是一个 electron + vue-cli3 工程在几百毫秒里完成订单渲染、隐藏窗口加载和静默出纸。这个 electron-vue-print-demo 把「打印小票」拆成了一个能独立跑起来的最小工程:主进程持有打印通道,渲染进程负责收银界面,小票模板独立成一个路由页面,订单元数据走 IPC 送进去,点一下直接出纸,不弹系统打印对话框。
真正难的地方是版面和设备:一张 58mm 的纸塞不下几个字,列对不齐就全乱;打印机名字在开发机和收银机上可能根本不一样,静默打印的机会只有一次,打歪了就是一张废纸。如果你在做收银、餐饮、取号、仓储这类需要现场出纸的桌面端,或者手上有个 electron 项目正被打印拖着,这份 demo 的骨架和坑位基本可以照抄。
2. 工程骨架拆解:vue-cli3 插件把主进程和渲染进程摆在哪
这个项目的工程结构由vue-cli-plugin-electron-builder这个插件决定,它把 vue-cli3 的构建能力直接复用到 Electron 上:一条命令同时起 dev server 和主进程,打包时又用 electron-builder 出安装包。理解它约定的目录,比背 API 重要得多,因为后面所有打印相关的代码都落在主进程里。
2.1 目录结构与依赖清单
一个能跑的最小版本,目录大致是下面这个样子。注意Receipt.vue是独立页面,它不参与收银界面的交互,只被隐藏窗口加载一次。
electron-vue-print-demo ├── public/ │ └── index.html ├── src/ │ ├── background.js # 主进程入口,打印通道全部在这里 │ ├── preload.js # 预加载脚本,只暴露白名单方法 │ ├── main.js # 渲染进程入口 │ ├── App.vue │ ├── router/ │ │ └── index.js # 普通页面 + /receipt 隐藏模板页 │ ├── views/ │ │ ├── Cashier.vue # 收银台主界面 │ │ └── Receipt.vue # 小票模板,隐藏窗口加载的就是它 │ ├── utils/ │ │ └── receipt.js # 订单对象 → 打印数据的映射 │ └── assets/ │ └── receipt.css # 打印专用样式,只在这个页面引入 ├── vue.config.js └── package.json依赖上不需要花哨的东西,package.json里这几行是核心:
{ "scripts": { "serve": "vue-cli-service serve", "electron:serve": "vue-cli-service electron:serve", "electron:build": "vue-cli-service electron:build", "postinstall": "electron-builder install-app-deps" }, "dependencies": { "dayjs": "^1.11.0" }, "devDependencies": { "electron": "^13.0.0", "electron-builder": "^22.0.0", "vue-cli-plugin-electron-builder": "~2.1.0" } }electron:serve做的事是:先起 webpack dev server,再把地址注入process.env.WEBPACK_DEV_SERVER_URL,最后拉起主进程。所以主进程里判断开发态和生产态,一律看这个环境变量在不在,而不是NODE_ENV——打包之后它会被替换成file://路径,这个分支写错就会出现「开发能打印、装了包打不开窗口」。
2.2 主进程里的窗口与 IPC 打印通道
主进程是整个打印链路的中枢。它要维护两类窗口:一个是用户看的收银窗口,一个是只用来渲染小票的隐藏窗口。隐藏窗口必须是单例并且长期持有引用,否则会被 GC 回收,上一次打印的任务跑到一半窗口没了,回调永远不会回来。
// src/background.js import { app, BrowserWindow, ipcMain } from 'electron' import path from 'path' let win = null let printWin = null // 隐藏窗口单例,别用局部变量 function createWindow () { win = new BrowserWindow({ width: 1280, height: 800, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, // 默认 true,别为了图方便关掉 nodeIntegration: false } }) if (process.env.WEBPACK_DEV_SERVER_URL) { win.loadURL(process.env.WEBPACK_DEV_SERVER_URL) } else { win.loadFile(path.join(__dirname, '../dist_electron/index.html')) } } app.whenReady().then(createWindow) app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() })窗口创建好之后,注册两条 IPC:一条查打印机列表,一条执行打印。ipcMain.handle走的是 Promise 语义,渲染进程用invoke拿结果,比老式的send+sendSync干净。
// 查询可用打印机,供设置页做下拉选择 ipcMain.handle('printer:list', async () => { const printers = await win.webContents.getPrintersAsync() return printers.map(p => ({ name: p.name, // 打印时必须原样传回的名字 displayName: p.displayName, // 给人看的名字,可能是中文 isDefault: p.isDefault, // 布尔值 status: p.status // 0 空闲,1 暂停,2 异常 })) }) // 真正执行打印 ipcMain.handle('print:receipt', async (_e, payload) => { return await printReceipt(payload) })这里有个容易踩的点:getPrintersAsync()是挂在webContents上的,不是挂在app上,也不是随便哪个窗口都能调。如果你在隐藏窗口里调,拿到的列表可能因为窗口还在加载而返回空数组,所以统一用主窗口的webContents查。老版本 Electron(9 以前)只有同步的getPrinters(),现在虽然还能用但已经废弃,新工程直接上异步版本。
2.3 预加载脚本:把打印能力收进白名单
contextIsolation打开之后,渲染进程拿不到require,所有能力必须从预加载脚本里显式暴露。这一步看着繁琐,实际是给打印接口做了一层收口——将来要加「打印预览」「重打上一单」,都在这一个文件里加方法,收银页面不用关心底层。
// src/preload.js const { contextBridge, ipcRenderer } = require('electron') contextBridge.exposeInMainWorld('printApi', { // 查打印机 list: () => ipcRenderer.invoke('printer:list'), // 提交一单打印任务 print: (payload) => ipcRenderer.invoke('print:receipt', payload), // 订阅打印状态,用于界面提示「打印中 / 打印失败」 onState: (handler) => { const listener = (_e, state) => handler(state) ipcRenderer.on('print:state', listener) return () => ipcRenderer.removeListener('print:state', listener) } })渲染进程里就变成了window.printApi.print({ ... })这么一句。注意onState返回了一个取消订阅的函数,Vue 组件的beforeDestroy里要调一下,否则热更新几次之后同一个状态会被处理多次,界面上出现三条重复的「打印失败」。
3. 小票模板:把一张订单塞进 58mm 宽的纸里
小票排版是这类项目里最耗时间的部分,也是最容易被低估的部分:API 调通只要半小时,版面调对可能要两天。58mm 的热敏纸实际可打印宽度大概 48mm,80mm 的机器大概 72mm,剩下的都被边距吃掉了。版面一旦超出,右侧会被硬裁,金额那列直接消失。
3.1 打印样式表的三条硬规则
样式表里做对三件事,后面的调试成本能砍掉一大半:固定宽度、关掉页边距、用等宽字体保证列对齐。
/* src/assets/receipt.css —— 只在隐藏窗口里生效 */ html, body { margin: 0; padding: 0; width: 58mm; /* 版面宽度,和 pageSize 的宽度保持一致 */ background: #fff; color: #000; font-family: monospace; /* 等宽,金额列才对得齐 */ font-size: 12px; line-height: 1.35; } @page { size: 58mm auto; /* 贴边输出,别留浏览器默认页边距 */ margin: 0; } .receipt { padding: 2mm 2mm 6mm; box-sizing: border-box; } /* 三列布局:名称自适应,数量金额固定宽 */ .row { display: flex; justify-content: space-between; } .row .name { flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .row .qty { width: 12mm; text-align: right; } .row .amount { width: 18mm; text-align: right; } .divider { border-top: 1px dashed #000; margin: 2mm 0; } .total { font-size: 15px; font-weight: 700; }font-size: 12px是经验值:热敏机大多是 203dpi,一个 12px 的汉字落在纸上大约 3mm 高,一行能放 16 个汉字左右,正好够「商品名 + 数量 + 小计」三列。想要更紧凑就调到 10px,但低于 10px 打印头容易糊成一片,尤其是小票纸质量一般的时候。字体这里用monospace让数字等宽,中文会回退到系统默认字体,如果发现中英文混排时行高不一致,把line-height固定成像素值而不是倍数,能稳住。
@page里的size: 58mm auto在部分 Chromium 版本里会被忽略,真正决定纸张尺寸的是后面 JS 传的pageSize。这两处要一起写,是为了让「开发时用浏览器打印预览看效果」这个调试手段也能大致对得上。
3.2 隐藏窗口渲染:不走预览,直接出纸
小票模板不能直接复用收银主窗口,因为主窗口的 DOM 里塞满了按钮和弹层,打印时会把它们一起算进高度。常见做法是开一个隐藏窗口,加载同一个前端工程里的#/receipt路由,把订单数据用executeJavaScript注进去。
// src/background.js async function ensurePrintWindow () { if (printWin && !printWin.isDestroyed()) return printWin printWin = new BrowserWindow({ show: false, // 不显示,但仍然是完整的渲染进程 width: 400, height: 800, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, backgroundThrottling: false // 后台窗口别降频,否则渲染慢半拍 } }) const url = process.env.WEBPACK_DEV_SERVER_URL ? `${process.env.WEBPACK_DEV_SERVER_URL}#/receipt` : `file://${path.join(__dirname, '../dist_electron/index.html')}#/receipt` await printWin.loadURL(url) return printWin } async function renderReceipt (data) { const wc = await ensurePrintWindow() // 注入数据并等模板渲染完成,返回值必须是可序列化的 await wc.webContents.executeJavaScript(` new Promise(resolve => { window.__setReceipt(${JSON.stringify(data)}) // 等一帧,让 Vue 把 DOM 更新完 requestAnimationFrame(() => setTimeout(resolve, 0)) }) `) }这里有两个细节值得单独说。第一,show: false的窗口在某些 Windows 显卡驱动下渲染结果为空,表现就是打出一张白纸,稳妥的做法是把它挪到屏幕外:x: -2000, y: -2000, show: true,视觉上看不见,但渲染管线是完整的。第二,JSON.stringify的数据里不能有Date对象、函数、undefined,订单里的时间要提前格式化成字符串,否则注入到页面里会变成Invalid Date。
backgroundThrottling: false这一项也建议加上。隐藏窗口在后台会被 Chromium 降频到 1fps,渲染时间从 30ms 变成 1s 以上,收银员会明显感觉到「点了没反应」。
3.3 高度自适应:先量后排
小票的长度是不固定的,买一件和买二十件差好几倍。pageSize的height又不能传 0,传小了后面的内容被截断,传大了尾巴拖出一长条空白纸。常见做法是渲染完先量一次真实高度,再换算成微米传给打印接口。
// 量出模板的实际高度(单位:CSS 像素) async function measureHeight (wc) { return await wc.webContents.executeJavaScript(` new Promise(resolve => { const read = () => requestAnimationFrame(() => resolve(Math.ceil(document.body.getBoundingClientRect().height)) ) // 字体没加载完就量,中文会按 fallback 字体算,高度偏小 if (document.fonts && document.fonts.ready) { document.fonts.ready.then(() => setTimeout(read, 30)) } else { setTimeout(read, 100) } }) `) } // CSS 像素 → 微米:1px = 1/96 英寸,1 英寸 = 25400 微米 const MICRON_PER_PX = 25400 / 96 const heightPx = await measureHeight(wc) const heightMicron = Math.ceil(heightPx * MICRON_PER_PX) + 8000 // 底部多留 8mm 走纸那个+ 8000是留白,让打印机的切刀有空间切在两张票之间,不然最后一行字可能被切掉一半。换算系数25400 / 96 ≈ 264.58值得记一下,所有从像素到打印尺寸的转换都靠它,写成魔法数字 264 会有累积误差,长票据末尾会偏移几毫米。
4. 静默打印链路:从 IPC 到出纸的完整调用
链路走到这里已经齐了:收银页面点结账 →window.printApi.print(order)→ 主进程注入数据到隐藏窗口 → 量高度 → 调用webContents.print。这一章把最后一步的参数、回调判定和排队机制讲透,这三块决定了实际用起来会不会出事故。
4.1 参数表:webContents.print 的每一项都在干什么
打印参数不多,但每一项错了都有具体后果。先看对照表:
| 参数 | 取值示例 | 作用与常见错误 |
|---|---|---|
silent | true | 关掉系统打印对话框。设成false每次都要人工点确认,收银场景不可用 |
deviceName | 'POS-58' | 目标打印机名,必须和getPrintersAsync()返回的name完全一致,留空则走默认打印机 |
printBackground | true | 不打背景时,虚线和浅灰底色会整段消失 |
margins | { marginType: 'none' } | 小票必须关页边距,否则左侧凭空多出 10mm 白边 |
pageSize | { width: 58000, height: 80000 } | 单位是微米,不是毫米,写58等于 0.058mm |
scaleFactor | 100 | 缩放百分比。调大能放大字,但也会放大版面宽度,容易超宽被裁 |
copies | 1 | 联数,后厨单常用的「一菜一单」靠它 |
color | false | 热敏机只有黑白,设true只是多一步转换 |
落到代码上,封装成一个返回 Promise 的函数,调用处才好串行排队:
// src/background.js function doPrint (wc, { deviceName, heightPx, copies = 1 }) { return new Promise((resolve) => { wc.print( { silent: true, printBackground: true, deviceName, margins: { marginType: 'none' }, pageSize: { width: Math.round(58 * 1000), // 58mm → 微米 height: Math.round(heightPx * (25400 / 96)) + 8000 // 内容高 + 走纸余量 }, scaleFactor: 100, copies, color: false }, (success, failureReason) => { // failureReason 只有 'cancelled' 和 'failed' 两种取值 resolve({ success, failureReason }) } ) }) }failureReason只有两个值,这个信息量其实够用:cancelled是用户或系统取消了任务,重试也没意义;failed才值得重试一次,通常是打印队列卡住或者驱动超时。注意回调是异步的,但它不代表纸已经出来了——只代表任务被系统打印队列接收。真要确认出纸,得去查打印机的状态接口,代价太高,实际项目里一般用「回调成功 + 状态提示」就够了。
4.2 回调判定与串行队列,别让第二单叠在第一单上
打印机是串行设备。上一单的数据还在往缓冲区里灌,下一单紧接着发过去,结果就是两张票的内容交叉在一起,或者其中一张整段丢失。收银高峰期连打五六单是常态,没有队列一定会翻车。
// src/background.js const jobQueue = [] let running = false function enqueue (job) { jobQueue.push(job) if (!running) drain() } async function drain () { running = true while (jobQueue.length) { const job = jobQueue.shift() let result = { success: false, failureReason: 'failed' } try { const wc = await ensurePrintWindow() await renderReceipt(job.data) const heightPx = await measureHeight(wc) result = await doPrint(wc, { deviceName: job.deviceName, heightPx, copies: job.copies || 1 }) } catch (e) { result = { success: false, failureReason: e.message } } // failed 重试一次,cancelled 不重试 if (!result.success && result.failureReason === 'failed' && !job.retried) { job.retried = true jobQueue.unshift(job) continue } win.webContents.send('print:state', { id: job.id, ...result }) } running = false }running这个标志位是关键,它保证同一时刻只有一个任务在跑。把重试逻辑放在队列里而不是递归调用,是为了避免「重试的任务插到新任务前面」这种顺序错乱。job.id由渲染进程生成,比如订单号加时间戳,界面拿到状态就能精确地把某一行标成「打印失败,点击重打」,而不是笼统弹一个 toast。
还有一个现实问题:切纸指令和打印任务是两个动作,如果打印机支持自动切纸,通常靠驱动在任务结束时下发。如果发现两张票粘在一起,可以在模板底部加一段空白div,高度约 10mm,比调pageSize的余量更可控。
4.3 打印机枚举与 ESC/POS 原生指令兜底
设备绑定这一步,设置页里必须做成可选,不能写死。用户换机器、装第二台打印机、把默认打印机改成 PDF 虚拟打印机,都可能发生。
// 渲染进程里的设置页 async function loadPrinters () { const list = await window.printApi.list() // Windows 上同一个物理打印机可能因为驱动不同出现多条记录 return list.filter(p => !/pdf|fax|onenote/i.test(p.name) && p.status !== 2) }过滤掉 PDF / 传真这类虚拟设备很有必要,它们会出现在列表里但不是真正的小票机,用户手一抖选了它,打印任务就变成在桌面上生成一个文件,收银员会以为打印机坏了。
再往下走一层,如果业务要求打印条形码、二维码,或者需要控制蜂鸣器、钱箱,webContents.print这条路就不够了,得直接发 ESC/POS 指令。常见做法是不经过打印驱动,把指令拼成二进制直接丢给打印机队列;在 Windows 上可以用copy /b把裸字节送进共享队列:
// src/background.js —— ESC/POS 兜底通道 import iconv from 'iconv-lite' import fs from 'fs' import os from 'os' import path from 'path' import { execFile } from 'child_process' // 拼一条最小可用指令:初始化 → 文本(GBK) → 走纸 → 切纸 function buildEscPos (text) { const init = Buffer.from([0x1b, 0x40]) // ESC @ 复位 const feed = Buffer.from([0x1b, 0x64, 0x05]) // ESC d 5 走纸 5 行 const cut = Buffer.from([0x1d, 0x56, 0x42, 0x00]) // GS V B 0 半切 return Buffer.concat([init, iconv.encode(text, 'gbk'), feed, cut]) } function rawPrint (printerName, text) { const tmp = path.join(os.tmpdir(), `r-${Date.now()}.bin`) fs.writeFileSync(tmp, buildEscPos(text)) return new Promise((resolve, reject) => { execFile('cmd', ['/c', 'copy', '/b', tmp, `\\\\localhost\\${printerName}`], (err) => { try { fs.unlinkSync(tmp) } catch (_) {} err ? reject(err) : resolve() }) }) }中文必须走iconv-lite转成gbk再发,直接发 UTF-8 字节打出来就是乱码方块——这是国内热敏机最常见的一个坑,因为打印机固件的字库是 GBK 码表,它不知道 UTF-8 是什么。这条路的好处是快,几十毫秒就能出纸;坏处是版面全靠自己算字符宽度,中英文混排时对齐很痛苦。我的建议是:纯文本票据用webContents.print走 HTML 排版,条形码、钱箱、蜂鸣这类硬件动作走 ESC/POS,两条路各管一段。
5. 常见问题排查:白纸、错位、打包后不出纸
下面这几条是我在实际项目里反复遇到的,按现象、原因、解决三段写,遇到问题可以直接对着排查。
5.1 排版与渲染类
现象一:调用成功,回调返回success: true,但出来的是一张纯白纸。原因多半在隐藏窗口没有真正完成渲染:show: false的窗口在部分显卡驱动下不参与合成,printBackground又是true,结果输出空白。另一种情况是loadURL的 Promise resolve 了,但 Vue 的mounted还没跑完,__setReceipt注入时 DOM 里根本没内容。 解决是把窗口改成屏幕外显示,x: -2000, y: -2000, show: true,再在注入数据之后等一次requestAnimationFrame加setTimeout(0)。如果还是白纸,就在打印前用wc.capturePage()截一张图存到临时目录,看一眼就能确定是渲染问题还是打印参数问题,这招比反复打真纸省事得多。
现象二:小票右侧内容被切掉,金额那一列完全看不见。原因有两个方向。一是pageSize.width写成了毫米,比如{ width: 58 },实际纸张只有 0.058mm 宽,所有内容挤成一列像素;二是模板body的宽度设成了 80mm 却打了 58mm 的纸,多出来的部分被裁。 解决是先统一单位,宽度一律58 * 1000;再确认 CSS 里html, body的宽度和纸宽一致,并且.receipt的padding算在box-sizing: border-box之内。80mm 的机器要把 58 换成 72 左右(去掉两边不可打印区),别直接换 80。
现象三:分隔虚线、表头灰色底纹打不出来。原因是printBackground没开,Chromium 默认不打印背景色和背景图,border-top: 1px dashed这种边框有时候也会被优化掉。 解决是显式传printBackground: true,并且把虚线改成实线的border-bottom: 1px solid #000,热敏机的分辨率下 1px 虚线本来就容易断成点。
5.2 设备、编码与打包类
现象四:开发机能打,打包安装到收银机之后列表里找不到打印机,或者选了也打不出来。原因是deviceName必须和系统里的打印机名完全一致。Windows 上同一个驱动装两次,第二个实例的名字会自动加后缀,变成POS-58 (副本 1)这种;而配置里存的是开发机上的名字,两边对不上,静默打印就直接失败,回调给failed。 解决是把打印机名字存进本地配置文件(app.getPath('userData')下面),首次启动强制让用户在设置页选一次,选完做一次「打印测试页」验证;同时在打印前做一次名称校验,找不到就退回默认打印机并给界面一个明确提示,而不是静默失败。
现象五:中文全部变成乱码或者问号。走 HTML 打印这条路时,乱码通常是因为隐藏窗口用的字体在目标机器上不存在,中文回退到了一个不含中文字形的字体;走 ESC/POS 那条路时,乱码是因为编码没转,UTF-8 字节被打印机按 GBK 解释。 解决分两条:HTML 路径把font-family落到系统一定有的中文字体上,别用网络字体;ESC/POS 路径统一用iconv-lite转gbk,并且确认打印机支持的代码页,必要时在指令开头加一条ESC t n切代码页。
6. 二次开发:换成自己的单据模板
拿到这份 demo 之后,绝大多数人的第一件事是把它改成自己业务的单据。改造可以按三层来推,从浅到深,每层都能独立验证。
第一层只改数据映射,不动版面。src/utils/receipt.js里做的是「订单对象 → 模板数据」的转换,把字段名、金额格式化、时间格式都收在这一个文件里。金额千万别在模板里做除法,浮点数出来的0.30000000000000004打在小票上很难看:
// src/utils/receipt.js import dayjs from 'dayjs' export function toReceiptData (order, shopName) { const items = order.items.map(it => ({ name: it.title, qty: it.count, // 分转元并保留两位,避免浮点误差 amount: (it.price * it.count / 100).toFixed(2) })) const total = (order.payAmount / 100).toFixed(2) return { shopName, orderNo: order.orderNo, time: dayjs(order.createdAt).format('YYYY-MM-DD HH:mm:ss'), items, total, payType: order.payType === 'wx' ? '扫码支付' : '现金', qrText: `https://example.invalid/o/${order.orderNo}` // 二维码内容 } }第二层改版面。真正的技巧是「多模板共存」而不是改一个模板:后厨单不需要金额、退款单要标红负数、标签单只要商品名和规格。做法是给Receipt.vue加一个type参数,通过路由 query 传进去,receipt.css里用类名区分。这样一套渲染逻辑能覆盖四五种单据,比复制五个页面好维护得多。
第三层是加验证手段。我现在的习惯是:开发阶段不碰真纸,先在设置页放一个「导出 PDF」按钮,内部调webContents.printToPDF把同一份模板输出成文件,肉眼确认版面和高度,确认没问题再打真机。这个小按钮至少能省掉一半的纸,也方便把效果图发给业务方确认。再往下可以做「重打上一单」和「打印日志」:每次打印往本地 SQLite 或 JSON 文件里记一条,包含订单号、打印机名、结果、耗时,出问题时能翻出到底是哪一单、哪台机器、什么原因失败,而不是靠收银员的记忆。这套日志上线之后,打印相关的客诉排查时间从半天缩短到十分钟。
从那以后我每次接打印需求,第一步都是在设置页塞一个「打印测试页」按钮和一份导出 PDF 的开关,先把模板和高度验完,再去碰设备和队列——顺序颠倒过来,排查成本会翻好几倍。希望帮到你。
本文还有配套的精品资源,点击获取