feat(product): 添加 mock 数据支持并修复滚动加载功能
新增功能: - 添加 mock 数据系统(src/api/mock/product.js) - 生成 100 个测试产品数据 - 支持 5 个产品分类 - 支持随机图片(Lorem Picsum 服务) - 支持搜索和分类过滤 - 模拟网络延迟(300-800ms) - 在 get_product.js 中添加 mock 数据开关(USE_MOCK_DATA) - 方便开发测试和后端联调 修复问题: - 修复产品中心页面滚动加载不触发的问题 - 使用 page-level scroll 替代 container scroll - 使用 sticky 定位固定顶部导航和搜索栏 - useReachBottom 现在可以正确监听页面触底事件 技术细节: - 移除 h-screen(小程序不支持 100vh) - 使用 sticky top-0 固定顶部区域 - 列表区域使用页面级滚动 - 添加调试日志方便排查问题 相关文件: - src/api/mock/product.js(新增) - src/api/get_product.js(修改) - src/pages/product-center/index.vue(修改) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Showing
3 changed files
with
223 additions
and
17 deletions
| 1 | import { fn, fetch } from '@/api/fn'; | 1 | import { fn, fetch } from '@/api/fn'; |
| 2 | +import { mockListAPI, mockDetailAPI } from './mock/product'; | ||
| 3 | + | ||
| 4 | +// ⚠️ Mock 数据开关 - 设置为 true 使用 mock 数据,false 使用真实 API | ||
| 5 | +const USE_MOCK_DATA = false; | ||
| 2 | 6 | ||
| 3 | const Api = { | 7 | const Api = { |
| 4 | Detail: '/srv/?a=get_product&t=detail', | 8 | Detail: '/srv/?a=get_product&t=detail', |
| ... | @@ -7,7 +11,7 @@ const Api = { | ... | @@ -7,7 +11,7 @@ const Api = { |
| 7 | 11 | ||
| 8 | /** | 12 | /** |
| 9 | * @description 产品详情 | 13 | * @description 产品详情 |
| 10 | - * @remark | 14 | + * @remark |
| 11 | * @param {Object} params 请求参数 | 15 | * @param {Object} params 请求参数 |
| 12 | * @param {string} params.client_id (可选) 主体id | 16 | * @param {string} params.client_id (可选) 主体id |
| 13 | * @param {string} params.i 产品id | 17 | * @param {string} params.i 产品id |
| ... | @@ -18,11 +22,11 @@ const Api = { | ... | @@ -18,11 +22,11 @@ const Api = { |
| 18 | id: integer; // 产品id | 22 | id: integer; // 产品id |
| 19 | product_name: string; // 产品名 | 23 | product_name: string; // 产品名 |
| 20 | recommend: string; // 推荐位: normal-普通, hot-热卖 | 24 | recommend: string; // 推荐位: normal-普通, hot-热卖 |
| 21 | - status: string; // | 25 | + status: string; // |
| 22 | - created_by: integer; // | 26 | + created_by: integer; // |
| 23 | - created_time: string; // | 27 | + created_time: string; // |
| 24 | - updated_by: integer; // | 28 | + updated_by: integer; // |
| 25 | - updated_time: string; // | 29 | + updated_time: string; // |
| 26 | form_sn: string; // 关联表单sn | 30 | form_sn: string; // 关联表单sn |
| 27 | product_description: string; // 产品描述 | 31 | product_description: string; // 产品描述 |
| 28 | categories: Array<{ | 32 | categories: Array<{ |
| ... | @@ -45,15 +49,21 @@ const Api = { | ... | @@ -45,15 +49,21 @@ const Api = { |
| 45 | * }; | 49 | * }; |
| 46 | * }>} | 50 | * }>} |
| 47 | */ | 51 | */ |
| 48 | -export const detailAPI = (params) => fn(fetch.get(Api.Detail, params)); | 52 | +export const detailAPI = (params) => { |
| 53 | + // 如果开启 Mock 数据,返回 mock 数据 | ||
| 54 | + if (USE_MOCK_DATA) { | ||
| 55 | + return mockDetailAPI(params); | ||
| 56 | + } | ||
| 57 | + return fn(fetch.get(Api.Detail, params)); | ||
| 58 | +}; | ||
| 49 | 59 | ||
| 50 | /** | 60 | /** |
| 51 | * @description 产品列表 | 61 | * @description 产品列表 |
| 52 | - * @remark | 62 | + * @remark |
| 53 | * @param {Object} params 请求参数 | 63 | * @param {Object} params 请求参数 |
| 54 | * @param {string} params.client_id (可选) 主体id | 64 | * @param {string} params.client_id (可选) 主体id |
| 55 | - * @param {string} params.limit (可选) | 65 | + * @param {string} params.limit (可选) |
| 56 | - * @param {string} params.page (可选) | 66 | + * @param {string} params.page (可选) |
| 57 | * @param {string} params.cid (可选) 分类id | 67 | * @param {string} params.cid (可选) 分类id |
| 58 | * @param {string} params.recommend (可选) 推荐位: normal-普通, hot-热卖 | 68 | * @param {string} params.recommend (可选) 推荐位: normal-普通, hot-热卖 |
| 59 | * @param {string} params.keyword (可选) 搜索 | 69 | * @param {string} params.keyword (可选) 搜索 |
| ... | @@ -69,7 +79,7 @@ export const detailAPI = (params) => fn(fetch.get(Api.Detail, params)); | ... | @@ -69,7 +79,7 @@ export const detailAPI = (params) => fn(fetch.get(Api.Detail, params)); |
| 69 | id: integer; // 产品id | 79 | id: integer; // 产品id |
| 70 | product_name: string; // 产品名 | 80 | product_name: string; // 产品名 |
| 71 | recommend: string; // 推荐位: normal-普通, hot-热卖 | 81 | recommend: string; // 推荐位: normal-普通, hot-热卖 |
| 72 | - form_sn: string; // | 82 | + form_sn: string; // |
| 73 | created_time: string; // 创建时间 | 83 | created_time: string; // 创建时间 |
| 74 | categories: Array<{ | 84 | categories: Array<{ |
| 75 | id: string; // 分类id | 85 | id: string; // 分类id |
| ... | @@ -87,4 +97,10 @@ export const detailAPI = (params) => fn(fetch.get(Api.Detail, params)); | ... | @@ -87,4 +97,10 @@ export const detailAPI = (params) => fn(fetch.get(Api.Detail, params)); |
| 87 | * }; | 97 | * }; |
| 88 | * }>} | 98 | * }>} |
| 89 | */ | 99 | */ |
| 90 | -export const listAPI = (params) => fn(fetch.get(Api.List, params)); | 100 | +export const listAPI = (params) => { |
| 101 | + // 如果开启 Mock 数据,返回 mock 数据 | ||
| 102 | + if (USE_MOCK_DATA) { | ||
| 103 | + return mockListAPI(params); | ||
| 104 | + } | ||
| 105 | + return fn(fetch.get(Api.List, params)); | ||
| 106 | +}; | ... | ... |
src/api/mock/product.js
0 → 100644
| 1 | +/** | ||
| 2 | + * @description Mock 数据 - 产品中心 | ||
| 3 | + * @note 用于测试滚动加载更多功能 | ||
| 4 | + */ | ||
| 5 | + | ||
| 6 | +// Mock 分类数据 | ||
| 7 | +export const mockCategories = [ | ||
| 8 | + { id: 1, name: '寿险' }, | ||
| 9 | + { id: 2, name: '健康险' }, | ||
| 10 | + { id: 3, name: '意外险' }, | ||
| 11 | + { id: 4, name: '年金险' }, | ||
| 12 | + { id: 5, name: '重疾险' } | ||
| 13 | +] | ||
| 14 | + | ||
| 15 | +// Mock 标签数据 | ||
| 16 | +const mockTags = [ | ||
| 17 | + { id: 1, name: '热销', bg_color: '#FEF3C7', text_color: '#92400E' }, | ||
| 18 | + { id: 2, name: '新品', bg_color: '#DBEAFE', text_color: '#1E40AF' }, | ||
| 19 | + { id: 3, name: '推荐', bg_color: '#D1FAE5', text_color: '#065F46' }, | ||
| 20 | + { id: 4, name: '限时', bg_color: '#FEE2E2', text_color: '#991B1B' } | ||
| 21 | +] | ||
| 22 | + | ||
| 23 | +// 生成单个产品数据 | ||
| 24 | +const generateProduct = (id) => { | ||
| 25 | + const recommendTypes = ['normal', 'hot'] | ||
| 26 | + const recommend = recommendTypes[Math.floor(Math.random() * recommendTypes.length)] | ||
| 27 | + | ||
| 28 | + // 随机选择 1-2 个标签 | ||
| 29 | + const tagCount = Math.floor(Math.random() * 2) + 1 | ||
| 30 | + const tags = [] | ||
| 31 | + for (let i = 0; i < tagCount; i++) { | ||
| 32 | + const tag = mockTags[Math.floor(Math.random() * mockTags.length)] | ||
| 33 | + if (!tags.find(t => t.id === tag.id)) { | ||
| 34 | + tags.push(tag) | ||
| 35 | + } | ||
| 36 | + } | ||
| 37 | + | ||
| 38 | + // 随机选择 1-2 个分类 | ||
| 39 | + const categoryCount = Math.floor(Math.random() * 2) + 1 | ||
| 40 | + const categories = [] | ||
| 41 | + for (let i = 0; i < categoryCount; i++) { | ||
| 42 | + const category = mockCategories[Math.floor(Math.random() * mockCategories.length)] | ||
| 43 | + if (!categories.find(c => c.id === category.id)) { | ||
| 44 | + categories.push(category) | ||
| 45 | + } | ||
| 46 | + } | ||
| 47 | + | ||
| 48 | + return { | ||
| 49 | + id: id, | ||
| 50 | + product_name: `测试产品 ${id} - ${categories.map(c => c.name).join('+')}`, | ||
| 51 | + recommend: recommend, | ||
| 52 | + form_sn: `product_form_${id}`, | ||
| 53 | + created_time: '2025-12-01 12:00:00', | ||
| 54 | + categories: categories, | ||
| 55 | + tags: tags, | ||
| 56 | + // 使用 Lorem Picsum 随机图片服务(基于产品 ID 确保图片固定) | ||
| 57 | + cover_image: `https://picsum.photos/300/200?random=${id}` | ||
| 58 | + } | ||
| 59 | +} | ||
| 60 | + | ||
| 61 | +// 生成产品列表 | ||
| 62 | +const generateProductList = (page, limit, cid = null) => { | ||
| 63 | + const start = page * limit | ||
| 64 | + const end = start + limit | ||
| 65 | + | ||
| 66 | + // 如果指定了分类,只返回该分类的产品 | ||
| 67 | + let filteredProducts = [] | ||
| 68 | + if (cid) { | ||
| 69 | + // 为每个分类生成固定数量的产品 | ||
| 70 | + const categoryProducts = [] | ||
| 71 | + for (let i = 1; i <= 50; i++) { | ||
| 72 | + const product = generateProduct(i) | ||
| 73 | + // 强制该产品属于指定分类 | ||
| 74 | + product.categories = mockCategories.find(c => String(c.id) === String(cid)) | ||
| 75 | + ? [mockCategories.find(c => String(c.id) === String(cid))] | ||
| 76 | + : [{ id: parseInt(cid), name: '测试分类' }] | ||
| 77 | + categoryProducts.push(product) | ||
| 78 | + } | ||
| 79 | + filteredProducts = categoryProducts | ||
| 80 | + } else { | ||
| 81 | + // 全部产品 | ||
| 82 | + for (let i = 1; i <= 100; i++) { | ||
| 83 | + filteredProducts.push(generateProduct(i)) | ||
| 84 | + } | ||
| 85 | + } | ||
| 86 | + | ||
| 87 | + const total = filteredProducts.length | ||
| 88 | + const list = filteredProducts.slice(start, end) | ||
| 89 | + | ||
| 90 | + return { | ||
| 91 | + list, | ||
| 92 | + total, | ||
| 93 | + hasMore: end < total | ||
| 94 | + } | ||
| 95 | +} | ||
| 96 | + | ||
| 97 | +/** | ||
| 98 | + * Mock 产品列表 API | ||
| 99 | + * @param {Object} params 请求参数 | ||
| 100 | + * @param {string} params.page 页码(从 0 开始) | ||
| 101 | + * @param {string} params.limit 每页数量 | ||
| 102 | + * @param {string} params.cid 分类 ID(可选) | ||
| 103 | + * @param {string} params.keyword 搜索关键词(可选) | ||
| 104 | + * @returns {Promise} 模拟 API 响应 | ||
| 105 | + */ | ||
| 106 | +export const mockListAPI = (params) => { | ||
| 107 | + return new Promise((resolve) => { | ||
| 108 | + // 模拟网络延迟(300-800ms) | ||
| 109 | + const delay = Math.floor(Math.random() * 500) + 300 | ||
| 110 | + | ||
| 111 | + setTimeout(() => { | ||
| 112 | + const page = parseInt(params.page) || 0 | ||
| 113 | + const limit = parseInt(params.limit) || 10 | ||
| 114 | + const cid = params.cid || null | ||
| 115 | + const keyword = params.keyword || '' | ||
| 116 | + | ||
| 117 | + let result = generateProductList(page, limit, cid) | ||
| 118 | + | ||
| 119 | + // 如果有搜索关键词,过滤产品 | ||
| 120 | + if (keyword) { | ||
| 121 | + result.list = result.list.filter(p => | ||
| 122 | + p.product_name.includes(keyword) | ||
| 123 | + ) | ||
| 124 | + // 搜索时重新计算总数 | ||
| 125 | + result.total = result.list.length + Math.floor(Math.random() * 20) | ||
| 126 | + } | ||
| 127 | + | ||
| 128 | + resolve({ | ||
| 129 | + code: 1, | ||
| 130 | + msg: 'success', | ||
| 131 | + data: { | ||
| 132 | + categories: mockCategories, | ||
| 133 | + list: result.list, | ||
| 134 | + total: result.total | ||
| 135 | + } | ||
| 136 | + }) | ||
| 137 | + }, delay) | ||
| 138 | + }) | ||
| 139 | +} | ||
| 140 | + | ||
| 141 | +/** | ||
| 142 | + * Mock 产品详情 API | ||
| 143 | + * @param {Object} params 请求参数 | ||
| 144 | + * @param {string} params.i 产品 ID | ||
| 145 | + * @returns {Promise} 模拟 API 响应 | ||
| 146 | + */ | ||
| 147 | +export const mockDetailAPI = (params) => { | ||
| 148 | + return new Promise((resolve) => { | ||
| 149 | + const delay = Math.floor(Math.random() * 500) + 300 | ||
| 150 | + | ||
| 151 | + setTimeout(() => { | ||
| 152 | + const id = parseInt(params.i) || 1 | ||
| 153 | + const product = generateProduct(id) | ||
| 154 | + | ||
| 155 | + // 添加额外的详情字段 | ||
| 156 | + product.product_description = `这是产品 ${id} 的详细描述。\n\n产品特点:\n1. 保障全面\n2. 灵活配置\n3. 理赔便捷` | ||
| 157 | + product.documents = [ | ||
| 158 | + { | ||
| 159 | + file_url: 'https://example.com/file1.pdf', | ||
| 160 | + file_name: '产品条款.pdf', | ||
| 161 | + file_size: '1024000', | ||
| 162 | + file_size_formatted: '1.0 MB' | ||
| 163 | + }, | ||
| 164 | + { | ||
| 165 | + file_url: 'https://example.com/file2.pdf', | ||
| 166 | + file_name: '产品说明.pdf', | ||
| 167 | + file_size: '512000', | ||
| 168 | + file_size_formatted: '512 KB' | ||
| 169 | + } | ||
| 170 | + ] | ||
| 171 | + product.status = 'active' | ||
| 172 | + product.created_by = 1 | ||
| 173 | + product.updated_by = 1 | ||
| 174 | + product.updated_time = '2025-12-01 12:00:00' | ||
| 175 | + | ||
| 176 | + resolve({ | ||
| 177 | + code: 1, | ||
| 178 | + msg: 'success', | ||
| 179 | + data: product | ||
| 180 | + }) | ||
| 181 | + }, delay) | ||
| 182 | + }) | ||
| 183 | +} |
| ... | @@ -3,8 +3,9 @@ | ... | @@ -3,8 +3,9 @@ |
| 3 | * @Description: 产品中心 - API 接口集成版本(含搜索功能) | 3 | * @Description: 产品中心 - API 接口集成版本(含搜索功能) |
| 4 | --> | 4 | --> |
| 5 | <template> | 5 | <template> |
| 6 | - <view class="h-screen bg-[#F9FAFB] flex flex-col"> | 6 | + <view class="bg-[#F9FAFB]"> |
| 7 | - <view class="bg-[#F9FAFB] z-10"> | 7 | + <!-- 固定在顶部的导航和搜索 --> |
| 8 | + <view class="bg-[#F9FAFB] sticky top-0 z-10"> | ||
| 8 | <NavHeader title="产品中心" /> | 9 | <NavHeader title="产品中心" /> |
| 9 | 10 | ||
| 10 | <!-- Search Bar --> | 11 | <!-- Search Bar --> |
| ... | @@ -43,8 +44,8 @@ | ... | @@ -43,8 +44,8 @@ |
| 43 | </view> | 44 | </view> |
| 44 | </view> | 45 | </view> |
| 45 | 46 | ||
| 46 | - <!-- 列表容器 - 使用原生滚动 --> | 47 | + <!-- 列表容器 - 页面级滚动 --> |
| 47 | - <view class="flex-1 min-h-0 overflow-y-auto pb-[calc(160rpx+env(safe-area-inset-bottom))]"> | 48 | + <view class="pb-[calc(160rpx+env(safe-area-inset-bottom))]"> |
| 48 | <!-- 加载状态 --> | 49 | <!-- 加载状态 --> |
| 49 | <view v-if="loading && products.length === 0" class="flex justify-center items-center py-[100rpx]"> | 50 | <view v-if="loading && products.length === 0" class="flex justify-center items-center py-[100rpx]"> |
| 50 | <text class="text-gray-400 text-[28rpx]">加载中...</text> | 51 | <text class="text-gray-400 text-[28rpx]">加载中...</text> |
| ... | @@ -398,9 +399,15 @@ useLoad(() => { | ... | @@ -398,9 +399,15 @@ useLoad(() => { |
| 398 | 399 | ||
| 399 | /** | 400 | /** |
| 400 | * 触底加载更多 | 401 | * 触底加载更多 |
| 402 | + * @description 使用 Taro 的 useReachBottom hook 监听页面滚动到底部 | ||
| 401 | */ | 403 | */ |
| 402 | useReachBottom(() => { | 404 | useReachBottom(() => { |
| 403 | - if (!hasMore.value || loading.value) return | 405 | + console.log('滚动到底部,加载更多') |
| 406 | + | ||
| 407 | + if (!hasMore.value || loading.value) { | ||
| 408 | + console.log('没有更多数据或正在加载中,跳过') | ||
| 409 | + return | ||
| 410 | + } | ||
| 404 | 411 | ||
| 405 | page.value += 1 | 412 | page.value += 1 |
| 406 | fetchProducts(true) | 413 | fetchProducts(true) | ... | ... |
-
Please register or login to post a comment