diff --git a/pwuniapp/pages/expert/apply.vue b/pwuniapp/pages/expert/apply.vue
index 8950888..40832bf 100644
--- a/pwuniapp/pages/expert/apply.vue
+++ b/pwuniapp/pages/expert/apply.vue
@@ -187,13 +187,13 @@
(可选,≤60 秒;审核与买家试听用)
-
语音沟通在游戏内或微信进行,请勿在录音中透露联系方式。
@@ -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;
diff --git a/pwuniapp/pages/expert/workspace.vue b/pwuniapp/pages/expert/workspace.vue
index 6d97855..f6869af 100644
--- a/pwuniapp/pages/expert/workspace.vue
+++ b/pwuniapp/pages/expert/workspace.vue
@@ -204,14 +204,14 @@
≤60 秒 · 仅本人录音
-
- {{ voiceRecording ? '停止录音' : (expert.voiceIntroUrl ? '重新录制' : '录制语音') }}
+
+ {{ voiceRecording ? `停止录音 ${voiceSeconds}s` : (expert.voiceIntroUrl ? '重新录制' : '录制语音') }}
-
+
{{ voicePlaying ? '停止播放' : '播放试听' }}
-
- {{ voiceUploading ? '上传中…' : (expert.voiceIntroUrl ? `已上传 · ${expert.voiceDuration || 0} 秒` : '暂未上传') }}
+
+ {{ voiceRecording ? `录音中 · 剩余 ${Math.max(0, 60 - voiceSeconds)}s` : (voiceUploading ? '上传中…' : (expert.voiceIntroUrl ? `已上传 · ${expert.voiceDuration || 0} 秒` : '暂未上传')) }}
语音仅用于买家试听介绍,实际语音沟通在游戏内或微信进行。
@@ -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;
diff --git a/server/tests/voice-intro-consistency.spec.ts b/server/tests/voice-intro-consistency.spec.ts
index 0fa7873..4f19f99 100644
--- a/server/tests/voice-intro-consistency.spec.ts
+++ b/server/tests/voice-intro-consistency.spec.ts
@@ -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 })) {