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,176 @@
<template>
<view class="user-card">
<!-- Not logged in state -->
<view v-if="!loggedIn" class="user-card__guest">
<view class="user-card__guest-avatar">
<image class="user-card__avatar-img" src="/static/default-avatar.png" mode="aspectFill" />
</view>
<view class="user-card__guest-info">
<text class="user-card__guest-title">Hi欢迎来到普拉提</text>
<text class="user-card__guest-sub">登录后查看个人数据</text>
</view>
<button class="user-card__login-btn" :loading="loading" @tap="handleLogin">
微信登录
</button>
</view>
<!-- Logged in state -->
<view v-else class="user-card__user">
<view class="user-card__avatar-wrap">
<image
class="user-card__avatar-img"
:src="avatarSrc"
mode="aspectFill"
@error="onAvatarError"
/>
</view>
<view class="user-card__info">
<text class="user-card__nickname">{{ user!.nickname }}</text>
<text v-if="maskedPhone" class="user-card__phone">{{ maskedPhone }}</text>
<text class="user-card__joined">注册于 {{ joinedDate }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { UserProfileResponse } from '@mp-pilates/shared'
import { formatDate } from '../utils/format'
const props = defineProps<{
loggedIn: boolean
user: UserProfileResponse | null
loading?: boolean
}>()
const emit = defineEmits<{
(e: 'login'): void
}>()
const avatarFailed = ref(false)
const avatarSrc = computed(() => {
if (avatarFailed.value || !props.user?.avatarUrl) {
return '/static/default-avatar.png'
}
return props.user.avatarUrl
})
const maskedPhone = computed(() => {
const phone = props.user?.phone
if (!phone) return null
// Mask middle 4 digits: 138****8888
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
})
const joinedDate = computed(() => {
if (!props.user?.createdAt) return ''
return formatDate(props.user.createdAt)
})
function onAvatarError() {
avatarFailed.value = true
}
function handleLogin() {
emit('login')
}
</script>
<style lang="scss" scoped>
.user-card {
background: $brand-color;
padding: 80rpx $spacing-lg $spacing-xl;
&__guest {
display: flex;
align-items: center;
gap: $spacing-md;
}
&__guest-avatar {
flex-shrink: 0;
}
&__guest-info {
flex: 1;
display: flex;
flex-direction: column;
gap: $spacing-xs;
}
&__guest-title {
font-size: 32rpx;
font-weight: 600;
color: #ffffff;
}
&__guest-sub {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.6);
}
&__login-btn {
flex-shrink: 0;
background: $accent-color;
color: #ffffff;
font-size: 26rpx;
font-weight: 500;
border: none;
border-radius: $radius-lg;
padding: 0 $spacing-md;
height: 64rpx;
line-height: 64rpx;
min-width: 160rpx;
&::after {
border: none;
}
}
&__user {
display: flex;
align-items: center;
gap: $spacing-md;
}
&__avatar-wrap {
flex-shrink: 0;
width: 120rpx;
height: 120rpx;
border-radius: 50%;
overflow: hidden;
border: 4rpx solid rgba(255, 255, 255, 0.3);
}
&__avatar-img {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
}
&__info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
}
&__nickname {
font-size: 36rpx;
font-weight: 600;
color: #ffffff;
}
&__phone {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.75);
}
&__joined {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.5);
}
}
</style>