Home: brand banner, studio info swiper, smart quick entries based on membership status, upcoming bookings, card shop horizontal scroll Booking: 7-day date selector, time period filter, slot cards with status, booking confirm popup with membership picker Profile: user card with login, training stats, menu with admin entry 8 reusable components: BrandBanner, StudioInfo, QuickEntry, UpcomingBooking, CardShop, DateSelector, SlotCard, BookingConfirmPopup, TimePeriodFilter, UserCard, TrainingStats, ProfileMenu
438 lines
12 KiB
Vue
438 lines
12 KiB
Vue
<template>
|
|
<view class="membership-page">
|
|
<!-- Pull-to-refresh scroll view -->
|
|
<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="memberships.length === 0" class="empty-wrap">
|
|
<text class="empty-icon">💳</text>
|
|
<text class="empty-title">暂无会员卡</text>
|
|
<text class="empty-sub">购买会员卡后即可预约课程</text>
|
|
<view class="empty-btn" @tap="goStore">
|
|
<text class="empty-btn-text">去购买</text>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- Membership list -->
|
|
<view v-else class="list">
|
|
<!-- Active cards -->
|
|
<view v-if="activeMemberships.length > 0">
|
|
<text class="group-title">有效会员卡</text>
|
|
<view
|
|
v-for="m in activeMemberships"
|
|
:key="m.id"
|
|
class="card-item card-item--active"
|
|
>
|
|
<view class="card-top" :class="cardTopClass(m)">
|
|
<view>
|
|
<text class="card-name">{{ m.cardType.name }}</text>
|
|
<text class="card-type-tag">{{ typeLabel(m.cardType.type) }}</text>
|
|
</view>
|
|
<view class="card-badge card-badge--active">
|
|
<text class="badge-text">有效</text>
|
|
</view>
|
|
</view>
|
|
<view class="card-body">
|
|
<view class="info-row" v-if="m.remainingTimes !== null">
|
|
<text class="info-label">剩余课时</text>
|
|
<text class="info-value info-value--highlight">{{ m.remainingTimes }} 次</text>
|
|
</view>
|
|
<view class="info-row">
|
|
<text class="info-label">有效期至</text>
|
|
<text class="info-value">{{ m.expireDate.slice(0, 10) }}</text>
|
|
</view>
|
|
<view class="info-row">
|
|
<text class="info-label">开始日期</text>
|
|
<text class="info-value">{{ m.startDate.slice(0, 10) }}</text>
|
|
</view>
|
|
</view>
|
|
<!-- Progress bar for time-based cards -->
|
|
<view v-if="m.remainingTimes !== null && m.cardType.totalTimes" class="progress-wrap">
|
|
<view class="progress-bar">
|
|
<view
|
|
class="progress-fill"
|
|
:style="{ width: progressWidth(m) }"
|
|
/>
|
|
</view>
|
|
<text class="progress-label">
|
|
已使用 {{ m.cardType.totalTimes - m.remainingTimes }}/{{ m.cardType.totalTimes }} 次
|
|
</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- Expired / used up cards -->
|
|
<view v-if="inactiveMemberships.length > 0" class="inactive-section">
|
|
<text class="group-title">历史记录</text>
|
|
<view
|
|
v-for="m in inactiveMemberships"
|
|
:key="m.id"
|
|
class="card-item card-item--inactive"
|
|
>
|
|
<view class="card-top card-top--inactive">
|
|
<view>
|
|
<text class="card-name card-name--dim">{{ m.cardType.name }}</text>
|
|
<text class="card-type-tag card-type-tag--dim">{{ typeLabel(m.cardType.type) }}</text>
|
|
</view>
|
|
<view class="card-badge" :class="statusBadgeClass(m.status)">
|
|
<text class="badge-text">{{ statusLabel(m.status) }}</text>
|
|
</view>
|
|
</view>
|
|
<view class="card-body">
|
|
<view class="info-row" v-if="m.remainingTimes !== null">
|
|
<text class="info-label">剩余课时</text>
|
|
<text class="info-value">{{ m.remainingTimes }} 次</text>
|
|
</view>
|
|
<view class="info-row">
|
|
<text class="info-label">有效期至</text>
|
|
<text class="info-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 { get } from '../../utils/request'
|
|
|
|
// ─── State ────────────────────────────────────────────────
|
|
const memberships = ref<MembershipWithCardType[]>([])
|
|
const loading = ref(false)
|
|
const refreshing = ref(false)
|
|
|
|
// ─── Computed ─────────────────────────────────────────────
|
|
const activeMemberships = computed(() =>
|
|
memberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
|
|
)
|
|
|
|
const inactiveMemberships = computed(() =>
|
|
memberships.value.filter((m) => m.status !== MembershipStatus.ACTIVE),
|
|
)
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────
|
|
function typeLabel(type: CardTypeCategory): string {
|
|
const map: Record<CardTypeCategory, string> = {
|
|
[CardTypeCategory.TIMES]: '次卡',
|
|
[CardTypeCategory.DURATION]: '月卡',
|
|
[CardTypeCategory.TRIAL]: '体验卡',
|
|
}
|
|
return map[type] ?? '会员卡'
|
|
}
|
|
|
|
function statusLabel(status: MembershipStatus): string {
|
|
const map: Record<MembershipStatus, string> = {
|
|
[MembershipStatus.ACTIVE]: '有效',
|
|
[MembershipStatus.EXPIRED]: '已过期',
|
|
[MembershipStatus.USED_UP]: '已用完',
|
|
}
|
|
return map[status] ?? status
|
|
}
|
|
|
|
function statusBadgeClass(status: MembershipStatus): string {
|
|
if (status === MembershipStatus.EXPIRED) return 'card-badge--expired'
|
|
if (status === MembershipStatus.USED_UP) return 'card-badge--used'
|
|
return ''
|
|
}
|
|
|
|
function cardTopClass(m: MembershipWithCardType): string {
|
|
if (m.cardType.type === CardTypeCategory.TRIAL) return 'card-top--trial'
|
|
if (m.cardType.type === CardTypeCategory.DURATION) return 'card-top--duration'
|
|
return 'card-top--times'
|
|
}
|
|
|
|
function progressWidth(m: MembershipWithCardType): string {
|
|
if (m.remainingTimes === null || !m.cardType.totalTimes) return '0%'
|
|
const pct = (m.remainingTimes / m.cardType.totalTimes) * 100
|
|
return `${Math.max(0, Math.min(100, pct))}%`
|
|
}
|
|
|
|
// ─── Data loading ─────────────────────────────────────────
|
|
async function loadMemberships() {
|
|
loading.value = true
|
|
try {
|
|
memberships.value = await get<MembershipWithCardType[]>('/membership/my')
|
|
} catch {
|
|
uni.showToast({ title: '加载失败,请下拉刷新', icon: 'none' })
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function onRefresh() {
|
|
refreshing.value = true
|
|
await loadMemberships()
|
|
refreshing.value = false
|
|
}
|
|
|
|
function goStore() {
|
|
uni.navigateBack({ delta: 10 })
|
|
// Navigate to store tab
|
|
uni.switchTab({ url: '/pages/home/index' })
|
|
}
|
|
|
|
// ─── Lifecycle ────────────────────────────────────────────
|
|
onMounted(loadMemberships)
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.membership-page {
|
|
min-height: 100vh;
|
|
background: #f5f3f0;
|
|
}
|
|
|
|
.scroll {
|
|
height: 100vh;
|
|
}
|
|
|
|
/* ── Loading ─────────────────────────────────────────── */
|
|
.loading-wrap {
|
|
padding: 24rpx;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 20rpx;
|
|
}
|
|
|
|
.skeleton-card {
|
|
height: 200rpx;
|
|
border-radius: 20rpx;
|
|
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
|
background-size: 400% 100%;
|
|
animation: shimmer 1.4s infinite;
|
|
}
|
|
|
|
@keyframes shimmer {
|
|
0% { background-position: 100% 0; }
|
|
100% { background-position: -100% 0; }
|
|
}
|
|
|
|
/* ── Empty ───────────────────────────────────────────── */
|
|
.empty-wrap {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 120rpx 40rpx;
|
|
gap: 20rpx;
|
|
}
|
|
|
|
.empty-icon {
|
|
font-size: 80rpx;
|
|
}
|
|
|
|
.empty-title {
|
|
font-size: 32rpx;
|
|
font-weight: 600;
|
|
color: #333;
|
|
}
|
|
|
|
.empty-sub {
|
|
font-size: 26rpx;
|
|
color: #999;
|
|
}
|
|
|
|
.empty-btn {
|
|
margin-top: 12rpx;
|
|
padding: 20rpx 56rpx;
|
|
border-radius: 44rpx;
|
|
background: #c9a87c;
|
|
}
|
|
|
|
.empty-btn-text {
|
|
font-size: 30rpx;
|
|
color: #fff;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* ── List ────────────────────────────────────────────── */
|
|
.list {
|
|
padding: 24rpx 24rpx 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8rpx;
|
|
}
|
|
|
|
.group-title {
|
|
font-size: 26rpx;
|
|
color: #999;
|
|
font-weight: 500;
|
|
padding: 8rpx 4rpx 12rpx;
|
|
display: block;
|
|
}
|
|
|
|
/* ── Card item ───────────────────────────────────────── */
|
|
.card-item {
|
|
background: #fff;
|
|
border-radius: 20rpx;
|
|
overflow: hidden;
|
|
margin-bottom: 16rpx;
|
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
|
|
|
&--inactive {
|
|
opacity: 0.75;
|
|
}
|
|
}
|
|
|
|
.card-top {
|
|
padding: 24rpx 28rpx;
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
|
|
&--times { background: linear-gradient(90deg, #1a1a2e, #2d2d5e); }
|
|
&--duration { background: linear-gradient(90deg, #6c3483, #9b59b6); }
|
|
&--trial { background: linear-gradient(90deg, #7d6608, #c9a87c); }
|
|
&--inactive { background: #888; }
|
|
}
|
|
|
|
.card-name {
|
|
font-size: 32rpx;
|
|
font-weight: 700;
|
|
color: #fff;
|
|
display: block;
|
|
margin-bottom: 6rpx;
|
|
|
|
&--dim { color: #ddd; }
|
|
}
|
|
|
|
.card-type-tag {
|
|
font-size: 20rpx;
|
|
color: rgba(255, 255, 255, 0.7);
|
|
font-weight: 400;
|
|
display: block;
|
|
|
|
&--dim { color: rgba(255, 255, 255, 0.5); }
|
|
}
|
|
|
|
.card-badge {
|
|
padding: 8rpx 20rpx;
|
|
border-radius: 20rpx;
|
|
border: 1rpx solid rgba(255, 255, 255, 0.4);
|
|
|
|
&--active { background: rgba(76, 175, 80, 0.25); }
|
|
&--expired { background: rgba(0, 0, 0, 0.2); }
|
|
&--used { background: rgba(0, 0, 0, 0.2); }
|
|
}
|
|
|
|
.badge-text {
|
|
font-size: 22rpx;
|
|
color: #fff;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.card-body {
|
|
padding: 20rpx 28rpx;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12rpx;
|
|
}
|
|
|
|
.info-row {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.info-label {
|
|
font-size: 26rpx;
|
|
color: #999;
|
|
}
|
|
|
|
.info-value {
|
|
font-size: 26rpx;
|
|
color: #333;
|
|
font-weight: 500;
|
|
|
|
&--highlight {
|
|
color: #c9a87c;
|
|
font-size: 30rpx;
|
|
font-weight: 700;
|
|
}
|
|
}
|
|
|
|
/* ── Progress bar ────────────────────────────────────── */
|
|
.progress-wrap {
|
|
padding: 0 28rpx 20rpx;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8rpx;
|
|
}
|
|
|
|
.progress-bar {
|
|
height: 8rpx;
|
|
background: #f0f0f0;
|
|
border-radius: 4rpx;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.progress-fill {
|
|
height: 100%;
|
|
background: linear-gradient(90deg, #c9a87c, #e8c88a);
|
|
border-radius: 4rpx;
|
|
transition: width 0.4s ease;
|
|
}
|
|
|
|
.progress-label {
|
|
font-size: 22rpx;
|
|
color: #bbb;
|
|
text-align: right;
|
|
}
|
|
|
|
/* ── Inactive section ────────────────────────────────── */
|
|
.inactive-section {
|
|
margin-top: 8rpx;
|
|
}
|
|
|
|
/* ── FAB ─────────────────────────────────────────────── */
|
|
.fab {
|
|
position: fixed;
|
|
bottom: calc(32rpx + env(safe-area-inset-bottom));
|
|
right: 32rpx;
|
|
background: #1a1a2e;
|
|
border-radius: 44rpx;
|
|
padding: 22rpx 36rpx;
|
|
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.2);
|
|
z-index: 100;
|
|
|
|
&:active {
|
|
opacity: 0.85;
|
|
}
|
|
}
|
|
|
|
.fab-text {
|
|
font-size: 28rpx;
|
|
font-weight: 700;
|
|
color: #c9a87c;
|
|
letter-spacing: 1rpx;
|
|
}
|
|
|
|
/* ── Spacer ──────────────────────────────────────────── */
|
|
.scroll-bottom-spacer {
|
|
height: 100rpx;
|
|
}
|
|
</style>
|