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('前端状态重置完成');
this._scrollListenerReady = false;
this._manualScrollSuppressUntil = 0;
this._escapedByUserScroll = false;
this._autoRelockCooldownUntil = 0;
this.$nextTick(() => {
this.ensureScrollListener();
});

View File

@ -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;
},

View File

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

View File

@ -733,11 +733,14 @@ let bounceTraceCount = 0;
const BOUNCE_TRACE_MAX = 240;
const bounceTraceLastTsByKey = new Map<string, number>();
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() {