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

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
+6 -11
View File
@@ -1,5 +1,10 @@
<!-- App.vue --> <template>
<view>
<GlobalLoading />
</view>
</template>
<script> <script>
import GlobalLoading from '@/components/global/GlobalLoading.vue'
export default { export default {
onLaunch: function() { onLaunch: function() {
console.log('App Launch') console.log('App Launch')
@@ -19,16 +24,6 @@ export default {
// 预加载课程数据 // 预加载课程数据
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
// 小程序端预请求数据 // 小程序端预请求数据
uni.request({
url: '/api/course/recommend',
method: 'GET',
success: (res) => {
uni.setStorageSync('course_cache', {
data: res.data,
time: Date.now()
})
}
})
// #endif // #endif
// 预加载训练数据 // 预加载训练数据
+85 -15
View File
@@ -1,3 +1,5 @@
// common/constants/routes.js
/** 与 pages.json 保持一致 */ /** 与 pages.json 保持一致 */
export const PAGE = { export const PAGE = {
INDEX: '/pages/index/index', INDEX: '/pages/index/index',
@@ -67,27 +69,61 @@ export function getCurrentRoutePath() {
return normalizePath(route ? `/${route}` : PAGE.INDEX) return normalizePath(route ? `/${route}` : PAGE.INDEX)
} }
/**
* 跳转到普通页面(非 TabBar 页面)
* 使用 navigateTo,保留页面栈,可以正常返回
*/
export function navigateToPage(url) { export function navigateToPage(url) {
uni.showLoading({ title: '加载中...', mask: true })
const path = normalizePath(url) const path = normalizePath(url)
// ✅ 如果目标是 TabBar 页面,不应该使用 navigateTo
// 这种情况应该使用 switchToTabPage(会清空页面栈)
if (TAB_PAGES.has(path)) { if (TAB_PAGES.has(path)) {
switchToTab(path) console.warn('[navigateToPage] 不应该用 navigateTo 跳转 TabBar 页面,请使用 switchToTabPage')
switchToTabPage(path)
return return
} }
console.log('[navigateToPage] 跳转到:', url)
uni.navigateTo({ uni.navigateTo({
url, url,
fail: (err) => { fail: (err) => {
console.error('[navigateTo]', url, err) console.error('[navigateTo]', url, err)
// 页面栈满时降级使用 redirectTo
if (err.errMsg && err.errMsg.includes('limit')) {
uni.redirectTo({ url })
} else {
uni.showToast({ title: '页面跳转失败', icon: 'none' }) uni.showToast({ title: '页面跳转失败', icon: 'none' })
} }
},
success: () => {
setTimeout(() => {
uni.hideLoading()
},3000)
}
}) })
} }
export function switchToTab(url) { /**
* 跳转到 TabBar 页面(清空页面栈)
* 用于从任何页面跳转到首页/课程/训练等 TabBar 页面
*/
export function switchToTabPage(url) {
const path = normalizePath(url) const path = normalizePath(url)
if (!TAB_PAGES.has(path)) {
console.warn('[switchToTabPage] 目标不是 TabBar 页面:', path)
navigateToPage(url)
return
}
if (getCurrentRoutePath() === path || tabNavigating) return if (getCurrentRoutePath() === path || tabNavigating) return
console.log('[switchToTabPage] 跳转到 TabBar:', path)
tabNavigating = true tabNavigating = true
uni.reLaunch({ uni.switchTab({ // ✅ 改用 switchTab,而不是 reLaunch
url: path, url: path,
complete: () => { complete: () => {
setTimeout(() => { setTimeout(() => {
@@ -95,25 +131,59 @@ export function switchToTab(url) {
}, 320) }, 320)
}, },
fail: (err) => { fail: (err) => {
console.error('[reLaunch]', path, err) console.error('[switchTab]', path, err)
// 降级使用 reLaunch
uni.reLaunch({
url: path,
complete: () => {
setTimeout(() => {
tabNavigating = false tabNavigating = false
uni.showToast({ title: '页面跳转失败', icon: 'none' }) }, 320)
}
})
} }
}) })
} }
export function goToMemberCenter() { /**
switchToTab(PAGE.MEMBER) * 重置到 TabBar 页面(清空所有历史)
} * 用于退出登录、强制跳转等场景
*/
export function reLaunchToTabPage(url) {
const path = normalizePath(url)
console.log('[reLaunchToTabPage] 重置到:', path)
export function goBackOrTab(fallbackUrl = PAGE.MEMBER) { uni.reLaunch({
uni.navigateBack({ url: path,
delta: 1, fail: (err) => {
fail: () => switchToTab(fallbackUrl) console.error('[reLaunch]', path, err)
uni.switchTab({ url: path })
}
}) })
} }
/** 子页面返回:统一回到 tab 页「个人中心」 */ /**
export function backToMemberCenter() { * 返回上一页,如果没有上一页则跳转到指定 TabBar 页面
switchToTab(PAGE.MEMBER) */
export function goBackOrTab(fallbackUrl = PAGE.MEMBER) {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack({ delta: 1 })
} else {
switchToTabPage(fallbackUrl)
}
}
/**
* 子页面返回个人中心
*/
export function backToMemberCenter() {
goBackOrTab(PAGE.MEMBER)
}
/**
* 子页面返回指定 TabBar 页面
*/
export function backToTab(tabUrl) {
goBackOrTab(tabUrl)
} }
@@ -0,0 +1,131 @@
<template>
<SkeletonBase>
<view class="skeleton-banner"></view>
<view class="skeleton-entry">
<view v-for="i in 4" :key="i" class="skeleton-entry-item">
<view class="skeleton-icon"></view>
<view class="skeleton-text"></view>
</view>
</view>
<view class="skeleton-section">
<view class="skeleton-section-title"></view>
<view v-for="i in 3" :key="i" class="skeleton-course-item">
<view class="skeleton-course-img"></view>
<view class="skeleton-course-info">
<view class="skeleton-course-title"></view>
<view class="skeleton-course-desc"></view>
</view>
</view>
</view>
</SkeletonBase>
</template>
<script setup>
import SkeletonBase from './SkeletonBase.vue'
</script>
<style lang="scss" scoped>
.skeleton-banner {
height: 300rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
margin: 20rpx;
border-radius: 20rpx;
}
.skeleton-entry {
display: flex;
justify-content: space-around;
padding: 30rpx 20rpx;
}
.skeleton-entry-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
}
.skeleton-icon {
width: 80rpx;
height: 80rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 40rpx;
}
.skeleton-text {
width: 60rpx;
height: 24rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 12rpx;
}
.skeleton-section {
padding: 20rpx;
}
.skeleton-section-title {
height: 40rpx;
width: 200rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 8rpx;
margin-bottom: 24rpx;
}
.skeleton-course-item {
display: flex;
gap: 20rpx;
margin-bottom: 24rpx;
padding: 20rpx;
background: #fff;
border-radius: 20rpx;
}
.skeleton-course-img {
width: 160rpx;
height: 160rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 16rpx;
}
.skeleton-course-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.skeleton-course-title {
height: 36rpx;
width: 80%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 8rpx;
}
.skeleton-course-desc {
height: 28rpx;
width: 60%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 8rpx;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>
+75 -13
View File
@@ -1,6 +1,6 @@
<!-- components/TabBar.vue --> <!-- components/TabBar.vue -->
<template> <template>
<view class="tab-bar"> <view v-if="shouldShowTabBar" class="tab-bar">
<view <view
v-for="(tab, index) in tabs" v-for="(tab, index) in tabs"
:key="tab.path" :key="tab.path"
@@ -19,7 +19,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue' import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { import {
PAGE, PAGE,
TAB_ROUTES, TAB_ROUTES,
@@ -37,6 +37,28 @@ const emit = defineEmits(['update:active', 'tab-change'])
// 当前激活的索引 - 默认从路由获取 // 当前激活的索引 - 默认从路由获取
const currentActiveIndex = ref(-1) const currentActiveIndex = ref(-1)
// 是否需要显示 TabBar
const shouldShowTabBar = ref(true)
// 不需要显示 TabBar 的页面路径列表(注意:不要带开头的 /)
const HIDE_TABBAR_PAGES = [
'pages/memberInfo/courseList', // 预约课程
'pages/memberInfo/courseDetail', // 课程详情
'pages/memberInfo/booking', // 我的预约
'pages/memberInfo/bodyTestReport', // 体测报告
'pages/groupCourse/list', // 团课列表
'pages/groupCourse/detail', // 团课详情
'pages/searchCourse/searchCourse', // 搜索课程
'pages/checkIn/checkIn', // 会员签到
'pages/memberInfo/myCourses', // 我的课程
'pages/memberInfo/coupons', // 我的优惠券
'pages/memberInfo/points', // 我的积分
'pages/memberInfo/pointsMall', // 积分商城
'pages/memberInfo/referral', // 邀请好友
'pages/memberInfo/userInfo', // 个人信息
'pages/memberInfo/memberCard', // 我的会员卡
]
// 从路由获取当前激活的 tab // 从路由获取当前激活的 tab
function getActiveIndexFromRoute() { function getActiveIndexFromRoute() {
const routePath = getCurrentRoutePath() const routePath = getCurrentRoutePath()
@@ -64,38 +86,77 @@ function syncActiveState() {
} }
} }
// 检查当前页面是否需要隐藏 TabBar
function checkShouldShow() {
let routePath = getCurrentRoutePath()
// 标准化路径:去掉开头的 /
if (routePath.startsWith('/')) {
routePath = routePath.slice(1)
}
// 去掉查询参数(?后面的内容)
if (routePath.includes('?')) {
routePath = routePath.split('?')[0]
}
// 检查是否在隐藏列表中
const shouldHide = HIDE_TABBAR_PAGES.includes(routePath)
shouldShowTabBar.value = !shouldHide
console.log('=== TabBar 显示控制 ===')
console.log('原始路径:', getCurrentRoutePath())
console.log('标准化路径:', routePath)
console.log('是否隐藏:', shouldHide)
console.log('是否显示 TabBar:', shouldShowTabBar.value)
}
// 监听路由变化(页面切换时自动同步) // 监听路由变化(页面切换时自动同步)
let routeWatcher = null let routeWatcher = null
let appRouteCallback = null
onMounted(() => { onMounted(() => {
// 初始同步 // 初始同步
syncActiveState() syncActiveState()
checkShouldShow()
// 监听路由变化(App 端)
// #ifdef APP-PLUS // #ifdef APP-PLUS
routeWatcher = plus.globalEvent.addEventListener('newintent', () => { // App 端:监听页面显示
setTimeout(syncActiveState, 50) routeWatcher = setInterval(() => {
}) syncActiveState()
checkShouldShow()
}, 300)
// #endif // #endif
// 监听页面显示(跨端通用) // #ifdef MP-WEIXIN
uni.onAppRoute ? uni.onAppRoute(() => { // 小程序端:监听路由变化
setTimeout(syncActiveState, 50) if (typeof uni.onAppRoute === 'function') {
}) : null appRouteCallback = () => {
setTimeout(() => {
syncActiveState()
checkShouldShow()
}, 100)
}
uni.onAppRoute(appRouteCallback)
}
// #endif
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
// 清理监听
// #ifdef APP-PLUS // #ifdef APP-PLUS
if (routeWatcher) { if (routeWatcher) {
plus.globalEvent.removeEventListener('newintent', routeWatcher) clearInterval(routeWatcher)
}
// #endif
// #ifdef MP-WEIXIN
if (appRouteCallback && typeof uni.offAppRoute === 'function') {
uni.offAppRoute(appRouteCallback)
} }
// #endif // #endif
}) })
// 监听 props 变化 // 监听 props 变化
watch(() => props.active, () => { watch(() => props.active, () => {
// 只有当 props 主动变化且不是来自路由同步时才更新
const routeIndex = getActiveIndexFromRoute() const routeIndex = getActiveIndexFromRoute()
if (routeIndex !== currentActiveIndex.value) { if (routeIndex !== currentActiveIndex.value) {
syncActiveState() syncActiveState()
@@ -179,6 +240,7 @@ function onTabTap(index) {
isSwitching = false isSwitching = false
// 跳转完成后,再次同步确保高亮正确 // 跳转完成后,再次同步确保高亮正确
syncActiveState() syncActiveState()
checkShouldShow()
}, 100) }, 100)
} }
}) })
+11
View File
@@ -15,6 +15,17 @@ app.$mount()
import { createSSRApp } from 'vue' import { createSSRApp } from 'vue'
export function createApp() { export function createApp() {
const app = createSSRApp(App) const app = createSSRApp(App)
// 全局混入:所有页面加载时自动隐藏 loading
app.mixin({
onLoad() {
// 页面加载完成,隐藏 loading
uni.hideLoading()
},
async onReady() {
uni.hideLoading()
}
})
return { return {
app app
} }
+1 -1
View File
@@ -50,7 +50,7 @@
"quickapp" : {}, "quickapp" : {},
/* */ /* */
"mp-weixin" : { "mp-weixin" : {
"appid" : "wx54631042f6754d55", "appid" : "wx8f0d644d1d8985f6",
"setting" : { "setting" : {
"urlCheck" : false "urlCheck" : false
}, },
+6
View File
@@ -263,6 +263,12 @@
"style": { "style": {
"navigationBarTitleText": "搜索课程" "navigationBarTitleText": "搜索课程"
} }
},
{
"path": "components/global/GlobalLoading",
"style": {
"navigationBarTitleText": ""
}
} }
], ],
"globalStyle": { "globalStyle": {
+17 -1
View File
@@ -1,5 +1,10 @@
<template> <template>
<view class="home-page"> <view class="home-page">
<!-- 骨架屏 -->
<HomeSkeleton v-if="loading" />
<!-- 实际内容 -->
<template v-else>
<!-- Banner轮播 --> <!-- Banner轮播 -->
<BannerSwiper /> <BannerSwiper />
@@ -17,15 +22,26 @@
<!-- TabBar --> <!-- TabBar -->
<TabBar /> <TabBar />
</template>
</view> </view>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue'
import BannerSwiper from '@/components/index/BannerSwiper.vue' import BannerSwiper from '@/components/index/BannerSwiper.vue'
import QuickEntry from '@/components/index/QuickEntry.vue' import QuickEntry from '@/components/index/QuickEntry.vue'
import RecommendCourses from '@/components/index/RecommendCourses.vue' import RecommendCourses from '@/components/index/RecommendCourses.vue'
import TodayRecommend from '@/components/index/TodayRecommend.vue' import TodayRecommend from '@/components/index/TodayRecommend.vue'
import TabBar from '@/components/TabBar.vue' import TabBar from '@/components/TabBar.vue'
import HomeSkeleton from '@/components/Skeleton/HomeSkeleton.vue'
const loading = ref(true)
onMounted(() => {
setTimeout(() => {
loading.value = false
}, 1500)
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -44,8 +44,9 @@
</view> </view>
</view> </view>
</view> </view>
<!-- TabBar -->
<TabBar />
</view> </view>
<TabBar :active="4" />
</template> </template>
<script> <script>
@@ -112,8 +112,10 @@
<!-- 搜索结果列表 --> <!-- 搜索结果列表 -->
<scroll-view <scroll-view
scroll-y scroll-y
class="course-scroll" enhanced
:show-scrollbar="false"
:scroll-with-animation="true" :scroll-with-animation="true"
class="course-scroll"
@scroll="handleScroll" @scroll="handleScroll"
@scrolltolower="loadMore" @scrolltolower="loadMore"
> >
@@ -127,11 +129,13 @@
</view> </view>
</view> </view>
<!-- 课程列表区域 -->
<view class="course-list"> <view class="course-list">
<!-- 真实数据项 -->
<view <view
class="course-card"
v-for="course in courses" v-for="course in courses"
:key="course.id" :key="'course-' + course.id"
class="course-card"
@tap="handleCourseClick(course)" @tap="handleCourseClick(course)"
> >
<view class="card-image-wrapper"> <view class="card-image-wrapper">
@@ -167,10 +171,28 @@
</view> </view>
</view> </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"> <view class="empty-icon">
<uni-icons type="search" size="80" color="#cbd5e1"></uni-icons> <uni-icons type="search" size="80" color="#cbd5e1"></uni-icons>
</view> </view>
@@ -179,30 +201,10 @@
<view class="empty-action" @tap="resetSearch">重新搜索</view> <view class="empty-action" @tap="resetSearch">重新搜索</view>
</view> </view>
<!-- 加载状态 --> <!-- 已经到底啦 -->
<view class="loading-state" v-if="loading"> <view class="no-more" v-if="!hasMore && courses.length > 0">
<view class="loading-spinner"> <text>已经到底啦~</text>
<view class="spinner"></view>
</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> </view>
</scroll-view> </scroll-view>
</view> </view>
@@ -230,7 +232,9 @@ const loadingMore = ref(false)
const activeFilter = ref('all') const activeFilter = ref('all')
// 课程列表 // 课程列表
const courses = ref([]) const courses = ref([])
// 加载状态 // 骨架屏数量(用于渐进式显示)
const skeletonCount = ref(5)
// 加载状态(控制加载提示)
const loading = ref(false) const loading = ref(false)
// 是否已加载过默认数据 // 是否已加载过默认数据
const loadedDefaultData = ref(false) const loadedDefaultData = ref(false)
@@ -349,49 +353,33 @@ const fetchCourses = async (searchKeyword = '', filter = 'all', page = 0, size =
loading.value = true loading.value = true
// 首次加载或搜索时显示骨架屏(数量与每页条数一致)
if (page === 0 && !append) {
skeletonCount.value = pageSize
courses.value = []
} else if (append && hasMore.value) {
// 上拉加载时也添加骨架屏(数量与每页条数一致)
skeletonCount.value += pageSize
}
try { try {
let res let res
// 测试模式:使用假数据 // 仅使用假数据,不请求后端
if (TEST_MODE) { // 模拟网络延迟:骨架屏显示3秒后再显示数据
// 模拟网络延迟 await new Promise(resolve => setTimeout(resolve, 3000))
await new Promise(resolve => setTimeout(resolve, 500))
// 生成假数据(传递所有筛选参数) // 生成假数据(传递所有筛选参数)
console.log('筛选参数:', { keyword: searchKeyword, filter, level: selectedLevelValue.value, duration: selectedDurationValue.value, sort: selectedSortValue.value }) 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) 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 })
}
if (res && res.content) { if (res && res.content) {
let filteredCourses = res.content let filteredCourses = res.content
// 非测试模式下,在客户端应用筛选条件(测试模式下假数据已过滤)
if (!TEST_MODE) {
// 0. 过滤已结束和人满的团课 // 0. 过滤已结束和人满的团课
filteredCourses = filteredCourses.filter(course => { filteredCourses = filteredCourses.filter(course => {
// 过滤已结束的课程 if (course.status === '2') return false
if (course.status === '2') { if (course.currentMembers && course.maxMembers && course.currentMembers >= course.maxMembers) return false
return false
}
// 过滤人满的课程(报名人数 >= 最大人数)
if (course.currentMembers && course.maxMembers && course.currentMembers >= course.maxMembers) {
return false
}
return true return true
}) })
@@ -409,18 +397,12 @@ const fetchCourses = async (searchKeyword = '', filter = 'all', page = 0, size =
filteredCourses = filteredCourses.filter(course => { filteredCourses = filteredCourses.filter(course => {
const courseName = course.courseName || '' const courseName = course.courseName || ''
switch (filter) { switch (filter) {
case 'yoga': case 'yoga': return courseName.includes('瑜伽') || course.courseType === '1'
return courseName.includes('瑜伽') || course.courseType === '1' case 'strength': return courseName.includes('力量') || courseName.includes('器械') || course.courseType === '3'
case 'strength': case 'cardio': return courseName.includes('有氧') || courseName.includes('动感') || course.courseType === '2'
return courseName.includes('力量') || courseName.includes('器械') || course.courseType === '3' case 'dance': return courseName.includes('舞蹈') || course.courseType === '4'
case 'cardio': case 'pilates': return courseName.includes('普拉提') || course.courseType === '5'
return courseName.includes('有氧') || courseName.includes('动感') || course.courseType === '2' default: return true
case 'dance':
return courseName.includes('舞蹈') || course.courseType === '4'
case 'pilates':
return courseName.includes('普拉提') || course.courseType === '5'
default:
return true
} }
}) })
} }
@@ -435,18 +417,13 @@ const fetchCourses = async (searchKeyword = '', filter = 'all', page = 0, size =
filteredCourses = filteredCourses.filter(course => { filteredCourses = filteredCourses.filter(course => {
const duration = course.duration || 60 const duration = course.duration || 60
switch (selectedDurationValue.value) { switch (selectedDurationValue.value) {
case 'short': case 'short': return duration <= 30
return duration <= 30 case 'medium': return duration > 30 && duration <= 60
case 'medium': case 'long': return duration > 60
return duration > 30 && duration <= 60 default: return true
case 'long':
return duration > 60
default:
return true
} }
}) })
} }
}
// 转换数据格式 // 转换数据格式
const formattedCourses = filteredCourses.map(course => ({ const formattedCourses = filteredCourses.map(course => ({
@@ -462,17 +439,28 @@ const fetchCourses = async (searchKeyword = '', filter = 'all', page = 0, size =
rawData: course rawData: course
})) }))
if (append) { // 渐进式替换骨架屏:逐个添加数据,同时减少骨架屏数量
courses.value = [...courses.value, ...formattedCourses] for (let i = 0; i < formattedCourses.length; i++) {
} else { // 使用微任务而非宏任务,避免阻塞滚动
courses.value = formattedCourses await Promise.resolve()
courses.value.push(formattedCourses[i])
skeletonCount.value = Math.max(0, skeletonCount.value - 1)
} }
// 判断是否还有更多数据 // 判断是否还有更多数据(支持无限滚动,最多100页)
hasMore.value = res.content.length >= size hasMore.value = res.content.length >= size && page < 99
// 如果到达底部,清空骨架屏
if (!hasMore.value) {
skeletonCount.value = 0
}
} else {
// 没有数据时清空骨架屏
skeletonCount.value = 0
} }
} catch (err) { } catch (err) {
console.error('获取课程失败:', err) console.error('获取课程失败:', err)
skeletonCount.value = 0
if (!append) { if (!append) {
useDefaultData() useDefaultData()
} }
@@ -527,11 +515,11 @@ const getLevel = (course) => {
return level return level
} }
// 生成测试假数据 // 生成测试假数据(支持无限滚动)
const generateMockData = (page, size, filter, level = '', duration = '', sort = 'id', keyword = '') => { const generateMockData = (page, size, filter, level = '', duration = '', sort = 'id', keyword = '') => {
const allCourses = [ // 基础课程数据
const baseCourses = [
{ {
id: 1,
courseName: 'HIIT高强度燃脂', courseName: 'HIIT高强度燃脂',
coverImage: 'https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=400&q=80', coverImage: 'https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=400&q=80',
duration: 30, duration: 30,
@@ -543,7 +531,6 @@ const generateMockData = (page, size, filter, level = '', duration = '', sort =
isNew: false isNew: false
}, },
{ {
id: 2,
courseName: '力量进阶训练', courseName: '力量进阶训练',
coverImage: 'https://images.unsplash.com/photo-1583454110551-21f2fa2afe61?w=400&q=80', coverImage: 'https://images.unsplash.com/photo-1583454110551-21f2fa2afe61?w=400&q=80',
duration: 45, duration: 45,
@@ -555,7 +542,6 @@ const generateMockData = (page, size, filter, level = '', duration = '', sort =
isNew: false isNew: false
}, },
{ {
id: 3,
courseName: '瑜伽·身心平衡', courseName: '瑜伽·身心平衡',
coverImage: 'https://images.unsplash.com/photo-1544367567-0f2fcb009e0b?w=400&q=80', coverImage: 'https://images.unsplash.com/photo-1544367567-0f2fcb009e0b?w=400&q=80',
duration: 60, duration: 60,
@@ -567,7 +553,6 @@ const generateMockData = (page, size, filter, level = '', duration = '', sort =
isNew: true isNew: true
}, },
{ {
id: 4,
courseName: '动感单车', courseName: '动感单车',
coverImage: 'https://images.unsplash.com/photo-1549880338-65ddcdfd017b?w=400&q=80', coverImage: 'https://images.unsplash.com/photo-1549880338-65ddcdfd017b?w=400&q=80',
duration: 45, duration: 45,
@@ -579,7 +564,6 @@ const generateMockData = (page, size, filter, level = '', duration = '', sort =
isNew: false isNew: false
}, },
{ {
id: 5,
courseName: '普拉提核心', courseName: '普拉提核心',
coverImage: 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=400&q=80', coverImage: 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=400&q=80',
duration: 50, duration: 50,
@@ -591,7 +575,6 @@ const generateMockData = (page, size, filter, level = '', duration = '', sort =
isNew: true isNew: true
}, },
{ {
id: 6,
courseName: '有氧舞蹈', courseName: '有氧舞蹈',
coverImage: 'https://images.unsplash.com/photo-1504384308090-c894fdcc538d?w=400&q=80', coverImage: 'https://images.unsplash.com/photo-1504384308090-c894fdcc538d?w=400&q=80',
duration: 40, duration: 40,
@@ -603,7 +586,6 @@ const generateMockData = (page, size, filter, level = '', duration = '', sort =
isNew: false isNew: false
}, },
{ {
id: 7,
courseName: '核心训练', courseName: '核心训练',
coverImage: 'https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=400&q=80', coverImage: 'https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=400&q=80',
duration: 25, duration: 25,
@@ -615,7 +597,6 @@ const generateMockData = (page, size, filter, level = '', duration = '', sort =
isNew: false isNew: false
}, },
{ {
id: 8,
courseName: '冥想放松', courseName: '冥想放松',
coverImage: 'https://images.unsplash.com/photo-1544367567-0f2fcb009e0b?w=400&q=80', coverImage: 'https://images.unsplash.com/photo-1544367567-0f2fcb009e0b?w=400&q=80',
duration: 60, duration: 60,
@@ -627,7 +608,6 @@ const generateMockData = (page, size, filter, level = '', duration = '', sort =
isNew: true isNew: true
}, },
{ {
id: 9,
courseName: '搏击操', courseName: '搏击操',
coverImage: 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=400&q=80', coverImage: 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=400&q=80',
duration: 45, duration: 45,
@@ -639,7 +619,6 @@ const generateMockData = (page, size, filter, level = '', duration = '', sort =
isNew: false isNew: false
}, },
{ {
id: 10,
courseName: '柔韧性训练', courseName: '柔韧性训练',
coverImage: 'https://images.unsplash.com/photo-1583454110551-21f2fa2afe61?w=400&q=80', coverImage: 'https://images.unsplash.com/photo-1583454110551-21f2fa2afe61?w=400&q=80',
duration: 40, duration: 40,
@@ -651,7 +630,6 @@ const generateMockData = (page, size, filter, level = '', duration = '', sort =
isNew: false isNew: false
}, },
{ {
id: 11,
courseName: '高强度间歇', courseName: '高强度间歇',
coverImage: 'https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=400&q=80', coverImage: 'https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=400&q=80',
duration: 30, duration: 30,
@@ -663,7 +641,6 @@ const generateMockData = (page, size, filter, level = '', duration = '', sort =
isNew: false isNew: false
}, },
{ {
id: 12,
courseName: '器械力量', courseName: '器械力量',
coverImage: 'https://images.unsplash.com/photo-1583454110551-21f2fa2afe61?w=400&q=80', coverImage: 'https://images.unsplash.com/photo-1583454110551-21f2fa2afe61?w=400&q=80',
duration: 55, duration: 55,
@@ -675,7 +652,6 @@ const generateMockData = (page, size, filter, level = '', duration = '', sort =
isNew: true isNew: true
}, },
{ {
id: 13,
courseName: '流瑜伽', courseName: '流瑜伽',
coverImage: 'https://images.unsplash.com/photo-1544367567-0f2fcb009e0b?w=400&q=80', coverImage: 'https://images.unsplash.com/photo-1544367567-0f2fcb009e0b?w=400&q=80',
duration: 70, duration: 70,
@@ -687,7 +663,6 @@ const generateMockData = (page, size, filter, level = '', duration = '', sort =
isNew: false isNew: false
}, },
{ {
id: 14,
courseName: '拉丁舞', courseName: '拉丁舞',
coverImage: 'https://images.unsplash.com/photo-1504384308090-c894fdcc538d?w=400&q=80', coverImage: 'https://images.unsplash.com/photo-1504384308090-c894fdcc538d?w=400&q=80',
duration: 50, duration: 50,
@@ -699,7 +674,6 @@ const generateMockData = (page, size, filter, level = '', duration = '', sort =
isNew: false isNew: false
}, },
{ {
id: 15,
courseName: '普拉提进阶', courseName: '普拉提进阶',
coverImage: 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=400&q=80', coverImage: 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=400&q=80',
duration: 60, 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 let filteredCourses = allCourses
@@ -1051,15 +1043,13 @@ onMounted(() => {
</script> </script>
<style lang="scss"> <style lang="scss">
/* 页面容器 - 禁止整体滚动 */ /* 页面容器 */
.search-page-container { .search-page-container {
height: 100vh; height: 100vh;
background-color: #f0f4f8; background-color: #f0f4f8;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
/* 阻止默认触摸滚动 */
touch-action: none;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
@@ -1341,6 +1331,96 @@ onMounted(() => {
margin: 0; margin: 0;
width: 100%; width: 100%;
box-sizing: border-box; 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; text-align: center;
font-size: 24rpx; font-size: 24rpx;
color: #cbd5e1; color: #cbd5e1;
margin: auto;
} }
/* 旋转动画 */ /* 旋转动画 */