Sub-pages: card purchase with WeChat Pay flow, my memberships with progress bars, my bookings with tabs, personal info editor Admin: management center grid, week template CRUD, slot adjustment, member management with search, order list with filters, card type CRUD with form modal, studio settings editor Admin Pinia store for all admin API calls
630 lines
16 KiB
Vue
630 lines
16 KiB
Vue
<template>
|
||
<view class="card-detail-page">
|
||
<!-- 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" 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>
|
||
|
||
<!-- Card content -->
|
||
<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">{{ card.name }}</text>
|
||
<view class="hero-price-row">
|
||
<text class="hero-currency">¥</text>
|
||
<text class="hero-price">{{ formatPrice(card.price) }}</text>
|
||
<text
|
||
v-if="card.originalPrice && card.originalPrice > card.price"
|
||
class="hero-original"
|
||
>
|
||
¥{{ formatPrice(card.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="card.totalTimes">
|
||
<text class="cell-value">{{ card.totalTimes }}</text>
|
||
<text class="cell-label">课时次数</text>
|
||
</view>
|
||
<view class="info-cell">
|
||
<text class="cell-value">{{ card.durationDays }}</text>
|
||
<text class="cell-label">有效天数</text>
|
||
</view>
|
||
<view class="info-cell">
|
||
<text class="cell-value">{{ unitPrice }}</text>
|
||
<text class="cell-label">{{ card.totalTimes ? '每次单价' : '按天均价' }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Description -->
|
||
<view v-if="card.description" class="desc-card">
|
||
<view class="section-header">
|
||
<view class="section-dot" />
|
||
<text class="section-title">课程说明</text>
|
||
</view>
|
||
<text class="desc-content">{{ card.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">购买后立即生效,有效期 {{ card.durationDays }} 天</text>
|
||
</view>
|
||
<view v-if="card.totalTimes" class="feature-item">
|
||
<text class="feature-dot">•</text>
|
||
<text class="feature-text">共 {{ card.totalTimes }} 次课时,可灵活安排上课时间</text>
|
||
</view>
|
||
<view v-if="!card.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(card.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 { get, post } from '../../utils/request'
|
||
import { formatPrice } from '../../utils/format'
|
||
import { useUserStore } from '../../stores/user'
|
||
|
||
const userStore = useUserStore()
|
||
|
||
// ─── Route params ──────────────────────────────────────────
|
||
const cardId = ref<string>('')
|
||
const isTrial = ref(false)
|
||
|
||
// ─── State ────────────────────────────────────────────────
|
||
const card = ref<CardType | null>(null)
|
||
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)}`
|
||
})
|
||
|
||
// ─── Data loading ─────────────────────────────────────────
|
||
async function loadCard() {
|
||
loading.value = true
|
||
try {
|
||
const types = await get<CardType[]>('/membership/card-types')
|
||
const activeTypes = types.filter((c) => c.isActive)
|
||
|
||
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 {
|
||
card.value = null
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// ─── 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 {
|
||
await userStore.login()
|
||
handleBuy()
|
||
} catch {
|
||
uni.showToast({ title: '登录失败', 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
|
||
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(() => {
|
||
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'
|
||
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%; }
|
||
}
|
||
|
||
@keyframes shimmer {
|
||
0% { background-position: 100% 0; }
|
||
100% { background-position: -100% 0; }
|
||
}
|
||
|
||
/* ── 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: #c9a87c;
|
||
}
|
||
|
||
.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, #1a1a2e 0%, #2d2d5e 100%);
|
||
}
|
||
|
||
&.hero--duration {
|
||
background: linear-gradient(135deg, #6c3483 0%, #9b59b6 100%);
|
||
}
|
||
|
||
&.hero--trial {
|
||
background: linear-gradient(135deg, #7d6608 0%, #c9a87c 100%);
|
||
}
|
||
}
|
||
|
||
/* Decorative background circles */
|
||
.hero-deco {
|
||
position: absolute;
|
||
border-radius: 50%;
|
||
background: rgba(255, 255, 255, 0.05);
|
||
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(255, 255, 255, 0.18);
|
||
border: 1rpx solid rgba(255, 255, 255, 0.3);
|
||
z-index: 1;
|
||
}
|
||
|
||
.hero-badge-text {
|
||
font-size: 22rpx;
|
||
color: #fff;
|
||
font-weight: 600;
|
||
letter-spacing: 1rpx;
|
||
}
|
||
|
||
.hero-name {
|
||
font-size: 48rpx;
|
||
font-weight: 800;
|
||
color: #fff;
|
||
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(255, 255, 255, 0.85);
|
||
line-height: 1;
|
||
}
|
||
|
||
.hero-price {
|
||
font-size: 64rpx;
|
||
font-weight: 800;
|
||
color: #fff;
|
||
line-height: 1;
|
||
}
|
||
|
||
.hero-original {
|
||
font-size: 28rpx;
|
||
color: rgba(255, 255, 255, 0.5);
|
||
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: #c9a87c;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 30rpx;
|
||
font-weight: 700;
|
||
color: #1a1a1a;
|
||
}
|
||
|
||
/* ── 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: #c9a87c;
|
||
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: #c9a87c;
|
||
}
|
||
|
||
.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: #c9a87c;
|
||
letter-spacing: 2rpx;
|
||
}
|
||
</style>
|