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('上传操作完成');