新增首页骨架屏并优化页面体验
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
// 预加载训练数据
|
// 预加载训练数据
|
||||||
|
|||||||
@@ -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)
|
||||||
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)
|
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)
|
||||||
tabNavigating = false
|
// 降级使用 reLaunch
|
||||||
uni.showToast({ title: '页面跳转失败', icon: 'none' })
|
uni.reLaunch({
|
||||||
|
url: path,
|
||||||
|
complete: () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
tabNavigating = false
|
||||||
|
}, 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>
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
"quickapp" : {},
|
"quickapp" : {},
|
||||||
/* 小程序特有相关 */
|
/* 小程序特有相关 */
|
||||||
"mp-weixin" : {
|
"mp-weixin" : {
|
||||||
"appid" : "wx54631042f6754d55",
|
"appid" : "wx8f0d644d1d8985f6",
|
||||||
"setting" : {
|
"setting" : {
|
||||||
"urlCheck" : false
|
"urlCheck" : false
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -263,6 +263,12 @@
|
|||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "搜索课程"
|
"navigationBarTitleText": "搜索课程"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "components/global/GlobalLoading",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"globalStyle": {
|
"globalStyle": {
|
||||||
|
|||||||
@@ -1,31 +1,47 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="home-page">
|
<view class="home-page">
|
||||||
<!-- Banner轮播 -->
|
<!-- 骨架屏 -->
|
||||||
<BannerSwiper />
|
<HomeSkeleton v-if="loading" />
|
||||||
|
|
||||||
<!-- 功能入口 -->
|
<!-- 实际内容 -->
|
||||||
<QuickEntry />
|
<template v-else>
|
||||||
|
<!-- Banner轮播 -->
|
||||||
|
<BannerSwiper />
|
||||||
|
|
||||||
<!-- 推荐课程 -->
|
<!-- 功能入口 -->
|
||||||
<RecommendCourses />
|
<QuickEntry />
|
||||||
|
|
||||||
<!-- 今日推荐 -->
|
<!-- 推荐课程 -->
|
||||||
<TodayRecommend />
|
<RecommendCourses />
|
||||||
|
|
||||||
<!-- 底部占位 -->
|
<!-- 今日推荐 -->
|
||||||
<view class="bottom-placeholder"></view>
|
<TodayRecommend />
|
||||||
|
|
||||||
<!-- TabBar -->
|
<!-- 底部占位 -->
|
||||||
<TabBar />
|
<view class="bottom-placeholder"></view>
|
||||||
|
|
||||||
|
<!-- 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,31 +201,11 @@
|
|||||||
<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>
|
|
||||||
<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>
|
||||||
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -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,103 +353,76 @@ 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
|
||||||
|
|
||||||
// 非测试模式下,在客户端应用筛选条件(测试模式下假数据已过滤)
|
// 0. 过滤已结束和人满的团课
|
||||||
if (!TEST_MODE) {
|
filteredCourses = filteredCourses.filter(course => {
|
||||||
// 0. 过滤已结束和人满的团课
|
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 => {
|
filteredCourses = filteredCourses.filter(course => {
|
||||||
// 过滤已结束的课程
|
const courseName = (course.courseName || '').toLowerCase()
|
||||||
if (course.status === '2') {
|
return courseName.includes(keyword)
|
||||||
return false
|
|
||||||
}
|
|
||||||
// 过滤人满的课程(报名人数 >= 最大人数)
|
|
||||||
if (course.currentMembers && course.maxMembers && course.currentMembers >= course.maxMembers) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 应用关键词搜索
|
// 应用快捷筛选
|
||||||
if (searchKeyword && searchKeyword.trim()) {
|
if (filter !== 'all') {
|
||||||
const keyword = searchKeyword.trim().toLowerCase()
|
filteredCourses = filteredCourses.filter(course => {
|
||||||
filteredCourses = filteredCourses.filter(course => {
|
const courseName = course.courseName || ''
|
||||||
const courseName = (course.courseName || '').toLowerCase()
|
switch (filter) {
|
||||||
return courseName.includes(keyword)
|
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 (filter !== 'all') {
|
if (selectedLevelValue.value) {
|
||||||
filteredCourses = filteredCourses.filter(course => {
|
filteredCourses = filteredCourses.filter(course => course.level === selectedLevelValue.value)
|
||||||
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) {
|
if (selectedDurationValue.value) {
|
||||||
filteredCourses = filteredCourses.filter(course => course.level === selectedLevelValue.value)
|
filteredCourses = filteredCourses.filter(course => {
|
||||||
}
|
const duration = course.duration || 60
|
||||||
|
switch (selectedDurationValue.value) {
|
||||||
// 应用时长筛选
|
case 'short': return duration <= 30
|
||||||
if (selectedDurationValue.value) {
|
case 'medium': return duration > 30 && duration <= 60
|
||||||
filteredCourses = filteredCourses.filter(course => {
|
case 'long': return duration > 60
|
||||||
const duration = course.duration || 60
|
default: return true
|
||||||
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
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 旋转动画 */
|
/* 旋转动画 */
|
||||||
|
|||||||
Reference in New Issue
Block a user