hookehuyr

feat(material-list): 重构资料列表页使用 NutTabs 替换 FilterTabs

- 移除 FilterTabs 组件依赖,改用 nut-tabs 实现自定义标签栏
- 新增测试页面 pages/test-tabs 用于验证自定义 Tabs 样式
- 调整全局样式,为 page 选择器添加 CSS 变量定义
- 更新组件类型声明,移除 NutSearchbar,添加 NutConfigProvider
- 在开发环境中注册测试页面路由
......@@ -16,19 +16,19 @@ declare module 'vue' {
NavHeader: typeof import('./src/components/NavHeader.vue')['default']
NutAvatar: typeof import('@nutui/nutui-taro')['Avatar']
NutButton: typeof import('@nutui/nutui-taro')['Button']
NutConfigProvider: typeof import('@nutui/nutui-taro')['ConfigProvider']
NutInput: typeof import('@nutui/nutui-taro')['Input']
NutPicker: typeof import('@nutui/nutui-taro')['Picker']
NutPopup: typeof import('@nutui/nutui-taro')['Popup']
NutRadio: typeof import('@nutui/nutui-taro')['Radio']
NutRadioGroup: typeof import('@nutui/nutui-taro')['RadioGroup']
NutSearchbar: typeof import('@nutui/nutui-taro')['Searchbar']
NutTabPane: typeof import('@nutui/nutui-taro')['TabPane']
NutTabs: typeof import('@nutui/nutui-taro')['Tabs']
NutUploader: typeof import('@nutui/nutui-taro')['Uploader']
OfficeViewer: typeof import('./src/components/OfficeViewer.vue')['default']
PdfPreview: typeof import('./src/components/PdfPreview.vue')['default']
Picker: typeof import('./src/components/time-picker-data/picker.vue')['default']
PlanPopup: typeof import('./src/components/PlanPopup/index.vue')['default']
PlanPopup: typeof import('./src/components/PlanSchemes/PlanPopup.vue')['default']
PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default']
QrCode: typeof import('./src/components/qrCode.vue')['default']
QrCodeSearch: typeof import('./src/components/qrCodeSearch.vue')['default']
......
## [2026-01-31] - 资料列表页重构与 FilterTabs 移除
### 重构
- 改造资料列表页 (`src/pages/material-list/index.vue`) 参考 `test-tabs` 实现
- 移除 `FilterTabs` 组件依赖,改用 `nut-tabs` 自定义头部实现
- 重构数据结构,从扁平列表调整为基于 Tab 的数据分布 (`tabsData`)
- 实现 `initTabsData` 函数进行数据初始化分配
- 添加 `displayTabsData` 计算属性支持跨 Tab 搜索过滤
- 增加深度样式覆盖 (`:deep`) 适配 NutUI Tabs 样式
- 添加空状态展示逻辑
---
**详细信息**
- **影响文件**: src/pages/material-list/index.vue
- **技术栈**: Vue 3, NutUI, Composition API
- **测试状态**: 已通过代码审查
- **备注**:
- 保持了原有的过滤逻辑(取余分配)
- 提升了代码的自包含性,减少了对外部组件的依赖
---
## [2026-01-31] - 优化知识库页面滚动结构
### 优化
......
......@@ -29,6 +29,7 @@ const pages = [
]
if (process.env.NODE_ENV === 'development') {
pages.push('pages/test-tabs/index')
// pages.push('pages/nfcTest/index')
// pages.push('pages/tailwindTest/index')
}
......
/*
* @Date: 2025-06-28 10:33:00
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-29 18:29:57
* @LastEditTime: 2026-01-31 19:52:31
* @FilePath: /manulife-weapp/src/app.js
* @Description: 应用入口文件
*/
......
......@@ -10,6 +10,8 @@
@tailwind components;
@tailwind utilities;
:root {
:root,
page {
--nut-primary-color: #007AFF;
--nut-tabs-horizontal-titles-height: 120rpx;
}
......
<!--
* @Date: 2026-01-31
* @Description: 资料列表页
* @Description: 资料列表页 - 已改造为 NutTabs 版本
-->
<template>
<view class="h-screen bg-[#F9FAFB] flex flex-col">
<view class="bg-[#F9FAFB]">
<view class="bg-[#F9FAFB] z-10">
<NavHeader :title="pageTitle" />
<view class="px-[32rpx] mt-[32rpx]">
......@@ -14,20 +14,33 @@
@search="onSearch"
/>
</view>
</view>
<view v-if="categories && categories.length > 0" class="px-[32rpx] mt-[32rpx]">
<FilterTabs
v-model="activeCategoryIndex"
:tabs="categories"
label-key="name"
wrapper-class="mb-[40rpx]"
/>
<!-- Tabs Container -->
<view class="flex-1 min-h-0 flex flex-col">
<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 class="flex-1 overflow-y-auto px-[32rpx] pb-[calc(160rpx+env(safe-area-inset-bottom))]">
<!-- 列表容器(独立于 nut-tab-pane) -->
<view class="flex-1 min-h-0 overflow-y-auto px-[32rpx] pb-[calc(160rpx+env(safe-area-inset-bottom))] box-border">
<view class="flex flex-col gap-[24rpx]">
<view v-for="(item, index) in list" :key="index"
<view v-for="(item, index) in currentList" :key="index"
class="material-item bg-white rounded-[24rpx] p-[24rpx] shadow-sm transition-all duration-200 border border-gray-50 flex flex-row"
:style="{ animationDelay: `${index * 50}ms` }">
......@@ -70,11 +83,14 @@
/>
</view>
</view>
<!-- 空状态 -->
<view v-if="currentList.length === 0" class="flex flex-col items-center justify-center py-[100rpx]">
<text class="text-gray-400 text-[28rpx]">暂无相关资料</text>
</view>
</view>
</view>
</view>
<!-- Tab Bar -->
<!-- <TabBar /> -->
</view>
</template>
......@@ -82,7 +98,6 @@
import { ref, computed } from 'vue'
import { useLoad } from '@tarojs/taro'
import NavHeader from '@/components/NavHeader.vue'
import TabBar from '@/components/TabBar.vue'
import SearchBar from '@/components/SearchBar.vue'
import ListItemActions from '@/components/ListItemActions/index.vue'
import { useListItemClick, ListType } from '@/composables/useListItemClick'
......@@ -90,34 +105,15 @@ import { getDocumentIcon, getDocumentLabel } from '@/utils/documentIcons'
import Taro from '@tarojs/taro'
const searchValue = ref('')
const categoryId = ref('')
const activeCategoryIndex = ref(0)
const activeTabId = ref('')
/**
* 页面标题
*
* @description 动态标题,根据传入的 title 参数显示,默认为"资料列表"
*/
const pageTitle = ref('资料列表')
/**
* 资料分类数据
*
* @description Mock 分类数据,用于展示 tab 筛选功能
* TODO: 后续从 API 接口获取,如果接口返回空数组则不显示 tab
*/
const categories = ref([
{ id: '', name: '全部资料' },
{ id: 'exam', name: '考试资料' },
{ id: 'product', name: '产品手册' },
{ id: 'training', name: '培训材料' },
{ id: 'case', name: '案例分享' },
])
/**
* 资料列表数据
*
* @description 包含文件信息、图标、收藏状态等完整资料信息
* 资料数据源
*/
const allList = ref([
{
......@@ -283,101 +279,113 @@ const allList = ref([
])
/**
* 根据选中的分类筛选资料列表
* 资料分类及列表数据
*
* @description 根据 activeCategoryIndex 筛选显示对应分类的资料
* @description 包含分类信息和对应的资料列表
*/
const list = computed(() => {
const activeCategory = categories.value[activeCategoryIndex.value]
let result = allList.value
const tabsData = ref([
{ id: '', name: '全部资料', list: [] },
{ id: 'exam', name: '考试资料', list: [] },
{ id: 'product', name: '产品手册', list: [] },
{ id: 'training', name: '培训材料', list: [] },
{ id: 'case', name: '案例分享', list: [] },
])
if (activeCategory && activeCategory.id) {
const index = activeCategoryIndex.value
result = result.filter((_, i) => (i + index) % (index + 2) === 0)
/**
* 初始化数据分布
* @description 根据分类规则将 allList 中的数据分配到各个 tab 中
*/
const initTabsData = () => {
tabsData.value.forEach((tab, index) => {
if (tab.id === '') {
tab.list = [...allList.value]
} else {
// 模拟分类逻辑:根据索引取余分配
// 保持与原逻辑一致:result = result.filter((_, i) => (i + index) % (index + 2) === 0)
tab.list = allList.value.filter((_, i) => (i + index) % (index + 2) === 0)
}
})
}
if (searchValue.value) {
const keyword = searchValue.value.toLowerCase()
result = result.filter(item => item.title.toLowerCase().includes(keyword) || item.desc.toLowerCase().includes(keyword))
}
/**
* 当前选中 Tab 的列表数据
* @description 根据 activeTabId 获取对应 tab 的列表数据
*/
const currentList = computed(() => {
// 找到当前选中的 tab
const currentTab = tabsData.value.find(tab => tab.id === activeTabId.value)
if (!currentTab) return []
return result
// 如果有搜索关键词,进行过滤
if (!searchValue.value) return currentTab.list
const keyword = searchValue.value.toLowerCase()
return currentTab.list.filter(item =>
item.title.toLowerCase().includes(keyword) ||
item.desc.toLowerCase().includes(keyword)
)
})
/**
* 页面加载时接收参数
*
* @description 使用 Taro 的 useLoad hook 接收路由参数
*/
useLoad((options) => {
console.log('[Material List] 页面参数:', options)
// 接收 title 参数并更新页面标题
if (options.title) {
pageTitle.value = options.title
console.log('[Material List] 页面标题:', pageTitle.value)
}
// 接收 categoryId 参数
if (options.categoryId) {
categoryId.value = options.categoryId
console.log('[Material List] 分类 ID:', categoryId.value)
activeTabId.value = options.categoryId
console.log('[Material List] 初始分类:', activeTabId.value)
// 根据 categoryId 加载对应的资料列表
loadMaterialsByCategory(categoryId.value)
} else {
console.log('[Material List] 无分类 ID,显示所有资料')
// 如果有特定的加载逻辑,可以在这里调用
// loadMaterialsByCategory(activeTabId.value)
}
// 初始化数据
initTabsData()
})
/**
* 根据分类 ID 加载资料列表
*
* @description 根据 categoryId 从 API 获取对应的资料列表
* @param {string} categoryId - 分类 ID
* Tab 点击处理
*/
const onTabClick = (id) => {
activeTabId.value = id
// 可以在这里触发加载逻辑
// loadMaterialsByCategory(id)
}
/**
* 根据分类 ID 加载资料列表 (模拟)
*/
const loadMaterialsByCategory = async (id) => {
try {
Taro.showLoading({ title: '加载中...', mask: true })
// TODO: 调用真实的 API 接口
// const res = await getMaterialsByCategoryAPI({ categoryId: id })
// if (res.code === 1) {
// list.value = res.data
// }
// 模拟 API 调用延迟
// 模拟 API 调用
await new Promise(resolve => setTimeout(resolve, 500))
console.log(`[Material List] 已加载分类 "${id}" 的资料列表`)
console.log(`[Material List] 已刷新分类 "${id}" 的资料列表`)
Taro.hideLoading()
} catch (error) {
console.error('[Material List] 加载资料列表失败:', error)
Taro.showToast({
title: '加载失败,请重试',
icon: 'none'
})
} finally {
Taro.hideLoading()
}
}
/**
* 搜索处理函数
*
* @description 处理用户搜索操作
*/
const onSearch = () => {
console.log('Searching for:', searchValue.value)
console.log('当前分类:', categoryId.value)
// TODO: 根据 categoryId 和 searchValue 进行搜索
console.log('当前分类:', activeTabId.value)
}
/**
* 使用文件列表点击处理器
*
* @description 配置为文件类型列表,点击时打开文件预览
*/
const { handleClick: onView } = useListItemClick({
listType: ListType.FILE,
......@@ -388,9 +396,6 @@ const { handleClick: onView } = useListItemClick({
/**
* 切换收藏状态
*
* @description 切换资料的收藏状态
* @param {Object} item - 资料项
*/
const toggleCollect = (item) => {
item.collected = !item.collected
......@@ -410,9 +415,12 @@ const onDelete = (item) => {
content: '确定要删除该资料吗?',
success: (res) => {
if (res.confirm) {
// 从 allList 中删除
const index = allList.value.findIndex(i => i.title === item.title)
if (index !== -1) {
allList.value.splice(index, 1)
// 重新初始化 tabsData
initTabsData()
Taro.showToast({ title: '已删除', icon: 'success' })
}
}
......@@ -421,8 +429,7 @@ const onDelete = (item) => {
}
</script>
<style lang="less" scoped>
<style lang="less">
@keyframes slideIn {
from {
opacity: 0;
......@@ -438,4 +445,59 @@ const onDelete = (item) => {
.material-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 32rpx;
gap: 24rpx;
transition: all 0.3s ease;
background-color: #F9FAFB;
// 隐藏滚动条
&::-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>
......
/**
* Tabs 测试页面配置
*/
export default {
navigationBarTitleText: 'Tabs 测试',
enablePullDownRefresh: false,
backgroundColor: '#f5f5f5'
}
<!--
NutUI Tabs 自定义标签栏测试页面
@description 测试 NutUI Tabs 组件的自定义标签栏功能
@page pages/test-tabs
@created 2026-01-31
-->
<template>
<view class="test-tabs-page">
<view class="tabs-container">
<nut-tabs v-model="activeTab">
<!-- 自定义标签栏 - 参考 FilterTabs 样式 -->
<template #titles>
<view class="filter-tabs-wrapper">
<view
v-for="item in tabList"
:key="item.paneKey"
:class="[
'filter-tab-item',
activeTab === item.paneKey ? 'filter-tab-active' : 'filter-tab-inactive'
]"
@tap="handleTabClick(item.paneKey)"
>
<text class="filter-tab-text">{{ item.title }}</text>
</view>
</view>
</template>
<!-- Tab 内容 -->
<nut-tab-pane v-for="item in tabList" :key="item.paneKey" :pane-key="item.paneKey">
<view class="tab-content">
<text class="content-text">{{ item.content }}</text>
<view class="content-list">
<view v-for="i in 3" :key="i" class="list-item">
<text>{{ item.title }} - 列表项 {{ i }}</text>
</view>
</view>
</view>
</nut-tab-pane>
</nut-tabs>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
// 当前激活的 Tab
const activeTab = ref('c1')
// Tab 列表数据
const tabList = ref([
{
title: 'Tab 1',
paneKey: 'c1',
content: '这是 Tab 1 的内容区域'
},
{
title: 'Tab 2',
paneKey: 'c2',
content: '这是 Tab 2 的内容区域'
},
{
title: 'Tab 3',
paneKey: 'c3',
content: '这是 Tab 3 的内容区域'
},
{
title: 'Tab 4',
paneKey: 'c4',
content: '这是 Tab 4 的内容区域'
},
{
title: 'Tab 5',
paneKey: 'c5',
content: '这是 Tab 5 的内容区域'
},
{
title: 'Tab 6',
paneKey: 'c6',
content: '这是 Tab 6 的内容区域'
}
])
/**
* 处理 Tab 点击事件
* @param {string} paneKey - Tab 的 paneKey
*/
const handleTabClick = (paneKey) => {
activeTab.value = paneKey
}
</script>
<style lang="less">
.test-tabs-page {
min-height: 100vh;
background-color: #f5f5f5;
}
.page-header {
padding: 40px 30px;
background-color: #fff;
border-bottom: 1px solid #eee;
}
.page-title {
font-size: 32px;
font-weight: bold;
color: #333;
}
.tabs-container {
background-color: #fff;
margin-top: 20px;
}
// FilterTabs 风格的标签栏
.filter-tabs-wrapper {
display: flex;
overflow-x: auto;
padding: 24px 30px;
gap: 24rpx;
transition: all 0.3s ease;
// 隐藏滚动条
&::-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: 16rpx 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;
}
// Tab 内容区域
.tab-content {
padding: 30px;
min-height: 400px;
}
.content-text {
font-size: 28px;
color: #333;
margin-bottom: 30px;
display: block;
}
.content-list {
margin-top: 30px;
}
.list-item {
padding: 24px;
background-color: #f9f9f9;
border-radius: 12px;
margin-bottom: 20px;
font-size: 26px;
color: #666;
}
</style>