378 lines
8.2 KiB
Vue
378 lines
8.2 KiB
Vue
<template>
|
||
<view class="card-shop">
|
||
<!-- Section header -->
|
||
<view class="section-header">
|
||
<text class="section-title">会员卡</text>
|
||
<text class="section-action" @tap="goToAllCards">全部</text>
|
||
</view>
|
||
|
||
<!-- Loading skeleton -->
|
||
<view v-if="loading" class="card-list">
|
||
<view
|
||
v-for="i in 3"
|
||
:key="i"
|
||
class="card-row skeleton-row"
|
||
>
|
||
<view class="skeleton-card-cover" />
|
||
<view class="skeleton-info">
|
||
<view class="skeleton-line skeleton-line--title" />
|
||
<view class="skeleton-line skeleton-line--sub" />
|
||
<view class="skeleton-line skeleton-line--price" />
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Card list -->
|
||
<view v-else-if="cardTypes.length" class="card-list">
|
||
<view
|
||
v-for="card in cardTypes"
|
||
:key="card.id"
|
||
class="card-row"
|
||
@tap="goToDetail(card.id)"
|
||
>
|
||
<!-- Card Cover — image if available, gradient fallback -->
|
||
<view class="card-cover" :class="card.coverUrl ? '' : getCardCoverClass(card.type)">
|
||
<image
|
||
v-if="card.coverUrl"
|
||
class="card-cover-img"
|
||
:src="card.coverUrl"
|
||
mode="aspectFill"
|
||
/>
|
||
<template v-else>
|
||
<view class="cover-deco cover-deco--1" />
|
||
<view class="cover-deco cover-deco--2" />
|
||
</template>
|
||
</view>
|
||
|
||
<!-- Card info — aligns with card-cover height -->
|
||
<view class="card-info">
|
||
<view class="info-top">
|
||
<text class="card-name">{{ card.name }}</text>
|
||
<text class="card-validity">有效期 {{ card.durationDays }} 天</text>
|
||
</view>
|
||
<view class="info-bottom">
|
||
<view v-if="card.totalTimes" class="card-times">
|
||
<text class="card-times-value">{{ card.totalTimes }}</text>
|
||
<text class="card-times-unit">课时</text>
|
||
</view>
|
||
<view class="price-row">
|
||
<text class="price-current">¥{{ formatPrice(card.price) }}</text>
|
||
<text
|
||
v-if="card.originalPrice && card.originalPrice > card.price"
|
||
class="price-original"
|
||
>
|
||
¥{{ formatPrice(card.originalPrice) }}
|
||
</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Arrow -->
|
||
<text class="card-arrow">›</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Empty -->
|
||
<view v-else class="empty-state">
|
||
<text class="empty-text">暂无可购买的会员卡</text>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted } from 'vue'
|
||
import type { CardType } from '@mp-pilates/shared'
|
||
import { get } from '../utils/request'
|
||
import { formatPrice, getCardCoverClass } from '../utils/format'
|
||
|
||
const cardTypes = ref<CardType[]>([])
|
||
const loading = ref(false)
|
||
const hasLoaded = ref(false)
|
||
|
||
async function fetchCardTypes() {
|
||
// Stale-While-Revalidate: only show skeleton on first load
|
||
// Subsequent refreshes silently update data in background
|
||
if (!hasLoaded.value) {
|
||
loading.value = true
|
||
}
|
||
try {
|
||
const result = await get<CardType[]>('/membership/card-types')
|
||
cardTypes.value = result
|
||
.filter((c) => c.isActive)
|
||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||
hasLoaded.value = true
|
||
} catch {
|
||
// Only show error toast on first load; silent fail on background refresh
|
||
if (!hasLoaded.value) {
|
||
uni.showToast({ title: '加载会员卡失败', icon: 'none' })
|
||
}
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(fetchCardTypes)
|
||
|
||
// Expose refresh method for parent page
|
||
defineExpose({ fetchCardTypes })
|
||
|
||
function goToDetail(id: string) {
|
||
uni.navigateTo({ url: `/pages/card/detail?id=${id}` })
|
||
}
|
||
|
||
function goToAllCards() {
|
||
uni.navigateTo({ url: '/pages/card/detail?showAll=1' })
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.card-shop {
|
||
background: #ffffff;
|
||
margin: 16rpx 24rpx 0;
|
||
padding-bottom: 20rpx;
|
||
border-radius: 20rpx;
|
||
overflow: hidden;
|
||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
||
}
|
||
|
||
/* ── Section header ── */
|
||
.section-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 32rpx 32rpx 20rpx;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 36rpx;
|
||
font-weight: 700;
|
||
color: #222;
|
||
}
|
||
|
||
.section-action {
|
||
font-size: 26rpx;
|
||
color: #999;
|
||
padding: 8rpx 24rpx;
|
||
border: 1rpx solid #ddd;
|
||
border-radius: 24rpx;
|
||
}
|
||
|
||
/* ── Card list ── */
|
||
.card-list {
|
||
padding: 0 32rpx;
|
||
}
|
||
|
||
.card-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 20rpx;
|
||
padding: 16rpx 0;
|
||
border-bottom: 1rpx solid rgba($brand-color, 0.08);
|
||
|
||
&:last-child {
|
||
border-bottom: none;
|
||
}
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════
|
||
CARD COVER — Clean minimal design
|
||
══════════════════════════════════════════════════════════ */
|
||
.card-cover {
|
||
width: 200rpx;
|
||
height: 130rpx;
|
||
border-radius: 16rpx;
|
||
overflow: hidden;
|
||
flex-shrink: 0;
|
||
position: relative;
|
||
}
|
||
|
||
.card-cover-img {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
/* Decorative circles */
|
||
.cover-deco {
|
||
position: absolute;
|
||
border-radius: 50%;
|
||
pointer-events: none;
|
||
|
||
&--1 {
|
||
width: 100rpx;
|
||
height: 100rpx;
|
||
top: -30rpx;
|
||
right: -20rpx;
|
||
background: rgba(255, 255, 255, 0.4);
|
||
}
|
||
|
||
&--2 {
|
||
width: 70rpx;
|
||
height: 70rpx;
|
||
bottom: -20rpx;
|
||
left: -10rpx;
|
||
background: rgba(255, 255, 255, 0.25);
|
||
}
|
||
}
|
||
|
||
/* Card cover backgrounds */
|
||
.cover--times {
|
||
background: linear-gradient(135deg, #E8D5C4 0%, #D4BFA8 100%);
|
||
}
|
||
|
||
.cover--duration {
|
||
background: linear-gradient(135deg, #D8C8DC 0%, #C4AECB 100%);
|
||
}
|
||
|
||
.cover--trial {
|
||
background: linear-gradient(135deg, #C8D8D2 0%, #A9C4BC 100%);
|
||
}
|
||
|
||
/* ── Card info — matches card-cover height ── */
|
||
.card-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
height: 130rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.info-top {
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
gap: 4rpx;
|
||
}
|
||
|
||
.info-bottom {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 16rpx;
|
||
}
|
||
|
||
.card-name {
|
||
font-size: 30rpx;
|
||
font-weight: 700;
|
||
color: $text-primary;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
letter-spacing: 0.5rpx;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.card-validity {
|
||
font-size: 23rpx;
|
||
color: $text-secondary;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.card-times {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 4rpx;
|
||
}
|
||
|
||
.card-times-value {
|
||
font-size: 34rpx;
|
||
font-weight: 800;
|
||
color: $brand-color;
|
||
line-height: 1;
|
||
}
|
||
|
||
.card-times-unit {
|
||
font-size: 20rpx;
|
||
color: $text-secondary;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.price-row {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 6rpx;
|
||
}
|
||
|
||
.price-current {
|
||
font-size: 32rpx;
|
||
font-weight: 800;
|
||
color: $brand-color;
|
||
line-height: 1;
|
||
}
|
||
|
||
.price-original {
|
||
font-size: 20rpx;
|
||
color: $text-hint;
|
||
text-decoration: line-through;
|
||
}
|
||
|
||
/* Arrow */
|
||
.card-arrow {
|
||
font-size: 44rpx;
|
||
color: $text-hint;
|
||
flex-shrink: 0;
|
||
transform: scaleX(0.5);
|
||
transform-origin: center;
|
||
}
|
||
|
||
/* ── Skeleton ── */
|
||
.skeleton-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 20rpx;
|
||
padding: 20rpx 0;
|
||
border-bottom: 1rpx solid rgba($brand-color, 0.08);
|
||
}
|
||
|
||
.skeleton-card-cover {
|
||
width: 240rpx;
|
||
height: 130rpx;
|
||
border-radius: 16rpx;
|
||
flex-shrink: 0;
|
||
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||
background-size: 400% 100%;
|
||
animation: shimmer 1.4s infinite;
|
||
}
|
||
|
||
.skeleton-info {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16rpx;
|
||
}
|
||
|
||
.skeleton-line {
|
||
height: 24rpx;
|
||
border-radius: 6rpx;
|
||
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||
background-size: 400% 100%;
|
||
animation: shimmer 1.4s infinite;
|
||
|
||
&--title {
|
||
width: 60%;
|
||
height: 30rpx;
|
||
}
|
||
|
||
&--sub {
|
||
width: 40%;
|
||
}
|
||
|
||
&--price {
|
||
width: 45%;
|
||
height: 36rpx;
|
||
}
|
||
}
|
||
|
||
/* ── Empty state ── */
|
||
.empty-state {
|
||
padding: 80rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.empty-text {
|
||
font-size: 28rpx;
|
||
color: $text-hint;
|
||
}
|
||
</style>
|