fix: 录音格式改回 aac + 对话 UX 优化

录音:
- 从 mp3/16kHz/48kbps 改回原始工作配置 aac/22050Hz/16kbps
  (mp3 和 48kbps 在部分微信版本兼容不佳)
- 增加 recordingDuration ref 显示录音秒数
- onError 增加 JSON.stringify 打印详细错误原因
- 增加权限被拒的判断提示

UX 优化:
- 每条消息增加角色头像 (🤖 AI / 👤 用户)
- 增加 msg-body 容器 + msg-label 角色标签 (面试官/我)
- 对话布局改为左侧头像+气泡+名称,右侧用户头像+气泡
- msg-body max-width:70% 限制气泡宽度
- 麦克风按钮放大(80rpx),增加文字标签「按住」/「Ns」
- 录音时禁用输入框(isRecording 时 textarea disabled)
This commit is contained in:
yuzhiran
2026-07-05 17:00:29 +08:00
parent b38b38d0c8
commit 8152278b86
+40 -17
View File
@@ -38,10 +38,15 @@
<!-- Chat area (both modes) --> <!-- Chat area (both modes) -->
<scroll-view class="chat-area" scroll-y :scroll-into-view="scrollToId" :scroll-with-animation="true" :class="{ 'chat-compact': avatarMode }"> <scroll-view class="chat-area" scroll-y :scroll-into-view="scrollToId" :scroll-with-animation="true" :class="{ 'chat-compact': avatarMode }">
<view v-for="(msg, idx) in messages" :key="idx" :id="'msg-' + idx" class="msg-row" :class="msg.role"> <view v-for="(msg, idx) in messages" :key="idx" :id="'msg-' + idx" class="msg-row" :class="msg.role">
<view v-if="msg.role === 'ai'" class="msg-avatar ai-avatar">🤖</view>
<view class="msg-body">
<view class="msg-label">{{ msg.role === 'ai' ? '面试官' : '我' }}</view>
<view class="msg-bubble" :class="msg.role"> <view class="msg-bubble" :class="msg.role">
<text>{{ msg.content }}</text> <text>{{ msg.content }}</text>
</view> </view>
</view> </view>
<view v-if="msg.role === 'user'" class="msg-avatar user-avatar">👤</view>
</view>
<view class="msg-row ai" v-if="aiLoading"> <view class="msg-row ai" v-if="aiLoading">
<view class="typing"> <view class="typing">
@@ -56,10 +61,11 @@
<view class="input-bar" v-if="!isComplete"> <view class="input-bar" v-if="!isComplete">
<view class="mic-btn" :class="{ recording: isRecording }" @touchstart="startRecord" @touchend="stopRecord" @touchcancel="stopRecord" @mousedown="startRecord" @mouseup="stopRecord" @mouseleave="stopRecord"> <view class="mic-btn" :class="{ recording: isRecording }" @touchstart="startRecord" @touchend="stopRecord" @touchcancel="stopRecord" @mousedown="startRecord" @mouseup="stopRecord" @mouseleave="stopRecord">
<text class="mic-icon">🎤</text> <text class="mic-icon">{{ isRecording ? '🔴' : '🎤' }}</text>
<text class="mic-label">{{ isRecording ? recordingDuration + 's' : '按住' }}</text>
</view> </view>
<view class="input-box"> <view class="input-box">
<textarea class="input-area" v-model="inputText" placeholder="输入你的回答..." :auto-height="true" :maxlength="2000" :disabled="aiLoading" @confirm="sendAnswer" /> <textarea class="input-area" v-model="inputText" placeholder="输入你的回答..." :auto-height="true" :maxlength="2000" :disabled="aiLoading || isRecording" @confirm="sendAnswer" />
</view> </view>
<view class="send-btn" :class="{ disabled: (!inputText.trim() && !isRecording) || aiLoading }" @click="sendAnswer"> <view class="send-btn" :class="{ disabled: (!inputText.trim() && !isRecording) || aiLoading }" @click="sendAnswer">
<text class="send-icon">{{ isRecording ? '◉' : '➤' }}</text> <text class="send-icon">{{ isRecording ? '◉' : '➤' }}</text>
@@ -146,25 +152,31 @@ const aiAmplitudeData = ref([])
const isSpeaking = ref(false) const isSpeaking = ref(false)
const dhRef = ref(null) const dhRef = ref(null)
const isRecording = ref(false) const isRecording = ref(false)
const recordingDuration = ref(0)
let recorder = null let recorder = null
let recordTimer = null
// 录音初始化(一次性创建 recorder 并设置事件)
function initRecorder() { function initRecorder() {
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
if (recorder) return // 已初始化 if (recorder) return
recorder = uni.getRecorderManager() recorder = uni.getRecorderManager()
recorder.onStart(() => { recorder.onStart(() => {
console.log('[Recorder] start recording') console.log('[Recorder] start recording')
recordingDuration.value = 0
recordTimer = setInterval(() => { recordingDuration.value++ }, 1000)
}) })
recorder.onError((err) => { recorder.onError((err) => {
console.error('[Recorder] error:', err) console.error('[Recorder] error:', JSON.stringify(err))
isRecording.value = false isRecording.value = false
uni.showToast({ title: '录音失败,请重试', icon: 'none' }) recordingDuration.value = 0
if (recordTimer) { clearInterval(recordTimer); recordTimer = null }
const msg = err?.errMsg?.includes('permission') ? '请允许录音权限' : (err?.errMsg || '录音失败,请重试')
uni.showToast({ title: msg, icon: 'none' })
}) })
recorder.onStop(async (res) => { recorder.onStop(async (res) => {
isRecording.value = false isRecording.value = false
console.log('[Recorder] stop, tempFilePath:', res.tempFilePath) recordingDuration.value = 0
if (recordTimer) { clearInterval(recordTimer); recordTimer = null }
if (!res.tempFilePath) { if (!res.tempFilePath) {
uni.showToast({ title: '录音文件为空', icon: 'none' }) uni.showToast({ title: '录音文件为空', icon: 'none' })
return return
@@ -417,10 +429,10 @@ function startRecord() {
return return
} }
recorder.start({ recorder.start({
format: 'mp3', format: 'aac',
sampleRate: 16000, sampleRate: 22050,
numberOfChannels: 1, numberOfChannels: 1,
encodeBitRate: 48000, encodeBitRate: 16000,
}) })
uni.vibrateShort({ type: 'medium' }) uni.vibrateShort({ type: 'medium' })
// #endif // #endif
@@ -471,11 +483,20 @@ function stopRecord() {
/* Chat */ /* Chat */
.chat-area { flex: 1; padding: 24rpx 20rpx; overflow-y: auto; } .chat-area { flex: 1; padding: 24rpx 20rpx; overflow-y: auto; }
.chat-compact { max-height: 40vh; } .chat-compact { max-height: 40vh; }
.msg-row { display: flex; margin-bottom: 24rpx; } .msg-row { display: flex; margin-bottom: 32rpx; align-items: flex-start; gap: 12rpx; }
.msg-row.ai { justify-content: flex-start; } .msg-row.ai { justify-content: flex-start; }
.msg-row.user { justify-content: flex-end; } .msg-row.user { justify-content: flex-end; }
.msg-bubble { max-width: 560rpx; padding: 20rpx 24rpx; line-height: 1.7; font-size: 26rpx; } .msg-avatar { width: 48rpx; height: 48rpx; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 24rpx; flex-shrink: 0; }
.ai-avatar { background: #EEF2FF; }
.user-avatar { background: #FEF3C7; }
.msg-body { max-width: 70%; display: flex; flex-direction: column; gap: 6rpx; }
.msg-row.user .msg-body { align-items: flex-end; }
.msg-label { font-size: 20rpx; color: #9CA3AF; padding: 0 8rpx; }
.msg-bubble { padding: 20rpx 24rpx; line-height: 1.7; font-size: 26rpx; word-break: break-word; }
.msg-bubble.ai { .msg-bubble.ai {
background: #FFFFFF; color: var(--color-text); background: #FFFFFF; color: var(--color-text);
border-radius: 0 var(--radius-lg) var(--radius-lg) var(--radius-lg); border-radius: 0 var(--radius-lg) var(--radius-lg) var(--radius-lg);
@@ -509,13 +530,15 @@ function stopRecord() {
.input-box { flex: 1; background: var(--color-bg); border-radius: var(--radius-md); padding: 12rpx 20rpx; } .input-box { flex: 1; background: var(--color-bg); border-radius: var(--radius-md); padding: 12rpx 20rpx; }
.input-area { width: 100%; font-size: 26rpx; color: var(--color-text); max-height: 160rpx; line-height: 1.5; } .input-area { width: 100%; font-size: 26rpx; color: var(--color-text); max-height: 160rpx; line-height: 1.5; }
.mic-btn { .mic-btn {
width: 64rpx; height: 64rpx; border-radius: 50%; background: #F3F4F6; width: 80rpx; height: 80rpx; border-radius: 50%; background: #F3F4F6;
display: flex; align-items: center; justify-content: center; flex-shrink: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; flex-shrink: 0;
transition: all 0.2s; transition: all 0.2s; gap: 2rpx;
} }
.mic-btn:active { transform: scale(0.9); } .mic-btn:active { transform: scale(0.9); }
.mic-btn.recording { background: #FEE2E2; animation: mic-pulse 1s infinite; } .mic-btn.recording { background: #FEE2E2; animation: mic-pulse 1s infinite; }
.mic-icon { font-size: 28rpx; } .mic-icon { font-size: 28rpx; line-height: 1; }
.mic-label { font-size: 16rpx; color: #9CA3AF; line-height: 1; }
.mic-btn.recording .mic-label { color: #EF4444; font-weight: 600; }
@keyframes mic-pulse { @keyframes mic-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); } 0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
50% { box-shadow: 0 0 0 16rpx rgba(239, 68, 68, 0); } 50% { box-shadow: 0 0 0 16rpx rgba(239, 68, 68, 0); }