Files
venue-miniapp/miniprogram/src/pages/comprehensive/courses/membership-detail.vue
T
gqt b06cf69942 feat: 商城改版、约课页合并三Tab、课程已预约状态后端注入
- 商城页改为Tab切换(储值卡/会员卡)+卡片列表,新增商品详情页
- 约课页合并会员卡/团课/门票三Tab,日期横滑选择器
- 会员卡预约新增详情页+二次确认弹窗
- 课程列表已预约状态改为后端返回booked/booking_id/booking_type
- 前端移除冗余的loadBookings调用,直接读session.booked字段
- 导航栏仅首页custom,其余页面恢复原生导航栏
- membership API支持date参数筛选
2026-07-13 20:37:31 +08:00

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>