1286 lines
40 KiB
JavaScript
1286 lines
40 KiB
JavaScript
/**
|
||
* 蓝牙打印服务类(新版:支持预设纸张尺寸适配)
|
||
* 功能:
|
||
* - 统一管理蓝牙打印机连接
|
||
* - 支持 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-WIDTH(150mm @200dpi = 1200 点),防止右半边被截断
|
||
gapSense: true, // 自动检测标签间隙,避免固定 GAP 值不匹配
|
||
// 字体大小沿用 200x200(mag/bold),位置用 150 原坐标,整体下移 3mm(24 点 @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+24,4 倍放大)
|
||
],
|
||
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-WIDTH(200mm @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();
|