refactor(ui): 提取可复用卡片组件并重构页面
- 新增 MaterialCard 和 ProductCard 组件,减少代码重复 - 重构首页、搜索页、本周热门资料页使用新组件 - 搜索页面集成真实API,支持分页加载和实时查询 - 移除测试数据,全部对接后端接口 影响文件: - src/components/MaterialCard.vue (新增) - src/components/ProductCard.vue (新增) - src/pages/index/index.vue - src/pages/search/index.vue - src/pages/week-hot-material/index.vue - components.d.ts - docs/CHANGELOG.md Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Showing
7 changed files
with
888 additions
and
650 deletions
| ... | @@ -18,6 +18,7 @@ declare module 'vue' { | ... | @@ -18,6 +18,7 @@ declare module 'vue' { |
| 18 | IndexNav: typeof import('./src/components/indexNav.vue')['default'] | 18 | IndexNav: typeof import('./src/components/indexNav.vue')['default'] |
| 19 | LifeInsuranceTemplate: typeof import('./src/components/PlanTemplates/LifeInsuranceTemplate.vue')['default'] | 19 | LifeInsuranceTemplate: typeof import('./src/components/PlanTemplates/LifeInsuranceTemplate.vue')['default'] |
| 20 | ListItemActions: typeof import('./src/components/ListItemActions/index.vue')['default'] | 20 | ListItemActions: typeof import('./src/components/ListItemActions/index.vue')['default'] |
| 21 | + MaterialCard: typeof import('./src/components/MaterialCard.vue')['default'] | ||
| 21 | NavHeader: typeof import('./src/components/NavHeader.vue')['default'] | 22 | NavHeader: typeof import('./src/components/NavHeader.vue')['default'] |
| 22 | NutAvatar: typeof import('@nutui/nutui-taro')['Avatar'] | 23 | NutAvatar: typeof import('@nutui/nutui-taro')['Avatar'] |
| 23 | NutButton: typeof import('@nutui/nutui-taro')['Button'] | 24 | NutButton: typeof import('@nutui/nutui-taro')['Button'] |
| ... | @@ -36,6 +37,7 @@ declare module 'vue' { | ... | @@ -36,6 +37,7 @@ declare module 'vue' { |
| 36 | PlanFormContainer: typeof import('./src/components/PlanFormContainer.vue')['default'] | 37 | PlanFormContainer: typeof import('./src/components/PlanFormContainer.vue')['default'] |
| 37 | PlanPopup: typeof import('./src/components/PlanSchemes/PlanPopup.vue')['default'] | 38 | PlanPopup: typeof import('./src/components/PlanSchemes/PlanPopup.vue')['default'] |
| 38 | PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default'] | 39 | PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default'] |
| 40 | + ProductCard: typeof import('./src/components/ProductCard.vue')['default'] | ||
| 39 | QrCode: typeof import('./src/components/qrCode.vue')['default'] | 41 | QrCode: typeof import('./src/components/qrCode.vue')['default'] |
| 40 | QrCodeSearch: typeof import('./src/components/qrCodeSearch.vue')['default'] | 42 | QrCodeSearch: typeof import('./src/components/qrCodeSearch.vue')['default'] |
| 41 | RadioGroup: typeof import('./src/components/PlanFields/RadioGroup.vue')['default'] | 43 | RadioGroup: typeof import('./src/components/PlanFields/RadioGroup.vue')['default'] | ... | ... |
| ... | @@ -5,6 +5,30 @@ | ... | @@ -5,6 +5,30 @@ |
| 5 | 5 | ||
| 6 | --- | 6 | --- |
| 7 | 7 | ||
| 8 | +## [2026-02-06] - 搜索页面联调完成 | ||
| 9 | + | ||
| 10 | +### 新增 | ||
| 11 | +- 集成搜索API,实现产品和资料的实时搜索功能 | ||
| 12 | +- 添加下拉刷新功能,支持刷新当前选中tab的数据 | ||
| 13 | +- 实现首次搜索自动选择tab逻辑(根据返回数据判断) | ||
| 14 | + | ||
| 15 | +### 优化 | ||
| 16 | +- 移除"全部"tab,只保留"产品"和"资料"两个tab | ||
| 17 | +- 点击tab时实时查询对应类型的数据,不再使用本地缓存 | ||
| 18 | +- 优化搜索结果展示,适配后端返回的数据结构 | ||
| 19 | + | ||
| 20 | +### 技术改进 | ||
| 21 | +- 使用 `searchAPI` 调用后端搜索接口 | ||
| 22 | +- 首次搜索不传 `type` 参数,根据返回的 `products.total` 和 `files.total` 自动选择有数据的tab | ||
| 23 | +- 切换tab时传递 `type` 参数('product' | 'file')进行精确查询 | ||
| 24 | +- 下拉刷新时使用当前选中的tab的type进行查询 | ||
| 25 | + | ||
| 26 | +### 修复 | ||
| 27 | +- 修复产品卡片显示逻辑,正确显示 `product_name`、`cover_image`、`tags` | ||
| 28 | +- 修复资料卡片显示逻辑,正确显示 `name`、`value`、`extension` | ||
| 29 | + | ||
| 30 | +--- | ||
| 31 | + | ||
| 8 | ## [2026-02-06] - 修复计划书表单重置不稳定问题 | 32 | ## [2026-02-06] - 修复计划书表单重置不稳定问题 |
| 9 | 33 | ||
| 10 | ### 修复 | 34 | ### 修复 | ... | ... |
src/components/MaterialCard.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <view class="flex flex-row bg-white rounded-[24rpx] p-[24rpx] shadow-md border border-gray-50 material-card"> | ||
| 3 | + <!-- 左侧图标 --> | ||
| 4 | + <view class="w-[88rpx] h-[88rpx] mr-[24rpx] flex-shrink-0 flex items-center justify-center bg-gradient-to-br from-blue-50 to-blue-100 rounded-[20rpx] shadow-inner self-start"> | ||
| 5 | + <image :src="iconUrl" class="w-[48rpx] h-[48rpx]" mode="aspectFit" /> | ||
| 6 | + </view> | ||
| 7 | + | ||
| 8 | + <!-- 内容区域 --> | ||
| 9 | + <view class="flex-1 min-w-0"> | ||
| 10 | + <!-- 标题 --> | ||
| 11 | + <view class="text-[#1F2937] text-[30rpx] font-bold leading-[1.4] mb-[8rpx] line-clamp-2"> | ||
| 12 | + {{ title }} | ||
| 13 | + </view> | ||
| 14 | + | ||
| 15 | + <!-- 学习人数信息 --> | ||
| 16 | + <view v-if="learners" class="flex items-center gap-[12rpx] mb-[16rpx]"> | ||
| 17 | + <view class="inline-flex items-center justify-center px-[12rpx] py-[4rpx] bg-orange-50 text-orange-600 text-[20rpx] font-medium rounded-[8rpx]"> | ||
| 18 | + <text>{{ learners }}</text> | ||
| 19 | + </view> | ||
| 20 | + <!-- 学习人数比例 --> | ||
| 21 | + <view v-if="readPeoplePercent !== undefined && readPeoplePercent !== null" class="inline-flex items-center justify-center px-[12rpx] py-[4rpx] bg-green-50 text-green-600 text-[20rpx] font-medium rounded-[8rpx]"> | ||
| 22 | + <text>{{ readPeoplePercent }}%热度</text> | ||
| 23 | + </view> | ||
| 24 | + </view> | ||
| 25 | + | ||
| 26 | + <!-- 文档类型和文件大小 --> | ||
| 27 | + <view class="flex items-center gap-[12rpx] mb-[16rpx]"> | ||
| 28 | + <view class="inline-flex items-center justify-center px-[12rpx] py-[4rpx] bg-gray-100 text-gray-500 text-[20rpx] font-medium rounded-[8rpx]"> | ||
| 29 | + <text>{{ docTypeLabel }}</text> | ||
| 30 | + </view> | ||
| 31 | + <view v-if="fileSize" class="text-[#9CA3AF] text-[22rpx]"> | ||
| 32 | + {{ fileSize }} | ||
| 33 | + </view> | ||
| 34 | + </view> | ||
| 35 | + | ||
| 36 | + <!-- 分割线 --> | ||
| 37 | + <view class="h-[1rpx] bg-gray-100 my-[20rpx]"></view> | ||
| 38 | + | ||
| 39 | + <!-- 操作按钮 --> | ||
| 40 | + <ListItemActions | ||
| 41 | + :viewable="true" | ||
| 42 | + :collectable="true" | ||
| 43 | + :deletable="false" | ||
| 44 | + :collected="collected" | ||
| 45 | + :item-id="String(id)" | ||
| 46 | + @view="handleView" | ||
| 47 | + @collect="handleCollect" | ||
| 48 | + /> | ||
| 49 | + </view> | ||
| 50 | + </view> | ||
| 51 | +</template> | ||
| 52 | + | ||
| 53 | +<script setup> | ||
| 54 | +/** | ||
| 55 | + * 资料卡片组件 | ||
| 56 | + * | ||
| 57 | + * @description 热门资料列表项卡片,展示资料图标、标题、学习人数和操作按钮 | ||
| 58 | + * @component MaterialCard | ||
| 59 | + */ | ||
| 60 | + | ||
| 61 | +import { defineProps, defineEmits } from 'vue'; | ||
| 62 | +import Taro from '@tarojs/taro'; | ||
| 63 | +import { getDocumentIcon, getDocumentLabel } from '@/utils/documentIcons'; | ||
| 64 | +import ListItemActions from '@/components/ListItemActions/index.vue'; | ||
| 65 | +import { useCollectOperation } from '@/composables/useCollectOperation'; | ||
| 66 | +import { useListItemClick, ListType } from '@/composables/useListItemClick'; | ||
| 67 | + | ||
| 68 | +/** | ||
| 69 | + * 组件属性 | ||
| 70 | + */ | ||
| 71 | +const props = defineProps({ | ||
| 72 | + /** 资料 ID */ | ||
| 73 | + id: { | ||
| 74 | + type: [Number, String], | ||
| 75 | + required: true | ||
| 76 | + }, | ||
| 77 | + /** 资料标题 */ | ||
| 78 | + title: { | ||
| 79 | + type: String, | ||
| 80 | + required: true | ||
| 81 | + }, | ||
| 82 | + /** 文件名(用于获取图标和标签) */ | ||
| 83 | + fileName: { | ||
| 84 | + type: String, | ||
| 85 | + default: '' | ||
| 86 | + }, | ||
| 87 | + /** 文件大小 */ | ||
| 88 | + fileSize: { | ||
| 89 | + type: String, | ||
| 90 | + default: '' | ||
| 91 | + }, | ||
| 92 | + /** 学习人数文本 */ | ||
| 93 | + learners: { | ||
| 94 | + type: String, | ||
| 95 | + default: '' | ||
| 96 | + }, | ||
| 97 | + /** 学习人数百分比(热度) */ | ||
| 98 | + readPeoplePercent: { | ||
| 99 | + type: Number, | ||
| 100 | + default: null | ||
| 101 | + }, | ||
| 102 | + /** 是否已收藏 */ | ||
| 103 | + collected: { | ||
| 104 | + type: Boolean, | ||
| 105 | + default: false | ||
| 106 | + }, | ||
| 107 | + /** 文件扩展名 */ | ||
| 108 | + extension: { | ||
| 109 | + type: String, | ||
| 110 | + default: '' | ||
| 111 | + }, | ||
| 112 | + /** 下载URL */ | ||
| 113 | + downloadUrl: { | ||
| 114 | + type: String, | ||
| 115 | + default: '' | ||
| 116 | + } | ||
| 117 | +}); | ||
| 118 | + | ||
| 119 | +/** | ||
| 120 | + * 组件事件(简化版) | ||
| 121 | + */ | ||
| 122 | +const emit = defineEmits({ | ||
| 123 | + /** 查看完成 */ | ||
| 124 | + viewed: (item) => true, | ||
| 125 | + /** 收藏状态改变 */ | ||
| 126 | + collectChanged: (item) => true | ||
| 127 | +}); | ||
| 128 | + | ||
| 129 | +/** | ||
| 130 | + * 获取文档图标 URL | ||
| 131 | + * | ||
| 132 | + * @description 根据文件名获取对应的文档类型图标 | ||
| 133 | + * @returns {string} 图标 URL | ||
| 134 | + */ | ||
| 135 | +const iconUrl = props.fileName ? getDocumentIcon(props.fileName) : ''; | ||
| 136 | + | ||
| 137 | +/** | ||
| 138 | + * 获取文档类型标签 | ||
| 139 | + * | ||
| 140 | + * @description 根据文件名获取文档类型标签文本 | ||
| 141 | + * @returns {string} 文档类型标签 | ||
| 142 | + */ | ||
| 143 | +const docTypeLabel = props.fileName ? getDocumentLabel(props.fileName) : ''; | ||
| 144 | + | ||
| 145 | +/** | ||
| 146 | + * 使用收藏操作 composable | ||
| 147 | + */ | ||
| 148 | +const { toggleCollect } = useCollectOperation(); | ||
| 149 | + | ||
| 150 | +/** | ||
| 151 | + * 使用文件列表点击处理器(内部实现) | ||
| 152 | + */ | ||
| 153 | +const { handleClick } = useListItemClick({ | ||
| 154 | + listType: ListType.FILE, | ||
| 155 | + onBeforeClick: async (item) => { | ||
| 156 | + /** | ||
| 157 | + * 检查文件类型并使用对应的预览方式 | ||
| 158 | + * - 图片文件:使用 Taro.previewImage 预览 | ||
| 159 | + * - 其他文件:继续默认的文件打开流程 | ||
| 160 | + */ | ||
| 161 | + const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'] | ||
| 162 | + const extension = item.extension?.toLowerCase() || '' | ||
| 163 | + | ||
| 164 | + console.log('[MaterialCard] 文件类型:', extension, '文件名:', item.title) | ||
| 165 | + | ||
| 166 | + if (imageExtensions.includes(extension)) { | ||
| 167 | + // 图片文件:使用 Taro 预览 | ||
| 168 | + console.log('[MaterialCard] 检测到图片文件,使用图片预览') | ||
| 169 | + | ||
| 170 | + // 构建图片列表(当前图片) | ||
| 171 | + const urls = [item.downloadUrl] | ||
| 172 | + | ||
| 173 | + try { | ||
| 174 | + // 短暂延迟后打开预览(让用户看到提示) | ||
| 175 | + await new Promise(resolve => setTimeout(resolve, 300)) | ||
| 176 | + | ||
| 177 | + await Taro.previewImage({ | ||
| 178 | + current: item.downloadUrl, | ||
| 179 | + urls: urls | ||
| 180 | + }) | ||
| 181 | + | ||
| 182 | + // 预览成功,阻止默认的文件打开行为 | ||
| 183 | + return false | ||
| 184 | + } catch (err) { | ||
| 185 | + console.error('[MaterialCard] 图片预览失败:', err) | ||
| 186 | + Taro.showToast({ | ||
| 187 | + title: '图片预览失败', | ||
| 188 | + icon: 'none', | ||
| 189 | + duration: 2000 | ||
| 190 | + }) | ||
| 191 | + // 预览失败,返回 true 继续默认行为 | ||
| 192 | + return true | ||
| 193 | + } | ||
| 194 | + } | ||
| 195 | + | ||
| 196 | + // 非图片文件:继续默认的文件打开流程 | ||
| 197 | + console.log('[MaterialCard] 非图片文件,使用默认打开方式') | ||
| 198 | + return true | ||
| 199 | + }, | ||
| 200 | + onAfterClick: (item) => { | ||
| 201 | + console.log('[MaterialCard] 用户打开了资料:', item.title) | ||
| 202 | + // 通知父组件查看完成 | ||
| 203 | + emit('viewed', item) | ||
| 204 | + } | ||
| 205 | +}) | ||
| 206 | + | ||
| 207 | +/** | ||
| 208 | + * 处理查看点击 | ||
| 209 | + * | ||
| 210 | + * @description 内部处理查看逻辑,调用 useListItemClick | ||
| 211 | + */ | ||
| 212 | +const handleView = () => { | ||
| 213 | + handleClick({ | ||
| 214 | + id: props.id, | ||
| 215 | + title: props.title, | ||
| 216 | + fileName: props.fileName, | ||
| 217 | + fileSize: props.fileSize, | ||
| 218 | + learners: props.learners, | ||
| 219 | + readPeoplePercent: props.readPeoplePercent, | ||
| 220 | + collected: props.collected, | ||
| 221 | + extension: props.extension, | ||
| 222 | + downloadUrl: props.downloadUrl | ||
| 223 | + }) | ||
| 224 | +}; | ||
| 225 | + | ||
| 226 | +/** | ||
| 227 | + * 处理收藏点击 | ||
| 228 | + * | ||
| 229 | + * @description 内部处理收藏逻辑,调用 useCollectOperation 并通知父组件 | ||
| 230 | + */ | ||
| 231 | +const handleCollect = () => { | ||
| 232 | + // 调用收藏操作 | ||
| 233 | + toggleCollect({ | ||
| 234 | + id: props.id, | ||
| 235 | + title: props.title, | ||
| 236 | + fileName: props.fileName, | ||
| 237 | + fileSize: props.fileSize, | ||
| 238 | + learners: props.learners, | ||
| 239 | + readPeoplePercent: props.readPeoplePercent, | ||
| 240 | + collected: props.collected, | ||
| 241 | + extension: props.extension, | ||
| 242 | + downloadUrl: props.downloadUrl | ||
| 243 | + }) | ||
| 244 | + | ||
| 245 | + // 通知父组件收藏状态改变 | ||
| 246 | + emit('collectChanged', { | ||
| 247 | + id: props.id, | ||
| 248 | + title: props.title, | ||
| 249 | + fileName: props.fileName, | ||
| 250 | + fileSize: props.fileSize, | ||
| 251 | + learners: props.learners, | ||
| 252 | + readPeoplePercent: props.readPeoplePercent, | ||
| 253 | + collected: !props.collected, // 新状态(取反) | ||
| 254 | + extension: props.extension, | ||
| 255 | + downloadUrl: props.downloadUrl | ||
| 256 | + }) | ||
| 257 | +}; | ||
| 258 | +</script> | ||
| 259 | + | ||
| 260 | +<style lang="less" scoped> | ||
| 261 | +.material-card { | ||
| 262 | + // 多行文本省略 | ||
| 263 | + .line-clamp-2 { | ||
| 264 | + display: -webkit-box; | ||
| 265 | + -webkit-box-orient: vertical; | ||
| 266 | + -webkit-line-clamp: 2; | ||
| 267 | + line-clamp: 2; | ||
| 268 | + overflow: hidden; | ||
| 269 | + word-break: break-all; | ||
| 270 | + } | ||
| 271 | +} | ||
| 272 | +</style> |
src/components/ProductCard.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <view class="bg-gray-50 rounded-[24rpx] p-[28rpx] product-card"> | ||
| 3 | + <!-- 产品名称 --> | ||
| 4 | + <text class="block text-gray-800 text-[28rpx] font-medium mb-[20rpx]">{{ productName }}</text> | ||
| 5 | + | ||
| 6 | + <!-- 产品标签 --> | ||
| 7 | + <view v-if="tags && tags.length" class="flex flex-wrap gap-[12rpx] mb-[24rpx]"> | ||
| 8 | + <view | ||
| 9 | + v-for="tag in tags" | ||
| 10 | + :key="tag.id" | ||
| 11 | + class="rounded-[8rpx] px-[16rpx] py-[6rpx]" | ||
| 12 | + :style="{ | ||
| 13 | + backgroundColor: tag.bg_color, | ||
| 14 | + color: tag.text_color | ||
| 15 | + }" | ||
| 16 | + > | ||
| 17 | + <text class="text-[22rpx]">{{ tag.name }}</text> | ||
| 18 | + </view> | ||
| 19 | + </view> | ||
| 20 | + | ||
| 21 | + <!-- 操作按钮 --> | ||
| 22 | + <view class="flex justify-between gap-[24rpx]"> | ||
| 23 | + <nut-button | ||
| 24 | + plain | ||
| 25 | + color="#2563EB" | ||
| 26 | + class="flex-1 !h-[64rpx] !rounded-[16rpx] !text-[26rpx] !m-0 !border-blue-600" | ||
| 27 | + @tap="handleDetail" | ||
| 28 | + > | ||
| 29 | + 产品详情 | ||
| 30 | + </nut-button> | ||
| 31 | + <nut-button | ||
| 32 | + color="#2563EB" | ||
| 33 | + class="flex-1 !h-[64rpx] !rounded-[16rpx] !text-[26rpx] !m-0" | ||
| 34 | + @tap="handlePlan" | ||
| 35 | + > | ||
| 36 | + 计划书 | ||
| 37 | + </nut-button> | ||
| 38 | + </view> | ||
| 39 | + </view> | ||
| 40 | +</template> | ||
| 41 | + | ||
| 42 | +<script setup> | ||
| 43 | +/** | ||
| 44 | + * 产品卡片组件 | ||
| 45 | + * | ||
| 46 | + * @description 热卖产品列表项卡片,展示产品名称、标签和操作按钮 | ||
| 47 | + * @component ProductCard | ||
| 48 | + */ | ||
| 49 | + | ||
| 50 | +import { defineProps, defineEmits } from 'vue'; | ||
| 51 | + | ||
| 52 | +/** | ||
| 53 | + * 组件属性 | ||
| 54 | + */ | ||
| 55 | +const props = defineProps({ | ||
| 56 | + /** 产品 ID */ | ||
| 57 | + productId: { | ||
| 58 | + type: Number, | ||
| 59 | + required: true | ||
| 60 | + }, | ||
| 61 | + /** 产品名称 */ | ||
| 62 | + productName: { | ||
| 63 | + type: String, | ||
| 64 | + required: true | ||
| 65 | + }, | ||
| 66 | + /** 产品标签数组 */ | ||
| 67 | + tags: { | ||
| 68 | + type: Array, | ||
| 69 | + default: () => [] | ||
| 70 | + } | ||
| 71 | +}); | ||
| 72 | + | ||
| 73 | +/** | ||
| 74 | + * 组件事件 | ||
| 75 | + */ | ||
| 76 | +const emit = defineEmits({ | ||
| 77 | + /** 点击产品详情按钮 */ | ||
| 78 | + detail: (productId) => typeof productId === 'number', | ||
| 79 | + /** 点击计划书按钮 */ | ||
| 80 | + plan: (productId) => typeof productId === 'number' | ||
| 81 | +}); | ||
| 82 | + | ||
| 83 | +/** | ||
| 84 | + * 处理产品详情点击 | ||
| 85 | + * | ||
| 86 | + * @description 触发 detail 事件,传递产品 ID | ||
| 87 | + */ | ||
| 88 | +const handleDetail = () => { | ||
| 89 | + emit('detail', props.productId); | ||
| 90 | +}; | ||
| 91 | + | ||
| 92 | +/** | ||
| 93 | + * 处理计划书点击 | ||
| 94 | + * | ||
| 95 | + * @description 触发 plan 事件,传递产品 ID | ||
| 96 | + */ | ||
| 97 | +const handlePlan = () => { | ||
| 98 | + emit('plan', props.productId); | ||
| 99 | +}; | ||
| 100 | +</script> | ||
| 101 | + | ||
| 102 | +<style lang="less" scoped> | ||
| 103 | +.product-card { | ||
| 104 | + // 可以添加卡片特定的样式 | ||
| 105 | +} | ||
| 106 | +</style> |
| ... | @@ -49,47 +49,16 @@ | ... | @@ -49,47 +49,16 @@ |
| 49 | </view> | 49 | </view> |
| 50 | 50 | ||
| 51 | <!-- 动态产品列表 --> | 51 | <!-- 动态产品列表 --> |
| 52 | - <view | 52 | + <ProductCard |
| 53 | v-for="(product, index) in hotProducts" | 53 | v-for="(product, index) in hotProducts" |
| 54 | :key="product.id" | 54 | :key="product.id" |
| 55 | - class="bg-gray-50 rounded-[24rpx] p-[28rpx]" | 55 | + :product-id="product.id" |
| 56 | + :product-name="product.product_name" | ||
| 57 | + :tags="product.tags" | ||
| 56 | :class="{ 'mb-[24rpx]': index < hotProducts.length - 1 }" | 58 | :class="{ 'mb-[24rpx]': index < hotProducts.length - 1 }" |
| 57 | - > | 59 | + @detail="goToProductDetail" |
| 58 | - <text class="block text-gray-800 text-[28rpx] font-medium mb-[20rpx]">{{ product.product_name }}</text> | 60 | + @plan="openPlanPopup" |
| 59 | - | 61 | + /> |
| 60 | - <!-- 动态标签 --> | ||
| 61 | - <view v-if="product.tags && product.tags.length" class="flex flex-wrap gap-[12rpx] mb-[24rpx]"> | ||
| 62 | - <view | ||
| 63 | - v-for="tag in product.tags" | ||
| 64 | - :key="tag.id" | ||
| 65 | - class="rounded-[8rpx] px-[16rpx] py-[6rpx]" | ||
| 66 | - :style="{ | ||
| 67 | - backgroundColor: tag.bg_color, | ||
| 68 | - color: tag.text_color | ||
| 69 | - }" | ||
| 70 | - > | ||
| 71 | - <text class="text-[22rpx]">{{ tag.name }}</text> | ||
| 72 | - </view> | ||
| 73 | - </view> | ||
| 74 | - | ||
| 75 | - <view class="flex justify-between gap-[24rpx]"> | ||
| 76 | - <nut-button | ||
| 77 | - plain | ||
| 78 | - color="#2563EB" | ||
| 79 | - class="flex-1 !h-[64rpx] !rounded-[16rpx] !text-[26rpx] !m-0 !border-blue-600" | ||
| 80 | - @tap="goToProductDetail(product.id)" | ||
| 81 | - > | ||
| 82 | - 产品详情 | ||
| 83 | - </nut-button> | ||
| 84 | - <nut-button | ||
| 85 | - color="#2563EB" | ||
| 86 | - class="flex-1 !h-[64rpx] !rounded-[16rpx] !text-[26rpx] !m-0" | ||
| 87 | - @tap="openPlanPopup(product.id)" | ||
| 88 | - > | ||
| 89 | - 计划书 | ||
| 90 | - </nut-button> | ||
| 91 | - </view> | ||
| 92 | - </view> | ||
| 93 | </view> | 62 | </view> |
| 94 | 63 | ||
| 95 | <!-- Hot Materials --> | 64 | <!-- Hot Materials --> |
| ... | @@ -105,53 +74,20 @@ | ... | @@ -105,53 +74,20 @@ |
| 105 | <!-- Material List --> | 74 | <!-- Material List --> |
| 106 | <view class="flex flex-col gap-[24rpx]"> | 75 | <view class="flex flex-col gap-[24rpx]"> |
| 107 | <!-- Material Items --> | 76 | <!-- Material Items --> |
| 108 | - <view v-for="(item, index) in hotMaterials" :key="item.id || index" | 77 | + <MaterialCard |
| 109 | - class="flex flex-row bg-white rounded-[24rpx] p-[24rpx] shadow-md border border-gray-50"> | 78 | + v-for="item in hotMaterials" |
| 110 | - | 79 | + :key="item.id" |
| 111 | - <!-- 左侧图标 --> | 80 | + :id="item.id" |
| 112 | - <view class="w-[88rpx] h-[88rpx] mr-[24rpx] flex-shrink-0 flex items-center justify-center bg-gradient-to-br from-blue-50 to-blue-100 rounded-[20rpx] shadow-inner self-start"> | 81 | + :title="item.title" |
| 113 | - <image :src="getDocumentIcon(item.fileName)" class="w-[48rpx] h-[48rpx]" mode="aspectFit" /> | 82 | + :file-name="item.fileName" |
| 114 | - </view> | 83 | + :file-size="item.fileSize" |
| 115 | - | 84 | + :learners="item.learners" |
| 116 | - <!-- 内容区域 --> | 85 | + :read-people-percent="item.readPeoplePercent" |
| 117 | - <view class="flex-1 min-w-0"> | 86 | + :collected="item.collected" |
| 118 | - <view class="text-[#1F2937] text-[30rpx] font-bold leading-[1.4] mb-[8rpx] line-clamp-2"> | 87 | + :extension="item.extension" |
| 119 | - {{ item.title }} | 88 | + :download-url="item.downloadUrl" |
| 120 | - </view> | 89 | + @collect-changed="handleCollectChanged(item, $event)" |
| 121 | - <!-- 学习人数信息 --> | 90 | + /> |
| 122 | - <view v-if="item.learners" class="flex items-center gap-[12rpx] mb-[16rpx]"> | ||
| 123 | - <view class="inline-flex items-center justify-center px-[12rpx] py-[4rpx] bg-orange-50 text-orange-600 text-[20rpx] font-medium rounded-[8rpx]"> | ||
| 124 | - <text>{{ item.learners }}</text> | ||
| 125 | - </view> | ||
| 126 | - <!-- 学习人数比例 --> | ||
| 127 | - <view v-if="item.readPeoplePercent !== undefined && item.readPeoplePercent !== null" class="inline-flex items-center justify-center px-[12rpx] py-[4rpx] bg-green-50 text-green-600 text-[20rpx] font-medium rounded-[8rpx]"> | ||
| 128 | - <text>{{ item.readPeoplePercent }}%热度</text> | ||
| 129 | - </view> | ||
| 130 | - </view> | ||
| 131 | - <!-- 文档类型和文件大小 --> | ||
| 132 | - <view class="flex items-center gap-[12rpx] mb-[16rpx]"> | ||
| 133 | - <view class="inline-flex items-center justify-center px-[12rpx] py-[4rpx] bg-gray-100 text-gray-500 text-[20rpx] font-medium rounded-[8rpx]"> | ||
| 134 | - <text>{{ getDocumentLabel(item.fileName) }}</text> | ||
| 135 | - </view> | ||
| 136 | - <view v-if="item.fileSize" class="text-[#9CA3AF] text-[22rpx]"> | ||
| 137 | - {{ item.fileSize }} | ||
| 138 | - </view> | ||
| 139 | - </view> | ||
| 140 | - | ||
| 141 | - <!-- 分割线 --> | ||
| 142 | - <view class="h-[1rpx] bg-gray-100 my-[20rpx]"></view> | ||
| 143 | - | ||
| 144 | - <!-- 操作按钮 --> | ||
| 145 | - <ListItemActions | ||
| 146 | - :viewable="true" | ||
| 147 | - :collectable="true" | ||
| 148 | - :deletable="false" | ||
| 149 | - :collected="item.collected" | ||
| 150 | - @view="onViewMaterial(item)" | ||
| 151 | - @collect="toggleMaterialCollect(item)" | ||
| 152 | - /> | ||
| 153 | - </view> | ||
| 154 | - </view> | ||
| 155 | </view> | 91 | </view> |
| 156 | </view> | 92 | </view> |
| 157 | </view> | 93 | </view> |
| ... | @@ -176,18 +112,18 @@ | ... | @@ -176,18 +112,18 @@ |
| 176 | import { ref, shallowRef } from 'vue'; | 112 | import { ref, shallowRef } from 'vue'; |
| 177 | import Taro, { useShareAppMessage, useLoad, useDidShow } from '@tarojs/taro'; | 113 | import Taro, { useShareAppMessage, useLoad, useDidShow } from '@tarojs/taro'; |
| 178 | import { useGo } from '@/hooks/useGo'; | 114 | import { useGo } from '@/hooks/useGo'; |
| 179 | -import { useListItemClick, ListType } from '@/composables/useListItemClick'; | ||
| 180 | -import { getDocumentIcon, getDocumentLabel } from '@/utils/documentIcons'; | ||
| 181 | import { useUserStore } from '@/stores/user'; | 115 | import { useUserStore } from '@/stores/user'; |
| 182 | import TabBar from '@/components/TabBar.vue'; | 116 | import TabBar from '@/components/TabBar.vue'; |
| 183 | import IconFont from '@/components/IconFont.vue'; | 117 | import IconFont from '@/components/IconFont.vue'; |
| 184 | import PlanFormContainer from '@/components/PlanFormContainer.vue'; | 118 | import PlanFormContainer from '@/components/PlanFormContainer.vue'; |
| 185 | -import ListItemActions from '@/components/ListItemActions/index.vue'; | 119 | +import ProductCard from '@/components/ProductCard.vue'; |
| 120 | +import MaterialCard from '@/components/MaterialCard.vue'; | ||
| 186 | import { listAPI } from '@/api/get_product'; | 121 | import { listAPI } from '@/api/get_product'; |
| 187 | import { weekHotAPI } from '@/api/file'; | 122 | import { weekHotAPI } from '@/api/file'; |
| 188 | import { useCollectOperation } from '@/composables/useCollectOperation'; | 123 | import { useCollectOperation } from '@/composables/useCollectOperation'; |
| 189 | import { homeIconAPI } from '@/api/home'; | 124 | import { homeIconAPI } from '@/api/home'; |
| 190 | 125 | ||
| 126 | + | ||
| 191 | // User Store | 127 | // User Store |
| 192 | const userStore = useUserStore(); | 128 | const userStore = useUserStore(); |
| 193 | 129 | ||
| ... | @@ -310,119 +246,16 @@ const hotProducts = ref([]); | ... | @@ -310,119 +246,16 @@ const hotProducts = ref([]); |
| 310 | /** | 246 | /** |
| 311 | * 获取热卖产品列表 | 247 | * 获取热卖产品列表 |
| 312 | * | 248 | * |
| 313 | - * @description ⚠️ 测试数据:后端接口和字段还没有准备好,暂时使用模拟数据进行测试 | 249 | + * @description 调用 listAPI 获取热卖产品列表 |
| 314 | - * 测试完成后需要移除,恢复使用真实的API调用 | ||
| 315 | - * Mock数据包含全部7种产品类型(2种人寿、3种重疾、4种储蓄) | ||
| 316 | */ | 250 | */ |
| 317 | const fetchHotProducts = async () => { | 251 | const fetchHotProducts = async () => { |
| 318 | try { | 252 | try { |
| 319 | - // TODO: 测试完成后,移除下面的 mock 数据,恢复使用真实 API | 253 | + const res = await listAPI({ |
| 320 | - // const res = await listAPI({ | 254 | + recommend: 'hot' |
| 321 | - // recommend: 'hot' | 255 | + }); |
| 322 | - // }); | 256 | + if (res.code === 1 && res.data && res.data.list) { |
| 323 | - // if (res.code === 1 && res.data && res.data.list) { | 257 | + hotProducts.value = res.data.list; |
| 324 | - // hotProducts.value = res.data.list; | 258 | + } |
| 325 | - // } | ||
| 326 | - | ||
| 327 | - // ⚠️ 测试数据开始 - 测试完成后需要移除 ⚠️ | ||
| 328 | - hotProducts.value = [ | ||
| 329 | - // 人寿保险产品(2种) | ||
| 330 | - { | ||
| 331 | - id: 1, | ||
| 332 | - product_name: 'WIOP3E 盈传创富保障计划 3 - 优选版', | ||
| 333 | - form_sn: 'life-insurance-wiop3e', | ||
| 334 | - recommend: 'hot', | ||
| 335 | - tags: [ | ||
| 336 | - { id: 1, name: '终身寿险', bg_color: '#DBEAFE', text_color: '#1E40AF' }, | ||
| 337 | - { id: 2, name: '美元', bg_color: '#FEF3C7', text_color: '#92400E' } | ||
| 338 | - ] | ||
| 339 | - }, | ||
| 340 | - { | ||
| 341 | - id: 2, | ||
| 342 | - product_name: 'WIOP3 - 盈传创富保障计划 3', | ||
| 343 | - form_sn: 'life-insurance-wiop3', | ||
| 344 | - recommend: 'hot', | ||
| 345 | - tags: [ | ||
| 346 | - { id: 1, name: '终身寿险', bg_color: '#DBEAFE', text_color: '#1E40AF' }, | ||
| 347 | - { id: 2, name: '美元', bg_color: '#FEF3C7', text_color: '#92400E' } | ||
| 348 | - ] | ||
| 349 | - }, | ||
| 350 | - // 重疾保险产品(3种) | ||
| 351 | - { | ||
| 352 | - id: 3, | ||
| 353 | - product_name: 'MPC 守护无间重疾', | ||
| 354 | - form_sn: 'critical-illness-mpc', | ||
| 355 | - recommend: 'hot', | ||
| 356 | - tags: [ | ||
| 357 | - { id: 1, name: '重疾保障', bg_color: '#FCE7F3', text_color: '#9F1239' }, | ||
| 358 | - { id: 2, name: '人民币', bg_color: '#D1FAE5', text_color: '#065F46' } | ||
| 359 | - ] | ||
| 360 | - }, | ||
| 361 | - { | ||
| 362 | - id: 4, | ||
| 363 | - product_name: 'MBC PRO 活跃人生重疾保 PRO', | ||
| 364 | - form_sn: 'critical-illness-mbc-pro', | ||
| 365 | - recommend: 'hot', | ||
| 366 | - tags: [ | ||
| 367 | - { id: 1, name: '重疾保障', bg_color: '#FCE7F3', text_color: '#9F1239' }, | ||
| 368 | - { id: 2, name: '人民币', bg_color: '#D1FAE5', text_color: '#065F46' } | ||
| 369 | - ] | ||
| 370 | - }, | ||
| 371 | - { | ||
| 372 | - id: 5, | ||
| 373 | - product_name: 'MBC2 活跃人生重疾保 2', | ||
| 374 | - form_sn: 'critical-illness-mbc2', | ||
| 375 | - recommend: 'hot', | ||
| 376 | - tags: [ | ||
| 377 | - { id: 1, name: '重疾保障', bg_color: '#FCE7F3', text_color: '#9F1239' }, | ||
| 378 | - { id: 2, name: '人民币', bg_color: '#D1FAE5', text_color: '#065F46' } | ||
| 379 | - ] | ||
| 380 | - }, | ||
| 381 | - // 储蓄型产品(4种)- GS, GC, FA, LV2 | ||
| 382 | - { | ||
| 383 | - id: 6, | ||
| 384 | - product_name: 'GS - 宏摯傳承保障計劃', | ||
| 385 | - form_sn: 'savings-gs', | ||
| 386 | - recommend: 'hot', | ||
| 387 | - tags: [ | ||
| 388 | - { id: 1, name: '储蓄分红', bg_color: '#E0E7FF', text_color: '#3730A3' }, | ||
| 389 | - { id: 2, name: '美元', bg_color: '#FEF3C7', text_color: '#92400E' } | ||
| 390 | - ] | ||
| 391 | - }, | ||
| 392 | - { | ||
| 393 | - id: 7, | ||
| 394 | - product_name: 'GC - 宏摯家傳承保險計劃', | ||
| 395 | - form_sn: 'savings-gc', | ||
| 396 | - recommend: 'hot', | ||
| 397 | - tags: [ | ||
| 398 | - { id: 1, name: '储蓄分红', bg_color: '#E0E7FF', text_color: '#3730A3' }, | ||
| 399 | - { id: 2, name: '美元', bg_color: '#FEF3C7', text_color: '#92400E' } | ||
| 400 | - ] | ||
| 401 | - }, | ||
| 402 | - { | ||
| 403 | - id: 8, | ||
| 404 | - product_name: 'FA - 宏浚傳承保障計劃', | ||
| 405 | - form_sn: 'savings-fa', | ||
| 406 | - recommend: 'hot', | ||
| 407 | - tags: [ | ||
| 408 | - { id: 1, name: '储蓄分红', bg_color: '#E0E7FF', text_color: '#3730A3' }, | ||
| 409 | - { id: 2, name: '美元', bg_color: '#FEF3C7', text_color: '#92400E' } | ||
| 410 | - ] | ||
| 411 | - }, | ||
| 412 | - { | ||
| 413 | - id: 9, | ||
| 414 | - product_name: 'LV2 - 赤霞珠終身壽險計劃2', | ||
| 415 | - form_sn: 'savings-lv2', | ||
| 416 | - recommend: 'hot', | ||
| 417 | - tags: [ | ||
| 418 | - { id: 1, name: '储蓄型终身寿险', bg_color: '#E0E7FF', text_color: '#3730A3' }, | ||
| 419 | - { id: 2, name: '美元', bg_color: '#FEF3C7', text_color: '#92400E' } | ||
| 420 | - ] | ||
| 421 | - } | ||
| 422 | - ]; | ||
| 423 | - // ⚠️ 测试数据结束 - 测试完成后需要移除 ⚠️ | ||
| 424 | - | ||
| 425 | - console.log('⚠️ 使用测试数据:热卖产品列表已 mock,包含全部7种产品类型'); | ||
| 426 | } catch (err) { | 259 | } catch (err) { |
| 427 | console.error('获取热卖产品失败:', err); | 260 | console.error('获取热卖产品失败:', err); |
| 428 | } | 261 | } |
| ... | @@ -457,16 +290,23 @@ const fetchHotMaterials = async () => { | ... | @@ -457,16 +290,23 @@ const fetchHotMaterials = async () => { |
| 457 | 290 | ||
| 458 | if (res.code === 1 && res.data && res.data.list) { | 291 | if (res.code === 1 && res.data && res.data.list) { |
| 459 | // 转换 API 数据格式为组件所需格式 | 292 | // 转换 API 数据格式为组件所需格式 |
| 460 | - hotMaterials.value = res.data.list.map(item => ({ | 293 | + hotMaterials.value = res.data.list.map(item => { |
| 461 | - id: item.meta_id, | 294 | + // 提取文件扩展名 |
| 462 | - title: item.name, | 295 | + const fileName = item.name || '未命名文件' |
| 463 | - fileName: item.name, | 296 | + const extension = item.extension || fileName.split('.').pop()?.toLowerCase() || '' |
| 464 | - downloadUrl: item.src, | 297 | + |
| 465 | - fileSize: item.size, | 298 | + return { |
| 466 | - learners: `${item.read_people_count}人学习`, | 299 | + id: item.meta_id, |
| 467 | - readPeoplePercent: item.read_people_percent, // 学习人数比例 | 300 | + title: item.name, |
| 468 | - collected: item.is_favorite | 301 | + fileName: fileName, |
| 469 | - })); | 302 | + downloadUrl: item.src, |
| 303 | + fileSize: item.size, | ||
| 304 | + extension: extension, | ||
| 305 | + learners: `${item.read_people_count}人学习`, | ||
| 306 | + readPeoplePercent: item.read_people_percent, // 学习人数比例 | ||
| 307 | + collected: item.is_favorite | ||
| 308 | + } | ||
| 309 | + }); | ||
| 470 | } else { | 310 | } else { |
| 471 | hotMaterials.value = []; | 311 | hotMaterials.value = []; |
| 472 | } | 312 | } |
| ... | @@ -482,19 +322,20 @@ const fetchHotMaterials = async () => { | ... | @@ -482,19 +322,20 @@ const fetchHotMaterials = async () => { |
| 482 | const go = useGo(); | 322 | const go = useGo(); |
| 483 | 323 | ||
| 484 | /** | 324 | /** |
| 485 | - * 使用文件列表点击处理器 | 325 | + * 处理收藏状态改变 |
| 486 | * | 326 | * |
| 487 | - * @description 配置为文件类型列表,点击时打开文件预览 | 327 | + * @description 当用户点击收藏按钮时,更新本地状态 |
| 328 | + * @param {Object} item - 资料对象 | ||
| 329 | + * @param {Object} newStatus - 新的状态 | ||
| 488 | */ | 330 | */ |
| 489 | -const { handleClick: onViewMaterial } = useListItemClick({ | 331 | +const handleCollectChanged = (item, newStatus) => { |
| 490 | - listType: ListType.FILE, | 332 | + console.log('[Index] 收藏状态改变:', item.title, newStatus.collected) |
| 491 | - onAfterClick: (item) => { | 333 | + // 找到对应的项并更新状态 |
| 492 | - console.log('用户打开了资料:', item.title); | 334 | + const material = hotMaterials.value.find(m => m.id === item.id) |
| 335 | + if (material) { | ||
| 336 | + material.collected = newStatus.collected | ||
| 493 | } | 337 | } |
| 494 | -}); | 338 | +}; |
| 495 | - | ||
| 496 | -// 使用收藏操作 composable | ||
| 497 | -const { toggleCollect: toggleMaterialCollect } = useCollectOperation(); | ||
| 498 | 339 | ||
| 499 | /** | 340 | /** |
| 500 | * 处理网格导航点击 | 341 | * 处理网格导航点击 |
| ... | @@ -574,13 +415,5 @@ useShareAppMessage(() => { | ... | @@ -574,13 +415,5 @@ useShareAppMessage(() => { |
| 574 | </script> | 415 | </script> |
| 575 | 416 | ||
| 576 | <style lang="less"> | 417 | <style lang="less"> |
| 577 | -/* 多行文本省略 */ | 418 | +// 样式已移到各组件内部 |
| 578 | -.line-clamp-2 { | ||
| 579 | - display: -webkit-box; | ||
| 580 | - -webkit-box-orient: vertical; | ||
| 581 | - -webkit-line-clamp: 2; | ||
| 582 | - line-clamp: 2; | ||
| 583 | - overflow: hidden; | ||
| 584 | - word-break: break-all; | ||
| 585 | -} | ||
| 586 | </style> | 419 | </style> | ... | ... |
| 1 | <!-- | 1 | <!-- |
| 2 | - * @Date: 2026-01-31 | 2 | + * @Date: 2026-02-06 |
| 3 | - * @Description: 搜索页面 - 固定搜索栏和Tab,列表可滚动 | 3 | + * @Description: 搜索页面 - 支持产品和资料搜索,实时查询API |
| 4 | --> | 4 | --> |
| 5 | <template> | 5 | <template> |
| 6 | - <view class="h-screen bg-[#F9FAFB] flex flex-col"> | 6 | + <view class="h-screen bg-[#FFF] flex flex-col"> |
| 7 | <!-- 固定顶部:导航栏 + 搜索栏 --> | 7 | <!-- 固定顶部:导航栏 + 搜索栏 --> |
| 8 | - <view class="bg-[#F9FAFB] z-10"> | 8 | + <view class="bg-[#FFF] z-10"> |
| 9 | <NavHeader title="搜索" /> | 9 | <NavHeader title="搜索" /> |
| 10 | 10 | ||
| 11 | <!-- Search Input --> | 11 | <!-- Search Input --> |
| ... | @@ -18,7 +18,6 @@ | ... | @@ -18,7 +18,6 @@ |
| 18 | :show-clear="true" | 18 | :show-clear="true" |
| 19 | @search="handleSearch" | 19 | @search="handleSearch" |
| 20 | @clear="clearSearch" | 20 | @clear="clearSearch" |
| 21 | - @blur="handleBlur" | ||
| 22 | /> | 21 | /> |
| 23 | </view> | 22 | </view> |
| 24 | </view> | 23 | </view> |
| ... | @@ -26,7 +25,7 @@ | ... | @@ -26,7 +25,7 @@ |
| 26 | <!-- Tabs + 列表容器 --> | 25 | <!-- Tabs + 列表容器 --> |
| 27 | <view class="flex-1 min-h-0 flex flex-col mt-[32rpx] px-[40rpx]"> | 26 | <view class="flex-1 min-h-0 flex flex-col mt-[32rpx] px-[40rpx]"> |
| 28 | <!-- Tabs Container --> | 27 | <!-- Tabs Container --> |
| 29 | - <nut-tabs v-model="activeTabId"> | 28 | + <nut-tabs v-model="activeTab"> |
| 30 | <!-- 自定义标签栏 --> | 29 | <!-- 自定义标签栏 --> |
| 31 | <template #titles> | 30 | <template #titles> |
| 32 | <view class="filter-tabs-wrapper"> | 31 | <view class="filter-tabs-wrapper"> |
| ... | @@ -35,7 +34,8 @@ | ... | @@ -35,7 +34,8 @@ |
| 35 | :key="item.id" | 34 | :key="item.id" |
| 36 | :class="[ | 35 | :class="[ |
| 37 | 'filter-tab-item', | 36 | 'filter-tab-item', |
| 38 | - activeTabId === item.id ? 'filter-tab-active' : 'filter-tab-inactive' | 37 | + activeTab === item.id ? 'filter-tab-active' : 'filter-tab-inactive', |
| 38 | + !activeTab ? 'filter-tab-inactive' : '' // 初始状态不高亮任何tab | ||
| 39 | ]" | 39 | ]" |
| 40 | @tap="onTabClick(item.id)" | 40 | @tap="onTabClick(item.id)" |
| 41 | > | 41 | > |
| ... | @@ -46,376 +46,453 @@ | ... | @@ -46,376 +46,453 @@ |
| 46 | </nut-tabs> | 46 | </nut-tabs> |
| 47 | 47 | ||
| 48 | <!-- Result Count --> | 48 | <!-- Result Count --> |
| 49 | - <view v-if="searchResults.length > 0" class="text-[#6B7280] text-[24rpx] mb-[24rpx]"> | 49 | + <view v-if="currentList.length > 0" class="text-[#6B7280] text-[24rpx] mb-[24rpx]"> |
| 50 | - 找到 {{ searchResults.length }} 个相关结果 | 50 | + 找到 {{ currentTotal }} 个相关结果 |
| 51 | </view> | 51 | </view> |
| 52 | 52 | ||
| 53 | - <!-- 可滚动列表区域 --> | 53 | + <!-- 可滚动列表区域(支持触底加载更多) --> |
| 54 | - <view | 54 | + <scroll-view |
| 55 | class="flex-1 min-h-0 overflow-y-auto pb-[calc(160rpx+env(safe-area-inset-bottom))] box-border" | 55 | class="flex-1 min-h-0 overflow-y-auto pb-[calc(160rpx+env(safe-area-inset-bottom))] box-border" |
| 56 | + scroll-y | ||
| 56 | > | 57 | > |
| 57 | <!-- Search Results --> | 58 | <!-- Search Results --> |
| 58 | <view | 59 | <view |
| 59 | - v-if="searchResults.length > 0" | 60 | + v-if="currentList.length > 0" |
| 60 | :key="listRenderKey" | 61 | :key="listRenderKey" |
| 61 | > | 62 | > |
| 62 | - <!-- Results List --> | 63 | + <!-- Product Results --> |
| 63 | - <view class="flex flex-col gap-[24rpx] pb-[40rpx]"> | 64 | + <view v-if="activeTab === 'product'" class="flex flex-col gap-[24rpx] pb-[40rpx]"> |
| 64 | - <!-- Product/Material Card --> | 65 | + <ProductCard |
| 65 | - <view | 66 | + v-for="(item, index) in currentList" |
| 66 | - v-for="(item, index) in searchResults" | ||
| 67 | :key="index" | 67 | :key="index" |
| 68 | - class="bg-white rounded-[24rpx] overflow-hidden shadow-sm search-result-item" | 68 | + :product-id="item.id" |
| 69 | + :product-name="item.product_name || item.name" | ||
| 70 | + :tags="item.tags || []" | ||
| 71 | + class="search-result-item" | ||
| 69 | :style="{ animationDelay: `${index * 30}ms` }" | 72 | :style="{ animationDelay: `${index * 30}ms` }" |
| 70 | - @tap="goToDetail(item)" | 73 | + @detail="goToProductDetail" |
| 71 | - > | 74 | + @plan="openPlanPopup" |
| 72 | - <!-- Image + Content Layout --> | 75 | + /> |
| 73 | - <view class="flex gap-[24rpx] p-[24rpx]"> | 76 | + </view> |
| 74 | - <!-- Image --> | 77 | + |
| 75 | - <image | 78 | + <!-- File Results --> |
| 76 | - class="w-[200rpx] h-[140rpx] rounded-[16rpx] bg-gray-100 flex-shrink-0" | 79 | + <view v-else-if="activeTab === 'file'" class="flex flex-col gap-[24rpx] pb-[40rpx]"> |
| 77 | - :src="item.image" | 80 | + <MaterialCard |
| 78 | - mode="aspectFill" | 81 | + v-for="(item, index) in currentList" |
| 79 | - /> | 82 | + :key="index" |
| 80 | - | 83 | + :id="item.id" |
| 81 | - <!-- Content --> | 84 | + :title="item.title" |
| 82 | - <view class="flex-1 flex flex-col justify-between py-[4rpx]"> | 85 | + :file-name="item.fileName" |
| 83 | - <!-- Title --> | 86 | + :file-size="item.fileSize" |
| 84 | - <view class="text-[#1F2937] text-[28rpx] font-medium leading-[1.4] line-clamp-2"> | 87 | + :learners="item.learners" |
| 85 | - {{ item.title }} | 88 | + :read-people-percent="item.readPeoplePercent" |
| 86 | - </view> | 89 | + :collected="item.collected" |
| 87 | - | 90 | + :extension="item.extension" |
| 88 | - <!-- Meta Info --> | 91 | + :download-url="item.downloadUrl" |
| 89 | - <view class="flex justify-between items-center"> | 92 | + class="search-result-item" |
| 90 | - <view class="flex gap-[12rpx]"> | 93 | + :style="{ animationDelay: `${index * 30}ms` }" |
| 91 | - <!-- Type Tag --> | 94 | + @collect-changed="handleCollectChanged(item, $event)" |
| 92 | - <view | 95 | + /> |
| 93 | - class="px-[12rpx] py-[4rpx] rounded-[8rpx] text-[22rpx]" | 96 | + </view> |
| 94 | - :class="item.type === '产品' ? 'bg-blue-50 text-blue-600' : 'bg-green-50 text-green-600'" | 97 | + |
| 95 | - > | 98 | + <!-- 加载更多提示 --> |
| 96 | - {{ item.type }} | 99 | + <view v-if="currentList.length > 0" class="flex items-center justify-center py-[40rpx]"> |
| 97 | - </view> | 100 | + <view v-if="loadingMore" class="flex items-center"> |
| 98 | - <!-- Hot Tag --> | 101 | + <view class="loading-spinner-small"></view> |
| 99 | - <view v-if="item.tag" class="bg-red-50 text-red-600 text-[22rpx] px-[12rpx] py-[4rpx] rounded-[8rpx]"> | 102 | + <text class="ml-[12rpx] text-[#9CA3AF] text-[24rpx]">加载中...</text> |
| 100 | - {{ item.tag }} | 103 | + </view> |
| 101 | - </view> | 104 | + <view v-else-if="!hasMore" class="text-[#9CA3AF] text-[24rpx]"> |
| 102 | - </view> | 105 | + 没有更多了 |
| 103 | - <view class="text-[#6B7280] text-[24rpx]"> | ||
| 104 | - {{ item.views || 0 }}人查看 | ||
| 105 | - </view> | ||
| 106 | - </view> | ||
| 107 | - </view> | ||
| 108 | - </view> | ||
| 109 | </view> | 106 | </view> |
| 110 | </view> | 107 | </view> |
| 111 | </view> | 108 | </view> |
| 112 | 109 | ||
| 113 | <!-- Empty State (已搜索但无结果) --> | 110 | <!-- Empty State (已搜索但无结果) --> |
| 114 | - <view v-else-if="hasSearched && searchResults.length === 0" class="flex flex-col items-center justify-center py-[40rpx]"> | 111 | + <view v-else-if="hasSearched && currentList.length === 0" class="flex flex-col items-center justify-center py-[40rpx]"> |
| 115 | <nut-empty description="暂无搜索结果" image="empty"> | 112 | <nut-empty description="暂无搜索结果" image="empty"> |
| 116 | <view class="text-[#9CA3AF] text-[24rpx] mt-[12rpx]">试试其他关键词吧</view> | 113 | <view class="text-[#9CA3AF] text-[24rpx] mt-[12rpx]">试试其他关键词吧</view> |
| 117 | </nut-empty> | 114 | </nut-empty> |
| 118 | </view> | 115 | </view> |
| 119 | 116 | ||
| 120 | <!-- Initial State (从未搜索过) --> | 117 | <!-- Initial State (从未搜索过) --> |
| 121 | - <view v-else-if="isInitialState" class="flex flex-col items-center justify-center py-[120rpx]"> | 118 | + <view v-else class="flex flex-col items-center justify-center py-[120rpx]"> |
| 122 | <IconFont name="search" class="text-gray-300 mb-[24rpx]" size="64" /> | 119 | <IconFont name="search" class="text-gray-300 mb-[24rpx]" size="64" /> |
| 123 | - <view class="text-[#6B7280] text-[28rpx]">{{ initialStateText.title }}</view> | 120 | + <view class="text-[#6B7280] text-[28rpx]">搜索产品或资料</view> |
| 124 | - <view class="text-[#9CA3AF] text-[24rpx] mt-[12rpx]">{{ initialStateText.subtitle }}</view> | 121 | + <view class="text-[#9CA3AF] text-[24rpx] mt-[12rpx]">输入关键词开始搜索,自动切换分类</view> |
| 125 | </view> | 122 | </view> |
| 126 | - </view> | 123 | + </scroll-view> |
| 127 | </view> | 124 | </view> |
| 125 | + | ||
| 126 | + <!-- Plan Form Container --> | ||
| 127 | + <!-- 仅当 selectedProduct 不为 null 时才渲染组件,避免 product prop required 警告 --> | ||
| 128 | + <PlanFormContainer | ||
| 129 | + v-if="selectedProduct" | ||
| 130 | + v-model:visible="showPlanPopup" | ||
| 131 | + :product="selectedProduct" | ||
| 132 | + @close="showPlanPopup = false" | ||
| 133 | + @submit="handlePlanSubmit" | ||
| 134 | + /> | ||
| 128 | </view> | 135 | </view> |
| 129 | </template> | 136 | </template> |
| 130 | 137 | ||
| 131 | <script setup> | 138 | <script setup> |
| 132 | -import { ref, computed, watch } from 'vue' | 139 | +import { ref, computed } from 'vue' |
| 133 | -import Taro from '@tarojs/taro' | 140 | +import Taro, { useReachBottom } from '@tarojs/taro' |
| 134 | import { useGo } from '@/hooks/useGo' | 141 | import { useGo } from '@/hooks/useGo' |
| 135 | import NavHeader from '@/components/NavHeader.vue' | 142 | import NavHeader from '@/components/NavHeader.vue' |
| 136 | import IconFont from '@/components/IconFont.vue' | 143 | import IconFont from '@/components/IconFont.vue' |
| 137 | import SearchBar from '@/components/SearchBar.vue' | 144 | import SearchBar from '@/components/SearchBar.vue' |
| 145 | +import ProductCard from '@/components/ProductCard.vue' | ||
| 146 | +import MaterialCard from '@/components/MaterialCard.vue' | ||
| 147 | +import PlanFormContainer from '@/components/PlanFormContainer.vue' | ||
| 148 | +import { searchAPI } from '@/api/search' | ||
| 138 | 149 | ||
| 139 | // Navigation | 150 | // Navigation |
| 140 | const go = useGo() | 151 | const go = useGo() |
| 141 | 152 | ||
| 153 | +// Plan Popup State | ||
| 154 | +const showPlanPopup = ref(false) | ||
| 155 | +const selectedProduct = ref(null) | ||
| 156 | + | ||
| 142 | // State | 157 | // State |
| 143 | const searchKeyword = ref('') | 158 | const searchKeyword = ref('') |
| 144 | -const activeTabId = ref('') | 159 | +const activeTab = ref('') // 当前选中的 tab(初始为空,不选中任何tab) |
| 160 | +const hasSearched = ref(false) // 是否已经搜索过 | ||
| 161 | +const listRenderKey = ref(0) | ||
| 162 | + | ||
| 163 | +// 数据状态 | ||
| 164 | +const products = ref([]) // 产品列表 | ||
| 165 | +const files = ref([]) // 资料列表 | ||
| 166 | +const productsTotal = ref(0) // 产品总数 | ||
| 167 | +const filesTotal = ref(0) // 资料总数 | ||
| 168 | +const loadingMore = ref(false) // 加载更多状态 | ||
| 169 | +const hasMore = ref(true) // 是否还有更多数据 | ||
| 170 | +const currentPage = ref(0) // 当前页码(从0开始) | ||
| 171 | +const pageSize = 20 // 每页数量 | ||
| 172 | + | ||
| 145 | /** | 173 | /** |
| 146 | - * 是否已经搜索过 | 174 | + * Tab 数据源(只保留产品和资料) |
| 147 | - * @description 一旦用户搜索过,此值将保持为 true,即使清空关键词也不会重置 | ||
| 148 | - * 用于区分"初始状态"和"空搜索结果" | ||
| 149 | */ | 175 | */ |
| 150 | -const hasSearched = ref(false) | 176 | +const tabsData = ref([ |
| 151 | -const listRenderKey = ref(0) | 177 | + { id: 'product', name: '产品' }, |
| 178 | + { id: 'file', name: '资料' }, | ||
| 179 | +]) | ||
| 152 | 180 | ||
| 153 | /** | 181 | /** |
| 154 | - * 是否显示初始状态 | 182 | + * 当前显示的列表 |
| 155 | - * @description 只有在从未搜索过且没有关键词时才显示初始状态 | ||
| 156 | */ | 183 | */ |
| 157 | -const isInitialState = computed(() => { | 184 | +const currentList = computed(() => { |
| 158 | - return !hasSearched.value && !searchKeyword.value.trim() | 185 | + // 如果没有选中任何tab,返回空数组 |
| 186 | + if (!activeTab.value) return [] | ||
| 187 | + | ||
| 188 | + if (activeTab.value === 'product') { | ||
| 189 | + return products.value | ||
| 190 | + } else { | ||
| 191 | + return files.value | ||
| 192 | + } | ||
| 159 | }) | 193 | }) |
| 160 | 194 | ||
| 161 | /** | 195 | /** |
| 162 | - * 初始状态的文案内容 | 196 | + * 当前列表总数 |
| 163 | - * @description 根据当前选中的分类显示不同的文案,提升用户体验 | ||
| 164 | */ | 197 | */ |
| 165 | -const initialStateText = computed(() => { | 198 | +const currentTotal = computed(() => { |
| 166 | - const currentTab = tabsData.value.find(tab => tab.id === activeTabId.value) | 199 | + // 如果没有选中任何tab,返回0 |
| 200 | + if (!activeTab.value) return 0 | ||
| 167 | 201 | ||
| 168 | - if (!currentTab) { | 202 | + if (activeTab.value === 'product') { |
| 169 | - return { | 203 | + return productsTotal.value |
| 170 | - title: '搜索培训资料、案例、产品', | 204 | + } else { |
| 171 | - subtitle: '输入关键词开始搜索' | 205 | + return filesTotal.value |
| 172 | - } | ||
| 173 | } | 206 | } |
| 207 | +}) | ||
| 174 | 208 | ||
| 175 | - // 根据分类返回不同的文案 | 209 | +/** |
| 176 | - switch (currentTab.id) { | 210 | + * 执行搜索 |
| 177 | - case 'product': | 211 | + * @param {string} keyword - 搜索关键字 |
| 178 | - return { | 212 | + * @param {string} type - 可选,'product' | 'file' | undefined |
| 179 | - title: '搜索保险产品', | 213 | + * @param {number} page - 页码(从0开始) |
| 180 | - subtitle: '输入产品名称或类型,如"重疾险"' | 214 | + * @param {number} limit - 每页数量 |
| 181 | - } | 215 | + * @param {boolean} isLoadMore - 是否为加载更多 |
| 182 | - case 'material': | 216 | + */ |
| 183 | - return { | 217 | +const performSearch = async (keyword, type, page = 0, limit = pageSize, isLoadMore = false) => { |
| 184 | - title: '搜索培训资料', | 218 | + try { |
| 185 | - subtitle: '输入资料关键词,如"销售话术"' | 219 | + // 如果是加载更多,使用 loadingMore 状态;否则使用 loading 状态 |
| 220 | + if (isLoadMore) { | ||
| 221 | + loadingMore.value = true | ||
| 222 | + } else { | ||
| 223 | + Taro.showLoading({ title: '搜索中...', mask: true }) | ||
| 224 | + } | ||
| 225 | + | ||
| 226 | + const params = { keyword, page, limit } | ||
| 227 | + if (type) params.type = type | ||
| 228 | + | ||
| 229 | + const res = await searchAPI(params) | ||
| 230 | + | ||
| 231 | + if (res.code === 1) { | ||
| 232 | + // 映射产品列表 | ||
| 233 | + const newProducts = res.data.products.list || [] | ||
| 234 | + | ||
| 235 | + // 映射资料列表(进行字段映射,与首页保持一致) | ||
| 236 | + const newFiles = (res.data.files.list || []).map(item => { | ||
| 237 | + // 提取文件扩展名 | ||
| 238 | + const fileName = item.name || '未命名文件' | ||
| 239 | + const extension = item.extension || fileName.split('.').pop()?.toLowerCase() || '' | ||
| 240 | + | ||
| 241 | + return { | ||
| 242 | + id: item.meta_id || item.id, | ||
| 243 | + title: item.name, | ||
| 244 | + fileName: fileName, | ||
| 245 | + fileSize: item.size || item.file_size, | ||
| 246 | + downloadUrl: item.src || item.value, | ||
| 247 | + extension: extension, | ||
| 248 | + learners: item.read_people_count ? `${item.read_people_count }人学习` : '', | ||
| 249 | + readPeoplePercent: item.read_people_percent, | ||
| 250 | + is_favorite: item.is_favorite, // 保留原始字段 | ||
| 251 | + collected: Boolean(item.is_favorite) // 转换为 Boolean 供 MaterialCard 使用 | ||
| 252 | + } | ||
| 253 | + }) | ||
| 254 | + | ||
| 255 | + // 根据是否为加载更多来处理数据 | ||
| 256 | + if (isLoadMore) { | ||
| 257 | + // 加载更多:追加数据 | ||
| 258 | + products.value = [...products.value, ...newProducts] | ||
| 259 | + files.value = [...files.value, ...newFiles] | ||
| 260 | + } else { | ||
| 261 | + // 首次加载或刷新:替换数据 | ||
| 262 | + products.value = newProducts | ||
| 263 | + files.value = newFiles | ||
| 186 | } | 264 | } |
| 187 | - default: | 265 | + |
| 188 | - return { | 266 | + productsTotal.value = res.data.products.total || 0 |
| 189 | - title: '搜索培训资料、案例、产品', | 267 | + filesTotal.value = res.data.files.total || 0 |
| 190 | - subtitle: '输入关键词开始搜索' | 268 | + |
| 269 | + // 判断是否还有更多数据 | ||
| 270 | + // 如果返回的数据量少于请求的量,说明没有更多了 | ||
| 271 | + const currentListLength = type === 'product' ? newProducts.length : newFiles.length | ||
| 272 | + hasMore.value = currentListLength >= limit | ||
| 273 | + | ||
| 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 | ||
| 191 | } | 282 | } |
| 283 | + | ||
| 284 | + hasSearched.value = true | ||
| 285 | + listRenderKey.value += 1 | ||
| 286 | + | ||
| 287 | + console.log('[Search] 搜索成功', { | ||
| 288 | + productsTotal: productsTotal.value, | ||
| 289 | + filesTotal: filesTotal.value, | ||
| 290 | + activeTab: activeTab.value, | ||
| 291 | + isLoadMore, | ||
| 292 | + hasMore: hasMore.value | ||
| 293 | + }) | ||
| 294 | + } else { | ||
| 295 | + Taro.showToast({ | ||
| 296 | + title: res.msg || '搜索失败', | ||
| 297 | + icon: 'none' | ||
| 298 | + }) | ||
| 299 | + } | ||
| 300 | + } catch (err) { | ||
| 301 | + console.error('[Search] 搜索失败:', err) | ||
| 302 | + Taro.showToast({ | ||
| 303 | + title: '搜索失败,请重试', | ||
| 304 | + icon: 'none' | ||
| 305 | + }) | ||
| 306 | + } finally { | ||
| 307 | + if (isLoadMore) { | ||
| 308 | + loadingMore.value = false | ||
| 309 | + } else { | ||
| 310 | + Taro.hideLoading() | ||
| 311 | + } | ||
| 192 | } | 312 | } |
| 193 | -}) | 313 | +} |
| 194 | 314 | ||
| 195 | /** | 315 | /** |
| 196 | - * Tab 数据源 | 316 | + * Tab 点击处理(实时查询) |
| 197 | - * @description 包含分类信息和对应的列表 | ||
| 198 | */ | 317 | */ |
| 199 | -const tabsData = ref([ | 318 | +const onTabClick = async (tabId) => { |
| 200 | - { id: '', name: '全部', list: [] }, | 319 | + if (activeTab.value === tabId) return |
| 201 | - { id: 'product', name: '产品', list: [] }, | 320 | + |
| 202 | - { id: 'material', name: '资料', list: [] }, | 321 | + // 立即切换 tab(响应更快) |
| 203 | -]) | 322 | + activeTab.value = tabId |
| 323 | + listRenderKey.value += 1 | ||
| 324 | + | ||
| 325 | + // 重置分页状态 | ||
| 326 | + currentPage.value = 0 | ||
| 327 | + hasMore.value = true | ||
| 328 | + | ||
| 329 | + // 如果已经搜索过,实时查询对应类型的数据 | ||
| 330 | + if (hasSearched.value && searchKeyword.value.trim()) { | ||
| 331 | + console.log('[Search] 切换 tab,实时查询:', tabId) | ||
| 332 | + await performSearch(searchKeyword.value.trim(), tabId, 0, pageSize, false) | ||
| 333 | + } | ||
| 334 | +} | ||
| 204 | 335 | ||
| 205 | /** | 336 | /** |
| 206 | - * 生成大量 Mock 数据用于测试长列表 | 337 | + * 提交搜索 |
| 207 | - * @description 生成 50 个产品 + 50 个资料,共 100 条数据 | ||
| 208 | */ | 338 | */ |
| 209 | -const generateMockData = () => { | 339 | +const handleSearch = async () => { |
| 210 | - const products = [] | 340 | + const keyword = searchKeyword.value.trim() |
| 211 | - const materials = [] | 341 | + if (!keyword) { |
| 212 | - | 342 | + Taro.showToast({ |
| 213 | - // 生成 50 个产品 | 343 | + title: '请输入搜索关键词', |
| 214 | - for (let i = 1; i <= 50; i++) { | 344 | + icon: 'none' |
| 215 | - products.push({ | ||
| 216 | - id: i, | ||
| 217 | - title: `保险产品 ${i} - ${getProductName(i)}`, | ||
| 218 | - type: '产品', | ||
| 219 | - tag: i % 3 === 0 ? '热卖' : (i % 5 === 0 ? '推荐' : ''), | ||
| 220 | - views: Math.floor(Math.random() * 500) + 50, | ||
| 221 | - image: `https://picsum.photos/seed/prod${i}/200/140`, | ||
| 222 | - category: 'product' | ||
| 223 | }) | 345 | }) |
| 346 | + return | ||
| 224 | } | 347 | } |
| 225 | 348 | ||
| 226 | - // 生成 50 个资料 | 349 | + console.log('[Search] 提交搜索:', keyword) |
| 227 | - for (let i = 1; i <= 50; i++) { | ||
| 228 | - materials.push({ | ||
| 229 | - id: 50 + i, | ||
| 230 | - title: `培训资料 ${i} - ${getMaterialName(i)}`, | ||
| 231 | - type: '资料', | ||
| 232 | - views: Math.floor(Math.random() * 300) + 30, | ||
| 233 | - image: `https://picsum.photos/seed/mat${i}/200/140`, | ||
| 234 | - category: 'material' | ||
| 235 | - }) | ||
| 236 | - } | ||
| 237 | 350 | ||
| 238 | - return [...products, ...materials] | 351 | + // 重置分页状态 |
| 239 | -} | 352 | + currentPage.value = 0 |
| 353 | + hasMore.value = true | ||
| 240 | 354 | ||
| 241 | -/** | 355 | + // 不传 type,让后端返回两种数据,前端自动选择 tab |
| 242 | - * 获取产品名称 | 356 | + await performSearch(keyword, undefined, 0, pageSize, false) |
| 243 | - */ | ||
| 244 | -const getProductName = (index) => { | ||
| 245 | - const names = [ | ||
| 246 | - '终身寿险', '百万医疗', '重疾保障', '意外保险', '年金保险', | ||
| 247 | - '教育金', '养老保险', '财富传承', '投资连结', '分红保险', | ||
| 248 | - '万能险', '定期寿险', '终身医疗', '高端医疗', '团体保险' | ||
| 249 | - ] | ||
| 250 | - return names[index % names.length] | ||
| 251 | } | 357 | } |
| 252 | 358 | ||
| 253 | /** | 359 | /** |
| 254 | - * 获取资料名称 | 360 | + * 清空搜索 |
| 255 | */ | 361 | */ |
| 256 | -const getMaterialName = (index) => { | 362 | +const clearSearch = () => { |
| 257 | - const names = [ | 363 | + console.log('[Search] 清空搜索') |
| 258 | - '销售话术', '产品培训', '案例分析', '合规指引', '核保规则', | 364 | + searchKeyword.value = '' |
| 259 | - '理赔流程', '客户服务', '市场分析', '竞争产品对比', '政策解读', | 365 | + hasSearched.value = false |
| 260 | - '新人培训', '晋升考核', '团队管理', '活动策划', '产说会流程' | 366 | + products.value = [] |
| 261 | - ] | 367 | + files.value = [] |
| 262 | - return names[index % names.length] | 368 | + productsTotal.value = 0 |
| 369 | + filesTotal.value = 0 | ||
| 370 | + activeTab.value = '' // 重置为空,不选中任何tab | ||
| 371 | + currentPage.value = 0 | ||
| 372 | + hasMore.value = true | ||
| 373 | + listRenderKey.value += 1 | ||
| 263 | } | 374 | } |
| 264 | 375 | ||
| 265 | -// All mock data | ||
| 266 | -const allData = ref(generateMockData()) | ||
| 267 | - | ||
| 268 | -console.log('[Search] 数据生成完成,总数:', allData.value.length) | ||
| 269 | -console.log('[Search] 产品数量:', allData.value.filter(item => item.category === 'product').length) | ||
| 270 | -console.log('[Search] 资料数量:', allData.value.filter(item => item.category === 'material').length) | ||
| 271 | - | ||
| 272 | /** | 376 | /** |
| 273 | - * 初始化数据分布 | 377 | + * 触底加载更多 |
| 274 | - * @description 根据分类规则将 allData 中的数据分配到各个 tab 中 | 378 | + * @description 使用防抖避免频繁触发 |
| 275 | */ | 379 | */ |
| 276 | -const initTabsData = () => { | 380 | +let loadMoreTimer = null |
| 277 | - tabsData.value.forEach((tab) => { | 381 | +useReachBottom(() => { |
| 278 | - if (tab.id === '') { | 382 | + // 如果正在加载更多或没有更多数据,不执行 |
| 279 | - tab.list = [...allData.value] | 383 | + if (loadingMore.value || !hasMore.value) { |
| 280 | - } else if (tab.id === 'product') { | 384 | + return |
| 281 | - tab.list = allData.value.filter(item => item.category === 'product') | ||
| 282 | - } else if (tab.id === 'material') { | ||
| 283 | - tab.list = allData.value.filter(item => item.category === 'material') | ||
| 284 | - } | ||
| 285 | - }) | ||
| 286 | - | ||
| 287 | - // 默认选中第一个 tab(全部) | ||
| 288 | - if (tabsData.value.length > 0) { | ||
| 289 | - activeTabId.value = tabsData.value[0].id | ||
| 290 | - console.log('[Search] 初始化完成,默认选中:', tabsData.value[0].name) | ||
| 291 | - console.log('[Search] 全部分类数据量:', tabsData.value[0].list.length) | ||
| 292 | - console.log('[Search] 产品分类数据量:', tabsData.value[1].list.length) | ||
| 293 | - console.log('[Search] 资料分类数据量:', tabsData.value[2].list.length) | ||
| 294 | } | 385 | } |
| 295 | -} | ||
| 296 | 386 | ||
| 297 | -// Search results | 387 | + // 如果没有搜索过或没有选中 tab,不执行 |
| 298 | -const searchResults = computed(() => { | 388 | + if (!hasSearched.value || !activeTab.value || !searchKeyword.value.trim()) { |
| 299 | - if (!hasSearched.value) return [] | 389 | + return |
| 300 | - | ||
| 301 | - // ✅ 如果没有关键词,返回空数组(不显示全部数据) | ||
| 302 | - if (!searchKeyword.value.trim()) { | ||
| 303 | - console.log('[Search Results] 没有关键词,返回空数组') | ||
| 304 | - return [] | ||
| 305 | } | 390 | } |
| 306 | 391 | ||
| 307 | - // 找到当前选中的 tab | 392 | + // 防抖:300ms 内只触发一次 |
| 308 | - const currentTab = tabsData.value.find(tab => tab.id === activeTabId.value) | 393 | + if (loadMoreTimer) { |
| 309 | - console.log('[Search Results] activeTabId:', activeTabId.value) | 394 | + clearTimeout(loadMoreTimer) |
| 310 | - console.log('[Search Results] currentTab:', currentTab) | 395 | + } |
| 311 | - console.log('[Search Results] currentTab.list.length:', currentTab?.list?.length || 0) | ||
| 312 | - | ||
| 313 | - if (!currentTab) return [] | ||
| 314 | - | ||
| 315 | - let results = currentTab.list | ||
| 316 | - | ||
| 317 | - // Filter by keyword | ||
| 318 | - const keyword = searchKeyword.value.toLowerCase() | ||
| 319 | - console.log('[Search Results] 搜索关键词:', keyword) | ||
| 320 | - console.log('[Search Results] 过滤前数量:', results.length) | ||
| 321 | - | ||
| 322 | - results = results.filter(item => | ||
| 323 | - item.title.toLowerCase().includes(keyword) | ||
| 324 | - ) | ||
| 325 | - | ||
| 326 | - console.log('[Search Results] 过滤后数量:', results.length) | ||
| 327 | 396 | ||
| 328 | - return results | 397 | + loadMoreTimer = setTimeout(async () => { |
| 398 | + console.log('[Search] 触底加载更多') | ||
| 399 | + | ||
| 400 | + // 页码 +1 | ||
| 401 | + currentPage.value += 1 | ||
| 402 | + | ||
| 403 | + // 加载下一页数据 | ||
| 404 | + await performSearch( | ||
| 405 | + searchKeyword.value.trim(), | ||
| 406 | + activeTab.value, | ||
| 407 | + currentPage.value, | ||
| 408 | + pageSize, | ||
| 409 | + true // 标记为加载更多 | ||
| 410 | + ) | ||
| 411 | + }, 300) | ||
| 329 | }) | 412 | }) |
| 330 | 413 | ||
| 331 | /** | 414 | /** |
| 332 | - * Tab 点击处理 | 415 | + * 跳转到产品详情页 |
| 416 | + * | ||
| 417 | + * @description 处理产品详情按钮点击事件 | ||
| 418 | + * @param {number} productId - 产品ID | ||
| 333 | */ | 419 | */ |
| 334 | -const onTabClick = (id) => { | 420 | +const goToProductDetail = (productId) => { |
| 335 | - activeTabId.value = id | 421 | + go('/pages/product-detail/index', { id: productId }) |
| 336 | - listRenderKey.value += 1 | ||
| 337 | - // 自动触发搜索(如果已经搜索过) | ||
| 338 | - if (hasSearched.value) { | ||
| 339 | - console.log('[Search] 切换分类到:', id, '结果数量:', searchResults.value.length) | ||
| 340 | - } | ||
| 341 | } | 422 | } |
| 342 | 423 | ||
| 343 | -// Handle search | 424 | +/** |
| 344 | -const handleSearch = () => { | 425 | + * 打开计划书弹窗 |
| 345 | - console.log('[Search handleSearch] 被调用') | 426 | + * |
| 346 | - console.log('[Search handleSearch] searchKeyword:', searchKeyword.value) | 427 | + * @description 根据产品ID找到对应的产品对象,并打开计划书表单 |
| 347 | - | 428 | + * @param {number} productId - 产品ID |
| 348 | - if (searchKeyword.value.trim()) { | 429 | + */ |
| 349 | - hasSearched.value = true | 430 | +const openPlanPopup = (productId) => { |
| 350 | - console.log('[Search handleSearch] hasSearched 已设置为 true') | 431 | + // 从产品列表中找到对应的产品 |
| 351 | - console.log('[Search handleSearch] 搜索关键词:', searchKeyword.value) | 432 | + const product = products.value.find(p => p.id === productId) |
| 352 | - console.log('[Search handleSearch] 当前分类:', activeTabId.value) | 433 | + |
| 353 | - console.log('[Search handleSearch] 搜索结果数量:', searchResults.value.length) | 434 | + if (!product) { |
| 354 | - } else { | 435 | + Taro.showToast({ |
| 355 | - console.log('[Search handleSearch] 搜索关键词为空,不执行搜索') | 436 | + title: '产品不存在', |
| 437 | + icon: 'none', | ||
| 438 | + duration: 2000 | ||
| 439 | + }) | ||
| 440 | + return | ||
| 356 | } | 441 | } |
| 357 | -} | ||
| 358 | 442 | ||
| 359 | -// Handle blur | 443 | + // 设置选中的产品 |
| 360 | -const handleBlur = () => { | 444 | + selectedProduct.value = product |
| 361 | - console.log('[Search handleBlur] 搜索框失去焦点') | 445 | + showPlanPopup.value = true |
| 362 | - // 可以在这里添加一些失去焦点时的逻辑,比如: | ||
| 363 | - // - 收起键盘 | ||
| 364 | - // - 记录搜索日志 | ||
| 365 | - // - 其他 UI 状态更新 | ||
| 366 | } | 446 | } |
| 367 | 447 | ||
| 368 | /** | 448 | /** |
| 369 | - * 清空搜索 | 449 | + * 处理计划书提交 |
| 370 | - * @description 清空搜索关键词并重置到初始状态 | 450 | + * |
| 371 | - * 让用户可以重新开始搜索,在不同分类下显示对应的引导文案 | 451 | + * @description 测试环境:前端不调用后端API,直接跳转到结果页 |
| 452 | + * 生产环境:需要调用 submitPlanAPI 提交表单数据 | ||
| 453 | + * @param {Object} formData - 表单数据 | ||
| 372 | */ | 454 | */ |
| 373 | -const clearSearch = () => { | 455 | +const handlePlanSubmit = (formData) => { |
| 374 | - console.log('[Search Clear] 清空搜索关键词') | 456 | + console.log('计划书提交:', { |
| 375 | - searchKeyword.value = '' | 457 | + product_id: selectedProduct.value.id, |
| 376 | - hasSearched.value = false // ✅ 重置到初始状态 | 458 | + product_name: selectedProduct.value.product_name || selectedProduct.value.name, |
| 377 | - listRenderKey.value += 1 | 459 | + form_sn: selectedProduct.value.form_sn, |
| 378 | - console.log('[Search Clear] hasSearched 已重置为 false,显示初始状态') | 460 | + form_data: formData |
| 379 | - console.log('[Search Clear] 当前分类:', activeTabId.value) | 461 | + }) |
| 380 | -} | ||
| 381 | 462 | ||
| 382 | -// Go to detail | 463 | + // 关闭弹窗 |
| 383 | -const goToDetail = (item) => { | 464 | + showPlanPopup.value = false |
| 384 | - if (item.category === 'product') { | 465 | + |
| 385 | - go('/pages/product-center/index') | 466 | + // TODO: 后端接口还没有准备好,暂时不调用API |
| 386 | - } else { | 467 | + // 测试完成后需要对接 submitPlanAPI |
| 387 | - go('/pages/material-list/index', { title: '搜索结果' }) | 468 | + // const res = await submitPlanAPI({ |
| 388 | - } | 469 | + // product_id: selectedProduct.value.id, |
| 470 | + // template: selectedProduct.value.form_sn, | ||
| 471 | + // form_data: formData | ||
| 472 | + // }); | ||
| 389 | 473 | ||
| 390 | - Taro.showToast({ | 474 | + // 模拟提交成功,跳转到结果页面 |
| 391 | - title: `查看${item.type}详情`, | 475 | + go('/pages/plan-submit-result/index', { |
| 392 | - icon: 'none', | 476 | + success: 'true' |
| 393 | - duration: 1500 | ||
| 394 | }) | 477 | }) |
| 395 | } | 478 | } |
| 396 | 479 | ||
| 397 | -// 初始化数据 | ||
| 398 | -initTabsData() | ||
| 399 | - | ||
| 400 | /** | 480 | /** |
| 401 | - * 监听搜索关键词变化,实现实时搜索 | 481 | + * 处理收藏状态改变 |
| 402 | - * @description 当用户输入关键词时,自动触发搜索,并标记"已搜索"状态 | 482 | + * |
| 403 | - * 当用户清空关键词时,重置到初始状态 | 483 | + * @description 当用户点击收藏按钮时,更新本地状态 |
| 484 | + * @param {Object} item - 资料对象 | ||
| 485 | + * @param {Object} newStatus - 新的状态 | ||
| 404 | */ | 486 | */ |
| 405 | -watch(searchKeyword, (newVal) => { | 487 | +const handleCollectChanged = (item, newStatus) => { |
| 406 | - if (newVal.trim()) { | 488 | + console.log('[Search] 收藏状态改变:', item.title, newStatus.collected) |
| 407 | - // ✅ 用户输入关键词时,标记为"已搜索" | 489 | + // 找到对应的项并更新状态 |
| 408 | - hasSearched.value = true | 490 | + const file = files.value.find(f => f.id === item.id) |
| 409 | - console.log('[Search Watch] 实时搜索触发,关键词:', newVal) | 491 | + if (file) { |
| 410 | - console.log('[Search Watch] 当前分类:', activeTabId.value) | 492 | + file.collected = newStatus.collected |
| 411 | - console.log('[Search Watch] 搜索结果数量:', searchResults.value.length) | ||
| 412 | - console.log('[Search Watch] hasSearched 设置为 true') | ||
| 413 | - } else { | ||
| 414 | - // ✅ 清空关键词时,重置到初始状态 | ||
| 415 | - hasSearched.value = false | ||
| 416 | - console.log('[Search Watch] 关键词已清空,重置到初始状态') | ||
| 417 | } | 493 | } |
| 418 | -}) | 494 | +} |
| 495 | + | ||
| 419 | </script> | 496 | </script> |
| 420 | 497 | ||
| 421 | <style lang="less"> | 498 | <style lang="less"> |
| ... | @@ -435,6 +512,25 @@ watch(searchKeyword, (newVal) => { | ... | @@ -435,6 +512,25 @@ watch(searchKeyword, (newVal) => { |
| 435 | animation: slideIn 0.3s cubic-bezier(0.2, 0.8, 0.2, 1) backwards; | 512 | animation: slideIn 0.3s cubic-bezier(0.2, 0.8, 0.2, 1) backwards; |
| 436 | } | 513 | } |
| 437 | 514 | ||
| 515 | +/* 加载动画 */ | ||
| 516 | +@keyframes spin { | ||
| 517 | + 0% { | ||
| 518 | + transform: rotate(0deg); | ||
| 519 | + } | ||
| 520 | + 100% { | ||
| 521 | + transform: rotate(360deg); | ||
| 522 | + } | ||
| 523 | +} | ||
| 524 | + | ||
| 525 | +.loading-spinner-small { | ||
| 526 | + width: 32rpx; | ||
| 527 | + height: 32rpx; | ||
| 528 | + border: 3rpx solid #E5E7EB; | ||
| 529 | + border-top-color: #4CAF50; | ||
| 530 | + border-radius: 50%; | ||
| 531 | + animation: spin 0.8s linear infinite; | ||
| 532 | +} | ||
| 533 | + | ||
| 438 | // FilterTabs 风格的标签栏 | 534 | // FilterTabs 风格的标签栏 |
| 439 | .filter-tabs-wrapper { | 535 | .filter-tabs-wrapper { |
| 440 | display: flex; | 536 | display: flex; |
| ... | @@ -442,7 +538,7 @@ watch(searchKeyword, (newVal) => { | ... | @@ -442,7 +538,7 @@ watch(searchKeyword, (newVal) => { |
| 442 | padding: 24rpx 24rpx; | 538 | padding: 24rpx 24rpx; |
| 443 | gap: 24rpx; | 539 | gap: 24rpx; |
| 444 | transition: all 0.3s ease; | 540 | transition: all 0.3s ease; |
| 445 | - background-color: #F9FAFB; | 541 | + background-color: #FFF; |
| 446 | width: 100%; | 542 | width: 100%; |
| 447 | 543 | ||
| 448 | // 隐藏滚动条 | 544 | // 隐藏滚动条 |
| ... | @@ -482,7 +578,7 @@ watch(searchKeyword, (newVal) => { | ... | @@ -482,7 +578,7 @@ watch(searchKeyword, (newVal) => { |
| 482 | font-weight: 500; | 578 | font-weight: 500; |
| 483 | } | 579 | } |
| 484 | 580 | ||
| 485 | -// 覆盖 NutUI Tabs 默认样式,隐藏原有的头部和内容(因为我们使用自定义头部和外部列表) | 581 | +// 覆盖 NutUI Tabs 默认样式 |
| 486 | :deep(.nut-tabs__titles) { | 582 | :deep(.nut-tabs__titles) { |
| 487 | display: none; | 583 | display: none; |
| 488 | } | 584 | } | ... | ... |
| 1 | <!-- | 1 | <!-- |
| 2 | - * @Date: 2026-02-05 | 2 | + * @Date: 2026-02-06 |
| 3 | - * @Description: 本周热门资料页 - 简化版资料列表(无搜索和Tab) | 3 | + * @Description: 本周热门资料页 - 使用 MaterialCard 组件 |
| 4 | --> | 4 | --> |
| 5 | <template> | 5 | <template> |
| 6 | <view class="h-screen bg-[#F9FAFB] flex flex-col py-[32rpx]"> | 6 | <view class="h-screen bg-[#F9FAFB] flex flex-col py-[32rpx]"> |
| ... | @@ -19,57 +19,21 @@ | ... | @@ -19,57 +19,21 @@ |
| 19 | </view> | 19 | </view> |
| 20 | 20 | ||
| 21 | <view v-else class="flex flex-col gap-[24rpx]"> | 21 | <view v-else class="flex flex-col gap-[24rpx]"> |
| 22 | - <view v-for="(item, index) in currentList" :key="item.meta_id" | 22 | + <MaterialCard |
| 23 | - class="material-item bg-white rounded-[24rpx] p-[24rpx] shadow-md transition-all duration-200 border border-gray-50 flex flex-row" | 23 | + v-for="(item, index) in currentList" |
| 24 | - :style="{ animationDelay: `${index * 50}ms` }"> | 24 | + :key="item.meta_id" |
| 25 | - | 25 | + :id="item.meta_id" |
| 26 | - <view | 26 | + :title="item.name" |
| 27 | - 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"> | 27 | + :file-name="item.name" |
| 28 | - <image | 28 | + :file-size="item.size" |
| 29 | - :src="getDocumentIcon(item.extension ? `file.${item.extension}` : item.name)" | 29 | + :learners="item.read_people_count ? `${item.read_people_count}人学习` : ''" |
| 30 | - class="w-[48rpx] h-[48rpx]" | 30 | + :read-people-percent="item.read_people_percent" |
| 31 | - mode="aspectFit" | 31 | + :collected="item.collected" |
| 32 | - /> | 32 | + :extension="item.extension" |
| 33 | - </view> | 33 | + :download-url="item.downloadUrl" |
| 34 | - | 34 | + :style="{ animationDelay: `${index * 50}ms` }" |
| 35 | - <view class="flex-1 min-w-0"> | 35 | + @collect-changed="handleCollectChanged(item, $event)" |
| 36 | - <view class="text-[#1F2937] text-[30rpx] font-bold leading-[1.4] mb-[8rpx] line-clamp-2"> | 36 | + /> |
| 37 | - {{ item.name }} | ||
| 38 | - </view> | ||
| 39 | - | ||
| 40 | - <!-- 学习人数信息(本周热门特有) --> | ||
| 41 | - <view v-if="item.read_people_count !== undefined" class="flex items-center gap-[12rpx] mb-[16rpx]"> | ||
| 42 | - <view class="inline-flex items-center justify-center px-[12rpx] py-[4rpx] bg-orange-50 text-orange-600 text-[20rpx] font-medium rounded-[8rpx]"> | ||
| 43 | - <text>{{ item.read_people_count }}人学习</text> | ||
| 44 | - </view> | ||
| 45 | - <!-- 热度百分比 --> | ||
| 46 | - <view v-if="item.read_people_percent !== undefined" class="inline-flex items-center justify-center px-[12rpx] py-[4rpx] bg-green-50 text-green-600 text-[20rpx] font-medium rounded-[8rpx]"> | ||
| 47 | - <text>{{ item.read_people_percent }}%热度</text> | ||
| 48 | - </view> | ||
| 49 | - </view> | ||
| 50 | - | ||
| 51 | - <view class="flex items-center gap-[12rpx] mb-[16rpx]"> | ||
| 52 | - <view | ||
| 53 | - class="inline-flex items-center justify-center px-[12rpx] py-[4rpx] bg-gray-100 text-gray-500 text-[20rpx] font-medium rounded-[8rpx]"> | ||
| 54 | - {{ getDocumentLabel(item.extension ? `file.${item.extension}` : item.name) }} | ||
| 55 | - </view> | ||
| 56 | - <view class="text-[#9CA3AF] text-[22rpx]"> | ||
| 57 | - {{ item.size }} | ||
| 58 | - </view> | ||
| 59 | - </view> | ||
| 60 | - | ||
| 61 | - <view class="h-[1rpx] bg-gray-100 my-[20rpx]"></view> | ||
| 62 | - | ||
| 63 | - <ListItemActions | ||
| 64 | - :viewable="true" | ||
| 65 | - :collectable="true" | ||
| 66 | - :collected="item.collected" | ||
| 67 | - :item-id="String(item.meta_id)" | ||
| 68 | - @view="onView(item)" | ||
| 69 | - @collect="toggleCollect(item)" | ||
| 70 | - /> | ||
| 71 | - </view> | ||
| 72 | - </view> | ||
| 73 | 37 | ||
| 74 | <!-- 空状态 --> | 38 | <!-- 空状态 --> |
| 75 | <view v-if="currentList.length === 0 && !loading && !loadingMore"> | 39 | <view v-if="currentList.length === 0 && !loading && !loadingMore"> |
| ... | @@ -93,14 +57,10 @@ | ... | @@ -93,14 +57,10 @@ |
| 93 | 57 | ||
| 94 | <script setup> | 58 | <script setup> |
| 95 | import { ref } from 'vue' | 59 | import { ref } from 'vue' |
| 96 | -import { useLoad, useReachBottom } from '@tarojs/taro' | 60 | +import Taro, { useLoad, useReachBottom } from '@tarojs/taro' |
| 97 | import NavHeader from '@/components/NavHeader.vue' | 61 | import NavHeader from '@/components/NavHeader.vue' |
| 98 | -import ListItemActions from '@/components/ListItemActions/index.vue' | 62 | +import MaterialCard from '@/components/MaterialCard.vue' |
| 99 | -import { useListItemClick, ListType } from '@/composables/useListItemClick' | ||
| 100 | -import { getDocumentIcon, getDocumentLabel } from '@/utils/documentIcons' | ||
| 101 | import { weekHotAPI } from '@/api/file' | 63 | import { weekHotAPI } from '@/api/file' |
| 102 | -import { useCollectOperation } from '@/composables/useCollectOperation' | ||
| 103 | -import Taro from '@tarojs/taro' | ||
| 104 | 64 | ||
| 105 | const listVisible = ref(true) | 65 | const listVisible = ref(true) |
| 106 | const listRenderKey = ref(0) | 66 | const listRenderKey = ref(0) |
| ... | @@ -112,26 +72,18 @@ const currentPage = ref(0) // 当前页码(从0开始) | ... | @@ -112,26 +72,18 @@ const currentPage = ref(0) // 当前页码(从0开始) |
| 112 | const pageSize = 20 // 每页数量 | 72 | const pageSize = 20 // 每页数量 |
| 113 | 73 | ||
| 114 | /** | 74 | /** |
| 115 | - * 转换文档数据格式 | 75 | + * 处理收藏状态改变 |
| 116 | - * @description 将 API 返回的文档数据转换为组件需要的格式 | 76 | + * |
| 117 | - * @param {Object} doc - API 返回的文档对象 | 77 | + * @description 当用户点击收藏按钮时,更新本地状态 |
| 118 | - * @returns {Object} 转换后的文档对象 | 78 | + * @param {Object} item - 资料对象 |
| 79 | + * @param {Object} newStatus - 新的状态 | ||
| 119 | */ | 80 | */ |
| 120 | -const transformDocItem = (doc) => { | 81 | +const handleCollectChanged = (item, newStatus) => { |
| 121 | - // 处理文件名为空的情况 | 82 | + console.log('[Week Hot] 收藏状态改变:', item.name, newStatus.collected) |
| 122 | - const fileName = doc.name || '未命名文件' | 83 | + // 找到对应的项并更新状态 |
| 123 | - // 如果没有扩展名,从文件名中提取(如果有) | 84 | + const material = currentList.value.find(m => m.meta_id === item.meta_id) |
| 124 | - const extension = doc.extension || fileName.split('.').pop()?.toLowerCase() || '' | 85 | + if (material) { |
| 125 | - | 86 | + material.collected = newStatus.collected |
| 126 | - return { | ||
| 127 | - meta_id: doc.meta_id, | ||
| 128 | - name: fileName, | ||
| 129 | - size: doc.size || '', | ||
| 130 | - downloadUrl: doc.src, | ||
| 131 | - extension: extension, | ||
| 132 | - collected: doc.is_favorite === '1' || doc.is_favorite === 1 || doc.is_favorite === true, | ||
| 133 | - read_people_count: doc.read_people_count, | ||
| 134 | - read_people_percent: doc.read_people_percent | ||
| 135 | } | 87 | } |
| 136 | } | 88 | } |
| 137 | 89 | ||
| ... | @@ -161,7 +113,22 @@ const fetchWeekHotList = async (params = {}, isLoadMore = false) => { | ... | @@ -161,7 +113,22 @@ const fetchWeekHotList = async (params = {}, isLoadMore = false) => { |
| 161 | 113 | ||
| 162 | // 处理列表数据 | 114 | // 处理列表数据 |
| 163 | if (res.data.list?.length) { | 115 | if (res.data.list?.length) { |
| 164 | - const listData = res.data.list.map(transformDocItem) | 116 | + // 直接映射为 MaterialCard 需要的格式 |
| 117 | + const listData = res.data.list.map(item => { | ||
| 118 | + const fileName = item.name || '未命名文件' | ||
| 119 | + const extension = item.extension || fileName.split('.').pop()?.toLowerCase() || '' | ||
| 120 | + | ||
| 121 | + return { | ||
| 122 | + meta_id: item.meta_id, | ||
| 123 | + name: fileName, | ||
| 124 | + size: item.size || '', | ||
| 125 | + downloadUrl: item.src, | ||
| 126 | + extension: extension, | ||
| 127 | + collected: item.is_favorite === '1' || item.is_favorite === 1 || item.is_favorite === true, | ||
| 128 | + read_people_count: item.read_people_count, | ||
| 129 | + read_people_percent: item.read_people_percent | ||
| 130 | + } | ||
| 131 | + }) | ||
| 165 | 132 | ||
| 166 | if (isLoadMore) { | 133 | if (isLoadMore) { |
| 167 | // 加载更多:追加数据 | 134 | // 加载更多:追加数据 |
| ... | @@ -248,68 +215,6 @@ useReachBottom(() => { | ... | @@ -248,68 +215,6 @@ useReachBottom(() => { |
| 248 | ) | 215 | ) |
| 249 | }, 300) | 216 | }, 300) |
| 250 | }) | 217 | }) |
| 251 | - | ||
| 252 | -/** | ||
| 253 | - * 使用文件列表点击处理器 | ||
| 254 | - * @description 添加图片预览功能,点击图片文件时使用 Taro.previewImage | ||
| 255 | - */ | ||
| 256 | -const { handleClick: onView } = useListItemClick({ | ||
| 257 | - listType: ListType.FILE, | ||
| 258 | - onBeforeClick: async (item) => { | ||
| 259 | - /** | ||
| 260 | - * 检查文件类型并使用对应的预览方式 | ||
| 261 | - * - 图片文件:使用 Taro.previewImage 预览 | ||
| 262 | - * - 其他文件:继续默认的文件打开流程 | ||
| 263 | - */ | ||
| 264 | - const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'] | ||
| 265 | - const extension = item.extension?.toLowerCase() || '' | ||
| 266 | - | ||
| 267 | - console.log('[Week Hot] 文件类型:', extension, '文件名:', item.name) | ||
| 268 | - | ||
| 269 | - if (imageExtensions.includes(extension)) { | ||
| 270 | - // 图片文件:使用 Taro 预览 | ||
| 271 | - console.log('[Week Hot] 检测到图片文件,使用图片预览') | ||
| 272 | - | ||
| 273 | - // 构建图片列表(当前图片) | ||
| 274 | - const urls = [item.downloadUrl] | ||
| 275 | - | ||
| 276 | - try { | ||
| 277 | - // 短暂延迟后打开预览(让用户看到提示) | ||
| 278 | - await new Promise(resolve => setTimeout(resolve, 300)) | ||
| 279 | - | ||
| 280 | - await Taro.previewImage({ | ||
| 281 | - current: item.downloadUrl, | ||
| 282 | - urls: urls | ||
| 283 | - }) | ||
| 284 | - | ||
| 285 | - // 预览成功,阻止默认的文件打开行为 | ||
| 286 | - return false | ||
| 287 | - } catch (err) { | ||
| 288 | - console.error('[Week Hot] 图片预览失败:', err) | ||
| 289 | - Taro.showToast({ | ||
| 290 | - title: '图片预览失败', | ||
| 291 | - icon: 'none', | ||
| 292 | - duration: 2000 | ||
| 293 | - }) | ||
| 294 | - // 预览失败,返回 true 继续默认行为 | ||
| 295 | - return true | ||
| 296 | - } | ||
| 297 | - } | ||
| 298 | - | ||
| 299 | - // 非图片文件:继续默认的文件打开流程 | ||
| 300 | - console.log('[Week Hot] 非图片文件,使用默认打开方式') | ||
| 301 | - return true | ||
| 302 | - }, | ||
| 303 | - onAfterClick: (item) => { | ||
| 304 | - console.log('用户打开了资料:', item.name) | ||
| 305 | - } | ||
| 306 | -}) | ||
| 307 | - | ||
| 308 | -/** | ||
| 309 | - * 切换收藏状态 | ||
| 310 | - * @description 使用 useCollectOperation composable 处理收藏操作 | ||
| 311 | - */ | ||
| 312 | -const { toggleCollect } = useCollectOperation() | ||
| 313 | </script> | 218 | </script> |
| 314 | 219 | ||
| 315 | <style lang="less"> | 220 | <style lang="less"> | ... | ... |
-
Please register or login to post a comment