hookehuyr

chore: 清理备份文件目录中的原始页面文件

移除 docs/backups/original-pages/ 目录下的四个备份文件:
- message-index.vue.bak
- product-center-index.vue.bak
- search-index.vue.bak
- material-list-index.vue.bak

这些文件是开发过程中的临时备份,现已不再需要。
<!--
* @Date: 2026-01-31
* @Description: 资料列表页 - 已改造为 NutTabs 版本
-->
<template>
<view class="bg-[#F9FAFB]">
<!-- 固定在顶部的导航和搜索 -->
<view class="bg-[#F9FAFB] sticky top-0 z-10">
<NavHeader :title="pageTitle" />
<view class="px-[32rpx] mt-[32rpx] mb-[24rpx]">
<SearchBar
v-model="searchValue"
placeholder="搜索资料..."
@search="onSearch"
@clear="onClear"
variant="rounded"
:show-border="true"
:show-clear="true"
/>
</view>
<!-- 动态显示 Tabs(仅在有分类时显示) -->
<nut-tabs v-if="hasCategories" 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
v-if="listVisible"
:key="listRenderKey"
class="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 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` }">
<view
class="w-[88rpx] h-[88rpx] mr-[24rpx] flex-shrink-0 flex items-center justify-center bg-gradient-to-br from-blue-50 to-blue-100 rounded-[20rpx] shadow-inner self-start">
<image
:src="getDocumentIcon(item.extension ? `file.${item.extension}` : item.fileName)"
class="w-[48rpx] h-[48rpx]"
mode="aspectFit"
/>
</view>
<view class="flex-1 min-w-0">
<h3 class="text-[#1F2937] text-[30rpx] font-bold leading-[1.4] line-clamp-2 mb-[8rpx]">
{{ item.title }}
</h3>
<p class="text-[#6B7280] text-[24rpx] leading-[1.4] line-clamp-1 mb-[16rpx]">
{{ item.desc }}
</p>
<view class="flex items-center gap-[12rpx] mb-[20rpx]">
<view
class="inline-flex items-center justify-center px-[12rpx] py-[4rpx] bg-gray-100 text-gray-500 text-[20rpx] font-medium rounded-[8rpx]">
{{ getDocumentLabel(item.extension ? `file.${item.extension}` : item.fileName) }}
</view>
<view class="text-[#9CA3AF] text-[22rpx]">
{{ item.size }}
</view>
</view>
<view class="h-[1rpx] bg-gray-100 my-[20rpx]"></view>
<ListItemActions
:viewable="true"
:collectable="true"
:collected="item.collected"
:item-id="String(item.meta_id || item.id)"
@view="onView(item)"
@collect="toggleCollect(item)"
@delete="onDelete(item)"
/>
</view>
</view>
<!-- 空状态 -->
<view v-if="currentList.length === 0 && !loading">
<nut-empty description="暂无相关资料" image="empty" />
</view>
<!-- 加载更多状态 -->
<view v-if="currentList.length > 0" class="load-more-container">
<view v-if="loadingMore" class="load-more-loading">
<view class="loading-spinner"></view>
<text class="ml-[16rpx] text-[#9CA3AF] text-[24rpx]">加载中...</text>
</view>
<view v-else-if="!hasMore" class="load-more-finished">
<text class="text-[#9CA3AF] text-[24rpx]">没有更多了</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, nextTick, watch } from 'vue'
import { useLoad, useReachBottom } from '@tarojs/taro'
import NavHeader from '@/components/NavHeader.vue'
import SearchBar from '@/components/SearchBar.vue'
import ListItemActions from '@/components/ListItemActions/index.vue'
import { useListItemClick, ListType } from '@/composables/useListItemClick'
import { getDocumentIcon, getDocumentLabel } from '@/utils/documentIcons'
import { debounce } from '@/utils/debounce'
import { fileListAPI } from '@/api/file'
import { mockFileListAPI } from '@/utils/mockData'
import { useCollectOperation } from '@/composables/useCollectOperation'
import Taro from '@tarojs/taro'
// ⚠️ MOCK 数据开关 - 开发环境使用 mock 数据,生产环境使用真实 API
const USE_MOCK_DATA = process.env.NODE_ENV === 'development'
const searchValue = ref('')
const activeTabId = ref('all') // 默认选中"全部"
const listVisible = ref(true)
const listRenderKey = ref(0)
/**
* 防抖搜索函数
* @description 使用防抖优化搜索性能,避免频繁请求接口
*/
const debouncedSearch = debounce(async () => {
console.log('[Material List] 防抖搜索触发')
await onSearch()
}, 500)
/**
* 加载状态
*/
const loading = ref(false)
/**
* 加载更多状态
* @description 区分首次加载和加载更多
*/
const loadingMore = ref(false)
/**
* 每页数量
*/
const pageSize = 10
/**
* 当前页码(从0开始)
*/
const currentPage = ref(0)
/**
* 是否有更多数据
*/
const hasMore = ref(true)
/**
* 各分类的分页状态缓存
* @description Map<categoryId, { currentPage, hasMore }>
*/
const categoryPageCache = ref(new Map())
/**
* API 返回的原始数据
*/
const data = ref(null)
/**
* 初始分类ID(从页面参数获取)
*/
const initialCategoryId = ref(null)
/**
* 是否有分类标签
* @description 根据后端返回的 children 判断
*/
const hasCategories = computed(() => {
return data.value?.children?.length > 0
})
/**
* 页面标题
*/
const pageTitle = ref('资料列表')
/**
* 资料数据源(从 API 获取)
*/
const allList = ref([])
/**
* 各个分类的缓存列表数据
* @description key: 分类ID,value: 该分类的列表数据
*/
const categoryListCache = ref(new Map()) // 使用 Map 缓存各分类的列表数据
/**
* 当前显示的列表数据
* @description 根据当前选中的 tab 和搜索关键词动态计算
*/
const currentList = ref([])
/**
* 资料分类数据
* @description 根据 API 返回的 children 构建 tabs,始终包含"全部"选项
*/
const tabsData = computed(() => {
// 始终包含"全部" tab
const tabs = [
{ id: 'all', name: '全部' }
]
// 如果有子分类,添加到 tabs
const children = data.value?.children || []
if (children.length > 0) {
children.forEach(child => {
tabs.push({
id: String(child.id),
name: child.category_name
})
})
}
return tabs
})
/**
* 转换文档数据格式
* @description 将 API 返回的文档数据转换为组件需要的格式
* @param {Object} doc - API 返回的文档对象
* @returns {Object} 转换后的文档对象
*/
const transformDocItem = (doc) => {
// 处理文件名为空的情况
const fileName = doc.name || '未命名文件'
// 如果没有扩展名,从文件名中提取(如果有)
const extension = doc.extension || fileName.split('.').pop()?.toLowerCase() || ''
return {
id: doc.id || doc.meta_id, // 兼容 id 和 meta_id
meta_id: doc.meta_id || doc.id, // 保存 meta_id 用于收藏 API
title: fileName,
desc: doc.post_date || '',
size: doc.size || '',
fileName: fileName,
downloadUrl: doc.value,
extension: extension,
collected: doc.is_favorite === '1' || doc.is_favorite === 1 // 从 API 返回的收藏状态
}
}
/**
* 获取文档分类列表
* @param {Object} params - 请求参数
* @param {string} params.cid - 分类ID(可选)
* @param {string} params.child_id - 子分类ID(可选)
* @param {string} params.keyword - 搜索关键词(可选)
* @param {number} params.page - 页码(从0开始)
* @param {number} params.limit - 每页数量
* @param {boolean} isLoadMore - 是否为加载更多
*/
const fetchMaterialList = async (params = {}, isLoadMore = false) => {
try {
// 如果是加载更多,使用 loadingMore 状态,否则使用 loading 状态
if (isLoadMore) {
loadingMore.value = true
} else {
loading.value = true
}
console.log('[Material List] 请求参数:', params)
console.log('[Material List] 使用 Mock 数据:', USE_MOCK_DATA)
// 根据开关选择使用真实 API 或 Mock 数据
const res = USE_MOCK_DATA
? await mockFileListAPI(params)
: await fileListAPI(params)
if (res.code === 1 && res.data) {
// 如果是初始请求(没有 child_id),保存完整的分类信息
if (!params.child_id && !params.keyword) {
data.value = res.data
// console.log('[Material List] 数据:', res.data)
// console.log('[Material List] 分类数量:', res.data.children?.length)
// console.log('[Material List] 文档数量:', res.data.list?.length)
// 处理并缓存"全部"列表
if (res.data.list?.length) {
const allListData = res.data.list.map(transformDocItem)
if (isLoadMore) {
// 加载更多:追加数据
allList.value = [...allList.value, ...allListData]
categoryListCache.value.set('all', allList.value)
// ✅ 同步更新 currentList(如果当前显示的是"全部")
if (activeTabId.value === 'all') {
currentList.value = allList.value
}
} else {
// 首次加载:替换数据
allList.value = allListData
categoryListCache.value.set('all', allListData)
// ✅ 同步更新 currentList(如果当前显示的是"全部")
if (activeTabId.value === 'all') {
currentList.value = allListData
}
}
// 判断是否还有更多数据
hasMore.value = allListData.length >= params.limit
} else {
if (isLoadMore) {
hasMore.value = false
} else {
allList.value = []
}
}
} else {
// 是子分类请求或搜索请求
const cacheKey = params.child_id || params.keyword || 'search'
if (res.data.list?.length) {
const listData = res.data.list.map(transformDocItem)
if (isLoadMore) {
// 加载更多:追加数据
const existingData = categoryListCache.value.get(cacheKey) || []
const newData = [...existingData, ...listData]
categoryListCache.value.set(cacheKey, newData)
// ✅ 同步更新 currentList(如果当前显示的是该缓存)
const currentCacheKey = params.child_id || params.keyword || 'all'
if (activeTabId.value === currentCacheKey) {
currentList.value = newData
}
} else {
// 首次加载:替换数据
categoryListCache.value.set(cacheKey, listData)
currentList.value = listData
}
// 判断是否还有更多数据
hasMore.value = listData.length >= params.limit
} else {
if (isLoadMore) {
hasMore.value = false
} else {
currentList.value = []
}
}
}
} else {
Taro.showToast({
title: res.msg || '获取资料列表失败',
icon: 'none',
duration: 2000
})
}
} catch (error) {
console.error('[Material List] 获取资料列表失败:', error)
Taro.showToast({
title: '加载失败',
icon: 'error',
duration: 2000
})
} finally {
if (isLoadMore) {
loadingMore.value = false
} else {
loading.value = false
}
}
}
/**
* 页面加载时接收参数
*/
useLoad(async (options) => {
console.log('[Material List] 页面参数:', options)
// 保存初始分类ID
if (options.id) {
initialCategoryId.value = options.id
}
// 设置页面标题
if (options.title) {
pageTitle.value = options.title
}
// 重置分页状态
currentPage.value = 0
hasMore.value = true
// 获取资料列表(初始请求)
await fetchMaterialList({
cid: options.id,
page: 0,
limit: pageSize
})
// 初始化当前列表为"全部"列表(等待请求完成后)
currentList.value = allList.value
})
/**
* Tab 点击处理
* @param {string} id - Tab ID
*/
const onTabClick = async (id) => {
activeTabId.value = id
listVisible.value = false
// 恢复或初始化该分类的分页状态
const pageState = categoryPageCache.value.get(id)
if (pageState) {
currentPage.value = pageState.currentPage
hasMore.value = pageState.hasMore
} else {
currentPage.value = 0
hasMore.value = true
}
// 判断是否是"全部" tab
if (id === 'all') {
// 显示"全部"列表(从缓存或 allList)
const cachedList = categoryListCache.value.get('all')
currentList.value = cachedList || allList.value || []
} else {
// 检查缓存中是否有该分类的数据
if (categoryListCache.value.has(id)) {
// 从缓存中获取
currentList.value = categoryListCache.value.get(id)
} else {
// 调用接口获取该分类的列表(第一页)
await fetchMaterialList({
cid: initialCategoryId.value,
child_id: id,
page: 0,
limit: pageSize
})
// 保存分页状态
categoryPageCache.value.set(id, {
currentPage: 0,
hasMore: hasMore.value
})
}
}
nextTick(() => {
listRenderKey.value += 1
listVisible.value = true
})
}
/**
* 触底加载更多
* @description 使用防抖避免频繁触发
*/
let loadMoreTimer = null
useReachBottom(() => {
// 如果正在加载或没有更多数据,不执行
if (loadingMore.value || loading.value || !hasMore.value) {
return
}
// 防抖:300ms 内只触发一次
if (loadMoreTimer) {
clearTimeout(loadMoreTimer)
}
loadMoreTimer = setTimeout(async () => {
console.log('[Material List] 触底加载更多')
// 页码 +1
currentPage.value += 1
// 构建请求参数
const params = {
cid: initialCategoryId.value,
page: currentPage.value,
limit: pageSize
}
// 判断当前状态:搜索、子分类、或全部
const isSearching = searchValue.value.trim() !== ''
if (isSearching) {
// 搜索模式
params.keyword = searchValue.value.trim()
if (activeTabId.value !== 'all') {
params.child_id = activeTabId.value
}
} else {
// 非搜索模式:如果当前选中的是子分类,添加 child_id 参数
if (activeTabId.value !== 'all') {
params.child_id = activeTabId.value
}
}
// 加载下一页数据
await fetchMaterialList(params, true) // true 表示加载更多
// 保存更新后的分页状态
let cacheKey
if (isSearching) {
cacheKey = params.keyword
} else {
cacheKey = activeTabId.value !== 'all' ? activeTabId.value : 'all'
}
categoryPageCache.value.set(cacheKey, {
currentPage: currentPage.value,
hasMore: hasMore.value
})
}, 300)
})
/**
* 搜索处理函数
* @description 根据 child_id 和 keyword 调用接口查询列表
*/
const onSearch = async () => {
console.log('Searching for:', searchValue.value)
console.log('当前分类:', activeTabId.value)
// 如果没有搜索关键词,清空搜索并恢复当前分类的列表
if (!searchValue.value.trim()) {
// 恢复当前分类的列表
if (activeTabId.value === 'all') {
const cachedList = categoryListCache.value.get('all')
currentList.value = cachedList || allList.value || []
} else {
const cachedList = categoryListCache.value.get(activeTabId.value)
if (cachedList) {
currentList.value = cachedList
} else {
// 如果缓存中没有,调用接口获取
await fetchMaterialList({
cid: initialCategoryId.value,
child_id: activeTabId.value,
page: 0,
limit: pageSize
})
}
}
// 恢复分页状态
const pageState = categoryPageCache.value.get(activeTabId.value)
if (pageState) {
currentPage.value = pageState.currentPage
hasMore.value = pageState.hasMore
}
return
}
// 构建请求参数
const params = {
cid: initialCategoryId.value,
page: 0,
limit: pageSize
}
// 如果当前选中的是子分类,添加 child_id 参数
if (activeTabId.value !== 'all') {
params.child_id = activeTabId.value
}
// 添加搜索关键词
params.keyword = searchValue.value.trim()
// 重置分页状态
currentPage.value = 0
hasMore.value = true
// 调用接口搜索
try {
loading.value = true
console.log('[Material List] 搜索使用 Mock 数据:', USE_MOCK_DATA)
// 根据开关选择使用真实 API 或 Mock 数据
const res = USE_MOCK_DATA
? await mockFileListAPI(params)
: await fileListAPI(params)
if (res.code === 1 && res.data) {
if (res.data.list?.length) {
const listData = res.data.list.map(transformDocItem)
currentList.value = listData
// 缓存搜索结果
categoryListCache.value.set(params.keyword, listData)
// 判断是否还有更多数据
hasMore.value = listData.length >= pageSize
} else {
currentList.value = []
hasMore.value = false
}
} else {
Taro.showToast({
title: res.msg || '搜索失败',
icon: 'none',
duration: 2000
})
}
} catch (error) {
console.error('[Material List] 搜索失败:', error)
Taro.showToast({
title: '搜索失败',
icon: 'error',
duration: 2000
})
} finally {
loading.value = false
}
}
/**
* 监听搜索关键字变化
* @description 实现实时搜索:用户输入时自动触发搜索(带防抖)
*/
watch(searchValue, (newValue, oldValue) => {
console.log('[Material List] searchValue 变化:', oldValue, '->', newValue)
// 如果搜索关键字为空,立即清除搜索(不需要防抖)
if (!newValue?.trim()) {
console.log('[Material List] 搜索关键字为空,立即清除')
onClear()
return
}
// 有搜索关键字,使用防抖搜索
console.log('[Material List] 触发防抖搜索')
debouncedSearch()
})
/**
* 清除搜索关键词
* @description 用户点击搜索框右侧的删除按钮时触发,重新请求当前分类的最新数据
*
* 场景说明:
* - 有tab:重新请求当前tab的数据(不带keyword)
* - 无tab:重新请求"全部"数据(不带keyword)
*/
const onClear = async () => {
console.log('[Material List] 清除搜索,重新请求数据')
console.log('[Material List] 当前分类:', activeTabId.value)
// 构建请求参数(不带 keyword)
const params = {
cid: initialCategoryId.value,
page: 0,
limit: pageSize
}
// 如果当前选中的是子分类,添加 child_id 参数
if (activeTabId.value !== 'all') {
params.child_id = activeTabId.value
}
// 重置分页状态为第一页
currentPage.value = 0
hasMore.value = true
// 重新请求接口(不带 keyword,获取最新数据)
await fetchMaterialList(params, false)
// 更新当前显示的列表
if (activeTabId.value === 'all') {
// 全部列表:使用 allList
currentList.value = allList.value
} else {
// 子分类列表:已经在 fetchMaterialList 中更新了 currentList
}
}
/**
* 使用文件列表点击处理器
* @description 添加图片预览功能,点击图片文件时使用 Taro.previewImage
*/
const { handleClick: onView } = useListItemClick({
listType: ListType.FILE,
onBeforeClick: async (item) => {
/**
* 检查文件类型并使用对应的预览方式
* - 图片文件:使用 Taro.previewImage 预览
* - 其他文件:继续默认的文件打开流程
*/
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']
const extension = item.extension?.toLowerCase() || ''
console.log('[Material List] 文件类型:', extension, '文件名:', item.title)
if (imageExtensions.includes(extension)) {
// 图片文件:使用 Taro 预览
console.log('[Material List] 检测到图片文件,使用图片预览')
// 构建图片列表(当前图片)
const urls = [item.downloadUrl]
try {
// 短暂延迟后打开预览(让用户看到提示)
await new Promise(resolve => setTimeout(resolve, 300))
await Taro.previewImage({
current: item.downloadUrl, // 当前显示图片的 http 链接
urls: urls // 需要预览的图片 http 链接列表
})
// 预览成功,阻止默认的文件打开行为
return false
} catch (err) {
console.error('[Material List] 图片预览失败:', err)
Taro.showToast({
title: '图片预览失败',
icon: 'none',
duration: 2000
})
// 预览失败,返回 true 继续默认行为
return true
}
}
// 非图片文件:继续默认的文件打开流程
console.log('[Material List] 非图片文件,使用默认打开方式')
return true
},
onAfterClick: (item) => {
console.log('用户打开了资料:', item.title)
}
})
/**
* 切换收藏状态
* @description 使用 useCollectOperation composable 处理收藏操作
*/
const { toggleCollect } = useCollectOperation()
/**
* 删除资料
*/
const onDelete = (item) => {
Taro.showModal({
title: '提示',
content: '确定要删除该资料吗?',
success: (res) => {
if (res.confirm) {
// 从 allList 中删除
const index = allList.value.findIndex(i => i.id === item.id)
if (index !== -1) {
allList.value.splice(index, 1)
// 重新渲染列表
listRenderKey.value += 1
Taro.showToast({ title: '已删除', icon: 'success' })
}
}
}
})
}
</script>
<style lang="less">
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.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;
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;
}
// 加载更多容器
.load-more-container {
display: flex;
justify-content: center;
align-items: center;
padding: 40rpx 0;
min-height: 80rpx;
}
.load-more-loading {
display: flex;
align-items: center;
justify-content: center;
}
.load-more-finished {
display: flex;
align-items: center;
justify-content: center;
}
// 自定义加载动画
.loading-spinner {
width: 32rpx;
height: 32rpx;
border: 4rpx solid #E5E7EB;
border-top-color: #2563EB;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
<template>
<view class="min-h-screen bg-[#F9FAFB] pb-safe">
<NavHeader title="我的消息" />
<!-- 列表区域 -->
<view class="p-4">
<template v-if="messageList.length > 0">
<view
v-for="item in messageList"
:key="item.id"
class="bg-white rounded-xl p-4 mb-3 shadow-sm active:opacity-70 transition-opacity"
@tap="handleItemClick(item)"
>
<view class="flex justify-between items-start mb-2">
<view class="flex-1 mr-2">
<view class="text-base font-bold text-gray-900 line-clamp-1">
{{ item.title }}
</view>
</view>
<text class="text-xs text-gray-400 shrink-0 mt-1">
{{ item.create_time }}
</text>
</view>
<view class="text-sm text-gray-600 line-clamp-2 leading-relaxed">
{{ item.intro || item.content || '暂无简介' }}
</view>
</view>
<!-- 加载更多/没有更多 -->
<view class="py-4 text-center text-[24rpx] text-gray-400">
<text v-if="loading">加载中...</text>
<text v-else-if="!hasMore">没有更多了</text>
<text v-else>上拉加载更多</text>
</view>
</template>
<!-- 空状态 -->
<nut-empty
v-else-if="!loading && messageList.length === 0"
description="暂无消息"
image="empty"
/>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { useLoad, usePullDownRefresh, useReachBottom, stopPullDownRefresh } from '@tarojs/taro'
import { useGo } from '@/hooks/useGo'
import NavHeader from '@/components/NavHeader.vue'
import { myListAPI } from '@/api/news'
import { mockMessageListAPI } from '@/utils/mockData'
// ⚠️ MOCK 数据开关 - 开发环境使用 mock 数据,生产环境使用真实 API
const USE_MOCK_DATA = process.env.NODE_ENV === 'development'
const go = useGo()
const messageList = ref([])
const page = ref(1)
const limit = ref(10)
const hasMore = ref(true)
const loading = ref(false)
/**
* @description 加载消息列表
* @param {boolean} refresh 是否刷新
*/
const fetchMessageList = async (refresh = false) => {
if (loading.value) return
if (refresh) {
page.value = 1
hasMore.value = true
} else if (!hasMore.value) {
return
}
loading.value = true
try {
console.log('[Message] 使用 Mock 数据:', USE_MOCK_DATA)
// 根据开关选择使用真实 API 或 Mock 数据
const res = USE_MOCK_DATA
? await mockMessageListAPI({
page: page.value,
limit: limit.value
})
: await myListAPI({
page: page.value,
limit: limit.value
})
if (res.code === 1) {
const list = res.data?.list || []
if (refresh) {
messageList.value = list
} else {
messageList.value = [...messageList.value, ...list]
}
if (list.length < limit.value) {
hasMore.value = false
} else {
page.value++
}
}
} catch (err) {
console.error('获取消息列表失败:', err)
} finally {
loading.value = false
if (refresh) {
stopPullDownRefresh()
}
}
}
/**
* @description 跳转到详情页
* @param {Object} item 消息对象
*/
const handleItemClick = (item) => {
go('/pages/message-detail/index', { id: item.id })
}
// 页面加载
useLoad(() => {
fetchMessageList(true)
})
// 下拉刷新
usePullDownRefresh(() => {
fetchMessageList(true)
})
// 上拉加载更多
useReachBottom(() => {
fetchMessageList()
})
</script>
<style lang="less">
/* Scoped styles if needed */
</style>
<!--
* @Date: 2026-01-31
* @Description: 产品中心 - API 接口集成版本(含搜索功能)
-->
<template>
<view class="bg-[#F9FAFB]">
<!-- 固定在顶部的导航和搜索 -->
<view class="bg-[#F9FAFB] sticky top-0 z-10">
<NavHeader title="产品中心" />
<!-- Search Bar -->
<view class="px-[24rpx] py-[16rpx] bg-white">
<SearchBar
v-model="searchValue"
placeholder="搜索产品名称..."
variant="rounded"
:show-clear="true"
@search="onSearch"
@input="onSearchInput"
@clear="onClear"
/>
</view>
<!-- 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="pb-[calc(160rpx+env(safe-area-inset-bottom))]">
<!-- 加载状态 -->
<view v-if="loading && products.length === 0" class="flex justify-center items-center py-[100rpx]">
<text class="text-gray-400 text-[28rpx]">加载中...</text>
</view>
<!-- Product List -->
<view v-else class="px-[40rpx]">
<!-- Card Item -->
<view v-for="(item, index) in products" :key="item.id"
class="bg-white rounded-[24rpx] overflow-hidden mb-[24rpx] shadow-sm active:scale-[0.98] transition-transform duration-200"
:style="{ animationDelay: `${index * 50}ms` }"
>
<!-- Product Content (Horizontal Layout) -->
<view class="flex gap-[24rpx]" @tap="handleProductClick(item)">
<!-- Image Container -->
<view class="relative w-[220rpx] h-[220rpx] flex-shrink-0 product-card-item">
<image :src="item.cover_image" class="w-full h-full object-cover bg-gray-100" mode="aspectFill" />
<!-- Tag -->
<view v-if="item.recommend === 'hot'"
class="absolute top-[12rpx] right-[12rpx] bg-red-500 text-white text-[20rpx] px-[12rpx] py-[4rpx] rounded-full">
热卖
</view>
</view>
<!-- Content -->
<view class="flex-1 flex flex-col py-[20rpx] pr-[20rpx]">
<!-- Title -->
<view class="text-[#1F2937] text-[32rpx] font-medium leading-[1.4] line-clamp-2 mb-[12rpx]">
{{ item.product_name }}
</view>
<!-- 动态标签 -->
<view v-if="item.tags && item.tags.length > 0" class="flex flex-wrap gap-[8rpx] mt-[8rpx] mb-[8rpx]">
<view
v-for="tag in item.tags"
:key="tag.id"
class="text-[20rpx] px-[12rpx] py-[4rpx] rounded-full"
:style="{
backgroundColor: tag.bg_color,
color: tag.text_color
}"
>
{{ tag.name }}
</view>
</view>
<!-- 按钮组 - 靠右对齐(纯文字按钮) -->
<view class="flex gap-[16rpx] ml-auto items-center mt-auto" @tap.stop>
<!-- 详情按钮 -->
<view
class="text-[24rpx] text-[#2563EB] bg-blue-50 px-[24rpx] py-[8rpx] rounded-full active:bg-blue-100 transition-colors duration-200"
@tap.stop="handleProductClick(item)"
>
详情
</view>
<!-- 计划书按钮 -->
<view
class="text-[24rpx] text-white bg-blue-500 px-[24rpx] py-[8rpx] rounded-full active:bg-blue-600 transition-colors duration-200"
@tap.stop="openPlanPopup(item)"
>
计划书
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 加载更多状态 -->
<view v-if="loading && products.length > 0" class="flex justify-center items-center py-[40rpx]">
<text class="text-gray-400 text-[28rpx]">加载中...</text>
</view>
<!-- 没有更多数据 -->
<view v-if="!hasMore && products.length > 0" class="flex justify-center items-center py-[40rpx]">
<text class="text-gray-400 text-[24rpx]">没有更多了</text>
</view>
<!-- 空状态 -->
<view v-if="!loading && products.length === 0">
<nut-empty description="暂无相关产品" image="empty" />
</view>
</view>
<!-- 计划书表单容器 -->
<!-- 测试数据:后端接口和字段还没有准备好,使用 PlanFormContainer 进行的前端测试 -->
<!-- 使用 v-if 条件渲染,避免 selectedProduct 为 null 时的 prop 类型检查错误 -->
<view v-if="showPlanPopup && selectedProduct">
<PlanFormContainer
v-model:visible="showPlanPopup"
:product="selectedProduct"
@close="showPlanPopup = false"
@submit="handlePlanSubmit"
/>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import Taro, { useLoad, useReachBottom } from '@tarojs/taro'
import NavHeader from '@/components/NavHeader.vue'
import SearchBar from '@/components/SearchBar.vue'
import PlanFormContainer from '@/components/PlanFormContainer.vue'
import { useListItemClick, ListType } from '@/composables/useListItemClick'
import { listAPI } from '@/api/get_product'
import { mockProductListAPI } from '@/utils/mockData'
// ⚠️ MOCK 数据开关 - 开发环境使用 mock 数据,生产环境使用真实 API
const USE_MOCK_DATA = process.env.NODE_ENV === 'development'
const activeTabId = ref('')
// 搜索状态
const searchValue = ref('')
// 搜索防抖定时器
let searchTimer = null
// 分页状态
const page = ref(0)
const limit = ref(10)
const loading = ref(false)
const hasMore = ref(true)
// 数据状态
const categories = ref([]) // 从接口获取的分类列表
const products = ref([]) // 当前产品列表
const total = ref(0) // 产品总数
// 计划书弹窗状态
const showPlanPopup = ref(false)
const selectedProduct = ref(null)
/**
* 标签栏数据(根据接口返回的 categories 生成)
* @description 包含"全部"选项和接口返回的分类
*/
const tabsData = computed(() => {
const allTab = { id: '', name: '全部' }
const categoryTabs = categories.value.map(cat => ({
id: String(cat.id),
name: cat.name
}))
return [allTab, ...categoryTabs]
})
/**
* 获取产品列表
* @description 根据 activeTabId 和 searchValue 获取对应分类的产品列表
*/
const fetchProducts = async (isLoadMore = false) => {
if (loading.value) return
loading.value = true
try {
const params = {
page: String(page.value),
limit: String(limit.value)
}
// 如果不是"全部"标签,添加分类 ID 参数
if (activeTabId.value !== '') {
params.cid = activeTabId.value
}
// 添加搜索关键词参数
if (searchValue.value) {
params.keyword = searchValue.value
}
console.log('[Product Center] 使用 Mock 数据:', USE_MOCK_DATA)
// 根据开关选择使用真实 API 或 Mock 数据
const res = USE_MOCK_DATA
? await mockProductListAPI(params)
: await listAPI(params)
if (res.code === 1 && res.data) {
// 更新分类列表(首次加载时)
if (!isLoadMore && res.data.categories) {
categories.value = res.data.categories
}
// 处理产品列表
if (isLoadMore) {
// 加载更多:追加数据
products.value = [...products.value, ...res.data.list]
} else {
// 首次加载或切换分类:替换数据
products.value = res.data.list || []
}
// 更新总数和分页状态
total.value = res.data.total || 0
hasMore.value = products.value.length < total.value
} else {
Taro.showToast({
title: res.msg || '获取产品列表失败',
icon: 'none'
})
}
} catch (err) {
console.error('获取产品列表失败:', err)
Taro.showToast({
title: '网络错误,请重试',
icon: 'none'
})
} finally {
loading.value = false
}
}
/**
* Tab 点击处理
* @description 切换分类,重置分页并重新加载数据
*/
const onTabClick = (id) => {
if (activeTabId.value === id) return
activeTabId.value = id
// 重置分页状态
page.value = 0
products.value = []
hasMore.value = true
// 重新加载数据(保持搜索状态)
fetchProducts(false)
}
/**
* 搜索输入处理(带防抖)
* @description 用户输入时实时搜索,使用防抖优化性能
* @param {string} value - 搜索关键词
*/
const onSearchInput = (value) => {
console.log('搜索输入:', value)
// 清除之前的定时器
if (searchTimer) {
clearTimeout(searchTimer)
}
// 设置新的定时器(500ms 后执行搜索)
searchTimer = setTimeout(() => {
// 重置分页状态
page.value = 0
products.value = []
hasMore.value = true
// 重新加载数据
fetchProducts(false)
}, 500)
}
/**
* 搜索处理(回车键)
* @description 用户按下回车或点击搜索按钮时触发
* @param {string} value - 搜索关键词
*/
const onSearch = (value) => {
console.log('搜索产品:', value)
// 清除防抖定时器
if (searchTimer) {
clearTimeout(searchTimer)
searchTimer = null
}
// 重置分页状态
page.value = 0
products.value = []
hasMore.value = true
// 重新加载数据
fetchProducts(false)
}
/**
* 清空搜索
* @description 用户点击清除按钮时触发
*/
const onClear = () => {
console.log('清空搜索')
// 清除防抖定时器
if (searchTimer) {
clearTimeout(searchTimer)
searchTimer = null
}
// 重置分页状态
page.value = 0
products.value = []
hasMore.value = true
// 重新加载数据
fetchProducts(false)
}
/**
* 使用产品列表点击处理器
* @description 配置为产品类型列表,点击时跳转到产品详情页
*/
const { handleClick: handleProductClick } = useListItemClick({
listType: ListType.PRODUCT,
onAfterClick: (item) => {
console.log('用户查看了产品:', item.product_name)
}
})
/**
* 打开计划书弹窗
* @description 根据产品对象打开计划书表单
* @param {Object} product - 产品对象
*/
const openPlanPopup = (product) => {
selectedProduct.value = product
showPlanPopup.value = true
}
/**
* 处理计划书提交
* @description 测试环境:前端不调用后端API,直接跳转到结果页
* 生产环境:需要调用 submitPlanAPI 提交表单数据
* @param {Object} formData - 表单数据
*/
const handlePlanSubmit = (formData) => {
console.log('计划书提交:', {
product_id: selectedProduct.value.id,
product_name: selectedProduct.value.product_name,
form_sn: selectedProduct.value.form_sn,
form_data: formData
})
// 关闭弹窗
showPlanPopup.value = false
// TODO: 后端接口还没有准备好,暂时不调用API
// 测试完成后需要对接 submitPlanAPI
// const res = await submitPlanAPI({
// product_id: selectedProduct.value.id,
// template: selectedProduct.value.form_sn,
// form_data: formData
// })
// 模拟提交成功,跳转到结果页面
Taro.navigateTo({
url: '/pages/plan-submit-result/index?success=true'
})
}
/**
* 页面加载时获取数据
*/
useLoad(() => {
fetchProducts(false)
})
/**
* 触底加载更多
* @description 使用 Taro 的 useReachBottom hook 监听页面滚动到底部
*/
useReachBottom(() => {
console.log('滚动到底部,加载更多')
if (!hasMore.value || loading.value) {
console.log('没有更多数据或正在加载中,跳过')
return
}
page.value += 1
fetchProducts(true)
})
</script>
<style lang="less">
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.product-card-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 40rpx;
gap: 24rpx;
transition: all 0.3s ease;
background-color: #F9FAFB;
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;
}
/* 多行文本省略 */
.line-clamp-2 {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
word-break: break-all;
}
</style>
<!--
* @Date: 2026-02-06
* @Description: 搜索页面 - 支持产品和资料搜索,实时查询API
-->
<template>
<view class="bg-[#FFF]">
<!-- 固定顶部:导航栏 + 搜索栏 + Tabs + 结果计数 -->
<view class="bg-[#FFF] sticky top-0 z-10">
<NavHeader title="搜索" />
<!-- Search Input -->
<view class="px-[40rpx] mt-[32rpx]">
<SearchBar
v-model="searchKeyword"
placeholder="搜索培训资料、案例、产品..."
variant="rounded"
:show-border="true"
:show-clear="true"
@search="handleSearch"
@clear="clearSearch"
/>
</view>
<!-- Tabs Container -->
<nut-tabs v-model="activeTab">
<!-- 自定义标签栏 -->
<template #titles>
<view class="filter-tabs-wrapper">
<view
v-for="item in tabsData"
:key="item.id"
:class="[
'filter-tab-item',
activeTab === item.id ? 'filter-tab-active' : 'filter-tab-inactive',
!activeTab ? 'filter-tab-inactive' : '' // 初始状态不高亮任何tab
]"
@tap="onTabClick(item.id)"
>
<text class="filter-tab-text">{{ item.name }}</text>
</view>
</view>
</template>
</nut-tabs>
<!-- Result Count -->
<view v-if="currentList.length > 0" class="px-[60rpx] text-[#6B7280] text-[24rpx] pb-[24rpx]">
找到 {{ currentTotal }} 个相关结果
</view>
</view>
<!-- 列表容器 -->
<view class="px-[40rpx] pb-[calc(160rpx+env(safe-area-inset-bottom))]">
<!-- Search Results -->
<view
v-if="currentList.length > 0"
:key="listRenderKey"
>
<!-- Product Results -->
<view v-if="activeTab === 'product'" class="flex flex-col gap-[24rpx] pb-[40rpx]">
<ProductCard
v-for="(item, index) in currentList"
:key="index"
:product-id="item.id"
:product-name="item.product_name || item.name"
:tags="item.tags || []"
class="search-result-item"
:style="{ animationDelay: `${index * 30}ms` }"
@detail="goToProductDetail"
@plan="openPlanPopup"
/>
</view>
<!-- File Results -->
<view v-else-if="activeTab === 'file'" class="flex flex-col gap-[24rpx] pb-[40rpx]">
<MaterialCard
v-for="(item, index) in currentList"
:key="index"
:id="item.id"
:title="item.title"
:file-name="item.fileName"
:file-size="item.fileSize"
:learners="item.learners"
:read-people-percent="item.readPeoplePercent"
:collected="item.collected"
:extension="item.extension"
:download-url="item.downloadUrl"
class="search-result-item"
:style="{ animationDelay: `${index * 30}ms` }"
@collect-changed="handleCollectChanged(item, $event)"
/>
</view>
<!-- 加载更多提示 -->
<view v-if="currentList.length > 0" class="flex items-center justify-center py-[40rpx]">
<view v-if="loadingMore" class="flex items-center">
<view class="loading-spinner-small"></view>
<text class="ml-[12rpx] text-[#9CA3AF] text-[24rpx]">加载中...</text>
</view>
<view v-else-if="!hasMore" class="text-[#9CA3AF] text-[24rpx]">
没有更多了
</view>
</view>
</view>
<!-- Empty State (已搜索但无结果) -->
<view v-else-if="hasSearched && currentList.length === 0" class="flex flex-col items-center justify-center py-[40rpx]">
<nut-empty description="暂无搜索结果" image="empty">
<view class="text-[#9CA3AF] text-[24rpx] mt-[12rpx]">试试其他关键词吧</view>
</nut-empty>
</view>
<!-- Initial State (从未搜索过) -->
<view v-else class="flex flex-col items-center justify-center py-[120rpx]">
<IconFont name="search" class="text-gray-300 mb-[24rpx]" size="64" />
<view class="text-[#6B7280] text-[28rpx]">搜索产品或资料</view>
<view class="text-[#9CA3AF] text-[24rpx] mt-[12rpx]">输入关键词开始搜索,自动切换分类</view>
</view>
</view>
<!-- Plan Form Container -->
<!-- 仅当 selectedProduct 不为 null 时才渲染组件,避免 product prop required 警告 -->
<PlanFormContainer
v-if="selectedProduct"
v-model:visible="showPlanPopup"
:product="selectedProduct"
@close="showPlanPopup = false"
@submit="handlePlanSubmit"
/>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import Taro, { useReachBottom } from '@tarojs/taro'
import { useGo } from '@/hooks/useGo'
import NavHeader from '@/components/NavHeader.vue'
import IconFont from '@/components/IconFont.vue'
import SearchBar from '@/components/SearchBar.vue'
import ProductCard from '@/components/ProductCard.vue'
import MaterialCard from '@/components/MaterialCard.vue'
import PlanFormContainer from '@/components/PlanFormContainer.vue'
import { searchAPI } from '@/api/search'
import { mockSearchAPI } from '@/utils/mockData'
// ⚠️ MOCK 数据开关 - 开发环境使用 mock 数据,生产环境使用真实 API
const USE_MOCK_DATA = process.env.NODE_ENV === 'development'
// Navigation
const go = useGo()
// Plan Popup State
const showPlanPopup = ref(false)
const selectedProduct = ref(null)
// State
const searchKeyword = ref('')
const activeTab = ref('') // 当前选中的 tab(初始为空,不选中任何tab)
const hasSearched = ref(false) // 是否已经搜索过
const listRenderKey = ref(0)
// 数据状态
const products = ref([]) // 产品列表
const files = ref([]) // 资料列表
const productsTotal = ref(0) // 产品总数
const filesTotal = ref(0) // 资料总数
const loadingMore = ref(false) // 加载更多状态
const hasMore = ref(true) // 是否还有更多数据
const currentPage = ref(0) // 当前页码(从0开始)
const pageSize = 20 // 每页数量
/**
* Tab 数据源(只保留产品和资料)
*/
const tabsData = ref([
{ id: 'product', name: '产品' },
{ id: 'file', name: '资料' },
])
/**
* 当前显示的列表
*/
const currentList = computed(() => {
// 如果没有选中任何tab,返回空数组
if (!activeTab.value) return []
if (activeTab.value === 'product') {
return products.value
} else {
return files.value
}
})
/**
* 当前列表总数
*/
const currentTotal = computed(() => {
// 如果没有选中任何tab,返回0
if (!activeTab.value) return 0
if (activeTab.value === 'product') {
return productsTotal.value
} else {
return filesTotal.value
}
})
/**
* 执行搜索
* @param {string} keyword - 搜索关键字
* @param {string} type - 可选,'product' | 'file' | undefined
* @param {number} page - 页码(从0开始)
* @param {number} limit - 每页数量
* @param {boolean} isLoadMore - 是否为加载更多
*/
const performSearch = async (keyword, type, page = 0, limit = pageSize, isLoadMore = false) => {
try {
// 如果是加载更多,使用 loadingMore 状态;否则使用 loading 状态
if (isLoadMore) {
loadingMore.value = true
} else {
Taro.showLoading({ title: '搜索中...', mask: true })
}
const params = { keyword, page, limit }
if (type) params.type = type
console.log('[Search] 使用 Mock 数据:', USE_MOCK_DATA)
// 根据开关选择使用真实 API 或 Mock 数据
const res = USE_MOCK_DATA
? await mockSearchAPI(params)
: await searchAPI(params)
if (res.code === 1) {
// 映射产品列表
const newProducts = res.data.products.list || []
// 映射资料列表(进行字段映射,与首页保持一致)
const newFiles = (res.data.files.list || []).map(item => {
// 提取文件扩展名
const fileName = item.name || '未命名文件'
const extension = item.extension || fileName.split('.').pop()?.toLowerCase() || ''
return {
id: item.meta_id || item.id,
title: item.name,
fileName: fileName,
fileSize: item.size || item.file_size,
downloadUrl: item.src || item.value,
extension: extension,
learners: item.read_people_count ? `${item.read_people_count }人学习` : '',
readPeoplePercent: item.read_people_percent,
is_favorite: item.is_favorite, // 保留原始字段
collected: Boolean(item.is_favorite) // 转换为 Boolean 供 MaterialCard 使用
}
})
// 根据是否为加载更多来处理数据
if (isLoadMore) {
// 加载更多:追加数据
products.value = [...products.value, ...newProducts]
files.value = [...files.value, ...newFiles]
} else {
// 首次加载或刷新:替换数据
products.value = newProducts
files.value = newFiles
}
productsTotal.value = res.data.products.total || 0
filesTotal.value = res.data.files.total || 0
// ⚠️ 重要:必须先自动选择 tab,然后再计算 hasMore
// 如果不传 type,自动选择有数据的 tab(仅首次搜索时)
if (!type && !isLoadMore) {
if (productsTotal.value > 0) {
activeTab.value = 'product'
} else if (filesTotal.value > 0) {
activeTab.value = 'file'
}
// 如果都为 0,默认 product
}
// 判断是否还有更多数据
// 使用当前列表长度与总数比较
// 注意:需要根据实际选择的tab来判断
const actualTab = type || activeTab.value
if (actualTab === 'product') {
hasMore.value = products.value.length < productsTotal.value
} else if (actualTab === 'file') {
hasMore.value = files.value.length < filesTotal.value
} else {
// 如果都没有选中,保守设置为false
hasMore.value = false
}
hasSearched.value = true
listRenderKey.value += 1
console.log('[Search] 搜索成功', {
productsTotal: productsTotal.value,
filesTotal: filesTotal.value,
activeTab: activeTab.value,
isLoadMore,
hasMore: hasMore.value
})
} else {
Taro.showToast({
title: res.msg || '搜索失败',
icon: 'none'
})
}
} catch (err) {
console.error('[Search] 搜索失败:', err)
Taro.showToast({
title: '搜索失败,请重试',
icon: 'none'
})
} finally {
if (isLoadMore) {
loadingMore.value = false
} else {
Taro.hideLoading()
}
}
}
/**
* Tab 点击处理(实时查询)
*/
const onTabClick = async (tabId) => {
if (activeTab.value === tabId) return
// 立即切换 tab(响应更快)
activeTab.value = tabId
listRenderKey.value += 1
// 重置分页状态
currentPage.value = 0
hasMore.value = true
// 如果已经搜索过,实时查询对应类型的数据
if (hasSearched.value && searchKeyword.value.trim()) {
console.log('[Search] 切换 tab,实时查询:', tabId)
await performSearch(searchKeyword.value.trim(), tabId, 0, pageSize, false)
}
}
/**
* 提交搜索
*/
const handleSearch = async () => {
const keyword = searchKeyword.value.trim()
if (!keyword) {
Taro.showToast({
title: '请输入搜索关键词',
icon: 'none'
})
return
}
console.log('[Search] 提交搜索:', keyword)
// 重置分页状态
currentPage.value = 0
hasMore.value = true
// 不传 type,让后端返回两种数据,前端自动选择 tab
await performSearch(keyword, undefined, 0, pageSize, false)
}
/**
* 清空搜索
*/
const clearSearch = () => {
console.log('[Search] 清空搜索')
searchKeyword.value = ''
hasSearched.value = false
products.value = []
files.value = []
productsTotal.value = 0
filesTotal.value = 0
activeTab.value = '' // 重置为空,不选中任何tab
currentPage.value = 0
hasMore.value = true
listRenderKey.value += 1
}
/**
* 触底加载更多
* @description 使用防抖避免频繁触发
*/
let loadMoreTimer = null
useReachBottom(() => {
// 如果正在加载更多或没有更多数据,不执行
if (loadingMore.value || !hasMore.value) {
return
}
// 如果没有搜索过或没有选中 tab,不执行
if (!hasSearched.value || !activeTab.value || !searchKeyword.value.trim()) {
return
}
// 防抖:300ms 内只触发一次
if (loadMoreTimer) {
clearTimeout(loadMoreTimer)
}
loadMoreTimer = setTimeout(async () => {
console.log('[Search] 触底加载更多')
// 页码 +1
currentPage.value += 1
// 加载下一页数据
await performSearch(
searchKeyword.value.trim(),
activeTab.value,
currentPage.value,
pageSize,
true // 标记为加载更多
)
}, 300)
})
/**
* 跳转到产品详情页
*
* @description 处理产品详情按钮点击事件
* @param {number} productId - 产品ID
*/
const goToProductDetail = (productId) => {
go('/pages/product-detail/index', { id: productId })
}
/**
* 打开计划书弹窗
*
* @description 根据产品ID找到对应的产品对象,并打开计划书表单
* @param {number} productId - 产品ID
*/
const openPlanPopup = (productId) => {
// 从产品列表中找到对应的产品
const product = products.value.find(p => p.id === productId)
if (!product) {
Taro.showToast({
title: '产品不存在',
icon: 'none',
duration: 2000
})
return
}
// 设置选中的产品
selectedProduct.value = product
showPlanPopup.value = true
}
/**
* 处理计划书提交
*
* @description 测试环境:前端不调用后端API,直接跳转到结果页
* 生产环境:需要调用 submitPlanAPI 提交表单数据
* @param {Object} formData - 表单数据
*/
const handlePlanSubmit = (formData) => {
console.log('计划书提交:', {
product_id: selectedProduct.value.id,
product_name: selectedProduct.value.product_name || selectedProduct.value.name,
form_sn: selectedProduct.value.form_sn,
form_data: formData
})
// 关闭弹窗
showPlanPopup.value = false
// TODO: 后端接口还没有准备好,暂时不调用API
// 测试完成后需要对接 submitPlanAPI
// const res = await submitPlanAPI({
// product_id: selectedProduct.value.id,
// template: selectedProduct.value.form_sn,
// form_data: formData
// });
// 模拟提交成功,跳转到结果页面
go('/pages/plan-submit-result/index', {
success: 'true'
})
}
/**
* 处理收藏状态改变
*
* @description 当用户点击收藏按钮时,更新本地状态
* @param {Object} item - 资料对象
* @param {Object} newStatus - 新的状态
*/
const handleCollectChanged = (item, newStatus) => {
console.log('[Search] 收藏状态改变:', item.title, newStatus.collected)
// 找到对应的项并更新状态
const file = files.value.find(f => f.id === item.id)
if (file) {
file.collected = newStatus.collected
}
}
</script>
<style lang="less">
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.search-result-item {
animation: slideIn 0.3s cubic-bezier(0.2, 0.8, 0.2, 1) backwards;
}
/* 加载动画 */
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading-spinner-small {
width: 32rpx;
height: 32rpx;
border: 3rpx solid #E5E7EB;
border-top-color: #4CAF50;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
// FilterTabs 风格的标签栏
.filter-tabs-wrapper {
display: flex;
overflow-x: auto;
padding: 24rpx 60rpx;
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>