diff --git a/api/100J/HBY100-J.js b/api/100J/HBY100-J.js index 6e7cbf9..47c47df 100644 --- a/api/100J/HBY100-J.js +++ b/api/100J/HBY100-J.js @@ -1,4 +1,5 @@ import request from '@/utils/request' +import Common from '@/utils/Common.js' // ================== 蓝牙协议封装类 ================== class HBY100JProtocol { @@ -205,45 +206,110 @@ class HBY100JProtocol { return reject(new Error('缺少文件地址或本地路径')); } const isLocalPath = !/^https?:\/\//i.test(fileUrlOrLocalPath); + if (onProgress) onProgress(1); const readFromPath = (path) => { + const doSend = (bytes) => { + this._sendVoiceChunks(bytes, fileType, CHUNK_SIZE, onProgress) + .then(resolve).catch(reject); + }; + // App 端 getFileSystemManager 未实现,直接用 plus.io.requestFileSystem+getFile + readFromPathPlus(path, doSend, reject); + }; + const readFileEntry = (entry, doSend, reject) => { + entry.file((file) => { + const reader = new plus.io.FileReader(); + reader.onloadend = (e) => { + try { + const buf = e.target.result; + const bytes = new Uint8Array(buf); + doSend(bytes); + } catch (err) { + console.error('[100J-蓝牙] 读取ArrayBuffer异常:', err); + reject(err); + } + }; + reader.onerror = () => reject(new Error('读取文件失败')); + reader.readAsArrayBuffer(file); + }, (err) => reject(err)); + }; + const readFromPathPlus = (path, doSend, reject) => { if (typeof plus === 'undefined' || !plus.io) { + console.error('[100J-蓝牙] 当前环境不支持文件读取(plus.io)'); reject(new Error('当前环境不支持文件读取')); return; } - plus.io.resolveLocalFileSystemURL(path, (entry) => { - entry.file((file) => { - const reader = new plus.io.FileReader(); - reader.onloadend = (e) => { - try { - const buf = e.target.result; - const bytes = new Uint8Array(buf); - this._sendVoiceChunks(bytes, fileType, CHUNK_SIZE, onProgress) - .then(resolve).catch(reject); - } catch (err) { - reject(err); - } - }; - reader.onerror = () => reject(new Error('读取文件失败')); - reader.readAsArrayBuffer(file); + // _downloads/ 用 requestFileSystem+getFile(避免 resolveLocalFileSystemURL 卡住) + 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)); - }, (err) => reject(err)); + return; + } + // _doc/ 用 requestFileSystem(PRIVATE_DOC),逐级 getDirectory 再 getFile(嵌套路径兼容) + 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)); + }; + next(0); + }, (err) => reject(err)); + return; + } + // 其他路径兜底 + 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)); }; if (isLocalPath) { // 本地路径:无网络时直接读取 readFromPath(fileUrlOrLocalPath); } else { - // 网络 URL:需下载后读取 - uni.downloadFile({ - url: fileUrlOrLocalPath, + // 网络 URL:优先用 uni.request 直接拉取 ArrayBuffer(类似 100 设备,无文件 IO),失败再走 downloadFile + let fetchUrl = fileUrlOrLocalPath; + if (fetchUrl.startsWith('http://')) fetchUrl = 'https://' + fetchUrl.slice(7); + uni.request({ + url: fetchUrl, + method: 'GET', + responseType: 'arraybuffer', + timeout: 60000, success: (res) => { - if (res.statusCode !== 200) { - reject(new Error('下载失败: ' + res.statusCode)); - return; + if (res.statusCode === 200 && res.data) { + const bytes = res.data instanceof ArrayBuffer ? new Uint8Array(res.data) : new Uint8Array(res.data || []); + if (bytes.length > 0) { + const doSend = (b) => { + this._sendVoiceChunks(b, fileType, CHUNK_SIZE, onProgress).then(resolve).catch(reject); + }; + doSend(bytes); + return; + } } - readFromPath(res.tempFilePath); + fallbackDownload(); }, - fail: (err) => reject(err) + fail: () => fallbackDownload() }); + const fallbackDownload = () => { + uni.downloadFile({ + url: fetchUrl, + success: (res) => { + if (res.statusCode !== 200 || !res.tempFilePath) { + reject(new Error('下载失败: ' + (res.statusCode || '无路径'))); + return; + } + Common.moveFileToDownloads(res.tempFilePath).then((p) => readFromPath(p)).catch(() => readFromPath(res.tempFilePath)); + }, + fail: (err) => reject(err) + }); + }; } }); } @@ -251,41 +317,49 @@ class HBY100JProtocol { _sendVoiceChunks(bytes, fileType, chunkSize, onProgress) { const total = bytes.length; const ft = (fileType & 0xFF) || 1; - const DELAY_AFTER_START = 80; // 开始包后、等设备响应后再发的缓冲(ms) - const DELAY_PACKET = 80; // 数据包间延时(ms),参考6155 + const DELAY_AFTER_START = 200; // 开始包后、等设备响应后再发的缓冲(ms) + const DELAY_PACKET = 200; // 数据包间延时(ms),设备收不全时适当加大 + 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(0); const bleToolPromise = import('@/utils/BleHelper.js').then(m => m.default.getBleTool()); - const send = (dataBytes) => { + const send = (dataBytes, label = '') => { const buf = new ArrayBuffer(dataBytes.length + 3); const v = new Uint8Array(buf); v[0] = 0xFA; v[1] = 0x05; for (let i = 0; i < dataBytes.length; i++) v[2 + i] = dataBytes[i]; v[v.length - 1] = 0xFF; + const hex = toHex(v); + const preview = v.length <= 32 ? hex : hex.slice(0, 96) + '...'; + console.log(`[100J-蓝牙] 发送${label} 共${v.length}字节:`, preview); return bleToolPromise.then(ble => ble.sendData(this.bleDeviceId, buf, this.SERVICE_UUID, this.WRITE_UUID)); }; 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 send(startData) - .then(() => waitPromise) - .then(() => delay(DELAY_AFTER_START)) + return send(startData, ' 开始包') + .then(() => { if (onProgress) onProgress(1); return waitPromise; }) + .then(() => { if (onProgress) onProgress(2); return delay(DELAY_AFTER_START); }) .then(() => { 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], ' 结束包')); } const chunk = bytes.slice(offset, Math.min(offset + chunkSize, total)); const chunkData = [ft, 1, seq & 0xFF, (seq >> 8) & 0xFF, ...chunk]; - return send(chunkData).then(() => { + return send(chunkData, ` #${seq} 数据包`).then(() => { seq++; - if (onProgress) onProgress(Math.min(100, Math.floor((offset + chunk.length) / total * 100))); + const pct = Math.round((offset + chunk.length) / total * 100); + if (onProgress) onProgress(Math.min(99, Math.max(3, pct))); return delay(DELAY_PACKET).then(() => sendNext(offset + chunk.length)); }); }; return sendNext(0); }) + .then(() => delay(DELAY_PACKET)) .then(() => { if (onProgress) onProgress(100); return { code: 200, msg: '语音文件已通过蓝牙上传' }; @@ -382,8 +456,8 @@ export function deviceDeleteAudioFile(params) { }) } -// 更新语音,使用语音(优先蓝牙:有 fileUrl 或 localPath 且蓝牙连接时通过蓝牙上传,否则走 4G) -// localPath:无网络时本地文件路径,可直接通过蓝牙发送 +// 更新语音/使用语音:蓝牙优先,4G 兜底(不影响原有 4G 音频下发) +// 有 fileUrl 或 localPath 且蓝牙可用时走蓝牙;否则或蓝牙失败时走 4G(与原先逻辑一致) export function deviceUpdateVoice(data) { const httpExec = () => request({ url: `/app/hby100j/device/updateVoice`, @@ -396,9 +470,9 @@ export function deviceUpdateVoice(data) { const hasFileUrl = fileUrl && typeof fileUrl === 'string' && fileUrl.length > 0; const fileSource = hasLocalPath ? localPath : (hasFileUrl ? fileUrl : null); if (!fileSource) { - return httpExec(); // 无文件源直接走 4G + return httpExec(); // 无文件源:直接 4G(原有逻辑) } - const bleExec = () => protocolInstance.uploadVoiceFileBle(fileSource, 1, data.onProgress); // fileType=1 语音 + const bleExec = () => protocolInstance.uploadVoiceFileBle(fileSource, 1, data.onProgress); return execWithBleFirst(bleExec, httpExec, '语音文件上传'); } // 100J信息 @@ -409,31 +483,15 @@ export function deviceDetail(id) { }) } -// 优先蓝牙:未连接时先尝试重连;蓝牙发送失败时回退4G +// 蓝牙优先、4G 兜底:未连接时尝试重连;蓝牙失败时回退 4G(保持原有 4G 通讯不变) function execWithBleFirst(bleExec, httpExec, logName) { - const doBle = () => { - return bleExec().then(res => ({ ...(res || {}), _channel: 'ble' })); - }; - const do4G = () => { - console.log('[100J-4G]', logName, '已通过HTTP发送', '(蓝牙不可用)'); - return httpExec().then(res => { res._channel = '4g'; return res; }); - }; + const doBle = () => bleExec().then(res => ({ ...(res || {}), _channel: 'ble' })); + const do4G = () => httpExec().then(res => { res._channel = '4g'; return res; }); if (protocolInstance.isBleConnected) { - console.log('[100J-蓝牙]', logName, '(连接正常)'); - return doBle().catch(err => { - console.log('[100J] 蓝牙发送失败,回退4G', err); - return do4G(); - }); + return doBle().catch(() => { console.log('[100J] 蓝牙失败,回退4G'); return do4G(); }); } return tryReconnectBle(2500).then(reconnected => { - if (reconnected) { - console.log('[100J-蓝牙]', logName, '(重连成功)'); - return doBle().catch(err => { - console.log('[100J] 蓝牙发送失败,回退4G', err); - return do4G(); - }); - } - return do4G(); + return reconnected ? doBle().catch(() => { console.log('[100J] 蓝牙失败,回退4G'); return do4G(); }) : do4G(); }); } diff --git a/pages/100J/audioManager/AudioList.vue b/pages/100J/audioManager/AudioList.vue index e744e95..7c9e716 100644 --- a/pages/100J/audioManager/AudioList.vue +++ b/pages/100J/audioManager/AudioList.vue @@ -106,6 +106,7 @@ deviceDeleteAudioFile, deviceUpdateVoice } from '@/api/100J/HBY100-J.js' + import { baseURL } from '@/utils/request.js' import { showLoading, hideLoading, @@ -237,7 +238,9 @@ if (!deviceId) return; const mergeLocal = (serverList) => { const key = `100J_local_audio_${deviceId}`; + const cacheKey = `100J_local_path_cache_${deviceId}`; const localList = uni.getStorageSync(key) || []; + const pathCache = uni.getStorageSync(cacheKey) || {}; const localMapped = localList.map(item => ({ ...item, fileNameExt: item.name || '本地语音', @@ -246,7 +249,12 @@ useStatus: 0, _isLocal: true })); - return [...localMapped, ...(serverList || [])]; + const enriched = (serverList || []).map(item => { + const urlKey = item.fileUrl || item.url || item.filePath || item.audioUrl || item.ossUrl; + const localPath = pathCache[urlKey] || pathCache[item.id]; + return localPath ? { ...item, localPath } : item; + }); + return [...localMapped, ...enriched]; }; deviceVoliceList({ deviceId }).then((res) => { if (res.code == 200) { @@ -486,10 +494,17 @@ Apply(item, index) { this.updateProgress = 0; this.isUpdating = true; + // 本地项优先用 localPath;云端项用 fileUrl(兼容多种字段名),相对路径补全 baseURL + let fileUrl = ''; + if (!item._isLocal) { + const raw = item.fileUrl || item.url || item.filePath || item.audioUrl || item.ossUrl || ''; + fileUrl = (typeof raw === 'string' && raw) ? (raw.startsWith('/') ? (baseURL + raw) : raw) : ''; + } + const localPath = (item.localPath && typeof item.localPath === 'string') ? item.localPath : ''; const data = { id: item.id, - fileUrl: item._isLocal ? '' : (item.fileUrl || item.url), - localPath: item._isLocal ? item.localPath : '', + fileUrl, + localPath, onProgress: (p) => { this.updateProgress = p; } }; // 整体超时 60 秒(仅影响蓝牙上传,4G HTTP 很快返回) @@ -502,11 +517,15 @@ }, 60000); deviceUpdateVoice(data).then((RES) => { clearTimeout(overallTimer); - console.log(RES, 'RES'); if (RES.code == 200) { + // 更新列表选中状态:当前项设为使用中,其他项取消 + const targetId = item.id || item.Id; + this.dataListA.forEach(it => { + it.useStatus = ((it.id || it.Id) === targetId) ? 1 : 0; + }); // 蓝牙上传:进度已由 onProgress 更新,直接完成 if (RES._channel === 'ble') { - uni.showToast({ title: '升级完成!', icon: 'success', duration: 2000 }); + uni.showToast({ title: '音频上传成功', icon: 'success', duration: 2000 }); this.isUpdating = false; setTimeout(() => { uni.navigateBack(); }, 1500); return; @@ -514,7 +533,7 @@ // 4G:订阅 MQTT 获取设备端进度,6 秒超时 this.upgradeTimer = setTimeout(() => { if (this.isUpdating) { - uni.showToast({ title: '升级进度同步超时', icon: 'none', duration: 2000 }); + uni.showToast({ title: '音频进度同步超时', icon: 'none', duration: 2000 }); this.isUpdating = false; this.updateProgress = 0; } @@ -530,7 +549,12 @@ this.updateProgress = progress; if (progress === 100) { clearTimeout(this.upgradeTimer); - uni.showToast({ title: '升级完成!', icon: 'success', duration: 2000 }); + // 更新列表选中状态(4G 成功时) + const targetId = item.id || item.Id; + this.dataListA.forEach(it => { + it.useStatus = ((it.id || it.Id) === targetId) ? 1 : 0; + }); + uni.showToast({ title: '音频上传成功', icon: 'success', duration: 2000 }); this.isUpdating = false; setTimeout(() => { uni.navigateBack(); }, 1500); } diff --git a/pages/100J/audioManager/Recording.vue b/pages/100J/audioManager/Recording.vue index 02efba2..764d71b 100644 --- a/pages/100J/audioManager/Recording.vue +++ b/pages/100J/audioManager/Recording.vue @@ -422,25 +422,42 @@ }, 1200); }, // 无网络时保存到本地,供蓝牙直接发送(不依赖 OSS) + // 将临时文件复制到持久化目录 _doc/100J_audio/,避免被系统清理 saveLocalForBle(filePath) { const deviceId = these.Status.ID; if (!deviceId) return; - const item = { - ...these.cEdit, - localPath: filePath, - fileUrl: '', - deviceId, - id: 'local_' + these.cEdit.Id, - _createTime: these.cEdit.createTime || Common.DateFormat(new Date(), "yyyy年MM月dd日"), - _isLocal: true + 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(); }; - 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() { @@ -507,6 +524,19 @@ } const resData = JSON.parse(res.data); if (resData.code === 200) { + // 缓存本地路径,Apply 时优先用本地文件走蓝牙,避免下载失败 + const deviceId = these.Status.ID; + if (deviceId) { + const cacheKey = `100J_local_path_cache_${deviceId}`; + const d = resData.data; + const fileUrl = (d && typeof d === 'object' && d.fileUrl) || (typeof d === 'string' ? d : ''); + if (filePath) { + let cache = uni.getStorageSync(cacheKey) || {}; + if (fileUrl) cache[fileUrl] = filePath; + if (d && typeof d === 'object' && d.id) cache[d.id] = filePath; + uni.setStorageSync(cacheKey, cache); + } + } // 合并两个存储操作 Promise.all([ new Promise((resolve, reject) => {