feat: 商城改版、约课页合并三Tab、课程已预约状态后端注入

- 商城页改为Tab切换(储值卡/会员卡)+卡片列表,新增商品详情页
- 约课页合并会员卡/团课/门票三Tab,日期横滑选择器
- 会员卡预约新增详情页+二次确认弹窗
- 课程列表已预约状态改为后端返回booked/booking_id/booking_type
- 前端移除冗余的loadBookings调用,直接读session.booked字段
- 导航栏仅首页custom,其余页面恢复原生导航栏
- membership API支持date参数筛选
This commit is contained in:
gqt
2026-07-13 20:37:31 +08:00
parent 5c97e8b1f8
commit b06cf69942
11 changed files with 1063 additions and 256 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
VITE_ICE_API_BASE_URL=https://venue.test.chatgqt.top
VITE_COMPREHENSIVE_API_BASE_URL=https://venue2.test.chatgqt.top
VITE_ICE_API_BASE_URL=https://8002.frp.chatgqt.top
VITE_COMPREHENSIVE_API_BASE_URL=https://8002.frp.chatgqt.top
-1
View File
@@ -21,7 +21,6 @@ page {
line-height: 1.5;
box-sizing: border-box;
-webkit-text-size-adjust: 100%;
padding-top: var(--status-bar-height, 0px);
}
view,
@@ -25,6 +25,9 @@ export const toSession = (backendSession = {}) => ({
status_label: backendSession.status_label || backendSession.status || '',
courts: toArray(backendSession.courts).map(toCourseCourt),
detail_html: backendSession.detail_html || '',
booked: backendSession.booked === true,
booking_id: backendSession.booking_id ?? null,
booking_type: backendSession.booking_type || null,
created_at: backendSession.created_at || '',
updated_at: backendSession.updated_at || ''
})
@@ -1,5 +1,5 @@
import { request } from './request'
import { toArray, toNumber, toPageQuery, toPagination } from './mappers'
import { toArray, toNumber, toPageQuery, toPagination, compactParams } from './mappers'
import { toOrderBrief } from './order'
const toPlan = (item = {}) => ({
@@ -51,7 +51,10 @@ const toAvailableSession = (backendSession = {}) => ({
booked_count: toNumber(backendSession.booked_count),
remaining_count: toNumber(backendSession.remaining_count),
price: backendSession.price || '0.00',
available_card_ids: toArray(backendSession.available_card_ids).map(toNumber)
available_card_ids: toArray(backendSession.available_card_ids).map(toNumber),
booked: backendSession.booked === true,
booking_id: backendSession.booking_id ?? null,
booking_type: backendSession.booking_type || null
})
const toCardBookingSession = (backendSession = {}) => ({
@@ -116,7 +119,10 @@ export const membershipApi = {
getAvailableSessions(params = {}) {
return request({
url: '/api/membership/available-courses',
data: toPageQuery(params)
data: compactParams({
...toPageQuery(params),
date: params.date
})
}).then((data) => toPagination(data, toAvailableSession))
},
createCardBooking({ cardId, sessionId }) {
+13 -1
View File
@@ -20,6 +20,7 @@
"path": "pages/home/index",
"style": {
"navigationBarTitleText": "首页",
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
},
@@ -92,6 +93,12 @@
"navigationBarTitleText": "课程详情"
}
},
{
"path": "pages/comprehensive/courses/membership-detail",
"style": {
"navigationBarTitleText": "次卡预约"
}
},
{
"path": "pages/comprehensive/courses/confirm",
"style": {
@@ -174,6 +181,12 @@
"enablePullDownRefresh": true
}
},
{
"path": "pages/comprehensive/mall/detail",
"style": {
"navigationBarTitleText": "商品详情"
}
},
{
"path": "pages/comprehensive/membership/cards",
"style": {
@@ -317,7 +330,6 @@
]
},
"globalStyle": {
"navigationStyle": "custom",
"navigationBarTextStyle": "black",
"navigationBarBackgroundColor": "#FFFFFF",
"backgroundColor": "#F8FAFC",
@@ -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>
-1
View File
@@ -119,7 +119,6 @@ defineExpose({ refresh: loadHome })
.home-container {
background-color: $bg-color-soft;
min-height: 100vh;
margin-top: calc(-1 * var(--status-bar-height, 0px));
}
/* 全屏轮播 */