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:
richarjiang
2026-04-02 15:25:57 +08:00
parent 3a29aca0db
commit 7a06b5e336
12 changed files with 1809 additions and 1680 deletions

View File

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