xiangyixiangqin/miniapp/components/VoiceRecorder/index.vue
2026-02-07 21:50:03 +08:00

282 lines
5.7 KiB
Vue

<template>
<view class="voice-recorder">
<!-- 按住说话按钮 -->
<view
class="voice-btn"
:class="{ recording: isRecording }"
@touchstart.prevent="handleTouchStart"
@touchmove.prevent="handleTouchMove"
@touchend.prevent="handleTouchEnd"
@touchcancel.prevent="handleTouchCancel"
>
<text>{{ btnText }}</text>
</view>
<!-- 录音提示弹窗 -->
<view class="voice-modal" v-if="showModal">
<view class="voice-modal-content" :class="{ cancel: isCancelling }">
<view class="voice-icon">
<view class="voice-wave" :class="{ active: isRecording && !isCancelling }"></view>
<text class="icon">{{ isCancelling ? '↩' : '🎤' }}</text>
</view>
<text class="voice-tip">{{ recordingTip }}</text>
<text class="voice-time" v-if="!isCancelling">{{ recordingTime }}s</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onUnmounted } from 'vue'
const emit = defineEmits(['send'])
const isRecording = ref(false)
const showModal = ref(false)
const isCancelling = ref(false)
const recordingTime = ref(0)
const recorderManager = ref(null)
const recordingTimer = ref(null)
const startY = ref(0)
// 是否是用户主动取消(上滑取消)
let cancelled = false
const btnText = computed(() => {
if (isRecording.value && isCancelling.value) return '松开取消'
if (isRecording.value) return '松开发送'
return '按住说话'
})
const recordingTip = computed(() => {
if (isCancelling.value) return '松开手指,取消发送'
return '正在录音...'
})
// 初始化录音管理器
const initRecorder = () => {
if (recorderManager.value) return
recorderManager.value = uni.getRecorderManager()
recorderManager.value.onStart(() => {
console.log('[VoiceRecorder] 录音开始')
isRecording.value = true
showModal.value = true
recordingTime.value = 0
cancelled = false
recordingTimer.value = setInterval(() => {
recordingTime.value++
if (recordingTime.value >= 60) {
stopRecording()
}
}, 1000)
})
recorderManager.value.onStop((res) => {
console.log('[VoiceRecorder] 录音结束:', res, '已取消:', cancelled)
clearTimer()
isRecording.value = false
showModal.value = false
isCancelling.value = false
// 如果是取消的,不发送
if (cancelled) {
cancelled = false
return
}
const duration = Math.ceil((res.duration || 0) / 1000)
if (duration < 1) {
uni.showToast({ title: '录音时间太短', icon: 'none' })
return
}
emit('send', {
tempFilePath: res.tempFilePath,
duration: duration
})
})
recorderManager.value.onError((err) => {
console.error('[VoiceRecorder] 录音错误:', err)
clearTimer()
isRecording.value = false
showModal.value = false
isCancelling.value = false
cancelled = false
uni.showToast({ title: '录音失败,请检查麦克风权限', icon: 'none' })
})
}
const clearTimer = () => {
if (recordingTimer.value) {
clearInterval(recordingTimer.value)
recordingTimer.value = null
}
}
const stopRecording = () => {
if (recorderManager.value) {
recorderManager.value.stop()
}
}
// 开始录音
const handleTouchStart = (e) => {
startY.value = e.touches[0].clientY
isCancelling.value = false
cancelled = false
initRecorder()
recorderManager.value.start({
duration: 60000,
sampleRate: 16000,
numberOfChannels: 1,
encodeBitRate: 48000,
format: 'mp3'
})
}
// 手指移动 - 上滑取消
const handleTouchMove = (e) => {
if (!isRecording.value) return
const moveY = e.touches[0].clientY
const diff = startY.value - moveY
// 上滑超过80px进入取消状态
isCancelling.value = diff > 80
}
// 松开手指 - 发送或取消
const handleTouchEnd = () => {
if (!isRecording.value) return
if (isCancelling.value) {
// 取消录音
cancelled = true
stopRecording()
} else {
// 正常结束,发送
stopRecording()
}
}
// 系统取消(如来电等)
const handleTouchCancel = () => {
if (!isRecording.value) return
cancelled = true
stopRecording()
}
onUnmounted(() => {
clearTimer()
})
</script>
<style lang="scss" scoped>
.voice-recorder {
flex: 1;
display: flex;
align-items: center;
}
.voice-btn {
flex: 1;
height: 72rpx;
line-height: 72rpx;
text-align: center;
background: #f5f5f5;
border-radius: 36rpx;
font-size: 28rpx;
color: #333;
&.recording {
background: #e0e0e0;
color: #999;
}
}
.voice-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
}
.voice-modal-content {
width: 300rpx;
height: 300rpx;
background: rgba(0, 0, 0, 0.75);
border-radius: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
&.cancel {
background: rgba(200, 50, 50, 0.85);
}
}
.voice-icon {
position: relative;
width: 120rpx;
height: 120rpx;
display: flex;
align-items: center;
justify-content: center;
.icon {
font-size: 60rpx;
z-index: 1;
}
}
.voice-wave {
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
border: 4rpx solid rgba(255, 255, 255, 0.3);
&.active {
animation: wave 1.2s ease-in-out infinite;
}
}
@keyframes wave {
0% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(1.5);
opacity: 0;
}
}
.voice-tip {
color: #fff;
font-size: 26rpx;
margin-top: 20rpx;
}
.voice-time {
color: rgba(255, 255, 255, 0.8);
font-size: 24rpx;
margin-top: 10rpx;
}
</style>