perf: 优化个人中心会员卡进度展示
This commit is contained in:
@@ -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(),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user