feat: 完善个人中心

This commit is contained in:
richarjiang
2026-04-04 10:46:20 +08:00
parent 817d5a85c5
commit 2b3b636c54
5 changed files with 258 additions and 176 deletions

View File

@@ -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>