873 lines
23 KiB
Vue
873 lines
23 KiB
Vue
<template>
|
||
<view class="card-detail-page" :style="{ paddingTop: navBarHeight }">
|
||
<CustomNavBar title="购买会员卡" show-back />
|
||
<!-- Loading state -->
|
||
<view v-if="loading" class="loading-wrap">
|
||
<view class="skeleton-header" />
|
||
<view class="skeleton-body">
|
||
<view class="skeleton-line w80" />
|
||
<view class="skeleton-line w60" />
|
||
<view class="skeleton-line w40" />
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Error state -->
|
||
<view v-else-if="!card && !showAll" class="error-wrap">
|
||
<text class="error-icon">😕</text>
|
||
<text class="error-text">会员卡信息加载失败</text>
|
||
<view class="retry-btn" @tap="loadCard">
|
||
<text class="retry-text">点击重试</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- All cards list mode -->
|
||
<template v-else-if="showAll">
|
||
<view v-if="loading" class="loading-wrap">
|
||
<view class="skeleton-header" />
|
||
<view class="skeleton-body">
|
||
<view class="skeleton-line w80" />
|
||
<view class="skeleton-line w60" />
|
||
<view class="skeleton-line w40" />
|
||
</view>
|
||
</view>
|
||
<view v-else-if="allCards.length" class="all-cards-list">
|
||
<view
|
||
v-for="c in allCards"
|
||
:key="c.id"
|
||
class="card-row"
|
||
@tap="goToDetail(c.id)"
|
||
>
|
||
<!-- Card Cover — clean minimal -->
|
||
<view class="card-cover" :class="getCardCoverClass(c.type)">
|
||
<view class="cover-deco cover-deco--1" />
|
||
<view class="cover-deco cover-deco--2" />
|
||
</view>
|
||
|
||
<!-- Card info — aligns with card-cover height -->
|
||
<view class="card-info">
|
||
<view class="info-top">
|
||
<text class="card-name">{{ c.name }}</text>
|
||
<text class="card-validity">有效期 {{ c.durationDays }} 天</text>
|
||
</view>
|
||
<view class="info-bottom">
|
||
<view v-if="c.totalTimes" class="card-times">
|
||
<text class="card-times-value">{{ c.totalTimes }}</text>
|
||
<text class="card-times-unit">课时</text>
|
||
</view>
|
||
<view class="price-row">
|
||
<text class="price-current">¥{{ formatPrice(c.price) }}</text>
|
||
<text
|
||
v-if="c.originalPrice && c.originalPrice > c.price"
|
||
class="price-original"
|
||
>
|
||
¥{{ formatPrice(c.originalPrice) }}
|
||
</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<text class="card-arrow">›</text>
|
||
</view>
|
||
</view>
|
||
<view v-else class="empty-state">
|
||
<text class="empty-text">暂无可购买的会员卡</text>
|
||
</view>
|
||
</template>
|
||
|
||
<!-- Card content (single card mode) -->
|
||
<template v-else>
|
||
<!-- Hero section -->
|
||
<view class="card-hero" :class="heroClass">
|
||
<!-- Decorative circles -->
|
||
<view class="hero-deco hero-deco--1" />
|
||
<view class="hero-deco hero-deco--2" />
|
||
|
||
<view class="hero-badge">
|
||
<text class="hero-badge-text">{{ typeLabel }}</text>
|
||
</view>
|
||
<text class="hero-name">{{ cardData.name }}</text>
|
||
<view class="hero-price-row">
|
||
<text class="hero-currency">¥</text>
|
||
<text class="hero-price">{{ formatPrice(cardData.price) }}</text>
|
||
<text
|
||
v-if="cardData.originalPrice && cardData.originalPrice > cardData.price"
|
||
class="hero-original"
|
||
>
|
||
¥{{ formatPrice(cardData.originalPrice) }}
|
||
</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Detail cards -->
|
||
<view class="detail-section">
|
||
<!-- Key info grid -->
|
||
<view class="info-card">
|
||
<view class="info-grid">
|
||
<view class="info-cell" v-if="cardData.totalTimes">
|
||
<text class="cell-value">{{ cardData.totalTimes }}</text>
|
||
<text class="cell-label">课时次数</text>
|
||
</view>
|
||
<view class="info-cell">
|
||
<text class="cell-value">{{ cardData.durationDays }}</text>
|
||
<text class="cell-label">有效天数</text>
|
||
</view>
|
||
<view class="info-cell">
|
||
<text class="cell-value">{{ unitPrice }}</text>
|
||
<text class="cell-label">{{ cardData.totalTimes ? '每次单价' : '按天均价' }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Description -->
|
||
<view v-if="cardData.description" class="desc-card">
|
||
<view class="section-header">
|
||
<view class="section-dot" />
|
||
<text class="section-title">课程说明</text>
|
||
</view>
|
||
<text class="desc-content">{{ cardData.description }}</text>
|
||
</view>
|
||
|
||
<!-- Features list -->
|
||
<view class="features-card">
|
||
<view class="section-header">
|
||
<view class="section-dot" />
|
||
<text class="section-title">购买须知</text>
|
||
</view>
|
||
<view class="feature-item">
|
||
<text class="feature-dot">•</text>
|
||
<text class="feature-text">购买后立即生效,有效期 {{ cardData.durationDays }} 天</text>
|
||
</view>
|
||
<view v-if="cardData.totalTimes" class="feature-item">
|
||
<text class="feature-dot">•</text>
|
||
<text class="feature-text">共 {{ cardData.totalTimes }} 次课时,可灵活安排上课时间</text>
|
||
</view>
|
||
<view v-if="!cardData.totalTimes" class="feature-item">
|
||
<text class="feature-dot">•</text>
|
||
<text class="feature-text">有效期内可无限次预约课程</text>
|
||
</view>
|
||
<view class="feature-item">
|
||
<text class="feature-dot">•</text>
|
||
<text class="feature-text">每次预约扣除 1 次课时(次卡)</text>
|
||
</view>
|
||
<view class="feature-item">
|
||
<text class="feature-dot">•</text>
|
||
<text class="feature-text">到期或课时用完后自动失效,不可退款</text>
|
||
</view>
|
||
<view class="feature-item">
|
||
<text class="feature-dot">•</text>
|
||
<text class="feature-text">支持微信支付,安全便捷</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Bottom action bar -->
|
||
<view class="bottom-bar">
|
||
<view class="price-summary">
|
||
<text class="summary-label">实付金额</text>
|
||
<text class="summary-price">¥{{ formatPrice(cardData.price) }}</text>
|
||
</view>
|
||
<view
|
||
class="buy-btn"
|
||
:class="{ 'buy-btn--loading': buying }"
|
||
@tap="handleBuy"
|
||
>
|
||
<text class="buy-btn-text">{{ buying ? '支付中...' : '立即购买' }}</text>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import type { CardType, CreateOrderResponse } from '@mp-pilates/shared'
|
||
import { CardTypeCategory } from '@mp-pilates/shared'
|
||
import { getErrorMessage } from '../../utils/auth'
|
||
import { get, post } from '../../utils/request'
|
||
import { formatPrice, getCardTypeLabel, getCardCoverClass } from '../../utils/format'
|
||
import { getSystemLayout } from '../../utils/system'
|
||
import { useUserStore } from '../../stores/user'
|
||
import { requestOrderPaidSubscriptionMessage } from '../../utils/wechat-subscription'
|
||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||
|
||
const userStore = useUserStore()
|
||
|
||
// ─── Nav bar height ──────────────────────────────────────────
|
||
const navBarHeight = ref('64px')
|
||
|
||
// ─── Route params ──────────────────────────────────────────
|
||
const cardId = ref<string>('')
|
||
const isTrial = ref(false)
|
||
const showAll = ref(false)
|
||
|
||
// ─── State ────────────────────────────────────────────────
|
||
const card = ref<CardType | null>(null)
|
||
const allCards = ref<CardType[]>([])
|
||
const loading = ref(false)
|
||
const buying = ref(false)
|
||
|
||
// ─── Computed ─────────────────────────────────────────────
|
||
const typeLabel = computed(() => {
|
||
if (!card.value) return ''
|
||
const map: Record<CardTypeCategory, string> = {
|
||
[CardTypeCategory.TIMES]: '次卡',
|
||
[CardTypeCategory.DURATION]: '月卡',
|
||
[CardTypeCategory.TRIAL]: '体验卡',
|
||
}
|
||
return map[card.value.type] ?? '会员卡'
|
||
})
|
||
|
||
const heroClass = computed(() => {
|
||
if (!card.value) return ''
|
||
if (card.value.type === CardTypeCategory.TRIAL) return 'hero--trial'
|
||
if (card.value.type === CardTypeCategory.DURATION) return 'hero--duration'
|
||
return 'hero--times'
|
||
})
|
||
|
||
const unitPrice = computed(() => {
|
||
if (!card.value) return '-'
|
||
if (card.value.totalTimes) {
|
||
const pricePerTime = card.value.price / card.value.totalTimes
|
||
return `¥${(pricePerTime / 100).toFixed(0)}`
|
||
}
|
||
const pricePerDay = card.value.price / card.value.durationDays
|
||
return `¥${(pricePerDay / 100).toFixed(0)}`
|
||
})
|
||
|
||
const cardData = computed<CardType>(() => card.value as CardType)
|
||
|
||
// ─── Data loading ─────────────────────────────────────────
|
||
async function loadCard() {
|
||
loading.value = true
|
||
try {
|
||
const types = await get<CardType[]>('/membership/card-types')
|
||
const activeTypes = types.filter((c) => c.isActive).sort((a, b) => a.sortOrder - b.sortOrder)
|
||
|
||
if (showAll.value) {
|
||
allCards.value = activeTypes
|
||
return
|
||
}
|
||
|
||
if (isTrial.value) {
|
||
// Auto-find the trial card type
|
||
card.value = activeTypes.find((c) => c.type === CardTypeCategory.TRIAL) ?? null
|
||
if (card.value) {
|
||
cardId.value = card.value.id
|
||
}
|
||
} else if (cardId.value) {
|
||
card.value = activeTypes.find((c) => c.id === cardId.value) ?? null
|
||
}
|
||
} catch {
|
||
if (!showAll.value) {
|
||
card.value = null
|
||
}
|
||
allCards.value = []
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// ─── Helpers ───────────────────────────────────────────────
|
||
function goToDetail(id: string) {
|
||
uni.navigateTo({ url: `/pages/card/detail?id=${id}` })
|
||
}
|
||
|
||
// ─── Buy flow ─────────────────────────────────────────────
|
||
async function handleBuy() {
|
||
if (buying.value || !card.value) return
|
||
|
||
// Ensure logged in
|
||
if (!userStore.loggedIn) {
|
||
uni.showModal({
|
||
title: '提示',
|
||
content: '请先登录后再购买会员卡',
|
||
confirmText: '去登录',
|
||
success: async (res) => {
|
||
if (res.confirm) {
|
||
try {
|
||
const { isNewUser } = await userStore.loginWithSetup()
|
||
if (!isNewUser) {
|
||
handleBuy()
|
||
}
|
||
} catch (err: unknown) {
|
||
uni.showToast({ title: getErrorMessage(err, '登录失败'), icon: 'none' })
|
||
}
|
||
}
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
uni.showModal({
|
||
title: '确认购买',
|
||
content: `确认购买「${card.value.name}」,实付 ¥${formatPrice(card.value.price)}?`,
|
||
confirmText: '确认支付',
|
||
success: async (res) => {
|
||
if (!res.confirm) return
|
||
await doPurchase()
|
||
},
|
||
})
|
||
}
|
||
|
||
async function doPurchase() {
|
||
if (!card.value) return
|
||
buying.value = true
|
||
uni.showLoading({ title: '创建订单...' })
|
||
|
||
try {
|
||
const result = await post<CreateOrderResponse>('/payment/create-order', {
|
||
cardTypeId: card.value.id,
|
||
})
|
||
|
||
uni.hideLoading()
|
||
|
||
// Launch WeChat Pay
|
||
await new Promise<void>((resolve, reject) => {
|
||
uni.requestPayment({
|
||
provider: 'wxpay',
|
||
timeStamp: result.paymentParams.timeStamp,
|
||
nonceStr: result.paymentParams.nonceStr,
|
||
package: result.paymentParams.package,
|
||
signType: result.paymentParams.signType as 'MD5' | 'HMAC-SHA256',
|
||
paySign: result.paymentParams.paySign,
|
||
success: () => resolve(),
|
||
fail: (err: { errMsg?: string }) => reject(new Error(err.errMsg ?? '支付取消')),
|
||
})
|
||
})
|
||
|
||
// Payment succeeded — refresh memberships then navigate
|
||
await requestOrderPaidSubscriptionMessage().catch(() => undefined)
|
||
uni.showToast({ title: '购买成功!', icon: 'success' })
|
||
await userStore.fetchMemberships()
|
||
setTimeout(() => {
|
||
uni.navigateTo({ url: '/pages/profile/membership' })
|
||
}, 1500)
|
||
} catch (err: unknown) {
|
||
uni.hideLoading()
|
||
const msg = err instanceof Error ? err.message : '支付失败,请重试'
|
||
if (!msg.includes('取消') && !msg.includes('cancel')) {
|
||
uni.showToast({ title: msg, icon: 'none' })
|
||
}
|
||
} finally {
|
||
buying.value = false
|
||
}
|
||
}
|
||
|
||
// ─── Lifecycle ────────────────────────────────────────────
|
||
onMounted(() => {
|
||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||
|
||
const pages = getCurrentPages()
|
||
const current = pages[pages.length - 1]
|
||
const options = (current as { options?: Record<string, string> }).options ?? {}
|
||
cardId.value = options.id ?? ''
|
||
isTrial.value = options.trial === '1'
|
||
showAll.value = options.showAll === '1'
|
||
loadCard()
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.card-detail-page {
|
||
min-height: 100vh;
|
||
background: #f5f3f0;
|
||
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
|
||
}
|
||
|
||
/* ── Loading ─────────────────────────────────────────── */
|
||
.loading-wrap {
|
||
padding: 0 0 32rpx;
|
||
}
|
||
|
||
.skeleton-header {
|
||
height: 380rpx;
|
||
background: linear-gradient(90deg, #e8e8e8 25%, #d8d8d8 50%, #e8e8e8 75%);
|
||
background-size: 400% 100%;
|
||
animation: shimmer 1.4s infinite;
|
||
}
|
||
|
||
.skeleton-body {
|
||
padding: 32rpx 24rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20rpx;
|
||
}
|
||
|
||
.skeleton-line {
|
||
height: 28rpx;
|
||
border-radius: 14rpx;
|
||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||
background-size: 400% 100%;
|
||
animation: shimmer 1.4s infinite;
|
||
|
||
&.w80 { width: 80%; }
|
||
&.w60 { width: 60%; }
|
||
&.w40 { width: 40%; }
|
||
}
|
||
|
||
/* ── Error ───────────────────────────────────────────── */
|
||
.error-wrap {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 160rpx 40rpx;
|
||
gap: 24rpx;
|
||
}
|
||
|
||
.error-icon {
|
||
font-size: 80rpx;
|
||
}
|
||
|
||
.error-text {
|
||
font-size: 30rpx;
|
||
color: #999;
|
||
}
|
||
|
||
.retry-btn {
|
||
padding: 20rpx 48rpx;
|
||
border-radius: 40rpx;
|
||
background: $primary-dark;
|
||
}
|
||
|
||
.retry-text {
|
||
font-size: 28rpx;
|
||
color: #fff;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* ── Hero ────────────────────────────────────────────── */
|
||
.card-hero {
|
||
padding: 64rpx 36rpx 56rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 18rpx;
|
||
position: relative;
|
||
overflow: hidden;
|
||
|
||
&.hero--times {
|
||
background: linear-gradient(135deg, #E8D5C4 0%, #D4BFA8 100%);
|
||
}
|
||
|
||
&.hero--duration {
|
||
background: linear-gradient(135deg, #D8C8DC 0%, #C4AECB 100%);
|
||
}
|
||
|
||
&.hero--trial {
|
||
background: linear-gradient(135deg, #C8D8D2 0%, #A9C4BC 100%);
|
||
}
|
||
}
|
||
|
||
/* Decorative background circles */
|
||
.hero-deco {
|
||
position: absolute;
|
||
border-radius: 50%;
|
||
background: rgba(255, 255, 255, 0.35);
|
||
pointer-events: none;
|
||
|
||
&--1 {
|
||
width: 320rpx;
|
||
height: 320rpx;
|
||
top: -80rpx;
|
||
right: -60rpx;
|
||
}
|
||
|
||
&--2 {
|
||
width: 200rpx;
|
||
height: 200rpx;
|
||
bottom: -40rpx;
|
||
left: 20rpx;
|
||
}
|
||
}
|
||
|
||
.hero-badge {
|
||
align-self: flex-start;
|
||
padding: 8rpx 22rpx;
|
||
border-radius: 20rpx;
|
||
background: rgba(74, 64, 53, 0.1);
|
||
border: 1rpx solid rgba(74, 64, 53, 0.15);
|
||
z-index: 1;
|
||
}
|
||
|
||
.hero-badge-text {
|
||
font-size: 22rpx;
|
||
color: $brand-color;
|
||
font-weight: 600;
|
||
letter-spacing: 1rpx;
|
||
}
|
||
|
||
.hero-name {
|
||
font-size: 48rpx;
|
||
font-weight: 800;
|
||
color: $brand-color;
|
||
letter-spacing: 1rpx;
|
||
z-index: 1;
|
||
}
|
||
|
||
.hero-price-row {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 8rpx;
|
||
z-index: 1;
|
||
}
|
||
|
||
.hero-currency {
|
||
font-size: 28rpx;
|
||
font-weight: 600;
|
||
color: rgba(74, 64, 53, 0.7);
|
||
line-height: 1;
|
||
}
|
||
|
||
.hero-price {
|
||
font-size: 64rpx;
|
||
font-weight: 800;
|
||
color: $brand-color;
|
||
line-height: 1;
|
||
}
|
||
|
||
.hero-original {
|
||
font-size: 28rpx;
|
||
color: rgba(74, 64, 53, 0.4);
|
||
text-decoration: line-through;
|
||
margin-left: 8rpx;
|
||
}
|
||
|
||
/* ── Detail section ──────────────────────────────────── */
|
||
.detail-section {
|
||
padding: 24rpx 24rpx 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20rpx;
|
||
}
|
||
|
||
/* ── Section header ──────────────────────────────────── */
|
||
.section-header {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
gap: 12rpx;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.section-dot {
|
||
width: 6rpx;
|
||
height: 28rpx;
|
||
border-radius: 3rpx;
|
||
background: $primary-dark;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 30rpx;
|
||
font-weight: 700;
|
||
color: $text-primary;
|
||
}
|
||
|
||
/* ── Info grid card ──────────────────────────────────── */
|
||
.info-card {
|
||
background: #fff;
|
||
border-radius: 20rpx;
|
||
padding: 32rpx 24rpx;
|
||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
||
}
|
||
|
||
.info-grid {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-around;
|
||
align-items: center;
|
||
}
|
||
|
||
.info-cell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
flex: 1;
|
||
position: relative;
|
||
|
||
& + & {
|
||
border-left: 1rpx solid #f0f0f0;
|
||
}
|
||
}
|
||
|
||
.cell-value {
|
||
font-size: 44rpx;
|
||
font-weight: 800;
|
||
color: #1a1a1a;
|
||
line-height: 1.1;
|
||
}
|
||
|
||
.cell-label {
|
||
font-size: 22rpx;
|
||
color: #999;
|
||
}
|
||
|
||
/* ── Description card ────────────────────────────────── */
|
||
.desc-card {
|
||
background: #fff;
|
||
border-radius: 20rpx;
|
||
padding: 28rpx 24rpx;
|
||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
||
}
|
||
|
||
.desc-content {
|
||
font-size: 27rpx;
|
||
color: #666;
|
||
line-height: 1.75;
|
||
}
|
||
|
||
/* ── Features card ───────────────────────────────────── */
|
||
.features-card {
|
||
background: #fff;
|
||
border-radius: 20rpx;
|
||
padding: 28rpx 24rpx;
|
||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.feature-item {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: flex-start;
|
||
gap: 12rpx;
|
||
padding: 6rpx 0;
|
||
}
|
||
|
||
.feature-dot {
|
||
font-size: 26rpx;
|
||
color: $primary-dark;
|
||
line-height: 1.65;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.feature-text {
|
||
font-size: 26rpx;
|
||
color: #555;
|
||
line-height: 1.65;
|
||
}
|
||
|
||
/* ── Bottom action bar ───────────────────────────────── */
|
||
.bottom-bar {
|
||
position: fixed;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
background: #fff;
|
||
border-top: 1rpx solid #f0ece8;
|
||
padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom));
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
gap: 24rpx;
|
||
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.06);
|
||
}
|
||
|
||
.price-summary {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4rpx;
|
||
}
|
||
|
||
.summary-label {
|
||
font-size: 22rpx;
|
||
color: #999;
|
||
}
|
||
|
||
.summary-price {
|
||
font-size: 40rpx;
|
||
font-weight: 800;
|
||
color: $primary-dark;
|
||
}
|
||
|
||
.buy-btn {
|
||
flex: 1;
|
||
height: 88rpx;
|
||
border-radius: 44rpx;
|
||
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-shadow: 0 4rpx 16rpx rgba(26, 26, 46, 0.3);
|
||
|
||
&:active {
|
||
opacity: 0.85;
|
||
}
|
||
|
||
&--loading {
|
||
opacity: 0.6;
|
||
}
|
||
}
|
||
|
||
.buy-btn-text {
|
||
font-size: 32rpx;
|
||
font-weight: 700;
|
||
color: $primary-dark;
|
||
letter-spacing: 2rpx;
|
||
}
|
||
|
||
/* ── All cards list ────────────────────────────────────── */
|
||
.all-cards-list {
|
||
padding: 24rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20rpx;
|
||
}
|
||
|
||
.card-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 20rpx;
|
||
padding: 20rpx;
|
||
background: #fff;
|
||
border-radius: 20rpx;
|
||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════
|
||
CARD COVER — Clean minimal design
|
||
══════════════════════════════════════════════════════════ */
|
||
.card-cover {
|
||
width: 200rpx;
|
||
height: 130rpx;
|
||
border-radius: 16rpx;
|
||
overflow: hidden;
|
||
flex-shrink: 0;
|
||
position: relative;
|
||
}
|
||
|
||
.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);
|
||
}
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.card-arrow {
|
||
font-size: 44rpx;
|
||
color: $text-hint;
|
||
flex-shrink: 0;
|
||
transform: scaleX(0.5);
|
||
transform-origin: center;
|
||
}
|
||
|
||
/* ── Empty state ─────────────────────────────────────── */
|
||
.empty-state {
|
||
padding: 160rpx 40rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.empty-text {
|
||
font-size: 28rpx;
|
||
color: #bbb;
|
||
}
|
||
</style>
|