Files
uni-pda/utils/BluetoothPrinterService.js
2026-06-02 15:28:25 +08:00

750 lines
20 KiB
JavaScript

/**
* 蓝牙打印服务类
* 功能:
* - 统一管理蓝牙打印机连接
* - 支持 CPCL 和 TSPL 两种指令格式
* - 自动与 Vuex store 同步配置
* - 提供指令生成模板
*
* 使用方法:
* import printerService from '@/utils/BluetoothPrinterService.js';
*
* // 初始化并连接
* await printerService.init();
* await printerService.autoConnect();
*
* // 打印操作
* await printerService.printText('Hello World');
* 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;
};
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.connectionChangeCallbacks = [];
// 连接状态监听器
this._connectionListener = null;
}
// ==================== 初始化 ====================
/**
* 初始化蓝牙适配器
* @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);
uni.showToast({ title: '蓝牙初始化失败,请检查蓝牙是否开启', icon: 'none' });
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;
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);
uni.showToast({ title: '打印机连接成功', icon: 'success' });
return true;
} catch (err) {
this.connected = false;
this.connecting = false;
this._notifyConnectionChange(false, 'failed');
console.error('自动连接失败', err);
uni.showToast({ title: '打印机连接失败', icon: 'none' });
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
});
});
// 等待服务发现
await this._delay(800);
// 验证保存的服务是否存在
const { services } = await this._getServices(deviceId);
const targetService = services.find(s => s.uuid === savedServiceId);
if (!targetService) {
console.warn('保存的服务不存在,尝试自动发现');
await this._disconnectDevice(deviceId);
throw new Error('SAVED_SERVICE_NOT_FOUND');
}
// 验证特征值是否存在且可写
const { characteristics } = await this._getCharacteristics(deviceId, savedServiceId);
const targetChar = characteristics.find(c =>
c.uuid === savedWriteCharId &&
(c.properties.write || c.properties.writeNoResponse)
);
if (!targetChar) {
console.warn('保存的特征值不可用,尝试自动发现');
await this._disconnectDevice(deviceId);
throw new Error('SAVED_CHAR_NOT_FOUND');
}
// 使用保存的配置成功
console.log('使用已保存配置连接成功');
this.deviceId = deviceId;
this.serviceId = savedServiceId;
this.writeCharId = savedWriteCharId;
// 更新 store
this._saveConfig({
deviceId,
serviceId: savedServiceId,
writeCharId: savedWriteCharId
});
resolve();
return;
} catch (err) {
// 如果不是"保存的配置无效"错误,则直接拒绝
if (err.message !== 'SAVED_SERVICE_NOT_FOUND' && err.message !== 'SAVED_CHAR_NOT_FOUND') {
await this._disconnectDevice(deviceId);
reject(err);
return;
}
// 否则继续自动发现
console.log('回退到自动发现服务...');
}
}
// 自动发现服务逻辑(保底方案)
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 指令)
* @returns {Promise<boolean>}
*/
async printText(content) {
if (!this.connected) {
uni.showToast({ title: '打印机未连接,请先连接打印机', icon: 'none' });
throw new Error('打印机未连接');
}
const buffer = this._stringToBuffer(content);
await this._writeData(buffer);
return true;
}
/**
* 打印废物标签
* @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 conf = [
{ 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: 630, y: 540 }
];
let commands = [];
commands.push('! 0 200 200 800 1');
commands.push(sizeCmd);
fieldOrder.forEach((field, index) => {
if (data[field]) {
const { x, y } = conf[index];
const label = fieldLabels[field] || field;
commands.push(`T 1 0 ${x} ${y} ${data[field]}`);
}
});
if (data.qrcode) {
commands.push(`B QR 624 584 M 2 U 4`);
commands.push(`L0,${data.qrcode}`);
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: '备注'
};
let commands = [];
commands.push(sizeCmd);
commands.push(gapCmd);
commands.push('CLS');
fieldOrder.forEach((field, index) => {
if (data[field]) {
const y = 20 + (index * 25);
const label = fieldLabels[field] || field;
const content = `${label}: ${data[field]}`;
commands.push(`TEXT 20 ${y} "4" 0 1 1 "${content}"`);
}
});
if (data.qrcode) {
commands.push(`QRCODE 150 400 L 5 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;
this._saveConfig({ paperWidth: width, paperHeight: height });
}
/**
* 获取当前配置
*/
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
};
}
// ==================== 回调管理 ====================
/**
* 注册连接状态变化回调
* @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;
}
}
/**
* 写入数据到蓝牙设备
*/
async _writeData(buffer) {
return new Promise((resolve, reject) => {
uni.writeBLECharacteristicValue({
deviceId: this.deviceId,
serviceId: this.serviceId,
characteristicId: this.writeCharId,
value: buffer,
writeType: 'writeNoResponse',
success: resolve,
fail: reject
});
});
}
/**
* 延迟
*/
_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();