Files
gym-manage/gym-manage-uniapp/pages/searchCourse/searchCourse.vue
T

1221 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="search-page-container">
<!-- 固定头部容器 -->
<view class="fixed-header">
<!-- 搜索栏 -->
<view class="search-bar-wrapper">
<view class="search-bar">
<uni-icons type="search" size="28" color="#94a3b8"></uni-icons>
<input
class="search-input"
type="text"
placeholder="搜索课程名称..."
v-model="keyword"
@confirm="handleSearch"
:focus="isFocus"
@focus="isFocus = true"
@blur="isFocus = false"
/>
<view class="search-actions">
<view class="clear-btn" v-if="keyword" @tap="clearKeyword">
<uni-icons type="clear" size="24" color="#94a3b8"></uni-icons>
</view>
<text class="search-btn" @tap="handleSearch">搜索</text>
</view>
</view>
</view>
<!-- 筛选容器 -->
<view class="filter-container">
<!-- 快捷筛选标签默认显示 -->
<scroll-view scroll-x class="filter-scroll" :show-scrollbar="false">
<view class="filter-tags">
<view
v-for="tag in filterTags"
:key="tag.value"
:class="['filter-tag', { active: activeFilter === tag.value }]"
@tap="activeFilter = tag.value; handleSearch()"
>
{{ tag.label }}
</view>
</view>
</scroll-view>
<!-- 下拉展开箭头 -->
<view class="filter-expand" @tap="toggleExpand">
<view class="expand-content">
<text class="expand-text">{{ showAdvancedFilter ? '收起筛选' : '展开筛选' }}</text>
<uni-icons
type="chevron-down"
size="24"
color="#f97316"
:class="{ expanded: showAdvancedFilter }"
></uni-icons>
</view>
</view>
<!-- 高级筛选内容可折叠 -->
<view class="advanced-content" :class="{ expanded: showAdvancedFilter }">
<!-- 课程级别 -->
<view class="filter-group">
<text class="filter-label">课程级别</text>
<view class="filter-options">
<view
v-for="level in levelOptions"
:key="level.value"
:class="['filter-option', { active: selectedLevelValue === level.value }]"
@tap="toggleLevel(level.value)"
>
{{ level.label }}
</view>
</view>
</view>
<!-- 课程时长 -->
<view class="filter-group">
<text class="filter-label">课程时长</text>
<view class="filter-options">
<view
v-for="duration in durationOptions"
:key="duration.value"
:class="['filter-option', { active: selectedDurationValue === duration.value }]"
@tap="toggleDuration(duration.value)"
>
{{ duration.label }}
</view>
</view>
</view>
<!-- 排序方式 -->
<view class="filter-group">
<text class="filter-label">排序方式</text>
<view class="filter-options">
<view
v-for="sort in sortOptions"
:key="sort.value"
:class="['filter-option', { active: selectedSortValue === sort.value }]"
@tap="setSort(sort.value)"
>
{{ sort.label }}
</view>
</view>
</view>
<!-- 筛选操作按钮 -->
<view class="filter-actions">
<view class="btn-reset" @tap="resetFilters">重置</view>
</view>
</view>
</view>
</view>
<!-- 搜索结果列表 -->
<scroll-view
scroll-y
enhanced
:show-scrollbar="false"
:scroll-with-animation="true"
class="course-scroll"
@scroll="handleScroll"
@scrolltolower="loadMore"
>
<!-- 刷新状态提示 -->
<view class="refresh-hint" v-if="isRefreshing">
<view class="refresh-content">
<view class="refresh-spinner">
<view class="spinner"></view>
</view>
<text class="refresh-text">刷新中...</text>
</view>
</view>
<!-- 课程列表区域 -->
<view class="course-list">
<!-- 真实数据项 -->
<view
v-for="course in courses"
:key="'course-' + course.id"
class="course-card"
@tap="handleCourseClick(course)"
>
<!-- 图片容器 -->
<view class="card-image-wrapper">
<!-- 占位符灰色背景 -->
<view class="card-image-placeholder">
<view class="placeholder-spinner"></view>
</view>
<!-- 实际图片 -->
<image
class="card-image"
:src="course.image"
mode="aspectFill"
lazy-load
></image>
<view class="image-overlay"></view>
<view :class="['course-tag', course.tagType]">{{ course.tag }}</view>
</view>
<view class="card-content">
<text class="course-name">{{ course.name }}</text>
<view class="course-meta">
<view class="meta-item duration">
<uni-icons type="clock" size="20" color="#64748b"></uni-icons>
<text>{{ course.duration }}</text>
</view>
<view class="meta-item level">
<text>{{ course.level }}</text>
</view>
<view class="meta-item course-type">
<text>{{ course.courseType }}</text>
</view>
</view>
<view class="course-footer">
<view class="participants">
<uni-icons type="users" size="20" color="#94a3b8"></uni-icons>
<text class="count">{{ course.participants }}</text>
<text class="label">人已报名</text>
</view>
</view>
</view>
</view>
<!-- 骨架屏项 -->
<view
v-for="i in skeletonCount"
:key="'skeleton-' + i"
class="course-card skeleton-course"
>
<view class="card-image-wrapper">
<view class="skeleton-card-image"></view>
</view>
<view class="card-content">
<view class="skeleton-card-tag"></view>
<view class="skeleton-card-title"></view>
<view class="course-meta">
<view class="skeleton-meta-item duration"></view>
<view class="skeleton-meta-item level"></view>
<view class="skeleton-meta-item course-type"></view>
</view>
<view class="course-footer">
<view class="skeleton-footer"></view>
</view>
</view>
</view>
</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="empty-state" v-if="courses.length === 0 && skeletonCount === 0 && !loading && !loadingMore">
<view class="empty-icon">
<uni-icons type="search" size="80" color="#cbd5e1"></uni-icons>
</view>
<text class="empty-title">未找到相关课程</text>
<text class="empty-desc">试试其他关键词或筛选条件</text>
<view class="empty-action" @tap="resetSearch">重新搜索</view>
</view>
<!-- 已经到底啦 -->
<view class="no-more" v-if="!hasMore && courses.length > 0 && !loadingMore">
<text>已经到底啦~</text>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { onPullDownRefresh } from '@dcloudio/uni-app'
import { getGroupCoursePage } from '@/api/main.js'
// 搜索关键词
const keyword = ref('')
// 当前页
const currentPage = ref(0)
// 每页数量
const pageSize = 10
// 是否有更多数据
const hasMore = ref(true)
// 是否正在加载更多
const loadingMore = ref(false)
// 活动筛选标签
const activeFilter = ref('all')
// 课程列表(不包含图片URL,只包含基本信息)
const courses = ref([])
// 骨架屏数量
const skeletonCount = ref(pageSize)
// 加载状态
const loading = ref(false)
// 搜索框焦点状态
const isFocus = ref(false)
// 是否显示高级筛选
const showAdvancedFilter = ref(false)
// 是否正在下拉刷新
const isRefreshing = ref(false)
// 是否正在滚动
const isScrolling = ref(false)
// 滚动节流定时器
let scrollTimer = null
// 当前筛选参数的签名
let currentFilterSignature = ''
// 高级筛选选项
const selectedLevelValue = ref('')
const selectedDurationValue = ref('')
const selectedSortValue = ref('id')
// 快捷筛选标签
const filterTags = [
{ label: '全部', value: 'all' },
{ label: '瑜伽', value: 'yoga' },
{ label: '力量', value: 'strength' },
{ label: '有氧', value: 'cardio' },
{ label: '舞蹈', value: 'dance' },
{ label: '普拉提', value: 'pilates' }
]
// 课程级别选项
const levelOptions = [
{ label: '初级', value: '初级' },
{ label: '中级', value: '中级' },
{ label: '高级', value: '高级' }
]
// 课程时长选项
const durationOptions = [
{ label: '30分钟内', value: 'short' },
{ label: '30-60分钟', value: 'medium' },
{ label: '60分钟以上', value: 'long' }
]
// 排序选项
const sortOptions = [
{ label: '默认', value: 'id' },
{ label: '最热门', value: 'participants' },
{ label: '最新开课', value: 'startTime' },
{ label: '时长最短', value: 'duration' }
]
// 处理滚动事件
const handleScroll = (e) => {
// 收起高级筛选
if (showAdvancedFilter.value && !isScrolling.value) {
isScrolling.value = true
showAdvancedFilter.value = false
if (scrollTimer) {
clearTimeout(scrollTimer)
}
scrollTimer = setTimeout(() => {
isScrolling.value = false
}, 300)
}
}
// 获取当前筛选参数签名
const getFilterSignature = () => {
return JSON.stringify({
keyword: keyword.value,
filter: activeFilter.value,
level: selectedLevelValue.value,
duration: selectedDurationValue.value,
sort: selectedSortValue.value
})
}
// 检查筛选条件是否变化
const hasFilterChanged = () => {
const newSignature = getFilterSignature()
if (newSignature !== currentFilterSignature) {
currentFilterSignature = newSignature
return true
}
return false
}
// 切换课程级别筛选
const toggleLevel = (level) => {
selectedLevelValue.value = selectedLevelValue.value === level ? '' : level
handleSearch()
}
// 切换课程时长筛选
const toggleDuration = (duration) => {
selectedDurationValue.value = selectedDurationValue.value === duration ? '' : duration
handleSearch()
}
// 设置排序方式
const setSort = (sort) => {
selectedSortValue.value = sort
handleSearch()
}
// 下拉刷新处理
const onRefresh = () => {
if (isRefreshing.value) return
isRefreshing.value = true
currentPage.value = 0
hasMore.value = true
fetchCourses(keyword.value, activeFilter.value, 0, pageSize, false)
.finally(() => {
isRefreshing.value = false
uni.stopPullDownRefresh()
})
}
// 监听下拉刷新
onPullDownRefresh(() => {
onRefresh()
})
/**
* 获取课程类型名称
*/
const getCourseTypeName = (courseType) => {
const typeMap = {
'1': '瑜伽',
'2': '有氧',
'3': '力量',
'4': '舞蹈',
'5': '普拉提'
}
return typeMap[courseType] || '其他'
}
/**
* 计算课程时长
*/
const calculateDuration = (startTime, endTime) => {
if (!startTime || !endTime) return '60分钟'
const start = new Date(startTime)
const end = new Date(endTime)
const diffMs = end - start
if (diffMs <= 0) return ''
const durationMinutes = Math.floor(diffMs / (1000 * 60))
return `${durationMinutes}分钟`
}
/**
* 获取课程标签
*/
const getTag = (course) => {
if (course.status === '2') return '已结束'
if (course.currentMembers >= course.maxMembers * 0.8) return '热门'
if (course.isNew) return '新课'
return '推荐'
}
/**
* 获取标签类型
*/
const getTagType = (course) => {
if (course.status === '2') return 'disabled'
if (course.currentMembers >= course.maxMembers * 0.8) return 'hot'
if (course.isNew) return 'new'
return 'default'
}
// 获取课程列表
const fetchCourses = async (searchKeyword = '', filter = 'all', page = 0, size = 10, append = false) => {
// 防止重复请求
if (loading.value) return
loading.value = true
// 处理骨架屏
if (!append && page === 0) {
skeletonCount.value = size
courses.value = []
}
try {
let result
// 构建请求参数
const params = {
page: page,
size: size,
sort: selectedSortValue.value || 'id',
order: 'desc',
keyword: searchKeyword
}
// 调用真实API
result = await getGroupCoursePage(params)
// 检查是否为最后一页
const isLastPage = result.hasMore === false
if (result && result.content && result.content.length > 0) {
// 筛选掉已结束和已满员的课程
const filteredCourses = result.content.filter(course => {
return course.status !== '2' && course.currentMembers < course.maxMembers
})
// 将后端返回的数据转换为前端需要的格式
const processedCourses = filteredCourses.map(course => ({
id: course.id,
image: course.coverImage || '',
tag: getTag(course),
tagType: getTagType(course),
courseType: getCourseTypeName(course.courseType),
name: course.courseName || '未知课程',
duration: calculateDuration(course.startTime, course.endTime),
level: course.level || '初级',
participants: course.currentMembers || 0,
rawData: course
}))
if (append) {
// 追加模式:逐个添加数据,同时移除骨架屏
for (let i = 0; i < processedCourses.length; i++) {
await Promise.resolve()
courses.value.push(processedCourses[i])
if (skeletonCount.value > 0) {
skeletonCount.value--
}
}
} else {
// 重置模式:直接替换
courses.value = processedCourses
skeletonCount.value = 0
}
// 更新是否有更多数据
hasMore.value = !isLastPage && processedCourses.length > 0
// 如果是最后一页且没有更多数据,显示提示
if (isLastPage) {
uni.showToast({
title: '已经到底了',
icon: 'none',
duration: 2000
})
}
} else {
// 没有数据时,确保不显示骨架屏
skeletonCount.value = 0
hasMore.value = false
// 如果是最后一页,显示提示
if (isLastPage && courses.value.length > 0) {
uni.showToast({
title: '已经到底了',
icon: 'none',
duration: 2000
})
}
}
} catch (err) {
console.error('获取课程失败:', err)
skeletonCount.value = 0
} finally {
loading.value = false
loadingMore.value = false
}
}
// 搜索处理
const handleSearch = () => {
// 重置分页
currentPage.value = 0
hasMore.value = true
loadingMore.value = false
// 更新筛选签名
currentFilterSignature = getFilterSignature()
fetchCourses(keyword.value, activeFilter.value, 0, pageSize, false)
}
// 加载更多
const loadMore = () => {
// 检查是否可以加载更多
if (!hasMore.value) {
console.log('没有更多数据了')
return
}
if (loading.value) {
console.log('正在加载中,跳过')
return
}
if (loadingMore.value) {
console.log('正在加载更多中,跳过')
return
}
console.log('触发加载更多,当前页:', currentPage.value, '已有课程数:', courses.value.length)
loadingMore.value = true
const nextPage = currentPage.value + 1
currentPage.value = nextPage
fetchCourses(keyword.value, activeFilter.value, nextPage, pageSize, true)
.catch((err) => {
console.error('加载更多失败:', err)
loadingMore.value = false
})
}
// 清除关键词
const clearKeyword = () => {
keyword.value = ''
handleSearch()
}
// 重置所有筛选
const resetSearch = () => {
keyword.value = ''
activeFilter.value = 'all'
selectedLevelValue.value = ''
selectedDurationValue.value = ''
selectedSortValue.value = 'id'
showAdvancedFilter.value = false
handleSearch()
}
// 重置高级筛选
const resetFilters = () => {
selectedLevelValue.value = ''
selectedDurationValue.value = ''
selectedSortValue.value = 'id'
handleSearch()
}
// 切换展开/收起
const toggleExpand = () => {
showAdvancedFilter.value = !showAdvancedFilter.value
}
// 课程点击
const handleCourseClick = (course) => {
uni.navigateTo({
url: `/pages/memberInfo/courseDetail?id=${course.id}`
})
}
// 组件挂载时获取数据
onMounted(() => {
currentFilterSignature = getFilterSignature()
fetchCourses()
})
// 组件卸载时清理资源
onUnmounted(() => {
// 清理滚动定时器
if (scrollTimer) {
clearTimeout(scrollTimer)
}
})
</script>
<style lang="scss">
/* 页面容器 */
.search-page-container {
height: 100vh;
background-color: #f0f4f8;
display: flex;
flex-direction: column;
overflow: hidden;
-webkit-overflow-scrolling: touch;
}
/* 固定头部容器 */
.fixed-header {
flex-shrink: 0;
background: #ffffff;
touch-action: none;
}
/* 搜索栏区域 */
.search-bar-wrapper {
padding: 24rpx;
background: #ffffff;
}
/* 筛选容器 */
.filter-container {
width: 100%;
background: #ffffff;
border-radius: 0;
overflow: hidden;
}
/* 搜索栏 */
.search-bar {
display: flex;
align-items: center;
background: #f8fafc;
border-radius: 40rpx;
padding: 0 24rpx;
height: 80rpx;
border: 2rpx solid #e2e8f0;
&:active, &:focus-within {
border-color: #f97316;
box-shadow: 0 0 0 4rpx rgba(249, 115, 22, 0.1);
}
.search-input {
flex: 1;
height: 100%;
font-size: 28rpx;
color: #1a202c;
background: transparent;
margin-left: 16rpx;
}
.search-actions {
display: flex;
align-items: center;
gap: 16rpx;
margin-left: 16rpx;
}
.clear-btn {
width: 40rpx;
height: 40rpx;
display: flex;
align-items: center;
justify-content: center;
}
.search-btn {
font-size: 28rpx;
color: #f97316;
font-weight: 600;
padding: 8rpx 20rpx;
}
}
/* 下拉刷新提示 */
.refresh-hint {
display: flex;
justify-content: center;
align-items: center;
padding: 30rpx 0;
}
.refresh-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
}
.refresh-spinner {
width: 40rpx;
height: 40rpx;
.spinner {
width: 100%;
height: 100%;
border: 4rpx solid #f3f3f3;
border-top: 4rpx solid #f97316;
border-radius: 50%;
animation: spin 1s linear infinite;
}
}
.refresh-text {
font-size: 24rpx;
color: #94a3b8;
}
/* 快捷筛选滚动区域 */
.filter-scroll {
white-space: nowrap;
padding: 20rpx 0;
.filter-tags {
display: inline-flex;
gap: 16rpx;
padding: 0 24rpx;
}
.filter-tag {
display: inline-block;
padding: 16rpx 32rpx;
background: #f8fafc;
border-radius: 40rpx;
font-size: 26rpx;
color: #64748b;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
&.active {
background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
color: #ffffff;
font-weight: 600;
box-shadow: 0 4rpx 12rpx rgba(249, 115, 22, 0.3);
}
&:active {
transform: scale(0.96);
}
}
}
/* 下拉展开按钮 */
.filter-expand {
padding: 16rpx 24rpx;
border-top: 1rpx solid #e2e8f0;
.expand-content {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
.expand-text {
font-size: 26rpx;
color: #64748b;
}
uni-icons {
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
&.expanded {
transform: rotate(180deg);
}
}
}
&:active {
background: #f8fafc;
}
}
/* 高级筛选内容 */
.advanced-content {
height: 0;
overflow: hidden;
opacity: 0;
transition: height 0.25s ease-out, opacity 0.15s ease-out;
background: #ffffff;
will-change: height, opacity;
transform: translateZ(0);
backface-visibility: hidden;
&.expanded {
height: 520rpx;
opacity: 1;
}
.filter-group {
padding: 0 24rpx;
margin-top: 20rpx;
.filter-label {
font-size: 26rpx;
color: #64748b;
margin-bottom: 12rpx;
display: block;
}
.filter-options {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.filter-option {
padding: 12rpx 24rpx;
background: #f8fafc;
border-radius: 24rpx;
font-size: 24rpx;
color: #64748b;
border: 2rpx solid transparent;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
&.active {
background: #fff7ed;
color: #f97316;
border-color: #f97316;
}
&:active {
transform: scale(0.96);
}
}
}
.filter-actions {
display: flex;
gap: 24rpx;
padding: 0 24rpx;
margin-top: 24rpx;
.btn-reset {
flex: 1;
padding: 20rpx;
border-radius: 24rpx;
text-align: center;
font-size: 28rpx;
font-weight: 600;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
background: #f8fafc;
color: #64748b;
&:active {
background: #f1f5f9;
}
}
}
}
/* 课程滚动区域 */
.course-scroll {
flex: 1;
height: 0;
touch-action: pan-y;
-webkit-overflow-scrolling: touch;
}
/* 课程列表 - Grid布局 */
.course-list {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24rpx;
padding: 24rpx;
box-sizing: border-box;
}
/* 课程卡片 */
.course-card {
width: 100%;
background: #ffffff;
border-radius: 24rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
&:active {
transform: scale(0.97);
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.12);
}
.card-image-wrapper {
position: relative;
width: 100%;
padding-top: 70%;
overflow: hidden;
}
.card-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 2; /* 确保图片在占位符之上 */
.course-card:active & {
transform: scale(1.03);
}
}
/* 图片占位符 */
.card-image-placeholder {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #f1f5f9;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
.placeholder-spinner {
width: 40rpx;
height: 40rpx;
border: 4rpx solid #e2e8f0;
border-top-color: #f97316;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.image-overlay {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: linear-gradient(to top, rgba(0,0,0,0.6) 0%, transparent 50%);
}
.course-tag {
position: absolute;
top: 16rpx;
right: 16rpx;
padding: 8rpx 16rpx;
border-radius: 12rpx;
font-size: 20rpx;
font-weight: 600;
color: #ffffff;
z-index: 2;
&.default {
background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
}
&.hot {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
}
&.new {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
}
&.disabled {
background: #94a3b8;
}
}
.card-content {
padding: 20rpx;
.course-name {
font-size: 30rpx;
font-weight: 700;
color: #1a202c;
margin-bottom: 12rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
.course-meta {
display: flex;
gap: 12rpx;
margin-bottom: 16rpx;
flex-wrap: wrap;
.meta-item {
display: flex;
align-items: center;
gap: 6rpx;
padding: 6rpx 12rpx;
border-radius: 8rpx;
&.duration {
background: #f1f5f9;
font-size: 22rpx;
color: #64748b;
}
&.level {
background: #fff7ed;
font-size: 22rpx;
color: #f97316;
font-weight: 600;
}
&.course-type {
background: #ecfdf5;
font-size: 22rpx;
color: #059669;
font-weight: 500;
}
}
}
.course-footer {
.participants {
display: flex;
align-items: center;
gap: 8rpx;
.count {
font-size: 26rpx;
font-weight: 600;
color: #f97316;
}
.label {
font-size: 22rpx;
color: #94a3b8;
}
}
}
}
}
/* 骨架屏样式 */
.skeleton-course {
.card-image-wrapper {
.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;
}
}
.card-content {
.skeleton-card-tag {
display: inline-block;
width: 80rpx;
height: 32rpx;
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-meta-item {
height: 32rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 8rpx;
&.duration {
width: 80rpx;
}
&.level {
width: 60rpx;
}
&.course-type {
width: 60rpx;
}
}
.skeleton-footer {
height: 28rpx;
width: 120rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 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; }
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 48rpx;
.empty-icon {
width: 160rpx;
height: 160rpx;
background: #f1f5f9;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24rpx;
}
.empty-title {
font-size: 32rpx;
color: #64748b;
font-weight: 600;
margin-bottom: 12rpx;
}
.empty-desc {
font-size: 26rpx;
color: #94a3b8;
margin-bottom: 32rpx;
}
.empty-action {
padding: 20rpx 48rpx;
background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
color: #ffffff;
font-size: 28rpx;
font-weight: 600;
border-radius: 40rpx;
&:active {
transform: scale(0.96);
}
}
}
/* 加载更多状态 */
.loading-more-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32rpx;
.loading-spinner {
&.small {
width: 40rpx;
height: 40rpx;
.spinner {
width: 100%;
height: 100%;
border: 4rpx solid #f3f3f3;
border-top: 4rpx solid #f97316;
border-radius: 50%;
animation: spin 1s linear infinite;
}
}
}
.loading-text {
font-size: 24rpx;
color: #94a3b8;
margin-top: 12rpx;
}
}
/* 没有更多数据 */
.no-more {
padding: 32rpx;
text-align: center;
font-size: 24rpx;
color: #cbd5e1;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>