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,429 @@
<template>
<!-- Overlay mask -->
<view v-if="visible" class="popup-mask" @tap="handleMaskTap">
<!-- Popup panel stop propagation so tapping inside doesn't close -->
<view class="popup-panel" @tap.stop>
<!-- Header -->
<view class="popup-header">
<text class="popup-title">确认预约</text>
<view class="close-btn" @tap="handleCancel">
<text class="close-icon"></text>
</view>
</view>
<!-- Course info -->
<view class="info-section">
<view class="info-row">
<text class="info-label">日期</text>
<text class="info-value">{{ slot?.date }}</text>
</view>
<view class="info-row">
<text class="info-label">时间</text>
<text class="info-value" v-if="slot">
{{ slot.startTime.slice(0, 5) }} - {{ slot.endTime.slice(0, 5) }}
</text>
</view>
<view class="info-row">
<text class="info-label">剩余</text>
<text class="info-value" v-if="slot">
{{ slot.capacity - slot.bookedCount }} 个名额
</text>
</view>
</view>
<view class="divider" />
<!-- Membership card selection -->
<view class="card-section">
<view class="section-label-row">
<text class="section-label">选择扣课会员卡</text>
</view>
<!-- Single membership -->
<view v-if="memberships.length === 1" class="card-single">
<view class="card-item selected">
<view class="card-icon-wrap">
<text class="card-icon">💳</text>
</view>
<view class="card-info">
<text class="card-name">{{ memberships[0].cardType.name }}</text>
<text class="card-remain" v-if="memberships[0].remainingTimes !== null">
剩余 {{ memberships[0].remainingTimes }}
</text>
<text class="card-remain" v-else>
有效期至 {{ memberships[0].expireDate.slice(0, 10) }}
</text>
</view>
<view class="check-mark">
<text class="check-icon"></text>
</view>
</view>
</view>
<!-- Multiple memberships picker -->
<view v-else-if="memberships.length > 1" class="card-picker-wrap">
<view
v-for="m in memberships"
:key="m.id"
class="card-item"
:class="{ selected: selectedMembershipId === m.id }"
@tap="selectedMembershipId = m.id"
>
<view class="card-icon-wrap">
<text class="card-icon">💳</text>
</view>
<view class="card-info">
<text class="card-name">{{ m.cardType.name }}</text>
<text class="card-remain" v-if="m.remainingTimes !== null">
剩余 {{ m.remainingTimes }}
</text>
<text class="card-remain" v-else>
有效期至 {{ m.expireDate.slice(0, 10) }}
</text>
</view>
<view class="check-mark" v-if="selectedMembershipId === m.id">
<text class="check-icon"></text>
</view>
</view>
</view>
<!-- No memberships fallback (should not normally appear) -->
<view v-else class="no-card-tip">
<text class="no-card-text">暂无可用会员卡</text>
</view>
</view>
<!-- Deduction tip -->
<view class="deduction-tip" v-if="selectedMembership">
<text class="deduction-text">
确认后将从{{ selectedMembership.cardType.name }}扣除 1 次课时
</text>
</view>
<!-- Action buttons -->
<view class="action-row">
<view class="btn-outline" @tap="handleCancel">
<text class="btn-outline-text">取消</text>
</view>
<view
class="btn-confirm"
:class="{ disabled: !selectedMembershipId }"
@tap="handleConfirm"
>
<text class="btn-confirm-text">确认预约</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
const props = defineProps<{
visible: boolean
slot: TimeSlotWithBookingStatus | null
memberships: MembershipWithCardType[]
}>()
const emit = defineEmits<{
(e: 'confirm', payload: { timeSlotId: string; membershipId: string }): void
(e: 'cancel'): void
(e: 'update:visible', val: boolean): void
}>()
const selectedMembershipId = ref<string>('')
// Auto-select the first membership when popup opens or memberships list changes
watch(
[() => props.visible, () => props.memberships],
([visible, memberships]) => {
if (visible && memberships.length > 0) {
selectedMembershipId.value = memberships[0].id
}
},
{ immediate: true },
)
const selectedMembership = computed(() =>
props.memberships.find((m) => m.id === selectedMembershipId.value) ?? null,
)
function handleConfirm() {
if (!props.slot || !selectedMembershipId.value) return
emit('confirm', {
timeSlotId: props.slot.id,
membershipId: selectedMembershipId.value,
})
}
function handleCancel() {
emit('cancel')
emit('update:visible', false)
}
function handleMaskTap() {
handleCancel()
}
</script>
<style lang="scss" scoped>
.popup-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 1000;
display: flex;
align-items: flex-end;
justify-content: center;
}
.popup-panel {
width: 100%;
background: #fff;
border-radius: 32rpx 32rpx 0 0;
padding: 32rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
gap: 0;
}
.popup-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 32rpx;
}
.popup-title {
font-size: 34rpx;
font-weight: 700;
color: #1a1a1a;
}
.close-btn {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
border-radius: 50%;
}
.close-icon {
font-size: 24rpx;
color: #999;
}
/* Info rows */
.info-section {
display: flex;
flex-direction: column;
gap: 20rpx;
margin-bottom: 28rpx;
}
.info-row {
display: flex;
flex-direction: row;
align-items: center;
}
.info-label {
font-size: 28rpx;
color: #999;
width: 80rpx;
flex-shrink: 0;
}
.info-value {
font-size: 28rpx;
color: #222;
font-weight: 500;
}
.divider {
height: 1rpx;
background: #f0f0f0;
margin: 8rpx 0 28rpx;
}
/* Card selection */
.card-section {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-bottom: 24rpx;
}
.section-label-row {
margin-bottom: 8rpx;
}
.section-label {
font-size: 28rpx;
color: #666;
font-weight: 500;
}
.card-single,
.card-picker-wrap {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.card-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 24rpx 20rpx;
border-radius: 16rpx;
border: 2rpx solid #f0f0f0;
background: #fafafa;
gap: 20rpx;
transition: border-color 0.15s, background 0.15s;
&.selected {
border-color: #c9a87c;
background: #fffbf5;
}
}
.card-icon-wrap {
width: 60rpx;
height: 60rpx;
border-radius: 14rpx;
background: linear-gradient(135deg, #d4b896, #c9a87c);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.card-icon {
font-size: 32rpx;
}
.card-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.card-name {
font-size: 28rpx;
font-weight: 600;
color: #222;
}
.card-remain {
font-size: 22rpx;
color: #999;
}
.check-mark {
width: 44rpx;
height: 44rpx;
border-radius: 50%;
background: #c9a87c;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.check-icon {
font-size: 24rpx;
color: #fff;
font-weight: 700;
}
.no-card-tip {
padding: 24rpx;
text-align: center;
}
.no-card-text {
font-size: 26rpx;
color: #bbb;
}
/* Deduction tip */
.deduction-tip {
background: #fffbf0;
border-radius: 12rpx;
padding: 16rpx 20rpx;
margin-bottom: 28rpx;
}
.deduction-text {
font-size: 24rpx;
color: #c9a87c;
line-height: 1.5;
}
/* Action buttons */
.action-row {
display: flex;
flex-direction: row;
gap: 20rpx;
margin-top: 8rpx;
}
.btn-outline {
flex: 1;
height: 88rpx;
border-radius: 44rpx;
border: 2rpx solid #e0e0e0;
display: flex;
align-items: center;
justify-content: center;
&:active {
background: #f5f5f5;
}
}
.btn-outline-text {
font-size: 30rpx;
color: #666;
font-weight: 500;
}
.btn-confirm {
flex: 2;
height: 88rpx;
border-radius: 44rpx;
background: #c9a87c;
display: flex;
align-items: center;
justify-content: center;
&:active {
opacity: 0.85;
}
&.disabled {
background: #e0e0e0;
}
}
.btn-confirm-text {
font-size: 30rpx;
color: #fff;
font-weight: 600;
letter-spacing: 1rpx;
.disabled & {
color: #bbb;
}
}
</style>