hookehuyr

feat: 提取 LoadMoreList 可复用组件并迁移页面

- 创建通用加载更多列表组件 LoadMoreList
  * 支持自定义头部、列表项、空状态插槽
  * 内置触底加载、加载状态、动画效果
  * 优化动画延迟策略(前10项逐个显示,其余立即显示)
- 迁移 week-hot-material 页面使用新组件
  * 代码量减少 ~18%
  * 移除重复的分页逻辑和样式
- 修复样式问题
  * 修复列表项黑色圆点(list-style: none)
  * 使用原生 Less 替代 TailwindCSS(兼容性更好)
- 创建迁移指南文档
- 减少 mock 数据延迟(100-300ms)用于开发测试

技术栈: Vue 3 + Composition API + Taro
收益: 提高代码复用性,降低维护成本,统一用户体验
......@@ -18,6 +18,7 @@ declare module 'vue' {
IndexNav: typeof import('./src/components/indexNav.vue')['default']
LifeInsuranceTemplate: typeof import('./src/components/PlanTemplates/LifeInsuranceTemplate.vue')['default']
ListItemActions: typeof import('./src/components/ListItemActions/index.vue')['default']
LoadMoreList: typeof import('./src/components/LoadMoreList/index.vue')['default']
MaterialCard: typeof import('./src/components/MaterialCard.vue')['default']
NavHeader: typeof import('./src/components/NavHeader.vue')['default']
NutAvatar: typeof import('@nutui/nutui-taro')['Avatar']
......@@ -35,7 +36,7 @@ declare module 'vue' {
PdfPreview: typeof import('./src/components/PdfPreview.vue')['default']
Picker: typeof import('./src/components/time-picker-data/picker.vue')['default']
PlanFormContainer: typeof import('./src/components/PlanFormContainer.vue')['default']
PlanPopup: typeof import('./src/components/PlanPopup/index.vue')['default']
PlanPopup: typeof import('./src/components/PlanSchemes/PlanPopup.vue')['default']
PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default']
ProductCard: typeof import('./src/components/ProductCard.vue')['default']
QrCode: typeof import('./src/components/qrCode.vue')['default']
......
......@@ -5,6 +5,39 @@
---
## [2026-02-08] - 提取通用加载更多列表组件
### 新增
- 创建 `LoadMoreList` 通用加载更多列表组件
- 封装分页状态管理(page、pageSize、hasMore)
- 支持触底加载更多(内置300ms防抖)
- 支持下拉刷新(可选)
- 支持自定义头部(通过slot)
- 支持自定义列表项(通过slot)
- 内置空状态和加载提示UI
- 内置列表项动画效果(slideIn + 延迟)
- 组件配置文件 `src/components/LoadMoreList/index.config.js`
- 迁移指南文档 `docs/LoadMoreList-迁移指南.md`
### 重构
- 重构 `src/pages/week-hot-material/index.vue` 使用 `LoadMoreList` 组件
- 代码行数减少 18%(283行 → 230行)
- 模板代码减少 34%(55行 → 36行)
- 样式代码减少 96%(68行 → 3行)
- 分页逻辑集中在组件内部
- 逻辑可复用,易于维护
---
## [2026-02-08] - 修复 LoadMoreList 组件导入路径
### 修复
- 修复 `week-hot-material` 页面加载 `LoadMoreList` 组件报错 "Cannot find module '@/components/LoadMoreList.vue'"
- 修正导入路径:从 `@/components/LoadMoreList.vue` 改为 `@/components/LoadMoreList`
- 原因:组件使用目录结构 `LoadMoreList/index.vue`,导入时应指向目录而非文件
---
## [2026-02-06] - 修复搜索栏清空按钮点击无效
### 修复
......
This diff is collapsed. Click to expand it.
/**
* LoadMoreList 组件配置
*
* @description 通用加载更多列表组件
*/
export default {
component: true,
usingComponents: {}
}
<!--
@description 通用加载更多列表组件
@features
- 支持自定义头部(通过slot)
- 支持下拉刷新
- 支持触底加载更多(带防抖)
- 支持多种列表项渲染(通过slot)
- 内置空状态和加载提示
- 内置列表项动画效果
@example
<LoadMoreList
:list="products"
:page="page"
:page-size="pageSize"
:has-more="hasMore"
:loading="loading"
:loading-more="loadingMore"
key-field="id"
@load-more="handleLoadMore"
@refresh="handleRefresh"
>
<template #header>
<NavHeader title="产品中心" />
</template>
<template #item="{ item }">
<ProductCard :product="item" />
</template>
</LoadMoreList>
-->
<template>
<view class="load-more-list" :class="{ 'has-header': showHeader }">
<!-- 可选固定头部 -->
<view v-if="showHeader" class="load-more-header sticky top-0 z-10">
<slot name="header"></slot>
</view>
<!-- 列表容器 -->
<view
class="load-more-content"
:class="{ 'no-padding': noPadding }"
>
<!-- 首次加载状态 -->
<view v-if="loading && list.length === 0" class="flex justify-center items-center py-[100rpx]">
<slot name="loading">
<view class="loading-spinner"></view>
<text class="ml-[16rpx] text-[#9CA3AF] text-[28rpx]">加载中...</text>
</slot>
</view>
<!-- 列表内容 -->
<view v-else class="list-container">
<view
v-for="(item, index) in displayList"
:key="item[keyField] || index"
class="list-item"
:style="getAnimationDelay(index)"
>
<!-- 使用slot渲染每个列表项 -->
<slot name="item" :item="item" :index="index"></slot>
</view>
<!-- 空状态 -->
<view v-if="list.length === 0 && !loading && !loadingMore">
<slot name="empty">
<nut-empty description="暂无数据" image="empty" />
</slot>
</view>
<!-- 加载更多提示 -->
<view v-if="list.length > 0" class="load-more-container">
<view v-if="loadingMore" class="load-more-loading">
<slot name="loading-more">
<view class="loading-spinner-small"></view>
<text class="ml-[16rpx] text-[#9CA3AF] text-[24rpx]">加载中...</text>
</slot>
</view>
<view v-else-if="!hasMore" class="load-more-finished">
<slot name="no-more">
<text class="text-[#9CA3AF] text-[24rpx]">没有更多了</text>
</slot>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue'
import { useReachBottom, usePullDownRefresh, stopPullDownRefresh } from '@tarojs/taro'
/**
* 通用加载更多列表组件
*
* @description 封装分页加载逻辑,支持自定义头部、列表项、空状态。
* 页面只需提供数据源和事件处理,组件内部处理触底加载、状态显示等。
*
* @component LoadMoreList
*
* @example
* <!-- 简单列表(无头部) -->
* <LoadMoreList
* :list="products"
* :page="page"
* :page-size="10"
* :has-more="hasMore"
* :loading="loading"
* :loading-more="loadingMore"
* key-field="id"
* @load-more="handleLoadMore"
* >
* <template #item="{ item }">
* <ProductCard :product="item" />
* </template>
* </LoadMoreList>
*
* @example
* <!-- 带头部和搜索的列表 -->
* <LoadMoreList
* :list="products"
* :page="page"
* :page-size="10"
* :has-more="hasMore"
* :loading="loading"
* :loading-more="loadingMore"
* :enable-pull-down-refresh="true"
* @load-more="handleLoadMore"
* @refresh="handleRefresh"
* >
* <template #header>
* <NavHeader title="产品中心" />
* <SearchBar v-model="searchValue" @search="onSearch" />
* </template>
* <template #item="{ item }">
* <ProductCard :product="item" />
* </template>
* <template #empty>
* <nut-empty description="暂无相关产品" image="empty" />
* </template>
* </LoadMoreList>
*/
const props = defineProps({
/**
* 列表数据源
* @type {Array<any>}
* @description 需要渲染的列表数据数组
*/
list: {
type: Array,
default: () => []
},
/**
* 当前页码
* @type {number}
* @description 当前页码(从0或1开始,根据API要求)
*/
page: {
type: Number,
required: true
},
/**
* 每页数量
* @type {number}
* @default 10
* @description 每页显示的数据条数
*/
pageSize: {
type: Number,
default: 10
},
/**
* 是否还有更多数据
* @type {boolean}
* @default true
* @description 用于控制是否显示"没有更多了"提示
*/
hasMore: {
type: Boolean,
default: true
},
/**
* 首次加载状态
* @type {boolean}
* @default false
* @description 首次加载时显示loading,隐藏列表
*/
loading: {
type: Boolean,
default: false
},
/**
* 加载更多状态
* @type {boolean}
* @default false
* @description 加载更多时在列表底部显示loading
*/
loadingMore: {
type: Boolean,
default: false
},
/**
* 唯一标识字段名
* @type {string}
* @default 'id'
* @description 用于v-for的key字段名,确保列表更新正确
*/
keyField: {
type: String,
default: 'id'
},
/**
* 是否显示固定头部
* @type {boolean}
* @default true
* @description 是否在顶部显示固定的头部区域
*/
showHeader: {
type: Boolean,
default: true
},
/**
* 是否启用下拉刷新
* @type {boolean}
* @default false
* @description 启用后,用户下拉会触发refresh事件
*/
enablePullDownRefresh: {
type: Boolean,
default: false
},
/**
* 列表容器是否不需要padding
* @type {boolean}
* @default false
* @description 设为true时,列表容器不添加默认的左右padding
*/
noPadding: {
type: Boolean,
default: false
}
})
const emit = defineEmits({
/**
* 加载更多事件
* @event {number} page - 下一页页码
* @description 当用户滚动到底部时触发,页码自动+1
*/
'load-more': (page) => typeof page === 'number',
/**
* 下拉刷新事件
* @description 当用户下拉刷新时触发,仅当enablePullDownRefresh为true时有效
*/
'refresh': null
})
/**
* 显示列表(用于渲染)
* @description 计算属性,返回非空的列表数组
*/
const displayList = computed(() => props.list || [])
/**
* 获取动画延迟
* @description 只为每批的前10项使用动画延迟,避免累积延迟
* @param {number} index - 列表项索引
* @returns {string} 动画延迟样式
*/
function getAnimationDelay(index) {
// 只为前10项使用动画延迟,其余立即显示
if (index < 10) {
return { animationDelay: `${index * 20}ms` }
}
// 第10项以后立即显示(无延迟)
return {}
}
/**
* 触底加载更多(使用防抖)
* @description 当滚动到底部时触发,300ms防抖避免频繁触发
*/
let loadMoreTimer = null
useReachBottom(() => {
// 如果正在加载或没有更多数据,不执行
if (props.loadingMore || props.loading || !props.hasMore) {
return
}
// 防抖:300ms 内只触发一次
if (loadMoreTimer) {
clearTimeout(loadMoreTimer)
}
loadMoreTimer = setTimeout(() => {
console.log('[LoadMoreList] 触底加载更多,当前页:', props.page, '下一页:', props.page + 1)
const nextPage = props.page + 1
emit('load-more', nextPage)
}, 300)
})
/**
* 下拉刷新
* @description 用户下拉时触发刷新事件,仅当enablePullDownRefresh为true时有效
*/
if (props.enablePullDownRefresh) {
usePullDownRefresh(() => {
console.log('[LoadMoreList] 下拉刷新')
emit('refresh')
stopPullDownRefresh()
})
}
</script>
<style lang="less">
.load-more-list {
min-height: 100vh;
background-color: #F9FAFB;
&.has-header {
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
}
}
.load-more-content {
// 列表容器样式
min-height: calc(100vh - 200rpx);
padding: 32rpx;
&.no-padding {
padding: 0;
}
}
// 列表容器
.list-container {
display: flex;
flex-direction: column;
gap: 24rpx;
// 去除列表项的黑点
view {
list-style: none;
}
}
// 列表项进入动画
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.list-item {
animation: slideIn 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) backwards;
// 确保去除列表样式
list-style: none;
}
// 加载更多容器
.load-more-container {
display: flex;
justify-content: center;
align-items: center;
padding: 40rpx 0;
min-height: 80rpx;
}
.load-more-loading {
display: flex;
align-items: center;
justify-content: center;
}
.load-more-finished {
display: flex;
align-items: center;
justify-content: center;
}
// 自定义加载动画
.loading-spinner {
width: 40rpx;
height: 40rpx;
border: 4rpx solid #E5E7EB;
border-top-color: #4CAF50;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-spinner-small {
width: 32rpx;
height: 32rpx;
border: 3rpx solid #E5E7EB;
border-top-color: #4CAF50;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
<!--
* @Date: 2026-02-06
* @Description: 本周热门资料页 - 使用 MaterialCard 组件
* @Date: 2026-02-08
* @Description: 本周热门资料页 - 使用 LoadMoreList 组件重构版本
-->
<template>
<view class="min-h-screen bg-[#F9FAFB] py-[32rpx]">
<NavHeader title="本周热门资料" />
<!-- 列表容器 -->
<view
v-if="listVisible"
:key="listRenderKey"
class="px-[32rpx]"
<LoadMoreList
:list="currentList"
:page="currentPage"
:page-size="pageSize"
:has-more="hasMore"
:loading="loading"
:loading-more="loadingMore"
key-field="meta_id"
@load-more="handleLoadMore"
>
<!-- 加载状态 -->
<view v-if="loading && currentList.length === 0" class="flex items-center justify-center py-[60rpx]">
<view class="loading-spinner"></view>
<text class="ml-[16rpx] text-[#9CA3AF] text-[28rpx]">加载中...</text>
</view>
<!-- 头部 -->
<template #header>
<NavHeader title="本周热门资料" />
</template>
<view v-else class="flex flex-col gap-[24rpx]">
<!-- 列表项 -->
<template #item="{ item }">
<MaterialCard
v-for="(item, index) in currentList"
:key="item.meta_id"
:id="item.meta_id"
:title="item.name"
:file-name="item.name"
:file-size="item.size"
:learners="item.read_people_count ? `${item.read_people_count}人学习` : ''"
:learners="item.learners"
:read-people-percent="item.read_people_percent"
:collected="item.collected"
:extension="item.extension"
:download-url="item.downloadUrl"
:style="{ animationDelay: `${index * 50}ms` }"
@collect-changed="handleCollectChanged(item, $event)"
/>
<!-- 空状态 -->
<view v-if="currentList.length === 0 && !loading && !loadingMore">
<nut-empty description="暂无热门资料" image="empty" />
</view>
<!-- 加载更多提示 -->
<view v-if="currentList.length > 0" class="flex items-center justify-center py-[40rpx]">
<view v-if="loadingMore" class="flex items-center">
<view class="loading-spinner-small"></view>
<text class="ml-[12rpx] text-[#9CA3AF] text-[24rpx]">加载中...</text>
</view>
<view v-else-if="!hasMore" class="text-[#9CA3AF] text-[24rpx]">
没有更多了
</view>
</view>
</view>
</view>
</view>
</template>
</LoadMoreList>
</template>
<script setup>
import { ref } from 'vue'
import Taro, { useLoad, useReachBottom } from '@tarojs/taro'
import Taro, { useLoad } from '@tarojs/taro'
import LoadMoreList from '@/components/LoadMoreList'
import NavHeader from '@/components/NavHeader.vue'
import MaterialCard from '@/components/MaterialCard.vue'
import { weekHotAPI } from '@/api/file'
import { mockWeekHotAPI } from '@/utils/mockData'
const listVisible = ref(true)
const listRenderKey = ref(0)
const loading = ref(false)
const loadingMore = ref(false) // 加载更多状态
const hasMore = ref(true) // 是否还有更多数据
const currentList = ref([])
const currentPage = ref(0) // 当前页码(从0开始)
const pageSize = 20 // 每页数量
// ⚠️ MOCK 数据开关 - 开发环境使用 mock 数据,生产环境使用真实 API
const USE_MOCK_DATA = process.env.NODE_ENV === 'development'
/**
* 当前列表数据
* @type {Ref<Array<any>>}
*/
const currentList = ref([])
/**
* 当前页码(从0开始)
* @type {Ref<number>}
*/
const currentPage = ref(0)
/**
* 每页数量
* @type {number}
*/
const pageSize = 20
/**
* 是否还有更多数据
* @type {Ref<boolean>}
*/
const hasMore = ref(true)
/**
* 首次加载状态
* @type {Ref<boolean>}
*/
const loading = ref(false)
/**
* 加载更多状态
* @type {Ref<boolean>}
*/
const loadingMore = ref(false)
/**
* 处理收藏状态改变
*
* @description 当用户点击收藏按钮时,更新本地状态
* @param {Object} item - 资料对象
* @param {Object} newStatus - 新的状态
* @param {Object} newStatus - 新的状态 { collected: boolean }
*/
const handleCollectChanged = (item, newStatus) => {
console.log('[Week Hot] 收藏状态改变:', item.name, newStatus.collected)
......@@ -93,10 +102,12 @@ const handleCollectChanged = (item, newStatus) => {
/**
* 获取本周热门资料列表
*
* @param {Object} params - 请求参数
* @param {number} params.page - 页码(从0开始)
* @param {number} params.limit - 每页数量
* @param {boolean} params.isLoadMore - 是否为加载更多
* @param {boolean} isLoadMore - 是否为加载更多
* @returns {Promise<void>}
*/
const fetchWeekHotList = async (params = {}, isLoadMore = false) => {
try {
......@@ -194,89 +205,25 @@ useLoad(async (options) => {
})
/**
* 触底加载更多
* @description 使用防抖避免频繁触发
* 处理加载更多事件
*
* @param {number} page - 下一页页码
* @returns {Promise<void>}
*/
let loadMoreTimer = null
useReachBottom(() => {
// 如果正在加载或没有更多数据,不执行
if (loadingMore.value || !hasMore.value) {
return
}
// 防抖:300ms 内只触发一次
if (loadMoreTimer) {
clearTimeout(loadMoreTimer)
}
const handleLoadMore = async (page) => {
console.log('[Week Hot] 加载更多,页码:', page)
loadMoreTimer = setTimeout(async () => {
console.log('[Week Hot] 触底加载更多')
// 页码 +1
currentPage.value += 1
// 更新页码
currentPage.value = page
// 加载下一页数据
await fetchWeekHotList(
{ page: currentPage.value, limit: pageSize },
{ page: page, limit: pageSize },
true // 标记为加载更多
)
}, 300)
})
}
</script>
<style lang="less">
/* 列表项进入动画 */
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.material-item {
animation: slideIn 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) backwards;
}
/* 加载动画 */
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading-spinner {
width: 40rpx;
height: 40rpx;
border: 4rpx solid #E5E7EB;
border-top-color: #4CAF50;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-spinner-small {
width: 32rpx;
height: 32rpx;
border: 3rpx solid #E5E7EB;
border-top-color: #4CAF50;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* 多行文本省略 */
.line-clamp-2 {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
word-break: break-all;
}
/* LoadMoreList 组件已内置样式,此处无需额外样式 */
</style>
......
......@@ -53,7 +53,7 @@ function generateRandomFavorite() {
* @param {number} max 最大延迟(ms)
* @returns {Promise}
*/
function mockDelay(min = 300, max = 800) {
function mockDelay(min = 100, max = 300) {
const delay = Math.random() * (max - min) + min
return new Promise(resolve => setTimeout(resolve, delay))
}
......