5c97e8b1f8
- pages.json 全局设置 navigationStyle: custom 移除原生导航栏 - App.vue 全局 page 添加状态栏安全区 padding-top - 综合场馆/冰场馆首页轮播图改为全屏模式(无圆角无边距、去除标题) - 轮播图高度改为 33.33vh 占屏幕三分之一 - VenueSwitchHeader 从轮播图上方移至公告活动板块下方 - 合并 tabBar 页面路由、清理废弃组件 VenueTabBar
419 lines
9.7 KiB
Vue
419 lines
9.7 KiB
Vue
<template>
|
||
<view class="courses-container">
|
||
<!-- 极简原生筛选栏 -->
|
||
<view class="native-filter-bar">
|
||
<!-- 微信原生日期选择器 -->
|
||
<picker
|
||
mode="date"
|
||
:value="selectedDate"
|
||
@change="onDateChange"
|
||
class="filter-picker-item"
|
||
>
|
||
<view class="picker-inner">
|
||
<text class="picker-label">日期:</text>
|
||
<text class="picker-value">{{ selectedDate ? formatDateDisplay(selectedDate) : '选择日期' }}</text>
|
||
<text class="picker-arrow">▼</text>
|
||
</view>
|
||
</picker>
|
||
|
||
<view class="vertical-divider"></view>
|
||
|
||
<!-- 微信原生教练单列选择器 -->
|
||
<picker
|
||
mode="selector"
|
||
:range="coachOptions"
|
||
range-key="name"
|
||
:value="selectedCoachIndex"
|
||
@change="onCoachChange"
|
||
class="filter-picker-item"
|
||
>
|
||
<view class="picker-inner">
|
||
<text class="picker-label">教练:</text>
|
||
<text class="picker-value">{{ selectedCoachName }}</text>
|
||
<text class="picker-arrow">▼</text>
|
||
</view>
|
||
</picker>
|
||
</view>
|
||
|
||
<!-- 课程列表(双栏高质感极简卡片) -->
|
||
<view class="course-list">
|
||
<StatePanel
|
||
v-if="listState === 'error'"
|
||
title="课程加载失败"
|
||
:description="listErrorMessage"
|
||
primary-text="重试"
|
||
type="error"
|
||
compact
|
||
@primary="loadCourses"
|
||
/>
|
||
|
||
<block v-else>
|
||
<view
|
||
v-for="course in courses"
|
||
:key="course.id"
|
||
class="course-card"
|
||
@tap="onViewDetail(course.id)"
|
||
>
|
||
<image :src="course.cover" mode="aspectFill" class="course-cover" />
|
||
|
||
<view class="course-info">
|
||
<view class="card-top-row">
|
||
<text class="course-title">{{ course.title }}</text>
|
||
<view class="badge success" v-if="course.is_booked">已预约</view>
|
||
</view>
|
||
|
||
<view class="course-details">
|
||
<text class="detail-item-text highlight-blue">时间:{{ formatTimeRange(course.start_time, course.end_time) }}</text>
|
||
<view class="detail-bottom-meta">
|
||
<text class="detail-item-text"><text class="label-dot">•</text>教练:{{ course.coach.name }}</text>
|
||
<text class="detail-item-text"><text class="label-dot">•</text>人数:已约 {{ course.booked_count }}/{{ course.capacity }}人</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</block>
|
||
|
||
<!-- 加载更多提示 -->
|
||
<view v-if="isLoadingMore" class="load-more-tip">
|
||
<text>加载中...</text>
|
||
</view>
|
||
<view v-else-if="courses.length > 0 && !hasMore" class="load-more-tip">
|
||
<text>— 已加载全部 —</text>
|
||
</view>
|
||
|
||
<!-- 空状态 -->
|
||
<StatePanel
|
||
v-if="listState !== 'error' && !isLoading && courses.length === 0"
|
||
title="暂无排课行程"
|
||
description="当前筛选条件下暂时没有可查看的课程。"
|
||
type="empty"
|
||
compact
|
||
/>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { courseApi } from '@/modules/ice/api/course'
|
||
import { errorMessage } from '@/modules/ice/api/session'
|
||
import StatePanel from '@/components/ice/StatePanel.vue'
|
||
|
||
const selectedDate = ref('')
|
||
const selectedCoachIndex = ref(0) // 索引 0 默认是 "全部教练"
|
||
const isLoading = ref(false)
|
||
const isLoadingMore = ref(false)
|
||
const listState = ref('idle')
|
||
const listErrorMessage = ref('')
|
||
const page = ref(1)
|
||
const pageSize = 20
|
||
const total = ref(0)
|
||
|
||
const hasMore = computed(() => courses.value.length < total.value)
|
||
|
||
const coaches = ref([])
|
||
|
||
// 原生 Picker 需要的可选数组
|
||
const coachOptions = computed(() => {
|
||
return [{ id: null, name: '全部教练' }, ...coaches.value]
|
||
})
|
||
|
||
const selectedCoachId = computed(() => {
|
||
return coachOptions.value[selectedCoachIndex.value]?.id || null
|
||
})
|
||
|
||
const selectedCoachName = computed(() => {
|
||
return coachOptions.value[selectedCoachIndex.value]?.name || '全部教练'
|
||
})
|
||
|
||
const courses = ref([])
|
||
|
||
onMounted(() => {
|
||
const today = new Date()
|
||
const pad = (n) => String(n).padStart(2, '0')
|
||
selectedDate.value = `${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad(today.getDate())}`
|
||
loadInitialData()
|
||
})
|
||
|
||
const loadInitialData = async () => {
|
||
await Promise.all([loadCoaches(), loadCourses()])
|
||
}
|
||
|
||
const loadCoaches = async () => {
|
||
try {
|
||
coaches.value = await courseApi.getCoaches()
|
||
} catch (err) {
|
||
coaches.value = []
|
||
}
|
||
}
|
||
|
||
const loadCourses = async () => {
|
||
if (isLoading.value) return
|
||
isLoading.value = true
|
||
try {
|
||
const data = await courseApi.getCourses({
|
||
date: selectedDate.value,
|
||
coachId: selectedCoachId.value,
|
||
page: 1,
|
||
pageSize
|
||
})
|
||
courses.value = data.items
|
||
total.value = data.total
|
||
listState.value = 'ready'
|
||
listErrorMessage.value = ''
|
||
} catch (err) {
|
||
courses.value = []
|
||
total.value = 0
|
||
listState.value = 'error'
|
||
listErrorMessage.value = errorMessage(err, '课程加载失败,请稍后重试。')
|
||
} finally {
|
||
isLoading.value = false
|
||
}
|
||
}
|
||
|
||
const loadMoreCourses = async () => {
|
||
if (isLoadingMore.value) return
|
||
isLoadingMore.value = true
|
||
try {
|
||
const data = await courseApi.getCourses({
|
||
date: selectedDate.value,
|
||
coachId: selectedCoachId.value,
|
||
page: page.value,
|
||
pageSize
|
||
})
|
||
courses.value = [...courses.value, ...data.items]
|
||
total.value = data.total
|
||
} catch (err) {
|
||
page.value--
|
||
uni.showToast({ title: errorMessage(err, '加载更多失败,请重试'), icon: 'none' })
|
||
} finally {
|
||
isLoadingMore.value = false
|
||
}
|
||
}
|
||
|
||
const onDateChange = (e) => {
|
||
selectedDate.value = e.detail.value
|
||
page.value = 1
|
||
loadCourses()
|
||
}
|
||
|
||
const onCoachChange = (e) => {
|
||
selectedCoachIndex.value = Number(e.detail.value)
|
||
page.value = 1
|
||
loadCourses()
|
||
}
|
||
|
||
const onViewDetail = (id) => {
|
||
uni.navigateTo({
|
||
url: `/pages/ice/courses/detail?id=${id}`
|
||
})
|
||
}
|
||
|
||
const formatDateDisplay = (dateStr) => {
|
||
if (!dateStr) return ''
|
||
const parts = dateStr.split('-')
|
||
return `${parts[1]}月${parts[2]}日`
|
||
}
|
||
|
||
const formatTimeRange = (startStr, endStr) => {
|
||
const start = new Date(startStr)
|
||
const end = new Date(endStr)
|
||
const pad = (n) => String(n).padStart(2, '0')
|
||
const mm = pad(start.getMonth() + 1)
|
||
const dd = pad(start.getDate())
|
||
const startHhMm = `${pad(start.getHours())}:${pad(start.getMinutes())}`
|
||
const endHhMm = `${pad(end.getHours())}:${pad(end.getMinutes())}`
|
||
return `${mm}-${dd} ${startHhMm} - ${endHhMm}`
|
||
}
|
||
|
||
const refresh = async () => {
|
||
page.value = 1
|
||
await loadCourses()
|
||
}
|
||
|
||
const loadMore = () => {
|
||
if (hasMore.value && !isLoadingMore.value) {
|
||
page.value++
|
||
loadMoreCourses()
|
||
}
|
||
}
|
||
|
||
defineExpose({ refresh, loadMore })
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.courses-container {
|
||
min-height: 100vh;
|
||
background-color: $bg-color-soft;
|
||
padding-bottom: $spacing-xl;
|
||
}
|
||
|
||
/* 极简原生筛选栏 */
|
||
.native-filter-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
background-color: $bg-color-main;
|
||
border-bottom: 1rpx solid $bg-color-hover;
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 10;
|
||
height: 90rpx;
|
||
}
|
||
|
||
.filter-picker-item {
|
||
flex: 1;
|
||
height: 100%;
|
||
}
|
||
|
||
.picker-inner {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 90rpx;
|
||
font-size: 26rpx;
|
||
color: $text-color-regular;
|
||
|
||
.picker-label {
|
||
color: $text-color-muted;
|
||
}
|
||
|
||
.picker-value {
|
||
color: $text-color-main;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.picker-arrow {
|
||
font-size: 16rpx;
|
||
color: $text-color-light;
|
||
margin-left: 8rpx;
|
||
margin-top: 2rpx;
|
||
}
|
||
}
|
||
|
||
.vertical-divider {
|
||
width: 1rpx;
|
||
height: 40rpx;
|
||
background-color: $bg-color-hover;
|
||
}
|
||
|
||
/* 课程卡片 */
|
||
.course-list {
|
||
padding: $spacing-md;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: $spacing-md;
|
||
}
|
||
|
||
.course-card {
|
||
display: flex;
|
||
background-color: $bg-color-main;
|
||
border: 1rpx solid rgba(226, 232, 240, 0.8);
|
||
border-radius: $border-radius-lg;
|
||
overflow: hidden;
|
||
transition: all 0.2s ease;
|
||
|
||
&:active {
|
||
background-color: $bg-color-hover;
|
||
}
|
||
|
||
.course-cover {
|
||
width: 180rpx;
|
||
height: 150rpx;
|
||
background-color: $bg-color-hover;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.course-info {
|
||
flex: 1;
|
||
padding: $spacing-sm $spacing-md;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.card-top-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: $spacing-xs;
|
||
}
|
||
|
||
.course-title {
|
||
font-size: 28rpx;
|
||
font-weight: 700;
|
||
color: $text-color-main;
|
||
line-height: 1.3;
|
||
display: -webkit-box;
|
||
-webkit-box-orient: vertical;
|
||
-webkit-line-clamp: 1;
|
||
overflow: hidden;
|
||
flex: 1;
|
||
}
|
||
|
||
.course-details {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4rpx;
|
||
}
|
||
|
||
.detail-item-text {
|
||
font-size: 22rpx;
|
||
color: $text-color-muted;
|
||
display: flex;
|
||
align-items: center;
|
||
|
||
&.highlight-blue {
|
||
color: $color-primary;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.label-dot {
|
||
font-size: 20rpx;
|
||
margin-right: 8rpx;
|
||
opacity: 0.8;
|
||
}
|
||
}
|
||
|
||
.detail-bottom-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: $spacing-xl;
|
||
margin-top: 2rpx;
|
||
}
|
||
}
|
||
|
||
/* 空状态 */
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 160rpx 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
|
||
.empty-icon {
|
||
font-size: 64rpx;
|
||
margin-bottom: $spacing-md;
|
||
opacity: 0.8;
|
||
}
|
||
|
||
.empty-text {
|
||
font-size: 28rpx;
|
||
font-weight: 600;
|
||
color: $text-color-main;
|
||
margin-bottom: $spacing-xs;
|
||
}
|
||
|
||
.empty-sub {
|
||
font-size: 22rpx;
|
||
color: $text-color-muted;
|
||
}
|
||
}
|
||
|
||
.load-more-tip {
|
||
text-align: center;
|
||
padding: $spacing-md 0;
|
||
font-size: 22rpx;
|
||
color: $text-color-light;
|
||
}
|
||
</style>
|