feat: 新增 FilterTabs 组件并统一筛选标签样式
将资料列表、知识库、收藏和计划书页面的横向筛选标签抽取为通用组件,避免重复的样式和滚动条隐藏逻辑。新增组件示例文件并更新全局组件类型声明。
Showing
9 changed files
with
182 additions
and
65 deletions
| ... | @@ -77,6 +77,11 @@ src/ | ... | @@ -77,6 +77,11 @@ src/ |
| 77 | ## 📄 页面说明 | 77 | ## 📄 页面说明 |
| 78 | 78 | ||
| 79 | - 计划书页面支持顶部搜索与标签固定,列表区域独立滚动,便于快速筛选和浏览 | 79 | - 计划书页面支持顶部搜索与标签固定,列表区域独立滚动,便于快速筛选和浏览 |
| 80 | +- 资料列表、知识库、收藏、计划书页面统一使用 FilterTabs 组件进行横向筛选 | ||
| 81 | + | ||
| 82 | +## ✅ 优化建议 | ||
| 83 | + | ||
| 84 | +- 新增筛选类页面优先复用 FilterTabs,避免重复样式与交互逻辑 | ||
| 80 | 85 | ||
| 81 | ## ⚙️ 配置说明 | 86 | ## ⚙️ 配置说明 |
| 82 | 87 | ... | ... |
| ... | @@ -8,6 +8,8 @@ export {} | ... | @@ -8,6 +8,8 @@ export {} |
| 8 | declare module 'vue' { | 8 | declare module 'vue' { |
| 9 | export interface GlobalComponents { | 9 | export interface GlobalComponents { |
| 10 | DocumentPreview: typeof import('./src/components/DocumentPreview/index.vue')['default'] | 10 | DocumentPreview: typeof import('./src/components/DocumentPreview/index.vue')['default'] |
| 11 | + FilterTabs: typeof import('./src/components/FilterTabs.vue')['default'] | ||
| 12 | + 'FilterTabs.example': typeof import('./src/components/FilterTabs.example.vue')['default'] | ||
| 11 | IconFont: typeof import('./src/components/IconFont.vue')['default'] | 13 | IconFont: typeof import('./src/components/IconFont.vue')['default'] |
| 12 | IndexNav: typeof import('./src/components/indexNav.vue')['default'] | 14 | IndexNav: typeof import('./src/components/indexNav.vue')['default'] |
| 13 | ListItemActions: typeof import('./src/components/ListItemActions/index.vue')['default'] | 15 | ListItemActions: typeof import('./src/components/ListItemActions/index.vue')['default'] | ... | ... |
| ... | @@ -6,6 +6,26 @@ | ... | @@ -6,6 +6,26 @@ |
| 6 | 6 | ||
| 7 | --- | 7 | --- |
| 8 | 8 | ||
| 9 | +## [2026-01-31] - 抽取筛选 Tabs 组件并统一使用 | ||
| 10 | + | ||
| 11 | +### 新增 | ||
| 12 | +- 新增 FilterTabs 组件与小程序示例文件 | ||
| 13 | + | ||
| 14 | +### 重构 | ||
| 15 | +- 资料列表、知识库、收藏、计划书页面统一使用 FilterTabs | ||
| 16 | +- 滚动条隐藏逻辑集中在 FilterTabs 内 | ||
| 17 | + | ||
| 18 | +--- | ||
| 19 | + | ||
| 20 | +**详细信息**: | ||
| 21 | +- **影响文件**: src/components/FilterTabs.vue, src/components/FilterTabs.example.vue, src/pages/material-list/index.vue, src/pages/knowledge-base/index.vue, src/pages/favorites/index.vue, src/pages/plan/index.vue, README.md | ||
| 22 | +- **技术栈**: Vue 3, Taro, TailwindCSS | ||
| 23 | +- **测试状态**: pnpm lint(存在既有警告) | ||
| 24 | +- **备注**: | ||
| 25 | + - 统一横向筛选标签样式与交互 | ||
| 26 | + | ||
| 27 | +--- | ||
| 28 | + | ||
| 9 | **详细信息**: | 29 | **详细信息**: |
| 10 | - **影响文件**: src/pages/plan/index.vue, README.md | 30 | - **影响文件**: src/pages/plan/index.vue, README.md |
| 11 | - **技术栈**: Vue 3, Taro, TailwindCSS | 31 | - **技术栈**: Vue 3, Taro, TailwindCSS | ... | ... |
src/components/FilterTabs.example.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <view class="p-[24rpx] bg-white"> | ||
| 3 | + <FilterTabs | ||
| 4 | + v-model="activeTab" | ||
| 5 | + :tabs="tabs" | ||
| 6 | + label-key="title" | ||
| 7 | + value-key="key" | ||
| 8 | + wrapper-class="mb-[24rpx]" | ||
| 9 | + @change="handleChange" | ||
| 10 | + > | ||
| 11 | + <template #label="{ item }"> | ||
| 12 | + <text>{{ item.title }}</text> | ||
| 13 | + </template> | ||
| 14 | + </FilterTabs> | ||
| 15 | + <view class="text-[24rpx] text-gray-500">当前选中:{{ activeTab }}</view> | ||
| 16 | + </view> | ||
| 17 | +</template> | ||
| 18 | + | ||
| 19 | +<script setup> | ||
| 20 | +import { ref } from 'vue' | ||
| 21 | +import FilterTabs from '@/components/FilterTabs.vue' | ||
| 22 | + | ||
| 23 | +const activeTab = ref('all') | ||
| 24 | +const tabs = [ | ||
| 25 | + { title: '全部', key: 'all' }, | ||
| 26 | + { title: '入职培训', key: 'onboarding' }, | ||
| 27 | + { title: '签单相关', key: 'signing' } | ||
| 28 | +] | ||
| 29 | + | ||
| 30 | +const handleChange = (value) => { | ||
| 31 | + console.log('选中项:', value) | ||
| 32 | +} | ||
| 33 | +</script> |
src/components/FilterTabs.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <view :class="['flex overflow-x-auto no-scrollbar space-x-[24rpx]', wrapperClass]"> | ||
| 3 | + <view v-for="(item, index) in tabs" :key="getItemKey(item, index)" | ||
| 4 | + :class="[ | ||
| 5 | + 'px-[32rpx] py-[16rpx] rounded-full text-[28rpx] whitespace-nowrap transition-colors', | ||
| 6 | + tabClass, | ||
| 7 | + isActive(item, index) ? activeClassComputed : inactiveClassComputed | ||
| 8 | + ]" | ||
| 9 | + @tap="handleSelect(item, index)" | ||
| 10 | + > | ||
| 11 | + <slot name="label" :item="item" :index="index"> | ||
| 12 | + {{ getItemLabel(item) }} | ||
| 13 | + </slot> | ||
| 14 | + </view> | ||
| 15 | + </view> | ||
| 16 | +</template> | ||
| 17 | + | ||
| 18 | +<script setup> | ||
| 19 | +import { computed } from 'vue' | ||
| 20 | + | ||
| 21 | +const props = defineProps({ | ||
| 22 | + tabs: { | ||
| 23 | + type: Array, | ||
| 24 | + default: () => [] | ||
| 25 | + }, | ||
| 26 | + modelValue: { | ||
| 27 | + type: [String, Number], | ||
| 28 | + default: 0 | ||
| 29 | + }, | ||
| 30 | + labelKey: { | ||
| 31 | + type: String, | ||
| 32 | + default: 'title' | ||
| 33 | + }, | ||
| 34 | + valueKey: { | ||
| 35 | + type: String, | ||
| 36 | + default: '' | ||
| 37 | + }, | ||
| 38 | + wrapperClass: { | ||
| 39 | + type: String, | ||
| 40 | + default: '' | ||
| 41 | + }, | ||
| 42 | + tabClass: { | ||
| 43 | + type: String, | ||
| 44 | + default: '' | ||
| 45 | + }, | ||
| 46 | + activeClass: { | ||
| 47 | + type: String, | ||
| 48 | + default: '' | ||
| 49 | + }, | ||
| 50 | + inactiveClass: { | ||
| 51 | + type: String, | ||
| 52 | + default: '' | ||
| 53 | + } | ||
| 54 | +}) | ||
| 55 | + | ||
| 56 | +const emit = defineEmits(['update:modelValue', 'change']) | ||
| 57 | + | ||
| 58 | +const activeClassComputed = computed(() => { | ||
| 59 | + return props.activeClass || 'bg-[#2563EB] text-white' | ||
| 60 | +}) | ||
| 61 | + | ||
| 62 | +const inactiveClassComputed = computed(() => { | ||
| 63 | + return props.inactiveClass || 'bg-[#F3F4F6] text-[#6B7280]' | ||
| 64 | +}) | ||
| 65 | + | ||
| 66 | +const getItemLabel = (item) => { | ||
| 67 | + if (typeof item === 'string') return item | ||
| 68 | + return item?.[props.labelKey] ?? '' | ||
| 69 | +} | ||
| 70 | + | ||
| 71 | +const getItemValue = (item, index) => { | ||
| 72 | + if (typeof item !== 'object' || item === null) return index | ||
| 73 | + if (props.valueKey && item[props.valueKey] !== undefined) return item[props.valueKey] | ||
| 74 | + return index | ||
| 75 | +} | ||
| 76 | + | ||
| 77 | +const getItemKey = (item, index) => { | ||
| 78 | + const value = getItemValue(item, index) | ||
| 79 | + return value ?? index | ||
| 80 | +} | ||
| 81 | + | ||
| 82 | +const isActive = (item, index) => { | ||
| 83 | + return getItemValue(item, index) === props.modelValue | ||
| 84 | +} | ||
| 85 | + | ||
| 86 | +const handleSelect = (item, index) => { | ||
| 87 | + const value = getItemValue(item, index) | ||
| 88 | + if (value === props.modelValue) return | ||
| 89 | + emit('update:modelValue', value) | ||
| 90 | + emit('change', value) | ||
| 91 | +} | ||
| 92 | +</script> | ||
| 93 | + | ||
| 94 | +<style lang="less" scoped> | ||
| 95 | +:deep(.no-scrollbar::-webkit-scrollbar) { | ||
| 96 | + display: none; | ||
| 97 | + width: 0; | ||
| 98 | + height: 0; | ||
| 99 | +} | ||
| 100 | + | ||
| 101 | +:deep(.no-scrollbar) { | ||
| 102 | + -ms-overflow-style: none; | ||
| 103 | + scrollbar-width: none; | ||
| 104 | +} | ||
| 105 | +</style> |
| ... | @@ -5,14 +5,12 @@ | ... | @@ -5,14 +5,12 @@ |
| 5 | 5 | ||
| 6 | <!-- Tabs Section --> | 6 | <!-- Tabs Section --> |
| 7 | <view class="bg-white mt-[2rpx] px-[24rpx] py-[20rpx]"> | 7 | <view class="bg-white mt-[2rpx] px-[24rpx] py-[20rpx]"> |
| 8 | - <div class="flex overflow-x-auto no-scrollbar space-x-[24rpx]"> | 8 | + <FilterTabs |
| 9 | - <div v-for="(tab, index) in tabs" :key="index" | 9 | + v-model="activeTab" |
| 10 | - class="px-[32rpx] py-[16rpx] rounded-full text-[28rpx] whitespace-nowrap transition-colors" | 10 | + :tabs="tabs" |
| 11 | - :class="activeTab === tab.key ? 'bg-[#2563EB] text-white' : 'bg-[#F3F4F6] text-[#6B7280]'" | 11 | + label-key="title" |
| 12 | - @click="activeTab = tab.key"> | 12 | + value-key="key" |
| 13 | - {{ tab.title }} | 13 | + /> |
| 14 | - </div> | ||
| 15 | - </div> | ||
| 16 | </view> | 14 | </view> |
| 17 | 15 | ||
| 18 | <!-- List Section --> | 16 | <!-- List Section --> |
| ... | @@ -71,7 +69,7 @@ import { useGo } from '@/hooks/useGo' | ... | @@ -71,7 +69,7 @@ import { useGo } from '@/hooks/useGo' |
| 71 | import { useFileOperation } from '@/composables/useFileOperation' | 69 | import { useFileOperation } from '@/composables/useFileOperation' |
| 72 | import { getDocumentIcon } from '@/utils/documentIcons' | 70 | import { getDocumentIcon } from '@/utils/documentIcons' |
| 73 | import IconFont from '@/components/IconFont.vue' | 71 | import IconFont from '@/components/IconFont.vue' |
| 74 | -import TabBar from '@/components/TabBar.vue' | 72 | +import FilterTabs from '@/components/FilterTabs.vue' |
| 75 | import NavHeader from '@/components/NavHeader.vue' | 73 | import NavHeader from '@/components/NavHeader.vue' |
| 76 | import ListItemActions from '@/components/ListItemActions/index.vue' | 74 | import ListItemActions from '@/components/ListItemActions/index.vue' |
| 77 | 75 | ||
| ... | @@ -147,14 +145,3 @@ const onDelete = (item) => { | ... | @@ -147,14 +145,3 @@ const onDelete = (item) => { |
| 147 | }) | 145 | }) |
| 148 | } | 146 | } |
| 149 | </script> | 147 | </script> |
| 150 | - | ||
| 151 | -<style lang="less"> | ||
| 152 | -.no-scrollbar::-webkit-scrollbar { | ||
| 153 | - display: none; | ||
| 154 | -} | ||
| 155 | - | ||
| 156 | -.no-scrollbar { | ||
| 157 | - -ms-overflow-style: none; | ||
| 158 | - scrollbar-width: none; | ||
| 159 | -} | ||
| 160 | -</style> | ... | ... |
| ... | @@ -7,14 +7,7 @@ | ... | @@ -7,14 +7,7 @@ |
| 7 | <div class="px-[40rpx] mt-[40rpx]"> | 7 | <div class="px-[40rpx] mt-[40rpx]"> |
| 8 | 8 | ||
| 9 | <!-- Filter Tabs --> | 9 | <!-- Filter Tabs --> |
| 10 | - <div class="flex overflow-x-auto no-scrollbar mb-[40rpx] space-x-[24rpx]"> | 10 | + <FilterTabs v-model="activeTab" :tabs="tabs" wrapper-class="mb-[40rpx]" /> |
| 11 | - <div v-for="(tab, index) in tabs" :key="index" | ||
| 12 | - class="px-[32rpx] py-[16rpx] rounded-full text-[28rpx] whitespace-nowrap transition-colors" | ||
| 13 | - :class="activeTab === index ? 'bg-[#2563EB] text-white' : 'bg-[#F3F4F6] text-[#6B7280]'" | ||
| 14 | - @click="activeTab = index"> | ||
| 15 | - {{ tab }} | ||
| 16 | - </div> | ||
| 17 | - </div> | ||
| 18 | 11 | ||
| 19 | <!-- Section Title --> | 12 | <!-- Section Title --> |
| 20 | <div class="text-[#1F2937] text-[32rpx] font-bold mb-[24rpx]"> | 13 | <div class="text-[#1F2937] text-[32rpx] font-bold mb-[24rpx]"> |
| ... | @@ -62,7 +55,7 @@ | ... | @@ -62,7 +55,7 @@ |
| 62 | <script setup> | 55 | <script setup> |
| 63 | import { ref, computed } from 'vue' | 56 | import { ref, computed } from 'vue' |
| 64 | import NavHeader from '@/components/NavHeader.vue' | 57 | import NavHeader from '@/components/NavHeader.vue' |
| 65 | -import TabBar from '@/components/TabBar.vue' | 58 | +import FilterTabs from '@/components/FilterTabs.vue' |
| 66 | import { useListItemClick, ListType } from '@/composables/useListItemClick' | 59 | import { useListItemClick, ListType } from '@/composables/useListItemClick' |
| 67 | 60 | ||
| 68 | const activeTab = ref(0) | 61 | const activeTab = ref(0) |
| ... | @@ -140,14 +133,3 @@ const { handleClick: handleProductClick } = useListItemClick({ | ... | @@ -140,14 +133,3 @@ const { handleClick: handleProductClick } = useListItemClick({ |
| 140 | } | 133 | } |
| 141 | }) | 134 | }) |
| 142 | </script> | 135 | </script> |
| 143 | - | ||
| 144 | -<style> | ||
| 145 | -.no-scrollbar::-webkit-scrollbar { | ||
| 146 | - display: none; | ||
| 147 | -} | ||
| 148 | - | ||
| 149 | -.no-scrollbar { | ||
| 150 | - -ms-overflow-style: none; | ||
| 151 | - scrollbar-width: none; | ||
| 152 | -} | ||
| 153 | -</style> | ... | ... |
| ... | @@ -20,14 +20,12 @@ | ... | @@ -20,14 +20,12 @@ |
| 20 | <!-- Category Tabs --> | 20 | <!-- Category Tabs --> |
| 21 | <!-- 根据是否有分类数据决定是否显示 tab --> | 21 | <!-- 根据是否有分类数据决定是否显示 tab --> |
| 22 | <div v-if="categories && categories.length > 0" class="px-[32rpx] mt-[32rpx]"> | 22 | <div v-if="categories && categories.length > 0" class="px-[32rpx] mt-[32rpx]"> |
| 23 | - <div class="flex overflow-x-auto no-scrollbar mb-[40rpx] space-x-[24rpx]"> | 23 | + <FilterTabs |
| 24 | - <div v-for="(category, index) in categories" :key="index" | 24 | + v-model="activeCategoryIndex" |
| 25 | - class="px-[32rpx] py-[16rpx] rounded-full text-[28rpx] whitespace-nowrap transition-colors" | 25 | + :tabs="categories" |
| 26 | - :class="activeCategoryIndex === index ? 'bg-[#2563EB] text-white' : 'bg-[#F3F4F6] text-[#6B7280]'" | 26 | + label-key="name" |
| 27 | - @tap="activeCategoryIndex = index"> | 27 | + wrapper-class="mb-[40rpx]" |
| 28 | - {{ category.name }} | 28 | + /> |
| 29 | - </div> | ||
| 30 | - </div> | ||
| 31 | </div> | 29 | </div> |
| 32 | 30 | ||
| 33 | <!-- Material List --> | 31 | <!-- Material List --> |
| ... | @@ -370,14 +368,6 @@ const onDelete = (item) => { | ... | @@ -370,14 +368,6 @@ const onDelete = (item) => { |
| 370 | </script> | 368 | </script> |
| 371 | 369 | ||
| 372 | <style lang="less" scoped> | 370 | <style lang="less" scoped> |
| 373 | -.no-scrollbar::-webkit-scrollbar { | ||
| 374 | - display: none; | ||
| 375 | -} | ||
| 376 | - | ||
| 377 | -.no-scrollbar { | ||
| 378 | - -ms-overflow-style: none; | ||
| 379 | - scrollbar-width: none; | ||
| 380 | -} | ||
| 381 | 371 | ||
| 382 | @keyframes slideIn { | 372 | @keyframes slideIn { |
| 383 | from { | 373 | from { | ... | ... |
| ... | @@ -15,14 +15,7 @@ | ... | @@ -15,14 +15,7 @@ |
| 15 | 15 | ||
| 16 | <!-- Tabs --> | 16 | <!-- Tabs --> |
| 17 | <view class="bg-white mt-[2rpx] px-[24rpx] py-[20rpx]"> | 17 | <view class="bg-white mt-[2rpx] px-[24rpx] py-[20rpx]"> |
| 18 | - <div class="flex overflow-x-auto no-scrollbar space-x-[24rpx]"> | 18 | + <FilterTabs v-model="activeTab" :tabs="tabs" label-key="title" /> |
| 19 | - <div v-for="(tab, index) in tabs" :key="index" | ||
| 20 | - class="px-[32rpx] py-[16rpx] rounded-full text-[28rpx] whitespace-nowrap transition-colors" | ||
| 21 | - :class="activeTab === index ? 'bg-[#2563EB] text-white' : 'bg-[#F3F4F6] text-[#6B7280]'" | ||
| 22 | - @click="activeTab = index"> | ||
| 23 | - {{ tab.title }} | ||
| 24 | - </div> | ||
| 25 | - </div> | ||
| 26 | </view> | 19 | </view> |
| 27 | </view> | 20 | </view> |
| 28 | 21 | ||
| ... | @@ -81,7 +74,7 @@ import { ref, computed } from 'vue' | ... | @@ -81,7 +74,7 @@ import { ref, computed } from 'vue' |
| 81 | import { useGo } from '@/hooks/useGo' | 74 | import { useGo } from '@/hooks/useGo' |
| 82 | import { useFileOperation } from '@/composables/useFileOperation' | 75 | import { useFileOperation } from '@/composables/useFileOperation' |
| 83 | import IconFont from '@/components/IconFont.vue' | 76 | import IconFont from '@/components/IconFont.vue' |
| 84 | -import TabBar from '@/components/TabBar.vue' | 77 | +import FilterTabs from '@/components/FilterTabs.vue' |
| 85 | import NavHeader from '@/components/NavHeader.vue' | 78 | import NavHeader from '@/components/NavHeader.vue' |
| 86 | import ListItemActions from '@/components/ListItemActions/index.vue' | 79 | import ListItemActions from '@/components/ListItemActions/index.vue' |
| 87 | import Taro from '@tarojs/taro' | 80 | import Taro from '@tarojs/taro' | ... | ... |
-
Please register or login to post a comment