Files
uni-pda/utils/BluetoothPrinterService.new.js
T
2026-08-18 14:46:36 +08:00

1351 lines
43 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: 140 }, // 名称(识别码以上,下2mm
{ x: 192, y: 190 }, // 类别(识别码以上,下2mm
{ x: 192, y: 240 }, // 代码(识别码以上,下2mm
{ x: 480, y: 240 }, // 形态(识别码以上,下2mm
{ x: 192, y: 290 }, // 主要成分(识别码以上,下2mm)
{ x: 192, y: 390 }, // 有害成分(识别码以上,下2mm)
{ x: 192, y: 488 }, // 注意(识别码以上,下2mm
{ x: 216, y: 576 }, // 识别码(含,下2mm
{ x: 256, y: 624 }, // 单位(识别码以下,下1mm
{ x: 288, y: 672 }, // 联系(识别码以下,下1mm
{ x: 192, y: 720 }, // 日期(识别码以下,下1mm
{ x: 480, y: 720 }, // 重量(识别码以下,下1mm
{ x: 150, y: 768 } // 备注(识别码以下,下1mm
],
// 标识点:正方形四角布局(1=左上、2=右上、3=左下、4=右下),无中心点
// 由 data.biaoshi 传入位置编号数组驱动,使用 √ 字符绘制
biaoshiPositions: {
1: { x: 608, y: 250 }, // 向左 2mm16点)
2: { x: 704, y: 250 },
3: { x: 608, y: 410 }, // 向左 2mm16点),向下 2mm40点)
4: { x: 704, y: 410 } // 向下 2mm40点)
},
biaoshiMag: [2, 2],
qrCode: { x: 604, y: 624, size: 4 } // 向左 1mm20点),向上 1mm20点)
},
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: [1, 1], 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 } // 备注(注意以下)
],
// 标识点:正方形四角布局(1=左上、2=右上、3=左下、4=右下),无中心点
biaoshiPositions: {
1: { x: 904, y: 399 }, // 左上(累计左移 4mm / 32点)
2: { x: 1064, y: 399 }, // 右上(累计左移 4mm / 32点)
3: { x: 904, y: 551 }, // 左下(累计左移 4mm + 下移 4mm / 各32点)
4: { x: 1064, y: 551 } // 右下(累计左移 4mm + 下移 4mm / 各32点)
},
biaoshiMag: [4, 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-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 } // 备注
],
// 标识点:正方形四角布局(1=左上、2=右上、3=左下、4=右下),无中心点
biaoshiPositions: {
1: { x: 1220, y: 544 },
2: { x: 1460, y: 544 },
3: { x: 1220, y: 724 },
4: { x: 1460, y: 724 }
},
biaoshiMag: [4, 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) => {
// 10001 表示蓝牙适配器已打开,视为成功(部分机型重复 open 会返回该码)
if (err && err.errCode === 10001) {
this.adapterOpen = true;
this._registerConnectionListener();
resolve();
return;
}
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 = '') {
// 建立 BLE 连接(与 configPrinter 一致,只用 deviceId 直连,秒连)
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)
// 若未提供已保存配置,回退到自动发现
if (savedServiceId && savedWriteCharId) {
console.log('使用已保存配置连接成功:', { savedServiceId, savedWriteCharId });
this.deviceId = deviceId;
this.serviceId = savedServiceId;
this.writeCharId = savedWriteCharId;
this._saveConfig({
deviceId,
serviceId: savedServiceId,
writeCharId: savedWriteCharId
});
return;
}
console.warn('未提供已保存配置,回退到自动发现');
const { services } = await this._getServices(deviceId);
if (!services || services.length === 0) {
throw new Error('未找到蓝牙服务');
}
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) {
throw new Error('未找到可写入的特征值');
}
this.deviceId = deviceId;
this.serviceId = foundService.uuid;
this.writeCharId = writeChar.uuid;
this._saveConfig({
deviceId,
serviceId: foundService.uuid,
writeCharId: writeChar.uuid
});
}
/**
* 断开设备连接(内部方法)
*/
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) {
// QR 块是打印指令的组成数据,发送失败会导致整页数据不完整,必须报错
console.error(`[打印] QR 块发送失败:`, err);
throw err;
}
} else {
// 普通行,逐行发送
const lineStr = line + '\n';
const buffer = this._stringToBuffer(lineStr);
const isCriticalLine = trimmed === 'PRINT' || trimmed === 'FORM';
if (isCriticalLine) {
// 关键打印指令:最多重试 3 次,不容忍失败
let delivered = false;
for (let retry = 0; retry < 3; retry++) {
try {
await this._writeData(buffer);
delivered = true;
break;
} catch (writeErr) {
if (retry < 2) {
const wait = 300 + retry * 300; // 300, 600ms
console.warn(`[打印] 关键行 "${trimmed}" 写入失败,${wait}ms后重试(${retry + 1}/3):`, writeErr.message || writeErr);
await this._delay(wait);
}
}
}
if (!delivered) {
throw new Error(`关键打印指令 "${trimmed}" 发送失败,打印机未收到执行命令,请重试打印`);
}
} else {
// 普通行:任意一行发送失败都视为打印数据不完整,必须报错
try {
await this._writeData(buffer);
} catch (err) {
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) {
// 正式打印入口:强制从 store 加载正式配置(含纸张尺寸/currentPreset/指令类型),
// 避免打印测试页的临时设置(纸张/指令类型仅在内存生效)泄漏到正式打印
this._loadConfig();
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'
];
const fieldLabels = {
mingcheng: '名称', leibie: '类别', daima: '代码', xingtai: '形态',
zhuyaochengfen: '主要成分', youhaichengfen: '有害成分', zhuyi: '注意',
shibiema: '识别码', danwei: '单位', lianxi: '联系', riqi: '日期',
zhongliang: '重量', beizhu: '备注'
};
// 优先使用预设布局,否则使用默认配置
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 }
];
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]}`);
} else {
// 无 mag 时显式复位成 1x1,避免继承上一个 T(如标识点的 2x2)的放大状态
commands.push(`SETMAG 1 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]}`);
}
});
// 标识点:根据 data.biaoshi 传入的位置编号数组,在正方形四角绘制 √
const biaoshiArr = Array.isArray(data.biaoshi) ? data.biaoshi : [];
if (biaoshiArr.length > 0) {
const positions = (layout && layout.biaoshiPositions) || {};
const mag = (layout && layout.biaoshiMag) || [2, 2];
commands.push(`SETMAG ${mag[0]} ${mag[1]}`);
biaoshiArr.forEach(posNum => {
const pos = positions[posNum];
if (pos) {
commands.push(`T 1 0 ${pos.x} ${pos.y}`);
}
});
// 标识 √ 画完后复位字号,避免影响后续二维码/字段(SETMAG 为累积状态)
commands.push(`SETMAG 1 1`);
}
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
});
}
/**
* 临时设置纸张尺寸(仅内存生效,不保存配置)
* 用于打印测试页面,不影响正式配置与 store
* @param {number} width - 宽度(mm)
* @param {number} height - 高度(mm)
*/
setPaperSizeTemporary(width, height) {
this.paperWidth = width;
this.paperHeight = height;
const presetKey = `${width}x${height}`;
if (PAPER_PRESETS[presetKey]) {
this.currentPreset = presetKey;
} else {
this.currentPreset = null;
}
}
/**
* 临时设置指令类型(仅内存生效,不保存配置)
* 用于打印测试页面,不影响正式配置与 store
* @param {'CPCL' | 'TSPL'} type
*/
setCommandTypeTemporary(type) {
this.commandType = type;
}
/**
* 获取所有可用的预设纸张尺寸
* @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() {
// 一致性修正:currentPreset 与当前纸张尺寸不符时,按尺寸重新推导
// (配置页只保存宽高、未保存 currentPreset 时也能正确选布局)
const presetKey = `${this.paperWidth}x${this.paperHeight}`;
if (this.currentPreset !== presetKey && PAPER_PRESETS[presetKey]) {
this.currentPreset = presetKey;
}
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, _lastError = null) {
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) {
const errCode = _lastError ? _lastError.errCode : 'unknown';
const errMsg = _lastError ? _lastError.errMsg : '';
// 三种 writeType(null/writeNoResponse/write) 全部尝试失败 = 数据未送达。
// 10007 多为"特征值不支持该写入方式/属性不支持",连续失败说明特征值
// 不可写或 serviceId/writeCharId 配置错误,绝不能静默视为成功,否则会
// 误报"打印成功"而打印机实际无反应。
throw new Error(`蓝牙写入失败(已重试${maxAttempts}次): errCode=${errCode}, errMsg=${errMsg},请重试或重新配对打印机`);
}
// 选择本次尝试的 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, err).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 次重试(含强制 write 模式)全部失败:数据确实未送达,必须报错
throw new Error(`尾包发送失败,打印数据不完整(末尾 ${end - offset} 字节可能丢失),请重试打印`);
}
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();