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)]" class="my-1 mx-2 h-[1px] bg-[var(--el-border-color-lighter)]"
></div> ></div>
<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="[ :class="[
item.disabled item.disabled
? '!text-[var(--el-text-color-disabled)] cursor-not-allowed hover:!bg-transparent' ? '!text-[var(--el-text-color-disabled)] cursor-not-allowed hover:!bg-transparent'
@ -32,7 +32,8 @@
]" ]"
@click.stop="handleSelect(item)" @click.stop="handleSelect(item)"
> >
{{ item.name }} <Icon v-if="item.icon" :icon="item.icon" :size="14" />
<span>{{ item.name }}</span>
</div> </div>
</template> </template>
</div> </div>
@ -42,6 +43,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue' import { computed } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { useImUiStore } from '../store/uiStore' import { useImUiStore } from '../store/uiStore'

View File

@ -537,7 +537,7 @@ async function handleContextMenu(e: MouseEvent) {
}) })
} }
/** 进入回复模式:把当前消息构造成 QuoteMessage 写入 draftStore,MessageInput 顶部引用条响应式出现 */ /** 进入引用模式:把当前消息构造成 QuoteMessage 写入 draftStoreMessageInput 顶部引用条响应式出现 */
function handleReply() { function handleReply() {
const conversation = conversationStore.activeConversation const conversation = conversationStore.activeConversation
if (!conversation) { 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) { async function scrollToBottom(smooth = false) {
nextTick(() => { // 1. scrollHeight /
if (!listRef.value) { // 1.1 v-for DOM scrollHeight
return await nextTick()
} if (!listRef.value) {
listRef.value.scrollTo({ return
top: listRef.value.scrollHeight, }
behavior: smooth ? 'smooth' : 'auto' // 1.2 smooth user / auto
}) listRef.value.scrollTo({
newMessageCount.value = 0 top: listRef.value.scrollHeight,
showJumpToBottom.value = false 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" :size="14"
class="flex-shrink-0" class="flex-shrink-0"
/> />
<span v-if="filePayload?.name" class="im-reply-preview__text min-w-0"> <span v-if="parsedPayload?.name" class="im-reply-preview__text min-w-0">
{{ filePayload.name }} {{ parsedPayload.name }}
</span> </span>
<span <span
v-if="filePayload?.size" v-if="parsedPayload?.size"
class="flex-shrink-0 text-[var(--el-text-color-placeholder)]" class="flex-shrink-0 text-[var(--el-text-color-placeholder)]"
> >
{{ formatFileSize(filePayload.size) }} {{ formatFileSize(parsedPayload.size) }}
</span> </span>
</template> </template>
<!-- 语音audio icon + 时长 --> <!-- 语音audio icon + 时长 -->
<template v-else-if="isVoice"> <template v-else-if="isVoice">
<Icon icon="ant-design:audio-outlined" :size="14" class="flex-shrink-0" /> <Icon icon="ant-design:audio-outlined" :size="14" class="flex-shrink-0" />
<span v-if="voicePayload?.duration" class="flex-shrink-0"> <span v-if="parsedPayload?.duration" class="flex-shrink-0">
{{ formatSeconds(voicePayload.duration) }} {{ formatSeconds(parsedPayload.duration) }}
</span> </span>
</template> </template>
@ -165,12 +165,8 @@ const textPreview = computed(() => {
: `${text.substring(0, MAX_TEXT_PREVIEW_LEN)}` : `${text.substring(0, MAX_TEXT_PREVIEW_LEN)}`
}) })
/** 文件 / 语音 payload 直接复用 parsedPayload省一次解析 */
const filePayload = computed(() => parsedPayload.value)
const voicePayload = computed(() => parsedPayload.value)
/** 文件 icon按扩展名挑色跟主气泡渲染同源 */ /** 文件 icon按扩展名挑色跟主气泡渲染同源 */
const fileIcon = computed(() => getFileIconInfo(filePayload.value?.name)) const fileIcon = computed(() => getFileIconInfo(parsedPayload.value?.name))
/** 缩略图 URL仅图片 / 视频从 quote.content 直接取,不依赖本地缓存 */ /** 缩略图 URL仅图片 / 视频从 quote.content 直接取,不依赖本地缓存 */
const thumbnailUrl = computed<string | undefined>(() => { const thumbnailUrl = computed<string | undefined>(() => {

View File

@ -42,6 +42,7 @@ export const useImUiStore = defineStore('imUiStore', () => {
disabled?: boolean disabled?: boolean
divided?: boolean // 是否在该项上方显示分割线(用于把"删除"等危险操作与上面的常规项隔开) divided?: boolean // 是否在该项上方显示分割线(用于把"删除"等危险操作与上面的常规项隔开)
danger?: boolean // 是否走危险操作样式(红色文字) danger?: boolean // 是否走危险操作样式(红色文字)
icon?: string // 可选 iconify 图标名(如 ant-design:delete-outlined不传则不渲染前置图标
} }
const contextMenu = reactive({ const contextMenu = reactive({