hookehuyr

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>
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]
169 + } else {
170 + // 首次加载或切换分类:替换数据
171 + products.value = res.data.list || []
172 + }
173 +
174 + // 更新总数和分页状态
175 + total.value = res.data.total || 0
176 + hasMore.value = products.value.length < total.value
164 } else { 177 } else {
165 - // 模拟分类逻辑:根据索引取余分配 178 + Taro.showToast({
166 - // 保持与原逻辑一致:result = result.filter((_, i) => (i + index) % (index + 2) === 0) 179 + title: res.msg || '获取产品列表失败',
167 - tab.list = allProducts.value.filter((_, i) => (i + index) % (index + 2) === 0) 180 + icon: 'none'
181 + })
168 } 182 }
169 - }) 183 + } catch (err) {
184 + console.error('获取产品列表失败:', err)
185 + Taro.showToast({
186 + title: '网络错误,请重试',
187 + icon: 'none'
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">
......