refactor(knowledge-base): 使用 NutTabs 重构产品知识库页面
- 移除 FilterTabs 组件,改用 NutUI Tabs 实现自定义标签栏 - 将数据模型重构为基于 ID 的分类,支持动态数据分配 - 优化列表渲染性能,添加切换动画和空状态提示 - 统一使用 view/image 等 uni-app 组件,提升跨端兼容性
Showing
1 changed file
with
200 additions
and
43 deletions
| 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> | ... | ... |
-
Please register or login to post a comment