Files
MoviePilot-Frontend/src/views/system/MessageView.vue
jxxghp b93f8f2bff fix: 消息中心首次打开时SSE与数据库消息重复显示
SSE消息只有date字段、note为null,数据库消息只有reg_time、note为{},
原getMessageKey将reg_time和date作为两个独立字段拼接签名导致同一条消息签名不同。
归一化时间字段(reg_time||date)和note字段后去重恢复正常。
2026-05-30 19:18:55 +08:00

391 lines
9.3 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts" setup>
import type { Message } from '@/api/types'
import MessageCard from '@/components/cards/MessageCard.vue'
import api from '@/api'
import { useI18n } from 'vue-i18n'
import { useBackground } from '@/composables/useBackground'
// 国际化
const { t } = useI18n()
const { useSSE } = useBackground()
// 消息列表
const messages = ref<Message[]>([])
// 当前页数据
const currData = ref<Message[]>([])
// 已加载消息的签名集合
// SSE 消息与数据库消息的字段来源不同date vs reg_time, null vs {}),签名已归一化处理。
const messageKeys = new Set<string>()
// 是否完成加载
const isLoaded = ref(false)
// 是否加载中
const loading = ref(false)
// 当前页码
const page = ref(1)
// 存量消息最新时间
const lastTime = ref('')
// 消息列表滚动容器
const messageListRef = ref<any>(null)
// 自动滚动状态
const shouldAutoScroll = ref(true)
const isSyncingScroll = ref(false)
const MESSAGE_AUTO_SCROLL_THRESHOLD = 64
let scrollTimer: number | undefined
let scrollReleaseTimer: number | undefined
// 生成消息去重签名
// SSE 消息只有 date 没有 reg_time数据库消息只有 reg_time 没有 date
// note 在 SSE 侧为 null数据库侧为 {},需要归一化。
function normalizeNote(note: Message['note']): string {
if (note == null) return ''
if (typeof note === 'string') return note
if (typeof note === 'object' && !Array.isArray(note) && Object.keys(note).length === 0) return ''
return JSON.stringify(note)
}
function getMessageKey(message: Message) {
return [
message.action ?? '',
message.userid ?? '',
message.reg_time || message.date || '',
message.title ?? '',
message.text ?? '',
message.image ?? '',
message.link ?? '',
normalizeNote(message.note),
].join('::')
}
// 获取消息时间
function getMessageTime(message: Message) {
return message.reg_time || message.date || ''
}
// 排序消息列表,确保最新消息始终位于底部
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) {
lastTime.value = messageTime
}
}
function getScrollContainer() {
const container = messageListRef.value?.$el ?? messageListRef.value
return container instanceof HTMLElement ? container : null
}
function isNearBottom(container: HTMLElement) {
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight
return distanceFromBottom <= Math.max(MESSAGE_AUTO_SCROLL_THRESHOLD, container.clientHeight / 3)
}
function updateAutoScrollState() {
const container = getScrollContainer()
if (!container || isSyncingScroll.value) {
return
}
shouldAutoScroll.value = isNearBottom(container)
}
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) {
return
}
isSyncingScroll.value = true
container.scrollTop = container.scrollHeight
requestAnimationFrame(() => {
const latestContainer = getScrollContainer()
if (!latestContainer) {
isSyncingScroll.value = false
return
}
latestContainer.scrollTop = latestContainer.scrollHeight
shouldAutoScroll.value = true
if (scrollReleaseTimer) {
window.clearTimeout(scrollReleaseTimer)
}
scrollReleaseTimer = window.setTimeout(() => {
isSyncingScroll.value = false
updateAutoScrollState()
}, 80)
})
}
function requestScrollToEnd(force = false) {
if (!force && !shouldAutoScroll.value) {
return
}
if (scrollTimer) {
window.clearTimeout(scrollTimer)
}
scrollTimer = window.setTimeout(() => {
nextTick(() => {
requestAnimationFrame(() => {
scrollContainerToEnd()
})
})
}, force ? 0 : 80)
}
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
}
messageKeys.add(messageKey)
messages.value.push(item)
updateLastTime(item)
hasNewMessage = true
}
if (hasNewMessage) {
messages.value = sortMessages(messages.value)
}
return hasNewMessage
}
// SSE消息处理函数
function handleSSEMessage(event: MessageEvent) {
const message = event.data
if (message) {
const object = JSON.parse(message)
if (mergeMessages([object])) {
requestScrollToEnd() // 新消息到达时触发智能滚动
}
}
}
// 使用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,
},
)
// 调用API加载存量消息
async function loadMessages({ done }: { done: any }) {
// 如果正在加载中,直接返回
if (loading.value) {
done('ok')
return
}
try {
// 设置加载中
loading.value = true
currData.value = await api.get('message/web', {
params: {
page: page.value,
size: 20,
},
})
// 已加载过
isLoaded.value = true
if (currData.value.length > 0) {
const hasNewMessage = mergeMessages(currData.value)
// 首次加载时滚动到底部
if (page.value === 1 && hasNewMessage) {
requestScrollToEnd(true)
}
// 页码+1
page.value++
// 完成
done('ok')
} else {
// 没有新数据
done('empty')
}
} catch (error) {
console.error('加载消息失败:', error)
done('error')
} finally {
loading.value = false
}
}
// 主动刷新最新一页消息作为SSE偶发丢流时的兜底
async function refreshLatestMessages() {
try {
const latestMessages = (await api.get('message/web', {
params: {
page: 1,
size: 20,
},
})) as Message[]
if (mergeMessages(latestMessages)) {
requestScrollToEnd()
}
} catch (error) {
console.error('刷新最新消息失败:', error)
}
}
// 比较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) => {
// 如果是ISO格式直接使用
if (time.includes('T')) {
return new Date(time).getTime()
}
// 如果是yyyy-MM-dd HH:mm:ss格式替换-为/
return new Date(time.replaceAll(/-/g, '/')).getTime()
}
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连接
function pauseSSE() {
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,
refreshLatestMessages,
forceScrollToEnd,
})
onMounted(() => {
nextTick(() => {
bindScrollListener()
requestScrollToEnd(true)
})
})
onBeforeUnmount(() => {
if (scrollTimer) {
window.clearTimeout(scrollTimer)
}
if (scrollReleaseTimer) {
window.clearTimeout(scrollReleaseTimer)
}
unbindScrollListener()
})
</script>
<template>
<VInfiniteScroll
ref="messageListRef"
:mode="!isLoaded ? 'intersect' : 'manual'"
side="start"
:items="messages"
class="overflow-auto h-full"
@load="loadMessages"
:load-more-text="t('message.loadMore') + ' ...'"
>
<template #loading>
<LoadingBanner />
</template>
<template #empty> {{ t('message.noMoreData') }} </template>
<VVirtualScroll renderless :items="messages" :item-height="160">
<template #default="{ item, index, itemRef }">
<div
: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'"
>
<div class="d-inline-flex flex-column" :class="item.action == 1 ? 'align-start' : 'align-end'">
<MessageCard :message="item" @imageload="handleImageLoad" />
</div>
</div>
</template>
</VVirtualScroll>
</VInfiniteScroll>
</template>