hookehuyr

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

# 带 Tab 的加载列表 - 状态保持改进方案
## 改进背景
当前实现在带 Tab 的加载列表(如搜索页面)中存在以下问题:
1. **分页状态无法分别跟踪**:使用单一的 `currentPage` ref,切换 tab 时会重置页码
2. **滚动位置无法保持**:使用页面级滚动,切换 tab 后滚动位置会丢失
用户期望的行为:
- 在一个列表中滚动到某个位置(比如第 3 页)
- 点击另一个 tab
- 再切换回原来的 tab
- 期望列表保持原来的滚动位置和分页状态,继续往下滚动时能从原来的位置加载
## 当前实现的问题
### ❌ 问题 1:分页状态无法分别跟踪
```javascript
// src/pages/search/index.vue:168
const currentPage = ref(0) // 单一页码,无法分别跟踪两个tab
// 切换 tab 时重置
const onTabClick = async (tabId) => {
// ...
currentPage.value = 0 // ❌ 重置为0,丢失了原来的页码
}
```
**影响**
- 产品列表滚动到第 3 页
- 切换到资料列表
- 再切回产品列表
- 页码变成 0,需要重新从第 1 页开始
### ❌ 问题 2:滚动位置无法保持
LoadMoreList 组件使用的是**页面级滚动**,而非 `scroll-view` 组件:
```vue
<!-- src/components/LoadMoreList/index.vue:39 -->
<view class="load-more-content">
<!-- 列表内容 -->
</view>
```
**影响**
- 列表滚动到某个位置
- 切换 tab(列表重新渲染)
- 页面滚动位置会重置到顶部
### ✅ 问题 3:数据可以保持
列表数据本身会被保留:
```javascript
// src/pages/search/index.vue:159-162
const products = ref([]) // 产品列表数据
const files = ref([]) // 资料列表数据
```
但这还不够,因为分页状态和滚动位置都丢失了。
## 改进方案
### 核心思路
1. **为每个 tab 维护独立的分页状态**(page、hasMore)
2. **使用 scroll-view 替代页面级滚动**,并保存每个 tab 的滚动位置
3. **切换 tab 时保存和恢复滚动位置**
4. **切换 tab 时不重新请求已有数据**
### 实现代码
#### 1. 状态管理改进
```javascript
// ❌ 原来的实现(单一状态)
// const currentPage = ref(0)
// const hasMore = ref(true)
// ✅ 改进方案:为每个 tab 维护独立的状态
const tabState = ref({
product: {
page: 0,
hasMore: true,
scrollTop: 0 // 保存滚动位置
},
file: {
page: 0,
hasMore: true,
scrollTop: 0
}
})
// 当前的 page 和 hasMore 从 tabState 中获取
const currentPage = computed(() => {
return activeTab.value ? tabState.value[activeTab.value].page : 0
})
const hasMore = computed(() => {
return activeTab.value ? tabState.value[activeTab.value].hasMore : true
})
```
#### 2. LoadMoreList 组件改进
```vue
<template>
<view class="load-more-list" :class="{ 'has-header': showHeader }">
<!-- 可选固定头部 -->
<view v-if="showHeader" class="load-more-header sticky top-0 z-10">
<slot name="header"></slot>
</view>
<!-- 使用 scroll-view 替代普通 view -->
<scroll-view
class="load-more-content"
:class="{ 'no-padding': noPadding }"
:scroll-top="scrollTop"
scroll-y
@scroll="handleScroll"
@scrolltolower="handleScrollToLower"
>
<!-- 列表内容(保持不变) -->
<view v-if="loading && list.length === 0">
<!-- loading -->
</view>
<view v-else class="list-container">
<view
v-for="(item, index) in displayList"
:key="item[keyField] || index"
class="list-item"
>
<slot name="item" :item="item" :index="index"></slot>
</view>
<!-- 空状态和加载更多提示 -->
</view>
</scroll-view>
</view>
</template>
<script setup>
import { computed } from 'vue'
import { usePullDownRefresh, stopPullDownRefresh } from '@tarojs/taro'
const props = defineProps({
// ... 原有 props
scrollTop: {
type: Number,
default: 0
}
})
const emit = defineEmits(['load-more', 'refresh', 'scroll'])
/**
* scroll-view 滚动事件
* @param {Object} e - 滚动事件对象
*/
const handleScroll = (e) => {
emit('scroll', e)
}
/**
* scroll-view 触底事件
*/
const handleScrollToLower = () => {
// 如果正在加载或没有更多数据,不执行
if (props.loadingMore || props.loading || !props.hasMore) {
return
}
console.log('[LoadMoreList] 触底加载更多,当前页:', props.page, '下一页:', props.page + 1)
const nextPage = props.page + 1
emit('load-more', nextPage)
}
// ... 其他代码保持不变
</script>
<style lang="less">
.load-more-content {
// 设置固定高度,使 scroll-view 正常工作
height: calc(100vh - 200rpx);
padding: 32rpx;
&.no-padding {
padding: 0;
}
}
// ... 其他样式保持不变
</style>
```
#### 3. 搜索页面改进
```javascript
// scroll-view 的滚动位置
const scrollViewScrollTop = ref(0)
/**
* scroll-view 滚动事件
* @param {Object} e - 滚动事件对象
*/
const handleScroll = (e) => {
if (!activeTab.value) return
// 保存当前 tab 的滚动位置
tabState.value[activeTab.value].scrollTop = e.detail.scrollTop
}
/**
* Tab 点击处理(改进版)
* @param {string} tabId - Tab ID
*/
const onTabClick = async (tabId) => {
if (activeTab.value === tabId) return
// 保存当前 tab 的滚动位置(在切换前)
if (activeTab.value) {
tabState.value[activeTab.value].scrollTop = scrollViewScrollTop.value
}
// 切换 tab
activeTab.value = tabId
// 恢复新 tab 的滚动位置
scrollViewScrollTop.value = tabState.value[tabId].scrollTop
// 如果没有搜索过,不执行
if (!hasSearched.value || !searchKeyword.value.trim()) {
return
}
// 检查当前 tab 是否已有数据,如果有则不重新请求
const currentData = activeTab.value === 'product' ? products.value : files.value
if (currentData.length > 0) {
console.log('[Search] Tab 已有数据,不重新请求')
return
}
// 如果没有数据,则请求
console.log('[Search] Tab 无数据,发起请求:', tabId, '页码:', tabState.value[tabId].page)
await performSearch(
searchKeyword.value.trim(),
tabId,
tabState.value[tabId].page,
pageSize,
false
)
}
/**
* 处理加载更多事件
* @param {number} page - 下一页页码
*/
const handleLoadMore = async (page) => {
console.log('[Search] 加载更多,tab:', activeTab.value, '页码:', page)
if (!activeTab.value) return
// 更新当前 tab 的 page
tabState.value[activeTab.value].page = page
// 如果没有搜索过或没有选中 tab,不执行
if (!hasSearched.value || !activeTab.value || !searchKeyword.value.trim()) {
return
}
// 加载下一页数据
await performSearch(
searchKeyword.value.trim(),
activeTab.value,
page,
pageSize,
true // 标记为加载更多
)
}
```
## 改进效果
### ✅ 改进后
1. **分页状态独立**:每个 tab 都有自己的 page 和 hasMore
2. **滚动位置保持**:切换 tab 后再切回,滚动位置会恢复到原来的位置
3. **不重复请求**:如果 tab 已经有数据,切换时不会重新请求
4. **无缝体验**:用户可以在不同 tab 之间自由切换,状态完全保持
## 使用场景
这个改进方案适用于所有带 Tab 的加载列表场景:
- 搜索页面(产品/资料)
- 订单列表(待付款/待发货/已完成)
- 消息列表(系统消息/订单消息)
- 任何带 Tab 的分页列表
## 实施优先级
⚠️ **当前不实施**:客户还未提出明确需求,暂时作为技术储备保存。
📝 **何时需要实施**
- 用户反馈切换 tab 时状态丢失
- 产品需求明确要求保持 tab 状态
- 需要提升用户体验时
## 参考文件
- 搜索页面:`src/pages/search/index.vue`
- LoadMoreList 组件:`src/components/LoadMoreList/index.vue`
......@@ -110,7 +110,7 @@ function generateWeekHotItem(id) {
return {
meta_id: id,
name: `${materialName} ${fileType.name.toUpperCase()}`,
src: `https://placehold.co/100x100/e2e8f0/475569?text=${fileType.extension.toUpperCase()}`,
src: `https://picsum.photos/seed/material-${id}-${fileType.extension}/100/100`,
size: generateRandomSize(),
read_people_count: generateRandomReadCount(),
read_people_percent: generateRandomReadPercent(),
......@@ -188,10 +188,10 @@ function generateMaterialItem(id) {
size: generateRandomSize(),
extension: fileType.extension,
collected: generateRandomFavorite() === '1',
src: `https://placehold.co/100x100/e2e8f0/475569?text=${fileType.extension.toUpperCase()}`,
downloadUrl: `https://placehold.co/100x100/e2e8f0/475569?text=${fileType.extension.toUpperCase()}`,
src: `https://picsum.photos/seed/file-${id}-${fileType.extension}/100/100`,
downloadUrl: `https://picsum.photos/seed/file-${id}-${fileType.extension}/100/100`,
post_date: new Date().toISOString(),
value: `https://placehold.co/100x100/e2e8f0/475569?text=${fileType.extension.toUpperCase()}`
value: `https://picsum.photos/seed/file-${id}-${fileType.extension}/100/100`
}
}
......@@ -302,7 +302,7 @@ function generateProductItem(id) {
id: id,
product_name: productName,
name: productName,
cover_image: `https://placehold.co/400x300/4caf50/ffffff?text=${encodeURIComponent(productName.substring(0, 6))}`,
cover_image: `https://picsum.photos/seed/product-${id}/400/300`,
recommend: recommend,
tags: tags,
description: '这是一款优质的保险产品,为您的家庭提供全面保障...',
......@@ -634,7 +634,7 @@ function generateFavoriteItem(id) {
meta_id: id,
name: `${materialName}.${fileType.extension}`,
size: generateRandomSize(),
src: `https://placehold.co/100x100/e2e8f0/475569?text=${fileType.extension.toUpperCase()}`,
src: `https://picsum.photos/seed/favorite-${id}-${fileType.extension}/100/100`,
created_time: formatDate(createDate)
}
}
......@@ -723,7 +723,7 @@ function generateFeedbackItem(id) {
if (hasImages) {
const imageCount = Math.floor(Math.random() * 3) + 1
for (let i = 0; i < imageCount; i++) {
images.push(`https://placehold.co/200x200/f3f4f6/9ca3af?text=截图${i + 1}`)
images.push(`https://picsum.photos/seed/feedback-${id}-${i}/200/200`)
}
}
......