From 591a5e64cc7fbcaaa7bbdc4c67f9bf2d134d5b5e Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Mon, 27 Apr 2026 00:13:38 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E5=8C=BA=E6=BB=9A=E5=8A=A8=E9=94=81=E5=AE=9A=E4=B8=8E=E6=89=8B?= =?UTF-8?q?=E5=8A=A8=E4=B8=8A=E6=BB=9A=E8=84=B1=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/src/app/methods/conversation.ts | 3 ++ static/src/app/methods/ui.ts | 53 +++++++++++++++---- static/src/app/state.ts | 2 + static/src/components/chat/ChatArea.vue | 69 +++++++++++++++++++++++-- 4 files changed, 114 insertions(+), 13 deletions(-) diff --git a/static/src/app/methods/conversation.ts b/static/src/app/methods/conversation.ts index 0335cfb..cfa85a9 100644 --- a/static/src/app/methods/conversation.ts +++ b/static/src/app/methods/conversation.ts @@ -106,6 +106,9 @@ export const conversationMethods = { debugLog('前端状态重置完成'); this._scrollListenerReady = false; + this._manualScrollSuppressUntil = 0; + this._escapedByUserScroll = false; + this._autoRelockCooldownUntil = 0; this.$nextTick(() => { this.ensureScrollListener(); }); diff --git a/static/src/app/methods/ui.ts b/static/src/app/methods/ui.ts index 6aed04e..57807c8 100644 --- a/static/src/app/methods/ui.ts +++ b/static/src/app/methods/ui.ts @@ -112,23 +112,52 @@ export const uiMethods = { handleStickStateChange(payload) { const escaped = !!payload?.escapedFromLock; + const isAtBottom = !!payload?.isAtBottom; + const isNearBottom = !!payload?.isNearBottom; + const nearBottom = isAtBottom || isNearBottom; + const userEscaped = !!this._escapedByUserScroll; uiBounceTrace( 'stick-state-change', { - isAtBottom: !!payload?.isAtBottom, - isNearBottom: !!payload?.isNearBottom, + isAtBottom, + isNearBottom, escapedFromLock: escaped, + userEscaped, autoScrollEnabled: this.autoScrollEnabled, userScrolling: this.userScrolling }, 'stick-state-change', 180 ); - this.stickIsAtBottom = !!payload?.isAtBottom; - this.stickIsNearBottom = !!payload?.isNearBottom; - this.chatSetScrollState({ - userScrolling: escaped - }); + this.stickIsAtBottom = isAtBottom; + this.stickIsNearBottom = isNearBottom; + + // 用户主动脱离锁定:只要没回到底部/近底部,就保持“手动滚动中”状态 + if (userEscaped) { + if (nearBottom) { + this._escapedByUserScroll = false; + this.chatSetScrollState({ userScrolling: false }); + } else { + this.chatSetScrollState({ userScrolling: true }); + } + return; + } + + // 非用户触发的 escaped(例如高度突增导致的短暂锚点漂移)不应解除锁定 + this.chatSetScrollState({ userScrolling: false }); + if (!escaped) { + return; + } + const active = typeof this.isOutputActive === 'function' ? this.isOutputActive() : true; + if (!this.autoScrollEnabled || !active) { + return; + } + const chatArea = this.getChatAreaController(); + if (chatArea && typeof chatArea.conditionalStickToBottom === 'function') { + chatArea.conditionalStickToBottom({ force: false }); + return; + } + conditionalScrollToBottomHelper(this); }, handleUserScrollIntent(payload) { @@ -139,6 +168,8 @@ export const uiMethods = { } // 用户手动滚动后,短时间内禁止任何自动追底,规避 escapedFromLock 状态抖动竞态 this._manualScrollSuppressUntil = Math.max(this._manualScrollSuppressUntil || 0, ts + 1600); + this._escapedByUserScroll = true; + this.chatSetScrollState({ userScrolling: true }); uiBounceTrace( 'ui.user-scroll-intent', { @@ -1322,6 +1353,7 @@ export const uiMethods = { 'ui.scrollToBottom:called', 80 ); + this._escapedByUserScroll = false; const chatArea = this.getChatAreaController(); if (chatArea && typeof chatArea.scrollToBottom === 'function') { chatArea.scrollToBottom({ @@ -1367,12 +1399,14 @@ export const uiMethods = { ); return; } - // 以 stick 引擎的 escapedFromLock 为最高优先级,避免状态同步竞态导致误追底 - if (stickState?.escapedFromLock) { + // 仅在“确认是用户主动脱离锁定”时才阻断自动追底; + // 对于内容高度突变导致的 escapedFromLock,不应长期卡住锁定。 + if (stickState?.escapedFromLock && this._escapedByUserScroll) { uiBounceTrace( 'ui.conditionalScrollToBottom:skip-escaped-lock', { escapedFromLock: !!stickState?.escapedFromLock, + escapedByUser: !!this._escapedByUserScroll, isNearBottom: !!stickState?.isNearBottom, isAtBottom: !!stickState?.isAtBottom }, @@ -1424,6 +1458,7 @@ export const uiMethods = { force: true }); } + this._escapedByUserScroll = false; this.chatSetScrollState({ autoScrollEnabled: true, userScrolling: false }); return true; }, diff --git a/static/src/app/state.ts b/static/src/app/state.ts index 88c3e41..f5961bb 100644 --- a/static/src/app/state.ts +++ b/static/src/app/state.ts @@ -155,6 +155,8 @@ export function dataState() { _boundDragLeave: null, _boundDrop: null, _manualScrollSuppressUntil: 0, + _escapedByUserScroll: false, + _autoRelockCooldownUntil: 0, // stick-to-bottom 状态(用于“回到底部”按钮显隐) stickIsAtBottom: true, diff --git a/static/src/components/chat/ChatArea.vue b/static/src/components/chat/ChatArea.vue index b830f15..34d191e 100644 --- a/static/src/components/chat/ChatArea.vue +++ b/static/src/components/chat/ChatArea.vue @@ -733,11 +733,14 @@ let bounceTraceCount = 0; const BOUNCE_TRACE_MAX = 240; const bounceTraceLastTsByKey = new Map(); let lastObservedTop = 0; +let lastObservedHeight = 0; let lastUserDownScrollTs = 0; +let lastUserWheelUpTs = 0; let lastProgrammaticHintTs = 0; let lastProgrammaticHintSource = ''; let suppressUserIntentUntil = 0; let scrollListener: ((event: Event) => void) | null = null; +let wheelListener: ((event: WheelEvent) => void) | null = null; let traceAttachLogged = false; function isScrollBounceTraceEnabled() { @@ -764,7 +767,9 @@ function getScrollMetrics(el: HTMLElement) { height, client, remain: height - top - client, - hasShowHtml: !!el.querySelector('show_html, show-html, .chat-inline-html, .chat-inline-card--html') + hasShowHtml: !!el.querySelector( + 'show_html, show-html, .chat-inline-html, .chat-inline-card--html' + ) }; } @@ -798,7 +803,11 @@ function detachBounceListener() { if (el && scrollListener) { el.removeEventListener('scroll', scrollListener as EventListener); } + if (el && wheelListener) { + el.removeEventListener('wheel', wheelListener as EventListener, true); + } scrollListener = null; + wheelListener = null; } function attachBounceListener() { @@ -807,16 +816,57 @@ function attachBounceListener() { if (scrollListener) { el.removeEventListener('scroll', scrollListener as EventListener); } + if (wheelListener) { + el.removeEventListener('wheel', wheelListener as EventListener, true); + } lastObservedTop = el.scrollTop || 0; + lastObservedHeight = el.scrollHeight || 0; + wheelListener = (event: WheelEvent) => { + const target = scrollRef.value; + if (!target) return; + const trusted = (event as any).isTrusted === true; + const deltaY = Number(event.deltaY || 0); + if (!(trusted && deltaY < -1 && Date.now() > suppressUserIntentUntil)) { + return; + } + const now = Date.now(); + lastUserWheelUpTs = now; + stopScroll(); + emit('user-scroll-intent', { + ts: now, + delta: deltaY, + top: target.scrollTop || 0 + }); + bounceTraceLog( + 'user-scroll-intent:wheel-up', + { deltaY, top: target.scrollTop || 0, ...getScrollMetrics(target) }, + 'user-scroll-intent:wheel-up', + 80 + ); + }; + el.addEventListener('wheel', wheelListener, { passive: true, capture: true }); scrollListener = (event: Event) => { const target = event.target as HTMLElement | null; if (!target) return; const top = target.scrollTop; const delta = top - lastObservedTop; + const height = target.scrollHeight || 0; + const heightDelta = height - lastObservedHeight; lastObservedTop = top; + lastObservedHeight = height; const now = Date.now(); const trusted = (event as any).isTrusted === true; - if (trusted && delta > 5 && now > suppressUserIntentUntil) { + const closeToProgrammaticHint = now - lastProgrammaticHintTs <= 140; + const likelyLayoutDriven = Math.abs(heightDelta) > 2; + const manualUpByWheel = delta < -3 && now - lastUserWheelUpTs <= 240; + const strongManualUp = delta < -18 && now > suppressUserIntentUntil && !closeToProgrammaticHint; + const shouldTreatAsUserIntent = + trusted && + Math.abs(delta) > 5 && + now > suppressUserIntentUntil && + (!closeToProgrammaticHint || strongManualUp) && + (delta < 0 ? strongManualUp || manualUpByWheel || !likelyLayoutDriven : !likelyLayoutDriven); + if (shouldTreatAsUserIntent) { // 用户手动滚动时,立即中断 stick 引擎中可能仍在运行的动画滚动 stopScroll(); lastUserDownScrollTs = now; @@ -827,7 +877,16 @@ function attachBounceListener() { }); bounceTraceLog( 'user-scroll-intent', - { delta, top, ts: lastUserDownScrollTs, ...getScrollMetrics(target) }, + { + delta, + top, + ts: lastUserDownScrollTs, + closeToProgrammaticHint, + likelyLayoutDriven, + manualUpByWheel, + strongManualUp, + ...getScrollMetrics(target) + }, 'user-scroll-intent', 120 ); @@ -974,7 +1033,9 @@ async function stickConditionalScrollToBottom(options: { force?: boolean } = {}) if (options.force) { return await stickScrollToBottom({ force: true, preserveScrollPosition: false }); } - return await stickScrollToBottom({ preserveScrollPosition: true }); + // conditional 场景由上层先判断“是否需要追底”,这里不要再使用 preserveScrollPosition, + // 否则一旦高度突增导致 escapedFromLock=true,后续会被卡在非底部。 + return await stickScrollToBottom({ preserveScrollPosition: false }); } function getStickState() {