Compare commits

...

3 Commits

Author SHA1 Message Date
zhonghua.li
87afece84f 分段压缩上传 2026-04-18 23:17:08 +08:00
zhonghua.li
e7976c0993 在录音时,不能息屏 2026-04-18 22:41:20 +08:00
zhonghua.li
811f5ad16b 调整接点页面 2026-04-18 21:53:48 +08:00
4 changed files with 391 additions and 232 deletions

View File

@@ -62,46 +62,9 @@
</view>
<text class="staff-name">{{ item.staffName || '未分配销售' }}</text>
</view>
<view class="service-status">
<view class="action-trigger">
<view class="action-trigger__primary" @click.stop="finishService(item)">
<text class="action-trigger__primary-text">结束服务</text>
</view>
<view class="action-trigger__more" @click.stop="toggleActionMenu(item.id)">
<uni-icons type="bottom" size="16" color="#007AFF"></uni-icons>
</view>
</view>
<view
v-if="actionMenuForId && String(actionMenuForId) === String(item.id)"
class="action-menu"
@click.stop
>
<view
class="action-menu-item"
@click.stop="onActionMenuSelect('record', item)"
>
<text>{{ isRecordingItem(item.id) ? '停止录音' : '开始录音' }}</text>
</view>
<view
class="action-menu-item"
@click.stop="onActionMenuSelect('supplement', item)"
>
<text>补录客户</text>
</view>
<view
class="action-menu-item"
:class="{ 'action-menu-item--disabled': isUploading(item.id) }"
@click.stop="onActionMenuSelect('upload', item)"
>
<text>{{ isUploading(item.id) ? '上传中' : '上传录音' }}</text>
</view>
</view>
<template v-if="showRecordStateIndicator">
<uni-icons type="bars" size="16" color="#007AFF"></uni-icons>
<text>{{ recordStateLabel }}</text>
</template>
<view class="service-status-indicator">
<uni-icons type="bars" size="16" color="#007AFF"></uni-icons>
<text class="service-status-indicator__text">{{ recordStateLabel }}</text>
</view>
</view>
@@ -145,6 +108,47 @@
<view class="service-duration">
<text>{{ item.durationText }}</text>
</view>
<view class="service-actions-row">
<view
v-if="!isRecordingItem(item.id)"
class="service-action-btn service-action-btn--record"
@click.stop="startPhoneRecord(item)"
>
<text>录音</text>
</view>
<view
v-else
class="service-action-btn service-action-btn--recording"
@click.stop="stopPhoneRecordAndUpload(item)"
>
<text>停止录音</text>
</view>
<view class="service-action-btn service-action-btn--supplement" @click.stop="showSupplementDialog(item)">
<text>补录客户</text>
</view>
<view class="service-action-btn service-action-btn--upload" @click.stop="chooseAndUploadFile(item)">
<text>上传录音</text>
</view>
<view class="service-action-btn service-action-btn--finish" @click.stop="finishService(item)">
<text>结束服务</text>
</view>
</view>
<view v-if="isRecordingItem(item.id)" class="recording-realtime">
<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>
@@ -154,12 +158,6 @@
</view>
</scroll-view>
<view
v-if="actionMenuForId"
class="action-menu-mask"
@click="closeActionMenu"
/>
<!-- 补录弹窗 -->
<view class="supplement-dialog-mask" v-if="showSupplementModal" @click="closeSupplementDialog">
<view class="supplement-dialog" @click.stop>
@@ -287,7 +285,20 @@ export default {
recorderManager: null,
recordingServiceId: null,
recordingContext: null,
actionMenuForId: null
recordingElapsedSeconds: 0,
recordingEstimatedSizeBytes: 0,
recordingTicker: null,
keepScreenOnEnabled: false,
// 上传前压缩策略:从录音源头降低采样率/码率,减少上传文件体积
recordingSampleRate: 16000,
recordingEncodeBitRate: 32000,
recordingSegmentDurationMs: 5 * 60 * 1000,
recordingManualStopRequested: false,
recordingCurrentSegmentNo: 0,
recordingCurrentSegmentStartAt: 0,
recordingCurrentSegmentElapsedSeconds: 0,
uploadingCountMap: {},
uploadResultMap: {}
}
},
mounted() {
@@ -299,6 +310,8 @@ export default {
this.fetchServiceStatusList();
},
beforeUnmount() {
this.stopRecordingTicker();
this.setKeepScreenOn(false);
if (this.recorderManager && this.recordingServiceId) {
try {
this.recorderManager.stop();
@@ -308,79 +321,160 @@ export default {
}
},
methods: {
toggleActionMenu(recordId) {
const id = recordId === undefined || recordId === null ? null : String(recordId);
if (!id) {
this.actionMenuForId = null;
return;
}
this.actionMenuForId = this.actionMenuForId === id ? null : id;
},
closeActionMenu() {
this.actionMenuForId = null;
},
onActionMenuSelect(action, item) {
if (!item || !item.id) {
this.closeActionMenu();
return;
}
const id = String(item.id);
if (this.actionMenuForId && String(this.actionMenuForId) !== id) {
// 避免列表复用导致错点
this.actionMenuForId = id;
}
if (action === 'record') {
if (this.isRecordingItem(item.id)) {
this.stopPhoneRecordAndUpload(item);
} else {
this.startPhoneRecord(item);
}
this.closeActionMenu();
return;
}
if (action === 'supplement') {
this.showSupplementDialog(item);
this.closeActionMenu();
return;
}
if (action === 'upload') {
if (this.isUploading(item.id)) {
return;
}
this.chooseAndUploadFile(item);
this.closeActionMenu();
return;
}
this.closeActionMenu();
},
initServiceRecorder() {
this.recorderSupported = typeof uni.getRecorderManager === 'function';
if (!this.recorderSupported) {
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;
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.recordingManualStopRequested = false;
this.recordingCurrentSegmentNo = 0;
this.recordingCurrentSegmentStartAt = 0;
this.recordingCurrentSegmentElapsedSeconds = 0;
this.setKeepScreenOn(false);
});
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' });
});
},
setKeepScreenOn(enabled) {
if (typeof uni.setKeepScreenOn !== 'function') {
return;
}
uni.setKeepScreenOn({
keepScreenOn: !!enabled,
success: () => {
this.keepScreenOnEnabled = !!enabled;
},
fail: () => {
this.keepScreenOnEnabled = false;
},
});
},
startRecordingTicker() {
this.stopRecordingTicker();
this.recordingElapsedSeconds = 0;
this.recordingEstimatedSizeBytes = 0;
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);
},
stopRecordingTicker() {
if (this.recordingTicker) {
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');
const remainSeconds = String(safeSeconds % 60).padStart(2, '0');
return `${minutes}:${remainSeconds}`;
},
formatRecordingSize(bytes) {
const size = Math.max(0, Number(bytes) || 0);
if (size < 1024) {
return `${size} B`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${(size / (1024 * 1024)).toFixed(2)} MB`;
},
isRecordingItem(recordId) {
if (!recordId || !this.recordingServiceId) {
return false;
@@ -410,21 +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'
});
} catch (e) {
this.recordingServiceId = null;
this.recordingContext = null;
uni.showToast({ title: '无法开始录音', icon: 'none' });
}
this.setKeepScreenOn(true);
this.startNewSegmentRecording();
},
/** 结束录音onStop 回调中会调用 uploadFileForRecord 上传至后端 */
stopPhoneRecordAndUpload(item) {
@@ -434,6 +522,7 @@ export default {
if (String(item.id) !== this.recordingServiceId) {
return;
}
this.recordingManualStopRequested = true;
this.recorderManager.stop();
},
/**
@@ -693,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) {
@@ -818,56 +941,82 @@ export default {
}
return headers;
},
async uploadFileForRecord(item, selectedFile) {
async uploadFileForRecord(item, selectedFile, options = {}) {
const recordId = String(item.id);
this.closeActionMenu();
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) {
@@ -878,8 +1027,6 @@ export default {
});
return;
}
this.closeActionMenu();
uni.showModal({
title: '确认结束',
content: `确定要结束这条服务记录吗?`,
@@ -1285,92 +1432,65 @@ export default {
font-weight: 500;
color: #333;
}
.service-status {
display: flex;
align-items: center;
gap: 8rpx;
}
.action-trigger {
.service-status-indicator {
display: inline-flex;
align-items: center;
gap: 6rpx;
padding: 0;
border-radius: 10rpx;
background-color: #EEF4FF;
overflow: hidden;
flex-shrink: 0;
}
.action-trigger__primary {
padding: 6rpx 12rpx;
display: inline-flex;
align-items: center;
justify-content: center;
background-color: transparent;
}
.action-trigger__primary-text {
.service-status-indicator__text {
font-size: 26rpx;
color: #007AFF;
white-space: nowrap;
}
.service-actions-row {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 18rpx;
}
.action-trigger__more {
padding: 6rpx 10rpx 6rpx 6rpx;
.service-action-btn {
padding: 8rpx 18rpx;
border-radius: 10rpx;
font-size: 24rpx;
line-height: 1.2;
display: inline-flex;
align-items: center;
justify-content: center;
border-left: 1px solid rgba(0, 122, 255, 0.15);
background-color: transparent;
border: 1px solid transparent;
background-color: #F8FAFF;
}
.action-menu {
position: absolute;
right: 24rpx;
top: 96rpx;
min-width: 220rpx;
background-color: #FFFFFF;
border-radius: 16rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.12);
border: 1px solid #EEF0F3;
z-index: 50;
overflow: hidden;
}
.action-menu-item {
padding: 22rpx 24rpx;
display: flex;
align-items: center;
justify-content: flex-start;
}
.action-menu-item text {
font-size: 28rpx;
color: #111827;
}
.action-menu-item:active {
background-color: #F5F7FA;
}
.action-menu-item--disabled {
opacity: 0.55;
}
.action-menu-mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-color: transparent;
z-index: 40;
}
.service-status text {
font-size: 26rpx;
.service-action-btn--record {
color: #007AFF;
border-color: rgba(0, 122, 255, 0.22);
}
.service-action-btn--recording {
color: #FF3B30;
border-color: rgba(255, 59, 48, 0.25);
}
.service-action-btn--supplement {
color: #10B981;
border-color: rgba(16, 185, 129, 0.25);
}
.service-action-btn--upload {
color: #7C3AED;
border-color: rgba(124, 58, 237, 0.25);
}
.service-action-btn--finish {
color: #EF4444;
border-color: rgba(239, 68, 68, 0.28);
background-color: #FFF7F7;
}
.customer-tags {
@@ -1543,6 +1663,45 @@ export default {
color: #999;
font-size: 28rpx;
}
.recording-realtime {
margin-top: 12rpx;
display: flex;
align-items: center;
gap: 20rpx;
}
.recording-realtime text {
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 {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long