From 6715384b0ad31b627f1fbef4776f420954d68c0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=AE=E5=BE=AE=E4=B8=80=E7=AC=91?= <709648985@qq.com> Date: Wed, 25 Mar 2026 10:08:28 +0800 Subject: [PATCH 01/11] =?UTF-8?q?100J=E4=BF=AE=E5=A4=8D=E8=93=9D=E7=89=99?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/100J/HBY100-J.js | 72 ++++++++++++++++++++++++++++++++------------ 1 file changed, 52 insertions(+), 20 deletions(-) diff --git a/api/100J/HBY100-J.js b/api/100J/HBY100-J.js index bae7095..266bb90 100644 --- a/api/100J/HBY100-J.js +++ b/api/100J/HBY100-J.js @@ -429,8 +429,27 @@ 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; + +/** 系统蓝牙开关是否开启(与手机设置一致,避免关机蓝牙仍白等 12s) */ +function getBleAdapterAvailable() { + return new Promise((resolve) => { + if (typeof uni.getBluetoothAdapterState !== 'function') { + resolve(true); + return; + } + uni.getBluetoothAdapterState({ + success: (res) => resolve(!!res.available), + 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 +466,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) => { @@ -565,28 +584,41 @@ function execWithBleFirst(bleExec, httpExec, logName, onWaiting) { return do4G(); }); } - // 无 bleDeviceId:页面可能在扫描,最多等 12s(否则用户以为点了没反应) + // 无 bleDeviceId:系统蓝牙关闭则立即 4G;开启则短时等页面扫描连上(不再白等 12s) if (!protocolInstance.bleDeviceId) { - if (typeof onWaiting === 'function') onWaiting(); - else showWaitUi('蓝牙连接中…'); - return waitForBleConnection(12000) - .then(connected => { - return connected ? doBle().catch(() => { + return getBleAdapterAvailable().then((adapterOk) => { + if (!adapterOk) { + console.log('[100J]', logName || '指令', '系统蓝牙未开启,走4G'); + return do4G(); + } + if (typeof onWaiting === 'function') onWaiting(); + else showWaitUi('蓝牙连接中…'); + return waitForBleConnection() + .then((connected) => { + return connected ? doBle().catch(() => { + console.log('[100J]', logName || '指令', '蓝牙失败,回退4G'); + return do4G(); + }) : do4G(); + }) + .finally(hideWaitUi); + }); + } + // 有 bleDeviceId 但未连:系统蓝牙关则直接 4G,否则短时重连 + return getBleAdapterAvailable().then((adapterOk) => { + if (!adapterOk) { + console.log('[100J]', logName || '指令', '系统蓝牙未开启,走4G'); + return do4G(); + } + if (typeof onWaiting !== 'function') showWaitUi('正在重连蓝牙…'); + return tryReconnectBle() + .then((reconnected) => { + return reconnected ? doBle().catch(() => { console.log('[100J]', logName || '指令', '蓝牙失败,回退4G'); return do4G(); }) : do4G(); }) .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); + }); } // 爆闪模式 From a18b2b81e8cc6485ee3d181d761bfa2991f3f22d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=AE=E5=BE=AE=E4=B8=80=E7=AC=91?= <709648985@qq.com> Date: Thu, 26 Mar 2026 15:12:16 +0800 Subject: [PATCH 02/11] =?UTF-8?q?100J=E4=BF=AE=E5=A4=8D=E4=BD=BF=E7=94=A84?= =?UTF-8?q?G=E8=BF=98=E6=8F=90=E7=A4=BA=E8=93=9D=E7=89=99=E7=9A=84?= =?UTF-8?q?=E6=96=87=E6=A1=88=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/100J/HBY100-J.js | 26 ++++++++++++++++++++++---- pages/100J/audioManager/AudioList.vue | 3 ++- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/api/100J/HBY100-J.js b/api/100J/HBY100-J.js index 266bb90..de32841 100644 --- a/api/100J/HBY100-J.js +++ b/api/100J/HBY100-J.js @@ -434,7 +434,10 @@ const WAIT_BLE_CONNECTED_MS = 5000; const BLE_POLL_INTERVAL_MS = 400; const BLE_RECONNECT_MS = 2000; -/** 系统蓝牙开关是否开启(与手机设置一致,避免关机蓝牙仍白等 12s) */ +/** + * 系统蓝牙是否可用。fail/available=false 时立即走 4G。 + * 若系统报开启,再与 BleHelper.data.available 交叉校验(关蓝牙后助手往往先变为 false,避免仍弹「连蓝牙」类误导)。 + */ function getBleAdapterAvailable() { return new Promise((resolve) => { if (typeof uni.getBluetoothAdapterState !== 'function') { @@ -442,7 +445,22 @@ function getBleAdapterAvailable() { return; } uni.getBluetoothAdapterState({ - success: (res) => resolve(!!res.available), + 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) }); }); @@ -592,7 +610,7 @@ function execWithBleFirst(bleExec, httpExec, logName, onWaiting) { return do4G(); } if (typeof onWaiting === 'function') onWaiting(); - else showWaitUi('蓝牙连接中…'); + else showWaitUi('请稍候…'); return waitForBleConnection() .then((connected) => { return connected ? doBle().catch(() => { @@ -609,7 +627,7 @@ function execWithBleFirst(bleExec, httpExec, logName, onWaiting) { console.log('[100J]', logName || '指令', '系统蓝牙未开启,走4G'); return do4G(); } - if (typeof onWaiting !== 'function') showWaitUi('正在重连蓝牙…'); + if (typeof onWaiting !== 'function') showWaitUi('请稍候…'); return tryReconnectBle() .then((reconnected) => { return reconnected ? doBle().catch(() => { diff --git a/pages/100J/audioManager/AudioList.vue b/pages/100J/audioManager/AudioList.vue index 32efd97..3136d07 100644 --- a/pages/100J/audioManager/AudioList.vue +++ b/pages/100J/audioManager/AudioList.vue @@ -514,7 +514,8 @@ fileUrl, localPath, onProgress: (p) => { this.updateProgress = p; }, - onWaiting: () => { uni.showToast({ title: '等待蓝牙连接中...', icon: 'none', duration: 2000 }); } + // 不传「蓝牙连接中」类提示:关蓝牙走 4G 时易误导;进度条 + 必要时全局请稍候即可 + onWaiting: () => {} }; // 整体超时 60 秒(仅影响蓝牙上传,4G HTTP 很快返回) const overallTimer = setTimeout(() => { From e7b40dbed662bc20f0f40b5a8cf013358a3d8ef9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=AE=E5=BE=AE=E4=B8=80=E7=AC=91?= <709648985@qq.com> Date: Thu, 26 Mar 2026 15:39:50 +0800 Subject: [PATCH 03/11] =?UTF-8?q?=E4=BF=AE=E5=A4=8D100J?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/100J/HBY100-J.js | 14 +++++- pages/100J/HBY100-J.vue | 62 ++++++++++++++++++++++----- pages/100J/audioManager/AudioList.vue | 30 +++++++++++-- 3 files changed, 90 insertions(+), 16 deletions(-) diff --git a/api/100J/HBY100-J.js b/api/100J/HBY100-J.js index de32841..06dec71 100644 --- a/api/100J/HBY100-J.js +++ b/api/100J/HBY100-J.js @@ -684,10 +684,20 @@ export function deviceUpdateVolume(data) { ); } -// 语音播放 +/** 蓝牙侧语音播报:自定义音(mode=7)部分固件需先 0x0C 带模式再 0x06,否则无声音 */ +function bleVoiceBroadcastChain(data) { + const on = Number(data.voiceBroadcast) === 1; + const mode = data.mode != null ? String(data.mode) : ''; + if (on && mode === '7') { + return protocolInstance.setForceAlarm(0, 7).then(() => protocolInstance.setVoiceBroadcast(1)); + } + return protocolInstance.setVoiceBroadcast(on ? 1 : 0); +} + +// 语音播放(HTTP 透传 data,便于后端识别 mode) export function deviceVoiceBroadcast(data) { return execWithBleFirst( - () => protocolInstance.setVoiceBroadcast(data.voiceBroadcast).then(() => ({ code: 200, msg: '操作成功(蓝牙)' })), + () => bleVoiceBroadcastChain(data).then(() => ({ code: 200, msg: '操作成功(蓝牙)' })), () => request({ url: `/app/hby100j/device/voiceBroadcast`, method: 'post', data }), '语音播报' ); diff --git a/pages/100J/HBY100-J.vue b/pages/100J/HBY100-J.vue index 97bac15..dc2514b 100644 --- a/pages/100J/HBY100-J.vue +++ b/pages/100J/HBY100-J.vue @@ -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; @@ -656,6 +674,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; @@ -1035,11 +1057,11 @@ } }) } 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 + }; deviceVoiceBroadcast(data).then((res) => { if (res.code == 200) { uni.showToast({ @@ -1052,7 +1074,25 @@ icon: 'none' }); } - }) + }).catch(() => { + uni.showToast({ title: '下发失败,请检查蓝牙或网络', icon: 'none' }); + }); + } else { + // 非强制报警态下选择内置音色:原先仅改 UI 未下发,设备无播报 + const data = { + deviceIds: [this.deviceInfo.deviceId], + voiceStrobeAlarm: 0, + mode: val + }; + deviceForceAlarmActivation(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' }); + }); } }, // 报警模式 diff --git a/pages/100J/audioManager/AudioList.vue b/pages/100J/audioManager/AudioList.vue index 3136d07..4a5dd4e 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) ? '使用中' : '使用' }} @@ -266,7 +266,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)); @@ -496,6 +497,27 @@ 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; @@ -530,6 +552,7 @@ if (RES.code == 200) { // 蓝牙上传:进度已由 onProgress 更新,直接完成 if (RES._channel === 'ble') { + this.syncVoiceListUseStatus(item); uni.showToast({ title: '音频上传成功', icon: 'success', duration: 2000 }); this.isUpdating = false; setTimeout(() => { uni.navigateBack(); }, 1500); @@ -554,6 +577,7 @@ this.updateProgress = progress; if (progress === 100) { clearTimeout(this.upgradeTimer); + this.syncVoiceListUseStatus(item); uni.showToast({ title: '音频上传成功', icon: 'success', duration: 2000 }); this.isUpdating = false; setTimeout(() => { uni.navigateBack(); }, 1500); From b99ac04c8813418453af41d13801568572d386d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=AE=E5=BE=AE=E4=B8=80=E7=AC=91?= <709648985@qq.com> Date: Thu, 26 Mar 2026 19:20:52 +0800 Subject: [PATCH 04/11] =?UTF-8?q?=E4=BC=98=E5=8C=96100j?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/100J/HBY100-J.js | 639 +++++++++++++++++++++++--- pages/100J/audioManager/AudioList.vue | 29 +- pages/100J/audioManager/Recording.vue | 144 +++++- pages/common/addBLE/addEquip.vue | 14 +- pages/common/index/index.vue | 9 +- utils/BleHelper.js | 9 +- utils/BleReceive.js | 18 +- utils/Common.js | 3 +- 8 files changed, 745 insertions(+), 120 deletions(-) diff --git a/api/100J/HBY100-J.js b/api/100J/HBY100-J.js index 06dec71..7b09bb7 100644 --- a/api/100J/HBY100-J.js +++ b/api/100J/HBY100-J.js @@ -1,6 +1,22 @@ 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; + } +} + // ================== 蓝牙协议封装类 ================== class HBY100JProtocol { constructor() { @@ -35,9 +51,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 = ''; } } @@ -205,24 +224,66 @@ class HBY100JProtocol { // phase: 0=开始 1=数据 2=结束 // 每包最大字节 蓝牙:CHUNK_SIZE=500 // 支持 fileUrl(需网络下载) 或 localPath(无网络时本地文件) - uploadVoiceFileBle(fileUrlOrLocalPath, fileType = 1, onProgress) { + // 说明:下发的是已录制/已落盘的 MP3(等)二进制分片,经 GATT 写特征;非 A2DP/HFP 等「蓝牙录音实时流」 + // meta.voiceListId:若存在 uni 中 100J_voice_b64_* 缓存(与 HBY100 存 Storage 同理),优先用缓存字节下发 + uploadVoiceFileBle(fileUrlOrLocalPath, fileType = 1, onProgress, meta = null) { const CHUNK_SIZE = 500; // 每包有效数据,参考 6155 deviceDetail.vue + 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; + } + } + 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 +303,238 @@ 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) => { + try { + // 仅非 App(如微信小程序)走 uni FSM。App-Plus 上禁止此处调用 getFileSystemManager: + // 否则会刷「not yet implemented」,且 readFile 的 success/fail 可能永不回调 → 永远进不了 plus.io 兜底,表现为「读不了本地文件」。 + const fsm = tryGetUniFileSystemManager(); + if (!fsm || typeof fsm.readFile !== 'function') { + onFail(); + return; + } + fsm.readFile({ + filePath: path, + success: (res) => { + try { + const raw = res && res.data; + let bytes = null; + if (raw instanceof ArrayBuffer) bytes = new Uint8Array(raw); + else if (raw instanceof Uint8Array) bytes = raw; + else if (raw && ArrayBuffer.isView(raw) && raw.buffer instanceof ArrayBuffer) { + bytes = new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength); + } + if (bytes && bytes.length > 0) { + console.log('[100J-蓝牙] readFile 已读出本地语音,字节:', bytes.length); + doSend(bytes); + return; + } + } catch (e) {} + onFail(); + }, + fail: () => onFail() + }); + } catch (e) { + onFail(); + } + }; + // _downloads/:与 Common.moveFileToDownloads 一致,文件在 PUBLIC_DOWNLOADS 根目录。 + // 优先 requestFileSystem+getFile(老版本注释:resolveLocalFileSystemURL 易卡住);resolve 仅作兜底。 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 读取开始, fileName=', fileName); + let finished = false; + const outerMs = 20000; + const outerT = setTimeout(() => { + if (finished) return; + finished = true; + reject(new Error('读取下载目录语音超时,请重新保存录音后重试')); + }, outerMs); + const finishOk = (bytes) => { + if (finished) return; + finished = true; + clearTimeout(outerT); + clearTimeout(reqFsGuardT); + doSend(bytes); + }; + const finishErr = (err) => { + if (finished) return; + finished = true; + clearTimeout(outerT); + clearTimeout(reqFsGuardT); + reject(err || new Error('读取文件失败')); + }; + let fallbackStarted = false; + const tryResolveUrl = () => { + if (finished || fallbackStarted) return; + fallbackStarted = true; + clearTimeout(reqFsGuardT); + console.log('[100J-蓝牙] _downloads 改用 resolveLocalFileSystemURL 兜底'); + 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) => { + readFileEntry(entry, finishOk, () => { + finishErr(new Error('_downloads 文件不存在或无法读取')); + }); + }, () => finishErr(new Error('_downloads 路径解析失败'))); + }; + const reqFsGuardMs = 6000; + const reqFsGuardT = setTimeout(() => { + if (finished || fallbackStarted) return; + console.log('[100J-蓝牙] _downloads requestFileSystem 超时,尝试 resolve 兜底'); + tryResolveUrl(); + }, reqFsGuardMs); + plus.io.requestFileSystem(plus.io.PUBLIC_DOWNLOADS, (fs) => { + if (finished || fallbackStarted) return; + fs.root.getFile(fileName, {}, (entry) => { + if (finished || fallbackStarted) return; + readFileEntry(entry, finishOk, (err) => { + if (finished) return; + clearTimeout(reqFsGuardT); + console.log('[100J-蓝牙] getFile 后读失败,尝试 resolve', err && err.message); + tryResolveUrl(); + }); + }, (err) => { + if (finished) return; + clearTimeout(reqFsGuardT); + console.log('[100J-蓝牙] getFile 失败,尝试 resolve', err && err.message); + tryResolveUrl(); + }); + }, (err) => { + if (finished) return; + clearTimeout(reqFsGuardT); + console.log('[100J-蓝牙] requestFileSystem 失败,尝试 resolve', err && err.message); + tryResolveUrl(); + }); + }); return; } - // _doc/ 用 requestFileSystem(PRIVATE_DOC),逐级 getDirectory 再 getFile(嵌套路径兼容) + // _doc/:与 _downloads 同理,App 只走 plus;并加 resolve 优先 + 超时,避免 requestFileSystem 链卡死 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; + } + let finished = false; + const outerMs = 20000; + const outerT = setTimeout(() => { + if (finished) return; + finished = true; + reject(new Error('读取本地语音超时(文档目录),请重新保存录音后重试')); + }, outerMs); + const finishOk = (bytes) => { + if (finished) return; + finished = true; + clearTimeout(outerT); + doSend(bytes); }; - next(0); - }, (err) => reject(err)); + const finishErr = (err) => { + if (finished) return; + finished = true; + clearTimeout(outerT); + reject(err || new Error('读取文件失败')); + }; + const tryWalkFs = () => { + 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, finishOk, finishErr), (err) => finishErr(err)); + return; + } + cur.getDirectory(dirs[i], { create: false }, (dir) => { cur = dir; next(i + 1); }, (err) => finishErr(err)); + }; + next(0); + }, (err) => finishErr(err)); + }; + let resolvePhaseDone = false; + const resolveT = setTimeout(() => { + if (resolvePhaseDone) return; + resolvePhaseDone = true; + console.log('[100J-蓝牙] _doc resolve 超时,改用 PRIVATE_DOC 逐级目录'); + tryWalkFs(); + }, 6000); + 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 (resolvePhaseDone) return; + resolvePhaseDone = true; + clearTimeout(resolveT); + readFileEntry(entry, finishOk, (err) => { + console.log('[100J-蓝牙] _doc resolve 后读失败,改用逐级目录', err && err.message); + tryWalkFs(); + }); + }, () => { + if (resolvePhaseDone) return; + resolvePhaseDone = true; + clearTimeout(resolveT); + tryWalkFs(); + }); + }); 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 +575,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 +587,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('无法检测网络,请连接网络后重试')); + } + }); } }); } @@ -362,11 +641,13 @@ 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, ' 开始包') + // 单包最大约 500+ 协议头尾,需 MTU>~520;BleHelper 里 setMtu 为异步且未与首写串联,易在未协商完就 write 导致长时间无回调→界面超时 + const waitPromise = this.waitForFileResponse(2500); + const prepMtuThenSend = (ble) => { + const run = () => { + 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); }) .then(() => { @@ -389,7 +670,18 @@ class HBY100JProtocol { if (onProgress) onProgress(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(() => {}) + .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 +702,191 @@ 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) => { + const done = () => resolve(); + if (!deviceId || voiceListId == null || !filePath) { + done(); + return; + } + const tryFs = () => { + const fsm = tryGetUniFileSystemManager(); + if (!fsm || typeof fsm.readFile !== 'function') return false; + fsm.readFile({ + filePath, + success: (res) => { + try { + const raw = res && res.data; + let bytes = null; + if (raw instanceof ArrayBuffer) bytes = new Uint8Array(raw); + else if (raw instanceof Uint8Array) bytes = raw; + else if (raw && ArrayBuffer.isView(raw) && raw.buffer instanceof ArrayBuffer) { + bytes = new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength); + } + if (bytes && bytes.length) put100JVoiceBleCache(deviceId, voiceListId, bytes); + } catch (e) {} + done(); + }, + fail: () => done() + }); + return true; + }; + if (tryFs()) return; + if (typeof plus === 'undefined' || !plus.io) { + done(); + return; + } + const readEntry = (entry, cb) => { + entry.file((file) => { + const reader = new plus.io.FileReader(); + reader.onloadend = (e) => { + try { + const buf = e.target.result; + if (buf && buf.byteLength) put100JVoiceBleCache(deviceId, voiceListId, new Uint8Array(buf)); + } catch (err) {} + cb(); + }; + reader.onerror = () => cb(); + reader.readAsArrayBuffer(file); + }, () => cb()); + }; + if (filePath.startsWith('_downloads/')) { + const name = filePath.replace(/^_downloads\//, ''); + plus.io.requestFileSystem(plus.io.PUBLIC_DOWNLOADS, (fs) => { + fs.root.getFile(name, {}, (entry) => readEntry(entry, done), () => done()); + }, () => done()); + return; + } + if (filePath.startsWith('_doc/')) { + const rel = filePath.replace(/^_doc\//, ''); + const parts = rel.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) => readEntry(entry, done), () => done()); + return; + } + cur.getDirectory(dirs[i], { create: false }, (dir) => { cur = dir; next(i + 1); }, () => done()); + }; + next(0); + }, () => done()); + return; + } + let resolvePath = filePath; + try { + if (plus.io.convertLocalFileSystemURL && !/^(?:_doc\/|_downloads\/|https?:)/i.test(filePath)) { + const c = plus.io.convertLocalFileSystemURL(filePath); + if (c) resolvePath = c; + } + } catch (e) {} + if (resolvePath.startsWith('/') && !resolvePath.startsWith('file://')) resolvePath = 'file://' + resolvePath; + plus.io.resolveLocalFileSystemURL(resolvePath, (entry) => readEntry(entry, done), () => done()); + }); +} + // 暴露给页面:解析蓝牙接收到的数据 export function parseBleData(buffer) { return protocolInstance.parseBleData(buffer); @@ -549,26 +1026,38 @@ 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())); +} + export function deviceUpdateVoice(data) { const httpExec = () => request({ url: `/app/hby100j/device/updateVoice`, method: 'post', data: { id: data.id } }); - 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().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 }); + const bleExec = () => protocolInstance.uploadVoiceFileBle(fileSource, 1, data.onProgress, { voiceListId: data.id }); + // 本地文件:禁止一切 4G 兜底(含蓝牙未开时),避免仅传 id 假成功 + return execWithBleFirst(bleExec, httpExec, '语音文件上传', data.onWaiting, { no4GFallback: hasLocalPath }); } // 100J信息 export function deviceDetail(id) { @@ -579,9 +1068,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 { @@ -597,26 +1107,21 @@ 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) { - console.log('[100J]', logName || '指令', '系统蓝牙未开启,走4G'); - return do4G(); + return go4GOrReject('系统蓝牙未开启,走4G'); } if (typeof onWaiting === 'function') onWaiting(); else showWaitUi('请稍候…'); return waitForBleConnection() .then((connected) => { - return connected ? doBle().catch(() => { - console.log('[100J]', logName || '指令', '蓝牙失败,回退4G'); - return do4G(); - }) : do4G(); + return connected ? doBle().catch(onBleSendFail) : go4GOrReject('蓝牙未连接'); }) .finally(hideWaitUi); }); @@ -624,16 +1129,12 @@ function execWithBleFirst(bleExec, httpExec, logName, onWaiting) { // 有 bleDeviceId 但未连:系统蓝牙关则直接 4G,否则短时重连 return getBleAdapterAvailable().then((adapterOk) => { if (!adapterOk) { - console.log('[100J]', logName || '指令', '系统蓝牙未开启,走4G'); - return do4G(); + return go4GOrReject('系统蓝牙未开启,走4G'); } if (typeof onWaiting !== 'function') showWaitUi('请稍候…'); return tryReconnectBle() .then((reconnected) => { - return reconnected ? doBle().catch(() => { - console.log('[100J]', logName || '指令', '蓝牙失败,回退4G'); - return do4G(); - }) : do4G(); + return reconnected ? doBle().catch(onBleSendFail) : go4GOrReject('蓝牙未连接'); }) .finally(hideWaitUi); }); diff --git a/pages/100J/audioManager/AudioList.vue b/pages/100J/audioManager/AudioList.vue index 4a5dd4e..3ee7e84 100644 --- a/pages/100J/audioManager/AudioList.vue +++ b/pages/100J/audioManager/AudioList.vue @@ -105,7 +105,9 @@ videRenameAudioFile, deviceDeleteAudioFile, deviceUpdateVoice, - updateBleStatus + updateBleStatus, + sync100JBleProtocolFromHelper, + remove100JVoiceBleCache } from '@/api/100J/HBY100-J.js' import { baseURL } from '@/utils/request.js' import { @@ -431,7 +433,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); @@ -528,12 +533,16 @@ 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; }, // 不传「蓝牙连接中」类提示:关蓝牙走 4G 时易误导;进度条 + 必要时全局请稍候即可 @@ -546,8 +555,9 @@ this.isUpdating = false; this.updateProgress = 0; } - }, 60000); - deviceUpdateVoice(data).then((RES) => { + }, 120000); // 蓝牙分片+MTU 协商+大包写入较慢,60s 易误报「操作超时」 + // 进入列表时的蓝牙快照可能过期;与 HBY100 详情页一致,从 BleHelper 按 MAC 再对齐一次,否则常静默走 4G、看不到 [100J-蓝牙] 分片日志 + sync100JBleProtocolFromHelper(this.device).then(() => deviceUpdateVoice(data)).then((RES) => { clearTimeout(overallTimer); if (RES.code == 200) { // 蓝牙上传:进度已由 onProgress 更新,直接完成 @@ -596,7 +606,8 @@ }).catch((err) => { clearTimeout(overallTimer); this.isUpdating = false; - uni.showToast({ title: err.message || '操作失败', icon: 'none', duration: 2000 }); + this.updateProgress = 0; + uni.showToast({ title: err.message || '操作失败', icon: 'none', duration: 2500 }); }); }, closePop: function() { diff --git a/pages/100J/audioManager/Recording.vue b/pages/100J/audioManager/Recording.vue index 764d71b..3850f39 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() { @@ -422,10 +423,19 @@ }, 1200); }, // 无网络时保存到本地,供蓝牙直接发送(不依赖 OSS) - // 将临时文件复制到持久化目录 _doc/100J_audio/,避免被系统清理 + // 当前部分机型 PUBLIC_DOWNLOADS 在读取阶段会出现 requestFileSystem/resolve 长时间无回调, + // 因此本地语音优先落到 _doc/100J_audio,后续蓝牙下发走 PRIVATE_DOC 分支更稳定。 saveLocalForBle(filePath) { const deviceId = these.Status.ID; - if (!deviceId) return; + if (!deviceId) { + uni.showToast({ title: '缺少设备信息,请从语音列表进入录音页', icon: 'none', duration: 2500 }); + return; + } + const warmCacheNow = (srcPath) => { + if (!srcPath) return; + // 像 HBY100 一样优先写 uni 存储:后续「使用」先读缓存,尽量不再依赖当场读文件 + cache100JVoiceFileForBle(deviceId, 'local_' + these.cEdit.Id, srcPath); + }; const doSave = (persistentPath) => { const item = { ...these.cEdit, @@ -442,22 +452,86 @@ uni.setStorageSync(key, list); these.AudioData.tempFilePath = ""; these.Status.isRecord = false; + // 再用持久路径补写一遍缓存(若前面已成功则覆盖同 key) + cache100JVoiceFileForBle(deviceId, item.id, persistentPath); uni.navigateBack(); }; - if (typeof plus !== 'undefined' && plus.io) { + const toPlusUrl = (p) => { + if (!p) return p; + try { + if (plus.io.convertLocalFileSystemURL) { + const c = plus.io.convertLocalFileSystemURL(p); + if (c) return c; + } + } catch (e) {} + return p; + }; + const copyToDocByReadWrite = (srcPath, fileName, onOk, onFail) => { + plus.io.resolveLocalFileSystemURL(toPlusUrl(srcPath), (srcEntry) => { + srcEntry.file((file) => { + const reader = new plus.io.FileReader(); + reader.onloadend = (e) => { + const buf = e.target.result; + if (!buf || !(buf.byteLength > 0)) { + onFail(new Error('读取录音文件为空')); + return; + } + plus.io.requestFileSystem(plus.io.PRIVATE_DOC, (fs) => { + fs.root.getDirectory('100J_audio', { create: true }, (dirEntry) => { + dirEntry.getFile(fileName, { create: true }, (newEntry) => { + newEntry.createWriter((writer) => { + writer.onwrite = () => onOk(); + writer.onerror = (ex) => onFail(ex || new Error('写入失败')); + try { + const blob = new Blob([buf], { type: 'application/octet-stream' }); + writer.write(blob); + } catch (wex) { + writer.write(buf); + } + }, onFail); + }, onFail); + }, onFail); + }, onFail); + }; + reader.onerror = () => onFail(new Error('读取录音失败')); + reader.readAsArrayBuffer(file); + }, onFail); + }, onFail); + }; + const fallbackDocSubdir = () => { + if (typeof plus === 'undefined' || !plus.io) { + doSave(filePath); + return; + } const fileName = 'audio_' + (these.cEdit.Id || Date.now()) + '.mp3'; - plus.io.resolveLocalFileSystemURL(filePath, (entry) => { + const docRel = '_doc/100J_audio/' + fileName; + const tryRwFallback = (reason) => { + console.warn('[100J] saveLocalForBle 复制失败,尝试读写落盘:', reason || ''); + copyToDocByReadWrite(filePath, fileName, () => doSave(docRel), () => doSave(filePath)); + }; + plus.io.resolveLocalFileSystemURL(toPlusUrl(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 { + entry.copyTo(dirEntry, fileName, () => { + doSave(docRel); + }, () => { tryRwFallback('copyTo'); }); + }, () => { tryRwFallback('getDirectory'); }); + }, () => { tryRwFallback('resolve _doc'); }); + }, () => { tryRwFallback('resolve src'); }); + }; + if (typeof plus === 'undefined' || !plus.io) { doSave(filePath); + return; } + const rawPath = (filePath && String(filePath).trim()) || ''; + // 保存流程开始时即尝试缓存一次(通常是 _doc/uniapp_temp 源路径) + warmCacheNow(rawPath); + // 已是 _doc 路径时直接保存;其余路径统一转存到 _doc/100J_audio + if (rawPath.indexOf('_doc/') === 0) { + doSave(rawPath); + return; + } + fallbackDocSubdir(); }, // 保存录音并上传(已修复文件格式问题) uploadLuYin() { @@ -478,19 +552,40 @@ console.log("自动添加.mp3扩展名,新路径:", uploadFilePath); } - console.log("上传文件路径:", uploadFilePath); - plus.io.resolveLocalFileSystemURL(uploadFilePath, (entry) => { - entry.getMetadata((metadata) => { - console.log("文件大小:", metadata.size, "字节"); - console.log("文件类型验证通过"); - this.doUpload(uploadFilePath); + const startOssUpload = () => { + console.log("上传文件路径:", uploadFilePath); + 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); + }; + + // 无网络时不调 OSS,直接落本地列表,避免 uploadFile 白失败;列表里点「使用」走蓝牙下发 + uni.getNetworkType({ + success: (res) => { + if (res.networkType === 'none') { + this.saveLocalForBle(uploadFilePath); + uni.showToast({ + title: '无网络,已保存到本地。请在列表连接蓝牙后点「使用」下发;有网后可再保存上传云端', + icon: 'none', + duration: 4000 + }); + return; + } + startOssUpload(); + }, + fail: () => { + startOssUpload(); + } }); }, @@ -535,6 +630,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); + } } } // 合并两个存储操作 diff --git a/pages/common/addBLE/addEquip.vue b/pages/common/addBLE/addEquip.vue index ac06bba..3635f4c 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; } } @@ -629,7 +627,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 a734c0d..8b06b19 100644 --- a/pages/common/index/index.vue +++ b/pages/common/index/index.vue @@ -300,7 +300,7 @@ }, bleStateRecovery() { console.log("蓝牙适配器恢复可用,重连断开的设备"); - ble.linkAllDevices(); + if (ble && ble.linkAllDevices) ble.linkAllDevices(); }, bleBreak(res) { @@ -327,7 +327,7 @@ let f = null; if (ble.data && ble.data.LinkedList) { f = ble.data.LinkedList.find(v => { - + if (!v) return false; if (v.macAddress && v.device && v.device.id) { return v.device.id == this.deviceList[i].id; } @@ -749,6 +749,9 @@ onLoad() { // console.error("首页加载"); + // 必须先初始化 ble:getTab/downCallback 会触发 updateBleStatu,否则会访问 null + ble = bleTool.getBleTool(); + recei = BleReceive.getBleReceive(); this.getTab() this.downCallback(); @@ -763,8 +766,6 @@ console.log('列表收到消息了么'); this.downCallback(); }); - ble = bleTool.getBleTool(); - recei = BleReceive.getBleReceive(); //蓝牙连接成功的回调 ble.addRecoveryCallback((res) => { // console.log("蓝牙连接成功的回调"); diff --git a/utils/BleHelper.js b/utils/BleHelper.js index f700c1c..f8ed745 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 4793bb0..4a62373 100644 --- a/utils/BleReceive.js +++ b/utils/BleReceive.js @@ -49,6 +49,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); @@ -73,7 +86,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 58f0695..acb048d 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 失败')); } ); }); From 553e24886f681d0f2f33649eb60a88a65a046638 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=AE=E5=BE=AE=E4=B8=80=E7=AC=91?= <709648985@qq.com> Date: Fri, 27 Mar 2026 09:53:17 +0800 Subject: [PATCH 05/11] =?UTF-8?q?=E6=8F=90=E4=BA=A4100J=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/100J/HBY100-J.js | 480 +++++++++++++------------- pages/100J/audioManager/AudioList.vue | 154 +++++---- pages/100J/audioManager/Recording.vue | 159 ++------- 3 files changed, 365 insertions(+), 428 deletions(-) diff --git a/api/100J/HBY100-J.js b/api/100J/HBY100-J.js index 7b09bb7..7bd7e17 100644 --- a/api/100J/HBY100-J.js +++ b/api/100J/HBY100-J.js @@ -17,6 +17,98 @@ function tryGetUniFileSystemManager() { } } +/** 从 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() { @@ -222,12 +314,13 @@ 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(无网络时本地文件) // 说明:下发的是已录制/已落盘的 MP3(等)二进制分片,经 GATT 写特征;非 A2DP/HFP 等「蓝牙录音实时流」 // meta.voiceListId:若存在 uni 中 100J_voice_b64_* 缓存(与 HBY100 存 Storage 同理),优先用缓存字节下发 uploadVoiceFileBle(fileUrlOrLocalPath, fileType = 1, onProgress, meta = null) { - const CHUNK_SIZE = 500; // 每包有效数据,参考 6155 deviceDetail.vue + // 协议 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 || ''); @@ -252,6 +345,7 @@ class HBY100JProtocol { 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('本地语音缓存不存在或已失效,请重新保存录音')); @@ -304,41 +398,38 @@ class HBY100JProtocol { }; const readFromPathPlus = (path, doSend, reject) => { const tryUniReadFile = (onFail) => { - try { - // 仅非 App(如微信小程序)走 uni FSM。App-Plus 上禁止此处调用 getFileSystemManager: - // 否则会刷「not yet implemented」,且 readFile 的 success/fail 可能永不回调 → 永远进不了 plus.io 兜底,表现为「读不了本地文件」。 - const fsm = tryGetUniFileSystemManager(); - if (!fsm || typeof fsm.readFile !== 'function') { - 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; } - fsm.readFile({ - filePath: path, - success: (res) => { - try { - const raw = res && res.data; - let bytes = null; - if (raw instanceof ArrayBuffer) bytes = new Uint8Array(raw); - else if (raw instanceof Uint8Array) bytes = raw; - else if (raw && ArrayBuffer.isView(raw) && raw.buffer instanceof ArrayBuffer) { - bytes = new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength); - } - if (bytes && bytes.length > 0) { - console.log('[100J-蓝牙] readFile 已读出本地语音,字节:', bytes.length); - doSend(bytes); + 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; } - } catch (e) {} - onFail(); - }, - fail: () => onFail() - }); - } catch (e) { - onFail(); - } + onFail(); + }, + fail: () => onFail() + }); + } catch (e) { + onFail(); + } + }); }; - // _downloads/:与 Common.moveFileToDownloads 一致,文件在 PUBLIC_DOWNLOADS 根目录。 - // 优先 requestFileSystem+getFile(老版本注释:resolveLocalFileSystemURL 易卡住);resolve 仅作兜底。 + // _downloads/:resolve 与 requestFileSystem 并行竞速,避免单一路径在部分机型上长期无回调 if (path && path.startsWith('_downloads/')) { const fileName = path.replace(/^_downloads\//, ''); tryUniReadFile(() => { @@ -346,7 +437,7 @@ class HBY100JProtocol { reject(new Error('当前环境不支持文件读取')); return; } - console.log('[100J-蓝牙] _downloads 读取开始, fileName=', fileName); + console.log('[100J-蓝牙] _downloads 并行 resolve+PUBLIC_DOWNLOADS, fileName=', fileName); let finished = false; const outerMs = 20000; const outerT = setTimeout(() => { @@ -354,123 +445,13 @@ class HBY100JProtocol { finished = true; reject(new Error('读取下载目录语音超时,请重新保存录音后重试')); }, outerMs); - const finishOk = (bytes) => { - if (finished) return; - finished = true; - clearTimeout(outerT); - clearTimeout(reqFsGuardT); - doSend(bytes); - }; - const finishErr = (err) => { - if (finished) return; - finished = true; - clearTimeout(outerT); - clearTimeout(reqFsGuardT); - reject(err || new Error('读取文件失败')); - }; - let fallbackStarted = false; - const tryResolveUrl = () => { - if (finished || fallbackStarted) return; - fallbackStarted = true; - clearTimeout(reqFsGuardT); - console.log('[100J-蓝牙] _downloads 改用 resolveLocalFileSystemURL 兜底'); - 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) => { - readFileEntry(entry, finishOk, () => { - finishErr(new Error('_downloads 文件不存在或无法读取')); - }); - }, () => finishErr(new Error('_downloads 路径解析失败'))); - }; - const reqFsGuardMs = 6000; - const reqFsGuardT = setTimeout(() => { - if (finished || fallbackStarted) return; - console.log('[100J-蓝牙] _downloads requestFileSystem 超时,尝试 resolve 兜底'); - tryResolveUrl(); - }, reqFsGuardMs); - plus.io.requestFileSystem(plus.io.PUBLIC_DOWNLOADS, (fs) => { - if (finished || fallbackStarted) return; - fs.root.getFile(fileName, {}, (entry) => { - if (finished || fallbackStarted) return; - readFileEntry(entry, finishOk, (err) => { - if (finished) return; - clearTimeout(reqFsGuardT); - console.log('[100J-蓝牙] getFile 后读失败,尝试 resolve', err && err.message); - tryResolveUrl(); - }); - }, (err) => { - if (finished) return; - clearTimeout(reqFsGuardT); - console.log('[100J-蓝牙] getFile 失败,尝试 resolve', err && err.message); - tryResolveUrl(); - }); - }, (err) => { - if (finished) return; - clearTimeout(reqFsGuardT); - console.log('[100J-蓝牙] requestFileSystem 失败,尝试 resolve', err && err.message); - tryResolveUrl(); - }); - }); - return; - } - // _doc/:与 _downloads 同理,App 只走 plus;并加 resolve 优先 + 超时,避免 requestFileSystem 链卡死 - if (path && path.startsWith('_doc/')) { - const relPath = path.replace(/^_doc\//, ''); - const parts = relPath.split('/'); - const fileName = parts.pop(); - const dirs = parts; - tryUniReadFile(() => { - if (typeof plus === 'undefined' || !plus.io) { - reject(new Error('当前环境不支持文件读取')); - return; - } - let finished = false; - const outerMs = 20000; - const outerT = setTimeout(() => { - if (finished) return; - finished = true; - reject(new Error('读取本地语音超时(文档目录),请重新保存录音后重试')); - }, outerMs); - const finishOk = (bytes) => { + const win = (bytes) => { if (finished) return; + if (!bytes || !(bytes.length > 0)) return; finished = true; clearTimeout(outerT); doSend(bytes); }; - const finishErr = (err) => { - if (finished) return; - finished = true; - clearTimeout(outerT); - reject(err || new Error('读取文件失败')); - }; - const tryWalkFs = () => { - 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, finishOk, finishErr), (err) => finishErr(err)); - return; - } - cur.getDirectory(dirs[i], { create: false }, (dir) => { cur = dir; next(i + 1); }, (err) => finishErr(err)); - }; - next(0); - }, (err) => finishErr(err)); - }; - let resolvePhaseDone = false; - const resolveT = setTimeout(() => { - if (resolvePhaseDone) return; - resolvePhaseDone = true; - console.log('[100J-蓝牙] _doc resolve 超时,改用 PRIVATE_DOC 逐级目录'); - tryWalkFs(); - }, 6000); let url = path; try { if (plus.io.convertLocalFileSystemURL) { @@ -482,19 +463,78 @@ class HBY100JProtocol { url = 'file://' + url; } plus.io.resolveLocalFileSystemURL(url, (entry) => { - if (resolvePhaseDone) return; - resolvePhaseDone = true; - clearTimeout(resolveT); - readFileEntry(entry, finishOk, (err) => { - console.log('[100J-蓝牙] _doc resolve 后读失败,改用逐级目录', err && err.message); - tryWalkFs(); - }); - }, () => { - if (resolvePhaseDone) return; - resolvePhaseDone = true; - clearTimeout(resolveT); - tryWalkFs(); - }); + 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/:PRIVATE_DOC 逐级与 resolve 并行,谁先读到有效字节谁胜出 + if (path && path.startsWith('_doc/')) { + const relPath = path.replace(/^_doc\//, ''); + const parts = relPath.split('/'); + const fileName = parts.pop(); + const dirs = parts; + 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); + }; + 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; } @@ -641,7 +681,7 @@ 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]; - // 单包最大约 500+ 协议头尾,需 MTU>~520;BleHelper 里 setMtu 为异步且未与首写串联,易在未协商完就 write 导致长时间无回调→界面超时 + // 单包约 507B(500 负载),依赖 MTU;Android 上为整包 write const waitPromise = this.waitForFileResponse(2500); const prepMtuThenSend = (ble) => { const run = () => { @@ -674,7 +714,9 @@ class HBY100JProtocol { try { if (typeof plus !== 'undefined' && plus.os && plus.os.name === 'Android' && ble.setMtu) { return ble.setMtu(this.bleDeviceId) - .catch(() => {}) + .catch((e) => { + console.warn('[100J-蓝牙] setBLEMTU 失败,大数据包可能无法一次写入,已用较小分片缓解:', e && (e.message || e)); + }) .then(() => new Promise((r) => setTimeout(r, 350))) .then(run); } @@ -804,86 +846,19 @@ export function remove100JVoiceBleCache(deviceId, voiceListId) { /** 保存录音/上传成功后:读落盘路径并写入缓存,点「使用」时可与 HBY100 一样不再依赖 plus 读文件 */ export function cache100JVoiceFileForBle(deviceId, voiceListId, filePath) { return new Promise((resolve) => { - const done = () => resolve(); if (!deviceId || voiceListId == null || !filePath) { - done(); + resolve(); return; } - const tryFs = () => { - const fsm = tryGetUniFileSystemManager(); - if (!fsm || typeof fsm.readFile !== 'function') return false; - fsm.readFile({ - filePath, - success: (res) => { - try { - const raw = res && res.data; - let bytes = null; - if (raw instanceof ArrayBuffer) bytes = new Uint8Array(raw); - else if (raw instanceof Uint8Array) bytes = raw; - else if (raw && ArrayBuffer.isView(raw) && raw.buffer instanceof ArrayBuffer) { - bytes = new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength); - } - if (bytes && bytes.length) put100JVoiceBleCache(deviceId, voiceListId, bytes); - } catch (e) {} - done(); - }, - fail: () => done() - }); - return true; - }; - if (tryFs()) return; - if (typeof plus === 'undefined' || !plus.io) { - done(); - return; - } - const readEntry = (entry, cb) => { - entry.file((file) => { - const reader = new plus.io.FileReader(); - reader.onloadend = (e) => { - try { - const buf = e.target.result; - if (buf && buf.byteLength) put100JVoiceBleCache(deviceId, voiceListId, new Uint8Array(buf)); - } catch (err) {} - cb(); - }; - reader.onerror = () => cb(); - reader.readAsArrayBuffer(file); - }, () => cb()); - }; - if (filePath.startsWith('_downloads/')) { - const name = filePath.replace(/^_downloads\//, ''); - plus.io.requestFileSystem(plus.io.PUBLIC_DOWNLOADS, (fs) => { - fs.root.getFile(name, {}, (entry) => readEntry(entry, done), () => done()); - }, () => done()); - return; - } - if (filePath.startsWith('_doc/')) { - const rel = filePath.replace(/^_doc\//, ''); - const parts = rel.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) => readEntry(entry, done), () => done()); - return; - } - cur.getDirectory(dirs[i], { create: false }, (dir) => { cur = dir; next(i + 1); }, () => done()); - }; - next(0); - }, () => done()); - return; - } - let resolvePath = filePath; - try { - if (plus.io.convertLocalFileSystemURL && !/^(?:_doc\/|_downloads\/|https?:)/i.test(filePath)) { - const c = plus.io.convertLocalFileSystemURL(filePath); - if (c) resolvePath = c; + // 仅用 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); } - } catch (e) {} - if (resolvePath.startsWith('/') && !resolvePath.startsWith('file://')) resolvePath = 'file://' + resolvePath; - plus.io.resolveLocalFileSystemURL(resolvePath, (entry) => readEntry(entry, done), () => done()); + resolve(); + }); }); } @@ -1032,11 +1007,15 @@ 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 lp = (data.localPath && String(data.localPath).trim()) || ''; const fu = (data.fileUrl && String(data.fileUrl).trim()) || ''; @@ -1052,12 +1031,31 @@ export function deviceUpdateVoice(data) { const fileSource = hasLocalPath ? (effectiveLocal || BLE_CACHE_SENTINEL) : (remoteUrl || null); if (!fileSource) { console.log('[100J] 语音上传:无 fileUrl/localPath,仅 HTTP updateVoice(不会走蓝牙传文件)'); - return httpExec().then((res) => { if (res && typeof res === 'object') res._channel = '4g'; return res; }); + return httpExec(0).then((res) => { if (res && typeof res === 'object') res._channel = '4g'; return res; }); } console.log('[100J] 语音上传:有文件源,尝试蓝牙下发文件', { isBleConnected: protocolInstance.isBleConnected, hasBleDeviceId: !!protocolInstance.bleDeviceId, local: !!hasLocalPath }); - const bleExec = () => protocolInstance.uploadVoiceFileBle(fileSource, 1, data.onProgress, { voiceListId: data.id }); + // 蓝牙传完文件后再调 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, httpExec, '语音文件上传', data.onWaiting, { no4GFallback: hasLocalPath }); + return execWithBleFirst(bleExec, http4g, '语音文件上传', data.onWaiting, { no4GFallback: hasLocalPath }); } // 100J信息 export function deviceDetail(id) { diff --git a/pages/100J/audioManager/AudioList.vue b/pages/100J/audioManager/AudioList.vue index 3ee7e84..a02dd0a 100644 --- a/pages/100J/audioManager/AudioList.vue +++ b/pages/100J/audioManager/AudioList.vue @@ -525,7 +525,11 @@ }, Apply(item, index) { this.updateProgress = 0; - this.isUpdating = true; + if (this.upgradeTimer) { + clearTimeout(this.upgradeTimer); + this.upgradeTimer = null; + } + // 本地项在无网时禁止下发,仅弹窗(isUpdating 在确认可执行后再置 true) // 本地项优先用 localPath;云端项用 fileUrl(兼容多种字段名),相对路径补全 baseURL let fileUrl = ''; let localPath = (item.localPath && typeof item.localPath === 'string') ? item.localPath : ''; @@ -548,67 +552,99 @@ // 不传「蓝牙连接中」类提示:关蓝牙走 4G 时易误导;进度条 + 必要时全局请稍候即可 onWaiting: () => {} }; - // 整体超时 60 秒(仅影响蓝牙上传,4G HTTP 很快返回) - const overallTimer = setTimeout(() => { - if (this.isUpdating) { - uni.showToast({ title: '操作超时', icon: 'none', duration: 2000 }); + const runDeviceUpdate = () => { + const overallTimer = setTimeout(() => { + if (this.isUpdating) { + uni.showToast({ title: '操作超时', icon: 'none', duration: 2000 }); + this.isUpdating = false; + this.updateProgress = 0; + } + }, 120000); + // 进入列表时的蓝牙快照可能过期;与 HBY100 详情页一致,从 BleHelper 按 MAC 再对齐一次 + sync100JBleProtocolFromHelper(this.device).then(() => deviceUpdateVoice(data)).then((RES) => { + clearTimeout(overallTimer); + 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; + 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; + } + }, 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); + this.syncVoiceListUseStatus(item); + 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; this.updateProgress = 0; - } - }, 120000); // 蓝牙分片+MTU 协商+大包写入较慢,60s 易误报「操作超时」 - // 进入列表时的蓝牙快照可能过期;与 HBY100 详情页一致,从 BleHelper 按 MAC 再对齐一次,否则常静默走 4G、看不到 [100J-蓝牙] 分片日志 - sync100JBleProtocolFromHelper(this.device).then(() => deviceUpdateVoice(data)).then((RES) => { - clearTimeout(overallTimer); - if (RES.code == 200) { - // 蓝牙上传:进度已由 onProgress 更新,直接完成 - if (RES._channel === 'ble') { - this.syncVoiceListUseStatus(item); - 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); - this.syncVoiceListUseStatus(item); - 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; - this.updateProgress = 0; - uni.showToast({ title: err.message || '操作失败', icon: 'none', duration: 2500 }); - }); + 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 3850f39..2ff2e10 100644 --- a/pages/100J/audioManager/Recording.vue +++ b/pages/100J/audioManager/Recording.vue @@ -422,117 +422,6 @@ hideLoading(these); }, 1200); }, - // 无网络时保存到本地,供蓝牙直接发送(不依赖 OSS) - // 当前部分机型 PUBLIC_DOWNLOADS 在读取阶段会出现 requestFileSystem/resolve 长时间无回调, - // 因此本地语音优先落到 _doc/100J_audio,后续蓝牙下发走 PRIVATE_DOC 分支更稳定。 - saveLocalForBle(filePath) { - const deviceId = these.Status.ID; - if (!deviceId) { - uni.showToast({ title: '缺少设备信息,请从语音列表进入录音页', icon: 'none', duration: 2500 }); - return; - } - const warmCacheNow = (srcPath) => { - if (!srcPath) return; - // 像 HBY100 一样优先写 uni 存储:后续「使用」先读缓存,尽量不再依赖当场读文件 - cache100JVoiceFileForBle(deviceId, 'local_' + these.cEdit.Id, srcPath); - }; - 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; - // 再用持久路径补写一遍缓存(若前面已成功则覆盖同 key) - cache100JVoiceFileForBle(deviceId, item.id, persistentPath); - uni.navigateBack(); - }; - const toPlusUrl = (p) => { - if (!p) return p; - try { - if (plus.io.convertLocalFileSystemURL) { - const c = plus.io.convertLocalFileSystemURL(p); - if (c) return c; - } - } catch (e) {} - return p; - }; - const copyToDocByReadWrite = (srcPath, fileName, onOk, onFail) => { - plus.io.resolveLocalFileSystemURL(toPlusUrl(srcPath), (srcEntry) => { - srcEntry.file((file) => { - const reader = new plus.io.FileReader(); - reader.onloadend = (e) => { - const buf = e.target.result; - if (!buf || !(buf.byteLength > 0)) { - onFail(new Error('读取录音文件为空')); - return; - } - plus.io.requestFileSystem(plus.io.PRIVATE_DOC, (fs) => { - fs.root.getDirectory('100J_audio', { create: true }, (dirEntry) => { - dirEntry.getFile(fileName, { create: true }, (newEntry) => { - newEntry.createWriter((writer) => { - writer.onwrite = () => onOk(); - writer.onerror = (ex) => onFail(ex || new Error('写入失败')); - try { - const blob = new Blob([buf], { type: 'application/octet-stream' }); - writer.write(blob); - } catch (wex) { - writer.write(buf); - } - }, onFail); - }, onFail); - }, onFail); - }, onFail); - }; - reader.onerror = () => onFail(new Error('读取录音失败')); - reader.readAsArrayBuffer(file); - }, onFail); - }, onFail); - }; - const fallbackDocSubdir = () => { - if (typeof plus === 'undefined' || !plus.io) { - doSave(filePath); - return; - } - const fileName = 'audio_' + (these.cEdit.Id || Date.now()) + '.mp3'; - const docRel = '_doc/100J_audio/' + fileName; - const tryRwFallback = (reason) => { - console.warn('[100J] saveLocalForBle 复制失败,尝试读写落盘:', reason || ''); - copyToDocByReadWrite(filePath, fileName, () => doSave(docRel), () => doSave(filePath)); - }; - plus.io.resolveLocalFileSystemURL(toPlusUrl(filePath), (entry) => { - plus.io.resolveLocalFileSystemURL('_doc/', (docEntry) => { - docEntry.getDirectory('100J_audio', { create: true }, (dirEntry) => { - entry.copyTo(dirEntry, fileName, () => { - doSave(docRel); - }, () => { tryRwFallback('copyTo'); }); - }, () => { tryRwFallback('getDirectory'); }); - }, () => { tryRwFallback('resolve _doc'); }); - }, () => { tryRwFallback('resolve src'); }); - }; - if (typeof plus === 'undefined' || !plus.io) { - doSave(filePath); - return; - } - const rawPath = (filePath && String(filePath).trim()) || ''; - // 保存流程开始时即尝试缓存一次(通常是 _doc/uniapp_temp 源路径) - warmCacheNow(rawPath); - // 已是 _doc 路径时直接保存;其余路径统一转存到 _doc/100J_audio - if (rawPath.indexOf('_doc/') === 0) { - doSave(rawPath); - return; - } - fallbackDocSubdir(); - }, // 保存录音并上传(已修复文件格式问题) uploadLuYin() { // 文件类型验证 @@ -554,6 +443,12 @@ 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, "字节"); @@ -569,15 +464,15 @@ }); }; - // 无网络时不调 OSS,直接落本地列表,避免 uploadFile 白失败;列表里点「使用」走蓝牙下发 + // 无网络不允许保存:无网本地项无法上传云端,列表里「使用」也无法可靠读本地蓝牙下发 uni.getNetworkType({ success: (res) => { if (res.networkType === 'none') { - this.saveLocalForBle(uploadFilePath); - uni.showToast({ - title: '无网络,已保存到本地。请在列表连接蓝牙后点「使用」下发;有网后可再保存上传云端', - icon: 'none', - duration: 4000 + uni.showModal({ + title: '无法保存', + content: '当前无网络,语音需上传云端后才能正常使用与蓝牙下发。请连接 WiFi 或移动网络后再点保存。', + showCancel: false, + confirmText: '知道了' }); return; } @@ -592,18 +487,27 @@ // 执行上传操作 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}`, @@ -690,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('上传操作完成'); From 4c6704ba8a6c7a17d9192641378236986320dae8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=AE=E5=BE=AE=E4=B8=80=E7=AC=91?= <709648985@qq.com> Date: Fri, 27 Mar 2026 10:13:52 +0800 Subject: [PATCH 06/11] =?UTF-8?q?=E6=9B=B4=E6=96=B0100J?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/100J/HBY100-J.js | 29 ++++++-- pages/100J/audioManager/AudioList.vue | 100 +++++++++++++++++++------- 2 files changed, 99 insertions(+), 30 deletions(-) diff --git a/api/100J/HBY100-J.js b/api/100J/HBY100-J.js index 7bd7e17..37eb207 100644 --- a/api/100J/HBY100-J.js +++ b/api/100J/HBY100-J.js @@ -663,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 = '') => { @@ -688,26 +701,30 @@ class HBY100JProtocol { 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); }) + .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: '语音文件已通过蓝牙上传' }; }); }; diff --git a/pages/100J/audioManager/AudioList.vue b/pages/100J/audioManager/AudioList.vue index a02dd0a..6ec61c5 100644 --- a/pages/100J/audioManager/AudioList.vue +++ b/pages/100J/audioManager/AudioList.vue @@ -233,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; @@ -525,10 +550,7 @@ }, Apply(item, index) { this.updateProgress = 0; - if (this.upgradeTimer) { - clearTimeout(this.upgradeTimer); - this.upgradeTimer = null; - } + this.clearVoiceApplyTimers(); // 本地项在无网时禁止下发,仅弹窗(isUpdating 在确认可执行后再置 true) // 本地项优先用 localPath;云端项用 fileUrl(兼容多种字段名),相对路径补全 baseURL let fileUrl = ''; @@ -548,21 +570,35 @@ // 本地合并项 mergeLocal 会把路径写在 fileUrl,需带给接口层做 effectiveLocal 兜底 fileUrl: item._isLocal ? (typeof item.fileUrl === 'string' ? item.fileUrl : '') : fileUrl, localPath, - onProgress: (p) => { this.updateProgress = p; }, + 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: () => {} }; const runDeviceUpdate = () => { - const overallTimer = setTimeout(() => { + // 大文件蓝牙分片耗时可远超 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: 2000 }); + uni.showToast({ title: '操作时间过长已中断,请重试或检查蓝牙连接', icon: 'none', duration: 2500 }); this.isUpdating = false; this.updateProgress = 0; } - }, 120000); + }, OVERALL_MS); // 进入列表时的蓝牙快照可能过期;与 HBY100 详情页一致,从 BleHelper 按 MAC 再对齐一次 sync100JBleProtocolFromHelper(this.device).then(() => deviceUpdateVoice(data)).then((RES) => { - clearTimeout(overallTimer); + if (this._applyOverallTimer) { + clearTimeout(this._applyOverallTimer); + this._applyOverallTimer = null; + } if (RES.code == 200) { // 蓝牙上传:进度已由 onProgress 更新,直接完成 if (RES._channel === 'ble') { @@ -576,37 +612,50 @@ this.syncVoiceListUseStatus(item); uni.showToast({ title, icon: RES._updateVoiceAfterBleFailed ? 'none' : 'success', duration: 2000 }); this.isUpdating = false; - setTimeout(() => { uni.navigateBack(); }, 1500); + this.scheduleNavigateBackAfterVoice(1500); return; } - // 4G:订阅 MQTT 获取设备端进度,6 秒超时 - this.upgradeTimer = setTimeout(() => { - if (this.isUpdating) { - uni.showToast({ title: '音频进度同步超时', icon: 'none', duration: 2000 }); + // 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; - } - }, 6000); + }, 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?.progress; + const progress = payloadObj.data != null && payloadObj.data.progress !== undefined + ? payloadObj.data.progress + : payloadObj.progress; if (progress !== undefined && !isNaN(progress) && progress >= 0 && progress <= 100) { - this.updateProgress = progress; - if (progress === 100) { - clearTimeout(this.upgradeTimer); + 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; - setTimeout(() => { uni.navigateBack(); }, 1500); + this.scheduleNavigateBackAfterVoice(1500); } } } catch (e) { - clearTimeout(this.upgradeTimer); console.error('解析MQTT payload失败:', e); + armMqttIdle(); } }); }); @@ -615,7 +664,10 @@ uni.showToast({ title: RES.msg || '操作失败', icon: 'none', duration: 1000 }); } }).catch((err) => { - clearTimeout(overallTimer); + if (this._applyOverallTimer) { + clearTimeout(this._applyOverallTimer); + this._applyOverallTimer = null; + } this.isUpdating = false; this.updateProgress = 0; uni.showToast({ title: err.message || '操作失败', icon: 'none', duration: 2500 }); From ab19f14f05298a8ebad565d4324fb721f8674ff7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=AE=E5=BE=AE=E4=B8=80=E7=AC=91?= <709648985@qq.com> Date: Fri, 27 Mar 2026 11:14:43 +0800 Subject: [PATCH 07/11] =?UTF-8?q?100j=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pages/100J/HBY100-J.vue | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/pages/100J/HBY100-J.vue b/pages/100J/HBY100-J.vue index dc2514b..2b47e37 100644 --- a/pages/100J/HBY100-J.vue +++ b/pages/100J/HBY100-J.vue @@ -1028,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 }; @@ -1055,7 +1055,7 @@ icon: 'none' }); } - }) + }); } else if (isVoiceOperate) { const data = { deviceId: this.deviceInfo.deviceId, @@ -1077,15 +1077,15 @@ }).catch(() => { uni.showToast({ title: '下发失败,请检查蓝牙或网络', icon: 'none' }); }); - } else { - // 非强制报警态下选择内置音色:原先仅改 UI 未下发,设备无播报 + } else if (prevVoiceType === '7' && val !== '7' && val !== '-1') { + // 从「播放语音」切到其它内置音色:先关播报;报警未开启时不走 forceAlarm,仅 UI 预选音色 const data = { - deviceIds: [this.deviceInfo.deviceId], - voiceStrobeAlarm: 0, + deviceId: this.deviceInfo.deviceId, + voiceBroadcast: 0, mode: val }; - deviceForceAlarmActivation(data).then((res) => { - if (res.code === 200) { + deviceVoiceBroadcast(data).then((res) => { + if (res.code == 200) { uni.showToast({ title: res.msg || '已切换', icon: 'none' }); } else { uni.showToast({ title: res.msg || '操作失败', icon: 'none' }); @@ -1094,6 +1094,7 @@ uni.showToast({ title: '下发失败,请检查蓝牙或网络', icon: 'none' }); }); } + // 未开启强制报警时,在 0–6 内置音色间切换或取消选中:只改按钮选中,不下发 }, // 报警模式 sosSetting(item) { From e0cef19da19d560f8724a62fecc6bb1ba3d3b43f Mon Sep 17 00:00:00 2001 From: fengerli <528575642@qq.com> Date: Fri, 27 Mar 2026 11:24:30 +0800 Subject: [PATCH 08/11] =?UTF-8?q?100J=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/index.js | 2 +- pages/100J/HBY100-J.vue | 56 +++++++++++++++++++++++++++-------------- 2 files changed, 38 insertions(+), 20 deletions(-) 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 dc2514b..c73f7f4 100644 --- a/pages/100J/HBY100-J.vue +++ b/pages/100J/HBY100-J.vue @@ -607,6 +607,8 @@ // 开启状 these.deviceInfo.voiceStrobeAlarm = 1; these.formData.sta_VoiceType = mode_alarm + '' + } else if (mode_alarm == 7) { + these.formData.sta_VoiceType !== 7 } else { // 关闭状态:赋值-1,表示关闭 these.deviceInfo.voiceStrobeAlarm = -1; @@ -771,7 +773,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'; }); @@ -809,7 +813,7 @@ // 关闭状态 that.formData.sta_LightType = '-1'; } - if (this.formData.sta_VoiceType === '7' || this.formData.sta_VoiceType === 7) { + if (this.formData.sta_VoiceType === '7' || res.data.alarmMode == 7) { this.formData.sta_VoiceType = (res.data.voiceStrobeAlarm ?? 0) + ''; } else { this.formData.sta_VoiceType = res.data.alarmMode + '' @@ -1028,18 +1032,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 }; @@ -1055,7 +1059,7 @@ icon: 'none' }); } - }) + }); } else if (isVoiceOperate) { const data = { deviceId: this.deviceInfo.deviceId, @@ -1075,25 +1079,38 @@ }); } }).catch(() => { - uni.showToast({ title: '下发失败,请检查蓝牙或网络', icon: 'none' }); + uni.showToast({ + title: '下发失败,请检查蓝牙或网络', + icon: 'none' + }); }); - } else { - // 非强制报警态下选择内置音色:原先仅改 UI 未下发,设备无播报 + } else if (prevVoiceType === '7' && val !== '7' && val !== '-1') { + // 从「播放语音」切到其它内置音色:先关播报;报警未开启时不走 forceAlarm,仅 UI 预选音色 const data = { - deviceIds: [this.deviceInfo.deviceId], - voiceStrobeAlarm: 0, + deviceId: this.deviceInfo.deviceId, + voiceBroadcast: 0, mode: val }; - deviceForceAlarmActivation(data).then((res) => { - if (res.code === 200) { - uni.showToast({ title: res.msg || '已切换', icon: 'none' }); + deviceVoiceBroadcast(data).then((res) => { + if (res.code == 200) { + uni.showToast({ + title: res.msg || '已切换', + icon: 'none' + }); } else { - uni.showToast({ title: res.msg || '操作失败', icon: 'none' }); + uni.showToast({ + title: res.msg || '操作失败', + icon: 'none' + }); } }).catch(() => { - uni.showToast({ title: '下发失败,请检查蓝牙或网络', icon: 'none' }); + uni.showToast({ + title: '下发失败,请检查蓝牙或网络', + icon: 'none' + }); }); } + // 未开启强制报警时,在 0–6 内置音色间切换或取消选中:只改按钮选中,不下发 }, // 报警模式 sosSetting(item) { @@ -1280,7 +1297,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; }, From 35d6574754517270f1f8d2f5ba3a2fc5bc46aa42 Mon Sep 17 00:00:00 2001 From: fengerli <528575642@qq.com> Date: Fri, 27 Mar 2026 13:40:13 +0800 Subject: [PATCH 09/11] 1 --- pages/100J/HBY100-J.vue | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/pages/100J/HBY100-J.vue b/pages/100J/HBY100-J.vue index c59cb4d..d57d905 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) : '' }} - + @@ -607,7 +607,8 @@ // 开启状 these.deviceInfo.voiceStrobeAlarm = 1; these.formData.sta_VoiceType = mode_alarm + '' - } else if (mode_alarm == 7) { + // 强制报警中,自定义语音,解除报警时,状态需要改变成未播放的状态的 + } else if (mode_alarm == 7 && enable_alarm == 0) { these.formData.sta_VoiceType !== 7 } else { // 关闭状态:赋值-1,表示关闭 @@ -1079,7 +1080,10 @@ }); } }).catch(() => { - uni.showToast({ title: '下发失败,请检查蓝牙或网络', icon: 'none' }); + uni.showToast({ + title: '下发失败,请检查蓝牙或网络', + icon: 'none' + }); }); } else if (prevVoiceType === '7' && val !== '7' && val !== '-1') { // 从「播放语音」切到其它内置音色:先关播报;报警未开启时不走 forceAlarm,仅 UI 预选音色 @@ -1090,7 +1094,10 @@ }; deviceVoiceBroadcast(data).then((res) => { if (res.code == 200) { - uni.showToast({ title: res.msg || '已切换', icon: 'none' }); + uni.showToast({ + title: res.msg || '已切换', + icon: 'none' + }); } else { uni.showToast({ title: res.msg || '操作失败', From b6ce2dbe2530c13a9afa993793004217d8033b7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=AE=E5=BE=AE=E4=B8=80=E7=AC=91?= <709648985@qq.com> Date: Fri, 27 Mar 2026 18:07:59 +0800 Subject: [PATCH 10/11] =?UTF-8?q?=E4=BC=98=E5=8C=96100J=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/100J/HBY100-J.js | 12 +++++++++--- pages/100J/HBY100-J.vue | 15 +++++++++++++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/api/100J/HBY100-J.js b/api/100J/HBY100-J.js index 37eb207..f470974 100644 --- a/api/100J/HBY100-J.js +++ b/api/100J/HBY100-J.js @@ -1200,21 +1200,27 @@ export function deviceUpdateVolume(data) { ); } -/** 蓝牙侧语音播报:自定义音(mode=7)部分固件需先 0x0C 带模式再 0x06,否则无声音 */ +/** 蓝牙侧语音播报:自定义音(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') { - return protocolInstance.setForceAlarm(0, 7).then(() => protocolInstance.setVoiceBroadcast(1)); + 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( () => bleVoiceBroadcastChain(data).then(() => ({ code: 200, msg: '操作成功(蓝牙)' })), - () => request({ url: `/app/hby100j/device/voiceBroadcast`, method: 'post', data }), + () => request({ url: `/app/hby100j/device/voiceBroadcast`, method: 'post', data: httpData }), '语音播报' ); } \ No newline at end of file diff --git a/pages/100J/HBY100-J.vue b/pages/100J/HBY100-J.vue index d57d905..6197ce3 100644 --- a/pages/100J/HBY100-J.vue +++ b/pages/100J/HBY100-J.vue @@ -1065,7 +1065,8 @@ const data = { deviceId: this.deviceInfo.deviceId, voiceBroadcast: Number(this.formData.sta_VoiceType) === -1 ? 0 : 1, - mode: this.formData.sta_VoiceType + mode: this.formData.sta_VoiceType, + voiceStrobeAlarm: this.deviceInfo.voiceStrobeAlarm }; deviceVoiceBroadcast(data).then((res) => { if (res.code == 200) { @@ -1090,7 +1091,8 @@ const data = { deviceId: this.deviceInfo.deviceId, voiceBroadcast: 0, - mode: val + mode: val, + voiceStrobeAlarm: this.deviceInfo.voiceStrobeAlarm }; deviceVoiceBroadcast(data).then((res) => { if (res.code == 200) { @@ -1118,6 +1120,15 @@ 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 From 6839e9fd4082aaa0d89993a795d90b48863d2b7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=AE=E5=BE=AE=E4=B8=80=E7=AC=91?= <709648985@qq.com> Date: Fri, 27 Mar 2026 18:21:12 +0800 Subject: [PATCH 11/11] =?UTF-8?q?=E6=9B=B4=E6=96=B0100J=E8=A7=A3=E9=99=A4?= =?UTF-8?q?=E5=91=8A=E8=AD=A6=E6=81=A2=E5=A4=8D=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pages/100J/HBY100-J.vue | 41 ++++++++++++++++++++--------------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/pages/100J/HBY100-J.vue b/pages/100J/HBY100-J.vue index 6197ce3..7e0df3d 100644 --- a/pages/100J/HBY100-J.vue +++ b/pages/100J/HBY100-J.vue @@ -570,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 { // 播放语音,上报消息 @@ -600,21 +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 + '' - // 强制报警中,自定义语音,解除报警时,状态需要改变成未播放的状态的 - } else if (mode_alarm == 7 && enable_alarm == 0) { - these.formData.sta_VoiceType !== 7 + 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 + ''; } } // 警示灯模式选中切换 @@ -814,10 +807,12 @@ // 关闭状态 that.formData.sta_LightType = '-1'; } - if (this.formData.sta_VoiceType === '7' || res.data.alarmMode == 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 + ''; } } }) @@ -1158,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 { @@ -1384,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 爆闪模式:警示灯开关/模式 @@ -1410,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) {