hookehuyr

feat(material): 完成本周热门资料功能并优化资料列表显示

新增功能:
- 创建本周热门资料详情页面,复用资料列表样式
- 学习人数和热度标签统一显示样式
- 添加文件大小显示

样式优化:
- 统一 tag 样式(padding、圆角、字体大小)
- 加强卡片阴影效果(shadow-sm → shadow-md)
- 添加多行文本省略样式支持(index、week-hot-material、knowledge-base)

Bug 修复:
- 修复收藏状态判断逻辑,支持布尔值 true
- 移除学习人数标签中的火焰图标(避免重复)

页面路由:
- 注册 week-hot-material 页面路由
- 首页"查看更多"链接指向新页面

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
......@@ -17,6 +17,7 @@ const pages = [
'pages/product-detail/index',
'pages/category-list/index',
'pages/material-list/index',
'pages/week-hot-material/index',
'pages/signing/index',
'pages/mine/index',
'pages/plan/index',
......
......@@ -93,10 +93,10 @@
</view>
<!-- Hot Materials -->
<view v-if="hotMaterials.length > 0" class="bg-white rounded-[32rpx] shadow-sm p-[32rpx] mb-[48rpx]">
<view v-if="hotMaterials.length > 0" class="bg-white rounded-[32rpx] shadow-md p-[32rpx] mb-[48rpx]">
<view class="flex justify-between items-center mb-[24rpx]">
<text class="text-gray-900 text-[32rpx] font-bold">本周热门资料</text>
<view class="flex items-center text-blue-600" @tap="go('/pages/material-list/index')">
<view class="flex items-center text-blue-600" @tap="go('/pages/week-hot-material/index')">
<text class="text-[26rpx] mr-[4rpx]">查看更多</text>
<IconFont name="rectRight" size="12" />
</view>
......@@ -106,7 +106,7 @@
<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] border border-gray-50">
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">
......@@ -115,17 +115,26 @@
<!-- 内容区域 -->
<view class="flex-1 min-w-0">
<text class="text-[#1F2937] text-[30rpx] font-bold leading-[1.4] line-clamp-2 mb-[8rpx]">
<view class="text-[#1F2937] text-[30rpx] font-bold leading-[1.4] mb-[8rpx] line-clamp-2">
{{ item.title }}
</text>
</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>
<text class="text-[#9CA3AF] text-[22rpx]">{{ item.learners }}</text>
<!-- 学习人数比例 -->
<view v-if="item.readPeoplePercent !== undefined && item.readPeoplePercent !== null" class="inline-flex items-center px-[8rpx] py-[2rpx] bg-green-50 text-green-600 text-[20rpx] font-medium rounded-[6rpx]">
<text>{{ item.readPeoplePercent }}%热度</text>
<view v-if="item.fileSize" class="text-[#9CA3AF] text-[22rpx]">
{{ item.fileSize }}
</view>
</view>
......@@ -388,3 +397,15 @@ 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>
......
......@@ -411,4 +411,14 @@ useReachBottom(() => {
:deep(.nut-tabs__content) {
display: none;
}
/* 多行文本省略 */
.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-02-05
* @Description: 本周热门资料页面配置
*/
export default {
navigationBarTitleText: '本周热门资料',
enablePullDownRefresh: true,
backgroundColor: '#F9FAFB',
navigationStyle: 'custom'
}
<!--
* @Date: 2026-02-05
* @Description: 本周热门资料页 - 简化版资料列表(无搜索和Tab)
-->
<template>
<view class="h-screen bg-[#F9FAFB] flex flex-col py-[32rpx]">
<NavHeader title="本周热门资料" />
<!-- 列表容器 -->
<view
v-if="listVisible"
:key="listRenderKey"
class="flex-1 min-h-0 overflow-y-auto px-[32rpx] pb-[calc(160rpx+env(safe-area-inset-bottom))] box-border"
>
<!-- 加载状态 -->
<view v-if="loading && currentList.length === 0" class="flex items-center justify-center py-[60rpx]">
<view class="loading-spinner"></view>
<text class="ml-[16rpx] text-[#9CA3AF] text-[28rpx]">加载中...</text>
</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>
<!-- 空状态 -->
<view v-if="currentList.length === 0 && !loading && !loadingMore">
<nut-empty description="暂无热门资料" image="empty" />
</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>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { 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 { weekHotAPI } from '@/api/file'
import { useCollectOperation } from '@/composables/useCollectOperation'
import Taro from '@tarojs/taro'
const listVisible = ref(true)
const listRenderKey = ref(0)
const loading = ref(false)
const loadingMore = ref(false) // 加载更多状态
const hasMore = ref(true) // 是否还有更多数据
const currentList = ref([])
const currentPage = ref(0) // 当前页码(从0开始)
const pageSize = 20 // 每页数量
/**
* 转换文档数据格式
* @description 将 API 返回的文档数据转换为组件需要的格式
* @param {Object} doc - API 返回的文档对象
* @returns {Object} 转换后的文档对象
*/
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
}
}
/**
* 获取本周热门资料列表
* @param {Object} params - 请求参数
* @param {number} params.page - 页码(从0开始)
* @param {number} params.limit - 每页数量
* @param {boolean} params.isLoadMore - 是否为加载更多
*/
const fetchWeekHotList = async (params = {}, isLoadMore = false) => {
try {
// 如果是加载更多,使用 loadingMore 状态,否则使用 loading 状态
if (isLoadMore) {
loadingMore.value = true
} else {
loading.value = true
}
console.log('[Week Hot] 请求参数:', params)
// 调用接口
const res = await weekHotAPI(params)
if (res.code === 1 && res.data) {
console.log('[Week Hot] 数据:', res.data)
// 处理列表数据
if (res.data.list?.length) {
const listData = res.data.list.map(transformDocItem)
if (isLoadMore) {
// 加载更多:追加数据
currentList.value = [...currentList.value, ...listData]
} else {
// 首次加载或刷新:替换数据
currentList.value = listData
}
// 判断是否还有更多数据
// 如果返回的数据量少于请求的量,说明没有更多了
hasMore.value = listData.length >= params.limit
} else {
// 没有数据了
if (isLoadMore) {
hasMore.value = false
} else {
currentList.value = []
}
}
} else {
Taro.showToast({
title: res.msg || '获取热门资料失败',
icon: 'none',
duration: 2000
})
}
} catch (error) {
console.error('[Week Hot] 获取热门资料失败:', error)
Taro.showToast({
title: '加载失败',
icon: 'error',
duration: 2000
})
} finally {
if (isLoadMore) {
loadingMore.value = false
} else {
loading.value = false
}
}
}
/**
* 页面加载时获取数据
*/
useLoad(async (options) => {
console.log('[Week Hot] 页面参数:', options)
// 重置分页状态
currentPage.value = 0
hasMore.value = true
// 获取本周热门资料列表
await fetchWeekHotList({ page: 0, limit: pageSize })
})
/**
* 触底加载更多
* @description 使用防抖避免频繁触发
*/
let loadMoreTimer = null
useReachBottom(() => {
// 如果正在加载或没有更多数据,不执行
if (loadingMore.value || !hasMore.value) {
return
}
// 防抖:300ms 内只触发一次
if (loadMoreTimer) {
clearTimeout(loadMoreTimer)
}
loadMoreTimer = setTimeout(async () => {
console.log('[Week Hot] 触底加载更多')
// 页码 +1
currentPage.value += 1
// 加载下一页数据
await fetchWeekHotList(
{ page: currentPage.value, limit: pageSize },
true // 标记为加载更多
)
}, 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 {
// 预览前提示用户可以长按保存
Taro.showToast({
title: '点击图片预览,长按可保存到相册',
icon: 'none',
duration: 2000
})
// 短暂延迟后打开预览(让用户看到提示)
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">
/* 列表项进入动画 */
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.material-item {
animation: slideIn 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) backwards;
}
/* 加载动画 */
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading-spinner {
width: 40rpx;
height: 40rpx;
border: 4rpx solid #E5E7EB;
border-top-color: #4CAF50;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-spinner-small {
width: 32rpx;
height: 32rpx;
border: 3rpx solid #E5E7EB;
border-top-color: #4CAF50;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* 多行文本省略 */
.line-clamp-2 {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
word-break: break-all;
}
</style>