mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-07-12 16:01:35 +08:00
Revert "Feat/virtualizarefactor: virtualization rework — unify Virtual components, fix memory leaks, migrate 15+ consumerstion rework (#472)"
This reverts commit 5953496d84.
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Message } from '@/api/types'
|
||||
import MessageCard from '@/components/cards/MessageCard.vue'
|
||||
import VirtualList from '@/components/virtual/VirtualList.vue'
|
||||
import api from '@/api'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
@@ -10,28 +9,28 @@ import { useBackgroundOptimization } from '@/composables/useBackgroundOptimizati
|
||||
const { t } = useI18n()
|
||||
const { useSSE } = useBackgroundOptimization()
|
||||
|
||||
// 消息列表(按时间升序:旧 -> 新,最新在底部)
|
||||
// 消息列表
|
||||
const messages = ref<Message[]>([])
|
||||
// 当前页数据
|
||||
const currData = ref<Message[]>([])
|
||||
|
||||
// 已加载消息的签名集合,仅按秒级时间戳会误吞同一秒内的不同消息
|
||||
// 已加载消息的签名集合
|
||||
// 使用消息内容签名去重,避免仅按秒级时间戳判断时误吞同一秒内的不同消息。
|
||||
const messageKeys = new Set<string>()
|
||||
|
||||
// 是否完成首屏加载(影响空态文案显示)
|
||||
// 是否完成加载
|
||||
const isLoaded = ref(false)
|
||||
// 是否加载中(避免并发)
|
||||
|
||||
// 是否加载中
|
||||
const loading = ref(false)
|
||||
// 还有更早的消息可加载?拉到空页后置 false
|
||||
const hasMoreOlder = ref(true)
|
||||
// 当前页码:拉取「更早」消息时递增
|
||||
|
||||
// 当前页码
|
||||
const page = ref(1)
|
||||
|
||||
// 存量消息最新时间
|
||||
const lastTime = ref('')
|
||||
|
||||
// 反向加载阈值。首屏完成 + forceScrollToEnd 之后才打开,否则
|
||||
// reverse sentinel 在 items 首次渲染时会和跳到底部竞速,多拉一页。
|
||||
const reverseThreshold = ref(0)
|
||||
|
||||
// 虚拟列表实例引用:用于读取 scrollEl 做智能滚动
|
||||
// 消息列表滚动容器
|
||||
const messageListRef = ref<any>(null)
|
||||
|
||||
// 自动滚动状态
|
||||
@@ -48,8 +47,7 @@ function getMessageTime(message: Message) {
|
||||
return message.reg_time || message.date || ''
|
||||
}
|
||||
|
||||
// 生成消息签名(多字段 fallback 链,单字段 keyField 表达不了,
|
||||
// 所以走 VirtualList 的 getItemKey 函数 prop)
|
||||
// 生成消息签名
|
||||
function getMessageKey(message: Message) {
|
||||
return [
|
||||
message.action ?? '',
|
||||
@@ -64,11 +62,12 @@ function getMessageKey(message: Message) {
|
||||
].join('::')
|
||||
}
|
||||
|
||||
// 排序确保最新消息始终位于底部
|
||||
// 排序消息列表,确保最新消息始终位于底部
|
||||
function sortMessages(items: Message[]) {
|
||||
return [...items].sort((a, b) => compareTime(getMessageTime(a), getMessageTime(b)))
|
||||
}
|
||||
|
||||
// 记录最新消息时间
|
||||
function updateLastTime(message: Message) {
|
||||
const messageTime = getMessageTime(message)
|
||||
if (messageTime && compareTime(messageTime, lastTime.value) > 0) {
|
||||
@@ -77,8 +76,7 @@ function updateLastTime(message: Message) {
|
||||
}
|
||||
|
||||
function getScrollContainer() {
|
||||
const container =
|
||||
messageListRef.value?.getScrollElement?.() ?? messageListRef.value?.$el ?? messageListRef.value
|
||||
const container = messageListRef.value?.$el ?? messageListRef.value
|
||||
|
||||
return container instanceof HTMLElement ? container : null
|
||||
}
|
||||
@@ -102,6 +100,21 @@ function handleScroll() {
|
||||
updateAutoScrollState()
|
||||
}
|
||||
|
||||
function bindScrollListener() {
|
||||
const container = getScrollContainer()
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
|
||||
container.removeEventListener('scroll', handleScroll)
|
||||
container.addEventListener('scroll', handleScroll, { passive: true })
|
||||
updateAutoScrollState()
|
||||
}
|
||||
|
||||
function unbindScrollListener() {
|
||||
getScrollContainer()?.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
|
||||
function scrollContainerToEnd() {
|
||||
const container = getScrollContainer()
|
||||
if (!container) {
|
||||
@@ -154,71 +167,101 @@ function forceScrollToEnd() {
|
||||
requestScrollToEnd(true)
|
||||
}
|
||||
|
||||
// 合并消息到当前列表
|
||||
function mergeMessages(items: Message[]) {
|
||||
let hasNewMessage = false
|
||||
|
||||
for (const item of sortMessages(items)) {
|
||||
const messageKey = getMessageKey(item)
|
||||
if (messageKeys.has(messageKey)) continue
|
||||
if (messageKeys.has(messageKey)) {
|
||||
continue
|
||||
}
|
||||
|
||||
messageKeys.add(messageKey)
|
||||
messages.value.push(item)
|
||||
updateLastTime(item)
|
||||
hasNewMessage = true
|
||||
}
|
||||
if (hasNewMessage) messages.value = sortMessages(messages.value)
|
||||
|
||||
if (hasNewMessage) {
|
||||
messages.value = sortMessages(messages.value)
|
||||
}
|
||||
|
||||
return hasNewMessage
|
||||
}
|
||||
|
||||
// SSE 新消息到达 -> 智能跟随到底
|
||||
// SSE消息处理函数
|
||||
function handleSSEMessage(event: MessageEvent) {
|
||||
const message = event.data
|
||||
if (!message) return
|
||||
|
||||
const object = JSON.parse(message)
|
||||
if (mergeMessages([object])) {
|
||||
requestScrollToEnd()
|
||||
if (message) {
|
||||
const object = JSON.parse(message)
|
||||
if (mergeMessages([object])) {
|
||||
requestScrollToEnd() // 新消息到达时触发智能滚动
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用优化的 SSE 连接
|
||||
const { manager } = useSSE(
|
||||
// 使用优化的SSE连接
|
||||
const { manager, isConnected } = useSSE(
|
||||
`${import.meta.env.VITE_API_BASE_URL}system/message?role=user`,
|
||||
handleSSEMessage,
|
||||
'message-view',
|
||||
{ backgroundCloseDelay: 5000, reconnectDelay: 3000, maxReconnectAttempts: 3 },
|
||||
{
|
||||
backgroundCloseDelay: 5000,
|
||||
reconnectDelay: 3000,
|
||||
maxReconnectAttempts: 3,
|
||||
},
|
||||
)
|
||||
|
||||
// 加载更早一页:首屏 + 用户向上滚动时由 @load-more-reverse 触发
|
||||
async function loadOlderMessages() {
|
||||
if (loading.value || !hasMoreOlder.value) return
|
||||
// 调用API加载存量消息
|
||||
async function loadMessages({ done }: { done: any }) {
|
||||
// 如果正在加载中,直接返回
|
||||
if (loading.value) {
|
||||
done('ok')
|
||||
return
|
||||
}
|
||||
try {
|
||||
// 设置加载中
|
||||
loading.value = true
|
||||
const data = (await api.get('message/web', {
|
||||
params: { page: page.value, size: 20 },
|
||||
})) as Message[]
|
||||
currData.value = await api.get('message/web', {
|
||||
params: {
|
||||
page: page.value,
|
||||
size: 20,
|
||||
},
|
||||
})
|
||||
// 已加载过
|
||||
isLoaded.value = true
|
||||
if (data.length > 0) {
|
||||
const hasNewMessage = mergeMessages(data)
|
||||
if (currData.value.length > 0) {
|
||||
const hasNewMessage = mergeMessages(currData.value)
|
||||
|
||||
// 首次加载时滚动到底部
|
||||
if (page.value === 1 && hasNewMessage) {
|
||||
requestScrollToEnd(true)
|
||||
}
|
||||
|
||||
// 页码+1
|
||||
page.value++
|
||||
// 完成
|
||||
done('ok')
|
||||
} else {
|
||||
hasMoreOlder.value = false
|
||||
// 没有新数据
|
||||
done('empty')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载消息失败:', error)
|
||||
done('error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 主动刷新最新一页,作为 SSE 偶发丢流时的兜底
|
||||
// 主动刷新最新一页消息,作为SSE偶发丢流时的兜底
|
||||
async function refreshLatestMessages() {
|
||||
try {
|
||||
const latestMessages = (await api.get('message/web', {
|
||||
params: { page: 1, size: 20 },
|
||||
params: {
|
||||
page: 1,
|
||||
size: 20,
|
||||
},
|
||||
})) as Message[]
|
||||
|
||||
if (mergeMessages(latestMessages)) {
|
||||
@@ -229,41 +272,57 @@ async function refreshLatestMessages() {
|
||||
}
|
||||
}
|
||||
|
||||
// 比较时间
|
||||
// 比较yyyy-MM-dd HH:mm:ss时间大小
|
||||
function compareTime(time1: string, time2: string) {
|
||||
if (!time1 && !time2) return 0
|
||||
if (!time1) return -1
|
||||
if (!time2) return 1
|
||||
|
||||
try {
|
||||
// 统一时间格式处理,支持多种格式
|
||||
const normalizeTime = (time: string) => {
|
||||
if (time.includes('T')) return new Date(time).getTime()
|
||||
// 如果是ISO格式,直接使用
|
||||
if (time.includes('T')) {
|
||||
return new Date(time).getTime()
|
||||
}
|
||||
// 如果是yyyy-MM-dd HH:mm:ss格式,替换-为/
|
||||
return new Date(time.replaceAll(/-/g, '/')).getTime()
|
||||
}
|
||||
return normalizeTime(time1) - normalizeTime(time2)
|
||||
|
||||
const timestamp1 = normalizeTime(time1)
|
||||
const timestamp2 = normalizeTime(time2)
|
||||
|
||||
return timestamp1 - timestamp2
|
||||
} catch (error) {
|
||||
console.error('时间比较错误:', error, 'time1:', time1, 'time2:', time2)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// 图片加载完成 -> 智能跟随(图片把行撑高,需要重判距底距离)
|
||||
// 图片加载完成时触发智能滚动
|
||||
function handleImageLoad() {
|
||||
requestScrollToEnd()
|
||||
}
|
||||
|
||||
// 暂停/恢复 SSE
|
||||
// 暂停SSE连接
|
||||
function pauseSSE() {
|
||||
manager?.removeMessageListener('message-view')
|
||||
if (manager) {
|
||||
manager.removeMessageListener('message-view')
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复SSE连接
|
||||
function resumeSSE() {
|
||||
if (manager) {
|
||||
// 先移除再重建监听,确保恢复时拿到一条新的SSE连接。
|
||||
manager.removeMessageListener('message-view')
|
||||
manager.addMessageListener('message-view', handleSSEMessage)
|
||||
}
|
||||
|
||||
refreshLatestMessages()
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
pauseSSE,
|
||||
resumeSSE,
|
||||
@@ -271,12 +330,10 @@ defineExpose({
|
||||
forceScrollToEnd,
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadOlderMessages()
|
||||
await nextTick()
|
||||
scrollContainerToEnd()
|
||||
requestAnimationFrame(() => {
|
||||
reverseThreshold.value = 1
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
bindScrollListener()
|
||||
requestScrollToEnd(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -288,43 +345,38 @@ onBeforeUnmount(() => {
|
||||
if (scrollReleaseTimer) {
|
||||
window.clearTimeout(scrollReleaseTimer)
|
||||
}
|
||||
|
||||
unbindScrollListener()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VirtualList
|
||||
<VInfiniteScroll
|
||||
ref="messageListRef"
|
||||
:mode="!isLoaded ? 'intersect' : 'manual'"
|
||||
side="start"
|
||||
:items="messages"
|
||||
:estimate-size="160"
|
||||
:get-item-key="getMessageKey"
|
||||
:load-more-reverse-threshold="reverseThreshold"
|
||||
container-height="100%"
|
||||
class="h-full overflow-auto"
|
||||
@load-more-reverse="loadOlderMessages"
|
||||
@scroll="handleScroll"
|
||||
class="overflow-auto h-full"
|
||||
@load="loadMessages"
|
||||
:load-more-text="t('message.loadMore') + ' ...'"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="d-flex justify-center align-center h-full text-medium-emphasis">
|
||||
{{ isLoaded ? t('message.noMoreData') : '' }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #loading>
|
||||
<LoadingBanner v-if="loading" />
|
||||
<LoadingBanner />
|
||||
</template>
|
||||
|
||||
<template #item="{ item }">
|
||||
<div
|
||||
class="chat-group d-flex mt-5 mb-8"
|
||||
:class="item.action == 1 ? 'flex-row align-start' : 'flex-row-reverse align-end'"
|
||||
>
|
||||
<template #empty> {{ t('message.noMoreData') }} </template>
|
||||
<VVirtualScroll renderless :items="messages" :item-height="160">
|
||||
<template #default="{ item, index, itemRef }">
|
||||
<div
|
||||
class="d-inline-flex flex-column"
|
||||
:class="item.action == 1 ? 'align-start' : 'align-end'"
|
||||
:ref="itemRef"
|
||||
:key="getMessageKey(item) || index"
|
||||
class="chat-group d-flex mt-5 mb-8"
|
||||
:class="item.action == 1 ? 'flex-row align-start' : 'flex-row-reverse align-end'"
|
||||
>
|
||||
<MessageCard :message="item" @imageload="handleImageLoad" />
|
||||
<div class="d-inline-flex flex-column" :class="item.action == 1 ? 'align-start' : 'align-end'">
|
||||
<MessageCard :message="item" @imageload="handleImageLoad" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VirtualList>
|
||||
</template>
|
||||
</VVirtualScroll>
|
||||
</VInfiniteScroll>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user