hookehuyr

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>
...@@ -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 ### 修复
......
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>
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">
......