Files
mp-pilates/packages/app/src/pages/profile/membership.vue
2026-04-09 10:24:44 +08:00

600 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="membership-page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="我的会员卡" show-back />
<scroll-view
class="scroll"
scroll-y
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
>
<!-- Loading skeleton -->
<view v-if="loading && !refreshing" class="loading-wrap">
<view v-for="i in 2" :key="i" class="skeleton-card" />
</view>
<!-- Empty state -->
<view v-else-if="allMemberships.length === 0" class="empty-wrap">
<view class="empty-card">
<view class="empty-deco empty-deco--1" />
<view class="empty-deco empty-deco--2" />
<text class="empty-title">还没有会员卡</text>
<text class="empty-sub">购买会员卡后即可预约课程</text>
<view class="empty-btn" @tap="goStore">
<text class="empty-btn-text">去选购</text>
</view>
</view>
</view>
<!-- Membership list -->
<view v-else class="list">
<!-- Active cards -->
<view v-if="activeMemberships.length > 0" class="group-section">
<view class="group-header">
<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)"
>
<!-- Decorative circles -->
<view class="mc-deco mc-deco--1" />
<view class="mc-deco mc-deco--2" />
<!-- Top row: name + status -->
<view class="mc-top">
<view class="mc-name-area">
<text class="mc-name">{{ m.cardType.name }}</text>
<view class="mc-type-tag">
<text class="mc-type-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
</view>
</view>
<view class="mc-status mc-status--active">
<view class="mc-status-dot" />
<text class="mc-status-text">有效</text>
</view>
</view>
<!-- Center: highlight number (times card) -->
<view v-if="m.remainingTimes !== null" class="mc-center">
<text class="mc-big-num">{{ m.remainingTimes }}</text>
<text class="mc-big-unit">次剩余</text>
<view v-if="m.cardType.totalTimes" 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) }} {{ m.cardType.totalTimes }}
</text>
</view>
</view>
<!-- Center: duration card (no times) -->
<view v-else class="mc-center">
<text class="mc-big-num">{{ daysRemaining(m) }}</text>
<text class="mc-big-unit">天剩余</text>
</view>
<!-- Bottom: dates -->
<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-sep" />
<view class="mc-date-item">
<text class="mc-date-label">到期</text>
<text class="mc-date-value">{{ m.expireDate.slice(0, 10) }}</text>
</view>
</view>
</view>
</view>
<!-- Expired / used up cards -->
<view v-if="inactiveMemberships.length > 0" class="group-section">
<view class="group-header">
<text class="group-title">历史记录</text>
<text class="group-count">{{ inactiveMemberships.length }} </text>
</view>
<view
v-for="m in inactiveMemberships"
:key="m.id"
class="mc mc--inactive"
>
<view class="mc-deco mc-deco--1" />
<view class="mc-top">
<view class="mc-name-area">
<text class="mc-name">{{ m.cardType.name }}</text>
<view class="mc-type-tag">
<text class="mc-type-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
</view>
</view>
<view class="mc-status" :class="inactiveStatusClass(m.status)">
<text class="mc-status-text">{{ statusLabel(m.status) }}</text>
</view>
</view>
<view class="mc-inactive-info">
<view v-if="m.remainingTimes !== null" class="mc-date-item">
<text class="mc-date-label">剩余</text>
<text class="mc-date-value">{{ m.remainingTimes }} </text>
</view>
<view class="mc-date-item">
<text class="mc-date-label">有效期至</text>
<text class="mc-date-value">{{ m.expireDate.slice(0, 10) }}</text>
</view>
</view>
</view>
</view>
</view>
<view class="scroll-bottom-spacer" />
</scroll-view>
<!-- Buy more FAB -->
<view class="fab" @tap="goStore">
<text class="fab-text">+ 购买会员卡</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
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 } from '../../utils/format'
import CustomNavBar from '../../components/CustomNavBar.vue'
const userStore = useUserStore()
const navBarHeight = ref('64px')
const loading = ref(false)
const refreshing = ref(false)
const allMemberships = computed(() => userStore.memberships as MembershipWithCardType[])
const activeMemberships = computed(() =>
allMemberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
)
const inactiveMemberships = computed(() =>
allMemberships.value.filter((m) => m.status !== MembershipStatus.ACTIVE),
)
function statusLabel(status: MembershipStatus): string {
const map: Record<MembershipStatus, string> = {
[MembershipStatus.ACTIVE]: '有效',
[MembershipStatus.EXPIRED]: '已过期',
[MembershipStatus.USED_UP]: '已用完',
}
return map[status] ?? status
}
function inactiveStatusClass(status: MembershipStatus): string {
if (status === MembershipStatus.USED_UP) return 'mc-status--used'
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))
}
async function loadMemberships() {
loading.value = true
try {
await userStore.fetchMemberships()
} catch {
uni.showToast({ title: '加载失败,请下拉刷新', icon: 'none' })
} finally {
loading.value = false
}
}
async function onRefresh() {
refreshing.value = true
await userStore.fetchMemberships()
refreshing.value = false
}
function goStore() {
uni.switchTab({ url: '/pages/home/index' })
}
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
loadMemberships()
})
</script>
<style lang="scss" scoped>
.membership-page {
min-height: 100vh;
background: $bg-page;
}
.scroll {
height: 100vh;
}
/* ── Loading ─────────────────────────────── */
.loading-wrap {
padding: 24rpx;
display: flex;
flex-direction: column;
gap: 24rpx;
}
.skeleton-card {
height: 320rpx;
border-radius: 24rpx;
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
/* ── Empty ────────────────────────────────── */
.empty-wrap {
padding: 80rpx 24rpx;
}
.empty-card {
position: relative;
overflow: hidden;
background: linear-gradient(135deg, #E8D5C4, #D8C8DC);
border-radius: 24rpx;
padding: 64rpx 40rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
}
.empty-deco {
position: absolute;
border-radius: 50%;
pointer-events: none;
&--1 {
width: 200rpx;
height: 200rpx;
top: -60rpx;
right: -40rpx;
background: rgba(255, 255, 255, 0.3);
}
&--2 {
width: 140rpx;
height: 140rpx;
bottom: -40rpx;
left: -20rpx;
background: rgba(255, 255, 255, 0.2);
}
}
.empty-title {
font-size: 34rpx;
font-weight: 700;
color: $brand-color;
z-index: 1;
}
.empty-sub {
font-size: 26rpx;
color: $text-secondary;
z-index: 1;
}
.empty-btn {
margin-top: 16rpx;
padding: 20rpx 56rpx;
border-radius: 40rpx;
background: rgba(74, 64, 53, 0.12);
z-index: 1;
&:active { background: rgba(74, 64, 53, 0.18); }
}
.empty-btn-text {
font-size: 28rpx;
color: $brand-color;
font-weight: 600;
}
/* ── List ─────────────────────────────────── */
.list {
padding: 16rpx 24rpx 0;
}
/* ── Group ────────────────────────────────── */
.group-section {
margin-bottom: 16rpx;
}
.group-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12rpx 8rpx 16rpx;
}
.group-title {
font-size: 28rpx;
color: $text-primary;
font-weight: 600;
}
.group-count {
font-size: 22rpx;
color: $text-hint;
}
/* ══════════════════════════════════════════════
MEMBERSHIP CARD (mc)
══════════════════════════════════════════════ */
.mc {
position: relative;
overflow: hidden;
border-radius: 24rpx;
padding: 28rpx 32rpx;
margin-bottom: 20rpx;
display: flex;
flex-direction: column;
gap: 24rpx;
}
/* Card type backgrounds */
.mc--times {
background: linear-gradient(135deg, #EDE0D4 0%, #E2D2C2 100%);
box-shadow: 0 4rpx 20rpx rgba(212, 191, 168, 0.3);
}
.mc--duration {
background: linear-gradient(135deg, #E0D4E4 0%, #D4C6DA 100%);
box-shadow: 0 4rpx 20rpx rgba(196, 174, 203, 0.3);
}
.mc--trial {
background: linear-gradient(135deg, #D4E2DC 0%, #C6D8D0 100%);
box-shadow: 0 4rpx 20rpx rgba(169, 196, 188, 0.3);
}
.mc--inactive {
background: linear-gradient(135deg, #E8E4E0, #DDD9D5);
box-shadow: 0 2rpx 12rpx rgba(180, 160, 130, 0.12);
opacity: 0.75;
gap: 16rpx;
}
/* Decorative circles */
.mc-deco {
position: absolute;
border-radius: 50%;
pointer-events: none;
&--1 {
width: 180rpx;
height: 180rpx;
top: -50rpx;
right: -30rpx;
background: rgba(255, 255, 255, 0.3);
}
&--2 {
width: 120rpx;
height: 120rpx;
bottom: -30rpx;
left: 40rpx;
background: rgba(255, 255, 255, 0.2);
}
}
/* ── Top row ──────────────────────────────── */
.mc-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
z-index: 1;
}
.mc-name-area {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.mc-name {
font-size: 34rpx;
font-weight: 700;
color: #2C2420;
line-height: 1.2;
}
.mc-type-tag {
align-self: flex-start;
padding: 4rpx 14rpx;
border-radius: 10rpx;
background: rgba(44, 36, 32, 0.1);
}
.mc-type-text {
font-size: 20rpx;
color: rgba(44, 36, 32, 0.6);
font-weight: 500;
}
/* Status */
.mc-status {
display: flex;
align-items: center;
gap: 8rpx;
padding: 6rpx 16rpx;
border-radius: 16rpx;
flex-shrink: 0;
}
.mc-status--active {
background: rgba(122, 158, 126, 0.18);
}
.mc-status--expired,
.mc-status--used {
background: rgba(74, 64, 53, 0.08);
}
.mc-status-dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: $success-color;
}
.mc-status-text {
font-size: 22rpx;
color: #2C2420;
font-weight: 600;
}
/* ── Center: big number ───────────────────── */
.mc-center {
display: flex;
flex-direction: column;
align-items: center;
padding: 8rpx 0;
z-index: 1;
}
.mc-big-num {
font-size: 80rpx;
font-weight: 800;
color: #2C2420;
line-height: 1;
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
}
.mc-big-unit {
font-size: 24rpx;
color: rgba(44, 36, 32, 0.55);
font-weight: 500;
margin-top: 4rpx;
}
/* Progress */
.mc-progress {
width: 100%;
max-width: 400rpx;
margin-top: 20rpx;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.mc-progress-track {
height: 10rpx;
background: rgba(44, 36, 32, 0.1);
border-radius: 5rpx;
overflow: hidden;
}
.mc-progress-fill {
height: 100%;
background: rgba(44, 36, 32, 0.35);
border-radius: 5rpx;
transition: width 0.4s ease;
}
.mc-progress-label {
font-size: 20rpx;
color: rgba(44, 36, 32, 0.45);
text-align: center;
}
/* ── Bottom: dates ────────────────────────── */
.mc-bottom {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
z-index: 1;
padding-top: 4rpx;
border-top: 1rpx solid rgba(44, 36, 32, 0.1);
}
.mc-date-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
}
.mc-date-sep {
width: 1rpx;
height: 40rpx;
background: rgba(44, 36, 32, 0.12);
flex-shrink: 0;
}
.mc-date-label {
font-size: 20rpx;
color: rgba(44, 36, 32, 0.4);
}
.mc-date-value {
font-size: 24rpx;
color: #2C2420;
font-weight: 600;
}
/* ── Inactive info ────────────────────────── */
.mc-inactive-info {
display: flex;
gap: 40rpx;
padding-left: 4rpx;
z-index: 1;
}
/* ── FAB ──────────────────────────────────── */
.fab {
position: fixed;
bottom: calc(32rpx + env(safe-area-inset-bottom));
right: 32rpx;
background: $brand-color;
border-radius: 44rpx;
padding: 22rpx 36rpx;
box-shadow: 0 6rpx 24rpx rgba(74, 64, 53, 0.25);
z-index: 100;
&:active { opacity: 0.85; }
}
.fab-text {
font-size: 28rpx;
font-weight: 600;
color: #fff;
letter-spacing: 1rpx;
}
/* ── Spacer ───────────────────────────────── */
.scroll-bottom-spacer {
height: 140rpx;
}
</style>