mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 23:56:42 +08:00
fix: scroll message list container to latest
This commit is contained in:
@@ -9,10 +9,7 @@ type MessageViewExpose = {
|
|||||||
pauseSSE?: () => void
|
pauseSSE?: () => void
|
||||||
resumeSSE?: () => void
|
resumeSSE?: () => void
|
||||||
refreshLatestMessages?: () => Promise<void> | void
|
refreshLatestMessages?: () => Promise<void> | void
|
||||||
}
|
forceScrollToEnd?: () => void
|
||||||
|
|
||||||
type MessageScrollPayload = {
|
|
||||||
force?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
@@ -71,24 +68,9 @@ const user_message = ref('')
|
|||||||
// 发送按钮是否可用
|
// 发送按钮是否可用
|
||||||
const sendButtonDisabled = ref(false)
|
const sendButtonDisabled = ref(false)
|
||||||
|
|
||||||
// 消息对话框引用
|
|
||||||
const messageDialogRef = ref<any>(null)
|
|
||||||
|
|
||||||
// 消息视图引用
|
// 消息视图引用
|
||||||
const messageViewRef = ref<MessageViewExpose | null>(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 = [
|
const shortcuts = [
|
||||||
{
|
{
|
||||||
@@ -166,104 +148,6 @@ function openMessageDialog() {
|
|||||||
messageDialog.value = true
|
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
|
// 拼接全部日志url
|
||||||
function allLoggingUrl() {
|
function allLoggingUrl() {
|
||||||
return `${import.meta.env.VITE_API_BASE_URL}system/logging?length=-1`
|
return `${import.meta.env.VITE_API_BASE_URL}system/logging?length=-1`
|
||||||
@@ -283,7 +167,7 @@ async function sendMessage() {
|
|||||||
|
|
||||||
// 发送成功后主动同步最新一页消息,避免SSE短暂断流时界面停留在旧状态。
|
// 发送成功后主动同步最新一页消息,避免SSE短暂断流时界面停留在旧状态。
|
||||||
// await messageViewRef.value?.refreshLatestMessages?.()
|
// await messageViewRef.value?.refreshLatestMessages?.()
|
||||||
forceScrollToEnd() // 发送消息后强制滚动到底部
|
messageViewRef.value?.forceScrollToEnd?.()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -304,12 +188,9 @@ defineExpose({
|
|||||||
// 监听消息对话框状态变化
|
// 监听消息对话框状态变化
|
||||||
watch(messageDialog, async newValue => {
|
watch(messageDialog, async newValue => {
|
||||||
if (newValue) {
|
if (newValue) {
|
||||||
shouldAutoScrollMessage.value = true
|
|
||||||
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
bindMessageScrollListener()
|
|
||||||
messageViewRef.value?.resumeSSE?.()
|
messageViewRef.value?.resumeSSE?.()
|
||||||
forceScrollToEnd()
|
messageViewRef.value?.forceScrollToEnd?.()
|
||||||
|
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
void clearAppBadge()
|
void clearAppBadge()
|
||||||
@@ -318,26 +199,12 @@ watch(messageDialog, async newValue => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
unbindMessageScrollListener()
|
|
||||||
|
|
||||||
if (messageViewRef.value?.pauseSSE) {
|
if (messageViewRef.value?.pauseSSE) {
|
||||||
// 对话框关闭时暂停SSE连接
|
// 对话框关闭时暂停SSE连接
|
||||||
messageViewRef.value.pauseSSE()
|
messageViewRef.value.pauseSSE()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (messageScrollTimer) {
|
|
||||||
window.clearTimeout(messageScrollTimer)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messageScrollReleaseTimer) {
|
|
||||||
window.clearTimeout(messageScrollReleaseTimer)
|
|
||||||
}
|
|
||||||
|
|
||||||
unbindMessageScrollListener()
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const shortcut = getQueryValue('shortcut')
|
const shortcut = getQueryValue('shortcut')
|
||||||
if (shortcut) {
|
if (shortcut) {
|
||||||
@@ -587,7 +454,6 @@ onMounted(() => {
|
|||||||
max-width="50rem"
|
max-width="50rem"
|
||||||
scrollable
|
scrollable
|
||||||
:fullscreen="!display.mdAndUp.value"
|
:fullscreen="!display.mdAndUp.value"
|
||||||
ref="messageDialogRef"
|
|
||||||
>
|
>
|
||||||
<VCard>
|
<VCard>
|
||||||
<VCardItem>
|
<VCardItem>
|
||||||
@@ -598,8 +464,8 @@ onMounted(() => {
|
|||||||
<VDialogCloseBtn @click="messageDialog = false" />
|
<VDialogCloseBtn @click="messageDialog = false" />
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
<VDivider />
|
<VDivider />
|
||||||
<VCardText ref="messageContentRef">
|
<VCardText>
|
||||||
<MessageView ref="messageViewRef" @scroll="scrollMessageToEnd" />
|
<MessageView ref="messageViewRef" />
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VDivider />
|
<VDivider />
|
||||||
<VCardActions class="pa-4">
|
<VCardActions class="pa-4">
|
||||||
|
|||||||
@@ -9,15 +9,6 @@ import { useBackgroundOptimization } from '@/composables/useBackgroundOptimizati
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { useSSE } = useBackgroundOptimization()
|
const { useSSE } = useBackgroundOptimization()
|
||||||
|
|
||||||
type ScrollPayload = {
|
|
||||||
force?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
// 定义事件
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'scroll', payload?: ScrollPayload): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// 消息列表
|
// 消息列表
|
||||||
const messages = ref<Message[]>([])
|
const messages = ref<Message[]>([])
|
||||||
// 当前页数据
|
// 当前页数据
|
||||||
@@ -39,6 +30,18 @@ const page = ref(1)
|
|||||||
// 存量消息最新时间
|
// 存量消息最新时间
|
||||||
const lastTime = ref('')
|
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) {
|
function getMessageTime(message: Message) {
|
||||||
return message.reg_time || message.date || ''
|
return message.reg_time || message.date || ''
|
||||||
@@ -72,13 +75,98 @@ function updateLastTime(message: Message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 请求父组件滚动,首屏历史消息需要强制到底,实时消息继续使用智能滚动。
|
function getScrollContainer() {
|
||||||
function requestScrollToEnd(force = false) {
|
const container = messageListRef.value?.$el ?? messageListRef.value
|
||||||
nextTick(() => {
|
|
||||||
emit('scroll', { force })
|
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[]) {
|
function mergeMessages(items: Message[]) {
|
||||||
let hasNewMessage = false
|
let hasNewMessage = false
|
||||||
@@ -239,16 +327,32 @@ defineExpose({
|
|||||||
pauseSSE,
|
pauseSSE,
|
||||||
resumeSSE,
|
resumeSSE,
|
||||||
refreshLatestMessages,
|
refreshLatestMessages,
|
||||||
|
forceScrollToEnd,
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 组件挂载后触发一次滚动事件
|
nextTick(() => {
|
||||||
requestScrollToEnd()
|
bindScrollListener()
|
||||||
|
requestScrollToEnd(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (scrollTimer) {
|
||||||
|
window.clearTimeout(scrollTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scrollReleaseTimer) {
|
||||||
|
window.clearTimeout(scrollReleaseTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
unbindScrollListener()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VInfiniteScroll
|
<VInfiniteScroll
|
||||||
|
ref="messageListRef"
|
||||||
:mode="!isLoaded ? 'intersect' : 'manual'"
|
:mode="!isLoaded ? 'intersect' : 'manual'"
|
||||||
side="start"
|
side="start"
|
||||||
:items="messages"
|
:items="messages"
|
||||||
|
|||||||
Reference in New Issue
Block a user