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

View File

@@ -1,21 +1,10 @@
<template> <template>
<view class="studio-info"> <view class="studio-info">
<!-- Horizontal photo strip --> <!-- Horizontal photo strip -->
<scroll-view <scroll-view v-if="studioInfo?.photos?.length" scroll-x class="photo-strip" :show-scrollbar="false">
v-if="studioInfo?.photos?.length"
scroll-x
class="photo-strip"
:show-scrollbar="false"
>
<view class="photo-strip-inner"> <view class="photo-strip-inner">
<image <image v-for="(photo, idx) in studioInfo.photos" :key="idx" class="strip-photo" :src="photo" mode="aspectFill"
v-for="(photo, idx) in studioInfo.photos" @tap="previewPhoto(idx)" />
:key="idx"
class="strip-photo"
:src="photo"
mode="aspectFill"
@tap="previewPhoto(idx)"
/>
</view> </view>
</scroll-view> </scroll-view>
@@ -24,7 +13,7 @@
<view class="location-left" @tap="handleAddressTap"> <view class="location-left" @tap="handleAddressTap">
<text class="location-icon">📍</text> <text class="location-icon">📍</text>
<text class="location-text"> <text class="location-text">
{{ studioInfo?.address || '地址加载中…' }} {{ studioInfo?.address || '深圳市宝安区西乡街道财富港 D 座 1203D' }}
</text> </text>
</view> </view>
<view class="phone-btn" @tap="handlePhoneTap"> <view class="phone-btn" @tap="handlePhoneTap">
@@ -129,7 +118,7 @@ function handlePhoneTap() {
.location-left { .location-left {
display: flex; display: flex;
align-items: flex-start; align-items: center;
gap: 12rpx; gap: 12rpx;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -138,7 +127,6 @@ function handlePhoneTap() {
.location-icon { .location-icon {
font-size: 28rpx; font-size: 28rpx;
flex-shrink: 0; flex-shrink: 0;
margin-top: 4rpx;
} }
.location-text { .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> <template>
<view class="user-card"> <view class="user-card">
<!-- Not logged in state --> <!-- Header: gradient background -->
<view v-if="!loggedIn" class="user-card__guest"> <view class="user-card__header">
<view class="user-card__guest-avatar"> <!-- Not logged in state -->
<image class="user-card__avatar-img" src="/static/default-avatar.png" mode="aspectFill" /> <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>
<view class="user-card__guest-info">
<text class="user-card__guest-title">Hi欢迎来到普拉提</text> <!-- Logged in state -->
<text class="user-card__guest-sub">登录后查看个人数据</text> <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> </view>
<button class="user-card__login-btn" :loading="loading" @tap="handleLogin">
微信登录
</button>
</view> </view>
<!-- Logged in state --> <!-- Stats row: shown only when logged in -->
<view v-else class="user-card__user"> <view v-if="loggedIn" class="user-card__stats">
<view class="user-card__avatar-wrap"> <view class="user-card__stat-item">
<image <text class="user-card__stat-value">{{ stats?.totalBookings ?? 0 }}</text>
class="user-card__avatar-img" <text class="user-card__stat-label">总训练()</text>
:src="avatarSrc"
mode="aspectFill"
@error="onAvatarError"
/>
</view> </view>
<view class="user-card__info"> <view class="user-card__stat-divider" />
<text class="user-card__nickname">{{ user!.nickname }}</text> <view class="user-card__stat-item">
<text v-if="maskedPhone" class="user-card__phone">{{ maskedPhone }}</text> <text class="user-card__stat-value">{{ stats?.monthBookings ?? 0 }}</text>
<text class="user-card__joined">注册于 {{ joinedDate }}</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> </view>
</view> </view>
@@ -35,12 +60,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { UserProfileResponse } from '@mp-pilates/shared' import type { UserProfileResponse, UserStatsResponse, MembershipWithCardType } from '@mp-pilates/shared'
import { formatDate } from '../utils/format' import { MembershipStatus } from '@mp-pilates/shared'
const props = defineProps<{ const props = defineProps<{
loggedIn: boolean loggedIn: boolean
user: UserProfileResponse | null user: UserProfileResponse | null
stats: UserStatsResponse | null
memberships?: readonly MembershipWithCardType[]
loading?: boolean loading?: boolean
}>() }>()
@@ -60,15 +87,29 @@ const avatarSrc = computed(() => {
const maskedPhone = computed(() => { const maskedPhone = computed(() => {
const phone = props.user?.phone const phone = props.user?.phone
if (!phone) return null if (!phone) return null
// Mask middle 4 digits: 138****8888
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
}) })
const joinedDate = computed(() => { // Derive VIP level from active memberships count
if (!props.user?.createdAt) return '' const activeMemberships = computed(() =>
return formatDate(props.user.createdAt) 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() { function onAvatarError() {
avatarFailed.value = true avatarFailed.value = true
} }
@@ -80,19 +121,21 @@ function handleLogin() {
<style lang="scss" scoped> <style lang="scss" scoped>
.user-card { .user-card {
background: $brand-color; background: linear-gradient(135deg, #7c3aed 0%, #a855f7 50%, #ec4899 100%);
padding: 80rpx $spacing-lg $spacing-xl; border-radius: 0 0 40rpx 40rpx;
overflow: hidden;
&__header {
padding: 60rpx $spacing-lg $spacing-lg;
}
// ── Guest state ──
&__guest { &__guest {
display: flex; display: flex;
align-items: center; align-items: center;
gap: $spacing-md; gap: $spacing-md;
} }
&__guest-avatar {
flex-shrink: 0;
}
&__guest-info { &__guest-info {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -108,12 +151,12 @@ function handleLogin() {
&__guest-sub { &__guest-sub {
font-size: 24rpx; font-size: 24rpx;
color: rgba(255, 255, 255, 0.6); color: rgba(255, 255, 255, 0.65);
} }
&__login-btn { &__login-btn {
flex-shrink: 0; flex-shrink: 0;
background: $accent-color; background: rgba(255, 255, 255, 0.2);
color: #ffffff; color: #ffffff;
font-size: 26rpx; font-size: 26rpx;
font-weight: 500; font-weight: 500;
@@ -129,6 +172,7 @@ function handleLogin() {
} }
} }
// ── Logged-in user ──
&__user { &__user {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -136,18 +180,35 @@ function handleLogin() {
} }
&__avatar-wrap { &__avatar-wrap {
position: relative;
flex-shrink: 0; flex-shrink: 0;
width: 120rpx; width: 120rpx;
height: 120rpx; height: 120rpx;
border-radius: 50%;
overflow: hidden;
border: 4rpx solid rgba(255, 255, 255, 0.3);
} }
&__avatar-img { &__avatar-img {
width: 120rpx; width: 120rpx;
height: 120rpx; height: 120rpx;
border-radius: 50%; 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 { &__info {
@@ -157,6 +218,12 @@ function handleLogin() {
gap: 8rpx; gap: 8rpx;
} }
&__name-row {
display: flex;
align-items: center;
gap: $spacing-sm;
}
&__nickname { &__nickname {
font-size: 36rpx; font-size: 36rpx;
font-weight: 600; font-weight: 600;
@@ -168,9 +235,43 @@ function handleLogin() {
color: rgba(255, 255, 255, 0.75); 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; font-size: 22rpx;
color: rgba(255, 255, 255, 0.5); color: rgba(255, 255, 255, 0.8);
margin-top: 6rpx;
} }
} }
</style> </style>

View File

@@ -1,46 +1,51 @@
<template> <template>
<view class="profile-page"> <view class="profile-page">
<!-- User card: always visible --> <!-- User card -->
<UserCard <UserCard
:logged-in="loggedIn" :logged-in="loggedIn"
:user="user" :user="user"
:stats="stats"
:memberships="memberships"
:loading="loginLoading" :loading="loginLoading"
@login="handleLogin" @login="handleLogin"
/> />
<!-- Logged-in content --> <!-- Menu section: always visible -->
<template v-if="loggedIn"> <ProfileMenu
<!-- Training stats: overlaps bottom of UserCard --> :is-admin="isAdmin"
<TrainingStats :stats="stats" /> :require-auth="loggedIn"
@clear-cache="handleClearCache"
@about="handleAbout"
@require-login="handleLogin"
/>
<!-- Menu section --> <!-- Logout button: only when logged in -->
<ProfileMenu :is-admin="isAdmin" /> <view v-if="loggedIn" class="profile-page__logout-wrap">
<button class="profile-page__logout-btn" @tap="handleLogout">退出登录</button>
<!-- Logout button --> </view>
<view class="profile-page__logout-wrap">
<button class="profile-page__logout-btn" @tap="handleLogout">退出登录</button>
</view>
</template>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app' import { onShow } from '@dcloudio/uni-app'
import { storeToRefs } from 'pinia'
import { useUserStore } from '../../stores/user' import { useUserStore } from '../../stores/user'
import UserCard from '../../components/UserCard.vue' import UserCard from '../../components/UserCard.vue'
import TrainingStats from '../../components/TrainingStats.vue'
import ProfileMenu from '../../components/ProfileMenu.vue' import ProfileMenu from '../../components/ProfileMenu.vue'
const userStore = useUserStore() const userStore = useUserStore()
const { loggedIn, user, stats, memberships, isAdmin } = storeToRefs(userStore)
const { loggedIn, user, stats, isAdmin } = userStore
const loginLoading = ref(false) const loginLoading = ref(false)
onShow(async () => { onShow(async () => {
if (loggedIn) { if (loggedIn.value) {
await Promise.all([userStore.fetchProfile(), userStore.fetchStats()]) await Promise.all([
userStore.fetchProfile(),
userStore.fetchStats(),
userStore.fetchMemberships(),
])
} }
}) })
@@ -49,8 +54,11 @@ async function handleLogin() {
loginLoading.value = true loginLoading.value = true
try { try {
await userStore.login() await userStore.login()
// After login, fetch stats immediately await Promise.all([
await Promise.all([userStore.fetchProfile(), userStore.fetchStats()]) userStore.fetchProfile(),
userStore.fetchStats(),
userStore.fetchMemberships(),
])
} catch { } catch {
uni.showToast({ title: '登录失败,请重试', icon: 'none' }) uni.showToast({ title: '登录失败,请重试', icon: 'none' })
} finally { } 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -78,11 +107,8 @@ function handleLogout() {
min-height: 100vh; min-height: 100vh;
background: $bg-page; background: $bg-page;
// Content area below the dark header card
// UserCard has its own dark bg, content sits on $bg-page
&__logout-wrap { &__logout-wrap {
margin: $spacing-xl $spacing-lg $spacing-lg; margin: $spacing-xl $spacing-lg $spacing-xl;
} }
&__logout-btn { &__logout-btn {