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,228 @@
<template>
<view class="slot-card">
<!-- Time & capacity info -->
<view class="slot-main">
<view class="slot-time-block">
<text class="slot-time">{{ slot.startTime.slice(0, 5) }} - {{ slot.endTime.slice(0, 5) }}</text>
<view class="slot-capacity" :class="capacityClass">
<text class="capacity-text">{{ capacityLabel }}</text>
</view>
</view>
<!-- Action area -->
<view class="slot-action">
<!-- OPEN + not booked by me -->
<template v-if="slot.status === TimeSlotStatus.OPEN && !slot.isBookedByMe">
<view class="btn btn-book" @tap.stop="emit('book', slot)">
<text class="btn-text">可预约</text>
</view>
</template>
<!-- OPEN + booked by me -->
<template v-else-if="slot.status === TimeSlotStatus.OPEN && slot.isBookedByMe">
<view class="booked-row">
<view class="badge-booked">
<text class="badge-text">已预约</text>
</view>
<view class="btn-cancel" @tap.stop="emit('cancel', slot)">
<text class="btn-cancel-text">取消</text>
</view>
</view>
</template>
<!-- FULL -->
<template v-else-if="slot.status === TimeSlotStatus.FULL">
<view class="btn btn-disabled">
<text class="btn-text">已约满</text>
</view>
</template>
<!-- CLOSED -->
<template v-else>
<view class="btn btn-disabled">
<text class="btn-text">已关闭</text>
</view>
</template>
</view>
</view>
<!-- Booked indicator bar -->
<view v-if="slot.isBookedByMe" class="booked-bar" />
</view>
</template>
<script setup lang="ts">
import type { TimeSlotWithBookingStatus } from '@mp-pilates/shared'
import { TimeSlotStatus } from '@mp-pilates/shared'
import { computed } from 'vue'
interface Props {
slot: TimeSlotWithBookingStatus
}
const props = defineProps<Props>()
const emit = defineEmits<{
book: [slot: TimeSlotWithBookingStatus]
cancel: [slot: TimeSlotWithBookingStatus]
}>()
const capacityLabel = computed(() => {
const { bookedCount, capacity, status } = props.slot
if (status === TimeSlotStatus.CLOSED) return '已关闭'
return `${bookedCount}/${capacity}`
})
const capacityClass = computed(() => {
const { bookedCount, capacity, status } = props.slot
if (status === TimeSlotStatus.CLOSED) return 'cap-closed'
if (status === TimeSlotStatus.FULL) return 'cap-full'
if (bookedCount >= capacity * 0.8) return 'cap-almost'
return 'cap-open'
})
</script>
<style lang="scss" scoped>
.slot-card {
background: #fff;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.06);
position: relative;
.booked-bar {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6rpx;
background: #c9a87c;
border-radius: 20rpx 0 0 20rpx;
}
.slot-main {
display: flex;
flex-direction: row;
align-items: center;
padding: 32rpx 28rpx 32rpx 36rpx;
gap: 20rpx;
}
.slot-time-block {
flex: 1;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.slot-time {
font-size: 36rpx;
font-weight: 700;
color: #1a1a1a;
letter-spacing: 1rpx;
}
.slot-capacity {
display: inline-flex;
align-self: flex-start;
.capacity-text {
font-size: 22rpx;
font-weight: 500;
padding: 4rpx 14rpx;
border-radius: 20rpx;
}
&.cap-open .capacity-text {
background: #f0faf3;
color: #4caf50;
}
&.cap-almost .capacity-text {
background: #fff8ed;
color: #f59e0b;
}
&.cap-full .capacity-text {
background: #fef0f0;
color: #ef4444;
}
&.cap-closed .capacity-text {
background: #f5f5f5;
color: #999;
}
}
.slot-action {
flex-shrink: 0;
}
.btn {
min-width: 140rpx;
height: 68rpx;
border-radius: 34rpx;
display: flex;
align-items: center;
justify-content: center;
padding: 0 28rpx;
.btn-text {
font-size: 26rpx;
font-weight: 600;
}
&.btn-book {
background: #c9a87c;
.btn-text {
color: #fff;
}
}
&.btn-disabled {
background: #f0f0f0;
.btn-text {
color: #bbb;
}
}
}
.booked-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 16rpx;
}
.badge-booked {
height: 52rpx;
padding: 0 20rpx;
background: #fff8ee;
border-radius: 26rpx;
display: flex;
align-items: center;
justify-content: center;
.badge-text {
font-size: 24rpx;
color: #c9a87c;
font-weight: 600;
}
}
.btn-cancel {
height: 52rpx;
padding: 0 16rpx;
display: flex;
align-items: center;
.btn-cancel-text {
font-size: 24rpx;
color: #ef4444;
font-weight: 500;
text-decoration: underline;
}
}
}
</style>