hookehuyr

refactor: 迁移所有剩余页面到 LoadMoreList 组件

- message 页面:添加下拉刷新功能
- product-center 页面:保留搜索、tabs、计划书弹窗
- material-list 页面:保留分类缓存、搜索防抖
- search 页面:保留双列表、自动 tab 切换、三种状态

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
<!--
* @Date: 2026-01-31
* @Description: 资料列表页 - 已改造为 NutTabs 版本
* @Date: 2026-02-08
* @Description: 资料/文档列表页 - 使用 LoadMoreList 组件重构版本
-->
<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"
<LoadMoreList
:list="currentList"
:page="currentPage"
:page-size="pageSize"
:has-more="hasMore"
:loading="loading"
:loading-more="loadingMore"
key-field="meta_id"
@load-more="handleLoadMore"
>
<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` }">
<!-- 头部:导航 + 搜索 + Tabs -->
<template #header>
<view class="sticky top-0 z-10 bg-[#F9FAFB]">
<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>
</template>
<!-- 列表项:资料卡片 -->
<template #item="{ item }">
<view
class="material-item bg-white rounded-[24rpx] p-[24rpx] shadow-sm transition-all duration-200 border border-gray-50 flex flex-row"
>
<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
......@@ -69,7 +75,7 @@
{{ item.desc }}
</p>
<view class="flex items-center gap-[12rpx] mb-[20rpx]">
<view class="flex items-center gap-[12rpx] mb-[16rpx]">
<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) }}
......@@ -92,33 +98,23 @@
/>
</view>
</view>
</template>
<!-- 空状态 -->
<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>
<!-- 空状态 -->
<template #empty>
<nut-empty description="暂无相关资料" image="empty" />
</template>
</LoadMoreList>
</view>
</template>
<script setup>
import { ref, computed, nextTick, watch } from 'vue'
import { useLoad, useReachBottom } from '@tarojs/taro'
import { ref, computed, watch } from 'vue'
import { useLoad } from '@tarojs/taro'
import NavHeader from '@/components/NavHeader.vue'
import SearchBar from '@/components/SearchBar.vue'
import ListItemActions from '@/components/ListItemActions/index.vue'
import LoadMoreList from '@/components/LoadMoreList'
import { useListItemClick, ListType } from '@/composables/useListItemClick'
import { getDocumentIcon, getDocumentLabel } from '@/utils/documentIcons'
import { debounce } from '@/utils/debounce'
......@@ -132,8 +128,6 @@ const USE_MOCK_DATA = process.env.NODE_ENV === 'development'
const searchValue = ref('')
const activeTabId = ref('all') // 默认选中"全部"
const listVisible = ref(true)
const listRenderKey = ref(0)
/**
* 防抖搜索函数
......@@ -267,6 +261,7 @@ const transformDocItem = (doc) => {
/**
* 获取文档分类列表
*
* @param {Object} params - 请求参数
* @param {string} params.cid - 分类ID(可选)
* @param {string} params.child_id - 子分类ID(可选)
......@@ -274,6 +269,7 @@ const transformDocItem = (doc) => {
* @param {number} params.page - 页码(从0开始)
* @param {number} params.limit - 每页数量
* @param {boolean} isLoadMore - 是否为加载更多
* @returns {Promise<void>}
*/
const fetchMaterialList = async (params = {}, isLoadMore = false) => {
try {
......@@ -296,9 +292,6 @@ const fetchMaterialList = async (params = {}, isLoadMore = false) => {
// 如果是初始请求(没有 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) {
......@@ -419,12 +412,67 @@ useLoad(async (options) => {
})
/**
* 处理加载更多事件
*
* @param {number} page - 下一页页码
* @returns {Promise<void>}
*/
const handleLoadMore = async (page) => {
console.log('[Material List] 加载更多,页码:', page)
// 更新页码
currentPage.value = page
// 构建请求参数
const params = {
cid: initialCategoryId.value,
page: page,
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
})
}
/**
* Tab 点击处理
*
* @param {string} id - Tab ID
*/
const onTabClick = async (id) => {
if (activeTabId.value === id) return
console.log('[Material List] 切换分类:', id)
activeTabId.value = id
listVisible.value = false
// 恢复或初始化该分类的分页状态
const pageState = categoryPageCache.value.get(id)
......@@ -462,82 +510,15 @@ const onTabClick = async (id) => {
})
}
}
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)
console.log('[Material List] 搜索产品:', searchValue.value)
console.log('[Material List] 当前分类:', activeTabId.value)
// 如果没有搜索关键词,清空搜索并恢复当前分类的列表
if (!searchValue.value.trim()) {
......@@ -651,15 +632,10 @@ watch(searchValue, (newValue, oldValue) => {
})
/**
* 清除搜索关键词
* @description 用户点击搜索框右侧的删除按钮时触发,重新请求当前分类的最新数据
*
* 场景说明:
* - 有tab:重新请求当前tab的数据(不带keyword)
* - 无tab:重新请求"全部"数据(不带keyword)
* 清空搜索
*/
const onClear = async () => {
console.log('[Material List] 清除搜索,重新请求数据')
console.log('[Material List] 清空搜索')
console.log('[Material List] 当前分类:', activeTabId.value)
// 构建请求参数(不带 keyword)
......@@ -742,7 +718,7 @@ const { handleClick: onView } = useListItemClick({
return true
},
onAfterClick: (item) => {
console.log('用户打开了资料:', item.title)
console.log('[Material List] 用户打开了资料:', item.title)
}
})
......@@ -765,8 +741,6 @@ const onDelete = (item) => {
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' })
}
}
......@@ -776,22 +750,6 @@ const onDelete = (item) => {
</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;
......@@ -848,40 +806,22 @@ const onDelete = (item) => {
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;
/* 多行文本省略 */
.line-clamp-1 {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
line-clamp: 1;
overflow: hidden;
word-break: break-all;
}
.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);
}
.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-08
* @Description: 我的消息页 - 使用 LoadMoreList 组件重构版本
-->
<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>
<LoadMoreList
:list="currentList"
:page="currentPage"
:page-size="pageSize"
:has-more="hasMore"
:loading="loading"
:loading-more="loadingMore"
:enable-pull-down-refresh="true"
key-field="id"
@load-more="handleLoadMore"
@refresh="handleRefresh"
>
<!-- 头部 -->
<template #header>
<NavHeader title="我的消息" />
</template>
<!-- 列表项 -->
<template #item="{ item }">
<view
class="message-item 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>
<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>
<text class="text-xs text-gray-400 shrink-0 mt-1">
{{ item.create_time }}
</text>
</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 class="text-sm text-gray-600 line-clamp-2 leading-relaxed">
{{ item.intro || item.content || '暂无简介' }}
</view>
</template>
<!-- 空状态 -->
<nut-empty
v-else-if="!loading && messageList.length === 0"
description="暂无消息"
image="empty"
/>
</view>
</view>
</view>
</template>
<!-- 空状态 -->
<template #empty>
<nut-empty description="暂无消息" image="empty" />
</template>
</LoadMoreList>
</template>
<script setup>
import { ref } from 'vue'
import { useLoad, usePullDownRefresh, useReachBottom, stopPullDownRefresh } from '@tarojs/taro'
import { useLoad } from '@tarojs/taro'
import { useGo } from '@/hooks/useGo'
import LoadMoreList from '@/components/LoadMoreList'
import NavHeader from '@/components/NavHeader.vue'
import { myListAPI } from '@/api/news'
import { mockMessageListAPI } from '@/utils/mockData'
......@@ -58,91 +64,165 @@ const USE_MOCK_DATA = process.env.NODE_ENV === 'development'
const go = useGo()
const messageList = ref([])
const page = ref(1)
const limit = ref(10)
/**
* 当前列表数据
* @type {Ref<Array<any>>}
*/
const currentList = ref([])
/**
* 当前页码(从1开始)
* @type {Ref<number>}
*/
const currentPage = ref(1)
/**
* 每页数量
* @type {number}
*/
const pageSize = 10
/**
* 是否还有更多数据
* @type {Ref<boolean>}
*/
const hasMore = ref(true)
const loading = ref(false)
/**
* @description 加载消息列表
* @param {boolean} refresh 是否刷新
* 首次加载状态
* @type {Ref<boolean>}
*/
const fetchMessageList = async (refresh = false) => {
if (loading.value) return
if (refresh) {
page.value = 1
hasMore.value = true
} else if (!hasMore.value) {
return
}
const loading = ref(false)
loading.value = true
/**
* 加载更多状态
* @type {Ref<boolean>}
*/
const loadingMore = ref(false)
/**
* 获取消息列表
*
* @param {Object} params - 请求参数
* @param {number} params.page - 页码(从1开始)
* @param {number} params.limit - 每页数量
* @param {boolean} isLoadMore - 是否为加载更多
* @returns {Promise<void>}
*/
const fetchMessageList = async (params = {}, isLoadMore = false) => {
try {
// 如果是加载更多,使用 loadingMore 状态,否则使用 loading 状态
if (isLoadMore) {
loadingMore.value = true
} else {
loading.value = true
}
console.log('[Message] 请求参数:', params)
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
? await mockMessageListAPI(params)
: await myListAPI(params)
if (res.code === 1 && res.data) {
console.log('[Message] 数据:', res.data)
// 处理列表数据
if (res.data.list?.length) {
const listData = res.data.list
if (isLoadMore) {
// 加载更多:追加数据
currentList.value = [...currentList.value, ...listData]
} else {
// 首次加载或刷新:替换数据
currentList.value = listData
}
// 判断是否还有更多数据
// 如果返回的数据量少于请求的量,说明没有更多了
hasMore.value = listData.length >= params.limit
} else {
messageList.value = [...messageList.value, ...list]
}
if (list.length < limit.value) {
hasMore.value = false
} else {
page.value++
// 没有数据了
if (isLoadMore) {
hasMore.value = false
} else {
currentList.value = []
}
}
} else {
console.error('[Message] API 返回错误:', res.msg)
}
} catch (err) {
console.error('获取消息列表失败:', err)
} catch (error) {
console.error('[Message] 获取消息列表失败:', error)
} finally {
loading.value = false
if (refresh) {
stopPullDownRefresh()
if (isLoadMore) {
loadingMore.value = false
} else {
loading.value = false
}
}
}
/**
* @description 跳转到详情页
* @param {Object} item 消息对象
* 页面加载时获取数据
*/
const handleItemClick = (item) => {
go('/pages/message-detail/index', { id: item.id })
}
useLoad(async (options) => {
console.log('[Message] 页面参数:', options)
// 页面加载
useLoad(() => {
fetchMessageList(true)
})
// 重置分页状态
currentPage.value = 1
hasMore.value = true
// 下拉刷新
usePullDownRefresh(() => {
fetchMessageList(true)
// 获取消息列表
await fetchMessageList({ page: 1, limit: pageSize })
})
// 上拉加载更多
useReachBottom(() => {
fetchMessageList()
})
/**
* 处理加载更多事件
*
* @param {number} page - 下一页页码
* @returns {Promise<void>}
*/
const handleLoadMore = async (page) => {
console.log('[Message] 加载更多,页码:', page)
// 更新页码
currentPage.value = page
// 加载下一页数据
await fetchMessageList(
{ page: page, limit: pageSize },
true // 标记为加载更多
)
}
/**
* 处理下拉刷新事件
*/
const handleRefresh = async () => {
console.log('[Message] 下拉刷新')
// 重置分页状态
currentPage.value = 1
hasMore.value = true
// 刷新数据
await fetchMessageList({ page: 1, limit: pageSize })
}
/**
* 跳转到详情页
*
* @param {Object} item - 消息对象
*/
const handleItemClick = (item) => {
go('/pages/message-detail/index', { id: item.id })
}
</script>
<style lang="less">
/* Scoped styles if needed */
/* LoadMoreList 组件已内置样式,此处无需额外样式 */
</style>
......
<!--
* @Date: 2026-01-31
* @Description: 产品中心 - API 接口集成版本(含搜索功能)
* @Date: 2026-02-08
* @Description: 产品中心 - 使用 LoadMoreList 组件重构版本
-->
<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 v-if="showPlanPopup && selectedProduct">
<PlanFormContainer
v-model:visible="showPlanPopup"
:product="selectedProduct"
@close="showPlanPopup = false"
@submit="handlePlanSubmit"
/>
</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` }"
<LoadMoreList
:list="currentList"
:page="currentPage"
:page-size="pageSize"
:has-more="hasMore"
:loading="loading"
:loading-more="loadingMore"
key-field="id"
@load-more="handleLoadMore"
>
<!-- 头部:导航 + 搜索 + Tabs -->
<template #header>
<view class="sticky top-0 z-10 bg-[#F9FAFB]">
<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>
</template>
<!-- 列表项:产品卡片 -->
<template #item="{ item }">
<view
class="bg-white rounded-[24rpx] overflow-hidden shadow-sm active:scale-[0.98] transition-transform duration-200 product-card"
@tap="handleProductClick(item)"
>
<!-- Product Content (Horizontal Layout) -->
<view class="flex gap-[24rpx]" @tap="handleProductClick(item)">
<view class="flex gap-[24rpx]">
<!-- Image Container -->
<view class="relative w-[220rpx] h-[220rpx] flex-shrink-0 product-card-item">
<view class="relative w-[220rpx] h-[220rpx] flex-shrink-0">
<image :src="item.cover_image" class="w-full h-full object-cover bg-gray-100" mode="aspectFill" />
<!-- Tag -->
<view v-if="item.recommend === 'hot'"
......@@ -113,68 +127,74 @@
</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>
</template>
<!-- 空状态 -->
<view v-if="!loading && products.length === 0">
<template #empty>
<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>
</template>
</LoadMoreList>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import Taro, { useLoad, useReachBottom } from '@tarojs/taro'
import Taro, { useLoad } from '@tarojs/taro'
import { useGo } from '@/hooks/useGo'
import { useListItemClick, ListType } from '@/composables/useListItemClick'
import LoadMoreList from '@/components/LoadMoreList'
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 go = useGo()
// 搜索状态
const searchValue = ref('')
// 搜索防抖定时器
let searchTimer = null
/**
* 当前列表数据
* @type {Ref<Array<any>>}
*/
const currentList = ref([])
// 分页状态
const page = ref(0)
const limit = ref(10)
const loading = ref(false)
/**
* 当前页码(从0开始)
* @type {Ref<number>}
*/
const currentPage = ref(0)
/**
* 每页数量
* @type {number}
*/
const pageSize = 10
/**
* 是否还有更多数据
* @type {Ref<boolean>}
*/
const hasMore = ref(true)
// 数据状态
/**
* 首次加载状态
* @type {Ref<boolean>}
*/
const loading = ref(false)
/**
* 加载更多状态
* @type {Ref<boolean>}
*/
const loadingMore = ref(false)
// 搜索和 Tabs 相关状态
const activeTabId = ref('')
const searchValue = ref('')
const categories = ref([]) // 从接口获取的分类列表
const products = ref([]) // 当前产品列表
const total = ref(0) // 产品总数
let searchTimer = null // 搜索防抖定时器
// 计划书弹窗状态
const showPlanPopup = ref(false)
......@@ -195,29 +215,23 @@ const tabsData = computed(() => {
/**
* 获取产品列表
* @description 根据 activeTabId 和 searchValue 获取对应分类的产品列表
*
* @param {Object} params - 请求参数
* @param {number} params.page - 页码(从0开始)
* @param {number} params.limit - 每页数量
* @param {boolean} isLoadMore - 是否为加载更多
* @returns {Promise<void>}
*/
const fetchProducts = async (isLoadMore = false) => {
if (loading.value) return
loading.value = true
const fetchProducts = async (params = {}, isLoadMore = false) => {
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
// 如果是加载更多,使用 loadingMore 状态,否则使用 loading 状态
if (isLoadMore) {
loadingMore.value = true
} else {
loading.value = true
}
console.log('[Product Center] 请求参数:', params)
console.log('[Product Center] 使用 Mock 数据:', USE_MOCK_DATA)
// 根据开关选择使用真实 API 或 Mock 数据
......@@ -226,65 +240,139 @@ const fetchProducts = async (isLoadMore = false) => {
: await listAPI(params)
if (res.code === 1 && res.data) {
console.log('[Product Center] 数据:', res.data)
// 更新分类列表(首次加载时)
if (!isLoadMore && res.data.categories) {
categories.value = res.data.categories
}
// 处理产品列表
if (isLoadMore) {
// 加载更多:追加数据
products.value = [...products.value, ...res.data.list]
if (res.data.list?.length) {
const listData = res.data.list
if (isLoadMore) {
// 加载更多:追加数据
currentList.value = [...currentList.value, ...listData]
} else {
// 首次加载或切换分类:替换数据
currentList.value = listData
}
// 判断是否还有更多数据
// 如果返回的数据量少于请求的量,说明没有更多了
hasMore.value = listData.length >= params.limit
} else {
// 首次加载或切换分类:替换数据
products.value = res.data.list || []
// 没有数据了
if (isLoadMore) {
hasMore.value = false
} else {
currentList.value = []
}
}
// 更新总数和分页状态
total.value = res.data.total || 0
hasMore.value = products.value.length < total.value
} else {
Taro.showToast({
title: res.msg || '获取产品列表失败',
icon: 'none'
})
console.error('[Product Center] API 返回错误:', res.msg)
}
} catch (err) {
console.error('获取产品列表失败:', err)
Taro.showToast({
title: '网络错误,请重试',
icon: 'none'
})
} catch (error) {
console.error('[Product Center] 获取产品列表失败:', error)
} finally {
loading.value = false
if (isLoadMore) {
loadingMore.value = false
} else {
loading.value = false
}
}
}
/**
* 页面加载时获取数据
*/
useLoad(async (options) => {
console.log('[Product Center] 页面参数:', options)
// 重置分页状态
currentPage.value = 0
hasMore.value = true
// 获取产品列表
await fetchProducts({ page: 0, limit: pageSize })
})
/**
* 处理加载更多事件
*
* @param {number} page - 下一页页码
* @returns {Promise<void>}
*/
const handleLoadMore = async (page) => {
console.log('[Product Center] 加载更多,页码:', page)
// 更新页码
currentPage.value = page
// 构建请求参数
const params = {
page: page,
limit: pageSize
}
// 如果不是"全部"标签,添加分类 ID 参数
if (activeTabId.value !== '') {
params.cid = activeTabId.value
}
// 添加搜索关键词参数
if (searchValue.value) {
params.keyword = searchValue.value
}
// 加载下一页数据
await fetchProducts(params, true)
}
/**
* Tab 点击处理
* @description 切换分类,重置分页并重新加载数据
*
* @param {string} id - 分类 ID
*/
const onTabClick = (id) => {
if (activeTabId.value === id) return
console.log('[Product Center] 切换分类:', id)
activeTabId.value = id
// 重置分页状态
page.value = 0
products.value = []
currentPage.value = 0
hasMore.value = true
// 构建请求参数
const params = {
page: 0,
limit: pageSize
}
// 如果不是"全部"标签,添加分类 ID 参数
if (id !== '') {
params.cid = id
}
// 添加搜索关键词参数
if (searchValue.value) {
params.keyword = searchValue.value
}
// 重新加载数据(保持搜索状态)
fetchProducts(false)
fetchProducts(params, false)
}
/**
* 搜索输入处理(带防抖)
* @description 用户输入时实时搜索,使用防抖优化性能
*
* @param {string} value - 搜索关键词
*/
const onSearchInput = (value) => {
console.log('搜索输入:', value)
console.log('[Product Center] 搜索输入:', value)
// 清除之前的定时器
if (searchTimer) {
......@@ -294,22 +382,37 @@ const onSearchInput = (value) => {
// 设置新的定时器(500ms 后执行搜索)
searchTimer = setTimeout(() => {
// 重置分页状态
page.value = 0
products.value = []
currentPage.value = 0
hasMore.value = true
// 构建请求参数
const params = {
page: 0,
limit: pageSize
}
// 如果不是"全部"标签,添加分类 ID 参数
if (activeTabId.value !== '') {
params.cid = activeTabId.value
}
// 添加搜索关键词参数
if (value) {
params.keyword = value
}
// 重新加载数据
fetchProducts(false)
fetchProducts(params, false)
}, 500)
}
/**
* 搜索处理(回车键)
* @description 用户按下回车或点击搜索按钮时触发
*
* @param {string} value - 搜索关键词
*/
const onSearch = (value) => {
console.log('搜索产品:', value)
console.log('[Product Center] 搜索产品:', value)
// 清除防抖定时器
if (searchTimer) {
......@@ -318,20 +421,34 @@ const onSearch = (value) => {
}
// 重置分页状态
page.value = 0
products.value = []
currentPage.value = 0
hasMore.value = true
// 构建请求参数
const params = {
page: 0,
limit: pageSize
}
// 如果不是"全部"标签,添加分类 ID 参数
if (activeTabId.value !== '') {
params.cid = activeTabId.value
}
// 添加搜索关键词参数
if (value) {
params.keyword = value
}
// 重新加载数据
fetchProducts(false)
fetchProducts(params, false)
}
/**
* 清空搜索
* @description 用户点击清除按钮时触发
*/
const onClear = () => {
console.log('清空搜索')
console.log('[Product Center] 清空搜索')
// 清除防抖定时器
if (searchTimer) {
......@@ -340,12 +457,22 @@ const onClear = () => {
}
// 重置分页状态
page.value = 0
products.value = []
currentPage.value = 0
hasMore.value = true
// 构建请求参数
const params = {
page: 0,
limit: pageSize
}
// 如果不是"全部"标签,添加分类 ID 参数
if (activeTabId.value !== '') {
params.cid = activeTabId.value
}
// 重新加载数据
fetchProducts(false)
fetchProducts(params, false)
}
/**
......@@ -355,13 +482,13 @@ const onClear = () => {
const { handleClick: handleProductClick } = useListItemClick({
listType: ListType.PRODUCT,
onAfterClick: (item) => {
console.log('用户查看了产品:', item.product_name)
console.log('[Product Center] 用户查看了产品:', item.product_name)
}
})
/**
* 打开计划书弹窗
* @description 根据产品对象打开计划书表单
*
* @param {Object} product - 产品对象
*/
const openPlanPopup = (product) => {
......@@ -371,12 +498,11 @@ const openPlanPopup = (product) => {
/**
* 处理计划书提交
* @description 测试环境:前端不调用后端API,直接跳转到结果页
* 生产环境:需要调用 submitPlanAPI 提交表单数据
*
* @param {Object} formData - 表单数据
*/
const handlePlanSubmit = (formData) => {
console.log('计划书提交:', {
console.log('[Product Center] 计划书提交:', {
product_id: selectedProduct.value.id,
product_name: selectedProduct.value.product_name,
form_sn: selectedProduct.value.form_sn,
......@@ -388,59 +514,15 @@ const handlePlanSubmit = (formData) => {
// 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;
......
<!--
* @Date: 2026-02-06
* @Description: 搜索页面 - 支持产品和资料搜索,实时查询API
* @Date: 2026-02-08
* @Description: 搜索页面 - 使用 LoadMoreList 组件重构版本
* @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>
<LoadMoreList
:list="currentList"
:page="currentPage"
:page-size="pageSize"
:has-more="hasMore"
:loading="loading"
:loading-more="loadingMore"
:show-header="true"
key-field="id"
@load-more="handleLoadMore"
>
<!-- 固定顶部:导航栏 + 搜索栏 + Tabs + 结果计数 -->
<template #header>
<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>
</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>
</template>
<!-- 列表项:根据 activeTab 动态渲染 -->
<template #item="{ item }">
<!-- Product Results -->
<ProductCard
v-if="activeTab === 'product'"
:product-id="item.id"
:product-name="item.product_name || item.name"
:tags="item.tags || []"
class="search-result-item"
@detail="goToProductDetail"
@plan="openPlanPopup"
/>
<!-- 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)"
/>
<MaterialCard
v-else-if="activeTab === 'file'"
: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"
@collect-changed="handleCollectChanged(item, $event)"
/>
</template>
<!-- 自定义空状态:处理三种状态 -->
<template #empty>
<!-- Initial State (从未搜索过) -->
<view v-if="!hasSearched" 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 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>
<!-- Empty State (已搜索但无结果) -->
<view v-else 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>
</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>
</template>
</LoadMoreList>
<!-- Plan Form Container -->
<!-- 仅当 selectedProduct 不为 null 时才渲染组件,避免 product prop required 警告 -->
......@@ -132,8 +124,9 @@
<script setup>
import { ref, computed } from 'vue'
import Taro, { useReachBottom } from '@tarojs/taro'
import Taro from '@tarojs/taro'
import { useGo } from '@/hooks/useGo'
import LoadMoreList from '@/components/LoadMoreList'
import NavHeader from '@/components/NavHeader.vue'
import IconFont from '@/components/IconFont.vue'
import SearchBar from '@/components/SearchBar.vue'
......@@ -146,9 +139,13 @@ import { mockSearchAPI } from '@/utils/mockData'
// ⚠️ MOCK 数据开关 - 开发环境使用 mock 数据,生产环境使用真实 API
const USE_MOCK_DATA = process.env.NODE_ENV === 'development'
// Navigation
const go = useGo()
/**
* 搜索页面状态管理
* @description 支持双类型(产品/资料)搜索,自动切换分类
*/
// Plan Popup State
const showPlanPopup = ref(false)
const selectedProduct = ref(null)
......@@ -157,13 +154,15 @@ const selectedProduct = ref(null)
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 loading = ref(false) // 首次加载状态
const loadingMore = ref(false) // 加载更多状态
const hasMore = ref(true) // 是否还有更多数据
const currentPage = ref(0) // 当前页码(从0开始)
......@@ -179,6 +178,7 @@ const tabsData = ref([
/**
* 当前显示的列表
* @description 根据 activeTab 动态返回对应的列表数据
*/
const currentList = computed(() => {
// 如果没有选中任何tab,返回空数组
......@@ -207,11 +207,13 @@ const currentTotal = computed(() => {
/**
* 执行搜索
*
* @param {string} keyword - 搜索关键字
* @param {string} type - 可选,'product' | 'file' | undefined
* @param {number} page - 页码(从0开始)
* @param {number} limit - 每页数量
* @param {boolean} isLoadMore - 是否为加载更多
* @returns {Promise<void>}
*/
const performSearch = async (keyword, type, page = 0, limit = pageSize, isLoadMore = false) => {
try {
......@@ -219,7 +221,7 @@ const performSearch = async (keyword, type, page = 0, limit = pageSize, isLoadMo
if (isLoadMore) {
loadingMore.value = true
} else {
Taro.showLoading({ title: '搜索中...', mask: true })
loading.value = true
}
const params = { keyword, page, limit }
......@@ -295,7 +297,6 @@ const performSearch = async (keyword, type, page = 0, limit = pageSize, isLoadMo
}
hasSearched.value = true
listRenderKey.value += 1
console.log('[Search] 搜索成功', {
productsTotal: productsTotal.value,
......@@ -320,20 +321,48 @@ const performSearch = async (keyword, type, page = 0, limit = pageSize, isLoadMo
if (isLoadMore) {
loadingMore.value = false
} else {
Taro.hideLoading()
loading.value = false
}
}
}
/**
* 处理加载更多事件
*
* @param {number} page - 下一页页码
* @returns {Promise<void>}
*/
const handleLoadMore = async (page) => {
console.log('[Search] 加载更多,页码:', page)
// 更新页码
currentPage.value = page
// 如果没有搜索过或没有选中 tab,不执行
if (!hasSearched.value || !activeTab.value || !searchKeyword.value.trim()) {
return
}
// 加载下一页数据
await performSearch(
searchKeyword.value.trim(),
activeTab.value,
page,
pageSize,
true // 标记为加载更多
)
}
/**
* Tab 点击处理(实时查询)
*
* @param {string} tabId - Tab ID
*/
const onTabClick = async (tabId) => {
if (activeTab.value === tabId) return
// 立即切换 tab(响应更快)
activeTab.value = tabId
listRenderKey.value += 1
// 重置分页状态
currentPage.value = 0
......@@ -383,51 +412,11 @@ const clearSearch = () => {
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) => {
......@@ -437,7 +426,6 @@ const goToProductDetail = (productId) => {
/**
* 打开计划书弹窗
*
* @description 根据产品ID找到对应的产品对象,并打开计划书表单
* @param {number} productId - 产品ID
*/
const openPlanPopup = (productId) => {
......@@ -461,8 +449,6 @@ const openPlanPopup = (productId) => {
/**
* 处理计划书提交
*
* @description 测试环境:前端不调用后端API,直接跳转到结果页
* 生产环境:需要调用 submitPlanAPI 提交表单数据
* @param {Object} formData - 表单数据
*/
const handlePlanSubmit = (formData) => {
......@@ -478,11 +464,6 @@ const handlePlanSubmit = (formData) => {
// 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', {
......@@ -493,9 +474,8 @@ const handlePlanSubmit = (formData) => {
/**
* 处理收藏状态改变
*
* @description 当用户点击收藏按钮时,更新本地状态
* @param {Object} item - 资料对象
* @param {Object} newStatus - 新的状态
* @param {Object} newStatus - 新的状态 { collected: boolean }
*/
const handleCollectChanged = (item, newStatus) => {
console.log('[Search] 收藏状态改变:', item.title, newStatus.collected)
......@@ -505,45 +485,9 @@ const handleCollectChanged = (item, newStatus) => {
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;
......@@ -599,4 +543,6 @@ const handleCollectChanged = (item, newStatus) => {
:deep(.nut-tabs__content) {
display: none;
}
/* LoadMoreList 组件已内置动画和加载状态,此处无需额外样式 */
</style>
......