feat(voice): 录音时实时读秒,60 秒自动停止
- 入驻申请页/大神工作台:点击开始录音后,按钮显示已录秒数,状态栏显示剩余秒数(每秒刷新) - 录音中按钮转为红橙渐变并呼吸闪烁,状态文字高亮;离开页面/出错自动清理计时器 - 60 秒到点自动 stop 录音,避免超长被服务端拒绝 - voice-intro-consistency 增补读秒护栏(107 套件/710 用例全绿)
这个提交包含在:
@@ -187,13 +187,13 @@
|
||||
<text class="label-hint">(可选,≤60 秒;审核与买家试听用)</text>
|
||||
</view>
|
||||
<view class="voice-row">
|
||||
<button class="voice-btn btn-active" :disabled="voiceUploading" @click="toggleRecord">
|
||||
{{ voiceRecording ? '停止录音' : (voiceRecorded ? '重新录制' : '开始录音') }}
|
||||
<button class="voice-btn btn-active" :class="{ recording: voiceRecording }" :disabled="voiceUploading" @click="toggleRecord">
|
||||
{{ voiceRecording ? `停止录音 ${voiceSeconds}s` : (voiceRecorded ? '重新录制' : '开始录音') }}
|
||||
</button>
|
||||
<button v-if="voiceRecorded" class="voice-btn btn-active" @click="playRecorded">
|
||||
<button v-if="voiceRecorded && !voiceRecording" class="voice-btn btn-active" @click="playRecorded">
|
||||
{{ voicePlaying ? '停止播放' : '试听' }}
|
||||
</button>
|
||||
<text class="voice-status">{{ voiceStatusText }}</text>
|
||||
<text class="voice-status" :class="{ recording: voiceRecording }">{{ voiceStatusText }}</text>
|
||||
</view>
|
||||
<text class="voice-tip">语音沟通在游戏内或微信进行,请勿在录音中透露联系方式。</text>
|
||||
</view>
|
||||
@@ -244,25 +244,49 @@ const voiceRecorded = ref(false);
|
||||
const voicePlaying = ref(false);
|
||||
const voiceUploading = ref(false);
|
||||
const voiceDuration = ref(0);
|
||||
const voiceSeconds = ref(0);
|
||||
let voiceTempPath = '';
|
||||
let recorderManager = null;
|
||||
let voiceAudioCtx = null;
|
||||
let voiceTimer = null;
|
||||
const voiceStatusText = computed(() => {
|
||||
if (voiceUploading.value) return '上传中…';
|
||||
if (voiceRecording.value) return `录音中 · 剩余 ${Math.max(0, 60 - voiceSeconds.value)}s`;
|
||||
if (voiceRecorded.value) return `已录制 ${voiceDuration.value} 秒(提交后自动上传)`;
|
||||
return '未录制(可跳过)';
|
||||
});
|
||||
|
||||
const stopVoiceTimer = () => {
|
||||
if (voiceTimer) {
|
||||
clearInterval(voiceTimer);
|
||||
voiceTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startVoiceTimer = () => {
|
||||
stopVoiceTimer();
|
||||
voiceSeconds.value = 0;
|
||||
voiceTimer = setInterval(() => {
|
||||
voiceSeconds.value += 1;
|
||||
if (voiceSeconds.value >= 60) {
|
||||
stopVoiceTimer();
|
||||
try { recorderManager && recorderManager.stop(); } catch (_) {}
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const ensureRecorder = () => {
|
||||
if (recorderManager) return recorderManager;
|
||||
recorderManager = uni.getRecorderManager();
|
||||
recorderManager.onStop((res) => {
|
||||
stopVoiceTimer();
|
||||
voiceRecording.value = false;
|
||||
voiceTempPath = res.tempFilePath || '';
|
||||
voiceDuration.value = Math.max(1, Math.round(Number(res.duration || 0) / 1000) || 1);
|
||||
voiceRecorded.value = Boolean(voiceTempPath);
|
||||
});
|
||||
recorderManager.onError(() => {
|
||||
stopVoiceTimer();
|
||||
voiceRecording.value = false;
|
||||
uni.showToast({ title: '录音失败,请检查麦克风权限', icon: 'none' });
|
||||
});
|
||||
@@ -278,7 +302,7 @@ const toggleRecord = () => {
|
||||
const start = () => {
|
||||
rm.start({ duration: 60000, format: 'mp3', sampleRate: 16000, numberOfChannels: 1, encodeBitRate: 48000 });
|
||||
voiceRecording.value = true;
|
||||
uni.showToast({ title: '开始录音,最长 60 秒', icon: 'none' });
|
||||
startVoiceTimer();
|
||||
};
|
||||
uni.authorize({
|
||||
scope: 'scope.record',
|
||||
@@ -305,6 +329,7 @@ const playRecorded = () => {
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
stopVoiceTimer();
|
||||
try { if (recorderManager && voiceRecording.value) recorderManager.stop(); } catch (_) {}
|
||||
try { if (voiceAudioCtx) { voiceAudioCtx.destroy(); voiceAudioCtx = null; } } catch (_) {}
|
||||
});
|
||||
@@ -639,10 +664,23 @@ onShow(() => {
|
||||
color: #FFFFFF;
|
||||
background: linear-gradient(135deg, #6366F1 0%, #8B5CF6 100%);
|
||||
}
|
||||
.voice-btn.recording {
|
||||
background: linear-gradient(135deg, #EF4444 0%, #F97316 100%);
|
||||
animation: voice-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes voice-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.72; }
|
||||
}
|
||||
.voice-status {
|
||||
font-size: 22rpx;
|
||||
color: var(--text-muted, #94A3B8);
|
||||
}
|
||||
.voice-status.recording {
|
||||
color: #F97316;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.voice-tip {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
|
||||
@@ -204,14 +204,14 @@
|
||||
<text class="sec-sub">≤60 秒 · 仅本人录音</text>
|
||||
</view>
|
||||
<view class="voice-row">
|
||||
<button class="btn-wd-records btn-active" :disabled="voiceUploading" @click="toggleRecord">
|
||||
{{ voiceRecording ? '停止录音' : (expert.voiceIntroUrl ? '重新录制' : '录制语音') }}
|
||||
<button class="btn-wd-records btn-active" :class="{ recording: voiceRecording }" :disabled="voiceUploading" @click="toggleRecord">
|
||||
{{ voiceRecording ? `停止录音 ${voiceSeconds}s` : (expert.voiceIntroUrl ? '重新录制' : '录制语音') }}
|
||||
</button>
|
||||
<button v-if="expert.voiceIntroUrl" class="btn-wd-records btn-active" @click="playVoice">
|
||||
<button v-if="expert.voiceIntroUrl && !voiceRecording" class="btn-wd-records btn-active" @click="playVoice">
|
||||
{{ voicePlaying ? '停止播放' : '播放试听' }}
|
||||
</button>
|
||||
<text class="voice-meta">
|
||||
{{ voiceUploading ? '上传中…' : (expert.voiceIntroUrl ? `已上传 · ${expert.voiceDuration || 0} 秒` : '暂未上传') }}
|
||||
<text class="voice-meta" :class="{ recording: voiceRecording }">
|
||||
{{ voiceRecording ? `录音中 · 剩余 ${Math.max(0, 60 - voiceSeconds)}s` : (voiceUploading ? '上传中…' : (expert.voiceIntroUrl ? `已上传 · ${expert.voiceDuration || 0} 秒` : '暂未上传')) }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="voice-tip">语音仅用于买家试听介绍,实际语音沟通在游戏内或微信进行。</text>
|
||||
@@ -607,18 +607,41 @@ const loadExpertServices = async () => {
|
||||
const voiceRecording = ref(false);
|
||||
const voiceUploading = ref(false);
|
||||
const voicePlaying = ref(false);
|
||||
const voiceSeconds = ref(0);
|
||||
let recorderManager = null;
|
||||
let voiceAudioCtx = null;
|
||||
let voiceTimer = null;
|
||||
|
||||
const stopVoiceTimer = () => {
|
||||
if (voiceTimer) {
|
||||
clearInterval(voiceTimer);
|
||||
voiceTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startVoiceTimer = () => {
|
||||
stopVoiceTimer();
|
||||
voiceSeconds.value = 0;
|
||||
voiceTimer = setInterval(() => {
|
||||
voiceSeconds.value += 1;
|
||||
if (voiceSeconds.value >= 60) {
|
||||
stopVoiceTimer();
|
||||
try { recorderManager && recorderManager.stop(); } catch (_) {}
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const ensureRecorder = () => {
|
||||
if (recorderManager) return recorderManager;
|
||||
recorderManager = uni.getRecorderManager();
|
||||
recorderManager.onStop((res) => {
|
||||
stopVoiceTimer();
|
||||
voiceRecording.value = false;
|
||||
const duration = Math.max(1, Math.round(Number(res.duration || 0) / 1000) || 1);
|
||||
uploadRecordedVoice(res.tempFilePath, duration);
|
||||
});
|
||||
recorderManager.onError(() => {
|
||||
stopVoiceTimer();
|
||||
voiceRecording.value = false;
|
||||
uni.showToast({ title: '录音失败,请检查麦克风权限', icon: 'none' });
|
||||
});
|
||||
@@ -648,7 +671,7 @@ const toggleRecord = () => {
|
||||
const start = () => {
|
||||
rm.start({ duration: 60000, format: 'mp3', sampleRate: 16000, numberOfChannels: 1, encodeBitRate: 48000 });
|
||||
voiceRecording.value = true;
|
||||
uni.showToast({ title: '开始录音,最长 60 秒', icon: 'none' });
|
||||
startVoiceTimer();
|
||||
};
|
||||
uni.authorize({
|
||||
scope: 'scope.record',
|
||||
@@ -676,6 +699,7 @@ const playVoice = () => {
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
stopVoiceTimer();
|
||||
try { if (recorderManager && voiceRecording.value) recorderManager.stop(); } catch (_) {}
|
||||
try { if (voiceAudioCtx) { voiceAudioCtx.destroy(); voiceAudioCtx = null; } } catch (_) {}
|
||||
});
|
||||
@@ -2296,6 +2320,27 @@ const switchBackToPlayer = () => {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.voice-meta.recording {
|
||||
color: #F97316;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.btn-wd-records {
|
||||
margin: 0;
|
||||
padding: 0 26rpx;
|
||||
height: 62rpx;
|
||||
line-height: 62rpx;
|
||||
border-radius: 31rpx;
|
||||
font-size: 24rpx;
|
||||
color: #FFFFFF;
|
||||
background: linear-gradient(135deg, #6366F1 0%, #8B5CF6 100%);
|
||||
|
||||
&::after { display: none; }
|
||||
}
|
||||
.btn-wd-records.recording {
|
||||
background: linear-gradient(135deg, #EF4444 0%, #F97316 100%);
|
||||
animation: voice-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.voice-tip {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
@@ -2305,6 +2350,11 @@ const switchBackToPlayer = () => {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes voice-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.72; }
|
||||
}
|
||||
|
||||
/* 服务包操作与表单弹窗 */
|
||||
.srv-ops {
|
||||
display: flex;
|
||||
|
||||
@@ -29,6 +29,19 @@ describe('第九十一批语音介绍三端一致性闸门', () => {
|
||||
expect(apply).toContain('duration: 60000');
|
||||
});
|
||||
|
||||
it('录音时实时读秒并在 60 秒自动停止', () => {
|
||||
const workspace = fs.readFileSync(path.join(root, 'pwuniapp/pages/expert/workspace.vue'), 'utf-8');
|
||||
for (const src of [apply, workspace]) {
|
||||
expect(src).toContain('startVoiceTimer');
|
||||
expect(src).toContain('stopVoiceTimer');
|
||||
expect(src).toContain('setInterval');
|
||||
expect(src).toContain('voiceSeconds.value >= 60');
|
||||
expect(src).toContain('recorderManager.stop()');
|
||||
}
|
||||
expect(apply).toContain('剩余 ${Math.max(0, 60 - voiceSeconds.value)}s');
|
||||
expect(workspace).toContain('剩余 ${Math.max(0, 60 - voiceSeconds)}s');
|
||||
});
|
||||
|
||||
const scanSensitive = (dir: string): string[] => {
|
||||
const hits: string[] = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
|
||||
在新工单中引用
屏蔽一个用户