hookehuyr

feat(页面组件): 重构收藏和计划书页面的标签栏为 NutTabs

- 将 favorites 和 plan 页面的 FilterTabs 组件替换为 NutTabs 实现自定义标签栏
- 统一在 knowledge-base、material-list、favorites 和 plan 页面的标签容器中添加 width: 100% 样式
- 为收藏和计划书列表项添加进场动画效果
- 重构数据逻辑,使用 tabsData 统一管理分类数据,支持动态数据分配
- 优化标签切换时的列表重渲染体验,避免内容闪烁
1 +<!--
2 + * @Date: 2026-01-31
3 + * @Description: 我的收藏 - 已改造为 NutTabs 版本
4 +-->
1 <template> 5 <template>
2 <view class="h-screen bg-gray-50 flex flex-col"> 6 <view class="h-screen bg-gray-50 flex flex-col">
3 - <view class="bg-gray-50"> 7 + <view class="bg-gray-50 z-10">
4 <NavHeader title="我的收藏" /> 8 <NavHeader title="我的收藏" />
5 9
6 - <view class="bg-white mt-[2rpx] px-[24rpx] py-[20rpx]"> 10 + <!-- Tabs Container -->
7 - <FilterTabs 11 + <view class="bg-white mt-[2rpx]">
8 - v-model="activeTab" 12 + <nut-tabs v-model="activeTabId">
9 - :tabs="tabs" 13 + <!-- 自定义标签栏 -->
10 - label-key="title" 14 + <template #titles>
11 - value-key="key" 15 + <view class="filter-tabs-wrapper">
12 - /> 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>
13 </view> 30 </view>
14 </view> 31 </view>
15 32
16 - <view class="flex-1 overflow-y-auto px-[24rpx] py-[24rpx] pb-[200rpx]"> 33 + <view
34 + v-if="listVisible"
35 + :key="listRenderKey"
36 + class="flex-1 min-h-0 overflow-y-auto px-[24rpx] py-[24rpx] pb-[200rpx]"
37 + >
17 <view v-for="(item, index) in filteredList" :key="index" 38 <view v-for="(item, index) in filteredList" :key="index"
18 - class="bg-white rounded-[24rpx] p-[24rpx] mb-[24rpx] shadow-sm"> 39 + class="bg-white rounded-[24rpx] p-[24rpx] mb-[24rpx] shadow-sm favorite-item"
40 + :style="{ animationDelay: `${index * 50}ms` }">
19 41
20 <!-- Header with Icon --> 42 <!-- Header with Icon -->
21 <view class="flex gap-[24rpx] mb-[12rpx]"> 43 <view class="flex gap-[24rpx] mb-[12rpx]">
...@@ -62,33 +84,38 @@ ...@@ -62,33 +84,38 @@
62 </template> 84 </template>
63 85
64 <script setup> 86 <script setup>
65 -import { ref, computed } from 'vue' 87 +import { ref, computed, nextTick } from 'vue'
66 import Taro from '@tarojs/taro' 88 import Taro from '@tarojs/taro'
67 import { useGo } from '@/hooks/useGo' 89 import { useGo } from '@/hooks/useGo'
68 import { useFileOperation } from '@/composables/useFileOperation' 90 import { useFileOperation } from '@/composables/useFileOperation'
69 import { getDocumentIcon } from '@/utils/documentIcons' 91 import { getDocumentIcon } from '@/utils/documentIcons'
70 import IconFont from '@/components/IconFont.vue' 92 import IconFont from '@/components/IconFont.vue'
71 -import FilterTabs from '@/components/FilterTabs.vue'
72 import NavHeader from '@/components/NavHeader.vue' 93 import NavHeader from '@/components/NavHeader.vue'
73 import ListItemActions from '@/components/ListItemActions/index.vue' 94 import ListItemActions from '@/components/ListItemActions/index.vue'
74 95
75 const go = useGo() 96 const go = useGo()
76 const { viewFile } = useFileOperation() 97 const { viewFile } = useFileOperation()
77 -const activeTab = ref('all') 98 +const activeTabId = ref('all')
99 +const listVisible = ref(true)
100 +const listRenderKey = ref(0)
78 101
79 -const tabs = [ 102 +/**
80 - { title: '全部', key: 'all' }, 103 + * Tab 数据源
81 - { title: '入职培训', key: 'onboarding' }, 104 + * @description 包含分类信息和对应的收藏列表
82 - { title: '签单相关', key: 'signing' }, 105 + */
83 - { title: '产品知识', key: 'product' } 106 +const tabsData = ref([
84 -] 107 + { id: 'all', name: '全部', list: [] },
108 + { id: 'onboarding', name: '入职培训', list: [] },
109 + { id: 'signing', name: '签单相关', list: [] },
110 + { id: 'product', name: '产品知识', list: [] }
111 +])
85 112
86 /** 113 /**
87 * Mock 数据:收藏列表 114 * Mock 数据:收藏列表
88 * 115 *
89 * @description 包含不同类型的文档文件 116 * @description 包含不同类型的文档文件
90 */ 117 */
91 -const list = ref([ 118 +const allList = ref([
92 { 119 {
93 id: 1, 120 id: 1,
94 title: '新员工入职培训手册.pdf', 121 title: '新员工入职培训手册.pdf',
...@@ -139,12 +166,44 @@ const list = ref([ ...@@ -139,12 +166,44 @@ const list = ref([
139 } 166 }
140 ]) 167 ])
141 168
169 +/**
170 + * 初始化数据分布
171 + * @description 根据分类规则将 allList 中的数据分配到各个 tab 中
172 + */
173 +const initTabsData = () => {
174 + tabsData.value.forEach((tab) => {
175 + if (tab.id === 'all') {
176 + tab.list = [...allList.value]
177 + } else if (tab.id === 'onboarding') {
178 + // 入职培训:type 为 onboarding 或 other
179 + tab.list = allList.value.filter(item => item.type === 'onboarding' || item.type === 'other')
180 + } else {
181 + tab.list = allList.value.filter(item => item.type === tab.id)
182 + }
183 + })
184 +}
185 +
142 const filteredList = computed(() => { 186 const filteredList = computed(() => {
143 - if (activeTab.value === 'all') return list.value 187 + // 找到当前选中的 tab
144 - return list.value.filter(item => item.type === activeTab.value) 188 + const currentTab = tabsData.value.find(tab => tab.id === activeTabId.value)
189 + if (!currentTab) return []
190 +
191 + return currentTab.list
145 }) 192 })
146 193
147 /** 194 /**
195 + * Tab 点击处理
196 + */
197 +const onTabClick = (id) => {
198 + activeTabId.value = id
199 + listVisible.value = false
200 + nextTick(() => {
201 + listRenderKey.value += 1
202 + listVisible.value = true
203 + })
204 +}
205 +
206 +/**
148 * 删除收藏 207 * 删除收藏
149 */ 208 */
150 const onDelete = (item) => { 209 const onDelete = (item) => {
...@@ -153,10 +212,93 @@ const onDelete = (item) => { ...@@ -153,10 +212,93 @@ const onDelete = (item) => {
153 content: '确定要删除该收藏吗?', 212 content: '确定要删除该收藏吗?',
154 success: (res) => { 213 success: (res) => {
155 if (res.confirm) { 214 if (res.confirm) {
156 - list.value = list.value.filter(i => i.id !== item.id) 215 + // 从 allList 中删除
157 - Taro.showToast({ title: '已删除', icon: 'success' }) 216 + const index = allList.value.findIndex(i => i.id === item.id)
217 + if (index !== -1) {
218 + allList.value.splice(index, 1)
219 + // 重新初始化 tabsData
220 + initTabsData()
221 + Taro.showToast({ title: '已删除', icon: 'success' })
222 + }
158 } 223 }
159 } 224 }
160 }) 225 })
161 } 226 }
227 +
228 +// 初始化数据
229 +initTabsData()
162 </script> 230 </script>
231 +
232 +<style lang="less">
233 +@keyframes slideIn {
234 + from {
235 + opacity: 0;
236 + transform: translateY(20rpx);
237 + }
238 +
239 + to {
240 + opacity: 1;
241 + transform: translateY(0);
242 + }
243 +}
244 +
245 +.favorite-item {
246 + animation: slideIn 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) backwards;
247 +}
248 +
249 +// FilterTabs 风格的标签栏
250 +.filter-tabs-wrapper {
251 + display: flex;
252 + overflow-x: auto;
253 + padding: 24rpx 24rpx;
254 + gap: 24rpx;
255 + transition: all 0.3s ease;
256 + background-color: #fff;
257 + width: 100%;
258 +
259 + // 隐藏滚动条
260 + &::-webkit-scrollbar {
261 + display: none;
262 + width: 0;
263 + height: 0;
264 + }
265 +
266 + -ms-overflow-style: none;
267 + scrollbar-width: none;
268 +}
269 +
270 +.filter-tab-item {
271 + display: flex;
272 + align-items: center;
273 + justify-content: center;
274 + padding: 0 32rpx;
275 + border-radius: 9999rpx;
276 + white-space: nowrap;
277 + transition: all 0.3s ease;
278 + flex-shrink: 0;
279 +}
280 +
281 +.filter-tab-active {
282 + background-color: #2563EB; // 蓝色背景
283 + color: #fff;
284 +}
285 +
286 +.filter-tab-inactive {
287 + background-color: #F3F4F6; // 灰色背景
288 + color: #6B7280;
289 +}
290 +
291 +.filter-tab-text {
292 + font-size: 28rpx;
293 + font-weight: 500;
294 +}
295 +
296 +// 覆盖 NutUI Tabs 默认样式,隐藏原有的头部和内容(因为我们使用自定义头部和外部列表)
297 +:deep(.nut-tabs__titles) {
298 + display: none;
299 +}
300 +
301 +:deep(.nut-tabs__content) {
302 + display: none;
303 +}
304 +</style>
......
...@@ -245,6 +245,7 @@ initTabsData() ...@@ -245,6 +245,7 @@ initTabsData()
245 gap: 24rpx; 245 gap: 24rpx;
246 transition: all 0.3s ease; 246 transition: all 0.3s ease;
247 background-color: #F9FAFB; 247 background-color: #F9FAFB;
248 + width: 100%;
248 249
249 // 隐藏滚动条 250 // 隐藏滚动条
250 &::-webkit-scrollbar { 251 &::-webkit-scrollbar {
......
...@@ -465,6 +465,7 @@ const onDelete = (item) => { ...@@ -465,6 +465,7 @@ const onDelete = (item) => {
465 gap: 24rpx; 465 gap: 24rpx;
466 transition: all 0.3s ease; 466 transition: all 0.3s ease;
467 background-color: #F9FAFB; 467 background-color: #F9FAFB;
468 + width: 100%;
468 469
469 // 隐藏滚动条 470 // 隐藏滚动条
470 &::-webkit-scrollbar { 471 &::-webkit-scrollbar {
......
1 +<!--
2 + * @Date: 2026-01-31
3 + * @Description: 我的计划书 - 已改造为 NutTabs 版本
4 +-->
1 <template> 5 <template>
2 <view class="h-screen bg-gray-50 flex flex-col"> 6 <view class="h-screen bg-gray-50 flex flex-col">
3 - <view class="bg-gray-50"> 7 + <view class="bg-gray-50 z-10">
4 <!-- Navigation Header --> 8 <!-- Navigation Header -->
5 <NavHeader title="我的计划书" /> 9 <NavHeader title="我的计划书" />
6 10
...@@ -14,16 +18,38 @@ ...@@ -14,16 +18,38 @@
14 /> 18 />
15 </view> 19 </view>
16 20
17 - <!-- Tabs --> 21 + <!-- Tabs Container -->
18 - <view class="bg-white mt-[2rpx] px-[24rpx] py-[20rpx]"> 22 + <view class="bg-white mt-[2rpx]">
19 - <FilterTabs v-model="activeTab" :tabs="tabs" label-key="title" /> 23 + <nut-tabs v-model="activeTabId">
24 + <!-- 自定义标签栏 -->
25 + <template #titles>
26 + <view class="filter-tabs-wrapper">
27 + <view
28 + v-for="item in tabsData"
29 + :key="item.id"
30 + :class="[
31 + 'filter-tab-item',
32 + activeTabId === item.id ? 'filter-tab-active' : 'filter-tab-inactive'
33 + ]"
34 + @tap="onTabClick(item.id)"
35 + >
36 + <text class="filter-tab-text">{{ item.name }}</text>
37 + </view>
38 + </view>
39 + </template>
40 + </nut-tabs>
20 </view> 41 </view>
21 </view> 42 </view>
22 43
23 <!-- Plan List --> 44 <!-- Plan List -->
24 - <view class="flex-1 overflow-y-auto px-[24rpx] py-[24rpx] pb-[200rpx]"> 45 + <view
46 + v-if="listVisible"
47 + :key="listRenderKey"
48 + class="flex-1 min-h-0 overflow-y-auto px-[24rpx] py-[24rpx] pb-[200rpx]"
49 + >
25 <view v-for="(item, index) in filteredList" :key="index" 50 <view v-for="(item, index) in filteredList" :key="index"
26 - class="bg-white rounded-[24rpx] p-[24rpx] mb-[24rpx] shadow-sm"> 51 + class="bg-white rounded-[24rpx] p-[24rpx] mb-[24rpx] shadow-sm plan-item"
52 + :style="{ animationDelay: `${index * 50}ms` }">
27 <!-- Header --> 53 <!-- Header -->
28 <view class="flex justify-between items-start mb-[16rpx]"> 54 <view class="flex justify-between items-start mb-[16rpx]">
29 <view class="flex-1"> 55 <view class="flex-1">
...@@ -71,10 +97,9 @@ ...@@ -71,10 +97,9 @@
71 </template> 97 </template>
72 98
73 <script setup> 99 <script setup>
74 -import { ref, computed } from 'vue' 100 +import { ref, computed, nextTick } from 'vue'
75 import { useFileOperation } from '@/composables/useFileOperation' 101 import { useFileOperation } from '@/composables/useFileOperation'
76 import IconFont from '@/components/IconFont.vue' 102 import IconFont from '@/components/IconFont.vue'
77 -import FilterTabs from '@/components/FilterTabs.vue'
78 import NavHeader from '@/components/NavHeader.vue' 103 import NavHeader from '@/components/NavHeader.vue'
79 import ListItemActions from '@/components/ListItemActions/index.vue' 104 import ListItemActions from '@/components/ListItemActions/index.vue'
80 import SearchBar from '@/components/SearchBar.vue' 105 import SearchBar from '@/components/SearchBar.vue'
...@@ -83,12 +108,19 @@ import Taro from '@tarojs/taro' ...@@ -83,12 +108,19 @@ import Taro from '@tarojs/taro'
83 const { viewFile } = useFileOperation() 108 const { viewFile } = useFileOperation()
84 109
85 const searchValue = ref('') 110 const searchValue = ref('')
86 -const activeTab = ref(0) 111 +const activeTabId = ref('')
87 -const tabs = [ 112 +const listVisible = ref(true)
88 - { title: '全部', key: 'all' }, 113 +const listRenderKey = ref(0)
89 - { title: '生成中', key: 'processing' }, 114 +
90 - { title: '已生成', key: 'generated' } 115 +/**
91 -] 116 + * Tab 数据源
117 + * @description 包含分类信息和对应的计划书列表
118 + */
119 +const tabsData = ref([
120 + { id: '', name: '全部', list: [] },
121 + { id: 'processing', name: '生成中', list: [] },
122 + { id: 'generated', name: '已生成', list: [] },
123 +])
92 124
93 /** 125 /**
94 * Mock 数据:计划书列表 126 * Mock 数据:计划书列表
...@@ -96,7 +128,7 @@ const tabs = [ ...@@ -96,7 +128,7 @@ const tabs = [
96 * @description 使用真实 PDF 文件进行测试 128 * @description 使用真实 PDF 文件进行测试
97 * downloadUrl 使用 Mozilla 的公开 PDF 测试文件 129 * downloadUrl 使用 Mozilla 的公开 PDF 测试文件
98 */ 130 */
99 -const list = ref([ 131 +const allList = ref([
100 { 132 {
101 id: 1, 133 id: 1,
102 title: '家庭财富传承保障计划(分红)', 134 title: '家庭财富传承保障计划(分红)',
...@@ -224,16 +256,28 @@ const list = ref([ ...@@ -224,16 +256,28 @@ const list = ref([
224 ]) 256 ])
225 257
226 /** 258 /**
259 + * 初始化数据分布
260 + * @description 根据分类规则将 allList 中的数据分配到各个 tab 中
261 + */
262 +const initTabsData = () => {
263 + tabsData.value.forEach((tab) => {
264 + if (tab.id === '') {
265 + tab.list = [...allList.value]
266 + } else {
267 + tab.list = allList.value.filter(item => item.status === tab.id)
268 + }
269 + })
270 +}
271 +
272 +/**
227 * 根据标签页和搜索关键词过滤列表 273 * 根据标签页和搜索关键词过滤列表
228 */ 274 */
229 const filteredList = computed(() => { 275 const filteredList = computed(() => {
230 - let result = list.value 276 + // 找到当前选中的 tab
277 + const currentTab = tabsData.value.find(tab => tab.id === activeTabId.value)
278 + if (!currentTab) return []
231 279
232 - // Filter by Tab 280 + let result = currentTab.list
233 - const currentKey = tabs[activeTab.value].key
234 - if (currentKey !== 'all') {
235 - result = result.filter(item => item.status === currentKey)
236 - }
237 281
238 // Filter by Search 282 // Filter by Search
239 if (searchValue.value) { 283 if (searchValue.value) {
...@@ -248,6 +292,18 @@ const filteredList = computed(() => { ...@@ -248,6 +292,18 @@ const filteredList = computed(() => {
248 }) 292 })
249 293
250 /** 294 /**
295 + * Tab 点击处理
296 + */
297 +const onTabClick = (id) => {
298 + activeTabId.value = id
299 + listVisible.value = false
300 + nextTick(() => {
301 + listRenderKey.value += 1
302 + listVisible.value = true
303 + })
304 +}
305 +
306 +/**
251 * 搜索处理 307 * 搜索处理
252 */ 308 */
253 const onSearch = (val) => { 309 const onSearch = (val) => {
...@@ -286,10 +342,93 @@ const onDelete = (item) => { ...@@ -286,10 +342,93 @@ const onDelete = (item) => {
286 content: '确定要删除该计划书吗?', 342 content: '确定要删除该计划书吗?',
287 success: (res) => { 343 success: (res) => {
288 if (res.confirm) { 344 if (res.confirm) {
289 - list.value = list.value.filter(i => i.id !== item.id) 345 + // 从 allList 中删除
290 - Taro.showToast({ title: '已删除', icon: 'success' }) 346 + const index = allList.value.findIndex(i => i.id === item.id)
347 + if (index !== -1) {
348 + allList.value.splice(index, 1)
349 + // 重新初始化 tabsData
350 + initTabsData()
351 + Taro.showToast({ title: '已删除', icon: 'success' })
352 + }
291 } 353 }
292 } 354 }
293 }) 355 })
294 } 356 }
357 +
358 +// 初始化数据
359 +initTabsData()
295 </script> 360 </script>
361 +
362 +<style lang="less">
363 +@keyframes slideIn {
364 + from {
365 + opacity: 0;
366 + transform: translateY(20rpx);
367 + }
368 +
369 + to {
370 + opacity: 1;
371 + transform: translateY(0);
372 + }
373 +}
374 +
375 +.plan-item {
376 + animation: slideIn 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) backwards;
377 +}
378 +
379 +// FilterTabs 风格的标签栏
380 +.filter-tabs-wrapper {
381 + display: flex;
382 + overflow-x: auto;
383 + padding: 24rpx 24rpx;
384 + gap: 24rpx;
385 + transition: all 0.3s ease;
386 + background-color: #fff;
387 + width: 100%;
388 +
389 + // 隐藏滚动条
390 + &::-webkit-scrollbar {
391 + display: none;
392 + width: 0;
393 + height: 0;
394 + }
395 +
396 + -ms-overflow-style: none;
397 + scrollbar-width: none;
398 +}
399 +
400 +.filter-tab-item {
401 + display: flex;
402 + align-items: center;
403 + justify-content: center;
404 + padding: 0 32rpx;
405 + border-radius: 9999rpx;
406 + white-space: nowrap;
407 + transition: all 0.3s ease;
408 + flex-shrink: 0;
409 +}
410 +
411 +.filter-tab-active {
412 + background-color: #2563EB; // 蓝色背景
413 + color: #fff;
414 +}
415 +
416 +.filter-tab-inactive {
417 + background-color: #F3F4F6; // 灰色背景
418 + color: #6B7280;
419 +}
420 +
421 +.filter-tab-text {
422 + font-size: 28rpx;
423 + font-weight: 500;
424 +}
425 +
426 +// 覆盖 NutUI Tabs 默认样式,隐藏原有的头部和内容(因为我们使用自定义头部和外部列表)
427 +:deep(.nut-tabs__titles) {
428 + display: none;
429 +}
430 +
431 +:deep(.nut-tabs__content) {
432 + display: none;
433 +}
434 +</style>
......