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>
<!--
* @Date: 2026-01-31
* @Description: 产品知识库 - 已改造为 NutTabs 版本
* @Description: 产品知识库 - API 接口集成版本
-->
<template>
<view class="h-screen bg-[#F9FAFB] flex flex-col">
......@@ -30,31 +30,31 @@
</view>
</view>
<!-- 列表容器(独立于 nut-tab-pane) -->
<view
v-if="listVisible"
:key="listRenderKey"
<!-- 列表容器 -->
<scroll-view
class="flex-1 min-h-0 overflow-y-auto px-[40rpx] pb-[calc(160rpx+env(safe-area-inset-bottom))]"
scroll-y
@scrolltolower="onScrollToLower"
>
<!-- Section Title -->
<view class="text-[#1F2937] text-[32rpx] font-bold mb-[24rpx]">
{{ currentTabName }}
<!-- 加载状态 -->
<view v-if="loading && products.length === 0" class="flex justify-center items-center py-[100rpx]">
<text class="text-gray-400 text-[28rpx]">加载中...</text>
</view>
<!-- Product Grid -->
<view class="flex flex-wrap justify-between">
<view v-else class="flex flex-wrap justify-between">
<!-- Card Item -->
<view v-for="(item, index) in filteredProducts" :key="index"
<view v-for="(item, index) in products" :key="item.id"
class="w-[48%] bg-white rounded-[24rpx] overflow-hidden mb-[24rpx] shadow-sm flex flex-col active:scale-[0.98] transition-transform duration-200"
:style="{ animationDelay: `${index * 50}ms` }"
@tap="handleProductClick(item)">
<!-- Image Container -->
<view class="relative w-full h-[200rpx] product-card-item">
<image :src="item.image" class="w-full h-full object-cover bg-gray-100" referrerpolicy="no-referrer" />
<image :src="item.cover_image" class="w-full h-full object-cover bg-gray-100" mode="aspectFill" />
<!-- Tag -->
<view v-if="item.tag"
<view v-if="item.recommend === 'hot'"
class="absolute top-[12rpx] right-[12rpx] bg-red-500 text-white text-[20rpx] px-[12rpx] py-[4rpx] rounded-full">
{{ item.tag }}
热卖
</view>
</view>
......@@ -62,162 +62,188 @@
<view class="p-[20rpx] flex flex-col flex-1">
<!-- Title -->
<view class="text-[#1F2937] text-[28rpx] font-medium leading-[1.4] line-clamp-2 mb-[16rpx]">
{{ item.title }}
{{ item.product_name }}
</view>
<!-- Desc -->
<view class="mt-auto self-start text-[22rpx] px-[12rpx] py-[4rpx] rounded-full"
:class="[getDescColor(item.id).bg, getDescColor(item.id).text]">
{{ item.desc }}
<!-- 动态标签 -->
<view v-if="item.tags && item.tags.length > 0" class="flex flex-wrap gap-[8rpx] mt-auto">
<view
v-for="tag in item.tags.slice(0, 2)"
:key="tag.id"
class="text-[20rpx] px-[12rpx] py-[4rpx] rounded-full"
:style="{
backgroundColor: tag.bg_color,
color: tag.text_color
}"
>
{{ tag.name }}
</view>
</view>
</view>
</view>
</view>
<!-- 加载更多状态 -->
<view v-if="loading && products.length > 0" class="flex justify-center items-center py-[40rpx]">
<text class="text-gray-400 text-[28rpx]">加载中...</text>
</view>
<!-- 没有更多数据 -->
<view v-if="!hasMore && products.length > 0" class="flex justify-center items-center py-[40rpx]">
<text class="text-gray-400 text-[28rpx]">没有更多了</text>
</view>
<!-- 空状态 -->
<view v-if="filteredProducts.length === 0" class="flex flex-col items-center justify-center py-[100rpx]">
<view v-if="!loading && products.length === 0" class="flex flex-col items-center justify-center py-[100rpx]">
<text class="text-gray-400 text-[28rpx]">暂无相关产品</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
import { ref, computed } from 'vue'
import Taro, { useLoad, useReachBottom } from '@tarojs/taro'
import NavHeader from '@/components/NavHeader.vue'
import { useListItemClick, ListType } from '@/composables/useListItemClick'
import { listAPI } from '@/api/get_product'
const activeTabId = ref('')
const listVisible = ref(true)
const listRenderKey = ref(0)
/**
* Tab 数据源
* @description 包含分类信息和对应的产品列表
*/
const tabsData = ref([
{ id: '', name: '全部产品', list: [] },
{ id: 'life', name: '人寿保险', list: [] },
{ id: 'medical', name: '医疗保险', list: [] },
{ id: 'accident', name: '意外保险', list: [] },
])
// 分页状态
const page = ref(0)
const limit = ref(10)
const loading = ref(false)
const hasMore = ref(true)
/**
* Desc 颜色调色板
*
* @description 为不同描述提供柔和的背景色和对应的文字颜色
*/
const descColorPalette = [
{ bg: 'bg-blue-50', text: 'text-blue-600' }, // 蓝色
{ bg: 'bg-green-50', text: 'text-green-600' }, // 绿色
{ bg: 'bg-purple-50', text: 'text-purple-600' }, // 紫色
{ bg: 'bg-orange-50', text: 'text-orange-600' }, // 橙色
{ bg: 'bg-pink-50', text: 'text-pink-600' }, // 粉色
{ bg: 'bg-teal-50', text: 'text-teal-600' }, // 青色
{ bg: 'bg-indigo-50', text: 'text-indigo-600' }, // 靛蓝色
{ bg: 'bg-red-50', text: 'text-red-600' }, // 红色
]
// 数据状态
const categories = ref([]) // 从接口获取的分类列表
const products = ref([]) // 当前产品列表
const total = ref(0) // 产品总数
/**
* 获取 desc 的颜色样式
*
* @description 根据 ID 获取固定的背景色和文字颜色
* @param {number} id - 产品 ID
* @returns {Object} 包含 bg 和 text 属性的颜色对象
* 标签栏数据(根据接口返回的 categories 生成)
* @description 包含"全部"选项和接口返回的分类
*/
const getDescColor = (id) => {
const index = id % descColorPalette.length
return descColorPalette[index]
}
const tabsData = computed(() => {
const allTab = { id: '', name: '全部' }
const categoryTabs = categories.value.map(cat => ({
id: String(cat.id),
name: cat.name
}))
return [allTab, ...categoryTabs]
})
/**
* 生成产品数据
*
* @description 生成模拟产品列表数据,每个产品包含唯一 id
* @returns {Array} 产品列表
* 获取产品列表
* @description 根据 activeTabId 获取对应分类的产品列表
*/
const generateProducts = () => {
return [
{ id: 1, title: '终身寿险尊享版', tag: '热卖', desc: '5年超值', image: `https://picsum.photos/seed/1/200/200` },
{ id: 2, title: '百万医疗保险计划', desc: '收益率3.5%', image: `https://picsum.photos/seed/2/200/200` },
{ id: 3, title: '意外伤害保障计划', desc: '保证收益万能', image: `https://picsum.photos/seed/3/200/200` },
{ id: 4, title: '分红型年金保险', tag: '热卖', desc: '保证收益万能', image: `https://picsum.photos/seed/4/200/200` },
{ id: 5, title: '重大疾病保险', desc: '收益率4.2%', image: `https://picsum.photos/seed/5/200/200` },
{ id: 6, title: '少儿教育金保险', tag: '热卖', desc: '教育专属', image: `https://picsum.photos/seed/6/200/200` },
{ id: 7, title: '高端医疗服务', desc: '尊享服务', image: `https://picsum.photos/seed/7/200/200` },
{ id: 8, title: '家庭财产保险', desc: '全家无忧', image: `https://picsum.photos/seed/8/200/200` },
{ id: 9, title: '长期护理保险', desc: '贴心保障', image: `https://picsum.photos/seed/9/200/200` },
{ id: 10, title: '投资连结保险', tag: '新品', desc: '稳健增值', image: `https://picsum.photos/seed/10/200/200` },
]
}
const fetchProducts = async (isLoadMore = false) => {
if (loading.value) return
const allProducts = ref(generateProducts())
loading.value = true
/**
* 初始化数据分布
* @description 根据分类规则将 allProducts 中的数据分配到各个 tab 中
*/
const initTabsData = () => {
tabsData.value.forEach((tab, index) => {
if (tab.id === '') {
tab.list = [...allProducts.value]
try {
const params = {
page: String(page.value),
limit: String(limit.value)
}
// 如果不是"全部"标签,添加分类 ID 参数
if (activeTabId.value !== '') {
params.cid = activeTabId.value
}
const res = await listAPI(params)
if (res.code === 1 && res.data) {
// 更新分类列表(首次加载时)
if (!isLoadMore && res.data.categories) {
categories.value = res.data.categories
}
// 处理产品列表
if (isLoadMore) {
// 加载更多:追加数据
products.value = [...products.value, ...res.data.list]
} else {
// 首次加载或切换分类:替换数据
products.value = res.data.list || []
}
// 更新总数和分页状态
total.value = res.data.total || 0
hasMore.value = products.value.length < total.value
} else {
// 模拟分类逻辑:根据索引取余分配
// 保持与原逻辑一致:result = result.filter((_, i) => (i + index) % (index + 2) === 0)
tab.list = allProducts.value.filter((_, i) => (i + index) % (index + 2) === 0)
Taro.showToast({
title: res.msg || '获取产品列表失败',
icon: 'none'
})
}
})
} catch (err) {
console.error('获取产品列表失败:', err)
Taro.showToast({
title: '网络错误,请重试',
icon: 'none'
})
} finally {
loading.value = false
}
}
/**
* 当前选中 Tab 的名称
* @description 根据 activeTabId 获取对应 tab 的名称
* Tab 点击处理
* @description 切换分类,重置分页并重新加载数据
*/
const currentTabName = computed(() => {
const currentTab = tabsData.value.find(tab => tab.id === activeTabId.value)
return currentTab ? currentTab.name : ''
})
const onTabClick = (id) => {
if (activeTabId.value === id) return
/**
* 当前选中 Tab 的产品列表
* @description 根据 activeTabId 获取对应 tab 的产品列表
*/
const filteredProducts = computed(() => {
// 找到当前选中的 tab
const currentTab = tabsData.value.find(tab => tab.id === activeTabId.value)
if (!currentTab) return []
activeTabId.value = id
return currentTab.list
})
// 重置分页状态
page.value = 0
products.value = []
hasMore.value = true
// 重新加载数据
fetchProducts(false)
}
/**
* Tab 点击处理
* 滚动到底部加载更多
* @description 触发上拉加载更多
*/
const onTabClick = (id) => {
activeTabId.value = id
listVisible.value = false
nextTick(() => {
listRenderKey.value += 1
listVisible.value = true
})
// 可以在这里触发加载逻辑
// loadProductsByCategory(id)
const onScrollToLower = () => {
if (!hasMore.value || loading.value) return
page.value += 1
fetchProducts(true)
}
/**
* 使用产品列表点击处理器
*
* @description 配置为产品类型列表,点击时跳转到产品详情页
*/
const { handleClick: handleProductClick } = useListItemClick({
listType: ListType.PRODUCT,
onAfterClick: (item) => {
console.log('用户查看了产品:', item.title)
console.log('用户查看了产品:', item.product_name)
}
})
// 初始化数据
initTabsData()
/**
* 页面加载时获取数据
*/
useLoad(() => {
fetchProducts(false)
})
/**
* 触底加载更多(备用方案)
*/
useReachBottom(() => {
onScrollToLower()
})
</script>
<style lang="less">
......