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:
richarjiang
2026-04-02 14:35:17 +08:00
parent 554fc30954
commit 3a29aca0db
26 changed files with 7766 additions and 74 deletions

View File

@@ -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>