b06cf69942
- 商城页改为Tab切换(储值卡/会员卡)+卡片列表,新增商品详情页 - 约课页合并会员卡/团课/门票三Tab,日期横滑选择器 - 会员卡预约新增详情页+二次确认弹窗 - 课程列表已预约状态改为后端返回booked/booking_id/booking_type - 前端移除冗余的loadBookings调用,直接读session.booked字段 - 导航栏仅首页custom,其余页面恢复原生导航栏 - membership API支持date参数筛选
353 lines
8.2 KiB
Vue
353 lines
8.2 KiB
Vue
<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>
|