新增首页骨架屏并优化页面体验

This commit is contained in:
future
2026-06-06 13:25:58 +08:00
parent 823d626440
commit be7eabdbb1
10 changed files with 578 additions and 205 deletions
@@ -112,8 +112,10 @@
<!-- 搜索结果列表 -->
<scroll-view
scroll-y
class="course-scroll"
enhanced
:show-scrollbar="false"
:scroll-with-animation="true"
class="course-scroll"
@scroll="handleScroll"
@scrolltolower="loadMore"
>
@@ -127,11 +129,13 @@
</view>
</view>
<!-- 课程列表区域 -->
<view class="course-list">
<!-- 真实数据项 -->
<view
class="course-card"
v-for="course in courses"
:key="course.id"
:key="'course-' + course.id"
class="course-card"
@tap="handleCourseClick(course)"
>
<view class="card-image-wrapper">
@@ -167,10 +171,28 @@
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<view class="empty-state" v-if="courses.length === 0 && !loading">
<!-- 骨架屏项渐进式显示数据回来后逐渐替换 -->
<view
v-for="i in skeletonCount"
:key="'skeleton-' + i"
class="skeleton-course-item"
>
<view class="skeleton-card-image-wrapper">
<view class="skeleton-card-image"></view>
</view>
<view class="skeleton-card-content">
<view class="skeleton-card-tag"></view>
<view class="skeleton-card-title"></view>
<view class="skeleton-card-meta">
<view class="skeleton-meta-item duration"></view>
<view class="skeleton-meta-item level"></view>
<view class="skeleton-meta-item participants"></view>
</view>
</view>
</view>
<!-- 空状态加载完成后且无数据时显示 -->
<view class="empty-state" v-if="courses.length === 0 && skeletonCount === 0 && !loading">
<view class="empty-icon">
<uni-icons type="search" size="80" color="#cbd5e1"></uni-icons>
</view>
@@ -179,31 +201,11 @@
<view class="empty-action" @tap="resetSearch">重新搜索</view>
</view>
<!-- 加载状态 -->
<view class="loading-state" v-if="loading">
<view class="loading-spinner">
<view class="spinner"></view>
</view>
<text class="loading-text">加载中...</text>
</view>
<!-- 加载更多 -->
<view class="load-more" v-if="hasMore && !loading && !loadingMore">
<text>上拉加载更多</text>
</view>
<!-- 加载更多中 -->
<view class="loading-more-state" v-if="loadingMore">
<view class="loading-spinner small">
<view class="spinner"></view>
</view>
<text class="loading-text">加载更多中...</text>
</view>
<!-- 没有更多数据 -->
<view class="no-more" v-if="!hasMore && !loading && !loadingMore">
<text>- 已加载全部课程 -</text>
<!-- 已经到底啦 -->
<view class="no-more" v-if="!hasMore && courses.length > 0">
<text>已经到底啦~</text>
</view>
</view>
</scroll-view>
</view>
</template>
@@ -230,7 +232,9 @@ const loadingMore = ref(false)
const activeFilter = ref('all')
// 课程列表
const courses = ref([])
// 加载状态
// 骨架屏数量(用于渐进式显示)
const skeletonCount = ref(5)
// 加载状态(控制加载提示)
const loading = ref(false)
// 是否已加载过默认数据
const loadedDefaultData = ref(false)
@@ -349,103 +353,76 @@ const fetchCourses = async (searchKeyword = '', filter = 'all', page = 0, size =
loading.value = true
// 首次加载或搜索时显示骨架屏(数量与每页条数一致)
if (page === 0 && !append) {
skeletonCount.value = pageSize
courses.value = []
} else if (append && hasMore.value) {
// 上拉加载时也添加骨架屏(数量与每页条数一致)
skeletonCount.value += pageSize
}
try {
let res
// 测试模式:使用假数据
if (TEST_MODE) {
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 500))
// 生成假数据(传递所有筛选参数)
console.log('筛选参数:', { keyword: searchKeyword, filter, level: selectedLevelValue.value, duration: selectedDurationValue.value, sort: selectedSortValue.value })
res = generateMockData(page, size, filter, selectedLevelValue.value, selectedDurationValue.value, selectedSortValue.value, searchKeyword)
} else {
// 正常模式:使用网络请求
const params = {
page: page,
size: size,
sort: selectedSortValue.value,
order: selectedSortValue.value === 'duration' ? 'asc' : 'desc'
}
// 添加关键词搜索参数
if (searchKeyword && searchKeyword.trim()) {
params.keyword = searchKeyword.trim()
}
res = await getGroupCoursePage(params, { cache: true, cacheTime: 5 * 60 * 1000 })
}
// 仅使用假数据,不请求后端
// 模拟网络延迟:骨架屏显示3秒后再显示数据
await new Promise(resolve => setTimeout(resolve, 3000))
// 生成假数据(传递所有筛选参数)
console.log('筛选参数:', { keyword: searchKeyword, filter, level: selectedLevelValue.value, duration: selectedDurationValue.value, sort: selectedSortValue.value })
res = generateMockData(page, size, filter, selectedLevelValue.value, selectedDurationValue.value, selectedSortValue.value, searchKeyword)
if (res && res.content) {
let filteredCourses = res.content
// 非测试模式下,在客户端应用筛选条件(测试模式下假数据已过滤)
if (!TEST_MODE) {
// 0. 过滤已结束和人满的团课
// 0. 过滤已结束和人满的团课
filteredCourses = filteredCourses.filter(course => {
if (course.status === '2') return false
if (course.currentMembers && course.maxMembers && course.currentMembers >= course.maxMembers) return false
return true
})
// 1. 应用关键词搜索
if (searchKeyword && searchKeyword.trim()) {
const keyword = searchKeyword.trim().toLowerCase()
filteredCourses = filteredCourses.filter(course => {
// 过滤已结束的课程
if (course.status === '2') {
return false
}
// 过滤人满的课程(报名人数 >= 最大人数)
if (course.currentMembers && course.maxMembers && course.currentMembers >= course.maxMembers) {
return false
}
return true
const courseName = (course.courseName || '').toLowerCase()
return courseName.includes(keyword)
})
}
// 应用快捷筛选
if (filter !== 'all') {
filteredCourses = filteredCourses.filter(course => {
const courseName = course.courseName || ''
switch (filter) {
case 'yoga': return courseName.includes('瑜伽') || course.courseType === '1'
case 'strength': return courseName.includes('力量') || courseName.includes('器械') || course.courseType === '3'
case 'cardio': return courseName.includes('有氧') || courseName.includes('动感') || course.courseType === '2'
case 'dance': return courseName.includes('舞蹈') || course.courseType === '4'
case 'pilates': return courseName.includes('普拉提') || course.courseType === '5'
default: return true
}
})
}
// 应用课程级别筛选
if (selectedLevelValue.value) {
filteredCourses = filteredCourses.filter(course => course.level === selectedLevelValue.value)
}
// 应用时长筛选
if (selectedDurationValue.value) {
filteredCourses = filteredCourses.filter(course => {
const duration = course.duration || 60
switch (selectedDurationValue.value) {
case 'short': return duration <= 30
case 'medium': return duration > 30 && duration <= 60
case 'long': return duration > 60
default: return true
}
})
// 1. 应用关键词搜索
if (searchKeyword && searchKeyword.trim()) {
const keyword = searchKeyword.trim().toLowerCase()
filteredCourses = filteredCourses.filter(course => {
const courseName = (course.courseName || '').toLowerCase()
return courseName.includes(keyword)
})
}
// 应用快捷筛选
if (filter !== 'all') {
filteredCourses = filteredCourses.filter(course => {
const courseName = course.courseName || ''
switch (filter) {
case 'yoga':
return courseName.includes('瑜伽') || course.courseType === '1'
case 'strength':
return courseName.includes('力量') || courseName.includes('器械') || course.courseType === '3'
case 'cardio':
return courseName.includes('有氧') || courseName.includes('动感') || course.courseType === '2'
case 'dance':
return courseName.includes('舞蹈') || course.courseType === '4'
case 'pilates':
return courseName.includes('普拉提') || course.courseType === '5'
default:
return true
}
})
}
// 应用课程级别筛选
if (selectedLevelValue.value) {
filteredCourses = filteredCourses.filter(course => course.level === selectedLevelValue.value)
}
// 应用时长筛选
if (selectedDurationValue.value) {
filteredCourses = filteredCourses.filter(course => {
const duration = course.duration || 60
switch (selectedDurationValue.value) {
case 'short':
return duration <= 30
case 'medium':
return duration > 30 && duration <= 60
case 'long':
return duration > 60
default:
return true
}
})
}
}
// 转换数据格式
@@ -462,17 +439,28 @@ const fetchCourses = async (searchKeyword = '', filter = 'all', page = 0, size =
rawData: course
}))
if (append) {
courses.value = [...courses.value, ...formattedCourses]
} else {
courses.value = formattedCourses
// 渐进式替换骨架屏:逐个添加数据,同时减少骨架屏数量
for (let i = 0; i < formattedCourses.length; i++) {
// 使用微任务而非宏任务,避免阻塞滚动
await Promise.resolve()
courses.value.push(formattedCourses[i])
skeletonCount.value = Math.max(0, skeletonCount.value - 1)
}
// 判断是否还有更多数据
hasMore.value = res.content.length >= size
// 判断是否还有更多数据(支持无限滚动,最多100页)
hasMore.value = res.content.length >= size && page < 99
// 如果到达底部,清空骨架屏
if (!hasMore.value) {
skeletonCount.value = 0
}
} else {
// 没有数据时清空骨架屏
skeletonCount.value = 0
}
} catch (err) {
console.error('获取课程失败:', err)
skeletonCount.value = 0
if (!append) {
useDefaultData()
}
@@ -527,11 +515,11 @@ const getLevel = (course) => {
return level
}
// 生成测试假数据
// 生成测试假数据(支持无限滚动)
const generateMockData = (page, size, filter, level = '', duration = '', sort = 'id', keyword = '') => {
const allCourses = [
// 基础课程数据
const baseCourses = [
{
id: 1,
courseName: 'HIIT高强度燃脂',
coverImage: 'https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=400&q=80',
duration: 30,
@@ -543,7 +531,6 @@ const generateMockData = (page, size, filter, level = '', duration = '', sort =
isNew: false
},
{
id: 2,
courseName: '力量进阶训练',
coverImage: 'https://images.unsplash.com/photo-1583454110551-21f2fa2afe61?w=400&q=80',
duration: 45,
@@ -555,7 +542,6 @@ const generateMockData = (page, size, filter, level = '', duration = '', sort =
isNew: false
},
{
id: 3,
courseName: '瑜伽·身心平衡',
coverImage: 'https://images.unsplash.com/photo-1544367567-0f2fcb009e0b?w=400&q=80',
duration: 60,
@@ -567,7 +553,6 @@ const generateMockData = (page, size, filter, level = '', duration = '', sort =
isNew: true
},
{
id: 4,
courseName: '动感单车',
coverImage: 'https://images.unsplash.com/photo-1549880338-65ddcdfd017b?w=400&q=80',
duration: 45,
@@ -579,7 +564,6 @@ const generateMockData = (page, size, filter, level = '', duration = '', sort =
isNew: false
},
{
id: 5,
courseName: '普拉提核心',
coverImage: 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=400&q=80',
duration: 50,
@@ -591,7 +575,6 @@ const generateMockData = (page, size, filter, level = '', duration = '', sort =
isNew: true
},
{
id: 6,
courseName: '有氧舞蹈',
coverImage: 'https://images.unsplash.com/photo-1504384308090-c894fdcc538d?w=400&q=80',
duration: 40,
@@ -603,7 +586,6 @@ const generateMockData = (page, size, filter, level = '', duration = '', sort =
isNew: false
},
{
id: 7,
courseName: '核心训练',
coverImage: 'https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=400&q=80',
duration: 25,
@@ -615,7 +597,6 @@ const generateMockData = (page, size, filter, level = '', duration = '', sort =
isNew: false
},
{
id: 8,
courseName: '冥想放松',
coverImage: 'https://images.unsplash.com/photo-1544367567-0f2fcb009e0b?w=400&q=80',
duration: 60,
@@ -627,7 +608,6 @@ const generateMockData = (page, size, filter, level = '', duration = '', sort =
isNew: true
},
{
id: 9,
courseName: '搏击操',
coverImage: 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=400&q=80',
duration: 45,
@@ -639,7 +619,6 @@ const generateMockData = (page, size, filter, level = '', duration = '', sort =
isNew: false
},
{
id: 10,
courseName: '柔韧性训练',
coverImage: 'https://images.unsplash.com/photo-1583454110551-21f2fa2afe61?w=400&q=80',
duration: 40,
@@ -651,7 +630,6 @@ const generateMockData = (page, size, filter, level = '', duration = '', sort =
isNew: false
},
{
id: 11,
courseName: '高强度间歇',
coverImage: 'https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=400&q=80',
duration: 30,
@@ -663,7 +641,6 @@ const generateMockData = (page, size, filter, level = '', duration = '', sort =
isNew: false
},
{
id: 12,
courseName: '器械力量',
coverImage: 'https://images.unsplash.com/photo-1583454110551-21f2fa2afe61?w=400&q=80',
duration: 55,
@@ -675,7 +652,6 @@ const generateMockData = (page, size, filter, level = '', duration = '', sort =
isNew: true
},
{
id: 13,
courseName: '流瑜伽',
coverImage: 'https://images.unsplash.com/photo-1544367567-0f2fcb009e0b?w=400&q=80',
duration: 70,
@@ -687,7 +663,6 @@ const generateMockData = (page, size, filter, level = '', duration = '', sort =
isNew: false
},
{
id: 14,
courseName: '拉丁舞',
coverImage: 'https://images.unsplash.com/photo-1504384308090-c894fdcc538d?w=400&q=80',
duration: 50,
@@ -699,7 +674,6 @@ const generateMockData = (page, size, filter, level = '', duration = '', sort =
isNew: false
},
{
id: 15,
courseName: '普拉提进阶',
coverImage: 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=400&q=80',
duration: 60,
@@ -712,6 +686,24 @@ const generateMockData = (page, size, filter, level = '', duration = '', sort =
}
]
// 生成无限数据:通过复制基础数据并添加页码后缀来模拟无限滚动
// 支持最多100页,每页10条,总共1000条假数据
const maxPages = 5
const pageOffset = page * size
const allCourses = []
for (let i = 0; i < maxPages * size; i++) {
const baseIndex = i % baseCourses.length
const pageNum = Math.floor(i / baseCourses.length)
const baseCourse = baseCourses[baseIndex]
allCourses.push({
...baseCourse,
id: i + 1,
courseName: `${baseCourse.courseName} ${pageNum + 1}` // 添加页码后缀区分不同页的数据
})
}
// 根据筛选条件过滤
let filteredCourses = allCourses
@@ -1051,15 +1043,13 @@ onMounted(() => {
</script>
<style lang="scss">
/* 页面容器 - 禁止整体滚动 */
/* 页面容器 */
.search-page-container {
height: 100vh;
background-color: #f0f4f8;
display: flex;
flex-direction: column;
overflow: hidden;
/* 阻止默认触摸滚动 */
touch-action: none;
-webkit-overflow-scrolling: touch;
}
@@ -1341,6 +1331,96 @@ onMounted(() => {
margin: 0;
width: 100%;
box-sizing: border-box;
min-height: 120%; /* 确保骨架屏显示时可以滚动 */
}
/* 骨架屏课程项 - 与真实卡片样式一致 */
.skeleton-course-item {
width: calc(50% - 13rpx);
margin-right: 24rpx;
margin-bottom: 24rpx;
background: #ffffff;
border-radius: 24rpx;
overflow: hidden;
box-sizing: border-box;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
&:nth-child(2n) {
margin-right: 0;
}
.skeleton-card-image-wrapper {
position: relative;
width: 100%;
padding-top: 70%;
overflow: hidden;
.skeleton-card-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
}
.skeleton-card-content {
padding: 20rpx;
.skeleton-card-tag {
display: inline-block;
padding: 6rpx 16rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 8rpx;
margin-bottom: 16rpx;
}
.skeleton-card-title {
height: 36rpx;
width: 90%;
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 8rpx;
margin-bottom: 16rpx;
}
.skeleton-card-meta {
display: flex;
align-items: center;
gap: 24rpx;
.skeleton-meta-item {
height: 28rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 8rpx;
&.duration {
width: 100rpx;
}
&.level {
width: 80rpx;
}
&.participants {
width: 120rpx;
}
}
}
}
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* 课程卡片 */
@@ -1611,6 +1691,7 @@ onMounted(() => {
text-align: center;
font-size: 24rpx;
color: #cbd5e1;
margin: auto;
}
/* 旋转动画 */