hookehuyr

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

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

这些文件是开发过程中的临时备份,现已不再需要。
1 -<!--
2 - * @Date: 2026-01-31
3 - * @Description: 资料列表页 - 已改造为 NutTabs 版本
4 --->
5 -<template>
6 - <view class="bg-[#F9FAFB]">
7 - <!-- 固定在顶部的导航和搜索 -->
8 - <view class="bg-[#F9FAFB] sticky top-0 z-10">
9 - <NavHeader :title="pageTitle" />
10 -
11 - <view class="px-[32rpx] mt-[32rpx] mb-[24rpx]">
12 - <SearchBar
13 - v-model="searchValue"
14 - placeholder="搜索资料..."
15 - @search="onSearch"
16 - @clear="onClear"
17 - variant="rounded"
18 - :show-border="true"
19 - :show-clear="true"
20 - />
21 - </view>
22 -
23 - <!-- 动态显示 Tabs(仅在有分类时显示) -->
24 - <nut-tabs v-if="hasCategories" v-model="activeTabId">
25 - <!-- 自定义标签栏 -->
26 - <template #titles>
27 - <view class="filter-tabs-wrapper">
28 - <view
29 - v-for="item in tabsData"
30 - :key="item.id"
31 - :class="[
32 - 'filter-tab-item',
33 - activeTabId === item.id ? 'filter-tab-active' : 'filter-tab-inactive'
34 - ]"
35 - @tap="onTabClick(item.id)"
36 - >
37 - <text class="filter-tab-text">{{ item.name }}</text>
38 - </view>
39 - </view>
40 - </template>
41 - </nut-tabs>
42 - </view>
43 -
44 - <!-- 列表容器 - 页面级滚动 -->
45 - <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"
53 - :style="{ animationDelay: `${index * 50}ms` }">
54 -
55 - <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">
57 - <image
58 - :src="getDocumentIcon(item.extension ? `file.${item.extension}` : item.fileName)"
59 - class="w-[48rpx] h-[48rpx]"
60 - mode="aspectFit"
61 - />
62 - </view>
63 -
64 - <view class="flex-1 min-w-0">
65 - <h3 class="text-[#1F2937] text-[30rpx] font-bold leading-[1.4] line-clamp-2 mb-[8rpx]">
66 - {{ item.title }}
67 - </h3>
68 - <p class="text-[#6B7280] text-[24rpx] leading-[1.4] line-clamp-1 mb-[16rpx]">
69 - {{ item.desc }}
70 - </p>
71 -
72 - <view class="flex items-center gap-[12rpx] mb-[20rpx]">
73 - <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]">
75 - {{ getDocumentLabel(item.extension ? `file.${item.extension}` : item.fileName) }}
76 - </view>
77 - <view class="text-[#9CA3AF] text-[22rpx]">
78 - {{ item.size }}
79 - </view>
80 - </view>
81 -
82 - <view class="h-[1rpx] bg-gray-100 my-[20rpx]"></view>
83 -
84 - <ListItemActions
85 - :viewable="true"
86 - :collectable="true"
87 - :collected="item.collected"
88 - :item-id="String(item.meta_id || item.id)"
89 - @view="onView(item)"
90 - @collect="toggleCollect(item)"
91 - @delete="onDelete(item)"
92 - />
93 - </view>
94 - </view>
95 -
96 - <!-- 空状态 -->
97 - <view v-if="currentList.length === 0 && !loading">
98 - <nut-empty description="暂无相关资料" image="empty" />
99 - </view>
100 -
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>
114 -</template>
115 -
116 -<script setup>
117 -import { ref, computed, nextTick, watch } from 'vue'
118 -import { useLoad, useReachBottom } from '@tarojs/taro'
119 -import NavHeader from '@/components/NavHeader.vue'
120 -import SearchBar from '@/components/SearchBar.vue'
121 -import ListItemActions from '@/components/ListItemActions/index.vue'
122 -import { useListItemClick, ListType } from '@/composables/useListItemClick'
123 -import { getDocumentIcon, getDocumentLabel } from '@/utils/documentIcons'
124 -import { debounce } from '@/utils/debounce'
125 -import { fileListAPI } from '@/api/file'
126 -import { mockFileListAPI } from '@/utils/mockData'
127 -import { useCollectOperation } from '@/composables/useCollectOperation'
128 -import Taro from '@tarojs/taro'
129 -
130 -// ⚠️ MOCK 数据开关 - 开发环境使用 mock 数据,生产环境使用真实 API
131 -const USE_MOCK_DATA = process.env.NODE_ENV === 'development'
132 -
133 -const searchValue = ref('')
134 -const activeTabId = ref('all') // 默认选中"全部"
135 -const listVisible = ref(true)
136 -const listRenderKey = ref(0)
137 -
138 -/**
139 - * 防抖搜索函数
140 - * @description 使用防抖优化搜索性能,避免频繁请求接口
141 - */
142 -const debouncedSearch = debounce(async () => {
143 - console.log('[Material List] 防抖搜索触发')
144 - await onSearch()
145 -}, 500)
146 -
147 -/**
148 - * 加载状态
149 - */
150 -const loading = ref(false)
151 -
152 -/**
153 - * 加载更多状态
154 - * @description 区分首次加载和加载更多
155 - */
156 -const loadingMore = ref(false)
157 -
158 -/**
159 - * 每页数量
160 - */
161 -const pageSize = 10
162 -
163 -/**
164 - * 当前页码(从0开始)
165 - */
166 -const currentPage = ref(0)
167 -
168 -/**
169 - * 是否有更多数据
170 - */
171 -const hasMore = ref(true)
172 -
173 -/**
174 - * 各分类的分页状态缓存
175 - * @description Map<categoryId, { currentPage, hasMore }>
176 - */
177 -const categoryPageCache = ref(new Map())
178 -
179 -/**
180 - * API 返回的原始数据
181 - */
182 -const data = ref(null)
183 -
184 -/**
185 - * 初始分类ID(从页面参数获取)
186 - */
187 -const initialCategoryId = ref(null)
188 -
189 -/**
190 - * 是否有分类标签
191 - * @description 根据后端返回的 children 判断
192 - */
193 -const hasCategories = computed(() => {
194 - return data.value?.children?.length > 0
195 -})
196 -
197 -/**
198 - * 页面标题
199 - */
200 -const pageTitle = ref('资料列表')
201 -
202 -/**
203 - * 资料数据源(从 API 获取)
204 - */
205 -const allList = ref([])
206 -
207 -/**
208 - * 各个分类的缓存列表数据
209 - * @description key: 分类ID,value: 该分类的列表数据
210 - */
211 -const categoryListCache = ref(new Map()) // 使用 Map 缓存各分类的列表数据
212 -
213 -/**
214 - * 当前显示的列表数据
215 - * @description 根据当前选中的 tab 和搜索关键词动态计算
216 - */
217 -const currentList = ref([])
218 -
219 -/**
220 - * 资料分类数据
221 - * @description 根据 API 返回的 children 构建 tabs,始终包含"全部"选项
222 - */
223 -const tabsData = computed(() => {
224 - // 始终包含"全部" tab
225 - const tabs = [
226 - { id: 'all', name: '全部' }
227 - ]
228 -
229 - // 如果有子分类,添加到 tabs
230 - const children = data.value?.children || []
231 - if (children.length > 0) {
232 - children.forEach(child => {
233 - tabs.push({
234 - id: String(child.id),
235 - name: child.category_name
236 - })
237 - })
238 - }
239 -
240 - return tabs
241 -})
242 -
243 -/**
244 - * 转换文档数据格式
245 - * @description 将 API 返回的文档数据转换为组件需要的格式
246 - * @param {Object} doc - API 返回的文档对象
247 - * @returns {Object} 转换后的文档对象
248 - */
249 -const transformDocItem = (doc) => {
250 - // 处理文件名为空的情况
251 - const fileName = doc.name || '未命名文件'
252 - // 如果没有扩展名,从文件名中提取(如果有)
253 - const extension = doc.extension || fileName.split('.').pop()?.toLowerCase() || ''
254 -
255 - return {
256 - id: doc.id || doc.meta_id, // 兼容 id 和 meta_id
257 - meta_id: doc.meta_id || doc.id, // 保存 meta_id 用于收藏 API
258 - title: fileName,
259 - desc: doc.post_date || '',
260 - size: doc.size || '',
261 - fileName: fileName,
262 - downloadUrl: doc.value,
263 - extension: extension,
264 - collected: doc.is_favorite === '1' || doc.is_favorite === 1 // 从 API 返回的收藏状态
265 - }
266 -}
267 -
268 -/**
269 - * 获取文档分类列表
270 - * @param {Object} params - 请求参数
271 - * @param {string} params.cid - 分类ID(可选)
272 - * @param {string} params.child_id - 子分类ID(可选)
273 - * @param {string} params.keyword - 搜索关键词(可选)
274 - * @param {number} params.page - 页码(从0开始)
275 - * @param {number} params.limit - 每页数量
276 - * @param {boolean} isLoadMore - 是否为加载更多
277 - */
278 -const fetchMaterialList = async (params = {}, isLoadMore = false) => {
279 - try {
280 - // 如果是加载更多,使用 loadingMore 状态,否则使用 loading 状态
281 - if (isLoadMore) {
282 - loadingMore.value = true
283 - } else {
284 - loading.value = true
285 - }
286 -
287 - console.log('[Material List] 请求参数:', params)
288 - console.log('[Material List] 使用 Mock 数据:', USE_MOCK_DATA)
289 -
290 - // 根据开关选择使用真实 API 或 Mock 数据
291 - const res = USE_MOCK_DATA
292 - ? await mockFileListAPI(params)
293 - : await fileListAPI(params)
294 -
295 - if (res.code === 1 && res.data) {
296 - // 如果是初始请求(没有 child_id),保存完整的分类信息
297 - if (!params.child_id && !params.keyword) {
298 - 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 -
303 - // 处理并缓存"全部"列表
304 - if (res.data.list?.length) {
305 - const allListData = res.data.list.map(transformDocItem)
306 -
307 - if (isLoadMore) {
308 - // 加载更多:追加数据
309 - allList.value = [...allList.value, ...allListData]
310 - categoryListCache.value.set('all', allList.value)
311 - // ✅ 同步更新 currentList(如果当前显示的是"全部")
312 - if (activeTabId.value === 'all') {
313 - currentList.value = allList.value
314 - }
315 - } else {
316 - // 首次加载:替换数据
317 - allList.value = allListData
318 - categoryListCache.value.set('all', allListData)
319 - // ✅ 同步更新 currentList(如果当前显示的是"全部")
320 - if (activeTabId.value === 'all') {
321 - currentList.value = allListData
322 - }
323 - }
324 -
325 - // 判断是否还有更多数据
326 - hasMore.value = allListData.length >= params.limit
327 - } else {
328 - if (isLoadMore) {
329 - hasMore.value = false
330 - } else {
331 - allList.value = []
332 - }
333 - }
334 - } else {
335 - // 是子分类请求或搜索请求
336 - const cacheKey = params.child_id || params.keyword || 'search'
337 -
338 - if (res.data.list?.length) {
339 - const listData = res.data.list.map(transformDocItem)
340 -
341 - if (isLoadMore) {
342 - // 加载更多:追加数据
343 - const existingData = categoryListCache.value.get(cacheKey) || []
344 - const newData = [...existingData, ...listData]
345 - categoryListCache.value.set(cacheKey, newData)
346 - // ✅ 同步更新 currentList(如果当前显示的是该缓存)
347 - const currentCacheKey = params.child_id || params.keyword || 'all'
348 - if (activeTabId.value === currentCacheKey) {
349 - currentList.value = newData
350 - }
351 - } else {
352 - // 首次加载:替换数据
353 - categoryListCache.value.set(cacheKey, listData)
354 - currentList.value = listData
355 - }
356 -
357 - // 判断是否还有更多数据
358 - hasMore.value = listData.length >= params.limit
359 - } else {
360 - if (isLoadMore) {
361 - hasMore.value = false
362 - } else {
363 - currentList.value = []
364 - }
365 - }
366 - }
367 - } else {
368 - Taro.showToast({
369 - title: res.msg || '获取资料列表失败',
370 - icon: 'none',
371 - duration: 2000
372 - })
373 - }
374 - } catch (error) {
375 - console.error('[Material List] 获取资料列表失败:', error)
376 - Taro.showToast({
377 - title: '加载失败',
378 - icon: 'error',
379 - duration: 2000
380 - })
381 - } finally {
382 - if (isLoadMore) {
383 - loadingMore.value = false
384 - } else {
385 - loading.value = false
386 - }
387 - }
388 -}
389 -
390 -/**
391 - * 页面加载时接收参数
392 - */
393 -useLoad(async (options) => {
394 - console.log('[Material List] 页面参数:', options)
395 -
396 - // 保存初始分类ID
397 - if (options.id) {
398 - initialCategoryId.value = options.id
399 - }
400 -
401 - // 设置页面标题
402 - if (options.title) {
403 - pageTitle.value = options.title
404 - }
405 -
406 - // 重置分页状态
407 - currentPage.value = 0
408 - hasMore.value = true
409 -
410 - // 获取资料列表(初始请求)
411 - await fetchMaterialList({
412 - cid: options.id,
413 - page: 0,
414 - limit: pageSize
415 - })
416 -
417 - // 初始化当前列表为"全部"列表(等待请求完成后)
418 - currentList.value = allList.value
419 -})
420 -
421 -/**
422 - * Tab 点击处理
423 - * @param {string} id - Tab ID
424 - */
425 -const onTabClick = async (id) => {
426 - activeTabId.value = id
427 - listVisible.value = false
428 -
429 - // 恢复或初始化该分类的分页状态
430 - const pageState = categoryPageCache.value.get(id)
431 - if (pageState) {
432 - currentPage.value = pageState.currentPage
433 - hasMore.value = pageState.hasMore
434 - } else {
435 - currentPage.value = 0
436 - hasMore.value = true
437 - }
438 -
439 - // 判断是否是"全部" tab
440 - if (id === 'all') {
441 - // 显示"全部"列表(从缓存或 allList)
442 - const cachedList = categoryListCache.value.get('all')
443 - currentList.value = cachedList || allList.value || []
444 - } else {
445 - // 检查缓存中是否有该分类的数据
446 - if (categoryListCache.value.has(id)) {
447 - // 从缓存中获取
448 - currentList.value = categoryListCache.value.get(id)
449 - } else {
450 - // 调用接口获取该分类的列表(第一页)
451 - await fetchMaterialList({
452 - cid: initialCategoryId.value,
453 - child_id: id,
454 - page: 0,
455 - limit: pageSize
456 - })
457 -
458 - // 保存分页状态
459 - categoryPageCache.value.set(id, {
460 - currentPage: 0,
461 - hasMore: hasMore.value
462 - })
463 - }
464 - }
465 -
466 - nextTick(() => {
467 - listRenderKey.value += 1
468 - listVisible.value = true
469 - })
470 -}
471 -
472 -/**
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 - * 搜索处理函数
536 - * @description 根据 child_id 和 keyword 调用接口查询列表
537 - */
538 -const onSearch = async () => {
539 - console.log('Searching for:', searchValue.value)
540 - console.log('当前分类:', activeTabId.value)
541 -
542 - // 如果没有搜索关键词,清空搜索并恢复当前分类的列表
543 - if (!searchValue.value.trim()) {
544 - // 恢复当前分类的列表
545 - if (activeTabId.value === 'all') {
546 - const cachedList = categoryListCache.value.get('all')
547 - currentList.value = cachedList || allList.value || []
548 - } else {
549 - const cachedList = categoryListCache.value.get(activeTabId.value)
550 - if (cachedList) {
551 - currentList.value = cachedList
552 - } else {
553 - // 如果缓存中没有,调用接口获取
554 - await fetchMaterialList({
555 - cid: initialCategoryId.value,
556 - child_id: activeTabId.value,
557 - page: 0,
558 - limit: pageSize
559 - })
560 - }
561 - }
562 -
563 - // 恢复分页状态
564 - const pageState = categoryPageCache.value.get(activeTabId.value)
565 - if (pageState) {
566 - currentPage.value = pageState.currentPage
567 - hasMore.value = pageState.hasMore
568 - }
569 - return
570 - }
571 -
572 - // 构建请求参数
573 - const params = {
574 - cid: initialCategoryId.value,
575 - page: 0,
576 - limit: pageSize
577 - }
578 -
579 - // 如果当前选中的是子分类,添加 child_id 参数
580 - if (activeTabId.value !== 'all') {
581 - params.child_id = activeTabId.value
582 - }
583 -
584 - // 添加搜索关键词
585 - params.keyword = searchValue.value.trim()
586 -
587 - // 重置分页状态
588 - currentPage.value = 0
589 - hasMore.value = true
590 -
591 - // 调用接口搜索
592 - try {
593 - loading.value = true
594 - console.log('[Material List] 搜索使用 Mock 数据:', USE_MOCK_DATA)
595 -
596 - // 根据开关选择使用真实 API 或 Mock 数据
597 - const res = USE_MOCK_DATA
598 - ? await mockFileListAPI(params)
599 - : await fileListAPI(params)
600 -
601 - if (res.code === 1 && res.data) {
602 - if (res.data.list?.length) {
603 - const listData = res.data.list.map(transformDocItem)
604 - currentList.value = listData
605 -
606 - // 缓存搜索结果
607 - categoryListCache.value.set(params.keyword, listData)
608 -
609 - // 判断是否还有更多数据
610 - hasMore.value = listData.length >= pageSize
611 - } else {
612 - currentList.value = []
613 - hasMore.value = false
614 - }
615 - } else {
616 - Taro.showToast({
617 - title: res.msg || '搜索失败',
618 - icon: 'none',
619 - duration: 2000
620 - })
621 - }
622 - } catch (error) {
623 - console.error('[Material List] 搜索失败:', error)
624 - Taro.showToast({
625 - title: '搜索失败',
626 - icon: 'error',
627 - duration: 2000
628 - })
629 - } finally {
630 - loading.value = false
631 - }
632 -}
633 -
634 -/**
635 - * 监听搜索关键字变化
636 - * @description 实现实时搜索:用户输入时自动触发搜索(带防抖)
637 - */
638 -watch(searchValue, (newValue, oldValue) => {
639 - console.log('[Material List] searchValue 变化:', oldValue, '->', newValue)
640 -
641 - // 如果搜索关键字为空,立即清除搜索(不需要防抖)
642 - if (!newValue?.trim()) {
643 - console.log('[Material List] 搜索关键字为空,立即清除')
644 - onClear()
645 - return
646 - }
647 -
648 - // 有搜索关键字,使用防抖搜索
649 - console.log('[Material List] 触发防抖搜索')
650 - debouncedSearch()
651 -})
652 -
653 -/**
654 - * 清除搜索关键词
655 - * @description 用户点击搜索框右侧的删除按钮时触发,重新请求当前分类的最新数据
656 - *
657 - * 场景说明:
658 - * - 有tab:重新请求当前tab的数据(不带keyword)
659 - * - 无tab:重新请求"全部"数据(不带keyword)
660 - */
661 -const onClear = async () => {
662 - console.log('[Material List] 清除搜索,重新请求数据')
663 - console.log('[Material List] 当前分类:', activeTabId.value)
664 -
665 - // 构建请求参数(不带 keyword)
666 - const params = {
667 - cid: initialCategoryId.value,
668 - page: 0,
669 - limit: pageSize
670 - }
671 -
672 - // 如果当前选中的是子分类,添加 child_id 参数
673 - if (activeTabId.value !== 'all') {
674 - params.child_id = activeTabId.value
675 - }
676 -
677 - // 重置分页状态为第一页
678 - currentPage.value = 0
679 - hasMore.value = true
680 -
681 - // 重新请求接口(不带 keyword,获取最新数据)
682 - await fetchMaterialList(params, false)
683 -
684 - // 更新当前显示的列表
685 - if (activeTabId.value === 'all') {
686 - // 全部列表:使用 allList
687 - currentList.value = allList.value
688 - } else {
689 - // 子分类列表:已经在 fetchMaterialList 中更新了 currentList
690 - }
691 -}
692 -
693 -/**
694 - * 使用文件列表点击处理器
695 - * @description 添加图片预览功能,点击图片文件时使用 Taro.previewImage
696 - */
697 -const { handleClick: onView } = useListItemClick({
698 - listType: ListType.FILE,
699 - onBeforeClick: async (item) => {
700 - /**
701 - * 检查文件类型并使用对应的预览方式
702 - * - 图片文件:使用 Taro.previewImage 预览
703 - * - 其他文件:继续默认的文件打开流程
704 - */
705 - const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']
706 - const extension = item.extension?.toLowerCase() || ''
707 -
708 - console.log('[Material List] 文件类型:', extension, '文件名:', item.title)
709 -
710 - if (imageExtensions.includes(extension)) {
711 - // 图片文件:使用 Taro 预览
712 - console.log('[Material List] 检测到图片文件,使用图片预览')
713 -
714 - // 构建图片列表(当前图片)
715 - const urls = [item.downloadUrl]
716 -
717 - try {
718 - // 短暂延迟后打开预览(让用户看到提示)
719 - await new Promise(resolve => setTimeout(resolve, 300))
720 -
721 - await Taro.previewImage({
722 - current: item.downloadUrl, // 当前显示图片的 http 链接
723 - urls: urls // 需要预览的图片 http 链接列表
724 - })
725 -
726 - // 预览成功,阻止默认的文件打开行为
727 - return false
728 - } catch (err) {
729 - console.error('[Material List] 图片预览失败:', err)
730 - Taro.showToast({
731 - title: '图片预览失败',
732 - icon: 'none',
733 - duration: 2000
734 - })
735 - // 预览失败,返回 true 继续默认行为
736 - return true
737 - }
738 - }
739 -
740 - // 非图片文件:继续默认的文件打开流程
741 - console.log('[Material List] 非图片文件,使用默认打开方式')
742 - return true
743 - },
744 - onAfterClick: (item) => {
745 - console.log('用户打开了资料:', item.title)
746 - }
747 -})
748 -
749 -/**
750 - * 切换收藏状态
751 - * @description 使用 useCollectOperation composable 处理收藏操作
752 - */
753 -const { toggleCollect } = useCollectOperation()
754 -
755 -/**
756 - * 删除资料
757 - */
758 -const onDelete = (item) => {
759 - Taro.showModal({
760 - title: '提示',
761 - content: '确定要删除该资料吗?',
762 - success: (res) => {
763 - if (res.confirm) {
764 - // 从 allList 中删除
765 - const index = allList.value.findIndex(i => i.id === item.id)
766 - if (index !== -1) {
767 - allList.value.splice(index, 1)
768 - // 重新渲染列表
769 - listRenderKey.value += 1
770 - Taro.showToast({ title: '已删除', icon: 'success' })
771 - }
772 - }
773 - }
774 - })
775 -}
776 -</script>
777 -
778 -<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 风格的标签栏
796 -.filter-tabs-wrapper {
797 - display: flex;
798 - overflow-x: auto;
799 - padding: 24rpx 32rpx;
800 - gap: 24rpx;
801 - transition: all 0.3s ease;
802 - background-color: #F9FAFB;
803 - width: 100%;
804 -
805 - // 隐藏滚动条
806 - &::-webkit-scrollbar {
807 - display: none;
808 - width: 0;
809 - height: 0;
810 - }
811 -
812 - -ms-overflow-style: none;
813 - scrollbar-width: none;
814 -}
815 -
816 -.filter-tab-item {
817 - display: flex;
818 - align-items: center;
819 - justify-content: center;
820 - padding: 0 32rpx;
821 - border-radius: 9999rpx;
822 - white-space: nowrap;
823 - transition: all 0.3s ease;
824 - flex-shrink: 0;
825 -}
826 -
827 -.filter-tab-active {
828 - background-color: #2563EB; // 蓝色背景
829 - color: #fff;
830 -}
831 -
832 -.filter-tab-inactive {
833 - background-color: #F3F4F6; // 灰色背景
834 - color: #6B7280;
835 -}
836 -
837 -.filter-tab-text {
838 - font-size: 28rpx;
839 - font-weight: 500;
840 -}
841 -
842 -// 覆盖 NutUI Tabs 默认样式,隐藏原有的头部和内容(因为我们使用自定义头部和外部列表)
843 -:deep(.nut-tabs__titles) {
844 - display: none;
845 -}
846 -
847 -:deep(.nut-tabs__content) {
848 - display: none;
849 -}
850 -
851 -// 加载更多容器
852 -.load-more-container {
853 - display: flex;
854 - justify-content: center;
855 - align-items: center;
856 - padding: 40rpx 0;
857 - min-height: 80rpx;
858 -}
859 -
860 -.load-more-loading {
861 - display: flex;
862 - align-items: center;
863 - justify-content: center;
864 -}
865 -
866 -.load-more-finished {
867 - display: flex;
868 - align-items: center;
869 - justify-content: center;
870 -}
871 -
872 -// 自定义加载动画
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 -}
887 -</style>
1 -<template>
2 - <view class="min-h-screen bg-[#F9FAFB] pb-safe">
3 - <NavHeader title="我的消息" />
4 -
5 - <!-- 列表区域 -->
6 - <view class="p-4">
7 - <template v-if="messageList.length > 0">
8 - <view
9 - v-for="item in messageList"
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)"
13 - >
14 - <view class="flex justify-between items-start mb-2">
15 - <view class="flex-1 mr-2">
16 - <view class="text-base font-bold text-gray-900 line-clamp-1">
17 - {{ item.title }}
18 - </view>
19 - </view>
20 - <text class="text-xs text-gray-400 shrink-0 mt-1">
21 - {{ item.create_time }}
22 - </text>
23 - </view>
24 -
25 - <view class="text-sm text-gray-600 line-clamp-2 leading-relaxed">
26 - {{ item.intro || item.content || '暂无简介' }}
27 - </view>
28 - </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>
37 -
38 - <!-- 空状态 -->
39 - <nut-empty
40 - v-else-if="!loading && messageList.length === 0"
41 - description="暂无消息"
42 - image="empty"
43 - />
44 - </view>
45 - </view>
46 -</template>
47 -
48 -<script setup>
49 -import { ref } from 'vue'
50 -import { useLoad, usePullDownRefresh, useReachBottom, stopPullDownRefresh } from '@tarojs/taro'
51 -import { useGo } from '@/hooks/useGo'
52 -import NavHeader from '@/components/NavHeader.vue'
53 -import { myListAPI } from '@/api/news'
54 -import { mockMessageListAPI } from '@/utils/mockData'
55 -
56 -// ⚠️ MOCK 数据开关 - 开发环境使用 mock 数据,生产环境使用真实 API
57 -const USE_MOCK_DATA = process.env.NODE_ENV === 'development'
58 -
59 -const go = useGo()
60 -
61 -const messageList = ref([])
62 -const page = ref(1)
63 -const limit = ref(10)
64 -const hasMore = ref(true)
65 -const loading = ref(false)
66 -
67 -/**
68 - * @description 加载消息列表
69 - * @param {boolean} refresh 是否刷新
70 - */
71 -const fetchMessageList = async (refresh = false) => {
72 - if (loading.value) return
73 -
74 - if (refresh) {
75 - page.value = 1
76 - hasMore.value = true
77 - } else if (!hasMore.value) {
78 - return
79 - }
80 -
81 - loading.value = true
82 -
83 - try {
84 - console.log('[Message] 使用 Mock 数据:', USE_MOCK_DATA)
85 -
86 - // 根据开关选择使用真实 API 或 Mock 数据
87 - const res = USE_MOCK_DATA
88 - ? await mockMessageListAPI({
89 - page: page.value,
90 - limit: limit.value
91 - })
92 - : await myListAPI({
93 - page: page.value,
94 - limit: limit.value
95 - })
96 -
97 - if (res.code === 1) {
98 - const list = res.data?.list || []
99 -
100 - if (refresh) {
101 - messageList.value = list
102 - } else {
103 - messageList.value = [...messageList.value, ...list]
104 - }
105 -
106 - if (list.length < limit.value) {
107 - hasMore.value = false
108 - } else {
109 - page.value++
110 - }
111 - }
112 - } catch (err) {
113 - console.error('获取消息列表失败:', err)
114 - } finally {
115 - loading.value = false
116 - if (refresh) {
117 - stopPullDownRefresh()
118 - }
119 - }
120 -}
121 -
122 -/**
123 - * @description 跳转到详情页
124 - * @param {Object} item 消息对象
125 - */
126 -const handleItemClick = (item) => {
127 - go('/pages/message-detail/index', { id: item.id })
128 -}
129 -
130 -// 页面加载
131 -useLoad(() => {
132 - fetchMessageList(true)
133 -})
134 -
135 -// 下拉刷新
136 -usePullDownRefresh(() => {
137 - fetchMessageList(true)
138 -})
139 -
140 -// 上拉加载更多
141 -useReachBottom(() => {
142 - fetchMessageList()
143 -})
144 -</script>
145 -
146 -<style lang="less">
147 -/* Scoped styles if needed */
148 -</style>
1 -<!--
2 - * @Date: 2026-01-31
3 - * @Description: 产品中心 - API 接口集成版本(含搜索功能)
4 --->
5 -<template>
6 - <view class="bg-[#F9FAFB]">
7 - <!-- 固定在顶部的导航和搜索 -->
8 - <view class="bg-[#F9FAFB] sticky top-0 z-10">
9 - <NavHeader title="产品中心" />
10 -
11 - <!-- Search Bar -->
12 - <view class="px-[24rpx] py-[16rpx] bg-white">
13 - <SearchBar
14 - v-model="searchValue"
15 - placeholder="搜索产品名称..."
16 - variant="rounded"
17 - :show-clear="true"
18 - @search="onSearch"
19 - @input="onSearchInput"
20 - @clear="onClear"
21 - />
22 - </view>
23 -
24 - <!-- Tabs Container -->
25 - <view class="bg-white mt-[2rpx]">
26 - <nut-tabs v-model="activeTabId">
27 - <!-- 自定义标签栏 -->
28 - <template #titles>
29 - <view class="filter-tabs-wrapper">
30 - <view
31 - v-for="item in tabsData"
32 - :key="item.id"
33 - :class="[
34 - 'filter-tab-item',
35 - activeTabId === item.id ? 'filter-tab-active' : 'filter-tab-inactive'
36 - ]"
37 - @tap="onTabClick(item.id)"
38 - >
39 - <text class="filter-tab-text">{{ item.name }}</text>
40 - </view>
41 - </view>
42 - </template>
43 - </nut-tabs>
44 - </view>
45 - </view>
46 -
47 - <!-- 列表容器 - 页面级滚动 -->
48 - <view class="pb-[calc(160rpx+env(safe-area-inset-bottom))]">
49 - <!-- 加载状态 -->
50 - <view v-if="loading && products.length === 0" class="flex justify-center items-center py-[100rpx]">
51 - <text class="text-gray-400 text-[28rpx]">加载中...</text>
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 - >
61 - <!-- Product Content (Horizontal Layout) -->
62 - <view class="flex gap-[24rpx]" @tap="handleProductClick(item)">
63 - <!-- Image Container -->
64 - <view class="relative w-[220rpx] h-[220rpx] flex-shrink-0 product-card-item">
65 - <image :src="item.cover_image" class="w-full h-full object-cover bg-gray-100" mode="aspectFill" />
66 - <!-- Tag -->
67 - <view v-if="item.recommend === 'hot'"
68 - class="absolute top-[12rpx] right-[12rpx] bg-red-500 text-white text-[20rpx] px-[12rpx] py-[4rpx] rounded-full">
69 - 热卖
70 - </view>
71 - </view>
72 -
73 - <!-- Content -->
74 - <view class="flex-1 flex flex-col py-[20rpx] pr-[20rpx]">
75 - <!-- Title -->
76 - <view class="text-[#1F2937] text-[32rpx] font-medium leading-[1.4] line-clamp-2 mb-[12rpx]">
77 - {{ item.product_name }}
78 - </view>
79 -
80 - <!-- 动态标签 -->
81 - <view v-if="item.tags && item.tags.length > 0" class="flex flex-wrap gap-[8rpx] mt-[8rpx] mb-[8rpx]">
82 - <view
83 - v-for="tag in item.tags"
84 - :key="tag.id"
85 - class="text-[20rpx] px-[12rpx] py-[4rpx] rounded-full"
86 - :style="{
87 - backgroundColor: tag.bg_color,
88 - color: tag.text_color
89 - }"
90 - >
91 - {{ tag.name }}
92 - </view>
93 - </view>
94 -
95 - <!-- 按钮组 - 靠右对齐(纯文字按钮) -->
96 - <view class="flex gap-[16rpx] ml-auto items-center mt-auto" @tap.stop>
97 - <!-- 详情按钮 -->
98 - <view
99 - class="text-[24rpx] text-[#2563EB] bg-blue-50 px-[24rpx] py-[8rpx] rounded-full active:bg-blue-100 transition-colors duration-200"
100 - @tap.stop="handleProductClick(item)"
101 - >
102 - 详情
103 - </view>
104 -
105 - <!-- 计划书按钮 -->
106 - <view
107 - class="text-[24rpx] text-white bg-blue-500 px-[24rpx] py-[8rpx] rounded-full active:bg-blue-600 transition-colors duration-200"
108 - @tap.stop="openPlanPopup(item)"
109 - >
110 - 计划书
111 - </view>
112 - </view>
113 - </view>
114 - </view>
115 - </view>
116 - </view>
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 -
128 - <!-- 空状态 -->
129 - <view v-if="!loading && products.length === 0">
130 - <nut-empty description="暂无相关产品" image="empty" />
131 - </view>
132 - </view>
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>
146 -</template>
147 -
148 -<script setup>
149 -import { ref, computed } from 'vue'
150 -import Taro, { useLoad, useReachBottom } from '@tarojs/taro'
151 -import NavHeader from '@/components/NavHeader.vue'
152 -import SearchBar from '@/components/SearchBar.vue'
153 -import PlanFormContainer from '@/components/PlanFormContainer.vue'
154 -import { useListItemClick, ListType } from '@/composables/useListItemClick'
155 -import { listAPI } from '@/api/get_product'
156 -import { mockProductListAPI } from '@/utils/mockData'
157 -
158 -// ⚠️ MOCK 数据开关 - 开发环境使用 mock 数据,生产环境使用真实 API
159 -const USE_MOCK_DATA = process.env.NODE_ENV === 'development'
160 -
161 -const activeTabId = ref('')
162 -
163 -// 搜索状态
164 -const searchValue = ref('')
165 -// 搜索防抖定时器
166 -let searchTimer = null
167 -
168 -// 分页状态
169 -const page = ref(0)
170 -const limit = ref(10)
171 -const loading = ref(false)
172 -const hasMore = ref(true)
173 -
174 -// 数据状态
175 -const categories = ref([]) // 从接口获取的分类列表
176 -const products = ref([]) // 当前产品列表
177 -const total = ref(0) // 产品总数
178 -
179 -// 计划书弹窗状态
180 -const showPlanPopup = ref(false)
181 -const selectedProduct = ref(null)
182 -
183 -/**
184 - * 标签栏数据(根据接口返回的 categories 生成)
185 - * @description 包含"全部"选项和接口返回的分类
186 - */
187 -const tabsData = computed(() => {
188 - const allTab = { id: '', name: '全部' }
189 - const categoryTabs = categories.value.map(cat => ({
190 - id: String(cat.id),
191 - name: cat.name
192 - }))
193 - return [allTab, ...categoryTabs]
194 -})
195 -
196 -/**
197 - * 获取产品列表
198 - * @description 根据 activeTabId 和 searchValue 获取对应分类的产品列表
199 - */
200 -const fetchProducts = async (isLoadMore = false) => {
201 - if (loading.value) return
202 -
203 - loading.value = true
204 -
205 - try {
206 - const params = {
207 - page: String(page.value),
208 - limit: String(limit.value)
209 - }
210 -
211 - // 如果不是"全部"标签,添加分类 ID 参数
212 - if (activeTabId.value !== '') {
213 - params.cid = activeTabId.value
214 - }
215 -
216 - // 添加搜索关键词参数
217 - if (searchValue.value) {
218 - params.keyword = searchValue.value
219 - }
220 -
221 - console.log('[Product Center] 使用 Mock 数据:', USE_MOCK_DATA)
222 -
223 - // 根据开关选择使用真实 API 或 Mock 数据
224 - const res = USE_MOCK_DATA
225 - ? await mockProductListAPI(params)
226 - : await listAPI(params)
227 -
228 - if (res.code === 1 && res.data) {
229 - // 更新分类列表(首次加载时)
230 - if (!isLoadMore && res.data.categories) {
231 - categories.value = res.data.categories
232 - }
233 -
234 - // 处理产品列表
235 - if (isLoadMore) {
236 - // 加载更多:追加数据
237 - products.value = [...products.value, ...res.data.list]
238 - } else {
239 - // 首次加载或切换分类:替换数据
240 - products.value = res.data.list || []
241 - }
242 -
243 - // 更新总数和分页状态
244 - total.value = res.data.total || 0
245 - hasMore.value = products.value.length < total.value
246 - } else {
247 - Taro.showToast({
248 - title: res.msg || '获取产品列表失败',
249 - icon: 'none'
250 - })
251 - }
252 - } catch (err) {
253 - console.error('获取产品列表失败:', err)
254 - Taro.showToast({
255 - title: '网络错误,请重试',
256 - icon: 'none'
257 - })
258 - } finally {
259 - loading.value = false
260 - }
261 -}
262 -
263 -/**
264 - * Tab 点击处理
265 - * @description 切换分类,重置分页并重新加载数据
266 - */
267 -const onTabClick = (id) => {
268 - if (activeTabId.value === id) return
269 -
270 - activeTabId.value = id
271 -
272 - // 重置分页状态
273 - page.value = 0
274 - products.value = []
275 - hasMore.value = true
276 -
277 - // 重新加载数据(保持搜索状态)
278 - fetchProducts(false)
279 -}
280 -
281 -/**
282 - * 搜索输入处理(带防抖)
283 - * @description 用户输入时实时搜索,使用防抖优化性能
284 - * @param {string} value - 搜索关键词
285 - */
286 -const onSearchInput = (value) => {
287 - console.log('搜索输入:', value)
288 -
289 - // 清除之前的定时器
290 - if (searchTimer) {
291 - clearTimeout(searchTimer)
292 - }
293 -
294 - // 设置新的定时器(500ms 后执行搜索)
295 - searchTimer = setTimeout(() => {
296 - // 重置分页状态
297 - page.value = 0
298 - products.value = []
299 - hasMore.value = true
300 -
301 - // 重新加载数据
302 - fetchProducts(false)
303 - }, 500)
304 -}
305 -
306 -/**
307 - * 搜索处理(回车键)
308 - * @description 用户按下回车或点击搜索按钮时触发
309 - * @param {string} value - 搜索关键词
310 - */
311 -const onSearch = (value) => {
312 - console.log('搜索产品:', value)
313 -
314 - // 清除防抖定时器
315 - if (searchTimer) {
316 - clearTimeout(searchTimer)
317 - searchTimer = null
318 - }
319 -
320 - // 重置分页状态
321 - page.value = 0
322 - products.value = []
323 - hasMore.value = true
324 -
325 - // 重新加载数据
326 - fetchProducts(false)
327 -}
328 -
329 -/**
330 - * 清空搜索
331 - * @description 用户点击清除按钮时触发
332 - */
333 -const onClear = () => {
334 - console.log('清空搜索')
335 -
336 - // 清除防抖定时器
337 - if (searchTimer) {
338 - clearTimeout(searchTimer)
339 - searchTimer = null
340 - }
341 -
342 - // 重置分页状态
343 - page.value = 0
344 - products.value = []
345 - hasMore.value = true
346 -
347 - // 重新加载数据
348 - fetchProducts(false)
349 -}
350 -
351 -/**
352 - * 使用产品列表点击处理器
353 - * @description 配置为产品类型列表,点击时跳转到产品详情页
354 - */
355 -const { handleClick: handleProductClick } = useListItemClick({
356 - listType: ListType.PRODUCT,
357 - onAfterClick: (item) => {
358 - console.log('用户查看了产品:', item.product_name)
359 - }
360 -})
361 -
362 -/**
363 - * 打开计划书弹窗
364 - * @description 根据产品对象打开计划书表单
365 - * @param {Object} product - 产品对象
366 - */
367 -const openPlanPopup = (product) => {
368 - selectedProduct.value = product
369 - showPlanPopup.value = true
370 -}
371 -
372 -/**
373 - * 处理计划书提交
374 - * @description 测试环境:前端不调用后端API,直接跳转到结果页
375 - * 生产环境:需要调用 submitPlanAPI 提交表单数据
376 - * @param {Object} formData - 表单数据
377 - */
378 -const handlePlanSubmit = (formData) => {
379 - console.log('计划书提交:', {
380 - product_id: selectedProduct.value.id,
381 - product_name: selectedProduct.value.product_name,
382 - form_sn: selectedProduct.value.form_sn,
383 - form_data: formData
384 - })
385 -
386 - // 关闭弹窗
387 - showPlanPopup.value = false
388 -
389 - // TODO: 后端接口还没有准备好,暂时不调用API
390 - // 测试完成后需要对接 submitPlanAPI
391 - // const res = await submitPlanAPI({
392 - // product_id: selectedProduct.value.id,
393 - // template: selectedProduct.value.form_sn,
394 - // form_data: formData
395 - // })
396 -
397 - // 模拟提交成功,跳转到结果页面
398 - Taro.navigateTo({
399 - url: '/pages/plan-submit-result/index?success=true'
400 - })
401 -}
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>
426 -
427 -<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 风格的标签栏
445 -.filter-tabs-wrapper {
446 - display: flex;
447 - overflow-x: auto;
448 - padding: 24rpx 40rpx;
449 - gap: 24rpx;
450 - transition: all 0.3s ease;
451 - background-color: #F9FAFB;
452 - width: 100%;
453 -
454 - // 隐藏滚动条
455 - &::-webkit-scrollbar {
456 - display: none;
457 - width: 0;
458 - height: 0;
459 - }
460 -
461 - -ms-overflow-style: none;
462 - scrollbar-width: none;
463 -}
464 -
465 -.filter-tab-item {
466 - display: flex;
467 - align-items: center;
468 - justify-content: center;
469 - padding: 0 32rpx;
470 - border-radius: 9999rpx;
471 - white-space: nowrap;
472 - transition: all 0.3s ease;
473 - flex-shrink: 0;
474 -}
475 -
476 -.filter-tab-active {
477 - background-color: #2563EB; // 蓝色背景
478 - color: #fff;
479 -}
480 -
481 -.filter-tab-inactive {
482 - background-color: #F3F4F6; // 灰色背景
483 - color: #6B7280;
484 -}
485 -
486 -.filter-tab-text {
487 - font-size: 28rpx;
488 - font-weight: 500;
489 -}
490 -
491 -// 覆盖 NutUI Tabs 默认样式,隐藏原有的头部和内容(因为我们使用自定义头部和外部列表)
492 -:deep(.nut-tabs__titles) {
493 - display: none;
494 -}
495 -
496 -:deep(.nut-tabs__content) {
497 - display: none;
498 -}
499 -
500 -/* 多行文本省略 */
501 -.line-clamp-2 {
502 - display: -webkit-box;
503 - -webkit-box-orient: vertical;
504 - -webkit-line-clamp: 2;
505 - line-clamp: 2;
506 - overflow: hidden;
507 - word-break: break-all;
508 -}
509 -</style>
1 -<!--
2 - * @Date: 2026-02-06
3 - * @Description: 搜索页面 - 支持产品和资料搜索,实时查询API
4 --->
5 -<template>
6 - <view class="bg-[#FFF]">
7 - <!-- 固定顶部:导航栏 + 搜索栏 + Tabs + 结果计数 -->
8 - <view class="bg-[#FFF] sticky top-0 z-10">
9 - <NavHeader title="搜索" />
10 -
11 - <!-- Search Input -->
12 - <view class="px-[40rpx] mt-[32rpx]">
13 - <SearchBar
14 - v-model="searchKeyword"
15 - placeholder="搜索培训资料、案例、产品..."
16 - variant="rounded"
17 - :show-border="true"
18 - :show-clear="true"
19 - @search="handleSearch"
20 - @clear="clearSearch"
21 - />
22 - </view>
23 -
24 - <!-- Tabs Container -->
25 - <nut-tabs v-model="activeTab">
26 - <!-- 自定义标签栏 -->
27 - <template #titles>
28 - <view class="filter-tabs-wrapper">
29 - <view
30 - v-for="item in tabsData"
31 - :key="item.id"
32 - :class="[
33 - 'filter-tab-item',
34 - activeTab === item.id ? 'filter-tab-active' : 'filter-tab-inactive',
35 - !activeTab ? 'filter-tab-inactive' : '' // 初始状态不高亮任何tab
36 - ]"
37 - @tap="onTabClick(item.id)"
38 - >
39 - <text class="filter-tab-text">{{ item.name }}</text>
40 - </view>
41 - </view>
42 - </template>
43 - </nut-tabs>
44 -
45 - <!-- Result Count -->
46 - <view v-if="currentList.length > 0" class="px-[60rpx] text-[#6B7280] text-[24rpx] pb-[24rpx]">
47 - 找到 {{ currentTotal }} 个相关结果
48 - </view>
49 - </view>
50 -
51 - <!-- 列表容器 -->
52 - <view class="px-[40rpx] pb-[calc(160rpx+env(safe-area-inset-bottom))]">
53 -
54 - <!-- Search Results -->
55 - <view
56 - v-if="currentList.length > 0"
57 - :key="listRenderKey"
58 - >
59 - <!-- Product Results -->
60 - <view v-if="activeTab === 'product'" class="flex flex-col gap-[24rpx] pb-[40rpx]">
61 - <ProductCard
62 - v-for="(item, index) in currentList"
63 - :key="index"
64 - :product-id="item.id"
65 - :product-name="item.product_name || item.name"
66 - :tags="item.tags || []"
67 - class="search-result-item"
68 - :style="{ animationDelay: `${index * 30}ms` }"
69 - @detail="goToProductDetail"
70 - @plan="openPlanPopup"
71 - />
72 - </view>
73 -
74 - <!-- File Results -->
75 - <view v-else-if="activeTab === 'file'" class="flex flex-col gap-[24rpx] pb-[40rpx]">
76 - <MaterialCard
77 - v-for="(item, index) in currentList"
78 - :key="index"
79 - :id="item.id"
80 - :title="item.title"
81 - :file-name="item.fileName"
82 - :file-size="item.fileSize"
83 - :learners="item.learners"
84 - :read-people-percent="item.readPeoplePercent"
85 - :collected="item.collected"
86 - :extension="item.extension"
87 - :download-url="item.downloadUrl"
88 - class="search-result-item"
89 - :style="{ animationDelay: `${index * 30}ms` }"
90 - @collect-changed="handleCollectChanged(item, $event)"
91 - />
92 - </view>
93 -
94 - <!-- 加载更多提示 -->
95 - <view v-if="currentList.length > 0" class="flex items-center justify-center py-[40rpx]">
96 - <view v-if="loadingMore" class="flex items-center">
97 - <view class="loading-spinner-small"></view>
98 - <text class="ml-[12rpx] text-[#9CA3AF] text-[24rpx]">加载中...</text>
99 - </view>
100 - <view v-else-if="!hasMore" class="text-[#9CA3AF] text-[24rpx]">
101 - 没有更多了
102 - </view>
103 - </view>
104 - </view>
105 -
106 - <!-- Empty State (已搜索但无结果) -->
107 - <view v-else-if="hasSearched && currentList.length === 0" class="flex flex-col items-center justify-center py-[40rpx]">
108 - <nut-empty description="暂无搜索结果" image="empty">
109 - <view class="text-[#9CA3AF] text-[24rpx] mt-[12rpx]">试试其他关键词吧</view>
110 - </nut-empty>
111 - </view>
112 -
113 - <!-- Initial State (从未搜索过) -->
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 -
121 - <!-- Plan Form Container -->
122 - <!-- 仅当 selectedProduct 不为 null 时才渲染组件,避免 product prop required 警告 -->
123 - <PlanFormContainer
124 - v-if="selectedProduct"
125 - v-model:visible="showPlanPopup"
126 - :product="selectedProduct"
127 - @close="showPlanPopup = false"
128 - @submit="handlePlanSubmit"
129 - />
130 - </view>
131 -</template>
132 -
133 -<script setup>
134 -import { ref, computed } from 'vue'
135 -import Taro, { useReachBottom } from '@tarojs/taro'
136 -import { useGo } from '@/hooks/useGo'
137 -import NavHeader from '@/components/NavHeader.vue'
138 -import IconFont from '@/components/IconFont.vue'
139 -import SearchBar from '@/components/SearchBar.vue'
140 -import ProductCard from '@/components/ProductCard.vue'
141 -import MaterialCard from '@/components/MaterialCard.vue'
142 -import PlanFormContainer from '@/components/PlanFormContainer.vue'
143 -import { searchAPI } from '@/api/search'
144 -import { mockSearchAPI } from '@/utils/mockData'
145 -
146 -// ⚠️ MOCK 数据开关 - 开发环境使用 mock 数据,生产环境使用真实 API
147 -const USE_MOCK_DATA = process.env.NODE_ENV === 'development'
148 -
149 -// Navigation
150 -const go = useGo()
151 -
152 -// Plan Popup State
153 -const showPlanPopup = ref(false)
154 -const selectedProduct = ref(null)
155 -
156 -// State
157 -const searchKeyword = ref('')
158 -const activeTab = ref('') // 当前选中的 tab(初始为空,不选中任何tab)
159 -const hasSearched = ref(false) // 是否已经搜索过
160 -const listRenderKey = ref(0)
161 -
162 -// 数据状态
163 -const products = ref([]) // 产品列表
164 -const files = ref([]) // 资料列表
165 -const productsTotal = ref(0) // 产品总数
166 -const filesTotal = ref(0) // 资料总数
167 -const loadingMore = ref(false) // 加载更多状态
168 -const hasMore = ref(true) // 是否还有更多数据
169 -const currentPage = ref(0) // 当前页码(从0开始)
170 -const pageSize = 20 // 每页数量
171 -
172 -/**
173 - * Tab 数据源(只保留产品和资料)
174 - */
175 -const tabsData = ref([
176 - { id: 'product', name: '产品' },
177 - { id: 'file', name: '资料' },
178 -])
179 -
180 -/**
181 - * 当前显示的列表
182 - */
183 -const currentList = computed(() => {
184 - // 如果没有选中任何tab,返回空数组
185 - if (!activeTab.value) return []
186 -
187 - if (activeTab.value === 'product') {
188 - return products.value
189 - } else {
190 - return files.value
191 - }
192 -})
193 -
194 -/**
195 - * 当前列表总数
196 - */
197 -const currentTotal = computed(() => {
198 - // 如果没有选中任何tab,返回0
199 - if (!activeTab.value) return 0
200 -
201 - if (activeTab.value === 'product') {
202 - return productsTotal.value
203 - } else {
204 - return filesTotal.value
205 - }
206 -})
207 -
208 -/**
209 - * 执行搜索
210 - * @param {string} keyword - 搜索关键字
211 - * @param {string} type - 可选,'product' | 'file' | undefined
212 - * @param {number} page - 页码(从0开始)
213 - * @param {number} limit - 每页数量
214 - * @param {boolean} isLoadMore - 是否为加载更多
215 - */
216 -const performSearch = async (keyword, type, page = 0, limit = pageSize, isLoadMore = false) => {
217 - try {
218 - // 如果是加载更多,使用 loadingMore 状态;否则使用 loading 状态
219 - if (isLoadMore) {
220 - loadingMore.value = true
221 - } else {
222 - Taro.showLoading({ title: '搜索中...', mask: true })
223 - }
224 -
225 - const params = { keyword, page, limit }
226 - if (type) params.type = type
227 -
228 - console.log('[Search] 使用 Mock 数据:', USE_MOCK_DATA)
229 -
230 - // 根据开关选择使用真实 API 或 Mock 数据
231 - const res = USE_MOCK_DATA
232 - ? await mockSearchAPI(params)
233 - : await searchAPI(params)
234 -
235 - if (res.code === 1) {
236 - // 映射产品列表
237 - const newProducts = res.data.products.list || []
238 -
239 - // 映射资料列表(进行字段映射,与首页保持一致)
240 - const newFiles = (res.data.files.list || []).map(item => {
241 - // 提取文件扩展名
242 - const fileName = item.name || '未命名文件'
243 - const extension = item.extension || fileName.split('.').pop()?.toLowerCase() || ''
244 -
245 - return {
246 - id: item.meta_id || item.id,
247 - title: item.name,
248 - fileName: fileName,
249 - fileSize: item.size || item.file_size,
250 - downloadUrl: item.src || item.value,
251 - extension: extension,
252 - learners: item.read_people_count ? `${item.read_people_count }人学习` : '',
253 - readPeoplePercent: item.read_people_percent,
254 - is_favorite: item.is_favorite, // 保留原始字段
255 - collected: Boolean(item.is_favorite) // 转换为 Boolean 供 MaterialCard 使用
256 - }
257 - })
258 -
259 - // 根据是否为加载更多来处理数据
260 - if (isLoadMore) {
261 - // 加载更多:追加数据
262 - products.value = [...products.value, ...newProducts]
263 - files.value = [...files.value, ...newFiles]
264 - } else {
265 - // 首次加载或刷新:替换数据
266 - products.value = newProducts
267 - files.value = newFiles
268 - }
269 -
270 - productsTotal.value = res.data.products.total || 0
271 - filesTotal.value = res.data.files.total || 0
272 -
273 - // ⚠️ 重要:必须先自动选择 tab,然后再计算 hasMore
274 - // 如果不传 type,自动选择有数据的 tab(仅首次搜索时)
275 - if (!type && !isLoadMore) {
276 - if (productsTotal.value > 0) {
277 - activeTab.value = 'product'
278 - } else if (filesTotal.value > 0) {
279 - activeTab.value = 'file'
280 - }
281 - // 如果都为 0,默认 product
282 - }
283 -
284 - // 判断是否还有更多数据
285 - // 使用当前列表长度与总数比较
286 - // 注意:需要根据实际选择的tab来判断
287 - const actualTab = type || activeTab.value
288 - if (actualTab === 'product') {
289 - hasMore.value = products.value.length < productsTotal.value
290 - } else if (actualTab === 'file') {
291 - hasMore.value = files.value.length < filesTotal.value
292 - } else {
293 - // 如果都没有选中,保守设置为false
294 - hasMore.value = false
295 - }
296 -
297 - hasSearched.value = true
298 - listRenderKey.value += 1
299 -
300 - console.log('[Search] 搜索成功', {
301 - productsTotal: productsTotal.value,
302 - filesTotal: filesTotal.value,
303 - activeTab: activeTab.value,
304 - isLoadMore,
305 - hasMore: hasMore.value
306 - })
307 - } else {
308 - Taro.showToast({
309 - title: res.msg || '搜索失败',
310 - icon: 'none'
311 - })
312 - }
313 - } catch (err) {
314 - console.error('[Search] 搜索失败:', err)
315 - Taro.showToast({
316 - title: '搜索失败,请重试',
317 - icon: 'none'
318 - })
319 - } finally {
320 - if (isLoadMore) {
321 - loadingMore.value = false
322 - } else {
323 - Taro.hideLoading()
324 - }
325 - }
326 -}
327 -
328 -/**
329 - * Tab 点击处理(实时查询)
330 - */
331 -const onTabClick = async (tabId) => {
332 - if (activeTab.value === tabId) return
333 -
334 - // 立即切换 tab(响应更快)
335 - activeTab.value = tabId
336 - listRenderKey.value += 1
337 -
338 - // 重置分页状态
339 - currentPage.value = 0
340 - hasMore.value = true
341 -
342 - // 如果已经搜索过,实时查询对应类型的数据
343 - if (hasSearched.value && searchKeyword.value.trim()) {
344 - console.log('[Search] 切换 tab,实时查询:', tabId)
345 - await performSearch(searchKeyword.value.trim(), tabId, 0, pageSize, false)
346 - }
347 -}
348 -
349 -/**
350 - * 提交搜索
351 - */
352 -const handleSearch = async () => {
353 - const keyword = searchKeyword.value.trim()
354 - if (!keyword) {
355 - Taro.showToast({
356 - title: '请输入搜索关键词',
357 - icon: 'none'
358 - })
359 - return
360 - }
361 -
362 - console.log('[Search] 提交搜索:', keyword)
363 -
364 - // 重置分页状态
365 - currentPage.value = 0
366 - hasMore.value = true
367 -
368 - // 不传 type,让后端返回两种数据,前端自动选择 tab
369 - await performSearch(keyword, undefined, 0, pageSize, false)
370 -}
371 -
372 -/**
373 - * 清空搜索
374 - */
375 -const clearSearch = () => {
376 - console.log('[Search] 清空搜索')
377 - searchKeyword.value = ''
378 - hasSearched.value = false
379 - products.value = []
380 - files.value = []
381 - productsTotal.value = 0
382 - filesTotal.value = 0
383 - activeTab.value = '' // 重置为空,不选中任何tab
384 - currentPage.value = 0
385 - hasMore.value = true
386 - listRenderKey.value += 1
387 -}
388 -
389 -/**
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 - * 跳转到产品详情页
429 - *
430 - * @description 处理产品详情按钮点击事件
431 - * @param {number} productId - 产品ID
432 - */
433 -const goToProductDetail = (productId) => {
434 - go('/pages/product-detail/index', { id: productId })
435 -}
436 -
437 -/**
438 - * 打开计划书弹窗
439 - *
440 - * @description 根据产品ID找到对应的产品对象,并打开计划书表单
441 - * @param {number} productId - 产品ID
442 - */
443 -const openPlanPopup = (productId) => {
444 - // 从产品列表中找到对应的产品
445 - const product = products.value.find(p => p.id === productId)
446 -
447 - if (!product) {
448 - Taro.showToast({
449 - title: '产品不存在',
450 - icon: 'none',
451 - duration: 2000
452 - })
453 - return
454 - }
455 -
456 - // 设置选中的产品
457 - selectedProduct.value = product
458 - showPlanPopup.value = true
459 -}
460 -
461 -/**
462 - * 处理计划书提交
463 - *
464 - * @description 测试环境:前端不调用后端API,直接跳转到结果页
465 - * 生产环境:需要调用 submitPlanAPI 提交表单数据
466 - * @param {Object} formData - 表单数据
467 - */
468 -const handlePlanSubmit = (formData) => {
469 - console.log('计划书提交:', {
470 - product_id: selectedProduct.value.id,
471 - product_name: selectedProduct.value.product_name || selectedProduct.value.name,
472 - form_sn: selectedProduct.value.form_sn,
473 - form_data: formData
474 - })
475 -
476 - // 关闭弹窗
477 - showPlanPopup.value = false
478 -
479 - // TODO: 后端接口还没有准备好,暂时不调用API
480 - // 测试完成后需要对接 submitPlanAPI
481 - // const res = await submitPlanAPI({
482 - // product_id: selectedProduct.value.id,
483 - // template: selectedProduct.value.form_sn,
484 - // form_data: formData
485 - // });
486 -
487 - // 模拟提交成功,跳转到结果页面
488 - go('/pages/plan-submit-result/index', {
489 - success: 'true'
490 - })
491 -}
492 -
493 -/**
494 - * 处理收藏状态改变
495 - *
496 - * @description 当用户点击收藏按钮时,更新本地状态
497 - * @param {Object} item - 资料对象
498 - * @param {Object} newStatus - 新的状态
499 - */
500 -const handleCollectChanged = (item, newStatus) => {
501 - console.log('[Search] 收藏状态改变:', item.title, newStatus.collected)
502 - // 找到对应的项并更新状态
503 - const file = files.value.find(f => f.id === item.id)
504 - if (file) {
505 - file.collected = newStatus.collected
506 - }
507 -}
508 -
509 -</script>
510 -
511 -<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 风格的标签栏
548 -.filter-tabs-wrapper {
549 - display: flex;
550 - overflow-x: auto;
551 - padding: 24rpx 60rpx;
552 - gap: 24rpx;
553 - transition: all 0.3s ease;
554 - background-color: #FFF;
555 - width: 100%;
556 -
557 - // 隐藏滚动条
558 - &::-webkit-scrollbar {
559 - display: none;
560 - width: 0;
561 - height: 0;
562 - }
563 -
564 - -ms-overflow-style: none;
565 - scrollbar-width: none;
566 -}
567 -
568 -.filter-tab-item {
569 - display: flex;
570 - align-items: center;
571 - justify-content: center;
572 - padding: 0 32rpx;
573 - border-radius: 9999rpx;
574 - white-space: nowrap;
575 - transition: all 0.3s ease;
576 - flex-shrink: 0;
577 -}
578 -
579 -.filter-tab-active {
580 - background-color: #2563EB; // 蓝色背景
581 - color: #fff;
582 -}
583 -
584 -.filter-tab-inactive {
585 - background-color: #F3F4F6; // 灰色背景
586 - color: #6B7280;
587 -}
588 -
589 -.filter-tab-text {
590 - font-size: 28rpx;
591 - font-weight: 500;
592 -}
593 -
594 -// 覆盖 NutUI Tabs 默认样式
595 -:deep(.nut-tabs__titles) {
596 - display: none;
597 -}
598 -
599 -:deep(.nut-tabs__content) {
600 - display: none;
601 -}
602 -</style>