hookehuyr

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

- 将 favorites 和 plan 页面的 FilterTabs 组件替换为 NutTabs 实现自定义标签栏
- 统一在 knowledge-base、material-list、favorites 和 plan 页面的标签容器中添加 width: 100% 样式
- 为收藏和计划书列表项添加进场动画效果
- 重构数据逻辑,使用 tabsData 统一管理分类数据,支持动态数据分配
- 优化标签切换时的列表重渲染体验,避免内容闪烁
<!--
* @Date: 2026-01-31
* @Description: 我的收藏 - 已改造为 NutTabs 版本
-->
<template>
<view class="h-screen bg-gray-50 flex flex-col">
<view class="bg-gray-50">
<view class="bg-gray-50 z-10">
<NavHeader title="我的收藏" />
<view class="bg-white mt-[2rpx] px-[24rpx] py-[20rpx]">
<FilterTabs
v-model="activeTab"
:tabs="tabs"
label-key="title"
value-key="key"
/>
<!-- Tabs Container -->
<view class="bg-white mt-[2rpx]">
<nut-tabs v-model="activeTabId">
<!-- 自定义标签栏 -->
<template #titles>
<view class="filter-tabs-wrapper">
<view
v-for="item in tabsData"
:key="item.id"
:class="[
'filter-tab-item',
activeTabId === item.id ? 'filter-tab-active' : 'filter-tab-inactive'
]"
@tap="onTabClick(item.id)"
>
<text class="filter-tab-text">{{ item.name }}</text>
</view>
</view>
</template>
</nut-tabs>
</view>
</view>
<view class="flex-1 overflow-y-auto px-[24rpx] py-[24rpx] pb-[200rpx]">
<view
v-if="listVisible"
:key="listRenderKey"
class="flex-1 min-h-0 overflow-y-auto px-[24rpx] py-[24rpx] pb-[200rpx]"
>
<view v-for="(item, index) in filteredList" :key="index"
class="bg-white rounded-[24rpx] p-[24rpx] mb-[24rpx] shadow-sm">
class="bg-white rounded-[24rpx] p-[24rpx] mb-[24rpx] shadow-sm favorite-item"
:style="{ animationDelay: `${index * 50}ms` }">
<!-- Header with Icon -->
<view class="flex gap-[24rpx] mb-[12rpx]">
......@@ -62,33 +84,38 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, nextTick } from 'vue'
import Taro from '@tarojs/taro'
import { useGo } from '@/hooks/useGo'
import { useFileOperation } from '@/composables/useFileOperation'
import { getDocumentIcon } from '@/utils/documentIcons'
import IconFont from '@/components/IconFont.vue'
import FilterTabs from '@/components/FilterTabs.vue'
import NavHeader from '@/components/NavHeader.vue'
import ListItemActions from '@/components/ListItemActions/index.vue'
const go = useGo()
const { viewFile } = useFileOperation()
const activeTab = ref('all')
const activeTabId = ref('all')
const listVisible = ref(true)
const listRenderKey = ref(0)
const tabs = [
{ title: '全部', key: 'all' },
{ title: '入职培训', key: 'onboarding' },
{ title: '签单相关', key: 'signing' },
{ title: '产品知识', key: 'product' }
]
/**
* Tab 数据源
* @description 包含分类信息和对应的收藏列表
*/
const tabsData = ref([
{ id: 'all', name: '全部', list: [] },
{ id: 'onboarding', name: '入职培训', list: [] },
{ id: 'signing', name: '签单相关', list: [] },
{ id: 'product', name: '产品知识', list: [] }
])
/**
* Mock 数据:收藏列表
*
* @description 包含不同类型的文档文件
*/
const list = ref([
const allList = ref([
{
id: 1,
title: '新员工入职培训手册.pdf',
......@@ -139,12 +166,44 @@ const list = ref([
}
])
/**
* 初始化数据分布
* @description 根据分类规则将 allList 中的数据分配到各个 tab 中
*/
const initTabsData = () => {
tabsData.value.forEach((tab) => {
if (tab.id === 'all') {
tab.list = [...allList.value]
} else if (tab.id === 'onboarding') {
// 入职培训:type 为 onboarding 或 other
tab.list = allList.value.filter(item => item.type === 'onboarding' || item.type === 'other')
} else {
tab.list = allList.value.filter(item => item.type === tab.id)
}
})
}
const filteredList = computed(() => {
if (activeTab.value === 'all') return list.value
return list.value.filter(item => item.type === activeTab.value)
// 找到当前选中的 tab
const currentTab = tabsData.value.find(tab => tab.id === activeTabId.value)
if (!currentTab) return []
return currentTab.list
})
/**
* Tab 点击处理
*/
const onTabClick = (id) => {
activeTabId.value = id
listVisible.value = false
nextTick(() => {
listRenderKey.value += 1
listVisible.value = true
})
}
/**
* 删除收藏
*/
const onDelete = (item) => {
......@@ -153,10 +212,93 @@ const onDelete = (item) => {
content: '确定要删除该收藏吗?',
success: (res) => {
if (res.confirm) {
list.value = list.value.filter(i => i.id !== item.id)
Taro.showToast({ title: '已删除', icon: 'success' })
// 从 allList 中删除
const index = allList.value.findIndex(i => i.id === item.id)
if (index !== -1) {
allList.value.splice(index, 1)
// 重新初始化 tabsData
initTabsData()
Taro.showToast({ title: '已删除', icon: 'success' })
}
}
}
})
}
// 初始化数据
initTabsData()
</script>
<style lang="less">
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.favorite-item {
animation: slideIn 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) backwards;
}
// FilterTabs 风格的标签栏
.filter-tabs-wrapper {
display: flex;
overflow-x: auto;
padding: 24rpx 24rpx;
gap: 24rpx;
transition: all 0.3s ease;
background-color: #fff;
width: 100%;
// 隐藏滚动条
&::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
-ms-overflow-style: none;
scrollbar-width: none;
}
.filter-tab-item {
display: flex;
align-items: center;
justify-content: center;
padding: 0 32rpx;
border-radius: 9999rpx;
white-space: nowrap;
transition: all 0.3s ease;
flex-shrink: 0;
}
.filter-tab-active {
background-color: #2563EB; // 蓝色背景
color: #fff;
}
.filter-tab-inactive {
background-color: #F3F4F6; // 灰色背景
color: #6B7280;
}
.filter-tab-text {
font-size: 28rpx;
font-weight: 500;
}
// 覆盖 NutUI Tabs 默认样式,隐藏原有的头部和内容(因为我们使用自定义头部和外部列表)
:deep(.nut-tabs__titles) {
display: none;
}
:deep(.nut-tabs__content) {
display: none;
}
</style>
......
......@@ -245,6 +245,7 @@ initTabsData()
gap: 24rpx;
transition: all 0.3s ease;
background-color: #F9FAFB;
width: 100%;
// 隐藏滚动条
&::-webkit-scrollbar {
......
......@@ -465,6 +465,7 @@ const onDelete = (item) => {
gap: 24rpx;
transition: all 0.3s ease;
background-color: #F9FAFB;
width: 100%;
// 隐藏滚动条
&::-webkit-scrollbar {
......
<!--
* @Date: 2026-01-31
* @Description: 我的计划书 - 已改造为 NutTabs 版本
-->
<template>
<view class="h-screen bg-gray-50 flex flex-col">
<view class="bg-gray-50">
<view class="bg-gray-50 z-10">
<!-- Navigation Header -->
<NavHeader title="我的计划书" />
......@@ -14,16 +18,38 @@
/>
</view>
<!-- Tabs -->
<view class="bg-white mt-[2rpx] px-[24rpx] py-[20rpx]">
<FilterTabs v-model="activeTab" :tabs="tabs" label-key="title" />
<!-- Tabs Container -->
<view class="bg-white mt-[2rpx]">
<nut-tabs v-model="activeTabId">
<!-- 自定义标签栏 -->
<template #titles>
<view class="filter-tabs-wrapper">
<view
v-for="item in tabsData"
:key="item.id"
:class="[
'filter-tab-item',
activeTabId === item.id ? 'filter-tab-active' : 'filter-tab-inactive'
]"
@tap="onTabClick(item.id)"
>
<text class="filter-tab-text">{{ item.name }}</text>
</view>
</view>
</template>
</nut-tabs>
</view>
</view>
<!-- Plan List -->
<view class="flex-1 overflow-y-auto px-[24rpx] py-[24rpx] pb-[200rpx]">
<view
v-if="listVisible"
:key="listRenderKey"
class="flex-1 min-h-0 overflow-y-auto px-[24rpx] py-[24rpx] pb-[200rpx]"
>
<view v-for="(item, index) in filteredList" :key="index"
class="bg-white rounded-[24rpx] p-[24rpx] mb-[24rpx] shadow-sm">
class="bg-white rounded-[24rpx] p-[24rpx] mb-[24rpx] shadow-sm plan-item"
:style="{ animationDelay: `${index * 50}ms` }">
<!-- Header -->
<view class="flex justify-between items-start mb-[16rpx]">
<view class="flex-1">
......@@ -71,10 +97,9 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, nextTick } from 'vue'
import { useFileOperation } from '@/composables/useFileOperation'
import IconFont from '@/components/IconFont.vue'
import FilterTabs from '@/components/FilterTabs.vue'
import NavHeader from '@/components/NavHeader.vue'
import ListItemActions from '@/components/ListItemActions/index.vue'
import SearchBar from '@/components/SearchBar.vue'
......@@ -83,12 +108,19 @@ import Taro from '@tarojs/taro'
const { viewFile } = useFileOperation()
const searchValue = ref('')
const activeTab = ref(0)
const tabs = [
{ title: '全部', key: 'all' },
{ title: '生成中', key: 'processing' },
{ title: '已生成', key: 'generated' }
]
const activeTabId = ref('')
const listVisible = ref(true)
const listRenderKey = ref(0)
/**
* Tab 数据源
* @description 包含分类信息和对应的计划书列表
*/
const tabsData = ref([
{ id: '', name: '全部', list: [] },
{ id: 'processing', name: '生成中', list: [] },
{ id: 'generated', name: '已生成', list: [] },
])
/**
* Mock 数据:计划书列表
......@@ -96,7 +128,7 @@ const tabs = [
* @description 使用真实 PDF 文件进行测试
* downloadUrl 使用 Mozilla 的公开 PDF 测试文件
*/
const list = ref([
const allList = ref([
{
id: 1,
title: '家庭财富传承保障计划(分红)',
......@@ -224,16 +256,28 @@ const list = ref([
])
/**
* 初始化数据分布
* @description 根据分类规则将 allList 中的数据分配到各个 tab 中
*/
const initTabsData = () => {
tabsData.value.forEach((tab) => {
if (tab.id === '') {
tab.list = [...allList.value]
} else {
tab.list = allList.value.filter(item => item.status === tab.id)
}
})
}
/**
* 根据标签页和搜索关键词过滤列表
*/
const filteredList = computed(() => {
let result = list.value
// 找到当前选中的 tab
const currentTab = tabsData.value.find(tab => tab.id === activeTabId.value)
if (!currentTab) return []
// Filter by Tab
const currentKey = tabs[activeTab.value].key
if (currentKey !== 'all') {
result = result.filter(item => item.status === currentKey)
}
let result = currentTab.list
// Filter by Search
if (searchValue.value) {
......@@ -248,6 +292,18 @@ const filteredList = computed(() => {
})
/**
* Tab 点击处理
*/
const onTabClick = (id) => {
activeTabId.value = id
listVisible.value = false
nextTick(() => {
listRenderKey.value += 1
listVisible.value = true
})
}
/**
* 搜索处理
*/
const onSearch = (val) => {
......@@ -286,10 +342,93 @@ const onDelete = (item) => {
content: '确定要删除该计划书吗?',
success: (res) => {
if (res.confirm) {
list.value = list.value.filter(i => i.id !== item.id)
Taro.showToast({ title: '已删除', icon: 'success' })
// 从 allList 中删除
const index = allList.value.findIndex(i => i.id === item.id)
if (index !== -1) {
allList.value.splice(index, 1)
// 重新初始化 tabsData
initTabsData()
Taro.showToast({ title: '已删除', icon: 'success' })
}
}
}
})
}
// 初始化数据
initTabsData()
</script>
<style lang="less">
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.plan-item {
animation: slideIn 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) backwards;
}
// FilterTabs 风格的标签栏
.filter-tabs-wrapper {
display: flex;
overflow-x: auto;
padding: 24rpx 24rpx;
gap: 24rpx;
transition: all 0.3s ease;
background-color: #fff;
width: 100%;
// 隐藏滚动条
&::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
-ms-overflow-style: none;
scrollbar-width: none;
}
.filter-tab-item {
display: flex;
align-items: center;
justify-content: center;
padding: 0 32rpx;
border-radius: 9999rpx;
white-space: nowrap;
transition: all 0.3s ease;
flex-shrink: 0;
}
.filter-tab-active {
background-color: #2563EB; // 蓝色背景
color: #fff;
}
.filter-tab-inactive {
background-color: #F3F4F6; // 灰色背景
color: #6B7280;
}
.filter-tab-text {
font-size: 28rpx;
font-weight: 500;
}
// 覆盖 NutUI Tabs 默认样式,隐藏原有的头部和内容(因为我们使用自定义头部和外部列表)
:deep(.nut-tabs__titles) {
display: none;
}
:deep(.nut-tabs__content) {
display: none;
}
</style>
......