feat(app): implement home, booking, and profile pages
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
This commit is contained in:
@@ -1,15 +1,561 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="placeholder">
|
||||
<text>购买会员卡 - 待实现</text>
|
||||
<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">
|
||||
<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-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">
|
||||
<text class="desc-title">课程说明</text>
|
||||
<text class="desc-content">{{ card.description }}</text>
|
||||
</view>
|
||||
|
||||
<!-- Features list -->
|
||||
<view class="features-card">
|
||||
<text class="features-title">购买须知</text>
|
||||
<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 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 param ──────────────────────────────────────────
|
||||
const cardId = ref<string>('')
|
||||
|
||||
// ─── 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 price = card.value.price / card.value.totalTimes
|
||||
return `¥${(price / 100).toFixed(0)}`
|
||||
}
|
||||
const price = card.value.price / card.value.durationDays
|
||||
return `¥${(price / 100).toFixed(0)}`
|
||||
})
|
||||
|
||||
// ─── Data loading ─────────────────────────────────────────
|
||||
async function loadCard() {
|
||||
if (!cardId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const types = await get<CardType[]>('/membership/card-types')
|
||||
card.value = types.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
|
||||
uni.showToast({ title: '购买成功!', icon: 'success' })
|
||||
// Refresh memberships in background
|
||||
await userStore.fetchMemberships()
|
||||
// Navigate back after a moment
|
||||
setTimeout(() => {
|
||||
uni.navigateBack()
|
||||
}, 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(() => {
|
||||
// Get id from page options
|
||||
const pages = getCurrentPages()
|
||||
const current = pages[pages.length - 1]
|
||||
const options = (current as { options?: Record<string, string> }).options ?? {}
|
||||
cardId.value = options.id ?? ''
|
||||
loadCard()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page { min-height: 100vh; background: #f5f5f5; }
|
||||
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
|
||||
.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: 360rpx;
|
||||
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: 60rpx 32rpx 52rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
|
||||
&.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%);
|
||||
}
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
align-self: flex-start;
|
||||
padding: 8rpx 20rpx;
|
||||
border-radius: 20rpx;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.hero-badge-text {
|
||||
font-size: 22rpx;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
.hero-name {
|
||||
font-size: 44rpx;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
.hero-price-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.hero-price {
|
||||
font-size: 56rpx;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hero-original {
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* ── Detail section ──────────────────────────────────── */
|
||||
.detail-section {
|
||||
padding: 24rpx 24rpx 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
/* ── 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);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.desc-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.desc-content {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.features-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.feature-dot {
|
||||
font-size: 26rpx;
|
||||
color: #c9a87c;
|
||||
line-height: 1.6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.feature-text {
|
||||
font-size: 26rpx;
|
||||
color: #555;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&--loading {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.buy-btn-text {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #c9a87c;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user