feat:聊天页面相关功能优化,提供用户体验感
This commit is contained in:
@@ -1,16 +1,24 @@
|
||||
<!--
|
||||
语音录制组件
|
||||
微信风格:长按录音 / 松手发送 / 上滑取消
|
||||
最长 60 秒,不足 1 秒提示太短
|
||||
- 微信风格:长按录音 / 松手发送 / 上滑取消,最长 60 秒
|
||||
- 事件兼容:同时支持 touch(移动端真机)与 mouse(PC H5 调试)
|
||||
- 平台适配:
|
||||
• App/小程序:uni.getRecorderManager()(原生实现)
|
||||
• H5 端:MediaRecorder + getUserMedia(uni-app H5 不支持 getRecorderManager,自行实现)
|
||||
-->
|
||||
<template>
|
||||
<view class="voice-recorder">
|
||||
<view
|
||||
class="record-btn"
|
||||
:class="{ 'recording': isRecording, 'cancel-zone': isCancelZone }"
|
||||
@touchstart.prevent="onTouchStart"
|
||||
@touchmove.prevent="onTouchMove"
|
||||
@touchend.prevent="onTouchEnd"
|
||||
@touchstart.prevent="onPressStart"
|
||||
@touchmove.prevent="onPressMove"
|
||||
@touchend.prevent="onPressEnd"
|
||||
@touchcancel.prevent="onPressEnd"
|
||||
@mousedown.prevent="onPressStart"
|
||||
@mousemove.prevent="onPressMove"
|
||||
@mouseup.prevent="onPressEnd"
|
||||
@mouseleave="onMouseLeave"
|
||||
>
|
||||
<text class="record-text">
|
||||
{{ isRecording ? (isCancelZone ? '松开取消' : '松开发送') : '按住 说话' }}
|
||||
@@ -31,6 +39,103 @@
|
||||
<script>
|
||||
import { ref, onBeforeUnmount } from 'vue'
|
||||
|
||||
/**
|
||||
* H5 端录音器实现(基于 MediaRecorder + getUserMedia)
|
||||
* 与 uni.getRecorderManager() 对齐接口:onStart / onStop / onError / start / stop
|
||||
*/
|
||||
class H5Recorder {
|
||||
constructor() {
|
||||
this._startCb = null
|
||||
this._stopCb = null
|
||||
this._errCb = null
|
||||
this._mediaRecorder = null
|
||||
this._stream = null
|
||||
this._chunks = []
|
||||
this._mimeType = ''
|
||||
}
|
||||
onStart(cb) { this._startCb = cb }
|
||||
onStop(cb) { this._stopCb = cb }
|
||||
onError(cb) { this._errCb = cb }
|
||||
async start() {
|
||||
try {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
this._errCb && this._errCb({ errMsg: '浏览器不支持录音(需 HTTPS 或 localhost)' })
|
||||
return
|
||||
}
|
||||
this._stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
const candidates = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/mp4',
|
||||
'audio/ogg;codecs=opus'
|
||||
]
|
||||
this._mimeType = candidates.find(m => {
|
||||
try { return typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(m) } catch { return false }
|
||||
}) || ''
|
||||
const opts = this._mimeType ? { mimeType: this._mimeType } : undefined
|
||||
this._mediaRecorder = new MediaRecorder(this._stream, opts)
|
||||
this._chunks = []
|
||||
this._mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data && e.data.size > 0) this._chunks.push(e.data)
|
||||
}
|
||||
this._mediaRecorder.onstart = () => {
|
||||
this._startCb && this._startCb()
|
||||
}
|
||||
this._mediaRecorder.onstop = () => {
|
||||
const mime = this._mediaRecorder?.mimeType || this._mimeType || 'audio/webm'
|
||||
const blob = new Blob(this._chunks, { type: mime })
|
||||
const tempFilePath = URL.createObjectURL(blob)
|
||||
if (this._stream) {
|
||||
this._stream.getTracks().forEach(t => t.stop())
|
||||
this._stream = null
|
||||
}
|
||||
this._stopCb && this._stopCb({
|
||||
tempFilePath,
|
||||
fileSize: blob.size,
|
||||
duration: 0,
|
||||
blob,
|
||||
mimeType: mime
|
||||
})
|
||||
}
|
||||
this._mediaRecorder.onerror = (e) => {
|
||||
this._errCb && this._errCb({ errMsg: e?.error?.message || '录音异常' })
|
||||
}
|
||||
this._mediaRecorder.start()
|
||||
} catch (err) {
|
||||
const msg = err && err.name === 'NotAllowedError'
|
||||
? '麦克风权限被拒绝,请在浏览器地址栏允许访问麦克风'
|
||||
: (err?.message || '无法启动录音')
|
||||
this._errCb && this._errCb({ errMsg: msg })
|
||||
}
|
||||
}
|
||||
stop() {
|
||||
try {
|
||||
if (this._mediaRecorder && this._mediaRecorder.state !== 'inactive') {
|
||||
this._mediaRecorder.stop()
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨端创建录音器:优先使用浏览器 MediaRecorder;否则回退到 uni.getRecorderManager
|
||||
*/
|
||||
function createRecorder() {
|
||||
// H5 优先走 MediaRecorder(uni-app H5 的 getRecorderManager 是 stub)
|
||||
if (typeof window !== 'undefined'
|
||||
&& typeof window.MediaRecorder !== 'undefined'
|
||||
&& navigator && navigator.mediaDevices) {
|
||||
return new H5Recorder()
|
||||
}
|
||||
// App / 小程序 等 uni 原生端
|
||||
if (typeof uni !== 'undefined' && typeof uni.getRecorderManager === 'function') {
|
||||
try {
|
||||
return uni.getRecorderManager()
|
||||
} catch (_) { /* fallthrough */ }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'VoiceRecorder',
|
||||
emits: ['recorded'],
|
||||
@@ -41,25 +146,43 @@ export default {
|
||||
let recorder = null
|
||||
let timer = null
|
||||
let startY = 0
|
||||
// 防止 touchstart 与 mousedown 重复触发,以及 mouseleave 误停
|
||||
let pressing = false
|
||||
|
||||
const onTouchStart = (e) => {
|
||||
startY = e.touches[0].clientY
|
||||
const getClientY = (e) => {
|
||||
if (e.touches && e.touches.length > 0) return e.touches[0].clientY
|
||||
if (e.changedTouches && e.changedTouches.length > 0) return e.changedTouches[0].clientY
|
||||
return typeof e.clientY === 'number' ? e.clientY : 0
|
||||
}
|
||||
|
||||
const onPressStart = (e) => {
|
||||
if (pressing) return
|
||||
pressing = true
|
||||
startY = getClientY(e)
|
||||
isCancelZone.value = false
|
||||
recordingTime.value = 0
|
||||
|
||||
recorder = uni.getRecorderManager()
|
||||
recorder = createRecorder()
|
||||
if (!recorder) {
|
||||
pressing = false
|
||||
uni.showToast({ title: '当前环境不支持录音', icon: 'none' })
|
||||
return
|
||||
}
|
||||
recorder.onStart(() => {
|
||||
isRecording.value = true
|
||||
timer = setInterval(() => {
|
||||
recordingTime.value++
|
||||
if (recordingTime.value >= 60) {
|
||||
onTouchEnd()
|
||||
onPressEnd()
|
||||
}
|
||||
}, 1000)
|
||||
})
|
||||
recorder.onStop((res) => {
|
||||
clearInterval(timer)
|
||||
const wasRecording = isRecording.value
|
||||
isRecording.value = false
|
||||
pressing = false
|
||||
if (!wasRecording) return
|
||||
if (isCancelZone.value) return
|
||||
if (recordingTime.value < 1) {
|
||||
uni.showToast({ title: '录音时间太短', icon: 'none' })
|
||||
@@ -68,35 +191,56 @@ export default {
|
||||
emit('recorded', {
|
||||
tempFilePath: res.tempFilePath,
|
||||
duration: recordingTime.value,
|
||||
fileSize: res.fileSize || 0
|
||||
fileSize: res.fileSize || 0,
|
||||
blob: res.blob || null,
|
||||
mimeType: res.mimeType || ''
|
||||
})
|
||||
})
|
||||
recorder.onError(() => {
|
||||
recorder.onError((err) => {
|
||||
clearInterval(timer)
|
||||
isRecording.value = false
|
||||
uni.showToast({ title: '录音失败', icon: 'none' })
|
||||
pressing = false
|
||||
uni.showToast({ title: err?.errMsg || '录音失败', icon: 'none' })
|
||||
})
|
||||
|
||||
recorder.start({
|
||||
format: 'mp3',
|
||||
duration: 60000,
|
||||
sampleRate: 44100,
|
||||
numberOfChannels: 1
|
||||
})
|
||||
// uni 原生 recorder.start 接收参数对象;H5Recorder.start 忽略参数
|
||||
try {
|
||||
recorder.start({
|
||||
format: 'mp3',
|
||||
duration: 60000,
|
||||
sampleRate: 44100,
|
||||
numberOfChannels: 1
|
||||
})
|
||||
} catch (e) {
|
||||
pressing = false
|
||||
isRecording.value = false
|
||||
uni.showToast({ title: e?.message || '无法启动录音', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const onTouchMove = (e) => {
|
||||
const onPressMove = (e) => {
|
||||
if (!isRecording.value) return
|
||||
const deltaY = startY - e.touches[0].clientY
|
||||
const y = getClientY(e)
|
||||
const deltaY = startY - y
|
||||
isCancelZone.value = deltaY > 80
|
||||
}
|
||||
|
||||
const onTouchEnd = () => {
|
||||
const onPressEnd = () => {
|
||||
if (recorder && isRecording.value) {
|
||||
recorder.stop()
|
||||
} else {
|
||||
// 未 onStart 就抬起:重置 pressing 避免死锁
|
||||
pressing = false
|
||||
}
|
||||
}
|
||||
|
||||
// 鼠标离开按钮(PC H5 兜底):视为取消录音
|
||||
const onMouseLeave = () => {
|
||||
if (!isRecording.value) return
|
||||
isCancelZone.value = true
|
||||
onPressEnd()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(timer)
|
||||
if (recorder && isRecording.value) {
|
||||
@@ -105,7 +249,15 @@ export default {
|
||||
}
|
||||
})
|
||||
|
||||
return { isRecording, isCancelZone, recordingTime, onTouchStart, onTouchMove, onTouchEnd }
|
||||
return {
|
||||
isRecording,
|
||||
isCancelZone,
|
||||
recordingTime,
|
||||
onPressStart,
|
||||
onPressMove,
|
||||
onPressEnd,
|
||||
onMouseLeave
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -122,6 +274,8 @@ export default {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 200ms;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.record-btn.recording {
|
||||
background-color: #DBEAFE;
|
||||
|
||||
@@ -3,29 +3,33 @@
|
||||
显示播放按钮 + 波形条 + 时长
|
||||
宽度按时长线性缩放:最小 120rpx(1秒),最大 500rpx(60秒)
|
||||
同一时间只允许一个语音播放(全局单例)
|
||||
"未听"红点:对方发来的语音,若本地未播放过,右侧显示红点;点击播放即清除(与微信一致)
|
||||
-->
|
||||
<template>
|
||||
<view v-if="msg.status === 2" class="recalled-text">消息已撤回</view>
|
||||
<view
|
||||
v-else
|
||||
class="msg-voice-wrap"
|
||||
:class="{ 'voice-self': isSelf, 'voice-playing': isPlaying }"
|
||||
:style="{ width: voiceWidth + 'rpx' }"
|
||||
@tap="onTogglePlay"
|
||||
>
|
||||
<view class="voice-icon" :class="{ 'voice-icon-self': isSelf }">
|
||||
<uni-icons :type="isPlaying ? 'sound-filled' : 'sound'" :size="18" :color="isSelf ? '#FFFFFF' : '#2563EB'" />
|
||||
<view v-else class="msg-voice-container">
|
||||
<view
|
||||
class="msg-voice-wrap"
|
||||
:class="{ 'voice-self': isSelf, 'voice-playing': isPlaying }"
|
||||
:style="{ width: voiceWidth + 'rpx' }"
|
||||
@tap="onTogglePlay"
|
||||
>
|
||||
<view class="voice-icon" :class="{ 'voice-icon-self': isSelf }">
|
||||
<uni-icons :type="isPlaying ? 'sound-filled' : 'sound'" :size="18" :color="isSelf ? '#FFFFFF' : '#2563EB'" />
|
||||
</view>
|
||||
<view class="voice-bars">
|
||||
<view v-for="i in 5" :key="i" class="voice-bar" :class="{ 'bar-self': isSelf }" :style="{ height: barHeights[i-1] + 'rpx' }" />
|
||||
</view>
|
||||
<text class="voice-duration" :class="{ 'duration-self': isSelf }">{{ duration }}"</text>
|
||||
</view>
|
||||
<view class="voice-bars">
|
||||
<view v-for="i in 5" :key="i" class="voice-bar" :class="{ 'bar-self': isSelf }" :style="{ height: barHeights[i-1] + 'rpx' }" />
|
||||
</view>
|
||||
<text class="voice-duration" :class="{ 'duration-self': isSelf }">{{ duration }}"</text>
|
||||
<view v-if="showUnplayedDot" class="unplayed-dot" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onBeforeUnmount } from 'vue'
|
||||
import { parseExtra } from '@/utils/file'
|
||||
import { useChatStore } from '@/store/chat'
|
||||
|
||||
let _globalAudio = null
|
||||
let _globalPlayingId = null
|
||||
@@ -38,6 +42,20 @@ export default {
|
||||
},
|
||||
setup(props) {
|
||||
const isPlaying = ref(false)
|
||||
const chatStore = useChatStore()
|
||||
|
||||
/**
|
||||
* 是否显示"未播放"红点:
|
||||
* - 仅对「对方发来的」语音显示(isSelf=false)
|
||||
* - 消息未被撤回(由外层 v-if 已保证)
|
||||
* - 本地存储未记录"已播放"
|
||||
*/
|
||||
const showUnplayedDot = computed(() => {
|
||||
if (props.isSelf) return false
|
||||
const id = props.msg.id
|
||||
if (!id) return false
|
||||
return !chatStore.isVoicePlayed(id)
|
||||
})
|
||||
|
||||
const voiceData = computed(() => {
|
||||
const extra = parseExtra(props.msg.extra)
|
||||
@@ -61,6 +79,11 @@ export default {
|
||||
const url = voiceData.value.url
|
||||
if (!url) return
|
||||
|
||||
// 对方发来的语音一经点击即视为"已播放",消除红点(与微信一致)
|
||||
if (!props.isSelf && props.msg.id) {
|
||||
chatStore.markVoicePlayed(props.msg.id)
|
||||
}
|
||||
|
||||
const msgId = props.msg.id || props.msg.client_msg_id
|
||||
|
||||
if (_globalPlayingId === msgId && _globalAudio) {
|
||||
@@ -107,12 +130,16 @@ export default {
|
||||
}
|
||||
})
|
||||
|
||||
return { isPlaying, duration, voiceWidth, barHeights, onTogglePlay }
|
||||
return { isPlaying, duration, voiceWidth, barHeights, onTogglePlay, showUnplayedDot }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.msg-voice-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.msg-voice-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -120,6 +147,16 @@ export default {
|
||||
padding: 4rpx 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* "未播放"红点:紧贴气泡右侧,不占据主体宽度 */
|
||||
.unplayed-dot {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 50%;
|
||||
background-color: #DC2626;
|
||||
margin-left: 10rpx;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 0 0 2rpx rgba(220, 38, 38, 0.15);
|
||||
}
|
||||
.voice-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user