chore: 清理备份文件目录中的原始页面文件
移除 docs/backups/original-pages/ 目录下的四个备份文件: - message-index.vue.bak - product-center-index.vue.bak - search-index.vue.bak - material-list-index.vue.bak 这些文件是开发过程中的临时备份,现已不再需要。
Showing
4 changed files
with
0 additions
and
2146 deletions
| 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> |
-
Please register or login to post a comment