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

@@ -0,0 +1,229 @@
<template>
<view v-if="bookingStore.upcomingBookings.length" class="upcoming-section">
<view class="section-header">
<text class="section-title">即将上课</text>
<text class="section-more" @tap="goToBookings">全部预约 </text>
</view>
<view
v-for="booking in displayedBookings"
:key="booking.id"
class="booking-card"
>
<!-- Date column -->
<view class="date-col">
<text class="date-day">{{ getDayNumber(booking.timeSlot.date) }}</text>
<text class="date-month">{{ getMonthLabel(booking.timeSlot.date) }}</text>
<text class="date-weekday">{{ getWeekdayLabel(booking.timeSlot.date) }}</text>
</view>
<!-- Divider -->
<view class="booking-divider" />
<!-- Details column -->
<view class="details-col">
<text class="card-name">{{ booking.membership.cardType.name }}</text>
<view class="time-row">
<text class="time-icon">🕐</text>
<text class="time-text">
{{ formatTime(booking.timeSlot.startTime) }} {{ formatTime(booking.timeSlot.endTime) }}
</text>
</view>
<view class="status-row">
<view class="status-dot" :class="statusDotClass(booking.status)" />
<text class="status-text" :class="statusTextClass(booking.status)">
{{ statusLabel(booking.status) }}
</text>
</view>
</view>
<!-- Right arrow -->
<text class="booking-arrow"></text>
</view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useBookingStore } from '../stores/booking'
import { getWeekdayLabel } from '../utils/format'
import { BookingStatus } from '@mp-pilates/shared'
const bookingStore = useBookingStore()
// Show at most 2 upcoming bookings
const displayedBookings = computed(() =>
bookingStore.upcomingBookings.slice(0, 2),
)
function getDayNumber(dateStr: string): string {
return new Date(dateStr).getDate().toString()
}
function getMonthLabel(dateStr: string): string {
const months = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
return months[new Date(dateStr).getMonth()]
}
function formatTime(timeStr: string): string {
// timeStr might be "HH:mm:ss" or "HH:mm"
return timeStr.slice(0, 5)
}
function statusLabel(status: BookingStatus): string {
const map: Record<BookingStatus, string> = {
[BookingStatus.CONFIRMED]: '已确认',
[BookingStatus.CANCELLED]: '已取消',
[BookingStatus.COMPLETED]: '已完成',
[BookingStatus.NO_SHOW]: '未出席',
}
return map[status] ?? status
}
function statusDotClass(status: BookingStatus): string {
if (status === BookingStatus.CONFIRMED) return 'dot--confirmed'
if (status === BookingStatus.COMPLETED) return 'dot--completed'
if (status === BookingStatus.CANCELLED) return 'dot--cancelled'
return 'dot--default'
}
function statusTextClass(status: BookingStatus): string {
if (status === BookingStatus.CONFIRMED) return 'text--confirmed'
if (status === BookingStatus.COMPLETED) return 'text--completed'
if (status === BookingStatus.CANCELLED) return 'text--cancelled'
return ''
}
function goToBookings() {
uni.navigateTo({ url: '/pages/profile/bookings' })
}
</script>
<style lang="scss" scoped>
.upcoming-section {
margin: 24rpx 24rpx 0;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.section-title {
font-size: 32rpx;
font-weight: 700;
color: #1a1a2e;
}
.section-more {
font-size: 26rpx;
color: #c9a87c;
}
.booking-card {
background: #ffffff;
border-radius: 16rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.08);
padding: 28rpx 28rpx;
display: flex;
align-items: center;
gap: 24rpx;
margin-bottom: 16rpx;
}
.date-col {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
width: 88rpx;
}
.date-day {
font-size: 52rpx;
font-weight: 800;
color: #1a1a2e;
line-height: 1;
}
.date-month {
font-size: 22rpx;
color: #c9a87c;
margin-top: 4rpx;
}
.date-weekday {
font-size: 22rpx;
color: #999;
margin-top: 4rpx;
}
.booking-divider {
width: 2rpx;
height: 80rpx;
background: #f0f0f0;
flex-shrink: 0;
}
.details-col {
flex: 1;
}
.card-name {
display: block;
font-size: 30rpx;
font-weight: 600;
color: #1a1a2e;
margin-bottom: 10rpx;
}
.time-row {
display: flex;
align-items: center;
gap: 8rpx;
margin-bottom: 10rpx;
}
.time-icon {
font-size: 24rpx;
}
.time-text {
font-size: 26rpx;
color: #555;
}
.status-row {
display: flex;
align-items: center;
gap: 8rpx;
}
.status-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
}
.dot--confirmed { background: #27ae60; }
.dot--completed { background: #3498db; }
.dot--cancelled { background: #e74c3c; }
.dot--default { background: #999; }
.status-text {
font-size: 24rpx;
color: #999;
}
.text--confirmed { color: #27ae60; }
.text--completed { color: #3498db; }
.text--cancelled { color: #e74c3c; }
.booking-arrow {
font-size: 36rpx;
color: #ccc;
flex-shrink: 0;
}
</style>