feat(app): implement home, booking, and profile pages

Home: brand banner, studio info swiper, smart quick entries based on
membership status, upcoming bookings, card shop horizontal scroll
Booking: 7-day date selector, time period filter, slot cards with
status, booking confirm popup with membership picker
Profile: user card with login, training stats, menu with admin entry
8 reusable components: BrandBanner, StudioInfo, QuickEntry,
UpcomingBooking, CardShop, DateSelector, SlotCard, BookingConfirmPopup,
TimePeriodFilter, UserCard, TrainingStats, ProfileMenu
This commit is contained in:
richarjiang
2026-04-02 14:35:17 +08:00
parent 554fc30954
commit 3a29aca0db
26 changed files with 7766 additions and 74 deletions

View File

@@ -1,23 +1,310 @@
<template>
<view class="booking-page">
<view class="placeholder">
<text>预约课程 - 待实现</text>
<!-- Sticky header area -->
<view class="sticky-header">
<!-- Date selector -->
<DateSelector v-model="selectedDate" @select="onDateSelect" />
<!-- Time period filter -->
<TimePeriodFilter v-model="selectedPeriod" @change="onPeriodChange" />
</view>
<!-- Slot list -->
<scroll-view
class="slot-scroll"
scroll-y
:style="{ height: scrollHeight }"
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
>
<!-- Loading skeleton -->
<view v-if="bookingStore.loadingSlots && !refreshing" class="loading-wrap">
<view v-for="i in 4" :key="i" class="skeleton-card" />
</view>
<!-- Empty state -->
<view v-else-if="filteredSlots.length === 0" class="empty-wrap">
<image class="empty-img" src="/static/images/empty-calendar.png" mode="aspectFit" />
<text class="empty-text">当日暂无可约时段</text>
<text class="empty-sub">请选择其他日期或时段</text>
</view>
<!-- Slot cards -->
<view v-else class="slot-list">
<SlotCard
v-for="slot in filteredSlots"
:key="slot.id"
:slot="slot"
@book="onBookTap"
@cancel="onCancelTap"
/>
</view>
<!-- Bottom padding spacer -->
<view class="scroll-bottom-spacer" />
</scroll-view>
<!-- Confirm popup -->
<BookingConfirmPopup
:visible="showConfirmPopup"
:slot="pendingSlot"
:memberships="userStore.activeMemberships as MembershipWithCardType[]"
@confirm="onConfirmBooking"
@cancel="showConfirmPopup = false"
/>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
import { TIME_PERIODS } from '@mp-pilates/shared'
import { useBookingStore } from '../../stores/booking'
import { useUserStore } from '../../stores/user'
import { formatDate, getDateRange } from '../../utils/format'
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)
// ─── Layout ───────────────────────────────────────────────
// Approximate scroll area height (vh minus sticky header ~220rpx + tabbar ~100rpx)
const scrollHeight = computed(() => {
const sysInfo = uni.getSystemInfoSync()
const headerPx = 220 * (sysInfo.windowWidth / 750)
const tabbarPx = 100 * (sysInfo.windowWidth / 750)
return `${sysInfo.windowHeight - headerPx - tabbarPx}px`
})
// ─── 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) {
// Filtering is done client-side via computed property
}
// ─── Book flow ────────────────────────────────────────────
async function onBookTap(slot: TimeSlotWithBookingStatus) {
// 1. Ensure logged in
if (!userStore.loggedIn) {
uni.showModal({
title: '提示',
content: '请先登录后再预约课程',
confirmText: '去登录',
success: async (res) => {
if (res.confirm) {
try {
await userStore.login()
await userStore.fetchMemberships()
// Retry booking flow after login
onBookTap(slot)
} catch {
uni.showToast({ title: '登录失败', icon: 'none' })
}
}
},
})
return
}
// 2. Ensure has valid membership
if (!userStore.hasValidMembership) {
uni.showModal({
title: '暂无可用会员卡',
content: '您当前没有有效的会员卡,购买后即可预约课程',
confirmText: '去购买',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/store/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 {
min-height: 100vh;
}
.placeholder {
background: #f5f3f0;
display: flex;
flex-direction: column;
}
/* ── Sticky header ─────────────────────────────────── */
.sticky-header {
position: sticky;
top: 0;
z-index: 100;
background: #fff;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
/* ── Scroll container ──────────────────────────────── */
.slot-scroll {
flex: 1;
width: 100%;
}
/* ── Slot list ─────────────────────────────────────── */
.slot-list {
display: flex;
flex-direction: column;
gap: 20rpx;
padding: 28rpx 24rpx 0;
}
/* ── Loading skeleton ──────────────────────────────── */
.loading-wrap {
display: flex;
flex-direction: column;
gap: 20rpx;
padding: 28rpx 24rpx;
}
.skeleton-card {
height: 140rpx;
border-radius: 20rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
/* ── Empty state ───────────────────────────────────── */
.empty-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 400rpx;
color: #999;
padding: 120rpx 40rpx;
gap: 16rpx;
}
.empty-img {
width: 200rpx;
height: 200rpx;
opacity: 0.5;
margin-bottom: 8rpx;
}
.empty-text {
font-size: 30rpx;
color: #666;
font-weight: 600;
}
.empty-sub {
font-size: 26rpx;
color: #bbb;
}
/* ── Bottom spacer ─────────────────────────────────── */
.scroll-bottom-spacer {
height: 48rpx;
}
</style>