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:
@@ -1,15 +1,443 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="placeholder">
|
||||
<text>我的预约 - 待实现</text>
|
||||
<view class="bookings-page">
|
||||
<!-- Tab filter -->
|
||||
<view class="tab-bar">
|
||||
<view
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === tab.key }"
|
||||
@tap="selectTab(tab.key)"
|
||||
>
|
||||
<text class="tab-label">{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Content -->
|
||||
<scroll-view
|
||||
class="scroll"
|
||||
scroll-y
|
||||
refresher-enabled
|
||||
:refresher-triggered="refreshing"
|
||||
@refresherrefresh="onRefresh"
|
||||
>
|
||||
<!-- Loading -->
|
||||
<view v-if="bookingStore.loadingBookings && !refreshing" class="loading-wrap">
|
||||
<view v-for="i in 3" :key="i" class="skeleton-card" />
|
||||
</view>
|
||||
|
||||
<!-- Empty -->
|
||||
<view v-else-if="filteredBookings.length === 0" class="empty-wrap">
|
||||
<text class="empty-icon">📅</text>
|
||||
<text class="empty-title">暂无预约记录</text>
|
||||
<text class="empty-sub">去预约一节课吧</text>
|
||||
<view class="empty-btn" @tap="goBooking">
|
||||
<text class="empty-btn-text">去预约</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Booking list -->
|
||||
<view v-else class="list">
|
||||
<view
|
||||
v-for="booking in filteredBookings"
|
||||
:key="booking.id"
|
||||
class="booking-card"
|
||||
>
|
||||
<!-- Date header stripe -->
|
||||
<view class="booking-stripe" :class="stripeClass(booking.status)" />
|
||||
|
||||
<!-- Card content -->
|
||||
<view class="booking-content">
|
||||
<view class="booking-main">
|
||||
<!-- Date + time -->
|
||||
<view class="booking-datetime">
|
||||
<text class="booking-date">{{ formatDateDisplay(booking.timeSlot.date) }}</text>
|
||||
<text class="booking-time">
|
||||
{{ booking.timeSlot.startTime.slice(0, 5) }} - {{ booking.timeSlot.endTime.slice(0, 5) }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- Status badge -->
|
||||
<view class="status-badge" :class="statusBadgeClass(booking.status)">
|
||||
<text class="status-text">{{ statusLabel(booking.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Membership used -->
|
||||
<view class="booking-meta">
|
||||
<text class="meta-label">💳 {{ booking.membership.cardType.name }}</text>
|
||||
</view>
|
||||
|
||||
<!-- Cancel button for confirmed upcoming bookings -->
|
||||
<view
|
||||
v-if="booking.status === BookingStatus.CONFIRMED && isUpcoming(booking.timeSlot.date)"
|
||||
class="cancel-row"
|
||||
>
|
||||
<view class="cancel-btn" @tap="handleCancel(booking)">
|
||||
<text class="cancel-text">取消预约</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="scroll-bottom-spacer" />
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import type { BookingWithDetails } from '@mp-pilates/shared'
|
||||
import { BookingStatus } from '@mp-pilates/shared'
|
||||
import { useBookingStore } from '../../stores/booking'
|
||||
import { formatDate } from '../../utils/format'
|
||||
|
||||
const bookingStore = useBookingStore()
|
||||
|
||||
// ─── Tab state ────────────────────────────────────────────
|
||||
type TabKey = 'upcoming' | 'all'
|
||||
|
||||
const tabs = [
|
||||
{ key: 'upcoming' as TabKey, label: '即将上课' },
|
||||
{ key: 'all' as TabKey, label: '全部记录' },
|
||||
]
|
||||
|
||||
const activeTab = ref<TabKey>('upcoming')
|
||||
const refreshing = ref(false)
|
||||
|
||||
// ─── Filtered bookings ────────────────────────────────────
|
||||
const filteredBookings = computed<BookingWithDetails[]>(() => {
|
||||
const all = bookingStore.myBookings as BookingWithDetails[]
|
||||
if (activeTab.value === 'upcoming') {
|
||||
const today = formatDate(new Date())
|
||||
return all.filter(
|
||||
(b) => b.status === BookingStatus.CONFIRMED && b.timeSlot.date >= today,
|
||||
).sort((a, b) => a.timeSlot.date.localeCompare(b.timeSlot.date))
|
||||
}
|
||||
return [...all].sort((a, b) => {
|
||||
// Most recent first
|
||||
if (b.timeSlot.date !== a.timeSlot.date) {
|
||||
return b.timeSlot.date.localeCompare(a.timeSlot.date)
|
||||
}
|
||||
return b.timeSlot.startTime.localeCompare(a.timeSlot.startTime)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────
|
||||
function isUpcoming(date: string): boolean {
|
||||
return date >= formatDate(new Date())
|
||||
}
|
||||
|
||||
function statusLabel(status: BookingStatus): string {
|
||||
const map: Record<BookingStatus, string> = {
|
||||
[BookingStatus.CONFIRMED]: '已预约',
|
||||
[BookingStatus.CANCELLED]: '已取消',
|
||||
[BookingStatus.COMPLETED]: '已完成',
|
||||
[BookingStatus.NO_SHOW]: '未出席',
|
||||
}
|
||||
return map[status] ?? status
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: BookingStatus): string {
|
||||
const map: Record<BookingStatus, string> = {
|
||||
[BookingStatus.CONFIRMED]: 'badge--confirmed',
|
||||
[BookingStatus.CANCELLED]: 'badge--cancelled',
|
||||
[BookingStatus.COMPLETED]: 'badge--completed',
|
||||
[BookingStatus.NO_SHOW]: 'badge--noshow',
|
||||
}
|
||||
return map[status] ?? ''
|
||||
}
|
||||
|
||||
function stripeClass(status: BookingStatus): string {
|
||||
const map: Record<BookingStatus, string> = {
|
||||
[BookingStatus.CONFIRMED]: 'stripe--confirmed',
|
||||
[BookingStatus.CANCELLED]: 'stripe--cancelled',
|
||||
[BookingStatus.COMPLETED]: 'stripe--completed',
|
||||
[BookingStatus.NO_SHOW]: 'stripe--noshow',
|
||||
}
|
||||
return map[status] ?? ''
|
||||
}
|
||||
|
||||
function formatDateDisplay(dateStr: string): string {
|
||||
// e.g. "2024-03-15" → "3月15日 周五"
|
||||
const d = new Date(dateStr)
|
||||
const month = d.getMonth() + 1
|
||||
const day = d.getDate()
|
||||
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
const weekday = weekdays[d.getDay()]
|
||||
return `${month}月${day}日 ${weekday}`
|
||||
}
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────
|
||||
function selectTab(key: TabKey) {
|
||||
activeTab.value = key
|
||||
}
|
||||
|
||||
async function onRefresh() {
|
||||
refreshing.value = true
|
||||
await bookingStore.fetchMyBookings()
|
||||
refreshing.value = false
|
||||
}
|
||||
|
||||
function goBooking() {
|
||||
uni.switchTab({ url: '/pages/booking/index' })
|
||||
}
|
||||
|
||||
async function handleCancel(booking: BookingWithDetails) {
|
||||
uni.showModal({
|
||||
title: '取消预约',
|
||||
content: `确定要取消 ${formatDateDisplay(booking.timeSlot.date)} ${booking.timeSlot.startTime.slice(0, 5)} 的课程吗?`,
|
||||
confirmText: '确定取消',
|
||||
confirmColor: '#ef4444',
|
||||
cancelText: '再想想',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
uni.showLoading({ title: '取消中...' })
|
||||
try {
|
||||
await bookingStore.cancelBooking(booking.id)
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '已取消预约', icon: 'success' })
|
||||
await bookingStore.fetchMyBookings()
|
||||
} catch (err: unknown) {
|
||||
uni.hideLoading()
|
||||
const msg = err instanceof Error ? err.message : '取消失败,请重试'
|
||||
uni.showToast({ title: msg, icon: 'none' })
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────
|
||||
onMounted(() => bookingStore.fetchMyBookings())
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page { min-height: 100vh; background: #f5f5f5; }
|
||||
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
|
||||
.bookings-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f3f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Tab bar ─────────────────────────────────────────── */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background: #fff;
|
||||
border-bottom: 1rpx solid #f0ece8;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 28rpx 0;
|
||||
position: relative;
|
||||
|
||||
&.active {
|
||||
.tab-label {
|
||||
color: #c9a87c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 40rpx;
|
||||
height: 4rpx;
|
||||
background: #c9a87c;
|
||||
border-radius: 2rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ── Scroll ──────────────────────────────────────────── */
|
||||
.scroll {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── Loading ─────────────────────────────────────────── */
|
||||
.loading-wrap {
|
||||
padding: 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 160rpx;
|
||||
border-radius: 16rpx;
|
||||
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 ───────────────────────────────────────────── */
|
||||
.empty-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 120rpx 40rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 80rpx;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.empty-sub {
|
||||
font-size: 26rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-btn {
|
||||
margin-top: 12rpx;
|
||||
padding: 20rpx 56rpx;
|
||||
border-radius: 44rpx;
|
||||
background: #c9a87c;
|
||||
}
|
||||
|
||||
.empty-btn-text {
|
||||
font-size: 30rpx;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── List ────────────────────────────────────────────── */
|
||||
.list {
|
||||
padding: 24rpx 24rpx 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
/* ── Booking card ────────────────────────────────────── */
|
||||
.booking-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.booking-stripe {
|
||||
width: 8rpx;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--confirmed { background: #c9a87c; }
|
||||
&--completed { background: #4caf50; }
|
||||
&--cancelled { background: #e0e0e0; }
|
||||
&--noshow { background: #ef4444; }
|
||||
}
|
||||
|
||||
.booking-content {
|
||||
flex: 1;
|
||||
padding: 24rpx 24rpx 24rpx 20rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.booking-main {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.booking-datetime {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.booking-date {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.booking-time {
|
||||
font-size: 24rpx;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 8rpx 18rpx;
|
||||
border-radius: 20rpx;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.badge--confirmed { background: #fff8ee; }
|
||||
&.badge--completed { background: #f0faf3; }
|
||||
&.badge--cancelled { background: #f5f5f5; }
|
||||
&.badge--noshow { background: #fef0f0; }
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
|
||||
.badge--confirmed & { color: #c9a87c; }
|
||||
.badge--completed & { color: #4caf50; }
|
||||
.badge--cancelled & { color: #bbb; }
|
||||
.badge--noshow & { color: #ef4444; }
|
||||
}
|
||||
|
||||
.booking-meta {
|
||||
.meta-label {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Cancel row ──────────────────────────────────────── */
|
||||
.cancel-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
padding: 8rpx 24rpx;
|
||||
}
|
||||
|
||||
.cancel-text {
|
||||
font-size: 24rpx;
|
||||
color: #ef4444;
|
||||
text-decoration: underline;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Spacer ──────────────────────────────────────────── */
|
||||
.scroll-bottom-spacer {
|
||||
height: 48rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user