✨ feat(im): 消息右键菜单优化 + 修复图片场景滚不到底
- MessageItem:「回复」→「引用」并加图标;撤回 / 删除互斥(自己消息 2 分钟内显示撤回,超出 / 对方消息显示删除),均加分割线 + 红色样式对齐微信;MENU_KEYS 抽 const 防 typo;引用块从气泡上方移到下方,selfSend 时竖线镜像到右侧 - MessagePanel:scrollToBottom 改 async + waitMediaSettled 等图片 / 视频元数据加载;用 expectedScrollTop drift 替代 distanceFromBottom,修复「图片加载完底部上移、误判用户已滚走」导致到不了底 - ReplyPreview:删等价的 filePayload / voicePayload alias,直接复用 parsedPayload - uiStore:ContextMenuItem 加 icon? 字段,支持菜单项前置图标im
parent
43666dc56c
commit
52fdf0bcab
|
|
@ -22,7 +22,7 @@
|
|||
class="my-1 mx-2 h-[1px] bg-[var(--el-border-color-lighter)]"
|
||||
></div>
|
||||
<div
|
||||
class="px-4 py-2 text-13px text-left cursor-pointer transition-colors hover:bg-[var(--el-fill-color)]"
|
||||
class="flex gap-2 items-center px-4 py-2 text-13px text-left cursor-pointer transition-colors hover:bg-[var(--el-fill-color)]"
|
||||
:class="[
|
||||
item.disabled
|
||||
? '!text-[var(--el-text-color-disabled)] cursor-not-allowed hover:!bg-transparent'
|
||||
|
|
@ -32,7 +32,8 @@
|
|||
]"
|
||||
@click.stop="handleSelect(item)"
|
||||
>
|
||||
{{ item.name }}
|
||||
<Icon v-if="item.icon" :icon="item.icon" :size="14" />
|
||||
<span>{{ item.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
|
@ -42,6 +43,7 @@
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import Icon from '@/components/Icon/src/Icon.vue'
|
||||
|
||||
import { useImUiStore } from '../store/uiStore'
|
||||
|
||||
|
|
|
|||
|
|
@ -537,7 +537,7 @@ async function handleContextMenu(e: MouseEvent) {
|
|||
})
|
||||
}
|
||||
|
||||
/** 进入回复模式:把当前消息构造成 QuoteMessage 写入 draftStore,MessageInput 顶部引用条响应式出现 */
|
||||
/** 进入引用模式:把当前消息构造成 QuoteMessage 写入 draftStore,MessageInput 顶部引用条响应式出现 */
|
||||
function handleReply() {
|
||||
const conversation = conversationStore.activeConversation
|
||||
if (!conversation) {
|
||||
|
|
|
|||
|
|
@ -304,24 +304,76 @@ function handleScroll() {
|
|||
}
|
||||
|
||||
/**
|
||||
* 滚到底部:切会话 / 收到新消息(且当前在底部)/ 用户主动点"回到底部" 都走这里
|
||||
* 滚到底部:切会话 / 收到新消息(且当前在底部)/ 用户主动点「回到底部」 都走这里
|
||||
*
|
||||
* 包 nextTick 是为了等 v-for 把新消息真正渲染进 DOM 后再算 scrollHeight,
|
||||
* 否则可能滚到的还是旧高度(差最后一条的位置)。smooth=true 走平滑动画,
|
||||
* 适合用户主动点击;初始 / 自动滚动用 auto,避免用户感知到动画拖拽
|
||||
* smooth=true 走平滑动画,适合用户主动点击;初始 / 自动滚动用 auto,避免用户感知到动画拖拽
|
||||
*/
|
||||
function scrollToBottom(smooth = false) {
|
||||
nextTick(() => {
|
||||
if (!listRef.value) {
|
||||
return
|
||||
}
|
||||
listRef.value.scrollTo({
|
||||
top: listRef.value.scrollHeight,
|
||||
behavior: smooth ? 'smooth' : 'auto'
|
||||
})
|
||||
newMessageCount.value = 0
|
||||
showJumpToBottom.value = false
|
||||
async function scrollToBottom(smooth = false) {
|
||||
// 1. 滚到当前 scrollHeight 的底部(图片 / 视频还在加载时只是大致到底)
|
||||
// 1.1 等 v-for 把新消息真正渲染进 DOM 后再算 scrollHeight,否则差最后一条的位置
|
||||
await nextTick()
|
||||
if (!listRef.value) {
|
||||
return
|
||||
}
|
||||
// 1.2 触发滚动;smooth 仅 user 主动点「回到底部」用,初始 / 自动滚走 auto 避免动画拖拽感
|
||||
listRef.value.scrollTo({
|
||||
top: listRef.value.scrollHeight,
|
||||
behavior: smooth ? 'smooth' : 'auto'
|
||||
})
|
||||
newMessageCount.value = 0
|
||||
showJumpToBottom.value = false
|
||||
// 1.3 记下「期望停下的 scrollTop」;图片 / 视频加载完底部会上移,scrollTop 没动就说明用户没手动滚走
|
||||
// 不能用 distanceFromBottom 判断:底部上移会让 distance 变大,被误判为「用户滚走了」直接放弃补滚
|
||||
const expectedScrollTop = listRef.value.scrollHeight - listRef.value.clientHeight
|
||||
|
||||
// 2. 等媒体加载完后补滚到真实底部
|
||||
// 2.1 等容器内未加载完的图片 / 视频元数据;加载完后 scrollHeight 会增长到真实底部
|
||||
await waitMediaSettled()
|
||||
if (!listRef.value) {
|
||||
return
|
||||
}
|
||||
// 2.2 仅在用户没手动滚走时(scrollTop 仍贴近 expectedScrollTop)才补滚,避免等待期间用户上翻被打断
|
||||
if (Math.abs(listRef.value.scrollTop - expectedScrollTop) > BOTTOM_THRESHOLD) {
|
||||
return
|
||||
}
|
||||
// 2.3 补滚到新底部
|
||||
listRef.value.scrollTo({ top: listRef.value.scrollHeight, behavior: 'auto' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待容器内未加载完的图片 / 视频;最多等 2s 防止超大资源把整个滚动跟进卡住
|
||||
*
|
||||
* 仅关心元数据(loadedmetadata / img.complete),不等真正解码,因为尺寸够算 scrollHeight 就行
|
||||
*/
|
||||
function waitMediaSettled(): Promise<void> {
|
||||
// 1. 收集容器内未加载完的图片 / 视频
|
||||
if (!listRef.value) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
// 1.1 一次扫 img + video,按 element 类型分别看「complete / readyState」过滤 pending
|
||||
const pendingMedia = Array.from(
|
||||
listRef.value.querySelectorAll<HTMLImageElement | HTMLVideoElement>('img, video')
|
||||
).filter((el) => (el instanceof HTMLImageElement ? !el.complete : el.readyState < 1))
|
||||
// 1.2 没有 pending 直接返回,省掉 Promise.race / setTimeout 闭包构造
|
||||
if (pendingMedia.length === 0) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
// 2. 等所有 pending 资源 load / error,最长 2s 兜底
|
||||
// 2.1 每个 element 都监听对应 loadedEvent + error,任一触发即 resolve(不让单条失败卡 race)
|
||||
const loadAll = Promise.all(
|
||||
pendingMedia.map(
|
||||
(el) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const loadedEvent = el instanceof HTMLImageElement ? 'load' : 'loadedmetadata'
|
||||
el.addEventListener(loadedEvent, () => resolve(), { once: true })
|
||||
el.addEventListener('error', () => resolve(), { once: true })
|
||||
})
|
||||
)
|
||||
).then(() => undefined)
|
||||
// 2.2 2s 超时兜底,防止超大资源 / 网络挂起把整个滚动跟进永久卡住
|
||||
const timeout = new Promise<void>((resolve) => setTimeout(resolve, 2000))
|
||||
return Promise.race([loadAll, timeout])
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -38,22 +38,22 @@
|
|||
:size="14"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<span v-if="filePayload?.name" class="im-reply-preview__text min-w-0">
|
||||
{{ filePayload.name }}
|
||||
<span v-if="parsedPayload?.name" class="im-reply-preview__text min-w-0">
|
||||
{{ parsedPayload.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="filePayload?.size"
|
||||
v-if="parsedPayload?.size"
|
||||
class="flex-shrink-0 text-[var(--el-text-color-placeholder)]"
|
||||
>
|
||||
{{ formatFileSize(filePayload.size) }}
|
||||
{{ formatFileSize(parsedPayload.size) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- 语音:audio icon + 时长 -->
|
||||
<template v-else-if="isVoice">
|
||||
<Icon icon="ant-design:audio-outlined" :size="14" class="flex-shrink-0" />
|
||||
<span v-if="voicePayload?.duration" class="flex-shrink-0">
|
||||
{{ formatSeconds(voicePayload.duration) }}
|
||||
<span v-if="parsedPayload?.duration" class="flex-shrink-0">
|
||||
{{ formatSeconds(parsedPayload.duration) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
|
|
@ -165,12 +165,8 @@ const textPreview = computed(() => {
|
|||
: `${text.substring(0, MAX_TEXT_PREVIEW_LEN)}…`
|
||||
})
|
||||
|
||||
/** 文件 / 语音 payload 直接复用 parsedPayload,省一次解析 */
|
||||
const filePayload = computed(() => parsedPayload.value)
|
||||
const voicePayload = computed(() => parsedPayload.value)
|
||||
|
||||
/** 文件 icon:按扩展名挑色,跟主气泡渲染同源 */
|
||||
const fileIcon = computed(() => getFileIconInfo(filePayload.value?.name))
|
||||
const fileIcon = computed(() => getFileIconInfo(parsedPayload.value?.name))
|
||||
|
||||
/** 缩略图 URL:仅图片 / 视频从 quote.content 直接取,不依赖本地缓存 */
|
||||
const thumbnailUrl = computed<string | undefined>(() => {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export const useImUiStore = defineStore('imUiStore', () => {
|
|||
disabled?: boolean
|
||||
divided?: boolean // 是否在该项上方显示分割线(用于把"删除"等危险操作与上面的常规项隔开)
|
||||
danger?: boolean // 是否走危险操作样式(红色文字)
|
||||
icon?: string // 可选 iconify 图标名(如 ant-design:delete-outlined);不传则不渲染前置图标
|
||||
}
|
||||
|
||||
const contextMenu = reactive({
|
||||
|
|
|
|||
Loading…
Reference in New Issue