Browse Source

feat(chat): 卡片/参考资料等大块内容改为对齐块顶部,不再滚过它

用户描述:卡片渲染很快,页面一下子滚动到卡片底部 —— 刚拿到卡片却看不到卡片。
机制:handleBusinessRecordRefreshScroll 在「高度增长 > 80px 或本来就在贴底距离内」时
scrollToBottom;卡片块是一次插入的整块(实测 300~900px),一步就滚到了它的底部。

用户选定方案 A:大块内容到达时把块自己的顶部对到视口顶部。PC 与 Mobile 各一处,口径一致:
- 新增 BUSINESS_RECORD_BLOCK_ANCHOR_GROWTH = 240(超过按块处理)、
  BUSINESS_RECORD_BLOCK_ANCHOR_OFFSET = 8
- 新增 anchorLargeBlockTop(heightGrowth):块起点 = 行顶 +(长高之前的行高),
  scrollToPositionInstant 对顶;不调用 syncAutoScrollFollowState,
  于是自动贴底保持关闭、正文继续在下方增长,用户滚回底部附近时自动恢复跟随
- 接在「大块增长」分支最前;未达阈值或拿不到元素时照旧走贴底逻辑

取值依据:正文流式增长是逐片累积的(单次 ResizeObserver 回调几十 px),
240px 明显高于它、又低于卡片块,正好只拦「一整块」。

验证:npm run build 通过;verify-answer-stream 165/165;validate-harness 通过;
npx vue-tsc --noEmit --types node,jest 显示本次改动 0 个新增类型错误。
⚠️ 观感需用户浏览器确认,阈值(240px)可按手感调整。

顺带记录:vue-tsc 直接跑会因 tsconfig 的 types 里写着 webpack-env 而 @types/webpack-env
未安装直接失败;全项目另有 65 条既有类型错误(非本次引入)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 1 day ago
parent
commit
4f6ab3fe18

+ 31 - 0
harness/progress.md

@@ -3420,3 +3420,34 @@ F073 之后正文是**即时渲染**的(流式期间打字机关着),但**
 或本来就在贴底距离内」时会 `scrollToBottom`,卡片是一整块到达(300~900px),
 于是一步滚到它的**底部**,用户看不到刚拿到的卡片。
 改这个是**换手感的决定**(不是修 bug),方案见对话。
+
+### 续 15:卡片块改为「对齐块顶部」而不是滚过它(用户选方案 A)
+
+**用户描述**:卡片渲染很快,页面一下子滚动到卡片**底部** —— 刚拿到卡片却看不到卡片。
+**机制**:`handleBusinessRecordRefreshScroll` 在「高度增长 > 80px 或本来就在贴底距离内」时
+`scrollToBottom`;卡片块是一次插入的整块(实测 300~900px),一步就滚到了它的底部。
+
+**用户选定方案 A**:大块内容到达时把**块自己的顶部**对到视口顶部。
+**实现**(PC 与 Mobile 各一处,口径一致):
+- 新增两个常量:`BUSINESS_RECORD_BLOCK_ANCHOR_GROWTH = 240`(超过就按块处理)、
+  `BUSINESS_RECORD_BLOCK_ANCHOR_OFFSET = 8`
+- 新增 `anchorLargeBlockTop(heightGrowth)`:块的起点 = 行顶 + (长高**之前**的行高),
+  用 `scrollToPositionInstant` 对顶;**不调用** `syncAutoScrollFollowState()`,
+  于是自动贴底保持关闭、正文继续在下方增长,用户滚回底部附近时自动恢复跟随
+- 接在「大块增长」分支最前;`heightGrowth` 未达阈值或拿不到元素时照旧走贴底逻辑
+
+**取值依据**:正文流式增长是逐片累积的(单次 ResizeObserver 回调几十 px),
+240px 明显高于它、又低于卡片块,正好只拦「一整块」。
+
+**运行过的验证**:
+- `npm run build` 通过;`verify-answer-stream` 165/165;`validate-harness` 通过
+- **类型检查**(`npx vue-tsc --noEmit --types node,jest`):本次改动**0 个新增错误**
+  (两文件的 3 条报错都在既有代码里)。顺带发现:`vue-tsc` 直接跑会因为
+  `tsconfig.json` 的 `types` 里写着 `webpack-env` 而 `@types/webpack-env` 没装**直接失败**,
+  且全项目有 **65 条既有类型错误** —— 见「已知欠债」
+- ⚠️ **观感只能由用户在浏览器确认**(两端各一处改动、阈值可调)
+
+**⚠️ 过程中踩的坑(记录)**:第一次用 node 脚本批量替换 Mobile 文件时,**多行匹配因 CRLF 失败**,
+只有单行那处生效 → 结果 `anchorLargeBlockTop` 引用了**未定义的常量**,
+而 `npm run build` **照样通过**(Vite 不做类型检查)。改用 Edit 工具重做,并用
+`vue-tsc` 复核。教训:**多行替换必须核对生效处数**,且构建通过 ≠ 代码正确。

+ 43 - 0
src/components/BusinessAssistantMobile.vue

@@ -460,6 +460,13 @@ const chatSuggestionsContainerRef = ref<HTMLElement | null>(null);
 const chatSuggestionsBottomPadding = ref(0);
 const BUSINESS_RECORD_BOTTOM_STICKY_DISTANCE = 24;
 const BUSINESS_RECORD_LARGE_HEIGHT_GROWTH = 80;
+/**
+ * 「一整块到达」的高度阈值:超过它就按**块**处理 —— 把块的**顶部**对到视口顶部,
+ * 而不是贴到底部(用户 2026-09-21 选择的做法)。与 PC 端同一口径(说明见 BusinessAssistantPC.vue)。
+ */
+const BUSINESS_RECORD_BLOCK_ANCHOR_GROWTH = 240;
+/** 块对顶时留的空隙(别把块的顶部贴死在视口最上沿) */
+const BUSINESS_RECORD_BLOCK_ANCHOR_OFFSET = 8;
 const hasStoppedAtLastAiRowTop = ref(false);
 const lastAiRowTopConsumedRecordId = ref<string | null>(null);
 const lastAiRowTopSnapshot = ref<{ recordId: string; rowOffsetTop: number } | null>(null);
@@ -1241,6 +1248,35 @@ const releaseScopeHeightLockIfReady = () => {
   return true;
 };
 
+/**
+ * 大块内容(卡片 / 参考资料)到达时,把**它自己的顶部**对到视口顶部。
+ * 与 PC 端同实现(说明见 BusinessAssistantPC.vue 里同名函数)。
+ */
+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;
+  }
+
+  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 scrollList = scrollListRef.value;
   if (scrollList && typeof scrollList.isAutoScrollTopPinned === "function" && scrollList.isAutoScrollTopPinned()) {
@@ -1345,6 +1381,13 @@ const handleBusinessRecordRefreshScroll = async (payload?: { heightGrowth?: numb
     distanceBeforeGrowth < BUSINESS_RECORD_BOTTOM_STICKY_DISTANCE ||
     heightGrowth > BUSINESS_RECORD_LARGE_HEIGHT_GROWTH
   ) {
+    // 一整块到达(卡片 / 参考资料):把块的**顶部**对到视口顶,而不是滚过它
+    // (用户 2026-09-21 选定;说明见 BusinessAssistantPC.vue 的 BUSINESS_RECORD_BLOCK_ANCHOR_GROWTH)
+    if (heightGrowth >= BUSINESS_RECORD_BLOCK_ANCHOR_GROWTH && anchorLargeBlockTop(heightGrowth)) {
+      payload?.onComplete?.();
+      return;
+    }
+
     if (distanceAfterGrowth <= 24) {
       scrollBusinessAssistantListToBottom(scrollListRef.value, 'instant');
     } else {

+ 54 - 0
src/components/BusinessAssistantPC.vue

@@ -645,6 +645,18 @@ const messageRowRefs = reactive<Record<string, HTMLElement | null>>({});
 const messageRowMinHeights = reactive<Record<string, number>>({});
 const BUSINESS_RECORD_BOTTOM_STICKY_DISTANCE = 24;
 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 lastAiRowTopConsumedRecordId = ref<string | null>(null);
 const lastAiRowTopSnapshot = ref<{ recordId: string; rowOffsetTop: number } | null>(null);
@@ -1133,6 +1145,41 @@ const releaseScopeHeightLockIfReady = () => {
   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 scrollList = scrollListRef.value;
   if (scrollList && typeof scrollList.isAutoScrollTopPinned === "function" && scrollList.isAutoScrollTopPinned()) {
@@ -1224,6 +1271,13 @@ const handleBusinessRecordRefreshScroll = async (payload?: { heightGrowth?: numb
     distanceBeforeGrowth < BUSINESS_RECORD_BOTTOM_STICKY_DISTANCE ||
     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),则瞬间跳到底部,否则平滑滚动
     if (distanceAfterGrowth <= 24) {