hookehuyr

feat: 实现 LoadMoreList 动态高度计算并修复双重滚动问题

### 新增功能
- LoadMoreList 组件支持动态高度测量
  - 使用 Taro.createSelectorQuery() 运行时测量
  - 新增 hasFooter prop(TabBar 页面)
  - 新增 extraBottomSpace prop(固定按钮等)
  - 支持响应式监听 props 变化
  - 支持页面生命周期重新测量(useDidShow)
  - 自动处理底部安全区域

### 修复
- 修复 4 个页面的双重滚动问题
  - feedback-list, favorites, material-list, product-center
  - 添加页面容器 height: 100vh 和 overflow: hidden
- 移除所有调试 console.log,保持代码整洁

### 配置更新
- feedback-list: extraBottomSpace=280(固定按钮)
- favorites: hasFooter=false
- material-list: hasFooter=false
- product-center: hasFooter=false
- search: hasFooter=false
- message: hasFooter=false
- week-hot-material: hasFooter=false

### 文档
- CHANGELOG.md: 记录双重滚动问题修复
- lessons-learned.md: 添加坑 5(双重滚动问题)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
......@@ -5,6 +5,57 @@
---
## [2026-02-08] - 修复 LoadMoreList 页面双重滚动问题
### 修复
- 修复 4 个使用 LoadMoreList 组件的页面出现双重滚动问题
- 页面级和 scroll-view 都可以滚动,导致用户体验混乱
- `src/pages/feedback-list/index.vue`
- `src/pages/favorites/index.vue`
- `src/pages/material-list/index.vue`
- `src/pages/product-center/index.vue`
- 在页面容器添加 `height: 100vh``overflow: hidden`
- TailwindCSS: `h-screen overflow-hidden`
- Less: `height: 100vh; overflow: hidden;`
### 问题原因
- 页面容器未设置固定高度(使用 `min-height` 或未设置)
- 页面容器未禁用溢出(缺少 `overflow: hidden`
- 导致页面级和组件级滚动同时生效
### 解决方案
- 页面容器设置固定高度:`height: 100vh`(或 `h-screen`
- 页面容器禁用溢出:`overflow: hidden`(或 `overflow-hidden`
- 让 LoadMoreList 内部的 scroll-view 处理所有滚动
- 固定元素(导航栏、搜索栏)放在 `#header` 插槽中
### 文档
-`docs/lessons-learned.md` 中添加"LoadMoreList 页面的双重滚动问题"记录
- 说明问题表现、原因分析、解决方案
- 提供最佳实践和检查清单
- 列出修复的 4 个页面和已正确的 3 个页面
### 收益
- ✅ 消除双重滚动,提升用户体验
- ✅ 固定元素(导航栏、搜索栏)始终可见
- ✅ 统一 LoadMoreList 页面的滚动行为
- ✅ 为所有使用 LoadMoreList 的页面提供标准模式
---
**详细信息**
- **影响文件**:
- `src/pages/feedback-list/index.vue`(修复双重滚动)
- `src/pages/favorites/index.vue`(修复双重滚动)
- `src/pages/material-list/index.vue`(修复双重滚动)
- `src/pages/product-center/index.vue`(修复双重滚动)
- `docs/lessons-learned.md`(添加经验教训)
- **技术栈**: Vue 3, Taro 4, TailwindCSS, Less
- **测试状态**: ✅ 已通过
- **备注**: 共检查 7 个使用 LoadMoreList 的页面,修复 4 个,3 个已正确
---
## [2026-02-08] - 修复 LoadMoreList 组件底部 padding 堆叠问题
### 修复
......
......@@ -583,6 +583,137 @@ padding: [top] [right] [bottom] [left];
- **第 1 次**: 在 material-list 页面发现底部 padding 过高
- **教训**: ⚠️ **LESS 嵌套选择器中,修饰符类的属性会与基础类堆叠,需要覆盖整个属性而不是只写子属性**
### ❌ 坑 5: LoadMoreList 页面的双重滚动问题 ⭐ 2026-02-08 新增
**问题描述**:
使用 `LoadMoreList` 组件的页面出现双重滚动问题:
- 整个页面可以滚动
- LoadMoreList 内部的 scroll-view 也可以滚动
- 用户滚动时体验混乱
**错误代码**:
```vue
<!-- ❌ 页面容器没有限制高度和溢出 -->
<template>
<view class="bg-[#F9FAFB]">
<LoadMoreList
:list="currentList"
:page="currentPage"
@load-more="handleLoadMore"
>
<!-- 列表项 -->
</LoadMoreList>
</view>
</template>
```
```less
// ❌ 缺少高度和溢出控制
.feedback-list {
min-height: 100vh; // ❌ 使用 min-height 而非 height
// ❌ 缺少 overflow: hidden
}
```
**错误表现**:
- 页面级和组件级都可以滚动
- 用户滚动时不确定是哪个在滚动
- 固定元素(如导航栏、筛选栏)可能随页面滚动消失
**原因分析**:
1. **页面容器未限制高度**: 使用 `min-height: 100vh` 或没有设置高度
2. **未禁用页面级溢出**: 缺少 `overflow: hidden`
3. **滚动上下文混乱**: 浏览器无法确定应该滚动哪个容器
**解决方案**: 在页面容器添加 `height: 100vh` 和 `overflow: hidden`
```vue
<!-- ✅ 正确:页面容器固定高度并禁用溢出 -->
<template>
<!-- ✅ TailwindCSS 方式 -->
<view class="h-screen overflow-hidden bg-[#F9FAFB]">
<LoadMoreList
:list="currentList"
:page="currentPage"
@load-more="handleLoadMore"
>
<!-- 列表项 -->
</LoadMoreList>
</view>
</template>
```
```less
// ✅ Less 方式
.feedback-list {
height: 100vh; // ✅ 固定高度
overflow: hidden; // ✅ 禁用页面级滚动
}
```
**关键点**:
- ✅ 页面容器必须设置 `height: 100vh`(固定高度)
- ✅ 页面容器必须设置 `overflow: hidden`(禁用页面级滚动)
- ✅ 让 LoadMoreList 内部的 scroll-view 处理所有滚动
- ✅ 如果页面有固定顶部(如导航栏、搜索栏),放在 LoadMoreList 的 `#header` 插槽中
**修复的页面**(共 4 个):
1. `src/pages/feedback-list/index.vue`
2. `src/pages/favorites/index.vue`
3. `src/pages/material-list/index.vue`
4. `src/pages/product-center/index.vue`
**已正确的页面**(共 3 个):
1. `src/pages/search/index.vue`(已正确配置)
2. `src/pages/message/index.vue`(LoadMoreList 是根元素)
3. `src/pages/week-hot-material/index.vue`(LoadMoreList 是根元素)
**最佳实践**:
```vue
<!-- ✅ 推荐的 LoadMoreList 页面结构 -->
<template>
<!-- 外层容器:固定高度,禁用溢出 -->
<view class="h-screen overflow-hidden bg-[#F9FAFB]">
<LoadMoreList
:list="currentList"
:page="currentPage"
:page-size="pageSize"
:has-more="hasMore"
:loading="loading"
:loading-more="loadingMore"
key-field="id"
@load-more="handleLoadMore"
>
<!-- 固定头部(可选) -->
<template #header>
<view class="sticky top-0 bg-white z-10">
<NavHeader title="页面标题" />
<SearchBar v-model="searchValue" />
</view>
</template>
<!-- 列表项 -->
<template #item="{ item }">
<ProductCard :product="item" />
</template>
</LoadMoreList>
</view>
</template>
```
**检查清单**:
使用 LoadMoreList 组件时,确认:
- [ ] 页面容器设置了 `height: 100vh`(或 `h-screen`)
- [ ] 页面容器设置了 `overflow: hidden`(或 `overflow-hidden`)
- [ ] 固定元素(导航栏、搜索栏)放在 `#header` 插槽中
- [ ] 只有 LoadMoreList 内部的 scroll-view 可以滚动
**适用场景**:
- ✅ 所有使用 LoadMoreList 组件的分页列表页面
- ✅ 需要固定顶部元素的滚动页面
- ✅ 需要防止双重滚动的任何场景
---
### ⚠️ 双设计宽度系统
......
......@@ -31,7 +31,7 @@
<template>
<view class="load-more-list" :class="{ 'has-header': showHeader }">
<!-- 可选固定头部 -->
<view v-if="showHeader" class="load-more-header">
<view v-if="showHeader" class="load-more-header" ref="headerRef">
<slot name="header"></slot>
</view>
......@@ -127,8 +127,9 @@
</template>
<script setup>
import { computed } from 'vue'
import { usePullDownRefresh, stopPullDownRefresh } from '@tarojs/taro'
import { computed, ref, onMounted, watch, nextTick as vueNextTick } from 'vue'
import Taro from '@tarojs/taro'
import { usePullDownRefresh, stopPullDownRefresh, useDidShow } from '@tarojs/taro'
/**
* 通用加载更多列表组件
......@@ -298,6 +299,28 @@ const props = defineProps({
enableScrollLoad: {
type: Boolean,
default: true
},
/**
* 页面是否有底部导航(TabBar 等)
* @type {boolean}
* @default false
* @description 如果页面有底部导航(如 TabBar),设为 true 会自动预留底部空间
*/
hasFooter: {
type: Boolean,
default: false
},
/**
* 额外底部空间(rpx)
* @type {number}
* @default 0
* @description 额外预留的底部空间(单位:rpx),用于固定按钮等非标准底部元素
*/
extraBottomSpace: {
type: Number,
default: 0
}
})
......@@ -323,17 +346,118 @@ const emit = defineEmits({
const displayList = computed(() => props.list || [])
/**
* 动态测量的头部高度(px)
* @description 使用 Taro.createSelectorQuery() 在运行时测量实际头部高度
*/
const headerHeight = ref(0)
/**
* 动态测量的底部导航高度(px)
* @description 如果页面有 TabBar 等底部导航,预留底部空间
*/
const footerHeight = ref(0)
/**
* 是否已完成高度测量
* @description 避免重复测量
*/
const heightMeasured = ref(false)
/**
* 测量头部和底部高度
* @description 使用 Taro.createSelectorQuery() 动态测量页面元素高度
*/
const measureHeaderHeight = async () => {
if (heightMeasured.value) return
try {
// 获取系统信息
const systemInfo = await Taro.getSystemInfo()
const { windowHeight, screenHeight, safeArea } = systemInfo
// 计算底部安全区域高度
const bottomSafeArea = screenHeight - safeArea.bottom
/**
* 计算底部高度
* @description 处理底部导航栏、安全区域和额外底部空间
*/
const calculateFooterHeight = () => {
let totalFooterHeight = 0
if (props.hasFooter) {
// 假设 TabBar 高度约为 50px(小程序标准 TabBar 高度)
totalFooterHeight = 50 + bottomSafeArea
} else {
// 无底部导航,只考虑安全区域
totalFooterHeight = bottomSafeArea
}
// 添加额外底部空间(将 rpx 转换为 px:750rpx 设计宽度标准,rpx / 2 = px)
if (props.extraBottomSpace > 0) {
const extraBottomSpacePx = props.extraBottomSpace / 2
totalFooterHeight += extraBottomSpacePx
}
footerHeight.value = totalFooterHeight
heightMeasured.value = true
}
let totalHeaderHeight = 0
// 如果有头部,测量头部实际高度
if (props.showHeader) {
await vueNextTick() // 等待 DOM 渲染完成
const query = Taro.createSelectorQuery()
query.select('.load-more-header').boundingClientRect()
query.exec((res) => {
if (res && res[0]) {
totalHeaderHeight = res[0].height
} else {
// 如果测量失败,使用默认估算值
totalHeaderHeight = 178 // 356rpx ≈ 178px
}
headerHeight.value = totalHeaderHeight
calculateFooterHeight() // 现在可以正确调用了
})
} else {
// 无头部
calculateFooterHeight()
}
} catch (err) {
console.error('[LoadMoreList] 测量高度失败:', err)
// 测量失败时使用默认值
headerHeight.value = props.showHeader ? 178 : 0
footerHeight.value = props.hasFooter ? 80 : 34 // 约50px TabBar + 30px安全区域
heightMeasured.value = true
}
}
// 组件挂载后测量高度
onMounted(() => {
measureHeaderHeight()
})
/**
* scroll-view 高度
* @description 动态计算 scroll-view 的高度
* @description 动态计算 scroll-view 的高度,使用运行时测量的实际高度
*/
const scrollHeight = computed(() => {
// 头部高度估算(rpx 单位)
// NavHeader(约88rpx) + SearchBar(约120rpx) + Tabs(约88rpx) + ResultCount(约60rpx) ≈ 356rpx
const headerHeight = props.showHeader ? '356rpx' : '0rpx'
if (!heightMeasured.value) {
// 未完成测量时,使用默认估算值
const defaultHeaderHeight = props.showHeader ? '178px' : '0px' // 356rpx ≈ 178px
const defaultFooterHeight = props.hasFooter ? '80px' : '34px'
return `calc(100vh - ${defaultHeaderHeight} - ${defaultFooterHeight})`
}
// 使用 calc() 计算剩余高度
// 100vh 是视口高度,减去头部高度
return `calc(100vh - ${headerHeight})`
// 使用测量到的实际高度(转换为 px)
const headerPx = headerHeight.value
const footerPx = footerHeight.value
// 计算滚动高度:视口高度 - 头部高度 - 底部高度
return `calc(100vh - ${headerPx}px - ${footerPx}px)`
})
/**
......@@ -356,20 +480,11 @@ function getAnimationDelay(index) {
* @description 当 scroll-view 滚动到底部时触发
*/
const handleScrollToLower = () => {
console.log('[LoadMoreList] scroll-view 触底事件触发', {
loadingMore: props.loadingMore,
loading: props.loading,
hasMore: props.hasMore,
page: props.page
})
// 如果正在加载或没有更多数据,不执行
if (props.loadingMore || props.loading || !props.hasMore) {
console.log('[LoadMoreList] 跳过加载:正在加载或没有更多数据')
return
}
console.log('[LoadMoreList] 触底加载更多,当前页:', props.page, '下一页:', props.page + 1)
const nextPage = props.page + 1
emit('load-more', nextPage)
}
......@@ -380,11 +495,28 @@ const handleScrollToLower = () => {
*/
if (props.enablePullDownRefresh) {
usePullDownRefresh(() => {
console.log('[LoadMoreList] 下拉刷新')
emit('refresh')
stopPullDownRefresh()
})
}
/**
* 监听 showHeader、hasFooter 和 extraBottomSpace 变化
* @description 当头部或底部配置变化时,重新测量高度
*/
watch([() => props.showHeader, () => props.hasFooter, () => props.extraBottomSpace], () => {
heightMeasured.value = false
measureHeaderHeight()
})
/**
* 页面显示时重新测量高度
* @description 确保在页面显示时高度计算正确(处理从其他页面返回的情况)
*/
useDidShow(() => {
heightMeasured.value = false
measureHeaderHeight()
})
</script>
<style lang="less">
......@@ -392,10 +524,6 @@ if (props.enablePullDownRefresh) {
background-color: #F9FAFB;
height: 100vh;
overflow: hidden;
&.has-header {
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
}
}
.load-more-header {
......@@ -419,8 +547,9 @@ if (props.enablePullDownRefresh) {
&.scrollable {
// 高度通过 :style 动态绑定
box-sizing: border-box;
// 重置基础 padding,只保留顶部和左右,底部使用特殊计算值
padding: 32rpx 32rpx calc(160rpx + env(safe-area-inset-bottom));
// 重置基础 padding,只保留顶部和左右,底部预留空间 + 安全区域
// 确保在所有设备(包括刘海屏)上"没有更多了"文字都能完整显示
padding: 32rpx 32rpx calc(env(safe-area-inset-bottom));
}
}
......
......@@ -3,7 +3,7 @@
* @Description: 我的收藏 - 使用 LoadMoreList 组件重构版本
-->
<template>
<view class="h-screen bg-gray-50 flex flex-col">
<view class="h-screen bg-gray-50 flex flex-col overflow-hidden">
<view class="bg-gray-50 z-10">
<NavHeader title="我的收藏" />
</view>
......@@ -17,6 +17,8 @@
:loading="loading"
:loading-more="loadingMore"
key-field="meta_id"
:show-header="false"
:has-footer="false"
@load-more="handleLoadMore"
>
<!-- 列表项 -->
......
......@@ -15,6 +15,9 @@
:loading="loading"
:loading-more="loadingMore"
key-field="id"
:show-header="false"
:has-footer="false"
:extra-bottom-space="280"
@load-more="handleLoadMore"
>
<!-- 列表项 -->
......@@ -314,9 +317,10 @@ onMounted(() => {
<style lang="less">
.feedback-list {
min-height: 100vh;
height: 100vh;
overflow: hidden;
background-color: #f9fafb;
padding-bottom: 160rpx; // 为固定按钮留出空间
// padding-bottom: 160rpx; // ❌ 已移除:LoadMoreList 已经通过 extraBottomSpace=114 预留了 57px 空间
.feedback-item {
background: white;
......
......@@ -3,7 +3,7 @@
* @Description: 资料/文档列表页 - 使用 LoadMoreList 组件重构版本
-->
<template>
<view class="bg-[#F9FAFB]">
<view class="h-screen overflow-hidden bg-[#F9FAFB]">
<LoadMoreList
:list="currentList"
:page="currentPage"
......@@ -12,6 +12,7 @@
:loading="loading"
:loading-more="loadingMore"
key-field="meta_id"
:has-footer="false"
@load-more="handleLoadMore"
>
<!-- 头部:导航 + 搜索 + Tabs -->
......
......@@ -12,6 +12,7 @@
:loading-more="loadingMore"
:enable-pull-down-refresh="true"
key-field="id"
:has-footer="false"
@load-more="handleLoadMore"
@refresh="handleRefresh"
>
......
......@@ -3,7 +3,7 @@
* @Description: 产品中心 - 使用 LoadMoreList 组件重构版本
-->
<template>
<view class="bg-[#F9FAFB]">
<view class="h-screen overflow-hidden bg-[#F9FAFB]">
<!-- 计划书弹窗 -->
<view v-if="showPlanPopup && selectedProduct">
<PlanFormContainer
......@@ -22,6 +22,7 @@
:loading="loading"
:loading-more="loadingMore"
key-field="id"
:has-footer="false"
@load-more="handleLoadMore"
>
<!-- 头部:导航 + 搜索 + Tabs -->
......@@ -30,7 +31,7 @@
<NavHeader title="产品中心" />
<!-- Search Bar -->
<view class="px-[24rpx] py-[16rpx] bg-white">
<view class="px-[24rpx] py-[16rpx] bg-[#F9FAFB]">
<SearchBar
v-model="searchValue"
placeholder="搜索产品名称..."
......
......@@ -14,6 +14,7 @@
:loading-more="loadingMore"
:show-header="true"
:enable-scroll-load="shouldEnableScrollLoad"
:has-footer="false"
key-field="id"
@load-more="handleLoadMore"
>
......
......@@ -11,6 +11,7 @@
:loading="loading"
:loading-more="loadingMore"
key-field="meta_id"
:has-footer="false"
@load-more="handleLoadMore"
>
<!-- 头部 -->
......