提交100J代码

This commit is contained in:
微微一笑
2026-03-27 09:53:17 +08:00
parent b99ac04c88
commit 553e24886f
3 changed files with 365 additions and 428 deletions

View File

@ -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>~520BleHelper 里 setMtu 为异步且未与首写串联,易在未协商完就 write 导致长时间无回调→界面超时
// 单包约 507B500 负载),依赖 MTUAndroid 上为整包 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=4G1=蓝牙(云端记录本次「使用」语音的通讯方式) */
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) {

View File

@ -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;

View File

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