perf: 优化个人中心会员卡进度展示

This commit is contained in:
richarjiang
2026-09-09 11:27:03 +08:00
parent 726c65f0f0
commit 57107c02dc
6 changed files with 213 additions and 120 deletions

View File

@@ -5,8 +5,9 @@
</view>
<!-- User card -->
<UserCard :logged-in="loggedIn" :has-profile="hasProfile" :user="user" :stats="stats" :memberships="memberships"
:loading="loginLoading" @login="handleLogin" @edit="goToInfo" />
<UserCard :logged-in="loggedIn" :has-profile="hasProfile" :user="user" :memberships="memberships" :now="membershipNow"
:memberships-loading="membershipsLoading" :memberships-loaded="membershipsLoaded" :memberships-error="membershipsError"
:loading="loginLoading" @login="handleLogin" @edit="goToInfo" @refresh-memberships="userStore.fetchMemberships()" />
<InviteCard />
@@ -47,10 +48,11 @@ import ProfileMenu from '../../components/ProfileMenu.vue'
const invite = useInviteStore()
const userStore = useUserStore()
const bookingStore = useBookingStore()
const { loggedIn, hasProfile, user, stats, memberships, isAdmin } = storeToRefs(userStore)
const { loggedIn, hasProfile, user, memberships, membershipsLoading, membershipsLoaded, membershipsError, isAdmin } = storeToRefs(userStore)
const { upcomingBookings } = storeToRefs(bookingStore)
const activityRefreshKey = ref(0)
const membershipNow = ref(Date.now())
const loginLoading = ref(false)
const navBarHeight = ref(getSystemLayout().navBarHeight)
const statusBarHeight = getSystemLayout().statusBarHeight
@@ -84,13 +86,13 @@ onMounted(() => {
})
onShow(async () => {
membershipNow.value = Date.now()
activityRefreshKey.value += 1
if (loggedIn.value) {
await Promise.all([
invite.refresh().catch(() => {}),
invite.refreshActivity().catch(() => {}),
userStore.fetchProfile(),
userStore.fetchStats(),
userStore.fetchMemberships(),
bookingStore.fetchUpcomingBookings(),
])
@@ -105,7 +107,6 @@ async function handleLogin() {
if (!isNewUser) {
await Promise.all([
invite.refreshActivity().catch(() => {}),
userStore.fetchStats(),
bookingStore.fetchUpcomingBookings(),
])
}

View File

@@ -2,7 +2,19 @@
<view class="membership-page" :style="{ paddingTop: navBarHeight, height: pageHeight }">
<CustomNavBar title="我的会员卡" show-back />
<scroll-view class="scroll" scroll-y refresher-enabled :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
<view v-if="loading && !refreshing && !allMemberships.length" class="loading-wrap">
<view v-if="userStore.loggedIn" class="practice-summary">
<view class="summary-heading"><text class="summary-kicker">MY PRACTICE</text><text class="summary-title">每一次练习都在积累</text></view>
<view class="summary-grid">
<view><text class="summary-value">{{ userStore.statsError ? '—' : userStore.stats?.totalBookings ?? '—' }}</text><text class="summary-label">累计上课 · </text></view>
<view><text class="summary-value">{{ userStore.statsError ? '—' : userStore.stats?.monthBookings ?? '—' }}</text><text class="summary-label">本月上课 · </text></view>
<view><text class="summary-value">{{ remainingLabel }}</text><text class="summary-label">剩余课时{{ finiteBalance > 0 || !unlimitedCount ? ' · 次' : '' }}</text></view>
</view>
<text v-if="unlimitedCount && finiteBalance > 0 && !userStore.membershipsError" class="summary-note">另有 {{ unlimitedCount }} 张有效不限次卡可在有效期内预约</text>
<button v-if="userStore.statsError" class="summary-retry" @tap="userStore.fetchStats()">练习统计暂时无法更新点击重试 </button>
</view>
<view v-if="!userStore.loggedIn" class="empty-wrap"><view class="empty-card"><text class="empty-title">登录后查看会员卡</text><button class="empty-btn" @tap="goProfile">前往个人中心</button></view></view>
<view v-else-if="userStore.membershipsError" class="empty-wrap"><view class="empty-card"><text class="empty-title">会员卡暂时未能更新</text><text class="empty-sub">请重试后查看最新余额和有效期。</text><button class="empty-btn" @tap="loadMemberships">重新加载</button></view></view>
<view v-else-if="loading && !refreshing && !allMemberships.length" class="loading-wrap">
<view v-for="i in 2" :key="i" class="skeleton-card" />
</view>
@@ -20,40 +32,15 @@
<text class="group-title">正在使用</text>
<text class="group-count">{{ activeMemberships.length }} 张有效卡</text>
</view>
<view v-for="m in activeMemberships" :key="m.id" class="mc" :class="cardBgClass(m.cardType.type)">
<view class="mc-top">
<view class="mc-name-area">
<text class="mc-name">{{ m.cardType.name }}</text>
<text class="mc-type-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
<view v-for="m in activeMemberships" :key="m.id" class="owned-card-wrap">
<OwnedMembershipCard :membership="m" :now="membershipNow">
<view class="mc-actions">
<button v-if="canRenewMembership(m)" class="mc-renew" @tap="goRenew(m)">续卡</button>
<button class="mc-book" @tap="goBooking">预约课程</button>
</view>
<text class="mc-status mc-status--active">有效</text>
</view>
<view class="mc-balance">
<view class="mc-number-row">
<text class="mc-big-num">{{ m.remainingTimes !== null ? m.remainingTimes : daysRemaining(m) }}</text>
<text class="mc-big-unit">{{ m.remainingTimes !== null ? '次可用' : '天剩余' }}</text>
</view>
<text v-if="m.remainingTimes === null" class="mc-duration-note">有效期内不限次数</text>
<view v-else-if="getMembershipTotalTimes(m)" class="mc-progress">
<view class="mc-progress-track"><view class="mc-progress-fill" :style="{ width: getMembershipProgressWidth(m) }" /></view>
<text class="mc-progress-label">已用 {{ getMembershipUsedTimes(m) }} / {{ getMembershipTotalTimes(m) }} </text>
</view>
</view>
<view class="mc-bottom">
<view class="mc-date-item">
<text class="mc-date-label">开始日期</text>
<text class="mc-date-value">{{ m.startDate.slice(0, 10) }}</text>
</view>
<view class="mc-date-item mc-date-item--end">
<text class="mc-date-label">有效期至</text>
<text class="mc-date-value">{{ m.expireDate.slice(0, 10) }}</text>
</view>
</view>
<view class="mc-actions">
<button v-if="canRenewMembership(m)" class="mc-renew" @tap="goRenew(m)">续卡</button>
<button class="mc-book" @tap="goBooking">预约课程</button>
</view>
</OwnedMembershipCard>
</view>
<text class="usage-note">已用次数包含预约扣次不等同于已完成上课进度条表示已用次数占比</text>
</view>
<view v-if="inactiveMemberships.length" class="group-section">
@@ -81,7 +68,7 @@
</view>
<view class="scroll-bottom-spacer" />
</scroll-view>
<view v-if="allMemberships.length" class="purchase-dock">
<view v-if="userStore.loggedIn && allMemberships.length" class="purchase-dock">
<button class="purchase-btn" @tap="goStore">选购会员卡</button>
</view>
</view>
@@ -94,15 +81,17 @@ import type { MembershipWithCardType } from '@mp-pilates/shared'
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
import { useUserStore } from '../../stores/user'
import { getSystemLayout } from '../../utils/system'
import { getCardTypeLabel, getMembershipProgressWidth, getMembershipUsedTimes, getMembershipTotalTimes } from '../../utils/format'
import { getCardTypeLabel } from '../../utils/format'
import CustomNavBar from '../../components/CustomNavBar.vue'
import OwnedMembershipCard from '../../components/OwnedMembershipCard.vue'
const userStore = useUserStore()
const navBarHeight = `${getSystemLayout().navBarHeight}px`
const pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`)
onResize(() => { pageHeight.value = `${uni.getWindowInfo().windowHeight}px` })
const loading = ref(false)
const loading = computed(() => userStore.membershipsLoading)
const membershipNow = ref(Date.now())
const refreshing = ref(false)
const allMemberships = computed(() => userStore.memberships as MembershipWithCardType[])
@@ -129,36 +118,23 @@ function inactiveStatusClass(status: MembershipStatus): string {
return 'mc-status--expired'
}
function cardBgClass(type: CardTypeCategory): string {
if (type === CardTypeCategory.TRIAL) return 'mc--trial'
if (type === CardTypeCategory.DURATION) return 'mc--duration'
return 'mc--times'
}
function daysRemaining(m: MembershipWithCardType): number {
const diff = new Date(m.expireDate).getTime() - Date.now()
return Math.max(0, Math.ceil(diff / 86_400_000))
}
const finiteBalance = computed(() => activeMemberships.value.reduce((sum, m) => sum + Math.max(0, m.remainingTimes ?? 0), 0))
const unlimitedCount = computed(() => activeMemberships.value.filter(m => m.remainingTimes === null).length)
const remainingLabel = computed(() => !userStore.membershipsLoaded || userStore.membershipsError ? '—' : finiteBalance.value > 0 ? finiteBalance.value : unlimitedCount.value ? '不限次' : 0)
async function loadMemberships() {
loading.value = true
try {
await userStore.fetchMemberships()
} catch {
uni.showToast({ title: '加载失败,请下拉刷新', icon: 'none' })
} finally {
loading.value = false
}
membershipNow.value = Date.now()
if (!userStore.loggedIn) return
await Promise.all([userStore.fetchMemberships(), userStore.fetchStats()])
}
async function onRefresh() {
if (refreshing.value) return
refreshing.value = true
try {
await userStore.fetchMemberships()
} finally {
refreshing.value = false
}
try { await loadMemberships() }
finally { refreshing.value = false }
}
function goProfile() { uni.switchTab({ url: '/pages/profile/index' }) }
function goBooking() {
uni.switchTab({ url: '/pages/booking/index' })
@@ -196,28 +172,12 @@ onShow(loadMemberships)
.group-title { font-size: 28rpx; font-weight: 500; }
.group-count { font-size: 22rpx; color: #8b817b; }
.mc { --balance-bg: #f3ebe3; --balance-ink: #8b6c5b; padding: 28rpx; margin-bottom: 20rpx; border: 1rpx solid #eee8e3; border-radius: 28rpx; background: #fff; }
.mc--duration { --balance-bg: #edf2e9; --balance-ink: #617d63; }
.mc--trial { --balance-bg: #f5e9e5; --balance-ink: #9b7768; }
.mc-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 20rpx; }
.mc-name-area { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
.mc-name { font-size: 30rpx; font-weight: 500; line-height: 1.5; overflow-wrap: anywhere; }
.mc-type-text { font-size: 22rpx; color: #8b817b; }
.mc-status { flex-shrink: 0; padding: 6rpx 16rpx; border-radius: 999rpx; background: #ede9e4; color: #8b817b; font-size: 21rpx; line-height: 1.4; }
.mc-status--active { background: #edf2ec; color: #617d73; }
.mc-balance { margin-top: 24rpx; padding: 22rpx 24rpx; background: var(--balance-bg); border-radius: 20rpx; }
.mc-number-row { display: flex; align-items: baseline; gap: 10rpx; }
.mc-big-num { font-size: 56rpx; font-weight: 400; line-height: 1.1; color: var(--balance-ink); font-variant-numeric: tabular-nums; }
.mc-big-unit { font-size: 23rpx; color: var(--balance-ink); }
.mc-duration-note { display: block; font-size: 21rpx; color: #7b8775; margin-top: 12rpx; }
.mc-progress { margin-top: 18rpx; }
.mc-progress-track { height: 6rpx; border-radius: 6rpx; overflow: hidden; background: rgba(255,255,255,0.8); }
.mc-progress-fill { height: 100%; border-radius: 6rpx; background: #b9a38f; }
.mc-progress-label { display: block; margin-top: 10rpx; font-size: 21rpx; color: #8b7b70; }
.mc-bottom { display: flex; justify-content: space-between; gap: 20rpx; margin-top: 22rpx; }
.mc-date-item { min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
.mc-date-item--end { text-align: right; }
.mc-date-label { font-size: 21rpx; color: #8b817b; line-height: 1.6; }
.mc-date-value { font-size: 24rpx; color: #6f655e; font-variant-numeric: tabular-nums; }
.mc-actions { display: flex; gap: 16rpx; margin-top: 26rpx; }
.mc-renew, .mc-book { flex: 1; margin: 0; padding: 0 24rpx; height: 72rpx; line-height: 72rpx; border: none; border-radius: 999rpx; font-size: 25rpx; font-weight: 400; &::after { border: none; } }
.mc-renew { background: #f3eee8; color: #8b7160; }
@@ -229,4 +189,16 @@ onShow(loadMemberships)
.purchase-dock { flex-shrink: 0; padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom)); border-top: 1rpx solid #eee8e3; background: #fbf9f6; }
.purchase-btn { display: block; width: 100%; margin: 0; padding: 0; height: 84rpx; line-height: 84rpx; border: none; border-radius: 999rpx; background: #6b8276; color: #fff; font-size: 28rpx; font-weight: 400; &::after { border: none; } }
.scroll-bottom-spacer { height: 20rpx; }
.practice-summary { padding: 32rpx; margin: 24rpx 32rpx 0; border-radius: 24rpx; background: #eef1e9; border: 1rpx solid #dce3d4; color: #465c4a; }
.summary-heading { display: flex; flex-direction: column; gap: 12rpx; }
.summary-kicker { font-size: 18rpx; letter-spacing: 3rpx; color: #6d7d64; }
.summary-title { font-size: 32rpx; font-family: 'Songti SC', 'STSong', serif; }
.summary-grid { display: flex; margin-top: 30rpx; padding-top: 24rpx; border-top: 1rpx solid #d4dec9; }
.summary-grid > view { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12rpx; text-align: center; }
.summary-grid > view + view { border-left: 1rpx solid #d4dec9; }
.summary-value { font-size: 42rpx; font-family: 'Baskerville', 'Times New Roman', serif; line-height: 1.3; font-variant-numeric: tabular-nums; }
.summary-label { font-size: 20rpx; color: #6d7d64; }
.summary-note, .usage-note { display: block; font-size: 21rpx; line-height: 1.8; color: #78816f; margin-top: 18rpx; }
.summary-retry { padding: 16rpx 0 0; margin: 0; color: #6d7d64; text-align: left; background: transparent; font-size: 23rpx; &::after { border: 0; } }
.owned-card-wrap { margin-bottom: 20rpx; }
</style>