feat(voice): 录音时实时读秒,60 秒自动停止

- 入驻申请页/大神工作台:点击开始录音后,按钮显示已录秒数,状态栏显示剩余秒数(每秒刷新)

- 录音中按钮转为红橙渐变并呼吸闪烁,状态文字高亮;离开页面/出错自动清理计时器

- 60 秒到点自动 stop 录音,避免超长被服务端拒绝

- voice-intro-consistency 增补读秒护栏(107 套件/710 用例全绿)
这个提交包含在:
xcbyy
2026-10-21 11:20:22 +08:00
父节点 ae62f2b805
当前提交 a249a5ee62
共有 3 个文件被更改,包括 112 次插入 和 11 次删除

查看文件

@@ -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 })) {