From 99d16bb3b9f3d3e17b3709655ac8edd65f9aa913 Mon Sep 17 00:00:00 2001 From: Zhu Junhao <2661664053@qq.com> Date: Thu, 3 Sep 2026 16:18:36 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=81=A2=E5=A4=8D=E6=9C=80=E6=96=B0?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E8=87=AA=E5=8A=A8=E8=B7=9F=E9=9A=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 点击回到底部后重新启用尾部跟随,并避免程序滚动过程被误判为用户上滚。监听流式内容尺寸变化,确保后续消息继续贴住最新位置。 --- web-ui/src/pages/ProjectPage.test.ts | 106 ++++++++++++++++++++++++++- web-ui/src/pages/ProjectPage.vue | 99 +++++++++++++++++++++++-- web-ui/src/styles.css | 4 +- 3 files changed, 199 insertions(+), 10 deletions(-) diff --git a/web-ui/src/pages/ProjectPage.test.ts b/web-ui/src/pages/ProjectPage.test.ts index b5f8817..13c0ce7 100644 --- a/web-ui/src/pages/ProjectPage.test.ts +++ b/web-ui/src/pages/ProjectPage.test.ts @@ -3,9 +3,39 @@ import { defineComponent, h } from 'vue' import { flushPromises, shallowMount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentEvent } from '../api' import ProjectPage from './ProjectPage.vue' +type StreamEventsCallback = (batch: AgentEvent[]) => void + const apiMock = vi.fn() +let outputResizeCallback: ResizeObserverCallback | null = null +const observeOutputMock = vi.fn() +const unobserveOutputMock = vi.fn() +const disconnectOutputObserverMock = vi.fn() + +/** + * JSDOM 不提供 ResizeObserver,这里保留组件注册的回调,模拟流式 Markdown + * 在事件已经写入后仍逐帧增高的真实浏览器行为。 + */ +class ResizeObserverMock { + constructor(callback: ResizeObserverCallback) { + outputResizeCallback = callback + } + + observe = observeOutputMock + unobserve = unobserveOutputMock + disconnect = disconnectOutputObserverMock +} + +vi.stubGlobal('ResizeObserver', ResizeObserverMock) + +const streamEventsMock = vi.fn(( + _projectId: string, + _after: number, + _onEvents: StreamEventsCallback, + _onError: (error: Error) => void +) => vi.fn()) vi.mock('vue-router', () => ({ useRoute: () => ({ params: { id: 'project-1' } }), @@ -14,7 +44,12 @@ vi.mock('vue-router', () => ({ vi.mock('../api', () => ({ api: (...args: unknown[]) => apiMock(...args), - streamEvents: vi.fn(() => vi.fn()) + streamEvents: ( + projectId: string, + after: number, + onEvents: StreamEventsCallback, + onError: (error: Error) => void + ) => streamEventsMock(projectId, after, onEvents, onError) })) vi.mock('../eventCache', () => ({ @@ -160,6 +195,12 @@ describe('ProjectPage 模型切换', () => { describe('ProjectPage 项目工作区', () => { beforeEach(() => { apiMock.mockReset() + streamEventsMock.mockReset() + streamEventsMock.mockReturnValue(vi.fn()) + outputResizeCallback = null + observeOutputMock.mockReset() + unobserveOutputMock.mockReset() + disconnectOutputObserverMock.mockReset() }) it('根据项目状态展示四阶段进度,并在右侧汇总模型、材料和生成文件', async () => { @@ -205,4 +246,67 @@ describe('ProjectPage 项目工作区', () => { expect(wrapper.get('.current-model-name').text()).toBe('当前模型 · model-a') }) + + it('用户向上滚动后暂停跟随新输出,并可通过箭头回到最新位置', async () => { + const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined) + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 600 }) + Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 1800 }) + Object.defineProperty(window, 'scrollY', { configurable: true, value: 1200 }) + const wrapper = mountProject('RUNNING') + await flushPromises() + scrollTo.mockClear() + + // 即使只向上移动 20px,也已代表用户主动回看历史,下一批输出不能把页面拉回底部。 + Object.defineProperty(window, 'scrollY', { configurable: true, value: 1180 }) + window.dispatchEvent(new Event('scroll')) + await wrapper.vm.$nextTick() + const latestButton = wrapper.get('.back-to-bottom') + expect(latestButton.attributes('aria-label')).toBe('转到最新输出') + + const streamCallback = streamEventsMock.mock.calls[0]?.[2] + expect(streamCallback).toBeTypeOf('function') + streamCallback!([{ + id: 1, + projectId: 'project-1', + runId: 'run-1', + type: 'TEXT_MESSAGE_CONTENT', + payload: { delta: '新输出' }, + createdAt: '2026-09-03T10:00:00Z' + }]) + await flushPromises() + + expect(scrollTo).not.toHaveBeenCalled() + await latestButton.trigger('click') + expect(scrollTo).toHaveBeenCalledWith({ top: 1800, behavior: 'auto' }) + expect(wrapper.find('.back-to-bottom').exists()).toBe(false) + + // 大文档滚动可能先派发尚未到达目标底部的中间事件,不能把程序滚动误判为用户再次上滚。 + Object.defineProperty(window, 'scrollY', { configurable: true, value: 1190 }) + window.dispatchEvent(new Event('scroll')) + await wrapper.vm.$nextTick() + expect(wrapper.find('.back-to-bottom').exists()).toBe(false) + + // 点击箭头代表重新进入持续跟随模式;同一批 Markdown 的平滑渲染继续增高时也必须跟随。 + scrollTo.mockClear() + Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 2000 }) + expect(outputResizeCallback).not.toBeNull() + outputResizeCallback!([], {} as ResizeObserver) + + expect(scrollTo).toHaveBeenCalledWith({ top: 2000 }) + + // 尺寸变化后的新流事件到达时仍应保持跟随,而不是重新显示箭头。 + scrollTo.mockClear() + streamCallback!([{ + id: 2, + projectId: 'project-1', + runId: 'run-1', + type: 'TEXT_MESSAGE_CONTENT', + payload: { delta: '后续输出' }, + createdAt: '2026-09-03T10:00:01Z' + }]) + await flushPromises() + + expect(scrollTo).toHaveBeenCalledWith({ top: 2000 }) + expect(wrapper.find('.back-to-bottom').exists()).toBe(false) + }) }) diff --git a/web-ui/src/pages/ProjectPage.vue b/web-ui/src/pages/ProjectPage.vue index 4314013..1c95575 100644 --- a/web-ui/src/pages/ProjectPage.vue +++ b/web-ui/src/pages/ProjectPage.vue @@ -2,6 +2,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { Document, UploadFilled } from '@element-plus/icons-vue' +import { ArrowDown } from '@lucide/vue' import { ElMessage, ElMessageBox } from 'element-plus' import AgentTimeline from '../components/AgentTimeline.vue' import MaterialAskCard from '../components/MaterialAskCard.vue' @@ -46,7 +47,7 @@ const controlLoading = ref(false) const deleting = ref(false) const historyLoading = ref(true) const streamError = ref('') -const showBackToBottom = ref(false) +const followsLatestOutput = ref(true) const currentModelConfigId = ref('') const modelPickerVisible = ref(false) const modelPickerLoading = ref(false) @@ -54,9 +55,15 @@ const modelPickerMode = ref<'resume' | 'switch'>('resume') const selectedModelConfigId = ref('') const modelCatalog = ref([]) const selectableModels = ref([]) +const outputContainer = ref(null) let stopStream: (() => void) | null = null +let outputResizeObserver: ResizeObserver | null = null let loadVersion = 0 +let previousScrollY = 0 +let hasObservedScrollPosition = false +let pendingFollowScrollTarget: number | null = null const folderInput = ref(null) +const LATEST_OUTPUT_THRESHOLD = 48 const projectId = computed(() => String(route.params.id || '')) const waitingPlan = computed(() => pendingAsk.value?.kind === 'planning' && plan.value?.status === 'DRAFT') @@ -120,6 +127,10 @@ function syncRunState(run: RunSummary, fallbackModelConfigId = '') { async function load(id: string) { const version = ++loadVersion historyLoading.value = true + followsLatestOutput.value = true + previousScrollY = window.scrollY + hasObservedScrollPosition = false + pendingFollowScrollTarget = null stopStream?.() stopStream = null project.value = null @@ -458,24 +469,87 @@ async function refreshArtifacts(id = projectId.value, version = loadVersion) { if (version === loadVersion && id === projectId.value) artifacts.value = value } +/** + * 根据用户的滚动方向和当前位置决定是否继续跟随流式输出。 + * 任意向上滚动都代表用户正在回看历史;向下滚动时只有真正接近底部才恢复自动跟随。 + */ function updateScrollState() { - showBackToBottom.value = window.scrollY + window.innerHeight < document.documentElement.scrollHeight - 240 + const currentScrollY = window.scrollY + const distanceFromBottom = document.documentElement.scrollHeight - window.scrollY - window.innerHeight + const scrollingUp = hasObservedScrollPosition && currentScrollY < previousScrollY + const completingFollowScroll = pendingFollowScrollTarget !== null + + if (completingFollowScroll) { + // 大文档滚动可能产生多个中间事件;抵达目标附近前,这些事件都属于同一次程序滚动。 + const reachedRequestedTarget = Math.abs(currentScrollY - pendingFollowScrollTarget!) <= LATEST_OUTPUT_THRESHOLD + if (reachedRequestedTarget || distanceFromBottom <= LATEST_OUTPUT_THRESHOLD) { + pendingFollowScrollTarget = null + followsLatestOutput.value = true + } + } else if (scrollingUp) { + followsLatestOutput.value = false + } else if (distanceFromBottom <= LATEST_OUTPUT_THRESHOLD) { + // 用户主动回到底部后恢复跟随;仅内容高度增加时则保留原来的跟随意图。 + followsLatestOutput.value = true + } + previousScrollY = currentScrollY + hasObservedScrollPosition = true } +/** + * 将窗口移动到当前文档底部,并记录本次程序滚动的目标位置。 + * + *

浏览器派发 scroll 事件晚于 scrollTo 调用,大文档还可能先派发一个或多个 + * 尚未到达底部的中间事件。目标位置必须单独保存,不能写入 previousScrollY, + * 否则中间坐标会因为小于目标值而被误判为用户向上滚动。

+ */ +function moveViewportToLatestOutput(behavior?: ScrollBehavior) { + const scrollHeight = document.documentElement.scrollHeight + const targetScrollY = Math.max(0, scrollHeight - window.innerHeight) + pendingFollowScrollTarget = Math.abs(window.scrollY - targetScrollY) <= LATEST_OUTPUT_THRESHOLD + ? null + : targetScrollY + hasObservedScrollPosition = true + const options: ScrollToOptions = { top: scrollHeight } + if (behavior) options.behavior = behavior + window.scrollTo(options) +} + +/** 点击悬浮箭头后立即回到最新输出,并重新启用后续输出跟随。 */ function scrollToBottom() { - window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' }) + followsLatestOutput.value = true + moveViewportToLatestOutput('auto') } watch(projectId, id => { if (id) void load(id) }, { immediate: true }) watch(() => events.value.length, async () => { - const followsTail = !showBackToBottom.value await nextTick() - if (followsTail) window.scrollTo({ top: document.documentElement.scrollHeight }) + // nextTick 后重新读取实时状态,确保用户在本轮渲染期间向上滚动也能立即中止跟随。 + if (followsLatestOutput.value) moveViewportToLatestOutput() +}) +watch(outputContainer, (current, previous) => { + if (!outputResizeObserver) return + if (previous) outputResizeObserver.unobserve(previous) + if (current) outputResizeObserver.observe(current) +}, { flush: 'post' }) +onMounted(() => { + /** + * Markstream 的平滑流式渲染会在 Vue nextTick 结束后继续逐帧增加内容高度。 + * 监听真实输出容器的尺寸变化,才能在同一批事件的渲染动画期间持续贴住底部。 + */ + outputResizeObserver = new ResizeObserver(() => { + if (followsLatestOutput.value) moveViewportToLatestOutput() + }) + if (outputContainer.value) outputResizeObserver.observe(outputContainer.value) + + window.addEventListener('scroll', updateScrollState, { passive: true }) + updateScrollState() }) -onMounted(() => window.addEventListener('scroll', updateScrollState, { passive: true })) onBeforeUnmount(() => { loadVersion++ stopStream?.() + outputResizeObserver?.disconnect() + outputResizeObserver = null window.removeEventListener('scroll', updateScrollState) }) @@ -527,7 +601,7 @@ onBeforeUnmount(() => { -
+
加载记录

企业材料

@@ -614,7 +688,16 @@ onBeforeUnmount(() => {
- +
新建或选择一个项目
diff --git a/web-ui/src/styles.css b/web-ui/src/styles.css index c19504a..f022371 100644 --- a/web-ui/src/styles.css +++ b/web-ui/src/styles.css @@ -191,7 +191,9 @@ a { color: inherit; text-decoration: none; } .artifact-row a { color: var(--blue); } .stream-error { display: block; border: 0; background: transparent; color: #c53131; margin: 24px auto; cursor: pointer; } .retry-button { display: block; margin: 28px auto 0; } -.back-to-bottom { position: fixed; right: 32px; bottom: 28px; width: 38px; height: 38px; border: 0; border-radius: 50%; background: #fff; color: var(--blue); box-shadow: 0 6px 22px rgba(29, 58, 111, .14); cursor: pointer; } +.back-to-bottom { position: fixed; left: 50%; bottom: 28px; z-index: 4; width: 40px; height: 40px; display: grid; place-items: center; border: 1px solid var(--line); border-radius: 50%; background: #fff; color: var(--blue); box-shadow: 0 6px 22px rgba(29, 58, 111, .14); transform: translateX(-50%); cursor: pointer; } +.back-to-bottom svg { width: 20px; height: 20px; } +.back-to-bottom:hover { background: #f4f7fb; } .back-to-bottom:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; } .empty-main { display: grid; place-items: center; min-height: 100vh; color: #8995a8; } .model-picker-field { display: grid; grid-template-columns: 86px minmax(0, 1fr); align-items: center; gap: 14px; min-height: 52px; }