// 网口打印机管理(80mm 终极美化版 · 无多余间距最终版) import net from 'net' import iconv from 'iconv-lite' // ======================== 全局工具函数 ======================== const getPrintWidth = (str) => { let width = 0 const s = String(str || '') for (let i = 0; i < s.length; i++) { const char = s.charAt(i) if (/[\u4e00-\u9fa5\u3000-\u303f\uff00-\uffef]/.test(char)) { width += 2 } else { width += 1 } } return width } const padLeftAlign = (str, targetWidth) => { const s = String(str || '') const currentWidth = getPrintWidth(s) const spaceNum = Math.max(0, targetWidth - currentWidth) return s + ' '.repeat(spaceNum) } const padRightAlign = (str, targetWidth) => { const s = String(str || '') const currentWidth = getPrintWidth(s) const spaceNum = Math.max(0, targetWidth - currentWidth) return ' '.repeat(spaceNum) + s } const truncateByPrintWidth = (str, maxWidth) => { const s = String(str || '') let width = 0 let result = '' for (const char of s) { const charWidth = /[\u4e00-\u9fa5\u3000-\u303f\uff00-\uffef]/.test(char) ? 2 : 1 if (width + charWidth > maxWidth) break result += char width += charWidth } return result } const centerAlign = (str, lineWidth = 48) => { const s = String(str || '').trim(); // 去除首尾空格,避免干扰 const currentWidth = getPrintWidth(s); if (currentWidth >= lineWidth) return s; // 内容超宽时直接返回,不居中 const totalPad = lineWidth - currentWidth; const leftPad = Math.floor(totalPad / 2); const rightPad = totalPad - leftPad; // 避免奇数宽度时总长度超行宽 // 用空格填充左右,保证总宽度严格等于 lineWidth return ' '.repeat(leftPad) + s + ' '.repeat(rightPad); }; const createDivider = (lineWidth = 48, type = 'full') => { const fullChar = '═' const thinChar = '─' const char = type === 'full' ? fullChar : thinChar return char.repeat(Math.ceil(lineWidth / 2)).substring(0, lineWidth) } const setupPrinter = (socket, options = {}) => { socket.write(Buffer.from([0x1B, 0x40])) if (options.fontSize) { socket.write(Buffer.from([0x1B, 0x21, options.fontSize])) } if (options.bold) { socket.write(Buffer.from([0x1B, 0x45, options.bold ? 0x01 : 0x00])) } } // 用餐模式 堂食 dine-in 外带 take-out 外卖 take-away const dineModeFilter = t => { if (t === 'dine-in') return '堂食' if (t === 'take-out') return '外带' if (t === 'take-away') return '外卖' return t } // ======================== 打印结算小票 ======================== export function printReceipt(printerIp, order) { return new Promise((resolve) => { const socket = new net.Socket() const lineWidth = 48 socket.setTimeout(8000) const formatAmount = (amount) => Number(amount || 0).toFixed(2) socket.connect(9100, printerIp, () => { try { setupPrinter(socket) // ======================== 标题区域(彻底修复居中问题!) ======================== const title1 = order.shop_name || ''; let title2 = `结算单 #${order.orderInfo.orderNum}` if (order.isBefore && !order.isGuest) { title2 = `${order.isBefore ? '预' : ''}结算单 #${order.orderInfo.orderNum}`; } if (order.isGuest && order.isBefore) { title2 = `客看单 #${order.orderInfo.orderNum}`; } // 核心修复:先写入空行清空打印机缓冲区,避免残留字符干扰居中起始位置 socket.write(iconv.encode('\n', 'gbk')); // 步骤1:重置打印机格式(避免残留格式影响) socket.write(Buffer.from([0x1B, 0x40])); // 步骤2:居中对齐标题 socket.write(Buffer.from([0x1B, 0x61, 0x01])); // 1 = center // 步骤3:设置标题1的格式(加粗+大号字体) socket.write(Buffer.from([0x1B, 0x21, 0x11])); // 字号放大(0x11 是常用放大值) socket.write(Buffer.from([0x1B, 0x45, 0x01])); // 加粗 socket.write(iconv.encode(title1 + '\n', 'gbk')); // 步骤4:恢复格式,打印标题2(常规字体) socket.write(Buffer.from([0x1B, 0x21, 0x00])); // 恢复默认字号 socket.write(Buffer.from([0x1B, 0x45, 0x00])); // 取消加粗 socket.write(iconv.encode(title2 + '\n\n', 'gbk')); socket.write(Buffer.from([0x1B, 0x61, 0x00])); // 0 = left // ======================== 订单信息(修复显示+换行问题) ======================== const tableLine = padLeftAlign('桌台号:', 8) + padLeftAlign(order.orderInfo.tableName || '无', 14) + '\n' socket.write(Buffer.from([0x1B, 0x21, 0x00])); // 正常字号 socket.write(Buffer.from([0x1B, 0x45, 0x01])); // 加粗 socket.write(iconv.encode(tableLine, 'gbk')); socket.write(Buffer.from([0x1B, 0x45, 0x00])); // 取消加粗 let orderInfo = '' // 修复:打印机-用餐模式 + 就餐人数 展示逻辑 const dineModeText = dineModeFilter(order.orderInfo.dineMode) // 1. 拼接打印机+用餐模式文本,空值兜底 const printerDineText = `${order.printerName || '未知打印机'}-${dineModeText || '未知模式'}` // 2. 截断超长文本(左侧分配20宽度,保证不超限) const truncatedPrinterDine = truncateByPrintWidth(printerDineText, 20) // 3. 左侧左对齐填充20宽度(保证占满分配空间,不浪费空白) const leftPart = padLeftAlign(truncatedPrinterDine, 20) // 4. 右侧就餐人数:空值兜底为“无”,右对齐填充28宽度(20+28=48,刚好行宽) const seatNumText = order.orderInfo.seatNum || '0' const rightPart = padRightAlign(`${seatNumText}人`, 28) // 加标签更清晰,也可只显示数字 orderInfo += leftPart + rightPart + '\n' if (!order.isBefore) { orderInfo += padLeftAlign('结账时间:', 10) + padLeftAlign(order.createdAt || '-', 38) + '\n' } orderInfo += createDivider(lineWidth, 'thin') + '\n' // ======================== 商品表格 ======================== orderInfo += padLeftAlign('品名', 18) orderInfo += padLeftAlign('单价', 8) orderInfo += padLeftAlign('数量', 6) orderInfo += padRightAlign('小计', 16) + '\n' orderInfo += createDivider(lineWidth, 'thin') + '\n' order.carts.forEach(item => { const name = truncateByPrintWidth(item.name || '未知商品', 18) const price = formatAmount(item.salePrice) const num = item.number || 1 const total = formatAmount(item.totalAmount) orderInfo += padLeftAlign(name, 18) orderInfo += padLeftAlign(price, 8) orderInfo += padLeftAlign(num, 6) orderInfo += padRightAlign(total, 16) + '\n' }) orderInfo += createDivider(lineWidth, 'thin') + '\n' orderInfo += padLeftAlign('原价', 10) + padRightAlign(formatAmount(order.originAmount), 38) + '\n' orderInfo += padLeftAlign('优惠金额', 10) + padRightAlign(`-${formatAmount(order.discountAllAmount)}`, 38) + '\n' orderInfo += padLeftAlign('备注:', 6) + padLeftAlign(order.remark || '无', 38) + '\n' orderInfo += createDivider(lineWidth, 'thin') + '\n' socket.write(iconv.encode(orderInfo, 'gbk')) // ======================== 付款信息 ======================== const payableLine = padLeftAlign('应付', 10) + padRightAlign(formatAmount(order.originAmount), 51) + '\n' socket.write(Buffer.from([0x1B, 0x21, 0x11])); // 放大字号 socket.write(Buffer.from([0x1B, 0x45, 0x01])); // 加粗 socket.write(iconv.encode(payableLine, 'gbk')); socket.write(Buffer.from([0x1B, 0x21, 0x00])); // 恢复默认字号 socket.write(Buffer.from([0x1B, 0x45, 0x00])); // 取消加粗 socket.write(iconv.encode(createDivider(lineWidth, 'thin') + '\n', 'gbk')); if (!order.isBefore) { const paidLine = padLeftAlign('已付', 10) + padRightAlign(formatAmount(order.orderInfo.payAmount), 51) + '\n' socket.write(Buffer.from([0x1B, 0x21, 0x11])); socket.write(Buffer.from([0x1B, 0x45, 0x01])); socket.write(iconv.encode(paidLine, 'gbk')); socket.write(Buffer.from([0x1B, 0x21, 0x00])); socket.write(Buffer.from([0x1B, 0x45, 0x00])); socket.write(iconv.encode(createDivider(lineWidth, 'thin') + '\n', 'gbk')); } // ======================== 底部 ======================== let bottom = '' bottom += padLeftAlign('操作员:', 8) + padLeftAlign(order.loginAccount || '无', 38) + '\n' bottom += padLeftAlign('打印时间:', 10) + padLeftAlign(new Date().toLocaleString(), 38) + '\n' bottom += padLeftAlign('订单号:', 8) + padLeftAlign(order.orderInfo.orderNo || '-', 38) + '\n' bottom += createDivider(lineWidth, 'full') + '\n' bottom += '\n\n' bottom += '\n\n' socket.write(iconv.encode(bottom, 'gbk')) socket.write(Buffer.from([0x1D, 0x56, 0x00])) setTimeout(() => { socket.end() resolve('success') }, 400) } catch (err) { console.error(err) socket.destroy() resolve('error') } }) socket.on('error', () => resolve('error')) socket.on('timeout', () => resolve('timeout')) socket.on('close', () => { }) }) } // ======================== 打印退菜单/退款小票 ======================== export function printRefund(printerIp, order) { console.log(JSON.stringify(order)); return new Promise((resolve) => { const socket = new net.Socket() const lineWidth = 48 socket.setTimeout(8000) const formatAmount = (amount) => Number(amount || 0).toFixed(2) socket.connect(9100, printerIp, () => { try { setupPrinter(socket) // ======================== 标题区域(彻底修复居中问题!) ======================== const title1 = order.shop_name || ''; let title2 = order.title || '退款单' // 核心修复:先写入空行清空打印机缓冲区,避免残留字符干扰居中起始位置 socket.write(iconv.encode('\n', 'gbk')); // 步骤1:重置打印机格式(避免残留格式影响) socket.write(Buffer.from([0x1B, 0x40])); // 步骤2:居中对齐标题 socket.write(Buffer.from([0x1B, 0x61, 0x01])); // 1 = center // 步骤3:设置标题1的格式(加粗+大号字体) socket.write(Buffer.from([0x1B, 0x21, 0x11])); // 字号放大(0x11 是常用放大值) socket.write(Buffer.from([0x1B, 0x45, 0x01])); // 加粗 socket.write(iconv.encode(title1 + '\n', 'gbk')); // 步骤4:恢复格式,打印标题2(常规字体) socket.write(Buffer.from([0x1B, 0x21, 0x00])); // 恢复默认字号 socket.write(Buffer.from([0x1B, 0x45, 0x00])); // 取消加粗 socket.write(iconv.encode(title2 + '\n\n', 'gbk')); socket.write(Buffer.from([0x1B, 0x61, 0x00])); // 0 = left // ======================== 订单信息(修复显示+换行问题) ======================== const tableLine = padLeftAlign('桌台号:', 8) + padLeftAlign(order.orderInfo.tableName || '无', 14) + '\n' socket.write(Buffer.from([0x1B, 0x21, 0x00])); // 正常字号 socket.write(Buffer.from([0x1B, 0x45, 0x01])); // 加粗 socket.write(iconv.encode(tableLine, 'gbk')); socket.write(Buffer.from([0x1B, 0x45, 0x00])); // 取消加粗 let orderInfo = '' // 修复:打印机-用餐模式 + 就餐人数 展示逻辑 const dineModeText = dineModeFilter(order.orderInfo.dineMode) // 1. 拼接打印机+用餐模式文本,空值兜底 const printerDineText = `${order.printerName || '未知打印机'}-${dineModeText || '未知模式'}` // 2. 截断超长文本(左侧分配20宽度,保证不超限) const truncatedPrinterDine = truncateByPrintWidth(printerDineText, 20) // 3. 左侧左对齐填充20宽度(保证占满分配空间,不浪费空白) const leftPart = padLeftAlign(truncatedPrinterDine, 20) // 4. 右侧就餐人数:空值兜底为“无”,右对齐填充28宽度(20+28=48,刚好行宽) const seatNumText = order.orderInfo.seatNum || '0' const rightPart = padRightAlign(`${seatNumText}人`, 28) // 加标签更清晰,也可只显示数字 if (order.carts.length > 0) { orderInfo += leftPart + rightPart + '\n' orderInfo += createDivider(lineWidth, 'thin') + '\n' // ======================== 商品表格 ======================== orderInfo += padLeftAlign('品名', 18) orderInfo += padLeftAlign('单价', 8) orderInfo += padLeftAlign('数量', 6) orderInfo += padRightAlign('小计', 16) + '\n' orderInfo += createDivider(lineWidth, 'thin') + '\n' order.carts.forEach(item => { const name = truncateByPrintWidth(item.name || '未知商品', 18) const price = formatAmount(item.salePrice) const num = item.number || 1 const total = formatAmount(item.totalAmount) orderInfo += padLeftAlign(name, 18) orderInfo += padLeftAlign(price, 8) orderInfo += padLeftAlign(num, 6) orderInfo += padRightAlign(total, 16) + '\n' }) orderInfo += createDivider(lineWidth, 'thin') + '\n' socket.write(iconv.encode(orderInfo, 'gbk')) } // ======================== 付款信息 ======================== let payableLine = '' payableLine += padLeftAlign('退款总计', 10) + padRightAlign(formatAmount(order.amount), 51) + '\n' socket.write(Buffer.from([0x1B, 0x21, 0x11])); // 放大字号 socket.write(Buffer.from([0x1B, 0x45, 0x01])); // 加粗 socket.write(iconv.encode(payableLine, 'gbk')); socket.write(Buffer.from([0x1B, 0x21, 0x00])); // 恢复默认字号 socket.write(Buffer.from([0x1B, 0x45, 0x00])); // 取消加粗 let refundInfo = '' refundInfo += padLeftAlign('退款方式:', 10) + padLeftAlign(order.refundMethod || '无', 38) + '\n' refundInfo += padLeftAlign('退款原因:', 10) + padLeftAlign(order.remark || '无', 38) + '\n' socket.write(iconv.encode(refundInfo, 'gbk')); socket.write(iconv.encode(createDivider(lineWidth, 'thin') + '\n', 'gbk')); // ======================== 底部 ======================== let bottom = '' bottom += padLeftAlign('操作员:', 8) + padLeftAlign(order.loginAccount || '无', 38) + '\n' bottom += padLeftAlign('打印时间:', 10) + padLeftAlign(new Date().toLocaleString(), 38) + '\n' bottom += padLeftAlign('原订单号:', 10) + padLeftAlign(order.orderInfo.orderNo || '-', 38) + '\n' bottom += createDivider(lineWidth, 'full') + '\n' bottom += '\n\n' bottom += '\n\n' socket.write(iconv.encode(bottom, 'gbk')) socket.write(Buffer.from([0x1D, 0x56, 0x00])) setTimeout(() => { socket.end() resolve('success') }, 400) } catch (err) { console.error(err) socket.destroy() resolve('error') } }) socket.on('error', () => resolve('error')) socket.on('timeout', () => resolve('timeout')) socket.on('close', () => { }) }) } // ======================== 打印交班小票(完整版 · 补齐分类/商品数据) ======================== export function printHandoverReceipt(printerIp, data) { return new Promise((resolve) => { const socket = new net.Socket() const lineWidth = 48 socket.setTimeout(8000) const formatAmount = (v) => Number(v || 0).toFixed(2) socket.connect(9100, printerIp, () => { try { setupPrinter(socket) // ======================== 标题区域(统一结算小票的居中修复逻辑) ======================== const title1 = data.shopName || '店铺'; const title2 = '交班小票'; // 核心修复:先写入空行清空打印机缓冲区,避免残留字符干扰居中起始位置 socket.write(iconv.encode('\n', 'gbk')); // 步骤1:重置打印机格式(避免残留格式影响) socket.write(Buffer.from([0x1B, 0x40])); // 步骤2:居中对齐标题 socket.write(Buffer.from([0x1B, 0x61, 0x01])); // 1 = center // 步骤3:设置标题1的格式(加粗+大号字体) socket.write(Buffer.from([0x1B, 0x21, 0x11])); // 字号放大(0x11 是常用放大值) socket.write(Buffer.from([0x1B, 0x45, 0x01])); // 加粗 socket.write(iconv.encode(title1 + '\n', 'gbk')); // 步骤4:恢复格式,打印标题2(常规字体) socket.write(Buffer.from([0x1B, 0x21, 0x00])); // 恢复默认字号 socket.write(Buffer.from([0x1B, 0x45, 0x00])); // 取消加粗 socket.write(iconv.encode(title2 + '\n\n', 'gbk')); socket.write(Buffer.from([0x1B, 0x61, 0x00])); // 0 = left socket.write(iconv.encode(createDivider(lineWidth, 'thin') + '\n', 'gbk')); // ======================== 基础信息区域(拆分当班/交班时间) ======================== let basicInfo = '' // 优化:拆分当班时间和交班时间,空值兜底 basicInfo += padLeftAlign('交班时间:', 10) + padLeftAlign(data.handoverTime || '-', 36) + '\n' basicInfo += padLeftAlign('收银员:', 8) + padLeftAlign(data.staffName || '-', 36) + '\n' basicInfo += padLeftAlign('交班周期:', 10) + padLeftAlign(`${data.loginTime}-${data.handoverTime}` || '-', 36) + '\n\n' // basicInfo += createDivider(lineWidth, 'thin') + '\n' // 收入明细 - 补充会员支付/充值字段 basicInfo += padLeftAlign('当班营业总额:', 10) + padLeftAlign(formatAmount(data.handAmount), 36) + '\n' basicInfo += padLeftAlign('实际收款的支付方式', 10) + '\n' basicInfo += padLeftAlign('现金', 10) + padRightAlign(formatAmount(data.cashAmount), 36) + '\n' basicInfo += padLeftAlign('微信', 10) + padRightAlign(formatAmount(data.wechatAmount), 36) + '\n' basicInfo += padLeftAlign('支付宝', 10) + padRightAlign(formatAmount(data.alipayAmount), 36) + '\n' basicInfo += padLeftAlign('二维码收款', 10) + padRightAlign(formatAmount(data.vipPay), 36) + '\n' basicInfo += padLeftAlign('扫码收款', 10) + padRightAlign(formatAmount(data.vipRecharge), 36) + '\n\n' basicInfo += padLeftAlign('非实际收款的支付方式', 10) + '\n' basicInfo += padLeftAlign('挂账', 10) + padRightAlign(formatAmount(data.creditAmount), 36) + '\n' basicInfo += padLeftAlign('余额', 10) + padRightAlign(formatAmount(data.vipPay), 36) + '\n' basicInfo += createDivider(lineWidth, 'thin') + '\n' socket.write(Buffer.from([0x1B, 0x21, 0x00])); // 正常字号 socket.write(iconv.encode(basicInfo, 'gbk')) let refundInfo = '' refundInfo += padLeftAlign('退款/退菜', 10) + '\n' refundInfo += padLeftAlign('退款金额', 10) + padRightAlign(formatAmount(data.refundAmount), 36) + '\n' refundInfo += padLeftAlign('退菜数量', 10) + padRightAlign(formatAmount(data.refundAmount), 36) + '\n' refundInfo += createDivider(lineWidth, 'thin') + '\n' socket.write(iconv.encode(refundInfo, 'gbk')) let orderInfo = '' const orderLabel = '订单(数量/订单总额)' const orderValue = `${data.orderCount}/${formatAmount(data.orderAmount)}` const orderLabelWidth = lineWidth - getPrintWidth(orderValue) orderInfo += padLeftAlign(orderLabel, orderLabelWidth) + orderValue + '\n\n' socket.write(iconv.encode(orderInfo, 'gbk')) // ======================== 分类数据区域 ======================== let categoryInfo = '' if (data.categoryDataList && data.categoryDataList.length) { categoryInfo += centerAlign('销售数据', lineWidth) + '\n' categoryInfo += createDivider(lineWidth, 'thin') + '\n' // 分类表头 categoryInfo += padLeftAlign('名称', 24) + padLeftAlign('数量', 12) + padRightAlign('总计', 12) + '\n' // categoryInfo += createDivider(lineWidth, 'thin') + '\n' // 分类数据行 data.categoryDataList.forEach(item => { const catName = truncateByPrintWidth(item.categoryName || '未知分类', 24) const num = item.num || 0 const amount = formatAmount(item.amount) categoryInfo += padLeftAlign(catName, 24) + padLeftAlign(num, 12) + padRightAlign(amount, 12) + '\n' }) categoryInfo += createDivider(lineWidth, 'thin') + '\n' } socket.write(iconv.encode(categoryInfo, 'gbk')) // ======================== 商品数据区域 ======================== let productInfo = '' if (data.printShop && data.productDataList && data.productDataList.length) { productInfo += centerAlign('商品数据', lineWidth) + '\n' productInfo += createDivider(lineWidth, 'thin') + '\n' // 商品表头 productInfo += padLeftAlign('商品', 36) + padRightAlign('数量', 12) + '\n' // productInfo += createDivider(lineWidth, 'thin') + '\n' // 商品数据行 data.productDataList.forEach(item => { const prodName = truncateByPrintWidth(item.productName || '未知商品', 36) const num = item.num || 0 productInfo += padLeftAlign(prodName, 36) + padRightAlign(num, 12) + '\n' }) productInfo += createDivider(lineWidth, 'thin') + '\n' } socket.write(iconv.encode(productInfo, 'gbk')) // ======================== 收入明细区域(优化对齐和格式) ======================== // let incomeInfo = '' // incomeInfo += centerAlign('【收支汇总】', lineWidth) + '\n' // incomeInfo += createDivider(lineWidth, 'thin') + '\n' // // 收支汇总项(补充挂账金额) // const incomeItems = [ // { label: '快捷收款金额:', val: data.quickInAmount }, // { label: '退款金额:', val: data.refundAmount }, // { label: '总收入:', val: data.handAmount }, // { label: '挂账金额:', val: data.creditAmount }, // { label: '总订单数:', val: data.orderCount || 0 } // ] // incomeItems.forEach((item, index) => { // const labelPart = padLeftAlign(item.label, 16) // let valPart = '' // // 总收入/总订单数加粗显示 // if (index === 2 || index === 4) { // socket.write(Buffer.from([0x1B, 0x45, 0x01])); // 加粗 // valPart = padRightAlign(index === 4 ? item.val : formatAmount(item.val), 32) // incomeInfo += labelPart + valPart + '\n' // socket.write(Buffer.from([0x1B, 0x45, 0x00])); // 取消加粗 // } else { // valPart = padRightAlign(formatAmount(item.val), 32) // incomeInfo += labelPart + valPart + '\n' // } // }) // incomeInfo += createDivider(lineWidth, 'full') + '\n' // socket.write(iconv.encode(incomeInfo, 'gbk')) // ======================== 底部区域(统一结算小票风格) ======================== let bottomInfo = '' bottomInfo += padLeftAlign('打印时间:', 12) + padRightAlign(data.printTime || new Date().toLocaleString(), 36) + '\n' bottomInfo += createDivider(lineWidth, 'full') + '\n' // 增加空行,避免打印内容紧贴纸边 bottomInfo += '\n\n' bottomInfo += '\n\n' socket.write(iconv.encode(bottomInfo, 'gbk')) // 切纸指令(统一结算小票的切纸逻辑) socket.write(Buffer.from([0x1D, 0x56, 0x00])) // 统一延迟关闭连接,保证数据完整发送 setTimeout(() => { socket.end() resolve('success') }, 400) } catch (err) { console.error('打印交班小票异常:', err) socket.destroy() resolve('error') } }) // 统一错误处理(对齐结算小票的错误处理逻辑) socket.on('error', (err) => { console.error('打印机连接错误:', err) resolve('error') }) socket.on('timeout', () => resolve('timeout')) socket.on('close', () => { }) }) }