feat:聊天页面相关功能优化,提供用户体验感
This commit is contained in:
@@ -34,12 +34,45 @@ export function uploadImage(filePath, fileName) {
|
||||
* @param {string} filePath 本地语音文件临时路径
|
||||
* @param {number} duration 语音时长(秒)
|
||||
* @param {string} [fileName] 文件名
|
||||
* @param {Blob} [blob] 可选:H5 端 MediaRecorder 产出的原始 Blob。传入时走 fetch+FormData
|
||||
* 路径,能精确控制 filename/mimeType(`uni.uploadFile` 对 blob: URL 推断的
|
||||
* filename 通常缺扩展名,会被后端白名单拦下)
|
||||
* @returns {Promise<{ url, file_name, size, duration, mime_type }>}
|
||||
*/
|
||||
export function uploadVoice(filePath, duration, fileName) {
|
||||
export function uploadVoice(filePath, duration, fileName, blob) {
|
||||
if (blob && typeof window !== 'undefined' && typeof FormData !== 'undefined') {
|
||||
return _doUploadBlob('/api/v1/upload/voice', blob, fileName || 'voice.webm', {
|
||||
duration: String(duration)
|
||||
})
|
||||
}
|
||||
return _doUpload('/api/v1/upload/voice', filePath, fileName, { duration: String(duration) })
|
||||
}
|
||||
|
||||
/**
|
||||
* H5 专用:直接用 Blob 上传(避免 uni.uploadFile 对 blob: URL 的 filename 不可控)
|
||||
*/
|
||||
function _doUploadBlob(apiPath, blob, fileName, extraFormData = {}) {
|
||||
const userStore = useUserStore()
|
||||
const token = userStore.token
|
||||
const fd = new FormData()
|
||||
fd.append('file', blob, fileName)
|
||||
Object.entries(extraFormData).forEach(([k, v]) => fd.append(k, v))
|
||||
return fetch(`${BASE_URL}${apiPath}`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: fd
|
||||
}).then(async (resp) => {
|
||||
let result
|
||||
try {
|
||||
result = await resp.json()
|
||||
} catch (_) {
|
||||
throw new Error(`上传失败(${resp.status})`)
|
||||
}
|
||||
if (result.code === 0) return result.data
|
||||
throw new Error(result.message || '上传失败')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部上传方法
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
:scroll-into-view="scrollToId"
|
||||
:scroll-with-animation="true"
|
||||
@scrolltoupper="onLoadMore"
|
||||
@scrolltolower="onScrollToLower"
|
||||
@scroll="onScroll"
|
||||
:lower-threshold="150"
|
||||
>
|
||||
<view v-if="hasMore" class="load-more" @tap="onLoadMore">
|
||||
<text class="load-more-text">{{ loadingMore ? '加载中...' : '加载更多' }}</text>
|
||||
@@ -100,6 +103,12 @@
|
||||
<view id="msg-bottom" style="height: 2rpx;" />
|
||||
</scroll-view>
|
||||
|
||||
<!-- 新消息悬浮提示(仅当用户远离底部且有新消息时显示) -->
|
||||
<view v-if="newMsgCount > 0" class="new-msg-hint" @tap="jumpToBottom">
|
||||
<uni-icons type="bottom" size="14" color="#2563EB" />
|
||||
<text class="new-msg-hint-text">{{ newMsgCount }} 条新消息</text>
|
||||
</view>
|
||||
|
||||
<!-- 输入栏 -->
|
||||
<view class="input-bar">
|
||||
<view class="voice-toggle-btn" @tap="toggleVoiceMode">
|
||||
@@ -165,6 +174,18 @@ export default {
|
||||
const scrollToId = ref('')
|
||||
const loadingMore = ref(false)
|
||||
|
||||
// 滚动位置感知:判断用户视图是否"贴近底部"
|
||||
// 策略:每次 @scroll 事件都对比"历史最大 scrollTop"(等价于贴底位置),
|
||||
// 差值 < 150 视为贴底。此方式不依赖 @scrolltolower 能否在程序化滚动后触发,
|
||||
// 对 scroll-into-view + 动态 scrollHeight 都能正确响应。
|
||||
const nearBottom = ref(true)
|
||||
// 用户未看到的新消息累计数(悬浮按钮显示用)
|
||||
const newMsgCount = ref(0)
|
||||
// 首次渲染完成前,避免滚动事件误判
|
||||
let scrollReady = false
|
||||
// 已记录的最大 scrollTop(scrollHeight 增大并滚到底时会同步增长)
|
||||
let maxScrollTop = 0
|
||||
|
||||
const messages = computed(() => chatStore.currentMessages)
|
||||
const hasMore = computed(() => conversationId.value > 0 && chatStore.hasMoreMap[conversationId.value] !== false)
|
||||
const isTyping = computed(() => chatStore.typingMap[conversationId.value] || false)
|
||||
@@ -242,6 +263,8 @@ export default {
|
||||
await chatStore.loadHistoryMessages(conversationId.value)
|
||||
}
|
||||
scrollToBottom()
|
||||
// 初次进入会话且消息已加载:等待渲染后启用滚动感知
|
||||
nextTick(() => { scrollReady = true })
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
@@ -251,6 +274,80 @@ export default {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动事件:维护 maxScrollTop,并据此更新 nearBottom
|
||||
* - scrollTop 持续增长(贴底滚动)→ maxScrollTop 同步增长 → 贴底
|
||||
* - scrollTop 相对 maxScrollTop 回退 > 150 → 用户主动上滑,视为远离底部
|
||||
*/
|
||||
const onScroll = (e) => {
|
||||
if (!scrollReady) return
|
||||
const scrollTop = e.detail?.scrollTop
|
||||
if (typeof scrollTop !== 'number') return
|
||||
if (scrollTop > maxScrollTop) maxScrollTop = scrollTop
|
||||
const wasNear = nearBottom.value
|
||||
nearBottom.value = (maxScrollTop - scrollTop) < 150
|
||||
// 由"远离"回到"贴底"的瞬间,清空未读悬浮并标记已读
|
||||
if (!wasNear && nearBottom.value) {
|
||||
if (newMsgCount.value > 0) newMsgCount.value = 0
|
||||
if (conversationId.value) chatStore.markRead(conversationId.value)
|
||||
}
|
||||
}
|
||||
|
||||
/** 滚动到达下边界(lower-threshold 内):权威地置贴底 */
|
||||
const onScrollToLower = () => {
|
||||
nearBottom.value = true
|
||||
if (newMsgCount.value > 0) {
|
||||
newMsgCount.value = 0
|
||||
}
|
||||
if (conversationId.value) {
|
||||
chatStore.markRead(conversationId.value)
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击悬浮提示:滚到底部、清零计数、标记已读 */
|
||||
const jumpToBottom = () => {
|
||||
scrollToBottom()
|
||||
newMsgCount.value = 0
|
||||
nearBottom.value = true
|
||||
if (conversationId.value) {
|
||||
chatStore.markRead(conversationId.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听消息数组变化:只对「末尾新增的消息」做处理
|
||||
// 关键点:必须对比末尾消息标识,而非仅看 length——加载历史时从头部插入大量消息,
|
||||
// length 会增加但 tail 不变,不应计入 newMsgCount
|
||||
let lastTailKey = ''
|
||||
const getTailKey = (msg) => msg ? String(msg.id || msg.client_msg_id || '') : ''
|
||||
watch(() => messages.value.length, (newLen) => {
|
||||
if (!scrollReady) return
|
||||
const tail = messages.value[newLen - 1]
|
||||
const tailKey = getTailKey(tail)
|
||||
// 末尾消息未变(仅头部插入历史消息)→ 不处理
|
||||
if (!tailKey || tailKey === lastTailKey) {
|
||||
lastTailKey = tailKey || lastTailKey
|
||||
return
|
||||
}
|
||||
const prevKey = lastTailKey
|
||||
lastTailKey = tailKey
|
||||
// 首次初始化(从空数组到有消息)不触发逻辑,由 loadInitialMessages 负责滚动
|
||||
if (!prevKey) return
|
||||
|
||||
// 仅对「对方发来的新消息」做累计;自己发出的消息在 onSend/onChoose 等处已显式滚动
|
||||
const myId = Number(userStore.userInfo?.id) || 0
|
||||
const fromOther = tail.sender_id && tail.sender_id !== myId
|
||||
if (!fromOther) return
|
||||
|
||||
if (nearBottom.value) {
|
||||
scrollToBottom()
|
||||
if (conversationId.value) {
|
||||
chatStore.markRead(conversationId.value)
|
||||
}
|
||||
} else {
|
||||
newMsgCount.value += 1
|
||||
}
|
||||
})
|
||||
|
||||
const onSend = () => {
|
||||
const content = inputText.value.trim()
|
||||
if (!content) return
|
||||
@@ -433,10 +530,13 @@ export default {
|
||||
// #endif
|
||||
}
|
||||
|
||||
const onVoiceRecorded = async ({ tempFilePath, duration }) => {
|
||||
const onVoiceRecorded = async ({ tempFilePath, duration, blob, mimeType }) => {
|
||||
try {
|
||||
uni.showLoading({ title: '发送中...' })
|
||||
const result = await uploadVoice(tempFilePath, duration)
|
||||
// H5 端:根据 mimeType 推断扩展名,传递 Blob 以走 fetch+FormData 精确上传
|
||||
const ext = mimeTypeToExt(mimeType)
|
||||
const fileName = ext ? `voice-${Date.now()}.${ext}` : undefined
|
||||
const result = await uploadVoice(tempFilePath, duration, fileName, blob)
|
||||
chatStore.sendVoiceMessage({
|
||||
conversationId: conversationId.value || 0,
|
||||
targetUserId: conversationId.value ? 0 : peerId.value,
|
||||
@@ -455,6 +555,18 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// 根据 MediaRecorder 的 mimeType 推断文件扩展名
|
||||
const mimeTypeToExt = (mime) => {
|
||||
if (!mime) return ''
|
||||
const m = mime.toLowerCase()
|
||||
if (m.includes('webm')) return 'webm'
|
||||
if (m.includes('ogg')) return 'ogg'
|
||||
if (m.includes('mp4') || m.includes('aac')) return 'm4a'
|
||||
if (m.includes('mpeg') || m.includes('mp3')) return 'mp3'
|
||||
if (m.includes('wav')) return 'wav'
|
||||
return ''
|
||||
}
|
||||
|
||||
const goToSettings = () => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/chat/settings?conversationId=${conversationId.value}&peerId=${peerId.value}&peerName=${encodeURIComponent(peerName.value)}&peerAvatar=${encodeURIComponent(peerAvatar.value)}`
|
||||
@@ -466,8 +578,10 @@ export default {
|
||||
inputText, scrollToId, loadingMore, convType,
|
||||
voiceMode, showMorePanel,
|
||||
messages, hasMore, isTyping,
|
||||
newMsgCount,
|
||||
isSelf, isRead, getReadLabel,
|
||||
onSend, onInputChange, onLoadMore,
|
||||
onScroll, onScrollToLower, jumpToBottom,
|
||||
onMsgLongPress, onResend, goBack, goToSettings,
|
||||
toggleVoiceMode, toggleMorePanel,
|
||||
onChooseImage, onChooseFile, onVoiceRecorded
|
||||
@@ -661,4 +775,31 @@ export default {
|
||||
padding: 12rpx !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* ===== 新消息悬浮提示(远离底部时展示,点击滚到底并标记已读) ===== */
|
||||
.new-msg-hint {
|
||||
position: fixed;
|
||||
right: 32rpx;
|
||||
/* 输入栏约 104rpx + 底部安全区 + 16rpx 间距 */
|
||||
bottom: calc(120rpx + env(safe-area-inset-bottom, 0rpx));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12rpx 24rpx;
|
||||
background-color: #FFFFFF;
|
||||
border: 1rpx solid #E2E8F0;
|
||||
border-radius: 32rpx;
|
||||
box-shadow: 0 4rpx 16rpx rgba(15, 23, 42, 0.12);
|
||||
z-index: 10;
|
||||
transition: opacity 150ms ease, transform 150ms ease;
|
||||
}
|
||||
.new-msg-hint:active {
|
||||
opacity: 0.85;
|
||||
transform: translateY(1rpx);
|
||||
}
|
||||
.new-msg-hint-text {
|
||||
font-size: 24rpx;
|
||||
color: #2563EB;
|
||||
margin-left: 6rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
:scroll-into-view="scrollToId"
|
||||
:scroll-with-animation="true"
|
||||
@scrolltoupper="onLoadMore"
|
||||
@scrolltolower="onScrollToLower"
|
||||
@scroll="onScroll"
|
||||
:lower-threshold="150"
|
||||
>
|
||||
<view v-if="hasMore" class="load-more" @tap="onLoadMore">
|
||||
<text class="load-more-text">{{ loadingMore ? '加载中...' : '加载更多' }}</text>
|
||||
@@ -124,6 +127,12 @@
|
||||
<view id="msg-bottom" style="height: 2rpx;" />
|
||||
</scroll-view>
|
||||
|
||||
<!-- 新消息悬浮提示(仅当用户远离底部且有新消息时显示) -->
|
||||
<view v-if="newMsgCount > 0" class="new-msg-hint" @tap="jumpToBottom">
|
||||
<uni-icons type="bottom" size="14" color="#2563EB" />
|
||||
<text class="new-msg-hint-text">{{ newMsgCount }} 条新消息</text>
|
||||
</view>
|
||||
|
||||
<!-- @成员选择器 -->
|
||||
<view v-if="showAtPicker" class="at-overlay" @tap="showAtPicker = false">
|
||||
<view class="at-panel" @tap.stop>
|
||||
@@ -238,6 +247,13 @@ export default {
|
||||
const voiceMode = ref(false)
|
||||
const showMorePanel = ref(false)
|
||||
|
||||
// 滚动位置感知(与单聊一致):远离底部时新消息转为悬浮提示,避免视图未见却已标已读
|
||||
// 使用 maxScrollTop 追踪策略:scrollTop 相对历史最大值回退 > 150 视为远离底部
|
||||
const nearBottom = ref(true)
|
||||
const newMsgCount = ref(0)
|
||||
let scrollReady = false
|
||||
let maxScrollTop = 0
|
||||
|
||||
const messages = computed(() => chatStore.currentMessages)
|
||||
const hasMore = computed(() => chatStore.hasMoreMap[conversationId.value] !== false)
|
||||
const selfAvatar = computed(() => userStore.userInfo?.avatar || '')
|
||||
@@ -349,6 +365,7 @@ export default {
|
||||
}
|
||||
scrollToBottom()
|
||||
markVisibleMessagesRead()
|
||||
nextTick(() => { scrollReady = true })
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
@@ -367,6 +384,37 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
/** 滚动事件:维护 maxScrollTop 并判断是否贴底(与单聊一致) */
|
||||
const onScroll = (e) => {
|
||||
if (!scrollReady) return
|
||||
const scrollTop = e.detail?.scrollTop
|
||||
if (typeof scrollTop !== 'number') return
|
||||
if (scrollTop > maxScrollTop) maxScrollTop = scrollTop
|
||||
const wasNear = nearBottom.value
|
||||
nearBottom.value = (maxScrollTop - scrollTop) < 150
|
||||
if (!wasNear && nearBottom.value) {
|
||||
if (newMsgCount.value > 0) newMsgCount.value = 0
|
||||
markVisibleMessagesRead()
|
||||
}
|
||||
}
|
||||
|
||||
/** 滚动接近底部触发:将 nearBottom 置 true 并立即标记已读 */
|
||||
const onScrollToLower = () => {
|
||||
nearBottom.value = true
|
||||
if (newMsgCount.value > 0) {
|
||||
newMsgCount.value = 0
|
||||
}
|
||||
markVisibleMessagesRead()
|
||||
}
|
||||
|
||||
/** 点击悬浮提示:滚到底、清零、标记已读 */
|
||||
const jumpToBottom = () => {
|
||||
scrollToBottom()
|
||||
newMsgCount.value = 0
|
||||
nearBottom.value = true
|
||||
markVisibleMessagesRead()
|
||||
}
|
||||
|
||||
// ==================== 消息发送 ====================
|
||||
|
||||
const onSend = () => {
|
||||
@@ -592,10 +640,23 @@ export default {
|
||||
// #endif
|
||||
}
|
||||
|
||||
const onVoiceRecorded = async ({ tempFilePath, duration }) => {
|
||||
const mimeTypeToExt = (mime) => {
|
||||
if (!mime) return ''
|
||||
const m = mime.toLowerCase()
|
||||
if (m.includes('webm')) return 'webm'
|
||||
if (m.includes('ogg')) return 'ogg'
|
||||
if (m.includes('mp4') || m.includes('aac')) return 'm4a'
|
||||
if (m.includes('mpeg') || m.includes('mp3')) return 'mp3'
|
||||
if (m.includes('wav')) return 'wav'
|
||||
return ''
|
||||
}
|
||||
|
||||
const onVoiceRecorded = async ({ tempFilePath, duration, blob, mimeType }) => {
|
||||
try {
|
||||
uni.showLoading({ title: '发送中...' })
|
||||
const result = await uploadVoice(tempFilePath, duration)
|
||||
const ext = mimeTypeToExt(mimeType)
|
||||
const fileName = ext ? `voice-${Date.now()}.${ext}` : undefined
|
||||
const result = await uploadVoice(tempFilePath, duration, fileName, blob)
|
||||
chatStore.sendVoiceMessage({
|
||||
conversationId: conversationId.value,
|
||||
voice: { url: result.url, duration: result.duration || duration, size: result.size, file_name: result.file_name }
|
||||
@@ -608,13 +669,38 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 监听新消息到达后标记已读 ====================
|
||||
|
||||
// ==================== 监听新消息到达,按滚动位置分流处理 ====================
|
||||
// 关键点:对比末尾消息标识,避免"加载更多"时头部插入历史消息被误计入 newMsgCount
|
||||
let lastTailKey = ''
|
||||
const getTailKey = (msg) => msg ? String(msg.id || msg.client_msg_id || '') : ''
|
||||
watch(
|
||||
() => messages.value.length,
|
||||
() => {
|
||||
scrollToBottom()
|
||||
markVisibleMessagesRead()
|
||||
(newLen) => {
|
||||
if (!scrollReady) return
|
||||
const tail = messages.value[newLen - 1]
|
||||
const tailKey = getTailKey(tail)
|
||||
if (!tailKey || tailKey === lastTailKey) {
|
||||
lastTailKey = tailKey || lastTailKey
|
||||
return
|
||||
}
|
||||
const prevKey = lastTailKey
|
||||
lastTailKey = tailKey
|
||||
if (!prevKey) return
|
||||
|
||||
// 系统消息(type===10 或 sender_id===0)与自己发的消息,不计入悬浮计数
|
||||
const isSystem = tail.type === 10 || tail.sender_id === 0
|
||||
const fromSelf = tail.sender_id === myId.value
|
||||
if (isSystem || fromSelf) {
|
||||
if (nearBottom.value) scrollToBottom()
|
||||
return
|
||||
}
|
||||
|
||||
if (nearBottom.value) {
|
||||
scrollToBottom()
|
||||
markVisibleMessagesRead()
|
||||
} else {
|
||||
newMsgCount.value += 1
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -623,9 +709,11 @@ export default {
|
||||
groupName, groupAvatar, selfAvatar, selfName,
|
||||
inputText, scrollToId, loadingMore, memberCount,
|
||||
messages, hasMore, showAtPicker, atMemberList, isMuted, isAllMuted,
|
||||
newMsgCount,
|
||||
isSelf, getMemberName, getMemberAvatar, getReadLabel,
|
||||
onSend, onInputChange, onSelectAtMember,
|
||||
onLoadMore, onMsgLongPress, onResend,
|
||||
onScroll, onScrollToLower, jumpToBottom,
|
||||
goBack, goToSettings, goToReadDetail,
|
||||
voiceMode, showMorePanel,
|
||||
toggleVoiceMode, toggleMorePanel,
|
||||
@@ -933,4 +1021,30 @@ export default {
|
||||
.bubble-media {
|
||||
max-width: 420rpx;
|
||||
}
|
||||
|
||||
/* ===== 新消息悬浮提示(远离底部时展示,点击滚到底并标记已读) ===== */
|
||||
.new-msg-hint {
|
||||
position: fixed;
|
||||
right: 32rpx;
|
||||
bottom: calc(120rpx + env(safe-area-inset-bottom, 0rpx));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12rpx 24rpx;
|
||||
background-color: #FFFFFF;
|
||||
border: 1rpx solid #E2E8F0;
|
||||
border-radius: 32rpx;
|
||||
box-shadow: 0 4rpx 16rpx rgba(15, 23, 42, 0.12);
|
||||
z-index: 10;
|
||||
transition: opacity 150ms ease, transform 150ms ease;
|
||||
}
|
||||
.new-msg-hint:active {
|
||||
opacity: 0.85;
|
||||
transform: translateY(1rpx);
|
||||
}
|
||||
.new-msg-hint-text {
|
||||
font-size: 24rpx;
|
||||
color: #2563EB;
|
||||
margin-left: 6rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -46,6 +46,71 @@ export const useChatStore = defineStore('chat', () => {
|
||||
/** 群聊已读计数:messageId -> readCount */
|
||||
const groupReadCountMap = ref({})
|
||||
|
||||
/**
|
||||
* 语音"已播放"状态:messageId -> true
|
||||
*
|
||||
* 说明:区别于通用"已读"(基于滚动可见);此状态记录用户是否"真的点击播放过"对方发来的语音。
|
||||
* - 仅对"对方发来的语音"有意义(自己发的无需标记)
|
||||
* - 本地持久化(按 userId 隔离),换设备会重新显示红点(与微信行为一致)
|
||||
* - 不同步到服务端:属于私人视图态,不影响对方
|
||||
*/
|
||||
const voicePlayedMap = ref({})
|
||||
/** 已加载 voicePlayed 的 userId,避免重复加载 */
|
||||
let _voicePlayedLoadedUserId = null
|
||||
|
||||
/** 生成 voicePlayed 的存储 key(按 userId 隔离) */
|
||||
const _voicePlayedStorageKey = (userId) => `echo:voice-played:${userId}`
|
||||
|
||||
/**
|
||||
* 从本地存储加载指定用户的语音已播放状态
|
||||
* 幂等:同一 userId 只会加载一次;切换用户时自动重新加载
|
||||
*/
|
||||
const loadVoicePlayedState = (userId) => {
|
||||
if (!userId) return
|
||||
if (_voicePlayedLoadedUserId === userId) return
|
||||
try {
|
||||
const raw = uni.getStorageSync(_voicePlayedStorageKey(userId))
|
||||
voicePlayedMap.value = raw && typeof raw === 'object' ? { ...raw } : {}
|
||||
} catch (_) {
|
||||
voicePlayedMap.value = {}
|
||||
}
|
||||
_voicePlayedLoadedUserId = userId
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记某条语音消息为"已播放",并持久化到本地存储
|
||||
* @param {number|string} messageId
|
||||
*/
|
||||
const markVoicePlayed = (messageId) => {
|
||||
if (!messageId) return
|
||||
const key = String(messageId)
|
||||
if (voicePlayedMap.value[key]) return
|
||||
voicePlayedMap.value[key] = true
|
||||
try {
|
||||
const userStore = useUserStore()
|
||||
const userId = userStore.userInfo?.id
|
||||
if (userId) {
|
||||
uni.setStorageSync(_voicePlayedStorageKey(userId), { ...voicePlayedMap.value })
|
||||
}
|
||||
} catch (_) { /* ignore storage errors */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询某条语音是否已播放
|
||||
* @param {number|string} messageId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isVoicePlayed = (messageId) => {
|
||||
if (!messageId) return false
|
||||
return !!voicePlayedMap.value[String(messageId)]
|
||||
}
|
||||
|
||||
/** 重置语音已播放状态(登出时调用) */
|
||||
const resetVoicePlayedState = () => {
|
||||
voicePlayedMap.value = {}
|
||||
_voicePlayedLoadedUserId = null
|
||||
}
|
||||
|
||||
// ==================== Computed ====================
|
||||
|
||||
/** 当前会话的消息列表(conversationId 为 null 时取 pending 队列) */
|
||||
@@ -211,6 +276,9 @@ export const useChatStore = defineStore('chat', () => {
|
||||
if (!conversationId) return
|
||||
const messages = messagesMap.value[conversationId] || []
|
||||
const beforeId = messages.length > 0 ? messages[0].id : 0
|
||||
// 仅在「首次加载(本地还没缓存任何消息)」时回填已读状态;
|
||||
// 滚动加载更多历史消息时不覆盖,避免把已通过 WS 增量到达的最新状态冲回旧值
|
||||
const isInitialLoad = messages.length === 0
|
||||
|
||||
const res = await imApi.getHistoryMessages(conversationId, beforeId)
|
||||
if (res.data) {
|
||||
@@ -221,6 +289,23 @@ export const useChatStore = defineStore('chat', () => {
|
||||
const existing = messagesMap.value[conversationId] || []
|
||||
messagesMap.value[conversationId] = [...list.reverse(), ...existing]
|
||||
}
|
||||
|
||||
// ========== 已读状态回填(修复页面刷新后已读标签变未读的 Bug) ==========
|
||||
if (isInitialLoad) {
|
||||
// 单聊:对方 last_read_msg_id → 写入 readStatusMap
|
||||
const peerLastRead = Number(res.data.peer_last_read_msg_id) || 0
|
||||
if (peerLastRead > 0) {
|
||||
readStatusMap.value[conversationId] = peerLastRead
|
||||
}
|
||||
// 群聊:自己发送消息的 read_count → 合并到 groupReadCountMap
|
||||
const rcMap = res.data.read_count_map
|
||||
if (rcMap && typeof rcMap === 'object') {
|
||||
Object.keys(rcMap).forEach(mid => {
|
||||
const count = Number(rcMap[mid]) || 0
|
||||
if (count > 0) groupReadCountMap.value[mid] = count
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +351,13 @@ export const useChatStore = defineStore('chat', () => {
|
||||
|
||||
/** 初始化 WebSocket 事件监听(幂等) */
|
||||
const initWsListeners = () => {
|
||||
// 懒加载语音播放态(按当前用户隔离,每次调用都会检查 userId 是否变更)
|
||||
try {
|
||||
const userStore = useUserStore()
|
||||
const uid = userStore.userInfo?.id
|
||||
if (uid) loadVoicePlayedState(uid)
|
||||
} catch (_) { /* ignore */ }
|
||||
|
||||
if (_wsInitialized) return
|
||||
_wsInitialized = true
|
||||
|
||||
@@ -308,9 +400,9 @@ export const useChatStore = defineStore('chat', () => {
|
||||
existingConv.unread_count = (existingConv.unread_count || 0) + 1
|
||||
}
|
||||
totalUnread.value++
|
||||
} else {
|
||||
markRead(data.conversation_id)
|
||||
}
|
||||
// 注意:当前会话的 markRead 由聊天页面根据滚动位置决定
|
||||
// (仅当用户视图贴近底部、能看到新消息时才触发),避免"视图未看到但已读"的 UX 错位
|
||||
}
|
||||
|
||||
/** 消息撤回推送 */
|
||||
@@ -492,6 +584,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
hasMoreMap,
|
||||
readStatusMap,
|
||||
groupReadCountMap,
|
||||
voicePlayedMap,
|
||||
currentMessages,
|
||||
sortedConversations,
|
||||
fetchConversations,
|
||||
@@ -509,6 +602,10 @@ export const useChatStore = defineStore('chat', () => {
|
||||
deleteConversation,
|
||||
clearHistory,
|
||||
setCurrentConversation,
|
||||
initWsListeners
|
||||
initWsListeners,
|
||||
markVoicePlayed,
|
||||
isVoicePlayed,
|
||||
loadVoicePlayedState,
|
||||
resetVoicePlayedState
|
||||
}
|
||||
})
|
||||
|
||||
@@ -120,6 +120,12 @@ export const useUserStore = defineStore('user', () => {
|
||||
} catch (e) {
|
||||
console.warn('登出 API 调用失败,仍然清除本地状态', e)
|
||||
}
|
||||
// 清理 chat store 中的私人视图态(语音已播放缓存按 userId 隔离存在 storage,
|
||||
// 此处只重置运行时 state,下次登录会按新 userId 加载对应缓存)
|
||||
try {
|
||||
const { useChatStore } = await import('@/store/chat')
|
||||
useChatStore().resetVoicePlayedState()
|
||||
} catch (_) { /* ignore */ }
|
||||
token.value = ''
|
||||
userInfo.value = null
|
||||
clearAll()
|
||||
|
||||
Reference in New Issue
Block a user