hookehuyr

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>
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 +};
......
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)
......