hookehuyr

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

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

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
...@@ -302,7 +302,7 @@ console.log(product.form_sn) // 应该有值,如 "life-insurance-wiop3e" ...@@ -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
......
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 ### 技术文档
......