hookehuyr

docs: 整理文档结构并使用中文命名

**主要变更**:
- 将组件相关文档移动到 guides/components/
  - LoadMoreList 迁移指南.md
  - LoadMoreList 完整使用指南.md
- 将 API/Mock 相关文档移动到 api-specs/数据文档/
  - Mock 数据完整总结.md
  - Mock 数据设置指南.md
  - API 集成日志.md
- 将测试相关文档移动到相应目录
  - 滚动加载测试指南.md → guides/testing/
  - 计划测试实施报告.md → reports/测试报告/
- 更新所有文档中的相对路径引用
- 添加文档命名使用中文规则到全局规则

**详细信息**:
- **影响文件**: docs/ 目录下所有文档
- **技术栈**: 文档组织
- **测试状态**: N/A
- **备注**: 提升文档可维护性和查找效率

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
......@@ -302,7 +302,7 @@ console.log(product.form_sn) // 应该有值,如 "life-insurance-wiop3e"
- [完整架构文档](./plan-entry-architecture.md)
- [经验教训总结](../lessons-learned/plan-entry-module-summary.md)
- [API 联调日志](../api-integration-log.md)
- [API 联调日志](../api-specs/API 集成日志.md)
---
......
......@@ -9,7 +9,6 @@ docs/
├── CHANGELOG.md # 项目变更日志(核心文档)
├── README.md # 本文件(文档导航索引)
├── lessons-learned.md # 经验教训总结(核心文档)
├── api-integration-log.md # API 联调日志
├── guides/ # 📘 使用指南和教程
│ ├── START_HERE.md # 新人入门指南
......@@ -77,7 +76,7 @@ docs/
### 核心文档
- 📖 [项目变更日志](CHANGELOG.md) - 所有功能、修复和优化的记录
- 📖 [经验教训总结](lessons-learned.md) - 开发中的最佳实践和常见陷阱
- 📖 [API 联调日志](api-integration-log.md) - 接口联调状态记录
- 📖 [API 联调日志](api-specs/API 集成日志.md) - 接口联调状态记录
### 新手入门
👉 **[guides/START_HERE.md](guides/START_HERE.md)** - 快速了解项目功能
......@@ -163,7 +162,7 @@ UI/UX 设计稿和生成的代码:
### 更新日志
- 项目级别的更新:修改 `CHANGELOG.md`
- API 联调记录:修改 `api-integration-log.md`
- API 联调记录:修改 `api-specs/API 集成日志.md`
---
......
# 原始页面备份
> **备份时间**: 2026-02-08
> **备份原因**: LoadMoreList 组件迁移前的代码备份
> **迁移提交**: ce4b99b refactor: 迁移所有剩余页面到 LoadMoreList 组件
---
## 📁 备份文件列表
| 文件 | 大小 | 说明 |
|------|------|------|
| `message-index.vue.bak` | 3.7 KB | 消息列表页(迁移前) |
| `product-center-index.vue.bak` | 13.9 KB | 产品中心页(迁移前) |
| `material-list-index.vue.bak` | 24.6 KB | 资料列表页(迁移前) |
| `search-index.vue.bak` | 17 KB | 搜索页(迁移前) |
---
## 🔄 如何恢复原始代码
### 方法 1: 手动恢复(推荐)
如果新组件有问题,可以手动恢复:
```bash
# 1. 删除当前文件
rm src/pages/message/index.vue
# 2. 从备份恢复
cp docs/backups/original-pages/message-index.vue.bak src/pages/message/index.vue
# 3. 重复其他文件...
```
### 方法 2: 使用 Git 恢复
使用 Git 命令恢复到迁移前的版本:
```bash
# 恢复单个文件
git checkout ce4b99b^ -- src/pages/message/index.vue
# 恢复所有文件
git checkout ce4b99b^ -- src/pages/message/index.vue \
src/pages/product-center/index.vue \
src/pages/material-list/index.vue \
src/pages/search/index.vue
```
---
## 📋 迁移前后对比
### message 页面
| 指标 | 迁移前 | 迁移后 |
|------|--------|--------|
| 代码行数 | 149 行 | 229 行 |
| 分页逻辑 | 手动实现 | LoadMoreList 组件 |
| 下拉刷新 | ❌ 无 | ✅ 有 |
### product-center 页面
| 指标 | 迁移前 | 迁移后 |
|------|--------|--------|
| 代码行数 | 510 行 | 592 行 |
| 分页逻辑 | 手动实现 | LoadMoreList 组件 |
| 搜索功能 | ✅ 有 | ✅ 保留 |
| Tabs | ✅ 有 | ✅ 保留 |
### material-list 页面
| 指标 | 迁移前 | 迁移后 |
|------|--------|--------|
| 代码行数 | 888 行 | 828 行 |
| 分页逻辑 | 手动实现 | LoadMoreList 组件 |
| 分类缓存 | ✅ 有 | ✅ 保留 |
| 搜索防抖 | ✅ 有 | ✅ 保留 |
### search 页面
| 指标 | 迁移前 | 迁移后 |
|------|--------|--------|
| 代码行数 | 603 行 | 549 行 |
| 分页逻辑 | 手动实现 | LoadMoreList 组件 |
| 双列表系统 | ✅ 有 | ✅ 保留 |
| 自动 tab 选择 | ✅ 有 | ✅ 保留 |
---
## ⚠️ 注意事项
1. **备份文件只读**: 这些是 `.bak` 文件,只用于参考,不应直接修改
2. **使用 Git 版本控制**: 建议使用 Git 命令恢复,而不是手动复制
3. **测试新组件**: 如果新组件有问题,先检查是否可以通过修改解决
4. **保留备份**: 建议保留这些备份文件,直到确认新组件完全稳定
---
## 🔍 迁移问题排查
如果新组件有问题,按以下步骤排查:
### 1. 检查 Props 是否正确
```vue
<!-- ❌ 错误:缺少必需 props -->
<LoadMoreList :list="products" @load-more="handleLoadMore">
<!-- ✅ 正确:包含所有必需 props -->
<LoadMoreList
:list="products"
:page="page"
:has-more="hasMore"
:loading="loading"
:loading-more="loadingMore"
@load-more="handleLoadMore"
>
```
### 2. 检查数据加载逻辑
```javascript
// ❌ 错误:未正确处理追加数据
const handleLoadMore = async (page) => {
const newData = await fetchData({ page })
currentList.value = newData // 错误:会覆盖之前的数据
}
// ✅ 正确:追加数据
const handleLoadMore = async (page) => {
const newData = await fetchData({ page })
currentList.value = [...currentList.value, ...newData]
}
```
### 3. 检查 hasMore 判断逻辑
```javascript
// ❌ 错误:使用总数量判断
hasMore.value = currentList.value.length < total
// ✅ 正确:使用返回数据量判断
hasMore.value = newData.length >= pageSize
```
### 4. 检查 page 初始值
```javascript
// 如果 API 页码从 1 开始
const page = ref(1)
// 如果 API 页码从 0 开始
const page = ref(0)
// 组件会自动 +1,所以不需要手动 +1
```
---
## 📖 相关文档
- [LoadMoreList 完整指南](../guides/components/LoadMoreList 完整使用指南.md)
- [LoadMoreList 迁移指南](../guides/components/LoadMoreList 迁移指南.md)
- [项目 CLAUDE.md](../../CLAUDE.md)
---
**创建时间**: 2026-02-08
**维护者**: Claude Code
<!--
* @Date: 2026-01-31
* @Description: 资料列表页 - 已改造为 NutTabs 版本
-->
<template>
<view class="bg-[#F9FAFB]">
<!-- 固定在顶部的导航和搜索 -->
<view class="bg-[#F9FAFB] sticky top-0 z-10">
<NavHeader :title="pageTitle" />
<view class="px-[32rpx] mt-[32rpx] mb-[24rpx]">
<SearchBar
v-model="searchValue"
placeholder="搜索资料..."
@search="onSearch"
@clear="onClear"
variant="rounded"
:show-border="true"
:show-clear="true"
/>
</view>
<!-- 动态显示 Tabs(仅在有分类时显示) -->
<nut-tabs v-if="hasCategories" v-model="activeTabId">
<!-- 自定义标签栏 -->
<template #titles>
<view class="filter-tabs-wrapper">
<view
v-for="item in tabsData"
:key="item.id"
:class="[
'filter-tab-item',
activeTabId === item.id ? 'filter-tab-active' : 'filter-tab-inactive'
]"
@tap="onTabClick(item.id)"
>
<text class="filter-tab-text">{{ item.name }}</text>
</view>
</view>
</template>
</nut-tabs>
</view>
<!-- 列表容器 - 页面级滚动 -->
<view
v-if="listVisible"
:key="listRenderKey"
class="px-[32rpx] pb-[calc(160rpx+env(safe-area-inset-bottom))] box-border"
>
<view class="flex flex-col gap-[24rpx]">
<view v-for="(item, index) in currentList" :key="index"
class="material-item bg-white rounded-[24rpx] p-[24rpx] shadow-sm transition-all duration-200 border border-gray-50 flex flex-row"
:style="{ animationDelay: `${index * 50}ms` }">
<view
class="w-[88rpx] h-[88rpx] mr-[24rpx] flex-shrink-0 flex items-center justify-center bg-gradient-to-br from-blue-50 to-blue-100 rounded-[20rpx] shadow-inner self-start">
<image
:src="getDocumentIcon(item.extension ? `file.${item.extension}` : item.fileName)"
class="w-[48rpx] h-[48rpx]"
mode="aspectFit"
/>
</view>
<view class="flex-1 min-w-0">
<h3 class="text-[#1F2937] text-[30rpx] font-bold leading-[1.4] line-clamp-2 mb-[8rpx]">
{{ item.title }}
</h3>
<p class="text-[#6B7280] text-[24rpx] leading-[1.4] line-clamp-1 mb-[16rpx]">
{{ item.desc }}
</p>
<view class="flex items-center gap-[12rpx] mb-[20rpx]">
<view
class="inline-flex items-center justify-center px-[12rpx] py-[4rpx] bg-gray-100 text-gray-500 text-[20rpx] font-medium rounded-[8rpx]">
{{ getDocumentLabel(item.extension ? `file.${item.extension}` : item.fileName) }}
</view>
<view class="text-[#9CA3AF] text-[22rpx]">
{{ item.size }}
</view>
</view>
<view class="h-[1rpx] bg-gray-100 my-[20rpx]"></view>
<ListItemActions
:viewable="true"
:collectable="true"
:collected="item.collected"
:item-id="String(item.meta_id || item.id)"
@view="onView(item)"
@collect="toggleCollect(item)"
@delete="onDelete(item)"
/>
</view>
</view>
<!-- 空状态 -->
<view v-if="currentList.length === 0 && !loading">
<nut-empty description="暂无相关资料" image="empty" />
</view>
<!-- 加载更多状态 -->
<view v-if="currentList.length > 0" class="load-more-container">
<view v-if="loadingMore" class="load-more-loading">
<view class="loading-spinner"></view>
<text class="ml-[16rpx] text-[#9CA3AF] text-[24rpx]">加载中...</text>
</view>
<view v-else-if="!hasMore" class="load-more-finished">
<text class="text-[#9CA3AF] text-[24rpx]">没有更多了</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, nextTick, watch } from 'vue'
import { useLoad, useReachBottom } from '@tarojs/taro'
import NavHeader from '@/components/NavHeader.vue'
import SearchBar from '@/components/SearchBar.vue'
import ListItemActions from '@/components/ListItemActions/index.vue'
import { useListItemClick, ListType } from '@/composables/useListItemClick'
import { getDocumentIcon, getDocumentLabel } from '@/utils/documentIcons'
import { debounce } from '@/utils/debounce'
import { fileListAPI } from '@/api/file'
import { mockFileListAPI } from '@/utils/mockData'
import { useCollectOperation } from '@/composables/useCollectOperation'
import Taro from '@tarojs/taro'
// ⚠️ MOCK 数据开关 - 开发环境使用 mock 数据,生产环境使用真实 API
const USE_MOCK_DATA = process.env.NODE_ENV === 'development'
const searchValue = ref('')
const activeTabId = ref('all') // 默认选中"全部"
const listVisible = ref(true)
const listRenderKey = ref(0)
/**
* 防抖搜索函数
* @description 使用防抖优化搜索性能,避免频繁请求接口
*/
const debouncedSearch = debounce(async () => {
console.log('[Material List] 防抖搜索触发')
await onSearch()
}, 500)
/**
* 加载状态
*/
const loading = ref(false)
/**
* 加载更多状态
* @description 区分首次加载和加载更多
*/
const loadingMore = ref(false)
/**
* 每页数量
*/
const pageSize = 10
/**
* 当前页码(从0开始)
*/
const currentPage = ref(0)
/**
* 是否有更多数据
*/
const hasMore = ref(true)
/**
* 各分类的分页状态缓存
* @description Map<categoryId, { currentPage, hasMore }>
*/
const categoryPageCache = ref(new Map())
/**
* API 返回的原始数据
*/
const data = ref(null)
/**
* 初始分类ID(从页面参数获取)
*/
const initialCategoryId = ref(null)
/**
* 是否有分类标签
* @description 根据后端返回的 children 判断
*/
const hasCategories = computed(() => {
return data.value?.children?.length > 0
})
/**
* 页面标题
*/
const pageTitle = ref('资料列表')
/**
* 资料数据源(从 API 获取)
*/
const allList = ref([])
/**
* 各个分类的缓存列表数据
* @description key: 分类ID,value: 该分类的列表数据
*/
const categoryListCache = ref(new Map()) // 使用 Map 缓存各分类的列表数据
/**
* 当前显示的列表数据
* @description 根据当前选中的 tab 和搜索关键词动态计算
*/
const currentList = ref([])
/**
* 资料分类数据
* @description 根据 API 返回的 children 构建 tabs,始终包含"全部"选项
*/
const tabsData = computed(() => {
// 始终包含"全部" tab
const tabs = [
{ id: 'all', name: '全部' }
]
// 如果有子分类,添加到 tabs
const children = data.value?.children || []
if (children.length > 0) {
children.forEach(child => {
tabs.push({
id: String(child.id),
name: child.category_name
})
})
}
return tabs
})
/**
* 转换文档数据格式
* @description 将 API 返回的文档数据转换为组件需要的格式
* @param {Object} doc - API 返回的文档对象
* @returns {Object} 转换后的文档对象
*/
const transformDocItem = (doc) => {
// 处理文件名为空的情况
const fileName = doc.name || '未命名文件'
// 如果没有扩展名,从文件名中提取(如果有)
const extension = doc.extension || fileName.split('.').pop()?.toLowerCase() || ''
return {
id: doc.id || doc.meta_id, // 兼容 id 和 meta_id
meta_id: doc.meta_id || doc.id, // 保存 meta_id 用于收藏 API
title: fileName,
desc: doc.post_date || '',
size: doc.size || '',
fileName: fileName,
downloadUrl: doc.value,
extension: extension,
collected: doc.is_favorite === '1' || doc.is_favorite === 1 // 从 API 返回的收藏状态
}
}
/**
* 获取文档分类列表
* @param {Object} params - 请求参数
* @param {string} params.cid - 分类ID(可选)
* @param {string} params.child_id - 子分类ID(可选)
* @param {string} params.keyword - 搜索关键词(可选)
* @param {number} params.page - 页码(从0开始)
* @param {number} params.limit - 每页数量
* @param {boolean} isLoadMore - 是否为加载更多
*/
const fetchMaterialList = async (params = {}, isLoadMore = false) => {
try {
// 如果是加载更多,使用 loadingMore 状态,否则使用 loading 状态
if (isLoadMore) {
loadingMore.value = true
} else {
loading.value = true
}
console.log('[Material List] 请求参数:', params)
console.log('[Material List] 使用 Mock 数据:', USE_MOCK_DATA)
// 根据开关选择使用真实 API 或 Mock 数据
const res = USE_MOCK_DATA
? await mockFileListAPI(params)
: await fileListAPI(params)
if (res.code === 1 && res.data) {
// 如果是初始请求(没有 child_id),保存完整的分类信息
if (!params.child_id && !params.keyword) {
data.value = res.data
// console.log('[Material List] 数据:', res.data)
// console.log('[Material List] 分类数量:', res.data.children?.length)
// console.log('[Material List] 文档数量:', res.data.list?.length)
// 处理并缓存"全部"列表
if (res.data.list?.length) {
const allListData = res.data.list.map(transformDocItem)
if (isLoadMore) {
// 加载更多:追加数据
allList.value = [...allList.value, ...allListData]
categoryListCache.value.set('all', allList.value)
// ✅ 同步更新 currentList(如果当前显示的是"全部")
if (activeTabId.value === 'all') {
currentList.value = allList.value
}
} else {
// 首次加载:替换数据
allList.value = allListData
categoryListCache.value.set('all', allListData)
// ✅ 同步更新 currentList(如果当前显示的是"全部")
if (activeTabId.value === 'all') {
currentList.value = allListData
}
}
// 判断是否还有更多数据
hasMore.value = allListData.length >= params.limit
} else {
if (isLoadMore) {
hasMore.value = false
} else {
allList.value = []
}
}
} else {
// 是子分类请求或搜索请求
const cacheKey = params.child_id || params.keyword || 'search'
if (res.data.list?.length) {
const listData = res.data.list.map(transformDocItem)
if (isLoadMore) {
// 加载更多:追加数据
const existingData = categoryListCache.value.get(cacheKey) || []
const newData = [...existingData, ...listData]
categoryListCache.value.set(cacheKey, newData)
// ✅ 同步更新 currentList(如果当前显示的是该缓存)
const currentCacheKey = params.child_id || params.keyword || 'all'
if (activeTabId.value === currentCacheKey) {
currentList.value = newData
}
} else {
// 首次加载:替换数据
categoryListCache.value.set(cacheKey, listData)
currentList.value = listData
}
// 判断是否还有更多数据
hasMore.value = listData.length >= params.limit
} else {
if (isLoadMore) {
hasMore.value = false
} else {
currentList.value = []
}
}
}
} else {
Taro.showToast({
title: res.msg || '获取资料列表失败',
icon: 'none',
duration: 2000
})
}
} catch (error) {
console.error('[Material List] 获取资料列表失败:', error)
Taro.showToast({
title: '加载失败',
icon: 'error',
duration: 2000
})
} finally {
if (isLoadMore) {
loadingMore.value = false
} else {
loading.value = false
}
}
}
/**
* 页面加载时接收参数
*/
useLoad(async (options) => {
console.log('[Material List] 页面参数:', options)
// 保存初始分类ID
if (options.id) {
initialCategoryId.value = options.id
}
// 设置页面标题
if (options.title) {
pageTitle.value = options.title
}
// 重置分页状态
currentPage.value = 0
hasMore.value = true
// 获取资料列表(初始请求)
await fetchMaterialList({
cid: options.id,
page: 0,
limit: pageSize
})
// 初始化当前列表为"全部"列表(等待请求完成后)
currentList.value = allList.value
})
/**
* Tab 点击处理
* @param {string} id - Tab ID
*/
const onTabClick = async (id) => {
activeTabId.value = id
listVisible.value = false
// 恢复或初始化该分类的分页状态
const pageState = categoryPageCache.value.get(id)
if (pageState) {
currentPage.value = pageState.currentPage
hasMore.value = pageState.hasMore
} else {
currentPage.value = 0
hasMore.value = true
}
// 判断是否是"全部" tab
if (id === 'all') {
// 显示"全部"列表(从缓存或 allList)
const cachedList = categoryListCache.value.get('all')
currentList.value = cachedList || allList.value || []
} else {
// 检查缓存中是否有该分类的数据
if (categoryListCache.value.has(id)) {
// 从缓存中获取
currentList.value = categoryListCache.value.get(id)
} else {
// 调用接口获取该分类的列表(第一页)
await fetchMaterialList({
cid: initialCategoryId.value,
child_id: id,
page: 0,
limit: pageSize
})
// 保存分页状态
categoryPageCache.value.set(id, {
currentPage: 0,
hasMore: hasMore.value
})
}
}
nextTick(() => {
listRenderKey.value += 1
listVisible.value = true
})
}
/**
* 触底加载更多
* @description 使用防抖避免频繁触发
*/
let loadMoreTimer = null
useReachBottom(() => {
// 如果正在加载或没有更多数据,不执行
if (loadingMore.value || loading.value || !hasMore.value) {
return
}
// 防抖:300ms 内只触发一次
if (loadMoreTimer) {
clearTimeout(loadMoreTimer)
}
loadMoreTimer = setTimeout(async () => {
console.log('[Material List] 触底加载更多')
// 页码 +1
currentPage.value += 1
// 构建请求参数
const params = {
cid: initialCategoryId.value,
page: currentPage.value,
limit: pageSize
}
// 判断当前状态:搜索、子分类、或全部
const isSearching = searchValue.value.trim() !== ''
if (isSearching) {
// 搜索模式
params.keyword = searchValue.value.trim()
if (activeTabId.value !== 'all') {
params.child_id = activeTabId.value
}
} else {
// 非搜索模式:如果当前选中的是子分类,添加 child_id 参数
if (activeTabId.value !== 'all') {
params.child_id = activeTabId.value
}
}
// 加载下一页数据
await fetchMaterialList(params, true) // true 表示加载更多
// 保存更新后的分页状态
let cacheKey
if (isSearching) {
cacheKey = params.keyword
} else {
cacheKey = activeTabId.value !== 'all' ? activeTabId.value : 'all'
}
categoryPageCache.value.set(cacheKey, {
currentPage: currentPage.value,
hasMore: hasMore.value
})
}, 300)
})
/**
* 搜索处理函数
* @description 根据 child_id 和 keyword 调用接口查询列表
*/
const onSearch = async () => {
console.log('Searching for:', searchValue.value)
console.log('当前分类:', activeTabId.value)
// 如果没有搜索关键词,清空搜索并恢复当前分类的列表
if (!searchValue.value.trim()) {
// 恢复当前分类的列表
if (activeTabId.value === 'all') {
const cachedList = categoryListCache.value.get('all')
currentList.value = cachedList || allList.value || []
} else {
const cachedList = categoryListCache.value.get(activeTabId.value)
if (cachedList) {
currentList.value = cachedList
} else {
// 如果缓存中没有,调用接口获取
await fetchMaterialList({
cid: initialCategoryId.value,
child_id: activeTabId.value,
page: 0,
limit: pageSize
})
}
}
// 恢复分页状态
const pageState = categoryPageCache.value.get(activeTabId.value)
if (pageState) {
currentPage.value = pageState.currentPage
hasMore.value = pageState.hasMore
}
return
}
// 构建请求参数
const params = {
cid: initialCategoryId.value,
page: 0,
limit: pageSize
}
// 如果当前选中的是子分类,添加 child_id 参数
if (activeTabId.value !== 'all') {
params.child_id = activeTabId.value
}
// 添加搜索关键词
params.keyword = searchValue.value.trim()
// 重置分页状态
currentPage.value = 0
hasMore.value = true
// 调用接口搜索
try {
loading.value = true
console.log('[Material List] 搜索使用 Mock 数据:', USE_MOCK_DATA)
// 根据开关选择使用真实 API 或 Mock 数据
const res = USE_MOCK_DATA
? await mockFileListAPI(params)
: await fileListAPI(params)
if (res.code === 1 && res.data) {
if (res.data.list?.length) {
const listData = res.data.list.map(transformDocItem)
currentList.value = listData
// 缓存搜索结果
categoryListCache.value.set(params.keyword, listData)
// 判断是否还有更多数据
hasMore.value = listData.length >= pageSize
} else {
currentList.value = []
hasMore.value = false
}
} else {
Taro.showToast({
title: res.msg || '搜索失败',
icon: 'none',
duration: 2000
})
}
} catch (error) {
console.error('[Material List] 搜索失败:', error)
Taro.showToast({
title: '搜索失败',
icon: 'error',
duration: 2000
})
} finally {
loading.value = false
}
}
/**
* 监听搜索关键字变化
* @description 实现实时搜索:用户输入时自动触发搜索(带防抖)
*/
watch(searchValue, (newValue, oldValue) => {
console.log('[Material List] searchValue 变化:', oldValue, '->', newValue)
// 如果搜索关键字为空,立即清除搜索(不需要防抖)
if (!newValue?.trim()) {
console.log('[Material List] 搜索关键字为空,立即清除')
onClear()
return
}
// 有搜索关键字,使用防抖搜索
console.log('[Material List] 触发防抖搜索')
debouncedSearch()
})
/**
* 清除搜索关键词
* @description 用户点击搜索框右侧的删除按钮时触发,重新请求当前分类的最新数据
*
* 场景说明:
* - 有tab:重新请求当前tab的数据(不带keyword)
* - 无tab:重新请求"全部"数据(不带keyword)
*/
const onClear = async () => {
console.log('[Material List] 清除搜索,重新请求数据')
console.log('[Material List] 当前分类:', activeTabId.value)
// 构建请求参数(不带 keyword)
const params = {
cid: initialCategoryId.value,
page: 0,
limit: pageSize
}
// 如果当前选中的是子分类,添加 child_id 参数
if (activeTabId.value !== 'all') {
params.child_id = activeTabId.value
}
// 重置分页状态为第一页
currentPage.value = 0
hasMore.value = true
// 重新请求接口(不带 keyword,获取最新数据)
await fetchMaterialList(params, false)
// 更新当前显示的列表
if (activeTabId.value === 'all') {
// 全部列表:使用 allList
currentList.value = allList.value
} else {
// 子分类列表:已经在 fetchMaterialList 中更新了 currentList
}
}
/**
* 使用文件列表点击处理器
* @description 添加图片预览功能,点击图片文件时使用 Taro.previewImage
*/
const { handleClick: onView } = useListItemClick({
listType: ListType.FILE,
onBeforeClick: async (item) => {
/**
* 检查文件类型并使用对应的预览方式
* - 图片文件:使用 Taro.previewImage 预览
* - 其他文件:继续默认的文件打开流程
*/
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']
const extension = item.extension?.toLowerCase() || ''
console.log('[Material List] 文件类型:', extension, '文件名:', item.title)
if (imageExtensions.includes(extension)) {
// 图片文件:使用 Taro 预览
console.log('[Material List] 检测到图片文件,使用图片预览')
// 构建图片列表(当前图片)
const urls = [item.downloadUrl]
try {
// 短暂延迟后打开预览(让用户看到提示)
await new Promise(resolve => setTimeout(resolve, 300))
await Taro.previewImage({
current: item.downloadUrl, // 当前显示图片的 http 链接
urls: urls // 需要预览的图片 http 链接列表
})
// 预览成功,阻止默认的文件打开行为
return false
} catch (err) {
console.error('[Material List] 图片预览失败:', err)
Taro.showToast({
title: '图片预览失败',
icon: 'none',
duration: 2000
})
// 预览失败,返回 true 继续默认行为
return true
}
}
// 非图片文件:继续默认的文件打开流程
console.log('[Material List] 非图片文件,使用默认打开方式')
return true
},
onAfterClick: (item) => {
console.log('用户打开了资料:', item.title)
}
})
/**
* 切换收藏状态
* @description 使用 useCollectOperation composable 处理收藏操作
*/
const { toggleCollect } = useCollectOperation()
/**
* 删除资料
*/
const onDelete = (item) => {
Taro.showModal({
title: '提示',
content: '确定要删除该资料吗?',
success: (res) => {
if (res.confirm) {
// 从 allList 中删除
const index = allList.value.findIndex(i => i.id === item.id)
if (index !== -1) {
allList.value.splice(index, 1)
// 重新渲染列表
listRenderKey.value += 1
Taro.showToast({ title: '已删除', icon: 'success' })
}
}
}
})
}
</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;
}
// FilterTabs 风格的标签栏
.filter-tabs-wrapper {
display: flex;
overflow-x: auto;
padding: 24rpx 32rpx;
gap: 24rpx;
transition: all 0.3s ease;
background-color: #F9FAFB;
width: 100%;
// 隐藏滚动条
&::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
-ms-overflow-style: none;
scrollbar-width: none;
}
.filter-tab-item {
display: flex;
align-items: center;
justify-content: center;
padding: 0 32rpx;
border-radius: 9999rpx;
white-space: nowrap;
transition: all 0.3s ease;
flex-shrink: 0;
}
.filter-tab-active {
background-color: #2563EB; // 蓝色背景
color: #fff;
}
.filter-tab-inactive {
background-color: #F3F4F6; // 灰色背景
color: #6B7280;
}
.filter-tab-text {
font-size: 28rpx;
font-weight: 500;
}
// 覆盖 NutUI Tabs 默认样式,隐藏原有的头部和内容(因为我们使用自定义头部和外部列表)
:deep(.nut-tabs__titles) {
display: none;
}
:deep(.nut-tabs__content) {
display: 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: 32rpx;
height: 32rpx;
border: 4rpx solid #E5E7EB;
border-top-color: #2563EB;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
<template>
<view class="min-h-screen bg-[#F9FAFB] pb-safe">
<NavHeader title="我的消息" />
<!-- 列表区域 -->
<view class="p-4">
<template v-if="messageList.length > 0">
<view
v-for="item in messageList"
:key="item.id"
class="bg-white rounded-xl p-4 mb-3 shadow-sm active:opacity-70 transition-opacity"
@tap="handleItemClick(item)"
>
<view class="flex justify-between items-start mb-2">
<view class="flex-1 mr-2">
<view class="text-base font-bold text-gray-900 line-clamp-1">
{{ item.title }}
</view>
</view>
<text class="text-xs text-gray-400 shrink-0 mt-1">
{{ item.create_time }}
</text>
</view>
<view class="text-sm text-gray-600 line-clamp-2 leading-relaxed">
{{ item.intro || item.content || '暂无简介' }}
</view>
</view>
<!-- 加载更多/没有更多 -->
<view class="py-4 text-center text-[24rpx] text-gray-400">
<text v-if="loading">加载中...</text>
<text v-else-if="!hasMore">没有更多了</text>
<text v-else>上拉加载更多</text>
</view>
</template>
<!-- 空状态 -->
<nut-empty
v-else-if="!loading && messageList.length === 0"
description="暂无消息"
image="empty"
/>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { useLoad, usePullDownRefresh, useReachBottom, stopPullDownRefresh } from '@tarojs/taro'
import { useGo } from '@/hooks/useGo'
import NavHeader from '@/components/NavHeader.vue'
import { myListAPI } from '@/api/news'
import { mockMessageListAPI } from '@/utils/mockData'
// ⚠️ MOCK 数据开关 - 开发环境使用 mock 数据,生产环境使用真实 API
const USE_MOCK_DATA = process.env.NODE_ENV === 'development'
const go = useGo()
const messageList = ref([])
const page = ref(1)
const limit = ref(10)
const hasMore = ref(true)
const loading = ref(false)
/**
* @description 加载消息列表
* @param {boolean} refresh 是否刷新
*/
const fetchMessageList = async (refresh = false) => {
if (loading.value) return
if (refresh) {
page.value = 1
hasMore.value = true
} else if (!hasMore.value) {
return
}
loading.value = true
try {
console.log('[Message] 使用 Mock 数据:', USE_MOCK_DATA)
// 根据开关选择使用真实 API 或 Mock 数据
const res = USE_MOCK_DATA
? await mockMessageListAPI({
page: page.value,
limit: limit.value
})
: await myListAPI({
page: page.value,
limit: limit.value
})
if (res.code === 1) {
const list = res.data?.list || []
if (refresh) {
messageList.value = list
} else {
messageList.value = [...messageList.value, ...list]
}
if (list.length < limit.value) {
hasMore.value = false
} else {
page.value++
}
}
} catch (err) {
console.error('获取消息列表失败:', err)
} finally {
loading.value = false
if (refresh) {
stopPullDownRefresh()
}
}
}
/**
* @description 跳转到详情页
* @param {Object} item 消息对象
*/
const handleItemClick = (item) => {
go('/pages/message-detail/index', { id: item.id })
}
// 页面加载
useLoad(() => {
fetchMessageList(true)
})
// 下拉刷新
usePullDownRefresh(() => {
fetchMessageList(true)
})
// 上拉加载更多
useReachBottom(() => {
fetchMessageList()
})
</script>
<style lang="less">
/* Scoped styles if needed */
</style>
<!--
* @Date: 2026-01-31
* @Description: 产品中心 - API 接口集成版本(含搜索功能)
-->
<template>
<view class="bg-[#F9FAFB]">
<!-- 固定在顶部的导航和搜索 -->
<view class="bg-[#F9FAFB] sticky top-0 z-10">
<NavHeader title="产品中心" />
<!-- Search Bar -->
<view class="px-[24rpx] py-[16rpx] bg-white">
<SearchBar
v-model="searchValue"
placeholder="搜索产品名称..."
variant="rounded"
:show-clear="true"
@search="onSearch"
@input="onSearchInput"
@clear="onClear"
/>
</view>
<!-- Tabs Container -->
<view class="bg-white mt-[2rpx]">
<nut-tabs v-model="activeTabId">
<!-- 自定义标签栏 -->
<template #titles>
<view class="filter-tabs-wrapper">
<view
v-for="item in tabsData"
:key="item.id"
:class="[
'filter-tab-item',
activeTabId === item.id ? 'filter-tab-active' : 'filter-tab-inactive'
]"
@tap="onTabClick(item.id)"
>
<text class="filter-tab-text">{{ item.name }}</text>
</view>
</view>
</template>
</nut-tabs>
</view>
</view>
<!-- 列表容器 - 页面级滚动 -->
<view class="pb-[calc(160rpx+env(safe-area-inset-bottom))]">
<!-- 加载状态 -->
<view v-if="loading && products.length === 0" class="flex justify-center items-center py-[100rpx]">
<text class="text-gray-400 text-[28rpx]">加载中...</text>
</view>
<!-- Product List -->
<view v-else class="px-[40rpx]">
<!-- Card Item -->
<view v-for="(item, index) in products" :key="item.id"
class="bg-white rounded-[24rpx] overflow-hidden mb-[24rpx] shadow-sm active:scale-[0.98] transition-transform duration-200"
:style="{ animationDelay: `${index * 50}ms` }"
>
<!-- Product Content (Horizontal Layout) -->
<view class="flex gap-[24rpx]" @tap="handleProductClick(item)">
<!-- Image Container -->
<view class="relative w-[220rpx] h-[220rpx] flex-shrink-0 product-card-item">
<image :src="item.cover_image" class="w-full h-full object-cover bg-gray-100" mode="aspectFill" />
<!-- Tag -->
<view v-if="item.recommend === 'hot'"
class="absolute top-[12rpx] right-[12rpx] bg-red-500 text-white text-[20rpx] px-[12rpx] py-[4rpx] rounded-full">
热卖
</view>
</view>
<!-- Content -->
<view class="flex-1 flex flex-col py-[20rpx] pr-[20rpx]">
<!-- Title -->
<view class="text-[#1F2937] text-[32rpx] font-medium leading-[1.4] line-clamp-2 mb-[12rpx]">
{{ item.product_name }}
</view>
<!-- 动态标签 -->
<view v-if="item.tags && item.tags.length > 0" class="flex flex-wrap gap-[8rpx] mt-[8rpx] mb-[8rpx]">
<view
v-for="tag in item.tags"
:key="tag.id"
class="text-[20rpx] px-[12rpx] py-[4rpx] rounded-full"
:style="{
backgroundColor: tag.bg_color,
color: tag.text_color
}"
>
{{ tag.name }}
</view>
</view>
<!-- 按钮组 - 靠右对齐(纯文字按钮) -->
<view class="flex gap-[16rpx] ml-auto items-center mt-auto" @tap.stop>
<!-- 详情按钮 -->
<view
class="text-[24rpx] text-[#2563EB] bg-blue-50 px-[24rpx] py-[8rpx] rounded-full active:bg-blue-100 transition-colors duration-200"
@tap.stop="handleProductClick(item)"
>
详情
</view>
<!-- 计划书按钮 -->
<view
class="text-[24rpx] text-white bg-blue-500 px-[24rpx] py-[8rpx] rounded-full active:bg-blue-600 transition-colors duration-200"
@tap.stop="openPlanPopup(item)"
>
计划书
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 加载更多状态 -->
<view v-if="loading && products.length > 0" class="flex justify-center items-center py-[40rpx]">
<text class="text-gray-400 text-[28rpx]">加载中...</text>
</view>
<!-- 没有更多数据 -->
<view v-if="!hasMore && products.length > 0" class="flex justify-center items-center py-[40rpx]">
<text class="text-gray-400 text-[24rpx]">没有更多了</text>
</view>
<!-- 空状态 -->
<view v-if="!loading && products.length === 0">
<nut-empty description="暂无相关产品" image="empty" />
</view>
</view>
<!-- 计划书表单容器 -->
<!-- 测试数据:后端接口和字段还没有准备好,使用 PlanFormContainer 进行的前端测试 -->
<!-- 使用 v-if 条件渲染,避免 selectedProduct 为 null 时的 prop 类型检查错误 -->
<view v-if="showPlanPopup && selectedProduct">
<PlanFormContainer
v-model:visible="showPlanPopup"
:product="selectedProduct"
@close="showPlanPopup = false"
@submit="handlePlanSubmit"
/>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import Taro, { useLoad, useReachBottom } from '@tarojs/taro'
import NavHeader from '@/components/NavHeader.vue'
import SearchBar from '@/components/SearchBar.vue'
import PlanFormContainer from '@/components/PlanFormContainer.vue'
import { useListItemClick, ListType } from '@/composables/useListItemClick'
import { listAPI } from '@/api/get_product'
import { mockProductListAPI } from '@/utils/mockData'
// ⚠️ MOCK 数据开关 - 开发环境使用 mock 数据,生产环境使用真实 API
const USE_MOCK_DATA = process.env.NODE_ENV === 'development'
const activeTabId = ref('')
// 搜索状态
const searchValue = ref('')
// 搜索防抖定时器
let searchTimer = null
// 分页状态
const page = ref(0)
const limit = ref(10)
const loading = ref(false)
const hasMore = ref(true)
// 数据状态
const categories = ref([]) // 从接口获取的分类列表
const products = ref([]) // 当前产品列表
const total = ref(0) // 产品总数
// 计划书弹窗状态
const showPlanPopup = ref(false)
const selectedProduct = ref(null)
/**
* 标签栏数据(根据接口返回的 categories 生成)
* @description 包含"全部"选项和接口返回的分类
*/
const tabsData = computed(() => {
const allTab = { id: '', name: '全部' }
const categoryTabs = categories.value.map(cat => ({
id: String(cat.id),
name: cat.name
}))
return [allTab, ...categoryTabs]
})
/**
* 获取产品列表
* @description 根据 activeTabId 和 searchValue 获取对应分类的产品列表
*/
const fetchProducts = async (isLoadMore = false) => {
if (loading.value) return
loading.value = true
try {
const params = {
page: String(page.value),
limit: String(limit.value)
}
// 如果不是"全部"标签,添加分类 ID 参数
if (activeTabId.value !== '') {
params.cid = activeTabId.value
}
// 添加搜索关键词参数
if (searchValue.value) {
params.keyword = searchValue.value
}
console.log('[Product Center] 使用 Mock 数据:', USE_MOCK_DATA)
// 根据开关选择使用真实 API 或 Mock 数据
const res = USE_MOCK_DATA
? await mockProductListAPI(params)
: await listAPI(params)
if (res.code === 1 && res.data) {
// 更新分类列表(首次加载时)
if (!isLoadMore && res.data.categories) {
categories.value = res.data.categories
}
// 处理产品列表
if (isLoadMore) {
// 加载更多:追加数据
products.value = [...products.value, ...res.data.list]
} else {
// 首次加载或切换分类:替换数据
products.value = res.data.list || []
}
// 更新总数和分页状态
total.value = res.data.total || 0
hasMore.value = products.value.length < total.value
} else {
Taro.showToast({
title: res.msg || '获取产品列表失败',
icon: 'none'
})
}
} catch (err) {
console.error('获取产品列表失败:', err)
Taro.showToast({
title: '网络错误,请重试',
icon: 'none'
})
} finally {
loading.value = false
}
}
/**
* Tab 点击处理
* @description 切换分类,重置分页并重新加载数据
*/
const onTabClick = (id) => {
if (activeTabId.value === id) return
activeTabId.value = id
// 重置分页状态
page.value = 0
products.value = []
hasMore.value = true
// 重新加载数据(保持搜索状态)
fetchProducts(false)
}
/**
* 搜索输入处理(带防抖)
* @description 用户输入时实时搜索,使用防抖优化性能
* @param {string} value - 搜索关键词
*/
const onSearchInput = (value) => {
console.log('搜索输入:', value)
// 清除之前的定时器
if (searchTimer) {
clearTimeout(searchTimer)
}
// 设置新的定时器(500ms 后执行搜索)
searchTimer = setTimeout(() => {
// 重置分页状态
page.value = 0
products.value = []
hasMore.value = true
// 重新加载数据
fetchProducts(false)
}, 500)
}
/**
* 搜索处理(回车键)
* @description 用户按下回车或点击搜索按钮时触发
* @param {string} value - 搜索关键词
*/
const onSearch = (value) => {
console.log('搜索产品:', value)
// 清除防抖定时器
if (searchTimer) {
clearTimeout(searchTimer)
searchTimer = null
}
// 重置分页状态
page.value = 0
products.value = []
hasMore.value = true
// 重新加载数据
fetchProducts(false)
}
/**
* 清空搜索
* @description 用户点击清除按钮时触发
*/
const onClear = () => {
console.log('清空搜索')
// 清除防抖定时器
if (searchTimer) {
clearTimeout(searchTimer)
searchTimer = null
}
// 重置分页状态
page.value = 0
products.value = []
hasMore.value = true
// 重新加载数据
fetchProducts(false)
}
/**
* 使用产品列表点击处理器
* @description 配置为产品类型列表,点击时跳转到产品详情页
*/
const { handleClick: handleProductClick } = useListItemClick({
listType: ListType.PRODUCT,
onAfterClick: (item) => {
console.log('用户查看了产品:', item.product_name)
}
})
/**
* 打开计划书弹窗
* @description 根据产品对象打开计划书表单
* @param {Object} product - 产品对象
*/
const openPlanPopup = (product) => {
selectedProduct.value = product
showPlanPopup.value = true
}
/**
* 处理计划书提交
* @description 测试环境:前端不调用后端API,直接跳转到结果页
* 生产环境:需要调用 submitPlanAPI 提交表单数据
* @param {Object} formData - 表单数据
*/
const handlePlanSubmit = (formData) => {
console.log('计划书提交:', {
product_id: selectedProduct.value.id,
product_name: selectedProduct.value.product_name,
form_sn: selectedProduct.value.form_sn,
form_data: formData
})
// 关闭弹窗
showPlanPopup.value = false
// TODO: 后端接口还没有准备好,暂时不调用API
// 测试完成后需要对接 submitPlanAPI
// const res = await submitPlanAPI({
// product_id: selectedProduct.value.id,
// template: selectedProduct.value.form_sn,
// form_data: formData
// })
// 模拟提交成功,跳转到结果页面
Taro.navigateTo({
url: '/pages/plan-submit-result/index?success=true'
})
}
/**
* 页面加载时获取数据
*/
useLoad(() => {
fetchProducts(false)
})
/**
* 触底加载更多
* @description 使用 Taro 的 useReachBottom hook 监听页面滚动到底部
*/
useReachBottom(() => {
console.log('滚动到底部,加载更多')
if (!hasMore.value || loading.value) {
console.log('没有更多数据或正在加载中,跳过')
return
}
page.value += 1
fetchProducts(true)
})
</script>
<style lang="less">
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.product-card-item {
animation: slideIn 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) backwards;
}
// FilterTabs 风格的标签栏
.filter-tabs-wrapper {
display: flex;
overflow-x: auto;
padding: 24rpx 40rpx;
gap: 24rpx;
transition: all 0.3s ease;
background-color: #F9FAFB;
width: 100%;
// 隐藏滚动条
&::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
-ms-overflow-style: none;
scrollbar-width: none;
}
.filter-tab-item {
display: flex;
align-items: center;
justify-content: center;
padding: 0 32rpx;
border-radius: 9999rpx;
white-space: nowrap;
transition: all 0.3s ease;
flex-shrink: 0;
}
.filter-tab-active {
background-color: #2563EB; // 蓝色背景
color: #fff;
}
.filter-tab-inactive {
background-color: #F3F4F6; // 灰色背景
color: #6B7280;
}
.filter-tab-text {
font-size: 28rpx;
font-weight: 500;
}
// 覆盖 NutUI Tabs 默认样式,隐藏原有的头部和内容(因为我们使用自定义头部和外部列表)
:deep(.nut-tabs__titles) {
display: none;
}
:deep(.nut-tabs__content) {
display: none;
}
/* 多行文本省略 */
.line-clamp-2 {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
word-break: break-all;
}
</style>
<!--
* @Date: 2026-02-06
* @Description: 搜索页面 - 支持产品和资料搜索,实时查询API
-->
<template>
<view class="bg-[#FFF]">
<!-- 固定顶部:导航栏 + 搜索栏 + Tabs + 结果计数 -->
<view class="bg-[#FFF] sticky top-0 z-10">
<NavHeader title="搜索" />
<!-- Search Input -->
<view class="px-[40rpx] mt-[32rpx]">
<SearchBar
v-model="searchKeyword"
placeholder="搜索培训资料、案例、产品..."
variant="rounded"
:show-border="true"
:show-clear="true"
@search="handleSearch"
@clear="clearSearch"
/>
</view>
<!-- Tabs Container -->
<nut-tabs v-model="activeTab">
<!-- 自定义标签栏 -->
<template #titles>
<view class="filter-tabs-wrapper">
<view
v-for="item in tabsData"
:key="item.id"
:class="[
'filter-tab-item',
activeTab === item.id ? 'filter-tab-active' : 'filter-tab-inactive',
!activeTab ? 'filter-tab-inactive' : '' // 初始状态不高亮任何tab
]"
@tap="onTabClick(item.id)"
>
<text class="filter-tab-text">{{ item.name }}</text>
</view>
</view>
</template>
</nut-tabs>
<!-- Result Count -->
<view v-if="currentList.length > 0" class="px-[60rpx] text-[#6B7280] text-[24rpx] pb-[24rpx]">
找到 {{ currentTotal }} 个相关结果
</view>
</view>
<!-- 列表容器 -->
<view class="px-[40rpx] pb-[calc(160rpx+env(safe-area-inset-bottom))]">
<!-- Search Results -->
<view
v-if="currentList.length > 0"
:key="listRenderKey"
>
<!-- Product Results -->
<view v-if="activeTab === 'product'" class="flex flex-col gap-[24rpx] pb-[40rpx]">
<ProductCard
v-for="(item, index) in currentList"
:key="index"
:product-id="item.id"
:product-name="item.product_name || item.name"
:tags="item.tags || []"
class="search-result-item"
:style="{ animationDelay: `${index * 30}ms` }"
@detail="goToProductDetail"
@plan="openPlanPopup"
/>
</view>
<!-- File Results -->
<view v-else-if="activeTab === 'file'" class="flex flex-col gap-[24rpx] pb-[40rpx]">
<MaterialCard
v-for="(item, index) in currentList"
:key="index"
:id="item.id"
:title="item.title"
:file-name="item.fileName"
:file-size="item.fileSize"
:learners="item.learners"
:read-people-percent="item.readPeoplePercent"
:collected="item.collected"
:extension="item.extension"
:download-url="item.downloadUrl"
class="search-result-item"
:style="{ animationDelay: `${index * 30}ms` }"
@collect-changed="handleCollectChanged(item, $event)"
/>
</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>
<!-- Empty State (已搜索但无结果) -->
<view v-else-if="hasSearched && currentList.length === 0" class="flex flex-col items-center justify-center py-[40rpx]">
<nut-empty description="暂无搜索结果" image="empty">
<view class="text-[#9CA3AF] text-[24rpx] mt-[12rpx]">试试其他关键词吧</view>
</nut-empty>
</view>
<!-- Initial State (从未搜索过) -->
<view v-else class="flex flex-col items-center justify-center py-[120rpx]">
<IconFont name="search" class="text-gray-300 mb-[24rpx]" size="64" />
<view class="text-[#6B7280] text-[28rpx]">搜索产品或资料</view>
<view class="text-[#9CA3AF] text-[24rpx] mt-[12rpx]">输入关键词开始搜索,自动切换分类</view>
</view>
</view>
<!-- Plan Form Container -->
<!-- 仅当 selectedProduct 不为 null 时才渲染组件,避免 product prop required 警告 -->
<PlanFormContainer
v-if="selectedProduct"
v-model:visible="showPlanPopup"
:product="selectedProduct"
@close="showPlanPopup = false"
@submit="handlePlanSubmit"
/>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import Taro, { useReachBottom } from '@tarojs/taro'
import { useGo } from '@/hooks/useGo'
import NavHeader from '@/components/NavHeader.vue'
import IconFont from '@/components/IconFont.vue'
import SearchBar from '@/components/SearchBar.vue'
import ProductCard from '@/components/ProductCard.vue'
import MaterialCard from '@/components/MaterialCard.vue'
import PlanFormContainer from '@/components/PlanFormContainer.vue'
import { searchAPI } from '@/api/search'
import { mockSearchAPI } from '@/utils/mockData'
// ⚠️ MOCK 数据开关 - 开发环境使用 mock 数据,生产环境使用真实 API
const USE_MOCK_DATA = process.env.NODE_ENV === 'development'
// Navigation
const go = useGo()
// Plan Popup State
const showPlanPopup = ref(false)
const selectedProduct = ref(null)
// State
const searchKeyword = ref('')
const activeTab = ref('') // 当前选中的 tab(初始为空,不选中任何tab)
const hasSearched = ref(false) // 是否已经搜索过
const listRenderKey = ref(0)
// 数据状态
const products = ref([]) // 产品列表
const files = ref([]) // 资料列表
const productsTotal = ref(0) // 产品总数
const filesTotal = ref(0) // 资料总数
const loadingMore = ref(false) // 加载更多状态
const hasMore = ref(true) // 是否还有更多数据
const currentPage = ref(0) // 当前页码(从0开始)
const pageSize = 20 // 每页数量
/**
* Tab 数据源(只保留产品和资料)
*/
const tabsData = ref([
{ id: 'product', name: '产品' },
{ id: 'file', name: '资料' },
])
/**
* 当前显示的列表
*/
const currentList = computed(() => {
// 如果没有选中任何tab,返回空数组
if (!activeTab.value) return []
if (activeTab.value === 'product') {
return products.value
} else {
return files.value
}
})
/**
* 当前列表总数
*/
const currentTotal = computed(() => {
// 如果没有选中任何tab,返回0
if (!activeTab.value) return 0
if (activeTab.value === 'product') {
return productsTotal.value
} else {
return filesTotal.value
}
})
/**
* 执行搜索
* @param {string} keyword - 搜索关键字
* @param {string} type - 可选,'product' | 'file' | undefined
* @param {number} page - 页码(从0开始)
* @param {number} limit - 每页数量
* @param {boolean} isLoadMore - 是否为加载更多
*/
const performSearch = async (keyword, type, page = 0, limit = pageSize, isLoadMore = false) => {
try {
// 如果是加载更多,使用 loadingMore 状态;否则使用 loading 状态
if (isLoadMore) {
loadingMore.value = true
} else {
Taro.showLoading({ title: '搜索中...', mask: true })
}
const params = { keyword, page, limit }
if (type) params.type = type
console.log('[Search] 使用 Mock 数据:', USE_MOCK_DATA)
// 根据开关选择使用真实 API 或 Mock 数据
const res = USE_MOCK_DATA
? await mockSearchAPI(params)
: await searchAPI(params)
if (res.code === 1) {
// 映射产品列表
const newProducts = res.data.products.list || []
// 映射资料列表(进行字段映射,与首页保持一致)
const newFiles = (res.data.files.list || []).map(item => {
// 提取文件扩展名
const fileName = item.name || '未命名文件'
const extension = item.extension || fileName.split('.').pop()?.toLowerCase() || ''
return {
id: item.meta_id || item.id,
title: item.name,
fileName: fileName,
fileSize: item.size || item.file_size,
downloadUrl: item.src || item.value,
extension: extension,
learners: item.read_people_count ? `${item.read_people_count }人学习` : '',
readPeoplePercent: item.read_people_percent,
is_favorite: item.is_favorite, // 保留原始字段
collected: Boolean(item.is_favorite) // 转换为 Boolean 供 MaterialCard 使用
}
})
// 根据是否为加载更多来处理数据
if (isLoadMore) {
// 加载更多:追加数据
products.value = [...products.value, ...newProducts]
files.value = [...files.value, ...newFiles]
} else {
// 首次加载或刷新:替换数据
products.value = newProducts
files.value = newFiles
}
productsTotal.value = res.data.products.total || 0
filesTotal.value = res.data.files.total || 0
// ⚠️ 重要:必须先自动选择 tab,然后再计算 hasMore
// 如果不传 type,自动选择有数据的 tab(仅首次搜索时)
if (!type && !isLoadMore) {
if (productsTotal.value > 0) {
activeTab.value = 'product'
} else if (filesTotal.value > 0) {
activeTab.value = 'file'
}
// 如果都为 0,默认 product
}
// 判断是否还有更多数据
// 使用当前列表长度与总数比较
// 注意:需要根据实际选择的tab来判断
const actualTab = type || activeTab.value
if (actualTab === 'product') {
hasMore.value = products.value.length < productsTotal.value
} else if (actualTab === 'file') {
hasMore.value = files.value.length < filesTotal.value
} else {
// 如果都没有选中,保守设置为false
hasMore.value = false
}
hasSearched.value = true
listRenderKey.value += 1
console.log('[Search] 搜索成功', {
productsTotal: productsTotal.value,
filesTotal: filesTotal.value,
activeTab: activeTab.value,
isLoadMore,
hasMore: hasMore.value
})
} else {
Taro.showToast({
title: res.msg || '搜索失败',
icon: 'none'
})
}
} catch (err) {
console.error('[Search] 搜索失败:', err)
Taro.showToast({
title: '搜索失败,请重试',
icon: 'none'
})
} finally {
if (isLoadMore) {
loadingMore.value = false
} else {
Taro.hideLoading()
}
}
}
/**
* Tab 点击处理(实时查询)
*/
const onTabClick = async (tabId) => {
if (activeTab.value === tabId) return
// 立即切换 tab(响应更快)
activeTab.value = tabId
listRenderKey.value += 1
// 重置分页状态
currentPage.value = 0
hasMore.value = true
// 如果已经搜索过,实时查询对应类型的数据
if (hasSearched.value && searchKeyword.value.trim()) {
console.log('[Search] 切换 tab,实时查询:', tabId)
await performSearch(searchKeyword.value.trim(), tabId, 0, pageSize, false)
}
}
/**
* 提交搜索
*/
const handleSearch = async () => {
const keyword = searchKeyword.value.trim()
if (!keyword) {
Taro.showToast({
title: '请输入搜索关键词',
icon: 'none'
})
return
}
console.log('[Search] 提交搜索:', keyword)
// 重置分页状态
currentPage.value = 0
hasMore.value = true
// 不传 type,让后端返回两种数据,前端自动选择 tab
await performSearch(keyword, undefined, 0, pageSize, false)
}
/**
* 清空搜索
*/
const clearSearch = () => {
console.log('[Search] 清空搜索')
searchKeyword.value = ''
hasSearched.value = false
products.value = []
files.value = []
productsTotal.value = 0
filesTotal.value = 0
activeTab.value = '' // 重置为空,不选中任何tab
currentPage.value = 0
hasMore.value = true
listRenderKey.value += 1
}
/**
* 触底加载更多
* @description 使用防抖避免频繁触发
*/
let loadMoreTimer = null
useReachBottom(() => {
// 如果正在加载更多或没有更多数据,不执行
if (loadingMore.value || !hasMore.value) {
return
}
// 如果没有搜索过或没有选中 tab,不执行
if (!hasSearched.value || !activeTab.value || !searchKeyword.value.trim()) {
return
}
// 防抖:300ms 内只触发一次
if (loadMoreTimer) {
clearTimeout(loadMoreTimer)
}
loadMoreTimer = setTimeout(async () => {
console.log('[Search] 触底加载更多')
// 页码 +1
currentPage.value += 1
// 加载下一页数据
await performSearch(
searchKeyword.value.trim(),
activeTab.value,
currentPage.value,
pageSize,
true // 标记为加载更多
)
}, 300)
})
/**
* 跳转到产品详情页
*
* @description 处理产品详情按钮点击事件
* @param {number} productId - 产品ID
*/
const goToProductDetail = (productId) => {
go('/pages/product-detail/index', { id: productId })
}
/**
* 打开计划书弹窗
*
* @description 根据产品ID找到对应的产品对象,并打开计划书表单
* @param {number} productId - 产品ID
*/
const openPlanPopup = (productId) => {
// 从产品列表中找到对应的产品
const product = products.value.find(p => p.id === productId)
if (!product) {
Taro.showToast({
title: '产品不存在',
icon: 'none',
duration: 2000
})
return
}
// 设置选中的产品
selectedProduct.value = product
showPlanPopup.value = true
}
/**
* 处理计划书提交
*
* @description 测试环境:前端不调用后端API,直接跳转到结果页
* 生产环境:需要调用 submitPlanAPI 提交表单数据
* @param {Object} formData - 表单数据
*/
const handlePlanSubmit = (formData) => {
console.log('计划书提交:', {
product_id: selectedProduct.value.id,
product_name: selectedProduct.value.product_name || selectedProduct.value.name,
form_sn: selectedProduct.value.form_sn,
form_data: formData
})
// 关闭弹窗
showPlanPopup.value = false
// TODO: 后端接口还没有准备好,暂时不调用API
// 测试完成后需要对接 submitPlanAPI
// const res = await submitPlanAPI({
// product_id: selectedProduct.value.id,
// template: selectedProduct.value.form_sn,
// form_data: formData
// });
// 模拟提交成功,跳转到结果页面
go('/pages/plan-submit-result/index', {
success: 'true'
})
}
/**
* 处理收藏状态改变
*
* @description 当用户点击收藏按钮时,更新本地状态
* @param {Object} item - 资料对象
* @param {Object} newStatus - 新的状态
*/
const handleCollectChanged = (item, newStatus) => {
console.log('[Search] 收藏状态改变:', item.title, newStatus.collected)
// 找到对应的项并更新状态
const file = files.value.find(f => f.id === item.id)
if (file) {
file.collected = newStatus.collected
}
}
</script>
<style lang="less">
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.search-result-item {
animation: slideIn 0.3s cubic-bezier(0.2, 0.8, 0.2, 1) backwards;
}
/* 加载动画 */
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading-spinner-small {
width: 32rpx;
height: 32rpx;
border: 3rpx solid #E5E7EB;
border-top-color: #4CAF50;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
// FilterTabs 风格的标签栏
.filter-tabs-wrapper {
display: flex;
overflow-x: auto;
padding: 24rpx 60rpx;
gap: 24rpx;
transition: all 0.3s ease;
background-color: #FFF;
width: 100%;
// 隐藏滚动条
&::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
-ms-overflow-style: none;
scrollbar-width: none;
}
.filter-tab-item {
display: flex;
align-items: center;
justify-content: center;
padding: 0 32rpx;
border-radius: 9999rpx;
white-space: nowrap;
transition: all 0.3s ease;
flex-shrink: 0;
}
.filter-tab-active {
background-color: #2563EB; // 蓝色背景
color: #fff;
}
.filter-tab-inactive {
background-color: #F3F4F6; // 灰色背景
color: #6B7280;
}
.filter-tab-text {
font-size: 28rpx;
font-weight: 500;
}
// 覆盖 NutUI Tabs 默认样式
:deep(.nut-tabs__titles) {
display: none;
}
:deep(.nut-tabs__content) {
display: none;
}
</style>
# LoadMoreList 组件完整指南
> **版本**: 2.0.0
> **更新时间**: 2026-02-08
> **维护者**: Claude Code
---
## 📚 目录
- [组件概述](#组件概述)
- [组件 API](#组件-api)
- [快速开始](#快速开始)
- [实际案例](#实际案例)
- [迁移模式](#迁移模式)
- [最佳实践](#最佳实践)
- [常见问题](#常见问题)
- [性能优化](#性能优化)
- [更新日志](#更新日志)
---
## 组件概述
### 🎯 设计目标
LoadMoreList 是一个**通用分页列表组件**,旨在解决以下问题:
1. **代码重复**:多个页面都实现了相同的分页加载逻辑
2. **维护困难**:修改动效需要同时改多个文件
3. **集成成本高**:新页面需要复制粘贴大量代码
### ✨ 核心特性
-**自动分页加载**:触底自动加载下一页(300ms 防抖)
-**下拉刷新**:可选的下拉刷新功能
-**多种状态**:首次加载、加载中、空状态、没有更多
-**动画效果**:列表项逐个进入动画(前10项延迟)
-**高度可定制**:支持自定义头部、列表项、空状态等
-**性能优化**:只为前10项使用动画延迟,避免累积延迟
### 📊 迁移收益
| 指标 | 迁移前 | 迁移后 | 改善 |
|------|--------|--------|------|
| 重复代码行数 | ~700 行 | 0 行 | -100% |
| 页面平均代码行数 | ~400 行 | ~300 行 | -25% |
| 动效统一性 | ❌ 不一致 | ✅ 完全统一 | ✅ |
| 维护成本 | ❌ 高(5个文件) | ✅ 低(1个文件) | ✅ |
| 新页面集成成本 | ~150 行 | ~10 行 | -93% |
---
## 组件 API
### Props 属性
| Prop | 类型 | 默认值 | 必需 | 说明 |
|------|------|--------|------|------|
| `list` | `Array<any>` | `[]` | ❌ | 列表数据源 |
| `page` | `Number` | - | ✅ | 当前页码(从0或1开始) |
| `pageSize` | `Number` | `10` | ❌ | 每页数量 |
| `hasMore` | `Boolean` | `true` | ❌ | 是否还有更多数据 |
| `loading` | `Boolean` | `false` | ❌ | 首次加载状态 |
| `loadingMore` | `Boolean` | `false` | ❌ | 加载更多状态 |
| `keyField` | `String` | `'id'` | ❌ | 唯一标识字段名 |
| `showHeader` | `Boolean` | `true` | ❌ | 是否显示固定头部区域 |
| `enablePullDownRefresh` | `Boolean` | `false` | ❌ | 是否启用下拉刷新 |
| `noPadding` | `Boolean` | `false` | ❌ | 列表容器是否不需要 padding |
### Events 事件
| Event | 参数 | 说明 |
|-------|------|------|
| `load-more` | `page: number` | 触底加载更多时触发,传入**下一页**页码(page + 1) |
| `refresh` | - | 下拉刷新时触发(需启用 `enablePullDownRefresh`) |
### Slots 插槽
| Slot | 作用域参数 | 说明 |
|------|-----------|------|
| `header` | - | 自定义固定头部区域(导航、搜索、tabs等) |
| `item` | `{ item, index }` | 自定义列表项渲染 |
| `loading` | - | 自定义首次加载状态 |
| `loading-more` | - | 自定义加载更多状态 |
| `empty` | - | 自定义空状态 |
| `no-more` | - | 自定义"没有更多"提示 |
---
## 快速开始
### 最简单的使用方式
```vue
<template>
<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 }">
<view class="product-item">{{ item.name }}</view>
</template>
</LoadMoreList>
</template>
<script setup>
import { ref } from 'vue'
import LoadMoreList from '@/components/LoadMoreList'
const products = ref([])
const page = ref(0)
const hasMore = ref(true)
const loading = ref(false)
const loadingMore = ref(false)
const handleLoadMore = async (nextPage) => {
page.value = nextPage
// 加载数据...
}
</script>
```
### 带头部和空状态
```vue
<template>
<LoadMoreList
:list="products"
:page="page"
:page-size="10"
:has-more="hasMore"
:loading="loading"
:loading-more="loadingMore"
@load-more="handleLoadMore"
>
<template #header>
<NavHeader title="产品中心" />
</template>
<template #item="{ item }">
<ProductCard :product="item" />
</template>
<template #empty>
<nut-empty description="暂无相关产品" image="empty" />
</template>
</LoadMoreList>
</template>
```
### 带下拉刷新
```vue
<template>
<LoadMoreList
:list="messages"
: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="我的消息" />
</template>
<template #item="{ item }">
<view class="message-item">{{ item.title }}</view>
</template>
</LoadMoreList>
</template>
<script setup>
const handleRefresh = async () => {
page.value = 0 // 或 1,根据 API 要求
hasMore.value = true
await fetchData(true) // true 表示刷新
}
</script>
```
---
## 实际案例
### 案例 1: 简单列表(week-hot-material、message)
**场景**: 只需展示列表,支持下拉刷新
**页面**: [week-hot-material](../src/pages/week-hot-material/index.vue)[message](../src/pages/message/index.vue)
```vue
<template>
<LoadMoreList
:list="currentList"
:page="currentPage"
:page-size="pageSize"
:has-more="hasMore"
:loading="loading"
:loading-more="loadingMore"
:enable-pull-down-refresh="true"
key-field="id"
@load-more="handleLoadMore"
@refresh="handleRefresh"
>
<template #header>
<NavHeader title="我的消息" />
</template>
<template #item="{ item }">
<view class="message-item" @tap="handleItemClick(item)">
<view class="title">{{ item.title }}</view>
<view class="intro">{{ item.intro }}</view>
</view>
</template>
</LoadMoreList>
</template>
<script setup>
import { ref } from 'vue'
import { useLoad } from '@tarojs/taro'
import LoadMoreList from '@/components/LoadMoreList'
const currentList = ref([])
const currentPage = ref(1)
const pageSize = 10
const hasMore = ref(true)
const loading = ref(false)
const loadingMore = ref(false)
// 加载数据
const fetchMessageList = async (params = {}, isLoadMore = false) => {
try {
if (isLoadMore) {
loadingMore.value = true
} else {
loading.value = true
}
const res = await myListAPI(params)
if (res.code === 1 && res.data) {
const listData = res.data.list || []
if (isLoadMore) {
currentList.value = [...currentList.value, ...listData]
} else {
currentList.value = listData
}
hasMore.value = listData.length >= pageSize
}
} catch (err) {
console.error('获取消息失败:', err)
} finally {
if (isLoadMore) {
loadingMore.value = false
} else {
loading.value = false
}
}
}
// 加载更多
const handleLoadMore = async (page) => {
currentPage.value = page
await fetchMessageList({ page, limit: pageSize }, true)
}
// 下拉刷新
const handleRefresh = async () => {
currentPage.value = 1
hasMore.value = true
await fetchMessageList({ page: 1, limit: pageSize })
}
// 页面加载
useLoad(async () => {
await fetchMessageList({ page: 1, limit: pageSize })
})
</script>
```
**要点**:
-`enable-pull-down-refresh="true"` 启用下拉刷新
- ✅ 实现 `handleRefresh` 函数,重置页码和 hasMore
- ✅ 使用 `...currentList.value, ...listData` 追加数据
---
### 案例 2: 带搜索和 Tabs 的列表(product-center)
**场景**: 需要搜索功能、分类 tabs、计划书弹窗
**页面**: [product-center](../src/pages/product-center/index.vue)
```vue
<template>
<view class="bg-[#F9FAFB]">
<!-- 计划书弹窗(放在 LoadMoreList 外部) -->
<view v-if="showPlanPopup && selectedProduct">
<PlanFormContainer
v-model:visible="showPlanPopup"
:product="selectedProduct"
@submit="handlePlanSubmit"
/>
</view>
<LoadMoreList
:list="currentList"
:page="currentPage"
:page-size="pageSize"
:has-more="hasMore"
:loading="loading"
:loading-more="loadingMore"
key-field="id"
@load-more="handleLoadMore"
>
<!-- 固定头部:导航 + 搜索 + Tabs -->
<template #header>
<view class="sticky top-0 z-10 bg-[#F9FAFB]">
<NavHeader title="产品中心" />
<!-- Search Bar -->
<view class="px-[24rpx] py-[16rpx] bg-white">
<SearchBar
v-model="searchValue"
placeholder="搜索产品名称..."
@search="onSearch"
@input="onSearchInput"
@clear="onClear"
/>
</view>
<!-- Tabs Container -->
<view class="bg-white mt-[2rpx]">
<nut-tabs v-model="activeTabId">
<!-- 自定义标签栏 -->
<template #titles>
<view class="filter-tabs-wrapper">
<view
v-for="item in tabsData"
:key="item.id"
:class="[
'filter-tab-item',
activeTabId === item.id ? 'filter-tab-active' : 'filter-tab-inactive'
]"
@tap="onTabClick(item.id)"
>
<text class="filter-tab-text">{{ item.name }}</text>
</view>
</view>
</template>
</nut-tabs>
</view>
</view>
</template>
<!-- 列表项:产品卡片 -->
<template #item="{ item }">
<view class="product-card" @tap="handleProductClick(item)">
<!-- 产品内容 -->
</view>
</template>
<!-- 空状态 -->
<template #empty>
<nut-empty description="暂无相关产品" image="empty" />
</template>
</LoadMoreList>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useLoad } from '@tarojs/taro'
import LoadMoreList from '@/components/LoadMoreList'
const currentList = ref([])
const currentPage = ref(0)
const pageSize = 10
const hasMore = ref(true)
const loading = ref(false)
const loadingMore = ref(false)
// 搜索和 Tabs 相关状态
const activeTabId = ref('')
const searchValue = ref('')
const categories = ref([]) // 从接口获取的分类列表
let searchTimer = null // 搜索防抖定时器
// 计划书弹窗状态
const showPlanPopup = ref(false)
const selectedProduct = ref(null)
// 标签栏数据(根据接口返回的 categories 生成)
const tabsData = computed(() => {
const allTab = { id: '', name: '全部' }
const categoryTabs = categories.value.map(cat => ({
id: String(cat.id),
name: cat.name
}))
return [allTab, ...categoryTabs]
})
// 加载产品列表
const fetchProducts = async (params = {}, isLoadMore = false) => {
try {
if (isLoadMore) {
loadingMore.value = true
} else {
loading.value = true
}
const res = await listAPI(params)
if (res.code === 1 && res.data) {
// 更新分类列表(首次加载时)
if (!isLoadMore && res.data.categories) {
categories.value = res.data.categories
}
// 处理产品列表
if (res.data.list?.length) {
const listData = res.data.list
if (isLoadMore) {
currentList.value = [...currentList.value, ...listData]
} else {
currentList.value = listData
}
hasMore.value = listData.length >= params.limit
}
}
} catch (error) {
console.error('获取产品列表失败:', error)
} finally {
if (isLoadMore) {
loadingMore.value = false
} else {
loading.value = false
}
}
}
// 页面加载时获取数据
useLoad(async (options) => {
await fetchProducts({ page: 0, limit: pageSize })
})
// 处理加载更多事件
const handleLoadMore = async (page) => {
currentPage.value = page
// 构建请求参数
const params = {
page: page,
limit: pageSize
}
// 如果不是"全部"标签,添加分类 ID 参数
if (activeTabId.value !== '') {
params.cid = activeTabId.value
}
// 添加搜索关键词参数
if (searchValue.value) {
params.keyword = searchValue.value
}
// 加载下一页数据
await fetchProducts(params, true)
}
// Tab 点击处理
const onTabClick = (id) => {
if (activeTabId.value === id) return
activeTabId.value = id
currentPage.value = 0
hasMore.value = true
// 构建请求参数
const params = {
page: 0,
limit: pageSize
}
if (id !== '') {
params.cid = id
}
if (searchValue.value) {
params.keyword = searchValue.value
}
// 重新加载数据
fetchProducts(params, false)
}
// 搜索输入处理(带防抖)
const onSearchInput = (value) => {
if (searchTimer) {
clearTimeout(searchTimer)
}
// 500ms 后执行搜索
searchTimer = setTimeout(() => {
currentPage.value = 0
hasMore.value = true
const params = {
page: 0,
limit: pageSize
}
if (activeTabId.value !== '') {
params.cid = activeTabId.value
}
if (value) {
params.keyword = value
}
fetchProducts(params, false)
}, 500)
}
// 其他处理函数...
</script>
```
**要点**:
-**固定头部**: 使用 `sticky top-0 z-10` 实现吸顶效果
-**搜索防抖**: 500ms 延迟,避免频繁请求
-**弹窗位置**: PlanFormContainer 放在 LoadMoreList 外部作为兄弟节点
-**参数构建**: 在 `handleLoadMore` 中同时处理分类和搜索状态
---
### 案例 3: 带分类缓存的列表(material-list)
**场景**: 需要缓存每个分类的分页状态,切换分类时保留滚动位置
**页面**: [material-list](../src/pages/material-list/index.vue)
```vue
<template>
<LoadMoreList
:list="currentList"
:page="currentPage"
:page-size="pageSize"
:has-more="hasMore"
:loading="loading"
:loading-more="loadingMore"
key-field="id"
@load-more="handleLoadMore"
>
<!-- 固定头部:导航 + 搜索 + Tabs -->
<template #header>
<view class="sticky top-0 z-10 bg-[#FFF]">
<NavHeader title="资料列表" />
<view class="px-[32rpx] py-[24rpx]">
<SearchBar
v-model="searchValue"
placeholder="搜索资料名称..."
@search="onSearch"
/>
</view>
<!-- Tabs Container -->
<nut-tabs v-model="activeTabId">
<template #titles>
<view class="filter-tabs-wrapper">
<view
v-for="item in tabsData"
:key="item.id"
:class="[
'filter-tab-item',
activeTabId === item.id ? 'filter-tab-active' : 'filter-tab-inactive'
]"
@tap="onTabClick(item.id)"
>
<text class="filter-tab-text">{{ item.name }}</text>
</view>
</view>
</template>
</nut-tabs>
</view>
</template>
<!-- 列表项 -->
<template #item="{ item }">
<MaterialCard
:id="item.id"
:title="item.name"
@collect-changed="handleCollectChanged"
/>
</template>
</LoadMoreList>
</template>
<script setup>
import { ref } from 'vue'
import { useLoad } from '@tarojs/taro'
import LoadMoreList from '@/components/LoadMoreList'
const currentList = ref([])
const currentPage = ref(0)
const pageSize = 20
const hasMore = ref(true)
const loading = ref(false)
const loadingMore = ref(false)
// 分类缓存(使用 Map 保存分页状态)
const categoryPageCache = ref(new Map())
const categoryListCache = ref(new Map())
// 搜索和 Tabs 状态
const searchValue = ref('')
const activeTabId = ref('all')
const initialCategoryId = ref('')
const tabsData = ref([
{ id: 'all', name: '全部' },
// ... 其他分类
])
// 加载资料列表
const fetchMaterialList = async (params = {}, isLoadMore = false) => {
try {
if (isLoadMore) {
loadingMore.value = true
} else {
loading.value = true
}
const res = await fileListAPI(params)
if (res.code === 1 && res.data) {
const listData = res.data.list || []
if (isLoadMore) {
currentList.value = [...currentList.value, ...listData]
} else {
currentList.value = listData
}
hasMore.value = listData.length >= params.limit
// ⭐ 保存分页状态到缓存
const isSearching = searchValue.value.trim() !== ''
let cacheKey = isSearching ? searchValue.value.trim() :
(activeTabId.value !== 'all' ? activeTabId.value : 'all')
categoryPageCache.value.set(cacheKey, {
currentPage: currentPage.value,
hasMore: hasMore.value
})
// ⭐ 保存列表数据到缓存
if (!isSearching) {
categoryListCache.value.set(cacheKey, [...currentList.value])
}
}
} catch (err) {
console.error('获取资料列表失败:', err)
} finally {
if (isLoadMore) {
loadingMore.value = false
} else {
loading.value = false
}
}
}
// 处理加载更多事件
const handleLoadMore = async (page) => {
currentPage.value = page
const isSearching = searchValue.value.trim() !== ''
const params = {
cid: initialCategoryId.value,
page: page,
limit: pageSize
}
if (isSearching) {
params.keyword = searchValue.value.trim()
if (activeTabId.value !== 'all') {
params.child_id = activeTabId.value
}
} else {
if (activeTabId.value !== 'all') {
params.child_id = activeTabId.value
}
}
await fetchMaterialList(params, true)
}
// Tab 点击处理
const onTabClick = (id) => {
if (activeTabId.value === id) return
activeTabId.value = id
// ⭐ 从缓存读取分页状态
const cached = categoryPageCache.value.get(id)
if (cached) {
currentPage.value = cached.currentPage
hasMore.value = cached.hasMore
currentList.value = categoryListCache.value.get(id) || []
} else {
currentPage.value = 0
hasMore.value = true
currentList.value = []
}
// 重新加载数据(如果缓存为空)
if (!cached || currentList.value.length === 0) {
fetchMaterialList({
cid: initialCategoryId.value,
child_id: id !== 'all' ? id : undefined,
page: currentPage.value,
limit: pageSize
})
}
}
</script>
```
**要点**:
-**分类缓存**: 使用 Map 保存每个分类的分页状态和列表数据
-**切换优化**: 切换分类时先从缓存读取,避免重新加载
-**搜索与分类区分**: 搜索结果不缓存,分类结果才缓存
---
### 案例 4: 双列表系统(search)
**场景**: 同时支持产品和资料搜索,自动选择有结果的 tab
**页面**: [search](../src/pages/search/index.vue)
```vue
<template>
<view class="bg-[#FFF]">
<LoadMoreList
:list="currentList"
:page="currentPage"
:page-size="pageSize"
:has-more="hasMore"
:loading="loading"
:loading-more="loadingMore"
:show-header="true"
key-field="id"
@load-more="handleLoadMore"
>
<!-- 固定顶部:导航栏 + 搜索栏 + Tabs + 结果计数 -->
<template #header>
<view class="bg-[#FFF] sticky top-0 z-10">
<NavHeader title="搜索" />
<!-- Search Input -->
<view class="px-[40rpx] mt-[32rpx]">
<SearchBar
v-model="searchKeyword"
placeholder="搜索培训资料、案例、产品..."
@search="handleSearch"
@clear="clearSearch"
/>
</view>
<!-- Tabs Container -->
<nut-tabs v-model="activeTab">
<template #titles>
<view class="filter-tabs-wrapper">
<view
v-for="item in tabsData"
:key="item.id"
:class="[
'filter-tab-item',
activeTab === item.id ? 'filter-tab-active' : 'filter-tab-inactive',
!activeTab ? 'filter-tab-inactive' : ''
]"
@tap="onTabClick(item.id)"
>
<text class="filter-tab-text">{{ item.name }}</text>
</view>
</view>
</template>
</nut-tabs>
<!-- Result Count -->
<view v-if="currentList.length > 0" class="px-[60rpx] text-[#6B7280] text-[24rpx] pb-[24rpx]">
找到 {{ currentTotal }} 个相关结果
</view>
</view>
</template>
<!-- 列表项:根据 activeTab 动态渲染 -->
<template #item="{ item }">
<!-- Product Results -->
<ProductCard
v-if="activeTab === 'product'"
:product-id="item.id"
:product-name="item.product_name || item.name"
:tags="item.tags || []"
@detail="goToProductDetail"
@plan="openPlanPopup"
/>
<!-- File Results -->
<MaterialCard
v-else-if="activeTab === 'file'"
:id="item.id"
:title="item.title"
:file-name="item.fileName"
:file-size="item.fileSize"
@collect-changed="handleCollectChanged"
/>
</template>
<!-- 自定义空状态:处理三种状态 -->
<template #empty>
<!-- Initial State (从未搜索过) -->
<view v-if="!hasSearched" class="flex flex-col items-center justify-center py-[120rpx]">
<IconFont name="search" class="text-gray-300 mb-[24rpx]" size="64" />
<view class="text-[#6B7280] text-[28rpx]">搜索产品或资料</view>
<view class="text-[#9CA3AF] text-[24rpx] mt-[12rpx]">输入关键词开始搜索,自动切换分类</view>
</view>
<!-- Empty State (已搜索但无结果) -->
<view v-else>
<nut-empty description="暂无搜索结果" image="empty">
<view class="text-[#9CA3AF] text-[24rpx] mt-[12rpx]">试试其他关键词吧</view>
</nut-empty>
</view>
</template>
</LoadMoreList>
<!-- Plan Form Container -->
<PlanFormContainer
v-if="selectedProduct"
v-model:visible="showPlanPopup"
:product="selectedProduct"
@submit="handlePlanSubmit"
/>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import LoadMoreList from '@/components/LoadMoreList'
// State
const searchKeyword = ref('')
const activeTab = ref('') // 当前选中的 tab(初始为空)
const hasSearched = ref(false) // 是否已经搜索过
// 数据状态 - 双列表系统
const products = ref([]) // 产品列表
const files = ref([]) // 资料列表
const productsTotal = ref(0) // 产品总数
const filesTotal = ref(0) // 资料总数
// 分页状态
const loading = ref(false)
const loadingMore = ref(false)
const hasMore = ref(true)
const currentPage = ref(0)
const pageSize = 20
/**
* 当前列表的列表
* @description 根据 activeTab 动态返回对应的列表数据
*/
const currentList = computed(() => {
if (!activeTab.value) return []
if (activeTab.value === 'product') {
return products.value
} else {
return files.value
}
})
/**
* 当前列表总数
*/
const currentTotal = computed(() => {
if (!activeTab.value) return 0
if (activeTab.value === 'product') {
return productsTotal.value
} else {
return filesTotal.value
}
})
/**
* 执行搜索
*/
const performSearch = async (keyword, type, page = 0, limit = pageSize, isLoadMore = false) => {
try {
if (isLoadMore) {
loadingMore.value = true
} else {
loading.value = true
}
const params = { keyword, page, limit }
if (type) params.type = type
const res = await searchAPI(params)
if (res.code === 1) {
// 映射产品列表
const newProducts = res.data.products.list || []
// 映射资料列表...
const newFiles = (res.data.files.list || []).map(item => ({ /* ... */ }))
// 根据是否为加载更多来处理数据
if (isLoadMore) {
products.value = [...products.value, ...newProducts]
files.value = [...files.value, ...newFiles]
} else {
products.value = newProducts
files.value = newFiles
}
productsTotal.value = res.data.products.total || 0
filesTotal.value = res.data.files.total || 0
// ⭐ 重要:自动选择有数据的 tab
if (!type && !isLoadMore) {
if (productsTotal.value > 0) {
activeTab.value = 'product'
} else if (filesTotal.value > 0) {
activeTab.value = 'file'
}
}
// 判断是否还有更多数据
const actualTab = type || activeTab.value
if (actualTab === 'product') {
hasMore.value = products.value.length < productsTotal.value
} else if (actualTab === 'file') {
hasMore.value = files.value.length < filesTotal.value
} else {
hasMore.value = false
}
hasSearched.value = true
}
} catch (err) {
console.error('搜索失败:', err)
} finally {
if (isLoadMore) {
loadingMore.value = false
} else {
loading.value = false
}
}
}
/**
* 处理加载更多事件
*/
const handleLoadMore = async (page) => {
currentPage.value = page
if (!hasSearched.value || !activeTab.value || !searchKeyword.value.trim()) {
return
}
await performSearch(
searchKeyword.value.trim(),
activeTab.value,
page,
pageSize,
true
)
}
/**
* Tab 点击处理(实时查询)
*/
const onTabClick = async (tabId) => {
if (activeTab.value === tabId) return
activeTab.value = tabId
currentPage.value = 0
hasMore.value = true
if (hasSearched.value && searchKeyword.value.trim()) {
await performSearch(searchKeyword.value.trim(), tabId, 0, pageSize, false)
}
}
/**
* 提交搜索
*/
const handleSearch = async () => {
const keyword = searchKeyword.value.trim()
if (!keyword) {
// 提示输入关键词
return
}
currentPage.value = 0
hasMore.value = true
// 不传 type,让后端返回两种数据,前端自动选择 tab
await performSearch(keyword, undefined, 0, pageSize, false)
}
/**
* 清空搜索
*/
const clearSearch = () => {
searchKeyword.value = ''
hasSearched.value = false
products.value = []
files.value = []
productsTotal.value = 0
filesTotal.value = 0
activeTab.value = ''
currentPage.value = 0
hasMore.value = true
}
</script>
```
**要点**:
-**双列表系统**: products 和 files 分别存储
-**动态列表**: `currentList` computed 根据 activeTab 动态返回
-**自动 tab 选择**: 首次搜索时自动选择有结果的 tab
-**三种空状态**: 初始状态、搜索无结果、有结果
---
## 迁移模式
### 模式 1: 简单列表迁移
**适用场景**: 只需展示列表,无复杂交互
**迁移步骤**:
1. **定义状态**
```javascript
const currentList = ref([])
const currentPage = ref(0)
const pageSize = 10
const hasMore = ref(true)
const loading = ref(false)
const loadingMore = ref(false)
```
2. **实现数据加载函数**
```javascript
const fetchData = async (params = {}, isLoadMore = false) => {
try {
if (isLoadMore) {
loadingMore.value = true
} else {
loading.value = true
}
const res = await yourAPI(params)
if (res.code === 1 && res.data) {
const listData = res.data.list || []
if (isLoadMore) {
currentList.value = [...currentList.value, ...listData]
} else {
currentList.value = listData
}
hasMore.value = listData.length >= pageSize
}
} catch (err) {
console.error('获取数据失败:', err)
} finally {
if (isLoadMore) {
loadingMore.value = false
} else {
loading.value = false
}
}
}
```
3. **实现 handleLoadMore**
```javascript
const handleLoadMore = async (page) => {
currentPage.value = page
await fetchData({ page, limit: pageSize }, true)
}
```
4. **使用组件**
```vue
<LoadMoreList
:list="currentList"
:page="currentPage"
:page-size="pageSize"
:has-more="hasMore"
:loading="loading"
:loading-more="loadingMore"
@load-more="handleLoadMore"
>
<template #item="{ item }">
<!-- 列表项内容 -->
</template>
</LoadMoreList>
```
---
### 模式 2: 带搜索的列表迁移
**适用场景**: 需要搜索功能,可能还需要 tabs
**关键点**:
1. **搜索防抖**: 500ms 延迟
```javascript
let searchTimer = null
const onSearchInput = (value) => {
if (searchTimer) {
clearTimeout(searchTimer)
}
searchTimer = setTimeout(() => {
currentPage.value = 0
hasMore.value = true
const params = {
page: 0,
limit: pageSize
}
if (value) {
params.keyword = value
}
fetchData(params, false)
}, 500)
}
```
2. **固定头部**: 使用 `sticky top-0 z-10`
```vue
<template #header>
<view class="sticky top-0 z-10 bg-[#FFF]">
<NavHeader title="产品中心" />
<SearchBar v-model="searchValue" @search="onSearch" />
</view>
</template>
```
---
### 模式 3: 带分类缓存的列表迁移
**适用场景**: 需要缓存每个分类的分页状态
**关键点**:
1. **使用 Map 保存缓存**
```javascript
const categoryPageCache = ref(new Map())
const categoryListCache = ref(new Map())
```
2. **保存状态到缓存**
```javascript
const cacheKey = activeTabId.value
categoryPageCache.value.set(cacheKey, {
currentPage: currentPage.value,
hasMore: hasMore.value
})
categoryListCache.value.set(cacheKey, [...currentList.value])
```
3. **从缓存读取**
```javascript
const cached = categoryPageCache.value.get(tabId)
if (cached) {
currentPage.value = cached.currentPage
hasMore.value = cached.hasMore
currentList.value = categoryListCache.value.get(tabId) || []
}
```
---
### 模式 4: 带下拉刷新的列表迁移
**适用场景**: 需要支持下拉刷新
**关键点**:
1. **启用下拉刷新**
```vue
<LoadMoreList
:enable-pull-down-refresh="true"
@refresh="handleRefresh"
>
<!-- ... -->
</LoadMoreList>
```
2. **实现 handleRefresh**
```javascript
const handleRefresh = async () => {
currentPage.value = 0 // 或 1,根据 API 要求
hasMore.value = true
await fetchData({ page: currentPage.value, limit: pageSize })
}
```
---
### 模式 5: 双列表系统迁移
**适用场景**: 需要支持多种类型的数据(产品和资料)
**关键点**:
1. **双列表存储**
```javascript
const products = ref([])
const files = ref([])
```
2. **动态列表 computed**
```javascript
const currentList = computed(() => {
if (activeTab.value === 'product') {
return products.value
} else {
return files.value
}
})
```
3. **动态列表总数 computed**
```javascript
const currentTotal = computed(() => {
if (activeTab.value === 'product') {
return productsTotal.value
} else {
return filesTotal.value
}
})
```
---
## 最佳实践
### ✅ 推荐做法
#### 1. 使用 `key-field` prop
确保列表更新正确,避免渲染问题。
```vue
<LoadMoreList
:list="products"
key-field="id" <!-- ✅ 使用唯一标识 -->
@load-more="handleLoadMore"
>
```
#### 2. 区分 `loading` 和 `loadingMore`
提升用户体验,显示不同的加载状态。
```javascript
// 首次加载
loading.value = true
currentList.value = []
// 加载更多
loadingMore.value = true
currentList.value = [...currentList.value, ...newData]
```
#### 3. 使用 JSDoc 注释
提升代码可读性。
```javascript
/**
* 获取产品列表
*
* @description 从 API 获取产品列表数据
* @param {Object} params - 请求参数
* @param {number} params.page - 页码
* @param {number} params.limit - 每页数量
* @param {boolean} isLoadMore - 是否为加载更多
* @returns {Promise<void>}
*/
const fetchProducts = async (params = {}, isLoadMore = false) => {
// ...
}
```
#### 4. 使用 slot 自定义
保持组件灵活性,不要过度修改组件内部代码。
```vue
<LoadMoreList>
<template #header>
<!-- 自定义头部 -->
</template>
<template #item="{ item }">
<!-- 自定义列表项 -->
</template>
<template #empty>
<!-- 自定义空状态 -->
</template>
</LoadMoreList>
```
#### 5. 使用 `computed` 简化模板
将复杂逻辑提取到 computed 中。
```javascript
const currentList = computed(() => {
if (activeTab.value === 'product') {
return products.value
} else {
return files.value
}
})
```
---
### ❌ 避免做法
#### 1. 在 slot 中处理分页
应该在父组件处理,不要在 slot 中修改分页状态。
```vue
<!-- ❌ 错误 -->
<template #item="{ item }">
<view @tap="loadMore()">加载更多</view>
</template>
<!-- ✅ 正确 -->
<LoadMoreList @load-more="handleLoadMore">
<!-- 组件内部会自动触发 load-more 事件 -->
</LoadMoreList>
```
#### 2. 忽略 `key-field`
可能导致列表更新异常。
```vue
<!-- ❌ 错误 -->
<LoadMoreList :list="products">
<!-- ✅ 正确 -->
<LoadMoreList :list="products" key-field="id">
```
#### 3. 直接修改 props
应该通过事件通知父组件。
```javascript
// ❌ 错误
const handleClick = () => {
props.list.push(newItem) // 直接修改 props
}
// ✅ 正确
const emit = defineEmits(['update'])
const handleClick = () => {
emit('update', [...props.list, newItem])
}
```
#### 4. 过度自定义 slot
能用默认的就用默认的,保持简洁。
```vue
<!-- ❌ 过度自定义 -->
<LoadMoreList>
<template #loading>
<!-- 复杂的加载动画 -->
</template>
<template #loading-more>
<!-- 复杂的加载动画 -->
</template>
</LoadMoreList>
<!-- ✅ 使用默认 -->
<LoadMoreList>
<!-- 组件内置的加载动画已经很好了 -->
</LoadMoreList>
```
---
## 常见问题
### Q1: 如何处理 API 页码从 1 开始还是从 0 开始?
**A**: 根据 API 要求设置初始页码。
```javascript
// 如果 API 页码从 1 开始
const currentPage = ref(1)
// 如果 API 页码从 0 开始
const currentPage = ref(0)
// handleLoadMore 不需要修改,组件会自动 +1
const handleLoadMore = async (page) => {
currentPage.value = page
await fetchData({ page, limit: pageSize }, true)
}
```
---
### Q2: 如何判断是否还有更多数据?
**A**: 比较返回的数据量与请求的数据量。
```javascript
if (res.code === 1 && res.data) {
const listData = res.data.list || []
if (isLoadMore) {
currentList.value = [...currentList.value, ...listData]
} else {
currentList.value = listData
}
// 如果返回的数据量 >= 请求的量,说明还有更多
hasMore.value = listData.length >= pageSize
}
```
---
### Q3: 如何实现搜索防抖?
**A**: 使用 `setTimeout` + `clearTimeout`
```javascript
let searchTimer = null
const onSearchInput = (value) => {
// 清除之前的定时器
if (searchTimer) {
clearTimeout(searchTimer)
}
// 设置新的定时器(500ms 后执行搜索)
searchTimer = setTimeout(() => {
currentPage.value = 0
hasMore.value = true
const params = {
page: 0,
limit: pageSize
}
if (value) {
params.keyword = value
}
fetchData(params, false)
}, 500)
}
```
---
### Q4: 如何缓存分类的分页状态?
**A**: 使用 Map 保存每个分类的状态。
```javascript
// 缓存 Map
const categoryPageCache = ref(new Map())
const categoryListCache = ref(new Map())
// 保存到缓存
const saveToCache = (tabId) => {
categoryPageCache.value.set(tabId, {
currentPage: currentPage.value,
hasMore: hasMore.value
})
categoryListCache.value.set(tabId, [...currentList.value])
}
// 从缓存读取
const loadFromCache = (tabId) => {
const cached = categoryPageCache.value.get(tabId)
if (cached) {
currentPage.value = cached.currentPage
hasMore.value = cached.hasMore
currentList.value = categoryListCache.value.get(tabId) || []
}
}
// Tab 点击时先尝试从缓存读取
const onTabClick = (tabId) => {
loadFromCache(tabId)
// 如果缓存为空,重新加载
if (currentList.value.length === 0) {
fetchData({ /* ... */ }, false)
}
}
```
---
### Q5: 如何实现双列表系统(如搜索页)?
**A**: 分别存储两个列表,使用 computed 动态返回。
```javascript
// 双列表存储
const products = ref([])
const files = ref([])
// 动态列表
const currentList = computed(() => {
if (activeTab.value === 'product') {
return products.value
} else {
return files.value
}
})
// 动态总数
const currentTotal = computed(() => {
if (activeTab.value === 'product') {
return productsTotal.value
} else {
return filesTotal.value
}
})
```
---
### Q6: 如何处理嵌套弹窗(如计划书弹窗)?
**A**: 将弹窗组件放在 LoadMoreList 外部作为兄弟节点。
```vue
<template>
<view>
<!-- 计划书弹窗(放在 LoadMoreList 外部) -->
<view v-if="showPlanPopup && selectedProduct">
<PlanFormContainer
v-model:visible="showPlanPopup"
:product="selectedProduct"
@submit="handlePlanSubmit"
/>
</view>
<!-- LoadMoreList -->
<LoadMoreList
:list="currentList"
@load-more="handleLoadMore"
>
<!-- ... -->
</LoadMoreList>
</view>
</template>
```
---
### Q7: 如何实现三种空状态(初始、搜索无结果、有结果)?
**A**: 使用状态变量控制不同的空状态显示。
```vue
<template #empty>
<!-- Initial State (从未搜索过) -->
<view v-if="!hasSearched">
<IconFont name="search" />
<view>搜索产品或资料</view>
</view>
<!-- Empty State (已搜索但无结果) -->
<view v-else>
<nut-empty description="暂无搜索结果" />
</view>
</template>
<script setup>
const hasSearched = ref(false)
const handleSearch = async () => {
// ...
hasSearched.value = true
}
const clearSearch = () => {
hasSearched.value = false
// ...
}
</script>
```
---
## 性能优化
### 1. 动画延迟优化
**问题**: 如果每个列表项都使用动画延迟,长列表会导致累积延迟。
**解决方案**: 只为前10项使用动画延迟。
```javascript
// ✅ LoadMoreList 组件内部已实现
function getAnimationDelay(index) {
// 只为前10项使用动画延迟
if (index < 10) {
return { animationDelay: `${index * 20}ms` }
}
// 第10项以后立即显示(无延迟)
return {}
}
```
**效果**:
- 前10项:逐个进入,形成波浪效果
- 第10项以后:立即显示,避免累积延迟
---
### 2. 触底加载防抖
**问题**: 用户滚动到底部时,`useReachBottom` 可能触发多次。
**解决方案**: 使用 300ms 防抖。
```javascript
// ✅ LoadMoreList 组件内部已实现
let loadMoreTimer = null
useReachBottom(() => {
// 如果正在加载或没有更多数据,不执行
if (props.loadingMore || props.loading || !props.hasMore) {
return
}
// 防抖:300ms 内只触发一次
if (loadMoreTimer) {
clearTimeout(loadMoreTimer)
}
loadMoreTimer = setTimeout(() => {
const nextPage = props.page + 1
emit('load-more', nextPage)
}, 300)
})
```
---
### 3. 搜索防抖
**问题**: 用户输入时频繁触发搜索请求。
**解决方案**: 使用 500ms 防抖。
```javascript
let searchTimer = null
const onSearchInput = (value) => {
if (searchTimer) {
clearTimeout(searchTimer)
}
searchTimer = setTimeout(() => {
// 执行搜索
fetchData({ keyword: value })
}, 500)
}
```
---
### 4. 分类缓存
**问题**: 切换分类时重新加载数据,用户体验差。
**解决方案**: 使用 Map 缓存每个分类的数据。
```javascript
const categoryPageCache = ref(new Map())
const categoryListCache = ref(new Map())
// 切换分类时先从缓存读取
const onTabClick = (tabId) => {
const cached = categoryPageCache.value.get(tabId)
if (cached) {
// 从缓存读取,立即显示
currentPage.value = cached.currentPage
currentList.value = categoryListCache.value.get(tabId) || []
} else {
// 缓存为空,重新加载
fetchData({ /* ... */ })
}
}
```
---
### 5. 数据追加 vs 替换
**问题**: 不正确处理数据会导致重复或丢失数据。
**解决方案**:
- **首次加载/刷新**: 替换数据
- **加载更多**: 追加数据
```javascript
if (isLoadMore) {
// 追加数据
currentList.value = [...currentList.value, ...listData]
} else {
// 替换数据
currentList.value = listData
}
```
---
## 更新日志
### v2.0.0 (2026-02-08)
#### 新增
- ✅ 完整的组件 API 文档
- ✅ 5 个页面的实际迁移案例
- ✅ 5 种迁移模式详解
- ✅ 最佳实践和常见问题
- ✅ 性能优化建议
#### 迁移完成
- ✅ week-hot-material 页面
- ✅ message 页面
- ✅ product-center 页面
- ✅ material-list 页面
- ✅ search 页面
#### 收益
- ✅ 减少重复代码 ~700 行
- ✅ 统一 5 个页面的分页加载逻辑
- ✅ 统一动画效果和加载状态
- ✅ 提升代码可维护性
---
## 相关文档
- [LoadMoreList 组件源码](../src/components/LoadMoreList/index.vue)
- [项目 CLAUDE.md](../CLAUDE.md)
- [经验教训总结](lessons-learned.md)
- [Vue 3 最佳实践](~/.claude/rules/vue-best-practices.md)
---
**创建时间**: 2026-02-08
**维护者**: Claude Code
**版本**: 2.0.0
......@@ -670,7 +670,7 @@ if (!Number.isNaN(birthDate.getTime())) {
### 项目文档
- [计划书架构设计](../plan/plan-entry-architecture.md)
- [API 联调日志](../api-integration-log.md)
- [API 联调日志](../api-specs/API 集成日志.md)
- [变更日志](../CHANGELOG.md)
### 技术文档
......