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>
Showing
15 changed files
with
4129 additions
and
5 deletions
| ... | @@ -302,7 +302,7 @@ console.log(product.form_sn) // 应该有值,如 "life-insurance-wiop3e" | ... | @@ -302,7 +302,7 @@ console.log(product.form_sn) // 应该有值,如 "life-insurance-wiop3e" |
| 302 | 302 | ||
| 303 | - [完整架构文档](./plan-entry-architecture.md) | 303 | - [完整架构文档](./plan-entry-architecture.md) |
| 304 | - [经验教训总结](../lessons-learned/plan-entry-module-summary.md) | 304 | - [经验教训总结](../lessons-learned/plan-entry-module-summary.md) |
| 305 | -- [API 联调日志](../api-integration-log.md) | 305 | +- [API 联调日志](../api-specs/API 集成日志.md) |
| 306 | 306 | ||
| 307 | --- | 307 | --- |
| 308 | 308 | ... | ... |
| ... | @@ -9,7 +9,6 @@ docs/ | ... | @@ -9,7 +9,6 @@ docs/ |
| 9 | ├── CHANGELOG.md # 项目变更日志(核心文档) | 9 | ├── CHANGELOG.md # 项目变更日志(核心文档) |
| 10 | ├── README.md # 本文件(文档导航索引) | 10 | ├── README.md # 本文件(文档导航索引) |
| 11 | ├── lessons-learned.md # 经验教训总结(核心文档) | 11 | ├── lessons-learned.md # 经验教训总结(核心文档) |
| 12 | -├── api-integration-log.md # API 联调日志 | ||
| 13 | │ | 12 | │ |
| 14 | ├── guides/ # 📘 使用指南和教程 | 13 | ├── guides/ # 📘 使用指南和教程 |
| 15 | │ ├── START_HERE.md # 新人入门指南 | 14 | │ ├── START_HERE.md # 新人入门指南 |
| ... | @@ -77,7 +76,7 @@ docs/ | ... | @@ -77,7 +76,7 @@ docs/ |
| 77 | ### 核心文档 | 76 | ### 核心文档 |
| 78 | - 📖 [项目变更日志](CHANGELOG.md) - 所有功能、修复和优化的记录 | 77 | - 📖 [项目变更日志](CHANGELOG.md) - 所有功能、修复和优化的记录 |
| 79 | - 📖 [经验教训总结](lessons-learned.md) - 开发中的最佳实践和常见陷阱 | 78 | - 📖 [经验教训总结](lessons-learned.md) - 开发中的最佳实践和常见陷阱 |
| 80 | -- 📖 [API 联调日志](api-integration-log.md) - 接口联调状态记录 | 79 | +- 📖 [API 联调日志](api-specs/API 集成日志.md) - 接口联调状态记录 |
| 81 | 80 | ||
| 82 | ### 新手入门 | 81 | ### 新手入门 |
| 83 | 👉 **[guides/START_HERE.md](guides/START_HERE.md)** - 快速了解项目功能 | 82 | 👉 **[guides/START_HERE.md](guides/START_HERE.md)** - 快速了解项目功能 |
| ... | @@ -163,7 +162,7 @@ UI/UX 设计稿和生成的代码: | ... | @@ -163,7 +162,7 @@ UI/UX 设计稿和生成的代码: |
| 163 | 162 | ||
| 164 | ### 更新日志 | 163 | ### 更新日志 |
| 165 | - 项目级别的更新:修改 `CHANGELOG.md` | 164 | - 项目级别的更新:修改 `CHANGELOG.md` |
| 166 | -- API 联调记录:修改 `api-integration-log.md` | 165 | +- API 联调记录:修改 `api-specs/API 集成日志.md` |
| 167 | 166 | ||
| 168 | --- | 167 | --- |
| 169 | 168 | ... | ... |
docs/backups/README.md
0 → 100644
| 1 | +# 原始页面备份 | ||
| 2 | + | ||
| 3 | +> **备份时间**: 2026-02-08 | ||
| 4 | +> **备份原因**: LoadMoreList 组件迁移前的代码备份 | ||
| 5 | +> **迁移提交**: ce4b99b refactor: 迁移所有剩余页面到 LoadMoreList 组件 | ||
| 6 | + | ||
| 7 | +--- | ||
| 8 | + | ||
| 9 | +## 📁 备份文件列表 | ||
| 10 | + | ||
| 11 | +| 文件 | 大小 | 说明 | | ||
| 12 | +|------|------|------| | ||
| 13 | +| `message-index.vue.bak` | 3.7 KB | 消息列表页(迁移前) | | ||
| 14 | +| `product-center-index.vue.bak` | 13.9 KB | 产品中心页(迁移前) | | ||
| 15 | +| `material-list-index.vue.bak` | 24.6 KB | 资料列表页(迁移前) | | ||
| 16 | +| `search-index.vue.bak` | 17 KB | 搜索页(迁移前) | | ||
| 17 | + | ||
| 18 | +--- | ||
| 19 | + | ||
| 20 | +## 🔄 如何恢复原始代码 | ||
| 21 | + | ||
| 22 | +### 方法 1: 手动恢复(推荐) | ||
| 23 | + | ||
| 24 | +如果新组件有问题,可以手动恢复: | ||
| 25 | + | ||
| 26 | +```bash | ||
| 27 | +# 1. 删除当前文件 | ||
| 28 | +rm src/pages/message/index.vue | ||
| 29 | + | ||
| 30 | +# 2. 从备份恢复 | ||
| 31 | +cp docs/backups/original-pages/message-index.vue.bak src/pages/message/index.vue | ||
| 32 | + | ||
| 33 | +# 3. 重复其他文件... | ||
| 34 | +``` | ||
| 35 | + | ||
| 36 | +### 方法 2: 使用 Git 恢复 | ||
| 37 | + | ||
| 38 | +使用 Git 命令恢复到迁移前的版本: | ||
| 39 | + | ||
| 40 | +```bash | ||
| 41 | +# 恢复单个文件 | ||
| 42 | +git checkout ce4b99b^ -- src/pages/message/index.vue | ||
| 43 | + | ||
| 44 | +# 恢复所有文件 | ||
| 45 | +git checkout ce4b99b^ -- src/pages/message/index.vue \ | ||
| 46 | + src/pages/product-center/index.vue \ | ||
| 47 | + src/pages/material-list/index.vue \ | ||
| 48 | + src/pages/search/index.vue | ||
| 49 | +``` | ||
| 50 | + | ||
| 51 | +--- | ||
| 52 | + | ||
| 53 | +## 📋 迁移前后对比 | ||
| 54 | + | ||
| 55 | +### message 页面 | ||
| 56 | + | ||
| 57 | +| 指标 | 迁移前 | 迁移后 | | ||
| 58 | +|------|--------|--------| | ||
| 59 | +| 代码行数 | 149 行 | 229 行 | | ||
| 60 | +| 分页逻辑 | 手动实现 | LoadMoreList 组件 | | ||
| 61 | +| 下拉刷新 | ❌ 无 | ✅ 有 | | ||
| 62 | + | ||
| 63 | +### product-center 页面 | ||
| 64 | + | ||
| 65 | +| 指标 | 迁移前 | 迁移后 | | ||
| 66 | +|------|--------|--------| | ||
| 67 | +| 代码行数 | 510 行 | 592 行 | | ||
| 68 | +| 分页逻辑 | 手动实现 | LoadMoreList 组件 | | ||
| 69 | +| 搜索功能 | ✅ 有 | ✅ 保留 | | ||
| 70 | +| Tabs | ✅ 有 | ✅ 保留 | | ||
| 71 | + | ||
| 72 | +### material-list 页面 | ||
| 73 | + | ||
| 74 | +| 指标 | 迁移前 | 迁移后 | | ||
| 75 | +|------|--------|--------| | ||
| 76 | +| 代码行数 | 888 行 | 828 行 | | ||
| 77 | +| 分页逻辑 | 手动实现 | LoadMoreList 组件 | | ||
| 78 | +| 分类缓存 | ✅ 有 | ✅ 保留 | | ||
| 79 | +| 搜索防抖 | ✅ 有 | ✅ 保留 | | ||
| 80 | + | ||
| 81 | +### search 页面 | ||
| 82 | + | ||
| 83 | +| 指标 | 迁移前 | 迁移后 | | ||
| 84 | +|------|--------|--------| | ||
| 85 | +| 代码行数 | 603 行 | 549 行 | | ||
| 86 | +| 分页逻辑 | 手动实现 | LoadMoreList 组件 | | ||
| 87 | +| 双列表系统 | ✅ 有 | ✅ 保留 | | ||
| 88 | +| 自动 tab 选择 | ✅ 有 | ✅ 保留 | | ||
| 89 | + | ||
| 90 | +--- | ||
| 91 | + | ||
| 92 | +## ⚠️ 注意事项 | ||
| 93 | + | ||
| 94 | +1. **备份文件只读**: 这些是 `.bak` 文件,只用于参考,不应直接修改 | ||
| 95 | +2. **使用 Git 版本控制**: 建议使用 Git 命令恢复,而不是手动复制 | ||
| 96 | +3. **测试新组件**: 如果新组件有问题,先检查是否可以通过修改解决 | ||
| 97 | +4. **保留备份**: 建议保留这些备份文件,直到确认新组件完全稳定 | ||
| 98 | + | ||
| 99 | +--- | ||
| 100 | + | ||
| 101 | +## 🔍 迁移问题排查 | ||
| 102 | + | ||
| 103 | +如果新组件有问题,按以下步骤排查: | ||
| 104 | + | ||
| 105 | +### 1. 检查 Props 是否正确 | ||
| 106 | + | ||
| 107 | +```vue | ||
| 108 | +<!-- ❌ 错误:缺少必需 props --> | ||
| 109 | +<LoadMoreList :list="products" @load-more="handleLoadMore"> | ||
| 110 | + | ||
| 111 | +<!-- ✅ 正确:包含所有必需 props --> | ||
| 112 | +<LoadMoreList | ||
| 113 | + :list="products" | ||
| 114 | + :page="page" | ||
| 115 | + :has-more="hasMore" | ||
| 116 | + :loading="loading" | ||
| 117 | + :loading-more="loadingMore" | ||
| 118 | + @load-more="handleLoadMore" | ||
| 119 | +> | ||
| 120 | +``` | ||
| 121 | + | ||
| 122 | +### 2. 检查数据加载逻辑 | ||
| 123 | + | ||
| 124 | +```javascript | ||
| 125 | +// ❌ 错误:未正确处理追加数据 | ||
| 126 | +const handleLoadMore = async (page) => { | ||
| 127 | + const newData = await fetchData({ page }) | ||
| 128 | + currentList.value = newData // 错误:会覆盖之前的数据 | ||
| 129 | +} | ||
| 130 | + | ||
| 131 | +// ✅ 正确:追加数据 | ||
| 132 | +const handleLoadMore = async (page) => { | ||
| 133 | + const newData = await fetchData({ page }) | ||
| 134 | + currentList.value = [...currentList.value, ...newData] | ||
| 135 | +} | ||
| 136 | +``` | ||
| 137 | + | ||
| 138 | +### 3. 检查 hasMore 判断逻辑 | ||
| 139 | + | ||
| 140 | +```javascript | ||
| 141 | +// ❌ 错误:使用总数量判断 | ||
| 142 | +hasMore.value = currentList.value.length < total | ||
| 143 | + | ||
| 144 | +// ✅ 正确:使用返回数据量判断 | ||
| 145 | +hasMore.value = newData.length >= pageSize | ||
| 146 | +``` | ||
| 147 | + | ||
| 148 | +### 4. 检查 page 初始值 | ||
| 149 | + | ||
| 150 | +```javascript | ||
| 151 | +// 如果 API 页码从 1 开始 | ||
| 152 | +const page = ref(1) | ||
| 153 | + | ||
| 154 | +// 如果 API 页码从 0 开始 | ||
| 155 | +const page = ref(0) | ||
| 156 | + | ||
| 157 | +// 组件会自动 +1,所以不需要手动 +1 | ||
| 158 | +``` | ||
| 159 | + | ||
| 160 | +--- | ||
| 161 | + | ||
| 162 | +## 📖 相关文档 | ||
| 163 | + | ||
| 164 | +- [LoadMoreList 完整指南](../guides/components/LoadMoreList 完整使用指南.md) | ||
| 165 | +- [LoadMoreList 迁移指南](../guides/components/LoadMoreList 迁移指南.md) | ||
| 166 | +- [项目 CLAUDE.md](../../CLAUDE.md) | ||
| 167 | + | ||
| 168 | +--- | ||
| 169 | + | ||
| 170 | +**创建时间**: 2026-02-08 | ||
| 171 | +**维护者**: Claude Code |
| 1 | +<!-- | ||
| 2 | + * @Date: 2026-01-31 | ||
| 3 | + * @Description: 资料列表页 - 已改造为 NutTabs 版本 | ||
| 4 | +--> | ||
| 5 | +<template> | ||
| 6 | + <view class="bg-[#F9FAFB]"> | ||
| 7 | + <!-- 固定在顶部的导航和搜索 --> | ||
| 8 | + <view class="bg-[#F9FAFB] sticky top-0 z-10"> | ||
| 9 | + <NavHeader :title="pageTitle" /> | ||
| 10 | + | ||
| 11 | + <view class="px-[32rpx] mt-[32rpx] mb-[24rpx]"> | ||
| 12 | + <SearchBar | ||
| 13 | + v-model="searchValue" | ||
| 14 | + placeholder="搜索资料..." | ||
| 15 | + @search="onSearch" | ||
| 16 | + @clear="onClear" | ||
| 17 | + variant="rounded" | ||
| 18 | + :show-border="true" | ||
| 19 | + :show-clear="true" | ||
| 20 | + /> | ||
| 21 | + </view> | ||
| 22 | + | ||
| 23 | + <!-- 动态显示 Tabs(仅在有分类时显示) --> | ||
| 24 | + <nut-tabs v-if="hasCategories" v-model="activeTabId"> | ||
| 25 | + <!-- 自定义标签栏 --> | ||
| 26 | + <template #titles> | ||
| 27 | + <view class="filter-tabs-wrapper"> | ||
| 28 | + <view | ||
| 29 | + v-for="item in tabsData" | ||
| 30 | + :key="item.id" | ||
| 31 | + :class="[ | ||
| 32 | + 'filter-tab-item', | ||
| 33 | + activeTabId === item.id ? 'filter-tab-active' : 'filter-tab-inactive' | ||
| 34 | + ]" | ||
| 35 | + @tap="onTabClick(item.id)" | ||
| 36 | + > | ||
| 37 | + <text class="filter-tab-text">{{ item.name }}</text> | ||
| 38 | + </view> | ||
| 39 | + </view> | ||
| 40 | + </template> | ||
| 41 | + </nut-tabs> | ||
| 42 | + </view> | ||
| 43 | + | ||
| 44 | + <!-- 列表容器 - 页面级滚动 --> | ||
| 45 | + <view | ||
| 46 | + v-if="listVisible" | ||
| 47 | + :key="listRenderKey" | ||
| 48 | + class="px-[32rpx] pb-[calc(160rpx+env(safe-area-inset-bottom))] box-border" | ||
| 49 | + > | ||
| 50 | + <view class="flex flex-col gap-[24rpx]"> | ||
| 51 | + <view v-for="(item, index) in currentList" :key="index" | ||
| 52 | + class="material-item bg-white rounded-[24rpx] p-[24rpx] shadow-sm transition-all duration-200 border border-gray-50 flex flex-row" | ||
| 53 | + :style="{ animationDelay: `${index * 50}ms` }"> | ||
| 54 | + | ||
| 55 | + <view | ||
| 56 | + 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"> | ||
| 57 | + <image | ||
| 58 | + :src="getDocumentIcon(item.extension ? `file.${item.extension}` : item.fileName)" | ||
| 59 | + class="w-[48rpx] h-[48rpx]" | ||
| 60 | + mode="aspectFit" | ||
| 61 | + /> | ||
| 62 | + </view> | ||
| 63 | + | ||
| 64 | + <view class="flex-1 min-w-0"> | ||
| 65 | + <h3 class="text-[#1F2937] text-[30rpx] font-bold leading-[1.4] line-clamp-2 mb-[8rpx]"> | ||
| 66 | + {{ item.title }} | ||
| 67 | + </h3> | ||
| 68 | + <p class="text-[#6B7280] text-[24rpx] leading-[1.4] line-clamp-1 mb-[16rpx]"> | ||
| 69 | + {{ item.desc }} | ||
| 70 | + </p> | ||
| 71 | + | ||
| 72 | + <view class="flex items-center gap-[12rpx] mb-[20rpx]"> | ||
| 73 | + <view | ||
| 74 | + class="inline-flex items-center justify-center px-[12rpx] py-[4rpx] bg-gray-100 text-gray-500 text-[20rpx] font-medium rounded-[8rpx]"> | ||
| 75 | + {{ getDocumentLabel(item.extension ? `file.${item.extension}` : item.fileName) }} | ||
| 76 | + </view> | ||
| 77 | + <view class="text-[#9CA3AF] text-[22rpx]"> | ||
| 78 | + {{ item.size }} | ||
| 79 | + </view> | ||
| 80 | + </view> | ||
| 81 | + | ||
| 82 | + <view class="h-[1rpx] bg-gray-100 my-[20rpx]"></view> | ||
| 83 | + | ||
| 84 | + <ListItemActions | ||
| 85 | + :viewable="true" | ||
| 86 | + :collectable="true" | ||
| 87 | + :collected="item.collected" | ||
| 88 | + :item-id="String(item.meta_id || item.id)" | ||
| 89 | + @view="onView(item)" | ||
| 90 | + @collect="toggleCollect(item)" | ||
| 91 | + @delete="onDelete(item)" | ||
| 92 | + /> | ||
| 93 | + </view> | ||
| 94 | + </view> | ||
| 95 | + | ||
| 96 | + <!-- 空状态 --> | ||
| 97 | + <view v-if="currentList.length === 0 && !loading"> | ||
| 98 | + <nut-empty description="暂无相关资料" image="empty" /> | ||
| 99 | + </view> | ||
| 100 | + | ||
| 101 | + <!-- 加载更多状态 --> | ||
| 102 | + <view v-if="currentList.length > 0" class="load-more-container"> | ||
| 103 | + <view v-if="loadingMore" class="load-more-loading"> | ||
| 104 | + <view class="loading-spinner"></view> | ||
| 105 | + <text class="ml-[16rpx] text-[#9CA3AF] text-[24rpx]">加载中...</text> | ||
| 106 | + </view> | ||
| 107 | + <view v-else-if="!hasMore" class="load-more-finished"> | ||
| 108 | + <text class="text-[#9CA3AF] text-[24rpx]">没有更多了</text> | ||
| 109 | + </view> | ||
| 110 | + </view> | ||
| 111 | + </view> | ||
| 112 | + </view> | ||
| 113 | + </view> | ||
| 114 | +</template> | ||
| 115 | + | ||
| 116 | +<script setup> | ||
| 117 | +import { ref, computed, nextTick, watch } from 'vue' | ||
| 118 | +import { useLoad, useReachBottom } from '@tarojs/taro' | ||
| 119 | +import NavHeader from '@/components/NavHeader.vue' | ||
| 120 | +import SearchBar from '@/components/SearchBar.vue' | ||
| 121 | +import ListItemActions from '@/components/ListItemActions/index.vue' | ||
| 122 | +import { useListItemClick, ListType } from '@/composables/useListItemClick' | ||
| 123 | +import { getDocumentIcon, getDocumentLabel } from '@/utils/documentIcons' | ||
| 124 | +import { debounce } from '@/utils/debounce' | ||
| 125 | +import { fileListAPI } from '@/api/file' | ||
| 126 | +import { mockFileListAPI } from '@/utils/mockData' | ||
| 127 | +import { useCollectOperation } from '@/composables/useCollectOperation' | ||
| 128 | +import Taro from '@tarojs/taro' | ||
| 129 | + | ||
| 130 | +// ⚠️ MOCK 数据开关 - 开发环境使用 mock 数据,生产环境使用真实 API | ||
| 131 | +const USE_MOCK_DATA = process.env.NODE_ENV === 'development' | ||
| 132 | + | ||
| 133 | +const searchValue = ref('') | ||
| 134 | +const activeTabId = ref('all') // 默认选中"全部" | ||
| 135 | +const listVisible = ref(true) | ||
| 136 | +const listRenderKey = ref(0) | ||
| 137 | + | ||
| 138 | +/** | ||
| 139 | + * 防抖搜索函数 | ||
| 140 | + * @description 使用防抖优化搜索性能,避免频繁请求接口 | ||
| 141 | + */ | ||
| 142 | +const debouncedSearch = debounce(async () => { | ||
| 143 | + console.log('[Material List] 防抖搜索触发') | ||
| 144 | + await onSearch() | ||
| 145 | +}, 500) | ||
| 146 | + | ||
| 147 | +/** | ||
| 148 | + * 加载状态 | ||
| 149 | + */ | ||
| 150 | +const loading = ref(false) | ||
| 151 | + | ||
| 152 | +/** | ||
| 153 | + * 加载更多状态 | ||
| 154 | + * @description 区分首次加载和加载更多 | ||
| 155 | + */ | ||
| 156 | +const loadingMore = ref(false) | ||
| 157 | + | ||
| 158 | +/** | ||
| 159 | + * 每页数量 | ||
| 160 | + */ | ||
| 161 | +const pageSize = 10 | ||
| 162 | + | ||
| 163 | +/** | ||
| 164 | + * 当前页码(从0开始) | ||
| 165 | + */ | ||
| 166 | +const currentPage = ref(0) | ||
| 167 | + | ||
| 168 | +/** | ||
| 169 | + * 是否有更多数据 | ||
| 170 | + */ | ||
| 171 | +const hasMore = ref(true) | ||
| 172 | + | ||
| 173 | +/** | ||
| 174 | + * 各分类的分页状态缓存 | ||
| 175 | + * @description Map<categoryId, { currentPage, hasMore }> | ||
| 176 | + */ | ||
| 177 | +const categoryPageCache = ref(new Map()) | ||
| 178 | + | ||
| 179 | +/** | ||
| 180 | + * API 返回的原始数据 | ||
| 181 | + */ | ||
| 182 | +const data = ref(null) | ||
| 183 | + | ||
| 184 | +/** | ||
| 185 | + * 初始分类ID(从页面参数获取) | ||
| 186 | + */ | ||
| 187 | +const initialCategoryId = ref(null) | ||
| 188 | + | ||
| 189 | +/** | ||
| 190 | + * 是否有分类标签 | ||
| 191 | + * @description 根据后端返回的 children 判断 | ||
| 192 | + */ | ||
| 193 | +const hasCategories = computed(() => { | ||
| 194 | + return data.value?.children?.length > 0 | ||
| 195 | +}) | ||
| 196 | + | ||
| 197 | +/** | ||
| 198 | + * 页面标题 | ||
| 199 | + */ | ||
| 200 | +const pageTitle = ref('资料列表') | ||
| 201 | + | ||
| 202 | +/** | ||
| 203 | + * 资料数据源(从 API 获取) | ||
| 204 | + */ | ||
| 205 | +const allList = ref([]) | ||
| 206 | + | ||
| 207 | +/** | ||
| 208 | + * 各个分类的缓存列表数据 | ||
| 209 | + * @description key: 分类ID,value: 该分类的列表数据 | ||
| 210 | + */ | ||
| 211 | +const categoryListCache = ref(new Map()) // 使用 Map 缓存各分类的列表数据 | ||
| 212 | + | ||
| 213 | +/** | ||
| 214 | + * 当前显示的列表数据 | ||
| 215 | + * @description 根据当前选中的 tab 和搜索关键词动态计算 | ||
| 216 | + */ | ||
| 217 | +const currentList = ref([]) | ||
| 218 | + | ||
| 219 | +/** | ||
| 220 | + * 资料分类数据 | ||
| 221 | + * @description 根据 API 返回的 children 构建 tabs,始终包含"全部"选项 | ||
| 222 | + */ | ||
| 223 | +const tabsData = computed(() => { | ||
| 224 | + // 始终包含"全部" tab | ||
| 225 | + const tabs = [ | ||
| 226 | + { id: 'all', name: '全部' } | ||
| 227 | + ] | ||
| 228 | + | ||
| 229 | + // 如果有子分类,添加到 tabs | ||
| 230 | + const children = data.value?.children || [] | ||
| 231 | + if (children.length > 0) { | ||
| 232 | + children.forEach(child => { | ||
| 233 | + tabs.push({ | ||
| 234 | + id: String(child.id), | ||
| 235 | + name: child.category_name | ||
| 236 | + }) | ||
| 237 | + }) | ||
| 238 | + } | ||
| 239 | + | ||
| 240 | + return tabs | ||
| 241 | +}) | ||
| 242 | + | ||
| 243 | +/** | ||
| 244 | + * 转换文档数据格式 | ||
| 245 | + * @description 将 API 返回的文档数据转换为组件需要的格式 | ||
| 246 | + * @param {Object} doc - API 返回的文档对象 | ||
| 247 | + * @returns {Object} 转换后的文档对象 | ||
| 248 | + */ | ||
| 249 | +const transformDocItem = (doc) => { | ||
| 250 | + // 处理文件名为空的情况 | ||
| 251 | + const fileName = doc.name || '未命名文件' | ||
| 252 | + // 如果没有扩展名,从文件名中提取(如果有) | ||
| 253 | + const extension = doc.extension || fileName.split('.').pop()?.toLowerCase() || '' | ||
| 254 | + | ||
| 255 | + return { | ||
| 256 | + id: doc.id || doc.meta_id, // 兼容 id 和 meta_id | ||
| 257 | + meta_id: doc.meta_id || doc.id, // 保存 meta_id 用于收藏 API | ||
| 258 | + title: fileName, | ||
| 259 | + desc: doc.post_date || '', | ||
| 260 | + size: doc.size || '', | ||
| 261 | + fileName: fileName, | ||
| 262 | + downloadUrl: doc.value, | ||
| 263 | + extension: extension, | ||
| 264 | + collected: doc.is_favorite === '1' || doc.is_favorite === 1 // 从 API 返回的收藏状态 | ||
| 265 | + } | ||
| 266 | +} | ||
| 267 | + | ||
| 268 | +/** | ||
| 269 | + * 获取文档分类列表 | ||
| 270 | + * @param {Object} params - 请求参数 | ||
| 271 | + * @param {string} params.cid - 分类ID(可选) | ||
| 272 | + * @param {string} params.child_id - 子分类ID(可选) | ||
| 273 | + * @param {string} params.keyword - 搜索关键词(可选) | ||
| 274 | + * @param {number} params.page - 页码(从0开始) | ||
| 275 | + * @param {number} params.limit - 每页数量 | ||
| 276 | + * @param {boolean} isLoadMore - 是否为加载更多 | ||
| 277 | + */ | ||
| 278 | +const fetchMaterialList = async (params = {}, isLoadMore = false) => { | ||
| 279 | + try { | ||
| 280 | + // 如果是加载更多,使用 loadingMore 状态,否则使用 loading 状态 | ||
| 281 | + if (isLoadMore) { | ||
| 282 | + loadingMore.value = true | ||
| 283 | + } else { | ||
| 284 | + loading.value = true | ||
| 285 | + } | ||
| 286 | + | ||
| 287 | + console.log('[Material List] 请求参数:', params) | ||
| 288 | + console.log('[Material List] 使用 Mock 数据:', USE_MOCK_DATA) | ||
| 289 | + | ||
| 290 | + // 根据开关选择使用真实 API 或 Mock 数据 | ||
| 291 | + const res = USE_MOCK_DATA | ||
| 292 | + ? await mockFileListAPI(params) | ||
| 293 | + : await fileListAPI(params) | ||
| 294 | + | ||
| 295 | + if (res.code === 1 && res.data) { | ||
| 296 | + // 如果是初始请求(没有 child_id),保存完整的分类信息 | ||
| 297 | + if (!params.child_id && !params.keyword) { | ||
| 298 | + data.value = res.data | ||
| 299 | + // console.log('[Material List] 数据:', res.data) | ||
| 300 | + // console.log('[Material List] 分类数量:', res.data.children?.length) | ||
| 301 | + // console.log('[Material List] 文档数量:', res.data.list?.length) | ||
| 302 | + | ||
| 303 | + // 处理并缓存"全部"列表 | ||
| 304 | + if (res.data.list?.length) { | ||
| 305 | + const allListData = res.data.list.map(transformDocItem) | ||
| 306 | + | ||
| 307 | + if (isLoadMore) { | ||
| 308 | + // 加载更多:追加数据 | ||
| 309 | + allList.value = [...allList.value, ...allListData] | ||
| 310 | + categoryListCache.value.set('all', allList.value) | ||
| 311 | + // ✅ 同步更新 currentList(如果当前显示的是"全部") | ||
| 312 | + if (activeTabId.value === 'all') { | ||
| 313 | + currentList.value = allList.value | ||
| 314 | + } | ||
| 315 | + } else { | ||
| 316 | + // 首次加载:替换数据 | ||
| 317 | + allList.value = allListData | ||
| 318 | + categoryListCache.value.set('all', allListData) | ||
| 319 | + // ✅ 同步更新 currentList(如果当前显示的是"全部") | ||
| 320 | + if (activeTabId.value === 'all') { | ||
| 321 | + currentList.value = allListData | ||
| 322 | + } | ||
| 323 | + } | ||
| 324 | + | ||
| 325 | + // 判断是否还有更多数据 | ||
| 326 | + hasMore.value = allListData.length >= params.limit | ||
| 327 | + } else { | ||
| 328 | + if (isLoadMore) { | ||
| 329 | + hasMore.value = false | ||
| 330 | + } else { | ||
| 331 | + allList.value = [] | ||
| 332 | + } | ||
| 333 | + } | ||
| 334 | + } else { | ||
| 335 | + // 是子分类请求或搜索请求 | ||
| 336 | + const cacheKey = params.child_id || params.keyword || 'search' | ||
| 337 | + | ||
| 338 | + if (res.data.list?.length) { | ||
| 339 | + const listData = res.data.list.map(transformDocItem) | ||
| 340 | + | ||
| 341 | + if (isLoadMore) { | ||
| 342 | + // 加载更多:追加数据 | ||
| 343 | + const existingData = categoryListCache.value.get(cacheKey) || [] | ||
| 344 | + const newData = [...existingData, ...listData] | ||
| 345 | + categoryListCache.value.set(cacheKey, newData) | ||
| 346 | + // ✅ 同步更新 currentList(如果当前显示的是该缓存) | ||
| 347 | + const currentCacheKey = params.child_id || params.keyword || 'all' | ||
| 348 | + if (activeTabId.value === currentCacheKey) { | ||
| 349 | + currentList.value = newData | ||
| 350 | + } | ||
| 351 | + } else { | ||
| 352 | + // 首次加载:替换数据 | ||
| 353 | + categoryListCache.value.set(cacheKey, listData) | ||
| 354 | + currentList.value = listData | ||
| 355 | + } | ||
| 356 | + | ||
| 357 | + // 判断是否还有更多数据 | ||
| 358 | + hasMore.value = listData.length >= params.limit | ||
| 359 | + } else { | ||
| 360 | + if (isLoadMore) { | ||
| 361 | + hasMore.value = false | ||
| 362 | + } else { | ||
| 363 | + currentList.value = [] | ||
| 364 | + } | ||
| 365 | + } | ||
| 366 | + } | ||
| 367 | + } else { | ||
| 368 | + Taro.showToast({ | ||
| 369 | + title: res.msg || '获取资料列表失败', | ||
| 370 | + icon: 'none', | ||
| 371 | + duration: 2000 | ||
| 372 | + }) | ||
| 373 | + } | ||
| 374 | + } catch (error) { | ||
| 375 | + console.error('[Material List] 获取资料列表失败:', error) | ||
| 376 | + Taro.showToast({ | ||
| 377 | + title: '加载失败', | ||
| 378 | + icon: 'error', | ||
| 379 | + duration: 2000 | ||
| 380 | + }) | ||
| 381 | + } finally { | ||
| 382 | + if (isLoadMore) { | ||
| 383 | + loadingMore.value = false | ||
| 384 | + } else { | ||
| 385 | + loading.value = false | ||
| 386 | + } | ||
| 387 | + } | ||
| 388 | +} | ||
| 389 | + | ||
| 390 | +/** | ||
| 391 | + * 页面加载时接收参数 | ||
| 392 | + */ | ||
| 393 | +useLoad(async (options) => { | ||
| 394 | + console.log('[Material List] 页面参数:', options) | ||
| 395 | + | ||
| 396 | + // 保存初始分类ID | ||
| 397 | + if (options.id) { | ||
| 398 | + initialCategoryId.value = options.id | ||
| 399 | + } | ||
| 400 | + | ||
| 401 | + // 设置页面标题 | ||
| 402 | + if (options.title) { | ||
| 403 | + pageTitle.value = options.title | ||
| 404 | + } | ||
| 405 | + | ||
| 406 | + // 重置分页状态 | ||
| 407 | + currentPage.value = 0 | ||
| 408 | + hasMore.value = true | ||
| 409 | + | ||
| 410 | + // 获取资料列表(初始请求) | ||
| 411 | + await fetchMaterialList({ | ||
| 412 | + cid: options.id, | ||
| 413 | + page: 0, | ||
| 414 | + limit: pageSize | ||
| 415 | + }) | ||
| 416 | + | ||
| 417 | + // 初始化当前列表为"全部"列表(等待请求完成后) | ||
| 418 | + currentList.value = allList.value | ||
| 419 | +}) | ||
| 420 | + | ||
| 421 | +/** | ||
| 422 | + * Tab 点击处理 | ||
| 423 | + * @param {string} id - Tab ID | ||
| 424 | + */ | ||
| 425 | +const onTabClick = async (id) => { | ||
| 426 | + activeTabId.value = id | ||
| 427 | + listVisible.value = false | ||
| 428 | + | ||
| 429 | + // 恢复或初始化该分类的分页状态 | ||
| 430 | + const pageState = categoryPageCache.value.get(id) | ||
| 431 | + if (pageState) { | ||
| 432 | + currentPage.value = pageState.currentPage | ||
| 433 | + hasMore.value = pageState.hasMore | ||
| 434 | + } else { | ||
| 435 | + currentPage.value = 0 | ||
| 436 | + hasMore.value = true | ||
| 437 | + } | ||
| 438 | + | ||
| 439 | + // 判断是否是"全部" tab | ||
| 440 | + if (id === 'all') { | ||
| 441 | + // 显示"全部"列表(从缓存或 allList) | ||
| 442 | + const cachedList = categoryListCache.value.get('all') | ||
| 443 | + currentList.value = cachedList || allList.value || [] | ||
| 444 | + } else { | ||
| 445 | + // 检查缓存中是否有该分类的数据 | ||
| 446 | + if (categoryListCache.value.has(id)) { | ||
| 447 | + // 从缓存中获取 | ||
| 448 | + currentList.value = categoryListCache.value.get(id) | ||
| 449 | + } else { | ||
| 450 | + // 调用接口获取该分类的列表(第一页) | ||
| 451 | + await fetchMaterialList({ | ||
| 452 | + cid: initialCategoryId.value, | ||
| 453 | + child_id: id, | ||
| 454 | + page: 0, | ||
| 455 | + limit: pageSize | ||
| 456 | + }) | ||
| 457 | + | ||
| 458 | + // 保存分页状态 | ||
| 459 | + categoryPageCache.value.set(id, { | ||
| 460 | + currentPage: 0, | ||
| 461 | + hasMore: hasMore.value | ||
| 462 | + }) | ||
| 463 | + } | ||
| 464 | + } | ||
| 465 | + | ||
| 466 | + nextTick(() => { | ||
| 467 | + listRenderKey.value += 1 | ||
| 468 | + listVisible.value = true | ||
| 469 | + }) | ||
| 470 | +} | ||
| 471 | + | ||
| 472 | +/** | ||
| 473 | + * 触底加载更多 | ||
| 474 | + * @description 使用防抖避免频繁触发 | ||
| 475 | + */ | ||
| 476 | +let loadMoreTimer = null | ||
| 477 | +useReachBottom(() => { | ||
| 478 | + // 如果正在加载或没有更多数据,不执行 | ||
| 479 | + if (loadingMore.value || loading.value || !hasMore.value) { | ||
| 480 | + return | ||
| 481 | + } | ||
| 482 | + | ||
| 483 | + // 防抖:300ms 内只触发一次 | ||
| 484 | + if (loadMoreTimer) { | ||
| 485 | + clearTimeout(loadMoreTimer) | ||
| 486 | + } | ||
| 487 | + | ||
| 488 | + loadMoreTimer = setTimeout(async () => { | ||
| 489 | + console.log('[Material List] 触底加载更多') | ||
| 490 | + | ||
| 491 | + // 页码 +1 | ||
| 492 | + currentPage.value += 1 | ||
| 493 | + | ||
| 494 | + // 构建请求参数 | ||
| 495 | + const params = { | ||
| 496 | + cid: initialCategoryId.value, | ||
| 497 | + page: currentPage.value, | ||
| 498 | + limit: pageSize | ||
| 499 | + } | ||
| 500 | + | ||
| 501 | + // 判断当前状态:搜索、子分类、或全部 | ||
| 502 | + const isSearching = searchValue.value.trim() !== '' | ||
| 503 | + | ||
| 504 | + if (isSearching) { | ||
| 505 | + // 搜索模式 | ||
| 506 | + params.keyword = searchValue.value.trim() | ||
| 507 | + if (activeTabId.value !== 'all') { | ||
| 508 | + params.child_id = activeTabId.value | ||
| 509 | + } | ||
| 510 | + } else { | ||
| 511 | + // 非搜索模式:如果当前选中的是子分类,添加 child_id 参数 | ||
| 512 | + if (activeTabId.value !== 'all') { | ||
| 513 | + params.child_id = activeTabId.value | ||
| 514 | + } | ||
| 515 | + } | ||
| 516 | + | ||
| 517 | + // 加载下一页数据 | ||
| 518 | + await fetchMaterialList(params, true) // true 表示加载更多 | ||
| 519 | + | ||
| 520 | + // 保存更新后的分页状态 | ||
| 521 | + let cacheKey | ||
| 522 | + if (isSearching) { | ||
| 523 | + cacheKey = params.keyword | ||
| 524 | + } else { | ||
| 525 | + cacheKey = activeTabId.value !== 'all' ? activeTabId.value : 'all' | ||
| 526 | + } | ||
| 527 | + categoryPageCache.value.set(cacheKey, { | ||
| 528 | + currentPage: currentPage.value, | ||
| 529 | + hasMore: hasMore.value | ||
| 530 | + }) | ||
| 531 | + }, 300) | ||
| 532 | +}) | ||
| 533 | + | ||
| 534 | +/** | ||
| 535 | + * 搜索处理函数 | ||
| 536 | + * @description 根据 child_id 和 keyword 调用接口查询列表 | ||
| 537 | + */ | ||
| 538 | +const onSearch = async () => { | ||
| 539 | + console.log('Searching for:', searchValue.value) | ||
| 540 | + console.log('当前分类:', activeTabId.value) | ||
| 541 | + | ||
| 542 | + // 如果没有搜索关键词,清空搜索并恢复当前分类的列表 | ||
| 543 | + if (!searchValue.value.trim()) { | ||
| 544 | + // 恢复当前分类的列表 | ||
| 545 | + if (activeTabId.value === 'all') { | ||
| 546 | + const cachedList = categoryListCache.value.get('all') | ||
| 547 | + currentList.value = cachedList || allList.value || [] | ||
| 548 | + } else { | ||
| 549 | + const cachedList = categoryListCache.value.get(activeTabId.value) | ||
| 550 | + if (cachedList) { | ||
| 551 | + currentList.value = cachedList | ||
| 552 | + } else { | ||
| 553 | + // 如果缓存中没有,调用接口获取 | ||
| 554 | + await fetchMaterialList({ | ||
| 555 | + cid: initialCategoryId.value, | ||
| 556 | + child_id: activeTabId.value, | ||
| 557 | + page: 0, | ||
| 558 | + limit: pageSize | ||
| 559 | + }) | ||
| 560 | + } | ||
| 561 | + } | ||
| 562 | + | ||
| 563 | + // 恢复分页状态 | ||
| 564 | + const pageState = categoryPageCache.value.get(activeTabId.value) | ||
| 565 | + if (pageState) { | ||
| 566 | + currentPage.value = pageState.currentPage | ||
| 567 | + hasMore.value = pageState.hasMore | ||
| 568 | + } | ||
| 569 | + return | ||
| 570 | + } | ||
| 571 | + | ||
| 572 | + // 构建请求参数 | ||
| 573 | + const params = { | ||
| 574 | + cid: initialCategoryId.value, | ||
| 575 | + page: 0, | ||
| 576 | + limit: pageSize | ||
| 577 | + } | ||
| 578 | + | ||
| 579 | + // 如果当前选中的是子分类,添加 child_id 参数 | ||
| 580 | + if (activeTabId.value !== 'all') { | ||
| 581 | + params.child_id = activeTabId.value | ||
| 582 | + } | ||
| 583 | + | ||
| 584 | + // 添加搜索关键词 | ||
| 585 | + params.keyword = searchValue.value.trim() | ||
| 586 | + | ||
| 587 | + // 重置分页状态 | ||
| 588 | + currentPage.value = 0 | ||
| 589 | + hasMore.value = true | ||
| 590 | + | ||
| 591 | + // 调用接口搜索 | ||
| 592 | + try { | ||
| 593 | + loading.value = true | ||
| 594 | + console.log('[Material List] 搜索使用 Mock 数据:', USE_MOCK_DATA) | ||
| 595 | + | ||
| 596 | + // 根据开关选择使用真实 API 或 Mock 数据 | ||
| 597 | + const res = USE_MOCK_DATA | ||
| 598 | + ? await mockFileListAPI(params) | ||
| 599 | + : await fileListAPI(params) | ||
| 600 | + | ||
| 601 | + if (res.code === 1 && res.data) { | ||
| 602 | + if (res.data.list?.length) { | ||
| 603 | + const listData = res.data.list.map(transformDocItem) | ||
| 604 | + currentList.value = listData | ||
| 605 | + | ||
| 606 | + // 缓存搜索结果 | ||
| 607 | + categoryListCache.value.set(params.keyword, listData) | ||
| 608 | + | ||
| 609 | + // 判断是否还有更多数据 | ||
| 610 | + hasMore.value = listData.length >= pageSize | ||
| 611 | + } else { | ||
| 612 | + currentList.value = [] | ||
| 613 | + hasMore.value = false | ||
| 614 | + } | ||
| 615 | + } else { | ||
| 616 | + Taro.showToast({ | ||
| 617 | + title: res.msg || '搜索失败', | ||
| 618 | + icon: 'none', | ||
| 619 | + duration: 2000 | ||
| 620 | + }) | ||
| 621 | + } | ||
| 622 | + } catch (error) { | ||
| 623 | + console.error('[Material List] 搜索失败:', error) | ||
| 624 | + Taro.showToast({ | ||
| 625 | + title: '搜索失败', | ||
| 626 | + icon: 'error', | ||
| 627 | + duration: 2000 | ||
| 628 | + }) | ||
| 629 | + } finally { | ||
| 630 | + loading.value = false | ||
| 631 | + } | ||
| 632 | +} | ||
| 633 | + | ||
| 634 | +/** | ||
| 635 | + * 监听搜索关键字变化 | ||
| 636 | + * @description 实现实时搜索:用户输入时自动触发搜索(带防抖) | ||
| 637 | + */ | ||
| 638 | +watch(searchValue, (newValue, oldValue) => { | ||
| 639 | + console.log('[Material List] searchValue 变化:', oldValue, '->', newValue) | ||
| 640 | + | ||
| 641 | + // 如果搜索关键字为空,立即清除搜索(不需要防抖) | ||
| 642 | + if (!newValue?.trim()) { | ||
| 643 | + console.log('[Material List] 搜索关键字为空,立即清除') | ||
| 644 | + onClear() | ||
| 645 | + return | ||
| 646 | + } | ||
| 647 | + | ||
| 648 | + // 有搜索关键字,使用防抖搜索 | ||
| 649 | + console.log('[Material List] 触发防抖搜索') | ||
| 650 | + debouncedSearch() | ||
| 651 | +}) | ||
| 652 | + | ||
| 653 | +/** | ||
| 654 | + * 清除搜索关键词 | ||
| 655 | + * @description 用户点击搜索框右侧的删除按钮时触发,重新请求当前分类的最新数据 | ||
| 656 | + * | ||
| 657 | + * 场景说明: | ||
| 658 | + * - 有tab:重新请求当前tab的数据(不带keyword) | ||
| 659 | + * - 无tab:重新请求"全部"数据(不带keyword) | ||
| 660 | + */ | ||
| 661 | +const onClear = async () => { | ||
| 662 | + console.log('[Material List] 清除搜索,重新请求数据') | ||
| 663 | + console.log('[Material List] 当前分类:', activeTabId.value) | ||
| 664 | + | ||
| 665 | + // 构建请求参数(不带 keyword) | ||
| 666 | + const params = { | ||
| 667 | + cid: initialCategoryId.value, | ||
| 668 | + page: 0, | ||
| 669 | + limit: pageSize | ||
| 670 | + } | ||
| 671 | + | ||
| 672 | + // 如果当前选中的是子分类,添加 child_id 参数 | ||
| 673 | + if (activeTabId.value !== 'all') { | ||
| 674 | + params.child_id = activeTabId.value | ||
| 675 | + } | ||
| 676 | + | ||
| 677 | + // 重置分页状态为第一页 | ||
| 678 | + currentPage.value = 0 | ||
| 679 | + hasMore.value = true | ||
| 680 | + | ||
| 681 | + // 重新请求接口(不带 keyword,获取最新数据) | ||
| 682 | + await fetchMaterialList(params, false) | ||
| 683 | + | ||
| 684 | + // 更新当前显示的列表 | ||
| 685 | + if (activeTabId.value === 'all') { | ||
| 686 | + // 全部列表:使用 allList | ||
| 687 | + currentList.value = allList.value | ||
| 688 | + } else { | ||
| 689 | + // 子分类列表:已经在 fetchMaterialList 中更新了 currentList | ||
| 690 | + } | ||
| 691 | +} | ||
| 692 | + | ||
| 693 | +/** | ||
| 694 | + * 使用文件列表点击处理器 | ||
| 695 | + * @description 添加图片预览功能,点击图片文件时使用 Taro.previewImage | ||
| 696 | + */ | ||
| 697 | +const { handleClick: onView } = useListItemClick({ | ||
| 698 | + listType: ListType.FILE, | ||
| 699 | + onBeforeClick: async (item) => { | ||
| 700 | + /** | ||
| 701 | + * 检查文件类型并使用对应的预览方式 | ||
| 702 | + * - 图片文件:使用 Taro.previewImage 预览 | ||
| 703 | + * - 其他文件:继续默认的文件打开流程 | ||
| 704 | + */ | ||
| 705 | + const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'] | ||
| 706 | + const extension = item.extension?.toLowerCase() || '' | ||
| 707 | + | ||
| 708 | + console.log('[Material List] 文件类型:', extension, '文件名:', item.title) | ||
| 709 | + | ||
| 710 | + if (imageExtensions.includes(extension)) { | ||
| 711 | + // 图片文件:使用 Taro 预览 | ||
| 712 | + console.log('[Material List] 检测到图片文件,使用图片预览') | ||
| 713 | + | ||
| 714 | + // 构建图片列表(当前图片) | ||
| 715 | + const urls = [item.downloadUrl] | ||
| 716 | + | ||
| 717 | + try { | ||
| 718 | + // 短暂延迟后打开预览(让用户看到提示) | ||
| 719 | + await new Promise(resolve => setTimeout(resolve, 300)) | ||
| 720 | + | ||
| 721 | + await Taro.previewImage({ | ||
| 722 | + current: item.downloadUrl, // 当前显示图片的 http 链接 | ||
| 723 | + urls: urls // 需要预览的图片 http 链接列表 | ||
| 724 | + }) | ||
| 725 | + | ||
| 726 | + // 预览成功,阻止默认的文件打开行为 | ||
| 727 | + return false | ||
| 728 | + } catch (err) { | ||
| 729 | + console.error('[Material List] 图片预览失败:', err) | ||
| 730 | + Taro.showToast({ | ||
| 731 | + title: '图片预览失败', | ||
| 732 | + icon: 'none', | ||
| 733 | + duration: 2000 | ||
| 734 | + }) | ||
| 735 | + // 预览失败,返回 true 继续默认行为 | ||
| 736 | + return true | ||
| 737 | + } | ||
| 738 | + } | ||
| 739 | + | ||
| 740 | + // 非图片文件:继续默认的文件打开流程 | ||
| 741 | + console.log('[Material List] 非图片文件,使用默认打开方式') | ||
| 742 | + return true | ||
| 743 | + }, | ||
| 744 | + onAfterClick: (item) => { | ||
| 745 | + console.log('用户打开了资料:', item.title) | ||
| 746 | + } | ||
| 747 | +}) | ||
| 748 | + | ||
| 749 | +/** | ||
| 750 | + * 切换收藏状态 | ||
| 751 | + * @description 使用 useCollectOperation composable 处理收藏操作 | ||
| 752 | + */ | ||
| 753 | +const { toggleCollect } = useCollectOperation() | ||
| 754 | + | ||
| 755 | +/** | ||
| 756 | + * 删除资料 | ||
| 757 | + */ | ||
| 758 | +const onDelete = (item) => { | ||
| 759 | + Taro.showModal({ | ||
| 760 | + title: '提示', | ||
| 761 | + content: '确定要删除该资料吗?', | ||
| 762 | + success: (res) => { | ||
| 763 | + if (res.confirm) { | ||
| 764 | + // 从 allList 中删除 | ||
| 765 | + const index = allList.value.findIndex(i => i.id === item.id) | ||
| 766 | + if (index !== -1) { | ||
| 767 | + allList.value.splice(index, 1) | ||
| 768 | + // 重新渲染列表 | ||
| 769 | + listRenderKey.value += 1 | ||
| 770 | + Taro.showToast({ title: '已删除', icon: 'success' }) | ||
| 771 | + } | ||
| 772 | + } | ||
| 773 | + } | ||
| 774 | + }) | ||
| 775 | +} | ||
| 776 | +</script> | ||
| 777 | + | ||
| 778 | +<style lang="less"> | ||
| 779 | +@keyframes slideIn { | ||
| 780 | + from { | ||
| 781 | + opacity: 0; | ||
| 782 | + transform: translateY(20rpx); | ||
| 783 | + } | ||
| 784 | + | ||
| 785 | + to { | ||
| 786 | + opacity: 1; | ||
| 787 | + transform: translateY(0); | ||
| 788 | + } | ||
| 789 | +} | ||
| 790 | + | ||
| 791 | +.material-item { | ||
| 792 | + animation: slideIn 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) backwards; | ||
| 793 | +} | ||
| 794 | + | ||
| 795 | +// FilterTabs 风格的标签栏 | ||
| 796 | +.filter-tabs-wrapper { | ||
| 797 | + display: flex; | ||
| 798 | + overflow-x: auto; | ||
| 799 | + padding: 24rpx 32rpx; | ||
| 800 | + gap: 24rpx; | ||
| 801 | + transition: all 0.3s ease; | ||
| 802 | + background-color: #F9FAFB; | ||
| 803 | + width: 100%; | ||
| 804 | + | ||
| 805 | + // 隐藏滚动条 | ||
| 806 | + &::-webkit-scrollbar { | ||
| 807 | + display: none; | ||
| 808 | + width: 0; | ||
| 809 | + height: 0; | ||
| 810 | + } | ||
| 811 | + | ||
| 812 | + -ms-overflow-style: none; | ||
| 813 | + scrollbar-width: none; | ||
| 814 | +} | ||
| 815 | + | ||
| 816 | +.filter-tab-item { | ||
| 817 | + display: flex; | ||
| 818 | + align-items: center; | ||
| 819 | + justify-content: center; | ||
| 820 | + padding: 0 32rpx; | ||
| 821 | + border-radius: 9999rpx; | ||
| 822 | + white-space: nowrap; | ||
| 823 | + transition: all 0.3s ease; | ||
| 824 | + flex-shrink: 0; | ||
| 825 | +} | ||
| 826 | + | ||
| 827 | +.filter-tab-active { | ||
| 828 | + background-color: #2563EB; // 蓝色背景 | ||
| 829 | + color: #fff; | ||
| 830 | +} | ||
| 831 | + | ||
| 832 | +.filter-tab-inactive { | ||
| 833 | + background-color: #F3F4F6; // 灰色背景 | ||
| 834 | + color: #6B7280; | ||
| 835 | +} | ||
| 836 | + | ||
| 837 | +.filter-tab-text { | ||
| 838 | + font-size: 28rpx; | ||
| 839 | + font-weight: 500; | ||
| 840 | +} | ||
| 841 | + | ||
| 842 | +// 覆盖 NutUI Tabs 默认样式,隐藏原有的头部和内容(因为我们使用自定义头部和外部列表) | ||
| 843 | +:deep(.nut-tabs__titles) { | ||
| 844 | + display: none; | ||
| 845 | +} | ||
| 846 | + | ||
| 847 | +:deep(.nut-tabs__content) { | ||
| 848 | + display: none; | ||
| 849 | +} | ||
| 850 | + | ||
| 851 | +// 加载更多容器 | ||
| 852 | +.load-more-container { | ||
| 853 | + display: flex; | ||
| 854 | + justify-content: center; | ||
| 855 | + align-items: center; | ||
| 856 | + padding: 40rpx 0; | ||
| 857 | + min-height: 80rpx; | ||
| 858 | +} | ||
| 859 | + | ||
| 860 | +.load-more-loading { | ||
| 861 | + display: flex; | ||
| 862 | + align-items: center; | ||
| 863 | + justify-content: center; | ||
| 864 | +} | ||
| 865 | + | ||
| 866 | +.load-more-finished { | ||
| 867 | + display: flex; | ||
| 868 | + align-items: center; | ||
| 869 | + justify-content: center; | ||
| 870 | +} | ||
| 871 | + | ||
| 872 | +// 自定义加载动画 | ||
| 873 | +.loading-spinner { | ||
| 874 | + width: 32rpx; | ||
| 875 | + height: 32rpx; | ||
| 876 | + border: 4rpx solid #E5E7EB; | ||
| 877 | + border-top-color: #2563EB; | ||
| 878 | + border-radius: 50%; | ||
| 879 | + animation: spin 0.8s linear infinite; | ||
| 880 | +} | ||
| 881 | + | ||
| 882 | +@keyframes spin { | ||
| 883 | + to { | ||
| 884 | + transform: rotate(360deg); | ||
| 885 | + } | ||
| 886 | +} | ||
| 887 | +</style> |
| 1 | +<template> | ||
| 2 | + <view class="min-h-screen bg-[#F9FAFB] pb-safe"> | ||
| 3 | + <NavHeader title="我的消息" /> | ||
| 4 | + | ||
| 5 | + <!-- 列表区域 --> | ||
| 6 | + <view class="p-4"> | ||
| 7 | + <template v-if="messageList.length > 0"> | ||
| 8 | + <view | ||
| 9 | + v-for="item in messageList" | ||
| 10 | + :key="item.id" | ||
| 11 | + class="bg-white rounded-xl p-4 mb-3 shadow-sm active:opacity-70 transition-opacity" | ||
| 12 | + @tap="handleItemClick(item)" | ||
| 13 | + > | ||
| 14 | + <view class="flex justify-between items-start mb-2"> | ||
| 15 | + <view class="flex-1 mr-2"> | ||
| 16 | + <view class="text-base font-bold text-gray-900 line-clamp-1"> | ||
| 17 | + {{ item.title }} | ||
| 18 | + </view> | ||
| 19 | + </view> | ||
| 20 | + <text class="text-xs text-gray-400 shrink-0 mt-1"> | ||
| 21 | + {{ item.create_time }} | ||
| 22 | + </text> | ||
| 23 | + </view> | ||
| 24 | + | ||
| 25 | + <view class="text-sm text-gray-600 line-clamp-2 leading-relaxed"> | ||
| 26 | + {{ item.intro || item.content || '暂无简介' }} | ||
| 27 | + </view> | ||
| 28 | + </view> | ||
| 29 | + | ||
| 30 | + <!-- 加载更多/没有更多 --> | ||
| 31 | + <view class="py-4 text-center text-[24rpx] text-gray-400"> | ||
| 32 | + <text v-if="loading">加载中...</text> | ||
| 33 | + <text v-else-if="!hasMore">没有更多了</text> | ||
| 34 | + <text v-else>上拉加载更多</text> | ||
| 35 | + </view> | ||
| 36 | + </template> | ||
| 37 | + | ||
| 38 | + <!-- 空状态 --> | ||
| 39 | + <nut-empty | ||
| 40 | + v-else-if="!loading && messageList.length === 0" | ||
| 41 | + description="暂无消息" | ||
| 42 | + image="empty" | ||
| 43 | + /> | ||
| 44 | + </view> | ||
| 45 | + </view> | ||
| 46 | +</template> | ||
| 47 | + | ||
| 48 | +<script setup> | ||
| 49 | +import { ref } from 'vue' | ||
| 50 | +import { useLoad, usePullDownRefresh, useReachBottom, stopPullDownRefresh } from '@tarojs/taro' | ||
| 51 | +import { useGo } from '@/hooks/useGo' | ||
| 52 | +import NavHeader from '@/components/NavHeader.vue' | ||
| 53 | +import { myListAPI } from '@/api/news' | ||
| 54 | +import { mockMessageListAPI } from '@/utils/mockData' | ||
| 55 | + | ||
| 56 | +// ⚠️ MOCK 数据开关 - 开发环境使用 mock 数据,生产环境使用真实 API | ||
| 57 | +const USE_MOCK_DATA = process.env.NODE_ENV === 'development' | ||
| 58 | + | ||
| 59 | +const go = useGo() | ||
| 60 | + | ||
| 61 | +const messageList = ref([]) | ||
| 62 | +const page = ref(1) | ||
| 63 | +const limit = ref(10) | ||
| 64 | +const hasMore = ref(true) | ||
| 65 | +const loading = ref(false) | ||
| 66 | + | ||
| 67 | +/** | ||
| 68 | + * @description 加载消息列表 | ||
| 69 | + * @param {boolean} refresh 是否刷新 | ||
| 70 | + */ | ||
| 71 | +const fetchMessageList = async (refresh = false) => { | ||
| 72 | + if (loading.value) return | ||
| 73 | + | ||
| 74 | + if (refresh) { | ||
| 75 | + page.value = 1 | ||
| 76 | + hasMore.value = true | ||
| 77 | + } else if (!hasMore.value) { | ||
| 78 | + return | ||
| 79 | + } | ||
| 80 | + | ||
| 81 | + loading.value = true | ||
| 82 | + | ||
| 83 | + try { | ||
| 84 | + console.log('[Message] 使用 Mock 数据:', USE_MOCK_DATA) | ||
| 85 | + | ||
| 86 | + // 根据开关选择使用真实 API 或 Mock 数据 | ||
| 87 | + const res = USE_MOCK_DATA | ||
| 88 | + ? await mockMessageListAPI({ | ||
| 89 | + page: page.value, | ||
| 90 | + limit: limit.value | ||
| 91 | + }) | ||
| 92 | + : await myListAPI({ | ||
| 93 | + page: page.value, | ||
| 94 | + limit: limit.value | ||
| 95 | + }) | ||
| 96 | + | ||
| 97 | + if (res.code === 1) { | ||
| 98 | + const list = res.data?.list || [] | ||
| 99 | + | ||
| 100 | + if (refresh) { | ||
| 101 | + messageList.value = list | ||
| 102 | + } else { | ||
| 103 | + messageList.value = [...messageList.value, ...list] | ||
| 104 | + } | ||
| 105 | + | ||
| 106 | + if (list.length < limit.value) { | ||
| 107 | + hasMore.value = false | ||
| 108 | + } else { | ||
| 109 | + page.value++ | ||
| 110 | + } | ||
| 111 | + } | ||
| 112 | + } catch (err) { | ||
| 113 | + console.error('获取消息列表失败:', err) | ||
| 114 | + } finally { | ||
| 115 | + loading.value = false | ||
| 116 | + if (refresh) { | ||
| 117 | + stopPullDownRefresh() | ||
| 118 | + } | ||
| 119 | + } | ||
| 120 | +} | ||
| 121 | + | ||
| 122 | +/** | ||
| 123 | + * @description 跳转到详情页 | ||
| 124 | + * @param {Object} item 消息对象 | ||
| 125 | + */ | ||
| 126 | +const handleItemClick = (item) => { | ||
| 127 | + go('/pages/message-detail/index', { id: item.id }) | ||
| 128 | +} | ||
| 129 | + | ||
| 130 | +// 页面加载 | ||
| 131 | +useLoad(() => { | ||
| 132 | + fetchMessageList(true) | ||
| 133 | +}) | ||
| 134 | + | ||
| 135 | +// 下拉刷新 | ||
| 136 | +usePullDownRefresh(() => { | ||
| 137 | + fetchMessageList(true) | ||
| 138 | +}) | ||
| 139 | + | ||
| 140 | +// 上拉加载更多 | ||
| 141 | +useReachBottom(() => { | ||
| 142 | + fetchMessageList() | ||
| 143 | +}) | ||
| 144 | +</script> | ||
| 145 | + | ||
| 146 | +<style lang="less"> | ||
| 147 | +/* Scoped styles if needed */ | ||
| 148 | +</style> |
| 1 | +<!-- | ||
| 2 | + * @Date: 2026-01-31 | ||
| 3 | + * @Description: 产品中心 - API 接口集成版本(含搜索功能) | ||
| 4 | +--> | ||
| 5 | +<template> | ||
| 6 | + <view class="bg-[#F9FAFB]"> | ||
| 7 | + <!-- 固定在顶部的导航和搜索 --> | ||
| 8 | + <view class="bg-[#F9FAFB] sticky top-0 z-10"> | ||
| 9 | + <NavHeader title="产品中心" /> | ||
| 10 | + | ||
| 11 | + <!-- Search Bar --> | ||
| 12 | + <view class="px-[24rpx] py-[16rpx] bg-white"> | ||
| 13 | + <SearchBar | ||
| 14 | + v-model="searchValue" | ||
| 15 | + placeholder="搜索产品名称..." | ||
| 16 | + variant="rounded" | ||
| 17 | + :show-clear="true" | ||
| 18 | + @search="onSearch" | ||
| 19 | + @input="onSearchInput" | ||
| 20 | + @clear="onClear" | ||
| 21 | + /> | ||
| 22 | + </view> | ||
| 23 | + | ||
| 24 | + <!-- Tabs Container --> | ||
| 25 | + <view class="bg-white mt-[2rpx]"> | ||
| 26 | + <nut-tabs v-model="activeTabId"> | ||
| 27 | + <!-- 自定义标签栏 --> | ||
| 28 | + <template #titles> | ||
| 29 | + <view class="filter-tabs-wrapper"> | ||
| 30 | + <view | ||
| 31 | + v-for="item in tabsData" | ||
| 32 | + :key="item.id" | ||
| 33 | + :class="[ | ||
| 34 | + 'filter-tab-item', | ||
| 35 | + activeTabId === item.id ? 'filter-tab-active' : 'filter-tab-inactive' | ||
| 36 | + ]" | ||
| 37 | + @tap="onTabClick(item.id)" | ||
| 38 | + > | ||
| 39 | + <text class="filter-tab-text">{{ item.name }}</text> | ||
| 40 | + </view> | ||
| 41 | + </view> | ||
| 42 | + </template> | ||
| 43 | + </nut-tabs> | ||
| 44 | + </view> | ||
| 45 | + </view> | ||
| 46 | + | ||
| 47 | + <!-- 列表容器 - 页面级滚动 --> | ||
| 48 | + <view class="pb-[calc(160rpx+env(safe-area-inset-bottom))]"> | ||
| 49 | + <!-- 加载状态 --> | ||
| 50 | + <view v-if="loading && products.length === 0" class="flex justify-center items-center py-[100rpx]"> | ||
| 51 | + <text class="text-gray-400 text-[28rpx]">加载中...</text> | ||
| 52 | + </view> | ||
| 53 | + | ||
| 54 | + <!-- Product List --> | ||
| 55 | + <view v-else class="px-[40rpx]"> | ||
| 56 | + <!-- Card Item --> | ||
| 57 | + <view v-for="(item, index) in products" :key="item.id" | ||
| 58 | + class="bg-white rounded-[24rpx] overflow-hidden mb-[24rpx] shadow-sm active:scale-[0.98] transition-transform duration-200" | ||
| 59 | + :style="{ animationDelay: `${index * 50}ms` }" | ||
| 60 | + > | ||
| 61 | + <!-- Product Content (Horizontal Layout) --> | ||
| 62 | + <view class="flex gap-[24rpx]" @tap="handleProductClick(item)"> | ||
| 63 | + <!-- Image Container --> | ||
| 64 | + <view class="relative w-[220rpx] h-[220rpx] flex-shrink-0 product-card-item"> | ||
| 65 | + <image :src="item.cover_image" class="w-full h-full object-cover bg-gray-100" mode="aspectFill" /> | ||
| 66 | + <!-- Tag --> | ||
| 67 | + <view v-if="item.recommend === 'hot'" | ||
| 68 | + class="absolute top-[12rpx] right-[12rpx] bg-red-500 text-white text-[20rpx] px-[12rpx] py-[4rpx] rounded-full"> | ||
| 69 | + 热卖 | ||
| 70 | + </view> | ||
| 71 | + </view> | ||
| 72 | + | ||
| 73 | + <!-- Content --> | ||
| 74 | + <view class="flex-1 flex flex-col py-[20rpx] pr-[20rpx]"> | ||
| 75 | + <!-- Title --> | ||
| 76 | + <view class="text-[#1F2937] text-[32rpx] font-medium leading-[1.4] line-clamp-2 mb-[12rpx]"> | ||
| 77 | + {{ item.product_name }} | ||
| 78 | + </view> | ||
| 79 | + | ||
| 80 | + <!-- 动态标签 --> | ||
| 81 | + <view v-if="item.tags && item.tags.length > 0" class="flex flex-wrap gap-[8rpx] mt-[8rpx] mb-[8rpx]"> | ||
| 82 | + <view | ||
| 83 | + v-for="tag in item.tags" | ||
| 84 | + :key="tag.id" | ||
| 85 | + class="text-[20rpx] px-[12rpx] py-[4rpx] rounded-full" | ||
| 86 | + :style="{ | ||
| 87 | + backgroundColor: tag.bg_color, | ||
| 88 | + color: tag.text_color | ||
| 89 | + }" | ||
| 90 | + > | ||
| 91 | + {{ tag.name }} | ||
| 92 | + </view> | ||
| 93 | + </view> | ||
| 94 | + | ||
| 95 | + <!-- 按钮组 - 靠右对齐(纯文字按钮) --> | ||
| 96 | + <view class="flex gap-[16rpx] ml-auto items-center mt-auto" @tap.stop> | ||
| 97 | + <!-- 详情按钮 --> | ||
| 98 | + <view | ||
| 99 | + class="text-[24rpx] text-[#2563EB] bg-blue-50 px-[24rpx] py-[8rpx] rounded-full active:bg-blue-100 transition-colors duration-200" | ||
| 100 | + @tap.stop="handleProductClick(item)" | ||
| 101 | + > | ||
| 102 | + 详情 | ||
| 103 | + </view> | ||
| 104 | + | ||
| 105 | + <!-- 计划书按钮 --> | ||
| 106 | + <view | ||
| 107 | + class="text-[24rpx] text-white bg-blue-500 px-[24rpx] py-[8rpx] rounded-full active:bg-blue-600 transition-colors duration-200" | ||
| 108 | + @tap.stop="openPlanPopup(item)" | ||
| 109 | + > | ||
| 110 | + 计划书 | ||
| 111 | + </view> | ||
| 112 | + </view> | ||
| 113 | + </view> | ||
| 114 | + </view> | ||
| 115 | + </view> | ||
| 116 | + </view> | ||
| 117 | + | ||
| 118 | + <!-- 加载更多状态 --> | ||
| 119 | + <view v-if="loading && products.length > 0" class="flex justify-center items-center py-[40rpx]"> | ||
| 120 | + <text class="text-gray-400 text-[28rpx]">加载中...</text> | ||
| 121 | + </view> | ||
| 122 | + | ||
| 123 | + <!-- 没有更多数据 --> | ||
| 124 | + <view v-if="!hasMore && products.length > 0" class="flex justify-center items-center py-[40rpx]"> | ||
| 125 | + <text class="text-gray-400 text-[24rpx]">没有更多了</text> | ||
| 126 | + </view> | ||
| 127 | + | ||
| 128 | + <!-- 空状态 --> | ||
| 129 | + <view v-if="!loading && products.length === 0"> | ||
| 130 | + <nut-empty description="暂无相关产品" image="empty" /> | ||
| 131 | + </view> | ||
| 132 | + </view> | ||
| 133 | + | ||
| 134 | + <!-- 计划书表单容器 --> | ||
| 135 | + <!-- 测试数据:后端接口和字段还没有准备好,使用 PlanFormContainer 进行的前端测试 --> | ||
| 136 | + <!-- 使用 v-if 条件渲染,避免 selectedProduct 为 null 时的 prop 类型检查错误 --> | ||
| 137 | + <view v-if="showPlanPopup && selectedProduct"> | ||
| 138 | + <PlanFormContainer | ||
| 139 | + v-model:visible="showPlanPopup" | ||
| 140 | + :product="selectedProduct" | ||
| 141 | + @close="showPlanPopup = false" | ||
| 142 | + @submit="handlePlanSubmit" | ||
| 143 | + /> | ||
| 144 | + </view> | ||
| 145 | + </view> | ||
| 146 | +</template> | ||
| 147 | + | ||
| 148 | +<script setup> | ||
| 149 | +import { ref, computed } from 'vue' | ||
| 150 | +import Taro, { useLoad, useReachBottom } from '@tarojs/taro' | ||
| 151 | +import NavHeader from '@/components/NavHeader.vue' | ||
| 152 | +import SearchBar from '@/components/SearchBar.vue' | ||
| 153 | +import PlanFormContainer from '@/components/PlanFormContainer.vue' | ||
| 154 | +import { useListItemClick, ListType } from '@/composables/useListItemClick' | ||
| 155 | +import { listAPI } from '@/api/get_product' | ||
| 156 | +import { mockProductListAPI } from '@/utils/mockData' | ||
| 157 | + | ||
| 158 | +// ⚠️ MOCK 数据开关 - 开发环境使用 mock 数据,生产环境使用真实 API | ||
| 159 | +const USE_MOCK_DATA = process.env.NODE_ENV === 'development' | ||
| 160 | + | ||
| 161 | +const activeTabId = ref('') | ||
| 162 | + | ||
| 163 | +// 搜索状态 | ||
| 164 | +const searchValue = ref('') | ||
| 165 | +// 搜索防抖定时器 | ||
| 166 | +let searchTimer = null | ||
| 167 | + | ||
| 168 | +// 分页状态 | ||
| 169 | +const page = ref(0) | ||
| 170 | +const limit = ref(10) | ||
| 171 | +const loading = ref(false) | ||
| 172 | +const hasMore = ref(true) | ||
| 173 | + | ||
| 174 | +// 数据状态 | ||
| 175 | +const categories = ref([]) // 从接口获取的分类列表 | ||
| 176 | +const products = ref([]) // 当前产品列表 | ||
| 177 | +const total = ref(0) // 产品总数 | ||
| 178 | + | ||
| 179 | +// 计划书弹窗状态 | ||
| 180 | +const showPlanPopup = ref(false) | ||
| 181 | +const selectedProduct = ref(null) | ||
| 182 | + | ||
| 183 | +/** | ||
| 184 | + * 标签栏数据(根据接口返回的 categories 生成) | ||
| 185 | + * @description 包含"全部"选项和接口返回的分类 | ||
| 186 | + */ | ||
| 187 | +const tabsData = computed(() => { | ||
| 188 | + const allTab = { id: '', name: '全部' } | ||
| 189 | + const categoryTabs = categories.value.map(cat => ({ | ||
| 190 | + id: String(cat.id), | ||
| 191 | + name: cat.name | ||
| 192 | + })) | ||
| 193 | + return [allTab, ...categoryTabs] | ||
| 194 | +}) | ||
| 195 | + | ||
| 196 | +/** | ||
| 197 | + * 获取产品列表 | ||
| 198 | + * @description 根据 activeTabId 和 searchValue 获取对应分类的产品列表 | ||
| 199 | + */ | ||
| 200 | +const fetchProducts = async (isLoadMore = false) => { | ||
| 201 | + if (loading.value) return | ||
| 202 | + | ||
| 203 | + loading.value = true | ||
| 204 | + | ||
| 205 | + try { | ||
| 206 | + const params = { | ||
| 207 | + page: String(page.value), | ||
| 208 | + limit: String(limit.value) | ||
| 209 | + } | ||
| 210 | + | ||
| 211 | + // 如果不是"全部"标签,添加分类 ID 参数 | ||
| 212 | + if (activeTabId.value !== '') { | ||
| 213 | + params.cid = activeTabId.value | ||
| 214 | + } | ||
| 215 | + | ||
| 216 | + // 添加搜索关键词参数 | ||
| 217 | + if (searchValue.value) { | ||
| 218 | + params.keyword = searchValue.value | ||
| 219 | + } | ||
| 220 | + | ||
| 221 | + console.log('[Product Center] 使用 Mock 数据:', USE_MOCK_DATA) | ||
| 222 | + | ||
| 223 | + // 根据开关选择使用真实 API 或 Mock 数据 | ||
| 224 | + const res = USE_MOCK_DATA | ||
| 225 | + ? await mockProductListAPI(params) | ||
| 226 | + : await listAPI(params) | ||
| 227 | + | ||
| 228 | + if (res.code === 1 && res.data) { | ||
| 229 | + // 更新分类列表(首次加载时) | ||
| 230 | + if (!isLoadMore && res.data.categories) { | ||
| 231 | + categories.value = res.data.categories | ||
| 232 | + } | ||
| 233 | + | ||
| 234 | + // 处理产品列表 | ||
| 235 | + if (isLoadMore) { | ||
| 236 | + // 加载更多:追加数据 | ||
| 237 | + products.value = [...products.value, ...res.data.list] | ||
| 238 | + } else { | ||
| 239 | + // 首次加载或切换分类:替换数据 | ||
| 240 | + products.value = res.data.list || [] | ||
| 241 | + } | ||
| 242 | + | ||
| 243 | + // 更新总数和分页状态 | ||
| 244 | + total.value = res.data.total || 0 | ||
| 245 | + hasMore.value = products.value.length < total.value | ||
| 246 | + } else { | ||
| 247 | + Taro.showToast({ | ||
| 248 | + title: res.msg || '获取产品列表失败', | ||
| 249 | + icon: 'none' | ||
| 250 | + }) | ||
| 251 | + } | ||
| 252 | + } catch (err) { | ||
| 253 | + console.error('获取产品列表失败:', err) | ||
| 254 | + Taro.showToast({ | ||
| 255 | + title: '网络错误,请重试', | ||
| 256 | + icon: 'none' | ||
| 257 | + }) | ||
| 258 | + } finally { | ||
| 259 | + loading.value = false | ||
| 260 | + } | ||
| 261 | +} | ||
| 262 | + | ||
| 263 | +/** | ||
| 264 | + * Tab 点击处理 | ||
| 265 | + * @description 切换分类,重置分页并重新加载数据 | ||
| 266 | + */ | ||
| 267 | +const onTabClick = (id) => { | ||
| 268 | + if (activeTabId.value === id) return | ||
| 269 | + | ||
| 270 | + activeTabId.value = id | ||
| 271 | + | ||
| 272 | + // 重置分页状态 | ||
| 273 | + page.value = 0 | ||
| 274 | + products.value = [] | ||
| 275 | + hasMore.value = true | ||
| 276 | + | ||
| 277 | + // 重新加载数据(保持搜索状态) | ||
| 278 | + fetchProducts(false) | ||
| 279 | +} | ||
| 280 | + | ||
| 281 | +/** | ||
| 282 | + * 搜索输入处理(带防抖) | ||
| 283 | + * @description 用户输入时实时搜索,使用防抖优化性能 | ||
| 284 | + * @param {string} value - 搜索关键词 | ||
| 285 | + */ | ||
| 286 | +const onSearchInput = (value) => { | ||
| 287 | + console.log('搜索输入:', value) | ||
| 288 | + | ||
| 289 | + // 清除之前的定时器 | ||
| 290 | + if (searchTimer) { | ||
| 291 | + clearTimeout(searchTimer) | ||
| 292 | + } | ||
| 293 | + | ||
| 294 | + // 设置新的定时器(500ms 后执行搜索) | ||
| 295 | + searchTimer = setTimeout(() => { | ||
| 296 | + // 重置分页状态 | ||
| 297 | + page.value = 0 | ||
| 298 | + products.value = [] | ||
| 299 | + hasMore.value = true | ||
| 300 | + | ||
| 301 | + // 重新加载数据 | ||
| 302 | + fetchProducts(false) | ||
| 303 | + }, 500) | ||
| 304 | +} | ||
| 305 | + | ||
| 306 | +/** | ||
| 307 | + * 搜索处理(回车键) | ||
| 308 | + * @description 用户按下回车或点击搜索按钮时触发 | ||
| 309 | + * @param {string} value - 搜索关键词 | ||
| 310 | + */ | ||
| 311 | +const onSearch = (value) => { | ||
| 312 | + console.log('搜索产品:', value) | ||
| 313 | + | ||
| 314 | + // 清除防抖定时器 | ||
| 315 | + if (searchTimer) { | ||
| 316 | + clearTimeout(searchTimer) | ||
| 317 | + searchTimer = null | ||
| 318 | + } | ||
| 319 | + | ||
| 320 | + // 重置分页状态 | ||
| 321 | + page.value = 0 | ||
| 322 | + products.value = [] | ||
| 323 | + hasMore.value = true | ||
| 324 | + | ||
| 325 | + // 重新加载数据 | ||
| 326 | + fetchProducts(false) | ||
| 327 | +} | ||
| 328 | + | ||
| 329 | +/** | ||
| 330 | + * 清空搜索 | ||
| 331 | + * @description 用户点击清除按钮时触发 | ||
| 332 | + */ | ||
| 333 | +const onClear = () => { | ||
| 334 | + console.log('清空搜索') | ||
| 335 | + | ||
| 336 | + // 清除防抖定时器 | ||
| 337 | + if (searchTimer) { | ||
| 338 | + clearTimeout(searchTimer) | ||
| 339 | + searchTimer = null | ||
| 340 | + } | ||
| 341 | + | ||
| 342 | + // 重置分页状态 | ||
| 343 | + page.value = 0 | ||
| 344 | + products.value = [] | ||
| 345 | + hasMore.value = true | ||
| 346 | + | ||
| 347 | + // 重新加载数据 | ||
| 348 | + fetchProducts(false) | ||
| 349 | +} | ||
| 350 | + | ||
| 351 | +/** | ||
| 352 | + * 使用产品列表点击处理器 | ||
| 353 | + * @description 配置为产品类型列表,点击时跳转到产品详情页 | ||
| 354 | + */ | ||
| 355 | +const { handleClick: handleProductClick } = useListItemClick({ | ||
| 356 | + listType: ListType.PRODUCT, | ||
| 357 | + onAfterClick: (item) => { | ||
| 358 | + console.log('用户查看了产品:', item.product_name) | ||
| 359 | + } | ||
| 360 | +}) | ||
| 361 | + | ||
| 362 | +/** | ||
| 363 | + * 打开计划书弹窗 | ||
| 364 | + * @description 根据产品对象打开计划书表单 | ||
| 365 | + * @param {Object} product - 产品对象 | ||
| 366 | + */ | ||
| 367 | +const openPlanPopup = (product) => { | ||
| 368 | + selectedProduct.value = product | ||
| 369 | + showPlanPopup.value = true | ||
| 370 | +} | ||
| 371 | + | ||
| 372 | +/** | ||
| 373 | + * 处理计划书提交 | ||
| 374 | + * @description 测试环境:前端不调用后端API,直接跳转到结果页 | ||
| 375 | + * 生产环境:需要调用 submitPlanAPI 提交表单数据 | ||
| 376 | + * @param {Object} formData - 表单数据 | ||
| 377 | + */ | ||
| 378 | +const handlePlanSubmit = (formData) => { | ||
| 379 | + console.log('计划书提交:', { | ||
| 380 | + product_id: selectedProduct.value.id, | ||
| 381 | + product_name: selectedProduct.value.product_name, | ||
| 382 | + form_sn: selectedProduct.value.form_sn, | ||
| 383 | + form_data: formData | ||
| 384 | + }) | ||
| 385 | + | ||
| 386 | + // 关闭弹窗 | ||
| 387 | + showPlanPopup.value = false | ||
| 388 | + | ||
| 389 | + // TODO: 后端接口还没有准备好,暂时不调用API | ||
| 390 | + // 测试完成后需要对接 submitPlanAPI | ||
| 391 | + // const res = await submitPlanAPI({ | ||
| 392 | + // product_id: selectedProduct.value.id, | ||
| 393 | + // template: selectedProduct.value.form_sn, | ||
| 394 | + // form_data: formData | ||
| 395 | + // }) | ||
| 396 | + | ||
| 397 | + // 模拟提交成功,跳转到结果页面 | ||
| 398 | + Taro.navigateTo({ | ||
| 399 | + url: '/pages/plan-submit-result/index?success=true' | ||
| 400 | + }) | ||
| 401 | +} | ||
| 402 | + | ||
| 403 | +/** | ||
| 404 | + * 页面加载时获取数据 | ||
| 405 | + */ | ||
| 406 | +useLoad(() => { | ||
| 407 | + fetchProducts(false) | ||
| 408 | +}) | ||
| 409 | + | ||
| 410 | +/** | ||
| 411 | + * 触底加载更多 | ||
| 412 | + * @description 使用 Taro 的 useReachBottom hook 监听页面滚动到底部 | ||
| 413 | + */ | ||
| 414 | +useReachBottom(() => { | ||
| 415 | + console.log('滚动到底部,加载更多') | ||
| 416 | + | ||
| 417 | + if (!hasMore.value || loading.value) { | ||
| 418 | + console.log('没有更多数据或正在加载中,跳过') | ||
| 419 | + return | ||
| 420 | + } | ||
| 421 | + | ||
| 422 | + page.value += 1 | ||
| 423 | + fetchProducts(true) | ||
| 424 | +}) | ||
| 425 | +</script> | ||
| 426 | + | ||
| 427 | +<style lang="less"> | ||
| 428 | +@keyframes slideIn { | ||
| 429 | + from { | ||
| 430 | + opacity: 0; | ||
| 431 | + transform: translateY(20rpx); | ||
| 432 | + } | ||
| 433 | + | ||
| 434 | + to { | ||
| 435 | + opacity: 1; | ||
| 436 | + transform: translateY(0); | ||
| 437 | + } | ||
| 438 | +} | ||
| 439 | + | ||
| 440 | +.product-card-item { | ||
| 441 | + animation: slideIn 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) backwards; | ||
| 442 | +} | ||
| 443 | + | ||
| 444 | +// FilterTabs 风格的标签栏 | ||
| 445 | +.filter-tabs-wrapper { | ||
| 446 | + display: flex; | ||
| 447 | + overflow-x: auto; | ||
| 448 | + padding: 24rpx 40rpx; | ||
| 449 | + gap: 24rpx; | ||
| 450 | + transition: all 0.3s ease; | ||
| 451 | + background-color: #F9FAFB; | ||
| 452 | + width: 100%; | ||
| 453 | + | ||
| 454 | + // 隐藏滚动条 | ||
| 455 | + &::-webkit-scrollbar { | ||
| 456 | + display: none; | ||
| 457 | + width: 0; | ||
| 458 | + height: 0; | ||
| 459 | + } | ||
| 460 | + | ||
| 461 | + -ms-overflow-style: none; | ||
| 462 | + scrollbar-width: none; | ||
| 463 | +} | ||
| 464 | + | ||
| 465 | +.filter-tab-item { | ||
| 466 | + display: flex; | ||
| 467 | + align-items: center; | ||
| 468 | + justify-content: center; | ||
| 469 | + padding: 0 32rpx; | ||
| 470 | + border-radius: 9999rpx; | ||
| 471 | + white-space: nowrap; | ||
| 472 | + transition: all 0.3s ease; | ||
| 473 | + flex-shrink: 0; | ||
| 474 | +} | ||
| 475 | + | ||
| 476 | +.filter-tab-active { | ||
| 477 | + background-color: #2563EB; // 蓝色背景 | ||
| 478 | + color: #fff; | ||
| 479 | +} | ||
| 480 | + | ||
| 481 | +.filter-tab-inactive { | ||
| 482 | + background-color: #F3F4F6; // 灰色背景 | ||
| 483 | + color: #6B7280; | ||
| 484 | +} | ||
| 485 | + | ||
| 486 | +.filter-tab-text { | ||
| 487 | + font-size: 28rpx; | ||
| 488 | + font-weight: 500; | ||
| 489 | +} | ||
| 490 | + | ||
| 491 | +// 覆盖 NutUI Tabs 默认样式,隐藏原有的头部和内容(因为我们使用自定义头部和外部列表) | ||
| 492 | +:deep(.nut-tabs__titles) { | ||
| 493 | + display: none; | ||
| 494 | +} | ||
| 495 | + | ||
| 496 | +:deep(.nut-tabs__content) { | ||
| 497 | + display: none; | ||
| 498 | +} | ||
| 499 | + | ||
| 500 | +/* 多行文本省略 */ | ||
| 501 | +.line-clamp-2 { | ||
| 502 | + display: -webkit-box; | ||
| 503 | + -webkit-box-orient: vertical; | ||
| 504 | + -webkit-line-clamp: 2; | ||
| 505 | + line-clamp: 2; | ||
| 506 | + overflow: hidden; | ||
| 507 | + word-break: break-all; | ||
| 508 | +} | ||
| 509 | +</style> |
| 1 | +<!-- | ||
| 2 | + * @Date: 2026-02-06 | ||
| 3 | + * @Description: 搜索页面 - 支持产品和资料搜索,实时查询API | ||
| 4 | +--> | ||
| 5 | +<template> | ||
| 6 | + <view class="bg-[#FFF]"> | ||
| 7 | + <!-- 固定顶部:导航栏 + 搜索栏 + Tabs + 结果计数 --> | ||
| 8 | + <view class="bg-[#FFF] sticky top-0 z-10"> | ||
| 9 | + <NavHeader title="搜索" /> | ||
| 10 | + | ||
| 11 | + <!-- Search Input --> | ||
| 12 | + <view class="px-[40rpx] mt-[32rpx]"> | ||
| 13 | + <SearchBar | ||
| 14 | + v-model="searchKeyword" | ||
| 15 | + placeholder="搜索培训资料、案例、产品..." | ||
| 16 | + variant="rounded" | ||
| 17 | + :show-border="true" | ||
| 18 | + :show-clear="true" | ||
| 19 | + @search="handleSearch" | ||
| 20 | + @clear="clearSearch" | ||
| 21 | + /> | ||
| 22 | + </view> | ||
| 23 | + | ||
| 24 | + <!-- Tabs Container --> | ||
| 25 | + <nut-tabs v-model="activeTab"> | ||
| 26 | + <!-- 自定义标签栏 --> | ||
| 27 | + <template #titles> | ||
| 28 | + <view class="filter-tabs-wrapper"> | ||
| 29 | + <view | ||
| 30 | + v-for="item in tabsData" | ||
| 31 | + :key="item.id" | ||
| 32 | + :class="[ | ||
| 33 | + 'filter-tab-item', | ||
| 34 | + activeTab === item.id ? 'filter-tab-active' : 'filter-tab-inactive', | ||
| 35 | + !activeTab ? 'filter-tab-inactive' : '' // 初始状态不高亮任何tab | ||
| 36 | + ]" | ||
| 37 | + @tap="onTabClick(item.id)" | ||
| 38 | + > | ||
| 39 | + <text class="filter-tab-text">{{ item.name }}</text> | ||
| 40 | + </view> | ||
| 41 | + </view> | ||
| 42 | + </template> | ||
| 43 | + </nut-tabs> | ||
| 44 | + | ||
| 45 | + <!-- Result Count --> | ||
| 46 | + <view v-if="currentList.length > 0" class="px-[60rpx] text-[#6B7280] text-[24rpx] pb-[24rpx]"> | ||
| 47 | + 找到 {{ currentTotal }} 个相关结果 | ||
| 48 | + </view> | ||
| 49 | + </view> | ||
| 50 | + | ||
| 51 | + <!-- 列表容器 --> | ||
| 52 | + <view class="px-[40rpx] pb-[calc(160rpx+env(safe-area-inset-bottom))]"> | ||
| 53 | + | ||
| 54 | + <!-- Search Results --> | ||
| 55 | + <view | ||
| 56 | + v-if="currentList.length > 0" | ||
| 57 | + :key="listRenderKey" | ||
| 58 | + > | ||
| 59 | + <!-- Product Results --> | ||
| 60 | + <view v-if="activeTab === 'product'" class="flex flex-col gap-[24rpx] pb-[40rpx]"> | ||
| 61 | + <ProductCard | ||
| 62 | + v-for="(item, index) in currentList" | ||
| 63 | + :key="index" | ||
| 64 | + :product-id="item.id" | ||
| 65 | + :product-name="item.product_name || item.name" | ||
| 66 | + :tags="item.tags || []" | ||
| 67 | + class="search-result-item" | ||
| 68 | + :style="{ animationDelay: `${index * 30}ms` }" | ||
| 69 | + @detail="goToProductDetail" | ||
| 70 | + @plan="openPlanPopup" | ||
| 71 | + /> | ||
| 72 | + </view> | ||
| 73 | + | ||
| 74 | + <!-- File Results --> | ||
| 75 | + <view v-else-if="activeTab === 'file'" class="flex flex-col gap-[24rpx] pb-[40rpx]"> | ||
| 76 | + <MaterialCard | ||
| 77 | + v-for="(item, index) in currentList" | ||
| 78 | + :key="index" | ||
| 79 | + :id="item.id" | ||
| 80 | + :title="item.title" | ||
| 81 | + :file-name="item.fileName" | ||
| 82 | + :file-size="item.fileSize" | ||
| 83 | + :learners="item.learners" | ||
| 84 | + :read-people-percent="item.readPeoplePercent" | ||
| 85 | + :collected="item.collected" | ||
| 86 | + :extension="item.extension" | ||
| 87 | + :download-url="item.downloadUrl" | ||
| 88 | + class="search-result-item" | ||
| 89 | + :style="{ animationDelay: `${index * 30}ms` }" | ||
| 90 | + @collect-changed="handleCollectChanged(item, $event)" | ||
| 91 | + /> | ||
| 92 | + </view> | ||
| 93 | + | ||
| 94 | + <!-- 加载更多提示 --> | ||
| 95 | + <view v-if="currentList.length > 0" class="flex items-center justify-center py-[40rpx]"> | ||
| 96 | + <view v-if="loadingMore" class="flex items-center"> | ||
| 97 | + <view class="loading-spinner-small"></view> | ||
| 98 | + <text class="ml-[12rpx] text-[#9CA3AF] text-[24rpx]">加载中...</text> | ||
| 99 | + </view> | ||
| 100 | + <view v-else-if="!hasMore" class="text-[#9CA3AF] text-[24rpx]"> | ||
| 101 | + 没有更多了 | ||
| 102 | + </view> | ||
| 103 | + </view> | ||
| 104 | + </view> | ||
| 105 | + | ||
| 106 | + <!-- Empty State (已搜索但无结果) --> | ||
| 107 | + <view v-else-if="hasSearched && currentList.length === 0" class="flex flex-col items-center justify-center py-[40rpx]"> | ||
| 108 | + <nut-empty description="暂无搜索结果" image="empty"> | ||
| 109 | + <view class="text-[#9CA3AF] text-[24rpx] mt-[12rpx]">试试其他关键词吧</view> | ||
| 110 | + </nut-empty> | ||
| 111 | + </view> | ||
| 112 | + | ||
| 113 | + <!-- Initial State (从未搜索过) --> | ||
| 114 | + <view v-else class="flex flex-col items-center justify-center py-[120rpx]"> | ||
| 115 | + <IconFont name="search" class="text-gray-300 mb-[24rpx]" size="64" /> | ||
| 116 | + <view class="text-[#6B7280] text-[28rpx]">搜索产品或资料</view> | ||
| 117 | + <view class="text-[#9CA3AF] text-[24rpx] mt-[12rpx]">输入关键词开始搜索,自动切换分类</view> | ||
| 118 | + </view> | ||
| 119 | + </view> | ||
| 120 | + | ||
| 121 | + <!-- Plan Form Container --> | ||
| 122 | + <!-- 仅当 selectedProduct 不为 null 时才渲染组件,避免 product prop required 警告 --> | ||
| 123 | + <PlanFormContainer | ||
| 124 | + v-if="selectedProduct" | ||
| 125 | + v-model:visible="showPlanPopup" | ||
| 126 | + :product="selectedProduct" | ||
| 127 | + @close="showPlanPopup = false" | ||
| 128 | + @submit="handlePlanSubmit" | ||
| 129 | + /> | ||
| 130 | + </view> | ||
| 131 | +</template> | ||
| 132 | + | ||
| 133 | +<script setup> | ||
| 134 | +import { ref, computed } from 'vue' | ||
| 135 | +import Taro, { useReachBottom } from '@tarojs/taro' | ||
| 136 | +import { useGo } from '@/hooks/useGo' | ||
| 137 | +import NavHeader from '@/components/NavHeader.vue' | ||
| 138 | +import IconFont from '@/components/IconFont.vue' | ||
| 139 | +import SearchBar from '@/components/SearchBar.vue' | ||
| 140 | +import ProductCard from '@/components/ProductCard.vue' | ||
| 141 | +import MaterialCard from '@/components/MaterialCard.vue' | ||
| 142 | +import PlanFormContainer from '@/components/PlanFormContainer.vue' | ||
| 143 | +import { searchAPI } from '@/api/search' | ||
| 144 | +import { mockSearchAPI } from '@/utils/mockData' | ||
| 145 | + | ||
| 146 | +// ⚠️ MOCK 数据开关 - 开发环境使用 mock 数据,生产环境使用真实 API | ||
| 147 | +const USE_MOCK_DATA = process.env.NODE_ENV === 'development' | ||
| 148 | + | ||
| 149 | +// Navigation | ||
| 150 | +const go = useGo() | ||
| 151 | + | ||
| 152 | +// Plan Popup State | ||
| 153 | +const showPlanPopup = ref(false) | ||
| 154 | +const selectedProduct = ref(null) | ||
| 155 | + | ||
| 156 | +// State | ||
| 157 | +const searchKeyword = ref('') | ||
| 158 | +const activeTab = ref('') // 当前选中的 tab(初始为空,不选中任何tab) | ||
| 159 | +const hasSearched = ref(false) // 是否已经搜索过 | ||
| 160 | +const listRenderKey = ref(0) | ||
| 161 | + | ||
| 162 | +// 数据状态 | ||
| 163 | +const products = ref([]) // 产品列表 | ||
| 164 | +const files = ref([]) // 资料列表 | ||
| 165 | +const productsTotal = ref(0) // 产品总数 | ||
| 166 | +const filesTotal = ref(0) // 资料总数 | ||
| 167 | +const loadingMore = ref(false) // 加载更多状态 | ||
| 168 | +const hasMore = ref(true) // 是否还有更多数据 | ||
| 169 | +const currentPage = ref(0) // 当前页码(从0开始) | ||
| 170 | +const pageSize = 20 // 每页数量 | ||
| 171 | + | ||
| 172 | +/** | ||
| 173 | + * Tab 数据源(只保留产品和资料) | ||
| 174 | + */ | ||
| 175 | +const tabsData = ref([ | ||
| 176 | + { id: 'product', name: '产品' }, | ||
| 177 | + { id: 'file', name: '资料' }, | ||
| 178 | +]) | ||
| 179 | + | ||
| 180 | +/** | ||
| 181 | + * 当前显示的列表 | ||
| 182 | + */ | ||
| 183 | +const currentList = computed(() => { | ||
| 184 | + // 如果没有选中任何tab,返回空数组 | ||
| 185 | + if (!activeTab.value) return [] | ||
| 186 | + | ||
| 187 | + if (activeTab.value === 'product') { | ||
| 188 | + return products.value | ||
| 189 | + } else { | ||
| 190 | + return files.value | ||
| 191 | + } | ||
| 192 | +}) | ||
| 193 | + | ||
| 194 | +/** | ||
| 195 | + * 当前列表总数 | ||
| 196 | + */ | ||
| 197 | +const currentTotal = computed(() => { | ||
| 198 | + // 如果没有选中任何tab,返回0 | ||
| 199 | + if (!activeTab.value) return 0 | ||
| 200 | + | ||
| 201 | + if (activeTab.value === 'product') { | ||
| 202 | + return productsTotal.value | ||
| 203 | + } else { | ||
| 204 | + return filesTotal.value | ||
| 205 | + } | ||
| 206 | +}) | ||
| 207 | + | ||
| 208 | +/** | ||
| 209 | + * 执行搜索 | ||
| 210 | + * @param {string} keyword - 搜索关键字 | ||
| 211 | + * @param {string} type - 可选,'product' | 'file' | undefined | ||
| 212 | + * @param {number} page - 页码(从0开始) | ||
| 213 | + * @param {number} limit - 每页数量 | ||
| 214 | + * @param {boolean} isLoadMore - 是否为加载更多 | ||
| 215 | + */ | ||
| 216 | +const performSearch = async (keyword, type, page = 0, limit = pageSize, isLoadMore = false) => { | ||
| 217 | + try { | ||
| 218 | + // 如果是加载更多,使用 loadingMore 状态;否则使用 loading 状态 | ||
| 219 | + if (isLoadMore) { | ||
| 220 | + loadingMore.value = true | ||
| 221 | + } else { | ||
| 222 | + Taro.showLoading({ title: '搜索中...', mask: true }) | ||
| 223 | + } | ||
| 224 | + | ||
| 225 | + const params = { keyword, page, limit } | ||
| 226 | + if (type) params.type = type | ||
| 227 | + | ||
| 228 | + console.log('[Search] 使用 Mock 数据:', USE_MOCK_DATA) | ||
| 229 | + | ||
| 230 | + // 根据开关选择使用真实 API 或 Mock 数据 | ||
| 231 | + const res = USE_MOCK_DATA | ||
| 232 | + ? await mockSearchAPI(params) | ||
| 233 | + : await searchAPI(params) | ||
| 234 | + | ||
| 235 | + if (res.code === 1) { | ||
| 236 | + // 映射产品列表 | ||
| 237 | + const newProducts = res.data.products.list || [] | ||
| 238 | + | ||
| 239 | + // 映射资料列表(进行字段映射,与首页保持一致) | ||
| 240 | + const newFiles = (res.data.files.list || []).map(item => { | ||
| 241 | + // 提取文件扩展名 | ||
| 242 | + const fileName = item.name || '未命名文件' | ||
| 243 | + const extension = item.extension || fileName.split('.').pop()?.toLowerCase() || '' | ||
| 244 | + | ||
| 245 | + return { | ||
| 246 | + id: item.meta_id || item.id, | ||
| 247 | + title: item.name, | ||
| 248 | + fileName: fileName, | ||
| 249 | + fileSize: item.size || item.file_size, | ||
| 250 | + downloadUrl: item.src || item.value, | ||
| 251 | + extension: extension, | ||
| 252 | + learners: item.read_people_count ? `${item.read_people_count }人学习` : '', | ||
| 253 | + readPeoplePercent: item.read_people_percent, | ||
| 254 | + is_favorite: item.is_favorite, // 保留原始字段 | ||
| 255 | + collected: Boolean(item.is_favorite) // 转换为 Boolean 供 MaterialCard 使用 | ||
| 256 | + } | ||
| 257 | + }) | ||
| 258 | + | ||
| 259 | + // 根据是否为加载更多来处理数据 | ||
| 260 | + if (isLoadMore) { | ||
| 261 | + // 加载更多:追加数据 | ||
| 262 | + products.value = [...products.value, ...newProducts] | ||
| 263 | + files.value = [...files.value, ...newFiles] | ||
| 264 | + } else { | ||
| 265 | + // 首次加载或刷新:替换数据 | ||
| 266 | + products.value = newProducts | ||
| 267 | + files.value = newFiles | ||
| 268 | + } | ||
| 269 | + | ||
| 270 | + productsTotal.value = res.data.products.total || 0 | ||
| 271 | + filesTotal.value = res.data.files.total || 0 | ||
| 272 | + | ||
| 273 | + // ⚠️ 重要:必须先自动选择 tab,然后再计算 hasMore | ||
| 274 | + // 如果不传 type,自动选择有数据的 tab(仅首次搜索时) | ||
| 275 | + if (!type && !isLoadMore) { | ||
| 276 | + if (productsTotal.value > 0) { | ||
| 277 | + activeTab.value = 'product' | ||
| 278 | + } else if (filesTotal.value > 0) { | ||
| 279 | + activeTab.value = 'file' | ||
| 280 | + } | ||
| 281 | + // 如果都为 0,默认 product | ||
| 282 | + } | ||
| 283 | + | ||
| 284 | + // 判断是否还有更多数据 | ||
| 285 | + // 使用当前列表长度与总数比较 | ||
| 286 | + // 注意:需要根据实际选择的tab来判断 | ||
| 287 | + const actualTab = type || activeTab.value | ||
| 288 | + if (actualTab === 'product') { | ||
| 289 | + hasMore.value = products.value.length < productsTotal.value | ||
| 290 | + } else if (actualTab === 'file') { | ||
| 291 | + hasMore.value = files.value.length < filesTotal.value | ||
| 292 | + } else { | ||
| 293 | + // 如果都没有选中,保守设置为false | ||
| 294 | + hasMore.value = false | ||
| 295 | + } | ||
| 296 | + | ||
| 297 | + hasSearched.value = true | ||
| 298 | + listRenderKey.value += 1 | ||
| 299 | + | ||
| 300 | + console.log('[Search] 搜索成功', { | ||
| 301 | + productsTotal: productsTotal.value, | ||
| 302 | + filesTotal: filesTotal.value, | ||
| 303 | + activeTab: activeTab.value, | ||
| 304 | + isLoadMore, | ||
| 305 | + hasMore: hasMore.value | ||
| 306 | + }) | ||
| 307 | + } else { | ||
| 308 | + Taro.showToast({ | ||
| 309 | + title: res.msg || '搜索失败', | ||
| 310 | + icon: 'none' | ||
| 311 | + }) | ||
| 312 | + } | ||
| 313 | + } catch (err) { | ||
| 314 | + console.error('[Search] 搜索失败:', err) | ||
| 315 | + Taro.showToast({ | ||
| 316 | + title: '搜索失败,请重试', | ||
| 317 | + icon: 'none' | ||
| 318 | + }) | ||
| 319 | + } finally { | ||
| 320 | + if (isLoadMore) { | ||
| 321 | + loadingMore.value = false | ||
| 322 | + } else { | ||
| 323 | + Taro.hideLoading() | ||
| 324 | + } | ||
| 325 | + } | ||
| 326 | +} | ||
| 327 | + | ||
| 328 | +/** | ||
| 329 | + * Tab 点击处理(实时查询) | ||
| 330 | + */ | ||
| 331 | +const onTabClick = async (tabId) => { | ||
| 332 | + if (activeTab.value === tabId) return | ||
| 333 | + | ||
| 334 | + // 立即切换 tab(响应更快) | ||
| 335 | + activeTab.value = tabId | ||
| 336 | + listRenderKey.value += 1 | ||
| 337 | + | ||
| 338 | + // 重置分页状态 | ||
| 339 | + currentPage.value = 0 | ||
| 340 | + hasMore.value = true | ||
| 341 | + | ||
| 342 | + // 如果已经搜索过,实时查询对应类型的数据 | ||
| 343 | + if (hasSearched.value && searchKeyword.value.trim()) { | ||
| 344 | + console.log('[Search] 切换 tab,实时查询:', tabId) | ||
| 345 | + await performSearch(searchKeyword.value.trim(), tabId, 0, pageSize, false) | ||
| 346 | + } | ||
| 347 | +} | ||
| 348 | + | ||
| 349 | +/** | ||
| 350 | + * 提交搜索 | ||
| 351 | + */ | ||
| 352 | +const handleSearch = async () => { | ||
| 353 | + const keyword = searchKeyword.value.trim() | ||
| 354 | + if (!keyword) { | ||
| 355 | + Taro.showToast({ | ||
| 356 | + title: '请输入搜索关键词', | ||
| 357 | + icon: 'none' | ||
| 358 | + }) | ||
| 359 | + return | ||
| 360 | + } | ||
| 361 | + | ||
| 362 | + console.log('[Search] 提交搜索:', keyword) | ||
| 363 | + | ||
| 364 | + // 重置分页状态 | ||
| 365 | + currentPage.value = 0 | ||
| 366 | + hasMore.value = true | ||
| 367 | + | ||
| 368 | + // 不传 type,让后端返回两种数据,前端自动选择 tab | ||
| 369 | + await performSearch(keyword, undefined, 0, pageSize, false) | ||
| 370 | +} | ||
| 371 | + | ||
| 372 | +/** | ||
| 373 | + * 清空搜索 | ||
| 374 | + */ | ||
| 375 | +const clearSearch = () => { | ||
| 376 | + console.log('[Search] 清空搜索') | ||
| 377 | + searchKeyword.value = '' | ||
| 378 | + hasSearched.value = false | ||
| 379 | + products.value = [] | ||
| 380 | + files.value = [] | ||
| 381 | + productsTotal.value = 0 | ||
| 382 | + filesTotal.value = 0 | ||
| 383 | + activeTab.value = '' // 重置为空,不选中任何tab | ||
| 384 | + currentPage.value = 0 | ||
| 385 | + hasMore.value = true | ||
| 386 | + listRenderKey.value += 1 | ||
| 387 | +} | ||
| 388 | + | ||
| 389 | +/** | ||
| 390 | + * 触底加载更多 | ||
| 391 | + * @description 使用防抖避免频繁触发 | ||
| 392 | + */ | ||
| 393 | +let loadMoreTimer = null | ||
| 394 | +useReachBottom(() => { | ||
| 395 | + // 如果正在加载更多或没有更多数据,不执行 | ||
| 396 | + if (loadingMore.value || !hasMore.value) { | ||
| 397 | + return | ||
| 398 | + } | ||
| 399 | + | ||
| 400 | + // 如果没有搜索过或没有选中 tab,不执行 | ||
| 401 | + if (!hasSearched.value || !activeTab.value || !searchKeyword.value.trim()) { | ||
| 402 | + return | ||
| 403 | + } | ||
| 404 | + | ||
| 405 | + // 防抖:300ms 内只触发一次 | ||
| 406 | + if (loadMoreTimer) { | ||
| 407 | + clearTimeout(loadMoreTimer) | ||
| 408 | + } | ||
| 409 | + | ||
| 410 | + loadMoreTimer = setTimeout(async () => { | ||
| 411 | + console.log('[Search] 触底加载更多') | ||
| 412 | + | ||
| 413 | + // 页码 +1 | ||
| 414 | + currentPage.value += 1 | ||
| 415 | + | ||
| 416 | + // 加载下一页数据 | ||
| 417 | + await performSearch( | ||
| 418 | + searchKeyword.value.trim(), | ||
| 419 | + activeTab.value, | ||
| 420 | + currentPage.value, | ||
| 421 | + pageSize, | ||
| 422 | + true // 标记为加载更多 | ||
| 423 | + ) | ||
| 424 | + }, 300) | ||
| 425 | +}) | ||
| 426 | + | ||
| 427 | +/** | ||
| 428 | + * 跳转到产品详情页 | ||
| 429 | + * | ||
| 430 | + * @description 处理产品详情按钮点击事件 | ||
| 431 | + * @param {number} productId - 产品ID | ||
| 432 | + */ | ||
| 433 | +const goToProductDetail = (productId) => { | ||
| 434 | + go('/pages/product-detail/index', { id: productId }) | ||
| 435 | +} | ||
| 436 | + | ||
| 437 | +/** | ||
| 438 | + * 打开计划书弹窗 | ||
| 439 | + * | ||
| 440 | + * @description 根据产品ID找到对应的产品对象,并打开计划书表单 | ||
| 441 | + * @param {number} productId - 产品ID | ||
| 442 | + */ | ||
| 443 | +const openPlanPopup = (productId) => { | ||
| 444 | + // 从产品列表中找到对应的产品 | ||
| 445 | + const product = products.value.find(p => p.id === productId) | ||
| 446 | + | ||
| 447 | + if (!product) { | ||
| 448 | + Taro.showToast({ | ||
| 449 | + title: '产品不存在', | ||
| 450 | + icon: 'none', | ||
| 451 | + duration: 2000 | ||
| 452 | + }) | ||
| 453 | + return | ||
| 454 | + } | ||
| 455 | + | ||
| 456 | + // 设置选中的产品 | ||
| 457 | + selectedProduct.value = product | ||
| 458 | + showPlanPopup.value = true | ||
| 459 | +} | ||
| 460 | + | ||
| 461 | +/** | ||
| 462 | + * 处理计划书提交 | ||
| 463 | + * | ||
| 464 | + * @description 测试环境:前端不调用后端API,直接跳转到结果页 | ||
| 465 | + * 生产环境:需要调用 submitPlanAPI 提交表单数据 | ||
| 466 | + * @param {Object} formData - 表单数据 | ||
| 467 | + */ | ||
| 468 | +const handlePlanSubmit = (formData) => { | ||
| 469 | + console.log('计划书提交:', { | ||
| 470 | + product_id: selectedProduct.value.id, | ||
| 471 | + product_name: selectedProduct.value.product_name || selectedProduct.value.name, | ||
| 472 | + form_sn: selectedProduct.value.form_sn, | ||
| 473 | + form_data: formData | ||
| 474 | + }) | ||
| 475 | + | ||
| 476 | + // 关闭弹窗 | ||
| 477 | + showPlanPopup.value = false | ||
| 478 | + | ||
| 479 | + // TODO: 后端接口还没有准备好,暂时不调用API | ||
| 480 | + // 测试完成后需要对接 submitPlanAPI | ||
| 481 | + // const res = await submitPlanAPI({ | ||
| 482 | + // product_id: selectedProduct.value.id, | ||
| 483 | + // template: selectedProduct.value.form_sn, | ||
| 484 | + // form_data: formData | ||
| 485 | + // }); | ||
| 486 | + | ||
| 487 | + // 模拟提交成功,跳转到结果页面 | ||
| 488 | + go('/pages/plan-submit-result/index', { | ||
| 489 | + success: 'true' | ||
| 490 | + }) | ||
| 491 | +} | ||
| 492 | + | ||
| 493 | +/** | ||
| 494 | + * 处理收藏状态改变 | ||
| 495 | + * | ||
| 496 | + * @description 当用户点击收藏按钮时,更新本地状态 | ||
| 497 | + * @param {Object} item - 资料对象 | ||
| 498 | + * @param {Object} newStatus - 新的状态 | ||
| 499 | + */ | ||
| 500 | +const handleCollectChanged = (item, newStatus) => { | ||
| 501 | + console.log('[Search] 收藏状态改变:', item.title, newStatus.collected) | ||
| 502 | + // 找到对应的项并更新状态 | ||
| 503 | + const file = files.value.find(f => f.id === item.id) | ||
| 504 | + if (file) { | ||
| 505 | + file.collected = newStatus.collected | ||
| 506 | + } | ||
| 507 | +} | ||
| 508 | + | ||
| 509 | +</script> | ||
| 510 | + | ||
| 511 | +<style lang="less"> | ||
| 512 | +@keyframes slideIn { | ||
| 513 | + from { | ||
| 514 | + opacity: 0; | ||
| 515 | + transform: translateY(20rpx); | ||
| 516 | + } | ||
| 517 | + | ||
| 518 | + to { | ||
| 519 | + opacity: 1; | ||
| 520 | + transform: translateY(0); | ||
| 521 | + } | ||
| 522 | +} | ||
| 523 | + | ||
| 524 | +.search-result-item { | ||
| 525 | + animation: slideIn 0.3s cubic-bezier(0.2, 0.8, 0.2, 1) backwards; | ||
| 526 | +} | ||
| 527 | + | ||
| 528 | +/* 加载动画 */ | ||
| 529 | +@keyframes spin { | ||
| 530 | + 0% { | ||
| 531 | + transform: rotate(0deg); | ||
| 532 | + } | ||
| 533 | + 100% { | ||
| 534 | + transform: rotate(360deg); | ||
| 535 | + } | ||
| 536 | +} | ||
| 537 | + | ||
| 538 | +.loading-spinner-small { | ||
| 539 | + width: 32rpx; | ||
| 540 | + height: 32rpx; | ||
| 541 | + border: 3rpx solid #E5E7EB; | ||
| 542 | + border-top-color: #4CAF50; | ||
| 543 | + border-radius: 50%; | ||
| 544 | + animation: spin 0.8s linear infinite; | ||
| 545 | +} | ||
| 546 | + | ||
| 547 | +// FilterTabs 风格的标签栏 | ||
| 548 | +.filter-tabs-wrapper { | ||
| 549 | + display: flex; | ||
| 550 | + overflow-x: auto; | ||
| 551 | + padding: 24rpx 60rpx; | ||
| 552 | + gap: 24rpx; | ||
| 553 | + transition: all 0.3s ease; | ||
| 554 | + background-color: #FFF; | ||
| 555 | + width: 100%; | ||
| 556 | + | ||
| 557 | + // 隐藏滚动条 | ||
| 558 | + &::-webkit-scrollbar { | ||
| 559 | + display: none; | ||
| 560 | + width: 0; | ||
| 561 | + height: 0; | ||
| 562 | + } | ||
| 563 | + | ||
| 564 | + -ms-overflow-style: none; | ||
| 565 | + scrollbar-width: none; | ||
| 566 | +} | ||
| 567 | + | ||
| 568 | +.filter-tab-item { | ||
| 569 | + display: flex; | ||
| 570 | + align-items: center; | ||
| 571 | + justify-content: center; | ||
| 572 | + padding: 0 32rpx; | ||
| 573 | + border-radius: 9999rpx; | ||
| 574 | + white-space: nowrap; | ||
| 575 | + transition: all 0.3s ease; | ||
| 576 | + flex-shrink: 0; | ||
| 577 | +} | ||
| 578 | + | ||
| 579 | +.filter-tab-active { | ||
| 580 | + background-color: #2563EB; // 蓝色背景 | ||
| 581 | + color: #fff; | ||
| 582 | +} | ||
| 583 | + | ||
| 584 | +.filter-tab-inactive { | ||
| 585 | + background-color: #F3F4F6; // 灰色背景 | ||
| 586 | + color: #6B7280; | ||
| 587 | +} | ||
| 588 | + | ||
| 589 | +.filter-tab-text { | ||
| 590 | + font-size: 28rpx; | ||
| 591 | + font-weight: 500; | ||
| 592 | +} | ||
| 593 | + | ||
| 594 | +// 覆盖 NutUI Tabs 默认样式 | ||
| 595 | +:deep(.nut-tabs__titles) { | ||
| 596 | + display: none; | ||
| 597 | +} | ||
| 598 | + | ||
| 599 | +:deep(.nut-tabs__content) { | ||
| 600 | + display: none; | ||
| 601 | +} | ||
| 602 | +</style> |
| 1 | +# LoadMoreList 组件完整指南 | ||
| 2 | + | ||
| 3 | +> **版本**: 2.0.0 | ||
| 4 | +> **更新时间**: 2026-02-08 | ||
| 5 | +> **维护者**: Claude Code | ||
| 6 | + | ||
| 7 | +--- | ||
| 8 | + | ||
| 9 | +## 📚 目录 | ||
| 10 | + | ||
| 11 | +- [组件概述](#组件概述) | ||
| 12 | +- [组件 API](#组件-api) | ||
| 13 | +- [快速开始](#快速开始) | ||
| 14 | +- [实际案例](#实际案例) | ||
| 15 | +- [迁移模式](#迁移模式) | ||
| 16 | +- [最佳实践](#最佳实践) | ||
| 17 | +- [常见问题](#常见问题) | ||
| 18 | +- [性能优化](#性能优化) | ||
| 19 | +- [更新日志](#更新日志) | ||
| 20 | + | ||
| 21 | +--- | ||
| 22 | + | ||
| 23 | +## 组件概述 | ||
| 24 | + | ||
| 25 | +### 🎯 设计目标 | ||
| 26 | + | ||
| 27 | +LoadMoreList 是一个**通用分页列表组件**,旨在解决以下问题: | ||
| 28 | + | ||
| 29 | +1. **代码重复**:多个页面都实现了相同的分页加载逻辑 | ||
| 30 | +2. **维护困难**:修改动效需要同时改多个文件 | ||
| 31 | +3. **集成成本高**:新页面需要复制粘贴大量代码 | ||
| 32 | + | ||
| 33 | +### ✨ 核心特性 | ||
| 34 | + | ||
| 35 | +- ✅ **自动分页加载**:触底自动加载下一页(300ms 防抖) | ||
| 36 | +- ✅ **下拉刷新**:可选的下拉刷新功能 | ||
| 37 | +- ✅ **多种状态**:首次加载、加载中、空状态、没有更多 | ||
| 38 | +- ✅ **动画效果**:列表项逐个进入动画(前10项延迟) | ||
| 39 | +- ✅ **高度可定制**:支持自定义头部、列表项、空状态等 | ||
| 40 | +- ✅ **性能优化**:只为前10项使用动画延迟,避免累积延迟 | ||
| 41 | + | ||
| 42 | +### 📊 迁移收益 | ||
| 43 | + | ||
| 44 | +| 指标 | 迁移前 | 迁移后 | 改善 | | ||
| 45 | +|------|--------|--------|------| | ||
| 46 | +| 重复代码行数 | ~700 行 | 0 行 | -100% | | ||
| 47 | +| 页面平均代码行数 | ~400 行 | ~300 行 | -25% | | ||
| 48 | +| 动效统一性 | ❌ 不一致 | ✅ 完全统一 | ✅ | | ||
| 49 | +| 维护成本 | ❌ 高(5个文件) | ✅ 低(1个文件) | ✅ | | ||
| 50 | +| 新页面集成成本 | ~150 行 | ~10 行 | -93% | | ||
| 51 | + | ||
| 52 | +--- | ||
| 53 | + | ||
| 54 | +## 组件 API | ||
| 55 | + | ||
| 56 | +### Props 属性 | ||
| 57 | + | ||
| 58 | +| Prop | 类型 | 默认值 | 必需 | 说明 | | ||
| 59 | +|------|------|--------|------|------| | ||
| 60 | +| `list` | `Array<any>` | `[]` | ❌ | 列表数据源 | | ||
| 61 | +| `page` | `Number` | - | ✅ | 当前页码(从0或1开始) | | ||
| 62 | +| `pageSize` | `Number` | `10` | ❌ | 每页数量 | | ||
| 63 | +| `hasMore` | `Boolean` | `true` | ❌ | 是否还有更多数据 | | ||
| 64 | +| `loading` | `Boolean` | `false` | ❌ | 首次加载状态 | | ||
| 65 | +| `loadingMore` | `Boolean` | `false` | ❌ | 加载更多状态 | | ||
| 66 | +| `keyField` | `String` | `'id'` | ❌ | 唯一标识字段名 | | ||
| 67 | +| `showHeader` | `Boolean` | `true` | ❌ | 是否显示固定头部区域 | | ||
| 68 | +| `enablePullDownRefresh` | `Boolean` | `false` | ❌ | 是否启用下拉刷新 | | ||
| 69 | +| `noPadding` | `Boolean` | `false` | ❌ | 列表容器是否不需要 padding | | ||
| 70 | + | ||
| 71 | +### Events 事件 | ||
| 72 | + | ||
| 73 | +| Event | 参数 | 说明 | | ||
| 74 | +|-------|------|------| | ||
| 75 | +| `load-more` | `page: number` | 触底加载更多时触发,传入**下一页**页码(page + 1) | | ||
| 76 | +| `refresh` | - | 下拉刷新时触发(需启用 `enablePullDownRefresh`) | | ||
| 77 | + | ||
| 78 | +### Slots 插槽 | ||
| 79 | + | ||
| 80 | +| Slot | 作用域参数 | 说明 | | ||
| 81 | +|------|-----------|------| | ||
| 82 | +| `header` | - | 自定义固定头部区域(导航、搜索、tabs等) | | ||
| 83 | +| `item` | `{ item, index }` | 自定义列表项渲染 | | ||
| 84 | +| `loading` | - | 自定义首次加载状态 | | ||
| 85 | +| `loading-more` | - | 自定义加载更多状态 | | ||
| 86 | +| `empty` | - | 自定义空状态 | | ||
| 87 | +| `no-more` | - | 自定义"没有更多"提示 | | ||
| 88 | + | ||
| 89 | +--- | ||
| 90 | + | ||
| 91 | +## 快速开始 | ||
| 92 | + | ||
| 93 | +### 最简单的使用方式 | ||
| 94 | + | ||
| 95 | +```vue | ||
| 96 | +<template> | ||
| 97 | + <LoadMoreList | ||
| 98 | + :list="products" | ||
| 99 | + :page="page" | ||
| 100 | + :page-size="10" | ||
| 101 | + :has-more="hasMore" | ||
| 102 | + :loading="loading" | ||
| 103 | + :loading-more="loadingMore" | ||
| 104 | + key-field="id" | ||
| 105 | + @load-more="handleLoadMore" | ||
| 106 | + > | ||
| 107 | + <template #item="{ item }"> | ||
| 108 | + <view class="product-item">{{ item.name }}</view> | ||
| 109 | + </template> | ||
| 110 | + </LoadMoreList> | ||
| 111 | +</template> | ||
| 112 | + | ||
| 113 | +<script setup> | ||
| 114 | +import { ref } from 'vue' | ||
| 115 | +import LoadMoreList from '@/components/LoadMoreList' | ||
| 116 | + | ||
| 117 | +const products = ref([]) | ||
| 118 | +const page = ref(0) | ||
| 119 | +const hasMore = ref(true) | ||
| 120 | +const loading = ref(false) | ||
| 121 | +const loadingMore = ref(false) | ||
| 122 | + | ||
| 123 | +const handleLoadMore = async (nextPage) => { | ||
| 124 | + page.value = nextPage | ||
| 125 | + // 加载数据... | ||
| 126 | +} | ||
| 127 | +</script> | ||
| 128 | +``` | ||
| 129 | + | ||
| 130 | +### 带头部和空状态 | ||
| 131 | + | ||
| 132 | +```vue | ||
| 133 | +<template> | ||
| 134 | + <LoadMoreList | ||
| 135 | + :list="products" | ||
| 136 | + :page="page" | ||
| 137 | + :page-size="10" | ||
| 138 | + :has-more="hasMore" | ||
| 139 | + :loading="loading" | ||
| 140 | + :loading-more="loadingMore" | ||
| 141 | + @load-more="handleLoadMore" | ||
| 142 | + > | ||
| 143 | + <template #header> | ||
| 144 | + <NavHeader title="产品中心" /> | ||
| 145 | + </template> | ||
| 146 | + | ||
| 147 | + <template #item="{ item }"> | ||
| 148 | + <ProductCard :product="item" /> | ||
| 149 | + </template> | ||
| 150 | + | ||
| 151 | + <template #empty> | ||
| 152 | + <nut-empty description="暂无相关产品" image="empty" /> | ||
| 153 | + </template> | ||
| 154 | + </LoadMoreList> | ||
| 155 | +</template> | ||
| 156 | +``` | ||
| 157 | + | ||
| 158 | +### 带下拉刷新 | ||
| 159 | + | ||
| 160 | +```vue | ||
| 161 | +<template> | ||
| 162 | + <LoadMoreList | ||
| 163 | + :list="messages" | ||
| 164 | + :page="page" | ||
| 165 | + :page-size="10" | ||
| 166 | + :has-more="hasMore" | ||
| 167 | + :loading="loading" | ||
| 168 | + :loading-more="loadingMore" | ||
| 169 | + :enable-pull-down-refresh="true" | ||
| 170 | + @load-more="handleLoadMore" | ||
| 171 | + @refresh="handleRefresh" | ||
| 172 | + > | ||
| 173 | + <template #header> | ||
| 174 | + <NavHeader title="我的消息" /> | ||
| 175 | + </template> | ||
| 176 | + | ||
| 177 | + <template #item="{ item }"> | ||
| 178 | + <view class="message-item">{{ item.title }}</view> | ||
| 179 | + </template> | ||
| 180 | + </LoadMoreList> | ||
| 181 | +</template> | ||
| 182 | + | ||
| 183 | +<script setup> | ||
| 184 | +const handleRefresh = async () => { | ||
| 185 | + page.value = 0 // 或 1,根据 API 要求 | ||
| 186 | + hasMore.value = true | ||
| 187 | + await fetchData(true) // true 表示刷新 | ||
| 188 | +} | ||
| 189 | +</script> | ||
| 190 | +``` | ||
| 191 | + | ||
| 192 | +--- | ||
| 193 | + | ||
| 194 | +## 实际案例 | ||
| 195 | + | ||
| 196 | +### 案例 1: 简单列表(week-hot-material、message) | ||
| 197 | + | ||
| 198 | +**场景**: 只需展示列表,支持下拉刷新 | ||
| 199 | + | ||
| 200 | +**页面**: [week-hot-material](../src/pages/week-hot-material/index.vue)、[message](../src/pages/message/index.vue) | ||
| 201 | + | ||
| 202 | +```vue | ||
| 203 | +<template> | ||
| 204 | + <LoadMoreList | ||
| 205 | + :list="currentList" | ||
| 206 | + :page="currentPage" | ||
| 207 | + :page-size="pageSize" | ||
| 208 | + :has-more="hasMore" | ||
| 209 | + :loading="loading" | ||
| 210 | + :loading-more="loadingMore" | ||
| 211 | + :enable-pull-down-refresh="true" | ||
| 212 | + key-field="id" | ||
| 213 | + @load-more="handleLoadMore" | ||
| 214 | + @refresh="handleRefresh" | ||
| 215 | + > | ||
| 216 | + <template #header> | ||
| 217 | + <NavHeader title="我的消息" /> | ||
| 218 | + </template> | ||
| 219 | + | ||
| 220 | + <template #item="{ item }"> | ||
| 221 | + <view class="message-item" @tap="handleItemClick(item)"> | ||
| 222 | + <view class="title">{{ item.title }}</view> | ||
| 223 | + <view class="intro">{{ item.intro }}</view> | ||
| 224 | + </view> | ||
| 225 | + </template> | ||
| 226 | + </LoadMoreList> | ||
| 227 | +</template> | ||
| 228 | + | ||
| 229 | +<script setup> | ||
| 230 | +import { ref } from 'vue' | ||
| 231 | +import { useLoad } from '@tarojs/taro' | ||
| 232 | +import LoadMoreList from '@/components/LoadMoreList' | ||
| 233 | + | ||
| 234 | +const currentList = ref([]) | ||
| 235 | +const currentPage = ref(1) | ||
| 236 | +const pageSize = 10 | ||
| 237 | +const hasMore = ref(true) | ||
| 238 | +const loading = ref(false) | ||
| 239 | +const loadingMore = ref(false) | ||
| 240 | + | ||
| 241 | +// 加载数据 | ||
| 242 | +const fetchMessageList = async (params = {}, isLoadMore = false) => { | ||
| 243 | + try { | ||
| 244 | + if (isLoadMore) { | ||
| 245 | + loadingMore.value = true | ||
| 246 | + } else { | ||
| 247 | + loading.value = true | ||
| 248 | + } | ||
| 249 | + | ||
| 250 | + const res = await myListAPI(params) | ||
| 251 | + | ||
| 252 | + if (res.code === 1 && res.data) { | ||
| 253 | + const listData = res.data.list || [] | ||
| 254 | + | ||
| 255 | + if (isLoadMore) { | ||
| 256 | + currentList.value = [...currentList.value, ...listData] | ||
| 257 | + } else { | ||
| 258 | + currentList.value = listData | ||
| 259 | + } | ||
| 260 | + | ||
| 261 | + hasMore.value = listData.length >= pageSize | ||
| 262 | + } | ||
| 263 | + } catch (err) { | ||
| 264 | + console.error('获取消息失败:', err) | ||
| 265 | + } finally { | ||
| 266 | + if (isLoadMore) { | ||
| 267 | + loadingMore.value = false | ||
| 268 | + } else { | ||
| 269 | + loading.value = false | ||
| 270 | + } | ||
| 271 | + } | ||
| 272 | +} | ||
| 273 | + | ||
| 274 | +// 加载更多 | ||
| 275 | +const handleLoadMore = async (page) => { | ||
| 276 | + currentPage.value = page | ||
| 277 | + await fetchMessageList({ page, limit: pageSize }, true) | ||
| 278 | +} | ||
| 279 | + | ||
| 280 | +// 下拉刷新 | ||
| 281 | +const handleRefresh = async () => { | ||
| 282 | + currentPage.value = 1 | ||
| 283 | + hasMore.value = true | ||
| 284 | + await fetchMessageList({ page: 1, limit: pageSize }) | ||
| 285 | +} | ||
| 286 | + | ||
| 287 | +// 页面加载 | ||
| 288 | +useLoad(async () => { | ||
| 289 | + await fetchMessageList({ page: 1, limit: pageSize }) | ||
| 290 | +}) | ||
| 291 | +</script> | ||
| 292 | +``` | ||
| 293 | + | ||
| 294 | +**要点**: | ||
| 295 | +- ✅ `enable-pull-down-refresh="true"` 启用下拉刷新 | ||
| 296 | +- ✅ 实现 `handleRefresh` 函数,重置页码和 hasMore | ||
| 297 | +- ✅ 使用 `...currentList.value, ...listData` 追加数据 | ||
| 298 | + | ||
| 299 | +--- | ||
| 300 | + | ||
| 301 | +### 案例 2: 带搜索和 Tabs 的列表(product-center) | ||
| 302 | + | ||
| 303 | +**场景**: 需要搜索功能、分类 tabs、计划书弹窗 | ||
| 304 | + | ||
| 305 | +**页面**: [product-center](../src/pages/product-center/index.vue) | ||
| 306 | + | ||
| 307 | +```vue | ||
| 308 | +<template> | ||
| 309 | + <view class="bg-[#F9FAFB]"> | ||
| 310 | + <!-- 计划书弹窗(放在 LoadMoreList 外部) --> | ||
| 311 | + <view v-if="showPlanPopup && selectedProduct"> | ||
| 312 | + <PlanFormContainer | ||
| 313 | + v-model:visible="showPlanPopup" | ||
| 314 | + :product="selectedProduct" | ||
| 315 | + @submit="handlePlanSubmit" | ||
| 316 | + /> | ||
| 317 | + </view> | ||
| 318 | + | ||
| 319 | + <LoadMoreList | ||
| 320 | + :list="currentList" | ||
| 321 | + :page="currentPage" | ||
| 322 | + :page-size="pageSize" | ||
| 323 | + :has-more="hasMore" | ||
| 324 | + :loading="loading" | ||
| 325 | + :loading-more="loadingMore" | ||
| 326 | + key-field="id" | ||
| 327 | + @load-more="handleLoadMore" | ||
| 328 | + > | ||
| 329 | + <!-- 固定头部:导航 + 搜索 + Tabs --> | ||
| 330 | + <template #header> | ||
| 331 | + <view class="sticky top-0 z-10 bg-[#F9FAFB]"> | ||
| 332 | + <NavHeader title="产品中心" /> | ||
| 333 | + | ||
| 334 | + <!-- Search Bar --> | ||
| 335 | + <view class="px-[24rpx] py-[16rpx] bg-white"> | ||
| 336 | + <SearchBar | ||
| 337 | + v-model="searchValue" | ||
| 338 | + placeholder="搜索产品名称..." | ||
| 339 | + @search="onSearch" | ||
| 340 | + @input="onSearchInput" | ||
| 341 | + @clear="onClear" | ||
| 342 | + /> | ||
| 343 | + </view> | ||
| 344 | + | ||
| 345 | + <!-- Tabs Container --> | ||
| 346 | + <view class="bg-white mt-[2rpx]"> | ||
| 347 | + <nut-tabs v-model="activeTabId"> | ||
| 348 | + <!-- 自定义标签栏 --> | ||
| 349 | + <template #titles> | ||
| 350 | + <view class="filter-tabs-wrapper"> | ||
| 351 | + <view | ||
| 352 | + v-for="item in tabsData" | ||
| 353 | + :key="item.id" | ||
| 354 | + :class="[ | ||
| 355 | + 'filter-tab-item', | ||
| 356 | + activeTabId === item.id ? 'filter-tab-active' : 'filter-tab-inactive' | ||
| 357 | + ]" | ||
| 358 | + @tap="onTabClick(item.id)" | ||
| 359 | + > | ||
| 360 | + <text class="filter-tab-text">{{ item.name }}</text> | ||
| 361 | + </view> | ||
| 362 | + </view> | ||
| 363 | + </template> | ||
| 364 | + </nut-tabs> | ||
| 365 | + </view> | ||
| 366 | + </view> | ||
| 367 | + </template> | ||
| 368 | + | ||
| 369 | + <!-- 列表项:产品卡片 --> | ||
| 370 | + <template #item="{ item }"> | ||
| 371 | + <view class="product-card" @tap="handleProductClick(item)"> | ||
| 372 | + <!-- 产品内容 --> | ||
| 373 | + </view> | ||
| 374 | + </template> | ||
| 375 | + | ||
| 376 | + <!-- 空状态 --> | ||
| 377 | + <template #empty> | ||
| 378 | + <nut-empty description="暂无相关产品" image="empty" /> | ||
| 379 | + </template> | ||
| 380 | + </LoadMoreList> | ||
| 381 | + </view> | ||
| 382 | +</template> | ||
| 383 | + | ||
| 384 | +<script setup> | ||
| 385 | +import { ref, computed } from 'vue' | ||
| 386 | +import { useLoad } from '@tarojs/taro' | ||
| 387 | +import LoadMoreList from '@/components/LoadMoreList' | ||
| 388 | + | ||
| 389 | +const currentList = ref([]) | ||
| 390 | +const currentPage = ref(0) | ||
| 391 | +const pageSize = 10 | ||
| 392 | +const hasMore = ref(true) | ||
| 393 | +const loading = ref(false) | ||
| 394 | +const loadingMore = ref(false) | ||
| 395 | + | ||
| 396 | +// 搜索和 Tabs 相关状态 | ||
| 397 | +const activeTabId = ref('') | ||
| 398 | +const searchValue = ref('') | ||
| 399 | +const categories = ref([]) // 从接口获取的分类列表 | ||
| 400 | +let searchTimer = null // 搜索防抖定时器 | ||
| 401 | + | ||
| 402 | +// 计划书弹窗状态 | ||
| 403 | +const showPlanPopup = ref(false) | ||
| 404 | +const selectedProduct = ref(null) | ||
| 405 | + | ||
| 406 | +// 标签栏数据(根据接口返回的 categories 生成) | ||
| 407 | +const tabsData = computed(() => { | ||
| 408 | + const allTab = { id: '', name: '全部' } | ||
| 409 | + const categoryTabs = categories.value.map(cat => ({ | ||
| 410 | + id: String(cat.id), | ||
| 411 | + name: cat.name | ||
| 412 | + })) | ||
| 413 | + return [allTab, ...categoryTabs] | ||
| 414 | +}) | ||
| 415 | + | ||
| 416 | +// 加载产品列表 | ||
| 417 | +const fetchProducts = async (params = {}, isLoadMore = false) => { | ||
| 418 | + try { | ||
| 419 | + if (isLoadMore) { | ||
| 420 | + loadingMore.value = true | ||
| 421 | + } else { | ||
| 422 | + loading.value = true | ||
| 423 | + } | ||
| 424 | + | ||
| 425 | + const res = await listAPI(params) | ||
| 426 | + | ||
| 427 | + if (res.code === 1 && res.data) { | ||
| 428 | + // 更新分类列表(首次加载时) | ||
| 429 | + if (!isLoadMore && res.data.categories) { | ||
| 430 | + categories.value = res.data.categories | ||
| 431 | + } | ||
| 432 | + | ||
| 433 | + // 处理产品列表 | ||
| 434 | + if (res.data.list?.length) { | ||
| 435 | + const listData = res.data.list | ||
| 436 | + | ||
| 437 | + if (isLoadMore) { | ||
| 438 | + currentList.value = [...currentList.value, ...listData] | ||
| 439 | + } else { | ||
| 440 | + currentList.value = listData | ||
| 441 | + } | ||
| 442 | + | ||
| 443 | + hasMore.value = listData.length >= params.limit | ||
| 444 | + } | ||
| 445 | + } | ||
| 446 | + } catch (error) { | ||
| 447 | + console.error('获取产品列表失败:', error) | ||
| 448 | + } finally { | ||
| 449 | + if (isLoadMore) { | ||
| 450 | + loadingMore.value = false | ||
| 451 | + } else { | ||
| 452 | + loading.value = false | ||
| 453 | + } | ||
| 454 | + } | ||
| 455 | +} | ||
| 456 | + | ||
| 457 | +// 页面加载时获取数据 | ||
| 458 | +useLoad(async (options) => { | ||
| 459 | + await fetchProducts({ page: 0, limit: pageSize }) | ||
| 460 | +}) | ||
| 461 | + | ||
| 462 | +// 处理加载更多事件 | ||
| 463 | +const handleLoadMore = async (page) => { | ||
| 464 | + currentPage.value = page | ||
| 465 | + | ||
| 466 | + // 构建请求参数 | ||
| 467 | + const params = { | ||
| 468 | + page: page, | ||
| 469 | + limit: pageSize | ||
| 470 | + } | ||
| 471 | + | ||
| 472 | + // 如果不是"全部"标签,添加分类 ID 参数 | ||
| 473 | + if (activeTabId.value !== '') { | ||
| 474 | + params.cid = activeTabId.value | ||
| 475 | + } | ||
| 476 | + | ||
| 477 | + // 添加搜索关键词参数 | ||
| 478 | + if (searchValue.value) { | ||
| 479 | + params.keyword = searchValue.value | ||
| 480 | + } | ||
| 481 | + | ||
| 482 | + // 加载下一页数据 | ||
| 483 | + await fetchProducts(params, true) | ||
| 484 | +} | ||
| 485 | + | ||
| 486 | +// Tab 点击处理 | ||
| 487 | +const onTabClick = (id) => { | ||
| 488 | + if (activeTabId.value === id) return | ||
| 489 | + | ||
| 490 | + activeTabId.value = id | ||
| 491 | + currentPage.value = 0 | ||
| 492 | + hasMore.value = true | ||
| 493 | + | ||
| 494 | + // 构建请求参数 | ||
| 495 | + const params = { | ||
| 496 | + page: 0, | ||
| 497 | + limit: pageSize | ||
| 498 | + } | ||
| 499 | + | ||
| 500 | + if (id !== '') { | ||
| 501 | + params.cid = id | ||
| 502 | + } | ||
| 503 | + | ||
| 504 | + if (searchValue.value) { | ||
| 505 | + params.keyword = searchValue.value | ||
| 506 | + } | ||
| 507 | + | ||
| 508 | + // 重新加载数据 | ||
| 509 | + fetchProducts(params, false) | ||
| 510 | +} | ||
| 511 | + | ||
| 512 | +// 搜索输入处理(带防抖) | ||
| 513 | +const onSearchInput = (value) => { | ||
| 514 | + if (searchTimer) { | ||
| 515 | + clearTimeout(searchTimer) | ||
| 516 | + } | ||
| 517 | + | ||
| 518 | + // 500ms 后执行搜索 | ||
| 519 | + searchTimer = setTimeout(() => { | ||
| 520 | + currentPage.value = 0 | ||
| 521 | + hasMore.value = true | ||
| 522 | + | ||
| 523 | + const params = { | ||
| 524 | + page: 0, | ||
| 525 | + limit: pageSize | ||
| 526 | + } | ||
| 527 | + | ||
| 528 | + if (activeTabId.value !== '') { | ||
| 529 | + params.cid = activeTabId.value | ||
| 530 | + } | ||
| 531 | + | ||
| 532 | + if (value) { | ||
| 533 | + params.keyword = value | ||
| 534 | + } | ||
| 535 | + | ||
| 536 | + fetchProducts(params, false) | ||
| 537 | + }, 500) | ||
| 538 | +} | ||
| 539 | + | ||
| 540 | +// 其他处理函数... | ||
| 541 | +</script> | ||
| 542 | +``` | ||
| 543 | + | ||
| 544 | +**要点**: | ||
| 545 | +- ✅ **固定头部**: 使用 `sticky top-0 z-10` 实现吸顶效果 | ||
| 546 | +- ✅ **搜索防抖**: 500ms 延迟,避免频繁请求 | ||
| 547 | +- ✅ **弹窗位置**: PlanFormContainer 放在 LoadMoreList 外部作为兄弟节点 | ||
| 548 | +- ✅ **参数构建**: 在 `handleLoadMore` 中同时处理分类和搜索状态 | ||
| 549 | + | ||
| 550 | +--- | ||
| 551 | + | ||
| 552 | +### 案例 3: 带分类缓存的列表(material-list) | ||
| 553 | + | ||
| 554 | +**场景**: 需要缓存每个分类的分页状态,切换分类时保留滚动位置 | ||
| 555 | + | ||
| 556 | +**页面**: [material-list](../src/pages/material-list/index.vue) | ||
| 557 | + | ||
| 558 | +```vue | ||
| 559 | +<template> | ||
| 560 | + <LoadMoreList | ||
| 561 | + :list="currentList" | ||
| 562 | + :page="currentPage" | ||
| 563 | + :page-size="pageSize" | ||
| 564 | + :has-more="hasMore" | ||
| 565 | + :loading="loading" | ||
| 566 | + :loading-more="loadingMore" | ||
| 567 | + key-field="id" | ||
| 568 | + @load-more="handleLoadMore" | ||
| 569 | + > | ||
| 570 | + <!-- 固定头部:导航 + 搜索 + Tabs --> | ||
| 571 | + <template #header> | ||
| 572 | + <view class="sticky top-0 z-10 bg-[#FFF]"> | ||
| 573 | + <NavHeader title="资料列表" /> | ||
| 574 | + | ||
| 575 | + <view class="px-[32rpx] py-[24rpx]"> | ||
| 576 | + <SearchBar | ||
| 577 | + v-model="searchValue" | ||
| 578 | + placeholder="搜索资料名称..." | ||
| 579 | + @search="onSearch" | ||
| 580 | + /> | ||
| 581 | + </view> | ||
| 582 | + | ||
| 583 | + <!-- Tabs Container --> | ||
| 584 | + <nut-tabs v-model="activeTabId"> | ||
| 585 | + <template #titles> | ||
| 586 | + <view class="filter-tabs-wrapper"> | ||
| 587 | + <view | ||
| 588 | + v-for="item in tabsData" | ||
| 589 | + :key="item.id" | ||
| 590 | + :class="[ | ||
| 591 | + 'filter-tab-item', | ||
| 592 | + activeTabId === item.id ? 'filter-tab-active' : 'filter-tab-inactive' | ||
| 593 | + ]" | ||
| 594 | + @tap="onTabClick(item.id)" | ||
| 595 | + > | ||
| 596 | + <text class="filter-tab-text">{{ item.name }}</text> | ||
| 597 | + </view> | ||
| 598 | + </view> | ||
| 599 | + </template> | ||
| 600 | + </nut-tabs> | ||
| 601 | + </view> | ||
| 602 | + </template> | ||
| 603 | + | ||
| 604 | + <!-- 列表项 --> | ||
| 605 | + <template #item="{ item }"> | ||
| 606 | + <MaterialCard | ||
| 607 | + :id="item.id" | ||
| 608 | + :title="item.name" | ||
| 609 | + @collect-changed="handleCollectChanged" | ||
| 610 | + /> | ||
| 611 | + </template> | ||
| 612 | + </LoadMoreList> | ||
| 613 | +</template> | ||
| 614 | + | ||
| 615 | +<script setup> | ||
| 616 | +import { ref } from 'vue' | ||
| 617 | +import { useLoad } from '@tarojs/taro' | ||
| 618 | +import LoadMoreList from '@/components/LoadMoreList' | ||
| 619 | + | ||
| 620 | +const currentList = ref([]) | ||
| 621 | +const currentPage = ref(0) | ||
| 622 | +const pageSize = 20 | ||
| 623 | +const hasMore = ref(true) | ||
| 624 | +const loading = ref(false) | ||
| 625 | +const loadingMore = ref(false) | ||
| 626 | + | ||
| 627 | +// 分类缓存(使用 Map 保存分页状态) | ||
| 628 | +const categoryPageCache = ref(new Map()) | ||
| 629 | +const categoryListCache = ref(new Map()) | ||
| 630 | + | ||
| 631 | +// 搜索和 Tabs 状态 | ||
| 632 | +const searchValue = ref('') | ||
| 633 | +const activeTabId = ref('all') | ||
| 634 | +const initialCategoryId = ref('') | ||
| 635 | +const tabsData = ref([ | ||
| 636 | + { id: 'all', name: '全部' }, | ||
| 637 | + // ... 其他分类 | ||
| 638 | +]) | ||
| 639 | + | ||
| 640 | +// 加载资料列表 | ||
| 641 | +const fetchMaterialList = async (params = {}, isLoadMore = false) => { | ||
| 642 | + try { | ||
| 643 | + if (isLoadMore) { | ||
| 644 | + loadingMore.value = true | ||
| 645 | + } else { | ||
| 646 | + loading.value = true | ||
| 647 | + } | ||
| 648 | + | ||
| 649 | + const res = await fileListAPI(params) | ||
| 650 | + | ||
| 651 | + if (res.code === 1 && res.data) { | ||
| 652 | + const listData = res.data.list || [] | ||
| 653 | + | ||
| 654 | + if (isLoadMore) { | ||
| 655 | + currentList.value = [...currentList.value, ...listData] | ||
| 656 | + } else { | ||
| 657 | + currentList.value = listData | ||
| 658 | + } | ||
| 659 | + | ||
| 660 | + hasMore.value = listData.length >= params.limit | ||
| 661 | + | ||
| 662 | + // ⭐ 保存分页状态到缓存 | ||
| 663 | + const isSearching = searchValue.value.trim() !== '' | ||
| 664 | + let cacheKey = isSearching ? searchValue.value.trim() : | ||
| 665 | + (activeTabId.value !== 'all' ? activeTabId.value : 'all') | ||
| 666 | + | ||
| 667 | + categoryPageCache.value.set(cacheKey, { | ||
| 668 | + currentPage: currentPage.value, | ||
| 669 | + hasMore: hasMore.value | ||
| 670 | + }) | ||
| 671 | + | ||
| 672 | + // ⭐ 保存列表数据到缓存 | ||
| 673 | + if (!isSearching) { | ||
| 674 | + categoryListCache.value.set(cacheKey, [...currentList.value]) | ||
| 675 | + } | ||
| 676 | + } | ||
| 677 | + } catch (err) { | ||
| 678 | + console.error('获取资料列表失败:', err) | ||
| 679 | + } finally { | ||
| 680 | + if (isLoadMore) { | ||
| 681 | + loadingMore.value = false | ||
| 682 | + } else { | ||
| 683 | + loading.value = false | ||
| 684 | + } | ||
| 685 | + } | ||
| 686 | +} | ||
| 687 | + | ||
| 688 | +// 处理加载更多事件 | ||
| 689 | +const handleLoadMore = async (page) => { | ||
| 690 | + currentPage.value = page | ||
| 691 | + | ||
| 692 | + const isSearching = searchValue.value.trim() !== '' | ||
| 693 | + const params = { | ||
| 694 | + cid: initialCategoryId.value, | ||
| 695 | + page: page, | ||
| 696 | + limit: pageSize | ||
| 697 | + } | ||
| 698 | + | ||
| 699 | + if (isSearching) { | ||
| 700 | + params.keyword = searchValue.value.trim() | ||
| 701 | + if (activeTabId.value !== 'all') { | ||
| 702 | + params.child_id = activeTabId.value | ||
| 703 | + } | ||
| 704 | + } else { | ||
| 705 | + if (activeTabId.value !== 'all') { | ||
| 706 | + params.child_id = activeTabId.value | ||
| 707 | + } | ||
| 708 | + } | ||
| 709 | + | ||
| 710 | + await fetchMaterialList(params, true) | ||
| 711 | +} | ||
| 712 | + | ||
| 713 | +// Tab 点击处理 | ||
| 714 | +const onTabClick = (id) => { | ||
| 715 | + if (activeTabId.value === id) return | ||
| 716 | + | ||
| 717 | + activeTabId.value = id | ||
| 718 | + | ||
| 719 | + // ⭐ 从缓存读取分页状态 | ||
| 720 | + const cached = categoryPageCache.value.get(id) | ||
| 721 | + if (cached) { | ||
| 722 | + currentPage.value = cached.currentPage | ||
| 723 | + hasMore.value = cached.hasMore | ||
| 724 | + currentList.value = categoryListCache.value.get(id) || [] | ||
| 725 | + } else { | ||
| 726 | + currentPage.value = 0 | ||
| 727 | + hasMore.value = true | ||
| 728 | + currentList.value = [] | ||
| 729 | + } | ||
| 730 | + | ||
| 731 | + // 重新加载数据(如果缓存为空) | ||
| 732 | + if (!cached || currentList.value.length === 0) { | ||
| 733 | + fetchMaterialList({ | ||
| 734 | + cid: initialCategoryId.value, | ||
| 735 | + child_id: id !== 'all' ? id : undefined, | ||
| 736 | + page: currentPage.value, | ||
| 737 | + limit: pageSize | ||
| 738 | + }) | ||
| 739 | + } | ||
| 740 | +} | ||
| 741 | +</script> | ||
| 742 | +``` | ||
| 743 | + | ||
| 744 | +**要点**: | ||
| 745 | +- ✅ **分类缓存**: 使用 Map 保存每个分类的分页状态和列表数据 | ||
| 746 | +- ✅ **切换优化**: 切换分类时先从缓存读取,避免重新加载 | ||
| 747 | +- ✅ **搜索与分类区分**: 搜索结果不缓存,分类结果才缓存 | ||
| 748 | + | ||
| 749 | +--- | ||
| 750 | + | ||
| 751 | +### 案例 4: 双列表系统(search) | ||
| 752 | + | ||
| 753 | +**场景**: 同时支持产品和资料搜索,自动选择有结果的 tab | ||
| 754 | + | ||
| 755 | +**页面**: [search](../src/pages/search/index.vue) | ||
| 756 | + | ||
| 757 | +```vue | ||
| 758 | +<template> | ||
| 759 | + <view class="bg-[#FFF]"> | ||
| 760 | + <LoadMoreList | ||
| 761 | + :list="currentList" | ||
| 762 | + :page="currentPage" | ||
| 763 | + :page-size="pageSize" | ||
| 764 | + :has-more="hasMore" | ||
| 765 | + :loading="loading" | ||
| 766 | + :loading-more="loadingMore" | ||
| 767 | + :show-header="true" | ||
| 768 | + key-field="id" | ||
| 769 | + @load-more="handleLoadMore" | ||
| 770 | + > | ||
| 771 | + <!-- 固定顶部:导航栏 + 搜索栏 + Tabs + 结果计数 --> | ||
| 772 | + <template #header> | ||
| 773 | + <view class="bg-[#FFF] sticky top-0 z-10"> | ||
| 774 | + <NavHeader title="搜索" /> | ||
| 775 | + | ||
| 776 | + <!-- Search Input --> | ||
| 777 | + <view class="px-[40rpx] mt-[32rpx]"> | ||
| 778 | + <SearchBar | ||
| 779 | + v-model="searchKeyword" | ||
| 780 | + placeholder="搜索培训资料、案例、产品..." | ||
| 781 | + @search="handleSearch" | ||
| 782 | + @clear="clearSearch" | ||
| 783 | + /> | ||
| 784 | + </view> | ||
| 785 | + | ||
| 786 | + <!-- Tabs Container --> | ||
| 787 | + <nut-tabs v-model="activeTab"> | ||
| 788 | + <template #titles> | ||
| 789 | + <view class="filter-tabs-wrapper"> | ||
| 790 | + <view | ||
| 791 | + v-for="item in tabsData" | ||
| 792 | + :key="item.id" | ||
| 793 | + :class="[ | ||
| 794 | + 'filter-tab-item', | ||
| 795 | + activeTab === item.id ? 'filter-tab-active' : 'filter-tab-inactive', | ||
| 796 | + !activeTab ? 'filter-tab-inactive' : '' | ||
| 797 | + ]" | ||
| 798 | + @tap="onTabClick(item.id)" | ||
| 799 | + > | ||
| 800 | + <text class="filter-tab-text">{{ item.name }}</text> | ||
| 801 | + </view> | ||
| 802 | + </view> | ||
| 803 | + </template> | ||
| 804 | + </nut-tabs> | ||
| 805 | + | ||
| 806 | + <!-- Result Count --> | ||
| 807 | + <view v-if="currentList.length > 0" class="px-[60rpx] text-[#6B7280] text-[24rpx] pb-[24rpx]"> | ||
| 808 | + 找到 {{ currentTotal }} 个相关结果 | ||
| 809 | + </view> | ||
| 810 | + </view> | ||
| 811 | + </template> | ||
| 812 | + | ||
| 813 | + <!-- 列表项:根据 activeTab 动态渲染 --> | ||
| 814 | + <template #item="{ item }"> | ||
| 815 | + <!-- Product Results --> | ||
| 816 | + <ProductCard | ||
| 817 | + v-if="activeTab === 'product'" | ||
| 818 | + :product-id="item.id" | ||
| 819 | + :product-name="item.product_name || item.name" | ||
| 820 | + :tags="item.tags || []" | ||
| 821 | + @detail="goToProductDetail" | ||
| 822 | + @plan="openPlanPopup" | ||
| 823 | + /> | ||
| 824 | + | ||
| 825 | + <!-- File Results --> | ||
| 826 | + <MaterialCard | ||
| 827 | + v-else-if="activeTab === 'file'" | ||
| 828 | + :id="item.id" | ||
| 829 | + :title="item.title" | ||
| 830 | + :file-name="item.fileName" | ||
| 831 | + :file-size="item.fileSize" | ||
| 832 | + @collect-changed="handleCollectChanged" | ||
| 833 | + /> | ||
| 834 | + </template> | ||
| 835 | + | ||
| 836 | + <!-- 自定义空状态:处理三种状态 --> | ||
| 837 | + <template #empty> | ||
| 838 | + <!-- Initial State (从未搜索过) --> | ||
| 839 | + <view v-if="!hasSearched" class="flex flex-col items-center justify-center py-[120rpx]"> | ||
| 840 | + <IconFont name="search" class="text-gray-300 mb-[24rpx]" size="64" /> | ||
| 841 | + <view class="text-[#6B7280] text-[28rpx]">搜索产品或资料</view> | ||
| 842 | + <view class="text-[#9CA3AF] text-[24rpx] mt-[12rpx]">输入关键词开始搜索,自动切换分类</view> | ||
| 843 | + </view> | ||
| 844 | + | ||
| 845 | + <!-- Empty State (已搜索但无结果) --> | ||
| 846 | + <view v-else> | ||
| 847 | + <nut-empty description="暂无搜索结果" image="empty"> | ||
| 848 | + <view class="text-[#9CA3AF] text-[24rpx] mt-[12rpx]">试试其他关键词吧</view> | ||
| 849 | + </nut-empty> | ||
| 850 | + </view> | ||
| 851 | + </template> | ||
| 852 | + </LoadMoreList> | ||
| 853 | + | ||
| 854 | + <!-- Plan Form Container --> | ||
| 855 | + <PlanFormContainer | ||
| 856 | + v-if="selectedProduct" | ||
| 857 | + v-model:visible="showPlanPopup" | ||
| 858 | + :product="selectedProduct" | ||
| 859 | + @submit="handlePlanSubmit" | ||
| 860 | + /> | ||
| 861 | + </view> | ||
| 862 | +</template> | ||
| 863 | + | ||
| 864 | +<script setup> | ||
| 865 | +import { ref, computed } from 'vue' | ||
| 866 | +import LoadMoreList from '@/components/LoadMoreList' | ||
| 867 | + | ||
| 868 | +// State | ||
| 869 | +const searchKeyword = ref('') | ||
| 870 | +const activeTab = ref('') // 当前选中的 tab(初始为空) | ||
| 871 | +const hasSearched = ref(false) // 是否已经搜索过 | ||
| 872 | + | ||
| 873 | +// 数据状态 - 双列表系统 | ||
| 874 | +const products = ref([]) // 产品列表 | ||
| 875 | +const files = ref([]) // 资料列表 | ||
| 876 | +const productsTotal = ref(0) // 产品总数 | ||
| 877 | +const filesTotal = ref(0) // 资料总数 | ||
| 878 | + | ||
| 879 | +// 分页状态 | ||
| 880 | +const loading = ref(false) | ||
| 881 | +const loadingMore = ref(false) | ||
| 882 | +const hasMore = ref(true) | ||
| 883 | +const currentPage = ref(0) | ||
| 884 | +const pageSize = 20 | ||
| 885 | + | ||
| 886 | +/** | ||
| 887 | + * 当前列表的列表 | ||
| 888 | + * @description 根据 activeTab 动态返回对应的列表数据 | ||
| 889 | + */ | ||
| 890 | +const currentList = computed(() => { | ||
| 891 | + if (!activeTab.value) return [] | ||
| 892 | + | ||
| 893 | + if (activeTab.value === 'product') { | ||
| 894 | + return products.value | ||
| 895 | + } else { | ||
| 896 | + return files.value | ||
| 897 | + } | ||
| 898 | +}) | ||
| 899 | + | ||
| 900 | +/** | ||
| 901 | + * 当前列表总数 | ||
| 902 | + */ | ||
| 903 | +const currentTotal = computed(() => { | ||
| 904 | + if (!activeTab.value) return 0 | ||
| 905 | + | ||
| 906 | + if (activeTab.value === 'product') { | ||
| 907 | + return productsTotal.value | ||
| 908 | + } else { | ||
| 909 | + return filesTotal.value | ||
| 910 | + } | ||
| 911 | +}) | ||
| 912 | + | ||
| 913 | +/** | ||
| 914 | + * 执行搜索 | ||
| 915 | + */ | ||
| 916 | +const performSearch = async (keyword, type, page = 0, limit = pageSize, isLoadMore = false) => { | ||
| 917 | + try { | ||
| 918 | + if (isLoadMore) { | ||
| 919 | + loadingMore.value = true | ||
| 920 | + } else { | ||
| 921 | + loading.value = true | ||
| 922 | + } | ||
| 923 | + | ||
| 924 | + const params = { keyword, page, limit } | ||
| 925 | + if (type) params.type = type | ||
| 926 | + | ||
| 927 | + const res = await searchAPI(params) | ||
| 928 | + | ||
| 929 | + if (res.code === 1) { | ||
| 930 | + // 映射产品列表 | ||
| 931 | + const newProducts = res.data.products.list || [] | ||
| 932 | + // 映射资料列表... | ||
| 933 | + const newFiles = (res.data.files.list || []).map(item => ({ /* ... */ })) | ||
| 934 | + | ||
| 935 | + // 根据是否为加载更多来处理数据 | ||
| 936 | + if (isLoadMore) { | ||
| 937 | + products.value = [...products.value, ...newProducts] | ||
| 938 | + files.value = [...files.value, ...newFiles] | ||
| 939 | + } else { | ||
| 940 | + products.value = newProducts | ||
| 941 | + files.value = newFiles | ||
| 942 | + } | ||
| 943 | + | ||
| 944 | + productsTotal.value = res.data.products.total || 0 | ||
| 945 | + filesTotal.value = res.data.files.total || 0 | ||
| 946 | + | ||
| 947 | + // ⭐ 重要:自动选择有数据的 tab | ||
| 948 | + if (!type && !isLoadMore) { | ||
| 949 | + if (productsTotal.value > 0) { | ||
| 950 | + activeTab.value = 'product' | ||
| 951 | + } else if (filesTotal.value > 0) { | ||
| 952 | + activeTab.value = 'file' | ||
| 953 | + } | ||
| 954 | + } | ||
| 955 | + | ||
| 956 | + // 判断是否还有更多数据 | ||
| 957 | + const actualTab = type || activeTab.value | ||
| 958 | + if (actualTab === 'product') { | ||
| 959 | + hasMore.value = products.value.length < productsTotal.value | ||
| 960 | + } else if (actualTab === 'file') { | ||
| 961 | + hasMore.value = files.value.length < filesTotal.value | ||
| 962 | + } else { | ||
| 963 | + hasMore.value = false | ||
| 964 | + } | ||
| 965 | + | ||
| 966 | + hasSearched.value = true | ||
| 967 | + } | ||
| 968 | + } catch (err) { | ||
| 969 | + console.error('搜索失败:', err) | ||
| 970 | + } finally { | ||
| 971 | + if (isLoadMore) { | ||
| 972 | + loadingMore.value = false | ||
| 973 | + } else { | ||
| 974 | + loading.value = false | ||
| 975 | + } | ||
| 976 | + } | ||
| 977 | +} | ||
| 978 | + | ||
| 979 | +/** | ||
| 980 | + * 处理加载更多事件 | ||
| 981 | + */ | ||
| 982 | +const handleLoadMore = async (page) => { | ||
| 983 | + currentPage.value = page | ||
| 984 | + | ||
| 985 | + if (!hasSearched.value || !activeTab.value || !searchKeyword.value.trim()) { | ||
| 986 | + return | ||
| 987 | + } | ||
| 988 | + | ||
| 989 | + await performSearch( | ||
| 990 | + searchKeyword.value.trim(), | ||
| 991 | + activeTab.value, | ||
| 992 | + page, | ||
| 993 | + pageSize, | ||
| 994 | + true | ||
| 995 | + ) | ||
| 996 | +} | ||
| 997 | + | ||
| 998 | +/** | ||
| 999 | + * Tab 点击处理(实时查询) | ||
| 1000 | + */ | ||
| 1001 | +const onTabClick = async (tabId) => { | ||
| 1002 | + if (activeTab.value === tabId) return | ||
| 1003 | + | ||
| 1004 | + activeTab.value = tabId | ||
| 1005 | + currentPage.value = 0 | ||
| 1006 | + hasMore.value = true | ||
| 1007 | + | ||
| 1008 | + if (hasSearched.value && searchKeyword.value.trim()) { | ||
| 1009 | + await performSearch(searchKeyword.value.trim(), tabId, 0, pageSize, false) | ||
| 1010 | + } | ||
| 1011 | +} | ||
| 1012 | + | ||
| 1013 | +/** | ||
| 1014 | + * 提交搜索 | ||
| 1015 | + */ | ||
| 1016 | +const handleSearch = async () => { | ||
| 1017 | + const keyword = searchKeyword.value.trim() | ||
| 1018 | + if (!keyword) { | ||
| 1019 | + // 提示输入关键词 | ||
| 1020 | + return | ||
| 1021 | + } | ||
| 1022 | + | ||
| 1023 | + currentPage.value = 0 | ||
| 1024 | + hasMore.value = true | ||
| 1025 | + | ||
| 1026 | + // 不传 type,让后端返回两种数据,前端自动选择 tab | ||
| 1027 | + await performSearch(keyword, undefined, 0, pageSize, false) | ||
| 1028 | +} | ||
| 1029 | + | ||
| 1030 | +/** | ||
| 1031 | + * 清空搜索 | ||
| 1032 | + */ | ||
| 1033 | +const clearSearch = () => { | ||
| 1034 | + searchKeyword.value = '' | ||
| 1035 | + hasSearched.value = false | ||
| 1036 | + products.value = [] | ||
| 1037 | + files.value = [] | ||
| 1038 | + productsTotal.value = 0 | ||
| 1039 | + filesTotal.value = 0 | ||
| 1040 | + activeTab.value = '' | ||
| 1041 | + currentPage.value = 0 | ||
| 1042 | + hasMore.value = true | ||
| 1043 | +} | ||
| 1044 | +</script> | ||
| 1045 | +``` | ||
| 1046 | + | ||
| 1047 | +**要点**: | ||
| 1048 | +- ✅ **双列表系统**: products 和 files 分别存储 | ||
| 1049 | +- ✅ **动态列表**: `currentList` computed 根据 activeTab 动态返回 | ||
| 1050 | +- ✅ **自动 tab 选择**: 首次搜索时自动选择有结果的 tab | ||
| 1051 | +- ✅ **三种空状态**: 初始状态、搜索无结果、有结果 | ||
| 1052 | + | ||
| 1053 | +--- | ||
| 1054 | + | ||
| 1055 | +## 迁移模式 | ||
| 1056 | + | ||
| 1057 | +### 模式 1: 简单列表迁移 | ||
| 1058 | + | ||
| 1059 | +**适用场景**: 只需展示列表,无复杂交互 | ||
| 1060 | + | ||
| 1061 | +**迁移步骤**: | ||
| 1062 | + | ||
| 1063 | +1. **定义状态** | ||
| 1064 | +```javascript | ||
| 1065 | +const currentList = ref([]) | ||
| 1066 | +const currentPage = ref(0) | ||
| 1067 | +const pageSize = 10 | ||
| 1068 | +const hasMore = ref(true) | ||
| 1069 | +const loading = ref(false) | ||
| 1070 | +const loadingMore = ref(false) | ||
| 1071 | +``` | ||
| 1072 | + | ||
| 1073 | +2. **实现数据加载函数** | ||
| 1074 | +```javascript | ||
| 1075 | +const fetchData = async (params = {}, isLoadMore = false) => { | ||
| 1076 | + try { | ||
| 1077 | + if (isLoadMore) { | ||
| 1078 | + loadingMore.value = true | ||
| 1079 | + } else { | ||
| 1080 | + loading.value = true | ||
| 1081 | + } | ||
| 1082 | + | ||
| 1083 | + const res = await yourAPI(params) | ||
| 1084 | + | ||
| 1085 | + if (res.code === 1 && res.data) { | ||
| 1086 | + const listData = res.data.list || [] | ||
| 1087 | + | ||
| 1088 | + if (isLoadMore) { | ||
| 1089 | + currentList.value = [...currentList.value, ...listData] | ||
| 1090 | + } else { | ||
| 1091 | + currentList.value = listData | ||
| 1092 | + } | ||
| 1093 | + | ||
| 1094 | + hasMore.value = listData.length >= pageSize | ||
| 1095 | + } | ||
| 1096 | + } catch (err) { | ||
| 1097 | + console.error('获取数据失败:', err) | ||
| 1098 | + } finally { | ||
| 1099 | + if (isLoadMore) { | ||
| 1100 | + loadingMore.value = false | ||
| 1101 | + } else { | ||
| 1102 | + loading.value = false | ||
| 1103 | + } | ||
| 1104 | + } | ||
| 1105 | +} | ||
| 1106 | +``` | ||
| 1107 | + | ||
| 1108 | +3. **实现 handleLoadMore** | ||
| 1109 | +```javascript | ||
| 1110 | +const handleLoadMore = async (page) => { | ||
| 1111 | + currentPage.value = page | ||
| 1112 | + await fetchData({ page, limit: pageSize }, true) | ||
| 1113 | +} | ||
| 1114 | +``` | ||
| 1115 | + | ||
| 1116 | +4. **使用组件** | ||
| 1117 | +```vue | ||
| 1118 | +<LoadMoreList | ||
| 1119 | + :list="currentList" | ||
| 1120 | + :page="currentPage" | ||
| 1121 | + :page-size="pageSize" | ||
| 1122 | + :has-more="hasMore" | ||
| 1123 | + :loading="loading" | ||
| 1124 | + :loading-more="loadingMore" | ||
| 1125 | + @load-more="handleLoadMore" | ||
| 1126 | +> | ||
| 1127 | + <template #item="{ item }"> | ||
| 1128 | + <!-- 列表项内容 --> | ||
| 1129 | + </template> | ||
| 1130 | +</LoadMoreList> | ||
| 1131 | +``` | ||
| 1132 | + | ||
| 1133 | +--- | ||
| 1134 | + | ||
| 1135 | +### 模式 2: 带搜索的列表迁移 | ||
| 1136 | + | ||
| 1137 | +**适用场景**: 需要搜索功能,可能还需要 tabs | ||
| 1138 | + | ||
| 1139 | +**关键点**: | ||
| 1140 | + | ||
| 1141 | +1. **搜索防抖**: 500ms 延迟 | ||
| 1142 | +```javascript | ||
| 1143 | +let searchTimer = null | ||
| 1144 | + | ||
| 1145 | +const onSearchInput = (value) => { | ||
| 1146 | + if (searchTimer) { | ||
| 1147 | + clearTimeout(searchTimer) | ||
| 1148 | + } | ||
| 1149 | + | ||
| 1150 | + searchTimer = setTimeout(() => { | ||
| 1151 | + currentPage.value = 0 | ||
| 1152 | + hasMore.value = true | ||
| 1153 | + | ||
| 1154 | + const params = { | ||
| 1155 | + page: 0, | ||
| 1156 | + limit: pageSize | ||
| 1157 | + } | ||
| 1158 | + | ||
| 1159 | + if (value) { | ||
| 1160 | + params.keyword = value | ||
| 1161 | + } | ||
| 1162 | + | ||
| 1163 | + fetchData(params, false) | ||
| 1164 | + }, 500) | ||
| 1165 | +} | ||
| 1166 | +``` | ||
| 1167 | + | ||
| 1168 | +2. **固定头部**: 使用 `sticky top-0 z-10` | ||
| 1169 | +```vue | ||
| 1170 | +<template #header> | ||
| 1171 | + <view class="sticky top-0 z-10 bg-[#FFF]"> | ||
| 1172 | + <NavHeader title="产品中心" /> | ||
| 1173 | + <SearchBar v-model="searchValue" @search="onSearch" /> | ||
| 1174 | + </view> | ||
| 1175 | +</template> | ||
| 1176 | +``` | ||
| 1177 | + | ||
| 1178 | +--- | ||
| 1179 | + | ||
| 1180 | +### 模式 3: 带分类缓存的列表迁移 | ||
| 1181 | + | ||
| 1182 | +**适用场景**: 需要缓存每个分类的分页状态 | ||
| 1183 | + | ||
| 1184 | +**关键点**: | ||
| 1185 | + | ||
| 1186 | +1. **使用 Map 保存缓存** | ||
| 1187 | +```javascript | ||
| 1188 | +const categoryPageCache = ref(new Map()) | ||
| 1189 | +const categoryListCache = ref(new Map()) | ||
| 1190 | +``` | ||
| 1191 | + | ||
| 1192 | +2. **保存状态到缓存** | ||
| 1193 | +```javascript | ||
| 1194 | +const cacheKey = activeTabId.value | ||
| 1195 | +categoryPageCache.value.set(cacheKey, { | ||
| 1196 | + currentPage: currentPage.value, | ||
| 1197 | + hasMore: hasMore.value | ||
| 1198 | +}) | ||
| 1199 | +categoryListCache.value.set(cacheKey, [...currentList.value]) | ||
| 1200 | +``` | ||
| 1201 | + | ||
| 1202 | +3. **从缓存读取** | ||
| 1203 | +```javascript | ||
| 1204 | +const cached = categoryPageCache.value.get(tabId) | ||
| 1205 | +if (cached) { | ||
| 1206 | + currentPage.value = cached.currentPage | ||
| 1207 | + hasMore.value = cached.hasMore | ||
| 1208 | + currentList.value = categoryListCache.value.get(tabId) || [] | ||
| 1209 | +} | ||
| 1210 | +``` | ||
| 1211 | + | ||
| 1212 | +--- | ||
| 1213 | + | ||
| 1214 | +### 模式 4: 带下拉刷新的列表迁移 | ||
| 1215 | + | ||
| 1216 | +**适用场景**: 需要支持下拉刷新 | ||
| 1217 | + | ||
| 1218 | +**关键点**: | ||
| 1219 | + | ||
| 1220 | +1. **启用下拉刷新** | ||
| 1221 | +```vue | ||
| 1222 | +<LoadMoreList | ||
| 1223 | + :enable-pull-down-refresh="true" | ||
| 1224 | + @refresh="handleRefresh" | ||
| 1225 | +> | ||
| 1226 | + <!-- ... --> | ||
| 1227 | +</LoadMoreList> | ||
| 1228 | +``` | ||
| 1229 | + | ||
| 1230 | +2. **实现 handleRefresh** | ||
| 1231 | +```javascript | ||
| 1232 | +const handleRefresh = async () => { | ||
| 1233 | + currentPage.value = 0 // 或 1,根据 API 要求 | ||
| 1234 | + hasMore.value = true | ||
| 1235 | + await fetchData({ page: currentPage.value, limit: pageSize }) | ||
| 1236 | +} | ||
| 1237 | +``` | ||
| 1238 | + | ||
| 1239 | +--- | ||
| 1240 | + | ||
| 1241 | +### 模式 5: 双列表系统迁移 | ||
| 1242 | + | ||
| 1243 | +**适用场景**: 需要支持多种类型的数据(产品和资料) | ||
| 1244 | + | ||
| 1245 | +**关键点**: | ||
| 1246 | + | ||
| 1247 | +1. **双列表存储** | ||
| 1248 | +```javascript | ||
| 1249 | +const products = ref([]) | ||
| 1250 | +const files = ref([]) | ||
| 1251 | +``` | ||
| 1252 | + | ||
| 1253 | +2. **动态列表 computed** | ||
| 1254 | +```javascript | ||
| 1255 | +const currentList = computed(() => { | ||
| 1256 | + if (activeTab.value === 'product') { | ||
| 1257 | + return products.value | ||
| 1258 | + } else { | ||
| 1259 | + return files.value | ||
| 1260 | + } | ||
| 1261 | +}) | ||
| 1262 | +``` | ||
| 1263 | + | ||
| 1264 | +3. **动态列表总数 computed** | ||
| 1265 | +```javascript | ||
| 1266 | +const currentTotal = computed(() => { | ||
| 1267 | + if (activeTab.value === 'product') { | ||
| 1268 | + return productsTotal.value | ||
| 1269 | + } else { | ||
| 1270 | + return filesTotal.value | ||
| 1271 | + } | ||
| 1272 | +}) | ||
| 1273 | +``` | ||
| 1274 | + | ||
| 1275 | +--- | ||
| 1276 | + | ||
| 1277 | +## 最佳实践 | ||
| 1278 | + | ||
| 1279 | +### ✅ 推荐做法 | ||
| 1280 | + | ||
| 1281 | +#### 1. 使用 `key-field` prop | ||
| 1282 | + | ||
| 1283 | +确保列表更新正确,避免渲染问题。 | ||
| 1284 | + | ||
| 1285 | +```vue | ||
| 1286 | +<LoadMoreList | ||
| 1287 | + :list="products" | ||
| 1288 | + key-field="id" <!-- ✅ 使用唯一标识 --> | ||
| 1289 | + @load-more="handleLoadMore" | ||
| 1290 | +> | ||
| 1291 | +``` | ||
| 1292 | + | ||
| 1293 | +#### 2. 区分 `loading` 和 `loadingMore` | ||
| 1294 | + | ||
| 1295 | +提升用户体验,显示不同的加载状态。 | ||
| 1296 | + | ||
| 1297 | +```javascript | ||
| 1298 | +// 首次加载 | ||
| 1299 | +loading.value = true | ||
| 1300 | +currentList.value = [] | ||
| 1301 | + | ||
| 1302 | +// 加载更多 | ||
| 1303 | +loadingMore.value = true | ||
| 1304 | +currentList.value = [...currentList.value, ...newData] | ||
| 1305 | +``` | ||
| 1306 | + | ||
| 1307 | +#### 3. 使用 JSDoc 注释 | ||
| 1308 | + | ||
| 1309 | +提升代码可读性。 | ||
| 1310 | + | ||
| 1311 | +```javascript | ||
| 1312 | +/** | ||
| 1313 | + * 获取产品列表 | ||
| 1314 | + * | ||
| 1315 | + * @description 从 API 获取产品列表数据 | ||
| 1316 | + * @param {Object} params - 请求参数 | ||
| 1317 | + * @param {number} params.page - 页码 | ||
| 1318 | + * @param {number} params.limit - 每页数量 | ||
| 1319 | + * @param {boolean} isLoadMore - 是否为加载更多 | ||
| 1320 | + * @returns {Promise<void>} | ||
| 1321 | + */ | ||
| 1322 | +const fetchProducts = async (params = {}, isLoadMore = false) => { | ||
| 1323 | + // ... | ||
| 1324 | +} | ||
| 1325 | +``` | ||
| 1326 | + | ||
| 1327 | +#### 4. 使用 slot 自定义 | ||
| 1328 | + | ||
| 1329 | +保持组件灵活性,不要过度修改组件内部代码。 | ||
| 1330 | + | ||
| 1331 | +```vue | ||
| 1332 | +<LoadMoreList> | ||
| 1333 | + <template #header> | ||
| 1334 | + <!-- 自定义头部 --> | ||
| 1335 | + </template> | ||
| 1336 | + | ||
| 1337 | + <template #item="{ item }"> | ||
| 1338 | + <!-- 自定义列表项 --> | ||
| 1339 | + </template> | ||
| 1340 | + | ||
| 1341 | + <template #empty> | ||
| 1342 | + <!-- 自定义空状态 --> | ||
| 1343 | + </template> | ||
| 1344 | +</LoadMoreList> | ||
| 1345 | +``` | ||
| 1346 | + | ||
| 1347 | +#### 5. 使用 `computed` 简化模板 | ||
| 1348 | + | ||
| 1349 | +将复杂逻辑提取到 computed 中。 | ||
| 1350 | + | ||
| 1351 | +```javascript | ||
| 1352 | +const currentList = computed(() => { | ||
| 1353 | + if (activeTab.value === 'product') { | ||
| 1354 | + return products.value | ||
| 1355 | + } else { | ||
| 1356 | + return files.value | ||
| 1357 | + } | ||
| 1358 | +}) | ||
| 1359 | +``` | ||
| 1360 | + | ||
| 1361 | +--- | ||
| 1362 | + | ||
| 1363 | +### ❌ 避免做法 | ||
| 1364 | + | ||
| 1365 | +#### 1. 在 slot 中处理分页 | ||
| 1366 | + | ||
| 1367 | +应该在父组件处理,不要在 slot 中修改分页状态。 | ||
| 1368 | + | ||
| 1369 | +```vue | ||
| 1370 | +<!-- ❌ 错误 --> | ||
| 1371 | +<template #item="{ item }"> | ||
| 1372 | + <view @tap="loadMore()">加载更多</view> | ||
| 1373 | +</template> | ||
| 1374 | + | ||
| 1375 | +<!-- ✅ 正确 --> | ||
| 1376 | +<LoadMoreList @load-more="handleLoadMore"> | ||
| 1377 | + <!-- 组件内部会自动触发 load-more 事件 --> | ||
| 1378 | +</LoadMoreList> | ||
| 1379 | +``` | ||
| 1380 | + | ||
| 1381 | +#### 2. 忽略 `key-field` | ||
| 1382 | + | ||
| 1383 | +可能导致列表更新异常。 | ||
| 1384 | + | ||
| 1385 | +```vue | ||
| 1386 | +<!-- ❌ 错误 --> | ||
| 1387 | +<LoadMoreList :list="products"> | ||
| 1388 | + | ||
| 1389 | +<!-- ✅ 正确 --> | ||
| 1390 | +<LoadMoreList :list="products" key-field="id"> | ||
| 1391 | +``` | ||
| 1392 | + | ||
| 1393 | +#### 3. 直接修改 props | ||
| 1394 | + | ||
| 1395 | +应该通过事件通知父组件。 | ||
| 1396 | + | ||
| 1397 | +```javascript | ||
| 1398 | +// ❌ 错误 | ||
| 1399 | +const handleClick = () => { | ||
| 1400 | + props.list.push(newItem) // 直接修改 props | ||
| 1401 | +} | ||
| 1402 | + | ||
| 1403 | +// ✅ 正确 | ||
| 1404 | +const emit = defineEmits(['update']) | ||
| 1405 | +const handleClick = () => { | ||
| 1406 | + emit('update', [...props.list, newItem]) | ||
| 1407 | +} | ||
| 1408 | +``` | ||
| 1409 | + | ||
| 1410 | +#### 4. 过度自定义 slot | ||
| 1411 | + | ||
| 1412 | +能用默认的就用默认的,保持简洁。 | ||
| 1413 | + | ||
| 1414 | +```vue | ||
| 1415 | +<!-- ❌ 过度自定义 --> | ||
| 1416 | +<LoadMoreList> | ||
| 1417 | + <template #loading> | ||
| 1418 | + <!-- 复杂的加载动画 --> | ||
| 1419 | + </template> | ||
| 1420 | + | ||
| 1421 | + <template #loading-more> | ||
| 1422 | + <!-- 复杂的加载动画 --> | ||
| 1423 | + </template> | ||
| 1424 | +</LoadMoreList> | ||
| 1425 | + | ||
| 1426 | +<!-- ✅ 使用默认 --> | ||
| 1427 | +<LoadMoreList> | ||
| 1428 | + <!-- 组件内置的加载动画已经很好了 --> | ||
| 1429 | +</LoadMoreList> | ||
| 1430 | +``` | ||
| 1431 | + | ||
| 1432 | +--- | ||
| 1433 | + | ||
| 1434 | +## 常见问题 | ||
| 1435 | + | ||
| 1436 | +### Q1: 如何处理 API 页码从 1 开始还是从 0 开始? | ||
| 1437 | + | ||
| 1438 | +**A**: 根据 API 要求设置初始页码。 | ||
| 1439 | + | ||
| 1440 | +```javascript | ||
| 1441 | +// 如果 API 页码从 1 开始 | ||
| 1442 | +const currentPage = ref(1) | ||
| 1443 | + | ||
| 1444 | +// 如果 API 页码从 0 开始 | ||
| 1445 | +const currentPage = ref(0) | ||
| 1446 | + | ||
| 1447 | +// handleLoadMore 不需要修改,组件会自动 +1 | ||
| 1448 | +const handleLoadMore = async (page) => { | ||
| 1449 | + currentPage.value = page | ||
| 1450 | + await fetchData({ page, limit: pageSize }, true) | ||
| 1451 | +} | ||
| 1452 | +``` | ||
| 1453 | + | ||
| 1454 | +--- | ||
| 1455 | + | ||
| 1456 | +### Q2: 如何判断是否还有更多数据? | ||
| 1457 | + | ||
| 1458 | +**A**: 比较返回的数据量与请求的数据量。 | ||
| 1459 | + | ||
| 1460 | +```javascript | ||
| 1461 | +if (res.code === 1 && res.data) { | ||
| 1462 | + const listData = res.data.list || [] | ||
| 1463 | + | ||
| 1464 | + if (isLoadMore) { | ||
| 1465 | + currentList.value = [...currentList.value, ...listData] | ||
| 1466 | + } else { | ||
| 1467 | + currentList.value = listData | ||
| 1468 | + } | ||
| 1469 | + | ||
| 1470 | + // 如果返回的数据量 >= 请求的量,说明还有更多 | ||
| 1471 | + hasMore.value = listData.length >= pageSize | ||
| 1472 | +} | ||
| 1473 | +``` | ||
| 1474 | + | ||
| 1475 | +--- | ||
| 1476 | + | ||
| 1477 | +### Q3: 如何实现搜索防抖? | ||
| 1478 | + | ||
| 1479 | +**A**: 使用 `setTimeout` + `clearTimeout`。 | ||
| 1480 | + | ||
| 1481 | +```javascript | ||
| 1482 | +let searchTimer = null | ||
| 1483 | + | ||
| 1484 | +const onSearchInput = (value) => { | ||
| 1485 | + // 清除之前的定时器 | ||
| 1486 | + if (searchTimer) { | ||
| 1487 | + clearTimeout(searchTimer) | ||
| 1488 | + } | ||
| 1489 | + | ||
| 1490 | + // 设置新的定时器(500ms 后执行搜索) | ||
| 1491 | + searchTimer = setTimeout(() => { | ||
| 1492 | + currentPage.value = 0 | ||
| 1493 | + hasMore.value = true | ||
| 1494 | + | ||
| 1495 | + const params = { | ||
| 1496 | + page: 0, | ||
| 1497 | + limit: pageSize | ||
| 1498 | + } | ||
| 1499 | + | ||
| 1500 | + if (value) { | ||
| 1501 | + params.keyword = value | ||
| 1502 | + } | ||
| 1503 | + | ||
| 1504 | + fetchData(params, false) | ||
| 1505 | + }, 500) | ||
| 1506 | +} | ||
| 1507 | +``` | ||
| 1508 | + | ||
| 1509 | +--- | ||
| 1510 | + | ||
| 1511 | +### Q4: 如何缓存分类的分页状态? | ||
| 1512 | + | ||
| 1513 | +**A**: 使用 Map 保存每个分类的状态。 | ||
| 1514 | + | ||
| 1515 | +```javascript | ||
| 1516 | +// 缓存 Map | ||
| 1517 | +const categoryPageCache = ref(new Map()) | ||
| 1518 | +const categoryListCache = ref(new Map()) | ||
| 1519 | + | ||
| 1520 | +// 保存到缓存 | ||
| 1521 | +const saveToCache = (tabId) => { | ||
| 1522 | + categoryPageCache.value.set(tabId, { | ||
| 1523 | + currentPage: currentPage.value, | ||
| 1524 | + hasMore: hasMore.value | ||
| 1525 | + }) | ||
| 1526 | + categoryListCache.value.set(tabId, [...currentList.value]) | ||
| 1527 | +} | ||
| 1528 | + | ||
| 1529 | +// 从缓存读取 | ||
| 1530 | +const loadFromCache = (tabId) => { | ||
| 1531 | + const cached = categoryPageCache.value.get(tabId) | ||
| 1532 | + if (cached) { | ||
| 1533 | + currentPage.value = cached.currentPage | ||
| 1534 | + hasMore.value = cached.hasMore | ||
| 1535 | + currentList.value = categoryListCache.value.get(tabId) || [] | ||
| 1536 | + } | ||
| 1537 | +} | ||
| 1538 | + | ||
| 1539 | +// Tab 点击时先尝试从缓存读取 | ||
| 1540 | +const onTabClick = (tabId) => { | ||
| 1541 | + loadFromCache(tabId) | ||
| 1542 | + | ||
| 1543 | + // 如果缓存为空,重新加载 | ||
| 1544 | + if (currentList.value.length === 0) { | ||
| 1545 | + fetchData({ /* ... */ }, false) | ||
| 1546 | + } | ||
| 1547 | +} | ||
| 1548 | +``` | ||
| 1549 | + | ||
| 1550 | +--- | ||
| 1551 | + | ||
| 1552 | +### Q5: 如何实现双列表系统(如搜索页)? | ||
| 1553 | + | ||
| 1554 | +**A**: 分别存储两个列表,使用 computed 动态返回。 | ||
| 1555 | + | ||
| 1556 | +```javascript | ||
| 1557 | +// 双列表存储 | ||
| 1558 | +const products = ref([]) | ||
| 1559 | +const files = ref([]) | ||
| 1560 | + | ||
| 1561 | +// 动态列表 | ||
| 1562 | +const currentList = computed(() => { | ||
| 1563 | + if (activeTab.value === 'product') { | ||
| 1564 | + return products.value | ||
| 1565 | + } else { | ||
| 1566 | + return files.value | ||
| 1567 | + } | ||
| 1568 | +}) | ||
| 1569 | + | ||
| 1570 | +// 动态总数 | ||
| 1571 | +const currentTotal = computed(() => { | ||
| 1572 | + if (activeTab.value === 'product') { | ||
| 1573 | + return productsTotal.value | ||
| 1574 | + } else { | ||
| 1575 | + return filesTotal.value | ||
| 1576 | + } | ||
| 1577 | +}) | ||
| 1578 | +``` | ||
| 1579 | + | ||
| 1580 | +--- | ||
| 1581 | + | ||
| 1582 | +### Q6: 如何处理嵌套弹窗(如计划书弹窗)? | ||
| 1583 | + | ||
| 1584 | +**A**: 将弹窗组件放在 LoadMoreList 外部作为兄弟节点。 | ||
| 1585 | + | ||
| 1586 | +```vue | ||
| 1587 | +<template> | ||
| 1588 | + <view> | ||
| 1589 | + <!-- 计划书弹窗(放在 LoadMoreList 外部) --> | ||
| 1590 | + <view v-if="showPlanPopup && selectedProduct"> | ||
| 1591 | + <PlanFormContainer | ||
| 1592 | + v-model:visible="showPlanPopup" | ||
| 1593 | + :product="selectedProduct" | ||
| 1594 | + @submit="handlePlanSubmit" | ||
| 1595 | + /> | ||
| 1596 | + </view> | ||
| 1597 | + | ||
| 1598 | + <!-- LoadMoreList --> | ||
| 1599 | + <LoadMoreList | ||
| 1600 | + :list="currentList" | ||
| 1601 | + @load-more="handleLoadMore" | ||
| 1602 | + > | ||
| 1603 | + <!-- ... --> | ||
| 1604 | + </LoadMoreList> | ||
| 1605 | + </view> | ||
| 1606 | +</template> | ||
| 1607 | +``` | ||
| 1608 | + | ||
| 1609 | +--- | ||
| 1610 | + | ||
| 1611 | +### Q7: 如何实现三种空状态(初始、搜索无结果、有结果)? | ||
| 1612 | + | ||
| 1613 | +**A**: 使用状态变量控制不同的空状态显示。 | ||
| 1614 | + | ||
| 1615 | +```vue | ||
| 1616 | +<template #empty> | ||
| 1617 | + <!-- Initial State (从未搜索过) --> | ||
| 1618 | + <view v-if="!hasSearched"> | ||
| 1619 | + <IconFont name="search" /> | ||
| 1620 | + <view>搜索产品或资料</view> | ||
| 1621 | + </view> | ||
| 1622 | + | ||
| 1623 | + <!-- Empty State (已搜索但无结果) --> | ||
| 1624 | + <view v-else> | ||
| 1625 | + <nut-empty description="暂无搜索结果" /> | ||
| 1626 | + </view> | ||
| 1627 | +</template> | ||
| 1628 | + | ||
| 1629 | +<script setup> | ||
| 1630 | +const hasSearched = ref(false) | ||
| 1631 | + | ||
| 1632 | +const handleSearch = async () => { | ||
| 1633 | + // ... | ||
| 1634 | + hasSearched.value = true | ||
| 1635 | +} | ||
| 1636 | + | ||
| 1637 | +const clearSearch = () => { | ||
| 1638 | + hasSearched.value = false | ||
| 1639 | + // ... | ||
| 1640 | +} | ||
| 1641 | +</script> | ||
| 1642 | +``` | ||
| 1643 | + | ||
| 1644 | +--- | ||
| 1645 | + | ||
| 1646 | +## 性能优化 | ||
| 1647 | + | ||
| 1648 | +### 1. 动画延迟优化 | ||
| 1649 | + | ||
| 1650 | +**问题**: 如果每个列表项都使用动画延迟,长列表会导致累积延迟。 | ||
| 1651 | + | ||
| 1652 | +**解决方案**: 只为前10项使用动画延迟。 | ||
| 1653 | + | ||
| 1654 | +```javascript | ||
| 1655 | +// ✅ LoadMoreList 组件内部已实现 | ||
| 1656 | +function getAnimationDelay(index) { | ||
| 1657 | + // 只为前10项使用动画延迟 | ||
| 1658 | + if (index < 10) { | ||
| 1659 | + return { animationDelay: `${index * 20}ms` } | ||
| 1660 | + } | ||
| 1661 | + // 第10项以后立即显示(无延迟) | ||
| 1662 | + return {} | ||
| 1663 | +} | ||
| 1664 | +``` | ||
| 1665 | + | ||
| 1666 | +**效果**: | ||
| 1667 | +- 前10项:逐个进入,形成波浪效果 | ||
| 1668 | +- 第10项以后:立即显示,避免累积延迟 | ||
| 1669 | + | ||
| 1670 | +--- | ||
| 1671 | + | ||
| 1672 | +### 2. 触底加载防抖 | ||
| 1673 | + | ||
| 1674 | +**问题**: 用户滚动到底部时,`useReachBottom` 可能触发多次。 | ||
| 1675 | + | ||
| 1676 | +**解决方案**: 使用 300ms 防抖。 | ||
| 1677 | + | ||
| 1678 | +```javascript | ||
| 1679 | +// ✅ LoadMoreList 组件内部已实现 | ||
| 1680 | +let loadMoreTimer = null | ||
| 1681 | + | ||
| 1682 | +useReachBottom(() => { | ||
| 1683 | + // 如果正在加载或没有更多数据,不执行 | ||
| 1684 | + if (props.loadingMore || props.loading || !props.hasMore) { | ||
| 1685 | + return | ||
| 1686 | + } | ||
| 1687 | + | ||
| 1688 | + // 防抖:300ms 内只触发一次 | ||
| 1689 | + if (loadMoreTimer) { | ||
| 1690 | + clearTimeout(loadMoreTimer) | ||
| 1691 | + } | ||
| 1692 | + | ||
| 1693 | + loadMoreTimer = setTimeout(() => { | ||
| 1694 | + const nextPage = props.page + 1 | ||
| 1695 | + emit('load-more', nextPage) | ||
| 1696 | + }, 300) | ||
| 1697 | +}) | ||
| 1698 | +``` | ||
| 1699 | + | ||
| 1700 | +--- | ||
| 1701 | + | ||
| 1702 | +### 3. 搜索防抖 | ||
| 1703 | + | ||
| 1704 | +**问题**: 用户输入时频繁触发搜索请求。 | ||
| 1705 | + | ||
| 1706 | +**解决方案**: 使用 500ms 防抖。 | ||
| 1707 | + | ||
| 1708 | +```javascript | ||
| 1709 | +let searchTimer = null | ||
| 1710 | + | ||
| 1711 | +const onSearchInput = (value) => { | ||
| 1712 | + if (searchTimer) { | ||
| 1713 | + clearTimeout(searchTimer) | ||
| 1714 | + } | ||
| 1715 | + | ||
| 1716 | + searchTimer = setTimeout(() => { | ||
| 1717 | + // 执行搜索 | ||
| 1718 | + fetchData({ keyword: value }) | ||
| 1719 | + }, 500) | ||
| 1720 | +} | ||
| 1721 | +``` | ||
| 1722 | + | ||
| 1723 | +--- | ||
| 1724 | + | ||
| 1725 | +### 4. 分类缓存 | ||
| 1726 | + | ||
| 1727 | +**问题**: 切换分类时重新加载数据,用户体验差。 | ||
| 1728 | + | ||
| 1729 | +**解决方案**: 使用 Map 缓存每个分类的数据。 | ||
| 1730 | + | ||
| 1731 | +```javascript | ||
| 1732 | +const categoryPageCache = ref(new Map()) | ||
| 1733 | +const categoryListCache = ref(new Map()) | ||
| 1734 | + | ||
| 1735 | +// 切换分类时先从缓存读取 | ||
| 1736 | +const onTabClick = (tabId) => { | ||
| 1737 | + const cached = categoryPageCache.value.get(tabId) | ||
| 1738 | + if (cached) { | ||
| 1739 | + // 从缓存读取,立即显示 | ||
| 1740 | + currentPage.value = cached.currentPage | ||
| 1741 | + currentList.value = categoryListCache.value.get(tabId) || [] | ||
| 1742 | + } else { | ||
| 1743 | + // 缓存为空,重新加载 | ||
| 1744 | + fetchData({ /* ... */ }) | ||
| 1745 | + } | ||
| 1746 | +} | ||
| 1747 | +``` | ||
| 1748 | + | ||
| 1749 | +--- | ||
| 1750 | + | ||
| 1751 | +### 5. 数据追加 vs 替换 | ||
| 1752 | + | ||
| 1753 | +**问题**: 不正确处理数据会导致重复或丢失数据。 | ||
| 1754 | + | ||
| 1755 | +**解决方案**: | ||
| 1756 | +- **首次加载/刷新**: 替换数据 | ||
| 1757 | +- **加载更多**: 追加数据 | ||
| 1758 | + | ||
| 1759 | +```javascript | ||
| 1760 | +if (isLoadMore) { | ||
| 1761 | + // 追加数据 | ||
| 1762 | + currentList.value = [...currentList.value, ...listData] | ||
| 1763 | +} else { | ||
| 1764 | + // 替换数据 | ||
| 1765 | + currentList.value = listData | ||
| 1766 | +} | ||
| 1767 | +``` | ||
| 1768 | + | ||
| 1769 | +--- | ||
| 1770 | + | ||
| 1771 | +## 更新日志 | ||
| 1772 | + | ||
| 1773 | +### v2.0.0 (2026-02-08) | ||
| 1774 | + | ||
| 1775 | +#### 新增 | ||
| 1776 | +- ✅ 完整的组件 API 文档 | ||
| 1777 | +- ✅ 5 个页面的实际迁移案例 | ||
| 1778 | +- ✅ 5 种迁移模式详解 | ||
| 1779 | +- ✅ 最佳实践和常见问题 | ||
| 1780 | +- ✅ 性能优化建议 | ||
| 1781 | + | ||
| 1782 | +#### 迁移完成 | ||
| 1783 | +- ✅ week-hot-material 页面 | ||
| 1784 | +- ✅ message 页面 | ||
| 1785 | +- ✅ product-center 页面 | ||
| 1786 | +- ✅ material-list 页面 | ||
| 1787 | +- ✅ search 页面 | ||
| 1788 | + | ||
| 1789 | +#### 收益 | ||
| 1790 | +- ✅ 减少重复代码 ~700 行 | ||
| 1791 | +- ✅ 统一 5 个页面的分页加载逻辑 | ||
| 1792 | +- ✅ 统一动画效果和加载状态 | ||
| 1793 | +- ✅ 提升代码可维护性 | ||
| 1794 | + | ||
| 1795 | +--- | ||
| 1796 | + | ||
| 1797 | +## 相关文档 | ||
| 1798 | + | ||
| 1799 | +- [LoadMoreList 组件源码](../src/components/LoadMoreList/index.vue) | ||
| 1800 | +- [项目 CLAUDE.md](../CLAUDE.md) | ||
| 1801 | +- [经验教训总结](lessons-learned.md) | ||
| 1802 | +- [Vue 3 最佳实践](~/.claude/rules/vue-best-practices.md) | ||
| 1803 | + | ||
| 1804 | +--- | ||
| 1805 | + | ||
| 1806 | +**创建时间**: 2026-02-08 | ||
| 1807 | +**维护者**: Claude Code | ||
| 1808 | +**版本**: 2.0.0 |
| ... | @@ -670,7 +670,7 @@ if (!Number.isNaN(birthDate.getTime())) { | ... | @@ -670,7 +670,7 @@ if (!Number.isNaN(birthDate.getTime())) { |
| 670 | 670 | ||
| 671 | ### 项目文档 | 671 | ### 项目文档 |
| 672 | - [计划书架构设计](../plan/plan-entry-architecture.md) | 672 | - [计划书架构设计](../plan/plan-entry-architecture.md) |
| 673 | -- [API 联调日志](../api-integration-log.md) | 673 | +- [API 联调日志](../api-specs/API 集成日志.md) |
| 674 | - [变更日志](../CHANGELOG.md) | 674 | - [变更日志](../CHANGELOG.md) |
| 675 | 675 | ||
| 676 | ### 技术文档 | 676 | ### 技术文档 | ... | ... |
-
Please register or login to post a comment