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
YunaiV 2026-05-01 23:04:56 +08:00
parent 43666dc56c
commit 52fdf0bcab
5 changed files with 80 additions and 29 deletions

View File

@ -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'

View File

@ -537,7 +537,7 @@ async function handleContextMenu(e: MouseEvent) {
})
}
/** 进入回复模式:把当前消息构造成 QuoteMessage 写入 draftStore,MessageInput 顶部引用条响应式出现 */
/** 进入引用模式:把当前消息构造成 QuoteMessage 写入 draftStoreMessageInput 顶部引用条响应式出现 */
function handleReply() {
const conversation = conversationStore.activeConversation
if (!conversation) {

View File

@ -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])
}
/**

View File

@ -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>(() => {

View File

@ -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({