feat: 完善个人中心
This commit is contained in:
@@ -1,33 +1,58 @@
|
||||
<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" />
|
||||
<!-- Header: gradient background -->
|
||||
<view class="user-card__header">
|
||||
<!-- Not logged in state -->
|
||||
<view v-if="!loggedIn" class="user-card__guest">
|
||||
<view class="user-card__avatar-wrap">
|
||||
<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>
|
||||
<view class="user-card__guest-info">
|
||||
<text class="user-card__guest-title">Hi,欢迎来到普拉提</text>
|
||||
<text class="user-card__guest-sub">登录后查看个人数据</text>
|
||||
|
||||
<!-- 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 class="user-card__vip-badge" v-if="vipLevel">
|
||||
<text class="user-card__vip-text">{{ vipLevel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="user-card__info">
|
||||
<view class="user-card__name-row">
|
||||
<text class="user-card__nickname">{{ user!.nickname }}</text>
|
||||
</view>
|
||||
<text v-if="maskedPhone" class="user-card__phone">{{ maskedPhone }}</text>
|
||||
</view>
|
||||
</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"
|
||||
/>
|
||||
<!-- Stats row: shown only when logged in -->
|
||||
<view v-if="loggedIn" class="user-card__stats">
|
||||
<view class="user-card__stat-item">
|
||||
<text class="user-card__stat-value">{{ stats?.totalBookings ?? 0 }}</text>
|
||||
<text class="user-card__stat-label">总训练(次)</text>
|
||||
</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 class="user-card__stat-divider" />
|
||||
<view class="user-card__stat-item">
|
||||
<text class="user-card__stat-value">{{ stats?.monthBookings ?? 0 }}</text>
|
||||
<text class="user-card__stat-label">本月(次)</text>
|
||||
</view>
|
||||
<view class="user-card__stat-divider" />
|
||||
<view class="user-card__stat-item">
|
||||
<text class="user-card__stat-value">{{ remainingSessions }}</text>
|
||||
<text class="user-card__stat-label">剩余课时(节)</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -35,12 +60,14 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { UserProfileResponse } from '@mp-pilates/shared'
|
||||
import { formatDate } from '../utils/format'
|
||||
import type { UserProfileResponse, UserStatsResponse, MembershipWithCardType } from '@mp-pilates/shared'
|
||||
import { MembershipStatus } from '@mp-pilates/shared'
|
||||
|
||||
const props = defineProps<{
|
||||
loggedIn: boolean
|
||||
user: UserProfileResponse | null
|
||||
stats: UserStatsResponse | null
|
||||
memberships?: readonly MembershipWithCardType[]
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
@@ -60,15 +87,29 @@ const avatarSrc = computed(() => {
|
||||
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)
|
||||
// Derive VIP level from active memberships count
|
||||
const activeMemberships = computed(() =>
|
||||
props.memberships?.filter((m) => m.status === MembershipStatus.ACTIVE) ?? [],
|
||||
)
|
||||
|
||||
const vipLevel = computed(() => {
|
||||
const count = activeMemberships.value.length
|
||||
if (count >= 3) return 'VIP3'
|
||||
if (count >= 2) return 'VIP2'
|
||||
if (count >= 1) return 'VIP1'
|
||||
return null
|
||||
})
|
||||
|
||||
// Sum remaining sessions from all active time-based memberships
|
||||
const remainingSessions = computed(() =>
|
||||
activeMemberships.value
|
||||
.filter((m) => m.cardType.type === 'TIMES')
|
||||
.reduce((sum, m) => sum + m.remainingCount, 0),
|
||||
)
|
||||
|
||||
function onAvatarError() {
|
||||
avatarFailed.value = true
|
||||
}
|
||||
@@ -80,19 +121,21 @@ function handleLogin() {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-card {
|
||||
background: $brand-color;
|
||||
padding: 80rpx $spacing-lg $spacing-xl;
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #a855f7 50%, #ec4899 100%);
|
||||
border-radius: 0 0 40rpx 40rpx;
|
||||
overflow: hidden;
|
||||
|
||||
&__header {
|
||||
padding: 60rpx $spacing-lg $spacing-lg;
|
||||
}
|
||||
|
||||
// ── Guest state ──
|
||||
&__guest {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
&__guest-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__guest-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -108,12 +151,12 @@ function handleLogin() {
|
||||
|
||||
&__guest-sub {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
&__login-btn {
|
||||
flex-shrink: 0;
|
||||
background: $accent-color;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: #ffffff;
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
@@ -129,6 +172,7 @@ function handleLogin() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Logged-in user ──
|
||||
&__user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -136,18 +180,35 @@ function handleLogin() {
|
||||
}
|
||||
|
||||
&__avatar-wrap {
|
||||
position: relative;
|
||||
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%;
|
||||
border: 4rpx solid rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
&__vip-badge {
|
||||
position: absolute;
|
||||
bottom: -6rpx;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
||||
border-radius: 20rpx;
|
||||
padding: 2rpx 12rpx;
|
||||
border: 2rpx solid #ffffff;
|
||||
}
|
||||
|
||||
&__vip-text {
|
||||
font-size: 18rpx;
|
||||
font-weight: 700;
|
||||
color: #7c2d12;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__info {
|
||||
@@ -157,6 +218,12 @@ function handleLogin() {
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
&__name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
&__nickname {
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
@@ -168,9 +235,43 @@ function handleLogin() {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
&__joined {
|
||||
// ── Stats row ──
|
||||
&__stats {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
margin: 0 $spacing-lg $spacing-lg;
|
||||
border-radius: $radius-lg;
|
||||
padding: $spacing-md 0;
|
||||
backdrop-filter: blur(10rpx);
|
||||
}
|
||||
|
||||
&__stat-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $spacing-xs 0;
|
||||
}
|
||||
|
||||
&__stat-divider {
|
||||
width: 1rpx;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
margin: $spacing-xs 0;
|
||||
}
|
||||
|
||||
&__stat-value {
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__stat-label {
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user