feat: 商城改版、约课页合并三Tab、课程已预约状态后端注入
- 商城页改为Tab切换(储值卡/会员卡)+卡片列表,新增商品详情页 - 约课页合并会员卡/团课/门票三Tab,日期横滑选择器 - 会员卡预约新增详情页+二次确认弹窗 - 课程列表已预约状态改为后端返回booked/booking_id/booking_type - 前端移除冗余的loadBookings调用,直接读session.booked字段 - 导航栏仅首页custom,其余页面恢复原生导航栏 - membership API支持date参数筛选
This commit is contained in:
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
VITE_ICE_API_BASE_URL=https://venue.test.chatgqt.top
|
VITE_ICE_API_BASE_URL=https://8002.frp.chatgqt.top
|
||||||
VITE_COMPREHENSIVE_API_BASE_URL=https://venue2.test.chatgqt.top
|
VITE_COMPREHENSIVE_API_BASE_URL=https://8002.frp.chatgqt.top
|
||||||
@@ -21,7 +21,6 @@ page {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
padding-top: var(--status-bar-height, 0px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
view,
|
view,
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export const toSession = (backendSession = {}) => ({
|
|||||||
status_label: backendSession.status_label || backendSession.status || '',
|
status_label: backendSession.status_label || backendSession.status || '',
|
||||||
courts: toArray(backendSession.courts).map(toCourseCourt),
|
courts: toArray(backendSession.courts).map(toCourseCourt),
|
||||||
detail_html: backendSession.detail_html || '',
|
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 || '',
|
created_at: backendSession.created_at || '',
|
||||||
updated_at: backendSession.updated_at || ''
|
updated_at: backendSession.updated_at || ''
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { request } from './request'
|
import { request } from './request'
|
||||||
import { toArray, toNumber, toPageQuery, toPagination } from './mappers'
|
import { toArray, toNumber, toPageQuery, toPagination, compactParams } from './mappers'
|
||||||
import { toOrderBrief } from './order'
|
import { toOrderBrief } from './order'
|
||||||
|
|
||||||
const toPlan = (item = {}) => ({
|
const toPlan = (item = {}) => ({
|
||||||
@@ -51,7 +51,10 @@ const toAvailableSession = (backendSession = {}) => ({
|
|||||||
booked_count: toNumber(backendSession.booked_count),
|
booked_count: toNumber(backendSession.booked_count),
|
||||||
remaining_count: toNumber(backendSession.remaining_count),
|
remaining_count: toNumber(backendSession.remaining_count),
|
||||||
price: backendSession.price || '0.00',
|
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 = {}) => ({
|
const toCardBookingSession = (backendSession = {}) => ({
|
||||||
@@ -116,7 +119,10 @@ export const membershipApi = {
|
|||||||
getAvailableSessions(params = {}) {
|
getAvailableSessions(params = {}) {
|
||||||
return request({
|
return request({
|
||||||
url: '/api/membership/available-courses',
|
url: '/api/membership/available-courses',
|
||||||
data: toPageQuery(params)
|
data: compactParams({
|
||||||
|
...toPageQuery(params),
|
||||||
|
date: params.date
|
||||||
|
})
|
||||||
}).then((data) => toPagination(data, toAvailableSession))
|
}).then((data) => toPagination(data, toAvailableSession))
|
||||||
},
|
},
|
||||||
createCardBooking({ cardId, sessionId }) {
|
createCardBooking({ cardId, sessionId }) {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"path": "pages/home/index",
|
"path": "pages/home/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "首页",
|
"navigationBarTitleText": "首页",
|
||||||
|
"navigationStyle": "custom",
|
||||||
"enablePullDownRefresh": true
|
"enablePullDownRefresh": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -92,6 +93,12 @@
|
|||||||
"navigationBarTitleText": "课程详情"
|
"navigationBarTitleText": "课程详情"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/comprehensive/courses/membership-detail",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "次卡预约"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/comprehensive/courses/confirm",
|
"path": "pages/comprehensive/courses/confirm",
|
||||||
"style": {
|
"style": {
|
||||||
@@ -174,6 +181,12 @@
|
|||||||
"enablePullDownRefresh": true
|
"enablePullDownRefresh": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/comprehensive/mall/detail",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "商品详情"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/comprehensive/membership/cards",
|
"path": "pages/comprehensive/membership/cards",
|
||||||
"style": {
|
"style": {
|
||||||
@@ -317,7 +330,6 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"globalStyle": {
|
"globalStyle": {
|
||||||
"navigationStyle": "custom",
|
|
||||||
"navigationBarTextStyle": "black",
|
"navigationBarTextStyle": "black",
|
||||||
"navigationBarBackgroundColor": "#FFFFFF",
|
"navigationBarBackgroundColor": "#FFFFFF",
|
||||||
"backgroundColor": "#F8FAFC",
|
"backgroundColor": "#F8FAFC",
|
||||||
|
|||||||
@@ -1,86 +1,158 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="courses-page">
|
<view class="courses-page">
|
||||||
<view class="filter-bar">
|
<UnauthLoginPanel
|
||||||
<picker mode="date" :value="selectedDate" @change="onDateChange" class="filter-item">
|
v-if="activeTab === 'membership' && !session.isLoggedIn"
|
||||||
<view class="filter-inner">
|
@login="login"
|
||||||
<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>
|
|
||||||
|
|
||||||
<view class="course-list">
|
<block v-else>
|
||||||
<view v-for="session in sessions" :key="session.id" class="course-card" @tap="goDetail(session.id)">
|
<view class="tab-bar">
|
||||||
<image :src="session.cover_image" mode="aspectFill" class="course-cover" />
|
<view class="tab-item" :class="{ active: activeTab === 'membership' }" @tap="switchTab('membership')">会员卡</view>
|
||||||
<view class="course-info">
|
<view class="tab-item" :class="{ active: activeTab === 'group' }" @tap="switchTab('group')">团课</view>
|
||||||
<view class="course-top">
|
<view class="tab-item" :class="{ active: activeTab === 'tickets' }" @tap="switchTab('tickets')">门票</view>
|
||||||
<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>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
<template v-if="activeTab === 'tickets'">
|
||||||
<view v-else-if="sessions.length === 0" class="empty-state">当前筛选条件下暂无课程</view>
|
<view v-if="ticketProduct" class="ticket-section">
|
||||||
<view v-else-if="!hasMore" class="load-more-tip">已加载全部</view>
|
<view class="card product-card">
|
||||||
</view>
|
<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>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 { 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 { 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 selectedDate = ref(todayString())
|
||||||
const selectedCoachIndex = ref(0)
|
|
||||||
const selectedCourtTypeIndex = ref(0)
|
|
||||||
const sessions = ref([])
|
const sessions = ref([])
|
||||||
const coaches = ref([])
|
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = 20
|
const pageSize = 20
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const isLoadingMore = ref(false)
|
const isLoadingMore = ref(false)
|
||||||
|
|
||||||
const courtTypeOptions = [
|
const ticketProduct = ref(null)
|
||||||
{ label: '全部', value: '' },
|
const ticketQty = ref(1)
|
||||||
{ label: '羽毛球', value: 'badminton' },
|
const ticketSubmitting = ref(false)
|
||||||
{ label: '篮球', value: 'basketball' }
|
const ticketTotal = computed(() => ticketProduct.value ? multiplyAmount(ticketProduct.value.unit_price, ticketQty.value) : '0.00')
|
||||||
]
|
|
||||||
|
|
||||||
const coachOptions = computed(() => [{ id: '', name: '全部教练' }, ...coaches.value])
|
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||||
const selectedCoachId = computed(() => coachOptions.value[selectedCoachIndex.value]?.id || '')
|
const dateOptions = computed(() => {
|
||||||
const selectedCoachName = computed(() => coachOptions.value[selectedCoachIndex.value]?.name || '全部教练')
|
const list = []
|
||||||
const selectedCourtType = computed(() => courtTypeOptions[selectedCourtTypeIndex.value]?.value || '')
|
for (let i = 0; i < 7; i++) {
|
||||||
const selectedCourtTypeLabel = computed(() => courtTypeOptions[selectedCourtTypeIndex.value]?.label || '全部')
|
const d = dayjs().add(i, 'day')
|
||||||
const hasMore = computed(() => sessions.value.length < total.value)
|
list.push({
|
||||||
|
value: d.format('YYYY-MM-DD'),
|
||||||
onMounted(async () => {
|
label: i === 0 ? '今天' : i === 1 ? '明天' : weekDays[d.day()],
|
||||||
const coachData = await venueApi.getCoaches()
|
date: `${d.month() + 1}/${d.date()}`
|
||||||
coaches.value = coachData.items
|
})
|
||||||
loadSessions(true)
|
}
|
||||||
|
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) => {
|
const loadSessions = async (reset) => {
|
||||||
|
if (activeTab.value === 'membership' && !session.isLoggedIn) return
|
||||||
if (reset) {
|
if (reset) {
|
||||||
page.value = 1
|
page.value = 1
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
@@ -88,13 +160,12 @@ const loadSessions = async (reset) => {
|
|||||||
isLoadingMore.value = true
|
isLoadingMore.value = true
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = await courseApi.getSessions({
|
let data
|
||||||
page: page.value,
|
if (activeTab.value === 'membership') {
|
||||||
pageSize,
|
data = await membershipApi.getAvailableSessions({ page: page.value, pageSize, date: selectedDate.value })
|
||||||
date: selectedDate.value,
|
} else {
|
||||||
coachId: selectedCoachId.value,
|
data = await courseApi.getSessions({ page: page.value, pageSize, date: selectedDate.value })
|
||||||
courtType: selectedCourtType.value
|
}
|
||||||
})
|
|
||||||
sessions.value = reset ? data.items : [...sessions.value, ...data.items]
|
sessions.value = reset ? data.items : [...sessions.value, ...data.items]
|
||||||
total.value = data.total
|
total.value = data.total
|
||||||
} finally {
|
} finally {
|
||||||
@@ -103,36 +174,96 @@ const loadSessions = async (reset) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onDateChange = (e) => {
|
const loadTicketProduct = async () => {
|
||||||
selectedDate.value = e.detail.value
|
if (ticketProduct.value) return
|
||||||
loadSessions(true)
|
try {
|
||||||
|
ticketProduct.value = await ticketApi.getTicketProduct()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onCoachChange = (e) => {
|
const decreaseTicketQty = () => {
|
||||||
selectedCoachIndex.value = Number(e.detail.value)
|
ticketQty.value = Math.max(1, ticketQty.value - 1)
|
||||||
loadSessions(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onCourtTypeChange = (e) => {
|
const increaseTicketQty = () => {
|
||||||
selectedCourtTypeIndex.value = Number(e.detail.value)
|
ticketQty.value += 1
|
||||||
loadSessions(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const goDetail = (id) => {
|
const buyTickets = async () => {
|
||||||
uni.navigateTo({ url: `/pages/comprehensive/courses/detail?id=${id}` })
|
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 () => {
|
const refresh = async () => {
|
||||||
page.value = 1
|
if (activeTab.value === 'tickets') {
|
||||||
await loadSessions(true)
|
ticketProduct.value = null
|
||||||
|
await loadTicketProduct()
|
||||||
|
} else {
|
||||||
|
page.value = 1
|
||||||
|
await loadSessions(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadMore = () => {
|
const loadMore = () => {
|
||||||
|
if (activeTab.value === 'tickets') return
|
||||||
if (!hasMore.value || isLoadingMore.value) return
|
if (!hasMore.value || isLoadingMore.value) return
|
||||||
page.value += 1
|
page.value += 1
|
||||||
loadSessions(false)
|
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 })
|
defineExpose({ refresh, loadMore })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -142,35 +273,93 @@ defineExpose({ refresh, loadMore })
|
|||||||
background: $bg-color-soft;
|
background: $bg-color-soft;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-bar {
|
.tab-bar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
height: 88rpx;
|
||||||
height: 94rpx;
|
|
||||||
background: $bg-color-main;
|
background: $bg-color-main;
|
||||||
border-bottom: 1rpx solid $border-color;
|
border-bottom: 1rpx solid $border-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-inner {
|
.tab-item {
|
||||||
height: 94rpx;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
width: 96rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: $border-radius-sm;
|
||||||
|
gap: 4rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-label {
|
.date-chip.active {
|
||||||
color: $text-color-light;
|
background: $text-color-main;
|
||||||
font-size: 20rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-value {
|
.date-label {
|
||||||
margin-top: 4rpx;
|
font-size: 22rpx;
|
||||||
color: $text-color-main;
|
font-weight: 700;
|
||||||
|
color: $text-color-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-chip.active .date-label {
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-day {
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
color: $text-color-main;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-chip.active .date-day {
|
||||||
|
color: #FFFFFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
.course-list {
|
.course-list {
|
||||||
@@ -181,7 +370,6 @@ defineExpose({ refresh, loadMore })
|
|||||||
}
|
}
|
||||||
|
|
||||||
.course-card {
|
.course-card {
|
||||||
@include minimal-card;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: $spacing-md;
|
gap: $spacing-md;
|
||||||
}
|
}
|
||||||
@@ -199,19 +387,31 @@ defineExpose({ refresh, loadMore })
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.course-top,
|
|
||||||
.course-bottom {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: $spacing-sm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.course-title {
|
.course-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
color: $text-color-main;
|
color: $text-color-main;
|
||||||
font-size: 30rpx;
|
font-size: 30rpx;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
line-height: 1.35;
|
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 {
|
.time-line {
|
||||||
@@ -229,9 +429,117 @@ defineExpose({ refresh, loadMore })
|
|||||||
font-size: 23rpx;
|
font-size: 23rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.course-bottom {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
margin-top: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.course-price {
|
.course-price {
|
||||||
color: $text-color-main;
|
color: $text-color-main;
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
font-weight: 900;
|
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>
|
</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 {
|
.home-container {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background-color: $bg-color-soft;
|
background-color: $bg-color-soft;
|
||||||
margin-top: calc(-1 * var(--status-bar-height, 0px));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner-wrapper {
|
.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>
|
<template>
|
||||||
<view class="mall-page">
|
<view class="mall-page">
|
||||||
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
<view class="tab-bar">
|
||||||
|
<view
|
||||||
<view class="section">
|
class="tab-item"
|
||||||
<view class="section-header">
|
:class="{ active: activeTab === 'stored_value' }"
|
||||||
<text class="section-title">次卡套餐</text>
|
@tap="activeTab = 'stored_value'"
|
||||||
|
>
|
||||||
|
储值卡
|
||||||
</view>
|
</view>
|
||||||
<view class="card-list">
|
<view
|
||||||
<view v-for="plan in plans" :key="`plan-${plan.id}`" class="card plan-card">
|
class="tab-item"
|
||||||
<view class="card-top">
|
:class="{ active: activeTab === 'membership' }"
|
||||||
<text class="item-name">{{ plan.name }}</text>
|
@tap="activeTab = 'membership'"
|
||||||
<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>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="section">
|
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
||||||
<view class="section-header">
|
|
||||||
<text class="section-title">储蓄卡</text>
|
<view v-else class="card-list">
|
||||||
</view>
|
<view
|
||||||
<view class="card-list">
|
v-for="item in currentList"
|
||||||
<view v-for="card in cards" :key="`card-${card.id}`" class="card value-card">
|
:key="item.id"
|
||||||
<view class="card-top">
|
class="card item-card"
|
||||||
<text class="item-name">{{ card.name }}</text>
|
@tap="goDetail(item)"
|
||||||
<text class="item-price">¥{{ card.price }}</text>
|
>
|
||||||
</view>
|
<view class="card-body">
|
||||||
<view class="value-row">
|
<text class="item-name">{{ item.name }}</text>
|
||||||
<text class="value-label">支付</text>
|
<text class="item-price">¥{{ item.price }}</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>
|
</view>
|
||||||
<view v-if="!isLoading && cards.length === 0" class="empty-state">暂无可购买的储蓄卡</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
<view v-if="currentList.length === 0" class="empty-state">暂无可购买的商品</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { membershipApi } from '@/modules/comprehensive/api/membership'
|
import { membershipApi } from '@/modules/comprehensive/api/membership'
|
||||||
import { storedValueApi } from '@/modules/comprehensive/api/storedValue'
|
import { storedValueApi } from '@/modules/comprehensive/api/storedValue'
|
||||||
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
||||||
|
|
||||||
const session = useComprehensiveSessionStore()
|
const session = useComprehensiveSessionStore()
|
||||||
|
const activeTab = ref('stored_value')
|
||||||
const plans = ref([])
|
const plans = ref([])
|
||||||
const cards = ref([])
|
const cards = ref([])
|
||||||
const isLoading = ref(false)
|
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 () => {
|
onMounted(async () => {
|
||||||
await session.init()
|
await session.init()
|
||||||
@@ -89,39 +74,10 @@ const loadPage = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ensureLogin = async () => {
|
const goDetail = (item) => {
|
||||||
if (session.isLoggedIn) return true
|
uni.navigateTo({
|
||||||
try {
|
url: `/pages/comprehensive/mall/detail?type=${item._type}&id=${item.id}`
|
||||||
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 = ''
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({ refresh: loadPage })
|
defineExpose({ refresh: loadPage })
|
||||||
@@ -130,40 +86,70 @@ defineExpose({ refresh: loadPage })
|
|||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.mall-page {
|
.mall-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: $spacing-md $spacing-md calc($spacing-lg + env(safe-area-inset-bottom));
|
|
||||||
background: $bg-color-soft;
|
background: $bg-color-soft;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
.tab-bar {
|
||||||
margin-bottom: $spacing-lg;
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
height: 88rpx;
|
||||||
|
background: $bg-color-main;
|
||||||
|
border-bottom: 1rpx solid $border-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-header {
|
.tab-item {
|
||||||
margin-bottom: $spacing-sm;
|
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;
|
color: $text-color-main;
|
||||||
font-size: 32rpx;
|
|
||||||
font-weight: 900;
|
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 {
|
.card-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $spacing-md;
|
gap: $spacing-sm;
|
||||||
|
padding: $spacing-md;
|
||||||
|
padding-bottom: calc($spacing-lg + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-card,
|
.item-card {
|
||||||
.value-card {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-top {
|
.item-card:active {
|
||||||
|
background-color: $bg-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
gap: $spacing-sm;
|
gap: $spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +157,7 @@ defineExpose({ refresh: loadPage })
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
color: $text-color-main;
|
color: $text-color-main;
|
||||||
font-size: 30rpx;
|
font-size: 30rpx;
|
||||||
font-weight: 900;
|
font-weight: 800;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,56 +166,4 @@ defineExpose({ refresh: loadPage })
|
|||||||
font-size: 36rpx;
|
font-size: 36rpx;
|
||||||
font-weight: 900;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -119,7 +119,6 @@ defineExpose({ refresh: loadHome })
|
|||||||
.home-container {
|
.home-container {
|
||||||
background-color: $bg-color-soft;
|
background-color: $bg-color-soft;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
margin-top: calc(-1 * var(--status-bar-height, 0px));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 全屏轮播 */
|
/* 全屏轮播 */
|
||||||
|
|||||||
Reference in New Issue
Block a user