600 lines
15 KiB
Vue
600 lines
15 KiB
Vue
<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>
|