perf: 优化个人中心会员卡进度展示
This commit is contained in:
54
packages/app/src/components/OwnedMembershipCard.vue
Normal file
54
packages/app/src/components/OwnedMembershipCard.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<view class="pass" :class="[tone, { 'pass--compact': compact }]">
|
||||
<view class="pass-heading">
|
||||
<view class="pass-identity"><text class="pass-kind">{{ getCardTypeLabel(membership.cardType.type) }} · MEMBERSHIP</text><text class="pass-name">{{ membership.cardType.name }}</text></view>
|
||||
<text class="pass-mark">{{ compact ? '查看 ›' : '有效' }}</text>
|
||||
</view>
|
||||
<view class="pass-balance">
|
||||
<text class="balance-label">{{ unlimited ? '有效期内' : '剩余' }}</text>
|
||||
<text class="balance-number">{{ unlimited ? '不限次' : membership.remainingTimes }}</text>
|
||||
<text v-if="!unlimited" class="balance-unit">次</text>
|
||||
<text v-if="!unlimited && total !== null" class="usage-label">已用 {{ used }} / {{ total }} 次</text>
|
||||
<text v-else-if="unlimited" class="usage-label">{{ days }} 天后到期</text>
|
||||
</view>
|
||||
<view v-if="!unlimited && total !== null && total > 0" class="usage-track" :aria-label="`已用${used}次,共${total}次`"><view class="usage-fill" :style="{ width: `${progress}%` }" /></view>
|
||||
<view v-else class="pass-rule" />
|
||||
<view class="pass-dates"><text v-if="!compact">{{ membership.startDate.slice(0, 10).replace(/-/g, '.') }} 起</text><text :class="{ 'expiry-soon': days <= 7 }">{{ membership.expireDate.slice(0, 10).replace(/-/g, '.') }} 到期</text></view>
|
||||
<slot />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { CardTypeCategory, type MembershipWithCardType } from '@mp-pilates/shared'
|
||||
import { getCardTypeLabel, getMembershipTotalTimes, getMembershipUsedTimes } from '../utils/format'
|
||||
|
||||
const props = defineProps<{ membership: MembershipWithCardType; compact?: boolean; now: number }>()
|
||||
const unlimited = computed(() => props.membership.remainingTimes === null)
|
||||
const total = computed(() => getMembershipTotalTimes(props.membership))
|
||||
const used = computed(() => getMembershipUsedTimes(props.membership))
|
||||
const progress = computed(() => total.value && total.value > 0 ? Math.min(100, Math.max(0, used.value / total.value * 100)) : 0)
|
||||
const days = computed(() => Math.max(0, Math.ceil((new Date(props.membership.expireDate).getTime() - props.now) / 86400000)))
|
||||
const tone = computed(() => props.membership.cardType.type === CardTypeCategory.DURATION ? 'pass--sage' : props.membership.cardType.type === CardTypeCategory.TRIAL ? 'pass--clay' : 'pass--sand')
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pass { --pass-bg: #f0e6d8; --pass-ink: #655240; --pass-line: #d7c3a9; --pass-track: #e3d4c1; box-sizing: border-box; padding: 30rpx; border-radius: 24rpx; background: var(--pass-bg); color: var(--pass-ink); border: 1rpx solid var(--pass-line); }
|
||||
.pass--sage { --pass-bg: #e7eee4; --pass-ink: #465f4d; --pass-line: #b8c9b3; --pass-track: #d4dfce; }
|
||||
.pass--clay { --pass-bg: #f2e5df; --pass-ink: #845c4a; --pass-line: #d9b9aa; --pass-track: #e7d0c4; }
|
||||
.pass-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20rpx; }
|
||||
.pass-identity { min-width: 0; flex: 1; }
|
||||
.pass-kind { display: block; font-size: 18rpx; letter-spacing: 2rpx; }
|
||||
.pass-name { display: block; margin-top: 12rpx; font-family: 'Songti SC', 'STSong', serif; font-size: 34rpx; line-height: 1.4; overflow-wrap: anywhere; }
|
||||
.pass-mark { flex-shrink: 0; font-size: 21rpx; padding-top: 4rpx; }
|
||||
.pass-balance { display: flex; align-items: baseline; flex-wrap: wrap; gap: 10rpx; margin-top: 30rpx; }
|
||||
.balance-label, .balance-unit { font-size: 23rpx; }
|
||||
.balance-number { font-family: 'Baskerville', 'Times New Roman', serif; font-size: 60rpx; line-height: 1.2; font-variant-numeric: tabular-nums; }
|
||||
.usage-label { font-size: 23rpx; margin-left: auto; }
|
||||
.usage-track { height: 7rpx; border-radius: 8rpx; background: var(--pass-track); overflow: hidden; margin-top: 22rpx; }
|
||||
.usage-fill { height: 100%; background: var(--pass-ink); border-radius: 8rpx; }
|
||||
.pass-rule { height: 1rpx; margin-top: 22rpx; background: var(--pass-line); }
|
||||
.pass-dates { display: flex; justify-content: space-between; gap: 16rpx; margin-top: 18rpx; font-size: 22rpx; font-variant-numeric: tabular-nums; }
|
||||
.expiry-soon { font-weight: 600; }
|
||||
.pass--compact { height: 100%; padding: 24rpx; border-radius: 20rpx; .pass-name { font-size: 30rpx; margin-top: 8rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .pass-balance { margin-top: 18rpx; } .balance-number { font-size: 42rpx; } .usage-label { font-size: 21rpx; } .pass-dates { justify-content: flex-end; font-size: 21rpx; margin-top: 14rpx; } .usage-track, .pass-rule { margin-top: 16rpx; } }
|
||||
</style>
|
||||
@@ -49,36 +49,35 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Stats row: shown only when profile is loaded -->
|
||||
<view v-if="loggedIn && hasProfile" class="user-card__stats">
|
||||
<view class="user-card__stat-item">
|
||||
<text class="user-card__stat-value">{{ stats?.totalBookings ?? '—' }}</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">{{ stats?.monthBookings ?? '—' }}</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>
|
||||
<button v-if="loggedIn && hasProfile" class="membership-row" @tap="handleMembershipTap">
|
||||
<text class="membership-row__label">会员卡</text>
|
||||
<text class="membership-row__value">{{ membershipLabel }}</text>
|
||||
<text v-if="!membershipsError && membershipsLoaded && activeMemberships.length > 1" class="membership-row__count">{{ activeMemberships.length }} 张</text>
|
||||
<text class="membership-row__arrow">›</text>
|
||||
</button>
|
||||
<view v-if="loggedIn && hasProfile && membershipsLoaded && !membershipsError && activeMemberships.length" class="mini-progress-list">
|
||||
<button v-for="item in cardProgress" :key="item.id" class="mini-progress" :aria-label="`${item.name},${item.label}`" @tap="handleMembershipTap">
|
||||
<view v-if="item.percent !== null" class="mini-progress__fill" :style="{ width: `${item.percent}%` }" />
|
||||
<view class="mini-progress__heading"><text class="mini-progress__name">{{ item.name }}</text><text class="mini-progress__usage">{{ item.label }}</text></view>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { UserProfileResponse, UserStatsResponse, MembershipWithCardType } from '@mp-pilates/shared'
|
||||
import type { UserProfileResponse, MembershipWithCardType } from '@mp-pilates/shared'
|
||||
import { MembershipStatus } from '@mp-pilates/shared'
|
||||
import { getCardTypeLabel, getMembershipTotalTimes, getMembershipUsedTimes } from '../utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
loggedIn: boolean
|
||||
hasProfile: boolean
|
||||
user: UserProfileResponse | null
|
||||
stats: UserStatsResponse | null
|
||||
now: number
|
||||
membershipsLoading?: boolean
|
||||
membershipsLoaded?: boolean
|
||||
membershipsError?: boolean
|
||||
memberships?: readonly MembershipWithCardType[]
|
||||
loading?: boolean
|
||||
}>()
|
||||
@@ -86,6 +85,7 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
(e: 'login'): void
|
||||
(e: 'edit'): void
|
||||
(e: 'refresh-memberships'): void
|
||||
}>()
|
||||
|
||||
const avatarFailed = ref(false)
|
||||
@@ -123,16 +123,32 @@ const activeMembershipCount = computed(
|
||||
|
||||
const hasMembership = computed(() => activeMembershipCount.value > 0)
|
||||
|
||||
function toSafeCount(value: number | null | undefined): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
}
|
||||
const membershipLabel = computed(() => {
|
||||
if (props.membershipsError) return '暂未更新,点击重试'
|
||||
if (!props.membershipsLoaded) return '加载中…'
|
||||
if (!activeMemberships.value.length) return '暂无有效卡'
|
||||
return [...new Set(activeMemberships.value.map(m => getCardTypeLabel(m.cardType.type)))].join(' · ')
|
||||
})
|
||||
const cardProgress = computed(() => activeMemberships.value.map(m => {
|
||||
const total = getMembershipTotalTimes(m)
|
||||
if (m.remainingTimes !== null) {
|
||||
const used = getMembershipUsedTimes(m)
|
||||
return { id: m.id, name: m.cardType.name,
|
||||
label: total && total > 0 ? `已用 ${used}/${total} 次 · 余 ${m.remainingTimes}` : `剩余 ${m.remainingTimes} 次`,
|
||||
percent: total && total > 0 ? Math.max(0, Math.min(100, used / total * 100)) : null }
|
||||
}
|
||||
const start = new Date(m.startDate).getTime()
|
||||
const end = new Date(m.expireDate).getTime()
|
||||
const remainingDays = Math.max(0, Math.ceil((end - props.now) / 86400000))
|
||||
return { id: m.id, name: m.cardType.name,
|
||||
label: `不限次 · 有效期剩 ${remainingDays} 天`,
|
||||
percent: end > start ? Math.max(0, Math.min(100, (end - props.now) / (end - start) * 100)) : null }
|
||||
}))
|
||||
|
||||
// Sum remaining sessions from all active count-limited memberships.
|
||||
const remainingSessions = computed(() =>
|
||||
activeMemberships.value
|
||||
.filter((m) => m.remainingTimes !== null)
|
||||
.reduce((sum, m) => sum + toSafeCount(m.remainingTimes), 0),
|
||||
)
|
||||
function handleMembershipTap() {
|
||||
if (props.membershipsError) { emit('refresh-memberships'); return }
|
||||
uni.navigateTo({ url: '/pages/profile/membership' })
|
||||
}
|
||||
|
||||
function onAvatarError() {
|
||||
avatarFailed.value = true
|
||||
@@ -155,11 +171,6 @@ function handleLogin() {
|
||||
&__member-label { flex-shrink: 0; font-size: 19rpx; color: #8b6c5b; background: #fbf5ef; padding: 4rpx 12rpx; border-radius: 999rpx; }
|
||||
&__phone { font-size: 24rpx; color: #8b7b70; }
|
||||
&__edit { flex-shrink: 0; font-size: 22rpx; color: #8b7b70; padding: 16rpx 0 16rpx 8rpx; }
|
||||
&__stats { display: flex; align-items: stretch; margin-top: 28rpx; padding-top: 26rpx; border-top: 1rpx solid #e3d7ce; }
|
||||
&__stat-item { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 10rpx; }
|
||||
&__stat-value { font-size: 40rpx; font-weight: 400; color: #6f5c50; line-height: 1.15; font-variant-numeric: tabular-nums; }
|
||||
&__stat-label { font-size: 20rpx; color: #8b7b70; }
|
||||
&__stat-divider { width: 1rpx; margin: 6rpx 0; background: #e3d7ce; }
|
||||
&__guest { display: flex; align-items: center; flex-wrap: wrap; gap: 20rpx; }
|
||||
&__guest-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10rpx; }
|
||||
&__guest-title { font-size: 32rpx; font-weight: 500; color: #514943; }
|
||||
@@ -169,4 +180,15 @@ function handleLogin() {
|
||||
&__nickname-skeleton { width: 150rpx; height: 32rpx; border-radius: 8rpx; background: #e6d9ce; }
|
||||
&__phone-skeleton { width: 180rpx; height: 22rpx; border-radius: 6rpx; background: #e6d9ce; }
|
||||
}
|
||||
.membership-row { display: flex; align-items: center; gap: 12rpx; width: 100%; margin: 20rpx 0 0; padding: 18rpx 0 0; min-height: 62rpx; border-radius: 0; border-top: 1rpx solid #e3d7ce; background: transparent; text-align: left; line-height: 1.5; font-size: 23rpx; color: #796759; &::after { border: none; } }
|
||||
.membership-row__label { flex-shrink: 0; color: #8b7b70; }
|
||||
.membership-row__value { flex: 1; min-width: 0; text-align: right; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.membership-row__count { flex-shrink: 0; font-size: 21rpx; color: #8b7b70; }
|
||||
.membership-row__arrow { flex-shrink: 0; font-size: 28rpx; color: #8b7b70; }
|
||||
.mini-progress-list { margin-top: 12rpx; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.mini-progress { position: relative; display: block; width: 100%; height: 48rpx; margin: 0; padding: 0 16rpx; overflow: hidden; border: 1rpx solid #d6d9ca; border-radius: 10rpx; background: #eeece4; text-align: left; line-height: 46rpx; &::after { border: none; } &:active { opacity: .8; } }
|
||||
.mini-progress__heading { position: relative; z-index: 1; display: flex; align-items: center; gap: 12rpx; height: 100%; font-size: 20rpx; color: #46513f; }
|
||||
.mini-progress__name { flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.mini-progress__usage { flex-shrink: 0; font-variant-numeric: tabular-nums; }
|
||||
.mini-progress__fill { position: absolute; inset: 0 auto 0 0; background: linear-gradient(90deg, #dce3d3, #c9d7bf); border-right: 1rpx solid rgba(109, 135, 91, .16); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user