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>
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
<!--
* @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">
......