feat: migrate ice venue module

This commit is contained in:
gqt
2026-06-08 21:00:32 +08:00
parent 8e1443f7e5
commit 0f4e04f4ad
28 changed files with 5684 additions and 0 deletions
+419
View File
@@ -0,0 +1,419 @@
<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 { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
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()
})
onPullDownRefresh(() => {
page.value = 1
loadCourses().finally(() => {
uni.stopPullDownRefresh()
})
})
onReachBottom(() => {
if (hasMore.value && !isLoadingMore.value) {
page.value++
loadMoreCourses()
}
})
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}`
}
</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>