Files
mp-pilates/packages/app/src/pages/card/detail.vue
2026-04-12 21:44:44 +08:00

873 lines
23 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="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>