- 将 admin 相关的 store/utils 移入对应子目录(pages/admin/stores、pages/admin/utils) - 更新 manifest.json、pages.json 路由与配置 - 个人中心 ProfileMenu 移除顶部「我的会员卡 / 我的预约」快捷卡片,与 UserCard 会员卡入口合并 - 「我的预约」下移至菜单列表,紧邻「个人信息」 - 清理 profile/index.vue 中不再使用的 bookingStore / 计算属性 / 透传
339 lines
13 KiB
Vue
339 lines
13 KiB
Vue
<template>
|
|
<view class="booking-page" :style="{ height: pageHeight }">
|
|
<!-- ──────────── Status bar spacing ──────────── -->
|
|
<view class="status-bar" :style="{ height: statusBarHeight }" />
|
|
|
|
<!-- ──────────── Page title ──────────── -->
|
|
<view class="page-header">
|
|
<text class="page-title">课程预约</text>
|
|
</view>
|
|
|
|
<!-- ──────────── Date & period filters ──────────── -->
|
|
<view class="filter-header">
|
|
<view class="calendar-heading">
|
|
<text class="calendar-month">{{ selectedMonthLabel }}</text>
|
|
<text class="calendar-hint">选择上课日期</text>
|
|
</view>
|
|
<DateSelector v-model="selectedDate" variant="soft" @select="onDateSelect" />
|
|
<TimePeriodFilter v-model="selectedPeriod" variant="soft" @change="onPeriodChange" />
|
|
</view>
|
|
|
|
<!-- ──────────── Slot list ──────────── -->
|
|
<scroll-view
|
|
class="slot-scroll"
|
|
scroll-y
|
|
refresher-enabled
|
|
:refresher-triggered="refreshing"
|
|
@refresherrefresh="onRefresh"
|
|
>
|
|
<!-- Loading skeleton -->
|
|
<view v-if="bookingStore.loadingSlots && !refreshing" class="loading-wrap">
|
|
<view v-for="i in 3" :key="i" class="skeleton-card">
|
|
<view class="skeleton-time" />
|
|
<view class="skeleton-body">
|
|
<view class="skeleton-title" />
|
|
<view class="skeleton-sub" />
|
|
</view>
|
|
<view class="skeleton-btn" />
|
|
</view>
|
|
</view>
|
|
|
|
<!-- Empty state -->
|
|
<view v-else-if="filteredSlots.length === 0" class="empty-wrap">
|
|
<text class="empty-text">这个时段还没有课程</text>
|
|
<text class="empty-sub">请选择其他日期或时段查看</text>
|
|
</view>
|
|
|
|
<!-- Slot cards -->
|
|
<view v-else class="slot-list">
|
|
<!-- Date summary -->
|
|
<view class="date-summary">
|
|
<text class="date-summary-title">{{ selectedDayLabel }}</text>
|
|
<text class="date-summary-text">{{ filteredSlots.length }} 个时段</text>
|
|
</view>
|
|
|
|
<SlotCard
|
|
v-for="item in filteredSlots"
|
|
:key="item.id"
|
|
:time-slot="item"
|
|
@book="onBookTap"
|
|
@cancel="onCancelTap"
|
|
@card-tap="onSlotCardTap"
|
|
/>
|
|
</view>
|
|
|
|
<!-- Bottom padding spacer -->
|
|
<view class="scroll-bottom-spacer" />
|
|
</scroll-view>
|
|
|
|
<!-- ──────────── Confirm popup ──────────── -->
|
|
<BookingConfirmPopup
|
|
v-if="showConfirmPopup"
|
|
:visible="true"
|
|
:time-slot="pendingSlot"
|
|
:memberships="userStore.activeMemberships as MembershipWithCardType[]"
|
|
@confirm="onConfirmBooking"
|
|
@cancel="showConfirmPopup = false"
|
|
/>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { onResize, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
|
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
|
|
import { BookingStatus, TIME_PERIODS } from '@mp-pilates/shared'
|
|
import { useBookingStore } from '../../stores/booking'
|
|
import { useUserStore } from '../../stores/user'
|
|
import { getErrorMessage } from '../../utils/auth'
|
|
import { formatDate } from '../../utils/format'
|
|
import { getSystemLayout } from '../../utils/system'
|
|
import DateSelector from '../../components/DateSelector.vue'
|
|
import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
|
|
import SlotCard from '../../components/SlotCard.vue'
|
|
import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue'
|
|
|
|
type PeriodKey = keyof typeof TIME_PERIODS | null
|
|
|
|
// ─── Stores ───────────────────────────────────────────────
|
|
const bookingStore = useBookingStore()
|
|
const userStore = useUserStore()
|
|
|
|
// ─── State ────────────────────────────────────────────────
|
|
const selectedDate = ref<string>(formatDate(new Date()))
|
|
const selectedPeriod = ref<PeriodKey>(null)
|
|
const showConfirmPopup = ref(false)
|
|
const pendingSlot = ref<TimeSlotWithBookingStatus | null>(null)
|
|
const refreshing = ref(false)
|
|
|
|
// ─── 微信分享 ───────────────────────────────────────────────
|
|
onShareAppMessage(() => {
|
|
return {
|
|
title: '预约普拉提课程,开启健康新生活',
|
|
path: '/pages/booking/index',
|
|
imageUrl: '',
|
|
}
|
|
})
|
|
|
|
onShareTimeline(() => {
|
|
return {
|
|
title: '预约普拉提课程,开启健康新生活',
|
|
query: '',
|
|
}
|
|
})
|
|
|
|
// ─── Layout ───────────────────────────────────────────────
|
|
const statusBarHeight = ref('20px')
|
|
const pageHeight = ref('100vh')
|
|
|
|
function updateLayout() {
|
|
statusBarHeight.value = `${getSystemLayout().statusBarHeight}px`
|
|
// The mini-program window already excludes its native tab bar. Flex layout
|
|
// gives the remaining height to the list as the filters change size.
|
|
pageHeight.value = `${uni.getWindowInfo().windowHeight}px`
|
|
}
|
|
|
|
updateLayout()
|
|
onResize(updateLayout)
|
|
|
|
const selectedMonthLabel = computed(() => {
|
|
const [year, month] = selectedDate.value.split('-')
|
|
return `${year} 年 ${Number(month)} 月`
|
|
})
|
|
const selectedDayLabel = computed(() => {
|
|
const [, month, day] = selectedDate.value.split('-')
|
|
return `${Number(month)} 月 ${Number(day)} 日的课程`
|
|
})
|
|
|
|
// ─── Filtered slots ───────────────────────────────────────
|
|
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
|
|
const slots = bookingStore.slots as TimeSlotWithBookingStatus[]
|
|
if (!selectedPeriod.value) return slots
|
|
|
|
const period = TIME_PERIODS[selectedPeriod.value]
|
|
return slots.filter((slot) => {
|
|
const t = slot.startTime
|
|
return t >= period.start && t < period.end
|
|
})
|
|
})
|
|
|
|
// ─── Data loading ─────────────────────────────────────────
|
|
async function loadSlots(date: string) {
|
|
await bookingStore.fetchSlots(date)
|
|
}
|
|
|
|
async function onRefresh() {
|
|
refreshing.value = true
|
|
await loadSlots(selectedDate.value)
|
|
refreshing.value = false
|
|
}
|
|
|
|
// ─── Event handlers ───────────────────────────────────────
|
|
function onDateSelect(date: string) {
|
|
selectedDate.value = date
|
|
loadSlots(date)
|
|
}
|
|
|
|
function onPeriodChange(_period: PeriodKey) {
|
|
// No-op: filtering is done client-side via computed property
|
|
void _period
|
|
}
|
|
|
|
// ─── Card tap → navigate to detail ───────────────────────
|
|
function onSlotCardTap(slot: TimeSlotWithBookingStatus) {
|
|
if (slot.isBookedByMe && slot.myBookingId) {
|
|
// Already booked → show booking detail
|
|
uni.navigateTo({ url: `/pages/booking/detail?id=${slot.myBookingId}` })
|
|
} else {
|
|
// Not booked → show slot preview with booking action
|
|
uni.navigateTo({ url: `/pages/booking/detail?slotId=${slot.id}&date=${slot.date}` })
|
|
}
|
|
}
|
|
|
|
// ─── Book flow ────────────────────────────────────────────
|
|
async function onBookTap(slot: TimeSlotWithBookingStatus) {
|
|
if (slot.isBookedByMe) {
|
|
if (slot.myBookingId) {
|
|
uni.navigateTo({ url: `/pages/booking/detail?id=${slot.myBookingId}` })
|
|
return
|
|
}
|
|
|
|
const title = slot.myBookingStatus === BookingStatus.PENDING_CONFIRMATION
|
|
? '该时段已预约,等待老师确认'
|
|
: '该时段已预约'
|
|
uni.showToast({ title, icon: 'none' })
|
|
return
|
|
}
|
|
|
|
// 1. Ensure logged in
|
|
if (!userStore.loggedIn) {
|
|
uni.showModal({
|
|
title: '提示',
|
|
content: '请先登录后再预约课程',
|
|
confirmText: '去登录',
|
|
success: async (res) => {
|
|
if (res.confirm) {
|
|
try {
|
|
const { isNewUser } = await userStore.loginWithSetup()
|
|
if (!isNewUser) {
|
|
onBookTap(slot)
|
|
}
|
|
} catch (err: unknown) {
|
|
uni.showToast({ title: getErrorMessage(err, '登录失败'), icon: 'none' })
|
|
}
|
|
}
|
|
},
|
|
})
|
|
return
|
|
}
|
|
|
|
// 2. Ensure has valid membership
|
|
if (!userStore.hasValidMembership) {
|
|
uni.showModal({
|
|
title: '暂无可用会员卡',
|
|
content: '您当前没有有效的会员卡,购买后即可预约课程',
|
|
confirmText: '去购买',
|
|
cancelText: '取消',
|
|
success: (res) => {
|
|
if (res.confirm) {
|
|
// Switch to home tab and auto-scroll to card shop
|
|
uni.$emit('scrollToCardShop')
|
|
uni.switchTab({ url: '/pages/home/index' })
|
|
}
|
|
},
|
|
})
|
|
return
|
|
}
|
|
|
|
// 3. Show confirm popup
|
|
pendingSlot.value = slot
|
|
showConfirmPopup.value = true
|
|
}
|
|
|
|
async function onConfirmBooking(payload: { timeSlotId: string; membershipId: string }) {
|
|
showConfirmPopup.value = false
|
|
|
|
uni.showLoading({ title: '预约中...' })
|
|
try {
|
|
await bookingStore.createBooking(payload)
|
|
uni.hideLoading()
|
|
uni.showToast({ title: '预约成功!', icon: 'success' })
|
|
// Refresh slots to reflect new booking status
|
|
await loadSlots(selectedDate.value)
|
|
} catch (err: unknown) {
|
|
uni.hideLoading()
|
|
const message = err instanceof Error ? err.message : '预约失败,请重试'
|
|
uni.showToast({ title: message, icon: 'none' })
|
|
}
|
|
}
|
|
|
|
async function onCancelTap(slot: TimeSlotWithBookingStatus) {
|
|
if (!slot.myBookingId) return
|
|
|
|
uni.showModal({
|
|
title: '取消预约',
|
|
content: '确定要取消这个预约吗?',
|
|
confirmText: '确定取消',
|
|
confirmColor: '#ef4444',
|
|
cancelText: '再想想',
|
|
success: async (res) => {
|
|
if (res.confirm) {
|
|
uni.showLoading({ title: '取消中...' })
|
|
try {
|
|
await bookingStore.cancelBooking(slot.myBookingId!)
|
|
uni.hideLoading()
|
|
uni.showToast({ title: '已取消预约', icon: 'success' })
|
|
await loadSlots(selectedDate.value)
|
|
} catch (err: unknown) {
|
|
uni.hideLoading()
|
|
const message = err instanceof Error ? err.message : '取消失败,请重试'
|
|
uni.showToast({ title: message, icon: 'none' })
|
|
}
|
|
}
|
|
},
|
|
})
|
|
}
|
|
|
|
// ─── Lifecycle ────────────────────────────────────────────
|
|
onMounted(async () => {
|
|
// Load memberships if logged in but not yet fetched
|
|
if (userStore.loggedIn && userStore.activeMemberships.length === 0) {
|
|
await userStore.fetchMemberships()
|
|
}
|
|
// Load today's slots
|
|
await loadSlots(selectedDate.value)
|
|
})
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.booking-page { height: 100vh; background: #fbf9f6; display: flex; flex-direction: column; overflow: hidden; }
|
|
.status-bar { flex-shrink: 0; }
|
|
.page-header { flex-shrink: 0; height: 88rpx; display: flex; align-items: center; justify-content: center; }
|
|
.page-title { font-size: 34rpx; font-weight: 500; color: #514943; }
|
|
.filter-header { flex-shrink: 0; border-bottom: 1rpx solid #eee8e3; }
|
|
.calendar-heading { display: flex; align-items: baseline; justify-content: space-between; padding: 24rpx 32rpx 16rpx; gap: 16rpx; }
|
|
.calendar-month { font-size: 28rpx; font-weight: 500; color: #514943; }
|
|
.calendar-hint { font-size: 22rpx; color: #8b817b; }
|
|
.slot-scroll { flex: 1; height: 0; min-height: 0; width: 100%; box-sizing: border-box; }
|
|
.slot-list { display: flex; flex-direction: column; padding-top: 28rpx; }
|
|
.date-summary { display: flex; justify-content: space-between; align-items: baseline; gap: 16rpx; padding: 0 32rpx 20rpx; }
|
|
.date-summary-title { font-size: 25rpx; color: #6f655e; }
|
|
.date-summary-text { font-size: 22rpx; color: #8b817b; }
|
|
.loading-wrap { display: flex; flex-direction: column; gap: 20rpx; padding: 28rpx 32rpx; }
|
|
.skeleton-card { height: 200rpx; box-sizing: border-box; border-radius: 28rpx; background: #fff; display: flex; align-items: center; padding: 28rpx; gap: 20rpx; }
|
|
.skeleton-time, .skeleton-title, .skeleton-sub, .skeleton-btn {
|
|
border-radius: 10rpx;
|
|
background: linear-gradient(90deg, #f0eae5 25%, #faf7f3 50%, #f0eae5 75%);
|
|
background-size: 400% 100%; animation: shimmer 1.4s infinite;
|
|
}
|
|
.skeleton-time { width: 90rpx; height: 70rpx; flex-shrink: 0; }
|
|
.skeleton-body { flex: 1; display: flex; flex-direction: column; gap: 12rpx; }
|
|
.skeleton-title { width: 80%; height: 28rpx; }
|
|
.skeleton-sub { width: 60%; height: 20rpx; }
|
|
.skeleton-btn { width: 110rpx; height: 60rpx; border-radius: 999rpx; flex-shrink: 0; }
|
|
.empty-wrap { margin: 32rpx; padding: 64rpx 28rpx; border-radius: 28rpx; background: #f3efea; display: flex; flex-direction: column; align-items: center; gap: 14rpx; }
|
|
.empty-text { font-size: 28rpx; color: #6f655e; font-weight: 400; }
|
|
.empty-sub { font-size: 23rpx; color: #8b817b; text-align: center; line-height: 1.6; }
|
|
.scroll-bottom-spacer { height: 28rpx; }
|
|
</style>
|