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:
176
packages/app/src/components/UserCard.vue
Normal file
176
packages/app/src/components/UserCard.vue
Normal 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>
|
||||
Reference in New Issue
Block a user