Files
venue-miniapp/miniprogram/src/pages/ice/courses/index.vue
T
gqt 5c97e8b1f8 feat: 去掉导航栏、首页轮播全屏占屏1/3、场馆切换移至公告活动下方
- pages.json 全局设置 navigationStyle: custom 移除原生导航栏
- App.vue 全局 page 添加状态栏安全区 padding-top
- 综合场馆/冰场馆首页轮播图改为全屏模式(无圆角无边距、去除标题)
- 轮播图高度改为 33.33vh 占屏幕三分之一
- VenueSwitchHeader 从轮播图上方移至公告活动板块下方
- 合并 tabBar 页面路由、清理废弃组件 VenueTabBar
2026-07-13 19:27:49 +08:00

419 lines
9.7 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="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>