diff --git a/api/100J/HBY100-J.js b/api/100J/HBY100-J.js index bae7095..f470974 100644 --- a/api/100J/HBY100-J.js +++ b/api/100J/HBY100-J.js @@ -1,6 +1,114 @@ import request from '@/utils/request' import Common from '@/utils/Common.js' +/** + * App-Plus 下 uni.getFileSystemManager 往往仅占位,readFile 可能永不回调。 + * 用 plus.io 判定 App(勿单依赖 plus.runtime,未就绪时会误走 FSM → 卡在 uploadVoiceFileBle 开始)。 + * 仅非 App(如微信小程序)再尝试 FSM。 + */ +function tryGetUniFileSystemManager() { + try { + if (typeof uni === 'undefined') return null; + if (typeof plus !== 'undefined' && plus.io) return null; + if (typeof uni.getFileSystemManager !== 'function') return null; + return uni.getFileSystemManager(); + } catch (e) { + return null; + } +} + +/** 从 readFile success 的 res.data 解析为 Uint8Array,失败返回 null */ +function _bytesFromReadFileResult(res) { + try { + const raw = res && res.data; + if (raw instanceof ArrayBuffer) return new Uint8Array(raw); + if (raw instanceof Uint8Array) return raw; + if (raw && ArrayBuffer.isView(raw) && raw.buffer instanceof ArrayBuffer) { + return new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength); + } + } catch (e) {} + return null; +} + +/** 为 readFile 准备多路径:App 上 FSM 常需绝对路径,_downloads/_doc 相对路径需 convert */ +function getLocalPathReadCandidates(path) { + const s = String(path || '').trim(); + if (!s) return []; + const out = [s]; + try { + if (typeof plus !== 'undefined' && plus.io && typeof plus.io.convertLocalFileSystemURL === 'function') { + if (/^_(?:downloads|doc|www)\//i.test(s)) { + const c = plus.io.convertLocalFileSystemURL(s); + if (c && typeof c === 'string' && c !== s) out.push(c); + } + } + } catch (e) {} + return [...new Set(out)]; +} + +/** + * App-Plus 上仍尝试 uni.getFileSystemManager().readFile(部分机型对 _downloads/_doc 路径可用), + * 带超时避免 success/fail 永不回调;与 tryGetUniFileSystemManager 不同:此处不因 plus.io 存在而跳过。 + */ +function tryUniReadFileSinglePath(filePath, timeoutMs) { + return new Promise((resolve) => { + try { + if (!filePath || typeof uni === 'undefined' || typeof uni.getFileSystemManager !== 'function') { + resolve(null); + return; + } + const fsm = uni.getFileSystemManager(); + if (!fsm || typeof fsm.readFile !== 'function') { + resolve(null); + return; + } + let finished = false; + const t = setTimeout(() => { + if (finished) return; + finished = true; + resolve(null); + }, timeoutMs); + fsm.readFile({ + filePath: filePath, + success: (res) => { + if (finished) return; + finished = true; + clearTimeout(t); + const bytes = _bytesFromReadFileResult(res); + resolve(bytes && bytes.length > 0 ? bytes : null); + }, + fail: () => { + if (finished) return; + finished = true; + clearTimeout(t); + resolve(null); + } + }); + } catch (e) { + resolve(null); + } + }); +} + +function tryUniReadFileOnAppWithTimeout(path, timeoutMs) { + const candidates = getLocalPathReadCandidates(path); + if (!candidates.length) return Promise.resolve(null); + const n = Math.min(candidates.length, 3); + const per = Math.max(2800, Math.ceil(timeoutMs / n)); + + const tryIdx = (i) => { + if (i >= candidates.length) return Promise.resolve(null); + return tryUniReadFileSinglePath(candidates[i], per).then((bytes) => { + if (bytes && bytes.length > 0) { + if (i > 0) console.log('[100J-蓝牙] readFile 备用路径成功, idx=', i, 'pathHead=', String(candidates[i]).slice(0, 64)); + return bytes; + } + return tryIdx(i + 1); + }); + }; + return tryIdx(0); +} + // ================== 蓝牙协议封装类 ================== class HBY100JProtocol { constructor() { @@ -35,9 +143,12 @@ class HBY100JProtocol { } setBleConnectionStatus(status, bleDeviceId = '') { - this.isBleConnected = status; + this.isBleConnected = !!status; if (bleDeviceId) { this.bleDeviceId = bleDeviceId; + } else if (!status) { + // 断开时必须清空,否则 execWithBleFirst 误判「有 id 未连」且 uploadVoiceFileBle 仍可能带旧 id + this.bleDeviceId = ''; } } @@ -203,26 +314,70 @@ class HBY100JProtocol { // 0x05 文件上传:分片传输,协议 FA 05 [fileType] [phase] [data...] FF // fileType: 1=语音 2=图片 3=动图 4=OTA // phase: 0=开始 1=数据 2=结束 - // 每包最大字节 蓝牙:CHUNK_SIZE=500 + // 每包最大负载见 uploadVoiceFileBle 内 CHUNK_SIZE(需与 MTU 匹配) // 支持 fileUrl(需网络下载) 或 localPath(无网络时本地文件) - uploadVoiceFileBle(fileUrlOrLocalPath, fileType = 1, onProgress) { - const CHUNK_SIZE = 500; // 每包有效数据,参考 6155 deviceDetail.vue + // 说明:下发的是已录制/已落盘的 MP3(等)二进制分片,经 GATT 写特征;非 A2DP/HFP 等「蓝牙录音实时流」 + // meta.voiceListId:若存在 uni 中 100J_voice_b64_* 缓存(与 HBY100 存 Storage 同理),优先用缓存字节下发 + uploadVoiceFileBle(fileUrlOrLocalPath, fileType = 1, onProgress, meta = null) { + // 协议 5.6:单包负载最大 500B,加 FA/05/FF 等约 507B;真机需已协商足够 MTU(见 BleHelper setBLEMTU 512) + const CHUNK_SIZE = 500; + const BLE_CACHE_SENTINEL = '__100J_BLE_CACHE__'; return new Promise((resolve, reject) => { + const srcStr = String(fileUrlOrLocalPath || ''); + const isLocalPath = !/^https?:\/\//i.test(srcStr); + const voiceListId = meta && meta.voiceListId != null ? meta.voiceListId : ''; + const did = this.deviceId || ''; + console.log('[100J-蓝牙] uploadVoiceFileBle 开始', { + isBleConnected: this.isBleConnected, + hasBleDeviceId: !!this.bleDeviceId, + isLocalPath, + sourceHead: srcStr.slice(0, 96), + voiceListId: voiceListId !== '' ? String(voiceListId) : '' + }); if (!this.isBleConnected || !this.bleDeviceId) { return reject(new Error('蓝牙未连接')); } + if (voiceListId !== '' && did) { + const cached = get100JVoiceBleCacheBytes(did, voiceListId); + if (cached && cached.length > 0) { + if (onProgress) onProgress(1); + console.log('[100J-蓝牙] 使用 uni 存储缓存下发(类比 HBY100 getStorage),字节:', cached.length); + this._sendVoiceChunks(cached, fileType, CHUNK_SIZE, onProgress).then(resolve).catch(reject); + return; + } + console.warn('[100J-蓝牙] 未命中 uni 缓存,将尝试读本地文件。key=', voiceBleCacheStorageKey(did, voiceListId), '若保存后立刻使用仍失败,请稍等再试或重新保存'); + } + if (fileUrlOrLocalPath === BLE_CACHE_SENTINEL) { + return reject(new Error('本地语音缓存不存在或已失效,请重新保存录音')); + } if (!fileUrlOrLocalPath) { return reject(new Error('缺少文件地址或本地路径')); } - const isLocalPath = !/^https?:\/\//i.test(fileUrlOrLocalPath); if (onProgress) onProgress(1); + let localReadWatchdog = null; + const clearLocalReadWatchdog = () => { + if (localReadWatchdog) { + clearTimeout(localReadWatchdog); + localReadWatchdog = null; + } + }; const readFromPath = (path) => { const doSend = (bytes) => { + clearLocalReadWatchdog(); + if (voiceListId !== '' && did && bytes && bytes.length) { + put100JVoiceBleCache(did, voiceListId, bytes); + } this._sendVoiceChunks(bytes, fileType, CHUNK_SIZE, onProgress) .then(resolve).catch(reject); }; - // App 端 getFileSystemManager 未实现,直接用 plus.io.requestFileSystem+getFile - readFromPathPlus(path, doSend, reject); + const onReadErr = (err) => { + clearLocalReadWatchdog(); + if (onProgress) onProgress(0); + reject(err); + }; + // HBY100 下发语音:不读 MP3 文件,而是 uni.getStorageSync(pcmStorageKey_id) 取服务端返回的 PCM 十六进制串再分包发蓝牙(见 pages/100/HBY100.vue audioApply)。 + // 100J 协议为 FA 05 传原始 MP3 二进制,必须从本地路径读文件;优先 readFile,失败再走 plus.io(部分机型 plus 回调不归位会触发外层看门狗)。 + readFromPathPlus(path, doSend, onReadErr); }; const readFileEntry = (entry, doSend, reject) => { entry.file((file) => { @@ -242,48 +397,184 @@ class HBY100JProtocol { }, (err) => reject(err)); }; const readFromPathPlus = (path, doSend, reject) => { - if (typeof plus === 'undefined' || !plus.io) { - console.error('[100J-蓝牙] 当前环境不支持文件读取(plus.io)'); - reject(new Error('当前环境不支持文件读取')); - return; - } - // _downloads/ 用 requestFileSystem+getFile(避免 resolveLocalFileSystemURL 卡住) + const tryUniReadFile = (onFail) => { + // 先走 uni readFile(含 App),带超时;旧逻辑在 plus 存在时完全跳过 FSM,导致只能卡 plus.io。 + tryUniReadFileOnAppWithTimeout(path, 5000).then((bytes) => { + if (bytes && bytes.length > 0) { + console.log('[100J-蓝牙] readFile 已读出本地语音,字节:', bytes.length); + doSend(bytes); + return; + } + try { + const fsm = tryGetUniFileSystemManager(); + if (!fsm || typeof fsm.readFile !== 'function') { + onFail(); + return; + } + fsm.readFile({ + filePath: path, + success: (res) => { + const b = _bytesFromReadFileResult(res); + if (b && b.length > 0) { + console.log('[100J-蓝牙] readFile(非App) 已读出本地语音,字节:', b.length); + doSend(b); + return; + } + onFail(); + }, + fail: () => onFail() + }); + } catch (e) { + onFail(); + } + }); + }; + // _downloads/:resolve 与 requestFileSystem 并行竞速,避免单一路径在部分机型上长期无回调 if (path && path.startsWith('_downloads/')) { const fileName = path.replace(/^_downloads\//, ''); - plus.io.requestFileSystem(plus.io.PUBLIC_DOWNLOADS, (fs) => { - fs.root.getFile(fileName, {}, (entry) => readFileEntry(entry, doSend, reject), (err) => reject(err)); - }, (err) => reject(err)); + tryUniReadFile(() => { + if (typeof plus === 'undefined' || !plus.io) { + reject(new Error('当前环境不支持文件读取')); + return; + } + console.log('[100J-蓝牙] _downloads 并行 resolve+PUBLIC_DOWNLOADS, fileName=', fileName); + let finished = false; + const outerMs = 20000; + const outerT = setTimeout(() => { + if (finished) return; + finished = true; + reject(new Error('读取下载目录语音超时,请重新保存录音后重试')); + }, outerMs); + const win = (bytes) => { + if (finished) return; + if (!bytes || !(bytes.length > 0)) return; + finished = true; + clearTimeout(outerT); + doSend(bytes); + }; + let url = path; + try { + if (plus.io.convertLocalFileSystemURL) { + const c = plus.io.convertLocalFileSystemURL(path); + if (c) url = c; + } + } catch (e) {} + if (typeof url === 'string' && url.startsWith('/') && !url.startsWith('file://')) { + url = 'file://' + url; + } + plus.io.resolveLocalFileSystemURL(url, (entry) => { + if (finished) return; + readFileEntry(entry, win, () => {}); + }, () => {}); + plus.io.requestFileSystem(plus.io.PUBLIC_DOWNLOADS, (fs) => { + if (finished) return; + fs.root.getFile(fileName, {}, (entry) => { + if (finished) return; + readFileEntry(entry, win, () => {}); + }, () => {}); + }, () => {}); + }); return; } - // _doc/ 用 requestFileSystem(PRIVATE_DOC),逐级 getDirectory 再 getFile(嵌套路径兼容) + // _doc/:PRIVATE_DOC 逐级与 resolve 并行,谁先读到有效字节谁胜出 if (path && path.startsWith('_doc/')) { const relPath = path.replace(/^_doc\//, ''); const parts = relPath.split('/'); const fileName = parts.pop(); const dirs = parts; - plus.io.requestFileSystem(plus.io.PRIVATE_DOC, (fs) => { - let cur = fs.root; - const next = (i) => { - if (i >= dirs.length) { - cur.getFile(fileName, { create: false }, (entry) => readFileEntry(entry, doSend, reject), (err) => reject(err)); - return; - } - cur.getDirectory(dirs[i], { create: false }, (dir) => { cur = dir; next(i + 1); }, (err) => reject(err)); + tryUniReadFile(() => { + if (typeof plus === 'undefined' || !plus.io) { + reject(new Error('当前环境不支持文件读取')); + return; + } + console.log('[100J-蓝牙] _doc 并行 resolve+PRIVATE_DOC, path=', path.slice(0, 96)); + let finished = false; + const outerMs = 20000; + const outerT = setTimeout(() => { + if (finished) return; + finished = true; + reject(new Error('读取本地语音超时(文档目录),请重新保存录音后重试')); + }, outerMs); + const win = (bytes) => { + if (finished) return; + if (!bytes || !(bytes.length > 0)) return; + finished = true; + clearTimeout(outerT); + doSend(bytes); }; - next(0); - }, (err) => reject(err)); + let url = path; + try { + if (plus.io.convertLocalFileSystemURL) { + const c = plus.io.convertLocalFileSystemURL(path); + if (c) url = c; + } + } catch (e) {} + if (typeof url === 'string' && url.startsWith('/') && !url.startsWith('file://')) { + url = 'file://' + url; + } + plus.io.resolveLocalFileSystemURL(url, (entry) => { + if (finished) return; + readFileEntry(entry, win, () => {}); + }, () => {}); + plus.io.requestFileSystem(plus.io.PRIVATE_DOC, (fs) => { + if (finished) return; + let cur = fs.root; + const next = (i) => { + if (finished) return; + if (i >= dirs.length) { + cur.getFile(fileName, { create: false }, (entry) => { + if (finished) return; + readFileEntry(entry, win, () => {}); + }, () => {}); + return; + } + cur.getDirectory(dirs[i], { create: false }, (dir) => { + cur = dir; + next(i + 1); + }, () => {}); + }; + next(0); + }, () => {}); + }); return; } - // 其他路径兜底 + if (typeof plus === 'undefined' || !plus.io) { + console.error('[100J-蓝牙] 当前环境不支持 plus.io,且非 _doc/_downloads 相对路径'); + reject(new Error('当前环境不支持文件读取')); + return; + } + // 其他路径兜底:部分机型 resolve 长期无回调,限时避免与外层读流程叠死 + // 与 Common.moveFileToDownloads 一致:uni 临时路径先 convert 再 resolve let resolvePath = path; - if (path && path.startsWith('/') && !path.startsWith('file://')) resolvePath = 'file://' + path; - plus.io.resolveLocalFileSystemURL(resolvePath, (entry) => readFileEntry(entry, doSend, reject), (err) => reject(err)); + try { + if (resolvePath && typeof plus !== 'undefined' && plus.io && plus.io.convertLocalFileSystemURL) { + const isRel = /^(?:_doc\/|_downloads\/|https?:)/i.test(resolvePath); + if (!isRel) { + const conv = plus.io.convertLocalFileSystemURL(resolvePath); + if (conv && typeof conv === 'string') resolvePath = conv; + } + } + } catch (e) {} + if (resolvePath && resolvePath.startsWith('/') && !resolvePath.startsWith('file://')) resolvePath = 'file://' + resolvePath; + let resolveDone = false; + const t = setTimeout(() => { + if (resolveDone) return; + resolveDone = true; + reject(new Error('解析本地文件路径超时,请重新保存录音')); + }, 12000); + plus.io.resolveLocalFileSystemURL(resolvePath, (entry) => { + if (resolveDone) return; + resolveDone = true; + clearTimeout(t); + readFileEntry(entry, doSend, reject); + }, (err) => { + if (resolveDone) return; + resolveDone = true; + clearTimeout(t); + reject(err); + }); }; - if (isLocalPath) { - // 本地路径:无网络时直接读取 - readFromPath(fileUrlOrLocalPath); - } else { - // 网络 URL:优先用 uni.request 拉取;加超时避免断网时进度长期卡在 1%~2% + const startUrlFetch = () => { let fetchUrl = fileUrlOrLocalPath; if (fetchUrl.startsWith('http://')) fetchUrl = 'https://' + fetchUrl.slice(7); if (onProgress) onProgress(2); @@ -324,6 +615,9 @@ class HBY100JProtocol { const bytes = res.data instanceof ArrayBuffer ? new Uint8Array(res.data) : new Uint8Array(res.data || []); if (bytes.length > 0) { const doSend = (b) => { + if (voiceListId !== '' && did && b && b.length) { + put100JVoiceBleCache(did, voiceListId, b); + } this._sendVoiceChunks(b, fileType, CHUNK_SIZE, onProgress).then(resolve).catch(reject); }; doSend(bytes); @@ -333,6 +627,31 @@ class HBY100JProtocol { fallbackDownload(); }) .catch(() => fallbackDownload()); + }; + if (isLocalPath) { + // 本地路径:无网络时直接读取;plus 回调偶发不返回时避免进度永远停在 1% + localReadWatchdog = setTimeout(() => { + localReadWatchdog = null; + if (onProgress) onProgress(0); + reject(new Error('读取本地语音超时,请重新录制')); + }, 60000); + readFromPath(fileUrlOrLocalPath); + } else { + // 网络 URL:无网时立即失败,避免卡在 1%~2% + uni.getNetworkType({ + success: (net) => { + if (net.networkType === 'none') { + if (onProgress) onProgress(0); + reject(new Error('无网络,无法下载云端语音,请连接网络后重试')); + return; + } + startUrlFetch(); + }, + fail: () => { + if (onProgress) onProgress(0); + reject(new Error('无法检测网络,请连接网络后重试')); + } + }); } }); } @@ -344,7 +663,20 @@ class HBY100JProtocol { const DELAY_PACKET = 80; // 数据包间延时(ms),参考6155 const toHex = (arr) => Array.from(arr).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' '); console.log('[100J-蓝牙] 语音下发总大小:', total, '字节, fileType=', ft); - if (onProgress) onProgress(1); + // 进度单调递增:前段固定 2→8,数据段占 8~95,结束包 99→100,避免先 5% 再掉回 1% 的错觉 + let progressPeak = 0; + const emitProgress = (raw) => { + const n = Math.round(Number(raw)); + if (!Number.isFinite(n)) return; + const v = Math.min(100, Math.max(progressPeak, n)); + progressPeak = v; + if (onProgress) onProgress(v); + }; + if (total <= 0) { + emitProgress(100); + return Promise.resolve({ code: 200, msg: '语音文件已通过蓝牙上传' }); + } + emitProgress(2); const bleToolPromise = import('@/utils/BleHelper.js').then(m => m.default.getBleTool()); let bleRef = null; const send = (dataBytes, label = '') => { @@ -362,34 +694,53 @@ class HBY100JProtocol { const delay = (ms) => new Promise(r => setTimeout(r, ms)); // 开始包: FA 05 [fileType] [phase=0] [size 4B LE] FF const startData = [ft, 0, total & 0xFF, (total >> 8) & 0xFF, (total >> 16) & 0xFF, (total >> 24) & 0xFF]; - const waitPromise = this.waitForFileResponse(1000); - return bleToolPromise.then(ble => { - bleRef = ble; - ble.setVoiceUploading(true); - return send(startData, ' 开始包') - .then(() => { if (onProgress) onProgress(3); return waitPromise; }) - .then(() => { if (onProgress) onProgress(5); return delay(DELAY_AFTER_START); }) + // 单包约 507B(500 负载),依赖 MTU;Android 上为整包 write + const waitPromise = this.waitForFileResponse(2500); + const prepMtuThenSend = (ble) => { + const run = () => { + bleRef = ble; + ble.setVoiceUploading(true); + return send(startData, ' 开始包') + .then(() => waitPromise) + .then(() => delay(DELAY_AFTER_START)) .then(() => { + emitProgress(8); let seq = 0; const sendNext = (offset) => { if (offset >= total) { - return delay(DELAY_PACKET).then(() => send([ft, 2], ' 结束包')); + return delay(DELAY_PACKET) + .then(() => send([ft, 2], ' 结束包')) + .then(() => { emitProgress(99); }); } const chunk = bytes.slice(offset, Math.min(offset + chunkSize, total)); const chunkData = [ft, 1, seq & 0xFF, (seq >> 8) & 0xFF, ...chunk]; return send(chunkData, ` #${seq} 数据包`).then(() => { seq++; - if (onProgress) onProgress(Math.min(100, Math.floor((offset + chunk.length) / total * 100))); + const doneRatio = (offset + chunk.length) / total; + emitProgress(8 + Math.round(doneRatio * 87)); return delay(DELAY_PACKET).then(() => sendNext(offset + chunk.length)); }); }; return sendNext(0); }) .then(() => { - if (onProgress) onProgress(100); + emitProgress(100); return { code: 200, msg: '语音文件已通过蓝牙上传' }; }); - }).finally(() => { + }; + try { + if (typeof plus !== 'undefined' && plus.os && plus.os.name === 'Android' && ble.setMtu) { + return ble.setMtu(this.bleDeviceId) + .catch((e) => { + console.warn('[100J-蓝牙] setBLEMTU 失败,大数据包可能无法一次写入,已用较小分片缓解:', e && (e.message || e)); + }) + .then(() => new Promise((r) => setTimeout(r, 350))) + .then(run); + } + } catch (e) {} + return run(); + }; + return bleToolPromise.then(ble => prepMtuThenSend(ble)).finally(() => { if (bleRef) bleRef.setVoiceUploading(false); }); } @@ -410,6 +761,124 @@ export function getBleStatus() { return { isConnected: protocolInstance.isBleConnected, bleDeviceId: protocolInstance.bleDeviceId, deviceId: protocolInstance.deviceId }; } +/** + * 按当前设备 MAC 与 BleHelper.LinkedList 对齐协议层连接状态。 + * 语音列表页进入时 eventChannel 携带的 getBleStatus() 可能过期;点「使用」前调用,确保真走 uploadVoiceFileBle。 + */ +export function sync100JBleProtocolFromHelper(deviceRow) { + if (!deviceRow || !deviceRow.deviceId) return Promise.resolve(); + const did = deviceRow.deviceId; + protocolInstance.deviceId = did; + return import('@/utils/BleHelper.js').then((m) => { + const bleTool = m.default.getBleTool(); + const mac = deviceRow.deviceMac || deviceRow.device_mac || ''; + if (!bleTool.data.available) { + updateBleStatus(false, '', did); + return; + } + const macNorm = (s) => String(s || '').replace(/:/g, '').toUpperCase(); + const target = macNorm(mac); + const last6 = target.length >= 6 ? target.slice(-6) : ''; + const list = bleTool.data.LinkedList || []; + const item = list.find((v) => { + if (!v) return false; + const mm = macNorm(v.macAddress || ''); + if (!mm) return false; + if (target && mm === target) return true; + return !!(last6 && mm.length >= 6 && mm.slice(-6) === last6); + }); + if (item && item.Linked && item.deviceId) { + updateBleStatus(true, item.deviceId, did); + } else { + updateBleStatus(false, '', did); + } + }).catch(() => {}); +} + +/** 与 HBY100 一致:语音内容进 uni 存储,下发时取出再发蓝牙。100 存 PCM 十六进制串;100J 存整文件 base64(比 hex 省空间),取出后转 Uint8Array,仍走 FA 05 二进制分片(设备协议未变)。 */ +export function voiceBleCacheStorageKey(deviceId, voiceListId) { + return '100J_voice_b64_' + String(deviceId) + '_' + String(voiceListId); +} + +function _uint8ToBase64Chunk(u8) { + if (!u8 || !u8.length) return ''; + try { + const buf = u8.buffer instanceof ArrayBuffer + ? u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) + : u8; + if (typeof uni !== 'undefined' && typeof uni.arrayBufferToBase64 === 'function') { + return uni.arrayBufferToBase64(buf); + } + } catch (e) {} + let binary = ''; + for (let i = 0; i < u8.length; i++) binary += String.fromCharCode(u8[i]); + return btoa(binary); +} + +function _base64ToUint8Array(b64) { + if (!b64 || typeof b64 !== 'string') return null; + const s = b64.replace(/\s/g, ''); + try { + if (typeof uni !== 'undefined' && typeof uni.base64ToArrayBuffer === 'function') { + const buf = uni.base64ToArrayBuffer(s); + return new Uint8Array(buf); + } + } catch (e) {} + try { + const binary = atob(s); + const len = binary.length; + const u8 = new Uint8Array(len); + for (let i = 0; i < len; i++) u8[i] = binary.charCodeAt(i); + return u8; + } catch (e) { + return null; + } +} + +export function put100JVoiceBleCache(deviceId, voiceListId, uint8Array) { + if (!deviceId || voiceListId == null || !uint8Array || !uint8Array.length) return; + try { + const b64 = _uint8ToBase64Chunk(uint8Array); + if (b64) uni.setStorageSync(voiceBleCacheStorageKey(deviceId, voiceListId), b64); + } catch (e) {} +} + +export function get100JVoiceBleCacheBytes(deviceId, voiceListId) { + if (!deviceId || voiceListId == null) return null; + try { + const b64 = uni.getStorageSync(voiceBleCacheStorageKey(deviceId, voiceListId)); + return _base64ToUint8Array(b64); + } catch (e) { + return null; + } +} + +export function remove100JVoiceBleCache(deviceId, voiceListId) { + if (!deviceId || voiceListId == null) return; + try { + uni.removeStorageSync(voiceBleCacheStorageKey(deviceId, voiceListId)); + } catch (e) {} +} + +/** 保存录音/上传成功后:读落盘路径并写入缓存,点「使用」时可与 HBY100 一样不再依赖 plus 读文件 */ +export function cache100JVoiceFileForBle(deviceId, voiceListId, filePath) { + return new Promise((resolve) => { + if (!deviceId || voiceListId == null || !filePath) { + resolve(); + return; + } + // 仅用 uni readFile + 超时;App 上不再走 tryGetUniFileSystemManager 跳过逻辑。 + // 旧版在此用 plus.requestFileSystem 且无总超时,部分机型永不回调 → Promise 挂死。 + tryUniReadFileOnAppWithTimeout(filePath, 4000).then((bytes) => { + if (bytes && bytes.length) { + put100JVoiceBleCache(deviceId, voiceListId, bytes); + console.log('[100J] cache100JVoiceFileForBle 已写入 uni 缓存(readFile),字节:', bytes.length); + } + resolve(); + }); + }); +} + // 暴露给页面:解析蓝牙接收到的数据 export function parseBleData(buffer) { return protocolInstance.parseBleData(buffer); @@ -429,8 +898,45 @@ export function fetchBleLocation() { return protocolInstance.getLocation(); } -// 等待蓝牙连接(扫描中时轮询,设备页可能在后台完成连接,100J 扫描约 15s) -function waitForBleConnection(maxWaitMs = 12000, intervalMs = 500) { +// 100J 设备页扫描最长约 15s,指令侧不宜空等过久;适配器关闭时不应再轮询 +const WAIT_BLE_CONNECTED_MS = 5000; +const BLE_POLL_INTERVAL_MS = 400; +const BLE_RECONNECT_MS = 2000; + +/** + * 系统蓝牙是否可用。fail/available=false 时立即走 4G。 + * 若系统报开启,再与 BleHelper.data.available 交叉校验(关蓝牙后助手往往先变为 false,避免仍弹「连蓝牙」类误导)。 + */ +function getBleAdapterAvailable() { + return new Promise((resolve) => { + if (typeof uni.getBluetoothAdapterState !== 'function') { + resolve(true); + return; + } + uni.getBluetoothAdapterState({ + success: (res) => { + if (!res.available) { + resolve(false); + return; + } + import('@/utils/BleHelper.js').then((m) => { + try { + const ble = m.default.getBleTool(); + if (ble && ble.data && ble.data.available === false) { + resolve(false); + return; + } + } catch (e) {} + resolve(true); + }).catch(() => resolve(true)); + }, + fail: () => resolve(false) + }); + }); +} + +// 等待协议层出现 bleDeviceId(页面后台扫描/连接中),超时则走 4G +function waitForBleConnection(maxWaitMs = WAIT_BLE_CONNECTED_MS, intervalMs = BLE_POLL_INTERVAL_MS) { if (protocolInstance.isBleConnected && protocolInstance.bleDeviceId) return Promise.resolve(true); return new Promise((resolve) => { const start = Date.now(); @@ -447,13 +953,13 @@ function waitForBleConnection(maxWaitMs = 12000, intervalMs = 500) { } setTimeout(tick, intervalMs); }; - console.log('[100J] 蓝牙未连接,等待扫描/连接中...', maxWaitMs, 'ms'); + console.log('[100J] 蓝牙未连接,短时等待扫描/连接…', maxWaitMs, 'ms'); tick(); }); } // 暴露给页面:尝试重连蓝牙(优先策略:断线后发指令前先尝试重连) -export function tryReconnectBle(timeoutMs = 2500) { +export function tryReconnectBle(timeoutMs = BLE_RECONNECT_MS) { if (protocolInstance.isBleConnected) return Promise.resolve(true); if (!protocolInstance.bleDeviceId) return Promise.resolve(false); return new Promise((resolve) => { @@ -512,26 +1018,61 @@ export function deviceDeleteAudioFile(params) { }) } -// 更新语音/使用语音:蓝牙优先,4G 兜底(不影响原有 4G 音频下发) -// 有 fileUrl 或 localPath 且蓝牙可用时走蓝牙;否则或蓝牙失败时走 4G(与原先逻辑一致) +// 更新语音/使用语音:蓝牙优先;云端 fileUrl 蓝牙失败时可 4G 仅传 id 触发设备拉流 +// 本地 localPath 仅能通过蓝牙下发,禁止回退 4G(否则服务端成功但设备无文件) +function isHttpUrlString(s) { + return !!(s && /^https?:\/\//i.test(String(s).trim())); +} + +/** 与后端约定:communicationMode 0=4G,1=蓝牙(云端记录本次「使用」语音的通讯方式) */ export function deviceUpdateVoice(data) { - const httpExec = () => request({ + const httpExec = (communicationMode) => request({ url: `/app/hby100j/device/updateVoice`, method: 'post', - data: { id: data.id } + data: { + id: data.id, + communicationMode: communicationMode === 1 ? 1 : 0 + } }); - const localPath = data.localPath; - const fileUrl = data.fileUrl; - const hasLocalPath = localPath && typeof localPath === 'string' && localPath.length > 0; - const hasFileUrl = fileUrl && typeof fileUrl === 'string' && fileUrl.length > 0; - const fileSource = hasLocalPath ? localPath : (hasFileUrl ? fileUrl : null); + const lp = (data.localPath && String(data.localPath).trim()) || ''; + const fu = (data.fileUrl && String(data.fileUrl).trim()) || ''; + // mergeLocal 会把本地路径塞进 fileUrl;列表项若丢 localPath 仍可能只剩「非 http」的 fileUrl + const effectiveLocal = (lp && !isHttpUrlString(lp)) ? lp : ((fu && !isHttpUrlString(fu)) ? fu : ''); + const BLE_CACHE_SENTINEL = '__100J_BLE_CACHE__'; + const hasBleCache = protocolInstance.deviceId && data.id != null && (() => { + const u8 = get100JVoiceBleCacheBytes(protocolInstance.deviceId, data.id); + return !!(u8 && u8.length); + })(); + const hasLocalPath = effectiveLocal.length > 0 || hasBleCache; + const remoteUrl = isHttpUrlString(fu) ? fu : (isHttpUrlString(lp) ? lp : ''); + const fileSource = hasLocalPath ? (effectiveLocal || BLE_CACHE_SENTINEL) : (remoteUrl || null); if (!fileSource) { - console.log('[100J] 语音上传:无 fileUrl/localPath,走 4G'); - return httpExec(); // 无文件源:直接 4G(原有逻辑) + console.log('[100J] 语音上传:无 fileUrl/localPath,仅 HTTP updateVoice(不会走蓝牙传文件)'); + return httpExec(0).then((res) => { if (res && typeof res === 'object') res._channel = '4g'; return res; }); } - console.log('[100J] 语音上传:有文件源,蓝牙优先', { isBleConnected: protocolInstance.isBleConnected, bleDeviceId: protocolInstance.bleDeviceId || '-' }); - const bleExec = () => protocolInstance.uploadVoiceFileBle(fileSource, 1, data.onProgress); - return execWithBleFirst(bleExec, httpExec, '语音文件上传', data.onWaiting); + console.log('[100J] 语音上传:有文件源,尝试蓝牙下发文件', { isBleConnected: protocolInstance.isBleConnected, hasBleDeviceId: !!protocolInstance.bleDeviceId, local: !!hasLocalPath }); + // 蓝牙传完文件后再调 updateVoice(communicationMode=1)。若仅 HTTP 失败而文件已下发,仍返回 _channel=ble, + // 切勿让整链 reject 触发 execWithBleFirst 走 4G+MQTT,否则界面等不到进度会误报「音频进度同步超时」。 + const bleExec = () => protocolInstance.uploadVoiceFileBle(fileSource, 1, data.onProgress, { voiceListId: data.id }) + .then(() => + httpExec(1) + .then((res) => { + if (res && typeof res === 'object') res._channel = 'ble'; + return res; + }) + .catch((e) => { + console.warn('[100J] 蓝牙已传完语音文件,updateVoice(communicationMode=1) 失败:', e && (e.message || e)); + return { + code: 200, + msg: '语音已通过蓝牙下发', + _channel: 'ble', + _updateVoiceAfterBleFailed: true + }; + }) + ); + const http4g = () => httpExec(0); + // 本地文件:禁止一切 4G 兜底(含蓝牙未开时),避免仅传 id 假成功 + return execWithBleFirst(bleExec, http4g, '语音文件上传', data.onWaiting, { no4GFallback: hasLocalPath }); } // 100J信息 export function deviceDetail(id) { @@ -542,9 +1083,30 @@ export function deviceDetail(id) { } // 蓝牙优先、4G 兜底:未连接时先等待扫描/连接,再尝试重连;蓝牙失败时回退 4G -function execWithBleFirst(bleExec, httpExec, logName, onWaiting) { +// opts.no4GFallback:本地语音等场景禁止走 HTTP,避免无网/假成功 +function execWithBleFirst(bleExec, httpExec, logName, onWaiting, opts = {}) { + const no4G = !!opts.no4GFallback; + const localBleOnlyMsg = '本地语音需通过蓝牙下发,请连接设备蓝牙后重试'; const doBle = () => bleExec().then(res => ({ ...(res || {}), _channel: 'ble' })); - const do4G = () => httpExec().then(res => { res._channel = '4g'; return res; }); + const do4G = () => httpExec().then((res) => { + console.log('[100J] 语音上传:已改走 HTTP(4G) updateVoice,未执行蓝牙文件分片'); + if (res && typeof res === 'object') res._channel = '4g'; + return res; + }); + const go4GOrReject = (reasonLog) => { + if (no4G) { + return Promise.reject(new Error(localBleOnlyMsg)); + } + if (reasonLog) console.log('[100J]', logName || '指令', reasonLog); + return do4G(); + }; + const onBleSendFail = (e) => { + if (no4G) { + return Promise.reject(e instanceof Error && e.message ? e : new Error(String((e && e.message) || '蓝牙发送语音失败,请靠近设备后重试'))); + } + console.log('[100J]', logName || '指令', '蓝牙失败,回退4G'); + return do4G(); + }; const hideWaitUi = () => { if (typeof onWaiting === 'function') return; try { @@ -560,33 +1122,37 @@ function execWithBleFirst(bleExec, httpExec, logName, onWaiting) { }; if (protocolInstance.isBleConnected && protocolInstance.bleDeviceId) { - return doBle().catch(() => { - console.log('[100J]', logName || '指令', '蓝牙失败,回退4G'); - return do4G(); + console.log('[100J] 语音上传:协议层已连接,执行蓝牙传文件'); + return doBle().catch(onBleSendFail); + } + console.log('[100J] 语音上传:协议层未就绪,将等待重连或走 4G', { isBleConnected: protocolInstance.isBleConnected, hasBleDeviceId: !!protocolInstance.bleDeviceId }); + // 无 bleDeviceId:系统蓝牙关闭则立即 4G;开启则短时等页面扫描连上(不再白等 12s) + if (!protocolInstance.bleDeviceId) { + return getBleAdapterAvailable().then((adapterOk) => { + if (!adapterOk) { + return go4GOrReject('系统蓝牙未开启,走4G'); + } + if (typeof onWaiting === 'function') onWaiting(); + else showWaitUi('请稍候…'); + return waitForBleConnection() + .then((connected) => { + return connected ? doBle().catch(onBleSendFail) : go4GOrReject('蓝牙未连接'); + }) + .finally(hideWaitUi); }); } - // 无 bleDeviceId:页面可能在扫描,最多等 12s(否则用户以为点了没反应) - if (!protocolInstance.bleDeviceId) { - if (typeof onWaiting === 'function') onWaiting(); - else showWaitUi('蓝牙连接中…'); - return waitForBleConnection(12000) - .then(connected => { - return connected ? doBle().catch(() => { - console.log('[100J]', logName || '指令', '蓝牙失败,回退4G'); - return do4G(); - }) : do4G(); + // 有 bleDeviceId 但未连:系统蓝牙关则直接 4G,否则短时重连 + return getBleAdapterAvailable().then((adapterOk) => { + if (!adapterOk) { + return go4GOrReject('系统蓝牙未开启,走4G'); + } + if (typeof onWaiting !== 'function') showWaitUi('请稍候…'); + return tryReconnectBle() + .then((reconnected) => { + return reconnected ? doBle().catch(onBleSendFail) : go4GOrReject('蓝牙未连接'); }) .finally(hideWaitUi); - } - if (typeof onWaiting !== 'function') showWaitUi('正在重连蓝牙…'); - return tryReconnectBle(2500) - .then(reconnected => { - return reconnected ? doBle().catch(() => { - console.log('[100J]', logName || '指令', '蓝牙失败,回退4G'); - return do4G(); - }) : do4G(); - }) - .finally(hideWaitUi); + }); } // 爆闪模式 @@ -634,11 +1200,27 @@ export function deviceUpdateVolume(data) { ); } -// 语音播放 +/** 蓝牙侧语音播报:自定义音(mode=7)且强制报警仍开时,需先 0x0C 关报警+模式7 再 0x06;已解除则不必重复下发 0x0C */ +function bleVoiceBroadcastChain(data) { + const on = Number(data.voiceBroadcast) === 1; + const mode = data.mode != null ? String(data.mode) : ''; + if (on && mode === '7') { + const alarmOn = Number(data.voiceStrobeAlarm) === 1; + if (alarmOn) { + return protocolInstance.setForceAlarm(0, 7).then(() => protocolInstance.setVoiceBroadcast(1)); + } + return protocolInstance.setVoiceBroadcast(1); + } + return protocolInstance.setVoiceBroadcast(on ? 1 : 0); +} + +// 语音播放(HTTP 透传 data,便于后端识别 mode) export function deviceVoiceBroadcast(data) { + const httpData = data && typeof data === 'object' ? { ...data } : data; + if (httpData && typeof httpData === 'object') delete httpData.voiceStrobeAlarm; return execWithBleFirst( - () => protocolInstance.setVoiceBroadcast(data.voiceBroadcast).then(() => ({ code: 200, msg: '操作成功(蓝牙)' })), - () => request({ url: `/app/hby100j/device/voiceBroadcast`, method: 'post', data }), + () => bleVoiceBroadcastChain(data).then(() => ({ code: 200, msg: '操作成功(蓝牙)' })), + () => request({ url: `/app/hby100j/device/voiceBroadcast`, method: 'post', data: httpData }), '语音播报' ); } \ No newline at end of file diff --git a/config/index.js b/config/index.js index cd10922..73af4ed 100644 --- a/config/index.js +++ b/config/index.js @@ -2,7 +2,7 @@ const config = { // 开发环境 development: { - BASE_URL: 'http://192.168.110.57:8000',//http://139.224.253.23:8000 + BASE_URL: 'http://192.168.2.34:8000',//http://139.224.253.23:8000 API_PREFIX: '', // MQTT 配置 MQTT_HOST: '47.120.79.150', diff --git a/pages/100J/HBY100-J.vue b/pages/100J/HBY100-J.vue index 5083e56..e05dab9 100644 --- a/pages/100J/HBY100-J.vue +++ b/pages/100J/HBY100-J.vue @@ -64,7 +64,7 @@ {{ deviceInfo && deviceInfo.longitude ? Number(deviceInfo.longitude).toFixed(4) : '' }} {{ deviceInfo && deviceInfo.latitude ? Number(deviceInfo.latitude).toFixed(4) : '' }} - + @@ -469,23 +469,41 @@ const eventChannel = this.getOpenerEventChannel(); var these = this; - // 低电量提示:同一百分比不重复弹(MQTT/蓝牙反复上报时避免刷屏);恢复高于 20% 后再次降低可再提示 + // 低电量:语音上传/蓝牙分包时电量字段易抖动,防抖 + 上传中不弹,避免「发送中频繁低电量」误报 this._lastBatteryLowToastPct = null; - this.$watch("deviceInfo.batteryPercentage", (newVal) => { - const n = Number(newVal); + this._batteryLowDebounceTimer = null; + this.$watch("deviceInfo.batteryPercentage", () => { + if (bleTool.isVoiceUploading && bleTool.isVoiceUploading()) { + if (this._batteryLowDebounceTimer) { + clearTimeout(this._batteryLowDebounceTimer); + this._batteryLowDebounceTimer = null; + } + return; + } + const n = Math.round(Number(this.deviceInfo.batteryPercentage)); if (!Number.isFinite(n)) return; if (n > 20) { this._lastBatteryLowToastPct = null; + if (this._batteryLowDebounceTimer) { + clearTimeout(this._batteryLowDebounceTimer); + this._batteryLowDebounceTimer = null; + } return; } - if (n <= 20 && this._lastBatteryLowToastPct !== n) { - this._lastBatteryLowToastPct = n; + if (this._batteryLowDebounceTimer) clearTimeout(this._batteryLowDebounceTimer); + this._batteryLowDebounceTimer = setTimeout(() => { + this._batteryLowDebounceTimer = null; + if (bleTool.isVoiceUploading && bleTool.isVoiceUploading()) return; + const cur = Math.round(Number(this.deviceInfo.batteryPercentage)); + if (!Number.isFinite(cur) || cur > 20) return; + if (this._lastBatteryLowToastPct === cur) return; + this._lastBatteryLowToastPct = cur; uni.showToast({ title: '设备电量低', icon: 'none', duration: 2000 }); - } + }, 800); }); eventChannel.on('detailData', function(data) { var device = data.data; @@ -552,13 +570,12 @@ // 报警模式,选中,首次上报值,或者切换设备按键上报值 const enable = siren_alarm.enable ?? 0; // 报警开关:1=开,0=关 const mode = siren_alarm.mode ?? 0; // 模式:0/1/2/3/4/5/6 - if (these.formData.sta_VoiceType != 7) { + if (String(these.formData.sta_VoiceType) !== '7') { if (enable === 1) { - // 开启状态 these.formData.sta_VoiceType = mode + ''; } else { - // 关闭状态:赋值-1,表示关闭 - these.formData.sta_VoiceType = mode + ''; + const m = Number(mode); + these.formData.sta_VoiceType = m === 7 ? '-1' : m + ''; } } else { // 播放语音,上报消息 @@ -582,18 +599,15 @@ } // 强制报警按键解除报警状态,app同步 } else if (funcType === '12') { - const enable_alarm = data.voice_strobe_alarm ?? - 0; // 报警开关:1=开,0=关 - const mode_alarm = data.mode ?? 0; // 模式:0/1/2/3/4/5/6 + const enable_alarm = data.voice_strobe_alarm ?? 0; + const mode_alarm = data.mode ?? 0; if (enable_alarm === 1) { - // 开启状 these.deviceInfo.voiceStrobeAlarm = 1; - these.formData.sta_VoiceType = mode_alarm + '' + these.formData.sta_VoiceType = mode_alarm + ''; } else { - // 关闭状态:赋值-1,表示关闭 these.deviceInfo.voiceStrobeAlarm = -1; - // 模式还是选中的,模式,解除也是 - these.formData.sta_VoiceType = mode_alarm + '' + const m = Number(mode_alarm); + these.formData.sta_VoiceType = m === 7 ? '-1' : m + ''; } } // 警示灯模式选中切换 @@ -656,6 +670,10 @@ this.Status.pageHide = true; }, onUnload() { + if (this._batteryLowDebounceTimer) { + clearTimeout(this._batteryLowDebounceTimer); + this._batteryLowDebounceTimer = null; + } if (this._hby100jBleAdapterHandler && typeof uni.offBluetoothAdapterStateChange === 'function') { uni.offBluetoothAdapterStateChange(this._hby100jBleAdapterHandler); this._hby100jBleAdapterHandler = null; @@ -749,7 +767,9 @@ this.formData.bleStatu = 'connecting'; bleTool.LinkBlue(f.deviceId, f.writeServiceId, f.wirteCharactId, f.notifyCharactId).then(() => { this.formData.bleStatu = true; - this.bleStateRecovry({ deviceId: f.deviceId }); + this.bleStateRecovry({ + deviceId: f.deviceId + }); }).catch(() => { this.formData.bleStatu = 'err'; }); @@ -787,10 +807,12 @@ // 关闭状态 that.formData.sta_LightType = '-1'; } - if (this.formData.sta_VoiceType === '7' || this.formData.sta_VoiceType === 7) { - this.formData.sta_VoiceType = (res.data.voiceStrobeAlarm ?? 0) + ''; + const alarmOnDetail = res.data.voiceStrobeAlarm === 1; + const amDetail = res.data.alarmMode != null ? Number(res.data.alarmMode) : 0; + if (alarmOnDetail) { + this.formData.sta_VoiceType = amDetail + ''; } else { - this.formData.sta_VoiceType = res.data.alarmMode + '' + this.formData.sta_VoiceType = amDetail === 7 ? '-1' : amDetail + ''; } } }) @@ -1006,18 +1028,18 @@ item = this.dic.sta_VoiceType[index]; } let val = item.key; + const prevVoiceType = this.formData.sta_VoiceType; if (this.formData.sta_VoiceType === val) { val = '-1'; } this.formData.sta_VoiceType = val; - // 模式类型为7时才去判断 console.log(val, 'valllll'); - const isVoiceOperate = val === '7' || val === '-1'; // 标记是否是语音开启/关闭操作 - if (this.deviceInfo.voiceStrobeAlarm == 1) { - // 如果强制报警已经开启了,那么切换下面的模式需要时,需要触发报警指令 + // 仅「播放语音」7 的开关走播报接口;-1 只有从 7 取消时才视为关播报,避免取消内置音色选中误调播报接口 + const isVoiceOperate = val === '7' || (val === '-1' && prevVoiceType === '7'); + if (this.deviceInfo.voiceStrobeAlarm === 1) { + // 强制报警已开启:切换下方模式需带报警下发 const data = { deviceIds: [this.deviceInfo.deviceId], - // 声光报警开关开启传1 voiceStrobeAlarm: 1, mode: this.formData.sta_VoiceType }; @@ -1033,13 +1055,14 @@ icon: 'none' }); } - }) + }); } else if (isVoiceOperate) { - console.log('我是谁'); - let data = { + const data = { deviceId: this.deviceInfo.deviceId, - voiceBroadcast: Number(this.formData.sta_VoiceType) === -1 ? 0 : 1 - } + voiceBroadcast: Number(this.formData.sta_VoiceType) === -1 ? 0 : 1, + mode: this.formData.sta_VoiceType, + voiceStrobeAlarm: this.deviceInfo.voiceStrobeAlarm + }; deviceVoiceBroadcast(data).then((res) => { if (res.code == 200) { uni.showToast({ @@ -1052,14 +1075,55 @@ icon: 'none' }); } - }) + }).catch(() => { + uni.showToast({ + title: '下发失败,请检查蓝牙或网络', + icon: 'none' + }); + }); + } else if (prevVoiceType === '7' && val !== '7' && val !== '-1') { + // 从「播放语音」切到其它内置音色:先关播报;报警未开启时不走 forceAlarm,仅 UI 预选音色 + const data = { + deviceId: this.deviceInfo.deviceId, + voiceBroadcast: 0, + mode: val, + voiceStrobeAlarm: this.deviceInfo.voiceStrobeAlarm + }; + deviceVoiceBroadcast(data).then((res) => { + if (res.code == 200) { + uni.showToast({ + title: res.msg || '已切换', + icon: 'none' + }); + } else { + uni.showToast({ + title: res.msg || '操作失败', + icon: 'none' + }); + } + }).catch(() => { + uni.showToast({ + title: '下发失败,请检查蓝牙或网络', + icon: 'none' + }); + }); } + // 未开启强制报警时,在 0–6 内置音色间切换或取消选中:只改按钮选中,不下发 }, // 报警模式 sosSetting(item) { console.log(this.deviceInfo, '44444'); console.log(item, 'tent'); const isClose = item === 0; + // 与「已解除不再重复关报警」对称:已在报警中不再弹窗重复下发「开启」,未报警时不再重复「解除」 + if (!isClose && this.deviceInfo.voiceStrobeAlarm === 1) { + uni.showToast({ title: '当前已在报警中', icon: 'none' }); + return; + } + if (isClose && this.deviceInfo.voiceStrobeAlarm !== 1) { + uni.showToast({ title: '当前未在报警中', icon: 'none' }); + return; + } if (!this.Status) this.Status = {}; if (!this.Status.Pop) this.Status.Pop = { showPop: false @@ -1089,7 +1153,8 @@ title: isClose ? '声光报警已解除' : '强制报警已开启', icon: 'none' }); - if (isClose && this.formData.sta_VoiceType === '7') { + // 解除后「播放语音」行与 mode7 绑定,需退出高亮(兼容 sta_VoiceType 为数字 7) + if (isClose && String(this.formData.sta_VoiceType) === '7') { this.formData.sta_VoiceType = '-1'; } } else { @@ -1240,7 +1305,8 @@ const last6 = target.slice(-6); const item = bleTool.data.LinkedList.find((v) => { const m = macNorm(v.macAddress || ''); - return v.deviceId === res.deviceId && (m === target || (m.length >= 6 && m.slice(-6) === last6)); + return v.deviceId === res.deviceId && (m === target || (m.length >= 6 && m.slice(-6) === + last6)); }); return !!item; }, @@ -1314,7 +1380,9 @@ this.formData.sta_VoiceType = (parsedData.alarmMode ?? 0) + ''; } else { this.$set(this.deviceInfo, 'voiceStrobeAlarm', -1); - this.formData.sta_VoiceType = (parsedData.alarmMode ?? 0) + ''; + const am = Number(parsedData.alarmMode ?? 0); + // 报警已关:设备仍可能带 mode7,避免「播放语音」仍显示为开启 + this.formData.sta_VoiceType = am === 7 ? '-1' : am + ''; } } // 0x0A 爆闪模式:警示灯开关/模式 @@ -1340,7 +1408,8 @@ this.formData.sta_VoiceType = (parsedData.alarmMode ?? 0) + ''; } else { this.$set(this.deviceInfo, 'voiceStrobeAlarm', -1); - this.formData.sta_VoiceType = (parsedData.alarmMode ?? 0) + ''; + const am = Number(parsedData.alarmMode ?? 0); + this.formData.sta_VoiceType = am === 7 ? '-1' : am + ''; } } if (parsedData.voiceBroadcast !== undefined) { diff --git a/pages/100J/audioManager/AudioList.vue b/pages/100J/audioManager/AudioList.vue index 32efd97..6ec61c5 100644 --- a/pages/100J/audioManager/AudioList.vue +++ b/pages/100J/audioManager/AudioList.vue @@ -29,8 +29,8 @@ - {{ item.useStatus == 1 ? '使用中' : '使用' }} + :class="{ 'active': isVoiceInUse(item), 'btn-default': !isVoiceInUse(item) }"> + {{ isVoiceInUse(item) ? '使用中' : '使用' }} @@ -105,7 +105,9 @@ videRenameAudioFile, deviceDeleteAudioFile, deviceUpdateVoice, - updateBleStatus + updateBleStatus, + sync100JBleProtocolFromHelper, + remove100JVoiceBleCache } from '@/api/100J/HBY100-J.js' import { baseURL } from '@/utils/request.js' import { @@ -231,12 +233,37 @@ console.log("页面返回") }, onUnload() { - // 页面卸载时断开MQTT连接 + this.clearVoiceApplyTimers(); if (this.mqttClient) { this.mqttClient.disconnect(); } }, methods: { + /** 清除「使用」语音相关的全部定时器,避免返回上一页后仍触发 toast / 二次 navigateBack */ + clearVoiceApplyTimers() { + if (this._applyOverallTimer) { + clearTimeout(this._applyOverallTimer); + this._applyOverallTimer = null; + } + if (this.upgradeTimer) { + clearTimeout(this.upgradeTimer); + this.upgradeTimer = null; + } + if (this._applyNavigateTimer) { + clearTimeout(this._applyNavigateTimer); + this._applyNavigateTimer = null; + } + }, + scheduleNavigateBackAfterVoice(delayMs = 1500) { + if (this._applyNavigateTimer) { + clearTimeout(this._applyNavigateTimer); + this._applyNavigateTimer = null; + } + this._applyNavigateTimer = setTimeout(() => { + this._applyNavigateTimer = null; + uni.navigateBack(); + }, delayMs); + }, //语音管理列表(合并云端 + 本地无网络保存的语音) getinitData(val, isLoadMore = false) { const deviceId = this.device.deviceId; @@ -266,7 +293,8 @@ this.total = res.total; const list = (res.data || []).map(item => ({ ...item, - createTime: item.createTime || Common.DateFormat(new Date(), "yyyy年MM月dd日") + createTime: item.createTime || Common.DateFormat(new Date(), "yyyy年MM月dd日"), + useStatus: Number(item.useStatus) === 1 ? 1 : 0 })); this.dataListA = mergeLocal(list); if (this.mescroll) this.mescroll.endBySize(this.dataListA.length, this.total + (this.dataListA.length - list.length)); @@ -430,7 +458,10 @@ let task = () => { if (item._isLocal) { // 本地项:从本地存储移除 - const key = `100J_local_audio_${this.device.deviceId}`; + const devId = this.device.deviceId; + const vid = (item.id != null && item.id !== '') ? item.id : item.fileId; + remove100JVoiceBleCache(devId, vid); + const key = `100J_local_audio_${devId}`; let list = uni.getStorageSync(key) || []; list = list.filter(l => l.id !== item.id && l.Id !== item.Id); uni.setStorageSync(key, list); @@ -496,9 +527,31 @@ this.checkList.push(item.Id); } }, + /** 与后端约定:仅 1 为使用中(避免字符串 "0" 在 class 里仍为 truthy) */ + isVoiceInUse(item) { + return Number(item && item.useStatus) === 1; + }, + /** 切换「使用」后同步整表:仅当前项为 1,其余为 0(避免第一项永远显示使用中) */ + syncVoiceListUseStatus(activeItem) { + const pickId = (o) => { + if (!o) return ''; + const v = o.id ?? o.fileId ?? o.Id; + return v != null && v !== '' ? String(v) : ''; + }; + const aid = pickId(activeItem); + if (!aid) return; + this.dataListA.forEach((row, i) => { + const rid = pickId(row); + const use = rid === aid ? 1 : 0; + if (Number(row.useStatus) !== use) { + this.$set(this.dataListA, i, { ...row, useStatus: use }); + } + }); + }, Apply(item, index) { this.updateProgress = 0; - this.isUpdating = true; + this.clearVoiceApplyTimers(); + // 本地项在无网时禁止下发,仅弹窗(isUpdating 在确认可执行后再置 true) // 本地项优先用 localPath;云端项用 fileUrl(兼容多种字段名),相对路径补全 baseURL let fileUrl = ''; let localPath = (item.localPath && typeof item.localPath === 'string') ? item.localPath : ''; @@ -506,73 +559,144 @@ const raw = item.fileUrl || item.url || item.filePath || item.audioUrl || item.ossUrl || ''; fileUrl = (typeof raw === 'string' && raw) ? (raw.startsWith('/') ? (baseURL + raw) : raw) : ''; } else { - // 本地项:localPath 优先,无则用 fileUrl(mergeLocal 中可能只有 fileUrl 存路径) - if (!localPath && item.fileUrl) localPath = item.fileUrl; + // 本地项:localPath 优先;mergeLocal 可能把路径放在 fileUrl,但勿把 http 当成本地路径 + if (!localPath && item.fileUrl) { + const cand = String(item.fileUrl).trim(); + if (cand && !/^https?:\/\//i.test(cand)) localPath = cand; + } } const data = { - id: item.id, - fileUrl, + id: (item.id != null && item.id !== '') ? item.id : item.fileId, + // 本地合并项 mergeLocal 会把路径写在 fileUrl,需带给接口层做 effectiveLocal 兜底 + fileUrl: item._isLocal ? (typeof item.fileUrl === 'string' ? item.fileUrl : '') : fileUrl, localPath, - onProgress: (p) => { this.updateProgress = p; }, - onWaiting: () => { uni.showToast({ title: '等待蓝牙连接中...', icon: 'none', duration: 2000 }); } + onProgress: (p) => { + const n = Math.min(100, Math.max(0, Math.round(Number(p) || 0))); + const cur = Number(this.updateProgress) || 0; + this.updateProgress = Math.max(cur, n); + }, + // 不传「蓝牙连接中」类提示:关蓝牙走 4G 时易误导;进度条 + 必要时全局请稍候即可 + onWaiting: () => {} }; - // 整体超时 60 秒(仅影响蓝牙上传,4G HTTP 很快返回) - const overallTimer = setTimeout(() => { - if (this.isUpdating) { - uni.showToast({ title: '操作超时', icon: 'none', duration: 2000 }); + const runDeviceUpdate = () => { + // 大文件蓝牙分片耗时可远超 2 分钟,整体超时放宽到 10 分钟(挂到实例上,便于 onUnload / 成功时清除) + const OVERALL_MS = 600000; + if (this._applyOverallTimer) { + clearTimeout(this._applyOverallTimer); + this._applyOverallTimer = null; + } + this._applyOverallTimer = setTimeout(() => { + this._applyOverallTimer = null; + if (this.isUpdating) { + uni.showToast({ title: '操作时间过长已中断,请重试或检查蓝牙连接', icon: 'none', duration: 2500 }); + this.isUpdating = false; + this.updateProgress = 0; + } + }, OVERALL_MS); + // 进入列表时的蓝牙快照可能过期;与 HBY100 详情页一致,从 BleHelper 按 MAC 再对齐一次 + sync100JBleProtocolFromHelper(this.device).then(() => deviceUpdateVoice(data)).then((RES) => { + if (this._applyOverallTimer) { + clearTimeout(this._applyOverallTimer); + this._applyOverallTimer = null; + } + if (RES.code == 200) { + // 蓝牙上传:进度已由 onProgress 更新,直接完成 + if (RES._channel === 'ble') { + if (this.upgradeTimer) { + clearTimeout(this.upgradeTimer); + this.upgradeTimer = null; + } + const title = RES._updateVoiceAfterBleFailed + ? '蓝牙已下发,云端同步失败可稍后重试' + : '音频上传成功'; + this.syncVoiceListUseStatus(item); + uni.showToast({ title, icon: RES._updateVoiceAfterBleFailed ? 'none' : 'success', duration: 2000 }); + this.isUpdating = false; + this.scheduleNavigateBackAfterVoice(1500); + return; + } + // 4G:MQTT 进度可能数十秒才上报,用「自上次进度起」滑动超时,避免误报 + const MQTT_IDLE_MS = 120000; + const armMqttIdle = () => { + if (this.upgradeTimer) clearTimeout(this.upgradeTimer); + this.upgradeTimer = setTimeout(() => { + if (!this.isUpdating) return; + uni.showToast({ + title: '长时间未收到设备进度,若语音已生效可返回查看', + icon: 'none', + duration: 3500 + }); + this.isUpdating = false; + this.updateProgress = 0; + }, MQTT_IDLE_MS); + }; + armMqttIdle(); + this.mqttClient = this.mqttClient || new MqttClient(); + this.mqttClient.connect(() => { + const statusTopic = `status/894078/HBY100/${this.device.deviceImei}`; + this.mqttClient.subscribe(statusTopic, (payload) => { + try { + const payloadObj = typeof payload === 'string' ? JSON.parse(payload) : payload; + const progress = payloadObj.data != null && payloadObj.data.progress !== undefined + ? payloadObj.data.progress + : payloadObj.progress; + if (progress !== undefined && !isNaN(progress) && progress >= 0 && progress <= 100) { + armMqttIdle(); + const cur = Number(this.updateProgress) || 0; + this.updateProgress = Math.max(cur, Math.round(progress)); + if (Number(progress) === 100) { + if (this.upgradeTimer) clearTimeout(this.upgradeTimer); + this.upgradeTimer = null; + this.syncVoiceListUseStatus(item); + uni.showToast({ title: '音频上传成功', icon: 'success', duration: 2000 }); + this.isUpdating = false; + this.scheduleNavigateBackAfterVoice(1500); + } + } + } catch (e) { + console.error('解析MQTT payload失败:', e); + armMqttIdle(); + } + }); + }); + } else { + this.isUpdating = false; + uni.showToast({ title: RES.msg || '操作失败', icon: 'none', duration: 1000 }); + } + }).catch((err) => { + if (this._applyOverallTimer) { + clearTimeout(this._applyOverallTimer); + this._applyOverallTimer = null; + } this.isUpdating = false; this.updateProgress = 0; - } - }, 60000); - deviceUpdateVoice(data).then((RES) => { - clearTimeout(overallTimer); - if (RES.code == 200) { - // 蓝牙上传:进度已由 onProgress 更新,直接完成 - if (RES._channel === 'ble') { - uni.showToast({ title: '音频上传成功', icon: 'success', duration: 2000 }); - this.isUpdating = false; - setTimeout(() => { uni.navigateBack(); }, 1500); - return; - } - // 4G:订阅 MQTT 获取设备端进度,6 秒超时 - this.upgradeTimer = setTimeout(() => { - if (this.isUpdating) { - uni.showToast({ title: '音频进度同步超时', icon: 'none', duration: 2000 }); - this.isUpdating = false; - this.updateProgress = 0; + uni.showToast({ title: err.message || '操作失败', icon: 'none', duration: 2500 }); + }); + }; + if (item._isLocal) { + uni.getNetworkType({ + success: (net) => { + if (net.networkType === 'none') { + uni.showModal({ + title: '无法使用', + content: '无网保存的本地语音无法通过蓝牙下发。请先连接 WiFi 或移动网络后,重新录制并保存(上传云端),再点「使用」。', + showCancel: false, + confirmText: '知道了' + }); + return; } - }, 6000); - this.mqttClient = this.mqttClient || new MqttClient(); - this.mqttClient.connect(() => { - const statusTopic = `status/894078/HBY100/${this.device.deviceImei}`; - this.mqttClient.subscribe(statusTopic, (payload) => { - try { - const payloadObj = typeof payload === 'string' ? JSON.parse(payload) : payload; - const progress = payloadObj.data?.progress; - if (progress !== undefined && !isNaN(progress) && progress >= 0 && progress <= 100) { - this.updateProgress = progress; - if (progress === 100) { - clearTimeout(this.upgradeTimer); - uni.showToast({ title: '音频上传成功', icon: 'success', duration: 2000 }); - this.isUpdating = false; - setTimeout(() => { uni.navigateBack(); }, 1500); - } - } - } catch (e) { - clearTimeout(this.upgradeTimer); - console.error('解析MQTT payload失败:', e); - } - }); - }); - } else { - this.isUpdating = false; - uni.showToast({ title: RES.msg || '操作失败', icon: 'none', duration: 1000 }); - } - }).catch((err) => { - clearTimeout(overallTimer); - this.isUpdating = false; - uni.showToast({ title: err.message || '操作失败', icon: 'none', duration: 2000 }); - }); + this.isUpdating = true; + runDeviceUpdate(); + }, + fail: () => { + this.isUpdating = true; + runDeviceUpdate(); + } + }); + return; + } + this.isUpdating = true; + runDeviceUpdate(); }, closePop: function() { this.Status.Pop.showPop = false; diff --git a/pages/100J/audioManager/Recording.vue b/pages/100J/audioManager/Recording.vue index 764d71b..2ff2e10 100644 --- a/pages/100J/audioManager/Recording.vue +++ b/pages/100J/audioManager/Recording.vue @@ -137,8 +137,9 @@ updateLoading } from '@/utils/loading.js'; import Common from '@/utils/Common.js'; - - + import { + cache100JVoiceFileForBle + } from '@/api/100J/HBY100-J.js'; export default { data() { @@ -421,44 +422,6 @@ hideLoading(these); }, 1200); }, - // 无网络时保存到本地,供蓝牙直接发送(不依赖 OSS) - // 将临时文件复制到持久化目录 _doc/100J_audio/,避免被系统清理 - saveLocalForBle(filePath) { - const deviceId = these.Status.ID; - if (!deviceId) return; - const doSave = (persistentPath) => { - const item = { - ...these.cEdit, - localPath: persistentPath, - fileUrl: '', - deviceId, - id: 'local_' + these.cEdit.Id, - _createTime: these.cEdit.createTime || Common.DateFormat(new Date(), "yyyy年MM月dd日"), - _isLocal: true - }; - const key = `100J_local_audio_${deviceId}`; - let list = uni.getStorageSync(key) || []; - list.unshift(item); - uni.setStorageSync(key, list); - these.AudioData.tempFilePath = ""; - these.Status.isRecord = false; - uni.navigateBack(); - }; - if (typeof plus !== 'undefined' && plus.io) { - const fileName = 'audio_' + (these.cEdit.Id || Date.now()) + '.mp3'; - plus.io.resolveLocalFileSystemURL(filePath, (entry) => { - plus.io.resolveLocalFileSystemURL('_doc/', (docEntry) => { - docEntry.getDirectory('100J_audio', { create: true }, (dirEntry) => { - entry.copyTo(dirEntry, fileName, (newEntry) => { - doSave(newEntry.fullPath); - }, () => { doSave(filePath); }); - }, () => { doSave(filePath); }); - }, () => { doSave(filePath); }); - }, () => { doSave(filePath); }); - } else { - doSave(filePath); - } - }, // 保存录音并上传(已修复文件格式问题) uploadLuYin() { // 文件类型验证 @@ -478,37 +441,73 @@ console.log("自动添加.mp3扩展名,新路径:", uploadFilePath); } - console.log("上传文件路径:", uploadFilePath); - plus.io.resolveLocalFileSystemURL(uploadFilePath, (entry) => { - entry.getMetadata((metadata) => { - console.log("文件大小:", metadata.size, "字节"); - console.log("文件类型验证通过"); + const startOssUpload = () => { + console.log("上传文件路径:", uploadFilePath); + // _downloads/_doc 相对路径上 resolve 部分机型会长期无回调,直接走 doUpload(内会 convert 供 uni.uploadFile) + const fp = String(uploadFilePath || ''); + if (fp.indexOf('_downloads/') === 0 || fp.indexOf('_doc/') === 0) { this.doUpload(uploadFilePath); + return; + } + plus.io.resolveLocalFileSystemURL(uploadFilePath, (entry) => { + entry.getMetadata((metadata) => { + console.log("文件大小:", metadata.size, "字节"); + console.log("文件类型验证通过"); + this.doUpload(uploadFilePath); + }, (err) => { + console.error("获取文件元数据失败:", err); + this.doUpload(this.AudioData.tempFilePath); + }); }, (err) => { - console.error("获取文件元数据失败:", err); + console.error("文件不存在或路径错误:", err); this.doUpload(this.AudioData.tempFilePath); }); - }, (err) => { - console.error("文件不存在或路径错误:", err); - this.doUpload(this.AudioData.tempFilePath); + }; + + // 无网络不允许保存:无网本地项无法上传云端,列表里「使用」也无法可靠读本地蓝牙下发 + uni.getNetworkType({ + success: (res) => { + if (res.networkType === 'none') { + uni.showModal({ + title: '无法保存', + content: '当前无网络,语音需上传云端后才能正常使用与蓝牙下发。请连接 WiFi 或移动网络后再点保存。', + showCancel: false, + confirmText: '知道了' + }); + return; + } + startOssUpload(); + }, + fail: () => { + startOssUpload(); + } }); }, // 执行上传操作 doUpload(filePath) { const key = `${Common.pcmStorageKey}_${this.cEdit.Id}`; - const store = uni.getStorageInfoSync(); - if (store.keys.includes(key)) return; + // 勿因历史 pcmStorageKey_* 存在就静默 return,否则用户点保存无反应、OSS 永不上传 const token = uni.getStorageSync('token'); const clientid = uni.getStorageSync('clientID'); const these = this; + let pathForUpload = filePath; + try { + if (typeof plus !== 'undefined' && plus.io && plus.io.convertLocalFileSystemURL) { + const fp = String(filePath || ''); + if (fp.indexOf('_downloads/') === 0 || fp.indexOf('_doc/') === 0) { + const c = plus.io.convertLocalFileSystemURL(fp); + if (c) pathForUpload = c; + } + } + } catch (e) {} showLoading(this, { text: "文件上传中" }); - console.log("最终上传文件路径:", filePath); + console.log("最终上传文件路径:", pathForUpload); uni.uploadFile({ url: baseURL + "/app/video/uploadAudioToOss", - filePath: filePath, + filePath: pathForUpload, name: 'file', header: { "Authorization": `Bearer ${token}`, @@ -535,6 +534,9 @@ if (fileUrl) cache[fileUrl] = filePath; if (d && typeof d === 'object' && d.id) cache[d.id] = filePath; uni.setStorageSync(cacheKey, cache); + if (d && typeof d === 'object' && d.id) { + cache100JVoiceFileForBle(deviceId, d.id, filePath); + } } } // 合并两个存储操作 @@ -592,14 +594,13 @@ }, fail: (err) => { console.error('上传文件失败:', err); - // 无网络时保存到本地,供蓝牙直接发送 - these.saveLocalForBle(filePath); - uni.showToast({ - title: '网络不可用,已保存到本地,可通过蓝牙发送', - icon: 'none', - duration: 3000 - }); these.timeOutCloseLoad(); + uni.showModal({ + title: '保存失败', + content: '文件未能上传到服务器。请检查网络后重试;无网时无法保存语音。', + showCancel: false, + confirmText: '知道了' + }); }, complete: () => { console.log('上传操作完成'); diff --git a/pages/common/addBLE/addEquip.vue b/pages/common/addBLE/addEquip.vue index ba8ec17..c21b672 100644 --- a/pages/common/addBLE/addEquip.vue +++ b/pages/common/addBLE/addEquip.vue @@ -185,7 +185,7 @@ }, onHide: function() { this.Status.isPageHidden = true; - ble.StopSearch(); + if (ble) ble.StopSearch(); }, onUnload() { @@ -205,7 +205,6 @@ } }, onLoad(option) { - debugger; eventChannel = this.getOpenerEventChannel(); eventChannel.on('detailData', function(rec) { @@ -351,11 +350,10 @@ // console.log("+++ 发现新设备,准备添加到列表:", JSON.stringify(device)); if (these.device && these.device.bluetoothName && device.name) { - if (these.device.bluetoothName === device.name || (device.name && device.name - .indexOf(these - .device.bluetoothName) > -1) || (device.name && this.device - .bluetoothName.indexOf( - device.name) > -1)) { + const bn = these.device.bluetoothName; + if (these.device.bluetoothName === device.name || + (device.name.indexOf(bn) > -1) || + (bn.indexOf(device.name) > -1)) { device.isTarget = true; } } @@ -635,7 +633,7 @@ if (f && f.macAddress) { - if (!this.device.deviceMac) { //走服务端验证 + if (!these.device || !these.device.deviceMac) { //走服务端验证 console.error("走服务端验证") request({ url: '/app/device/getDeviceInfoByDeviceMac', diff --git a/pages/common/index/index.vue b/pages/common/index/index.vue index 1fbee95..87e4aeb 100644 --- a/pages/common/index/index.vue +++ b/pages/common/index/index.vue @@ -252,12 +252,8 @@ activeTabInfo: '', dic: { showMsgTypes: ['BJQ6170', 'HBY210', 'HBY670', 'BJQ6075', 'BJQ6075J'], //需要发送消息的类型 - showMapTypes: ['BJQ6170', 'HBY210', 'HBY670', 'BJQ6075', 'HBY018A', 'HBY100-J', 'BJQ6075J', 'HBY008A', - 'HBY100-Y' - ], //需要显示地图的类型 - showCallPolice: ['BJQ6170', 'HBY210', 'HBY670', 'BJQ6075', 'HBY018A', 'HBY100-J', 'BJQ6075J', - 'HBY008A', 'HBY100-Y' - ] //需要发送报警的类型 + showMapTypes: ['BJQ6170', 'HBY210', 'HBY670', 'BJQ6075', 'HBY018A', 'HBY100-J', 'BJQ6075J', 'HBY008A','HBY100-Y'], //需要显示地图的类型 + showCallPolice: ['BJQ6170', 'HBY210', 'HBY670', 'BJQ6075', 'HBY018A', 'HBY100-J', 'BJQ6075J','HBY008A', 'HBY100-Y'] //需要发送报警的类型 }, isPageShow: true } diff --git a/static/images/common/battry.png b/static/images/common/battry.png new file mode 100644 index 0000000..7c812ac Binary files /dev/null and b/static/images/common/battry.png differ diff --git a/static/images/common/time.png b/static/images/common/time.png new file mode 100644 index 0000000..68a4826 Binary files /dev/null and b/static/images/common/time.png differ diff --git a/utils/BleHelper.js b/utils/BleHelper.js index 0634897..23f74fc 100644 --- a/utils/BleHelper.js +++ b/utils/BleHelper.js @@ -38,11 +38,10 @@ class BleHelper { if (linkedDevices && linkedDevices.length && linkedDevices.length > 0) { // console.log("111111", linkedDevices); linkedDevices = linkedDevices.filter((v) => { - if (v) { - v.Linked = false; - v.notifyState = false; - } - return v.device ? true : false; + if (!v) return false; + v.Linked = false; + v.notifyState = false; + return !!v.device; }); } diff --git a/utils/BleReceive.js b/utils/BleReceive.js index 17341ae..b6c1a87 100644 --- a/utils/BleReceive.js +++ b/utils/BleReceive.js @@ -50,6 +50,19 @@ class BleReceive { ReceiveData(receive, f, path, recArr) { + // 100J:首页等场景 LinkedList 项可能未带齐 mac/device,但语音分片上传依赖 parseBleData 消费 FB 05 + const sid = receive && receive.serviceId ? String(receive.serviceId) : ''; + const is100JAe30 = /ae30/i.test(sid); + const fReady = f && f.macAddress && f.device && f.device.id; + if (is100JAe30 && receive && receive.bytes && receive.bytes.length >= 3 && !fReady) { + try { + parseBleData(new Uint8Array(receive.bytes)); + } catch (e) { + console.warn('[100J] ReceiveData 兜底解析失败', e); + } + return receive; + } + if (f && f.macAddress && f.device && f.device.id) { let handler = null; let keys = Object.keys(this.HandlerMap); @@ -74,7 +87,10 @@ class BleReceive { } } else { - console.log("已收到该消息,但无法处理", receive, "f:", f); + // 100J AE30 二进制帧在 f 不完整时已在上方 parseBleData,此处不再误报「无法处理」 + if (!is100JAe30) { + console.log("已收到该消息,但无法处理", receive, "f:", f); + } } return receive; diff --git a/utils/Common.js b/utils/Common.js index 5d501b9..5c64780 100644 --- a/utils/Common.js +++ b/utils/Common.js @@ -445,7 +445,8 @@ export default { }); }, (error) => { - console.log('文件不存在/路径错误:', error.message); // 核心问题! + console.log('文件不存在/路径错误:', error.message); + reject(error || new Error('resolveLocalFileSystemURL 失败')); } ); });