From 8c3883e356659ca3e54db8c54409c5c9b3d5738a Mon Sep 17 00:00:00 2001 From: Zhu Junhao <2661664053@qq.com> Date: Sat, 5 Sep 2026 10:37:43 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E6=B5=81=E5=BC=8F=E8=BE=93=E5=87=BA=E6=BB=9A=E5=8A=A8=E8=B7=9F?= =?UTF-8?q?=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 | 124 ++++++++++++++++++- web-ui/src/pages/ProjectPage.vue | 177 ++++++++++++++++++++++++++- 2 files changed, 293 insertions(+), 8 deletions(-) diff --git a/web-ui/src/pages/ProjectPage.test.ts b/web-ui/src/pages/ProjectPage.test.ts index 13c0ce7..9b21176 100644 --- a/web-ui/src/pages/ProjectPage.test.ts +++ b/web-ui/src/pages/ProjectPage.test.ts @@ -2,7 +2,7 @@ import { defineComponent, h } from 'vue' import { flushPromises, shallowMount } from '@vue/test-utils' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentEvent } from '../api' import ProjectPage from './ProjectPage.vue' @@ -13,6 +13,7 @@ let outputResizeCallback: ResizeObserverCallback | null = null const observeOutputMock = vi.fn() const unobserveOutputMock = vi.fn() const disconnectOutputObserverMock = vi.fn() +const mountedWrappers: Array<{ unmount: () => void }> = [] /** * JSDOM 不提供 ResizeObserver,这里保留组件注册的回调,模拟流式 Markdown @@ -139,7 +140,7 @@ function mountProject(runStatus: 'RUNNING' | 'INTERRUPTED', options: ProjectFixt throw new Error(`未处理的测试请求:${url}`) }) - return shallowMount(ProjectPage, { + const wrapper = shallowMount(ProjectPage, { global: { stubs: { 'el-button': ElButtonStub, @@ -151,8 +152,16 @@ function mountProject(runStatus: 'RUNNING' | 'INTERRUPTED', options: ProjectFixt } } }) + mountedWrappers.push(wrapper) + return wrapper } +afterEach(() => { + // ProjectPage 会注册全局滚动监听;每个用例结束后必须卸载,避免前一个实例 + // 修改下一用例的 followsLatestOutput 状态,造成测试假阳性或假阴性。 + while (mountedWrappers.length) mountedWrappers.pop()!.unmount() +}) + describe('ProjectPage 模型切换', () => { beforeEach(() => { // Vitest 会把钩子返回的函数当作清理回调,因此这里不能直接返回 mockReset() 的返回值。 @@ -280,12 +289,112 @@ describe('ProjectPage 项目工作区', () => { expect(scrollTo).toHaveBeenCalledWith({ top: 1800, behavior: 'auto' }) expect(wrapper.find('.back-to-bottom').exists()).toBe(false) + // 箭头点击后如果输出容器先发生尺寸变化,必须保留程序滚动目标, + // 不能因 ResizeObserver 的再次滚动把“持续跟随”状态提前结束。 + Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 1900 }) + outputResizeCallback!([], {} as ResizeObserver) + // 大文档滚动可能先派发尚未到达目标底部的中间事件,不能把程序滚动误判为用户再次上滚。 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) + // 点击后浏览器可能先派发仍接近旧位置的事件;即使距离底部小于通用阈值, + // 也不能提前结束程序滚动跟踪,否则紧接着的布局滚动会再次显示箭头。 + Object.defineProperty(window, 'scrollY', { configurable: true, value: 1180 }) + window.dispatchEvent(new Event('scroll')) + Object.defineProperty(window, 'scrollY', { configurable: true, value: 1170 }) + window.dispatchEvent(new Event('scroll')) + await wrapper.vm.$nextTick() + expect(wrapper.find('.back-to-bottom').exists()).toBe(false) + + // 用户上滚事件先于 scroll 到达时,必须立即取消跟随,不能被尺寸观察器抢回底部。 + window.dispatchEvent(new WheelEvent('wheel', { deltaY: -24 })) + scrollTo.mockClear() + Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 2100 }) + outputResizeCallback!([], {} as ResizeObserver) + await wrapper.vm.$nextTick() + expect(scrollTo).not.toHaveBeenCalled() + expect(wrapper.find('.back-to-bottom').exists()).toBe(true) + + // 已取消的程序滚动可能仍会派发到达底部的延迟事件,不能借此错误恢复跟随。 + Object.defineProperty(window, 'scrollY', { configurable: true, value: 1500 }) + window.dispatchEvent(new Event('scroll')) + await wrapper.vm.$nextTick() + expect(wrapper.find('.back-to-bottom').exists()).toBe(true) + + // 用户明确向下滚动并抵达底部时,仍应恢复持续跟随。 + window.dispatchEvent(new WheelEvent('wheel', { deltaY: 32 })) + window.dispatchEvent(new Event('scroll')) + await wrapper.vm.$nextTick() + expect(wrapper.find('.back-to-bottom').exists()).toBe(false) + + // 在输入控件内使用方向键不应改变页面的自动跟随状态。 + const modelInput = document.createElement('input') + document.body.appendChild(modelInput) + modelInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })) + await wrapper.vm.$nextTick() + expect(wrapper.find('.back-to-bottom').exists()).toBe(false) + modelInput.remove() + + // 控件内部的图标/子节点事件也不应触发页面滚动状态切换。 + const control = document.createElement('button') + const icon = document.createElement('span') + control.appendChild(icon) + document.body.appendChild(control) + window.dispatchEvent(new WheelEvent('wheel', { deltaY: -24 })) + Object.defineProperty(window, 'scrollY', { configurable: true, value: 1100 }) + window.dispatchEvent(new Event('scroll')) + await wrapper.vm.$nextTick() + expect(wrapper.find('.back-to-bottom').exists()).toBe(true) + icon.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + await wrapper.vm.$nextTick() + expect(wrapper.find('.back-to-bottom').exists()).toBe(true) + control.remove() + + // 空格键同样会向下翻页,回到底部后应恢复持续跟随。 + window.dispatchEvent(new WheelEvent('wheel', { deltaY: -24 })) + Object.defineProperty(window, 'scrollY', { configurable: true, value: 1100 }) + window.dispatchEvent(new Event('scroll')) + await wrapper.vm.$nextTick() + expect(wrapper.find('.back-to-bottom').exists()).toBe(true) + window.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })) + Object.defineProperty(window, 'scrollY', { configurable: true, value: 1500 }) + window.dispatchEvent(new Event('scroll')) + await wrapper.vm.$nextTick() + expect(wrapper.find('.back-to-bottom').exists()).toBe(false) + + // 滚动条拖动期间,旧程序滚动到达底部不能提前恢复跟随;释放后向下拖到底部才恢复。 + Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 1000 }) + window.dispatchEvent(new WheelEvent('wheel', { deltaY: -24 })) + Object.defineProperty(window, 'scrollY', { configurable: true, value: 1100 }) + window.dispatchEvent(new Event('scroll')) + await wrapper.vm.$nextTick() + expect(wrapper.find('.back-to-bottom').exists()).toBe(true) + window.dispatchEvent(new MouseEvent('mousedown', { clientX: 1000, clientY: 300 })) + window.dispatchEvent(new MouseEvent('mousemove', { clientX: 1000, clientY: 100 })) + Object.defineProperty(window, 'scrollY', { configurable: true, value: 1500 }) + window.dispatchEvent(new Event('scroll')) + await wrapper.vm.$nextTick() + expect(wrapper.find('.back-to-bottom').exists()).toBe(true) + window.dispatchEvent(new MouseEvent('mouseup', { clientX: 1000 })) + await wrapper.vm.$nextTick() + expect(wrapper.find('.back-to-bottom').exists()).toBe(true) + + // 向下拖动滚动条并释放到底部时才恢复跟随。 + window.dispatchEvent(new MouseEvent('mousedown', { clientX: 1000, clientY: 100 })) + window.dispatchEvent(new MouseEvent('mousemove', { clientX: 1000, clientY: 300 })) + window.dispatchEvent(new MouseEvent('mouseup', { clientX: 1000, clientY: 300 })) + await wrapper.vm.$nextTick() + expect(wrapper.find('.back-to-bottom').exists()).toBe(false) + + // 触摸滚动/滚动条拖动没有 wheel 事件,也必须能取消跟随。 + window.dispatchEvent(new TouchEvent('touchmove')) + await wrapper.vm.$nextTick() + expect(wrapper.find('.back-to-bottom').exists()).toBe(true) + await wrapper.get('.back-to-bottom').trigger('click') + // 点击箭头代表重新进入持续跟随模式;同一批 Markdown 的平滑渲染继续增高时也必须跟随。 scrollTo.mockClear() Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 2000 }) @@ -294,6 +403,17 @@ describe('ProjectPage 项目工作区', () => { expect(scrollTo).toHaveBeenCalledWith({ top: 2000 }) + // 即使新内容只让底部前移 20px,也必须记录程序目标,防止中间 scroll 事件误停跟随。 + scrollTo.mockClear() + Object.defineProperty(window, 'scrollY', { configurable: true, value: 1380 }) + Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 2000 }) + outputResizeCallback!([], {} as ResizeObserver) + expect(scrollTo).toHaveBeenCalledWith({ top: 2000 }) + Object.defineProperty(window, 'scrollY', { configurable: true, value: 1370 }) + window.dispatchEvent(new Event('scroll')) + await wrapper.vm.$nextTick() + expect(wrapper.find('.back-to-bottom').exists()).toBe(false) + // 尺寸变化后的新流事件到达时仍应保持跟随,而不是重新显示箭头。 scrollTo.mockClear() streamCallback!([{ diff --git a/web-ui/src/pages/ProjectPage.vue b/web-ui/src/pages/ProjectPage.vue index 1c95575..7715f81 100644 --- a/web-ui/src/pages/ProjectPage.vue +++ b/web-ui/src/pages/ProjectPage.vue @@ -62,8 +62,17 @@ let loadVersion = 0 let previousScrollY = 0 let hasObservedScrollPosition = false let pendingFollowScrollTarget: number | null = null +type UserScrollIntent = 'none' | 'up' | 'down' +let userScrollIntent: UserScrollIntent = 'none' +let touchStartY: number | null = null +let scrollbarDragLastY: number | null = null +let scrollbarDragDirection: 'none' | 'up' | 'down' = 'none' +let scrollbarDragActive = false const folderInput = ref(null) const LATEST_OUTPUT_THRESHOLD = 48 +// 程序滚动只有真正抵达目标位置才算完成,不能复用用户回到底部的宽松阈值。 +// 否则浏览器派发的中间 scroll 事件会提前清除待跟随目标,后续输出就会停止跟随。 +const PROGRAMMATIC_SCROLL_THRESHOLD = 2 const projectId = computed(() => String(route.params.id || '')) const waitingPlan = computed(() => pendingAsk.value?.kind === 'planning' && plan.value?.status === 'DRAFT') @@ -131,6 +140,11 @@ async function load(id: string) { previousScrollY = window.scrollY hasObservedScrollPosition = false pendingFollowScrollTarget = null + userScrollIntent = 'none' + touchStartY = null + scrollbarDragLastY = null + scrollbarDragDirection = 'none' + scrollbarDragActive = false stopStream?.() stopStream = null project.value = null @@ -480,17 +494,20 @@ function updateScrollState() { const completingFollowScroll = pendingFollowScrollTarget !== null if (completingFollowScroll) { - // 大文档滚动可能产生多个中间事件;抵达目标附近前,这些事件都属于同一次程序滚动。 - const reachedRequestedTarget = Math.abs(currentScrollY - pendingFollowScrollTarget!) <= LATEST_OUTPUT_THRESHOLD - if (reachedRequestedTarget || distanceFromBottom <= LATEST_OUTPUT_THRESHOLD) { + // 大文档滚动可能产生多个中间事件;只有抵达程序滚动目标(允许极小的像素误差) + // 才能结束跟踪,不能因为“接近底部”的用户阈值而提前清除目标。 + const reachedRequestedTarget = Math.abs(currentScrollY - pendingFollowScrollTarget!) <= PROGRAMMATIC_SCROLL_THRESHOLD + if (reachedRequestedTarget) { pendingFollowScrollTarget = null followsLatestOutput.value = true } - } else if (scrollingUp) { + } else if (scrollingUp && userScrollIntent !== 'down') { followsLatestOutput.value = false - } else if (distanceFromBottom <= LATEST_OUTPUT_THRESHOLD) { + userScrollIntent = 'up' + } else if (!scrollbarDragActive && distanceFromBottom <= LATEST_OUTPUT_THRESHOLD && userScrollIntent !== 'up') { // 用户主动回到底部后恢复跟随;仅内容高度增加时则保留原来的跟随意图。 followsLatestOutput.value = true + userScrollIntent = 'none' } previousScrollY = currentScrollY hasObservedScrollPosition = true @@ -506,7 +523,9 @@ function updateScrollState() { 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 + // 所有程序滚动(箭头点击、事件更新、ResizeObserver)统一只在真正抵达目标时 + // 清除待跟随标记,避免近底部的中间 scroll 事件破坏持续跟随状态。 + pendingFollowScrollTarget = Math.abs(window.scrollY - targetScrollY) <= PROGRAMMATIC_SCROLL_THRESHOLD ? null : targetScrollY hasObservedScrollPosition = true @@ -518,9 +537,137 @@ function moveViewportToLatestOutput(behavior?: ScrollBehavior) { /** 点击悬浮箭头后立即回到最新输出,并重新启用后续输出跟随。 */ function scrollToBottom() { followsLatestOutput.value = true + userScrollIntent = 'none' + touchStartY = null + // 点击箭头即使当前已经接近底部,也必须追踪这次程序滚动的完整过程, + // 防止浏览器先派发的中间 scroll 事件被误判成用户向上滚动。 moveViewportToLatestOutput('auto') } +/** 判断事件是否发生在表单控件或可编辑元素内,避免控件内部操作误触发页面滚动状态。 */ +function isInteractiveTarget(target: EventTarget | null) { + const element = target instanceof Element ? target : null + return Boolean(element && ( + (element instanceof HTMLElement && element.isContentEditable) + || element.closest('input, textarea, select, option, button, [contenteditable="true"]') + )) +} + +/** 当前已经位于最新输出附近时恢复跟随,覆盖没有产生 scroll 事件的输入场景。 */ +function restoreFollowIfAtLatest() { + const distanceFromBottom = document.documentElement.scrollHeight - window.scrollY - window.innerHeight + if (distanceFromBottom <= LATEST_OUTPUT_THRESHOLD) { + followsLatestOutput.value = true + userScrollIntent = 'none' + return true + } + return false +} + +/** + * 在浏览器派发 scroll 之前捕获用户的真实上滚意图。 + * + *

输出区域持续变化时 ResizeObserver 也会触发程序滚动;如果只依赖 scroll + * 事件,程序滚动可能抢在用户的滚动事件之前执行,导致用户无法回看历史。

+ */ +function handleUserWheel(event: WheelEvent) { + if (isInteractiveTarget(event.target)) return + if (event.deltaY !== 0) { + // 一旦用户开始滚轮操作,先取消尚未完成的程序滚动;否则其延迟 scroll + // 事件可能在用户上滚后再次把页面状态恢复到底部。 + pendingFollowScrollTarget = null + } + if (event.deltaY < 0) { + followsLatestOutput.value = false + userScrollIntent = 'up' + } else if (event.deltaY > 0) { + userScrollIntent = 'down' + // 页面已经在底部附近时,浏览器可能不会再派发 scroll 事件。 + restoreFollowIfAtLatest() + } +} + +/** 拖动浏览器右侧滚动条时没有 wheel 事件,需要单独取消程序滚动目标。 */ +function handleUserScrollbarDrag(event: MouseEvent) { + const scrollbarStart = document.documentElement.clientWidth + if (event.clientX >= scrollbarStart) { + followsLatestOutput.value = false + pendingFollowScrollTarget = null + scrollbarDragLastY = event.clientY + scrollbarDragDirection = 'none' + scrollbarDragActive = true + // 鼠标按下时还无法判断拖动方向,先按上滚保护,释放时再依据实际起止位置修正。 + userScrollIntent = 'up' + } +} + +/** 根据滚动条指针的实际位移记录用户拖动方向,避免把程序 scroll 事件当成用户方向。 */ +function handleUserScrollbarMove(event: MouseEvent) { + if (!scrollbarDragActive || scrollbarDragLastY === null) return + if (event.clientY < scrollbarDragLastY) scrollbarDragDirection = 'up' + else if (event.clientY > scrollbarDragLastY) scrollbarDragDirection = 'down' + scrollbarDragLastY = event.clientY +} + +/** 释放滚动条后按实际起止位置确认是否回到底部,结束本次滚动条交互会话。 */ +function handleUserMouseUp() { + if (!scrollbarDragActive) return + const distanceFromBottom = document.documentElement.scrollHeight - window.scrollY - window.innerHeight + scrollbarDragActive = false + scrollbarDragLastY = null + const draggedUp = scrollbarDragDirection === 'up' + scrollbarDragDirection = 'none' + if (!draggedUp && distanceFromBottom <= LATEST_OUTPUT_THRESHOLD) { + followsLatestOutput.value = true + userScrollIntent = 'none' + } +} + +/** 记录触摸开始位置,供 touchmove 判断用户是向上还是向下拖动页面。 */ +function handleUserTouchStart(event: TouchEvent) { + touchStartY = event.touches[0]?.clientY ?? null +} + +/** 触摸滚动同样要在 scroll 事件之前取消程序滚动,并记录用户滚动方向。 */ +function handleUserTouchMove(event: TouchEvent) { + if (isInteractiveTarget(event.target)) return + pendingFollowScrollTarget = null + const currentY = event.touches[0]?.clientY + if (touchStartY === null || currentY === undefined) { + // 无法读取触点坐标时按上滚处理,确保不会因延迟程序事件抢回底部。 + followsLatestOutput.value = false + userScrollIntent = 'up' + return + } + const pageScrollDelta = touchStartY - currentY + if (pageScrollDelta < 0) { + followsLatestOutput.value = false + userScrollIntent = 'up' + } else if (pageScrollDelta > 0) { + userScrollIntent = 'down' + restoreFollowIfAtLatest() + } +} + +/** 触摸手势结束后清理起始坐标,避免下一次无 touchstart 的异常事件复用旧坐标。 */ +function handleUserTouchEnd() { + touchStartY = null +} + +/** 键盘 PageUp、Home、ArrowUp 同样代表用户主动回看历史,应立即暂停跟随。 */ +function handleUserKeydown(event: KeyboardEvent) { + if (isInteractiveTarget(event.target)) return + if (event.key === 'PageUp' || event.key === 'Home' || event.key === 'ArrowUp') { + followsLatestOutput.value = false + pendingFollowScrollTarget = null + userScrollIntent = 'up' + } else if (event.key === 'PageDown' || event.key === 'End' || event.key === 'ArrowDown' || event.key === ' ' || event.key === 'Spacebar') { + pendingFollowScrollTarget = null + userScrollIntent = 'down' + restoreFollowIfAtLatest() + } +} + watch(projectId, id => { if (id) void load(id) }, { immediate: true }) watch(() => events.value.length, async () => { await nextTick() @@ -543,6 +690,15 @@ onMounted(() => { if (outputContainer.value) outputResizeObserver.observe(outputContainer.value) window.addEventListener('scroll', updateScrollState, { passive: true }) + window.addEventListener('wheel', handleUserWheel, { passive: true }) + window.addEventListener('mousedown', handleUserScrollbarDrag) + window.addEventListener('mousemove', handleUserScrollbarMove) + window.addEventListener('mouseup', handleUserMouseUp) + window.addEventListener('touchstart', handleUserTouchStart, { passive: true }) + window.addEventListener('touchmove', handleUserTouchMove, { passive: true }) + window.addEventListener('touchend', handleUserTouchEnd, { passive: true }) + window.addEventListener('touchcancel', handleUserTouchEnd, { passive: true }) + window.addEventListener('keydown', handleUserKeydown) updateScrollState() }) onBeforeUnmount(() => { @@ -551,6 +707,15 @@ onBeforeUnmount(() => { outputResizeObserver?.disconnect() outputResizeObserver = null window.removeEventListener('scroll', updateScrollState) + window.removeEventListener('wheel', handleUserWheel) + window.removeEventListener('mousedown', handleUserScrollbarDrag) + window.removeEventListener('mousemove', handleUserScrollbarMove) + window.removeEventListener('mouseup', handleUserMouseUp) + window.removeEventListener('touchstart', handleUserTouchStart) + window.removeEventListener('touchmove', handleUserTouchMove) + window.removeEventListener('touchend', handleUserTouchEnd) + window.removeEventListener('touchcancel', handleUserTouchEnd) + window.removeEventListener('keydown', handleUserKeydown) })