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:
@@ -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); }
|
||||||
|
|||||||
Reference in New Issue
Block a user