diff --git a/src/layouts/components/ShortcutBar.vue b/src/layouts/components/ShortcutBar.vue index 233db96d..9e847a57 100644 --- a/src/layouts/components/ShortcutBar.vue +++ b/src/layouts/components/ShortcutBar.vue @@ -9,10 +9,7 @@ type MessageViewExpose = { pauseSSE?: () => void resumeSSE?: () => void refreshLatestMessages?: () => Promise | void -} - -type MessageScrollPayload = { - force?: boolean + forceScrollToEnd?: () => void } // 国际化 @@ -71,24 +68,9 @@ const user_message = ref('') // 发送按钮是否可用 const sendButtonDisabled = ref(false) -// 消息对话框引用 -const messageDialogRef = ref(null) - // 消息视图引用 const messageViewRef = ref(null) -// 滚动容器引用 -const messageContentRef = ref() - -// 消息滚动状态 -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" > @@ -598,8 +464,8 @@ onMounted(() => { - - + + diff --git a/src/views/system/MessageView.vue b/src/views/system/MessageView.vue index 13c42fae..ffbf7a4f 100644 --- a/src/views/system/MessageView.vue +++ b/src/views/system/MessageView.vue @@ -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([]) // 当前页数据 @@ -39,6 +30,18 @@ const page = ref(1) // 存量消息最新时间 const lastTime = ref('') +// 消息列表滚动容器 +const messageListRef = ref(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() })