hookehuyr

refactor(config): 统一使用 picsum.photos 占位图服务

1 +# 带 Tab 的加载列表 - 状态保持改进方案
2 +
3 +## 改进背景
4 +
5 +当前实现在带 Tab 的加载列表(如搜索页面)中存在以下问题:
6 +
7 +1. **分页状态无法分别跟踪**:使用单一的 `currentPage` ref,切换 tab 时会重置页码
8 +2. **滚动位置无法保持**:使用页面级滚动,切换 tab 后滚动位置会丢失
9 +
10 +用户期望的行为:
11 +- 在一个列表中滚动到某个位置(比如第 3 页)
12 +- 点击另一个 tab
13 +- 再切换回原来的 tab
14 +- 期望列表保持原来的滚动位置和分页状态,继续往下滚动时能从原来的位置加载
15 +
16 +## 当前实现的问题
17 +
18 +### ❌ 问题 1:分页状态无法分别跟踪
19 +
20 +```javascript
21 +// src/pages/search/index.vue:168
22 +const currentPage = ref(0) // 单一页码,无法分别跟踪两个tab
23 +
24 +// 切换 tab 时重置
25 +const onTabClick = async (tabId) => {
26 + // ...
27 + currentPage.value = 0 // ❌ 重置为0,丢失了原来的页码
28 +}
29 +```
30 +
31 +**影响**
32 +- 产品列表滚动到第 3 页
33 +- 切换到资料列表
34 +- 再切回产品列表
35 +- 页码变成 0,需要重新从第 1 页开始
36 +
37 +### ❌ 问题 2:滚动位置无法保持
38 +
39 +LoadMoreList 组件使用的是**页面级滚动**,而非 `scroll-view` 组件:
40 +
41 +```vue
42 +<!-- src/components/LoadMoreList/index.vue:39 -->
43 +<view class="load-more-content">
44 + <!-- 列表内容 -->
45 +</view>
46 +```
47 +
48 +**影响**
49 +- 列表滚动到某个位置
50 +- 切换 tab(列表重新渲染)
51 +- 页面滚动位置会重置到顶部
52 +
53 +### ✅ 问题 3:数据可以保持
54 +
55 +列表数据本身会被保留:
56 +
57 +```javascript
58 +// src/pages/search/index.vue:159-162
59 +const products = ref([]) // 产品列表数据
60 +const files = ref([]) // 资料列表数据
61 +```
62 +
63 +但这还不够,因为分页状态和滚动位置都丢失了。
64 +
65 +## 改进方案
66 +
67 +### 核心思路
68 +
69 +1. **为每个 tab 维护独立的分页状态**(page、hasMore)
70 +2. **使用 scroll-view 替代页面级滚动**,并保存每个 tab 的滚动位置
71 +3. **切换 tab 时保存和恢复滚动位置**
72 +4. **切换 tab 时不重新请求已有数据**
73 +
74 +### 实现代码
75 +
76 +#### 1. 状态管理改进
77 +
78 +```javascript
79 +// ❌ 原来的实现(单一状态)
80 +// const currentPage = ref(0)
81 +// const hasMore = ref(true)
82 +
83 +// ✅ 改进方案:为每个 tab 维护独立的状态
84 +const tabState = ref({
85 + product: {
86 + page: 0,
87 + hasMore: true,
88 + scrollTop: 0 // 保存滚动位置
89 + },
90 + file: {
91 + page: 0,
92 + hasMore: true,
93 + scrollTop: 0
94 + }
95 +})
96 +
97 +// 当前的 page 和 hasMore 从 tabState 中获取
98 +const currentPage = computed(() => {
99 + return activeTab.value ? tabState.value[activeTab.value].page : 0
100 +})
101 +
102 +const hasMore = computed(() => {
103 + return activeTab.value ? tabState.value[activeTab.value].hasMore : true
104 +})
105 +```
106 +
107 +#### 2. LoadMoreList 组件改进
108 +
109 +```vue
110 +<template>
111 + <view class="load-more-list" :class="{ 'has-header': showHeader }">
112 + <!-- 可选固定头部 -->
113 + <view v-if="showHeader" class="load-more-header sticky top-0 z-10">
114 + <slot name="header"></slot>
115 + </view>
116 +
117 + <!-- 使用 scroll-view 替代普通 view -->
118 + <scroll-view
119 + class="load-more-content"
120 + :class="{ 'no-padding': noPadding }"
121 + :scroll-top="scrollTop"
122 + scroll-y
123 + @scroll="handleScroll"
124 + @scrolltolower="handleScrollToLower"
125 + >
126 + <!-- 列表内容(保持不变) -->
127 + <view v-if="loading && list.length === 0">
128 + <!-- loading -->
129 + </view>
130 +
131 + <view v-else class="list-container">
132 + <view
133 + v-for="(item, index) in displayList"
134 + :key="item[keyField] || index"
135 + class="list-item"
136 + >
137 + <slot name="item" :item="item" :index="index"></slot>
138 + </view>
139 +
140 + <!-- 空状态和加载更多提示 -->
141 + </view>
142 + </scroll-view>
143 + </view>
144 +</template>
145 +
146 +<script setup>
147 +import { computed } from 'vue'
148 +import { usePullDownRefresh, stopPullDownRefresh } from '@tarojs/taro'
149 +
150 +const props = defineProps({
151 + // ... 原有 props
152 + scrollTop: {
153 + type: Number,
154 + default: 0
155 + }
156 +})
157 +
158 +const emit = defineEmits(['load-more', 'refresh', 'scroll'])
159 +
160 +/**
161 + * scroll-view 滚动事件
162 + * @param {Object} e - 滚动事件对象
163 + */
164 +const handleScroll = (e) => {
165 + emit('scroll', e)
166 +}
167 +
168 +/**
169 + * scroll-view 触底事件
170 + */
171 +const handleScrollToLower = () => {
172 + // 如果正在加载或没有更多数据,不执行
173 + if (props.loadingMore || props.loading || !props.hasMore) {
174 + return
175 + }
176 +
177 + console.log('[LoadMoreList] 触底加载更多,当前页:', props.page, '下一页:', props.page + 1)
178 + const nextPage = props.page + 1
179 + emit('load-more', nextPage)
180 +}
181 +
182 +// ... 其他代码保持不变
183 +</script>
184 +
185 +<style lang="less">
186 +.load-more-content {
187 + // 设置固定高度,使 scroll-view 正常工作
188 + height: calc(100vh - 200rpx);
189 + padding: 32rpx;
190 +
191 + &.no-padding {
192 + padding: 0;
193 + }
194 +}
195 +
196 +// ... 其他样式保持不变
197 +</style>
198 +```
199 +
200 +#### 3. 搜索页面改进
201 +
202 +```javascript
203 +// scroll-view 的滚动位置
204 +const scrollViewScrollTop = ref(0)
205 +
206 +/**
207 + * scroll-view 滚动事件
208 + * @param {Object} e - 滚动事件对象
209 + */
210 +const handleScroll = (e) => {
211 + if (!activeTab.value) return
212 +
213 + // 保存当前 tab 的滚动位置
214 + tabState.value[activeTab.value].scrollTop = e.detail.scrollTop
215 +}
216 +
217 +/**
218 + * Tab 点击处理(改进版)
219 + * @param {string} tabId - Tab ID
220 + */
221 +const onTabClick = async (tabId) => {
222 + if (activeTab.value === tabId) return
223 +
224 + // 保存当前 tab 的滚动位置(在切换前)
225 + if (activeTab.value) {
226 + tabState.value[activeTab.value].scrollTop = scrollViewScrollTop.value
227 + }
228 +
229 + // 切换 tab
230 + activeTab.value = tabId
231 +
232 + // 恢复新 tab 的滚动位置
233 + scrollViewScrollTop.value = tabState.value[tabId].scrollTop
234 +
235 + // 如果没有搜索过,不执行
236 + if (!hasSearched.value || !searchKeyword.value.trim()) {
237 + return
238 + }
239 +
240 + // 检查当前 tab 是否已有数据,如果有则不重新请求
241 + const currentData = activeTab.value === 'product' ? products.value : files.value
242 + if (currentData.length > 0) {
243 + console.log('[Search] Tab 已有数据,不重新请求')
244 + return
245 + }
246 +
247 + // 如果没有数据,则请求
248 + console.log('[Search] Tab 无数据,发起请求:', tabId, '页码:', tabState.value[tabId].page)
249 + await performSearch(
250 + searchKeyword.value.trim(),
251 + tabId,
252 + tabState.value[tabId].page,
253 + pageSize,
254 + false
255 + )
256 +}
257 +
258 +/**
259 + * 处理加载更多事件
260 + * @param {number} page - 下一页页码
261 + */
262 +const handleLoadMore = async (page) => {
263 + console.log('[Search] 加载更多,tab:', activeTab.value, '页码:', page)
264 +
265 + if (!activeTab.value) return
266 +
267 + // 更新当前 tab 的 page
268 + tabState.value[activeTab.value].page = page
269 +
270 + // 如果没有搜索过或没有选中 tab,不执行
271 + if (!hasSearched.value || !activeTab.value || !searchKeyword.value.trim()) {
272 + return
273 + }
274 +
275 + // 加载下一页数据
276 + await performSearch(
277 + searchKeyword.value.trim(),
278 + activeTab.value,
279 + page,
280 + pageSize,
281 + true // 标记为加载更多
282 + )
283 +}
284 +```
285 +
286 +## 改进效果
287 +
288 +### ✅ 改进后
289 +
290 +1. **分页状态独立**:每个 tab 都有自己的 page 和 hasMore
291 +2. **滚动位置保持**:切换 tab 后再切回,滚动位置会恢复到原来的位置
292 +3. **不重复请求**:如果 tab 已经有数据,切换时不会重新请求
293 +4. **无缝体验**:用户可以在不同 tab 之间自由切换,状态完全保持
294 +
295 +## 使用场景
296 +
297 +这个改进方案适用于所有带 Tab 的加载列表场景:
298 +
299 +- 搜索页面(产品/资料)
300 +- 订单列表(待付款/待发货/已完成)
301 +- 消息列表(系统消息/订单消息)
302 +- 任何带 Tab 的分页列表
303 +
304 +## 实施优先级
305 +
306 +⚠️ **当前不实施**:客户还未提出明确需求,暂时作为技术储备保存。
307 +
308 +📝 **何时需要实施**
309 +- 用户反馈切换 tab 时状态丢失
310 +- 产品需求明确要求保持 tab 状态
311 +- 需要提升用户体验时
312 +
313 +## 参考文件
314 +
315 +- 搜索页面:`src/pages/search/index.vue`
316 +- LoadMoreList 组件:`src/components/LoadMoreList/index.vue`
...@@ -110,7 +110,7 @@ function generateWeekHotItem(id) { ...@@ -110,7 +110,7 @@ function generateWeekHotItem(id) {
110 return { 110 return {
111 meta_id: id, 111 meta_id: id,
112 name: `${materialName} ${fileType.name.toUpperCase()}`, 112 name: `${materialName} ${fileType.name.toUpperCase()}`,
113 - src: `https://placehold.co/100x100/e2e8f0/475569?text=${fileType.extension.toUpperCase()}`, 113 + src: `https://picsum.photos/seed/material-${id}-${fileType.extension}/100/100`,
114 size: generateRandomSize(), 114 size: generateRandomSize(),
115 read_people_count: generateRandomReadCount(), 115 read_people_count: generateRandomReadCount(),
116 read_people_percent: generateRandomReadPercent(), 116 read_people_percent: generateRandomReadPercent(),
...@@ -188,10 +188,10 @@ function generateMaterialItem(id) { ...@@ -188,10 +188,10 @@ function generateMaterialItem(id) {
188 size: generateRandomSize(), 188 size: generateRandomSize(),
189 extension: fileType.extension, 189 extension: fileType.extension,
190 collected: generateRandomFavorite() === '1', 190 collected: generateRandomFavorite() === '1',
191 - src: `https://placehold.co/100x100/e2e8f0/475569?text=${fileType.extension.toUpperCase()}`, 191 + src: `https://picsum.photos/seed/file-${id}-${fileType.extension}/100/100`,
192 - downloadUrl: `https://placehold.co/100x100/e2e8f0/475569?text=${fileType.extension.toUpperCase()}`, 192 + downloadUrl: `https://picsum.photos/seed/file-${id}-${fileType.extension}/100/100`,
193 post_date: new Date().toISOString(), 193 post_date: new Date().toISOString(),
194 - value: `https://placehold.co/100x100/e2e8f0/475569?text=${fileType.extension.toUpperCase()}` 194 + value: `https://picsum.photos/seed/file-${id}-${fileType.extension}/100/100`
195 } 195 }
196 } 196 }
197 197
...@@ -302,7 +302,7 @@ function generateProductItem(id) { ...@@ -302,7 +302,7 @@ function generateProductItem(id) {
302 id: id, 302 id: id,
303 product_name: productName, 303 product_name: productName,
304 name: productName, 304 name: productName,
305 - cover_image: `https://placehold.co/400x300/4caf50/ffffff?text=${encodeURIComponent(productName.substring(0, 6))}`, 305 + cover_image: `https://picsum.photos/seed/product-${id}/400/300`,
306 recommend: recommend, 306 recommend: recommend,
307 tags: tags, 307 tags: tags,
308 description: '这是一款优质的保险产品,为您的家庭提供全面保障...', 308 description: '这是一款优质的保险产品,为您的家庭提供全面保障...',
...@@ -634,7 +634,7 @@ function generateFavoriteItem(id) { ...@@ -634,7 +634,7 @@ function generateFavoriteItem(id) {
634 meta_id: id, 634 meta_id: id,
635 name: `${materialName}.${fileType.extension}`, 635 name: `${materialName}.${fileType.extension}`,
636 size: generateRandomSize(), 636 size: generateRandomSize(),
637 - src: `https://placehold.co/100x100/e2e8f0/475569?text=${fileType.extension.toUpperCase()}`, 637 + src: `https://picsum.photos/seed/favorite-${id}-${fileType.extension}/100/100`,
638 created_time: formatDate(createDate) 638 created_time: formatDate(createDate)
639 } 639 }
640 } 640 }
...@@ -723,7 +723,7 @@ function generateFeedbackItem(id) { ...@@ -723,7 +723,7 @@ function generateFeedbackItem(id) {
723 if (hasImages) { 723 if (hasImages) {
724 const imageCount = Math.floor(Math.random() * 3) + 1 724 const imageCount = Math.floor(Math.random() * 3) + 1
725 for (let i = 0; i < imageCount; i++) { 725 for (let i = 0; i < imageCount; i++) {
726 - images.push(`https://placehold.co/200x200/f3f4f6/9ca3af?text=截图${i + 1}`) 726 + images.push(`https://picsum.photos/seed/feedback-${id}-${i}/200/200`)
727 } 727 }
728 } 728 }
729 729
......