520 lines
24 KiB
Vue
520 lines
24 KiB
Vue
<template>
|
||
<view class="search-page">
|
||
<view class="header-wrap" :style="{ paddingTop: statusBarHeight + 'px' }">
|
||
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
|
||
<text class="nav-title">⌕ 团课搜索</text>
|
||
</view>
|
||
<view class="search-area">
|
||
<view class="search-box">
|
||
<text class="search-symbol">⌕</text>
|
||
<input class="search-input" v-model="searchKeyword" placeholder="搜索课程名称、教练、场地..."
|
||
placeholder-style="color: rgba(255,255,255,0.35);" @input="onSearchInput" />
|
||
<text v-if="searchKeyword" class="clear-btn" @click="clearSearch">✕</text>
|
||
</view>
|
||
|
||
<view class="date-range-row">
|
||
<picker mode="date" :value="startDate" @change="onStartDateChange" :end="endDate || '2099-12-31'">
|
||
<view class="date-range-item">
|
||
<text class="range-label">开始</text>
|
||
<text class="range-value" :class="{ placeholder: !startDate }">{{ startDate || '选择日期' }}</text>
|
||
</view>
|
||
</picker>
|
||
<text class="range-sep">至</text>
|
||
<picker mode="date" :value="endDate" @change="onEndDateChange" :start="startDate || '2020-01-01'">
|
||
<view class="date-range-item">
|
||
<text class="range-label">结束</text>
|
||
<text class="range-value" :class="{ placeholder: !endDate }">{{ endDate || '选择日期' }}</text>
|
||
</view>
|
||
</picker>
|
||
<text v-if="startDate || endDate" class="date-clear" @click="clearDateRange">✕</text>
|
||
</view>
|
||
|
||
<view class="period-row">
|
||
<view v-for="(period, idx) in periods" :key="idx" class="period-item"
|
||
:class="{ active: currentPeriod === period.value }" @click="selectPeriod(period.value)">
|
||
<text>{{ period.label }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="type-section">
|
||
<view class="type-all-row">
|
||
<view class="filter-tab" :class="{ active: currentFilter === 'all' }" @click="switchFilter('all')">全部</view>
|
||
</view>
|
||
<view class="type-grid" :class="{ expanded: typesExpanded }">
|
||
<view v-for="(row, ri) in typeRows" :key="ri" class="type-row" :class="{ 'row-visible': isRowVisible(ri) }">
|
||
<view v-for="(tab, ti) in row" :key="ti" class="filter-tab"
|
||
:class="{ active: currentFilter === tab.value }" @click="selectType(tab)">
|
||
{{ tab.label }}
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view v-if="typeRows.length > 1" class="type-expand-row">
|
||
<text class="type-expand-btn" @click="typesExpanded = !typesExpanded">
|
||
{{ typesExpanded ? '收起 ▲' : '展开更多 ▾' }}
|
||
</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="sort-bar">
|
||
<text class="result-count">共 {{ filteredCourses.length }} 个课程</text>
|
||
<view class="sort-options">
|
||
<text v-for="(sort, idx) in sortOptions" :key="idx" class="sort-item"
|
||
:class="{ active: currentSort === sort.value }" @click="switchSort(sort.value)">
|
||
{{ sort.label }}
|
||
</text>
|
||
</view>
|
||
</view>
|
||
|
||
<scroll-view class="course-list" scroll-y :style="{ height: 'calc(100vh - ' + totalHeaderHeight + 'px - 104px)' }">
|
||
<view class="course-list-inner">
|
||
<view v-for="(course, idx) in filteredCourses" :key="idx" class="course-item" @click="goToDetail(course)">
|
||
<view class="course-card">
|
||
<view class="course-cover">
|
||
<view class="cover-skeleton" v-if="course.coverImage && !course.coverLoaded && !course.coverError"></view>
|
||
<image v-if="course.coverImage" class="cover-img" :class="{ loaded: course.coverLoaded }" :src="course.coverImage" mode="aspectFill" @load="onCoverLoad(course)" @error="onCoverError(course)"></image>
|
||
<view class="cover-placeholder" v-if="(!course.coverImage || course.coverError)">
|
||
<text class="cover-icon">⛫</text>
|
||
<text class="cover-type-name">{{ course.typeName }}</text>
|
||
</view>
|
||
<view class="cover-overlay">
|
||
<view class="cover-badge diff-badge" :class="'diff-' + course.difficultyLevel">
|
||
<text class="diff-badge-star" v-for="s in course.difficultyStars" :key="s">★</text>
|
||
<text class="diff-badge-label">{{ course.difficultyLabel }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view class="course-body">
|
||
<view class="course-top">
|
||
<text class="course-name">{{ course.courseName }}</text>
|
||
<text class="course-price" :class="{ free: course.storedValueAmount === 0 }">
|
||
{{ course.storedValueAmount > 0 ? '¥' + course.storedValueAmount + '/次' : '免费' }}
|
||
</text>
|
||
</view>
|
||
<view class="course-labels" v-if="course.labels && course.labels.length">
|
||
<text v-for="(label, li) in course.labels" :key="li" class="course-label"
|
||
:style="{ background: label.color + '1a', color: label.color }">{{ label.labelName }}</text>
|
||
</view>
|
||
|
||
<view class="course-meta">
|
||
<text class="meta-icon">🕓</text>
|
||
<text class="course-date">{{ course.shortDate }}</text>
|
||
<text class="course-time">{{ course.shortTime }} - {{ course.endShortTime }}</text>
|
||
<text class="course-duration" v-if="course.duration">{{ course.duration }}</text>
|
||
</view>
|
||
<view class="course-meta">
|
||
<text class="meta-icon">📍</text>
|
||
<text class="course-location">{{ course.location }}</text>
|
||
</view>
|
||
<view class="course-bottom">
|
||
<text class="course-capacity">已预约 {{ course.currentMembers }}/{{ course.maxMembers }}人</text>
|
||
<button class="btn-book" :class="{ disabled: !course.canBook }" :disabled="!course.canBook"
|
||
@click.stop="bookCourse(course)">
|
||
{{ course.canBook ? '立即预约' : course.statusLabel }}
|
||
</button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="filteredCourses.length === 0" class="empty-state">
|
||
<text class="empty-symbol">⊙</text>
|
||
<text class="empty-text">暂无匹配的课程</text>
|
||
</view>
|
||
|
||
<view class="bottom-safe"></view>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
const courseApi = require('../../api/course')
|
||
const bookingApi = require('../../api/booking')
|
||
const store = require('../../store/index')
|
||
const { resolveCoverUrl } = require('../../utils/request')
|
||
|
||
export default {
|
||
data() {
|
||
return {
|
||
statusBarHeight: 0,
|
||
navBarHeight: 44,
|
||
totalHeaderHeight: 44,
|
||
loading: true,
|
||
searchKeyword: '', startDate: '', endDate: '',
|
||
currentPeriod: 'all', currentFilter: 'all', currentSort: 'time',
|
||
periods: [
|
||
{ label: '全部', value: 'all' },
|
||
{ label: '早晨', value: 'morning' },
|
||
{ label: '上午', value: 'forenoon' },
|
||
{ label: '下午', value: 'afternoon' },
|
||
{ label: '晚间', value: 'evening' }
|
||
],
|
||
courseTypes: [], // 从后端获取的团课类型列表
|
||
typesExpanded: false, // 类型列表是否展开
|
||
visibleRowStart: 0, // 折叠时显示的起始行索引
|
||
COLS_PER_ROW: 4, // 每行类型数量
|
||
sortOptions: [
|
||
{ label: '时间', value: 'time' },
|
||
{ label: '难度', value: 'difficulty' },
|
||
{ label: '热度', value: 'popular' }
|
||
],
|
||
courses: [],
|
||
activeBookedCourseIds: new Set() // 用户当前已预约(status=0)的课程ID集合
|
||
}
|
||
},
|
||
onLoad() {
|
||
const systemInfo = uni.getSystemInfoSync()
|
||
const statusBarHeight = systemInfo.statusBarHeight || 20
|
||
let navBarHeight = 44
|
||
// #ifdef MP-WEIXIN
|
||
const menuButton = uni.getMenuButtonBoundingClientRect()
|
||
if (menuButton) {
|
||
navBarHeight = (menuButton.top - statusBarHeight) * 2 + menuButton.height
|
||
}
|
||
// #endif
|
||
this.statusBarHeight = statusBarHeight
|
||
this.navBarHeight = navBarHeight
|
||
this.totalHeaderHeight = statusBarHeight + navBarHeight
|
||
this.loadCourses()
|
||
this.loadTypes()
|
||
},
|
||
onShow() {
|
||
this.loadActiveBookings()
|
||
},
|
||
onPullDownRefresh() {
|
||
Promise.all([this.loadCourses(), this.loadTypes(), this.loadActiveBookings()])
|
||
.finally(() => { uni.stopPullDownRefresh() })
|
||
},
|
||
computed: {
|
||
// 将类型列表按每行 COLS_PER_ROW 个拆分为二维数组
|
||
typeRows() {
|
||
const tabs = this.courseTypes.map(t => ({
|
||
label: t.typeName,
|
||
value: String(t.id)
|
||
}))
|
||
const rows = []
|
||
for (let i = 0; i < tabs.length; i += this.COLS_PER_ROW) {
|
||
rows.push(tabs.slice(i, i + this.COLS_PER_ROW))
|
||
}
|
||
return rows
|
||
},
|
||
filteredCourses() {
|
||
let list = this.courses.map(c => {
|
||
const bd = this.getTypeDifficulty(c.courseType)
|
||
const st = c.startTime || ''
|
||
const et = c.endTime || ''
|
||
// 兼容 "2026-07-15T16:45:00" 和 "2026-07-15 16:45:00" 两种格式
|
||
const dt = st ? new Date(st.replace(/ /, 'T')) : null
|
||
const edt = et ? new Date(et.replace(/ /, 'T')) : null
|
||
const hh = dt && !isNaN(dt) ? String(dt.getHours()).padStart(2, '0') : '--'
|
||
const mm = dt && !isNaN(dt) ? String(dt.getMinutes()).padStart(2, '0') : '--'
|
||
const md = dt && !isNaN(dt) ? String(dt.getMonth() + 1).padStart(2, '0') : '--'
|
||
const dd = dt && !isNaN(dt) ? String(dt.getDate()).padStart(2, '0') : '--'
|
||
const ehh = edt && !isNaN(edt) ? String(edt.getHours()).padStart(2, '0') : '--'
|
||
const emm = edt && !isNaN(edt) ? String(edt.getMinutes()).padStart(2, '0') : '--'
|
||
|
||
// 计算持续时间(分钟)
|
||
let durationStr = ''
|
||
if (dt && edt && !isNaN(dt) && !isNaN(edt)) {
|
||
const diffMin = Math.round((edt - dt) / 60000)
|
||
if (diffMin > 0) {
|
||
const hours = Math.floor(diffMin / 60)
|
||
const mins = diffMin % 60
|
||
durationStr = hours > 0 ? hours + '小时' + (mins > 0 ? mins + '分钟' : '') : diffMin + '分钟'
|
||
}
|
||
}
|
||
const statusStr = c.status != null ? Number(c.status) : 0
|
||
const curMembers = c.currentMembers != null ? c.currentMembers : 0
|
||
const maxMems = c.maxMembers != null ? c.maxMembers : 0
|
||
const isFull = maxMems > 0 && curMembers >= maxMems
|
||
|
||
let statusLabel = '正常'
|
||
let canBook = statusStr === 0 && !isFull
|
||
if (statusStr === 1) statusLabel = '已取消'
|
||
else if (statusStr === 2) statusLabel = '已结束'
|
||
else if (statusStr !== 0) statusLabel = '已关闭'
|
||
if (!canBook && statusStr === 0 && isFull) statusLabel = '已约满'
|
||
|
||
const diffLevel = bd <= 3 ? 'low' : bd <= 6 ? 'mid' : 'high'
|
||
const diffLabel = bd <= 3 ? '入门' : bd <= 6 ? '中等' : '困难'
|
||
const diffColor = diffLevel === 'low' ? '#00E676' : diffLevel === 'mid' ? '#FFA502' : '#FF5252'
|
||
const diffStars = Math.min(Math.max(Math.ceil(bd / 2), 1), 5)
|
||
|
||
return {
|
||
...c,
|
||
currentMembers: curMembers,
|
||
maxMembers: maxMems,
|
||
isFull: isFull,
|
||
canBook: canBook,
|
||
difficultyLevel: diffLevel,
|
||
difficultyLabel: diffLabel,
|
||
difficultyColor: diffColor,
|
||
difficultyStars: diffStars,
|
||
difficultyPercent: Math.round(bd * 10),
|
||
baseDifficulty: bd,
|
||
shortDate: md + '-' + dd,
|
||
shortTime: hh + ':' + mm,
|
||
endShortTime: ehh + ':' + emm,
|
||
duration: durationStr,
|
||
period: dt && !isNaN(dt) ? (hh < 8 ? 'morning' : hh < 12 ? 'forenoon' : hh < 17 ? 'afternoon' : 'evening') : 'all',
|
||
category: c.category || c.typeCategory || '',
|
||
typeName: c.typeName || (c.typeInfo && c.typeInfo.typeName) || '',
|
||
coachName: c.coachName || (c.typeInfo && c.typeInfo.coachName) || '',
|
||
location: c.location || '',
|
||
status: statusStr,
|
||
statusLabel: statusLabel,
|
||
storedValueAmount: c.storedValueAmount != null ? c.storedValueAmount : 0,
|
||
labels: this.getTypeLabels(c.courseType),
|
||
coverImage: resolveCoverUrl(c.coverImage),
|
||
}
|
||
})
|
||
if (this.searchKeyword) {
|
||
const kw = this.searchKeyword.toLowerCase()
|
||
list = list.filter(c =>
|
||
(c.courseName || '').toLowerCase().includes(kw) ||
|
||
(c.coachName || '').toLowerCase().includes(kw) ||
|
||
(c.location || '').toLowerCase().includes(kw)
|
||
)
|
||
}
|
||
if (this.currentFilter !== 'all') list = list.filter(c => String(c.courseType) === this.currentFilter)
|
||
if (this.currentPeriod !== 'all') list = list.filter(c => c.period === this.currentPeriod)
|
||
// 日期范围过滤:根据课程 startTime 的日期部分进行筛选
|
||
if (this.startDate || this.endDate) {
|
||
list = list.filter(c => {
|
||
const courseDate = (c.startTime || '').substring(0, 10)
|
||
if (!courseDate) return false
|
||
if (this.startDate && courseDate < this.startDate) return false
|
||
if (this.endDate && courseDate > this.endDate) return false
|
||
return true
|
||
})
|
||
}
|
||
if (this.currentSort === 'difficulty') list = [...list].sort((a, b) => a.baseDifficulty - b.baseDifficulty)
|
||
else if (this.currentSort === 'popular') list = [...list].sort((a, b) => (b.currentMembers || 0) - (a.currentMembers || 0))
|
||
else if (this.currentSort === 'time') {
|
||
list = [...list].sort((a, b) => {
|
||
const ta = a.startTime || ''
|
||
const tb = b.startTime || ''
|
||
return ta < tb ? -1 : ta > tb ? 1 : 0
|
||
})
|
||
}
|
||
// 只显示可预约的团课,且排除用户已主动预约的课程
|
||
list = list.filter(c => c.canBook && !this.activeBookedCourseIds.has(c.id))
|
||
return list
|
||
}
|
||
},
|
||
methods: {
|
||
// 从已加载的课程类型中匹配标签
|
||
getTypeLabels(courseType) {
|
||
const type = this.getTypeInfo(courseType)
|
||
return type && type.labels ? type.labels : []
|
||
},
|
||
// 从已加载的课程类型中匹配难度
|
||
getTypeDifficulty(courseType) {
|
||
const type = this.getTypeInfo(courseType)
|
||
return type && type.baseDifficulty != null ? type.baseDifficulty : 5
|
||
},
|
||
// 查找课程类型信息
|
||
getTypeInfo(courseType) {
|
||
return this.courseTypes.find(t => String(t.id) === String(courseType))
|
||
},
|
||
// 判断某行是否可见:展开时全部可见,折叠时只有 visibleRowStart 行可见
|
||
isRowVisible(rowIndex) {
|
||
if (this.typesExpanded) return true
|
||
return rowIndex === this.visibleRowStart
|
||
},
|
||
async loadCourses() {
|
||
this.loading = true
|
||
try {
|
||
const res = await courseApi.getCoursesByPage({ page: 0, size: 50 })
|
||
// 兼容 PageResponse 格式 { content: [...], totalElements, ... } 或直接数组
|
||
const list = Array.isArray(res) ? res : (res.content || [])
|
||
this.courses = list
|
||
} catch (e) {
|
||
console.error('加载课程列表失败:', e)
|
||
} finally {
|
||
this.loading = false
|
||
}
|
||
},
|
||
async loadTypes() {
|
||
try {
|
||
const types = await courseApi.getCourseTypes()
|
||
// 按 id 排序
|
||
this.courseTypes = (Array.isArray(types) ? types : []).sort((a, b) => (a.id || 0) - (b.id || 0))
|
||
} catch (e) {
|
||
console.error('加载课程类型失败:', e)
|
||
}
|
||
},
|
||
async loadActiveBookings() {
|
||
try {
|
||
const memberInfo = store.getMemberInfo()
|
||
const memberId = memberInfo ? (memberInfo.memberId || memberInfo.id) : null
|
||
if (!memberId) {
|
||
this.activeBookedCourseIds = new Set()
|
||
return
|
||
}
|
||
const list = await bookingApi.getMemberBookings(memberId)
|
||
// 只记录状态为"0"(已预约)的课程ID,status=1(已取消)、2(已出席)、3(缺席)不挡
|
||
const bookings = Array.isArray(list) ? list : (list.content || [])
|
||
this.activeBookedCourseIds = new Set(
|
||
bookings.filter(b => String(b.status) === '0').map(b => b.courseId)
|
||
)
|
||
} catch (e) {
|
||
console.error('加载预约记录失败:', e)
|
||
}
|
||
},
|
||
onSearchInput() {},
|
||
clearSearch() { this.searchKeyword = '' },
|
||
onCoverLoad(course) { course.coverLoaded = true },
|
||
onCoverError(course) { course.coverError = true },
|
||
onStartDateChange(e) { this.startDate = e.detail.value },
|
||
onEndDateChange(e) { this.endDate = e.detail.value },
|
||
clearDateRange() { this.startDate = ''; this.endDate = '' },
|
||
selectPeriod(value) { this.currentPeriod = value },
|
||
switchFilter(value) { this.currentFilter = value },
|
||
selectType(tab) {
|
||
this.currentFilter = tab.value
|
||
// 找到该类型所在的行索引,折叠到那一行
|
||
const idx = this.typeRows.findIndex(row => row.some(t => t.value === tab.value))
|
||
if (idx >= 0) this.visibleRowStart = idx
|
||
this.typesExpanded = false
|
||
},
|
||
switchSort(value) { this.currentSort = value },
|
||
goToDetail(course) { uni.navigateTo({ url: '/pages/course-detail/course-detail?id=' + course.id }) },
|
||
async bookCourse(course) {
|
||
if (!course.canBook) return
|
||
const memberInfo = store.getMemberInfo()
|
||
const memberId = memberInfo ? (memberInfo.memberId || memberInfo.id) : null
|
||
if (!memberId) {
|
||
uni.showToast({ title: '请先登录', icon: 'none' })
|
||
return
|
||
}
|
||
uni.showLoading({ title: '预约中...' })
|
||
try {
|
||
await bookingApi.bookCourse({
|
||
courseId: course.id,
|
||
memberId: memberId
|
||
})
|
||
uni.hideLoading()
|
||
uni.showToast({ title: '预约成功', icon: 'success' })
|
||
// 跳转到「我的课程」页面
|
||
setTimeout(() => { uni.switchTab({ url: '/pages/my-courses/my-courses' }) }, 1000)
|
||
} catch (e) {
|
||
uni.hideLoading()
|
||
console.error('预约失败:', e)
|
||
const msg = (e.data && e.data.message) || '预约失败,请重试'
|
||
uni.showToast({ title: msg, icon: 'none' })
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.search-page { min-height: 100vh; background: #F5F7FA; overflow-x: hidden; }
|
||
.header-wrap { background: #1A1A1A; }
|
||
.nav-bar { background: #1A1A1A; padding: 0 20px; display: flex; align-items: center; }
|
||
.nav-title { font-size: 18px; font-weight: 700; color: #FFFFFF; }
|
||
|
||
.search-area { background: #1A1A1A; padding: 18px 16px 20px; }
|
||
.search-box { background: rgba(255,255,255,0.12); border-radius: 40px; padding: 0 16px; display: flex; align-items: center; height: 42px; margin-bottom: 14px; }
|
||
.search-symbol { font-size: 15px; color: rgba(255,255,255,0.5); flex-shrink: 0; margin-right: 8px; }
|
||
.search-input { flex: 1; font-size: 14px; color: #FFFFFF; height: 100%; }
|
||
.clear-btn { font-size: 15px; color: rgba(255,255,255,0.5); padding: 4px; flex-shrink: 0; }
|
||
|
||
.date-range-row { display: flex; align-items: center; margin-bottom: 14px; }
|
||
.date-range-row picker { flex: 1; margin-right: 10px; }
|
||
.date-range-item { background: rgba(255,255,255,0.1); border-radius: 12px; padding: 10px 14px; }
|
||
.range-label { font-size: 11px; color: rgba(255,255,255,0.5); display: block; margin-bottom: 2px; }
|
||
.range-value { font-size: 14px; font-weight: 600; color: #FFFFFF; }
|
||
.range-value.placeholder { color: rgba(255,255,255,0.35); font-weight: 400; }
|
||
.range-sep { color: rgba(255,255,255,0.5); font-size: 14px; flex-shrink: 0; margin-right: 10px; }
|
||
.date-clear { font-size: 16px; color: rgba(255,255,255,0.4); padding: 8px; flex-shrink: 0; }
|
||
|
||
.period-row { display: flex; }
|
||
.period-item { flex: 1; text-align: center; padding: 7px 0; border-radius: 40px; font-size: 13px; color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.08); margin: 0 3px; }
|
||
.period-item.active { background: #00E676; color: #1A1A1A; font-weight: 600; }
|
||
|
||
.type-section { background: #FFFFFF; padding: 12px 16px 14px; border-bottom: 1px solid #EEEEEE; }
|
||
.type-all-row { margin-bottom: 6px; }
|
||
.type-grid { }
|
||
.type-row {
|
||
display: flex; flex-wrap: wrap;
|
||
max-height: 0; opacity: 0;
|
||
margin-bottom: 0; overflow: hidden;
|
||
transition: max-height 0.25s ease, opacity 0.25s ease, margin-bottom 0.25s ease;
|
||
}
|
||
.type-row.row-visible { max-height: 100px; opacity: 1; margin-bottom: 6px; }
|
||
.type-grid.expanded .type-row { max-height: 100px; opacity: 1; margin-bottom: 6px; }
|
||
.filter-tab { display: inline-block; padding: 6px 16px; border-radius: 40px; font-size: 13px; color: #7A7E84; background: #F5F7FA; margin-right: 8px; margin-bottom: 6px; }
|
||
.filter-tab.active { background: #00E676; color: #1A1A1A; font-weight: 600; }
|
||
.type-expand-row { text-align: center; padding-top: 2px; }
|
||
.type-expand-btn { font-size: 12px; color: #00C853; font-weight: 500; }
|
||
|
||
.sort-bar { display: flex; justify-content: space-between; align-items: center; padding: 10px 16px; background: #FFFFFF; border-bottom: 1px solid #EEEEEE; }
|
||
.result-count { font-size: 13px; color: #7A7E84; }
|
||
.sort-options { display: flex; }
|
||
.sort-item { font-size: 13px; color: #7A7E84; margin-right: 16px; }
|
||
.sort-item.active { color: #00C853; font-weight: 600; }
|
||
|
||
.course-list-inner { padding: 12px 16px 0; }
|
||
.course-item { margin-bottom: 14px; border-radius: 16px; box-shadow: 0 4px 16px rgba(0,0,0,0.07); overflow: hidden; }
|
||
.course-card { background: #FFFFFF; border-radius: 16px; display: flex; flex-direction: column; }
|
||
|
||
/* ---- 封面区域 ---- */
|
||
.course-cover { width: 100%; height: 170px; background: linear-gradient(135deg, #E8F5E9 0%, #C8E6C9 100%); display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; }
|
||
.cover-skeleton { position: absolute; inset: 0; background: linear-gradient(90deg, #E8E8E8 25%, #F5F5F5 50%, #E8E8E8 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; z-index: 1; }
|
||
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
||
.cover-img { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; transition: opacity 0.3s; z-index: 0; }
|
||
.cover-img.loaded { opacity: 1; z-index: 2; }
|
||
|
||
/* 无封面时的占位 */
|
||
.cover-placeholder { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 0; }
|
||
.cover-icon { font-size: 36px; margin-bottom: 4px; }
|
||
.cover-type-name { font-size: 16px; font-weight: 700; color: #00C853; }
|
||
|
||
/* 封面覆盖层 - 难度角标 */
|
||
.cover-overlay { position: absolute; top: 10px; right: 10px; z-index: 5; }
|
||
.diff-badge { display: flex; align-items: center; padding: 4px 10px; border-radius: 20px; background: rgba(0,0,0,0.55); backdrop-filter: blur(4px); }
|
||
.diff-badge-star { font-size: 11px; margin-right: 1px; }
|
||
.diff-badge-label { font-size: 11px; font-weight: 600; margin-left: 4px; }
|
||
.diff-badge.diff-low .diff-badge-star { color: #00E676; }
|
||
.diff-badge.diff-low .diff-badge-label { color: #69F0AE; }
|
||
.diff-badge.diff-mid .diff-badge-star { color: #FFA502; }
|
||
.diff-badge.diff-mid .diff-badge-label { color: #FFBE4D; }
|
||
.diff-badge.diff-high .diff-badge-star { color: #FF5252; }
|
||
.diff-badge.diff-high .diff-badge-label { color: #FF8A80; }
|
||
|
||
/* ---- 卡片内容 ---- */
|
||
.course-body { padding: 14px 16px 16px; display: flex; flex-direction: column; }
|
||
.course-top { display: flex; justify-content: space-between; align-items: center; }
|
||
.course-name { font-size: 17px; font-weight: 700; color: #1E1E1E; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.course-price { font-size: 20px; font-weight: 700; color: #00C853; flex-shrink: 0; margin-left: 8px; }
|
||
.course-price.free { color: #00C853; }
|
||
|
||
/* 标签 */
|
||
.course-labels { display: flex; flex-wrap: wrap; margin-top: 8px; }
|
||
.course-label { font-size: 11px; padding: 2px 8px; border-radius: 10px; margin-right: 6px; margin-bottom: 4px; }
|
||
|
||
/* 元数据行 */
|
||
.course-meta { display: flex; align-items: center; font-size: 13px; color: #7A7E84; margin-top: 6px; }
|
||
.meta-icon { font-size: 12px; margin-right: 4px; flex-shrink: 0; }
|
||
.course-date { flex-shrink: 0; }
|
||
.course-time { margin-left: 8px; }
|
||
.course-duration { margin-left: 8px; color: #00C853; font-weight: 500; }
|
||
.course-location { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
|
||
/* 底部预约行 */
|
||
.course-bottom { display: flex; align-items: center; margin-top: 12px; padding-top: 10px; border-top: 1px solid #F0F0F0; }
|
||
.course-capacity { font-size: 13px; color: #7A7E84; flex: 1; }
|
||
.btn-book { background: #00E676; color: #1A1A1A; border: none; padding: 10px 28px; border-radius: 40px; font-weight: 700; font-size: 15px; line-height: 1.2; flex-shrink: 0; margin-left: auto; text-align: center; box-shadow: 0 2px 8px rgba(0,230,118,0.3); }
|
||
.btn-book::after { border: none; }
|
||
.btn-book.disabled { background: #E0E0E0; color: #BDBDBD; box-shadow: none; }
|
||
|
||
.empty-state { display: flex; flex-direction: column; align-items: center; padding: 60px 0; }
|
||
.empty-symbol { font-size: 40px; color: #BDBDBD; }
|
||
.empty-text { font-size: 14px; color: #BDBDBD; }
|
||
.bottom-safe { height: 30px; }
|
||
</style>
|