浏览器连接 新北洋BTP-P33/P32蓝牙打印机,打印 二维码

浏览器连接 新北洋BTP-P33/P32蓝牙打印机,打印 二维码

  1. 连接全程用的就是浏览器原生 Web Bluetooth API,没有任何第三方库,这个api存在一定的兼容问题,谨慎使用。
  2. 转码库 gbk.js ,一个小而快的GBK库,支持浏览器,有中文的话,需要转码,BTP-P33/P32这个型号不支持UTF-8,不转换成 GBK 格式,中文打印出来就是乱码。

结合ai和网上扒的部分代码实现的,懒得介绍各个模块,交给ai分析,完整代码移步至最后。

1、蓝牙连接相关

1.1 蓝牙连接(打印机连接)

  1. 函数入口

    • 命名:getBluetooth,异步函数,负责完成一次完整的「选设备 → 连 GATT → 缓存服务」流程。
  2. 连接锁

    • 先调用 closeBluetooth() 把上一次可能未断开的设备强制释放。
    • isConnecting.value 做布尔锁,防止用户多次点击造成并发请求。
  3. 浏览器兼容性检查

    • 判断全局是否存在 navigator.bluetooth,没有就弹错误通知并直接返回。
  4. 第一次设备选择(带过滤器)

    • 通过 requestDevice 只列出名字或前缀符合的打印机:
      -- 精确名:BTP-P32BTP-P33
      -- 前缀:BTPPrinter
    • 指定 optionalServices 为后续打印指令所需 UUID 数组。
    • acceptAllDevices: false 强制走过滤器,减少用户误选。
  5. 提前注册断开监听

    • 给选中的 device 绑定 gattserverdisconnected 事件,一旦硬件断电或超出范围可立即触发 onDisconnected 回调,方便 UI 状态同步。
  6. 成功反馈

    • 把设备对象写入 ConnectedBluetooth.value,供后续打印逻辑使用。
    • 弹通知告诉用户「设备已选择」并显示设备名。
  7. 预拉服务/特征值

    • 调用 discoverServicesAndCharacteristics(device) 一次性拿到所有服务和特征并缓存,减少真正打印时的往返延迟。
  8. 第一次异常处理(firstError

    • NotFoundError:用户没看到任何匹配设备 → 弹 confirm 询问「是否放宽条件显示所有蓝牙设备」。
      -- 若用户点「确定」则第二次调用 requestDevice,这次 acceptAllDevices: true,过滤器失效。
      -- 若二次选择仍失败,弹「未找到任何蓝牙设备」。
    • NotAllowedError:用户拒绝权限 → 提示「请允许浏览器访问蓝牙」。
    • 其他错误 → 统一弹「连接失败」并输出具体 message
  9. 连接锁释放

    • 无论成功还是任何分支的异常,都在 finally 里把 isConnecting.value 重置为 false,保证下次可重新点击。
  10. 整体特点

    • 采用「先精确后宽泛」两步走策略,兼顾易用性与兼容性。
    • 所有耗时/异步操作都放在 try 内,用户侧只有「选择弹窗」会阻塞,其余异常均友好提示。
    • 通过「断开监听 + 预缓存服务」让后续打印阶段只需纯粹写特征值,缩短真正出纸时间。
js 复制代码
    // 蓝牙连接逻辑(增加连接状态锁定)
    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 断开逻辑(关闭打印机连接)

  1. 函数入口

    • 命名:closeBluetooth,异步函数,专门负责"干净"地释放当前已连接的蓝牙设备及其缓存对象。
  2. 空值保护

    • 先判断 ConnectedBluetooth.value 是否存在;若无,直接跳过所有断开步骤,避免空对象报错。
  3. GATT 连接状态二次确认

    • 再判断 ConnectedBluetooth.value.gatt.connected 是否为 true
      -- 仅当仍处于连接状态才调用 .disconnect(),防止对"已断设备"重复操作。
    • 使用 await 等待 disconnect 完成,确保底层链路真正释放。
  4. 成功反馈

    • 一旦 disconnect 成功(或设备本来就未连接),立即弹通知告诉用户「××设备已断开」。
  5. 清空全局缓存

    • 把三个核心响应式变量全部置空:
      -- ConnectedBluetooth.value = null(设备对象)
      -- currentService.value = null(上次缓存的服务)
      -- currentCharacteristic.value = null(上次缓存的特征值)
    • 保证下次连接时不会误用旧引用。
  6. 异常兜底

    • 整个流程包在 try...catch 内:
      -- 若 disconnect() 抛出任何异常,立即弹错误通知「无法断开蓝牙连接」,避免 UI 卡死。
    • 即使出现异常,也会执行到 finally 隐式逻辑(此处代码无显式 finally,但变量已提前置空),确保状态一致。
  7. 整体特点

    • 双重判断(存在性 + 连接态)避免冗余调用。
    • 无论成功或失败,用户都能得到明确提示。
    • 缓存清零后,后续 getBluetooth() 可安全重新连接新设备。

代码如 1.1 中断开逻辑。

2、打印相关

打印需要处理以下几个问题:

  1. 打印机不支持utf-8,需要进行转码处理。
  2. 使用的打印语言是 ESC/POS ,不会问题不大,可以问ai,慢慢尝试即可。
  3. ESC/POS 如何打印二维码(条形码未尝试)。
  4. 受浏览器限制,单次传输文件非常小,需要分包传输打印。

下面把"打印链路"上的 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 里含物料名称、料号、规格、工单号、数量、人员、二维码内容、打印份数等字段

关键步骤

  1. 二维码指令

    • gbk(it.qrcode) 算出实际字节长度 qrLen
    • 按 ESC/POS 手册拼 4 段命令
      • 存储二维码数据 → 选择模型 → 设置模块大小 → 打印
    • 最终得到 qrCmd: Uint8Array
  2. 文字区

    • 物料名称可能超长 → 用 breakLine(...,36) 切成 1~2 行
    • 固定字段:料号、规格、工单号、总量、排程数、人员
    • 每行末尾手动加 \n 或双 \n 增大行间距
  3. 二维码下方居中文字

    • 再次 gbk(it.qrcode+'\n') 供人眼核对
  4. 拼总指令

    • 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(打印份数)

执行流程

  1. 设备检查

    • 当前无连接 → 自动调用 getBluetooth() 让用户选设备
    • 拿到特征值 char(内部 connectAndPrint() 负责发现服务/特征并返回)
  2. 生成总队列

    • 双重循环:list.forEach × for(printNum)
    • 每份调用 buildOneLabel(it) 得到 Uint8Array,压入 queue 数组
    • 结果:queue.length = Σ(printNum),顺序即最终出纸顺序
  3. 顺序写打印机

    • 遍历 queue,下标从 0 开始
    • 每写一张:
      • await writeLarge(char, queue[idx])
      • UI 弹 notification.info 显示进度 "第 idx+1 / 总张数"
      • 机械延迟 400 ms(等走纸、切刀完成,再发下一张)
  4. 异常策略

    • 任意一张失败(蓝牙断开、写特征值抛错)→ 立即弹错误通知并 return终止整个批次
    • 用户需检查纸张/电量后手动重打
  5. 完成提示

    • 全部 queue 写完统一弹 notification.success:"批量打印完成"

一张图总结链路

arduino 复制代码
业务数据 printData
     ↓  buildOneLabel
单张 ESC/POS 字节流
     ↓  writeLarge(244 字节切片)
蓝牙特征值
     ↓  print 调度器循环
纸张 / 二维码 / 文字 / 切刀

以上 4 个函数分工明确、耦合度低:

  • breakLine 只关心"截断"
  • buildOneLabel 只关心"拼指令"
  • writeLarge 只关心"拆包写特征值"
  • print 只关心"队列+进度+异常"

后续想换打印机、改纸宽、改二维码大小,只需在对应函数内部调整即可,不会牵一发动全身。

js 复制代码
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: '批量打印完成' });
    };

踩坑:

  1. 根据打印机支持什么格式的数据,一定要转码。
  2. 写入的时候需要检测支持什么方法,不然就可能出现和我一样的问题,上周还能正常打印,这周就打印不了,ai没发现文图,自己查半天才发现是写入方法不支持了,这太离谱,为啥突然不支持了也不知道原因。
  3. 分包啥的ai就能完成,问题不大。

检测支持的方法:

js 复制代码
    /* ---------- 大数据分包 ---------- */
    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、完整代码

  1. 完整代码,导出为单列,共用一个蓝牙连接服务,你在这里连接成功了,在别的地方只要没有断开连接,直接传入数据打印即可,无需重复连接。
  2. 导出 print, getBluetooth, ConnectedBluetooth ,打印函数,蓝牙连接,蓝牙信息。打印函数内部已经自动做判断了,直接打印也行,会自动判断是否需要连接,在外部做更精细的判断也行,根据业务需要调整。
  3. 无UI界面,直接调用即可。
  4. 这是我遇到过比较复杂的一个需求了,大家看情况调整吧,对你的打印机不一定适用。

完整代码如下:

js 复制代码
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   // 永远返回同一个
相关推荐
边洛洛3 小时前
next.js项目部署流程
开发语言·前端·javascript
非凡ghost3 小时前
Syncovery Premium(文件同步软件)
前端·javascript·后端
trsoliu3 小时前
2025前端AI Coding产品与实战案例大盘点
前端·ai编程
云中雾丽3 小时前
react-checkbox的封装
前端
乐园游梦记3 小时前
告别Ctrl+F5!解决VUE生产环境缓存更新的终极方案
前端
岁月宁静3 小时前
用 Node.js 封装豆包语音识别AI模型接口:双向实时流式传输音频和文本
前端·人工智能·node.js
猪猪拆迁队3 小时前
前端图形架构设计:AI生成设计稿落地实践
前端·后端·ai编程
岁月宁静3 小时前
Vue 3.5 + WangEditor 打造智能笔记编辑器:语音识别功能深度实现
前端·javascript·vue.js