浏览器连接 新北洋BTP-P33/P32蓝牙打印机,打印 二维码
- 连接全程用的就是浏览器原生 Web Bluetooth API,没有任何第三方库,这个api存在一定的兼容问题,谨慎使用。
- 转码库 gbk.js ,一个小而快的GBK库,支持浏览器,有中文的话,需要转码,BTP-P33/P32这个型号不支持UTF-8,不转换成 GBK 格式,中文打印出来就是乱码。
结合ai和网上扒的部分代码实现的,懒得介绍各个模块,交给ai分析,完整代码移步至最后。
1、蓝牙连接相关
1.1 蓝牙连接(打印机连接)
-
函数入口
- 命名:
getBluetooth,异步函数,负责完成一次完整的「选设备 → 连 GATT → 缓存服务」流程。
-
连接锁
- 先调用
closeBluetooth() 把上一次可能未断开的设备强制释放。
- 用
isConnecting.value 做布尔锁,防止用户多次点击造成并发请求。
-
浏览器兼容性检查
- 判断全局是否存在
navigator.bluetooth,没有就弹错误通知并直接返回。
-
第一次设备选择(带过滤器)
- 通过
requestDevice 只列出名字或前缀符合的打印机:
– 精确名:BTP-P32、BTP-P33
– 前缀:BTP、Printer
- 指定
optionalServices 为后续打印指令所需 UUID 数组。
-
acceptAllDevices: false 强制走过滤器,减少用户误选。
-
提前注册断开监听
- 给选中的
device 绑定 gattserverdisconnected 事件,一旦硬件断电或超出范围可立即触发 onDisconnected 回调,方便 UI 状态同步。
-
成功反馈
- 把设备对象写入
ConnectedBluetooth.value,供后续打印逻辑使用。
- 弹通知告诉用户「设备已选择」并显示设备名。
-
预拉服务/特征值
- 调用
discoverServicesAndCharacteristics(device) 一次性拿到所有服务和特征并缓存,减少真正打印时的往返延迟。
-
第一次异常处理(firstError)
-
NotFoundError:用户没看到任何匹配设备 → 弹 confirm 询问「是否放宽条件显示所有蓝牙设备」。
– 若用户点「确定」则第二次调用 requestDevice,这次 acceptAllDevices: true,过滤器失效。
– 若二次选择仍失败,弹「未找到任何蓝牙设备」。
-
NotAllowedError:用户拒绝权限 → 提示「请允许浏览器访问蓝牙」。
- 其他错误 → 统一弹「连接失败」并输出具体
message。
-
连接锁释放
- 无论成功还是任何分支的异常,都在
finally 里把 isConnecting.value 重置为 false,保证下次可重新点击。
-
整体特点
- 采用「先精确后宽泛」两步走策略,兼顾易用性与兼容性。
- 所有耗时/异步操作都放在
try 内,用户侧只有「选择弹窗」会阻塞,其余异常均友好提示。
- 通过「断开监听 + 预缓存服务」让后续打印阶段只需纯粹写特征值,缩短真正出纸时间。
// 蓝牙连接逻辑(增加连接状态锁定)
const getBluetooth = async () => {
// 先断开已有连接
await closeBluetooth();
if (isConnecting.value) return;
isConnecting.value = true;
let device;
try {
// @ts-ignore
if (!navigator.bluetooth) {
notification.error({
message: '浏览器不支持',
description: '请使用Chrome、Edge等支持Web Bluetooth API的浏览器'
});
return;
}
// 优先过滤打印机设备
//@ts-ignore
device = await navigator.bluetooth.requestDevice({
filters: [
{ name: 'BTP-P32' },
{ name: 'BTP-P33' },
{ namePrefix: 'BTP' },
{ namePrefix: 'Printer' } // 通用打印机名称前缀
],
optionalServices: possibleServices,
acceptAllDevices: false
});
// 监听设备断开事件
device.addEventListener('gattserverdisconnected', onDisconnected);
notification.success({
message: '设备已选择',
description: `名称:${device.name || '未知设备'}`
});
ConnectedBluetooth.value = device;
// 提前获取服务和特征值,减少打印时的耗时
await discoverServicesAndCharacteristics(device);
} catch (firstError: any) {
if (firstError.name === 'NotFoundError') {
const userConfirm = confirm(
'未找到指定打印机,是否显示所有蓝牙设备?\n' +
'提示:请确保打印机已开启并处于可配对状态'
);
if (userConfirm) {
try {
// @ts-ignore
device = await navigator.bluetooth.requestDevice({
acceptAllDevices: true,
optionalServices: possibleServices
});
device.addEventListener('gattserverdisconnected', onDisconnected);
ConnectedBluetooth.value = device;
await discoverServicesAndCharacteristics(device);
notification.success({
message: '设备已选择',
description: `名称:${device.name || '未知设备'}`
});
} catch (e) {
notification.error({ message: '选择设备失败', description: '未找到任何蓝牙设备' });
}
}
} else if (firstError.name === 'NotAllowedError') {
notification.error({
message: '权限被拒绝',
description: '请允许浏览器访问蓝牙设备'
});
} else {
notification.error({
message: '连接失败',
description: firstError.message || '未知错误'
});
}
} finally {
isConnecting.value = false;
}
}
// 断开连接处理
const onDisconnected = (event: any) => {
const device = event.target;
notification.warning({
message: '设备已断开',
description: `${device.name || '蓝牙设备'}连接已丢失`
});
ConnectedBluetooth.value = null;
currentService.value = null;
currentCharacteristic.value = null;
};
// 断开连接逻辑
const closeBluetooth = async () => {
try {
if (ConnectedBluetooth.value) {
if (ConnectedBluetooth.value.gatt.connected) {
await ConnectedBluetooth.value.gatt.disconnect();
}
notification.success({
message: '断开成功',
description: `${ConnectedBluetooth.value?.name || '设备'}已断开`
});
}
ConnectedBluetooth.value = null;
currentService.value = null;
currentCharacteristic.value = null;
} catch (error) {
notification.error({
message: '断开失败',
description: '无法断开蓝牙连接'
});
}
}
// 发现设备支持的服务和特征值(动态探测)
const discoverServicesAndCharacteristics = async (device: any) => {
try {
const server = await device.gatt.connect();
const services = await server.getPrimaryServices();
// 遍历所有服务,寻找支持的特征值
for (const service of services) {
const characteristics = await service.getCharacteristics();
for (const characteristic of characteristics) {
// 检查特征值是否可写入
const properties = characteristic.properties;
if (properties.write || properties.writeWithoutResponse) {
currentService.value = service;
currentCharacteristic.value = characteristic;
notification.success({
message: `服务:${characteristic.uuid}`,
description: `特征值:${characteristic.uuid}`,
})
return true; // 找到可用的特征值后退出
}
}
}
// 如果没有找到预设的特征值,提示用户
notification.warning({
message: '未找到打印特征值',
description: '设备可能不支持打印功能'
});
return false;
} catch (error) {
console.error('发现服务失败:', error);
return false;
}
};
// 连接并获取打印特征值(增加重试机制)
const connectAndPrint = async (retries = 2) => {
if (!ConnectedBluetooth.value) {
notification.error({ message: '未连接蓝牙设备' });
return null;
}
try {
// 检查当前连接状态
if (!ConnectedBluetooth.value.gatt.connected) {
await ConnectedBluetooth.value.gatt.connect();
// 重新发现服务
await discoverServicesAndCharacteristics(ConnectedBluetooth.value);
}
if (currentCharacteristic.value) {
return currentCharacteristic.value;
}
// 如果之前没有发现特征值,再次尝试
const success = await discoverServicesAndCharacteristics(ConnectedBluetooth.value);
return success ? currentCharacteristic.value : null;
} catch (error) {
console.error('连接打印服务失败:', error);
if (retries > 0) {
console.log(`重试连接(剩余${retries}次)`);
return connectAndPrint(retries - 1); // 重试机制
}
notification.error({ message: '连接失败', description: '无法连接到打印服务' });
return null;
}
};
1.2 断开逻辑(关闭打印机连接)
-
函数入口
- 命名:
closeBluetooth,异步函数,专门负责“干净”地释放当前已连接的蓝牙设备及其缓存对象。
-
空值保护
- 先判断
ConnectedBluetooth.value 是否存在;若无,直接跳过所有断开步骤,避免空对象报错。
-
GATT 连接状态二次确认
- 再判断
ConnectedBluetooth.value.gatt.connected 是否为 true:
– 仅当仍处于连接状态才调用 .disconnect(),防止对“已断设备”重复操作。
- 使用
await 等待 disconnect 完成,确保底层链路真正释放。
-
成功反馈
- 一旦
disconnect 成功(或设备本来就未连接),立即弹通知告诉用户「××设备已断开」。
-
清空全局缓存
- 把三个核心响应式变量全部置空:
– ConnectedBluetooth.value = null(设备对象)
– currentService.value = null(上次缓存的服务)
– currentCharacteristic.value = null(上次缓存的特征值)
- 保证下次连接时不会误用旧引用。
-
异常兜底
- 整个流程包在
try…catch 内:
– 若 disconnect() 抛出任何异常,立即弹错误通知「无法断开蓝牙连接」,避免 UI 卡死。
- 即使出现异常,也会执行到
finally 隐式逻辑(此处代码无显式 finally,但变量已提前置空),确保状态一致。
-
整体特点
- 双重判断(存在性 + 连接态)避免冗余调用。
- 无论成功或失败,用户都能得到明确提示。
- 缓存清零后,后续
getBluetooth() 可安全重新连接新设备。
代码如 1.1 中断开逻辑。
2、打印相关
打印需要处理以下几个问题:
- 打印机不支持utf-8,需要进行转码处理。
- 使用的打印语言是 ESC/POS ,不会问题不大,可以问ai,慢慢尝试即可。
-
ESC/POS 如何打印二维码(条形码未尝试)。
- 受浏览器限制,单次传输文件非常小,需要分包传输打印。
下面把“打印链路”上的 4 个核心函数按 “输入-处理-输出” 逐条拆开,让你一眼看懂它们各自在 “拼指令 → 拆包 → 写特征值 → 批量调度” 中的角色与边界。
1. breakLine
作用:按 GBK 字节长度 硬截断长字符串,保证每行绝对不超过打印机纸宽。
输入:
-
str:任意中文/英文/符号混合字符串
-
maxByte:默认 36 字节(18 个汉字,36 个 ASCII)
处理:
- 逐字符
iconvLite.encode(ch, 'gbk') 计算真实字节数(1 或 2)
- 累加字节,超上限就切一行,继续累加
- 最后一行不足上限也单独 push
输出:
-
string[]:每行都保证 byte ≤ maxByte,数组空时返回 [''],避免后续逻辑空指针
2. buildOneLabel(it: printData)
作用:把 一条业务数据 变成 一张完整标签的 ESC/POS 字节流(二维码+文字+对齐+走纸+切刀)。
输入:it 里含物料名称、料号、规格、工单号、数量、人员、二维码内容、打印份数等字段
关键步骤:
-
二维码指令
- 先
gbk(it.qrcode) 算出实际字节长度 qrLen
- 按 ESC/POS 手册拼 4 段命令:
- 存储二维码数据 → 选择模型 → 设置模块大小 → 打印
- 最终得到
qrCmd: Uint8Array
-
文字区
- 物料名称可能超长 → 用
breakLine(...,36) 切成 1~2 行
- 固定字段:料号、规格、工单号、总量、排程数、人员
- 每行末尾手动加
\n 或双 \n 增大行间距
-
二维码下方居中文字
- 再次
gbk(it.qrcode+'\n') 供人眼核对
-
拼总指令
-
0x1b 0x40 初始化打印机
-
0x1b 0x74 0x01 指定 GBK 内码表
- 文字 → 居中 → 二维码 → 加粗 → 下方文字 → 走纸 8 行 → 切纸
- 全部展开成 单条 Uint8Array,后续直接丢给蓝牙特征值
输出:Uint8Array——一张标签的“原子”打印数据包
3. writeLarge(char, data: Uint8Array)
作用:把 “任意长度” 的 ESC/POS 指令安全地拆包写进蓝牙特征值,避免 MTU 溢出。
输入:
-
char:Web Bluetooth 特征值对象
-
data:单张标签完整字节流(通常 400~800 字节)
处理:
- 按 244 字节 静态切片(兼容常见 247 MTU,留 3 byte 协议头)
- 优先使用
writeValueWithoutResponse(速度快,无回包)
- 若特征值不支持,则退回到
writeValue(有回包,稍慢)
- 每写完一块 sleep 20 ms ——给打印机缓存/蓝牙控制器喘息,防止丢包
输出:无返回值;全部块写完后函数退出
异常:若特征值两种写模式都不支持,直接抛 Error 强制中断上层循环
4. print(list: printData[])
作用:批量调度器——把 N 条业务数据 × 每条 printNum 份,顺序送进打印机,并实时反馈进度/异常。
输入:list 数组,每个元素含业务字段 + printNum(打印份数)
执行流程:
-
设备检查
- 当前无连接 → 自动调用
getBluetooth() 让用户选设备
- 拿到特征值
char(内部 connectAndPrint() 负责发现服务/特征并返回)
-
生成总队列
- 双重循环:
list.forEach × for(printNum)
- 每份调用
buildOneLabel(it) 得到 Uint8Array,压入 queue 数组
- 结果:
queue.length = Σ(printNum),顺序即最终出纸顺序
-
顺序写打印机
- 遍历
queue,下标从 0 开始
- 每写一张:
await writeLarge(char, queue[idx])
- UI 弹
notification.info 显示进度 “第 idx+1 / 总张数”
- 机械延迟
400 ms(等走纸、切刀完成,再发下一张)
-
异常策略
- 任意一张失败(蓝牙断开、写特征值抛错)→ 立即弹错误通知并
return,终止整个批次
- 用户需检查纸张/电量后手动重打
-
完成提示
- 全部
queue 写完统一弹 notification.success:“批量打印完成”
一张图总结链路
业务数据 printData
↓ buildOneLabel
单张 ESC/POS 字节流
↓ writeLarge(244 字节切片)
蓝牙特征值
↓ print 调度器循环
纸张 / 二维码 / 文字 / 切刀
以上 4 个函数分工明确、耦合度低:
-
breakLine 只关心“截断”
-
buildOneLabel 只关心“拼指令”
-
writeLarge 只关心“拆包写特征值”
-
print 只关心“队列+进度+异常”
后续想换打印机、改纸宽、改二维码大小,只需在对应函数内部调整即可,不会牵一发动全身。
export type printData = {
mitemName: string, // 物料名称
mitemCode: string, // 物料编码
spec: string, // 规格
mo: string, // 工单号
num: number, // 总量
scheduleNum: number,// 排程数量
user: string, // 打印人
qrcode: string, // 二维码
printNum: number, // 打印次数
}
/** 按字节长度截断(GBK 一个汉字 2 字节) */
function breakLine(str: string, maxByte: number = 36): string[] {
const lines: string[] = [];
let buf = '';
let byte = 0;
for (const ch of str) {
const sz = iconvLite.encode(ch, 'gbk').length; // 1 或 2
if (byte + sz > maxByte) { // 超了先存
lines.push(buf);
buf = ch;
byte = sz;
} else {
buf += ch;
byte += sz;
}
}
if (buf) lines.push(buf);
return lines.length ? lines : [''];
}
// 新增:单张指令生成器(根据业务字段拼 ESC/POS)
const buildOneLabel = (it: printData) => {
const gbk = (str: string) => {
// iconv-lite 直接返回 Uint8Array,无需额外处理
return iconvLite.encode(str, 'gbk');
};
/* ---------- 二维码 ---------- */
const qrBytes = gbk(it.qrcode);
const qrLen = qrBytes.length;
const qrCmd = new Uint8Array([
0x1d, 0x28, 0x6b, qrLen + 3, 0x00, 0x31, 0x50, 0x30, ...qrBytes,
0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x45, 0x30,
0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x43, 0x08,
0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x51, 0x30
]);
const nameLines = breakLine(it.mitemName, 36); // 36 字节 ≈ 18 汉字
/* ---------- 文字(每行末尾加2个换行符加大间距) ---------- */
let text = '';
if (nameLines.length > 1) {
text = [
` \n`,
` 物料名称:${nameLines[0]}`,
` ${nameLines[1]}`,
` 料号:${it.mitemCode}\n`,
` 规格:${it.spec}\n`,
` 工单号:${it.mo}\n`,
` 工单总量:${it.num}\n`,
` 排程数量:${it.scheduleNum}\n`,
` 人员:${it.user}\n`,
` \n`,
` \n`,
].join('\n');
} else {
text = [
` \n`,
` 物料名称:${it.mitemName}\n`,
` 料号:${it.mitemCode}\n`,
` 规格:${it.spec}\n`,
` 工单号:${it.mo}\n`,
` 工单总量:${it.num}\n`,
` 排程数量:${it.scheduleNum}\n`,
` 人员:${it.user}\n`,
` \n`,
` \n`,
].join('\n');
}
/* ---------- 二维码下方文字(居中显示) ---------- */
const qrContentText = gbk(`${it.qrcode}\n`);
return new Uint8Array([
...[0x1b, 0x40], // 初始化
...[0x1b, 0x74, 0x01], // 选择GBK编码
...gbk(text), // 打印文字(已加大行间距)
...[0x1b, 0x61, 0x01], // 文字居中对齐
...qrCmd, // 打印二维码
...gbk('\n'), // 二维码与文字之间加一个换行
...[0x1b, 0x45, 0x01], // 加粗
...qrContentText, // 打印二维码内容文字
...[0x1b, 0x64, 0x08], // 走纸8行(比原来多2行,适配新增文字)
...[0x1b, 0x69] // 切纸(无刀可删)
]);
};
/* ---------- 大数据分包 ---------- */
const writeLarge = async (char: any, data: Uint8Array) => {
const chunk = 244;
// 检查特征值是否支持writeWithoutResponse
if (char.properties.writeWithoutResponse) {
for (let i = 0; i < data.length; i += chunk) {
await char.writeValueWithoutResponse(data.slice(i, i + chunk));
await new Promise(r => setTimeout(r, 20)); // 蓝牙喘息
}
} else if (char.properties.write) {
for (let i = 0; i < data.length; i += chunk) {
await char.writeValue(data.slice(i, i + chunk));
await new Promise(r => setTimeout(r, 20)); // 蓝牙喘息
}
} else {
throw new Error('特征值不支持写入操作');
}
};
/* ---------- 批量打印(新 print) ---------- */
const print = async (list: printData[]) => {
if (!ConnectedBluetooth.value) return getBluetooth();
const char = await connectAndPrint();
if (!char) return;
// 1. 生成总队列:每条数据重复 printNum 次
const queue: Uint8Array[] = [];
list.forEach(it => {
for (let i = 0; i < it.printNum; i++) queue.push(buildOneLabel(it));
});
// 2. 顺序打印
for (let idx = 0; idx < queue.length; idx++) {
try {
await writeLarge(char, queue[idx]);
notification.info({
message: `进度 ${idx + 1}/${queue.length}`,
description: `正在打印:${list.find(it => it.printNum > 0)?.mitemCode}`
});
await new Promise(r => setTimeout(r, 400)); // 等机械完成
} catch (e) {
notification.error({
message: `打印中断`,
description: `第 ${idx + 1} 张失败:${e}`
});
return; // 立即停止
}
}
notification.success({ message: '批量打印完成' });
};
踩坑:
- 根据打印机支持什么格式的数据,一定要转码。
- 写入的时候需要检测支持什么方法,不然就可能出现和我一样的问题,上周还能正常打印,这周就打印不了,ai没发现文图,自己查半天才发现是写入方法不支持了,这太离谱,为啥突然不支持了也不知道原因。
- 分包啥的ai就能完成,问题不大。
检测支持的方法:
/* ---------- 大数据分包 ---------- */
const writeLarge = async (char: any, data: Uint8Array) => {
const chunk = 244;
// 检查特征值是否支持writeWithoutResponse
if (char.properties.writeWithoutResponse) {
for (let i = 0; i < data.length; i += chunk) {
await char.writeValueWithoutResponse(data.slice(i, i + chunk));
await new Promise(r => setTimeout(r, 20)); // 蓝牙喘息
}
} else if (char.properties.write) {
for (let i = 0; i < data.length; i += chunk) {
await char.writeValue(data.slice(i, i + chunk));
await new Promise(r => setTimeout(r, 20)); // 蓝牙喘息
}
} else {
throw new Error('特征值不支持写入操作');
}
};
3、打印效果

原数据没问题的,图中马赛克为敏感数据手动打码。
4、完整代码
- 完整代码,导出为单列,共用一个蓝牙连接服务,你在这里连接成功了,在别的地方只要没有断开连接,直接传入数据打印即可,无需重复连接。
- 导出 print, getBluetooth, ConnectedBluetooth ,打印函数,蓝牙连接,蓝牙信息。打印函数内部已经自动做判断了,直接打印也行,会自动判断是否需要连接,在外部做更精细的判断也行,根据业务需要调整。
- 无UI界面,直接调用即可。
- 这是我遇到过比较复杂的一个需求了,大家看情况调整吧,对你的打印机不一定适用。
完整代码如下:
import { notification } from "ant-design-vue";
import { ref } from "vue";
import iconvLite from 'gbk.js'; // 引入编码库
export type printData = {
mitemName: string, // 物料名称
mitemCode: string, // 物料编码
spec: string, // 规格
mo: string, // 工单号
num: number, // 总量
scheduleNum: number,// 排程数量
user: string, // 打印人
qrcode: string, // 二维码
printNum: number, // 打印次数
}
const BluetoothModule = () => {
// 蓝牙服务和特征值UUID配置(优先尝试常见打印服务)
const possibleServices = [
'00001101-0000-1000-8000-00805f9b34fb', // SPP服务(最常用)
'0000ffe0-0000-1000-8000-00805f9b34fb',
'49535343-fe7d-4ae5-8fa9-9fafd205e455',
'6e400001-b5a3-f393-e0a9-e50e24dcca9e',
'49535343-1e4d-4bd9-ba61-23c647249616'
];
const possibleCharacteristics = [
'0000ffe1-0000-1000-8000-00805f9b34fb',
'0000ff01-0000-1000-8000-00805f9b34fb',
'49535343-8841-43f4-a8d4-ecbe34729bb3',
'6e400002-b5a3-f393-e0a9-e50e24dcca9e',
'0000ffe1-0000-1000-8000-00805f9b34fb'
];
const ConnectedBluetooth: any = ref(null)
const currentService: any = ref(null)
const currentCharacteristic: any = ref(null)
const isConnecting: any = ref(false)
// 蓝牙连接逻辑(增加连接状态锁定)
const getBluetooth = async () => {
// 先断开已有连接
await closeBluetooth();
if (isConnecting.value) return;
isConnecting.value = true;
let device;
try {
// @ts-ignore
if (!navigator.bluetooth) {
notification.error({
message: '浏览器不支持',
description: '请使用Chrome、Edge等支持Web Bluetooth API的浏览器'
});
return;
}
// 优先过滤打印机设备
//@ts-ignore
device = await navigator.bluetooth.requestDevice({
filters: [
{ name: 'BTP-P32' },
{ name: 'BTP-P33' },
{ namePrefix: 'BTP' },
{ namePrefix: 'Printer' } // 通用打印机名称前缀
],
optionalServices: possibleServices,
acceptAllDevices: false
});
// 监听设备断开事件
device.addEventListener('gattserverdisconnected', onDisconnected);
notification.success({
message: '设备已选择',
description: `名称:${device.name || '未知设备'}`
});
ConnectedBluetooth.value = device;
// 提前获取服务和特征值,减少打印时的耗时
await discoverServicesAndCharacteristics(device);
} catch (firstError: any) {
if (firstError.name === 'NotFoundError') {
const userConfirm = confirm(
'未找到指定打印机,是否显示所有蓝牙设备?\n' +
'提示:请确保打印机已开启并处于可配对状态'
);
if (userConfirm) {
try {
// @ts-ignore
device = await navigator.bluetooth.requestDevice({
acceptAllDevices: true,
optionalServices: possibleServices
});
device.addEventListener('gattserverdisconnected', onDisconnected);
ConnectedBluetooth.value = device;
await discoverServicesAndCharacteristics(device);
notification.success({
message: '设备已选择',
description: `名称:${device.name || '未知设备'}`
});
} catch (e) {
notification.error({ message: '选择设备失败', description: '未找到任何蓝牙设备' });
}
}
} else if (firstError.name === 'NotAllowedError') {
notification.error({
message: '权限被拒绝',
description: '请允许浏览器访问蓝牙设备'
});
} else {
notification.error({
message: '连接失败',
description: firstError.message || '未知错误'
});
}
} finally {
isConnecting.value = false;
}
}
// 断开连接处理
const onDisconnected = (event: any) => {
const device = event.target;
notification.warning({
message: '设备已断开',
description: `${device.name || '蓝牙设备'}连接已丢失`
});
ConnectedBluetooth.value = null;
currentService.value = null;
currentCharacteristic.value = null;
};
// 断开连接逻辑
const closeBluetooth = async () => {
try {
if (ConnectedBluetooth.value) {
if (ConnectedBluetooth.value.gatt.connected) {
await ConnectedBluetooth.value.gatt.disconnect();
}
notification.success({
message: '断开成功',
description: `${ConnectedBluetooth.value?.name || '设备'}已断开`
});
}
ConnectedBluetooth.value = null;
currentService.value = null;
currentCharacteristic.value = null;
} catch (error) {
notification.error({
message: '断开失败',
description: '无法断开蓝牙连接'
});
}
}
// 发现设备支持的服务和特征值(动态探测)
const discoverServicesAndCharacteristics = async (device: any) => {
try {
const server = await device.gatt.connect();
const services = await server.getPrimaryServices();
// 遍历所有服务,寻找支持的特征值
for (const service of services) {
const characteristics = await service.getCharacteristics();
for (const characteristic of characteristics) {
// 检查特征值是否可写入
const properties = characteristic.properties;
if (properties.write || properties.writeWithoutResponse) {
currentService.value = service;
currentCharacteristic.value = characteristic;
notification.success({
message: `服务:${characteristic.uuid}`,
description: `特征值:${characteristic.uuid}`,
})
return true; // 找到可用的特征值后退出
}
}
}
// 如果没有找到预设的特征值,提示用户
notification.warning({
message: '未找到打印特征值',
description: '设备可能不支持打印功能'
});
return false;
} catch (error) {
console.error('发现服务失败:', error);
return false;
}
};
// 连接并获取打印特征值(增加重试机制)
const connectAndPrint = async (retries = 2) => {
if (!ConnectedBluetooth.value) {
notification.error({ message: '未连接蓝牙设备' });
return null;
}
try {
// 检查当前连接状态
if (!ConnectedBluetooth.value.gatt.connected) {
await ConnectedBluetooth.value.gatt.connect();
// 重新发现服务
await discoverServicesAndCharacteristics(ConnectedBluetooth.value);
}
if (currentCharacteristic.value) {
return currentCharacteristic.value;
}
// 如果之前没有发现特征值,再次尝试
const success = await discoverServicesAndCharacteristics(ConnectedBluetooth.value);
return success ? currentCharacteristic.value : null;
} catch (error) {
console.error('连接打印服务失败:', error);
if (retries > 0) {
console.log(`重试连接(剩余${retries}次)`);
return connectAndPrint(retries - 1); // 重试机制
}
notification.error({ message: '连接失败', description: '无法连接到打印服务' });
return null;
}
};
/** 按字节长度截断(GBK 一个汉字 2 字节) */
function breakLine(str: string, maxByte: number = 36): string[] {
const lines: string[] = [];
let buf = '';
let byte = 0;
for (const ch of str) {
const sz = iconvLite.encode(ch, 'gbk').length; // 1 或 2
if (byte + sz > maxByte) { // 超了先存
lines.push(buf);
buf = ch;
byte = sz;
} else {
buf += ch;
byte += sz;
}
}
if (buf) lines.push(buf);
return lines.length ? lines : [''];
}
// 新增:单张指令生成器(根据业务字段拼 ESC/POS)
const buildOneLabel = (it: printData) => {
const gbk = (str: string) => {
// iconv-lite 直接返回 Uint8Array,无需额外处理
return iconvLite.encode(str, 'gbk');
};
/* ---------- 二维码 ---------- */
const qrBytes = gbk(it.qrcode);
const qrLen = qrBytes.length;
const qrCmd = new Uint8Array([
0x1d, 0x28, 0x6b, qrLen + 3, 0x00, 0x31, 0x50, 0x30, ...qrBytes,
0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x45, 0x30,
0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x43, 0x08,
0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x51, 0x30
]);
const nameLines = breakLine(it.mitemName, 36); // 36 字节 ≈ 18 汉字
/* ---------- 文字(每行末尾加2个换行符加大间距) ---------- */
let text = '';
if (nameLines.length > 1) {
text = [
` \n`,
` 物料名称:${nameLines[0]}`,
` ${nameLines[1]}`,
` 料号:${it.mitemCode}\n`,
` 规格:${it.spec}\n`,
` 工单号:${it.mo}\n`,
` 工单总量:${it.num}\n`,
` 排程数量:${it.scheduleNum}\n`,
` 人员:${it.user}\n`,
` \n`,
` \n`,
].join('\n');
} else {
text = [
` \n`,
` 物料名称:${it.mitemName}\n`,
` 料号:${it.mitemCode}\n`,
` 规格:${it.spec}\n`,
` 工单号:${it.mo}\n`,
` 工单总量:${it.num}\n`,
` 排程数量:${it.scheduleNum}\n`,
` 人员:${it.user}\n`,
` \n`,
` \n`,
].join('\n');
}
/* ---------- 二维码下方文字(居中显示) ---------- */
const qrContentText = gbk(`${it.qrcode}\n`);
return new Uint8Array([
...[0x1b, 0x40], // 初始化
...[0x1b, 0x74, 0x01], // 选择GBK编码
...gbk(text), // 打印文字(已加大行间距)
...[0x1b, 0x61, 0x01], // 文字居中对齐
...qrCmd, // 打印二维码
...gbk('\n'), // 二维码与文字之间加一个换行
...[0x1b, 0x45, 0x01], // 加粗
...qrContentText, // 打印二维码内容文字
...[0x1b, 0x64, 0x08], // 走纸8行(比原来多2行,适配新增文字)
...[0x1b, 0x69] // 切纸(无刀可删)
]);
};
/* ---------- 大数据分包 ---------- */
const writeLarge = async (char: any, data: Uint8Array) => {
const chunk = 244;
// 检查特征值是否支持writeWithoutResponse
if (char.properties.writeWithoutResponse) {
for (let i = 0; i < data.length; i += chunk) {
await char.writeValueWithoutResponse(data.slice(i, i + chunk));
await new Promise(r => setTimeout(r, 20)); // 蓝牙喘息
}
} else if (char.properties.write) {
for (let i = 0; i < data.length; i += chunk) {
await char.writeValue(data.slice(i, i + chunk));
await new Promise(r => setTimeout(r, 20)); // 蓝牙喘息
}
} else {
throw new Error('特征值不支持写入操作');
}
};
/* ---------- 批量打印(新 print) ---------- */
const print = async (list: printData[]) => {
if (!ConnectedBluetooth.value) return getBluetooth();
const char = await connectAndPrint();
if (!char) return;
// 1. 生成总队列:每条数据重复 printNum 次
const queue: Uint8Array[] = [];
list.forEach(it => {
for (let i = 0; i < it.printNum; i++) queue.push(buildOneLabel(it));
});
// 2. 顺序打印
for (let idx = 0; idx < queue.length; idx++) {
try {
await writeLarge(char, queue[idx]);
notification.info({
message: `进度 ${idx + 1}/${queue.length}`,
description: `正在打印:${list.find(it => it.printNum > 0)?.mitemCode}`
});
await new Promise(r => setTimeout(r, 400)); // 等机械完成
} catch (e) {
notification.error({
message: `打印中断`,
description: `第 ${idx + 1} 张失败:${e}`
});
return; // 立即停止
}
}
notification.success({ message: '批量打印完成' });
};
return {
print,
getBluetooth,
ConnectedBluetooth,
}
}
// utils/Bluetooth.ts 末尾
const bluetoothInstance = BluetoothModule()
export default () => bluetoothInstance // 永远返回同一个