fix: 修复聊天区滚动锁定与手动上滚脱锁

This commit is contained in:
JOJO 2026-04-27 00:13:38 +08:00
parent ed5315eef6
commit 591a5e64cc
4 changed files with 114 additions and 13 deletions

View File

@ -106,6 +106,9 @@ export const conversationMethods = {
debugLog('前端状态重置完成'); debugLog('前端状态重置完成');
this._scrollListenerReady = false; this._scrollListenerReady = false;
this._manualScrollSuppressUntil = 0;
this._escapedByUserScroll = false;
this._autoRelockCooldownUntil = 0;
this.$nextTick(() => { this.$nextTick(() => {
this.ensureScrollListener(); this.ensureScrollListener();
}); });

View File

@ -112,23 +112,52 @@ export const uiMethods = {
handleStickStateChange(payload) { handleStickStateChange(payload) {
const escaped = !!payload?.escapedFromLock; const escaped = !!payload?.escapedFromLock;
const isAtBottom = !!payload?.isAtBottom;
const isNearBottom = !!payload?.isNearBottom;
const nearBottom = isAtBottom || isNearBottom;
const userEscaped = !!this._escapedByUserScroll;
uiBounceTrace( uiBounceTrace(
'stick-state-change', 'stick-state-change',
{ {
isAtBottom: !!payload?.isAtBottom, isAtBottom,
isNearBottom: !!payload?.isNearBottom, isNearBottom,
escapedFromLock: escaped, escapedFromLock: escaped,
userEscaped,
autoScrollEnabled: this.autoScrollEnabled, autoScrollEnabled: this.autoScrollEnabled,
userScrolling: this.userScrolling userScrolling: this.userScrolling
}, },
'stick-state-change', 'stick-state-change',
180 180
); );
this.stickIsAtBottom = !!payload?.isAtBottom; this.stickIsAtBottom = isAtBottom;
this.stickIsNearBottom = !!payload?.isNearBottom; this.stickIsNearBottom = isNearBottom;
this.chatSetScrollState({
userScrolling: escaped // 用户主动脱离锁定:只要没回到底部/近底部,就保持“手动滚动中”状态
}); 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) { handleUserScrollIntent(payload) {
@ -139,6 +168,8 @@ export const uiMethods = {
} }
// 用户手动滚动后,短时间内禁止任何自动追底,规避 escapedFromLock 状态抖动竞态 // 用户手动滚动后,短时间内禁止任何自动追底,规避 escapedFromLock 状态抖动竞态
this._manualScrollSuppressUntil = Math.max(this._manualScrollSuppressUntil || 0, ts + 1600); this._manualScrollSuppressUntil = Math.max(this._manualScrollSuppressUntil || 0, ts + 1600);
this._escapedByUserScroll = true;
this.chatSetScrollState({ userScrolling: true });
uiBounceTrace( uiBounceTrace(
'ui.user-scroll-intent', 'ui.user-scroll-intent',
{ {
@ -1322,6 +1353,7 @@ export const uiMethods = {
'ui.scrollToBottom:called', 'ui.scrollToBottom:called',
80 80
); );
this._escapedByUserScroll = false;
const chatArea = this.getChatAreaController(); const chatArea = this.getChatAreaController();
if (chatArea && typeof chatArea.scrollToBottom === 'function') { if (chatArea && typeof chatArea.scrollToBottom === 'function') {
chatArea.scrollToBottom({ chatArea.scrollToBottom({
@ -1367,12 +1399,14 @@ export const uiMethods = {
); );
return; return;
} }
// 以 stick 引擎的 escapedFromLock 为最高优先级,避免状态同步竞态导致误追底 // 仅在“确认是用户主动脱离锁定”时才阻断自动追底;
if (stickState?.escapedFromLock) { // 对于内容高度突变导致的 escapedFromLock不应长期卡住锁定。
if (stickState?.escapedFromLock && this._escapedByUserScroll) {
uiBounceTrace( uiBounceTrace(
'ui.conditionalScrollToBottom:skip-escaped-lock', 'ui.conditionalScrollToBottom:skip-escaped-lock',
{ {
escapedFromLock: !!stickState?.escapedFromLock, escapedFromLock: !!stickState?.escapedFromLock,
escapedByUser: !!this._escapedByUserScroll,
isNearBottom: !!stickState?.isNearBottom, isNearBottom: !!stickState?.isNearBottom,
isAtBottom: !!stickState?.isAtBottom isAtBottom: !!stickState?.isAtBottom
}, },
@ -1424,6 +1458,7 @@ export const uiMethods = {
force: true force: true
}); });
} }
this._escapedByUserScroll = false;
this.chatSetScrollState({ autoScrollEnabled: true, userScrolling: false }); this.chatSetScrollState({ autoScrollEnabled: true, userScrolling: false });
return true; return true;
}, },

View File

@ -155,6 +155,8 @@ export function dataState() {
_boundDragLeave: null, _boundDragLeave: null,
_boundDrop: null, _boundDrop: null,
_manualScrollSuppressUntil: 0, _manualScrollSuppressUntil: 0,
_escapedByUserScroll: false,
_autoRelockCooldownUntil: 0,
// stick-to-bottom 状态(用于“回到底部”按钮显隐) // stick-to-bottom 状态(用于“回到底部”按钮显隐)
stickIsAtBottom: true, stickIsAtBottom: true,

View File

@ -733,11 +733,14 @@ let bounceTraceCount = 0;
const BOUNCE_TRACE_MAX = 240; const BOUNCE_TRACE_MAX = 240;
const bounceTraceLastTsByKey = new Map<string, number>(); const bounceTraceLastTsByKey = new Map<string, number>();
let lastObservedTop = 0; let lastObservedTop = 0;
let lastObservedHeight = 0;
let lastUserDownScrollTs = 0; let lastUserDownScrollTs = 0;
let lastUserWheelUpTs = 0;
let lastProgrammaticHintTs = 0; let lastProgrammaticHintTs = 0;
let lastProgrammaticHintSource = ''; let lastProgrammaticHintSource = '';
let suppressUserIntentUntil = 0; let suppressUserIntentUntil = 0;
let scrollListener: ((event: Event) => void) | null = null; let scrollListener: ((event: Event) => void) | null = null;
let wheelListener: ((event: WheelEvent) => void) | null = null;
let traceAttachLogged = false; let traceAttachLogged = false;
function isScrollBounceTraceEnabled() { function isScrollBounceTraceEnabled() {
@ -764,7 +767,9 @@ function getScrollMetrics(el: HTMLElement) {
height, height,
client, client,
remain: height - top - 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) { if (el && scrollListener) {
el.removeEventListener('scroll', scrollListener as EventListener); el.removeEventListener('scroll', scrollListener as EventListener);
} }
if (el && wheelListener) {
el.removeEventListener('wheel', wheelListener as EventListener, true);
}
scrollListener = null; scrollListener = null;
wheelListener = null;
} }
function attachBounceListener() { function attachBounceListener() {
@ -807,16 +816,57 @@ function attachBounceListener() {
if (scrollListener) { if (scrollListener) {
el.removeEventListener('scroll', scrollListener as EventListener); el.removeEventListener('scroll', scrollListener as EventListener);
} }
if (wheelListener) {
el.removeEventListener('wheel', wheelListener as EventListener, true);
}
lastObservedTop = el.scrollTop || 0; 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) => { scrollListener = (event: Event) => {
const target = event.target as HTMLElement | null; const target = event.target as HTMLElement | null;
if (!target) return; if (!target) return;
const top = target.scrollTop; const top = target.scrollTop;
const delta = top - lastObservedTop; const delta = top - lastObservedTop;
const height = target.scrollHeight || 0;
const heightDelta = height - lastObservedHeight;
lastObservedTop = top; lastObservedTop = top;
lastObservedHeight = height;
const now = Date.now(); const now = Date.now();
const trusted = (event as any).isTrusted === true; 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 // stick
stopScroll(); stopScroll();
lastUserDownScrollTs = now; lastUserDownScrollTs = now;
@ -827,7 +877,16 @@ function attachBounceListener() {
}); });
bounceTraceLog( bounceTraceLog(
'user-scroll-intent', 'user-scroll-intent',
{ delta, top, ts: lastUserDownScrollTs, ...getScrollMetrics(target) }, {
delta,
top,
ts: lastUserDownScrollTs,
closeToProgrammaticHint,
likelyLayoutDriven,
manualUpByWheel,
strongManualUp,
...getScrollMetrics(target)
},
'user-scroll-intent', 'user-scroll-intent',
120 120
); );
@ -974,7 +1033,9 @@ async function stickConditionalScrollToBottom(options: { force?: boolean } = {})
if (options.force) { if (options.force) {
return await stickScrollToBottom({ force: true, preserveScrollPosition: false }); return await stickScrollToBottom({ force: true, preserveScrollPosition: false });
} }
return await stickScrollToBottom({ preserveScrollPosition: true }); // conditional 使 preserveScrollPosition
// escapedFromLock=true
return await stickScrollToBottom({ preserveScrollPosition: false });
} }
function getStickState() { function getStickState() {