feat(app): implement all sub-pages and admin management pages
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
This commit is contained in:
@@ -23,12 +23,17 @@
|
||||
<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-price">¥{{ formatPrice(card.price) }}</text>
|
||||
<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"
|
||||
@@ -60,28 +65,38 @@
|
||||
|
||||
<!-- Description -->
|
||||
<view v-if="card.description" class="desc-card">
|
||||
<text class="desc-title">课程说明</text>
|
||||
<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">
|
||||
<text class="features-title">购买须知</text>
|
||||
<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>
|
||||
<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>
|
||||
<text class="feature-text">每次预约扣除 1 次课时(次卡)</text>
|
||||
</view>
|
||||
<view class="feature-item">
|
||||
<text class="feature-dot">•</text>
|
||||
<text class="feature-text">到期或课时用完后自动失效</text>
|
||||
<text class="feature-text">到期或课时用完后自动失效,不可退款</text>
|
||||
</view>
|
||||
<view class="feature-item">
|
||||
<text class="feature-dot">•</text>
|
||||
@@ -118,8 +133,9 @@ import { useUserStore } from '../../stores/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// ─── Route param ──────────────────────────────────────────
|
||||
// ─── Route params ──────────────────────────────────────────
|
||||
const cardId = ref<string>('')
|
||||
const isTrial = ref(false)
|
||||
|
||||
// ─── State ────────────────────────────────────────────────
|
||||
const card = ref<CardType | null>(null)
|
||||
@@ -147,20 +163,29 @@ const heroClass = computed(() => {
|
||||
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 pricePerTime = card.value.price / card.value.totalTimes
|
||||
return `¥${(pricePerTime / 100).toFixed(0)}`
|
||||
}
|
||||
const price = card.value.price / card.value.durationDays
|
||||
return `¥${(price / 100).toFixed(0)}`
|
||||
const pricePerDay = card.value.price / card.value.durationDays
|
||||
return `¥${(pricePerDay / 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
|
||||
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 {
|
||||
@@ -229,13 +254,11 @@ async function doPurchase() {
|
||||
})
|
||||
})
|
||||
|
||||
// Payment succeeded
|
||||
// Payment succeeded — refresh memberships then navigate
|
||||
uni.showToast({ title: '购买成功!', icon: 'success' })
|
||||
// Refresh memberships in background
|
||||
await userStore.fetchMemberships()
|
||||
// Navigate back after a moment
|
||||
setTimeout(() => {
|
||||
uni.navigateBack()
|
||||
uni.navigateTo({ url: '/pages/profile/membership' })
|
||||
}, 1500)
|
||||
} catch (err: unknown) {
|
||||
uni.hideLoading()
|
||||
@@ -250,11 +273,11 @@ async function doPurchase() {
|
||||
|
||||
// ─── 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 ?? ''
|
||||
isTrial.value = options.trial === '1'
|
||||
loadCard()
|
||||
})
|
||||
</script>
|
||||
@@ -272,7 +295,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.skeleton-header {
|
||||
height: 360rpx;
|
||||
height: 380rpx;
|
||||
background: linear-gradient(90deg, #e8e8e8 25%, #d8d8d8 50%, #e8e8e8 75%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
@@ -335,10 +358,12 @@ onMounted(() => {
|
||||
|
||||
/* ── Hero ────────────────────────────────────────────── */
|
||||
.card-hero {
|
||||
padding: 60rpx 32rpx 52rpx;
|
||||
padding: 64rpx 36rpx 56rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
gap: 18rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&.hero--times {
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #2d2d5e 100%);
|
||||
@@ -353,12 +378,35 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 20rpx;
|
||||
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 {
|
||||
@@ -369,28 +417,39 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.hero-name {
|
||||
font-size: 44rpx;
|
||||
font-size: 48rpx;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
letter-spacing: 1rpx;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero-price-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 16rpx;
|
||||
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: 56rpx;
|
||||
font-size: 64rpx;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.hero-original {
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
text-decoration: line-through;
|
||||
margin-left: 8rpx;
|
||||
}
|
||||
|
||||
/* ── Detail section ──────────────────────────────────── */
|
||||
@@ -401,6 +460,29 @@ onMounted(() => {
|
||||
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;
|
||||
@@ -447,21 +529,12 @@ onMounted(() => {
|
||||
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;
|
||||
font-size: 27rpx;
|
||||
color: #666;
|
||||
line-height: 1.7;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
/* ── Features card ───────────────────────────────────── */
|
||||
@@ -472,13 +545,6 @@ onMounted(() => {
|
||||
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 {
|
||||
@@ -486,19 +552,20 @@ onMounted(() => {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 12rpx;
|
||||
padding: 6rpx 0;
|
||||
}
|
||||
|
||||
.feature-dot {
|
||||
font-size: 26rpx;
|
||||
color: #c9a87c;
|
||||
line-height: 1.6;
|
||||
line-height: 1.65;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.feature-text {
|
||||
font-size: 26rpx;
|
||||
color: #555;
|
||||
line-height: 1.6;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
/* ── Bottom action bar ───────────────────────────────── */
|
||||
@@ -542,6 +609,7 @@ onMounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4rpx 16rpx rgba(26, 26, 46, 0.3);
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
|
||||
Reference in New Issue
Block a user