feat: add membership card course booking flow

This commit is contained in:
gqt
2026-07-12 20:58:06 +08:00
parent 1cc96633ad
commit 4b11447875
8 changed files with 1346 additions and 8 deletions
@@ -1,5 +1,5 @@
import { request } from './request'
import { toArray, toNumber } from './mappers'
import { toArray, toNumber, toPageQuery, toPagination } from './mappers'
import { toOrderBrief } from './order'
const toPlan = (item = {}) => ({
@@ -15,6 +15,79 @@ const toPlan = (item = {}) => ({
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 = {
getPlans() {
return request({
@@ -27,5 +100,41 @@ export const membershipApi = {
method: 'POST',
data: { plan_id: planId }
}).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)
}
}
+28
View File
@@ -144,6 +144,34 @@
"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",
"style": {
@@ -12,18 +12,24 @@
{{ tab.label }}
</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 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">
<text class="item-title">{{ getItemTitle(item) }}</text>
</view>
<text class="item-meta primary">{{ getItemTime(item) }}</text>
<text class="item-meta">{{ getItemDesc(item) }}</text>
<view class="card-bottom">
<text class="item-meta">订单金额</text>
<text class="amount">¥{{ item.order?.amount || '--' }}</text>
<text class="item-meta">{{ isMembershipCourse ? '次卡预约' : '订单金额' }}</text>
<text v-if="isMembershipCourse" class="amount tag-amount">次卡预约</text>
<text v-else class="amount">¥{{ item.order?.amount || '--' }}</text>
</view>
</view>
<view v-if="!isLoading && bookedItems.length === 0" class="empty-state">暂无已预约记录</view>
@@ -38,6 +44,7 @@
import { computed, onMounted, ref } from 'vue'
import { bookingApi } from '@/modules/comprehensive/api/booking'
import { ticketApi } from '@/modules/comprehensive/api/ticket'
import { membershipApi } from '@/modules/comprehensive/api/membership'
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
import { formatDateTime, formatTimeRange } from '@/modules/comprehensive/utils/date'
import VenueTabBar from '@/components/VenueTabBar.vue'
@@ -45,6 +52,7 @@ import UnauthLoginPanel from '@/components/UnauthLoginPanel.vue'
const session = useComprehensiveSessionStore()
const businessType = ref('course')
const courseSubType = ref('regular')
const items = ref([])
const isLoading = ref(false)
@@ -54,6 +62,13 @@ const businessTabs = [
{ label: '门票', value: 'ticket' }
]
const courseSubTabs = [
{ label: '普通预约', value: 'regular' },
{ label: '次卡预约', value: 'membership' }
]
const isMembershipCourse = computed(() => businessType.value === 'course' && courseSubType.value === 'membership')
const bookedItems = computed(() => {
return items.value.filter((item) => ['booked', 'available'].includes(item.status))
})
@@ -70,6 +85,12 @@ const login = async () => {
const selectBusiness = (value) => {
businessType.value = value
if (value !== 'course') courseSubType.value = 'regular'
loadItems()
}
const selectCourseSub = (value) => {
courseSubType.value = value
loadItems()
}
@@ -77,8 +98,13 @@ const loadItems = async () => {
isLoading.value = true
try {
if (businessType.value === 'course') {
const data = await bookingApi.getCourseBookings({ page: 1, pageSize: 50 })
items.value = data.items
if (courseSubType.value === 'membership') {
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') {
const data = await bookingApi.getPrivateBookings({ page: 1, pageSize: 50 })
items.value = data.items
@@ -111,14 +137,21 @@ const getItemTime = (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} 个场地`
return item.status === 'available' ? '可到馆核销使用' : item.status_label
}
const goDetail = (item) => {
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') {
uni.navigateTo({ url: `/pages/comprehensive/bookings/private-detail?id=${item.id}` })
} else if (item.order_id) {
@@ -165,6 +198,30 @@ const goDetail = (item) => {
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 {
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>