分段压缩上传

This commit is contained in:
zhonghua.li
2026-04-18 23:17:08 +08:00
parent e7976c0993
commit 87afece84f
4 changed files with 251 additions and 79 deletions

View File

@@ -127,7 +127,7 @@
<text>补录客户</text>
</view>
<view class="service-action-btn service-action-btn--upload" @click.stop="chooseAndUploadFile(item)">
<text>{{ isUploading(item.id) ? '上传中' : '上传录音' }}</text>
<text>上传录音</text>
</view>
<view class="service-action-btn service-action-btn--finish" @click.stop="finishService(item)">
<text>结束服务</text>
@@ -137,6 +137,18 @@
<text>已录音{{ formatRecordingElapsed(recordingElapsedSeconds) }}</text>
<text>当前大小{{ formatRecordingSize(recordingEstimatedSizeBytes) }}</text>
</view>
<view v-if="getUploadResultList(item.id).length" class="upload-result-list">
<view
v-for="segment in getUploadResultList(item.id)"
:key="segment.id"
class="upload-result-item"
>
<text class="upload-result-item__main">
{{ segment.segmentNo }}{{ segment.durationText || '--:--' }} / {{ segment.sizeText || '--' }}{{ segment.statusText }}
</text>
<text v-if="segment.message" class="upload-result-item__sub">{{ segment.message }}</text>
</view>
</view>
</view>
<view class="service-status-empty" v-if="!serviceStatusLoading && !serviceStatusList.length">
<text>{{ emptyListHint }}</text>
@@ -276,7 +288,17 @@ export default {
recordingElapsedSeconds: 0,
recordingEstimatedSizeBytes: 0,
recordingTicker: null,
keepScreenOnEnabled: false
keepScreenOnEnabled: false,
// 上传前压缩策略:从录音源头降低采样率/码率,减少上传文件体积
recordingSampleRate: 16000,
recordingEncodeBitRate: 32000,
recordingSegmentDurationMs: 5 * 60 * 1000,
recordingManualStopRequested: false,
recordingCurrentSegmentNo: 0,
recordingCurrentSegmentStartAt: 0,
recordingCurrentSegmentElapsedSeconds: 0,
uploadingCountMap: {},
uploadResultMap: {}
}
},
mounted() {
@@ -305,27 +327,71 @@ export default {
return;
}
this.recorderManager = uni.getRecorderManager();
// 用户点击「停止」后由系统回调此处,再将临时录音文件上传到 /api/audio/upload
// 录音每段停止后回调:静默上传,若非手动停止则继续下一段
this.recorderManager.onStop((res) => {
const ctx = this.recordingContext;
const recordId = this.recordingServiceId;
const currentSegmentNo = this.recordingCurrentSegmentNo;
const currentSegmentElapsedSeconds = this.recordingCurrentSegmentElapsedSeconds || this.recordingElapsedSeconds || 0;
const shouldContinue =
!!recordId &&
!!ctx &&
!!ctx.id &&
!this.recordingManualStopRequested;
const normalizedSegmentNo = currentSegmentNo > 0 ? currentSegmentNo : 1;
this.stopRecordingTicker();
const tempFilePath = res.tempFilePath || '';
if (ctx && ctx.id) {
const name = `service_record_${ctx.id}_${Date.now()}_part${normalizedSegmentNo}.mp3`;
if (!tempFilePath) {
this.pushUploadResult(String(ctx.id), {
id: `seg_${String(ctx.id)}_${normalizedSegmentNo}_${Date.now()}`,
segmentNo: normalizedSegmentNo,
durationText: this.formatRecordingElapsed(currentSegmentElapsedSeconds),
sizeText: '--',
status: 'failed',
statusText: '上传失败',
message: '未获取到录音文件已重试1次',
});
} else {
this.uploadFileForRecord(
ctx,
{
path: tempFilePath,
name,
size: Number(res.fileSize) || 0,
},
{
silent: true,
segmentNo: normalizedSegmentNo,
durationSeconds: currentSegmentElapsedSeconds,
retryOnceOnFail: true,
}
);
}
}
if (shouldContinue) {
this.setKeepScreenOn(true);
setTimeout(() => {
this.startNewSegmentRecording();
}, 80);
return;
}
this.recordingServiceId = null;
this.recordingContext = null;
this.stopRecordingTicker();
this.recordingManualStopRequested = false;
this.recordingCurrentSegmentNo = 0;
this.recordingCurrentSegmentStartAt = 0;
this.recordingCurrentSegmentElapsedSeconds = 0;
this.setKeepScreenOn(false);
const tempFilePath = res.tempFilePath || '';
if (!ctx || !ctx.id) {
return;
}
if (!tempFilePath) {
uni.showToast({ title: '未获取到录音文件', icon: 'none' });
return;
}
const name = `service_record_${ctx.id}_${Date.now()}.mp3`;
this.uploadFileForRecord(ctx, { path: tempFilePath, name });
});
this.recorderManager.onError(() => {
this.recordingServiceId = null;
this.recordingContext = null;
this.recordingManualStopRequested = false;
this.recordingCurrentSegmentNo = 0;
this.recordingCurrentSegmentStartAt = 0;
this.recordingCurrentSegmentElapsedSeconds = 0;
this.stopRecordingTicker();
this.setKeepScreenOn(false);
uni.showToast({ title: '录音出错', icon: 'none' });
@@ -349,10 +415,10 @@ export default {
this.stopRecordingTicker();
this.recordingElapsedSeconds = 0;
this.recordingEstimatedSizeBytes = 0;
// 96kbpsencodeBitRate约等于每秒 12000 字节
const bytesPerSecond = 96000 / 8;
const bytesPerSecond = (Number(this.recordingEncodeBitRate) || 32000) / 8;
this.recordingTicker = setInterval(() => {
this.recordingElapsedSeconds += 1;
this.recordingCurrentSegmentElapsedSeconds = this.recordingElapsedSeconds;
this.recordingEstimatedSizeBytes = Math.floor(this.recordingElapsedSeconds * bytesPerSecond);
}, 1000);
},
@@ -361,9 +427,38 @@ export default {
clearInterval(this.recordingTicker);
}
this.recordingTicker = null;
this.recordingCurrentSegmentElapsedSeconds = this.recordingElapsedSeconds;
this.recordingElapsedSeconds = 0;
this.recordingEstimatedSizeBytes = 0;
},
startNewSegmentRecording() {
if (!this.recorderManager || !this.recordingServiceId || !this.recordingContext?.id) {
return;
}
this.recordingCurrentSegmentNo += 1;
this.recordingCurrentSegmentStartAt = Date.now();
this.recordingCurrentSegmentElapsedSeconds = 0;
try {
this.recorderManager.start({
duration: this.recordingSegmentDurationMs,
sampleRate: this.recordingSampleRate,
numberOfChannels: 1,
encodeBitRate: this.recordingEncodeBitRate,
format: 'mp3'
});
this.startRecordingTicker();
} catch (e) {
this.recordingServiceId = null;
this.recordingContext = null;
this.recordingManualStopRequested = false;
this.recordingCurrentSegmentNo = 0;
this.recordingCurrentSegmentStartAt = 0;
this.recordingCurrentSegmentElapsedSeconds = 0;
this.stopRecordingTicker();
this.setKeepScreenOn(false);
uni.showToast({ title: '无法开始录音', icon: 'none' });
}
},
formatRecordingElapsed(seconds) {
const safeSeconds = Math.max(0, Number(seconds) || 0);
const minutes = String(Math.floor(safeSeconds / 60)).padStart(2, '0');
@@ -409,25 +504,15 @@ export default {
uni.showToast({ title: '正在上传,请稍候', icon: 'none' });
return;
}
this.recordingManualStopRequested = false;
this.recordingCurrentSegmentNo = 0;
this.recordingCurrentSegmentStartAt = 0;
this.recordingCurrentSegmentElapsedSeconds = 0;
this.setUploadResultList(String(item.id), []);
this.recordingContext = { ...item };
this.recordingServiceId = String(item.id);
try {
this.recorderManager.start({
duration: 600000,
sampleRate: 44100,
numberOfChannels: 1,
encodeBitRate: 96000,
format: 'mp3'
});
this.setKeepScreenOn(true);
this.startRecordingTicker();
} catch (e) {
this.recordingServiceId = null;
this.recordingContext = null;
this.stopRecordingTicker();
this.setKeepScreenOn(false);
uni.showToast({ title: '无法开始录音', icon: 'none' });
}
this.setKeepScreenOn(true);
this.startNewSegmentRecording();
},
/** 结束录音onStop 回调中会调用 uploadFileForRecord 上传至后端 */
stopPhoneRecordAndUpload(item) {
@@ -437,8 +522,7 @@ export default {
if (String(item.id) !== this.recordingServiceId) {
return;
}
this.stopRecordingTicker();
this.setKeepScreenOn(false);
this.recordingManualStopRequested = true;
this.recorderManager.stop();
},
/**
@@ -698,7 +782,41 @@ export default {
},
isUploading(recordId) {
if (!recordId) return false;
return this.uploadingIds.includes(String(recordId));
return Number(this.uploadingCountMap[String(recordId)] || 0) > 0;
},
increaseUploadingCount(recordId) {
const key = String(recordId);
const next = Number(this.uploadingCountMap[key] || 0) + 1;
this.uploadingCountMap = { ...this.uploadingCountMap, [key]: next };
},
decreaseUploadingCount(recordId) {
const key = String(recordId);
const current = Number(this.uploadingCountMap[key] || 0);
const next = Math.max(0, current - 1);
this.uploadingCountMap = { ...this.uploadingCountMap, [key]: next };
},
getUploadResultList(recordId) {
const key = String(recordId || '');
const list = this.uploadResultMap[key];
return Array.isArray(list) ? list : [];
},
setUploadResultList(recordId, list) {
const key = String(recordId || '');
this.uploadResultMap = { ...this.uploadResultMap, [key]: Array.isArray(list) ? list : [] };
},
pushUploadResult(recordId, segment) {
const list = this.getUploadResultList(recordId);
this.setUploadResultList(recordId, [...list, segment]);
},
updateUploadResult(recordId, segmentResultId, patch = {}) {
const list = this.getUploadResultList(recordId);
const nextList = list.map((it) => {
if (it.id !== segmentResultId) {
return it;
}
return { ...it, ...patch };
});
this.setUploadResultList(recordId, nextList);
},
/** 仅允许扩展名为 .mp3 的文件(不区分大小写) */
isMp3UploadFile(selected) {
@@ -823,55 +941,82 @@ export default {
}
return headers;
},
async uploadFileForRecord(item, selectedFile) {
async uploadFileForRecord(item, selectedFile, options = {}) {
const recordId = String(item.id);
this.uploadingIds = [...this.uploadingIds, recordId];
const segmentNo = Number(options.segmentNo) || 1;
const durationSeconds = Math.max(0, Number(options.durationSeconds) || 0);
const fileSize = Math.max(0, Number(selectedFile?.size) || 0);
const resultId = `seg_${recordId}_${segmentNo}_${Date.now()}`;
this.pushUploadResult(recordId, {
id: resultId,
segmentNo,
durationText: this.formatRecordingElapsed(durationSeconds),
sizeText: this.formatRecordingSize(fileSize),
status: 'uploading',
statusText: '上传中',
message: '',
});
this.increaseUploadingCount(recordId);
try {
uni.showLoading({
title: '上传中...'
});
const uploadRes = await uni.uploadFile({
url: getApiUrl('/api/audio/upload'),
filePath: selectedFile.path,
name: 'file',
formData: {
id: item.id,
audioId: item.id,
customerId: item.customerId || '',
customerName: item.customerName || '',
fileName: selectedFile.name || `service_file_${Date.now()}`
},
header: this.getAuthHeaders(),
timeout: 60000
});
let result = {};
try {
result = typeof uploadRes?.data === 'string' ? JSON.parse(uploadRes.data) : (uploadRes?.data || {});
} catch (e) {
result = uploadRes?.data || {};
}
if (uploadRes?.statusCode === 200 && result?.success !== false) {
uni.showToast({
title: '上传成功',
icon: 'success'
const maxAttempt = options.retryOnceOnFail ? 2 : 1;
let lastErrorMessage = '';
for (let attempt = 1; attempt <= maxAttempt; attempt += 1) {
const uploadRes = await uni.uploadFile({
url: getApiUrl('/api/audio/upload'),
filePath: selectedFile.path,
name: 'file',
formData: {
id: item.id,
audioId: item.id,
customerId: item.customerId || '',
customerName: item.customerName || '',
fileName: selectedFile.name || `service_file_${Date.now()}`,
compress: true
},
header: this.getAuthHeaders(),
timeout: 60000
});
this.serviceStatusPage.current = 1;
this.fetchServiceStatusList({ force: true });
return;
let result = {};
try {
result = typeof uploadRes?.data === 'string' ? JSON.parse(uploadRes.data) : (uploadRes?.data || {});
} catch (e) {
result = uploadRes?.data || {};
}
if (uploadRes?.statusCode === 200 && result?.success !== false) {
this.updateUploadResult(recordId, resultId, {
status: 'success',
statusText: '上传成功',
message: attempt > 1 ? `${attempt}次尝试成功` : '',
});
this.serviceStatusPage.current = 1;
this.fetchServiceStatusList({ force: true });
return true;
}
lastErrorMessage = result?.message || `上传失败HTTP ${uploadRes?.statusCode || '--'}`;
if (attempt < maxAttempt) {
this.updateUploadResult(recordId, resultId, {
status: 'retrying',
statusText: '重试中',
message: `${lastErrorMessage},正在重试`,
});
}
}
uni.showToast({
title: result?.message || '上传失败',
icon: 'none'
this.updateUploadResult(recordId, resultId, {
status: 'failed',
statusText: '上传失败',
message: `${lastErrorMessage || '未知错误'}已重试1次`,
});
return false;
} catch (error) {
console.error('上传文件失败:', error);
uni.showToast({
title: error?.errMsg || error?.message || '上传失败',
icon: 'none'
this.updateUploadResult(recordId, resultId, {
status: 'failed',
statusText: '上传失败',
message: `${error?.errMsg || error?.message || '未知错误'}已重试1次`,
});
return false;
} finally {
uni.hideLoading();
this.uploadingIds = this.uploadingIds.filter(id => id !== recordId);
this.decreaseUploadingCount(recordId);
}
},
async finishService(item) {
@@ -1530,6 +1675,33 @@ export default {
font-size: 24rpx;
color: #FF3B30;
}
.upload-result-list {
margin-top: 12rpx;
padding: 12rpx 16rpx;
background-color: #F8FAFF;
border-radius: 10rpx;
border: 1px solid #E7EEFF;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.upload-result-item {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.upload-result-item__main {
font-size: 24rpx;
color: #374151;
}
.upload-result-item__sub {
font-size: 22rpx;
color: #6B7280;
}
/* 补录弹窗样式 */
.supplement-dialog-mask {