feat: 集成知识库页面产品列表 API 接口
### 主要改动 1. **集成产品列表 API**(listAPI) - 从接口获取产品数据和分类列表 - 使用 `data.categories` 作为标签栏数据(动态生成) - 产品卡片数据严格按照接口文档映射 - 移除所有硬编码的模拟数据 2. **实现分类筛选功能** - 点击标签时传递 `cid` 参数到接口 - 每次切换分类时重置分页状态(page=0, products=[]) - 支持查看"全部"或特定分类的产品 3. **实现滚动加载功能** - 使用 `scroll-view` 的 `@scrolltolower` 事件 - 滚动到底部时自动加载下一页 - 加载更多时追加数据(不替换) - 根据总数判断是否还有更多数据 4. **数据映射(严格遵循接口文档)** - `item.id` → 产品 ID - `item.product_name` → 产品名称 - `item.cover_image` → 产品封面图 - `item.recommend` → 推价位(hot/normal) - `item.tags` → 动态标签(含 bg_color、text_color) - `categories` → 分类列表(用于标签栏) - `total` → 产品总数 ### 状态管理 - `loading`: 加载状态(显示加载提示) - `hasMore`: 是否还有更多数据 - `page`: 当前页码(从 0 开始) - `limit`: 每页数量(10) - `categories`: 分类列表(从接口获取) - `products`: 当前产品列表 - `total`: 产品总数 ### 移除的硬编码 - 移除硬编码的 `tabsData`(人寿保险、医疗保险、意外保险) - 移除模拟产品数据生成函数 - 移除 `descColorPalette` 和 `getDescColor` 函数 - 移除 `currentTabName` 和 `filteredProducts` 计算属性 ### 用户体验优化 - 首次加载显示"加载中..."提示 - 滚动到底部时显示"加载中..." - 没有更多数据时显示"没有更多了" - 无数据时显示"暂无相关产品" ### 影响文件 - src/pages/knowledge-base/index.vue(完整重构) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Showing
1 changed file
with
143 additions
and
117 deletions
| 1 | <!-- | 1 | <!-- |
| 2 | * @Date: 2026-01-31 | 2 | * @Date: 2026-01-31 |
| 3 | - * @Description: 产品知识库 - 已改造为 NutTabs 版本 | 3 | + * @Description: 产品知识库 - API 接口集成版本 |
| 4 | --> | 4 | --> |
| 5 | <template> | 5 | <template> |
| 6 | <view class="h-screen bg-[#F9FAFB] flex flex-col"> | 6 | <view class="h-screen bg-[#F9FAFB] flex flex-col"> |
| ... | @@ -30,31 +30,31 @@ | ... | @@ -30,31 +30,31 @@ |
| 30 | </view> | 30 | </view> |
| 31 | </view> | 31 | </view> |
| 32 | 32 | ||
| 33 | - <!-- 列表容器(独立于 nut-tab-pane) --> | 33 | + <!-- 列表容器 --> |
| 34 | - <view | 34 | + <scroll-view |
| 35 | - v-if="listVisible" | ||
| 36 | - :key="listRenderKey" | ||
| 37 | class="flex-1 min-h-0 overflow-y-auto px-[40rpx] pb-[calc(160rpx+env(safe-area-inset-bottom))]" | 35 | class="flex-1 min-h-0 overflow-y-auto px-[40rpx] pb-[calc(160rpx+env(safe-area-inset-bottom))]" |
| 36 | + scroll-y | ||
| 37 | + @scrolltolower="onScrollToLower" | ||
| 38 | > | 38 | > |
| 39 | - <!-- Section Title --> | 39 | + <!-- 加载状态 --> |
| 40 | - <view class="text-[#1F2937] text-[32rpx] font-bold mb-[24rpx]"> | 40 | + <view v-if="loading && products.length === 0" class="flex justify-center items-center py-[100rpx]"> |
| 41 | - {{ currentTabName }} | 41 | + <text class="text-gray-400 text-[28rpx]">加载中...</text> |
| 42 | </view> | 42 | </view> |
| 43 | 43 | ||
| 44 | <!-- Product Grid --> | 44 | <!-- Product Grid --> |
| 45 | - <view class="flex flex-wrap justify-between"> | 45 | + <view v-else class="flex flex-wrap justify-between"> |
| 46 | <!-- Card Item --> | 46 | <!-- Card Item --> |
| 47 | - <view v-for="(item, index) in filteredProducts" :key="index" | 47 | + <view v-for="(item, index) in products" :key="item.id" |
| 48 | class="w-[48%] bg-white rounded-[24rpx] overflow-hidden mb-[24rpx] shadow-sm flex flex-col active:scale-[0.98] transition-transform duration-200" | 48 | class="w-[48%] bg-white rounded-[24rpx] overflow-hidden mb-[24rpx] shadow-sm flex flex-col active:scale-[0.98] transition-transform duration-200" |
| 49 | :style="{ animationDelay: `${index * 50}ms` }" | 49 | :style="{ animationDelay: `${index * 50}ms` }" |
| 50 | @tap="handleProductClick(item)"> | 50 | @tap="handleProductClick(item)"> |
| 51 | <!-- Image Container --> | 51 | <!-- Image Container --> |
| 52 | <view class="relative w-full h-[200rpx] product-card-item"> | 52 | <view class="relative w-full h-[200rpx] product-card-item"> |
| 53 | - <image :src="item.image" class="w-full h-full object-cover bg-gray-100" referrerpolicy="no-referrer" /> | 53 | + <image :src="item.cover_image" class="w-full h-full object-cover bg-gray-100" mode="aspectFill" /> |
| 54 | <!-- Tag --> | 54 | <!-- Tag --> |
| 55 | - <view v-if="item.tag" | 55 | + <view v-if="item.recommend === 'hot'" |
| 56 | class="absolute top-[12rpx] right-[12rpx] bg-red-500 text-white text-[20rpx] px-[12rpx] py-[4rpx] rounded-full"> | 56 | class="absolute top-[12rpx] right-[12rpx] bg-red-500 text-white text-[20rpx] px-[12rpx] py-[4rpx] rounded-full"> |
| 57 | - {{ item.tag }} | 57 | + 热卖 |
| 58 | </view> | 58 | </view> |
| 59 | </view> | 59 | </view> |
| 60 | 60 | ||
| ... | @@ -62,162 +62,188 @@ | ... | @@ -62,162 +62,188 @@ |
| 62 | <view class="p-[20rpx] flex flex-col flex-1"> | 62 | <view class="p-[20rpx] flex flex-col flex-1"> |
| 63 | <!-- Title --> | 63 | <!-- Title --> |
| 64 | <view class="text-[#1F2937] text-[28rpx] font-medium leading-[1.4] line-clamp-2 mb-[16rpx]"> | 64 | <view class="text-[#1F2937] text-[28rpx] font-medium leading-[1.4] line-clamp-2 mb-[16rpx]"> |
| 65 | - {{ item.title }} | 65 | + {{ item.product_name }} |
| 66 | </view> | 66 | </view> |
| 67 | 67 | ||
| 68 | - <!-- Desc --> | 68 | + <!-- 动态标签 --> |
| 69 | - <view class="mt-auto self-start text-[22rpx] px-[12rpx] py-[4rpx] rounded-full" | 69 | + <view v-if="item.tags && item.tags.length > 0" class="flex flex-wrap gap-[8rpx] mt-auto"> |
| 70 | - :class="[getDescColor(item.id).bg, getDescColor(item.id).text]"> | 70 | + <view |
| 71 | - {{ item.desc }} | 71 | + v-for="tag in item.tags.slice(0, 2)" |
| 72 | + :key="tag.id" | ||
| 73 | + class="text-[20rpx] px-[12rpx] py-[4rpx] rounded-full" | ||
| 74 | + :style="{ | ||
| 75 | + backgroundColor: tag.bg_color, | ||
| 76 | + color: tag.text_color | ||
| 77 | + }" | ||
| 78 | + > | ||
| 79 | + {{ tag.name }} | ||
| 80 | + </view> | ||
| 72 | </view> | 81 | </view> |
| 73 | </view> | 82 | </view> |
| 74 | </view> | 83 | </view> |
| 75 | </view> | 84 | </view> |
| 76 | 85 | ||
| 86 | + <!-- 加载更多状态 --> | ||
| 87 | + <view v-if="loading && products.length > 0" class="flex justify-center items-center py-[40rpx]"> | ||
| 88 | + <text class="text-gray-400 text-[28rpx]">加载中...</text> | ||
| 89 | + </view> | ||
| 90 | + | ||
| 91 | + <!-- 没有更多数据 --> | ||
| 92 | + <view v-if="!hasMore && products.length > 0" class="flex justify-center items-center py-[40rpx]"> | ||
| 93 | + <text class="text-gray-400 text-[28rpx]">没有更多了</text> | ||
| 94 | + </view> | ||
| 95 | + | ||
| 77 | <!-- 空状态 --> | 96 | <!-- 空状态 --> |
| 78 | - <view v-if="filteredProducts.length === 0" class="flex flex-col items-center justify-center py-[100rpx]"> | 97 | + <view v-if="!loading && products.length === 0" class="flex flex-col items-center justify-center py-[100rpx]"> |
| 79 | <text class="text-gray-400 text-[28rpx]">暂无相关产品</text> | 98 | <text class="text-gray-400 text-[28rpx]">暂无相关产品</text> |
| 80 | </view> | 99 | </view> |
| 81 | - </view> | 100 | + </scroll-view> |
| 82 | </view> | 101 | </view> |
| 83 | </template> | 102 | </template> |
| 84 | 103 | ||
| 85 | <script setup> | 104 | <script setup> |
| 86 | -import { ref, computed, nextTick } from 'vue' | 105 | +import { ref, computed } from 'vue' |
| 106 | +import Taro, { useLoad, useReachBottom } from '@tarojs/taro' | ||
| 87 | import NavHeader from '@/components/NavHeader.vue' | 107 | import NavHeader from '@/components/NavHeader.vue' |
| 88 | import { useListItemClick, ListType } from '@/composables/useListItemClick' | 108 | import { useListItemClick, ListType } from '@/composables/useListItemClick' |
| 109 | +import { listAPI } from '@/api/get_product' | ||
| 89 | 110 | ||
| 90 | const activeTabId = ref('') | 111 | const activeTabId = ref('') |
| 91 | -const listVisible = ref(true) | ||
| 92 | -const listRenderKey = ref(0) | ||
| 93 | 112 | ||
| 94 | -/** | 113 | +// 分页状态 |
| 95 | - * Tab 数据源 | 114 | +const page = ref(0) |
| 96 | - * @description 包含分类信息和对应的产品列表 | 115 | +const limit = ref(10) |
| 97 | - */ | 116 | +const loading = ref(false) |
| 98 | -const tabsData = ref([ | 117 | +const hasMore = ref(true) |
| 99 | - { id: '', name: '全部产品', list: [] }, | ||
| 100 | - { id: 'life', name: '人寿保险', list: [] }, | ||
| 101 | - { id: 'medical', name: '医疗保险', list: [] }, | ||
| 102 | - { id: 'accident', name: '意外保险', list: [] }, | ||
| 103 | -]) | ||
| 104 | 118 | ||
| 105 | -/** | 119 | +// 数据状态 |
| 106 | - * Desc 颜色调色板 | 120 | +const categories = ref([]) // 从接口获取的分类列表 |
| 107 | - * | 121 | +const products = ref([]) // 当前产品列表 |
| 108 | - * @description 为不同描述提供柔和的背景色和对应的文字颜色 | 122 | +const total = ref(0) // 产品总数 |
| 109 | - */ | ||
| 110 | -const descColorPalette = [ | ||
| 111 | - { bg: 'bg-blue-50', text: 'text-blue-600' }, // 蓝色 | ||
| 112 | - { bg: 'bg-green-50', text: 'text-green-600' }, // 绿色 | ||
| 113 | - { bg: 'bg-purple-50', text: 'text-purple-600' }, // 紫色 | ||
| 114 | - { bg: 'bg-orange-50', text: 'text-orange-600' }, // 橙色 | ||
| 115 | - { bg: 'bg-pink-50', text: 'text-pink-600' }, // 粉色 | ||
| 116 | - { bg: 'bg-teal-50', text: 'text-teal-600' }, // 青色 | ||
| 117 | - { bg: 'bg-indigo-50', text: 'text-indigo-600' }, // 靛蓝色 | ||
| 118 | - { bg: 'bg-red-50', text: 'text-red-600' }, // 红色 | ||
| 119 | -] | ||
| 120 | 123 | ||
| 121 | /** | 124 | /** |
| 122 | - * 获取 desc 的颜色样式 | 125 | + * 标签栏数据(根据接口返回的 categories 生成) |
| 123 | - * | 126 | + * @description 包含"全部"选项和接口返回的分类 |
| 124 | - * @description 根据 ID 获取固定的背景色和文字颜色 | ||
| 125 | - * @param {number} id - 产品 ID | ||
| 126 | - * @returns {Object} 包含 bg 和 text 属性的颜色对象 | ||
| 127 | */ | 127 | */ |
| 128 | -const getDescColor = (id) => { | 128 | +const tabsData = computed(() => { |
| 129 | - const index = id % descColorPalette.length | 129 | + const allTab = { id: '', name: '全部' } |
| 130 | - return descColorPalette[index] | 130 | + const categoryTabs = categories.value.map(cat => ({ |
| 131 | -} | 131 | + id: String(cat.id), |
| 132 | + name: cat.name | ||
| 133 | + })) | ||
| 134 | + return [allTab, ...categoryTabs] | ||
| 135 | +}) | ||
| 132 | 136 | ||
| 133 | /** | 137 | /** |
| 134 | - * 生成产品数据 | 138 | + * 获取产品列表 |
| 135 | - * | 139 | + * @description 根据 activeTabId 获取对应分类的产品列表 |
| 136 | - * @description 生成模拟产品列表数据,每个产品包含唯一 id | ||
| 137 | - * @returns {Array} 产品列表 | ||
| 138 | */ | 140 | */ |
| 139 | -const generateProducts = () => { | 141 | +const fetchProducts = async (isLoadMore = false) => { |
| 140 | - return [ | 142 | + if (loading.value) return |
| 141 | - { id: 1, title: '终身寿险尊享版', tag: '热卖', desc: '5年超值', image: `https://picsum.photos/seed/1/200/200` }, | ||
| 142 | - { id: 2, title: '百万医疗保险计划', desc: '收益率3.5%', image: `https://picsum.photos/seed/2/200/200` }, | ||
| 143 | - { id: 3, title: '意外伤害保障计划', desc: '保证收益万能', image: `https://picsum.photos/seed/3/200/200` }, | ||
| 144 | - { id: 4, title: '分红型年金保险', tag: '热卖', desc: '保证收益万能', image: `https://picsum.photos/seed/4/200/200` }, | ||
| 145 | - { id: 5, title: '重大疾病保险', desc: '收益率4.2%', image: `https://picsum.photos/seed/5/200/200` }, | ||
| 146 | - { id: 6, title: '少儿教育金保险', tag: '热卖', desc: '教育专属', image: `https://picsum.photos/seed/6/200/200` }, | ||
| 147 | - { id: 7, title: '高端医疗服务', desc: '尊享服务', image: `https://picsum.photos/seed/7/200/200` }, | ||
| 148 | - { id: 8, title: '家庭财产保险', desc: '全家无忧', image: `https://picsum.photos/seed/8/200/200` }, | ||
| 149 | - { id: 9, title: '长期护理保险', desc: '贴心保障', image: `https://picsum.photos/seed/9/200/200` }, | ||
| 150 | - { id: 10, title: '投资连结保险', tag: '新品', desc: '稳健增值', image: `https://picsum.photos/seed/10/200/200` }, | ||
| 151 | - ] | ||
| 152 | -} | ||
| 153 | 143 | ||
| 154 | -const allProducts = ref(generateProducts()) | 144 | + loading.value = true |
| 155 | 145 | ||
| 156 | -/** | 146 | + try { |
| 157 | - * 初始化数据分布 | 147 | + const params = { |
| 158 | - * @description 根据分类规则将 allProducts 中的数据分配到各个 tab 中 | 148 | + page: String(page.value), |
| 159 | - */ | 149 | + limit: String(limit.value) |
| 160 | -const initTabsData = () => { | 150 | + } |
| 161 | - tabsData.value.forEach((tab, index) => { | 151 | + |
| 162 | - if (tab.id === '') { | 152 | + // 如果不是"全部"标签,添加分类 ID 参数 |
| 163 | - tab.list = [...allProducts.value] | 153 | + if (activeTabId.value !== '') { |
| 154 | + params.cid = activeTabId.value | ||
| 155 | + } | ||
| 156 | + | ||
| 157 | + const res = await listAPI(params) | ||
| 158 | + | ||
| 159 | + if (res.code === 1 && res.data) { | ||
| 160 | + // 更新分类列表(首次加载时) | ||
| 161 | + if (!isLoadMore && res.data.categories) { | ||
| 162 | + categories.value = res.data.categories | ||
| 163 | + } | ||
| 164 | + | ||
| 165 | + // 处理产品列表 | ||
| 166 | + if (isLoadMore) { | ||
| 167 | + // 加载更多:追加数据 | ||
| 168 | + products.value = [...products.value, ...res.data.list] | ||
| 164 | } else { | 169 | } else { |
| 165 | - // 模拟分类逻辑:根据索引取余分配 | 170 | + // 首次加载或切换分类:替换数据 |
| 166 | - // 保持与原逻辑一致:result = result.filter((_, i) => (i + index) % (index + 2) === 0) | 171 | + products.value = res.data.list || [] |
| 167 | - tab.list = allProducts.value.filter((_, i) => (i + index) % (index + 2) === 0) | ||
| 168 | } | 172 | } |
| 173 | + | ||
| 174 | + // 更新总数和分页状态 | ||
| 175 | + total.value = res.data.total || 0 | ||
| 176 | + hasMore.value = products.value.length < total.value | ||
| 177 | + } else { | ||
| 178 | + Taro.showToast({ | ||
| 179 | + title: res.msg || '获取产品列表失败', | ||
| 180 | + icon: 'none' | ||
| 181 | + }) | ||
| 182 | + } | ||
| 183 | + } catch (err) { | ||
| 184 | + console.error('获取产品列表失败:', err) | ||
| 185 | + Taro.showToast({ | ||
| 186 | + title: '网络错误,请重试', | ||
| 187 | + icon: 'none' | ||
| 169 | }) | 188 | }) |
| 189 | + } finally { | ||
| 190 | + loading.value = false | ||
| 191 | + } | ||
| 170 | } | 192 | } |
| 171 | 193 | ||
| 172 | /** | 194 | /** |
| 173 | - * 当前选中 Tab 的名称 | 195 | + * Tab 点击处理 |
| 174 | - * @description 根据 activeTabId 获取对应 tab 的名称 | 196 | + * @description 切换分类,重置分页并重新加载数据 |
| 175 | */ | 197 | */ |
| 176 | -const currentTabName = computed(() => { | 198 | +const onTabClick = (id) => { |
| 177 | - const currentTab = tabsData.value.find(tab => tab.id === activeTabId.value) | 199 | + if (activeTabId.value === id) return |
| 178 | - return currentTab ? currentTab.name : '' | ||
| 179 | -}) | ||
| 180 | 200 | ||
| 181 | -/** | 201 | + activeTabId.value = id |
| 182 | - * 当前选中 Tab 的产品列表 | ||
| 183 | - * @description 根据 activeTabId 获取对应 tab 的产品列表 | ||
| 184 | - */ | ||
| 185 | -const filteredProducts = computed(() => { | ||
| 186 | - // 找到当前选中的 tab | ||
| 187 | - const currentTab = tabsData.value.find(tab => tab.id === activeTabId.value) | ||
| 188 | - if (!currentTab) return [] | ||
| 189 | 202 | ||
| 190 | - return currentTab.list | 203 | + // 重置分页状态 |
| 191 | -}) | 204 | + page.value = 0 |
| 205 | + products.value = [] | ||
| 206 | + hasMore.value = true | ||
| 207 | + | ||
| 208 | + // 重新加载数据 | ||
| 209 | + fetchProducts(false) | ||
| 210 | +} | ||
| 192 | 211 | ||
| 193 | /** | 212 | /** |
| 194 | - * Tab 点击处理 | 213 | + * 滚动到底部加载更多 |
| 214 | + * @description 触发上拉加载更多 | ||
| 195 | */ | 215 | */ |
| 196 | -const onTabClick = (id) => { | 216 | +const onScrollToLower = () => { |
| 197 | - activeTabId.value = id | 217 | + if (!hasMore.value || loading.value) return |
| 198 | - listVisible.value = false | 218 | + |
| 199 | - nextTick(() => { | 219 | + page.value += 1 |
| 200 | - listRenderKey.value += 1 | 220 | + fetchProducts(true) |
| 201 | - listVisible.value = true | ||
| 202 | - }) | ||
| 203 | - // 可以在这里触发加载逻辑 | ||
| 204 | - // loadProductsByCategory(id) | ||
| 205 | } | 221 | } |
| 206 | 222 | ||
| 207 | /** | 223 | /** |
| 208 | * 使用产品列表点击处理器 | 224 | * 使用产品列表点击处理器 |
| 209 | - * | ||
| 210 | * @description 配置为产品类型列表,点击时跳转到产品详情页 | 225 | * @description 配置为产品类型列表,点击时跳转到产品详情页 |
| 211 | */ | 226 | */ |
| 212 | const { handleClick: handleProductClick } = useListItemClick({ | 227 | const { handleClick: handleProductClick } = useListItemClick({ |
| 213 | listType: ListType.PRODUCT, | 228 | listType: ListType.PRODUCT, |
| 214 | onAfterClick: (item) => { | 229 | onAfterClick: (item) => { |
| 215 | - console.log('用户查看了产品:', item.title) | 230 | + console.log('用户查看了产品:', item.product_name) |
| 216 | } | 231 | } |
| 217 | }) | 232 | }) |
| 218 | 233 | ||
| 219 | -// 初始化数据 | 234 | +/** |
| 220 | -initTabsData() | 235 | + * 页面加载时获取数据 |
| 236 | + */ | ||
| 237 | +useLoad(() => { | ||
| 238 | + fetchProducts(false) | ||
| 239 | +}) | ||
| 240 | + | ||
| 241 | +/** | ||
| 242 | + * 触底加载更多(备用方案) | ||
| 243 | + */ | ||
| 244 | +useReachBottom(() => { | ||
| 245 | + onScrollToLower() | ||
| 246 | +}) | ||
| 221 | </script> | 247 | </script> |
| 222 | 248 | ||
| 223 | <style lang="less"> | 249 | <style lang="less"> | ... | ... |
-
Please register or login to post a comment