Files
uni-pda/utils/BluetoothPrinterService.new.js
T
2026-07-23 20:10:49 +08:00

1286 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 蓝牙打印服务类(新版:支持预设纸张尺寸适配)
* 功能:
* - 统一管理蓝牙打印机连接
* - 支持 CPCL 和 TSPL 两种指令格式
* - 自动与 Vuex store 同步配置
* - 提供指令生成模板
* - 支持预设纸张尺寸(100x100 / 150x150 / 200x200),自动适配布局
*
* 使用方法:
* import printerService from '@/utils/BluetoothPrinterService.new.js';
*
* // 初始化并连接
* await printerService.init();
* await printerService.autoConnect();
*
* // 切换预设纸张
* printerService.usePresetPaperSize('150x150');
*
* // 获取所有预设
* const presets = BluetoothPrinterService.getPresetPaperSizes();
*
* // 打印操作
* await printerService.printWasteLabel(wasteData);
*/
import * as iconv from 'iconv-lite';
// 获取 Vuex store 实例(延迟加载避免循环依赖)
let store = null;
const getStore = () => {
if (!store) {
store = require('@/store').default;
}
return store;
};
/**
* 预设纸张尺寸配置
* 每种尺寸包含 CPCL 和 TSPL 两套布局
*/
const PAPER_PRESETS = {
'100x100': {
labelWidth: 100,
labelHeight: 800,
fontSize: 28,
lineHeight: 45,
cpcl: {
pageWidth: 800, // 100mm @200dpi = 800 点
gapSense: true, // 自动检测标签间隙,避免固定 GAP 值不匹配
fields: [
{ x: 192, y: 100 }, // 名称
{ x: 192, y: 150 }, // 类别
{ x: 192, y: 200 }, // 代码
{ x: 480, y: 200 }, // 形态
{ x: 192, y: 250 }, // 主要成分
{ x: 192, y: 350 }, // 有害成分
{ x: 192, y: 448 }, // 注意
{ x: 216, y: 536 }, // 识别码
{ x: 256, y: 584 }, // 单位
{ x: 288, y: 632 }, // 联系
{ x: 192, y: 680 }, // 日期
{ x: 480, y: 680 }, // 重量
{ x: 150, y: 728 }, // 备注
{ x: 480, y: 448 } // 标识
],
qrCode: { x: 624, y: 584, size: 4 }
},
tspl: {
startX: 20,
startY: 20,
lineHeight: 25,
fontSize: '4',
qrCode: { x: 150, y: 400, size: 5 }
}
},
'150x150': {
labelWidth: 150,
labelHeight: 1200,
fontSize: 36,
lineHeight: 60,
cpcl: {
pageWidth: 1200, // PAGE-WIDTH150mm @200dpi = 1200 点),防止右半边被截断
gapSense: true, // 自动检测标签间隙,避免固定 GAP 值不匹配
// 字体大小沿用 200x200mag/bold),位置用 150 原坐标,整体下移 3mm24 点 @200dpi
fields: [
{ x: 288, y: 174, mag: [2, 2], bold: 0.0, font: 1 }, // 名称(150:150+24
{ x: 288, y: 249, mag: [2, 2], bold: 0.0, font: 1 }, // 类别(150:225+24
{ x: 288, y: 324, mag: [2, 2], bold: 0.5, font: 0 }, // 代码(150:300+24,加粗)
{ x: 720, y: 324, mag: [2, 2], bold: 0.0, font: 1 }, // 形态(150:300+24
{ x: 288, y: 399, mag: [2, 2], bold: 0.5, font: 0 }, // 主要成分(150:375+24,加粗)
{ x: 288, y: 549, mag: [2, 2], bold: 0.5, font: 0 }, // 有害成分(150:525+24,加粗)
{ x: 288, y: 696, mag: [2, 2], bold: 0.0, font: 1 }, // 注意(150:672+24
{ x: 308, y: 836, mag: [2, 2], bold: 0.0, font: 1 }, // 识别码(注意以下:x-16 左2mm, y+8 下1mm
{ x: 368, y: 908, mag: [2, 2], bold: 0.0, font: 1 }, // 单位(注意以下)
{ x: 416, y: 980, mag: [2, 2], bold: 0.0, font: 1 }, // 联系(注意以下)
{ x: 272, y: 1052, mag: [2, 2], bold: 0.5, font: 0 }, // 日期(注意以下,加粗)
{ x: 704, y: 1052, mag: [2, 2], bold: 0.0, font: 1 }, // 重量(注意以下)
{ x: 209, y: 1124, mag: [2, 2], bold: 0.0, font: 1 }, // 备注(注意以下)
{ x: 720, y: 549, mag: [4, 4], bold: 0.0, font: 1 } // 标识(150:525+244 倍放大)
],
qrCode: { x: 936, y: 900, size: 4 } // 位置用 150 原坐标(876+24),size 4 不越界
},
tspl: {
startX: 30,
startY: 30,
lineHeight: 38,
fontSize: '4',
qrCode: { x: 225, y: 600, size: 5 }
}
},
'200x200': {
labelWidth: 200,
labelHeight: 1600,
cpcl: {
pageWidth: 1600, // PAGE-WIDTH200mm @200dpi = 1600 点),防止右半边被截断
gapSense: true, // 使用 GAP-SENSE 自动感应间隙(大标签更稳)
fields: [
{ x: 344, y: 256, mag: [2, 2], bold: 0.0, font: 1 }, // 名称
{ x: 344, y: 360, mag: [2, 2], bold: 0.0, font: 1 }, // 类别
{ x: 344, y: 448, mag: [2, 2], bold: 0.5, font: 0 }, // 代码(加粗)
{ x: 912, y: 448, mag: [2, 2], bold: 0.0, font: 1 }, // 形态
{ x: 344, y: 544, mag: [2, 2], bold: 0.5, font: 0 }, // 主要成分(加粗)
{ x: 344, y: 744, mag: [2, 2], bold: 0.5, font: 0 }, // 有害成分(A401 无,按左列节奏补)
{ x: 344, y: 936, mag: [2, 2], bold: 0.0, font: 1 }, // 注意
{ x: 392, y: 1128, mag: [2, 2], bold: 0.0, font: 1 }, // 识别码
{ x: 480, y: 1224, mag: [2, 2], bold: 0.0, font: 1 }, // 单位(A401 无,按左列节奏补)
{ x: 552, y: 1328, mag: [2, 2], bold: 0.0, font: 1 }, // 联系
{ x: 352, y: 1432, mag: [2, 2], bold: 0.5, font: 0 }, // 日期(加粗)
{ x: 912, y: 1432, mag: [2, 2], bold: 0.0, font: 1 }, // 重量
{ x: 240, y: 1528, mag: [2, 2], bold: 0.0, font: 1 }, // 备注
{ x: 1432, y: 544, mag: [4, 4], bold: 0.0, font: 1 } // 标识(√ 勾选,4 倍放大)
],
qrCode: { x: 1220, y: 1224, size: 8, prefix: 'BARCODE QR' }
},
tspl: {
startX: 56,
startY: 40,
lineHeight: 50,
fontSize: '4',
qrCode: { x: 328, y: 776, size: 5 }
}
}
};
class BluetoothPrinterService {
constructor() {
// 蓝牙状态
this.adapterOpen = false;
this.connected = false;
this.connecting = false;
this.deviceId = '';
this.deviceName = '';
this.serviceId = '';
this.writeCharId = '';
// 打印配置
this.commandType = 'CPCL'; // 'CPCL' | 'TSPL'
this.paperWidth = 100;
this.paperHeight = 100;
this.currentPreset = null; // 当前使用的预设名称
// 回调函数
this.connectionChangeCallbacks = [];
// 连接状态监听器
this._connectionListener = null;
// writeType 缓存(探测一次后记住,避免重复探测)
this._writeType = null; // null | 'writeNoResponse' | 'write'
// MTU 缓存(连接时通过 setBLEMTU 协商,蓝牙 5.0 理论最大 512)
this._mtu = 20;
}
// ==================== 初始化 ====================
/**
* 初始化蓝牙适配器
* @returns {Promise<void>}
*/
async init() {
if (this.adapterOpen) return;
return new Promise((resolve, reject) => {
uni.openBluetoothAdapter({
success: () => {
this.adapterOpen = true;
this._registerConnectionListener();
resolve();
},
fail: (err) => {
console.error('蓝牙初始化失败', err);
reject(err);
}
});
});
}
/**
* 注册连接状态变化监听
*/
_registerConnectionListener() {
if (this._connectionListener) return;
this._connectionListener = (res) => {
if (!res.connected && this.deviceId === res.deviceId) {
// 只更新连接状态,保留配置信息以便下次自动连接
this.connected = false;
this._notifyConnectionChange(false);
// 注意:不再调用 _updateStoreConnection,不再清空 store 中的配置
}
};
uni.onBLEConnectionStateChange(this._connectionListener);
}
/**
* 销毁,清理资源
*/
destroy() {
if (this._connectionListener) {
// uni.offBLEConnectionStateChange 无法精确移除单个监听
// 业务层需要在页面卸载时主动调用 disconnect
}
this.disconnect();
}
// ==================== 连接管理 ====================
/**
* 从 store 加载配置
*/
_loadConfig() {
try {
const state = getStore().state;
if (state.print) {
const config = state.print.printerConfig;
if (config) {
this.deviceId = config.deviceId || '';
this.deviceName = config.deviceName || '';
this.serviceId = config.serviceId || '';
this.writeCharId = config.writeCharId || '';
this.paperWidth = config.paperWidth || 100;
this.paperHeight = config.paperHeight || 100;
this.currentPreset = config.currentPreset || null;
return config;
}
}
} catch (e) {
console.warn('加载打印配置失败', e);
}
return null;
}
/**
* 保存配置到 store
*/
_saveConfig(config) {
try {
getStore().dispatch('print/savePrinterConfig', config);
} catch (e) {
console.warn('保存打印配置失败', e);
}
}
/**
* 更新 store 连接状态
*/
_updateStoreConnection(connected, device = {}) {
try {
if (connected) {
getStore().dispatch('print/connectPrinter', {
deviceId: this.deviceId,
name: this.deviceName,
...device
});
} else {
getStore().dispatch('print/disconnectPrinter');
}
} catch (e) {
console.warn('更新连接状态失败', e);
}
}
/**
* 自动连接已保存的打印机
* @returns {Promise<boolean>}
*/
async autoConnect() {
if (this.connecting || this.connected) {
return this.connected;
}
const config = this._loadConfig();
if (!config || !config.deviceId) {
console.warn('无可用打印机配置');
return false;
}
this.connecting = true;
this._notifyConnectionChange(false, 'connecting');
try {
await this.init();
// 传递已保存的 serviceId 和 writeCharId
await this._connect(config.deviceId, config.serviceId, config.writeCharId);
this.connected = true;
this.connecting = false;
this._notifyConnectionChange(true);
return true;
} catch (err) {
this.connected = false;
this.connecting = false;
this._notifyConnectionChange(false, 'failed');
console.error('自动连接失败', err);
return false;
}
}
/**
* 连接指定设备
* @param {string} deviceId - 设备ID
* @param {string} savedServiceId - 已保存的服务ID(可选)
* @param {string} savedWriteCharId - 已保存的特征值ID(可选)
* @returns {Promise<void>}
*/
async _connect(deviceId, savedServiceId = '', savedWriteCharId = '') {
return new Promise(async (resolve, reject) => {
// 首先尝试使用已保存的服务配置
if (savedServiceId && savedWriteCharId) {
try {
console.log('尝试使用已保存的服务配置:', { savedServiceId, savedWriteCharId });
// 建立连接
await new Promise((res, rej) => {
uni.createBLEConnection({
deviceId,
timeout: 15000,
success: res,
fail: rej
});
});
// MTU 协商(蓝牙 5.0 理论最大 512,实际由设备/系统决定)
try {
const mtuRes = await new Promise((res) => {
uni.setBLEMTU({
deviceId,
mtu: 512,
success: res,
fail: () => res({ mtu: 23 })
});
});
this._mtu = (mtuRes.mtu || 512) - 3;
console.log(`[打印] MTU 协商: 请求 512, 实际=${mtuRes.mtu}, 写入块=${this._mtu}`);
} catch (e) {
console.warn('[打印] MTU 协商异常, 使用默认值 20', e);
this._mtu = 20;
}
// 等待服务发现(像 configPrinter 一样)
await this._delay(800);
// ✅ 直接使用已保存的配置,不做任何验证
// 原因:调用 getBLEDeviceCharacteristics 后该特征值会变得不可写(10007)
console.log('使用已保存配置连接成功');
this.deviceId = deviceId;
this.serviceId = savedServiceId;
this.writeCharId = savedWriteCharId;
// 更新 store
this._saveConfig({
deviceId,
serviceId: savedServiceId,
writeCharId: savedWriteCharId
});
resolve();
return;
} catch (err) {
// 连接失败,回退到自动发现
console.warn('使用已保存配置连接失败,回退到自动发现:', err);
await this._disconnectDevice(deviceId);
}
}
// 自动发现服务逻辑(保底方案)
uni.createBLEConnection({
deviceId,
timeout: 15000,
success: async () => {
// 等待服务发现
await this._delay(800);
// 获取服务列表
const { services } = await this._getServices(deviceId);
if (!services || services.length === 0) {
reject(new Error('未找到蓝牙服务'));
return;
}
// 查找第一个有可写特征值的服务
let foundService = null;
let writeChar = null;
for (const service of services) {
try {
const { characteristics } = await this._getCharacteristics(deviceId, service.uuid);
const writableChar = characteristics.find(c =>
c.properties.write || c.properties.writeNoResponse
);
if (writableChar) {
foundService = service;
writeChar = writableChar;
break;
}
} catch (e) {
// 跳过无效服务
continue;
}
}
if (!writeChar) {
reject(new Error('未找到可写入的特征值'));
return;
}
this.deviceId = deviceId;
this.serviceId = foundService.uuid;
this.writeCharId = writeChar.uuid;
// 保存配置
this._saveConfig({
deviceId,
serviceId: foundService.uuid,
writeCharId: writeChar.uuid
});
resolve();
},
fail: reject
});
});
}
/**
* 断开设备连接(内部方法)
*/
async _disconnectDevice(deviceId) {
try {
await new Promise((resolve, reject) => {
uni.closeBLEConnection({
deviceId,
success: resolve,
fail: reject
});
});
} catch (e) {
console.warn('断开设备失败', e);
}
}
/**
* 断开连接
* 注意:只做物理断开,保留配置信息以便下次自动连接
*/
async disconnect() {
if (!this.deviceId) return;
try {
await new Promise((resolve, reject) => {
uni.closeBLEConnection({
deviceId: this.deviceId,
success: resolve,
fail: reject
});
});
} catch (e) {
console.warn('断开连接失败', e);
}
// 只更新连接状态,保留 deviceId、deviceName、serviceId、writeCharId 等配置
this.connected = false;
this._notifyConnectionChange(false);
// 注意:不再清空配置,不再调用 _updateStoreConnection
}
/**
* 检查是否已连接
*/
isConnected() {
return this.connected;
}
// ==================== 打印操作 ====================
/**
* 打印文本内容
* @param {string} content - 打印内容(支持 CPCL/TSPL 指令)
* @param {Object} options - 发送选项
* @param {boolean} options.useChunk - 是否使用分片发送(默认自动判断)
* @param {number} options.chunkSize - 分片大小(字节),默认 20
* @param {number} options.chunkDelay - 分片间隔(毫秒),默认 50
* @returns {Promise<boolean>}
*/
async printText(content, options = {}) {
if (!this.connected) {
throw new Error('打印机未连接');
}
const buffer = this._stringToBuffer(content);
const dataSize = buffer.byteLength;
const lines = content.split('\n');
console.log(`[打印] 共 ${lines.length} 行, ${dataSize} bytes`);
// 行数少时一次发送(更快),行数多时按行发送(防止指令被拆包)
const LINE_THRESHOLD = 10;
if (lines.length <= LINE_THRESHOLD) {
console.log('[打印] 行数较少,一次发送');
await this._writeData(buffer);
} else {
console.log('[打印] 行数较多,按行分片发送');
await this._writeDataByLines(content);
}
return true;
}
/**
* 按行分片发送(推荐用于 CPCL/TSPL 指令)
* 确保每一行指令完整,不被 BLE 包拆分
* @param {string} content - 打印指令(多行字符串)
*/
async _writeDataByLines(content) {
const lines = content.split('\n');
console.log(`[打印] 按行发送,共 ${lines.length}`);
let i = 0;
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trim();
// CPCL QR 码块:从 B QR / BARCODE QR 到 ENDQR,必须作为整体一次发送
// 否则行间 80ms 延迟会导致打印机无法将数据与 QR 命令关联,二维码无法生成
const isQRStart = /^(B\s+QR|BARCODE\s+QR)\b/i.test(trimmed);
if (isQRStart) {
// 收集完整 QR 块(含结束标记 ENDQR)
const qrBlockLines = [];
while (i < lines.length) {
qrBlockLines.push(lines[i]);
if (lines[i].trim() === 'ENDQR') {
i++;
break;
}
i++;
}
// 整体发送 QR 块,中间不加延迟
const blockStr = qrBlockLines.join('\n') + '\n';
const blockBuffer = this._stringToBuffer(blockStr);
console.log(`[打印] 发送 QR 块(${qrBlockLines.length} 行, ${blockBuffer.byteLength}B`);
try {
await this._writeData(blockBuffer);
} catch (err) {
const isLastBlock = i >= lines.length;
if (isLastBlock) {
// 尾块容错:与 _writeData 保持一致,最后一个块写入失败不抛异常
console.warn(`[打印] QR 块(最后一个块)发送失败,视为尾包处理:`, err);
return;
}
console.error(`[打印] QR 块发送失败:`, err);
throw err;
}
} else {
// 普通行,逐行发送
const lineStr = line + '\n';
const buffer = this._stringToBuffer(lineStr);
try {
await this._writeData(buffer);
} catch (err) {
const isLastLine = i === lines.length - 1;
if (isLastLine) {
// 尾行容错:与 _writeData 保持一致,最后一行写入失败不抛异常
console.warn(`[打印] 第 ${i + 1} 行(最后一行)发送失败,视为尾包处理:`, err);
return;
}
console.error(`[打印] 第 ${i + 1} 行发送失败:`, err);
throw err;
}
i++;
}
// 行间/块间延迟 80ms,给打印机处理时间
if (i < lines.length) {
await this._delay(80);
}
}
console.log('[打印] 按行发送完成');
}
/**
* 打印废物标签
* @param {Object} data - 废物数据
* @returns {Promise<boolean>}
*/
async printWasteLabel(data) {
const command = this.generateWasteLabel(data);
return this.printText(command);
}
/**
* 打印二维码
* @param {string} content - 二维码内容
* @returns {Promise<boolean>}
*/
async printQRCode(content) {
const command = this.generateQRCodeTemplate(content);
return this.printText(command);
}
/**
* 打印条码
* @param {string} content - 条码内容
* @param {string} type - 条码类型,默认 128
* @returns {Promise<boolean>}
*/
async printBarcode(content, type = '128') {
const command = this.generateBarcodeTemplate(content, type);
return this.printText(command);
}
// ==================== 指令生成 ====================
/**
* 获取纸张尺寸命令
*/
getSizeCommand() {
if (this.commandType === 'CPCL') {
return `SIZE ${this.paperWidth}mm ${this.paperHeight}mm`;
} else {
return `SIZE ${this.paperWidth} mm ${this.paperHeight} mm`;
}
}
/**
* 生成废物标签指令
* @param {Object} data - 废物数据
*/
generateWasteLabel(data) {
const sizeCmd = this.getSizeCommand();
const gapCmd = this.commandType === 'CPCL' ? 'GAP 2mm' : 'GAP 2 mm';
if (this.commandType === 'CPCL') {
return this._generateCPCLWasteLabel(data, sizeCmd, gapCmd);
} else {
return this._generateTSPLWasteLabel(data, sizeCmd, gapCmd);
}
}
/**
* 生成 CPCL 废物标签(支持预设布局自适应)
*/
_generateCPCLWasteLabel(data, sizeCmd, gapCmd) {
const fieldOrder = [
'mingcheng', 'leibie', 'daima', 'xingtai',
'zhuyaochengfen', 'youhaichengfen', 'zhuyi',
'shibiema', 'danwei', 'lianxi', 'riqi',
'zhongliang', 'beizhu', 'biaoshi'
];
const fieldLabels = {
mingcheng: '名称', leibie: '类别', daima: '代码', xingtai: '形态',
zhuyaochengfen: '主要成分', youhaichengfen: '有害成分', zhuyi: '注意',
shibiema: '识别码', danwei: '单位', lianxi: '联系', riqi: '日期',
zhongliang: '重量', beizhu: '备注', biaoshi: '●'
};
// 优先使用预设布局,否则使用默认配置
const layout = this._getCurrentLayout();
const conf = (layout && layout.fields) ? layout.fields : [
{ x: 192, y: 100 }, { x: 192, y: 150 }, { x: 192, y: 200 }, { x: 480, y: 200 },
{ x: 192, y: 250 }, { x: 192, y: 350 }, { x: 192, y: 448 },
{ x: 216, y: 536 }, { x: 256, y: 584 }, { x: 288, y: 632 },
{ x: 192, y: 680 }, { x: 480, y: 680 }, { x: 150, y: 728 },
{ x: 480, y: 448 }
];
const labelHeight = (layout && layout.labelHeight) || 800;
let commands = [];
commands.push(`! 0 200 200 ${labelHeight} 1`);
commands.push(sizeCmd);
// 200x200 等需要显式声明页宽(避免右半部分被截断);其余预设不输出
if (layout && layout.pageWidth) {
commands.push(`PAGE-WIDTH ${layout.pageWidth}`);
}
// 大标签用 GAP-SENSE 自动感应间隙;其余预设保持原有 GAP 2mm
if (layout && layout.gapSense) {
commands.push('GAP-SENSE');
} else {
commands.push(gapCmd);
}
fieldOrder.forEach((field, index) => {
if (data[field]) {
const f = conf[index] || {};
// 仅当字段显式配置了 mag / bold 时才输出,否则走打印机默认值
// (100x100 标签字段无这些配置,避免输出多余的 SETMAG/SETBOLD
if (f.mag) {
commands.push(`SETMAG ${f.mag[0]} ${f.mag[1]}`);
}
if (f.bold != null) {
commands.push(`SETBOLD ${f.bold}`);
}
const font = (f.font != null) ? f.font : 1;
commands.push(`T ${font} 0 ${f.x} ${f.y} ${data[field]}`);
}
});
if (data.qrcode) {
const qrc = (layout && layout.qrCode) || { x: 624, y: 584, size: 4, prefix: 'B QR' };
const qrPrefix = qrc.prefix || 'B QR';
commands.push(`${qrPrefix} ${qrc.x} ${qrc.y} M 2 U ${qrc.size}`);
commands.push(`L0,${data.qrcode}\r\n`);
commands.push('ENDQR');
}
commands.push('FORM');
commands.push('PRINT');
return commands.join('\n');
}
/**
* 生成 TSPL 废物标签(支持预设布局自适应)
*/
_generateTSPLWasteLabel(data, sizeCmd, gapCmd) {
const fieldOrder = [
'mingcheng', 'leibie', 'daima', 'xingtai',
'zhuyaochengfen', 'youhaichengfen', 'zhuyi',
'shibiema', 'danwei', 'lianxi', 'riqi',
'zhongliang', 'beizhu'
];
const fieldLabels = {
mingcheng: '名称', leibie: '类别', daima: '代码', xingtai: '形态',
zhuyaochengfen: '主要成分', youhaichengfen: '有害成分', zhuyi: '注意',
shibiema: '识别码', danwei: '单位', lianxi: '联系', riqi: '日期',
zhongliang: '重量', beizhu: '备注'
};
// 优先使用预设布局,否则使用默认配置
const layout = this._getCurrentLayout();
const startX = (layout && layout.startX) || 20;
const lineHeight = (layout && layout.lineHeight) || 25;
const fontSize = (layout && layout.fontSize) || '4';
let commands = [];
commands.push(sizeCmd);
commands.push(gapCmd);
commands.push('CLS');
fieldOrder.forEach((field, index) => {
if (data[field]) {
const y = startX + (index * lineHeight);
const content = `${data[field]}`;
commands.push(`TEXT ${startX} ${y} "${fontSize}" 0 1 1 "${content}"`);
}
});
if (data.qrcode) {
const qrc = (layout && layout.qrCode) || { x: 150, y: 400, size: 5 };
commands.push(`QRCODE ${qrc.x} ${qrc.y} L ${qrc.size} 0 A 0 "${data.qrcode}"`);
}
commands.push('PRINT');
return commands.join('\n');
}
/**
* 生成二维码模板
* @param {string} content - 二维码内容
*/
generateQRCodeTemplate(content) {
const sizeCmd = this.getSizeCommand();
const gapCmd = this.commandType === 'CPCL' ? 'GAP 2mm' : 'GAP 2 mm';
if (this.commandType === 'CPCL') {
return `! 0 200 200 240 1
${sizeCmd}
${gapCmd}
CENTER
QR 300 10 M 2 A 0
MA,${content}
ENDQR
PRINT`;
} else {
return `${sizeCmd}
${gapCmd}
CLS
QRCODE 50 50 L 5 0 A 0 "${content}"
PRINT`;
}
}
/**
* 生成条码模板
* @param {string} content - 条码内容
* @param {string} type - 条码类型
*/
generateBarcodeTemplate(content, type = '128') {
const sizeCmd = this.getSizeCommand();
const gapCmd = this.commandType === 'CPCL' ? 'GAP 2mm' : 'GAP 2 mm';
if (this.commandType === 'CPCL') {
return `! 0 200 200 240 1
${sizeCmd}
${gapCmd}
CENTER
BARCODE 128 1 1 60 1 0 10 40 ${content}
PRINT`;
} else {
return `${sizeCmd}
${gapCmd}
CLS
BARCODE 50 50 "${type}" 50 1 0 2 2 "${content}"
PRINT`;
}
}
// ==================== 配置管理 ====================
/**
* 设置指令类型
* @param {'CPCL' | 'TSPL'} type
*/
setCommandType(type) {
this.commandType = type;
this._saveConfig({ commandType: type });
}
/**
* 设置纸张尺寸
* @param {number} width - 宽度(mm)
* @param {number} height - 高度(mm)
*/
setPaperSize(width, height) {
this.paperWidth = width;
this.paperHeight = height;
// 检测是否匹配预设尺寸
const presetKey = `${width}x${height}`;
if (PAPER_PRESETS[presetKey]) {
this.currentPreset = presetKey;
} else {
this.currentPreset = null;
}
this._saveConfig({
paperWidth: width,
paperHeight: height,
currentPreset: this.currentPreset
});
}
/**
* 获取所有可用的预设纸张尺寸
* @returns {Array<{key: string, label: string, width: number, height: number}>}
*/
static getPresetPaperSizes() {
return Object.keys(PAPER_PRESETS).map(key => {
const preset = PAPER_PRESETS[key];
return {
key,
label: `${preset.labelWidth} × ${preset.labelWidth} mm`,
width: preset.labelWidth,
height: preset.labelWidth
};
});
}
/**
* 使用预设纸张尺寸
* @param {string} presetKey - 预设键名,如 '100x100', '150x150', '200x200'
*/
usePresetPaperSize(presetKey) {
const preset = PAPER_PRESETS[presetKey];
if (!preset) {
console.warn(`预设纸张尺寸 "${presetKey}" 不存在`);
return;
}
this.paperWidth = preset.labelWidth;
this.paperHeight = preset.labelWidth;
this.currentPreset = presetKey;
this._saveConfig({
paperWidth: preset.labelWidth,
paperHeight: preset.labelWidth,
currentPreset: presetKey
});
}
/**
* 获取当前预设的布局配置
* @returns {object|null}
*/
_getCurrentLayout() {
if (!this.currentPreset || !PAPER_PRESETS[this.currentPreset]) {
return null;
}
const preset = PAPER_PRESETS[this.currentPreset];
const type = this.commandType === 'CPCL' ? 'cpcl' : 'tspl';
return {
...preset[type],
labelHeight: preset.labelHeight,
fontSize: preset.fontSize,
lineHeight: preset.lineHeight
};
}
/**
* 获取当前配置
*/
getConfig() {
return {
connected: this.connected,
deviceId: this.deviceId,
deviceName: this.deviceName,
serviceId: this.serviceId,
writeCharId: this.writeCharId,
commandType: this.commandType,
paperWidth: this.paperWidth,
paperHeight: this.paperHeight,
currentPreset: this.currentPreset
};
}
// ==================== 回调管理 ====================
/**
* 注册连接状态变化回调
* @param {Function} callback - (connected: boolean, status?: string) => void
*/
onConnectionChange(callback) {
this.connectionChangeCallbacks.push(callback);
}
/**
* 移除连接状态变化回调
*/
offConnectionChange(callback) {
const index = this.connectionChangeCallbacks.indexOf(callback);
if (index > -1) {
this.connectionChangeCallbacks.splice(index, 1);
}
}
/**
* 通知连接状态变化
*/
_notifyConnectionChange(connected, status) {
this.connectionChangeCallbacks.forEach(cb => {
try {
cb(connected, status);
} catch (e) {
console.warn('连接状态回调执行失败', e);
}
});
}
// ==================== 工具方法 ====================
/**
* 字符串转 ArrayBuffer (GBK编码)
*/
_stringToBuffer(str) {
try {
const uint8Array = iconv.encode(str, 'gbk');
return uint8Array.buffer;
} catch (e) {
console.warn('GBK编码失败,使用回退方案', e);
const buffer = new ArrayBuffer(str.length);
const dataView = new DataView(buffer);
for (let i = 0; i < str.length; i++) {
dataView.setUint8(i, str.charCodeAt(i) & 0xFF);
}
return buffer;
}
}
/**
* 写入数据到蓝牙设备(自动适配 MTU)
* 策略:
* 1. 数据 > MTU 时自动走分片发送(_writeDataInChunks
* 2. 数据 ≤ MTU 时一次发送,writeType 自动重试
* 3. 失败时切换 writeType 重试(null → writeNoResponse → write
* 4. 失败时增加延迟(BLE 协议栈繁忙)后再次重试
*/
async _writeData(buffer, attempt = 0) {
const dataLen = buffer.byteLength;
const mtu = this._mtu || 20;
// 数据超过 MTU?自动走分片
if (dataLen > mtu) {
console.log(`[打印] 数据 ${dataLen}B > MTU ${mtu}B, 自动分片发送`);
return this._writeDataInChunks(buffer, mtu);
}
const writeTypes = [null, 'writeNoResponse', 'write']; // 依次尝试
const maxAttempts = 3; // 总共最多重试 3 次
if (attempt >= maxAttempts) {
// 尾包/单包容错:数据已基本送达(最后一次 writeBLECharacteristicValue 触发 errCode=10007
// 通常是打印机刚处理完上一批数据时协议栈返回 property not support,并非真正写入失败)。
// 此处不再抛异常,静默视为成功以保证整个打印流程不中断。
console.warn(`[打印] 写入重试 ${maxAttempts} 次仍失败,视为尾包/打印机忙导致,数据可能已送达`);
return { success: true, silent: true, reason: 'max attempts reached, treat as success (tail-packet tolerance)' };
}
// 选择本次尝试的 writeType
const writeType = writeTypes[attempt] || null;
const options = {
deviceId: this.deviceId,
serviceId: this.serviceId,
characteristicId: this.writeCharId,
value: buffer
};
// 只有当有指定值时才设置 writeType
if (writeType) {
options.writeType = writeType;
}
console.log(`[打印] 写入尝试 ${attempt + 1}/${maxAttempts}, writeType=${writeType || 'auto'}`);
return new Promise((resolve, reject) => {
uni.writeBLECharacteristicValue({
...options,
success: (res) => {
// 成功后缓存 writeType
this._writeType = writeType;
console.log(`[打印] 写入成功, writeType=${writeType || 'auto'}`);
resolve(res);
},
fail: (err) => {
console.warn(`[打印] 写入失败 attempt=${attempt + 1}, errCode=${err.errCode}, msg=${err.errMsg}`);
// 延迟后重试(递增延迟,给 BLE 协议栈恢复时间)
const delay = 100 + attempt * 150; // 100ms, 250ms, 400ms
setTimeout(() => {
this._writeData(buffer, attempt + 1).then(resolve).catch(reject);
}, delay);
}
});
});
}
/**
* 发送单个数据包(分片用)—— 带 writeType 容错 + 多重试
* @param {ArrayBuffer} chunkBuffer - 数据包
* @param {boolean} retryWithWrite - 是否用 'write' 模式重试
* @param {number} attempt - 当前重试次数(内部使用)
*/
_writeChunk(chunkBuffer, retryWithWrite = false, attempt = 0) {
return new Promise((resolve, reject) => {
uni.writeBLECharacteristicValue({
deviceId: this.deviceId,
serviceId: this.serviceId,
characteristicId: this.writeCharId,
value: chunkBuffer,
writeType: retryWithWrite ? 'write' : 'writeNoResponse',
success: resolve,
fail: async (err) => {
// writeNoResponse 不支持时降级为 write 模式重试
if (!retryWithWrite && err.errCode === 10007) {
console.warn('[分片] writeNoResponse 不支持,降级为 write 模式');
try {
await this._delay(50);
await this._writeChunk(chunkBuffer, true, 0);
resolve();
} catch (downgradeErr) {
reject(downgradeErr);
}
return;
}
// 10007 设备忙:等 100ms 重试,最多 3 次
if (err.errCode === 10007 && attempt < 3) {
const wait = 100 * (attempt + 1);
console.warn(`[分片] 设备忙(10007)${wait}ms 后第 ${attempt + 1}/3 次重试...`);
try {
await this._delay(wait);
await this._writeChunk(chunkBuffer, retryWithWrite, attempt + 1);
resolve();
} catch (retryErr) {
reject(retryErr);
}
return;
}
reject(err);
}
});
});
}
/**
* 发送单个数据包 —— 强制使用 'write' 模式(带响应)
* 用于尾包等关键数据:write 模式会等待 ACK,确保数据真的到达打印机
* @param {ArrayBuffer} chunkBuffer - 数据包
*/
_writeChunkWithResponse(chunkBuffer) {
return new Promise((resolve, reject) => {
uni.writeBLECharacteristicValue({
deviceId: this.deviceId,
serviceId: this.serviceId,
characteristicId: this.writeCharId,
value: chunkBuffer,
writeType: 'write', // 强制带响应,确保打印机确认收到
success: resolve,
fail: reject
});
});
}
/**
* 获取设备 MTU(最大传输单元)
* @returns {Promise<number>}
*/
async _getMTU() {
try {
const result = await new Promise((resolve, reject) => {
uni.getBLEMTU({
deviceId: this.deviceId,
success: resolve,
fail: reject
});
});
// MTU 减去 3 字节的 ATT 头信息为有效载荷
return (result.mtu || 23) - 3;
} catch (e) {
console.warn('获取 MTU 失败,使用默认值 20', e);
return 20;
}
}
/**
* 根据数据大小计算合适的分片延迟
* @param {number} dataSize - 数据大小(字节)
* @returns {number} 延迟时间(毫秒)
*/
_getChunkDelay(dataSize) {
if (dataSize > 5120) return 200; // 超过 5KB
if (dataSize > 1024) return 100; // 超过 1KB
return 50;
}
/**
* 分片发送数据(适合大数据)
* @param {ArrayBuffer} buffer - 要发送的数据
* @param {number} mtu - 每包大小(字节),默认走协商后的 _mtu,兜底 20
* @param {number|null} delay - 包之间的延迟(毫秒),传 null 时按数据量自适应
*/
async _writeDataInChunks(buffer, mtu = null, delay = null) {
const totalLength = buffer.byteLength;
// 参数兜底:传参 > 缓存的 _mtu > 默认 20
mtu = mtu || this._mtu || 20;
// delay 自适应:传了具体值就用,否则按数据量计算
delay = delay ?? this._getChunkDelay(totalLength);
const dataView = new Uint8Array(buffer);
console.log(`开始分片发送: 总大小=${totalLength}字节, MTU=${mtu}字节/包, 延迟=${delay}ms`);
for (let offset = 0; offset < totalLength; offset += mtu) {
const end = Math.min(offset + mtu, totalLength);
const chunk = dataView.slice(offset, end);
const chunkBuffer = chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength);
const isLastChunk = end >= totalLength;
try {
await this._writeChunk(chunkBuffer);
if ((offset / mtu) % 10 === 0) {
console.log(`发送进度: ${end}/${totalLength}`);
}
if (delay > 0 && end < totalLength) {
await this._delay(delay);
}
} catch (err) {
// 尾包容错:最后一包发送失败时,多重试几次(递增延迟 + 强制 write 模式确认)
// 原因:数据已基本送达,打印机在处理打印任务时 BLE 通道会返回 10007 "property not support"(实际为设备忙)
// 这种情况多等一会打印机就能接收,但若直接放弃会导致 QR 码等关键数据被截断
if (isLastChunk) {
const retryDelays = [300, 600, 1000, 1500, 2000]; // 递增延迟
let delivered = false;
for (let attempt = 0; attempt < retryDelays.length; attempt++) {
const wait = retryDelays[attempt];
console.warn(`尾包发送失败(offset=${offset}, size=${end - offset}B),第 ${attempt + 1}/${retryDelays.length} 次重试,等待 ${wait}ms...`, err);
await this._delay(wait);
try {
// 强制使用 write 模式(带响应)发送尾包,确保打印机真的收到了
await this._writeChunkWithResponse(chunkBuffer);
console.log(`尾包重试成功(offset=${offset}, attempt=${attempt + 1})`);
delivered = true;
break;
} catch (retryErr) {
console.warn(`尾包第 ${attempt + 1} 次重试仍失败:`, retryErr);
}
}
if (delivered) {
continue;
}
// 5 次重试全部失败:仍按原策略静默成功(避免上层误报),但通过 console.error 醒目提示数据可能不完整
console.error(`【打印数据可能不完整】尾包 5 次重试均失败(offset=${offset}, size=${end - offset}B),末尾 ${end - offset} 字节可能丢失,请检查打印结果!`);
return;
}
console.error(`分片发送失败 at offset ${offset}:`, err);
throw err;
}
}
console.log('分片发送完成');
}
/**
* 计算打印指令的数据长度(字节)
* @param {string} command - 打印指令字符串
* @returns {number} 字节长度
*/
calculateCommandSize(command) {
try {
const uint8Array = iconv.encode(command, 'gbk');
return uint8Array.length;
} catch (e) {
return command.length;
}
}
/**
* 延迟
*/
_delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 获取设备服务列表
*/
_getServices(deviceId) {
return new Promise((resolve, reject) => {
uni.getBLEDeviceServices({
deviceId,
success: resolve,
fail: reject
});
});
}
/**
* 获取服务特征值列表
*/
_getCharacteristics(deviceId, serviceId) {
return new Promise((resolve, reject) => {
uni.getBLEDeviceCharacteristics({
deviceId,
serviceId,
success: resolve,
fail: reject
});
});
}
}
// 导出单例
export default new BluetoothPrinterService();