feat: add membership card course booking flow
This commit is contained in:
@@ -10,15 +10,26 @@ const expectIncludes = (content, expected, label) => {
|
|||||||
if (!content.includes(expected)) failures.push(`${label} is missing: ${expected}`)
|
if (!content.includes(expected)) failures.push(`${label} is missing: ${expected}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const expectExcludes = (content, unexpected, label) => {
|
||||||
|
if (content.includes(unexpected)) failures.push(`${label} should not contain: ${unexpected}`)
|
||||||
|
}
|
||||||
|
|
||||||
const membershipApi = readFileSync(resolve(srcRoot, 'modules/comprehensive/api/membership.js'), 'utf8')
|
const membershipApi = readFileSync(resolve(srcRoot, 'modules/comprehensive/api/membership.js'), 'utf8')
|
||||||
const storedValueApi = readFileSync(resolve(srcRoot, 'modules/comprehensive/api/storedValue.js'), 'utf8')
|
const storedValueApi = readFileSync(resolve(srcRoot, 'modules/comprehensive/api/storedValue.js'), 'utf8')
|
||||||
const pagesJson = readFileSync(resolve(srcRoot, 'pages.json'), 'utf8')
|
const pagesJson = readFileSync(resolve(srcRoot, 'pages.json'), 'utf8')
|
||||||
const mallPage = readFileSync(resolve(srcRoot, 'pages/comprehensive/mall/index.vue'), 'utf8')
|
const mallPage = readFileSync(resolve(srcRoot, 'pages/comprehensive/mall/index.vue'), 'utf8')
|
||||||
|
const availablePage = readFileSync(resolve(srcRoot, 'pages/comprehensive/membership/available-sessions.vue'), 'utf8')
|
||||||
|
const bookingDetail = readFileSync(resolve(srcRoot, 'pages/comprehensive/membership/booking-detail.vue'), 'utf8')
|
||||||
|
|
||||||
expectIncludes(membershipApi, "url: '/api/membership/plans'", 'membership plans')
|
expectIncludes(membershipApi, "url: '/api/membership/plans'", 'membership plans')
|
||||||
expectIncludes(membershipApi, "url: '/api/membership/orders'", 'membership order')
|
expectIncludes(membershipApi, "url: '/api/membership/orders'", 'membership order')
|
||||||
expectIncludes(membershipApi, 'data: { plan_id: planId }', 'membership payload')
|
expectIncludes(membershipApi, 'data: { plan_id: planId }', 'membership payload')
|
||||||
expectIncludes(membershipApi, 'toOrderBrief(data.order)', 'membership order mapping')
|
expectIncludes(membershipApi, 'toOrderBrief(data.order)', 'membership order mapping')
|
||||||
|
expectIncludes(membershipApi, "url: '/api/membership/cards'", 'member cards')
|
||||||
|
expectIncludes(membershipApi, 'cards/${cardId}/usage-records', 'card usage records')
|
||||||
|
expectIncludes(membershipApi, "url: '/api/membership/available-courses'", 'available sessions')
|
||||||
|
expectIncludes(membershipApi, "url: '/api/membership/bookings'", 'card bookings')
|
||||||
|
expectIncludes(membershipApi, 'data: { card_id: cardId, session_id: sessionId }', 'card booking payload')
|
||||||
expectIncludes(storedValueApi, "url: '/api/stored-value/cards'", 'stored value cards')
|
expectIncludes(storedValueApi, "url: '/api/stored-value/cards'", 'stored value cards')
|
||||||
expectIncludes(storedValueApi, "url: '/api/stored-value/orders'", 'stored value order')
|
expectIncludes(storedValueApi, "url: '/api/stored-value/orders'", 'stored value order')
|
||||||
expectIncludes(storedValueApi, 'data: { card_id: cardId }', 'stored value payload')
|
expectIncludes(storedValueApi, 'data: { card_id: cardId }', 'stored value payload')
|
||||||
@@ -26,6 +37,8 @@ expectIncludes(storedValueApi, 'toOrderBrief(data.order)', 'stored value order m
|
|||||||
expectIncludes(pagesJson, 'pages/comprehensive/mall/index', 'mall route')
|
expectIncludes(pagesJson, 'pages/comprehensive/mall/index', 'mall route')
|
||||||
expectIncludes(mallPage, '储蓄卡', 'stored value UI copy')
|
expectIncludes(mallPage, '储蓄卡', 'stored value UI copy')
|
||||||
expectIncludes(mallPage, 'face_value', 'face value display')
|
expectIncludes(mallPage, 'face_value', 'face value display')
|
||||||
|
expectIncludes(availablePage, 'available_card_ids', 'available card selection')
|
||||||
|
expectExcludes(bookingDetail, 'booking.order', 'card booking has no order')
|
||||||
|
|
||||||
if (failures.length) {
|
if (failures.length) {
|
||||||
console.error(`Comprehensive commerce verification failed with ${failures.length} issue(s):`)
|
console.error(`Comprehensive commerce verification failed with ${failures.length} issue(s):`)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { request } from './request'
|
import { request } from './request'
|
||||||
import { toArray, toNumber } from './mappers'
|
import { toArray, toNumber, toPageQuery, toPagination } from './mappers'
|
||||||
import { toOrderBrief } from './order'
|
import { toOrderBrief } from './order'
|
||||||
|
|
||||||
const toPlan = (item = {}) => ({
|
const toPlan = (item = {}) => ({
|
||||||
@@ -15,6 +15,79 @@ const toPlan = (item = {}) => ({
|
|||||||
created_at: item.created_at || ''
|
created_at: item.created_at || ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const toCard = (backendCard = {}) => ({
|
||||||
|
id: backendCard.id ?? null,
|
||||||
|
plan_id: backendCard.plan_id ?? null,
|
||||||
|
plan_name: backendCard.plan_name || '',
|
||||||
|
total_count: toNumber(backendCard.total_count),
|
||||||
|
remaining_count: toNumber(backendCard.remaining_count),
|
||||||
|
status: backendCard.status || '',
|
||||||
|
status_label: backendCard.status_label || backendCard.status || '',
|
||||||
|
order_id: backendCard.order_id ?? null,
|
||||||
|
created_at: backendCard.created_at || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const toUsageRecord = (backendRecord = {}) => ({
|
||||||
|
id: backendRecord.id ?? null,
|
||||||
|
card_id: backendRecord.card_id ?? null,
|
||||||
|
count: toNumber(backendRecord.count),
|
||||||
|
kind: backendRecord.kind || '',
|
||||||
|
kind_label: backendRecord.kind_label || backendRecord.kind || '',
|
||||||
|
booking_id: backendRecord.booking_id ?? null,
|
||||||
|
order_id: backendRecord.order_id ?? null,
|
||||||
|
remark: backendRecord.remark || '',
|
||||||
|
created_at: backendRecord.created_at || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const toAvailableSession = (backendSession = {}) => ({
|
||||||
|
id: backendSession.id ?? null,
|
||||||
|
course_id: backendSession.course_id ?? null,
|
||||||
|
title: backendSession.title || '',
|
||||||
|
cover_image: backendSession.cover_image || '',
|
||||||
|
coach_name: backendSession.coach_name || '',
|
||||||
|
start_at: backendSession.start_at || '',
|
||||||
|
end_at: backendSession.end_at || '',
|
||||||
|
capacity: toNumber(backendSession.capacity),
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
const toCardBookingSession = (backendSession = {}) => ({
|
||||||
|
id: backendSession.id ?? null,
|
||||||
|
course_id: backendSession.course_id ?? null,
|
||||||
|
title: backendSession.title || '',
|
||||||
|
cover_image: backendSession.cover_image || '',
|
||||||
|
coach_name: backendSession.coach_name || '',
|
||||||
|
start_at: backendSession.start_at || '',
|
||||||
|
end_at: backendSession.end_at || '',
|
||||||
|
capacity: toNumber(backendSession.capacity),
|
||||||
|
booked_count: toNumber(backendSession.booked_count),
|
||||||
|
remaining_count: toNumber(backendSession.remaining_count),
|
||||||
|
price: backendSession.price || '0.00'
|
||||||
|
})
|
||||||
|
|
||||||
|
const toCardBookingCard = (backendCard = {}) => ({
|
||||||
|
id: backendCard.id ?? null,
|
||||||
|
plan_name: backendCard.plan_name || '',
|
||||||
|
remaining_count: toNumber(backendCard.remaining_count)
|
||||||
|
})
|
||||||
|
|
||||||
|
const toCardBooking = (backendBooking = {}) => ({
|
||||||
|
id: backendBooking.id ?? null,
|
||||||
|
card_id: backendBooking.card_id ?? null,
|
||||||
|
session_id: backendBooking.session_id ?? null,
|
||||||
|
status: backendBooking.status || '',
|
||||||
|
status_label: backendBooking.status_label || backendBooking.status || '',
|
||||||
|
checked_in_at: backendBooking.checked_in_at || null,
|
||||||
|
cancelled_at: backendBooking.cancelled_at || null,
|
||||||
|
cancel_reason: backendBooking.cancel_reason || '',
|
||||||
|
created_at: backendBooking.created_at || '',
|
||||||
|
card: toCardBookingCard(backendBooking.card),
|
||||||
|
session: toCardBookingSession(backendBooking.session)
|
||||||
|
})
|
||||||
|
|
||||||
export const membershipApi = {
|
export const membershipApi = {
|
||||||
getPlans() {
|
getPlans() {
|
||||||
return request({
|
return request({
|
||||||
@@ -27,5 +100,41 @@ export const membershipApi = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: { plan_id: planId }
|
data: { plan_id: planId }
|
||||||
}).then((data) => toOrderBrief(data.order))
|
}).then((data) => toOrderBrief(data.order))
|
||||||
|
},
|
||||||
|
getCards(params = {}) {
|
||||||
|
return request({
|
||||||
|
url: '/api/membership/cards',
|
||||||
|
data: toPageQuery(params)
|
||||||
|
}).then((data) => toPagination(data, toCard))
|
||||||
|
},
|
||||||
|
getUsageRecords(cardId, params = {}) {
|
||||||
|
return request({
|
||||||
|
url: `/api/membership/cards/${cardId}/usage-records`,
|
||||||
|
data: toPageQuery(params)
|
||||||
|
}).then((data) => toPagination(data, toUsageRecord))
|
||||||
|
},
|
||||||
|
getAvailableSessions(params = {}) {
|
||||||
|
return request({
|
||||||
|
url: '/api/membership/available-courses',
|
||||||
|
data: toPageQuery(params)
|
||||||
|
}).then((data) => toPagination(data, toAvailableSession))
|
||||||
|
},
|
||||||
|
createCardBooking({ cardId, sessionId }) {
|
||||||
|
return request({
|
||||||
|
url: '/api/membership/bookings',
|
||||||
|
method: 'POST',
|
||||||
|
data: { card_id: cardId, session_id: sessionId }
|
||||||
|
}).then(toCardBooking)
|
||||||
|
},
|
||||||
|
getCardBookings(params = {}) {
|
||||||
|
return request({
|
||||||
|
url: '/api/membership/bookings',
|
||||||
|
data: toPageQuery(params)
|
||||||
|
}).then((data) => toPagination(data, toCardBooking))
|
||||||
|
},
|
||||||
|
getCardBookingDetail(bookingId) {
|
||||||
|
return request({
|
||||||
|
url: `/api/membership/bookings/${bookingId}`
|
||||||
|
}).then(toCardBooking)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,6 +144,34 @@
|
|||||||
"enablePullDownRefresh": true
|
"enablePullDownRefresh": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/comprehensive/membership/cards",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "我的次卡",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/comprehensive/membership/card-detail",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "次卡详情",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/comprehensive/membership/available-sessions",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "可约排课",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/comprehensive/membership/booking-detail",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "次卡预约详情",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/ice/home/index",
|
"path": "pages/ice/home/index",
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
@@ -12,18 +12,24 @@
|
|||||||
{{ tab.label }}
|
{{ tab.label }}
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view v-if="businessType === 'course'" class="sub-tabs">
|
||||||
|
<view v-for="sub in courseSubTabs" :key="sub.value" class="sub-tab" :class="{ active: courseSubType === sub.value }" @tap="selectCourseSub(sub.value)">
|
||||||
|
{{ sub.label }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="list">
|
<view class="list">
|
||||||
<view v-for="item in bookedItems" :key="`${businessType}-${item.id}`" class="booking-card card" @tap="goDetail(item)">
|
<view v-for="item in bookedItems" :key="`${businessType}-${courseSubType}-${item.id}`" class="booking-card card" @tap="goDetail(item)">
|
||||||
<view class="card-top">
|
<view class="card-top">
|
||||||
<text class="item-title">{{ getItemTitle(item) }}</text>
|
<text class="item-title">{{ getItemTitle(item) }}</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="item-meta primary">{{ getItemTime(item) }}</text>
|
<text class="item-meta primary">{{ getItemTime(item) }}</text>
|
||||||
<text class="item-meta">{{ getItemDesc(item) }}</text>
|
<text class="item-meta">{{ getItemDesc(item) }}</text>
|
||||||
<view class="card-bottom">
|
<view class="card-bottom">
|
||||||
<text class="item-meta">订单金额</text>
|
<text class="item-meta">{{ isMembershipCourse ? '次卡预约' : '订单金额' }}</text>
|
||||||
<text class="amount">¥{{ item.order?.amount || '--' }}</text>
|
<text v-if="isMembershipCourse" class="amount tag-amount">次卡预约</text>
|
||||||
|
<text v-else class="amount">¥{{ item.order?.amount || '--' }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="!isLoading && bookedItems.length === 0" class="empty-state">暂无已预约记录</view>
|
<view v-if="!isLoading && bookedItems.length === 0" class="empty-state">暂无已预约记录</view>
|
||||||
@@ -38,6 +44,7 @@
|
|||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { bookingApi } from '@/modules/comprehensive/api/booking'
|
import { bookingApi } from '@/modules/comprehensive/api/booking'
|
||||||
import { ticketApi } from '@/modules/comprehensive/api/ticket'
|
import { ticketApi } from '@/modules/comprehensive/api/ticket'
|
||||||
|
import { membershipApi } from '@/modules/comprehensive/api/membership'
|
||||||
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
||||||
import { formatDateTime, formatTimeRange } from '@/modules/comprehensive/utils/date'
|
import { formatDateTime, formatTimeRange } from '@/modules/comprehensive/utils/date'
|
||||||
import VenueTabBar from '@/components/VenueTabBar.vue'
|
import VenueTabBar from '@/components/VenueTabBar.vue'
|
||||||
@@ -45,6 +52,7 @@ import UnauthLoginPanel from '@/components/UnauthLoginPanel.vue'
|
|||||||
|
|
||||||
const session = useComprehensiveSessionStore()
|
const session = useComprehensiveSessionStore()
|
||||||
const businessType = ref('course')
|
const businessType = ref('course')
|
||||||
|
const courseSubType = ref('regular')
|
||||||
const items = ref([])
|
const items = ref([])
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
|
||||||
@@ -54,6 +62,13 @@ const businessTabs = [
|
|||||||
{ label: '门票', value: 'ticket' }
|
{ label: '门票', value: 'ticket' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const courseSubTabs = [
|
||||||
|
{ label: '普通预约', value: 'regular' },
|
||||||
|
{ label: '次卡预约', value: 'membership' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const isMembershipCourse = computed(() => businessType.value === 'course' && courseSubType.value === 'membership')
|
||||||
|
|
||||||
const bookedItems = computed(() => {
|
const bookedItems = computed(() => {
|
||||||
return items.value.filter((item) => ['booked', 'available'].includes(item.status))
|
return items.value.filter((item) => ['booked', 'available'].includes(item.status))
|
||||||
})
|
})
|
||||||
@@ -70,6 +85,12 @@ const login = async () => {
|
|||||||
|
|
||||||
const selectBusiness = (value) => {
|
const selectBusiness = (value) => {
|
||||||
businessType.value = value
|
businessType.value = value
|
||||||
|
if (value !== 'course') courseSubType.value = 'regular'
|
||||||
|
loadItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectCourseSub = (value) => {
|
||||||
|
courseSubType.value = value
|
||||||
loadItems()
|
loadItems()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,8 +98,13 @@ const loadItems = async () => {
|
|||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
if (businessType.value === 'course') {
|
if (businessType.value === 'course') {
|
||||||
const data = await bookingApi.getCourseBookings({ page: 1, pageSize: 50 })
|
if (courseSubType.value === 'membership') {
|
||||||
items.value = data.items
|
const data = await membershipApi.getCardBookings({ page: 1, pageSize: 50 })
|
||||||
|
items.value = data.items
|
||||||
|
} else {
|
||||||
|
const data = await bookingApi.getCourseBookings({ page: 1, pageSize: 50 })
|
||||||
|
items.value = data.items
|
||||||
|
}
|
||||||
} else if (businessType.value === 'private') {
|
} else if (businessType.value === 'private') {
|
||||||
const data = await bookingApi.getPrivateBookings({ page: 1, pageSize: 50 })
|
const data = await bookingApi.getPrivateBookings({ page: 1, pageSize: 50 })
|
||||||
items.value = data.items
|
items.value = data.items
|
||||||
@@ -111,14 +137,21 @@ const getItemTime = (item) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getItemDesc = (item) => {
|
const getItemDesc = (item) => {
|
||||||
if (businessType.value === 'course') return `${item.session?.coach_name || ''} · ${item.session?.courts?.map((court) => court.name).join('、') || ''}`
|
if (businessType.value === 'course') {
|
||||||
|
if (courseSubType.value === 'membership') return `${item.session?.coach_name || ''}`
|
||||||
|
return `${item.session?.coach_name || ''} · ${item.session?.courts?.map((court) => court.name).join('、') || ''}`
|
||||||
|
}
|
||||||
if (businessType.value === 'private') return `共 ${item.courts?.length || 0} 个场地`
|
if (businessType.value === 'private') return `共 ${item.courts?.length || 0} 个场地`
|
||||||
return item.status === 'available' ? '可到馆核销使用' : item.status_label
|
return item.status === 'available' ? '可到馆核销使用' : item.status_label
|
||||||
}
|
}
|
||||||
|
|
||||||
const goDetail = (item) => {
|
const goDetail = (item) => {
|
||||||
if (businessType.value === 'course') {
|
if (businessType.value === 'course') {
|
||||||
uni.navigateTo({ url: `/pages/comprehensive/bookings/course-detail?id=${item.id}` })
|
if (courseSubType.value === 'membership') {
|
||||||
|
uni.navigateTo({ url: `/pages/comprehensive/membership/booking-detail?id=${item.id}` })
|
||||||
|
} else {
|
||||||
|
uni.navigateTo({ url: `/pages/comprehensive/bookings/course-detail?id=${item.id}` })
|
||||||
|
}
|
||||||
} else if (businessType.value === 'private') {
|
} else if (businessType.value === 'private') {
|
||||||
uni.navigateTo({ url: `/pages/comprehensive/bookings/private-detail?id=${item.id}` })
|
uni.navigateTo({ url: `/pages/comprehensive/bookings/private-detail?id=${item.id}` })
|
||||||
} else if (item.order_id) {
|
} else if (item.order_id) {
|
||||||
@@ -165,6 +198,30 @@ const goDetail = (item) => {
|
|||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sub-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
margin-top: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-tab {
|
||||||
|
flex: 1;
|
||||||
|
height: 56rpx;
|
||||||
|
border-radius: $border-radius-base;
|
||||||
|
background: $bg-color-hover;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-tab.active {
|
||||||
|
background: $color-primary;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
.list {
|
.list {
|
||||||
padding: $spacing-md;
|
padding: $spacing-md;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
<template>
|
||||||
|
<view class="available-page">
|
||||||
|
<UnauthLoginPanel
|
||||||
|
v-if="!session.isLoggedIn"
|
||||||
|
@login="login"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<block v-else>
|
||||||
|
<view class="session-list">
|
||||||
|
<view v-for="session in sessions" :key="session.id" class="card session-card" @tap="onSessionTap(session)">
|
||||||
|
<image :src="session.cover_image" mode="aspectFill" class="session-cover" />
|
||||||
|
<view class="session-info">
|
||||||
|
<view class="session-top">
|
||||||
|
<text class="session-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="session-bottom">
|
||||||
|
<text class="meta">剩余 {{ session.remaining_count }}/{{ session.capacity }}</text>
|
||||||
|
<text class="session-price">¥{{ session.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 v-else-if="!hasMore" class="load-more-tip">已加载全部</view>
|
||||||
|
<view v-if="isLoadingMore" class="load-more-tip">加载中...</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="cardPickerVisible" class="picker-mask" @tap="closeCardPicker">
|
||||||
|
<view class="picker-sheet" @tap.stop>
|
||||||
|
<text class="picker-title">选择次卡</text>
|
||||||
|
<view v-for="cardId in pickerCardIds" :key="cardId" class="picker-item" @tap="confirmCard(cardId)">
|
||||||
|
{{ getCardLabel(cardId) }}
|
||||||
|
</view>
|
||||||
|
<view class="picker-item cancel" @tap="closeCardPicker">取消</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||||
|
import { membershipApi } from '@/modules/comprehensive/api/membership'
|
||||||
|
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
||||||
|
import { formatTimeRange } from '@/modules/comprehensive/utils/date'
|
||||||
|
import UnauthLoginPanel from '@/components/UnauthLoginPanel.vue'
|
||||||
|
|
||||||
|
const session = useComprehensiveSessionStore()
|
||||||
|
const preselectedCardId = ref('')
|
||||||
|
const sessions = ref([])
|
||||||
|
const cards = ref([])
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = 20
|
||||||
|
const total = ref(0)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const isLoadingMore = ref(false)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
const cardPickerVisible = ref(false)
|
||||||
|
const pendingSession = ref(null)
|
||||||
|
|
||||||
|
const hasMore = computed(() => sessions.value.length < total.value)
|
||||||
|
|
||||||
|
onLoad(async (query) => {
|
||||||
|
preselectedCardId.value = query.card_id ? String(query.card_id) : ''
|
||||||
|
await session.init()
|
||||||
|
if (session.isLoggedIn) loadAll()
|
||||||
|
})
|
||||||
|
|
||||||
|
onPullDownRefresh(() => {
|
||||||
|
loadAll().finally(() => uni.stopPullDownRefresh())
|
||||||
|
})
|
||||||
|
|
||||||
|
onReachBottom(() => {
|
||||||
|
if (!hasMore.value || isLoadingMore.value) return
|
||||||
|
page.value += 1
|
||||||
|
loadSessions(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadAll = async () => {
|
||||||
|
await Promise.all([
|
||||||
|
loadSessions(true),
|
||||||
|
loadCards()
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSessions = async (reset) => {
|
||||||
|
if (reset) {
|
||||||
|
page.value = 1
|
||||||
|
isLoading.value = true
|
||||||
|
} else {
|
||||||
|
isLoadingMore.value = true
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await membershipApi.getAvailableSessions({ page: page.value, pageSize })
|
||||||
|
let items = data.items
|
||||||
|
if (preselectedCardId.value) {
|
||||||
|
items = items.filter((item) => item.available_card_ids.includes(Number(preselectedCardId.value)))
|
||||||
|
}
|
||||||
|
sessions.value = reset ? items : [...sessions.value, ...items]
|
||||||
|
total.value = data.total
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
isLoadingMore.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadCards = async () => {
|
||||||
|
const data = await membershipApi.getCards({ page: 1, pageSize: 50 })
|
||||||
|
cards.value = data.items.filter((card) => card.status === 'active' && card.remaining_count > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSessionTap = (session) => {
|
||||||
|
if (isSubmitting.value) return
|
||||||
|
if (session.remaining_count <= 0) {
|
||||||
|
uni.showToast({ title: '该课程已约满', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const availableIds = session.available_card_ids
|
||||||
|
if (availableIds.length === 0) {
|
||||||
|
uni.showToast({ title: '暂无可用次卡', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (preselectedCardId.value) {
|
||||||
|
submitBooking(preselectedCardId.value, session.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (availableIds.length === 1) {
|
||||||
|
submitBooking(String(availableIds[0]), session.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingSession.value = session
|
||||||
|
cardPickerVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitBooking = async (cardId, sessionId) => {
|
||||||
|
if (isSubmitting.value) return
|
||||||
|
isSubmitting.value = true
|
||||||
|
uni.showLoading({ title: '提交中...' })
|
||||||
|
try {
|
||||||
|
const booking = await membershipApi.createCardBooking({ cardId, sessionId })
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.redirectTo({ url: `/pages/comprehensive/membership/booking-detail?id=${booking.id}` })
|
||||||
|
} catch (error) {
|
||||||
|
uni.hideLoading()
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
cardPickerVisible.value = false
|
||||||
|
pendingSession.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickerCardIds = computed(() => {
|
||||||
|
if (!pendingSession.value) return []
|
||||||
|
return pendingSession.value.available_card_ids.map(String)
|
||||||
|
})
|
||||||
|
|
||||||
|
const getCardLabel = (cardId) => {
|
||||||
|
const card = cards.value.find((item) => String(item.id) === String(cardId))
|
||||||
|
if (!card) return `次卡 #${cardId}`
|
||||||
|
return `${card.plan_name}(剩余${card.remaining_count}次)`
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmCard = (cardId) => {
|
||||||
|
if (!pendingSession.value) return
|
||||||
|
submitBooking(cardId, pendingSession.value.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeCardPicker = () => {
|
||||||
|
cardPickerVisible.value = false
|
||||||
|
pendingSession.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = async () => {
|
||||||
|
await session.login()
|
||||||
|
loadAll()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.available-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $bg-color-soft;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-md;
|
||||||
|
padding: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card {
|
||||||
|
display: flex;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-cover {
|
||||||
|
width: 180rpx;
|
||||||
|
height: 172rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: $border-radius-base;
|
||||||
|
background: $bg-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-top,
|
||||||
|
.session-bottom {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-title {
|
||||||
|
flex: 1;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-line {
|
||||||
|
display: block;
|
||||||
|
margin-top: $spacing-sm;
|
||||||
|
color: $color-primary;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6rpx;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 23rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-price {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-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: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-sheet {
|
||||||
|
width: 100%;
|
||||||
|
background: $bg-color-main;
|
||||||
|
border-radius: $border-radius-lg $border-radius-lg 0 0;
|
||||||
|
padding: $spacing-md;
|
||||||
|
padding-bottom: calc($spacing-md + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-title {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 24rpx;
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-item {
|
||||||
|
height: 88rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
border-bottom: 1rpx solid $bg-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-item.cancel {
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: none;
|
||||||
|
margin-top: $spacing-sm;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
<template>
|
||||||
|
<view class="booking-detail-page container page-bottom-safe">
|
||||||
|
<UnauthLoginPanel
|
||||||
|
v-if="!session.isLoggedIn"
|
||||||
|
@login="login"
|
||||||
|
/>
|
||||||
|
<view v-else-if="loaded && !booking.id" class="card not-found-card">
|
||||||
|
<text class="not-found-text">预约不存在</text>
|
||||||
|
<text class="not-found-desc">该次卡预约可能已被删除或链接失效</text>
|
||||||
|
</view>
|
||||||
|
<block v-else-if="booking.id">
|
||||||
|
<view class="status-card card">
|
||||||
|
<text class="status-title">{{ booking.status_label }}</text>
|
||||||
|
<text class="status-desc">{{ statusDesc }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card session-card">
|
||||||
|
<image :src="booking.session.cover_image" mode="aspectFill" class="cover" />
|
||||||
|
<view class="session-info">
|
||||||
|
<text class="session-title">{{ booking.session.title }}</text>
|
||||||
|
<text class="meta primary">{{ formatTimeRange(booking.session.start_at, booking.session.end_at) }}</text>
|
||||||
|
<text class="meta">{{ booking.session.coach_name }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<text class="card-title">次卡信息</text>
|
||||||
|
<view class="info-row">
|
||||||
|
<text>次卡名称</text>
|
||||||
|
<text>{{ booking.card.plan_name }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text>剩余次数</text>
|
||||||
|
<text>{{ booking.card.remaining_count }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<text class="card-title">预约时间线</text>
|
||||||
|
<view class="timeline-item">
|
||||||
|
<text class="dot"></text>
|
||||||
|
<view>
|
||||||
|
<text class="timeline-title">创建预约</text>
|
||||||
|
<text class="timeline-time">{{ formatDateTime(booking.created_at) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="booking.checked_in_at" class="timeline-item">
|
||||||
|
<text class="dot success"></text>
|
||||||
|
<view>
|
||||||
|
<text class="timeline-title">已核销</text>
|
||||||
|
<text class="timeline-time">{{ formatDateTime(booking.checked_in_at) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="booking.cancelled_at" class="timeline-item">
|
||||||
|
<text class="dot danger"></text>
|
||||||
|
<view>
|
||||||
|
<text class="timeline-title">已取消</text>
|
||||||
|
<text class="timeline-time">{{ formatDateTime(booking.cancelled_at) }}</text>
|
||||||
|
<text v-if="booking.cancel_reason" class="timeline-reason">原因:{{ booking.cancel_reason }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
<view v-else class="load-more-tip">加载中...</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||||
|
import { membershipApi } from '@/modules/comprehensive/api/membership'
|
||||||
|
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
||||||
|
import { formatDateTime, formatTimeRange } from '@/modules/comprehensive/utils/date'
|
||||||
|
import UnauthLoginPanel from '@/components/UnauthLoginPanel.vue'
|
||||||
|
|
||||||
|
const session = useComprehensiveSessionStore()
|
||||||
|
const bookingId = ref('')
|
||||||
|
const booking = ref({})
|
||||||
|
const loaded = ref(false)
|
||||||
|
|
||||||
|
const statusDesc = computed(() => {
|
||||||
|
if (!booking.value || !booking.value.id) return ''
|
||||||
|
if (booking.value.status === 'booked') return '预约已生效,到馆后按场馆流程核销。'
|
||||||
|
if (booking.value.status === 'checked_in') return '预约已核销,次数已扣减。'
|
||||||
|
if (booking.value.status === 'cancelled') return booking.value.cancel_reason || '预约已取消。'
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
onLoad(async (query) => {
|
||||||
|
bookingId.value = query.id
|
||||||
|
await session.init()
|
||||||
|
if (session.isLoggedIn) await loadBooking()
|
||||||
|
})
|
||||||
|
|
||||||
|
const login = async () => {
|
||||||
|
await session.login()
|
||||||
|
await loadBooking()
|
||||||
|
}
|
||||||
|
|
||||||
|
onPullDownRefresh(() => {
|
||||||
|
loadBooking().finally(() => uni.stopPullDownRefresh())
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadBooking = async () => {
|
||||||
|
try {
|
||||||
|
booking.value = await membershipApi.getCardBookingDetail(bookingId.value)
|
||||||
|
} finally {
|
||||||
|
loaded.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.booking-detail-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $bg-color-soft;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card,
|
||||||
|
.session-card,
|
||||||
|
.card {
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-title,
|
||||||
|
.status-desc,
|
||||||
|
.session-title,
|
||||||
|
.meta,
|
||||||
|
.card-title,
|
||||||
|
.timeline-title,
|
||||||
|
.timeline-time,
|
||||||
|
.not-found-text,
|
||||||
|
.not-found-desc {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-title {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 42rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-desc {
|
||||||
|
margin: $spacing-sm 0 $spacing-md;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card {
|
||||||
|
display: flex;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
width: 180rpx;
|
||||||
|
height: 150rpx;
|
||||||
|
border-radius: $border-radius-base;
|
||||||
|
background: $bg-color-hover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-title,
|
||||||
|
.card-title {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
margin-top: $spacing-xs;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
margin-top: $spacing-sm;
|
||||||
|
color: $color-primary;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
min-height: 70rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-md;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 25rpx;
|
||||||
|
border-bottom: 1rpx solid $bg-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row text:last-child {
|
||||||
|
flex: 1;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-weight: 800;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
display: flex;
|
||||||
|
gap: $spacing-md;
|
||||||
|
padding: $spacing-md 0;
|
||||||
|
border-bottom: 1rpx solid $bg-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 18rpx;
|
||||||
|
height: 18rpx;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $color-primary;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.success {
|
||||||
|
background: $color-success;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.danger {
|
||||||
|
background: $color-danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-title {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-time {
|
||||||
|
margin-top: 4rpx;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-reason {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
color: $color-danger;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-card {
|
||||||
|
text-align: center;
|
||||||
|
padding: $spacing-xl $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-text {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-desc {
|
||||||
|
margin-top: $spacing-sm;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
<template>
|
||||||
|
<view class="card-detail-page container page-bottom-safe">
|
||||||
|
<UnauthLoginPanel
|
||||||
|
v-if="!session.isLoggedIn"
|
||||||
|
@login="login"
|
||||||
|
/>
|
||||||
|
<view v-else-if="!card && !isLoadingCard" class="card not-found-card">
|
||||||
|
<text class="not-found-text">次卡不存在</text>
|
||||||
|
<text class="not-found-desc">该次卡可能已被删除或链接失效</text>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="!card" class="load-more-tip">加载中...</view>
|
||||||
|
<block v-else-if="card">
|
||||||
|
<view class="card info-card">
|
||||||
|
<view class="card-top">
|
||||||
|
<text class="plan-name">{{ card.plan_name }}</text>
|
||||||
|
<text class="badge" :class="statusBadgeClass(card.status)">{{ card.status_label }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="count-row">
|
||||||
|
<view class="count-block">
|
||||||
|
<text class="count-value">{{ card.remaining_count }}</text>
|
||||||
|
<text class="count-label">剩余次数</text>
|
||||||
|
</view>
|
||||||
|
<view class="count-divider" />
|
||||||
|
<view class="count-block">
|
||||||
|
<text class="count-value muted">{{ card.total_count }}</text>
|
||||||
|
<text class="count-label">总次数</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text>购买时间</text>
|
||||||
|
<text>{{ formatDate(card.created_at) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card" v-if="applicableCourses.length > 0">
|
||||||
|
<text class="card-title">适用课程</text>
|
||||||
|
<view v-for="course in applicableCourses" :key="course.id" class="course-item">
|
||||||
|
<text class="course-title">{{ course.title }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<text class="card-title">使用记录</text>
|
||||||
|
<view v-if="usageRecords.length === 0 && !isLoadingRecords" class="empty-mini">暂无使用记录</view>
|
||||||
|
<view v-for="record in usageRecords" :key="record.id" class="record-item">
|
||||||
|
<view class="record-top">
|
||||||
|
<text class="record-kind">{{ record.kind_label }}</text>
|
||||||
|
<text class="record-count" :class="{ minus: record.count < 0, plus: record.count > 0 }">
|
||||||
|
{{ record.count > 0 ? '+' : '' }}{{ record.count }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<text v-if="record.remark" class="record-remark">{{ record.remark }}</text>
|
||||||
|
<text class="record-time">{{ formatDateTime(record.created_at) }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="isLoadingRecords" class="load-more-tip">加载中...</view>
|
||||||
|
<view v-else-if="usageRecords.length > 0 && !hasMoreRecords" class="load-more-tip">已加载全部</view>
|
||||||
|
<view v-if="isLoadingMoreRecords" class="load-more-tip">加载中...</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="fixed-action-bar">
|
||||||
|
<button class="primary-button action-btn" @tap="goBooking" :disabled="card.remaining_count <= 0">
|
||||||
|
{{ card.remaining_count > 0 ? '去预约' : '次卡已用完' }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||||
|
import { membershipApi } from '@/modules/comprehensive/api/membership'
|
||||||
|
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
||||||
|
import { formatDateTime, formatDate } from '@/modules/comprehensive/utils/date'
|
||||||
|
import UnauthLoginPanel from '@/components/UnauthLoginPanel.vue'
|
||||||
|
|
||||||
|
const session = useComprehensiveSessionStore()
|
||||||
|
const cardId = ref('')
|
||||||
|
const card = ref(null)
|
||||||
|
const plans = ref([])
|
||||||
|
const usageRecords = ref([])
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = 20
|
||||||
|
const total = ref(0)
|
||||||
|
const isLoadingRecords = ref(false)
|
||||||
|
const isLoadingMoreRecords = ref(false)
|
||||||
|
const isLoadingCard = ref(false)
|
||||||
|
|
||||||
|
const hasMoreRecords = computed(() => usageRecords.value.length < total.value)
|
||||||
|
|
||||||
|
const applicableCourses = computed(() => {
|
||||||
|
if (!card.value || !card.value.plan_id) return []
|
||||||
|
const plan = plans.value.find((item) => item.id === card.value.plan_id)
|
||||||
|
return plan ? plan.courses : []
|
||||||
|
})
|
||||||
|
|
||||||
|
onLoad(async (query) => {
|
||||||
|
cardId.value = query.id
|
||||||
|
await session.init()
|
||||||
|
if (session.isLoggedIn) loadAll()
|
||||||
|
})
|
||||||
|
|
||||||
|
const login = async () => {
|
||||||
|
await session.login()
|
||||||
|
loadAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
onPullDownRefresh(() => {
|
||||||
|
loadAll().finally(() => uni.stopPullDownRefresh())
|
||||||
|
})
|
||||||
|
|
||||||
|
onReachBottom(() => {
|
||||||
|
if (!hasMoreRecords.value || isLoadingMoreRecords.value) return
|
||||||
|
page.value += 1
|
||||||
|
loadUsageRecords(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadAll = async () => {
|
||||||
|
await Promise.all([
|
||||||
|
loadCard(),
|
||||||
|
loadPlans(),
|
||||||
|
loadUsageRecords(true)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadCard = async () => {
|
||||||
|
isLoadingCard.value = true
|
||||||
|
try {
|
||||||
|
const data = await membershipApi.getCards({ page: 1, pageSize: 50 })
|
||||||
|
card.value = data.items.find((item) => String(item.id) === String(cardId.value)) || null
|
||||||
|
} finally {
|
||||||
|
isLoadingCard.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPlans = async () => {
|
||||||
|
plans.value = await membershipApi.getPlans()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadUsageRecords = async (reset) => {
|
||||||
|
if (reset) {
|
||||||
|
page.value = 1
|
||||||
|
isLoadingRecords.value = true
|
||||||
|
} else {
|
||||||
|
isLoadingMoreRecords.value = true
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await membershipApi.getUsageRecords(cardId.value, { page: page.value, pageSize })
|
||||||
|
usageRecords.value = reset ? data.items : [...usageRecords.value, ...data.items]
|
||||||
|
total.value = data.total
|
||||||
|
} finally {
|
||||||
|
isLoadingRecords.value = false
|
||||||
|
isLoadingMoreRecords.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const goBooking = () => {
|
||||||
|
uni.navigateTo({ url: `/pages/comprehensive/membership/available-sessions?card_id=${cardId.value}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusBadgeClass = (status) => {
|
||||||
|
if (status === 'active') return 'success'
|
||||||
|
if (status === 'exhausted') return 'warning'
|
||||||
|
if (status === 'expired') return 'muted'
|
||||||
|
return 'muted'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.card-detail-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $bg-color-soft;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card,
|
||||||
|
.card {
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-name {
|
||||||
|
flex: 1;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 4rpx 16rpx;
|
||||||
|
border-radius: $border-radius-sm;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.success {
|
||||||
|
background: $color-success-light;
|
||||||
|
color: $color-success;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.warning {
|
||||||
|
background: $color-warning-light;
|
||||||
|
color: $color-warning;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.muted {
|
||||||
|
background: $bg-color-hover;
|
||||||
|
color: $text-color-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-row {
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-block {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-value {
|
||||||
|
color: $color-primary;
|
||||||
|
font-size: 44rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-value.muted {
|
||||||
|
color: $text-color-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-label {
|
||||||
|
margin-top: 4rpx;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-divider {
|
||||||
|
width: 1rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
background: $border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
min-height: 60rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-md;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 25rpx;
|
||||||
|
border-top: 1rpx solid $bg-color-hover;
|
||||||
|
padding-top: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row text:last-child {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
display: block;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-item {
|
||||||
|
padding: $spacing-sm 0;
|
||||||
|
border-bottom: 1rpx solid $bg-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-title {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-mini {
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 24rpx;
|
||||||
|
text-align: center;
|
||||||
|
padding: $spacing-md 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-item {
|
||||||
|
padding: $spacing-sm 0;
|
||||||
|
border-bottom: 1rpx solid $bg-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-kind {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-count {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-count.minus {
|
||||||
|
color: $color-danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-count.plus {
|
||||||
|
color: $color-success;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-remark {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 23rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-time {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
color: $text-color-light;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-card {
|
||||||
|
text-align: center;
|
||||||
|
padding: $spacing-xl $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-text {
|
||||||
|
display: block;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-desc {
|
||||||
|
display: block;
|
||||||
|
margin-top: $spacing-sm;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<template>
|
||||||
|
<view class="cards-page">
|
||||||
|
<UnauthLoginPanel
|
||||||
|
v-if="!session.isLoggedIn"
|
||||||
|
@login="login"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<block v-else>
|
||||||
|
<view class="list">
|
||||||
|
<view v-for="card in cards" :key="card.id" class="card membership-card" @tap="goDetail(card.id)">
|
||||||
|
<view class="card-top">
|
||||||
|
<text class="plan-name">{{ card.plan_name }}</text>
|
||||||
|
<text class="badge" :class="statusBadgeClass(card.status)">{{ card.status_label }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="count-row">
|
||||||
|
<view class="count-block">
|
||||||
|
<text class="count-value">{{ card.remaining_count }}</text>
|
||||||
|
<text class="count-label">剩余次数</text>
|
||||||
|
</view>
|
||||||
|
<view class="count-divider" />
|
||||||
|
<view class="count-block">
|
||||||
|
<text class="count-value muted">{{ card.total_count }}</text>
|
||||||
|
<text class="count-label">总次数</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
||||||
|
<view v-else-if="cards.length === 0" class="empty-state">暂无次卡</view>
|
||||||
|
<view v-else-if="!hasMore" class="load-more-tip">已加载全部</view>
|
||||||
|
<view v-if="isLoadingMore" class="load-more-tip">加载中...</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||||
|
import { membershipApi } from '@/modules/comprehensive/api/membership'
|
||||||
|
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
||||||
|
import UnauthLoginPanel from '@/components/UnauthLoginPanel.vue'
|
||||||
|
|
||||||
|
const session = useComprehensiveSessionStore()
|
||||||
|
const cards = ref([])
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = 20
|
||||||
|
const total = ref(0)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const isLoadingMore = ref(false)
|
||||||
|
|
||||||
|
const hasMore = computed(() => cards.value.length < total.value)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await session.init()
|
||||||
|
if (session.isLoggedIn) loadCards(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
onPullDownRefresh(() => {
|
||||||
|
loadCards(true).finally(() => uni.stopPullDownRefresh())
|
||||||
|
})
|
||||||
|
|
||||||
|
onReachBottom(() => {
|
||||||
|
if (!hasMore.value || isLoadingMore.value) return
|
||||||
|
page.value += 1
|
||||||
|
loadCards(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadCards = async (reset) => {
|
||||||
|
if (reset) {
|
||||||
|
page.value = 1
|
||||||
|
isLoading.value = true
|
||||||
|
} else {
|
||||||
|
isLoadingMore.value = true
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await membershipApi.getCards({ page: page.value, pageSize })
|
||||||
|
cards.value = reset ? data.items : [...cards.value, ...data.items]
|
||||||
|
total.value = data.total
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
isLoadingMore.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = async () => {
|
||||||
|
await session.login()
|
||||||
|
loadCards(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const goDetail = (id) => {
|
||||||
|
uni.navigateTo({ url: `/pages/comprehensive/membership/card-detail?id=${id}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusBadgeClass = (status) => {
|
||||||
|
if (status === 'active') return 'success'
|
||||||
|
if (status === 'exhausted') return 'warning'
|
||||||
|
if (status === 'expired') return 'muted'
|
||||||
|
return 'muted'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.cards-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $bg-color-soft;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
padding: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.membership-card {
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-name {
|
||||||
|
flex: 1;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 4rpx 16rpx;
|
||||||
|
border-radius: $border-radius-sm;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.success {
|
||||||
|
background: $color-success-light;
|
||||||
|
color: $color-success;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.warning {
|
||||||
|
background: $color-warning-light;
|
||||||
|
color: $color-warning;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.muted {
|
||||||
|
background: $bg-color-hover;
|
||||||
|
color: $text-color-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-row {
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-block {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-value {
|
||||||
|
color: $color-primary;
|
||||||
|
font-size: 44rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-value.muted {
|
||||||
|
color: $text-color-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-label {
|
||||||
|
margin-top: 4rpx;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-divider {
|
||||||
|
width: 1rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
background: $border-color;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user