hookehuyr

refactor(knowledge-base): 使用 NutTabs 重构产品知识库页面

- 移除 FilterTabs 组件,改用 NutUI Tabs 实现自定义标签栏
- 将数据模型重构为基于 ID 的分类,支持动态数据分配
- 优化列表渲染性能,添加切换动画和空状态提示
- 统一使用 view/image 等 uni-app 组件,提升跨端兼容性
1 +<!--
2 + * @Date: 2026-01-31
3 + * @Description: 产品知识库 - 已改造为 NutTabs 版本
4 +-->
1 <template> 5 <template>
2 - <div class="h-screen bg-[#F9FAFB] flex flex-col"> 6 + <view class="h-screen bg-[#F9FAFB] flex flex-col">
3 - <div class="bg-[#F9FAFB]"> 7 + <view class="bg-[#F9FAFB] z-10">
4 - <!-- Navigation Header -->
5 <NavHeader title="产品知识库" /> 8 <NavHeader title="产品知识库" />
6 9
7 - <!-- Filter Tabs --> 10 + <!-- Tabs Container -->
8 - <div class="px-[40rpx] mt-[40rpx]"> 11 + <view class="flex-1 min-h-0 flex flex-col">
9 - <FilterTabs v-model="activeTab" :tabs="tabs" wrapper-class="mb-[40rpx]" /> 12 + <nut-tabs v-model="activeTabId">
10 - </div> 13 + <!-- 自定义标签栏 -->
11 - </div> 14 + <template #titles>
15 + <view class="filter-tabs-wrapper">
16 + <view
17 + v-for="item in tabsData"
18 + :key="item.id"
19 + :class="[
20 + 'filter-tab-item',
21 + activeTabId === item.id ? 'filter-tab-active' : 'filter-tab-inactive'
22 + ]"
23 + @tap="onTabClick(item.id)"
24 + >
25 + <text class="filter-tab-text">{{ item.name }}</text>
26 + </view>
27 + </view>
28 + </template>
29 + </nut-tabs>
30 + </view>
31 + </view>
12 32
13 - <div class="flex-1 overflow-y-auto px-[40rpx] pb-[calc(160rpx+env(safe-area-inset-bottom))]"> 33 + <!-- 列表容器(独立于 nut-tab-pane) -->
34 + <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))]"
38 + >
14 <!-- Section Title --> 39 <!-- Section Title -->
15 - <div class="text-[#1F2937] text-[32rpx] font-bold mb-[24rpx]"> 40 + <view class="text-[#1F2937] text-[32rpx] font-bold mb-[24rpx]">
16 - {{ tabs[activeTab] }} 41 + {{ currentTabName }}
17 - </div> 42 + </view>
18 43
19 <!-- Product Grid --> 44 <!-- Product Grid -->
20 - <div class="flex flex-wrap justify-between"> 45 + <view class="flex flex-wrap justify-between">
21 <!-- Card Item --> 46 <!-- Card Item -->
22 - <div v-for="(item, index) in filteredProducts" :key="index" 47 + <view v-for="(item, index) in filteredProducts" :key="index"
23 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` }"
24 @tap="handleProductClick(item)"> 50 @tap="handleProductClick(item)">
25 <!-- Image Container --> 51 <!-- Image Container -->
26 - <div class="relative w-full h-[200rpx]"> 52 + <view class="relative w-full h-[200rpx] product-card-item">
27 - <img :src="item.image" class="w-full h-full object-cover bg-gray-100" referrerpolicy="no-referrer" /> 53 + <image :src="item.image" class="w-full h-full object-cover bg-gray-100" referrerpolicy="no-referrer" />
28 <!-- Tag --> 54 <!-- Tag -->
29 - <div v-if="item.tag" 55 + <view v-if="item.tag"
30 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">
31 {{ item.tag }} 57 {{ item.tag }}
32 - </div> 58 + </view>
33 - </div> 59 + </view>
34 60
35 <!-- Content --> 61 <!-- Content -->
36 - <div class="p-[20rpx] flex flex-col flex-1"> 62 + <view class="p-[20rpx] flex flex-col flex-1">
37 <!-- Title --> 63 <!-- Title -->
38 - <div 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]">
39 {{ item.title }} 65 {{ item.title }}
40 - </div> 66 + </view>
41 67
42 <!-- Desc --> 68 <!-- Desc -->
43 - <div class="mt-auto self-start text-[22rpx] px-[12rpx] py-[4rpx] rounded-full" 69 + <view class="mt-auto self-start text-[22rpx] px-[12rpx] py-[4rpx] rounded-full"
44 :class="[getDescColor(item.id).bg, getDescColor(item.id).text]"> 70 :class="[getDescColor(item.id).bg, getDescColor(item.id).text]">
45 {{ item.desc }} 71 {{ item.desc }}
46 - </div> 72 + </view>
47 - </div> 73 + </view>
48 - </div> 74 + </view>
49 - </div> 75 + </view>
50 - </div> 76 +
51 - 77 + <!-- 空状态 -->
52 - <!-- Tab Bar --> 78 + <view v-if="filteredProducts.length === 0" class="flex flex-col items-center justify-center py-[100rpx]">
53 - <!-- <TabBar current="home" /> --> 79 + <text class="text-gray-400 text-[28rpx]">暂无相关产品</text>
54 - </div> 80 + </view>
81 + </view>
82 + </view>
55 </template> 83 </template>
56 84
57 <script setup> 85 <script setup>
58 -import { ref, computed } from 'vue' 86 +import { ref, computed, nextTick } from 'vue'
59 import NavHeader from '@/components/NavHeader.vue' 87 import NavHeader from '@/components/NavHeader.vue'
60 -import FilterTabs from '@/components/FilterTabs.vue'
61 import { useListItemClick, ListType } from '@/composables/useListItemClick' 88 import { useListItemClick, ListType } from '@/composables/useListItemClick'
62 89
63 -const activeTab = ref(0) 90 +const activeTabId = ref('')
64 -const tabs = ['全部产品', '人寿保险', '医疗保险', '意外保险'] 91 +const listVisible = ref(true)
92 +const listRenderKey = ref(0)
93 +
94 +/**
95 + * Tab 数据源
96 + * @description 包含分类信息和对应的产品列表
97 + */
98 +const tabsData = ref([
99 + { id: '', name: '全部产品', list: [] },
100 + { id: 'life', name: '人寿保险', list: [] },
101 + { id: 'medical', name: '医疗保险', list: [] },
102 + { id: 'accident', name: '意外保险', list: [] },
103 +])
65 104
66 /** 105 /**
67 * Desc 颜色调色板 106 * Desc 颜色调色板
...@@ -107,23 +146,65 @@ const generateProducts = () => { ...@@ -107,23 +146,65 @@ const generateProducts = () => {
107 { id: 6, title: '少儿教育金保险', tag: '热卖', desc: '教育专属', image: `https://picsum.photos/seed/6/200/200` }, 146 { id: 6, title: '少儿教育金保险', tag: '热卖', desc: '教育专属', image: `https://picsum.photos/seed/6/200/200` },
108 { id: 7, title: '高端医疗服务', desc: '尊享服务', image: `https://picsum.photos/seed/7/200/200` }, 147 { id: 7, title: '高端医疗服务', desc: '尊享服务', image: `https://picsum.photos/seed/7/200/200` },
109 { id: 8, title: '家庭财产保险', desc: '全家无忧', image: `https://picsum.photos/seed/8/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` },
110 ] 151 ]
111 } 152 }
112 153
113 -const products = ref(generateProducts()) 154 +const allProducts = ref(generateProducts())
114 155
115 /** 156 /**
116 - * 根据当前标签筛选产品 157 + * 初始化数据分布
117 - * 158 + * @description 根据分类规则将 allProducts 中的数据分配到各个 tab 中
118 - * @description 根据选中的标签页筛选显示对应产品 159 + */
160 +const initTabsData = () => {
161 + tabsData.value.forEach((tab, index) => {
162 + if (tab.id === '') {
163 + tab.list = [...allProducts.value]
164 + } else {
165 + // 模拟分类逻辑:根据索引取余分配
166 + // 保持与原逻辑一致:result = result.filter((_, i) => (i + index) % (index + 2) === 0)
167 + tab.list = allProducts.value.filter((_, i) => (i + index) % (index + 2) === 0)
168 + }
169 + })
170 +}
171 +
172 +/**
173 + * 当前选中 Tab 的名称
174 + * @description 根据 activeTabId 获取对应 tab 的名称
175 + */
176 +const currentTabName = computed(() => {
177 + const currentTab = tabsData.value.find(tab => tab.id === activeTabId.value)
178 + return currentTab ? currentTab.name : ''
179 +})
180 +
181 +/**
182 + * 当前选中 Tab 的产品列表
183 + * @description 根据 activeTabId 获取对应 tab 的产品列表
119 */ 184 */
120 const filteredProducts = computed(() => { 185 const filteredProducts = computed(() => {
121 - if (activeTab.value === 0) return products.value 186 + // 找到当前选中的 tab
122 - // Mock filtering 187 + const currentTab = tabsData.value.find(tab => tab.id === activeTabId.value)
123 - return products.value.filter((_, i) => (i + activeTab.value) % 2 === 0) 188 + if (!currentTab) return []
189 +
190 + return currentTab.list
124 }) 191 })
125 192
126 /** 193 /**
194 + * Tab 点击处理
195 + */
196 +const onTabClick = (id) => {
197 + activeTabId.value = id
198 + listVisible.value = false
199 + nextTick(() => {
200 + listRenderKey.value += 1
201 + listVisible.value = true
202 + })
203 + // 可以在这里触发加载逻辑
204 + // loadProductsByCategory(id)
205 +}
206 +
207 +/**
127 * 使用产品列表点击处理器 208 * 使用产品列表点击处理器
128 * 209 *
129 * @description 配置为产品类型列表,点击时跳转到产品详情页 210 * @description 配置为产品类型列表,点击时跳转到产品详情页
...@@ -134,4 +215,80 @@ const { handleClick: handleProductClick } = useListItemClick({ ...@@ -134,4 +215,80 @@ const { handleClick: handleProductClick } = useListItemClick({
134 console.log('用户查看了产品:', item.title) 215 console.log('用户查看了产品:', item.title)
135 } 216 }
136 }) 217 })
218 +
219 +// 初始化数据
220 +initTabsData()
137 </script> 221 </script>
222 +
223 +<style lang="less">
224 +@keyframes slideIn {
225 + from {
226 + opacity: 0;
227 + transform: translateY(20rpx);
228 + }
229 +
230 + to {
231 + opacity: 1;
232 + transform: translateY(0);
233 + }
234 +}
235 +
236 +.product-card-item {
237 + animation: slideIn 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) backwards;
238 +}
239 +
240 +// FilterTabs 风格的标签栏
241 +.filter-tabs-wrapper {
242 + display: flex;
243 + overflow-x: auto;
244 + padding: 24rpx 40rpx;
245 + gap: 24rpx;
246 + transition: all 0.3s ease;
247 + background-color: #F9FAFB;
248 +
249 + // 隐藏滚动条
250 + &::-webkit-scrollbar {
251 + display: none;
252 + width: 0;
253 + height: 0;
254 + }
255 +
256 + -ms-overflow-style: none;
257 + scrollbar-width: none;
258 +}
259 +
260 +.filter-tab-item {
261 + display: flex;
262 + align-items: center;
263 + justify-content: center;
264 + padding: 0 32rpx;
265 + border-radius: 9999rpx;
266 + white-space: nowrap;
267 + transition: all 0.3s ease;
268 + flex-shrink: 0;
269 +}
270 +
271 +.filter-tab-active {
272 + background-color: #2563EB; // 蓝色背景
273 + color: #fff;
274 +}
275 +
276 +.filter-tab-inactive {
277 + background-color: #F3F4F6; // 灰色背景
278 + color: #6B7280;
279 +}
280 +
281 +.filter-tab-text {
282 + font-size: 28rpx;
283 + font-weight: 500;
284 +}
285 +
286 +// 覆盖 NutUI Tabs 默认样式,隐藏原有的头部和内容(因为我们使用自定义头部和外部列表)
287 +:deep(.nut-tabs__titles) {
288 + display: none;
289 +}
290 +
291 +:deep(.nut-tabs__content) {
292 + display: none;
293 +}
294 +</style>
......