feat: 商城改版、约课页合并三Tab、课程已预约状态后端注入
- 商城页改为Tab切换(储值卡/会员卡)+卡片列表,新增商品详情页 - 约课页合并会员卡/团课/门票三Tab,日期横滑选择器 - 会员卡预约新增详情页+二次确认弹窗 - 课程列表已预约状态改为后端返回booked/booking_id/booking_type - 前端移除冗余的loadBookings调用,直接读session.booked字段 - 导航栏仅首页custom,其余页面恢复原生导航栏 - membership API支持date参数筛选
This commit is contained in:
@@ -1,86 +1,158 @@
|
||||
<template>
|
||||
<view class="courses-page">
|
||||
<view class="filter-bar">
|
||||
<picker mode="date" :value="selectedDate" @change="onDateChange" class="filter-item">
|
||||
<view class="filter-inner">
|
||||
<text class="filter-label">日期</text>
|
||||
<text class="filter-value">{{ selectedDate || '全部日期' }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<picker mode="selector" :range="coachOptions" range-key="name" :value="selectedCoachIndex" @change="onCoachChange" class="filter-item">
|
||||
<view class="filter-inner">
|
||||
<text class="filter-label">教练</text>
|
||||
<text class="filter-value">{{ selectedCoachName }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<picker mode="selector" :range="courtTypeOptions" range-key="label" :value="selectedCourtTypeIndex" @change="onCourtTypeChange" class="filter-item">
|
||||
<view class="filter-inner">
|
||||
<text class="filter-label">类型</text>
|
||||
<text class="filter-value">{{ selectedCourtTypeLabel }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<UnauthLoginPanel
|
||||
v-if="activeTab === 'membership' && !session.isLoggedIn"
|
||||
@login="login"
|
||||
/>
|
||||
|
||||
<view class="course-list">
|
||||
<view v-for="session in sessions" :key="session.id" class="course-card" @tap="goDetail(session.id)">
|
||||
<image :src="session.cover_image" mode="aspectFill" class="course-cover" />
|
||||
<view class="course-info">
|
||||
<view class="course-top">
|
||||
<text class="course-title">{{ session.title }}</text>
|
||||
</view>
|
||||
<text class="time-line">{{ formatTimeRange(session.start_at, session.end_at) }}</text>
|
||||
<text class="meta">{{ session.coach_name }}</text>
|
||||
<view class="course-bottom">
|
||||
<text class="meta">已约 {{ session.booked_count }}/{{ session.capacity }}</text>
|
||||
<text class="course-price">¥{{ session.price }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<block v-else>
|
||||
<view class="tab-bar">
|
||||
<view class="tab-item" :class="{ active: activeTab === 'membership' }" @tap="switchTab('membership')">会员卡</view>
|
||||
<view class="tab-item" :class="{ active: activeTab === 'group' }" @tap="switchTab('group')">团课</view>
|
||||
<view class="tab-item" :class="{ active: activeTab === 'tickets' }" @tap="switchTab('tickets')">门票</view>
|
||||
</view>
|
||||
|
||||
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
||||
<view v-else-if="sessions.length === 0" class="empty-state">当前筛选条件下暂无课程</view>
|
||||
<view v-else-if="!hasMore" class="load-more-tip">已加载全部</view>
|
||||
</view>
|
||||
<template v-if="activeTab === 'tickets'">
|
||||
<view v-if="ticketProduct" class="ticket-section">
|
||||
<view class="card product-card">
|
||||
<view class="product-head">
|
||||
<view>
|
||||
<text class="product-title">篮球门票</text>
|
||||
<text class="product-subtitle">购买后进入会员权益账户,到馆由前台核销。</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="price-block">
|
||||
<text class="unit-price">¥{{ ticketProduct.unit_price }}</text>
|
||||
<text class="unit-label">/ 次</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card count-card">
|
||||
<text class="card-title">购买次数</text>
|
||||
<view class="stepper-row">
|
||||
<button class="step-btn" @tap="decreaseTicketQty">-</button>
|
||||
<text class="quantity">{{ ticketQty }}</text>
|
||||
<button class="step-btn" @tap="increaseTicketQty">+</button>
|
||||
</view>
|
||||
<view class="amount-row">
|
||||
<text>合计金额</text>
|
||||
<text>¥{{ ticketTotal }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="fixed-action-bar">
|
||||
<view class="bar-price">
|
||||
<text class="bar-label">合计</text>
|
||||
<text class="price-text">¥{{ ticketTotal }}</text>
|
||||
</view>
|
||||
<button class="primary-button submit-btn" :disabled="ticketSubmitting || !ticketProduct.is_active" @tap="buyTickets">
|
||||
立即购买
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="load-more-tip">加载中...</view>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<scroll-view scroll-x class="date-bar" :show-scrollbar="false">
|
||||
<view class="date-list">
|
||||
<view
|
||||
v-for="d in dateOptions"
|
||||
:key="d.value"
|
||||
class="date-chip"
|
||||
:class="{ active: selectedDate === d.value }"
|
||||
@tap="onDateSelect(d.value)"
|
||||
>
|
||||
<text class="date-label">{{ d.label }}</text>
|
||||
<text class="date-day">{{ d.date }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="course-list">
|
||||
<view v-for="item in sessions" :key="item.id" class="card course-card" @tap="goDetail(item)">
|
||||
<image :src="item.cover_image" mode="aspectFill" class="course-cover" />
|
||||
<view class="course-info">
|
||||
<view class="course-top">
|
||||
<text class="course-title">{{ item.title }}</text>
|
||||
<text v-if="item.booked" class="booked-badge">已预约</text>
|
||||
</view>
|
||||
<text class="time-line">{{ formatTimeRange(item.start_at, item.end_at) }}</text>
|
||||
<text class="meta">{{ item.coach_name }}</text>
|
||||
<view class="course-bottom">
|
||||
<text class="meta">剩余 {{ item.remaining_count }}/{{ item.capacity }}</text>
|
||||
<text class="course-price">¥{{ item.price }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
||||
<view v-else-if="sessions.length === 0" class="empty-state">当天暂无课程</view>
|
||||
</view>
|
||||
</template>
|
||||
</block>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||
import dayjs from 'dayjs'
|
||||
import { courseApi } from '@/modules/comprehensive/api/course'
|
||||
import { venueApi } from '@/modules/comprehensive/api/venue'
|
||||
import { membershipApi } from '@/modules/comprehensive/api/membership'
|
||||
import { ticketApi } from '@/modules/comprehensive/api/ticket'
|
||||
import { multiplyAmount } from '@/modules/comprehensive/utils/money'
|
||||
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
||||
import { formatTimeRange, todayString } from '@/modules/comprehensive/utils/date'
|
||||
import UnauthLoginPanel from '@/components/UnauthLoginPanel.vue'
|
||||
|
||||
const session = useComprehensiveSessionStore()
|
||||
const activeTab = ref('membership')
|
||||
const selectedDate = ref(todayString())
|
||||
const selectedCoachIndex = ref(0)
|
||||
const selectedCourtTypeIndex = ref(0)
|
||||
const sessions = ref([])
|
||||
const coaches = ref([])
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const total = ref(0)
|
||||
const isLoading = ref(false)
|
||||
const isLoadingMore = ref(false)
|
||||
|
||||
const courtTypeOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '羽毛球', value: 'badminton' },
|
||||
{ label: '篮球', value: 'basketball' }
|
||||
]
|
||||
const ticketProduct = ref(null)
|
||||
const ticketQty = ref(1)
|
||||
const ticketSubmitting = ref(false)
|
||||
const ticketTotal = computed(() => ticketProduct.value ? multiplyAmount(ticketProduct.value.unit_price, ticketQty.value) : '0.00')
|
||||
|
||||
const coachOptions = computed(() => [{ id: '', name: '全部教练' }, ...coaches.value])
|
||||
const selectedCoachId = computed(() => coachOptions.value[selectedCoachIndex.value]?.id || '')
|
||||
const selectedCoachName = computed(() => coachOptions.value[selectedCoachIndex.value]?.name || '全部教练')
|
||||
const selectedCourtType = computed(() => courtTypeOptions[selectedCourtTypeIndex.value]?.value || '')
|
||||
const selectedCourtTypeLabel = computed(() => courtTypeOptions[selectedCourtTypeIndex.value]?.label || '全部')
|
||||
const hasMore = computed(() => sessions.value.length < total.value)
|
||||
|
||||
onMounted(async () => {
|
||||
const coachData = await venueApi.getCoaches()
|
||||
coaches.value = coachData.items
|
||||
loadSessions(true)
|
||||
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
const dateOptions = computed(() => {
|
||||
const list = []
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = dayjs().add(i, 'day')
|
||||
list.push({
|
||||
value: d.format('YYYY-MM-DD'),
|
||||
label: i === 0 ? '今天' : i === 1 ? '明天' : weekDays[d.day()],
|
||||
date: `${d.month() + 1}/${d.date()}`
|
||||
})
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
const hasMore = computed(() => sessions.value.length < total.value)
|
||||
|
||||
const switchTab = (tab) => {
|
||||
activeTab.value = tab
|
||||
if (tab === 'tickets') {
|
||||
loadTicketProduct()
|
||||
} else {
|
||||
loadSessions(true)
|
||||
}
|
||||
}
|
||||
|
||||
const onDateSelect = (date) => {
|
||||
selectedDate.value = date
|
||||
loadSessions(true)
|
||||
}
|
||||
|
||||
const loadSessions = async (reset) => {
|
||||
if (activeTab.value === 'membership' && !session.isLoggedIn) return
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
isLoading.value = true
|
||||
@@ -88,13 +160,12 @@ const loadSessions = async (reset) => {
|
||||
isLoadingMore.value = true
|
||||
}
|
||||
try {
|
||||
const data = await courseApi.getSessions({
|
||||
page: page.value,
|
||||
pageSize,
|
||||
date: selectedDate.value,
|
||||
coachId: selectedCoachId.value,
|
||||
courtType: selectedCourtType.value
|
||||
})
|
||||
let data
|
||||
if (activeTab.value === 'membership') {
|
||||
data = await membershipApi.getAvailableSessions({ page: page.value, pageSize, date: selectedDate.value })
|
||||
} else {
|
||||
data = await courseApi.getSessions({ page: page.value, pageSize, date: selectedDate.value })
|
||||
}
|
||||
sessions.value = reset ? data.items : [...sessions.value, ...data.items]
|
||||
total.value = data.total
|
||||
} finally {
|
||||
@@ -103,36 +174,96 @@ const loadSessions = async (reset) => {
|
||||
}
|
||||
}
|
||||
|
||||
const onDateChange = (e) => {
|
||||
selectedDate.value = e.detail.value
|
||||
loadSessions(true)
|
||||
const loadTicketProduct = async () => {
|
||||
if (ticketProduct.value) return
|
||||
try {
|
||||
ticketProduct.value = await ticketApi.getTicketProduct()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const onCoachChange = (e) => {
|
||||
selectedCoachIndex.value = Number(e.detail.value)
|
||||
loadSessions(true)
|
||||
const decreaseTicketQty = () => {
|
||||
ticketQty.value = Math.max(1, ticketQty.value - 1)
|
||||
}
|
||||
|
||||
const onCourtTypeChange = (e) => {
|
||||
selectedCourtTypeIndex.value = Number(e.detail.value)
|
||||
loadSessions(true)
|
||||
const increaseTicketQty = () => {
|
||||
ticketQty.value += 1
|
||||
}
|
||||
|
||||
const goDetail = (id) => {
|
||||
uni.navigateTo({ url: `/pages/comprehensive/courses/detail?id=${id}` })
|
||||
const buyTickets = async () => {
|
||||
if (ticketSubmitting.value || !ticketProduct.value?.is_active) return
|
||||
ticketSubmitting.value = true
|
||||
try {
|
||||
const orderIds = []
|
||||
for (let i = 0; i < ticketQty.value; i++) {
|
||||
const data = await ticketApi.createTicketOrder()
|
||||
orderIds.push(data.order.id)
|
||||
}
|
||||
if (orderIds.length > 1) {
|
||||
uni.showToast({ title: `已创建${orderIds.length}笔订单,请逐笔完成支付`, icon: 'none', duration: 2000 })
|
||||
}
|
||||
uni.navigateTo({ url: `/pages/comprehensive/pay/index?order_id=${orderIds[0]}` })
|
||||
} finally {
|
||||
ticketSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goDetail = (item) => {
|
||||
if (item.booked && item.booking_id) {
|
||||
if (item.booking_type === 'card') {
|
||||
uni.navigateTo({ url: `/pages/comprehensive/membership/booking-detail?id=${item.booking_id}` })
|
||||
} else {
|
||||
uni.navigateTo({ url: `/pages/comprehensive/bookings/course-detail?id=${item.booking_id}` })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (activeTab.value === 'membership') {
|
||||
uni.navigateTo({ url: `/pages/comprehensive/courses/membership-detail?id=${item.id}` })
|
||||
} else {
|
||||
uni.navigateTo({ url: `/pages/comprehensive/courses/detail?id=${item.id}` })
|
||||
}
|
||||
}
|
||||
|
||||
const login = async () => {
|
||||
await session.login()
|
||||
loadSessions(true)
|
||||
}
|
||||
|
||||
const refresh = async () => {
|
||||
page.value = 1
|
||||
await loadSessions(true)
|
||||
if (activeTab.value === 'tickets') {
|
||||
ticketProduct.value = null
|
||||
await loadTicketProduct()
|
||||
} else {
|
||||
page.value = 1
|
||||
await loadSessions(true)
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (activeTab.value === 'tickets') return
|
||||
if (!hasMore.value || isLoadingMore.value) return
|
||||
page.value += 1
|
||||
loadSessions(false)
|
||||
}
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
await refresh()
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
loadMore()
|
||||
})
|
||||
|
||||
session.init().then(() => {
|
||||
if (activeTab.value === 'membership' && session.isLoggedIn) {
|
||||
loadSessions(true)
|
||||
} else if (activeTab.value === 'group') {
|
||||
loadSessions(true)
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({ refresh, loadMore })
|
||||
</script>
|
||||
|
||||
@@ -142,35 +273,93 @@ defineExpose({ refresh, loadMore })
|
||||
background: $bg-color-soft;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
.tab-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
height: 94rpx;
|
||||
display: flex;
|
||||
height: 88rpx;
|
||||
background: $bg-color-main;
|
||||
border-bottom: 1rpx solid $border-color;
|
||||
}
|
||||
|
||||
.filter-inner {
|
||||
height: 94rpx;
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: $text-color-muted;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
color: $text-color-main;
|
||||
font-weight: 900;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-item.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 48rpx;
|
||||
height: 4rpx;
|
||||
border-radius: 2rpx;
|
||||
background: $text-color-main;
|
||||
}
|
||||
|
||||
.date-bar {
|
||||
position: sticky;
|
||||
top: 88rpx;
|
||||
z-index: 9;
|
||||
background: $bg-color-main;
|
||||
border-bottom: 1rpx solid $border-color;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.date-list {
|
||||
display: inline-flex;
|
||||
padding: $spacing-sm $spacing-xs;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.date-chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 96rpx;
|
||||
height: 88rpx;
|
||||
border-radius: $border-radius-sm;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
color: $text-color-light;
|
||||
font-size: 20rpx;
|
||||
.date-chip.active {
|
||||
background: $text-color-main;
|
||||
}
|
||||
|
||||
.filter-value {
|
||||
margin-top: 4rpx;
|
||||
color: $text-color-main;
|
||||
.date-label {
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
color: $text-color-muted;
|
||||
}
|
||||
|
||||
.date-chip.active .date-label {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.date-day {
|
||||
font-size: 24rpx;
|
||||
font-weight: 800;
|
||||
color: $text-color-main;
|
||||
}
|
||||
|
||||
.date-chip.active .date-day {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.course-list {
|
||||
@@ -181,7 +370,6 @@ defineExpose({ refresh, loadMore })
|
||||
}
|
||||
|
||||
.course-card {
|
||||
@include minimal-card;
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
@@ -199,19 +387,31 @@ defineExpose({ refresh, loadMore })
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.course-top,
|
||||
.course-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.course-title {
|
||||
flex: 1;
|
||||
color: $text-color-main;
|
||||
font-size: 30rpx;
|
||||
font-weight: 900;
|
||||
line-height: 1.35;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.course-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.booked-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 4rpx 14rpx;
|
||||
border-radius: $border-radius-sm;
|
||||
background: $color-success-light;
|
||||
color: $color-success;
|
||||
font-size: 20rpx;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.time-line {
|
||||
@@ -229,9 +429,117 @@ defineExpose({ refresh, loadMore })
|
||||
font-size: 23rpx;
|
||||
}
|
||||
|
||||
.course-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
|
||||
.course-price {
|
||||
color: $text-color-main;
|
||||
font-size: 28rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
/* 篮球门票 */
|
||||
.ticket-section {
|
||||
padding: $spacing-md;
|
||||
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.product-card,
|
||||
.count-card {
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.product-head,
|
||||
.amount-row,
|
||||
.stepper-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.product-title,
|
||||
.card-title {
|
||||
display: block;
|
||||
color: $text-color-main;
|
||||
font-size: 34rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.product-subtitle {
|
||||
display: block;
|
||||
margin-top: $spacing-xs;
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.price-block {
|
||||
margin-top: $spacing-xl;
|
||||
}
|
||||
|
||||
.unit-price {
|
||||
color: $text-color-main;
|
||||
font-size: 64rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.unit-label {
|
||||
color: $text-color-muted;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.stepper-row {
|
||||
margin: $spacing-lg 0;
|
||||
}
|
||||
|
||||
.step-btn {
|
||||
width: 86rpx;
|
||||
height: 70rpx;
|
||||
border-radius: $border-radius-base;
|
||||
background: $text-color-main;
|
||||
color: #FFFFFF;
|
||||
font-size: 38rpx;
|
||||
font-weight: 900;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.quantity {
|
||||
color: $text-color-main;
|
||||
font-size: 42rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.amount-row {
|
||||
padding-top: $spacing-md;
|
||||
border-top: 1rpx solid $bg-color-hover;
|
||||
color: $text-color-muted;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.amount-row text:last-child {
|
||||
color: $text-color-main;
|
||||
font-size: 34rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.bar-price {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
display: block;
|
||||
color: $text-color-light;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 320rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
@@ -73,7 +73,6 @@ defineExpose({ refresh: loadPage })
|
||||
.home-container {
|
||||
min-height: 100vh;
|
||||
background-color: $bg-color-soft;
|
||||
margin-top: calc(-1 * var(--status-bar-height, 0px));
|
||||
}
|
||||
|
||||
.banner-wrapper {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<view class="detail-page">
|
||||
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
||||
|
||||
<block v-else-if="item">
|
||||
<view class="card price-card">
|
||||
<text class="item-name">{{ item.name }}</text>
|
||||
<text class="item-price">¥{{ item.price }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="type === 'membership'" class="card info-card">
|
||||
<view class="info-row">
|
||||
<text class="info-label">总次数</text>
|
||||
<text class="info-value">{{ item.total_count }} 次</text>
|
||||
</view>
|
||||
<view v-if="item.courses && item.courses.length" class="course-section">
|
||||
<text class="info-label">适用课程</text>
|
||||
<view class="course-tags">
|
||||
<text v-for="course in item.courses" :key="course.id" class="course-tag">{{ course.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="type === 'stored_value'" class="card info-card">
|
||||
<view class="info-row">
|
||||
<text class="info-label">支付金额</text>
|
||||
<text class="info-value">¥{{ item.price }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">到账金额</text>
|
||||
<text class="info-value highlight">¥{{ item.face_value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="fixed-action-bar">
|
||||
<button
|
||||
class="primary-button bar-btn"
|
||||
:disabled="submitting"
|
||||
@tap="handleBuy"
|
||||
>
|
||||
{{ submitting ? '提交中...' : '立即支付' }}
|
||||
</button>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<view v-else class="empty-state">商品不存在或已下架</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { membershipApi } from '@/modules/comprehensive/api/membership'
|
||||
import { storedValueApi } from '@/modules/comprehensive/api/storedValue'
|
||||
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
||||
|
||||
const session = useComprehensiveSessionStore()
|
||||
const type = ref('')
|
||||
const itemId = ref(null)
|
||||
const item = ref(null)
|
||||
const isLoading = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
onLoad(async (query) => {
|
||||
type.value = query.type || ''
|
||||
itemId.value = Number(query.id)
|
||||
await loadDetail()
|
||||
})
|
||||
|
||||
const loadDetail = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
if (type.value === 'membership') {
|
||||
const plans = await membershipApi.getPlans()
|
||||
item.value = plans.find((p) => p.id === itemId.value)
|
||||
} else if (type.value === 'stored_value') {
|
||||
const cards = await storedValueApi.getCards()
|
||||
item.value = cards.find((c) => c.id === itemId.value)
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const ensureLogin = async () => {
|
||||
if (session.isLoggedIn) return true
|
||||
try {
|
||||
await session.login()
|
||||
return true
|
||||
} catch (error) {
|
||||
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const handleBuy = async () => {
|
||||
if (submitting.value) return
|
||||
if (!(await ensureLogin())) return
|
||||
submitting.value = true
|
||||
try {
|
||||
let order
|
||||
if (type.value === 'membership') {
|
||||
order = await membershipApi.createMembershipOrder(item.value.id)
|
||||
} else {
|
||||
order = await storedValueApi.createOrder(item.value.id)
|
||||
}
|
||||
uni.redirectTo({ url: `/pages/comprehensive/pay/index?order_id=${order.id}` })
|
||||
} catch (error) {
|
||||
uni.showToast({ title: '下单失败,请重试', icon: 'none' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.detail-page {
|
||||
min-height: 100vh;
|
||||
padding: $spacing-md $spacing-md calc(160rpx + env(safe-area-inset-bottom));
|
||||
background: $bg-color-soft;
|
||||
}
|
||||
|
||||
.price-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
padding: $spacing-xl $spacing-md;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
color: $text-color-main;
|
||||
font-size: 36rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.item-price {
|
||||
color: $color-primary;
|
||||
font-size: 56rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: $text-color-muted;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: $text-color-main;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.info-value.highlight {
|
||||
color: $color-success;
|
||||
}
|
||||
|
||||
.course-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.course-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.course-tag {
|
||||
padding: 6rpx 16rpx;
|
||||
border-radius: $border-radius-sm;
|
||||
background: $color-primary-light;
|
||||
color: $color-primary;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bar-btn {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,74 +1,59 @@
|
||||
<template>
|
||||
<view class="mall-page">
|
||||
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">次卡套餐</text>
|
||||
<view class="tab-bar">
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === 'stored_value' }"
|
||||
@tap="activeTab = 'stored_value'"
|
||||
>
|
||||
储值卡
|
||||
</view>
|
||||
<view class="card-list">
|
||||
<view v-for="plan in plans" :key="`plan-${plan.id}`" class="card plan-card">
|
||||
<view class="card-top">
|
||||
<text class="item-name">{{ plan.name }}</text>
|
||||
<text class="item-price">¥{{ plan.price }}</text>
|
||||
</view>
|
||||
<text class="item-meta">总次数 {{ plan.total_count }}</text>
|
||||
<view v-if="plan.courses.length" class="course-tags">
|
||||
<text v-for="course in plan.courses" :key="course.id" class="course-tag">{{ course.title }}</text>
|
||||
</view>
|
||||
<button
|
||||
class="primary-button buy-btn"
|
||||
:disabled="submittingId === `plan-${plan.id}`"
|
||||
@tap="buyPlan(plan)"
|
||||
>
|
||||
{{ submittingId === `plan-${plan.id}` ? '提交中...' : '立即购买' }}
|
||||
</button>
|
||||
</view>
|
||||
<view v-if="!isLoading && plans.length === 0" class="empty-state">暂无可购买的次卡套餐</view>
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === 'membership' }"
|
||||
@tap="activeTab = 'membership'"
|
||||
>
|
||||
会员卡
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">储蓄卡</text>
|
||||
</view>
|
||||
<view class="card-list">
|
||||
<view v-for="card in cards" :key="`card-${card.id}`" class="card value-card">
|
||||
<view class="card-top">
|
||||
<text class="item-name">{{ card.name }}</text>
|
||||
<text class="item-price">¥{{ card.price }}</text>
|
||||
</view>
|
||||
<view class="value-row">
|
||||
<text class="value-label">支付</text>
|
||||
<text class="value-pay">¥{{ card.price }}</text>
|
||||
<text class="value-arrow">到账</text>
|
||||
<text class="value-face">¥{{ card.face_value }}</text>
|
||||
</view>
|
||||
<button
|
||||
class="primary-button buy-btn"
|
||||
:disabled="submittingId === `card-${card.id}`"
|
||||
@tap="buyCard(card)"
|
||||
>
|
||||
{{ submittingId === `card-${card.id}` ? '提交中...' : '立即购买' }}
|
||||
</button>
|
||||
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
||||
|
||||
<view v-else class="card-list">
|
||||
<view
|
||||
v-for="item in currentList"
|
||||
:key="item.id"
|
||||
class="card item-card"
|
||||
@tap="goDetail(item)"
|
||||
>
|
||||
<view class="card-body">
|
||||
<text class="item-name">{{ item.name }}</text>
|
||||
<text class="item-price">¥{{ item.price }}</text>
|
||||
</view>
|
||||
<view v-if="!isLoading && cards.length === 0" class="empty-state">暂无可购买的储蓄卡</view>
|
||||
</view>
|
||||
<view v-if="currentList.length === 0" class="empty-state">暂无可购买的商品</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { membershipApi } from '@/modules/comprehensive/api/membership'
|
||||
import { storedValueApi } from '@/modules/comprehensive/api/storedValue'
|
||||
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
||||
|
||||
const session = useComprehensiveSessionStore()
|
||||
const activeTab = ref('stored_value')
|
||||
const plans = ref([])
|
||||
const cards = ref([])
|
||||
const isLoading = ref(false)
|
||||
const submittingId = ref('')
|
||||
|
||||
const currentList = computed(() => {
|
||||
if (activeTab.value === 'membership') {
|
||||
return plans.value.map((p) => ({ ...p, _type: 'membership' }))
|
||||
}
|
||||
return cards.value.map((c) => ({ ...c, _type: 'stored_value' }))
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await session.init()
|
||||
@@ -89,39 +74,10 @@ const loadPage = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const ensureLogin = async () => {
|
||||
if (session.isLoggedIn) return true
|
||||
try {
|
||||
await session.login()
|
||||
return true
|
||||
} catch (error) {
|
||||
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const buyPlan = async (plan) => {
|
||||
if (submittingId.value) return
|
||||
if (!(await ensureLogin())) return
|
||||
submittingId.value = `plan-${plan.id}`
|
||||
try {
|
||||
const order = await membershipApi.createMembershipOrder(plan.id)
|
||||
uni.navigateTo({ url: `/pages/comprehensive/pay/index?order_id=${order.id}` })
|
||||
} finally {
|
||||
submittingId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const buyCard = async (card) => {
|
||||
if (submittingId.value) return
|
||||
if (!(await ensureLogin())) return
|
||||
submittingId.value = `card-${card.id}`
|
||||
try {
|
||||
const order = await storedValueApi.createOrder(card.id)
|
||||
uni.navigateTo({ url: `/pages/comprehensive/pay/index?order_id=${order.id}` })
|
||||
} finally {
|
||||
submittingId.value = ''
|
||||
}
|
||||
const goDetail = (item) => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/comprehensive/mall/detail?type=${item._type}&id=${item.id}`
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({ refresh: loadPage })
|
||||
@@ -130,40 +86,70 @@ defineExpose({ refresh: loadPage })
|
||||
<style lang="scss" scoped>
|
||||
.mall-page {
|
||||
min-height: 100vh;
|
||||
padding: $spacing-md $spacing-md calc($spacing-lg + env(safe-area-inset-bottom));
|
||||
background: $bg-color-soft;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: $spacing-lg;
|
||||
.tab-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
height: 88rpx;
|
||||
background: $bg-color-main;
|
||||
border-bottom: 1rpx solid $border-color;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: $spacing-sm;
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: $text-color-muted;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
.tab-item.active {
|
||||
color: $text-color-main;
|
||||
font-size: 32rpx;
|
||||
font-weight: 900;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-item.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 48rpx;
|
||||
height: 4rpx;
|
||||
border-radius: 2rpx;
|
||||
background: $text-color-main;
|
||||
}
|
||||
|
||||
.card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
gap: $spacing-sm;
|
||||
padding: $spacing-md;
|
||||
padding-bottom: calc($spacing-lg + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.plan-card,
|
||||
.value-card {
|
||||
.item-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.card-top {
|
||||
.item-card:active {
|
||||
background-color: $bg-color-hover;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
@@ -171,7 +157,7 @@ defineExpose({ refresh: loadPage })
|
||||
flex: 1;
|
||||
color: $text-color-main;
|
||||
font-size: 30rpx;
|
||||
font-weight: 900;
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
@@ -180,56 +166,4 @@ defineExpose({ refresh: loadPage })
|
||||
font-size: 36rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
display: block;
|
||||
margin-top: $spacing-sm;
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.course-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-xs;
|
||||
margin-top: $spacing-sm;
|
||||
}
|
||||
|
||||
.course-tag {
|
||||
padding: 4rpx 14rpx;
|
||||
border-radius: $border-radius-sm;
|
||||
background: $color-primary-light;
|
||||
color: $color-primary;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.value-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
margin-top: $spacing-sm;
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.value-label,
|
||||
.value-arrow {
|
||||
color: $text-color-light;
|
||||
}
|
||||
|
||||
.value-pay {
|
||||
color: $text-color-main;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.value-face {
|
||||
color: $color-success;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.buy-btn {
|
||||
margin-top: $spacing-md;
|
||||
height: 80rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user