新增首页骨架屏并优化页面体验
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
<!-- App.vue -->
|
||||
<template>
|
||||
<view>
|
||||
<GlobalLoading />
|
||||
</view>
|
||||
</template>
|
||||
<script>
|
||||
import GlobalLoading from '@/components/global/GlobalLoading.vue'
|
||||
export default {
|
||||
onLaunch: function() {
|
||||
console.log('App Launch')
|
||||
@@ -19,16 +24,6 @@ export default {
|
||||
// 预加载课程数据
|
||||
// #ifdef MP-WEIXIN
|
||||
// 小程序端预请求数据
|
||||
uni.request({
|
||||
url: '/api/course/recommend',
|
||||
method: 'GET',
|
||||
success: (res) => {
|
||||
uni.setStorageSync('course_cache', {
|
||||
data: res.data,
|
||||
time: Date.now()
|
||||
})
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
|
||||
// 预加载训练数据
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// common/constants/routes.js
|
||||
|
||||
/** 与 pages.json 保持一致 */
|
||||
export const PAGE = {
|
||||
INDEX: '/pages/index/index',
|
||||
@@ -67,27 +69,61 @@ export function getCurrentRoutePath() {
|
||||
return normalizePath(route ? `/${route}` : PAGE.INDEX)
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到普通页面(非 TabBar 页面)
|
||||
* 使用 navigateTo,保留页面栈,可以正常返回
|
||||
*/
|
||||
export function navigateToPage(url) {
|
||||
uni.showLoading({ title: '加载中...', mask: true })
|
||||
const path = normalizePath(url)
|
||||
|
||||
// ✅ 如果目标是 TabBar 页面,不应该使用 navigateTo
|
||||
// 这种情况应该使用 switchToTabPage(会清空页面栈)
|
||||
if (TAB_PAGES.has(path)) {
|
||||
switchToTab(path)
|
||||
console.warn('[navigateToPage] 不应该用 navigateTo 跳转 TabBar 页面,请使用 switchToTabPage')
|
||||
switchToTabPage(path)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[navigateToPage] 跳转到:', url)
|
||||
|
||||
uni.navigateTo({
|
||||
url,
|
||||
fail: (err) => {
|
||||
console.error('[navigateTo]', url, err)
|
||||
uni.showToast({ title: '页面跳转失败', icon: 'none' })
|
||||
}
|
||||
// 页面栈满时降级使用 redirectTo
|
||||
if (err.errMsg && err.errMsg.includes('limit')) {
|
||||
uni.redirectTo({ url })
|
||||
} else {
|
||||
uni.showToast({ title: '页面跳转失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
success: () => {
|
||||
setTimeout(() => {
|
||||
uni.hideLoading()
|
||||
},3000)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function switchToTab(url) {
|
||||
/**
|
||||
* 跳转到 TabBar 页面(清空页面栈)
|
||||
* 用于从任何页面跳转到首页/课程/训练等 TabBar 页面
|
||||
*/
|
||||
export function switchToTabPage(url) {
|
||||
const path = normalizePath(url)
|
||||
if (!TAB_PAGES.has(path)) {
|
||||
console.warn('[switchToTabPage] 目标不是 TabBar 页面:', path)
|
||||
navigateToPage(url)
|
||||
return
|
||||
}
|
||||
|
||||
if (getCurrentRoutePath() === path || tabNavigating) return
|
||||
|
||||
|
||||
console.log('[switchToTabPage] 跳转到 TabBar:', path)
|
||||
|
||||
tabNavigating = true
|
||||
uni.reLaunch({
|
||||
uni.switchTab({ // ✅ 改用 switchTab,而不是 reLaunch
|
||||
url: path,
|
||||
complete: () => {
|
||||
setTimeout(() => {
|
||||
@@ -95,25 +131,59 @@ export function switchToTab(url) {
|
||||
}, 320)
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('[reLaunch]', path, err)
|
||||
tabNavigating = false
|
||||
uni.showToast({ title: '页面跳转失败', icon: 'none' })
|
||||
console.error('[switchTab]', path, err)
|
||||
// 降级使用 reLaunch
|
||||
uni.reLaunch({
|
||||
url: path,
|
||||
complete: () => {
|
||||
setTimeout(() => {
|
||||
tabNavigating = false
|
||||
}, 320)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function goToMemberCenter() {
|
||||
switchToTab(PAGE.MEMBER)
|
||||
}
|
||||
|
||||
export function goBackOrTab(fallbackUrl = PAGE.MEMBER) {
|
||||
uni.navigateBack({
|
||||
delta: 1,
|
||||
fail: () => switchToTab(fallbackUrl)
|
||||
/**
|
||||
* 重置到 TabBar 页面(清空所有历史)
|
||||
* 用于退出登录、强制跳转等场景
|
||||
*/
|
||||
export function reLaunchToTabPage(url) {
|
||||
const path = normalizePath(url)
|
||||
console.log('[reLaunchToTabPage] 重置到:', path)
|
||||
|
||||
uni.reLaunch({
|
||||
url: path,
|
||||
fail: (err) => {
|
||||
console.error('[reLaunch]', path, err)
|
||||
uni.switchTab({ url: path })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 子页面返回:统一回到 tab 页「个人中心」 */
|
||||
export function backToMemberCenter() {
|
||||
switchToTab(PAGE.MEMBER)
|
||||
/**
|
||||
* 返回上一页,如果没有上一页则跳转到指定 TabBar 页面
|
||||
*/
|
||||
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>
|
||||
@@ -1,6 +1,6 @@
|
||||
<!-- components/TabBar.vue -->
|
||||
<template>
|
||||
<view class="tab-bar">
|
||||
<view v-if="shouldShowTabBar" class="tab-bar">
|
||||
<view
|
||||
v-for="(tab, index) in tabs"
|
||||
:key="tab.path"
|
||||
@@ -19,7 +19,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import {
|
||||
PAGE,
|
||||
TAB_ROUTES,
|
||||
@@ -37,6 +37,28 @@ const emit = defineEmits(['update:active', 'tab-change'])
|
||||
// 当前激活的索引 - 默认从路由获取
|
||||
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
|
||||
function getActiveIndexFromRoute() {
|
||||
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 appRouteCallback = null
|
||||
|
||||
onMounted(() => {
|
||||
// 初始同步
|
||||
syncActiveState()
|
||||
checkShouldShow()
|
||||
|
||||
// 监听路由变化(App 端)
|
||||
// #ifdef APP-PLUS
|
||||
routeWatcher = plus.globalEvent.addEventListener('newintent', () => {
|
||||
setTimeout(syncActiveState, 50)
|
||||
})
|
||||
// App 端:监听页面显示
|
||||
routeWatcher = setInterval(() => {
|
||||
syncActiveState()
|
||||
checkShouldShow()
|
||||
}, 300)
|
||||
// #endif
|
||||
|
||||
// 监听页面显示(跨端通用)
|
||||
uni.onAppRoute ? uni.onAppRoute(() => {
|
||||
setTimeout(syncActiveState, 50)
|
||||
}) : null
|
||||
// #ifdef MP-WEIXIN
|
||||
// 小程序端:监听路由变化
|
||||
if (typeof uni.onAppRoute === 'function') {
|
||||
appRouteCallback = () => {
|
||||
setTimeout(() => {
|
||||
syncActiveState()
|
||||
checkShouldShow()
|
||||
}, 100)
|
||||
}
|
||||
uni.onAppRoute(appRouteCallback)
|
||||
}
|
||||
// #endif
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 清理监听
|
||||
// #ifdef APP-PLUS
|
||||
if (routeWatcher) {
|
||||
plus.globalEvent.removeEventListener('newintent', routeWatcher)
|
||||
clearInterval(routeWatcher)
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
if (appRouteCallback && typeof uni.offAppRoute === 'function') {
|
||||
uni.offAppRoute(appRouteCallback)
|
||||
}
|
||||
// #endif
|
||||
})
|
||||
|
||||
// 监听 props 变化
|
||||
watch(() => props.active, () => {
|
||||
// 只有当 props 主动变化且不是来自路由同步时才更新
|
||||
const routeIndex = getActiveIndexFromRoute()
|
||||
if (routeIndex !== currentActiveIndex.value) {
|
||||
syncActiveState()
|
||||
@@ -179,6 +240,7 @@ function onTabTap(index) {
|
||||
isSwitching = false
|
||||
// 跳转完成后,再次同步确保高亮正确
|
||||
syncActiveState()
|
||||
checkShouldShow()
|
||||
}, 100)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -15,6 +15,17 @@ app.$mount()
|
||||
import { createSSRApp } from 'vue'
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
// 全局混入:所有页面加载时自动隐藏 loading
|
||||
app.mixin({
|
||||
onLoad() {
|
||||
// 页面加载完成,隐藏 loading
|
||||
uni.hideLoading()
|
||||
},
|
||||
|
||||
async onReady() {
|
||||
uni.hideLoading()
|
||||
}
|
||||
})
|
||||
return {
|
||||
app
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
"quickapp" : {},
|
||||
/* 小程序特有相关 */
|
||||
"mp-weixin" : {
|
||||
"appid" : "wx54631042f6754d55",
|
||||
"appid" : "wx8f0d644d1d8985f6",
|
||||
"setting" : {
|
||||
"urlCheck" : false
|
||||
},
|
||||
|
||||
@@ -263,6 +263,12 @@
|
||||
"style": {
|
||||
"navigationBarTitleText": "搜索课程"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "components/global/GlobalLoading",
|
||||
"style": {
|
||||
"navigationBarTitleText": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
|
||||
@@ -1,31 +1,47 @@
|
||||
<template>
|
||||
<view class="home-page">
|
||||
<!-- Banner轮播 -->
|
||||
<BannerSwiper />
|
||||
<view class="home-page">
|
||||
<!-- 骨架屏 -->
|
||||
<HomeSkeleton v-if="loading" />
|
||||
|
||||
<!-- 实际内容 -->
|
||||
<template v-else>
|
||||
<!-- Banner轮播 -->
|
||||
<BannerSwiper />
|
||||
|
||||
<!-- 功能入口 -->
|
||||
<QuickEntry />
|
||||
<!-- 功能入口 -->
|
||||
<QuickEntry />
|
||||
|
||||
<!-- 推荐课程 -->
|
||||
<RecommendCourses />
|
||||
<!-- 推荐课程 -->
|
||||
<RecommendCourses />
|
||||
|
||||
<!-- 今日推荐 -->
|
||||
<TodayRecommend />
|
||||
<!-- 今日推荐 -->
|
||||
<TodayRecommend />
|
||||
|
||||
<!-- 底部占位 -->
|
||||
<view class="bottom-placeholder"></view>
|
||||
<!-- 底部占位 -->
|
||||
<view class="bottom-placeholder"></view>
|
||||
|
||||
<!-- TabBar -->
|
||||
<TabBar />
|
||||
<!-- TabBar -->
|
||||
<TabBar />
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import BannerSwiper from '@/components/index/BannerSwiper.vue'
|
||||
import QuickEntry from '@/components/index/QuickEntry.vue'
|
||||
import RecommendCourses from '@/components/index/RecommendCourses.vue'
|
||||
import TodayRecommend from '@/components/index/TodayRecommend.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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -44,8 +44,9 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- TabBar -->
|
||||
<TabBar />
|
||||
</view>
|
||||
<TabBar :active="4" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* 旋转动画 */
|
||||
|
||||
Reference in New Issue
Block a user