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' {
IndexNav: typeof import('./src/components/indexNav.vue')['default']
LifeInsuranceTemplate: typeof import('./src/components/PlanTemplates/LifeInsuranceTemplate.vue')['default']
ListItemActions: typeof import('./src/components/ListItemActions/index.vue')['default']
MaterialCard: typeof import('./src/components/MaterialCard.vue')['default']
NavHeader: typeof import('./src/components/NavHeader.vue')['default']
NutAvatar: typeof import('@nutui/nutui-taro')['Avatar']
NutButton: typeof import('@nutui/nutui-taro')['Button']
......@@ -36,6 +37,7 @@ declare module 'vue' {
PlanFormContainer: typeof import('./src/components/PlanFormContainer.vue')['default']
PlanPopup: typeof import('./src/components/PlanSchemes/PlanPopup.vue')['default']
PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default']
ProductCard: typeof import('./src/components/ProductCard.vue')['default']
QrCode: typeof import('./src/components/qrCode.vue')['default']
QrCodeSearch: typeof import('./src/components/qrCodeSearch.vue')['default']
RadioGroup: typeof import('./src/components/PlanFields/RadioGroup.vue')['default']
......
......@@ -5,6 +5,30 @@
---
## [2026-02-06] - 搜索页面联调完成
### 新增
- 集成搜索API,实现产品和资料的实时搜索功能
- 添加下拉刷新功能,支持刷新当前选中tab的数据
- 实现首次搜索自动选择tab逻辑(根据返回数据判断)
### 优化
- 移除"全部"tab,只保留"产品"和"资料"两个tab
- 点击tab时实时查询对应类型的数据,不再使用本地缓存
- 优化搜索结果展示,适配后端返回的数据结构
### 技术改进
- 使用 `searchAPI` 调用后端搜索接口
- 首次搜索不传 `type` 参数,根据返回的 `products.total``files.total` 自动选择有数据的tab
- 切换tab时传递 `type` 参数('product' | 'file')进行精确查询
- 下拉刷新时使用当前选中的tab的type进行查询
### 修复
- 修复产品卡片显示逻辑,正确显示 `product_name``cover_image``tags`
- 修复资料卡片显示逻辑,正确显示 `name``value``extension`
---
## [2026-02-06] - 修复计划书表单重置不稳定问题
### 修复
......
<template>
<view class="flex flex-row bg-white rounded-[24rpx] p-[24rpx] shadow-md border border-gray-50 material-card">
<!-- 左侧图标 -->
<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">
<image :src="iconUrl" class="w-[48rpx] h-[48rpx]" mode="aspectFit" />
</view>
<!-- 内容区域 -->
<view class="flex-1 min-w-0">
<!-- 标题 -->
<view class="text-[#1F2937] text-[30rpx] font-bold leading-[1.4] mb-[8rpx] line-clamp-2">
{{ title }}
</view>
<!-- 学习人数信息 -->
<view v-if="learners" class="flex items-center gap-[12rpx] mb-[16rpx]">
<view class="inline-flex items-center justify-center px-[12rpx] py-[4rpx] bg-orange-50 text-orange-600 text-[20rpx] font-medium rounded-[8rpx]">
<text>{{ learners }}</text>
</view>
<!-- 学习人数比例 -->
<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]">
<text>{{ readPeoplePercent }}%热度</text>
</view>
</view>
<!-- 文档类型和文件大小 -->
<view class="flex items-center gap-[12rpx] mb-[16rpx]">
<view class="inline-flex items-center justify-center px-[12rpx] py-[4rpx] bg-gray-100 text-gray-500 text-[20rpx] font-medium rounded-[8rpx]">
<text>{{ docTypeLabel }}</text>
</view>
<view v-if="fileSize" class="text-[#9CA3AF] text-[22rpx]">
{{ fileSize }}
</view>
</view>
<!-- 分割线 -->
<view class="h-[1rpx] bg-gray-100 my-[20rpx]"></view>
<!-- 操作按钮 -->
<ListItemActions
:viewable="true"
:collectable="true"
:deletable="false"
:collected="collected"
:item-id="String(id)"
@view="handleView"
@collect="handleCollect"
/>
</view>
</view>
</template>
<script setup>
/**
* 资料卡片组件
*
* @description 热门资料列表项卡片,展示资料图标、标题、学习人数和操作按钮
* @component MaterialCard
*/
import { defineProps, defineEmits } from 'vue';
import Taro from '@tarojs/taro';
import { getDocumentIcon, getDocumentLabel } from '@/utils/documentIcons';
import ListItemActions from '@/components/ListItemActions/index.vue';
import { useCollectOperation } from '@/composables/useCollectOperation';
import { useListItemClick, ListType } from '@/composables/useListItemClick';
/**
* 组件属性
*/
const props = defineProps({
/** 资料 ID */
id: {
type: [Number, String],
required: true
},
/** 资料标题 */
title: {
type: String,
required: true
},
/** 文件名(用于获取图标和标签) */
fileName: {
type: String,
default: ''
},
/** 文件大小 */
fileSize: {
type: String,
default: ''
},
/** 学习人数文本 */
learners: {
type: String,
default: ''
},
/** 学习人数百分比(热度) */
readPeoplePercent: {
type: Number,
default: null
},
/** 是否已收藏 */
collected: {
type: Boolean,
default: false
},
/** 文件扩展名 */
extension: {
type: String,
default: ''
},
/** 下载URL */
downloadUrl: {
type: String,
default: ''
}
});
/**
* 组件事件(简化版)
*/
const emit = defineEmits({
/** 查看完成 */
viewed: (item) => true,
/** 收藏状态改变 */
collectChanged: (item) => true
});
/**
* 获取文档图标 URL
*
* @description 根据文件名获取对应的文档类型图标
* @returns {string} 图标 URL
*/
const iconUrl = props.fileName ? getDocumentIcon(props.fileName) : '';
/**
* 获取文档类型标签
*
* @description 根据文件名获取文档类型标签文本
* @returns {string} 文档类型标签
*/
const docTypeLabel = props.fileName ? getDocumentLabel(props.fileName) : '';
/**
* 使用收藏操作 composable
*/
const { toggleCollect } = useCollectOperation();
/**
* 使用文件列表点击处理器(内部实现)
*/
const { handleClick } = useListItemClick({
listType: ListType.FILE,
onBeforeClick: async (item) => {
/**
* 检查文件类型并使用对应的预览方式
* - 图片文件:使用 Taro.previewImage 预览
* - 其他文件:继续默认的文件打开流程
*/
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']
const extension = item.extension?.toLowerCase() || ''
console.log('[MaterialCard] 文件类型:', extension, '文件名:', item.title)
if (imageExtensions.includes(extension)) {
// 图片文件:使用 Taro 预览
console.log('[MaterialCard] 检测到图片文件,使用图片预览')
// 构建图片列表(当前图片)
const urls = [item.downloadUrl]
try {
// 短暂延迟后打开预览(让用户看到提示)
await new Promise(resolve => setTimeout(resolve, 300))
await Taro.previewImage({
current: item.downloadUrl,
urls: urls
})
// 预览成功,阻止默认的文件打开行为
return false
} catch (err) {
console.error('[MaterialCard] 图片预览失败:', err)
Taro.showToast({
title: '图片预览失败',
icon: 'none',
duration: 2000
})
// 预览失败,返回 true 继续默认行为
return true
}
}
// 非图片文件:继续默认的文件打开流程
console.log('[MaterialCard] 非图片文件,使用默认打开方式')
return true
},
onAfterClick: (item) => {
console.log('[MaterialCard] 用户打开了资料:', item.title)
// 通知父组件查看完成
emit('viewed', item)
}
})
/**
* 处理查看点击
*
* @description 内部处理查看逻辑,调用 useListItemClick
*/
const handleView = () => {
handleClick({
id: props.id,
title: props.title,
fileName: props.fileName,
fileSize: props.fileSize,
learners: props.learners,
readPeoplePercent: props.readPeoplePercent,
collected: props.collected,
extension: props.extension,
downloadUrl: props.downloadUrl
})
};
/**
* 处理收藏点击
*
* @description 内部处理收藏逻辑,调用 useCollectOperation 并通知父组件
*/
const handleCollect = () => {
// 调用收藏操作
toggleCollect({
id: props.id,
title: props.title,
fileName: props.fileName,
fileSize: props.fileSize,
learners: props.learners,
readPeoplePercent: props.readPeoplePercent,
collected: props.collected,
extension: props.extension,
downloadUrl: props.downloadUrl
})
// 通知父组件收藏状态改变
emit('collectChanged', {
id: props.id,
title: props.title,
fileName: props.fileName,
fileSize: props.fileSize,
learners: props.learners,
readPeoplePercent: props.readPeoplePercent,
collected: !props.collected, // 新状态(取反)
extension: props.extension,
downloadUrl: props.downloadUrl
})
};
</script>
<style lang="less" scoped>
.material-card {
// 多行文本省略
.line-clamp-2 {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
word-break: break-all;
}
}
</style>
<template>
<view class="bg-gray-50 rounded-[24rpx] p-[28rpx] product-card">
<!-- 产品名称 -->
<text class="block text-gray-800 text-[28rpx] font-medium mb-[20rpx]">{{ productName }}</text>
<!-- 产品标签 -->
<view v-if="tags && tags.length" class="flex flex-wrap gap-[12rpx] mb-[24rpx]">
<view
v-for="tag in tags"
:key="tag.id"
class="rounded-[8rpx] px-[16rpx] py-[6rpx]"
:style="{
backgroundColor: tag.bg_color,
color: tag.text_color
}"
>
<text class="text-[22rpx]">{{ tag.name }}</text>
</view>
</view>
<!-- 操作按钮 -->
<view class="flex justify-between gap-[24rpx]">
<nut-button
plain
color="#2563EB"
class="flex-1 !h-[64rpx] !rounded-[16rpx] !text-[26rpx] !m-0 !border-blue-600"
@tap="handleDetail"
>
产品详情
</nut-button>
<nut-button
color="#2563EB"
class="flex-1 !h-[64rpx] !rounded-[16rpx] !text-[26rpx] !m-0"
@tap="handlePlan"
>
计划书
</nut-button>
</view>
</view>
</template>
<script setup>
/**
* 产品卡片组件
*
* @description 热卖产品列表项卡片,展示产品名称、标签和操作按钮
* @component ProductCard
*/
import { defineProps, defineEmits } from 'vue';
/**
* 组件属性
*/
const props = defineProps({
/** 产品 ID */
productId: {
type: Number,
required: true
},
/** 产品名称 */
productName: {
type: String,
required: true
},
/** 产品标签数组 */
tags: {
type: Array,
default: () => []
}
});
/**
* 组件事件
*/
const emit = defineEmits({
/** 点击产品详情按钮 */
detail: (productId) => typeof productId === 'number',
/** 点击计划书按钮 */
plan: (productId) => typeof productId === 'number'
});
/**
* 处理产品详情点击
*
* @description 触发 detail 事件,传递产品 ID
*/
const handleDetail = () => {
emit('detail', props.productId);
};
/**
* 处理计划书点击
*
* @description 触发 plan 事件,传递产品 ID
*/
const handlePlan = () => {
emit('plan', props.productId);
};
</script>
<style lang="less" scoped>
.product-card {
// 可以添加卡片特定的样式
}
</style>
......@@ -49,47 +49,16 @@
</view>
<!-- 动态产品列表 -->
<view
<ProductCard
v-for="(product, index) in hotProducts"
:key="product.id"
class="bg-gray-50 rounded-[24rpx] p-[28rpx]"
:product-id="product.id"
:product-name="product.product_name"
:tags="product.tags"
:class="{ 'mb-[24rpx]': index < hotProducts.length - 1 }"
>
<text class="block text-gray-800 text-[28rpx] font-medium mb-[20rpx]">{{ product.product_name }}</text>
<!-- 动态标签 -->
<view v-if="product.tags && product.tags.length" class="flex flex-wrap gap-[12rpx] mb-[24rpx]">
<view
v-for="tag in product.tags"
:key="tag.id"
class="rounded-[8rpx] px-[16rpx] py-[6rpx]"
:style="{
backgroundColor: tag.bg_color,
color: tag.text_color
}"
>
<text class="text-[22rpx]">{{ tag.name }}</text>
</view>
</view>
<view class="flex justify-between gap-[24rpx]">
<nut-button
plain
color="#2563EB"
class="flex-1 !h-[64rpx] !rounded-[16rpx] !text-[26rpx] !m-0 !border-blue-600"
@tap="goToProductDetail(product.id)"
>
产品详情
</nut-button>
<nut-button
color="#2563EB"
class="flex-1 !h-[64rpx] !rounded-[16rpx] !text-[26rpx] !m-0"
@tap="openPlanPopup(product.id)"
>
计划书
</nut-button>
</view>
</view>
@detail="goToProductDetail"
@plan="openPlanPopup"
/>
</view>
<!-- Hot Materials -->
......@@ -105,53 +74,20 @@
<!-- Material List -->
<view class="flex flex-col gap-[24rpx]">
<!-- Material Items -->
<view v-for="(item, index) in hotMaterials" :key="item.id || index"
class="flex flex-row bg-white rounded-[24rpx] p-[24rpx] shadow-md border border-gray-50">
<!-- 左侧图标 -->
<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">
<image :src="getDocumentIcon(item.fileName)" class="w-[48rpx] h-[48rpx]" mode="aspectFit" />
</view>
<!-- 内容区域 -->
<view class="flex-1 min-w-0">
<view class="text-[#1F2937] text-[30rpx] font-bold leading-[1.4] mb-[8rpx] line-clamp-2">
{{ item.title }}
</view>
<!-- 学习人数信息 -->
<view v-if="item.learners" class="flex items-center gap-[12rpx] mb-[16rpx]">
<view class="inline-flex items-center justify-center px-[12rpx] py-[4rpx] bg-orange-50 text-orange-600 text-[20rpx] font-medium rounded-[8rpx]">
<text>{{ item.learners }}</text>
</view>
<!-- 学习人数比例 -->
<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]">
<text>{{ item.readPeoplePercent }}%热度</text>
</view>
</view>
<!-- 文档类型和文件大小 -->
<view class="flex items-center gap-[12rpx] mb-[16rpx]">
<view class="inline-flex items-center justify-center px-[12rpx] py-[4rpx] bg-gray-100 text-gray-500 text-[20rpx] font-medium rounded-[8rpx]">
<text>{{ getDocumentLabel(item.fileName) }}</text>
</view>
<view v-if="item.fileSize" class="text-[#9CA3AF] text-[22rpx]">
{{ item.fileSize }}
</view>
</view>
<!-- 分割线 -->
<view class="h-[1rpx] bg-gray-100 my-[20rpx]"></view>
<!-- 操作按钮 -->
<ListItemActions
:viewable="true"
:collectable="true"
:deletable="false"
:collected="item.collected"
@view="onViewMaterial(item)"
@collect="toggleMaterialCollect(item)"
/>
</view>
</view>
<MaterialCard
v-for="item in hotMaterials"
:key="item.id"
:id="item.id"
:title="item.title"
:file-name="item.fileName"
:file-size="item.fileSize"
:learners="item.learners"
:read-people-percent="item.readPeoplePercent"
:collected="item.collected"
:extension="item.extension"
:download-url="item.downloadUrl"
@collect-changed="handleCollectChanged(item, $event)"
/>
</view>
</view>
</view>
......@@ -176,18 +112,18 @@
import { ref, shallowRef } from 'vue';
import Taro, { useShareAppMessage, useLoad, useDidShow } from '@tarojs/taro';
import { useGo } from '@/hooks/useGo';
import { useListItemClick, ListType } from '@/composables/useListItemClick';
import { getDocumentIcon, getDocumentLabel } from '@/utils/documentIcons';
import { useUserStore } from '@/stores/user';
import TabBar from '@/components/TabBar.vue';
import IconFont from '@/components/IconFont.vue';
import PlanFormContainer from '@/components/PlanFormContainer.vue';
import ListItemActions from '@/components/ListItemActions/index.vue';
import ProductCard from '@/components/ProductCard.vue';
import MaterialCard from '@/components/MaterialCard.vue';
import { listAPI } from '@/api/get_product';
import { weekHotAPI } from '@/api/file';
import { useCollectOperation } from '@/composables/useCollectOperation';
import { homeIconAPI } from '@/api/home';
// User Store
const userStore = useUserStore();
......@@ -310,119 +246,16 @@ const hotProducts = ref([]);
/**
* 获取热卖产品列表
*
* @description ⚠️ 测试数据:后端接口和字段还没有准备好,暂时使用模拟数据进行测试
* 测试完成后需要移除,恢复使用真实的API调用
* Mock数据包含全部7种产品类型(2种人寿、3种重疾、4种储蓄)
* @description 调用 listAPI 获取热卖产品列表
*/
const fetchHotProducts = async () => {
try {
// TODO: 测试完成后,移除下面的 mock 数据,恢复使用真实 API
// const res = await listAPI({
// recommend: 'hot'
// });
// if (res.code === 1 && res.data && res.data.list) {
// hotProducts.value = res.data.list;
// }
// ⚠️ 测试数据开始 - 测试完成后需要移除 ⚠️
hotProducts.value = [
// 人寿保险产品(2种)
{
id: 1,
product_name: 'WIOP3E 盈传创富保障计划 3 - 优选版',
form_sn: 'life-insurance-wiop3e',
recommend: 'hot',
tags: [
{ id: 1, name: '终身寿险', bg_color: '#DBEAFE', text_color: '#1E40AF' },
{ id: 2, name: '美元', bg_color: '#FEF3C7', text_color: '#92400E' }
]
},
{
id: 2,
product_name: 'WIOP3 - 盈传创富保障计划 3',
form_sn: 'life-insurance-wiop3',
recommend: 'hot',
tags: [
{ id: 1, name: '终身寿险', bg_color: '#DBEAFE', text_color: '#1E40AF' },
{ id: 2, name: '美元', bg_color: '#FEF3C7', text_color: '#92400E' }
]
},
// 重疾保险产品(3种)
{
id: 3,
product_name: 'MPC 守护无间重疾',
form_sn: 'critical-illness-mpc',
recommend: 'hot',
tags: [
{ id: 1, name: '重疾保障', bg_color: '#FCE7F3', text_color: '#9F1239' },
{ id: 2, name: '人民币', bg_color: '#D1FAE5', text_color: '#065F46' }
]
},
{
id: 4,
product_name: 'MBC PRO 活跃人生重疾保 PRO',
form_sn: 'critical-illness-mbc-pro',
recommend: 'hot',
tags: [
{ id: 1, name: '重疾保障', bg_color: '#FCE7F3', text_color: '#9F1239' },
{ id: 2, name: '人民币', bg_color: '#D1FAE5', text_color: '#065F46' }
]
},
{
id: 5,
product_name: 'MBC2 活跃人生重疾保 2',
form_sn: 'critical-illness-mbc2',
recommend: 'hot',
tags: [
{ id: 1, name: '重疾保障', bg_color: '#FCE7F3', text_color: '#9F1239' },
{ id: 2, name: '人民币', bg_color: '#D1FAE5', text_color: '#065F46' }
]
},
// 储蓄型产品(4种)- GS, GC, FA, LV2
{
id: 6,
product_name: 'GS - 宏摯傳承保障計劃',
form_sn: 'savings-gs',
recommend: 'hot',
tags: [
{ id: 1, name: '储蓄分红', bg_color: '#E0E7FF', text_color: '#3730A3' },
{ id: 2, name: '美元', bg_color: '#FEF3C7', text_color: '#92400E' }
]
},
{
id: 7,
product_name: 'GC - 宏摯家傳承保險計劃',
form_sn: 'savings-gc',
recommend: 'hot',
tags: [
{ id: 1, name: '储蓄分红', bg_color: '#E0E7FF', text_color: '#3730A3' },
{ id: 2, name: '美元', bg_color: '#FEF3C7', text_color: '#92400E' }
]
},
{
id: 8,
product_name: 'FA - 宏浚傳承保障計劃',
form_sn: 'savings-fa',
recommend: 'hot',
tags: [
{ id: 1, name: '储蓄分红', bg_color: '#E0E7FF', text_color: '#3730A3' },
{ id: 2, name: '美元', bg_color: '#FEF3C7', text_color: '#92400E' }
]
},
{
id: 9,
product_name: 'LV2 - 赤霞珠終身壽險計劃2',
form_sn: 'savings-lv2',
recommend: 'hot',
tags: [
{ id: 1, name: '储蓄型终身寿险', bg_color: '#E0E7FF', text_color: '#3730A3' },
{ id: 2, name: '美元', bg_color: '#FEF3C7', text_color: '#92400E' }
]
}
];
// ⚠️ 测试数据结束 - 测试完成后需要移除 ⚠️
console.log('⚠️ 使用测试数据:热卖产品列表已 mock,包含全部7种产品类型');
const res = await listAPI({
recommend: 'hot'
});
if (res.code === 1 && res.data && res.data.list) {
hotProducts.value = res.data.list;
}
} catch (err) {
console.error('获取热卖产品失败:', err);
}
......@@ -457,16 +290,23 @@ const fetchHotMaterials = async () => {
if (res.code === 1 && res.data && res.data.list) {
// 转换 API 数据格式为组件所需格式
hotMaterials.value = res.data.list.map(item => ({
id: item.meta_id,
title: item.name,
fileName: item.name,
downloadUrl: item.src,
fileSize: item.size,
learners: `${item.read_people_count}人学习`,
readPeoplePercent: item.read_people_percent, // 学习人数比例
collected: item.is_favorite
}));
hotMaterials.value = res.data.list.map(item => {
// 提取文件扩展名
const fileName = item.name || '未命名文件'
const extension = item.extension || fileName.split('.').pop()?.toLowerCase() || ''
return {
id: item.meta_id,
title: item.name,
fileName: fileName,
downloadUrl: item.src,
fileSize: item.size,
extension: extension,
learners: `${item.read_people_count}人学习`,
readPeoplePercent: item.read_people_percent, // 学习人数比例
collected: item.is_favorite
}
});
} else {
hotMaterials.value = [];
}
......@@ -482,19 +322,20 @@ const fetchHotMaterials = async () => {
const go = useGo();
/**
* 使用文件列表点击处理器
* 处理收藏状态改变
*
* @description 配置为文件类型列表,点击时打开文件预览
* @description 当用户点击收藏按钮时,更新本地状态
* @param {Object} item - 资料对象
* @param {Object} newStatus - 新的状态
*/
const { handleClick: onViewMaterial } = useListItemClick({
listType: ListType.FILE,
onAfterClick: (item) => {
console.log('用户打开了资料:', item.title);
const handleCollectChanged = (item, newStatus) => {
console.log('[Index] 收藏状态改变:', item.title, newStatus.collected)
// 找到对应的项并更新状态
const material = hotMaterials.value.find(m => m.id === item.id)
if (material) {
material.collected = newStatus.collected
}
});
// 使用收藏操作 composable
const { toggleCollect: toggleMaterialCollect } = useCollectOperation();
};
/**
* 处理网格导航点击
......@@ -574,13 +415,5 @@ useShareAppMessage(() => {
</script>
<style lang="less">
/* 多行文本省略 */
.line-clamp-2 {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
word-break: break-all;
}
// 样式已移到各组件内部
</style>
......
<!--
* @Date: 2026-01-31
* @Description: 搜索页面 - 固定搜索栏和Tab,列表可滚动
* @Date: 2026-02-06
* @Description: 搜索页面 - 支持产品和资料搜索,实时查询API
-->
<template>
<view class="h-screen bg-[#F9FAFB] flex flex-col">
<view class="h-screen bg-[#FFF] flex flex-col">
<!-- 固定顶部:导航栏 + 搜索栏 -->
<view class="bg-[#F9FAFB] z-10">
<view class="bg-[#FFF] z-10">
<NavHeader title="搜索" />
<!-- Search Input -->
......@@ -18,7 +18,6 @@
:show-clear="true"
@search="handleSearch"
@clear="clearSearch"
@blur="handleBlur"
/>
</view>
</view>
......@@ -26,7 +25,7 @@
<!-- Tabs + 列表容器 -->
<view class="flex-1 min-h-0 flex flex-col mt-[32rpx] px-[40rpx]">
<!-- Tabs Container -->
<nut-tabs v-model="activeTabId">
<nut-tabs v-model="activeTab">
<!-- 自定义标签栏 -->
<template #titles>
<view class="filter-tabs-wrapper">
......@@ -35,7 +34,8 @@
:key="item.id"
:class="[
'filter-tab-item',
activeTabId === item.id ? 'filter-tab-active' : 'filter-tab-inactive'
activeTab === item.id ? 'filter-tab-active' : 'filter-tab-inactive',
!activeTab ? 'filter-tab-inactive' : '' // 初始状态不高亮任何tab
]"
@tap="onTabClick(item.id)"
>
......@@ -46,376 +46,453 @@
</nut-tabs>
<!-- Result Count -->
<view v-if="searchResults.length > 0" class="text-[#6B7280] text-[24rpx] mb-[24rpx]">
找到 {{ searchResults.length }} 个相关结果
<view v-if="currentList.length > 0" class="text-[#6B7280] text-[24rpx] mb-[24rpx]">
找到 {{ currentTotal }} 个相关结果
</view>
<!-- 可滚动列表区域 -->
<view
<!-- 可滚动列表区域(支持触底加载更多) -->
<scroll-view
class="flex-1 min-h-0 overflow-y-auto pb-[calc(160rpx+env(safe-area-inset-bottom))] box-border"
scroll-y
>
<!-- Search Results -->
<view
v-if="searchResults.length > 0"
v-if="currentList.length > 0"
:key="listRenderKey"
>
<!-- Results List -->
<view class="flex flex-col gap-[24rpx] pb-[40rpx]">
<!-- Product/Material Card -->
<view
v-for="(item, index) in searchResults"
<!-- Product Results -->
<view v-if="activeTab === 'product'" class="flex flex-col gap-[24rpx] pb-[40rpx]">
<ProductCard
v-for="(item, index) in currentList"
:key="index"
class="bg-white rounded-[24rpx] overflow-hidden shadow-sm search-result-item"
:product-id="item.id"
:product-name="item.product_name || item.name"
:tags="item.tags || []"
class="search-result-item"
:style="{ animationDelay: `${index * 30}ms` }"
@tap="goToDetail(item)"
>
<!-- Image + Content Layout -->
<view class="flex gap-[24rpx] p-[24rpx]">
<!-- Image -->
<image
class="w-[200rpx] h-[140rpx] rounded-[16rpx] bg-gray-100 flex-shrink-0"
:src="item.image"
mode="aspectFill"
/>
<!-- Content -->
<view class="flex-1 flex flex-col justify-between py-[4rpx]">
<!-- Title -->
<view class="text-[#1F2937] text-[28rpx] font-medium leading-[1.4] line-clamp-2">
{{ item.title }}
</view>
<!-- Meta Info -->
<view class="flex justify-between items-center">
<view class="flex gap-[12rpx]">
<!-- Type Tag -->
<view
class="px-[12rpx] py-[4rpx] rounded-[8rpx] text-[22rpx]"
:class="item.type === '产品' ? 'bg-blue-50 text-blue-600' : 'bg-green-50 text-green-600'"
>
{{ item.type }}
</view>
<!-- Hot Tag -->
<view v-if="item.tag" class="bg-red-50 text-red-600 text-[22rpx] px-[12rpx] py-[4rpx] rounded-[8rpx]">
{{ item.tag }}
</view>
</view>
<view class="text-[#6B7280] text-[24rpx]">
{{ item.views || 0 }}人查看
</view>
</view>
</view>
</view>
@detail="goToProductDetail"
@plan="openPlanPopup"
/>
</view>
<!-- File Results -->
<view v-else-if="activeTab === 'file'" class="flex flex-col gap-[24rpx] pb-[40rpx]">
<MaterialCard
v-for="(item, index) in currentList"
:key="index"
:id="item.id"
:title="item.title"
:file-name="item.fileName"
:file-size="item.fileSize"
:learners="item.learners"
:read-people-percent="item.readPeoplePercent"
:collected="item.collected"
:extension="item.extension"
:download-url="item.downloadUrl"
class="search-result-item"
:style="{ animationDelay: `${index * 30}ms` }"
@collect-changed="handleCollectChanged(item, $event)"
/>
</view>
<!-- 加载更多提示 -->
<view v-if="currentList.length > 0" class="flex items-center justify-center py-[40rpx]">
<view v-if="loadingMore" class="flex items-center">
<view class="loading-spinner-small"></view>
<text class="ml-[12rpx] text-[#9CA3AF] text-[24rpx]">加载中...</text>
</view>
<view v-else-if="!hasMore" class="text-[#9CA3AF] text-[24rpx]">
没有更多了
</view>
</view>
</view>
<!-- Empty State (已搜索但无结果) -->
<view v-else-if="hasSearched && searchResults.length === 0" class="flex flex-col items-center justify-center py-[40rpx]">
<view v-else-if="hasSearched && currentList.length === 0" class="flex flex-col items-center justify-center py-[40rpx]">
<nut-empty description="暂无搜索结果" image="empty">
<view class="text-[#9CA3AF] text-[24rpx] mt-[12rpx]">试试其他关键词吧</view>
</nut-empty>
</view>
<!-- Initial State (从未搜索过) -->
<view v-else-if="isInitialState" class="flex flex-col items-center justify-center py-[120rpx]">
<view v-else class="flex flex-col items-center justify-center py-[120rpx]">
<IconFont name="search" class="text-gray-300 mb-[24rpx]" size="64" />
<view class="text-[#6B7280] text-[28rpx]">{{ initialStateText.title }}</view>
<view class="text-[#9CA3AF] text-[24rpx] mt-[12rpx]">{{ initialStateText.subtitle }}</view>
<view class="text-[#6B7280] text-[28rpx]">搜索产品或资料</view>
<view class="text-[#9CA3AF] text-[24rpx] mt-[12rpx]">输入关键词开始搜索,自动切换分类</view>
</view>
</view>
</scroll-view>
</view>
<!-- Plan Form Container -->
<!-- 仅当 selectedProduct 不为 null 时才渲染组件,避免 product prop required 警告 -->
<PlanFormContainer
v-if="selectedProduct"
v-model:visible="showPlanPopup"
:product="selectedProduct"
@close="showPlanPopup = false"
@submit="handlePlanSubmit"
/>
</view>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import Taro from '@tarojs/taro'
import { ref, computed } from 'vue'
import Taro, { useReachBottom } from '@tarojs/taro'
import { useGo } from '@/hooks/useGo'
import NavHeader from '@/components/NavHeader.vue'
import IconFont from '@/components/IconFont.vue'
import SearchBar from '@/components/SearchBar.vue'
import ProductCard from '@/components/ProductCard.vue'
import MaterialCard from '@/components/MaterialCard.vue'
import PlanFormContainer from '@/components/PlanFormContainer.vue'
import { searchAPI } from '@/api/search'
// Navigation
const go = useGo()
// Plan Popup State
const showPlanPopup = ref(false)
const selectedProduct = ref(null)
// State
const searchKeyword = ref('')
const activeTabId = ref('')
const activeTab = ref('') // 当前选中的 tab(初始为空,不选中任何tab)
const hasSearched = ref(false) // 是否已经搜索过
const listRenderKey = ref(0)
// 数据状态
const products = ref([]) // 产品列表
const files = ref([]) // 资料列表
const productsTotal = ref(0) // 产品总数
const filesTotal = ref(0) // 资料总数
const loadingMore = ref(false) // 加载更多状态
const hasMore = ref(true) // 是否还有更多数据
const currentPage = ref(0) // 当前页码(从0开始)
const pageSize = 20 // 每页数量
/**
* 是否已经搜索过
* @description 一旦用户搜索过,此值将保持为 true,即使清空关键词也不会重置
* 用于区分"初始状态"和"空搜索结果"
* Tab 数据源(只保留产品和资料)
*/
const hasSearched = ref(false)
const listRenderKey = ref(0)
const tabsData = ref([
{ id: 'product', name: '产品' },
{ id: 'file', name: '资料' },
])
/**
* 是否显示初始状态
* @description 只有在从未搜索过且没有关键词时才显示初始状态
* 当前显示的列表
*/
const isInitialState = computed(() => {
return !hasSearched.value && !searchKeyword.value.trim()
const currentList = computed(() => {
// 如果没有选中任何tab,返回空数组
if (!activeTab.value) return []
if (activeTab.value === 'product') {
return products.value
} else {
return files.value
}
})
/**
* 初始状态的文案内容
* @description 根据当前选中的分类显示不同的文案,提升用户体验
* 当前列表总数
*/
const initialStateText = computed(() => {
const currentTab = tabsData.value.find(tab => tab.id === activeTabId.value)
const currentTotal = computed(() => {
// 如果没有选中任何tab,返回0
if (!activeTab.value) return 0
if (!currentTab) {
return {
title: '搜索培训资料、案例、产品',
subtitle: '输入关键词开始搜索'
}
if (activeTab.value === 'product') {
return productsTotal.value
} else {
return filesTotal.value
}
})
// 根据分类返回不同的文案
switch (currentTab.id) {
case 'product':
return {
title: '搜索保险产品',
subtitle: '输入产品名称或类型,如"重疾险"'
}
case 'material':
return {
title: '搜索培训资料',
subtitle: '输入资料关键词,如"销售话术"'
/**
* 执行搜索
* @param {string} keyword - 搜索关键字
* @param {string} type - 可选,'product' | 'file' | undefined
* @param {number} page - 页码(从0开始)
* @param {number} limit - 每页数量
* @param {boolean} isLoadMore - 是否为加载更多
*/
const performSearch = async (keyword, type, page = 0, limit = pageSize, isLoadMore = false) => {
try {
// 如果是加载更多,使用 loadingMore 状态;否则使用 loading 状态
if (isLoadMore) {
loadingMore.value = true
} else {
Taro.showLoading({ title: '搜索中...', mask: true })
}
const params = { keyword, page, limit }
if (type) params.type = type
const res = await searchAPI(params)
if (res.code === 1) {
// 映射产品列表
const newProducts = res.data.products.list || []
// 映射资料列表(进行字段映射,与首页保持一致)
const newFiles = (res.data.files.list || []).map(item => {
// 提取文件扩展名
const fileName = item.name || '未命名文件'
const extension = item.extension || fileName.split('.').pop()?.toLowerCase() || ''
return {
id: item.meta_id || item.id,
title: item.name,
fileName: fileName,
fileSize: item.size || item.file_size,
downloadUrl: item.src || item.value,
extension: extension,
learners: item.read_people_count ? `${item.read_people_count }人学习` : '',
readPeoplePercent: item.read_people_percent,
is_favorite: item.is_favorite, // 保留原始字段
collected: Boolean(item.is_favorite) // 转换为 Boolean 供 MaterialCard 使用
}
})
// 根据是否为加载更多来处理数据
if (isLoadMore) {
// 加载更多:追加数据
products.value = [...products.value, ...newProducts]
files.value = [...files.value, ...newFiles]
} else {
// 首次加载或刷新:替换数据
products.value = newProducts
files.value = newFiles
}
default:
return {
title: '搜索培训资料、案例、产品',
subtitle: '输入关键词开始搜索'
productsTotal.value = res.data.products.total || 0
filesTotal.value = res.data.files.total || 0
// 判断是否还有更多数据
// 如果返回的数据量少于请求的量,说明没有更多了
const currentListLength = type === 'product' ? newProducts.length : newFiles.length
hasMore.value = currentListLength >= limit
// 如果不传 type,自动选择有数据的 tab(仅首次搜索时)
if (!type && !isLoadMore) {
if (productsTotal.value > 0) {
activeTab.value = 'product'
} else if (filesTotal.value > 0) {
activeTab.value = 'file'
}
// 如果都为 0,默认 product
}
hasSearched.value = true
listRenderKey.value += 1
console.log('[Search] 搜索成功', {
productsTotal: productsTotal.value,
filesTotal: filesTotal.value,
activeTab: activeTab.value,
isLoadMore,
hasMore: hasMore.value
})
} else {
Taro.showToast({
title: res.msg || '搜索失败',
icon: 'none'
})
}
} catch (err) {
console.error('[Search] 搜索失败:', err)
Taro.showToast({
title: '搜索失败,请重试',
icon: 'none'
})
} finally {
if (isLoadMore) {
loadingMore.value = false
} else {
Taro.hideLoading()
}
}
})
}
/**
* Tab 数据源
* @description 包含分类信息和对应的列表
* Tab 点击处理(实时查询)
*/
const tabsData = ref([
{ id: '', name: '全部', list: [] },
{ id: 'product', name: '产品', list: [] },
{ id: 'material', name: '资料', list: [] },
])
const onTabClick = async (tabId) => {
if (activeTab.value === tabId) return
// 立即切换 tab(响应更快)
activeTab.value = tabId
listRenderKey.value += 1
// 重置分页状态
currentPage.value = 0
hasMore.value = true
// 如果已经搜索过,实时查询对应类型的数据
if (hasSearched.value && searchKeyword.value.trim()) {
console.log('[Search] 切换 tab,实时查询:', tabId)
await performSearch(searchKeyword.value.trim(), tabId, 0, pageSize, false)
}
}
/**
* 生成大量 Mock 数据用于测试长列表
* @description 生成 50 个产品 + 50 个资料,共 100 条数据
* 提交搜索
*/
const generateMockData = () => {
const products = []
const materials = []
// 生成 50 个产品
for (let i = 1; i <= 50; i++) {
products.push({
id: i,
title: `保险产品 ${i} - ${getProductName(i)}`,
type: '产品',
tag: i % 3 === 0 ? '热卖' : (i % 5 === 0 ? '推荐' : ''),
views: Math.floor(Math.random() * 500) + 50,
image: `https://picsum.photos/seed/prod${i}/200/140`,
category: 'product'
const handleSearch = async () => {
const keyword = searchKeyword.value.trim()
if (!keyword) {
Taro.showToast({
title: '请输入搜索关键词',
icon: 'none'
})
return
}
// 生成 50 个资料
for (let i = 1; i <= 50; i++) {
materials.push({
id: 50 + i,
title: `培训资料 ${i} - ${getMaterialName(i)}`,
type: '资料',
views: Math.floor(Math.random() * 300) + 30,
image: `https://picsum.photos/seed/mat${i}/200/140`,
category: 'material'
})
}
console.log('[Search] 提交搜索:', keyword)
return [...products, ...materials]
}
// 重置分页状态
currentPage.value = 0
hasMore.value = true
/**
* 获取产品名称
*/
const getProductName = (index) => {
const names = [
'终身寿险', '百万医疗', '重疾保障', '意外保险', '年金保险',
'教育金', '养老保险', '财富传承', '投资连结', '分红保险',
'万能险', '定期寿险', '终身医疗', '高端医疗', '团体保险'
]
return names[index % names.length]
// 不传 type,让后端返回两种数据,前端自动选择 tab
await performSearch(keyword, undefined, 0, pageSize, false)
}
/**
* 获取资料名称
* 清空搜索
*/
const getMaterialName = (index) => {
const names = [
'销售话术', '产品培训', '案例分析', '合规指引', '核保规则',
'理赔流程', '客户服务', '市场分析', '竞争产品对比', '政策解读',
'新人培训', '晋升考核', '团队管理', '活动策划', '产说会流程'
]
return names[index % names.length]
const clearSearch = () => {
console.log('[Search] 清空搜索')
searchKeyword.value = ''
hasSearched.value = false
products.value = []
files.value = []
productsTotal.value = 0
filesTotal.value = 0
activeTab.value = '' // 重置为空,不选中任何tab
currentPage.value = 0
hasMore.value = true
listRenderKey.value += 1
}
// All mock data
const allData = ref(generateMockData())
console.log('[Search] 数据生成完成,总数:', allData.value.length)
console.log('[Search] 产品数量:', allData.value.filter(item => item.category === 'product').length)
console.log('[Search] 资料数量:', allData.value.filter(item => item.category === 'material').length)
/**
* 初始化数据分布
* @description 根据分类规则将 allData 中的数据分配到各个 tab 中
* 触底加载更多
* @description 使用防抖避免频繁触发
*/
const initTabsData = () => {
tabsData.value.forEach((tab) => {
if (tab.id === '') {
tab.list = [...allData.value]
} else if (tab.id === 'product') {
tab.list = allData.value.filter(item => item.category === 'product')
} else if (tab.id === 'material') {
tab.list = allData.value.filter(item => item.category === 'material')
}
})
// 默认选中第一个 tab(全部)
if (tabsData.value.length > 0) {
activeTabId.value = tabsData.value[0].id
console.log('[Search] 初始化完成,默认选中:', tabsData.value[0].name)
console.log('[Search] 全部分类数据量:', tabsData.value[0].list.length)
console.log('[Search] 产品分类数据量:', tabsData.value[1].list.length)
console.log('[Search] 资料分类数据量:', tabsData.value[2].list.length)
let loadMoreTimer = null
useReachBottom(() => {
// 如果正在加载更多或没有更多数据,不执行
if (loadingMore.value || !hasMore.value) {
return
}
}
// Search results
const searchResults = computed(() => {
if (!hasSearched.value) return []
// ✅ 如果没有关键词,返回空数组(不显示全部数据)
if (!searchKeyword.value.trim()) {
console.log('[Search Results] 没有关键词,返回空数组')
return []
// 如果没有搜索过或没有选中 tab,不执行
if (!hasSearched.value || !activeTab.value || !searchKeyword.value.trim()) {
return
}
// 找到当前选中的 tab
const currentTab = tabsData.value.find(tab => tab.id === activeTabId.value)
console.log('[Search Results] activeTabId:', activeTabId.value)
console.log('[Search Results] currentTab:', currentTab)
console.log('[Search Results] currentTab.list.length:', currentTab?.list?.length || 0)
if (!currentTab) return []
let results = currentTab.list
// Filter by keyword
const keyword = searchKeyword.value.toLowerCase()
console.log('[Search Results] 搜索关键词:', keyword)
console.log('[Search Results] 过滤前数量:', results.length)
results = results.filter(item =>
item.title.toLowerCase().includes(keyword)
)
console.log('[Search Results] 过滤后数量:', results.length)
// 防抖:300ms 内只触发一次
if (loadMoreTimer) {
clearTimeout(loadMoreTimer)
}
return results
loadMoreTimer = setTimeout(async () => {
console.log('[Search] 触底加载更多')
// 页码 +1
currentPage.value += 1
// 加载下一页数据
await performSearch(
searchKeyword.value.trim(),
activeTab.value,
currentPage.value,
pageSize,
true // 标记为加载更多
)
}, 300)
})
/**
* Tab 点击处理
* 跳转到产品详情页
*
* @description 处理产品详情按钮点击事件
* @param {number} productId - 产品ID
*/
const onTabClick = (id) => {
activeTabId.value = id
listRenderKey.value += 1
// 自动触发搜索(如果已经搜索过)
if (hasSearched.value) {
console.log('[Search] 切换分类到:', id, '结果数量:', searchResults.value.length)
}
const goToProductDetail = (productId) => {
go('/pages/product-detail/index', { id: productId })
}
// Handle search
const handleSearch = () => {
console.log('[Search handleSearch] 被调用')
console.log('[Search handleSearch] searchKeyword:', searchKeyword.value)
if (searchKeyword.value.trim()) {
hasSearched.value = true
console.log('[Search handleSearch] hasSearched 已设置为 true')
console.log('[Search handleSearch] 搜索关键词:', searchKeyword.value)
console.log('[Search handleSearch] 当前分类:', activeTabId.value)
console.log('[Search handleSearch] 搜索结果数量:', searchResults.value.length)
} else {
console.log('[Search handleSearch] 搜索关键词为空,不执行搜索')
/**
* 打开计划书弹窗
*
* @description 根据产品ID找到对应的产品对象,并打开计划书表单
* @param {number} productId - 产品ID
*/
const openPlanPopup = (productId) => {
// 从产品列表中找到对应的产品
const product = products.value.find(p => p.id === productId)
if (!product) {
Taro.showToast({
title: '产品不存在',
icon: 'none',
duration: 2000
})
return
}
}
// Handle blur
const handleBlur = () => {
console.log('[Search handleBlur] 搜索框失去焦点')
// 可以在这里添加一些失去焦点时的逻辑,比如:
// - 收起键盘
// - 记录搜索日志
// - 其他 UI 状态更新
// 设置选中的产品
selectedProduct.value = product
showPlanPopup.value = true
}
/**
* 清空搜索
* @description 清空搜索关键词并重置到初始状态
* 让用户可以重新开始搜索,在不同分类下显示对应的引导文案
* 处理计划书提交
*
* @description 测试环境:前端不调用后端API,直接跳转到结果页
* 生产环境:需要调用 submitPlanAPI 提交表单数据
* @param {Object} formData - 表单数据
*/
const clearSearch = () => {
console.log('[Search Clear] 清空搜索关键词')
searchKeyword.value = ''
hasSearched.value = false // ✅ 重置到初始状态
listRenderKey.value += 1
console.log('[Search Clear] hasSearched 已重置为 false,显示初始状态')
console.log('[Search Clear] 当前分类:', activeTabId.value)
}
const handlePlanSubmit = (formData) => {
console.log('计划书提交:', {
product_id: selectedProduct.value.id,
product_name: selectedProduct.value.product_name || selectedProduct.value.name,
form_sn: selectedProduct.value.form_sn,
form_data: formData
})
// Go to detail
const goToDetail = (item) => {
if (item.category === 'product') {
go('/pages/product-center/index')
} else {
go('/pages/material-list/index', { title: '搜索结果' })
}
// 关闭弹窗
showPlanPopup.value = false
// TODO: 后端接口还没有准备好,暂时不调用API
// 测试完成后需要对接 submitPlanAPI
// const res = await submitPlanAPI({
// product_id: selectedProduct.value.id,
// template: selectedProduct.value.form_sn,
// form_data: formData
// });
Taro.showToast({
title: `查看${item.type}详情`,
icon: 'none',
duration: 1500
// 模拟提交成功,跳转到结果页面
go('/pages/plan-submit-result/index', {
success: 'true'
})
}
// 初始化数据
initTabsData()
/**
* 监听搜索关键词变化,实现实时搜索
* @description 当用户输入关键词时,自动触发搜索,并标记"已搜索"状态
* 当用户清空关键词时,重置到初始状态
* 处理收藏状态改变
*
* @description 当用户点击收藏按钮时,更新本地状态
* @param {Object} item - 资料对象
* @param {Object} newStatus - 新的状态
*/
watch(searchKeyword, (newVal) => {
if (newVal.trim()) {
// ✅ 用户输入关键词时,标记为"已搜索"
hasSearched.value = true
console.log('[Search Watch] 实时搜索触发,关键词:', newVal)
console.log('[Search Watch] 当前分类:', activeTabId.value)
console.log('[Search Watch] 搜索结果数量:', searchResults.value.length)
console.log('[Search Watch] hasSearched 设置为 true')
} else {
// ✅ 清空关键词时,重置到初始状态
hasSearched.value = false
console.log('[Search Watch] 关键词已清空,重置到初始状态')
const handleCollectChanged = (item, newStatus) => {
console.log('[Search] 收藏状态改变:', item.title, newStatus.collected)
// 找到对应的项并更新状态
const file = files.value.find(f => f.id === item.id)
if (file) {
file.collected = newStatus.collected
}
})
}
</script>
<style lang="less">
......@@ -435,6 +512,25 @@ watch(searchKeyword, (newVal) => {
animation: slideIn 0.3s cubic-bezier(0.2, 0.8, 0.2, 1) backwards;
}
/* 加载动画 */
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading-spinner-small {
width: 32rpx;
height: 32rpx;
border: 3rpx solid #E5E7EB;
border-top-color: #4CAF50;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
// FilterTabs 风格的标签栏
.filter-tabs-wrapper {
display: flex;
......@@ -442,7 +538,7 @@ watch(searchKeyword, (newVal) => {
padding: 24rpx 24rpx;
gap: 24rpx;
transition: all 0.3s ease;
background-color: #F9FAFB;
background-color: #FFF;
width: 100%;
// 隐藏滚动条
......@@ -482,7 +578,7 @@ watch(searchKeyword, (newVal) => {
font-weight: 500;
}
// 覆盖 NutUI Tabs 默认样式,隐藏原有的头部和内容(因为我们使用自定义头部和外部列表)
// 覆盖 NutUI Tabs 默认样式
:deep(.nut-tabs__titles) {
display: none;
}
......
<!--
* @Date: 2026-02-05
* @Description: 本周热门资料页 - 简化版资料列表(无搜索和Tab)
* @Date: 2026-02-06
* @Description: 本周热门资料页 - 使用 MaterialCard 组件
-->
<template>
<view class="h-screen bg-[#F9FAFB] flex flex-col py-[32rpx]">
......@@ -19,57 +19,21 @@
</view>
<view v-else class="flex flex-col gap-[24rpx]">
<view v-for="(item, index) in currentList" :key="item.meta_id"
class="material-item bg-white rounded-[24rpx] p-[24rpx] shadow-md transition-all duration-200 border border-gray-50 flex flex-row"
:style="{ animationDelay: `${index * 50}ms` }">
<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">
<image
:src="getDocumentIcon(item.extension ? `file.${item.extension}` : item.name)"
class="w-[48rpx] h-[48rpx]"
mode="aspectFit"
/>
</view>
<view class="flex-1 min-w-0">
<view class="text-[#1F2937] text-[30rpx] font-bold leading-[1.4] mb-[8rpx] line-clamp-2">
{{ item.name }}
</view>
<!-- 学习人数信息(本周热门特有) -->
<view v-if="item.read_people_count !== undefined" class="flex items-center gap-[12rpx] mb-[16rpx]">
<view class="inline-flex items-center justify-center px-[12rpx] py-[4rpx] bg-orange-50 text-orange-600 text-[20rpx] font-medium rounded-[8rpx]">
<text>{{ item.read_people_count }}人学习</text>
</view>
<!-- 热度百分比 -->
<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]">
<text>{{ item.read_people_percent }}%热度</text>
</view>
</view>
<view class="flex items-center gap-[12rpx] mb-[16rpx]">
<view
class="inline-flex items-center justify-center px-[12rpx] py-[4rpx] bg-gray-100 text-gray-500 text-[20rpx] font-medium rounded-[8rpx]">
{{ getDocumentLabel(item.extension ? `file.${item.extension}` : item.name) }}
</view>
<view class="text-[#9CA3AF] text-[22rpx]">
{{ item.size }}
</view>
</view>
<view class="h-[1rpx] bg-gray-100 my-[20rpx]"></view>
<ListItemActions
:viewable="true"
:collectable="true"
:collected="item.collected"
:item-id="String(item.meta_id)"
@view="onView(item)"
@collect="toggleCollect(item)"
/>
</view>
</view>
<MaterialCard
v-for="(item, index) in currentList"
:key="item.meta_id"
:id="item.meta_id"
:title="item.name"
:file-name="item.name"
:file-size="item.size"
:learners="item.read_people_count ? `${item.read_people_count}人学习` : ''"
:read-people-percent="item.read_people_percent"
:collected="item.collected"
:extension="item.extension"
:download-url="item.downloadUrl"
:style="{ animationDelay: `${index * 50}ms` }"
@collect-changed="handleCollectChanged(item, $event)"
/>
<!-- 空状态 -->
<view v-if="currentList.length === 0 && !loading && !loadingMore">
......@@ -93,14 +57,10 @@
<script setup>
import { ref } from 'vue'
import { useLoad, useReachBottom } from '@tarojs/taro'
import Taro, { useLoad, useReachBottom } from '@tarojs/taro'
import NavHeader from '@/components/NavHeader.vue'
import ListItemActions from '@/components/ListItemActions/index.vue'
import { useListItemClick, ListType } from '@/composables/useListItemClick'
import { getDocumentIcon, getDocumentLabel } from '@/utils/documentIcons'
import MaterialCard from '@/components/MaterialCard.vue'
import { weekHotAPI } from '@/api/file'
import { useCollectOperation } from '@/composables/useCollectOperation'
import Taro from '@tarojs/taro'
const listVisible = ref(true)
const listRenderKey = ref(0)
......@@ -112,26 +72,18 @@ const currentPage = ref(0) // 当前页码(从0开始)
const pageSize = 20 // 每页数量
/**
* 转换文档数据格式
* @description 将 API 返回的文档数据转换为组件需要的格式
* @param {Object} doc - API 返回的文档对象
* @returns {Object} 转换后的文档对象
* 处理收藏状态改变
*
* @description 当用户点击收藏按钮时,更新本地状态
* @param {Object} item - 资料对象
* @param {Object} newStatus - 新的状态
*/
const transformDocItem = (doc) => {
// 处理文件名为空的情况
const fileName = doc.name || '未命名文件'
// 如果没有扩展名,从文件名中提取(如果有)
const extension = doc.extension || fileName.split('.').pop()?.toLowerCase() || ''
return {
meta_id: doc.meta_id,
name: fileName,
size: doc.size || '',
downloadUrl: doc.src,
extension: extension,
collected: doc.is_favorite === '1' || doc.is_favorite === 1 || doc.is_favorite === true,
read_people_count: doc.read_people_count,
read_people_percent: doc.read_people_percent
const handleCollectChanged = (item, newStatus) => {
console.log('[Week Hot] 收藏状态改变:', item.name, newStatus.collected)
// 找到对应的项并更新状态
const material = currentList.value.find(m => m.meta_id === item.meta_id)
if (material) {
material.collected = newStatus.collected
}
}
......@@ -161,7 +113,22 @@ const fetchWeekHotList = async (params = {}, isLoadMore = false) => {
// 处理列表数据
if (res.data.list?.length) {
const listData = res.data.list.map(transformDocItem)
// 直接映射为 MaterialCard 需要的格式
const listData = res.data.list.map(item => {
const fileName = item.name || '未命名文件'
const extension = item.extension || fileName.split('.').pop()?.toLowerCase() || ''
return {
meta_id: item.meta_id,
name: fileName,
size: item.size || '',
downloadUrl: item.src,
extension: extension,
collected: item.is_favorite === '1' || item.is_favorite === 1 || item.is_favorite === true,
read_people_count: item.read_people_count,
read_people_percent: item.read_people_percent
}
})
if (isLoadMore) {
// 加载更多:追加数据
......@@ -248,68 +215,6 @@ useReachBottom(() => {
)
}, 300)
})
/**
* 使用文件列表点击处理器
* @description 添加图片预览功能,点击图片文件时使用 Taro.previewImage
*/
const { handleClick: onView } = useListItemClick({
listType: ListType.FILE,
onBeforeClick: async (item) => {
/**
* 检查文件类型并使用对应的预览方式
* - 图片文件:使用 Taro.previewImage 预览
* - 其他文件:继续默认的文件打开流程
*/
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']
const extension = item.extension?.toLowerCase() || ''
console.log('[Week Hot] 文件类型:', extension, '文件名:', item.name)
if (imageExtensions.includes(extension)) {
// 图片文件:使用 Taro 预览
console.log('[Week Hot] 检测到图片文件,使用图片预览')
// 构建图片列表(当前图片)
const urls = [item.downloadUrl]
try {
// 短暂延迟后打开预览(让用户看到提示)
await new Promise(resolve => setTimeout(resolve, 300))
await Taro.previewImage({
current: item.downloadUrl,
urls: urls
})
// 预览成功,阻止默认的文件打开行为
return false
} catch (err) {
console.error('[Week Hot] 图片预览失败:', err)
Taro.showToast({
title: '图片预览失败',
icon: 'none',
duration: 2000
})
// 预览失败,返回 true 继续默认行为
return true
}
}
// 非图片文件:继续默认的文件打开流程
console.log('[Week Hot] 非图片文件,使用默认打开方式')
return true
},
onAfterClick: (item) => {
console.log('用户打开了资料:', item.name)
}
})
/**
* 切换收藏状态
* @description 使用 useCollectOperation composable 处理收藏操作
*/
const { toggleCollect } = useCollectOperation()
</script>
<style lang="less">
......