fix: scroll message list container to latest

This commit is contained in:
jxxghp
2026-05-15 18:56:37 +08:00
parent 48546e1999
commit 0fda7c70de
2 changed files with 124 additions and 154 deletions

View File

@@ -9,10 +9,7 @@ type MessageViewExpose = {
pauseSSE?: () => void
resumeSSE?: () => void
refreshLatestMessages?: () => Promise<void> | void
}
type MessageScrollPayload = {
force?: boolean
forceScrollToEnd?: () => void
}
// 国际化
@@ -71,24 +68,9 @@ const user_message = ref('')
// 发送按钮是否可用
const sendButtonDisabled = ref(false)
// 消息对话框引用
const messageDialogRef = ref<any>(null)
// 消息视图引用
const messageViewRef = ref<MessageViewExpose | null>(null)
// 滚动容器引用
const messageContentRef = ref<any>()
// 消息滚动状态
const shouldAutoScrollMessage = ref(true)
const isSyncingMessageScroll = ref(false)
const MESSAGE_AUTO_SCROLL_THRESHOLD = 64
let messageScrollTimer: number | undefined
let messageScrollReleaseTimer: number | undefined
// 定义捷径列表
const shortcuts = [
{
@@ -166,104 +148,6 @@ function openMessageDialog() {
messageDialog.value = true
}
function getMessageScrollContainer() {
const container = messageContentRef.value?.$el ?? messageContentRef.value
return container instanceof HTMLElement ? container : null
}
function isMessageNearBottom(container: HTMLElement) {
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight
return distanceFromBottom <= Math.max(MESSAGE_AUTO_SCROLL_THRESHOLD, container.clientHeight / 3)
}
function updateMessageAutoScrollState() {
const container = getMessageScrollContainer()
if (!container || isSyncingMessageScroll.value) {
return
}
shouldAutoScrollMessage.value = isMessageNearBottom(container)
}
function handleMessageScroll() {
updateMessageAutoScrollState()
}
function bindMessageScrollListener() {
const container = getMessageScrollContainer()
if (!container) {
return
}
container.removeEventListener('scroll', handleMessageScroll)
container.addEventListener('scroll', handleMessageScroll, { passive: true })
updateMessageAutoScrollState()
}
function unbindMessageScrollListener() {
getMessageScrollContainer()?.removeEventListener('scroll', handleMessageScroll)
}
function scrollMessageContainerToEnd() {
const container = getMessageScrollContainer()
if (!container) {
return
}
isSyncingMessageScroll.value = true
container.scrollTop = container.scrollHeight
requestAnimationFrame(() => {
const latestContainer = getMessageScrollContainer()
if (!latestContainer) {
isSyncingMessageScroll.value = false
return
}
latestContainer.scrollTop = latestContainer.scrollHeight
shouldAutoScrollMessage.value = true
if (messageScrollReleaseTimer) {
window.clearTimeout(messageScrollReleaseTimer)
}
messageScrollReleaseTimer = window.setTimeout(() => {
isSyncingMessageScroll.value = false
updateMessageAutoScrollState()
}, 80)
})
}
function scheduleMessageScroll(force = false) {
if (!force && !shouldAutoScrollMessage.value) {
return
}
if (messageScrollTimer) {
window.clearTimeout(messageScrollTimer)
}
messageScrollTimer = window.setTimeout(() => {
nextTick(() => {
requestAnimationFrame(() => {
scrollMessageContainerToEnd()
})
})
}, force ? 0 : 80)
}
// 智能滚动到底部:首次打开时允许强制滚动,后续实时消息尊重用户当前位置。
function scrollMessageToEnd(payload?: MessageScrollPayload) {
scheduleMessageScroll(Boolean(payload?.force))
}
// 强制滚动到底部(用于发送消息后)
function forceScrollToEnd() {
scheduleMessageScroll(true)
}
// 拼接全部日志url
function allLoggingUrl() {
return `${import.meta.env.VITE_API_BASE_URL}system/logging?length=-1`
@@ -283,7 +167,7 @@ async function sendMessage() {
// 发送成功后主动同步最新一页消息避免SSE短暂断流时界面停留在旧状态。
// await messageViewRef.value?.refreshLatestMessages?.()
forceScrollToEnd() // 发送消息后强制滚动到底部
messageViewRef.value?.forceScrollToEnd?.()
} catch (error) {
console.error(error)
} finally {
@@ -304,12 +188,9 @@ defineExpose({
// 监听消息对话框状态变化
watch(messageDialog, async newValue => {
if (newValue) {
shouldAutoScrollMessage.value = true
await nextTick()
bindMessageScrollListener()
messageViewRef.value?.resumeSSE?.()
forceScrollToEnd()
messageViewRef.value?.forceScrollToEnd?.()
window.setTimeout(() => {
void clearAppBadge()
@@ -318,26 +199,12 @@ watch(messageDialog, async newValue => {
return
}
unbindMessageScrollListener()
if (messageViewRef.value?.pauseSSE) {
// 对话框关闭时暂停SSE连接
messageViewRef.value.pauseSSE()
}
})
onBeforeUnmount(() => {
if (messageScrollTimer) {
window.clearTimeout(messageScrollTimer)
}
if (messageScrollReleaseTimer) {
window.clearTimeout(messageScrollReleaseTimer)
}
unbindMessageScrollListener()
})
onMounted(() => {
const shortcut = getQueryValue('shortcut')
if (shortcut) {
@@ -587,7 +454,6 @@ onMounted(() => {
max-width="50rem"
scrollable
:fullscreen="!display.mdAndUp.value"
ref="messageDialogRef"
>
<VCard>
<VCardItem>
@@ -598,8 +464,8 @@ onMounted(() => {
<VDialogCloseBtn @click="messageDialog = false" />
</VCardItem>
<VDivider />
<VCardText ref="messageContentRef">
<MessageView ref="messageViewRef" @scroll="scrollMessageToEnd" />
<VCardText>
<MessageView ref="messageViewRef" />
</VCardText>
<VDivider />
<VCardActions class="pa-4">

View File

@@ -9,15 +9,6 @@ import { useBackgroundOptimization } from '@/composables/useBackgroundOptimizati
const { t } = useI18n()
const { useSSE } = useBackgroundOptimization()
type ScrollPayload = {
force?: boolean
}
// 定义事件
const emit = defineEmits<{
(e: 'scroll', payload?: ScrollPayload): void
}>()
// 消息列表
const messages = ref<Message[]>([])
// 当前页数据
@@ -39,6 +30,18 @@ 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
// 获取消息时间
function getMessageTime(message: Message) {
return message.reg_time || message.date || ''
@@ -72,13 +75,98 @@ function updateLastTime(message: Message) {
}
}
// 请求父组件滚动,首屏历史消息需要强制到底,实时消息继续使用智能滚动。
function requestScrollToEnd(force = false) {
nextTick(() => {
emit('scroll', { force })
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
@@ -239,16 +327,32 @@ defineExpose({
pauseSSE,
resumeSSE,
refreshLatestMessages,
forceScrollToEnd,
})
onMounted(() => {
// 组件挂载后触发一次滚动事件
requestScrollToEnd()
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"