refactor: 迁移所有剩余页面到 LoadMoreList 组件
- message 页面:添加下拉刷新功能 - product-center 页面:保留搜索、tabs、计划书弹窗 - material-list 页面:保留分类缓存、搜索防抖 - search 页面:保留双列表、自动 tab 切换、三种状态 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Showing
4 changed files
with
582 additions
and
534 deletions
| 1 | <!-- | 1 | <!-- |
| 2 | - * @Date: 2026-01-31 | 2 | + * @Date: 2026-02-08 |
| 3 | - * @Description: 资料列表页 - 已改造为 NutTabs 版本 | 3 | + * @Description: 资料/文档列表页 - 使用 LoadMoreList 组件重构版本 |
| 4 | --> | 4 | --> |
| 5 | <template> | 5 | <template> |
| 6 | <view class="bg-[#F9FAFB]"> | 6 | <view class="bg-[#F9FAFB]"> |
| 7 | - <!-- 固定在顶部的导航和搜索 --> | 7 | + <LoadMoreList |
| 8 | - <view class="bg-[#F9FAFB] sticky top-0 z-10"> | 8 | + :list="currentList" |
| 9 | + :page="currentPage" | ||
| 10 | + :page-size="pageSize" | ||
| 11 | + :has-more="hasMore" | ||
| 12 | + :loading="loading" | ||
| 13 | + :loading-more="loadingMore" | ||
| 14 | + key-field="meta_id" | ||
| 15 | + @load-more="handleLoadMore" | ||
| 16 | + > | ||
| 17 | + <!-- 头部:导航 + 搜索 + Tabs --> | ||
| 18 | + <template #header> | ||
| 19 | + <view class="sticky top-0 z-10 bg-[#F9FAFB]"> | ||
| 9 | <NavHeader :title="pageTitle" /> | 20 | <NavHeader :title="pageTitle" /> |
| 10 | 21 | ||
| 11 | <view class="px-[32rpx] mt-[32rpx] mb-[24rpx]"> | 22 | <view class="px-[32rpx] mt-[32rpx] mb-[24rpx]"> |
| ... | @@ -40,18 +51,13 @@ | ... | @@ -40,18 +51,13 @@ |
| 40 | </template> | 51 | </template> |
| 41 | </nut-tabs> | 52 | </nut-tabs> |
| 42 | </view> | 53 | </view> |
| 54 | + </template> | ||
| 43 | 55 | ||
| 44 | - <!-- 列表容器 - 页面级滚动 --> | 56 | + <!-- 列表项:资料卡片 --> |
| 57 | + <template #item="{ item }"> | ||
| 45 | <view | 58 | <view |
| 46 | - v-if="listVisible" | ||
| 47 | - :key="listRenderKey" | ||
| 48 | - class="px-[32rpx] pb-[calc(160rpx+env(safe-area-inset-bottom))] box-border" | ||
| 49 | - > | ||
| 50 | - <view class="flex flex-col gap-[24rpx]"> | ||
| 51 | - <view v-for="(item, index) in currentList" :key="index" | ||
| 52 | class="material-item bg-white rounded-[24rpx] p-[24rpx] shadow-sm transition-all duration-200 border border-gray-50 flex flex-row" | 59 | class="material-item bg-white rounded-[24rpx] p-[24rpx] shadow-sm transition-all duration-200 border border-gray-50 flex flex-row" |
| 53 | - :style="{ animationDelay: `${index * 50}ms` }"> | 60 | + > |
| 54 | - | ||
| 55 | <view | 61 | <view |
| 56 | 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"> | 62 | 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"> |
| 57 | <image | 63 | <image |
| ... | @@ -69,7 +75,7 @@ | ... | @@ -69,7 +75,7 @@ |
| 69 | {{ item.desc }} | 75 | {{ item.desc }} |
| 70 | </p> | 76 | </p> |
| 71 | 77 | ||
| 72 | - <view class="flex items-center gap-[12rpx] mb-[20rpx]"> | 78 | + <view class="flex items-center gap-[12rpx] mb-[16rpx]"> |
| 73 | <view | 79 | <view |
| 74 | class="inline-flex items-center justify-center px-[12rpx] py-[4rpx] bg-gray-100 text-gray-500 text-[20rpx] font-medium rounded-[8rpx]"> | 80 | class="inline-flex items-center justify-center px-[12rpx] py-[4rpx] bg-gray-100 text-gray-500 text-[20rpx] font-medium rounded-[8rpx]"> |
| 75 | {{ getDocumentLabel(item.extension ? `file.${item.extension}` : item.fileName) }} | 81 | {{ getDocumentLabel(item.extension ? `file.${item.extension}` : item.fileName) }} |
| ... | @@ -92,33 +98,23 @@ | ... | @@ -92,33 +98,23 @@ |
| 92 | /> | 98 | /> |
| 93 | </view> | 99 | </view> |
| 94 | </view> | 100 | </view> |
| 101 | + </template> | ||
| 95 | 102 | ||
| 96 | <!-- 空状态 --> | 103 | <!-- 空状态 --> |
| 97 | - <view v-if="currentList.length === 0 && !loading"> | 104 | + <template #empty> |
| 98 | <nut-empty description="暂无相关资料" image="empty" /> | 105 | <nut-empty description="暂无相关资料" image="empty" /> |
| 99 | - </view> | 106 | + </template> |
| 100 | - | 107 | + </LoadMoreList> |
| 101 | - <!-- 加载更多状态 --> | ||
| 102 | - <view v-if="currentList.length > 0" class="load-more-container"> | ||
| 103 | - <view v-if="loadingMore" class="load-more-loading"> | ||
| 104 | - <view class="loading-spinner"></view> | ||
| 105 | - <text class="ml-[16rpx] text-[#9CA3AF] text-[24rpx]">加载中...</text> | ||
| 106 | - </view> | ||
| 107 | - <view v-else-if="!hasMore" class="load-more-finished"> | ||
| 108 | - <text class="text-[#9CA3AF] text-[24rpx]">没有更多了</text> | ||
| 109 | - </view> | ||
| 110 | - </view> | ||
| 111 | - </view> | ||
| 112 | - </view> | ||
| 113 | </view> | 108 | </view> |
| 114 | </template> | 109 | </template> |
| 115 | 110 | ||
| 116 | <script setup> | 111 | <script setup> |
| 117 | -import { ref, computed, nextTick, watch } from 'vue' | 112 | +import { ref, computed, watch } from 'vue' |
| 118 | -import { useLoad, useReachBottom } from '@tarojs/taro' | 113 | +import { useLoad } from '@tarojs/taro' |
| 119 | import NavHeader from '@/components/NavHeader.vue' | 114 | import NavHeader from '@/components/NavHeader.vue' |
| 120 | import SearchBar from '@/components/SearchBar.vue' | 115 | import SearchBar from '@/components/SearchBar.vue' |
| 121 | import ListItemActions from '@/components/ListItemActions/index.vue' | 116 | import ListItemActions from '@/components/ListItemActions/index.vue' |
| 117 | +import LoadMoreList from '@/components/LoadMoreList' | ||
| 122 | import { useListItemClick, ListType } from '@/composables/useListItemClick' | 118 | import { useListItemClick, ListType } from '@/composables/useListItemClick' |
| 123 | import { getDocumentIcon, getDocumentLabel } from '@/utils/documentIcons' | 119 | import { getDocumentIcon, getDocumentLabel } from '@/utils/documentIcons' |
| 124 | import { debounce } from '@/utils/debounce' | 120 | import { debounce } from '@/utils/debounce' |
| ... | @@ -132,8 +128,6 @@ const USE_MOCK_DATA = process.env.NODE_ENV === 'development' | ... | @@ -132,8 +128,6 @@ const USE_MOCK_DATA = process.env.NODE_ENV === 'development' |
| 132 | 128 | ||
| 133 | const searchValue = ref('') | 129 | const searchValue = ref('') |
| 134 | const activeTabId = ref('all') // 默认选中"全部" | 130 | const activeTabId = ref('all') // 默认选中"全部" |
| 135 | -const listVisible = ref(true) | ||
| 136 | -const listRenderKey = ref(0) | ||
| 137 | 131 | ||
| 138 | /** | 132 | /** |
| 139 | * 防抖搜索函数 | 133 | * 防抖搜索函数 |
| ... | @@ -267,6 +261,7 @@ const transformDocItem = (doc) => { | ... | @@ -267,6 +261,7 @@ const transformDocItem = (doc) => { |
| 267 | 261 | ||
| 268 | /** | 262 | /** |
| 269 | * 获取文档分类列表 | 263 | * 获取文档分类列表 |
| 264 | + * | ||
| 270 | * @param {Object} params - 请求参数 | 265 | * @param {Object} params - 请求参数 |
| 271 | * @param {string} params.cid - 分类ID(可选) | 266 | * @param {string} params.cid - 分类ID(可选) |
| 272 | * @param {string} params.child_id - 子分类ID(可选) | 267 | * @param {string} params.child_id - 子分类ID(可选) |
| ... | @@ -274,6 +269,7 @@ const transformDocItem = (doc) => { | ... | @@ -274,6 +269,7 @@ const transformDocItem = (doc) => { |
| 274 | * @param {number} params.page - 页码(从0开始) | 269 | * @param {number} params.page - 页码(从0开始) |
| 275 | * @param {number} params.limit - 每页数量 | 270 | * @param {number} params.limit - 每页数量 |
| 276 | * @param {boolean} isLoadMore - 是否为加载更多 | 271 | * @param {boolean} isLoadMore - 是否为加载更多 |
| 272 | + * @returns {Promise<void>} | ||
| 277 | */ | 273 | */ |
| 278 | const fetchMaterialList = async (params = {}, isLoadMore = false) => { | 274 | const fetchMaterialList = async (params = {}, isLoadMore = false) => { |
| 279 | try { | 275 | try { |
| ... | @@ -296,9 +292,6 @@ const fetchMaterialList = async (params = {}, isLoadMore = false) => { | ... | @@ -296,9 +292,6 @@ const fetchMaterialList = async (params = {}, isLoadMore = false) => { |
| 296 | // 如果是初始请求(没有 child_id),保存完整的分类信息 | 292 | // 如果是初始请求(没有 child_id),保存完整的分类信息 |
| 297 | if (!params.child_id && !params.keyword) { | 293 | if (!params.child_id && !params.keyword) { |
| 298 | data.value = res.data | 294 | data.value = res.data |
| 299 | - // console.log('[Material List] 数据:', res.data) | ||
| 300 | - // console.log('[Material List] 分类数量:', res.data.children?.length) | ||
| 301 | - // console.log('[Material List] 文档数量:', res.data.list?.length) | ||
| 302 | 295 | ||
| 303 | // 处理并缓存"全部"列表 | 296 | // 处理并缓存"全部"列表 |
| 304 | if (res.data.list?.length) { | 297 | if (res.data.list?.length) { |
| ... | @@ -419,12 +412,67 @@ useLoad(async (options) => { | ... | @@ -419,12 +412,67 @@ useLoad(async (options) => { |
| 419 | }) | 412 | }) |
| 420 | 413 | ||
| 421 | /** | 414 | /** |
| 415 | + * 处理加载更多事件 | ||
| 416 | + * | ||
| 417 | + * @param {number} page - 下一页页码 | ||
| 418 | + * @returns {Promise<void>} | ||
| 419 | + */ | ||
| 420 | +const handleLoadMore = async (page) => { | ||
| 421 | + console.log('[Material List] 加载更多,页码:', page) | ||
| 422 | + | ||
| 423 | + // 更新页码 | ||
| 424 | + currentPage.value = page | ||
| 425 | + | ||
| 426 | + // 构建请求参数 | ||
| 427 | + const params = { | ||
| 428 | + cid: initialCategoryId.value, | ||
| 429 | + page: page, | ||
| 430 | + limit: pageSize | ||
| 431 | + } | ||
| 432 | + | ||
| 433 | + // 判断当前状态:搜索、子分类、或全部 | ||
| 434 | + const isSearching = searchValue.value.trim() !== '' | ||
| 435 | + | ||
| 436 | + if (isSearching) { | ||
| 437 | + // 搜索模式 | ||
| 438 | + params.keyword = searchValue.value.trim() | ||
| 439 | + if (activeTabId.value !== 'all') { | ||
| 440 | + params.child_id = activeTabId.value | ||
| 441 | + } | ||
| 442 | + } else { | ||
| 443 | + // 非搜索模式:如果当前选中的是子分类,添加 child_id 参数 | ||
| 444 | + if (activeTabId.value !== 'all') { | ||
| 445 | + params.child_id = activeTabId.value | ||
| 446 | + } | ||
| 447 | + } | ||
| 448 | + | ||
| 449 | + // 加载下一页数据 | ||
| 450 | + await fetchMaterialList(params, true) // true 表示加载更多 | ||
| 451 | + | ||
| 452 | + // 保存更新后的分页状态 | ||
| 453 | + let cacheKey | ||
| 454 | + if (isSearching) { | ||
| 455 | + cacheKey = params.keyword | ||
| 456 | + } else { | ||
| 457 | + cacheKey = activeTabId.value !== 'all' ? activeTabId.value : 'all' | ||
| 458 | + } | ||
| 459 | + categoryPageCache.value.set(cacheKey, { | ||
| 460 | + currentPage: currentPage.value, | ||
| 461 | + hasMore: hasMore.value | ||
| 462 | + }) | ||
| 463 | +} | ||
| 464 | + | ||
| 465 | +/** | ||
| 422 | * Tab 点击处理 | 466 | * Tab 点击处理 |
| 467 | + * | ||
| 423 | * @param {string} id - Tab ID | 468 | * @param {string} id - Tab ID |
| 424 | */ | 469 | */ |
| 425 | const onTabClick = async (id) => { | 470 | const onTabClick = async (id) => { |
| 471 | + if (activeTabId.value === id) return | ||
| 472 | + | ||
| 473 | + console.log('[Material List] 切换分类:', id) | ||
| 474 | + | ||
| 426 | activeTabId.value = id | 475 | activeTabId.value = id |
| 427 | - listVisible.value = false | ||
| 428 | 476 | ||
| 429 | // 恢复或初始化该分类的分页状态 | 477 | // 恢复或初始化该分类的分页状态 |
| 430 | const pageState = categoryPageCache.value.get(id) | 478 | const pageState = categoryPageCache.value.get(id) |
| ... | @@ -462,82 +510,15 @@ const onTabClick = async (id) => { | ... | @@ -462,82 +510,15 @@ const onTabClick = async (id) => { |
| 462 | }) | 510 | }) |
| 463 | } | 511 | } |
| 464 | } | 512 | } |
| 465 | - | ||
| 466 | - nextTick(() => { | ||
| 467 | - listRenderKey.value += 1 | ||
| 468 | - listVisible.value = true | ||
| 469 | - }) | ||
| 470 | } | 513 | } |
| 471 | 514 | ||
| 472 | /** | 515 | /** |
| 473 | - * 触底加载更多 | ||
| 474 | - * @description 使用防抖避免频繁触发 | ||
| 475 | - */ | ||
| 476 | -let loadMoreTimer = null | ||
| 477 | -useReachBottom(() => { | ||
| 478 | - // 如果正在加载或没有更多数据,不执行 | ||
| 479 | - if (loadingMore.value || loading.value || !hasMore.value) { | ||
| 480 | - return | ||
| 481 | - } | ||
| 482 | - | ||
| 483 | - // 防抖:300ms 内只触发一次 | ||
| 484 | - if (loadMoreTimer) { | ||
| 485 | - clearTimeout(loadMoreTimer) | ||
| 486 | - } | ||
| 487 | - | ||
| 488 | - loadMoreTimer = setTimeout(async () => { | ||
| 489 | - console.log('[Material List] 触底加载更多') | ||
| 490 | - | ||
| 491 | - // 页码 +1 | ||
| 492 | - currentPage.value += 1 | ||
| 493 | - | ||
| 494 | - // 构建请求参数 | ||
| 495 | - const params = { | ||
| 496 | - cid: initialCategoryId.value, | ||
| 497 | - page: currentPage.value, | ||
| 498 | - limit: pageSize | ||
| 499 | - } | ||
| 500 | - | ||
| 501 | - // 判断当前状态:搜索、子分类、或全部 | ||
| 502 | - const isSearching = searchValue.value.trim() !== '' | ||
| 503 | - | ||
| 504 | - if (isSearching) { | ||
| 505 | - // 搜索模式 | ||
| 506 | - params.keyword = searchValue.value.trim() | ||
| 507 | - if (activeTabId.value !== 'all') { | ||
| 508 | - params.child_id = activeTabId.value | ||
| 509 | - } | ||
| 510 | - } else { | ||
| 511 | - // 非搜索模式:如果当前选中的是子分类,添加 child_id 参数 | ||
| 512 | - if (activeTabId.value !== 'all') { | ||
| 513 | - params.child_id = activeTabId.value | ||
| 514 | - } | ||
| 515 | - } | ||
| 516 | - | ||
| 517 | - // 加载下一页数据 | ||
| 518 | - await fetchMaterialList(params, true) // true 表示加载更多 | ||
| 519 | - | ||
| 520 | - // 保存更新后的分页状态 | ||
| 521 | - let cacheKey | ||
| 522 | - if (isSearching) { | ||
| 523 | - cacheKey = params.keyword | ||
| 524 | - } else { | ||
| 525 | - cacheKey = activeTabId.value !== 'all' ? activeTabId.value : 'all' | ||
| 526 | - } | ||
| 527 | - categoryPageCache.value.set(cacheKey, { | ||
| 528 | - currentPage: currentPage.value, | ||
| 529 | - hasMore: hasMore.value | ||
| 530 | - }) | ||
| 531 | - }, 300) | ||
| 532 | -}) | ||
| 533 | - | ||
| 534 | -/** | ||
| 535 | * 搜索处理函数 | 516 | * 搜索处理函数 |
| 536 | * @description 根据 child_id 和 keyword 调用接口查询列表 | 517 | * @description 根据 child_id 和 keyword 调用接口查询列表 |
| 537 | */ | 518 | */ |
| 538 | const onSearch = async () => { | 519 | const onSearch = async () => { |
| 539 | - console.log('Searching for:', searchValue.value) | 520 | + console.log('[Material List] 搜索产品:', searchValue.value) |
| 540 | - console.log('当前分类:', activeTabId.value) | 521 | + console.log('[Material List] 当前分类:', activeTabId.value) |
| 541 | 522 | ||
| 542 | // 如果没有搜索关键词,清空搜索并恢复当前分类的列表 | 523 | // 如果没有搜索关键词,清空搜索并恢复当前分类的列表 |
| 543 | if (!searchValue.value.trim()) { | 524 | if (!searchValue.value.trim()) { |
| ... | @@ -651,15 +632,10 @@ watch(searchValue, (newValue, oldValue) => { | ... | @@ -651,15 +632,10 @@ watch(searchValue, (newValue, oldValue) => { |
| 651 | }) | 632 | }) |
| 652 | 633 | ||
| 653 | /** | 634 | /** |
| 654 | - * 清除搜索关键词 | 635 | + * 清空搜索 |
| 655 | - * @description 用户点击搜索框右侧的删除按钮时触发,重新请求当前分类的最新数据 | ||
| 656 | - * | ||
| 657 | - * 场景说明: | ||
| 658 | - * - 有tab:重新请求当前tab的数据(不带keyword) | ||
| 659 | - * - 无tab:重新请求"全部"数据(不带keyword) | ||
| 660 | */ | 636 | */ |
| 661 | const onClear = async () => { | 637 | const onClear = async () => { |
| 662 | - console.log('[Material List] 清除搜索,重新请求数据') | 638 | + console.log('[Material List] 清空搜索') |
| 663 | console.log('[Material List] 当前分类:', activeTabId.value) | 639 | console.log('[Material List] 当前分类:', activeTabId.value) |
| 664 | 640 | ||
| 665 | // 构建请求参数(不带 keyword) | 641 | // 构建请求参数(不带 keyword) |
| ... | @@ -742,7 +718,7 @@ const { handleClick: onView } = useListItemClick({ | ... | @@ -742,7 +718,7 @@ const { handleClick: onView } = useListItemClick({ |
| 742 | return true | 718 | return true |
| 743 | }, | 719 | }, |
| 744 | onAfterClick: (item) => { | 720 | onAfterClick: (item) => { |
| 745 | - console.log('用户打开了资料:', item.title) | 721 | + console.log('[Material List] 用户打开了资料:', item.title) |
| 746 | } | 722 | } |
| 747 | }) | 723 | }) |
| 748 | 724 | ||
| ... | @@ -765,8 +741,6 @@ const onDelete = (item) => { | ... | @@ -765,8 +741,6 @@ const onDelete = (item) => { |
| 765 | const index = allList.value.findIndex(i => i.id === item.id) | 741 | const index = allList.value.findIndex(i => i.id === item.id) |
| 766 | if (index !== -1) { | 742 | if (index !== -1) { |
| 767 | allList.value.splice(index, 1) | 743 | allList.value.splice(index, 1) |
| 768 | - // 重新渲染列表 | ||
| 769 | - listRenderKey.value += 1 | ||
| 770 | Taro.showToast({ title: '已删除', icon: 'success' }) | 744 | Taro.showToast({ title: '已删除', icon: 'success' }) |
| 771 | } | 745 | } |
| 772 | } | 746 | } |
| ... | @@ -776,22 +750,6 @@ const onDelete = (item) => { | ... | @@ -776,22 +750,6 @@ const onDelete = (item) => { |
| 776 | </script> | 750 | </script> |
| 777 | 751 | ||
| 778 | <style lang="less"> | 752 | <style lang="less"> |
| 779 | -@keyframes slideIn { | ||
| 780 | - from { | ||
| 781 | - opacity: 0; | ||
| 782 | - transform: translateY(20rpx); | ||
| 783 | - } | ||
| 784 | - | ||
| 785 | - to { | ||
| 786 | - opacity: 1; | ||
| 787 | - transform: translateY(0); | ||
| 788 | - } | ||
| 789 | -} | ||
| 790 | - | ||
| 791 | -.material-item { | ||
| 792 | - animation: slideIn 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) backwards; | ||
| 793 | -} | ||
| 794 | - | ||
| 795 | // FilterTabs 风格的标签栏 | 753 | // FilterTabs 风格的标签栏 |
| 796 | .filter-tabs-wrapper { | 754 | .filter-tabs-wrapper { |
| 797 | display: flex; | 755 | display: flex; |
| ... | @@ -848,40 +806,22 @@ const onDelete = (item) => { | ... | @@ -848,40 +806,22 @@ const onDelete = (item) => { |
| 848 | display: none; | 806 | display: none; |
| 849 | } | 807 | } |
| 850 | 808 | ||
| 851 | -// 加载更多容器 | 809 | +/* 多行文本省略 */ |
| 852 | -.load-more-container { | 810 | +.line-clamp-1 { |
| 853 | - display: flex; | 811 | + display: -webkit-box; |
| 854 | - justify-content: center; | 812 | + -webkit-box-orient: vertical; |
| 855 | - align-items: center; | 813 | + -webkit-line-clamp: 1; |
| 856 | - padding: 40rpx 0; | 814 | + line-clamp: 1; |
| 857 | - min-height: 80rpx; | 815 | + overflow: hidden; |
| 858 | -} | 816 | + word-break: break-all; |
| 859 | - | ||
| 860 | -.load-more-loading { | ||
| 861 | - display: flex; | ||
| 862 | - align-items: center; | ||
| 863 | - justify-content: center; | ||
| 864 | } | 817 | } |
| 865 | 818 | ||
| 866 | -.load-more-finished { | 819 | +.line-clamp-2 { |
| 867 | - display: flex; | 820 | + display: -webkit-box; |
| 868 | - align-items: center; | 821 | + -webkit-box-orient: vertical; |
| 869 | - justify-content: center; | 822 | + -webkit-line-clamp: 2; |
| 870 | -} | 823 | + line-clamp: 2; |
| 871 | - | 824 | + overflow: hidden; |
| 872 | -// 自定义加载动画 | 825 | + word-break: break-all; |
| 873 | -.loading-spinner { | ||
| 874 | - width: 32rpx; | ||
| 875 | - height: 32rpx; | ||
| 876 | - border: 4rpx solid #E5E7EB; | ||
| 877 | - border-top-color: #2563EB; | ||
| 878 | - border-radius: 50%; | ||
| 879 | - animation: spin 0.8s linear infinite; | ||
| 880 | -} | ||
| 881 | - | ||
| 882 | -@keyframes spin { | ||
| 883 | - to { | ||
| 884 | - transform: rotate(360deg); | ||
| 885 | - } | ||
| 886 | } | 826 | } |
| 887 | </style> | 827 | </style> | ... | ... |
| 1 | +<!-- | ||
| 2 | + * @Date: 2026-02-08 | ||
| 3 | + * @Description: 我的消息页 - 使用 LoadMoreList 组件重构版本 | ||
| 4 | +--> | ||
| 1 | <template> | 5 | <template> |
| 2 | - <view class="min-h-screen bg-[#F9FAFB] pb-safe"> | 6 | + <LoadMoreList |
| 7 | + :list="currentList" | ||
| 8 | + :page="currentPage" | ||
| 9 | + :page-size="pageSize" | ||
| 10 | + :has-more="hasMore" | ||
| 11 | + :loading="loading" | ||
| 12 | + :loading-more="loadingMore" | ||
| 13 | + :enable-pull-down-refresh="true" | ||
| 14 | + key-field="id" | ||
| 15 | + @load-more="handleLoadMore" | ||
| 16 | + @refresh="handleRefresh" | ||
| 17 | + > | ||
| 18 | + <!-- 头部 --> | ||
| 19 | + <template #header> | ||
| 3 | <NavHeader title="我的消息" /> | 20 | <NavHeader title="我的消息" /> |
| 21 | + </template> | ||
| 4 | 22 | ||
| 5 | - <!-- 列表区域 --> | 23 | + <!-- 列表项 --> |
| 6 | - <view class="p-4"> | 24 | + <template #item="{ item }"> |
| 7 | - <template v-if="messageList.length > 0"> | ||
| 8 | <view | 25 | <view |
| 9 | - v-for="item in messageList" | 26 | + class="message-item bg-white rounded-xl p-4 mb-3 shadow-sm active:opacity-70 transition-opacity" |
| 10 | - :key="item.id" | ||
| 11 | - class="bg-white rounded-xl p-4 mb-3 shadow-sm active:opacity-70 transition-opacity" | ||
| 12 | @tap="handleItemClick(item)" | 27 | @tap="handleItemClick(item)" |
| 13 | > | 28 | > |
| 14 | <view class="flex justify-between items-start mb-2"> | 29 | <view class="flex justify-between items-start mb-2"> |
| ... | @@ -26,29 +41,20 @@ | ... | @@ -26,29 +41,20 @@ |
| 26 | {{ item.intro || item.content || '暂无简介' }} | 41 | {{ item.intro || item.content || '暂无简介' }} |
| 27 | </view> | 42 | </view> |
| 28 | </view> | 43 | </view> |
| 29 | - | ||
| 30 | - <!-- 加载更多/没有更多 --> | ||
| 31 | - <view class="py-4 text-center text-[24rpx] text-gray-400"> | ||
| 32 | - <text v-if="loading">加载中...</text> | ||
| 33 | - <text v-else-if="!hasMore">没有更多了</text> | ||
| 34 | - <text v-else>上拉加载更多</text> | ||
| 35 | - </view> | ||
| 36 | </template> | 44 | </template> |
| 37 | 45 | ||
| 38 | <!-- 空状态 --> | 46 | <!-- 空状态 --> |
| 39 | - <nut-empty | 47 | + <template #empty> |
| 40 | - v-else-if="!loading && messageList.length === 0" | 48 | + <nut-empty description="暂无消息" image="empty" /> |
| 41 | - description="暂无消息" | 49 | + </template> |
| 42 | - image="empty" | 50 | + </LoadMoreList> |
| 43 | - /> | ||
| 44 | - </view> | ||
| 45 | - </view> | ||
| 46 | </template> | 51 | </template> |
| 47 | 52 | ||
| 48 | <script setup> | 53 | <script setup> |
| 49 | import { ref } from 'vue' | 54 | import { ref } from 'vue' |
| 50 | -import { useLoad, usePullDownRefresh, useReachBottom, stopPullDownRefresh } from '@tarojs/taro' | 55 | +import { useLoad } from '@tarojs/taro' |
| 51 | import { useGo } from '@/hooks/useGo' | 56 | import { useGo } from '@/hooks/useGo' |
| 57 | +import LoadMoreList from '@/components/LoadMoreList' | ||
| 52 | import NavHeader from '@/components/NavHeader.vue' | 58 | import NavHeader from '@/components/NavHeader.vue' |
| 53 | import { myListAPI } from '@/api/news' | 59 | import { myListAPI } from '@/api/news' |
| 54 | import { mockMessageListAPI } from '@/utils/mockData' | 60 | import { mockMessageListAPI } from '@/utils/mockData' |
| ... | @@ -58,91 +64,165 @@ const USE_MOCK_DATA = process.env.NODE_ENV === 'development' | ... | @@ -58,91 +64,165 @@ const USE_MOCK_DATA = process.env.NODE_ENV === 'development' |
| 58 | 64 | ||
| 59 | const go = useGo() | 65 | const go = useGo() |
| 60 | 66 | ||
| 61 | -const messageList = ref([]) | 67 | +/** |
| 62 | -const page = ref(1) | 68 | + * 当前列表数据 |
| 63 | -const limit = ref(10) | 69 | + * @type {Ref<Array<any>>} |
| 70 | + */ | ||
| 71 | +const currentList = ref([]) | ||
| 72 | + | ||
| 73 | +/** | ||
| 74 | + * 当前页码(从1开始) | ||
| 75 | + * @type {Ref<number>} | ||
| 76 | + */ | ||
| 77 | +const currentPage = ref(1) | ||
| 78 | + | ||
| 79 | +/** | ||
| 80 | + * 每页数量 | ||
| 81 | + * @type {number} | ||
| 82 | + */ | ||
| 83 | +const pageSize = 10 | ||
| 84 | + | ||
| 85 | +/** | ||
| 86 | + * 是否还有更多数据 | ||
| 87 | + * @type {Ref<boolean>} | ||
| 88 | + */ | ||
| 64 | const hasMore = ref(true) | 89 | const hasMore = ref(true) |
| 65 | -const loading = ref(false) | ||
| 66 | 90 | ||
| 67 | /** | 91 | /** |
| 68 | - * @description 加载消息列表 | 92 | + * 首次加载状态 |
| 69 | - * @param {boolean} refresh 是否刷新 | 93 | + * @type {Ref<boolean>} |
| 70 | */ | 94 | */ |
| 71 | -const fetchMessageList = async (refresh = false) => { | 95 | +const loading = ref(false) |
| 72 | - if (loading.value) return | ||
| 73 | 96 | ||
| 74 | - if (refresh) { | 97 | +/** |
| 75 | - page.value = 1 | 98 | + * 加载更多状态 |
| 76 | - hasMore.value = true | 99 | + * @type {Ref<boolean>} |
| 77 | - } else if (!hasMore.value) { | 100 | + */ |
| 78 | - return | 101 | +const loadingMore = ref(false) |
| 79 | - } | ||
| 80 | 102 | ||
| 103 | +/** | ||
| 104 | + * 获取消息列表 | ||
| 105 | + * | ||
| 106 | + * @param {Object} params - 请求参数 | ||
| 107 | + * @param {number} params.page - 页码(从1开始) | ||
| 108 | + * @param {number} params.limit - 每页数量 | ||
| 109 | + * @param {boolean} isLoadMore - 是否为加载更多 | ||
| 110 | + * @returns {Promise<void>} | ||
| 111 | + */ | ||
| 112 | +const fetchMessageList = async (params = {}, isLoadMore = false) => { | ||
| 113 | + try { | ||
| 114 | + // 如果是加载更多,使用 loadingMore 状态,否则使用 loading 状态 | ||
| 115 | + if (isLoadMore) { | ||
| 116 | + loadingMore.value = true | ||
| 117 | + } else { | ||
| 81 | loading.value = true | 118 | loading.value = true |
| 119 | + } | ||
| 82 | 120 | ||
| 83 | - try { | 121 | + console.log('[Message] 请求参数:', params) |
| 84 | console.log('[Message] 使用 Mock 数据:', USE_MOCK_DATA) | 122 | console.log('[Message] 使用 Mock 数据:', USE_MOCK_DATA) |
| 85 | 123 | ||
| 86 | // 根据开关选择使用真实 API 或 Mock 数据 | 124 | // 根据开关选择使用真实 API 或 Mock 数据 |
| 87 | const res = USE_MOCK_DATA | 125 | const res = USE_MOCK_DATA |
| 88 | - ? await mockMessageListAPI({ | 126 | + ? await mockMessageListAPI(params) |
| 89 | - page: page.value, | 127 | + : await myListAPI(params) |
| 90 | - limit: limit.value | 128 | + |
| 91 | - }) | 129 | + if (res.code === 1 && res.data) { |
| 92 | - : await myListAPI({ | 130 | + console.log('[Message] 数据:', res.data) |
| 93 | - page: page.value, | 131 | + |
| 94 | - limit: limit.value | 132 | + // 处理列表数据 |
| 95 | - }) | 133 | + if (res.data.list?.length) { |
| 96 | - | 134 | + const listData = res.data.list |
| 97 | - if (res.code === 1) { | 135 | + |
| 98 | - const list = res.data?.list || [] | 136 | + if (isLoadMore) { |
| 99 | - | 137 | + // 加载更多:追加数据 |
| 100 | - if (refresh) { | 138 | + currentList.value = [...currentList.value, ...listData] |
| 101 | - messageList.value = list | ||
| 102 | } else { | 139 | } else { |
| 103 | - messageList.value = [...messageList.value, ...list] | 140 | + // 首次加载或刷新:替换数据 |
| 141 | + currentList.value = listData | ||
| 104 | } | 142 | } |
| 105 | 143 | ||
| 106 | - if (list.length < limit.value) { | 144 | + // 判断是否还有更多数据 |
| 145 | + // 如果返回的数据量少于请求的量,说明没有更多了 | ||
| 146 | + hasMore.value = listData.length >= params.limit | ||
| 147 | + } else { | ||
| 148 | + // 没有数据了 | ||
| 149 | + if (isLoadMore) { | ||
| 107 | hasMore.value = false | 150 | hasMore.value = false |
| 108 | } else { | 151 | } else { |
| 109 | - page.value++ | 152 | + currentList.value = [] |
| 153 | + } | ||
| 110 | } | 154 | } |
| 155 | + } else { | ||
| 156 | + console.error('[Message] API 返回错误:', res.msg) | ||
| 111 | } | 157 | } |
| 112 | - } catch (err) { | 158 | + } catch (error) { |
| 113 | - console.error('获取消息列表失败:', err) | 159 | + console.error('[Message] 获取消息列表失败:', error) |
| 114 | } finally { | 160 | } finally { |
| 161 | + if (isLoadMore) { | ||
| 162 | + loadingMore.value = false | ||
| 163 | + } else { | ||
| 115 | loading.value = false | 164 | loading.value = false |
| 116 | - if (refresh) { | ||
| 117 | - stopPullDownRefresh() | ||
| 118 | } | 165 | } |
| 119 | } | 166 | } |
| 120 | } | 167 | } |
| 121 | 168 | ||
| 122 | /** | 169 | /** |
| 123 | - * @description 跳转到详情页 | 170 | + * 页面加载时获取数据 |
| 124 | - * @param {Object} item 消息对象 | ||
| 125 | */ | 171 | */ |
| 126 | -const handleItemClick = (item) => { | 172 | +useLoad(async (options) => { |
| 127 | - go('/pages/message-detail/index', { id: item.id }) | 173 | + console.log('[Message] 页面参数:', options) |
| 128 | -} | ||
| 129 | 174 | ||
| 130 | -// 页面加载 | 175 | + // 重置分页状态 |
| 131 | -useLoad(() => { | 176 | + currentPage.value = 1 |
| 132 | - fetchMessageList(true) | 177 | + hasMore.value = true |
| 133 | -}) | ||
| 134 | 178 | ||
| 135 | -// 下拉刷新 | 179 | + // 获取消息列表 |
| 136 | -usePullDownRefresh(() => { | 180 | + await fetchMessageList({ page: 1, limit: pageSize }) |
| 137 | - fetchMessageList(true) | ||
| 138 | }) | 181 | }) |
| 139 | 182 | ||
| 140 | -// 上拉加载更多 | 183 | +/** |
| 141 | -useReachBottom(() => { | 184 | + * 处理加载更多事件 |
| 142 | - fetchMessageList() | 185 | + * |
| 143 | -}) | 186 | + * @param {number} page - 下一页页码 |
| 187 | + * @returns {Promise<void>} | ||
| 188 | + */ | ||
| 189 | +const handleLoadMore = async (page) => { | ||
| 190 | + console.log('[Message] 加载更多,页码:', page) | ||
| 191 | + | ||
| 192 | + // 更新页码 | ||
| 193 | + currentPage.value = page | ||
| 194 | + | ||
| 195 | + // 加载下一页数据 | ||
| 196 | + await fetchMessageList( | ||
| 197 | + { page: page, limit: pageSize }, | ||
| 198 | + true // 标记为加载更多 | ||
| 199 | + ) | ||
| 200 | +} | ||
| 201 | + | ||
| 202 | +/** | ||
| 203 | + * 处理下拉刷新事件 | ||
| 204 | + */ | ||
| 205 | +const handleRefresh = async () => { | ||
| 206 | + console.log('[Message] 下拉刷新') | ||
| 207 | + | ||
| 208 | + // 重置分页状态 | ||
| 209 | + currentPage.value = 1 | ||
| 210 | + hasMore.value = true | ||
| 211 | + | ||
| 212 | + // 刷新数据 | ||
| 213 | + await fetchMessageList({ page: 1, limit: pageSize }) | ||
| 214 | +} | ||
| 215 | + | ||
| 216 | +/** | ||
| 217 | + * 跳转到详情页 | ||
| 218 | + * | ||
| 219 | + * @param {Object} item - 消息对象 | ||
| 220 | + */ | ||
| 221 | +const handleItemClick = (item) => { | ||
| 222 | + go('/pages/message-detail/index', { id: item.id }) | ||
| 223 | +} | ||
| 144 | </script> | 224 | </script> |
| 145 | 225 | ||
| 146 | <style lang="less"> | 226 | <style lang="less"> |
| 147 | -/* Scoped styles if needed */ | 227 | +/* LoadMoreList 组件已内置样式,此处无需额外样式 */ |
| 148 | </style> | 228 | </style> | ... | ... |
| 1 | <!-- | 1 | <!-- |
| 2 | - * @Date: 2026-01-31 | 2 | + * @Date: 2026-02-08 |
| 3 | - * @Description: 产品中心 - API 接口集成版本(含搜索功能) | 3 | + * @Description: 产品中心 - 使用 LoadMoreList 组件重构版本 |
| 4 | --> | 4 | --> |
| 5 | <template> | 5 | <template> |
| 6 | <view class="bg-[#F9FAFB]"> | 6 | <view class="bg-[#F9FAFB]"> |
| 7 | - <!-- 固定在顶部的导航和搜索 --> | 7 | + <!-- 计划书弹窗 --> |
| 8 | - <view class="bg-[#F9FAFB] sticky top-0 z-10"> | 8 | + <view v-if="showPlanPopup && selectedProduct"> |
| 9 | + <PlanFormContainer | ||
| 10 | + v-model:visible="showPlanPopup" | ||
| 11 | + :product="selectedProduct" | ||
| 12 | + @close="showPlanPopup = false" | ||
| 13 | + @submit="handlePlanSubmit" | ||
| 14 | + /> | ||
| 15 | + </view> | ||
| 16 | + | ||
| 17 | + <LoadMoreList | ||
| 18 | + :list="currentList" | ||
| 19 | + :page="currentPage" | ||
| 20 | + :page-size="pageSize" | ||
| 21 | + :has-more="hasMore" | ||
| 22 | + :loading="loading" | ||
| 23 | + :loading-more="loadingMore" | ||
| 24 | + key-field="id" | ||
| 25 | + @load-more="handleLoadMore" | ||
| 26 | + > | ||
| 27 | + <!-- 头部:导航 + 搜索 + Tabs --> | ||
| 28 | + <template #header> | ||
| 29 | + <view class="sticky top-0 z-10 bg-[#F9FAFB]"> | ||
| 9 | <NavHeader title="产品中心" /> | 30 | <NavHeader title="产品中心" /> |
| 10 | 31 | ||
| 11 | <!-- Search Bar --> | 32 | <!-- Search Bar --> |
| ... | @@ -43,25 +64,18 @@ | ... | @@ -43,25 +64,18 @@ |
| 43 | </nut-tabs> | 64 | </nut-tabs> |
| 44 | </view> | 65 | </view> |
| 45 | </view> | 66 | </view> |
| 67 | + </template> | ||
| 46 | 68 | ||
| 47 | - <!-- 列表容器 - 页面级滚动 --> | 69 | + <!-- 列表项:产品卡片 --> |
| 48 | - <view class="pb-[calc(160rpx+env(safe-area-inset-bottom))]"> | 70 | + <template #item="{ item }"> |
| 49 | - <!-- 加载状态 --> | 71 | + <view |
| 50 | - <view v-if="loading && products.length === 0" class="flex justify-center items-center py-[100rpx]"> | 72 | + class="bg-white rounded-[24rpx] overflow-hidden shadow-sm active:scale-[0.98] transition-transform duration-200 product-card" |
| 51 | - <text class="text-gray-400 text-[28rpx]">加载中...</text> | 73 | + @tap="handleProductClick(item)" |
| 52 | - </view> | ||
| 53 | - | ||
| 54 | - <!-- Product List --> | ||
| 55 | - <view v-else class="px-[40rpx]"> | ||
| 56 | - <!-- Card Item --> | ||
| 57 | - <view v-for="(item, index) in products" :key="item.id" | ||
| 58 | - class="bg-white rounded-[24rpx] overflow-hidden mb-[24rpx] shadow-sm active:scale-[0.98] transition-transform duration-200" | ||
| 59 | - :style="{ animationDelay: `${index * 50}ms` }" | ||
| 60 | > | 74 | > |
| 61 | <!-- Product Content (Horizontal Layout) --> | 75 | <!-- Product Content (Horizontal Layout) --> |
| 62 | - <view class="flex gap-[24rpx]" @tap="handleProductClick(item)"> | 76 | + <view class="flex gap-[24rpx]"> |
| 63 | <!-- Image Container --> | 77 | <!-- Image Container --> |
| 64 | - <view class="relative w-[220rpx] h-[220rpx] flex-shrink-0 product-card-item"> | 78 | + <view class="relative w-[220rpx] h-[220rpx] flex-shrink-0"> |
| 65 | <image :src="item.cover_image" class="w-full h-full object-cover bg-gray-100" mode="aspectFill" /> | 79 | <image :src="item.cover_image" class="w-full h-full object-cover bg-gray-100" mode="aspectFill" /> |
| 66 | <!-- Tag --> | 80 | <!-- Tag --> |
| 67 | <view v-if="item.recommend === 'hot'" | 81 | <view v-if="item.recommend === 'hot'" |
| ... | @@ -113,68 +127,74 @@ | ... | @@ -113,68 +127,74 @@ |
| 113 | </view> | 127 | </view> |
| 114 | </view> | 128 | </view> |
| 115 | </view> | 129 | </view> |
| 116 | - </view> | 130 | + </template> |
| 117 | - | ||
| 118 | - <!-- 加载更多状态 --> | ||
| 119 | - <view v-if="loading && products.length > 0" class="flex justify-center items-center py-[40rpx]"> | ||
| 120 | - <text class="text-gray-400 text-[28rpx]">加载中...</text> | ||
| 121 | - </view> | ||
| 122 | - | ||
| 123 | - <!-- 没有更多数据 --> | ||
| 124 | - <view v-if="!hasMore && products.length > 0" class="flex justify-center items-center py-[40rpx]"> | ||
| 125 | - <text class="text-gray-400 text-[24rpx]">没有更多了</text> | ||
| 126 | - </view> | ||
| 127 | 131 | ||
| 128 | <!-- 空状态 --> | 132 | <!-- 空状态 --> |
| 129 | - <view v-if="!loading && products.length === 0"> | 133 | + <template #empty> |
| 130 | <nut-empty description="暂无相关产品" image="empty" /> | 134 | <nut-empty description="暂无相关产品" image="empty" /> |
| 131 | - </view> | 135 | + </template> |
| 132 | - </view> | 136 | + </LoadMoreList> |
| 133 | - | ||
| 134 | - <!-- 计划书表单容器 --> | ||
| 135 | - <!-- 测试数据:后端接口和字段还没有准备好,使用 PlanFormContainer 进行的前端测试 --> | ||
| 136 | - <!-- 使用 v-if 条件渲染,避免 selectedProduct 为 null 时的 prop 类型检查错误 --> | ||
| 137 | - <view v-if="showPlanPopup && selectedProduct"> | ||
| 138 | - <PlanFormContainer | ||
| 139 | - v-model:visible="showPlanPopup" | ||
| 140 | - :product="selectedProduct" | ||
| 141 | - @close="showPlanPopup = false" | ||
| 142 | - @submit="handlePlanSubmit" | ||
| 143 | - /> | ||
| 144 | - </view> | ||
| 145 | </view> | 137 | </view> |
| 146 | </template> | 138 | </template> |
| 147 | 139 | ||
| 148 | <script setup> | 140 | <script setup> |
| 149 | import { ref, computed } from 'vue' | 141 | import { ref, computed } from 'vue' |
| 150 | -import Taro, { useLoad, useReachBottom } from '@tarojs/taro' | 142 | +import Taro, { useLoad } from '@tarojs/taro' |
| 143 | +import { useGo } from '@/hooks/useGo' | ||
| 144 | +import { useListItemClick, ListType } from '@/composables/useListItemClick' | ||
| 145 | +import LoadMoreList from '@/components/LoadMoreList' | ||
| 151 | import NavHeader from '@/components/NavHeader.vue' | 146 | import NavHeader from '@/components/NavHeader.vue' |
| 152 | import SearchBar from '@/components/SearchBar.vue' | 147 | import SearchBar from '@/components/SearchBar.vue' |
| 153 | import PlanFormContainer from '@/components/PlanFormContainer.vue' | 148 | import PlanFormContainer from '@/components/PlanFormContainer.vue' |
| 154 | -import { useListItemClick, ListType } from '@/composables/useListItemClick' | ||
| 155 | import { listAPI } from '@/api/get_product' | 149 | import { listAPI } from '@/api/get_product' |
| 156 | import { mockProductListAPI } from '@/utils/mockData' | 150 | import { mockProductListAPI } from '@/utils/mockData' |
| 157 | 151 | ||
| 158 | // ⚠️ MOCK 数据开关 - 开发环境使用 mock 数据,生产环境使用真实 API | 152 | // ⚠️ MOCK 数据开关 - 开发环境使用 mock 数据,生产环境使用真实 API |
| 159 | const USE_MOCK_DATA = process.env.NODE_ENV === 'development' | 153 | const USE_MOCK_DATA = process.env.NODE_ENV === 'development' |
| 160 | 154 | ||
| 161 | -const activeTabId = ref('') | 155 | +const go = useGo() |
| 162 | 156 | ||
| 163 | -// 搜索状态 | 157 | +/** |
| 164 | -const searchValue = ref('') | 158 | + * 当前列表数据 |
| 165 | -// 搜索防抖定时器 | 159 | + * @type {Ref<Array<any>>} |
| 166 | -let searchTimer = null | 160 | + */ |
| 161 | +const currentList = ref([]) | ||
| 167 | 162 | ||
| 168 | -// 分页状态 | 163 | +/** |
| 169 | -const page = ref(0) | 164 | + * 当前页码(从0开始) |
| 170 | -const limit = ref(10) | 165 | + * @type {Ref<number>} |
| 171 | -const loading = ref(false) | 166 | + */ |
| 167 | +const currentPage = ref(0) | ||
| 168 | + | ||
| 169 | +/** | ||
| 170 | + * 每页数量 | ||
| 171 | + * @type {number} | ||
| 172 | + */ | ||
| 173 | +const pageSize = 10 | ||
| 174 | + | ||
| 175 | +/** | ||
| 176 | + * 是否还有更多数据 | ||
| 177 | + * @type {Ref<boolean>} | ||
| 178 | + */ | ||
| 172 | const hasMore = ref(true) | 179 | const hasMore = ref(true) |
| 173 | 180 | ||
| 174 | -// 数据状态 | 181 | +/** |
| 182 | + * 首次加载状态 | ||
| 183 | + * @type {Ref<boolean>} | ||
| 184 | + */ | ||
| 185 | +const loading = ref(false) | ||
| 186 | + | ||
| 187 | +/** | ||
| 188 | + * 加载更多状态 | ||
| 189 | + * @type {Ref<boolean>} | ||
| 190 | + */ | ||
| 191 | +const loadingMore = ref(false) | ||
| 192 | + | ||
| 193 | +// 搜索和 Tabs 相关状态 | ||
| 194 | +const activeTabId = ref('') | ||
| 195 | +const searchValue = ref('') | ||
| 175 | const categories = ref([]) // 从接口获取的分类列表 | 196 | const categories = ref([]) // 从接口获取的分类列表 |
| 176 | -const products = ref([]) // 当前产品列表 | 197 | +let searchTimer = null // 搜索防抖定时器 |
| 177 | -const total = ref(0) // 产品总数 | ||
| 178 | 198 | ||
| 179 | // 计划书弹窗状态 | 199 | // 计划书弹窗状态 |
| 180 | const showPlanPopup = ref(false) | 200 | const showPlanPopup = ref(false) |
| ... | @@ -195,29 +215,23 @@ const tabsData = computed(() => { | ... | @@ -195,29 +215,23 @@ const tabsData = computed(() => { |
| 195 | 215 | ||
| 196 | /** | 216 | /** |
| 197 | * 获取产品列表 | 217 | * 获取产品列表 |
| 198 | - * @description 根据 activeTabId 和 searchValue 获取对应分类的产品列表 | 218 | + * |
| 219 | + * @param {Object} params - 请求参数 | ||
| 220 | + * @param {number} params.page - 页码(从0开始) | ||
| 221 | + * @param {number} params.limit - 每页数量 | ||
| 222 | + * @param {boolean} isLoadMore - 是否为加载更多 | ||
| 223 | + * @returns {Promise<void>} | ||
| 199 | */ | 224 | */ |
| 200 | -const fetchProducts = async (isLoadMore = false) => { | 225 | +const fetchProducts = async (params = {}, isLoadMore = false) => { |
| 201 | - if (loading.value) return | ||
| 202 | - | ||
| 203 | - loading.value = true | ||
| 204 | - | ||
| 205 | try { | 226 | try { |
| 206 | - const params = { | 227 | + // 如果是加载更多,使用 loadingMore 状态,否则使用 loading 状态 |
| 207 | - page: String(page.value), | 228 | + if (isLoadMore) { |
| 208 | - limit: String(limit.value) | 229 | + loadingMore.value = true |
| 209 | - } | 230 | + } else { |
| 210 | - | 231 | + loading.value = true |
| 211 | - // 如果不是"全部"标签,添加分类 ID 参数 | ||
| 212 | - if (activeTabId.value !== '') { | ||
| 213 | - params.cid = activeTabId.value | ||
| 214 | - } | ||
| 215 | - | ||
| 216 | - // 添加搜索关键词参数 | ||
| 217 | - if (searchValue.value) { | ||
| 218 | - params.keyword = searchValue.value | ||
| 219 | } | 232 | } |
| 220 | 233 | ||
| 234 | + console.log('[Product Center] 请求参数:', params) | ||
| 221 | console.log('[Product Center] 使用 Mock 数据:', USE_MOCK_DATA) | 235 | console.log('[Product Center] 使用 Mock 数据:', USE_MOCK_DATA) |
| 222 | 236 | ||
| 223 | // 根据开关选择使用真实 API 或 Mock 数据 | 237 | // 根据开关选择使用真实 API 或 Mock 数据 |
| ... | @@ -226,65 +240,139 @@ const fetchProducts = async (isLoadMore = false) => { | ... | @@ -226,65 +240,139 @@ const fetchProducts = async (isLoadMore = false) => { |
| 226 | : await listAPI(params) | 240 | : await listAPI(params) |
| 227 | 241 | ||
| 228 | if (res.code === 1 && res.data) { | 242 | if (res.code === 1 && res.data) { |
| 243 | + console.log('[Product Center] 数据:', res.data) | ||
| 244 | + | ||
| 229 | // 更新分类列表(首次加载时) | 245 | // 更新分类列表(首次加载时) |
| 230 | if (!isLoadMore && res.data.categories) { | 246 | if (!isLoadMore && res.data.categories) { |
| 231 | categories.value = res.data.categories | 247 | categories.value = res.data.categories |
| 232 | } | 248 | } |
| 233 | 249 | ||
| 234 | // 处理产品列表 | 250 | // 处理产品列表 |
| 251 | + if (res.data.list?.length) { | ||
| 252 | + const listData = res.data.list | ||
| 253 | + | ||
| 235 | if (isLoadMore) { | 254 | if (isLoadMore) { |
| 236 | // 加载更多:追加数据 | 255 | // 加载更多:追加数据 |
| 237 | - products.value = [...products.value, ...res.data.list] | 256 | + currentList.value = [...currentList.value, ...listData] |
| 238 | } else { | 257 | } else { |
| 239 | // 首次加载或切换分类:替换数据 | 258 | // 首次加载或切换分类:替换数据 |
| 240 | - products.value = res.data.list || [] | 259 | + currentList.value = listData |
| 241 | } | 260 | } |
| 242 | 261 | ||
| 243 | - // 更新总数和分页状态 | 262 | + // 判断是否还有更多数据 |
| 244 | - total.value = res.data.total || 0 | 263 | + // 如果返回的数据量少于请求的量,说明没有更多了 |
| 245 | - hasMore.value = products.value.length < total.value | 264 | + hasMore.value = listData.length >= params.limit |
| 246 | } else { | 265 | } else { |
| 247 | - Taro.showToast({ | 266 | + // 没有数据了 |
| 248 | - title: res.msg || '获取产品列表失败', | 267 | + if (isLoadMore) { |
| 249 | - icon: 'none' | 268 | + hasMore.value = false |
| 250 | - }) | 269 | + } else { |
| 270 | + currentList.value = [] | ||
| 251 | } | 271 | } |
| 252 | - } catch (err) { | 272 | + } |
| 253 | - console.error('获取产品列表失败:', err) | 273 | + } else { |
| 254 | - Taro.showToast({ | 274 | + console.error('[Product Center] API 返回错误:', res.msg) |
| 255 | - title: '网络错误,请重试', | 275 | + } |
| 256 | - icon: 'none' | 276 | + } catch (error) { |
| 257 | - }) | 277 | + console.error('[Product Center] 获取产品列表失败:', error) |
| 258 | } finally { | 278 | } finally { |
| 279 | + if (isLoadMore) { | ||
| 280 | + loadingMore.value = false | ||
| 281 | + } else { | ||
| 259 | loading.value = false | 282 | loading.value = false |
| 260 | } | 283 | } |
| 284 | + } | ||
| 285 | +} | ||
| 286 | + | ||
| 287 | +/** | ||
| 288 | + * 页面加载时获取数据 | ||
| 289 | + */ | ||
| 290 | +useLoad(async (options) => { | ||
| 291 | + console.log('[Product Center] 页面参数:', options) | ||
| 292 | + | ||
| 293 | + // 重置分页状态 | ||
| 294 | + currentPage.value = 0 | ||
| 295 | + hasMore.value = true | ||
| 296 | + | ||
| 297 | + // 获取产品列表 | ||
| 298 | + await fetchProducts({ page: 0, limit: pageSize }) | ||
| 299 | +}) | ||
| 300 | + | ||
| 301 | +/** | ||
| 302 | + * 处理加载更多事件 | ||
| 303 | + * | ||
| 304 | + * @param {number} page - 下一页页码 | ||
| 305 | + * @returns {Promise<void>} | ||
| 306 | + */ | ||
| 307 | +const handleLoadMore = async (page) => { | ||
| 308 | + console.log('[Product Center] 加载更多,页码:', page) | ||
| 309 | + | ||
| 310 | + // 更新页码 | ||
| 311 | + currentPage.value = page | ||
| 312 | + | ||
| 313 | + // 构建请求参数 | ||
| 314 | + const params = { | ||
| 315 | + page: page, | ||
| 316 | + limit: pageSize | ||
| 317 | + } | ||
| 318 | + | ||
| 319 | + // 如果不是"全部"标签,添加分类 ID 参数 | ||
| 320 | + if (activeTabId.value !== '') { | ||
| 321 | + params.cid = activeTabId.value | ||
| 322 | + } | ||
| 323 | + | ||
| 324 | + // 添加搜索关键词参数 | ||
| 325 | + if (searchValue.value) { | ||
| 326 | + params.keyword = searchValue.value | ||
| 327 | + } | ||
| 328 | + | ||
| 329 | + // 加载下一页数据 | ||
| 330 | + await fetchProducts(params, true) | ||
| 261 | } | 331 | } |
| 262 | 332 | ||
| 263 | /** | 333 | /** |
| 264 | * Tab 点击处理 | 334 | * Tab 点击处理 |
| 265 | - * @description 切换分类,重置分页并重新加载数据 | 335 | + * |
| 336 | + * @param {string} id - 分类 ID | ||
| 266 | */ | 337 | */ |
| 267 | const onTabClick = (id) => { | 338 | const onTabClick = (id) => { |
| 268 | if (activeTabId.value === id) return | 339 | if (activeTabId.value === id) return |
| 269 | 340 | ||
| 341 | + console.log('[Product Center] 切换分类:', id) | ||
| 342 | + | ||
| 270 | activeTabId.value = id | 343 | activeTabId.value = id |
| 271 | 344 | ||
| 272 | // 重置分页状态 | 345 | // 重置分页状态 |
| 273 | - page.value = 0 | 346 | + currentPage.value = 0 |
| 274 | - products.value = [] | ||
| 275 | hasMore.value = true | 347 | hasMore.value = true |
| 276 | 348 | ||
| 349 | + // 构建请求参数 | ||
| 350 | + const params = { | ||
| 351 | + page: 0, | ||
| 352 | + limit: pageSize | ||
| 353 | + } | ||
| 354 | + | ||
| 355 | + // 如果不是"全部"标签,添加分类 ID 参数 | ||
| 356 | + if (id !== '') { | ||
| 357 | + params.cid = id | ||
| 358 | + } | ||
| 359 | + | ||
| 360 | + // 添加搜索关键词参数 | ||
| 361 | + if (searchValue.value) { | ||
| 362 | + params.keyword = searchValue.value | ||
| 363 | + } | ||
| 364 | + | ||
| 277 | // 重新加载数据(保持搜索状态) | 365 | // 重新加载数据(保持搜索状态) |
| 278 | - fetchProducts(false) | 366 | + fetchProducts(params, false) |
| 279 | } | 367 | } |
| 280 | 368 | ||
| 281 | /** | 369 | /** |
| 282 | * 搜索输入处理(带防抖) | 370 | * 搜索输入处理(带防抖) |
| 283 | - * @description 用户输入时实时搜索,使用防抖优化性能 | 371 | + * |
| 284 | * @param {string} value - 搜索关键词 | 372 | * @param {string} value - 搜索关键词 |
| 285 | */ | 373 | */ |
| 286 | const onSearchInput = (value) => { | 374 | const onSearchInput = (value) => { |
| 287 | - console.log('搜索输入:', value) | 375 | + console.log('[Product Center] 搜索输入:', value) |
| 288 | 376 | ||
| 289 | // 清除之前的定时器 | 377 | // 清除之前的定时器 |
| 290 | if (searchTimer) { | 378 | if (searchTimer) { |
| ... | @@ -294,22 +382,37 @@ const onSearchInput = (value) => { | ... | @@ -294,22 +382,37 @@ const onSearchInput = (value) => { |
| 294 | // 设置新的定时器(500ms 后执行搜索) | 382 | // 设置新的定时器(500ms 后执行搜索) |
| 295 | searchTimer = setTimeout(() => { | 383 | searchTimer = setTimeout(() => { |
| 296 | // 重置分页状态 | 384 | // 重置分页状态 |
| 297 | - page.value = 0 | 385 | + currentPage.value = 0 |
| 298 | - products.value = [] | ||
| 299 | hasMore.value = true | 386 | hasMore.value = true |
| 300 | 387 | ||
| 388 | + // 构建请求参数 | ||
| 389 | + const params = { | ||
| 390 | + page: 0, | ||
| 391 | + limit: pageSize | ||
| 392 | + } | ||
| 393 | + | ||
| 394 | + // 如果不是"全部"标签,添加分类 ID 参数 | ||
| 395 | + if (activeTabId.value !== '') { | ||
| 396 | + params.cid = activeTabId.value | ||
| 397 | + } | ||
| 398 | + | ||
| 399 | + // 添加搜索关键词参数 | ||
| 400 | + if (value) { | ||
| 401 | + params.keyword = value | ||
| 402 | + } | ||
| 403 | + | ||
| 301 | // 重新加载数据 | 404 | // 重新加载数据 |
| 302 | - fetchProducts(false) | 405 | + fetchProducts(params, false) |
| 303 | }, 500) | 406 | }, 500) |
| 304 | } | 407 | } |
| 305 | 408 | ||
| 306 | /** | 409 | /** |
| 307 | * 搜索处理(回车键) | 410 | * 搜索处理(回车键) |
| 308 | - * @description 用户按下回车或点击搜索按钮时触发 | 411 | + * |
| 309 | * @param {string} value - 搜索关键词 | 412 | * @param {string} value - 搜索关键词 |
| 310 | */ | 413 | */ |
| 311 | const onSearch = (value) => { | 414 | const onSearch = (value) => { |
| 312 | - console.log('搜索产品:', value) | 415 | + console.log('[Product Center] 搜索产品:', value) |
| 313 | 416 | ||
| 314 | // 清除防抖定时器 | 417 | // 清除防抖定时器 |
| 315 | if (searchTimer) { | 418 | if (searchTimer) { |
| ... | @@ -318,20 +421,34 @@ const onSearch = (value) => { | ... | @@ -318,20 +421,34 @@ const onSearch = (value) => { |
| 318 | } | 421 | } |
| 319 | 422 | ||
| 320 | // 重置分页状态 | 423 | // 重置分页状态 |
| 321 | - page.value = 0 | 424 | + currentPage.value = 0 |
| 322 | - products.value = [] | ||
| 323 | hasMore.value = true | 425 | hasMore.value = true |
| 324 | 426 | ||
| 427 | + // 构建请求参数 | ||
| 428 | + const params = { | ||
| 429 | + page: 0, | ||
| 430 | + limit: pageSize | ||
| 431 | + } | ||
| 432 | + | ||
| 433 | + // 如果不是"全部"标签,添加分类 ID 参数 | ||
| 434 | + if (activeTabId.value !== '') { | ||
| 435 | + params.cid = activeTabId.value | ||
| 436 | + } | ||
| 437 | + | ||
| 438 | + // 添加搜索关键词参数 | ||
| 439 | + if (value) { | ||
| 440 | + params.keyword = value | ||
| 441 | + } | ||
| 442 | + | ||
| 325 | // 重新加载数据 | 443 | // 重新加载数据 |
| 326 | - fetchProducts(false) | 444 | + fetchProducts(params, false) |
| 327 | } | 445 | } |
| 328 | 446 | ||
| 329 | /** | 447 | /** |
| 330 | * 清空搜索 | 448 | * 清空搜索 |
| 331 | - * @description 用户点击清除按钮时触发 | ||
| 332 | */ | 449 | */ |
| 333 | const onClear = () => { | 450 | const onClear = () => { |
| 334 | - console.log('清空搜索') | 451 | + console.log('[Product Center] 清空搜索') |
| 335 | 452 | ||
| 336 | // 清除防抖定时器 | 453 | // 清除防抖定时器 |
| 337 | if (searchTimer) { | 454 | if (searchTimer) { |
| ... | @@ -340,12 +457,22 @@ const onClear = () => { | ... | @@ -340,12 +457,22 @@ const onClear = () => { |
| 340 | } | 457 | } |
| 341 | 458 | ||
| 342 | // 重置分页状态 | 459 | // 重置分页状态 |
| 343 | - page.value = 0 | 460 | + currentPage.value = 0 |
| 344 | - products.value = [] | ||
| 345 | hasMore.value = true | 461 | hasMore.value = true |
| 346 | 462 | ||
| 463 | + // 构建请求参数 | ||
| 464 | + const params = { | ||
| 465 | + page: 0, | ||
| 466 | + limit: pageSize | ||
| 467 | + } | ||
| 468 | + | ||
| 469 | + // 如果不是"全部"标签,添加分类 ID 参数 | ||
| 470 | + if (activeTabId.value !== '') { | ||
| 471 | + params.cid = activeTabId.value | ||
| 472 | + } | ||
| 473 | + | ||
| 347 | // 重新加载数据 | 474 | // 重新加载数据 |
| 348 | - fetchProducts(false) | 475 | + fetchProducts(params, false) |
| 349 | } | 476 | } |
| 350 | 477 | ||
| 351 | /** | 478 | /** |
| ... | @@ -355,13 +482,13 @@ const onClear = () => { | ... | @@ -355,13 +482,13 @@ const onClear = () => { |
| 355 | const { handleClick: handleProductClick } = useListItemClick({ | 482 | const { handleClick: handleProductClick } = useListItemClick({ |
| 356 | listType: ListType.PRODUCT, | 483 | listType: ListType.PRODUCT, |
| 357 | onAfterClick: (item) => { | 484 | onAfterClick: (item) => { |
| 358 | - console.log('用户查看了产品:', item.product_name) | 485 | + console.log('[Product Center] 用户查看了产品:', item.product_name) |
| 359 | } | 486 | } |
| 360 | }) | 487 | }) |
| 361 | 488 | ||
| 362 | /** | 489 | /** |
| 363 | * 打开计划书弹窗 | 490 | * 打开计划书弹窗 |
| 364 | - * @description 根据产品对象打开计划书表单 | 491 | + * |
| 365 | * @param {Object} product - 产品对象 | 492 | * @param {Object} product - 产品对象 |
| 366 | */ | 493 | */ |
| 367 | const openPlanPopup = (product) => { | 494 | const openPlanPopup = (product) => { |
| ... | @@ -371,12 +498,11 @@ const openPlanPopup = (product) => { | ... | @@ -371,12 +498,11 @@ const openPlanPopup = (product) => { |
| 371 | 498 | ||
| 372 | /** | 499 | /** |
| 373 | * 处理计划书提交 | 500 | * 处理计划书提交 |
| 374 | - * @description 测试环境:前端不调用后端API,直接跳转到结果页 | 501 | + * |
| 375 | - * 生产环境:需要调用 submitPlanAPI 提交表单数据 | ||
| 376 | * @param {Object} formData - 表单数据 | 502 | * @param {Object} formData - 表单数据 |
| 377 | */ | 503 | */ |
| 378 | const handlePlanSubmit = (formData) => { | 504 | const handlePlanSubmit = (formData) => { |
| 379 | - console.log('计划书提交:', { | 505 | + console.log('[Product Center] 计划书提交:', { |
| 380 | product_id: selectedProduct.value.id, | 506 | product_id: selectedProduct.value.id, |
| 381 | product_name: selectedProduct.value.product_name, | 507 | product_name: selectedProduct.value.product_name, |
| 382 | form_sn: selectedProduct.value.form_sn, | 508 | form_sn: selectedProduct.value.form_sn, |
| ... | @@ -388,59 +514,15 @@ const handlePlanSubmit = (formData) => { | ... | @@ -388,59 +514,15 @@ const handlePlanSubmit = (formData) => { |
| 388 | 514 | ||
| 389 | // TODO: 后端接口还没有准备好,暂时不调用API | 515 | // TODO: 后端接口还没有准备好,暂时不调用API |
| 390 | // 测试完成后需要对接 submitPlanAPI | 516 | // 测试完成后需要对接 submitPlanAPI |
| 391 | - // const res = await submitPlanAPI({ | ||
| 392 | - // product_id: selectedProduct.value.id, | ||
| 393 | - // template: selectedProduct.value.form_sn, | ||
| 394 | - // form_data: formData | ||
| 395 | - // }) | ||
| 396 | 517 | ||
| 397 | // 模拟提交成功,跳转到结果页面 | 518 | // 模拟提交成功,跳转到结果页面 |
| 398 | Taro.navigateTo({ | 519 | Taro.navigateTo({ |
| 399 | url: '/pages/plan-submit-result/index?success=true' | 520 | url: '/pages/plan-submit-result/index?success=true' |
| 400 | }) | 521 | }) |
| 401 | } | 522 | } |
| 402 | - | ||
| 403 | -/** | ||
| 404 | - * 页面加载时获取数据 | ||
| 405 | - */ | ||
| 406 | -useLoad(() => { | ||
| 407 | - fetchProducts(false) | ||
| 408 | -}) | ||
| 409 | - | ||
| 410 | -/** | ||
| 411 | - * 触底加载更多 | ||
| 412 | - * @description 使用 Taro 的 useReachBottom hook 监听页面滚动到底部 | ||
| 413 | - */ | ||
| 414 | -useReachBottom(() => { | ||
| 415 | - console.log('滚动到底部,加载更多') | ||
| 416 | - | ||
| 417 | - if (!hasMore.value || loading.value) { | ||
| 418 | - console.log('没有更多数据或正在加载中,跳过') | ||
| 419 | - return | ||
| 420 | - } | ||
| 421 | - | ||
| 422 | - page.value += 1 | ||
| 423 | - fetchProducts(true) | ||
| 424 | -}) | ||
| 425 | </script> | 523 | </script> |
| 426 | 524 | ||
| 427 | <style lang="less"> | 525 | <style lang="less"> |
| 428 | -@keyframes slideIn { | ||
| 429 | - from { | ||
| 430 | - opacity: 0; | ||
| 431 | - transform: translateY(20rpx); | ||
| 432 | - } | ||
| 433 | - | ||
| 434 | - to { | ||
| 435 | - opacity: 1; | ||
| 436 | - transform: translateY(0); | ||
| 437 | - } | ||
| 438 | -} | ||
| 439 | - | ||
| 440 | -.product-card-item { | ||
| 441 | - animation: slideIn 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) backwards; | ||
| 442 | -} | ||
| 443 | - | ||
| 444 | // FilterTabs 风格的标签栏 | 526 | // FilterTabs 风格的标签栏 |
| 445 | .filter-tabs-wrapper { | 527 | .filter-tabs-wrapper { |
| 446 | display: flex; | 528 | display: flex; | ... | ... |
| 1 | <!-- | 1 | <!-- |
| 2 | - * @Date: 2026-02-06 | 2 | + * @Date: 2026-02-08 |
| 3 | - * @Description: 搜索页面 - 支持产品和资料搜索,实时查询API | 3 | + * @Description: 搜索页面 - 使用 LoadMoreList 组件重构版本 |
| 4 | + * @description 支持产品和资料搜索,实时查询API,自动切换分类 | ||
| 4 | --> | 5 | --> |
| 5 | <template> | 6 | <template> |
| 6 | <view class="bg-[#FFF]"> | 7 | <view class="bg-[#FFF]"> |
| 8 | + <LoadMoreList | ||
| 9 | + :list="currentList" | ||
| 10 | + :page="currentPage" | ||
| 11 | + :page-size="pageSize" | ||
| 12 | + :has-more="hasMore" | ||
| 13 | + :loading="loading" | ||
| 14 | + :loading-more="loadingMore" | ||
| 15 | + :show-header="true" | ||
| 16 | + key-field="id" | ||
| 17 | + @load-more="handleLoadMore" | ||
| 18 | + > | ||
| 7 | <!-- 固定顶部:导航栏 + 搜索栏 + Tabs + 结果计数 --> | 19 | <!-- 固定顶部:导航栏 + 搜索栏 + Tabs + 结果计数 --> |
| 20 | + <template #header> | ||
| 8 | <view class="bg-[#FFF] sticky top-0 z-10"> | 21 | <view class="bg-[#FFF] sticky top-0 z-10"> |
| 9 | <NavHeader title="搜索" /> | 22 | <NavHeader title="搜索" /> |
| 10 | 23 | ||
| ... | @@ -47,35 +60,24 @@ | ... | @@ -47,35 +60,24 @@ |
| 47 | 找到 {{ currentTotal }} 个相关结果 | 60 | 找到 {{ currentTotal }} 个相关结果 |
| 48 | </view> | 61 | </view> |
| 49 | </view> | 62 | </view> |
| 63 | + </template> | ||
| 50 | 64 | ||
| 51 | - <!-- 列表容器 --> | 65 | + <!-- 列表项:根据 activeTab 动态渲染 --> |
| 52 | - <view class="px-[40rpx] pb-[calc(160rpx+env(safe-area-inset-bottom))]"> | 66 | + <template #item="{ item }"> |
| 53 | - | ||
| 54 | - <!-- Search Results --> | ||
| 55 | - <view | ||
| 56 | - v-if="currentList.length > 0" | ||
| 57 | - :key="listRenderKey" | ||
| 58 | - > | ||
| 59 | <!-- Product Results --> | 67 | <!-- Product Results --> |
| 60 | - <view v-if="activeTab === 'product'" class="flex flex-col gap-[24rpx] pb-[40rpx]"> | ||
| 61 | <ProductCard | 68 | <ProductCard |
| 62 | - v-for="(item, index) in currentList" | 69 | + v-if="activeTab === 'product'" |
| 63 | - :key="index" | ||
| 64 | :product-id="item.id" | 70 | :product-id="item.id" |
| 65 | :product-name="item.product_name || item.name" | 71 | :product-name="item.product_name || item.name" |
| 66 | :tags="item.tags || []" | 72 | :tags="item.tags || []" |
| 67 | class="search-result-item" | 73 | class="search-result-item" |
| 68 | - :style="{ animationDelay: `${index * 30}ms` }" | ||
| 69 | @detail="goToProductDetail" | 74 | @detail="goToProductDetail" |
| 70 | @plan="openPlanPopup" | 75 | @plan="openPlanPopup" |
| 71 | /> | 76 | /> |
| 72 | - </view> | ||
| 73 | 77 | ||
| 74 | <!-- File Results --> | 78 | <!-- File Results --> |
| 75 | - <view v-else-if="activeTab === 'file'" class="flex flex-col gap-[24rpx] pb-[40rpx]"> | ||
| 76 | <MaterialCard | 79 | <MaterialCard |
| 77 | - v-for="(item, index) in currentList" | 80 | + v-else-if="activeTab === 'file'" |
| 78 | - :key="index" | ||
| 79 | :id="item.id" | 81 | :id="item.id" |
| 80 | :title="item.title" | 82 | :title="item.title" |
| 81 | :file-name="item.fileName" | 83 | :file-name="item.fileName" |
| ... | @@ -86,37 +88,27 @@ | ... | @@ -86,37 +88,27 @@ |
| 86 | :extension="item.extension" | 88 | :extension="item.extension" |
| 87 | :download-url="item.downloadUrl" | 89 | :download-url="item.downloadUrl" |
| 88 | class="search-result-item" | 90 | class="search-result-item" |
| 89 | - :style="{ animationDelay: `${index * 30}ms` }" | ||
| 90 | @collect-changed="handleCollectChanged(item, $event)" | 91 | @collect-changed="handleCollectChanged(item, $event)" |
| 91 | /> | 92 | /> |
| 92 | - </view> | 93 | + </template> |
| 93 | 94 | ||
| 94 | - <!-- 加载更多提示 --> | 95 | + <!-- 自定义空状态:处理三种状态 --> |
| 95 | - <view v-if="currentList.length > 0" class="flex items-center justify-center py-[40rpx]"> | 96 | + <template #empty> |
| 96 | - <view v-if="loadingMore" class="flex items-center"> | 97 | + <!-- Initial State (从未搜索过) --> |
| 97 | - <view class="loading-spinner-small"></view> | 98 | + <view v-if="!hasSearched" class="flex flex-col items-center justify-center py-[120rpx]"> |
| 98 | - <text class="ml-[12rpx] text-[#9CA3AF] text-[24rpx]">加载中...</text> | 99 | + <IconFont name="search" class="text-gray-300 mb-[24rpx]" size="64" /> |
| 99 | - </view> | 100 | + <view class="text-[#6B7280] text-[28rpx]">搜索产品或资料</view> |
| 100 | - <view v-else-if="!hasMore" class="text-[#9CA3AF] text-[24rpx]"> | 101 | + <view class="text-[#9CA3AF] text-[24rpx] mt-[12rpx]">输入关键词开始搜索,自动切换分类</view> |
| 101 | - 没有更多了 | ||
| 102 | - </view> | ||
| 103 | - </view> | ||
| 104 | </view> | 102 | </view> |
| 105 | 103 | ||
| 106 | <!-- Empty State (已搜索但无结果) --> | 104 | <!-- Empty State (已搜索但无结果) --> |
| 107 | - <view v-else-if="hasSearched && currentList.length === 0" class="flex flex-col items-center justify-center py-[40rpx]"> | 105 | + <view v-else class="flex flex-col items-center justify-center py-[40rpx]"> |
| 108 | <nut-empty description="暂无搜索结果" image="empty"> | 106 | <nut-empty description="暂无搜索结果" image="empty"> |
| 109 | <view class="text-[#9CA3AF] text-[24rpx] mt-[12rpx]">试试其他关键词吧</view> | 107 | <view class="text-[#9CA3AF] text-[24rpx] mt-[12rpx]">试试其他关键词吧</view> |
| 110 | </nut-empty> | 108 | </nut-empty> |
| 111 | </view> | 109 | </view> |
| 112 | - | 110 | + </template> |
| 113 | - <!-- Initial State (从未搜索过) --> | 111 | + </LoadMoreList> |
| 114 | - <view v-else class="flex flex-col items-center justify-center py-[120rpx]"> | ||
| 115 | - <IconFont name="search" class="text-gray-300 mb-[24rpx]" size="64" /> | ||
| 116 | - <view class="text-[#6B7280] text-[28rpx]">搜索产品或资料</view> | ||
| 117 | - <view class="text-[#9CA3AF] text-[24rpx] mt-[12rpx]">输入关键词开始搜索,自动切换分类</view> | ||
| 118 | - </view> | ||
| 119 | - </view> | ||
| 120 | 112 | ||
| 121 | <!-- Plan Form Container --> | 113 | <!-- Plan Form Container --> |
| 122 | <!-- 仅当 selectedProduct 不为 null 时才渲染组件,避免 product prop required 警告 --> | 114 | <!-- 仅当 selectedProduct 不为 null 时才渲染组件,避免 product prop required 警告 --> |
| ... | @@ -132,8 +124,9 @@ | ... | @@ -132,8 +124,9 @@ |
| 132 | 124 | ||
| 133 | <script setup> | 125 | <script setup> |
| 134 | import { ref, computed } from 'vue' | 126 | import { ref, computed } from 'vue' |
| 135 | -import Taro, { useReachBottom } from '@tarojs/taro' | 127 | +import Taro from '@tarojs/taro' |
| 136 | import { useGo } from '@/hooks/useGo' | 128 | import { useGo } from '@/hooks/useGo' |
| 129 | +import LoadMoreList from '@/components/LoadMoreList' | ||
| 137 | import NavHeader from '@/components/NavHeader.vue' | 130 | import NavHeader from '@/components/NavHeader.vue' |
| 138 | import IconFont from '@/components/IconFont.vue' | 131 | import IconFont from '@/components/IconFont.vue' |
| 139 | import SearchBar from '@/components/SearchBar.vue' | 132 | import SearchBar from '@/components/SearchBar.vue' |
| ... | @@ -146,9 +139,13 @@ import { mockSearchAPI } from '@/utils/mockData' | ... | @@ -146,9 +139,13 @@ import { mockSearchAPI } from '@/utils/mockData' |
| 146 | // ⚠️ MOCK 数据开关 - 开发环境使用 mock 数据,生产环境使用真实 API | 139 | // ⚠️ MOCK 数据开关 - 开发环境使用 mock 数据,生产环境使用真实 API |
| 147 | const USE_MOCK_DATA = process.env.NODE_ENV === 'development' | 140 | const USE_MOCK_DATA = process.env.NODE_ENV === 'development' |
| 148 | 141 | ||
| 149 | -// Navigation | ||
| 150 | const go = useGo() | 142 | const go = useGo() |
| 151 | 143 | ||
| 144 | +/** | ||
| 145 | + * 搜索页面状态管理 | ||
| 146 | + * @description 支持双类型(产品/资料)搜索,自动切换分类 | ||
| 147 | + */ | ||
| 148 | + | ||
| 152 | // Plan Popup State | 149 | // Plan Popup State |
| 153 | const showPlanPopup = ref(false) | 150 | const showPlanPopup = ref(false) |
| 154 | const selectedProduct = ref(null) | 151 | const selectedProduct = ref(null) |
| ... | @@ -157,13 +154,15 @@ const selectedProduct = ref(null) | ... | @@ -157,13 +154,15 @@ const selectedProduct = ref(null) |
| 157 | const searchKeyword = ref('') | 154 | const searchKeyword = ref('') |
| 158 | const activeTab = ref('') // 当前选中的 tab(初始为空,不选中任何tab) | 155 | const activeTab = ref('') // 当前选中的 tab(初始为空,不选中任何tab) |
| 159 | const hasSearched = ref(false) // 是否已经搜索过 | 156 | const hasSearched = ref(false) // 是否已经搜索过 |
| 160 | -const listRenderKey = ref(0) | ||
| 161 | 157 | ||
| 162 | -// 数据状态 | 158 | +// 数据状态 - 双列表系统 |
| 163 | const products = ref([]) // 产品列表 | 159 | const products = ref([]) // 产品列表 |
| 164 | const files = ref([]) // 资料列表 | 160 | const files = ref([]) // 资料列表 |
| 165 | const productsTotal = ref(0) // 产品总数 | 161 | const productsTotal = ref(0) // 产品总数 |
| 166 | const filesTotal = ref(0) // 资料总数 | 162 | const filesTotal = ref(0) // 资料总数 |
| 163 | + | ||
| 164 | +// 分页状态 | ||
| 165 | +const loading = ref(false) // 首次加载状态 | ||
| 167 | const loadingMore = ref(false) // 加载更多状态 | 166 | const loadingMore = ref(false) // 加载更多状态 |
| 168 | const hasMore = ref(true) // 是否还有更多数据 | 167 | const hasMore = ref(true) // 是否还有更多数据 |
| 169 | const currentPage = ref(0) // 当前页码(从0开始) | 168 | const currentPage = ref(0) // 当前页码(从0开始) |
| ... | @@ -179,6 +178,7 @@ const tabsData = ref([ | ... | @@ -179,6 +178,7 @@ const tabsData = ref([ |
| 179 | 178 | ||
| 180 | /** | 179 | /** |
| 181 | * 当前显示的列表 | 180 | * 当前显示的列表 |
| 181 | + * @description 根据 activeTab 动态返回对应的列表数据 | ||
| 182 | */ | 182 | */ |
| 183 | const currentList = computed(() => { | 183 | const currentList = computed(() => { |
| 184 | // 如果没有选中任何tab,返回空数组 | 184 | // 如果没有选中任何tab,返回空数组 |
| ... | @@ -207,11 +207,13 @@ const currentTotal = computed(() => { | ... | @@ -207,11 +207,13 @@ const currentTotal = computed(() => { |
| 207 | 207 | ||
| 208 | /** | 208 | /** |
| 209 | * 执行搜索 | 209 | * 执行搜索 |
| 210 | + * | ||
| 210 | * @param {string} keyword - 搜索关键字 | 211 | * @param {string} keyword - 搜索关键字 |
| 211 | * @param {string} type - 可选,'product' | 'file' | undefined | 212 | * @param {string} type - 可选,'product' | 'file' | undefined |
| 212 | * @param {number} page - 页码(从0开始) | 213 | * @param {number} page - 页码(从0开始) |
| 213 | * @param {number} limit - 每页数量 | 214 | * @param {number} limit - 每页数量 |
| 214 | * @param {boolean} isLoadMore - 是否为加载更多 | 215 | * @param {boolean} isLoadMore - 是否为加载更多 |
| 216 | + * @returns {Promise<void>} | ||
| 215 | */ | 217 | */ |
| 216 | const performSearch = async (keyword, type, page = 0, limit = pageSize, isLoadMore = false) => { | 218 | const performSearch = async (keyword, type, page = 0, limit = pageSize, isLoadMore = false) => { |
| 217 | try { | 219 | try { |
| ... | @@ -219,7 +221,7 @@ const performSearch = async (keyword, type, page = 0, limit = pageSize, isLoadMo | ... | @@ -219,7 +221,7 @@ const performSearch = async (keyword, type, page = 0, limit = pageSize, isLoadMo |
| 219 | if (isLoadMore) { | 221 | if (isLoadMore) { |
| 220 | loadingMore.value = true | 222 | loadingMore.value = true |
| 221 | } else { | 223 | } else { |
| 222 | - Taro.showLoading({ title: '搜索中...', mask: true }) | 224 | + loading.value = true |
| 223 | } | 225 | } |
| 224 | 226 | ||
| 225 | const params = { keyword, page, limit } | 227 | const params = { keyword, page, limit } |
| ... | @@ -295,7 +297,6 @@ const performSearch = async (keyword, type, page = 0, limit = pageSize, isLoadMo | ... | @@ -295,7 +297,6 @@ const performSearch = async (keyword, type, page = 0, limit = pageSize, isLoadMo |
| 295 | } | 297 | } |
| 296 | 298 | ||
| 297 | hasSearched.value = true | 299 | hasSearched.value = true |
| 298 | - listRenderKey.value += 1 | ||
| 299 | 300 | ||
| 300 | console.log('[Search] 搜索成功', { | 301 | console.log('[Search] 搜索成功', { |
| 301 | productsTotal: productsTotal.value, | 302 | productsTotal: productsTotal.value, |
| ... | @@ -320,20 +321,48 @@ const performSearch = async (keyword, type, page = 0, limit = pageSize, isLoadMo | ... | @@ -320,20 +321,48 @@ const performSearch = async (keyword, type, page = 0, limit = pageSize, isLoadMo |
| 320 | if (isLoadMore) { | 321 | if (isLoadMore) { |
| 321 | loadingMore.value = false | 322 | loadingMore.value = false |
| 322 | } else { | 323 | } else { |
| 323 | - Taro.hideLoading() | 324 | + loading.value = false |
| 324 | } | 325 | } |
| 325 | } | 326 | } |
| 326 | } | 327 | } |
| 327 | 328 | ||
| 328 | /** | 329 | /** |
| 330 | + * 处理加载更多事件 | ||
| 331 | + * | ||
| 332 | + * @param {number} page - 下一页页码 | ||
| 333 | + * @returns {Promise<void>} | ||
| 334 | + */ | ||
| 335 | +const handleLoadMore = async (page) => { | ||
| 336 | + console.log('[Search] 加载更多,页码:', page) | ||
| 337 | + | ||
| 338 | + // 更新页码 | ||
| 339 | + currentPage.value = page | ||
| 340 | + | ||
| 341 | + // 如果没有搜索过或没有选中 tab,不执行 | ||
| 342 | + if (!hasSearched.value || !activeTab.value || !searchKeyword.value.trim()) { | ||
| 343 | + return | ||
| 344 | + } | ||
| 345 | + | ||
| 346 | + // 加载下一页数据 | ||
| 347 | + await performSearch( | ||
| 348 | + searchKeyword.value.trim(), | ||
| 349 | + activeTab.value, | ||
| 350 | + page, | ||
| 351 | + pageSize, | ||
| 352 | + true // 标记为加载更多 | ||
| 353 | + ) | ||
| 354 | +} | ||
| 355 | + | ||
| 356 | +/** | ||
| 329 | * Tab 点击处理(实时查询) | 357 | * Tab 点击处理(实时查询) |
| 358 | + * | ||
| 359 | + * @param {string} tabId - Tab ID | ||
| 330 | */ | 360 | */ |
| 331 | const onTabClick = async (tabId) => { | 361 | const onTabClick = async (tabId) => { |
| 332 | if (activeTab.value === tabId) return | 362 | if (activeTab.value === tabId) return |
| 333 | 363 | ||
| 334 | // 立即切换 tab(响应更快) | 364 | // 立即切换 tab(响应更快) |
| 335 | activeTab.value = tabId | 365 | activeTab.value = tabId |
| 336 | - listRenderKey.value += 1 | ||
| 337 | 366 | ||
| 338 | // 重置分页状态 | 367 | // 重置分页状态 |
| 339 | currentPage.value = 0 | 368 | currentPage.value = 0 |
| ... | @@ -383,51 +412,11 @@ const clearSearch = () => { | ... | @@ -383,51 +412,11 @@ const clearSearch = () => { |
| 383 | activeTab.value = '' // 重置为空,不选中任何tab | 412 | activeTab.value = '' // 重置为空,不选中任何tab |
| 384 | currentPage.value = 0 | 413 | currentPage.value = 0 |
| 385 | hasMore.value = true | 414 | hasMore.value = true |
| 386 | - listRenderKey.value += 1 | ||
| 387 | } | 415 | } |
| 388 | 416 | ||
| 389 | /** | 417 | /** |
| 390 | - * 触底加载更多 | ||
| 391 | - * @description 使用防抖避免频繁触发 | ||
| 392 | - */ | ||
| 393 | -let loadMoreTimer = null | ||
| 394 | -useReachBottom(() => { | ||
| 395 | - // 如果正在加载更多或没有更多数据,不执行 | ||
| 396 | - if (loadingMore.value || !hasMore.value) { | ||
| 397 | - return | ||
| 398 | - } | ||
| 399 | - | ||
| 400 | - // 如果没有搜索过或没有选中 tab,不执行 | ||
| 401 | - if (!hasSearched.value || !activeTab.value || !searchKeyword.value.trim()) { | ||
| 402 | - return | ||
| 403 | - } | ||
| 404 | - | ||
| 405 | - // 防抖:300ms 内只触发一次 | ||
| 406 | - if (loadMoreTimer) { | ||
| 407 | - clearTimeout(loadMoreTimer) | ||
| 408 | - } | ||
| 409 | - | ||
| 410 | - loadMoreTimer = setTimeout(async () => { | ||
| 411 | - console.log('[Search] 触底加载更多') | ||
| 412 | - | ||
| 413 | - // 页码 +1 | ||
| 414 | - currentPage.value += 1 | ||
| 415 | - | ||
| 416 | - // 加载下一页数据 | ||
| 417 | - await performSearch( | ||
| 418 | - searchKeyword.value.trim(), | ||
| 419 | - activeTab.value, | ||
| 420 | - currentPage.value, | ||
| 421 | - pageSize, | ||
| 422 | - true // 标记为加载更多 | ||
| 423 | - ) | ||
| 424 | - }, 300) | ||
| 425 | -}) | ||
| 426 | - | ||
| 427 | -/** | ||
| 428 | * 跳转到产品详情页 | 418 | * 跳转到产品详情页 |
| 429 | * | 419 | * |
| 430 | - * @description 处理产品详情按钮点击事件 | ||
| 431 | * @param {number} productId - 产品ID | 420 | * @param {number} productId - 产品ID |
| 432 | */ | 421 | */ |
| 433 | const goToProductDetail = (productId) => { | 422 | const goToProductDetail = (productId) => { |
| ... | @@ -437,7 +426,6 @@ const goToProductDetail = (productId) => { | ... | @@ -437,7 +426,6 @@ const goToProductDetail = (productId) => { |
| 437 | /** | 426 | /** |
| 438 | * 打开计划书弹窗 | 427 | * 打开计划书弹窗 |
| 439 | * | 428 | * |
| 440 | - * @description 根据产品ID找到对应的产品对象,并打开计划书表单 | ||
| 441 | * @param {number} productId - 产品ID | 429 | * @param {number} productId - 产品ID |
| 442 | */ | 430 | */ |
| 443 | const openPlanPopup = (productId) => { | 431 | const openPlanPopup = (productId) => { |
| ... | @@ -461,8 +449,6 @@ const openPlanPopup = (productId) => { | ... | @@ -461,8 +449,6 @@ const openPlanPopup = (productId) => { |
| 461 | /** | 449 | /** |
| 462 | * 处理计划书提交 | 450 | * 处理计划书提交 |
| 463 | * | 451 | * |
| 464 | - * @description 测试环境:前端不调用后端API,直接跳转到结果页 | ||
| 465 | - * 生产环境:需要调用 submitPlanAPI 提交表单数据 | ||
| 466 | * @param {Object} formData - 表单数据 | 452 | * @param {Object} formData - 表单数据 |
| 467 | */ | 453 | */ |
| 468 | const handlePlanSubmit = (formData) => { | 454 | const handlePlanSubmit = (formData) => { |
| ... | @@ -478,11 +464,6 @@ const handlePlanSubmit = (formData) => { | ... | @@ -478,11 +464,6 @@ const handlePlanSubmit = (formData) => { |
| 478 | 464 | ||
| 479 | // TODO: 后端接口还没有准备好,暂时不调用API | 465 | // TODO: 后端接口还没有准备好,暂时不调用API |
| 480 | // 测试完成后需要对接 submitPlanAPI | 466 | // 测试完成后需要对接 submitPlanAPI |
| 481 | - // const res = await submitPlanAPI({ | ||
| 482 | - // product_id: selectedProduct.value.id, | ||
| 483 | - // template: selectedProduct.value.form_sn, | ||
| 484 | - // form_data: formData | ||
| 485 | - // }); | ||
| 486 | 467 | ||
| 487 | // 模拟提交成功,跳转到结果页面 | 468 | // 模拟提交成功,跳转到结果页面 |
| 488 | go('/pages/plan-submit-result/index', { | 469 | go('/pages/plan-submit-result/index', { |
| ... | @@ -493,9 +474,8 @@ const handlePlanSubmit = (formData) => { | ... | @@ -493,9 +474,8 @@ const handlePlanSubmit = (formData) => { |
| 493 | /** | 474 | /** |
| 494 | * 处理收藏状态改变 | 475 | * 处理收藏状态改变 |
| 495 | * | 476 | * |
| 496 | - * @description 当用户点击收藏按钮时,更新本地状态 | ||
| 497 | * @param {Object} item - 资料对象 | 477 | * @param {Object} item - 资料对象 |
| 498 | - * @param {Object} newStatus - 新的状态 | 478 | + * @param {Object} newStatus - 新的状态 { collected: boolean } |
| 499 | */ | 479 | */ |
| 500 | const handleCollectChanged = (item, newStatus) => { | 480 | const handleCollectChanged = (item, newStatus) => { |
| 501 | console.log('[Search] 收藏状态改变:', item.title, newStatus.collected) | 481 | console.log('[Search] 收藏状态改变:', item.title, newStatus.collected) |
| ... | @@ -505,45 +485,9 @@ const handleCollectChanged = (item, newStatus) => { | ... | @@ -505,45 +485,9 @@ const handleCollectChanged = (item, newStatus) => { |
| 505 | file.collected = newStatus.collected | 485 | file.collected = newStatus.collected |
| 506 | } | 486 | } |
| 507 | } | 487 | } |
| 508 | - | ||
| 509 | </script> | 488 | </script> |
| 510 | 489 | ||
| 511 | <style lang="less"> | 490 | <style lang="less"> |
| 512 | -@keyframes slideIn { | ||
| 513 | - from { | ||
| 514 | - opacity: 0; | ||
| 515 | - transform: translateY(20rpx); | ||
| 516 | - } | ||
| 517 | - | ||
| 518 | - to { | ||
| 519 | - opacity: 1; | ||
| 520 | - transform: translateY(0); | ||
| 521 | - } | ||
| 522 | -} | ||
| 523 | - | ||
| 524 | -.search-result-item { | ||
| 525 | - animation: slideIn 0.3s cubic-bezier(0.2, 0.8, 0.2, 1) backwards; | ||
| 526 | -} | ||
| 527 | - | ||
| 528 | -/* 加载动画 */ | ||
| 529 | -@keyframes spin { | ||
| 530 | - 0% { | ||
| 531 | - transform: rotate(0deg); | ||
| 532 | - } | ||
| 533 | - 100% { | ||
| 534 | - transform: rotate(360deg); | ||
| 535 | - } | ||
| 536 | -} | ||
| 537 | - | ||
| 538 | -.loading-spinner-small { | ||
| 539 | - width: 32rpx; | ||
| 540 | - height: 32rpx; | ||
| 541 | - border: 3rpx solid #E5E7EB; | ||
| 542 | - border-top-color: #4CAF50; | ||
| 543 | - border-radius: 50%; | ||
| 544 | - animation: spin 0.8s linear infinite; | ||
| 545 | -} | ||
| 546 | - | ||
| 547 | // FilterTabs 风格的标签栏 | 491 | // FilterTabs 风格的标签栏 |
| 548 | .filter-tabs-wrapper { | 492 | .filter-tabs-wrapper { |
| 549 | display: flex; | 493 | display: flex; |
| ... | @@ -599,4 +543,6 @@ const handleCollectChanged = (item, newStatus) => { | ... | @@ -599,4 +543,6 @@ const handleCollectChanged = (item, newStatus) => { |
| 599 | :deep(.nut-tabs__content) { | 543 | :deep(.nut-tabs__content) { |
| 600 | display: none; | 544 | display: none; |
| 601 | } | 545 | } |
| 546 | + | ||
| 547 | +/* LoadMoreList 组件已内置动画和加载状态,此处无需额外样式 */ | ||
| 602 | </style> | 548 | </style> | ... | ... |
-
Please register or login to post a comment