282 lines
5.7 KiB
Vue
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>
|