Files
gym-manage/gym-manage-uniapp/pages/searchCourse/searchCourse.vue
T
2026-06-12 15:43:01 +08:00

1391 lines
34 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'
// 测试模式
const TEST_MODE = true
// 搜索关键词
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 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 generateSingleCourse = (seed, filterParams) => {
// 使用确定性随机函数
const random = (n) => {
const x = Math.sin(seed + n) * 10000
return x - Math.floor(x)
}
const prefixes = [
'燃脂', '塑形', '增肌', '柔韧', '平衡', '协调', '爆发', '耐力', '核心', '康复',
'晨练', '午间', '晚训', '周末', '精品', '强化', '入门', '进阶', '突破', '蜕变',
'活力', '轻盈', '力量', '身心', '自由', '挑战', '超越', '燃烧', '唤醒', '放松',
'瑜伽', '普拉提', '舞蹈', '搏击', '骑行', '拉伸', '冥想'
]
const suffixes = [
'HIIT训练', '力量训练', '有氧操', '瑜伽课', '普拉提', '舞蹈课', '搏击操',
'动感单车', '杠铃课', '私教课', '团课', '课程', '训练营', '特训班', '体验课',
'拉伸课', '冥想课', '综合课', '专项课', '公开课'
]
const levels = ['初级', '中级', '高级']
const courseTypes = ['1', '2', '3', '4', '5']
const durations = [20, 25, 30, 35, 40, 45, 50, 55, 60, 70, 80, 90]
// 根据筛选条件调整数据生成
let levelValue = levels[Math.floor(random(1) * levels.length)]
let courseType = courseTypes[Math.floor(random(2) * courseTypes.length)]
let durationValue = durations[Math.floor(random(3) * durations.length)]
let prefix = prefixes[Math.floor(random(4) * prefixes.length)]
let suffix = suffixes[Math.floor(random(5) * suffixes.length)]
// 如果筛选了级别,强制匹配
if (filterParams.level) {
levelValue = filterParams.level
}
// 如果筛选了时长,强制匹配
if (filterParams.duration) {
switch (filterParams.duration) {
case 'short':
durationValue = Math.floor(random(6) * 11) + 20 // 20-30分钟
break
case 'medium':
durationValue = Math.floor(random(7) * 31) + 31 // 31-60分钟
break
case 'long':
durationValue = Math.floor(random(8) * 41) + 61 // 61-100分钟
break
}
}
// 如果筛选了快捷类型,调整课程名称和类型
if (filterParams.filter && filterParams.filter !== 'all') {
switch (filterParams.filter) {
case 'yoga':
prefix = '瑜伽'
suffix = '冥想课'
courseType = '1'
break
case 'strength':
prefix = '力量'
suffix = '训练课'
courseType = '3'
break
case 'cardio':
prefix = '有氧'
suffix = '燃脂课'
courseType = '2'
break
case 'dance':
prefix = '舞蹈'
suffix = '律动课'
courseType = '4'
break
case 'pilates':
prefix = '普拉提'
suffix = '核心课'
courseType = '5'
break
}
}
// 如果有关键词搜索,调整名称
let courseName = `${prefix}${suffix}`
if (filterParams.keyword && filterParams.keyword.trim()) {
if (courseName.includes(filterParams.keyword) || Math.random() > 0.5) {
courseName = `${filterParams.keyword}${courseName}`
}
}
const maxMembers = Math.floor(random(9) * 30) + 10
const currentMembers = Math.floor(random(10) * maxMembers)
const isNew = random(11) > 0.7
return {
id: Math.floor(seed),
courseName: courseName,
coverImage: `https://picsum.photos/id/${Math.floor(random(12) * 100) + 1}/400/300`,
duration: durationValue,
level: levelValue,
currentMembers: currentMembers,
maxMembers: maxMembers,
courseType: courseType,
status: '1',
isNew: isNew
}
}
/**
* 生成随机课程数据(保证每页都有数据)
*/
const generateMockDataForPage = (page, size, filterParams) => {
console.log(`[生成数据] 第 ${page} 页,每页 ${size} 条,筛选条件:`, filterParams)
const coursesData = []
const baseSeed = page * size * 10007 + Date.now()
// 生成指定数量的课程(确保每页都有数据)
for (let i = 0; i < size; i++) {
const seed = baseSeed + i * 12345
const course = generateSingleCourse(seed, filterParams)
coursesData.push(course)
}
// 应用关键词搜索(在已生成数据中过滤)
let filteredCourses = [...coursesData]
if (filterParams.keyword && filterParams.keyword.trim()) {
const kw = filterParams.keyword.trim().toLowerCase()
filteredCourses = filteredCourses.filter(c =>
(c.courseName || '').toLowerCase().includes(kw)
)
}
// 如果关键词过滤后没有数据,生成一些匹配关键词的数据
if (filteredCourses.length === 0 && filterParams.keyword && filterParams.keyword.trim()) {
console.log(`关键词 "${filterParams.keyword}" 无匹配数据,生成匹配数据`)
for (let i = 0; i < size; i++) {
filteredCourses.push({
id: Math.floor(Math.random() * 100000) + page * size + i,
courseName: `${filterParams.keyword}课程${i + 1}`,
coverImage: `https://picsum.photos/id/${(i % 50) + 1}/400/300`,
duration: filterParams.duration === 'short' ? 25 : (filterParams.duration === 'medium' ? 45 : 60),
level: filterParams.level || (i % 3 === 0 ? '初级' : (i % 3 === 1 ? '中级' : '高级')),
currentMembers: Math.floor(Math.random() * 30) + 1,
maxMembers: 30,
courseType: '1',
status: '1',
isNew: true
})
}
}
// 排序
filteredCourses.sort((a, b) => {
switch (filterParams.sort) {
case 'participants': return b.currentMembers - a.currentMembers
case 'startTime': return b.id - a.id
case 'duration': return a.duration - b.duration
default: return a.id - b.id
}
})
// 转换为显示格式
const formattedCourses = filteredCourses.map(c => ({
id: c.id,
image: c.coverImage,
tag: getTag(c),
tagType: getTagType(c),
courseType: getCourseTypeName(c.courseType),
name: c.courseName,
duration: `${c.duration}分钟`,
level: c.level,
participants: c.currentMembers,
rawData: c
}))
// 永远返回 true,实现真正的无限滚动
const hasMoreData = true
console.log(`[生成数据完成] 第 ${page} 页生成了 ${formattedCourses.length} 条数据, hasMore=${hasMoreData}`)
return {
content: formattedCourses,
hasMore: hasMoreData
}
}
// 获取课程列表
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 filterParams = {
keyword: searchKeyword,
filter: filter,
level: selectedLevelValue.value,
duration: selectedDurationValue.value,
sort: selectedSortValue.value
}
if (TEST_MODE) {
// 模拟网络延迟
const delay = 500 + Math.random() * 500
await new Promise(resolve => setTimeout(resolve, delay))
result = generateMockDataForPage(page, size, filterParams)
} else {
// TODO: 替换为真实API调用
await new Promise(resolve => setTimeout(resolve, 500))
result = generateMockDataForPage(page, size, filterParams)
}
if (result && result.content && result.content.length > 0) {
// 课程对象直接包含图片URL
const processedCourses = result.content.map(course => ({
id: course.id,
image: course.image,
tag: course.tag,
tagType: course.tagType,
courseType: course.courseType,
name: course.name,
duration: course.duration,
level: course.level,
participants: course.participants,
rawData: course.rawData
}))
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 = result.hasMore !== false
} else {
// 没有数据时,确保不显示骨架屏
skeletonCount.value = 0
if (!append && courses.value.length === 0) {
// 没有数据时,确保显示空状态,但 hasMore 设为 false 防止无限加载
hasMore.value = false
}
}
} 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
// 加载更多时添加骨架屏
skeletonCount.value += pageSize
fetchCourses(keyword.value, activeFilter.value, nextPage, pageSize, true)
.catch((err) => {
console.error('加载更多失败:', err)
loadingMore.value = false
skeletonCount.value = Math.max(0, skeletonCount.value - pageSize)
})
}
// 清除关键词
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/course/detail?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>