perf(component): 优化消息列表

This commit is contained in:
Dawn
2025-03-07 02:08:59 +08:00
parent 51b7d621fa
commit 1bc38ba4c1
10 changed files with 258 additions and 676 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ export default {
'oxlint src',
createCommand('pnpm eslint --fix', ''),
createCommand('prettier --write', '--write'),
() => 'pnpm test:run',
// () => 'pnpm test:run',
() => 'vue-tsc --noEmit'
]
}
+140
View File
@@ -0,0 +1,140 @@
<template>
<div ref="containerRef" class="list-container" @scroll="handleScroll">
<n-flex v-if="!isLoadingMore && isLast" justify="center" class="box-border absolute-x-center pt-10px">
<span class="text-(12px #909090)">以下是全部消息内容</span>
</n-flex>
<n-flex v-if="isLoadingMore" justify="center" class="box-border absolute-x-center pt-10px loading-indicator">
<img class="size-16px" src="@/assets/img/loading.svg" alt="" />
<span class="text-(14px #909090)">加载中</span>
</n-flex>
<div class="list-content pt-26px">
<div v-for="(item, index) in items" :key="item.message?.id" :id="`item-${item.message?.id}`">
<slot :item="item" :index="index"></slot>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
items: any[]
estimatedItemHeight?: number
buffer?: number
isLoadingMore?: boolean
isLast?: boolean
}>()
const emit = defineEmits<{
scroll: [event: Event]
scrollDirectionChange: [direction: 'up' | 'down']
}>()
// 容器元素引用
const containerRef = ref<HTMLElement | null>(null)
// 上次滚动位置
const lastScrollTop = ref(0)
// 滚动事件处理
const handleScroll = (event: Event) => {
emit('scroll', event)
if (!containerRef.value) return
const currentScrollTop = containerRef.value.scrollTop
// 发出滚动方向变化事件
if (currentScrollTop < lastScrollTop.value) {
emit('scrollDirectionChange', 'up')
} else if (currentScrollTop > lastScrollTop.value) {
emit('scrollDirectionChange', 'down')
}
lastScrollTop.value = currentScrollTop
}
// 类型定义
export type MessageListExpose = {
scrollTo: (options: { index?: number; position?: 'top' | 'bottom'; behavior?: ScrollBehavior }) => void
getContainer: () => HTMLElement | null
}
// 暴露方法和引用
defineExpose<MessageListExpose>({
scrollTo: (options: { index?: number; position?: 'top' | 'bottom'; behavior?: ScrollBehavior }) => {
if (!containerRef.value) return
const executeScroll = () => {
if (!containerRef.value) return
if (options.position === 'bottom') {
// 滚动到底部
containerRef.value.scrollTo({
top: containerRef.value.scrollHeight,
behavior: options.behavior || 'auto'
})
} else if (options.position === 'top') {
// 滚动到顶部
containerRef.value.scrollTo({
top: 0,
behavior: options.behavior || 'auto'
})
} else if (typeof options.index === 'number' && options.index >= 0 && options.index < props.items.length) {
// 滚动到指定索引位置
const element = document.getElementById(`item-${props.items[options.index].message?.id}`)
if (element) {
element.scrollIntoView({
behavior: options.behavior || 'auto',
block: 'start'
})
}
}
}
// 立即执行一次,并在短暂延迟后再次执行以确保内容已完全加载
executeScroll()
setTimeout(executeScroll, 100)
},
getContainer: () => containerRef.value
})
</script>
<style scoped>
.list-container {
position: relative;
overflow-y: auto;
height: 100%;
}
.list-container::-webkit-scrollbar {
width: 6px;
}
.list-container::-webkit-scrollbar-thumb {
background-color: rgba(144, 144, 144, 0.3);
border-radius: 3px;
transition: background-color 0.3s;
min-height: 75px;
z-index: 999;
}
.list-container::-webkit-scrollbar-thumb:hover {
background-color: rgba(144, 144, 144, 0.5);
}
.list-container::-webkit-scrollbar-track {
background: transparent;
}
.list-content {
position: absolute;
left: 0;
right: 0;
top: 0;
will-change: transform;
}
.loading-indicator {
transition: opacity 0.3s ease;
padding: 8px 0;
}
</style>
-381
View File
@@ -1,381 +0,0 @@
<template>
<div ref="containerRef" class="virtual-list-container" @scroll="handleScroll">
<n-flex v-if="!isLoadingMore && isLast" justify="center" class="box-border absolute-x-center pt-10px">
<span class="text-(12px #909090)">以下是全部消息内容</span>
</n-flex>
<n-flex v-if="isLoadingMore" justify="center" class="box-border absolute-x-center pt-10px">
<img class="size-16px" src="@/assets/img/loading.svg" alt="" />
<span class="text-(14px #909090)">加载中</span>
</n-flex>
<div class="virtual-list-phantom" :style="{ height: `${totalHeight}px` }"></div>
<div class="virtual-list-content" :style="{ transform: `translateY(${offset}px)` }">
<div v-for="item in visibleData" :key="item.message?.id" :id="`item-${item.message?.id}`">
<slot :item="item" :index="item._index"></slot>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
items: any[]
estimatedItemHeight?: number
buffer?: number
isLoadingMore?: boolean
isLast?: boolean
}>()
const emit = defineEmits<{
scroll: [event: Event]
scrollDirectionChange: [direction: 'up' | 'down']
}>()
// 常量定义
const DEFAULT_ESTIMATED_HEIGHT = 80 // 默认预估的每项高度
const BUFFER_SIZE = props.buffer || 5 // 上下缓冲区域的数量
const OVERSCAN_SIZE = 1000 // 预渲染区域的像素高度,防止滚动时出现空白
const MAX_CACHE_SIZE = 1000 // 高度缓存的最大数量
const LOADING_OFFSET = 26 // 加载中需要的偏移量(26px是加载动画的高度)
const ESTIMATED_ITEM_HEIGHT = props.estimatedItemHeight || DEFAULT_ESTIMATED_HEIGHT // 每项的预估高度
// 响应式引用
const containerRef = ref<HTMLElement | null>(null) // 容器元素引用
const offset = ref(0) // 内容区域的偏移量
const heights = ref<Map<string, number>>(new Map()) // 存储每个项目的实际高度,key为消息ID
const visibleRange = ref({ start: 0, end: 0 }) // 当前可见区域的起始和结束索引
const isScrolling = ref(false) // 是否正在滚动中
const rafId = ref<number | null>(null) // requestAnimationFrame的ID
const lastScrollTop = ref(0) // 上次滚动位置
const consecutiveStaticFrames = ref(0) // 连续静止帧计数
// ResizeObserver 实例
const resizeObserver = ref<ResizeObserver | null>(null)
// 清理过期的高度缓存
const cleanupHeightCache = () => {
if (heights.value.size > MAX_CACHE_SIZE) {
// 获取所有键并按照最近使用时间排序
const keys = Array.from(heights.value.keys())
const visibleKeys = new Set(
props.items
.slice(Math.max(0, visibleRange.value.start - BUFFER_SIZE), visibleRange.value.end + BUFFER_SIZE + 1)
.map((item) => item.message?.id?.toString())
.filter(Boolean)
)
// 保留可见区域的缓存
const keysToDelete = keys.filter((key) => !visibleKeys.has(key))
const deleteCount = keysToDelete.length - MAX_CACHE_SIZE / 2
if (deleteCount > 0) {
for (const key of keysToDelete.slice(0, deleteCount)) {
heights.value.delete(key)
}
}
}
}
// 计算可见项目
const visibleData = computed(() => {
// 根据可见范围切片并添加索引信息
return props.items.slice(visibleRange.value.start, visibleRange.value.end + 1).map((item, index) => ({
...item,
_index: visibleRange.value.start + index // 添加真实索引,用于渲染
}))
})
// 计算列表总高度
const totalHeight = computed(() => {
// 累加所有项目的高度,如果没有缓存则使用预估高度
return props.items.reduce((total, item) => {
return total + (heights.value.get(item.message?.id?.toString()) || ESTIMATED_ITEM_HEIGHT)
}, 0)
})
// 监听列表数据变化
watch(
() => props.items,
(newItems, oldItems) => {
// 如果列表完全重置,清空高度缓存
if (newItems.length === 0 || oldItems.length === 0) {
heights.value.clear()
}
// 数据变化时重新计算可见范围和更新高度
updateVisibleRange()
nextTick(() => {
updateItemHeight()
})
},
{ deep: true }
)
// 更新项目实际高度
const updateItemHeight = () => {
if (!containerRef.value) return
// 遍历可见项目,测量并缓存实际高度
for (const item of visibleData.value) {
const id = item.message?.id?.toString()
if (!id) continue
const el = document.getElementById(`item-${id}`)
if (el) {
const height = el.getBoundingClientRect().height
heights.value.set(id, height)
}
}
// 清理过期缓存
cleanupHeightCache()
// 非滚动状态下更新可见范围
if (!isScrolling.value) {
updateVisibleRange()
}
}
// 根据滚动位置计算起始索引
const getStartIndex = (scrollTop: number) => {
const accumulatedHeights: number[] = []
let totalHeight = 0
// 预计算累积高度 O(n),但只需要在列表数据变化时更新
props.items.forEach((item, index) => {
totalHeight += heights.value.get(item.message?.id?.toString()) || ESTIMATED_ITEM_HEIGHT
accumulatedHeights[index] = totalHeight
})
// 二分查找 O(log n)
let left = 0
let right = accumulatedHeights.length - 1
const target = scrollTop - OVERSCAN_SIZE
while (left <= right) {
const mid = Math.floor((left + right) / 2)
if (accumulatedHeights[mid] < target) {
left = mid + 1
} else {
right = mid - 1
}
}
return Math.max(0, left - BUFFER_SIZE)
}
// 计算指定索引的偏移量
const getOffsetForIndex = (index: number) => {
let total = 0
// 累加到目标索引前的所有项目高度
for (let i = 0; i < index; i++) {
const itemHeight = heights.value.get(props.items[i].message?.id?.toString()) || ESTIMATED_ITEM_HEIGHT
total += itemHeight
}
return total
}
// 更新可见范围
const updateVisibleRange = () => {
if (!containerRef.value) return
const scrollTop = containerRef.value.scrollTop
const clientHeight = containerRef.value.clientHeight
// 计算起始索引
const start = getStartIndex(scrollTop)
let total = 0
let end = start
// 累加高度直到超过可视区域加上预渲染区域
while (total < clientHeight + OVERSCAN_SIZE * 2 && end < props.items.length) {
const itemHeight = heights.value.get(props.items[end].message?.id?.toString()) || ESTIMATED_ITEM_HEIGHT
total += itemHeight
end++
}
// 确保结束索引不超出范围,并添加缓冲区
end = Math.min(props.items.length - 1, end + BUFFER_SIZE)
// 更新可见范围和偏移量
visibleRange.value = { start, end }
// 加上加载中需要的偏移量
offset.value = getOffsetForIndex(start) + LOADING_OFFSET
}
// 更新可见范围的帧动画处理
const updateFrame = () => {
if (!containerRef.value) return
const currentScrollTop = containerRef.value.scrollTop
// 检查滚动位置是否变化
if (currentScrollTop !== lastScrollTop.value) {
// 发生滚动,重置静止帧计数
consecutiveStaticFrames.value = 0
// 发出滚动方向变化事件
if (currentScrollTop < lastScrollTop.value) {
emit('scrollDirectionChange', 'up')
} else if (currentScrollTop > lastScrollTop.value) {
emit('scrollDirectionChange', 'down')
}
updateVisibleRange()
lastScrollTop.value = currentScrollTop
} else {
// 滚动位置未变化,增加静止帧计数
consecutiveStaticFrames.value++
// 如果连续3帧未发生滚动,认为滚动已结束
if (consecutiveStaticFrames.value >= 3) {
// 滚动结束,更新高度并停止动画
isScrolling.value = false
updateItemHeight()
if (rafId.value !== null) {
cancelAnimationFrame(rafId.value)
rafId.value = null
}
return
}
}
// 继续下一帧
rafId.value = requestAnimationFrame(updateFrame)
}
// 滚动事件处理
const handleScroll = (event: Event) => {
emit('scroll', event)
// 标记滚动状态并开始帧动画
if (!isScrolling.value) {
isScrolling.value = true
consecutiveStaticFrames.value = 0
if (rafId.value === null) {
rafId.value = requestAnimationFrame(updateFrame)
}
}
}
onMounted(() => {
// 初始化可见范围
updateVisibleRange()
// 使用 ResizeObserver 监听容器大小变化
if (containerRef.value) {
resizeObserver.value = new ResizeObserver(() => {
updateVisibleRange()
nextTick(() => {
updateItemHeight()
})
})
resizeObserver.value.observe(containerRef.value)
}
// 初始化高度计算
nextTick(() => {
updateItemHeight()
})
})
onBeforeUnmount(() => {
// 清理动画
if (rafId.value !== null) {
cancelAnimationFrame(rafId.value)
rafId.value = null
}
// 清理 ResizeObserver
if (resizeObserver.value) {
resizeObserver.value.disconnect()
resizeObserver.value = null
}
// 清理缓存
heights.value.clear()
})
// 类型定义
export type VirtualListExpose = {
scrollTo: (options: { index?: number; position?: 'top' | 'bottom'; behavior?: ScrollBehavior }) => void
getContainer: () => HTMLElement | null
}
// 暴露方法和引用
defineExpose<VirtualListExpose>({
scrollTo: (options: { index?: number; position?: 'top' | 'bottom'; behavior?: ScrollBehavior }) => {
if (!containerRef.value) return
const executeScroll = () => {
if (!containerRef.value) return
if (options.position === 'bottom') {
// 滚动到底部前确保高度已更新
nextTick(() => {
updateItemHeight()
nextTick(() => {
if (containerRef.value) {
containerRef.value.scrollTop = totalHeight.value
}
})
})
} else if (options.position === 'top') {
// 滚动到顶部
containerRef.value.scrollTop = 0
} else if (typeof options.index === 'number') {
// 滚动到指定索引位置
const offset = getOffsetForIndex(options.index)
containerRef.value.scrollTo({
top: offset,
behavior: options.behavior || 'auto'
})
}
}
// 立即执行一次,并在短暂延迟后再次执行以确保内容已完全加载
executeScroll()
setTimeout(executeScroll, 100)
},
getContainer: () => containerRef.value
})
</script>
<style scoped>
.virtual-list-container {
position: relative;
overflow-y: auto;
height: 100%;
}
.virtual-list-container::-webkit-scrollbar {
width: 6px;
}
.virtual-list-container::-webkit-scrollbar-thumb {
background-color: rgba(144, 144, 144, 0.3);
border-radius: 3px;
transition: background-color 0.3s;
min-height: 75px;
}
.virtual-list-container::-webkit-scrollbar-thumb:hover {
background-color: rgba(144, 144, 144, 0.5);
}
.virtual-list-container::-webkit-scrollbar-track {
background: transparent;
}
.virtual-list-phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1;
}
.virtual-list-content {
position: absolute;
left: 0;
right: 0;
top: 0;
will-change: transform;
}
</style>
+73 -59
View File
@@ -39,10 +39,10 @@
</n-flex>
<!-- 中间聊天内容(使用虚拟列表) -->
<VirtualList
<MessageList
v-else
id="image-chat-main"
ref="virtualListInst"
ref="messageListInst"
:items="chatMessageList"
:estimatedItemHeight="itemSize"
:buffer="5"
@@ -50,6 +50,7 @@
:isLast="messageOptions?.isLast"
@scroll="handleScroll"
@scroll-direction-change="handleScrollDirectionChange"
@loadMore="handleLoadMore"
style="max-height: calc(100vh - 260px)">
<template #default="{ item, index }">
<n-flex
@@ -235,6 +236,7 @@
@mouseenter="handleMouseEnter(item.message.id)"
@mouseleave="handleMouseLeave"
class="w-fit relative flex flex-col"
:data-key="item.fromUser.uid === userUid ? `U${item.message.id}` : `Q${item.message.id}`"
:class="item.fromUser.uid === userUid ? 'items-end' : 'items-start'"
:style="{ '--bubble-max-width': chatStore.isGroup ? '32vw' : '50vw' }"
@select="$event.click(item)"
@@ -350,7 +352,7 @@
</div>
</n-flex>
</template>
</VirtualList>
</MessageList>
</Transition>
<!-- 弹出框 -->
@@ -379,19 +381,7 @@
</div>
</n-modal>
<!-- 悬浮按钮提示(部悬浮) // TODO 要结合已读未读功能来判断之前的信息有多少没有读,当现在的距离没有到最底部并且又有新消息来未读的时候显示下标的更多信息 (nyh -> 2024-03-07 01:27:22)-->
<!-- <header class="float-header" :class="chatStore.isGroup ? 'right-220px' : 'right-50px'">
<div class="float-box">
<n-flex justify="space-between" align="center">
<n-icon :color="'#13987f'">
<svg><use href="#double-up"></use></svg>
</n-icon>
<span class="text-12px">xx条新信息</span>
</n-flex>
</div>
</header> -->
<!-- 悬浮按钮提示(底部悬浮) -->
<!-- 悬浮按钮提示(部悬浮) -->
<footer
class="float-footer"
v-if="floatFooter && currentNewMsgCount?.count && currentNewMsgCount.count > 0"
@@ -423,7 +413,7 @@ import { type } from '@tauri-apps/plugin-os'
import { useUserStore } from '@/stores/user.ts'
import { useNetwork } from '@vueuse/core'
import { AvatarUtils } from '@/utils/AvatarUtils'
import VirtualList, { type VirtualListExpose } from '@/components/common/VirtualList.vue'
import MessageList, { type MessageListExpose } from '@/components/common/MessageList.vue'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
import { useTauriListener } from '@/hooks/useTauriListener'
import { useGroupStore } from '@/stores/group.ts'
@@ -462,7 +452,7 @@ const activeReply = ref('')
/** item最小高度,用于计算滚动大小和位置 */
const itemSize = computed(() => (chatStore.isGroup ? 90 : 76))
/** 虚拟列表 */
const virtualListInst = useTemplateRef<VirtualListExpose>('virtualListInst')
const messageListInst = useTemplateRef<MessageListExpose>('messageListInst')
/** 手动触发Popover显示 */
const infoPopover = ref(false)
// 记录 requestAnimationFrame 的返回值
@@ -499,8 +489,8 @@ provide('popoverControls', { enableScroll })
// 添加防抖处理
const debouncedScrollToBottom = useDebounceFn(() => {
if (!virtualListInst.value) return
virtualListInst.value?.scrollTo({ position: 'bottom', behavior: 'instant' })
if (!messageListInst.value) return
messageListInst.value?.scrollTo({ position: 'bottom', behavior: 'instant' })
}, 100)
// 监听会话切换
@@ -531,7 +521,7 @@ watch(
if (isLoadingMore.value) {
if (scrollTop.value < 26) {
requestAnimationFrame(() => {
virtualListInst.value?.scrollTo({ index: value.length - oldValue.length })
messageListInst.value?.scrollTo({ index: value.length - oldValue.length })
})
}
return
@@ -539,16 +529,16 @@ watch(
// 优先级1:用户发送的消息,始终滚动到底部
if (latestMessage?.fromUser?.uid === userUid.value) {
virtualListInst.value?.scrollTo({ position: 'bottom', behavior: 'instant' })
messageListInst.value?.scrollTo({ position: 'bottom', behavior: 'instant' })
return
}
// 优先级2:已经在底部时的新消息
const container = virtualListInst.value?.getContainer()
const container = messageListInst.value?.getContainer()
if (container) {
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight
if (distanceFromBottom <= 300) {
virtualListInst.value?.scrollTo({ position: 'bottom', behavior: 'smooth' })
messageListInst.value?.scrollTo({ position: 'bottom', behavior: 'instant' })
return
}
@@ -578,7 +568,7 @@ const handleScrollDirectionChange = (direction: 'up' | 'down') => {
/** 处理滚动事件(用于页脚显示功能) */
const handleScroll = () => {
if (isAutoScrolling.value) return // 如果是自动滚动,不处理
const container = virtualListInst.value?.getContainer()
const container = messageListInst.value?.getContainer()
if (!container) return
// 获取已滚动的距离
@@ -634,7 +624,7 @@ const handleScroll = () => {
const handleTransitionComplete = () => {
if (!messageOptions.value?.isLoading) {
nextTick(() => {
virtualListInst.value?.scrollTo({ position: 'bottom', behavior: 'instant' })
messageListInst.value?.scrollTo({ position: 'bottom', behavior: 'instant' })
})
}
}
@@ -699,7 +689,7 @@ const jumpToReplyMsg = (key: string) => {
// 找到对应消息的索引
const messageIndex = chatMessageList.value.findIndex((msg) => msg.message.id === key)
if (messageIndex !== -1) {
virtualListInst.value?.scrollTo({ index: messageIndex, behavior: 'instant' })
messageListInst.value?.scrollTo({ index: messageIndex, behavior: 'instant' })
activeReply.value = key
}
})
@@ -710,35 +700,31 @@ const jumpToReplyMsg = (key: string) => {
* @param index 下标
* @param id 用户ID
*/
// const addToDomUpdateQueue = (index: number, id: number) => {
// // 使用 nextTick 确保虚拟列表渲染完最新的项目后进行滚动
// nextTick(() => {
// if (!floatFooter.value || id === userUid.value) {
// virtualListInst.value?.scrollTo({ position: 'bottom', behavior: 'auto' })
// }
// /** data-key标识的气泡,添加前缀用于区分用户消息,不然气泡动画会被覆盖 */
// const dataKey = id === userUid.value ? `U${index}` : `Q${index}`
// const lastMessageElement = document.querySelector(`[data-key="${dataKey}"]`) as HTMLElement
// if (lastMessageElement) {
// // 添加动画类
// lastMessageElement.classList.add('bubble-animation')
// // 监听动画结束事件
// const handleAnimationEnd = () => {
// lastMessageElement.classList.remove('bubble-animation')
// lastMessageElement.removeEventListener('animationend', handleAnimationEnd)
// }
// lastMessageElement.addEventListener('animationend', handleAnimationEnd)
// }
// })
// chatStore.clearNewMsgCount()
// }
const addToDomUpdateQueue = (index: string, id: string) => {
// 使用 nextTick 确保虚拟列表渲染完最新的项目后进行滚动
nextTick(() => {
/** data-key标识的气泡,添加前缀用于区分用户消息,不然气泡动画会被覆盖 */
const dataKey = id === userUid.value ? `U${index}` : `Q${index}`
const lastMessageElement = document.querySelector(`[data-key="${dataKey}"]`) as HTMLElement
if (lastMessageElement) {
// 添加动画类
lastMessageElement.classList.add('bubble-animation')
// 监听动画结束事件
const handleAnimationEnd = () => {
lastMessageElement.classList.remove('bubble-animation')
lastMessageElement.removeEventListener('animationend', handleAnimationEnd)
}
lastMessageElement.addEventListener('animationend', handleAnimationEnd)
}
})
}
/** 点击后滚动到底部 */
const scrollBottom = () => {
if (!virtualListInst.value) return
if (!messageListInst.value) return
nextTick(() => {
virtualListInst.value?.scrollTo({ position: 'bottom', behavior: 'instant' })
messageListInst.value?.scrollTo({ position: 'bottom', behavior: 'instant' })
})
}
@@ -799,15 +785,43 @@ const handleReEdit = (msgId: string) => {
}
}
onMounted(async () => {
requestAnimationFrame(() => {
virtualListInst.value?.scrollTo({ position: 'bottom', behavior: 'instant' })
// 处理加载更多
const handleLoadMore = async () => {
// 如果正在加载或已经触发了加载,则不重复触发
if (messageOptions.value?.isLoading || isLoadingMore.value) return
// 记录当前的内容高度
const container = messageListInst.value?.getContainer()
if (!container) return
const oldScrollHeight = container.scrollHeight
isLoadingMore.value = true
// 禁用滚动交互但保持滚动条显示
container.style.pointerEvents = 'none'
await new Promise((resolve) => setTimeout(resolve, 300))
await chatStore.loadMore()
// 加载完成后,计算新增内容的高度差,并设置滚动位置
nextTick(() => {
const newScrollHeight = container.scrollHeight
const heightDiff = newScrollHeight - oldScrollHeight
if (heightDiff > 0) {
container.scrollTop = heightDiff
}
// 恢复滚动交互
nextTick(() => {
container.style.pointerEvents = 'auto'
isLoadingMore.value = false
})
})
useMitt.on(MittEnum.SEND_MESSAGE, async (messageType: MessageType) => {
await chatStore.pushMsg(messageType)
// nextTick(() => {
// addToDomUpdateQueue(event.message.id, event.fromUser.uid)
// })
}
onMounted(async () => {
nextTick(() => {
messageListInst.value?.scrollTo({ position: 'bottom', behavior: 'instant' })
})
useMitt.on(MittEnum.MESSAGE_ANIMATION, async (messageType: MessageType) => {
addToDomUpdateQueue(messageType.message.id, messageType.fromUser.uid)
})
useMitt.on(`${MittEnum.INFO_POPOVER}-Main`, (event: any) => {
selectKey.value = event.uid
+3 -3
View File
@@ -58,8 +58,6 @@ export enum MittEnum {
UPDATE_MSG_TOTAL = 'updateMsgTotal',
/** 显示消息框 */
MSG_BOX_SHOW = 'msgBoxShow',
/** 发送消息 */
SEND_MESSAGE = 'sendMessage',
/** 跳到发送信息 */
TO_SEND_MSG = 'toSendMsg',
/** 缩小窗口 */
@@ -89,7 +87,9 @@ export enum MittEnum {
/** 隐藏会话 */
HIDE_SESSION = 'hideSession',
/** 定位会话 */
LOCATE_SESSION = 'locateSession'
LOCATE_SESSION = 'locateSession',
/** 消息动画 */
MESSAGE_ANIMATION = 'messageAnimation'
}
/** 主题类型 */
+1 -16
View File
@@ -301,6 +301,7 @@ export const useMsgInput = (messageInputDom: Ref) => {
// 先添加到消息列表 - 此时会显示本地预览
chatStore.pushMsg(tempMsg)
useMitt.emit(MittEnum.MESSAGE_ANIMATION, tempMsg)
console.log('👾临时消息:', tempMsg)
// 设置发送状态的定时器
@@ -354,22 +355,6 @@ export const useMsgInput = (messageInputDom: Ref) => {
// 更新会话最后活动时间
chatStore.updateSessionLastActiveTime(globalStore.currentSession.roomId)
// // 保存到数据库
// await db.value?.execute(
// 'INSERT INTO message (room_id, from_uid, content, reply_msg_id, status, gap_count, type, create_time, update_time) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)',
// [
// globalStore.currentSession.roomId,
// userUid.value,
// msg.content,
// msg.reply,
// 0,
// 0,
// msg.type,
// new Date().getTime(),
// new Date().getTime()
// ]
// )
// 消息发送成功后释放预览URL
if (msg.type === MsgEnum.IMAGE && msg.url.startsWith('blob:')) {
URL.revokeObjectURL(msg.url)
+3
View File
@@ -181,6 +181,9 @@ useMitt.on(WsResponseMessageType.MSG_RECALL, (data: RevokedMsgType) => {
})
useMitt.on(WsResponseMessageType.RECEIVE_MESSAGE, async (data: MessageType) => {
chatStore.pushMsg(data)
if (data.fromUser.uid !== userStore.userInfo.uid) {
useMitt.emit(MittEnum.MESSAGE_ANIMATION, data)
}
// 接收到通知就设置图标闪烁
const username = useUserInfo(data.fromUser.uid).value.name!
// 不是自己发的消息才通知
-192
View File
@@ -1,192 +0,0 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import VirtualList from '../components/common/VirtualList.vue'
// 模拟数据生成函数
const generateMockItems = (count: number) => {
return Array.from({ length: count }, (_, index) => ({
message: {
id: index + 1,
body: { content: `Message ${index + 1}` }
}
}))
}
// 性能测试辅助函数
const measurePerformance = async (fn: () => Promise<void> | void) => {
const start = performance.now()
await fn()
return performance.now() - start
}
// 内存使用测试辅助函数
interface MemoryInfo {
jsHeapSizeLimit: number
totalJSHeapSize: number
usedJSHeapSize: number
}
declare global {
interface Performance {
memory?: MemoryInfo
}
}
describe('VirtualList Component', () => {
// 测试基本渲染
it('should render with default props', () => {
const wrapper = mount(VirtualList, {
props: {
items: [],
estimatedItemHeight: 80,
buffer: 5
}
})
expect(wrapper.exists()).toBe(true)
expect(wrapper.find('.virtual-list-container').exists()).toBe(true)
})
// 测试高度计算
it('should calculate total height correctly', async () => {
const items = generateMockItems(10)
const estimatedItemHeight = 80
const wrapper = mount(VirtualList, {
props: {
items,
estimatedItemHeight,
buffer: 5
}
})
await nextTick()
const phantom = wrapper.find('.virtual-list-phantom')
expect(phantom.attributes('style')).toContain(`height: ${items.length * estimatedItemHeight}px`)
})
// 测试可见项目计算
it('should calculate visible items correctly', async () => {
const items = generateMockItems(100)
const wrapper = mount(VirtualList, {
props: {
items,
estimatedItemHeight: 80,
buffer: 5
}
})
await nextTick()
// 检查初始渲染的项目数量是否合理(考虑到缓冲区)
const renderedItems = wrapper.findAll('[id^="item-"]')
expect(renderedItems.length).toBeGreaterThan(0)
expect(renderedItems.length).toBeLessThan(items.length)
})
// 测试滚动事件
it('should emit scroll events', async () => {
const wrapper = mount(VirtualList, {
props: {
items: generateMockItems(100),
estimatedItemHeight: 80,
buffer: 5
}
})
const container = wrapper.find('.virtual-list-container')
await container.trigger('scroll')
expect(wrapper.emitted('scroll')).toBeTruthy()
})
// 测试高度缓存清理
it('should cleanup height cache when exceeding limit', async () => {
const items = generateMockItems(2000) // 创建足够多的项目以触发清理
const wrapper = mount(VirtualList, {
props: {
items,
estimatedItemHeight: 80,
buffer: 5
}
})
await nextTick()
// 模拟滚动以触发高度缓存
const container = wrapper.get('.virtual-list-container')
Object.defineProperty(container.element, 'scrollTop', { value: 1000 })
await container.trigger('scroll')
// 等待清理完成
await new Promise((resolve) => setTimeout(resolve, 100))
// 验证缓存大小是否在合理范围内
const vm = wrapper.vm as any
expect(vm.heights.size).toBeLessThanOrEqual(1000)
})
// 测试 scrollTo 方法
it('should scroll to specified position', async () => {
const wrapper = mount(VirtualList, {
props: {
items: generateMockItems(100),
estimatedItemHeight: 80,
buffer: 5
}
})
const vm = wrapper.vm as any
// 测试滚动到底部
await vm.scrollTo({ position: 'bottom' })
await nextTick()
// 测试滚动到顶部
await vm.scrollTo({ position: 'top' })
await nextTick()
// 测试滚动到指定索引
await vm.scrollTo({ index: 50 })
await nextTick()
})
describe('Performance Benchmarks', () => {
// 测试大数据量渲染性能
it('should render large datasets efficiently', async () => {
const itemCount = 10000
const items = generateMockItems(itemCount)
const renderTime = await measurePerformance(async () => {
mount(VirtualList, {
props: {
items,
estimatedItemHeight: 80,
buffer: 5
}
})
await nextTick()
})
// 验证渲染时间是否在可接受范围内(例如小于1秒)
expect(renderTime).toBeLessThan(1000)
})
// 测试大量数据更新性能
it('should handle large data updates efficiently', async () => {
const wrapper = mount(VirtualList, {
props: {
items: generateMockItems(1000),
estimatedItemHeight: 80,
buffer: 5
}
})
const updateTime = await measurePerformance(async () => {
await wrapper.setProps({
items: generateMockItems(2000)
})
await nextTick()
})
// 验证更新时间是否在可接受范围内(例如小于500ms)
expect(updateTime).toBeLessThan(500)
})
})
})
+1
View File
@@ -22,6 +22,7 @@ declare module 'vue' {
Image: typeof import('./../components/rightBox/renderMessage/Image.vue')['default']
InfoPopover: typeof import('./../components/common/InfoPopover.vue')['default']
LoadingSpinner: typeof import('./../components/common/LoadingSpinner.vue')['default']
MessageList: typeof import('./../components/common/MessageList.vue')['default']
MsgInput: typeof import('./../components/rightBox/MsgInput.vue')['default']
NaiveProvider: typeof import('./../components/common/NaiveProvider.vue')['default']
NAlert: typeof import('naive-ui')['NAlert']
+36 -24
View File
@@ -87,7 +87,7 @@
:loading="loading"
:disabled="loginDisabled"
class="w-full mt-8px mb-50px"
@click="normalLogin()"
@click="debouncedLogin()"
color="#13987f">
<span>{{ loginText }}</span>
</n-button>
@@ -120,7 +120,7 @@
:loading="loading"
:disabled="loginDisabled"
class="w-200px mt-12px mb-40px"
@click="normalLogin(true)"
@click="debouncedLogin(true)"
color="#13987f">
{{ loginText }}
</n-button>
@@ -168,7 +168,7 @@ import { useSettingStore } from '@/stores/setting.ts'
import { AvatarUtils } from '@/utils/AvatarUtils'
import { useMitt } from '@/hooks/useMitt'
import { WsResponseMessageType } from '@/services/wsType'
import { useNetwork } from '@vueuse/core'
import { useNetwork, useDebounceFn } from '@vueuse/core'
import { useUserStatusStore } from '@/stores/userStatus'
import { clearListener } from '@/utils/ReadCountQueue'
import { useGlobalStore } from '@/stores/global'
@@ -196,22 +196,27 @@ const info = ref({
})
/** 协议 */
const protocol = ref(true)
const loginDisabled = ref(false)
const loginDisabled = ref(!isOnline.value)
const loading = ref(false)
const arrowStatus = ref(false)
const moreShow = ref(false)
const isAutoLogin = ref(false)
const isAutoLogin = ref(login.value.autoLogin && TOKEN.value && REFRESH_TOKEN.value)
const { setLoginState } = useLogin()
const accountPH = ref('输入HuLa账号')
const passwordPH = ref('输入HuLa密码')
/** 登录按钮的文本内容 */
const loginText = ref('登录')
const loginText = ref(isOnline.value ? (isAutoLogin.value ? '登录' : '登录') : '网络异常')
/** 是否直接跳转 */
const isJumpDirectly = ref(false)
const { createWebviewWindow } = useWindow()
watchEffect(() => {
loginDisabled.value = !(info.value.account && info.value.password && protocol.value)
loginDisabled.value = !(info.value.account && info.value.password && protocol.value && isOnline.value)
})
watch(isOnline, (v) => {
loginDisabled.value = !v
loginText.value = v ? (isAutoLogin.value ? '登录' : '登录') : '网络异常'
})
// 监听账号输入
@@ -233,13 +238,6 @@ watch(
}
)
watch(isOnline, (v) => {
if (v) {
loginDisabled.value = false
loginText.value = '登录'
}
})
/** 删除账号列表内容 */
const delAccount = (item: UserInfoType) => {
// 获取删除前账户列表的长度
@@ -271,14 +269,14 @@ const giveAccount = (item: UserInfoType) => {
/**登录后创建主页窗口*/
const normalLogin = async (auto = false) => {
loading.value = true
loginText.value = '登录中...'
loginDisabled.value = true
// 根据auto参数决定从哪里获取登录信息
const loginInfo = auto ? (userStore.userInfo as UserInfoType) : info.value
const { account } = loginInfo
// 自动登录
if (auto) {
isAutoLogin.value = true
loginText.value = '登录中...'
// 添加2秒延迟
await new Promise((resolve) => setTimeout(resolve, 1200))
@@ -298,10 +296,20 @@ const normalLogin = async (auto = false) => {
await openHomeWindow()
loading.value = false
} catch (error) {
console.error('自动登录失败')
localStorage.removeItem('TOKEN')
isAutoLogin.value = false
loginDisabled.value = true
console.error('自动登录失败', error)
// 如果是网络异常,不删除token
if (!isOnline.value) {
loginDisabled.value = true
loginText.value = '网络异常'
loading.value = false
} else {
// 其他错误才清除token并重置状态
localStorage.removeItem('TOKEN')
isAutoLogin.value = false
loginDisabled.value = true
loginText.value = '登录'
loading.value = false
}
}
return
}
@@ -349,6 +357,8 @@ const normalLogin = async (auto = false) => {
})
.catch(() => {
loading.value = false
loginDisabled.value = false
loginText.value = '登录'
// 如果是自动登录失败,重置按钮状态允许手动登录
if (auto) {
loginDisabled.value = false
@@ -357,6 +367,8 @@ const normalLogin = async (auto = false) => {
})
}
const debouncedLogin = useDebounceFn(normalLogin, 500)
const openHomeWindow = async () => {
await createWebviewWindow('HuLa', 'home', 960, 720, 'login', true)
}
@@ -380,7 +392,7 @@ const closeMenu = (event: MouseEvent) => {
const enterKey = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !loginDisabled.value) {
normalLogin()
debouncedLogin()
}
}
@@ -424,9 +436,9 @@ onMounted(async () => {
loginText.value = '服务异常断开'
})
// 自动登录
if (login.value.autoLogin && TOKEN.value && REFRESH_TOKEN.value) {
normalLogin(true)
// 自动登录时直接触发登录
if (isAutoLogin.value) {
debouncedLogin(true)
} else {
loginHistories.length > 0 && giveAccount(loginHistories[0])
}