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

@@ -11,7 +11,7 @@
:class="{ 'profile-menu__item--admin': item.isAdmin }"
hover-class="profile-menu__item--hover"
hover-stay-time="150"
@tap="navigate(item.path!)"
@tap="handleTap(item)"
>
<view class="profile-menu__icon-wrap" :class="{ 'profile-menu__icon-wrap--admin': item.isAdmin }">
<text class="profile-menu__icon">{{ item.icon }}</text>
@@ -19,6 +19,7 @@
<text class="profile-menu__title" :class="{ 'profile-menu__title--admin': item.isAdmin }">
{{ item.title }}
</text>
<text v-if="item.badge" class="profile-menu__badge">{{ item.badge }}</text>
<text class="profile-menu__arrow"></text>
</view>
</template>
@@ -35,10 +36,20 @@ interface MenuItem {
title?: string
path?: string
isAdmin?: boolean
badge?: string
action?: 'clear' | 'about'
requireAuth?: boolean
}
const props = defineProps<{
isAdmin: boolean
requireAuth?: boolean
}>()
const emit = defineEmits<{
(e: 'clear-cache'): void
(e: 'about'): void
(e: 'require-login'): void
}>()
const menuItems = computed<MenuItem[]>(() => {
@@ -49,6 +60,7 @@ const menuItems = computed<MenuItem[]>(() => {
icon: '💳',
title: '我的会员卡',
path: '/pages/profile/membership',
requireAuth: true,
},
{
key: 'bookings',
@@ -56,6 +68,7 @@ const menuItems = computed<MenuItem[]>(() => {
icon: '📅',
title: '我的预约',
path: '/pages/profile/bookings',
requireAuth: true,
},
{
key: 'info',
@@ -63,11 +76,30 @@ const menuItems = computed<MenuItem[]>(() => {
icon: '👤',
title: '个人信息',
path: '/pages/profile/info',
requireAuth: true,
},
{
key: 'sep1',
type: 'separator',
},
{
key: 'clear',
type: 'item',
icon: '🗑️',
title: '清除缓存',
action: 'clear',
},
{
key: 'about',
type: 'item',
icon: '',
title: '关于我们',
action: 'about',
},
]
if (props.isAdmin) {
items.push({ key: 'sep', type: 'separator' })
items.push({ key: 'sep2', type: 'separator' })
items.push({
key: 'admin',
type: 'item',
@@ -75,14 +107,25 @@ const menuItems = computed<MenuItem[]>(() => {
title: '管理中心',
path: '/pages/admin/index',
isAdmin: true,
requireAuth: true,
})
}
return items
})
function navigate(path: string) {
uni.navigateTo({ url: path })
function handleTap(item: MenuItem) {
if (item.requireAuth && !props.requireAuth) {
emit('require-login')
return
}
if (item.action === 'clear') {
emit('clear-cache')
} else if (item.action === 'about') {
emit('about')
} else if (item.path) {
uni.navigateTo({ url: item.path })
}
}
</script>
@@ -90,7 +133,7 @@ function navigate(path: string) {
.profile-menu {
background: $bg-card;
border-radius: $radius-lg;
margin: $spacing-md $spacing-lg 0;
margin: $spacing-lg $spacing-lg 0;
overflow: hidden;
&__separator {
@@ -111,11 +154,11 @@ function navigate(path: string) {
}
&--hover {
background: #f9f9f9;
background: #f5f5f5;
}
&--admin {
// Admin row gets a subtle accent tint
background: rgba($accent-color, 0.04);
}
}
@@ -123,7 +166,7 @@ function navigate(path: string) {
width: 64rpx;
height: 64rpx;
border-radius: $radius-sm;
background: rgba($brand-color, 0.06);
background: rgba($brand-color, 0.08);
display: flex;
align-items: center;
justify-content: center;
@@ -144,7 +187,6 @@ function navigate(path: string) {
flex: 1;
font-size: 30rpx;
color: $text-primary;
font-weight: 400;
&--admin {
color: $accent-color;
@@ -152,6 +194,15 @@ function navigate(path: string) {
}
}
&__badge {
font-size: 22rpx;
color: #ffffff;
background: $error-color;
border-radius: 20rpx;
padding: 2rpx 12rpx;
margin-right: $spacing-sm;
}
&__arrow {
font-size: 36rpx;
color: $text-hint;

View File

@@ -1,21 +1,10 @@
<template>
<view class="studio-info">
<!-- Horizontal photo strip -->
<scroll-view
v-if="studioInfo?.photos?.length"
scroll-x
class="photo-strip"
:show-scrollbar="false"
>
<scroll-view v-if="studioInfo?.photos?.length" scroll-x class="photo-strip" :show-scrollbar="false">
<view class="photo-strip-inner">
<image
v-for="(photo, idx) in studioInfo.photos"
:key="idx"
class="strip-photo"
:src="photo"
mode="aspectFill"
@tap="previewPhoto(idx)"
/>
<image v-for="(photo, idx) in studioInfo.photos" :key="idx" class="strip-photo" :src="photo" mode="aspectFill"
@tap="previewPhoto(idx)" />
</view>
</scroll-view>
@@ -24,7 +13,7 @@
<view class="location-left" @tap="handleAddressTap">
<text class="location-icon">📍</text>
<text class="location-text">
{{ studioInfo?.address || '地址加载中…' }}
{{ studioInfo?.address || '深圳市宝安区西乡街道财富港 D 座 1203D' }}
</text>
</view>
<view class="phone-btn" @tap="handlePhoneTap">
@@ -129,7 +118,7 @@ function handlePhoneTap() {
.location-left {
display: flex;
align-items: flex-start;
align-items: center;
gap: 12rpx;
flex: 1;
min-width: 0;
@@ -138,7 +127,6 @@ function handlePhoneTap() {
.location-icon {
font-size: 28rpx;
flex-shrink: 0;
margin-top: 4rpx;
}
.location-text {

View File

@@ -1,84 +0,0 @@
<template>
<view class="training-stats">
<view class="training-stats__item">
<text class="training-stats__value">{{ stats?.monthBookings ?? 0 }}</text>
<text class="training-stats__unit"></text>
<text class="training-stats__label">本月训练</text>
</view>
<view class="training-stats__divider" />
<view class="training-stats__item">
<text class="training-stats__value">{{ stats?.monthDays ?? 0 }}</text>
<text class="training-stats__unit"></text>
<text class="training-stats__label">训练天数</text>
</view>
<view class="training-stats__divider" />
<view class="training-stats__item">
<text class="training-stats__value">{{ stats?.monthHours ?? 0 }}</text>
<text class="training-stats__unit">小时</text>
<text class="training-stats__label">训练时长</text>
</view>
</view>
</template>
<script setup lang="ts">
import type { UserStatsResponse } from '@mp-pilates/shared'
defineProps<{
stats: UserStatsResponse | null
}>()
</script>
<style lang="scss" scoped>
.training-stats {
display: flex;
align-items: stretch;
background: $bg-card;
border-radius: $radius-lg;
margin: 0 $spacing-lg;
padding: $spacing-md 0;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.08);
// Pull card up to overlap the dark header
margin-top: -$spacing-xl;
position: relative;
z-index: 1;
&__item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $spacing-sm 0;
}
&__divider {
width: 1rpx;
background: $border-color;
margin: $spacing-xs 0;
}
&__value {
font-size: 48rpx;
font-weight: 700;
color: $brand-color;
line-height: 1;
}
&__unit {
font-size: 22rpx;
color: $text-secondary;
margin-top: 4rpx;
}
&__label {
font-size: 24rpx;
color: $text-hint;
margin-top: $spacing-xs;
}
}
</style>

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>

View File

@@ -1,46 +1,51 @@
<template>
<view class="profile-page">
<!-- User card: always visible -->
<!-- User card -->
<UserCard
:logged-in="loggedIn"
:user="user"
:stats="stats"
:memberships="memberships"
:loading="loginLoading"
@login="handleLogin"
/>
<!-- Logged-in content -->
<template v-if="loggedIn">
<!-- Training stats: overlaps bottom of UserCard -->
<TrainingStats :stats="stats" />
<!-- Menu section: always visible -->
<ProfileMenu
:is-admin="isAdmin"
:require-auth="loggedIn"
@clear-cache="handleClearCache"
@about="handleAbout"
@require-login="handleLogin"
/>
<!-- Menu section -->
<ProfileMenu :is-admin="isAdmin" />
<!-- Logout button -->
<view class="profile-page__logout-wrap">
<button class="profile-page__logout-btn" @tap="handleLogout">退出登录</button>
</view>
</template>
<!-- Logout button: only when logged in -->
<view v-if="loggedIn" class="profile-page__logout-wrap">
<button class="profile-page__logout-btn" @tap="handleLogout">退出登录</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { storeToRefs } from 'pinia'
import { useUserStore } from '../../stores/user'
import UserCard from '../../components/UserCard.vue'
import TrainingStats from '../../components/TrainingStats.vue'
import ProfileMenu from '../../components/ProfileMenu.vue'
const userStore = useUserStore()
const { loggedIn, user, stats, isAdmin } = userStore
const { loggedIn, user, stats, memberships, isAdmin } = storeToRefs(userStore)
const loginLoading = ref(false)
onShow(async () => {
if (loggedIn) {
await Promise.all([userStore.fetchProfile(), userStore.fetchStats()])
if (loggedIn.value) {
await Promise.all([
userStore.fetchProfile(),
userStore.fetchStats(),
userStore.fetchMemberships(),
])
}
})
@@ -49,8 +54,11 @@ async function handleLogin() {
loginLoading.value = true
try {
await userStore.login()
// After login, fetch stats immediately
await Promise.all([userStore.fetchProfile(), userStore.fetchStats()])
await Promise.all([
userStore.fetchProfile(),
userStore.fetchStats(),
userStore.fetchMemberships(),
])
} catch {
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
} finally {
@@ -71,6 +79,27 @@ function handleLogout() {
},
})
}
function handleClearCache() {
uni.showModal({
title: '清除缓存',
content: '确定要清除本地缓存数据吗?',
success(res) {
if (res.confirm) {
uni.clearStorageSync()
uni.showToast({ title: '缓存已清除', icon: 'success' })
}
},
})
}
function handleAbout() {
uni.showModal({
title: '关于我们',
content: 'Focus Core 普拉提工作室\n版本 1.0.0\n\n专注核心遇见更好的自己',
showCancel: false,
})
}
</script>
<style lang="scss" scoped>
@@ -78,11 +107,8 @@ function handleLogout() {
min-height: 100vh;
background: $bg-page;
// Content area below the dark header card
// UserCard has its own dark bg, content sits on $bg-page
&__logout-wrap {
margin: $spacing-xl $spacing-lg $spacing-lg;
margin: $spacing-xl $spacing-lg $spacing-xl;
}
&__logout-btn {