feat: 商城改版、约课页合并三Tab、课程已预约状态后端注入
- 商城页改为Tab切换(储值卡/会员卡)+卡片列表,新增商品详情页 - 约课页合并会员卡/团课/门票三Tab,日期横滑选择器 - 会员卡预约新增详情页+二次确认弹窗 - 课程列表已预约状态改为后端返回booked/booking_id/booking_type - 前端移除冗余的loadBookings调用,直接读session.booked字段 - 导航栏仅首页custom,其余页面恢复原生导航栏 - membership API支持date参数筛选
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
<template>
|
||||
<view class="detail-page page-bottom-safe" v-if="sessionData">
|
||||
<image :src="sessionData.cover_image" mode="aspectFill" class="hero-image" />
|
||||
<view class="content">
|
||||
<view class="title-card card">
|
||||
<view class="title-row">
|
||||
<text class="course-title">{{ sessionData.title }}</text>
|
||||
</view>
|
||||
<view class="price-row">
|
||||
<text class="price-text">¥{{ sessionData.price }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="info-card card">
|
||||
<view v-for="item in infoItems" :key="item.label" class="info-row">
|
||||
<text class="info-label">{{ item.label }}</text>
|
||||
<text class="info-value">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section-header">
|
||||
<text class="section-title">课程详情</text>
|
||||
</view>
|
||||
<view class="rich-card card">
|
||||
<rich-text :nodes="sessionData.detail_html"></rich-text>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<text class="card-title">选择次卡</text>
|
||||
<view v-if="availableCards.length" class="card-pick-list">
|
||||
<view
|
||||
v-for="card in availableCards"
|
||||
:key="card.id"
|
||||
class="card-pick-item"
|
||||
:class="{ active: selectedCardId === String(card.id) }"
|
||||
@tap="selectedCardId = String(card.id)"
|
||||
>
|
||||
<view class="card-pick-info">
|
||||
<text class="card-pick-name">{{ card.plan_name }}</text>
|
||||
<text class="card-pick-meta">剩余 {{ card.remaining_count }} 次</text>
|
||||
</view>
|
||||
<view class="card-pick-check" v-if="selectedCardId === String(card.id)">✓</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-state">暂无可用次卡</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="fixed-action-bar">
|
||||
<button class="primary-button submit-btn" :disabled="!canBook || submitting" @tap="onBookTap">
|
||||
{{ submitting ? '提交中...' : '使用次卡预约' }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view v-if="confirmVisible" class="confirm-mask" @tap="closeConfirm">
|
||||
<view class="confirm-sheet" @tap.stop>
|
||||
<text class="confirm-title">确认预约</text>
|
||||
<view class="confirm-body">
|
||||
<view class="confirm-row">
|
||||
<text class="confirm-label">课程</text>
|
||||
<text class="confirm-value">{{ sessionData.title }}</text>
|
||||
</view>
|
||||
<view class="confirm-row">
|
||||
<text class="confirm-label">时间</text>
|
||||
<text class="confirm-value">{{ formatTimeRange(sessionData.start_at, sessionData.end_at) }}</text>
|
||||
</view>
|
||||
<view class="confirm-row">
|
||||
<text class="confirm-label">次卡</text>
|
||||
<text class="confirm-value">{{ selectedCardLabel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="confirm-actions">
|
||||
<button class="ghost-button cancel-btn" @tap="closeConfirm">取消</button>
|
||||
<button class="primary-button ok-btn" :disabled="submitting" @tap="confirmBook">确认预约</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="load-more-tip">加载中...</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { courseApi } from '@/modules/comprehensive/api/course'
|
||||
import { membershipApi } from '@/modules/comprehensive/api/membership'
|
||||
import { formatTimeRange } from '@/modules/comprehensive/utils/date'
|
||||
|
||||
const sessionData = ref(null)
|
||||
const cards = ref([])
|
||||
const selectedCardId = ref('')
|
||||
const confirmVisible = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const availableCards = computed(() =>
|
||||
cards.value.filter((c) => c.status === 'available' && c.remaining_count > 0)
|
||||
)
|
||||
|
||||
const canBook = computed(() => !!selectedCardId.value)
|
||||
|
||||
const selectedCardLabel = computed(() => {
|
||||
const card = availableCards.value.find((c) => String(c.id) === selectedCardId.value)
|
||||
return card ? `${card.plan_name}(剩余${card.remaining_count}次)` : ''
|
||||
})
|
||||
|
||||
const infoItems = computed(() => {
|
||||
if (!sessionData.value) return []
|
||||
return [
|
||||
{ label: '上课时间', value: formatTimeRange(sessionData.value.start_at, sessionData.value.end_at) },
|
||||
{ label: '授课教练', value: sessionData.value.coach_name },
|
||||
{ label: '上课场地', value: sessionData.value.courts.map((item) => item.name).join('、') },
|
||||
{ label: '剩余名额', value: `${sessionData.value.remaining_count}/${sessionData.value.capacity}` }
|
||||
]
|
||||
})
|
||||
|
||||
onLoad(async (query) => {
|
||||
const [detail, cardData] = await Promise.all([
|
||||
courseApi.getSessionDetail(query.id),
|
||||
membershipApi.getCards({ page: 1, pageSize: 50 })
|
||||
])
|
||||
sessionData.value = detail
|
||||
cards.value = cardData.items
|
||||
})
|
||||
|
||||
const onBookTap = () => {
|
||||
if (!canBook.value) return
|
||||
confirmVisible.value = true
|
||||
}
|
||||
|
||||
const closeConfirm = () => {
|
||||
confirmVisible.value = false
|
||||
}
|
||||
|
||||
const confirmBook = async () => {
|
||||
if (submitting.value || !selectedCardId.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const booking = await membershipApi.createCardBooking({
|
||||
cardId: selectedCardId.value,
|
||||
sessionId: sessionData.value.id
|
||||
})
|
||||
uni.redirectTo({ url: `/pages/comprehensive/membership/booking-detail?id=${booking.id}` })
|
||||
} catch {
|
||||
uni.showToast({ title: '预约失败,请重试', icon: 'none' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
confirmVisible.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.detail-page {
|
||||
min-height: 100vh;
|
||||
background: $bg-color-soft;
|
||||
}
|
||||
|
||||
.hero-image {
|
||||
width: 100%;
|
||||
height: 420rpx;
|
||||
background: $bg-color-hover;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
.title-card {
|
||||
margin-top: -54rpx;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.title-row,
|
||||
.price-row,
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.course-title {
|
||||
flex: 1;
|
||||
color: $text-color-main;
|
||||
font-size: 38rpx;
|
||||
font-weight: 900;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.price-row {
|
||||
margin-top: $spacing-md;
|
||||
}
|
||||
|
||||
.info-card,
|
||||
.rich-card {
|
||||
margin-top: $spacing-md;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
min-height: 72rpx;
|
||||
border-bottom: 1rpx solid $bg-color-hover;
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
flex: 1;
|
||||
color: $text-color-main;
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.rich-card {
|
||||
color: $text-color-regular;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: block;
|
||||
margin-bottom: $spacing-md;
|
||||
color: $text-color-main;
|
||||
font-size: 28rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.card-pick-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.card-pick-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border: 2rpx solid $border-color;
|
||||
border-radius: $border-radius-base;
|
||||
background: $bg-color-main;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.card-pick-item.active {
|
||||
border-color: $text-color-main;
|
||||
background: $bg-color-hover;
|
||||
}
|
||||
|
||||
.card-pick-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.card-pick-name {
|
||||
color: $text-color-main;
|
||||
font-size: 28rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.card-pick-meta {
|
||||
color: $text-color-muted;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.card-pick-check {
|
||||
color: $text-color-main;
|
||||
font-size: 32rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.confirm-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.confirm-sheet {
|
||||
width: 600rpx;
|
||||
background: $bg-color-main;
|
||||
border-radius: $border-radius-lg;
|
||||
padding: $spacing-lg $spacing-md;
|
||||
}
|
||||
|
||||
.confirm-title {
|
||||
display: block;
|
||||
text-align: center;
|
||||
color: $text-color-main;
|
||||
font-size: 34rpx;
|
||||
font-weight: 900;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.confirm-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
.confirm-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.confirm-label {
|
||||
color: $text-color-muted;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.confirm-value {
|
||||
flex: 1;
|
||||
color: $text-color-main;
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ok-btn {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user