✨ 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)]"
|
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'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -537,7 +537,7 @@ async function handleContextMenu(e: MouseEvent) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 进入回复模式:把当前消息构造成 QuoteMessage 写入 draftStore,MessageInput 顶部引用条响应式出现 */
|
/** 进入引用模式:把当前消息构造成 QuoteMessage 写入 draftStore,MessageInput 顶部引用条响应式出现 */
|
||||||
function handleReply() {
|
function handleReply() {
|
||||||
const conversation = conversationStore.activeConversation
|
const conversation = conversationStore.activeConversation
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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>(() => {
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue