|
@@ -645,6 +645,18 @@ const messageRowRefs = reactive<Record<string, HTMLElement | null>>({});
|
|
|
const messageRowMinHeights = reactive<Record<string, number>>({});
|
|
const messageRowMinHeights = reactive<Record<string, number>>({});
|
|
|
const BUSINESS_RECORD_BOTTOM_STICKY_DISTANCE = 24;
|
|
const BUSINESS_RECORD_BOTTOM_STICKY_DISTANCE = 24;
|
|
|
const BUSINESS_RECORD_LARGE_HEIGHT_GROWTH = 80;
|
|
const BUSINESS_RECORD_LARGE_HEIGHT_GROWTH = 80;
|
|
|
|
|
+/**
|
|
|
|
|
+ * 「一整块到达」的高度阈值:超过它就按**块**处理 —— 把块的**顶部**对到视口顶部,
|
|
|
|
|
+ * 而不是贴到底部(用户 2026-09-21 选择的做法)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 为什么需要:卡片块 / 参考资料块是**一次插入**的(实测 300~900px),
|
|
|
|
|
+ * 贴到底部等于一步滚过整块 —— 用户刚拿到卡片却看不到卡片。
|
|
|
|
|
+ * 取值依据:正文流式增长是逐片累积的(单次 ResizeObserver 回调通常几十 px),
|
|
|
|
|
+ * 240px 明显高于它、又低于卡片块,正好只拦「一整块」。
|
|
|
|
|
+ */
|
|
|
|
|
+const BUSINESS_RECORD_BLOCK_ANCHOR_GROWTH = 240;
|
|
|
|
|
+/** 块对顶时留的空隙(别把块的顶部贴死在视口最上沿) */
|
|
|
|
|
+const BUSINESS_RECORD_BLOCK_ANCHOR_OFFSET = 8;
|
|
|
const hasStoppedAtLastAiRowTop = ref(false);
|
|
const hasStoppedAtLastAiRowTop = ref(false);
|
|
|
const lastAiRowTopConsumedRecordId = ref<string | null>(null);
|
|
const lastAiRowTopConsumedRecordId = ref<string | null>(null);
|
|
|
const lastAiRowTopSnapshot = ref<{ recordId: string; rowOffsetTop: number } | null>(null);
|
|
const lastAiRowTopSnapshot = ref<{ recordId: string; rowOffsetTop: number } | null>(null);
|
|
@@ -1133,6 +1145,41 @@ const releaseScopeHeightLockIfReady = () => {
|
|
|
return true;
|
|
return true;
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * 大块内容(卡片 / 参考资料)到达时,把**它自己的顶部**对到视口顶部。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 为什么:这类块是一次插入的整块,贴到底部等于一步滚过整块 —— 用户刚拿到卡片却看不到卡片。
|
|
|
|
|
+ * 对顶之后不调用 `syncAutoScrollFollowState()`:自动贴底保持关闭,正文继续在下方流式增长;
|
|
|
|
|
+ * 用户自己滚回底部附近时(`handleBusinessScrollPositionChange`)会自动恢复跟随。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 拿不到元素 / DOM 时返回 false,调用方照旧走贴底逻辑。
|
|
|
|
|
+ */
|
|
|
|
|
+const anchorLargeBlockTop = (heightGrowth: number): boolean => {
|
|
|
|
|
+ const scrollList = scrollListRef.value;
|
|
|
|
|
+ const lastAiMessage = getLastAiMessage();
|
|
|
|
|
+ if (!scrollList || typeof scrollList.getScrollElement !== "function" || !lastAiMessage) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const scrollElement = scrollList.getScrollElement();
|
|
|
|
|
+ const rowElement = messageRowRefs[String(lastAiMessage.id)];
|
|
|
|
|
+ if (!scrollElement || !rowElement) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 新内容的起点 = 行顶 + 长高**之前**的行高(ResizeObserver 是长高后才回调的)
|
|
|
|
|
+ const rowTop = getRowScrollTop(scrollElement, rowElement);
|
|
|
|
|
+ const rowHeightAfter = rowElement.getBoundingClientRect().height;
|
|
|
|
|
+ const blockTop = rowTop + Math.max(0, rowHeightAfter - heightGrowth) - BUSINESS_RECORD_BLOCK_ANCHOR_OFFSET;
|
|
|
|
|
+
|
|
|
|
|
+ if (typeof scrollList.scrollToPositionInstant === "function") {
|
|
|
|
|
+ scrollList.scrollToPositionInstant(blockTop);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ scrollElement.scrollTop = blockTop;
|
|
|
|
|
+ }
|
|
|
|
|
+ return true;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
const handleBusinessRecordRefreshScroll = async (payload?: { heightGrowth?: number; scrollDelta?: number; onComplete?: () => void }) => {
|
|
const handleBusinessRecordRefreshScroll = async (payload?: { heightGrowth?: number; scrollDelta?: number; onComplete?: () => void }) => {
|
|
|
const scrollList = scrollListRef.value;
|
|
const scrollList = scrollListRef.value;
|
|
|
if (scrollList && typeof scrollList.isAutoScrollTopPinned === "function" && scrollList.isAutoScrollTopPinned()) {
|
|
if (scrollList && typeof scrollList.isAutoScrollTopPinned === "function" && scrollList.isAutoScrollTopPinned()) {
|
|
@@ -1224,6 +1271,13 @@ const handleBusinessRecordRefreshScroll = async (payload?: { heightGrowth?: numb
|
|
|
distanceBeforeGrowth < BUSINESS_RECORD_BOTTOM_STICKY_DISTANCE ||
|
|
distanceBeforeGrowth < BUSINESS_RECORD_BOTTOM_STICKY_DISTANCE ||
|
|
|
heightGrowth > BUSINESS_RECORD_LARGE_HEIGHT_GROWTH
|
|
heightGrowth > BUSINESS_RECORD_LARGE_HEIGHT_GROWTH
|
|
|
) {
|
|
) {
|
|
|
|
|
+ // 一整块到达(卡片 / 参考资料):把块的**顶部**对到视口顶,而不是滚过它
|
|
|
|
|
+ // (用户 2026-09-21 选定;详见 BUSINESS_RECORD_BLOCK_ANCHOR_GROWTH 的说明)
|
|
|
|
|
+ if (heightGrowth >= BUSINESS_RECORD_BLOCK_ANCHOR_GROWTH && anchorLargeBlockTop(heightGrowth)) {
|
|
|
|
|
+ payload?.onComplete?.();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// 高度增加后,判断新增前是否贴近底部。
|
|
// 高度增加后,判断新增前是否贴近底部。
|
|
|
// 如果现在距离底部极近 (<= 24px),则瞬间跳到底部,否则平滑滚动
|
|
// 如果现在距离底部极近 (<= 24px),则瞬间跳到底部,否则平滑滚动
|
|
|
if (distanceAfterGrowth <= 24) {
|
|
if (distanceAfterGrowth <= 24) {
|